Skip to content

A character is a number

Level: 101 · for anyone starting from zero

One line: ASCII is an agreement that 65 means A: 128 numbers, each assigned a character by a committee in 1963. The table's layout — digits at 0x30, uppercase at 0x41, lowercase at 0x61, one bit apart — is not an accident, and it is why ord('7') - ord('0') and x ^ 0x20 work.

The agreement

A byte holds a number. Text needs characters. The only bridge between them is a table that says which number stands for which character, and the first one everybody adopted was ASCII, the American Standard Code for Information Interchange:

  0.. 31   control characters   TAB=9  LF=10  CR=13  ESC=27  NUL=0
 32        space
 48.. 57   0 1 2 3 4 5 6 7 8 9          0x30..0x39
 65.. 90   A B C ... Z                  0x41..0x5A
 97..122   a b c ... z                  0x61..0x7A
127        DEL

Everything else in 32..126 is punctuation. That is the whole table: 128 entries, seven bits. In Python, ord('A') looks a character up in it and chr(65) looks a number up.

The layout is designed

The committee did not scatter the letters. Three consequences of where they put things:

  • Digits start at 0x30, so a digit's value is its code minus 0x30. ord('7') - ord('0') is 7, in every language.
  • Uppercase and lowercase are exactly 0x20 apart — 32, which is bit 5. 'A' is 0100 0001 and 'a' is 0110 0001. Flipping that one bit changes case; x | 0x20 forces lower and x & ~0x20 forces upper. tr 'a-z' 'A-Z' and every to_upper you have ever called is that bit.
  • The first 32 are not characters you can see. They are commands to a teletype: ring the bell, back up, tab, feed a line, return the carriage. Three of them are still everywhere — TAB, LF (Unix newline) and CR (half of the Windows newline). NUL is what ends a string in C.

Seven bits, and the unclaimed half

128 patterns need seven bits, and a byte has eight. Every ASCII byte therefore has its top bit zero, and the other 128 patterns — 0x80 through 0xFF — belong to nobody. Every country and every vendor claimed them differently, which produced the code pages of the next lesson, and eventually Unicode. The c3 a9 you saw in the hex dump of café are two of those top-bit-set bytes. ASCII has nothing to say about them.

In Python

Verified output of a_character_is_a_number_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. THE AGREEMENT: ord() looks a character up, chr() looks a number up
   'A'  ord ->  65   hex 41   bits 01000001   chr(65) -> 'A'
   'a'  ord ->  97   hex 61   bits 01100001   chr(97) -> 'a'
   '0'  ord ->  48   hex 30   bits 00110000   chr(48) -> '0'
   ' '  ord ->  32   hex 20   bits 00100000   chr(32) -> ' '
   '~'  ord -> 126   hex 7E   bits 01111110   chr(126) -> '~'

2. THE TABLE HAS A LAYOUT, AND IT IS NOT AN ACCIDENT
   digits start at 0x30:   0=30 1=31 2=32 3=33 4=34 5=35 6=36 7=37 8=38 9=39
   uppercase at 0x41:      A=41 B=42 C=43 D=44 E=45 F=46 ...
   lowercase at 0x61:      a=61 b=62 c=63 d=64 e=65 f=66 ...
   'a' - 'A' = 32 = 0x20 = 0010 0000: one bit apart, bit 5

3. TWO TRICKS THE LAYOUT MAKES POSSIBLE
   digit value  : ord('7') - ord('0') = 7
   flip case    : chr(ord('a') ^ 0x20) = 'A',  chr(ord('Q') ^ 0x20) = 'q'
   force lower  : chr(ord('Q') | 0x20) = 'q'   force upper: chr(ord('q') & ~0x20) = 'Q'

4. THE FIRST 32 ARE NOT LETTERS: control characters, from teletype days
     0  0x00  NUL  '\x00'
     7  0x07  BEL  '\x07'
     8  0x08  BS   '\x08'
     9  0x09  TAB  '\t'
    10  0x0A  LF   '\n'
    13  0x0D  CR   '\r'
    27  0x1B  ESC  '\x1b'
   127  0x7F  DEL  '\x7f'
   TAB, LF and CR are the three you will meet every week; NUL ends a C string.

