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 minus0x30.ord('7') - ord('0')is 7, in every language. - Uppercase and lowercase are exactly
0x20apart — 32, which is bit 5.'A'is0100 0001and'a'is0110 0001. Flipping that one bit changes case;x | 0x20forces lower andx & ~0x20forces upper.tr 'a-z' 'A-Z'and everyto_upperyou 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) andCR(half of the Windows newline).NULis 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¶
- Reading a hex dump — the right-hand column is this table
- Control characters — TAB, LF, CR, NUL and the ones that still bite
- Code pages — what everyone did with the unclaimed 128
- Meet the
char↗ — the Rust library's page onchar