72 In practice — handling Unicode and multibyte encodings
What to know first
mbstate_tLooking back
Through chapters 70 and 71 the advice “handle UTF-8 byte strings as they are” kept returning. But if you handle bytes as they are, how do you do something like “delete the third character”?
A. Because most programs never do that. Count what they actually do: read, store, compare, concatenate, search, and hand back out. All six work on UTF-8 byte strings with no decoding at all.
The work that needs characters — moving a cursor, finding a line-break point, counting letters — belongs to an editor or a renderer, and at that layer even code points are not enough; you need grapheme clusters. This chapter draws that line: how far to go on bytes, where decoding begins, and what to watch for there.
The need for this chapter, and its context
By the end of this chapter
The questions this chapter answers
- How do you count grapheme clusters?
72.1 The principle — UTF-8 inside, conversion only at the boundary
The conclusion first.
| Place | What to use |
|---|---|
| Inside the program | UTF-8 byte strings (char *, length in bytes) |
| Files, networks, databases | UTF-8 |
| Calling the Windows API | Convert to UTF-16 at the boundary only |
| Character-level editing and rendering | Decode to code points or graphemes only where needed |
Table 73.1
Why this design wins follows straight from UTF-8′s properties.
| Property of UTF-8 | What it buys |
|---|---|
| Fully compatible with ASCII | Existing code, protocols and file formats keep working |
| Trail bytes are always 0x80 or above | They never collide with separators like '/', '\\' or ',' |
| It is self-synchronising | A character boundary can be found from any position |
| Byte order equals code-point order | strcmp gives a code-point-ordered sort |
| It has no endianness | No byte order mark is needed |
Table 73.2
examples-en/ch72/utf8_scan.c
/* Reading UTF-8 by hand — the rule, and what must be rejected (RFC 3629). */
#include <stdio.h>
#include <stddef.h>
#include <string.h>
/* The whole rule of UTF-8 fits in one table.
0xxxxxxx -> 1 byte, U+0000..U+007F
110xxxxx 10xxxxxx -> 2 bytes, U+0080..U+07FF
1110xxxx 10xxxxxx 10xxxxxx -> 3 bytes, U+0800..U+FFFF
11110xxx 10xxxxxx 10xxxxxx 10xxxxxx -> 4 bytes, U+10000..U+10FFFF
A trail byte is always 10xxxxxx. So from any byte you can tell whether you
are at the start of a character or inside one — that is self-synchronisation. */
typedef enum { OK, BAD_LEAD, BAD_TRAIL, TRUNCATED, OVERLONG, SURROGATE, TOO_BIG } verdict;
static const char *why(verdict v)
{
switch (v) {
case OK: return "fine";
case BAD_LEAD: return "not a value that can lead a character";
case BAD_TRAIL: return "a trail byte is not 10xxxxxx";
case TRUNCATED: return "not enough bytes";
case OVERLONG: return "overlong — this could be written shorter";
case SURROGATE: return "surrogates (U+D800..DFFF) cannot be encoded";
case TOO_BIG: return "beyond U+10FFFF";
}
return "?";
}
/* Read one character. On success the byte count goes into *len. */
static verdict decode(const unsigned char *s, size_t n,
unsigned long *cp, size_t *len)
{
if (n == 0) return TRUNCATED;
unsigned char c = s[0];
size_t need;
unsigned long v;
if (c < 0x80) { need = 1; v = c; }
else if ((c & 0xE0) == 0xC0) { need = 2; v = c & 0x1Fu; }
else if ((c & 0xF0) == 0xE0) { need = 3; v = c & 0x0Fu; }
else if ((c & 0xF8) == 0xF0) { need = 4; v = c & 0x07u; }
else return BAD_LEAD; /* starts 10xxxxxx, or 11111xxx */
if (n < need) return TRUNCATED;
for (size_t i = 1; i < need; i++) {
if ((s[i] & 0xC0) != 0x80) return BAD_TRAIL;
v = (v << 6) | (unsigned long)(s[i] & 0x3Fu);
}
/* From here on: shapes that parse but must not be accepted */
static const unsigned long lowest[5] = { 0, 0, 0x80, 0x800, 0x10000 };
if (v < lowest[need]) return OVERLONG;
if (v >= 0xD800UL && v <= 0xDFFFUL) return SURROGATE;
if (v > 0x10FFFFUL) return TOO_BIG;
*cp = v; *len = need;
return OK;
}
static void scan(const char *label, const unsigned char *s, size_t n)
{
printf("\n[%s]", label);
for (size_t i = 0; i < n; i++) printf(" %02X", s[i]);
puts("");
for (size_t pos = 0; pos < n; ) {
unsigned long cp = 0;
size_t len = 0;
verdict v = decode(s + pos, n - pos, &cp, &len);
if (v == OK) {
printf(" %zu byte%s -> U+%04lX\n", len, len == 1 ? "" : "s", cp);
pos += len;
} else {
printf(" rejected: %s\n", why(v));
pos += 1; /* drop one byte and resynchronise */
break;
}
}
}
int main(void)
{
/* the good ones */
scan("ASCII", (const unsigned char *)"Hi", 2);
scan("Hangul U+D55C", (const unsigned char *)"\xED\x95\x9C", 3);
scan("emoji", (const unsigned char *)"\xF0\x9F\x98\x80", 4);
/* the ones that must be rejected */
scan("starts on a trail byte", (const unsigned char *)"\x9C", 1);
scan("a truncated three-byte", (const unsigned char *)"\xED\x95", 2);
scan("a bad trail byte", (const unsigned char *)"\xED\x41\x9C", 3);
scan("overlong C0 80", (const unsigned char *)"\xC0\x80", 2);
scan("surrogate ED A0 80", (const unsigned char *)"\xED\xA0\x80", 3);
scan("out of range F5 80 80 80", (const unsigned char *)"\xF5\x80\x80\x80", 4);
puts("\nSelf-synchronisation — point at any byte and you know where you are:");
const unsigned char *s = (const unsigned char *)"a한글b";
size_t n = strlen((const char *)s);
for (size_t i = 0; i < n; i++)
printf(" byte %zu (%02X): %s\n", i, s[i],
(s[i] & 0xC0) == 0x80 ? "inside a character" : "start of a character");
return 0;
}
Output
[ASCII] 48 69
1 byte -> U+0048
1 byte -> U+0069
[Hangul U+D55C] ED 95 9C
3 bytes -> U+D55C
[emoji] F0 9F 98 80
4 bytes -> U+1F600
[starts on a trail byte] 9C
rejected: not a value that can lead a character
[a truncated three-byte] ED 95
rejected: not enough bytes
[a bad trail byte] ED 41 9C
rejected: a trail byte is not 10xxxxxx
[overlong C0 80] C0 80
rejected: overlong — this could be written shorter
[surrogate ED A0 80] ED A0 80
rejected: surrogates (U+D800..DFFF) cannot be encoded
[out of range F5 80 80 80] F5 80 80 80
rejected: beyond U+10FFFF
Self-synchronisation — point at any byte and you know where you are:
byte 0 (61): start of a character
byte 1 (ED): start of a character
byte 2 (95): inside a character
byte 3 (9C): inside a character
byte 4 (EA): start of a character
byte 5 (B8): inside a character
byte 6 (80): inside a character
byte 7 (62): start of a character
The last part of the demonstration shows self-synchronisation. Point at any byte and testing (b & 0xC0) == 0x80 alone tells you whether you are inside a character or at its start — which is why a buffer can be cut anywhere and recovered, and why a scanner can move on to the next character in damaged data.
72.2 Validation — what to reject even when the shape is right
Code that reads UTF-8 must validate. The rules are RFC 3629 (STD 63), and three things are not caught by the shape alone.
| What to reject | Example | Why |
|---|---|---|
| Overlong encodings | C0 80 | U+0000 written in two bytes — a classic way past a check |
| Encoded surrogates | ED A0 80 | U+D800 is not a character (chapter 71) |
| Out of range | F5 80 80 80 | Beyond U+10FFFF |
Table 73.3
The demonstration rejects each of the three. The first row matters most — overlong encodings are a security problem. There really were attacks that wrote .. or / in several bytes to slip past a path check, so that a later layer would interpret them again. That is why “only the shortest form is valid” became a requirement of the specification.
A common misconception. “UTF-8 is just bytes, so it can be passed through without validation”
Passing it through the middle untouched is mostly safe. The problem is the moment of interpretation.
An unvalidated byte string can be interpreted differently by different layers, and that mismatch becomes a security hole — the first layer sees “odd bytes” and lets them by, the next reads them as /. This is especially so where a web server, a file system and a database meet.
One discipline — validate once at the input boundary and trust it afterwards. Marking clearly in the code where that happened is part of the discipline. Part XII’s u8 family in proven is an example of enforcing it through the type.
72.3 The three layers of “how many characters”, and the fourth
Chapter 71 said length has three answers. Counting what a reader sees makes four.
examples-en/ch72/graphemes.c
/* "How many characters" has three answers — bytes, code points, and what a reader sees. */
#include <locale.h>
#include <stdio.h>
#include <string.h>
#include <wchar.h>
/* The full rule for "one character as a reader sees it" (a grapheme cluster)
is Unicode UAX #29, and a proper implementation needs tables. Here we count
with a *simplified rule* covering the four cases you meet most — the point is
to see the concept, not to be complete.
(1) combining marks (U+0300..U+036F and friends) join the previous one
(2) Hangul conjoining jamo (U+1100..U+11FF) join the previous one
(3) whatever follows a ZWJ (U+200D) joins the previous one
(4) regional indicators (U+1F1E6..U+1F1FF) pair up into a flag */
static int is_combining(unsigned long c)
{
return (c >= 0x0300 && c <= 0x036F) /* combining diacritics */
|| (c >= 0x1160 && c <= 0x11FF) /* Hangul vowels and final consonants */
|| (c >= 0xFE00 && c <= 0xFE0F) /* variation selectors */
|| (c >= 0x1F3FB && c <= 0x1F3FF); /* skin-tone modifiers */
}
static int is_regional(unsigned long c) { return c >= 0x1F1E6 && c <= 0x1F1FF; }
static void measure(const char *label, const char *s)
{
size_t bytes = strlen(s);
size_t cps = 0, clusters = 0;
mbstate_t st;
memset(&st, 0, sizeof st);
unsigned long prev = 0;
int prev_was_zwj = 0, prev_regional = 0;
for (size_t pos = 0; pos < bytes; ) {
wchar_t wc = 0;
size_t r = mbrtowc(&wc, s + pos, bytes - pos, &st);
if (r == (size_t)-1 || r == (size_t)-2) break;
if (r == 0) r = 1;
pos += r;
cps++;
unsigned long c = (unsigned long)wc;
int joins = (cps > 1) && (is_combining(c) || prev_was_zwj || c == 0x200D
|| (is_regional(c) && prev_regional));
if (!joins) clusters++;
prev_was_zwj = (c == 0x200D);
prev_regional = is_regional(c) && !prev_regional;
prev = c;
}
(void)prev;
printf(" bytes %2zu code points %2zu visible characters %2zu %s\n",
bytes, cps, clusters, label);
}
int main(void)
{
const char *loc = setlocale(LC_CTYPE, "C.UTF-8");
if (!loc) loc = setlocale(LC_CTYPE, "en_US.UTF-8");
if (!loc) { puts("no UTF-8 locale — skipping this demonstration"); return 0; }
puts("[the same letter, written two ways]");
measure("\"가\" precomposed, U+AC00", "\uAC00");
measure("\"가\" composed, U+1100 U+1161", "\u1100\u1161");
measure("\"e\\u0301\", combining accent", "e\u0301");
measure("\"\\u00E9\", precomposed", "\u00E9");
puts("\n[emoji: several code points that look like one character]");
measure("grinning face", "\U0001F600");
measure("family, joined by ZWJ", "\U0001F468\u200D\U0001F469\u200D\U0001F467");
measure("a flag, two indicators", "\U0001F1F0\U0001F1F7");
measure("wave + skin tone", "\U0001F44B\U0001F3FD");
puts("\n[one sentence]");
measure("\"Hello, 세계!\"", "Hello, 세계!");
puts("\nSo: before asking \"how many characters\", settle *which layer*.");
puts(" bytes = what you store and send, code points = Unicode's unit,");
puts(" grapheme clusters = what the cursor steps over (UAX #29).");
puts("The count above uses four simplified rules — a complete decision");
puts("belongs to a dedicated library such as ICU.");
return 0;
}
Output
[the same letter, written two ways]
bytes 3 code points 1 visible characters 1 "가" precomposed, U+AC00
bytes 6 code points 2 visible characters 1 "가" composed, U+1100 U+1161
bytes 3 code points 2 visible characters 1 "e\u0301", combining accent
bytes 2 code points 1 visible characters 1 "\u00E9", precomposed
[emoji: several code points that look like one character]
bytes 4 code points 1 visible characters 1 grinning face
bytes 18 code points 5 visible characters 1 family, joined by ZWJ
bytes 8 code points 2 visible characters 1 a flag, two indicators
bytes 8 code points 2 visible characters 1 wave + skin tone
[one sentence]
bytes 14 code points 10 visible characters 10 "Hello, 세계!"
So: before asking "how many characters", settle *which layer*.
bytes = what you store and send, code points = Unicode's unit,
grapheme clusters = what the cursor steps over (UAX #29).
The count above uses four simplified rules — a complete decision
belongs to a dedicated library such as ICU.
| Layer | Unit | Where it is used |
|---|---|---|
| Bytes | char | Storage, transmission, buffer sizes |
| Code units | A UTF-16 unit, … | The length of Java and JavaScript |
| Code points | One Unicode value | Normalisation, classification, conversion |
| Grapheme clusters | What a reader sees as one character | Cursor movement, counting, truncation |
Table 73.4
The demonstration measures the difference. One emoji family is 18 bytes, 5 code points, 1 visible character. An invisible character, ZWJ (U+200D), joined three people into one. A flag is two regional indicators gathered into one.
The same happens in Hangul. “가” can be written as the precomposed U+AC00 or as U+1100 (ㄱ) + U+1161 (ㅏ) — what appears on screen is the same letter.
Q. How do you count grapheme clusters?
A. The rules are Unicode Annex UAX #29. It defines “where a character breaks” by combinations of character properties, and implementing it properly needs the Unicode database.
The demonstration’s count is a simplified version covering the four cases you meet most — combining marks, Hangul conjoining jamo, ZWJ joins and regional indicator pairs. That is enough to count most emoji and Hangul correctly, but it is not complete.
The practical conclusion: do not implement it yourself. Where grapheme units are genuinely needed (an editor’s cursor, display width), use ICU or its like. The purpose of this section is to know when they are needed: not for storage, comparison or transmission, but only when handling what a person sees.
72.4 Normalisation — the same letter, different bytes
The two ways of writing “가” become a problem at once in practice, because different bytes make strcmp say different. To the user it is the same letter, yet the search misses, file names collide, and a login name is treated as another.
Unicode handles this with normalisation (UAX #15). There are four forms.
| Form | What | Example |
|---|---|---|
| NFC | Compose as far as possible (the usual recommendation) | ㄱ+ㅏ → 가 |
| NFD | Decompose as far as possible | 가 → ㄱ+ㅏ |
| NFKC, NFKD | Also unify compatibility characters that look different | ㈜ → (주), fullwidth A → A |
Table 73.5
In practice. macOS file names and NFD
macOS’s HFS+ file system stored file names normalised into something close to NFD. So a Korean file name created on macOS and moved to Linux often appeared with its jamo pulled apart.
For the same reason archives broke, web servers returned 404, and git status reported perfectly good files as modified. Git ended up adding a core.precomposeunicode setting to put names back into NFC on macOS.
Two lessons. One, normalise before comparing strings — especially when using something a person typed (a file name, a user name, a search term) as a key. Two, settle on one form across the whole system — usually NFC.
Standard C has no normalisation function. ICU or its like is required.
72.5 Case and sorting depend on the language
The assumption that toupper turns one character into one character also collapses under Unicode.
| Example | What happens | So |
|---|---|---|
Turkish i | Its capital is İ (dotted I) | Without a locale it cannot be done right |
Turkish I | Its lower case is ı (dotless i) | The exact opposite of English |
German ß | Its capital is SS, two letters | Not a one-to-one mapping |
Greek Σ | At the end of a word it is ς | It depends on position |
Table 73.6
In practice. The Turkish i — code that really did break
The common idiom “to compare case-insensitively, upper-case both and compare” breaks in a Turkish locale. Upper-casing "file" gives "FİLE", which is not "FILE".
Because of this, extension checks failed, HTTP header names did not match and configuration keys went unrecognised in real software. Java’s toUpperCase(Locale.ROOT) exists as a separate call because of this problem.
The remedy is to separate the layers. What a machine compares — protocols, identifiers, file extensions — is folded by ASCII rules only, independent of the locale. The locale is used only for names shown to people.
/* for machines — ASCII only, independent of the locale */
static char ascii_lower(char c)
{ return (c >= 'A' && c <= 'Z') ? (char)(c - 'A' + 'a') : c; }72.6 Still alive — the legacy two-byte encodings
The world has not all become UTF-8. Old files, old databases and the protocols of old equipment still carry regional two-byte encodings.
| Encoding | Region | Underlying standard |
|---|---|---|
| EUC-KR / CP949 (UHC) | Korea | KS X 1001 (formerly KS C 5601) |
| Shift_JIS / CP932 | Japan | JIS X 0208 |
| Big5 | Taiwan, Hong Kong | — (a de facto standard) |
| GBK / GB 18030 | China | GB 18030-2022 (a mandatory Chinese national standard) |
Table 73.7
Their common structure is “lead byte + trail byte”. And in some of them the trail byte reaches into ASCII — which is where the famous accident happens.
examples-en/ch72/legacy_lead.c
/* Legacy two-byte encodings — what happens when trail bytes overlap ASCII. */
#include <stdio.h>
#include <string.h>
/* Each encoding has its own lead-byte and trail-byte ranges.
The dangerous ones are those whose trail bytes reach into ASCII. */
static void show_table(void)
{
puts("byte ranges per encoding (two-byte characters)");
puts(" encoding lead bytes trail bytes 0x5C('\\') as trail?");
puts(" EUC-KR A1-FE A1-FE no");
puts(" CP949(UHC) 81-FE 41-5A,61-7A,81-FE no");
puts(" Shift_JIS 81-9F,E0-FC 40-7E,80-FC * yes");
puts(" Big5 81-FE 40-7E,A1-FE * yes");
puts(" GBK 81-FE 40-FE(not 7F) * yes");
}
/* A path written in Shift_JIS: C:\表\ソ.txt
表 = 95 5C, ソ = 83 5C — both have 0x5C as their trail byte (checked with iconv). */
static const unsigned char sjis_path[] = {
'C', ':', 0x5C, 0x95, 0x5C, 0x5C, 0x83, 0x5C, '.', 't', 'x', 't', 0
};
/* The same in EUC-KR: C:\한글.txt (한 = C7 D1, 글 = B1 DB) */
static const unsigned char euckr_path[] = {
'C', ':', 0x5C, 0xC7, 0xD1, 0xB1, 0xDB, '.', 't', 'x', 't', 0
};
static int sjis_is_lead(unsigned char c)
{ return (c >= 0x81 && c <= 0x9F) || (c >= 0xE0 && c <= 0xFC); }
static int euckr_is_lead(unsigned char c) { return c >= 0xA1 && c <= 0xFE; }
/* Find the last separator, stepping over lead bytes — the correct way */
static long safe_last_sep(const unsigned char *s, int (*is_lead)(unsigned char))
{
long last = -1;
for (size_t i = 0; s[i]; ) {
if (is_lead(s[i]) && s[i + 1]) { i += 2; continue; } /* a two-byte character */
if (s[i] == 0x5C) last = (long)i;
i += 1;
}
return last;
}
static void dump(const char *label, const unsigned char *s)
{
printf(" %-10s", label);
for (size_t i = 0; s[i]; i++) printf(" %02X", s[i]);
puts("");
}
static void test(const char *name, const unsigned char *path,
int (*is_lead)(unsigned char))
{
printf("\n[%s]\n", name);
dump("bytes", path);
/* The usual way — look at bytes only, find the last '\\' */
const char *hit = strrchr((const char *)path, 0x5C);
long naive = hit ? (long)(hit - (const char *)path) : -1;
long safe = safe_last_sep(path, is_lead);
printf(" last '\\' found by strrchr: at %ld\n", naive);
printf(" a lead-byte-aware scan: at %ld\n", safe);
if (naive == safe) puts(" -> the same. This encoding is safe here.");
else {
puts(" -> different! strrchr took a *trail byte* for a separator.");
printf(" Cut there and the file name becomes \"");
for (size_t i = (size_t)naive + 1; path[i]; i++) printf("%02X ", path[i]);
puts("\" — a broken fragment.");
}
}
int main(void)
{
show_table();
test("Shift_JIS: C:\\表\\ソ.txt", sjis_path, sjis_is_lead);
test("EUC-KR: C:\\한글.txt", euckr_path, euckr_is_lead);
puts("\nThis accident even has a name in Japan — dame-moji (ダメ文字),");
puts("with 表, ソ, 十 and ダ as the usual victims. UTF-8 does not have it:");
puts("its trail bytes are always 0x80 or above, so they never touch ASCII.");
const unsigned char utf8_path[] = "C:\\한글.txt";
dump("UTF-8", utf8_path);
const char *h = strrchr((const char *)utf8_path, 0x5C);
printf(" last '\\' found by strrchr: at %ld — always right\n",
h ? (long)(h - (const char *)utf8_path) : -1);
return 0;
}
Output
byte ranges per encoding (two-byte characters)
encoding lead bytes trail bytes 0x5C('\') as trail?
EUC-KR A1-FE A1-FE no
CP949(UHC) 81-FE 41-5A,61-7A,81-FE no
Shift_JIS 81-9F,E0-FC 40-7E,80-FC * yes
Big5 81-FE 40-7E,A1-FE * yes
GBK 81-FE 40-FE(not 7F) * yes
[Shift_JIS: C:\表\ソ.txt]
bytes 43 3A 5C 95 5C 5C 83 5C 2E 74 78 74
last '\' found by strrchr: at 7
a lead-byte-aware scan: at 5
-> different! strrchr took a *trail byte* for a separator.
Cut there and the file name becomes "2E 74 78 74 " — a broken fragment.
[EUC-KR: C:\한글.txt]
bytes 43 3A 5C C7 D1 B1 DB 2E 74 78 74
last '\' found by strrchr: at 2
a lead-byte-aware scan: at 2
-> the same. This encoding is safe here.
This accident even has a name in Japan — dame-moji (ダメ文字),
with 表, ソ, 十 and ダ as the usual victims. UTF-8 does not have it:
its trail bytes are always 0x80 or above, so they never touch ASCII.
UTF-8 43 3A 5C ED 95 9C EA B8 80 2E 74 78 74
last '\' found by strrchr: at 2 — always right
The demonstration reproduces it. In Shift_JIS, 表 is 95 5C and ソ is 83 5C, and that second byte 0x5C is the ASCII backslash. So strrchr(path, '\\') mistakes a character’s trail byte for a path separator. In the demonstration it points at 7 instead of the correct 5, and cutting there breaks the file name.
In Japan these characters even have a name — dame-moji (ダメ文字). 表, ソ, 十 and ダ caused accidents over and over in path handling, escaping and SQL.
| Encoding | Trail byte range | 0x5C as a trail? |
|---|---|---|
| EUC-KR | A1~FE | No — safe |
| CP949 | 41~5A, 61~7A, 81~FE | No — safe |
| Shift_JIS | 40~7E, 80~FC | ★ Yes |
| Big5 | 40~7E, A1~FE | ★ Yes |
| GBK | 40~FE (not 7F) | ★ Yes |
| UTF-8 | 80~BF | No — structurally impossible |
Table 73.8
The Korean encodings escaped this accident by luck. EUC-KR’s trail bytes are all 0xA1 or above and so never touch ASCII. Code handling Japanese or Chinese, by contrast, must use a lead-byte-aware scan.
Counter-example. Scanning a legacy-encoded string byte by byte
char *ext = strrchr(filename, '.'); /* can misbehave in Shift_JIS */
for (char *p = s; *p; p++) /* mistakes a trail byte for a character */
if (*p == ',') split(p);On meeting a lead byte you must step over two. The safe_last_sep of the demonstration is that shape. With standard functions, advance one character at a time with mblen or mbrtowc — but only if the locale is that encoding (chapter 70).
The better prescription is not to create the problem — convert to UTF-8 at the input boundary and handle only UTF-8 inside.
72.7 What does the converting
| Means | Where | Character |
|---|---|---|
iconv | POSIX | Named by encoding. Outside standard C, but on every Unix |
The MultiByteToWideChar family | Windows | Named by code page number |
| ICU | Portable | The most complete — normalisation, collation, graphemes |
The mbrtowc family | Standard C | Only the locale’s encoding (chapter 70) |
| Writing it yourself | — | UTF-8 ↔ UTF-16/32 is short. The demonstrations are the example |
Table 73.9
Let us restate that standard C alone cannot handle an arbitrary encoding. mbrtowc knows only “the encoding of the current locale”. So a task like “read a EUC-KR file and save it as UTF-8” is outside standard C and needs iconv or a conversion table of your own.
Recap
| What to remember | The point |
|---|---|
| Design | UTF-8 byte strings inside, conversion at the boundary only |
| Validation | Once, at the input boundary. Reject overlong, surrogate and out-of-range |
| Length | Bytes, code units, code points, graphemes — settle which layer first |
| Normalisation | To NFC before comparing. Not in standard C |
| Case | ASCII rules for machines. Remember the Turkish i |
| Legacy | Shift_JIS, Big5 and GBK have trail bytes that reach into ASCII |
| Scanning | In legacy encodings, know the lead bytes and step over |
| Conversion | iconv, ICU, platform APIs. Standard C only does the locale’s encoding |
Table 73.10
The story of characters that began in chapter 67 closes after six chapters — from the judgement of one byte through locales and wchar_t to the prescriptions of practice. The next chapter is what recent standards added, and the long argument over “safe” functions.