5. ALL 128 FIT IN SEVEN BITS, SO THE TOP BIT OF EVERY ASCII BYTE IS 0
   'A'    01000001
   'z'    01111010
   '~'    01111110
   '\x7f' 01111111
   The 128 patterns with the top bit SET are unclaimed by ASCII.
   Everyone claimed them differently. That is the next lesson.

6. THE WHOLE PRINTABLE TABLE, 32..126
    32=   33=!  34="  35=#  36=$  37=%  38=&  39='  40=(  41=)  42=*  43=+  44=,  45=-  46=.  47=/
    48=0  49=1  50=2  51=3  52=4  53=5  54=6  55=7  56=8  57=9  58=:  59=;  60=<  61==  62=>  63=?
    64=@  65=A  66=B  67=C  68=D  69=E  70=F  71=G  72=H  73=I  74=J  75=K  76=L  77=M  78=N  79=O
    80=P  81=Q  82=R  83=S  84=T  85=U  86=V  87=W  88=X  89=Y  90=Z  91=[  92=\  93=]  94=^  95=_
    96=`  97=a  98=b  99=c 100=d 101=e 102=f 103=g 104=h 105=i 106=j 107=k 108=l 109=m 110=n 111=o
   112=p 113=q 114=r 115=s 116=t 117=u 118=v 119=w 120=x 121=y 122=z 123={ 124=| 125=} 126=~

In the terminal

Verified output of a_character_is_a_number_sh.sh — regenerated by tools/run_examples.py, never hand-typed.

1. CHARACTER -> NUMBER: printf's quote trick

$ printf '%d\n' "'A"
65

$ printf '%d %d %d\n' "'a" "'0" "' "
97 48 32

2. NUMBER -> CHARACTER: an octal or hex escape

$ printf '\101\102\103\n'
ABC

$ printf '\x61\x62\x63\n'
abc

3. THE CONTROL CHARACTERS ARE BYTES TOO: od shows them by name

$ printf 'a\tb\nc\r\n' | od -An -tx1 -c | tidy
  61  09  62  0a  63  0d  0a
   a  \t   b  \n   c  \r  \n

4. THE CASE BIT: tr flips ASCII letters by the same rule

$ echo 'Hello, World' | tr 'a-z' 'A-Z'
HELLO, WORLD

5. THE WHOLE TABLE IS ON YOUR MACHINE: man ascii (not run here)
   Try:  man ascii

The printf '%d' "'A" trick is obscure and worth knowing: a leading quote tells printf to take the code of the character rather than parse it as a number. man ascii prints the whole table with decimal, octal and hex side by side, on every Mac and Linux box.

In Rust

Verified output of a_character_is_a_number_rs.rs — regenerated by tools/run_examples.py, never hand-typed.

1. b'A' IS A u8. 'A' IS A char. THEY AGREE ON THE NUMBER.
   b'A' = 65   'A' as u32 = 65   equal: true
   65u8 as char = 'A'   char::from(65u8) = 'A'

2. THE LAYOUT TRICKS, IN BYTE ARITHMETIC
   b'7' - b'0'        = 7
   (b'a' ^ 0x20) as char = 'A'
   b'q'.to_ascii_uppercase() = 'Q'   (std does the same bit flip, safely)

3. std CAN ASK EVERY QUESTION ABOUT THE TABLE
    'A'  0x41  ascii=true  alpha=true  digit=false ctrl=false upper=true
    'a'  0x61  ascii=true  alpha=true  digit=false ctrl=false upper=false
    '7'  0x37  ascii=true  alpha=false digit=true  ctrl=false upper=false
    ' '  0x20  ascii=true  alpha=false digit=false ctrl=false upper=false
   '\n'  0x0A  ascii=true  alpha=false digit=false ctrl=true  upper=false
    'Ã'  0xC3  ascii=false alpha=false digit=false ctrl=false upper=false
   0xC3 is not ASCII: the top bit is set. `as char` still gives SOMETHING — see the note on the page.

4. A BYTE STRING IS ASCII WHEN EVERY BYTE IS
   b"Hi there".is_ascii() = true
   "caf\u{e9}".is_ascii()   = false

b'A' is a u8 and 'A' is a char. For ASCII they hold the same number, and u8 has the whole is_ascii_* family so you never write the range checks by hand. One thing to notice in section 3: 0xC3 as char does not fail. Rust reads the byte as the Unicode code point U+00C3 (Ã), which is what Latin-1 says that byte means. That is a reading, and for a byte that came out of a UTF-8 file it is the wrong one — which is exactly the mojibake mechanism of chapter 3. The Rust library's Meet the char is where char is taught properly.

If you are coming from Python or ABAP

Python. ord and chr you know. Two things worth adding: they are defined on Unicode code points, not ASCII, so ord('é') is 233 and chr(0x1F600) is an emoji — ASCII is just the first 128 rows of the table Python actually uses. And the bytes type has its own case methods (b'abc'.upper()) that are ASCII-only by design, because a byte string has no idea what agreement it is under, and ASCII is the only one safe to assume.

ABAP. A c field or a string holds characters, never bytes, and on a Unicode system each character is stored as UTF-16 — so 'A' is the two bytes 00 41, not the one byte 41. To get the ASCII byte you convert: cl_abap_codepage=>convert_to( source = 'A' codepage = 'UTF-8' ) returns the xstring 41. The control characters you know as constants — cl_abap_char_utilities=>newline, =>cr_lf, =>horizontal_tab — are the rows 10, 13+10 and 9 of the table above; they have names because there is no way to type them in a literal. (Not machine-checked — CI cannot run ABAP.)

Try it

cd 02_Characters/a_character_is_a_number/examples
python3 a_character_is_a_number_py.py
bash a_character_is_a_number_sh.sh
rustc --edition 2024 a_character_is_a_number_rs.rs -o /tmp/ascii && /tmp/ascii

Without the machine: what is 'Z' in hex? What does 0x5A ^ 0x20 give, as a character? What byte ends a line on Unix, and what two bytes on Windows? Check with ord, chr, and printf 'x\n' | xxd.

Practice

Three ASCII tricks, and where each one falls off. Without running anything: what is ord('7') - ord('0'), and what does ord('a') ^ 0x20 give? Say why each works — the answer is about the 1963 layout, not about arithmetic.

Then the edge. Try the same two tricks on '7' (full-width), '٧' (Arabic-Indic), 'é' and 'ß', and say which of the four breaks each trick and how. One of them makes a string longer.

Answers

Verified output of a_character_is_a_number_kata_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. ord('7') - ord('0')
   55 - 48 = 7
   The digits are ten consecutive codes starting at 0x30, so subtracting
   the first one turns a digit into its value. It is not a conversion
   routine; it is a subtraction that happens to be right.

2. x ^ 0x20 ON A LETTER
   'A' 0x41 01000001  ^0x20 -> 'a'
   'a' 0x61 01100001  ^0x20 -> 'A'
   Upper and lower differ in exactly ONE bit, because 0x41 and 0x61 are
   0x20 apart and the alphabet is contiguous in both runs. So case is a
   bit flip, both directions, with one operator and no table.

3. WHERE EACH ONE STOPS
   '9'                    ord-0x30 gives 7      int() gives 7
   full-width '7'         ord-0x30 gives 65255  int() gives 7
   Arabic-Indic '٧'       ord-0x30 gives 1591   int() gives 7
   Two of those three are digits by Unicode's own definition -- int()
   accepts them and str.isdigit() is True -- and the subtraction gives a
   number in the thousands, because they are nowhere near 0x30.

   And the case trick:
   'é' 0x00e9  ^0x20 -> 'É'   upper() -> 'É'
   'ż' 0x017c  ^0x20 -> 'Ŝ'   upper() -> 'Ż'
   The XOR gives a different letter, not a case change. Beyond 0x7F
   nothing guarantees the two cases are 0x20 apart, or adjacent, or even
   the same length -- 'sz' uppercases to two characters.
   len('ß'.upper()) == 2

See also