33 글자와 부호화 — strings·fmt·utf8·codec·hash
먼저 알아야 할 것
slice u8 이다돌아보기
9장에서 “문자열은 따로 된 타입이 없는가” 라는 물음에 무엇이라 답했는가?
답. 문자열 리터럴은 slice u8 이고, 글자가 몇 개인지나 UTF-8 로 올바른지는 타입이 아니라 라이브러리의 op 이 답한다고 했다. 바이트와 글자를 같은 것으로 여기는 순간 생기는 결함을 피하려는 선택이다. 이 장이 그 라이브러리를 둘러본다.
이 장의 필요성과 맥락
이 장이 끝나면
strings 로 문자열을 뷰로 다루고, fmt 로 호출자의 버퍼에 글자를 조립하는 법을 익힌다. utf8 이 올바르지 않은 바이트를 치환 문자 없이 none 으로 알린다는 것, codec 의 16진·base64 와 hash 의 세 가지 해시(자리 고르기·손상 검출·SHA-256)가 각각 무엇에 쓰이는지 알게 된다. 그 밖의 글자 모듈(strbuf·utf16·unicode·regex)이 어디에 있는지도 보게 된다.이 장에서 답할 질문
fmt.put_str을 부를 때마다guard is_some을 적는 것이 번거롭지 않은가?
33.1 자르고 찾고 조립한다#
examples/ch33/request.low
module request .
rem run: main
use strings .
use fmt .
use stdio .
proc main input out cap io . input al cap allocator . output u8 . effects io alloc .
do
let line slice u8 "GET /users/42 HTTP/1.1" .
guard strings.starts_with line. "GET " . else return 1 . .
let rest slice u8 strings.remove_prefix line. "GET " . .
let sp option usize strings.find rest. " " 0 . .
guard is_some sp. . else return 2 . .
let path slice u8 subslice rest. 0 some_value sp. . . .
let g option mut slice u8 alloc_bytes al. capacity 64 . .
guard is_some g. . else return 3 . .
let buf mut slice u8 some_value g. . .
let p1 option usize fmt.put_str buf. 0 "path=" . .
guard is_some p1. . else return 4 . .
let p2 option usize fmt.put_str buf. some_value p1. . path. . .
guard is_some p2. . else return 4 . .
let p3 option usize fmt.put_str buf. some_value p2. . " len=" . .
guard is_some p3. . else return 4 . .
let p4 option usize fmt.put_u64 buf. some_value p3. . cast u64 len path. . . . .
guard is_some p4. . else return 4 . .
let p5 option usize fmt.put_nl buf. some_value p4. . . .
guard is_some p5. . else return 4 . .
let w usize stdio.write out. 1 subslice buf. 0 some_value p5. . . . .
return 0 .
end .
실행 결과
$ lowentc --run main request.low
path=/users/42 len=9
main() = 0
strings.starts_with·strings.remove_prefix·strings.find는 모두slice u8을 받는다.remove_prefix와subslice가 돌려주는 것은 새 버퍼가 아니라 원래 바이트를 가리키는 뷰다. 복사가 없다.fmt.put_str buf. pos. s. .와fmt.put_u64 buf. pos. n. .은 호출자의 버퍼buf의pos자리부터 쓰고, 다음 쓸 자리를option으로 준다. 자리가 모자라면 한 글자도 쓰지 않고none이다.- 조립이 끝나면
subslice buf 0 끝을 표준출력에 쓴다.
버퍼를 cap allocator 로 받았으므로 머리에 alloc 이 보인다. strings 와 fmt 자체는 할당하지 않는다. 버퍼를 어디서 얻을지는 호출자의 일이다.
문. fmt.put_str 을 부를 때마다 guard is_some 을 적는 것이 번거롭지 않은가?
답. 번거롭다. 대신 한 번이라도 자리가 모자라면 그 자리에서 멈추고, 절반만 쓴 출력이 나가지 않는다. 버퍼를 넉넉히 잡고 반복을 줄이려면 strbuf 를 쓴다. strbuf.append 는 소유 버퍼에 이어 붙이고 실패를 result 로 준다. strings 가 읽기(뷰)라면 strbuf 는 쓰기다.
33.2 UTF-8 — 치환 문자를 만들지 않는다#
examples/ch33/chars.low
module chars .
rem run: characters [236,149,136,235,133,149]
rem run: characters [97,98,99]
rem run: characters [236,149]
rem run: first_char [236,149,136,235,133,149]
use utf8 .
fn characters input s slice u8 . output usize .
do
let n option usize utf8.count_chars s. . .
guard is_some n. . else return 999 . .
return some_value n. . .
end .
fn first_char input s slice u8 . output u64 .
do
let c option u64 utf8.decode s. 0 . .
guard is_some c. . else return 0 . .
return some_value c. . .
end .
실행 결과
$ lowentc --run characters chars.low [236,149,136,235,133,149]
characters([236,149,136,235,133,149]) = 2
arg0 (written) = [236,149,136,235,133,149]
$ lowentc --run characters chars.low [97,98,99]
characters([97,98,99]) = 3
arg0 (written) = [97,98,99]
$ lowentc --run characters chars.low [236,149]
characters([236,149]) = 999
arg0 (written) = [236,149]
$ lowentc --run first_char chars.low [236,149,136,235,133,149]
first_char([236,149,136,235,133,149]) = 50504
arg0 (written) = [236,149,136,235,133,149]
utf8.count_chars는 글자 수를 센다. 한글 “안녕” 은 6 바이트, 2 글자다.- 끝이 잘린
[236,149]는 올바른 UTF-8 이 아니므로none이고, 예제는 999 를 돌려준다. utf8.decode s. 0 .은 0 번 자리의 글자 하나를 코드포인트로 준다. 50504 는 U+C548, “안” 이다.
많은 라이브러리는 올바르지 않은 바이트를 만나면 치환 문자(U+FFFD)를 넣고 계속 간다. 그러면 입력이 망가졌다는 사실이 출력 속에 묻힌다. utf8 은 none 으로 알린다. 망가진 입력을 어떻게 다룰지는 부르는 쪽이 정한다.
UTF-16 을 쓰는 세계(Windows API·Java·JavaScript)와 값을 주고받을 때는 utf16 이 slice u16 위에서 서로게이트 쌍을 합치고 쪼갠다. 짝이 맞지 않으면 역시 none 이다. 글자인지·숫자인지·공백인지·폭이 0 인지를 묻는 표는 unicode 모듈에 있다. 그 표는 유니코드 자료에서 기계적으로 뽑았다. 손으로 고른 판정은 빠뜨린 구간에서 오류 대신 조용히 false 를 내기 때문이다.
33.3 16진과 base64#
examples/ch33/hexout.low
module hexout .
rem run: main
use codec .
use stdio .
proc main input out cap io . input al cap allocator . output u8 . effects io alloc .
do
let g option mut slice u8 alloc_bytes al. capacity 16 . .
guard is_some g. . else return 1 . .
let dst mut slice u8 some_value g. . .
let n option usize codec.hex_enc "Lowent" dst. . .
guard is_some n. . else return 2 . .
let w usize stdio.write out. 1 subslice dst. 0 some_value n. . . . .
let nl usize stdio.write out. 1 "\n" . .
let small option usize codec.hex_enc "too long for eight" subslice dst. 0 8 . . .
guard is_none small. . else return 3 . .
return 0 .
end .
실행 결과
$ lowentc --run main hexout.low
4c6f77656e74
main() = 0
codec.hex_enc src. dst. . 는 src 를 16진 글자로 dst 에 쓰고 실제로 쓴 바이트 수를 준다. 결과는 dst 통째가 아니라 앞의 n 바이트다. 여섯 글자 “Lowent” 는 열두 글자 4c6f77656e74 가 된다. 8 바이트 자리에 18 글자를 부호화하려 하면 none 이고 아무것도 쓰지 않는다 — 전량 아니면 무다. hex_dec·b64_enc·b64_dec 도 같은 모양이다.
흔한 오해. 부호화는 암호화의 한 가지다
aead 를 쓴다(36장). codec 과 aead 가 다른 모듈인 것은 헌장의 “셈과 권위를 가른다” 와 같은 까닭이다 — 다른 일에 한 이름을 주지 않는다.33.4 해시 — 세 가지 물음에 세 가지 답#
examples/ch33/codes.low
module codes .
rem run: checksum [104,101,108,108,111]
rem run: slot [104,101,108,108,111] 16
use hash .
fn checksum input data slice u8 . output u64 .
do
return hash.crc data. . .
end .
fn slot input key slice u8 . input nslots u64 . output u64 .
requires gt nslots. 0 . .
do
return hash.bucket_of key. nslots. . .
end .
실행 결과
$ lowentc --run checksum codes.low [104,101,108,108,111]
checksum([104,101,108,108,111]) = 907060870
arg0 (written) = [104,101,108,108,111]
$ lowentc --run slot codes.low [104,101,108,108,111] 16
slot([104,101,108,108,111], 16) = 11
arg0 (written) = [104,101,108,108,111]
hash 모듈은 서로 다른 물음에 서로 다른 해시를 준다.
| 물음 | op | 쓰는 해시 |
|---|---|---|
| 해시맵의 어느 칸에 넣나 | of · bucket_of · bucket_mask | FNV-1a — 빠르고 고르다 |
| 저장·전송 중에 손상됐나 | crc · crc_ok | CRC-32 — 우연한 비트 오류를 잡는다 |
| 누가 일부러 바꾸지 않았나 | sha256 · sha256_ok | SHA-256 — 충돌을 찾기 어렵다 |
표 33.1 — hash 모듈이 답하는 물음
slot 은 키 “hello” 를 16 칸 가운데 11 번에 넣는다. bucket_of 는 나머지 mod 로 칸을 고르므로 결과가 언제나 칸 수보다 작고(4장), 그래서 해시 테이블의 색인 경계 검사가 지워진다. 칸 고르기용 해시로 변조를 막으려 하거나, 암호 해시로 해시맵 칸을 고르면 둘 다 틀린 도구를 쓴 것이다. 이름이 물음을 가른다.
33.5 정규식 — 역추적하지 않는다#
regex 는 패턴을 한 번 컴파일해 프로그램(slice u64)으로 만들어 두고, 같은 프로그램으로 여러 입력을 맞춘다. 컴파일 결과와 작업 공간이 모두 호출자의 슬라이스이므로 할당이 없다.
흔한 정규식 엔진은 갈림길에서 한 길을 끝까지 가 보고 막히면 되돌아오는 역추적 방식이라, 어떤 패턴과 입력에서는 시간이 입력 길이에 대해 지수로 늘어난다(ReDoS). regex 는 모든 갈림길을 동시에 한 걸음씩 진행하는 Pike VM 방식이라 시간이 입력 길이에 비례한다. 대신 역참조처럼 역추적을 요구하는 기능은 없다. 무엇을 하지 않는지가 모듈 문서의 첫머리에 적혀 있다.
33.6 흔한 실수#
반례. 접두사가 있었는지 묻지 않고 뗀 결과를 믿는다
examples/ch33/mistake_removeprefix.low
module mistake_removeprefix .
rem run: path_len [71,69,84,32,47,97]
rem run: path_len [80,79,83,84,32,47,97]
use strings .
rem ✘ 접두사가 있었는지 묻지 않고 뗀 결과를 믿는다 --- 없으면 원본 그대로 돌아온다
fn path_len input line slice u8 . output usize .
do
let rest slice u8 strings.remove_prefix line. "GET " . .
return len rest. . .
end .
실행 결과
$ lowentc --run path_len mistake_removeprefix.low [71,69,84,32,47,97]
path_len([71,69,84,32,47,97]) = 2
arg0 (written) = [71,69,84,32,47,97]
$ lowentc --run path_len mistake_removeprefix.low [80,79,83,84,32,47,97]
path_len([80,79,83,84,32,47,97]) = 7
arg0 (written) = [80,79,83,84,32,47,97]
strings.remove_prefix 는 접두사가 없으면 원본 그대로 돌려준다. 실패를 알리지 않는 대신 결과가 언제나 쓸 수 있는 뷰가 되게 한 설계다. 그래서 POST /a 를 받으면 “GET “ 을 뗀 것처럼 보이는 일곱 바이트가 돌아온다. 접두사가 있어야 뜻이 서는 자리라면 starts_with 로 먼저 묻는다.
examples/ch33/removeprefix_fixed.low
module removeprefix_fixed .
rem run: path_len [71,69,84,32,47,97]
rem run: path_len [80,79,83,84,32,47,97]
use strings .
rem 먼저 묻고, 없으면 따로 답한다
fn path_len input line slice u8 . output usize .
do
guard strings.starts_with line. "GET " . else return 0 . .
return len strings.remove_prefix line. "GET " . . .
end .
실행 결과
$ lowentc --run path_len removeprefix_fixed.low [71,69,84,32,47,97]
path_len([71,69,84,32,47,97]) = 2
arg0 (written) = [71,69,84,32,47,97]
$ lowentc --run path_len removeprefix_fixed.low [80,79,83,84,32,47,97]
path_len([80,79,83,84,32,47,97]) = 0
arg0 (written) = [80,79,83,84,32,47,97]
반례. 부호화의 결과를 출력 버퍼 통째로 여긴다
examples/ch33/mistake_wholedst.low
module mistake_wholedst .
rem run: sizes 0
use codec .
proc sizes input al cap allocator . output usize . effects alloc .
do
let g option mut slice u8 alloc_bytes al. capacity 16 . .
guard is_some g. . else return 1 . .
let dst mut slice u8 some_value g. . .
let n option usize codec.hex_enc "Lowent" dst. . .
guard is_some n. . else return 2 . .
rem ✘ 결과를 `dst` 통째로 여기면 뒤에 쓰지 않은 네 바이트가 붙는다 --- 쓴 수는 12, 버퍼는 16
return add mul len dst. . 100 . some_value n. . . .
end .
실행 결과
$ lowentc --run sizes mistake_wholedst.low 0
sizes(0) = 1612
codec.hex_enc 는 16 바이트 버퍼에 12 바이트를 쓰고 12 를 돌려준다(결과 1612 는 버퍼 16 과 쓴 수 12 를 한데 적은 것이다). dst 통째를 결과로 쓰면 쓰지 않은 네 바이트가 뒤에 붙는다. C 에서 sprintf 의 반환값을 버리고 버퍼 전체를 보내는 실수와 같다. 결과는 언제나 subslice dst. 0 n. . 이다.
흔한 오해. len 은 글자 수이고, 바이트 자리가 곧 글자 자리다
examples/ch33/bytes_not_chars.low
module bytes_not_chars .
rem run: lengths [236,149,136,235,133,149]
rem run: decode_at [236,149,136,235,133,149] 1
rem run: decode_at [236,149,136,235,133,149] 3
use utf8 .
rem `len` 은 바이트 수다 --- "안녕" 은 6 바이트, 2 글자
fn lengths input s slice u8 . output usize .
do
let chars option usize utf8.count_chars s. . .
guard is_some chars. . else return 0 . .
return add mul len s. . 10 . some_value chars. . . .
end .
rem 글자는 바이트 1 에서 시작하지 않는다 --- 둘째 글자는 바이트 3 에서 시작한다
fn decode_at input s slice u8 . input at usize . output u64 .
do
let c option u64 utf8.decode s. at. . .
guard is_some c. . else return 0 . .
return some_value c. . .
end .
실행 결과
$ lowentc --run lengths bytes_not_chars.low [236,149,136,235,133,149]
lengths([236,149,136,235,133,149]) = 62
arg0 (written) = [236,149,136,235,133,149]
$ lowentc --run decode_at bytes_not_chars.low [236,149,136,235,133,149] 1
decode_at([236,149,136,235,133,149], 1) = 0
arg0 (written) = [236,149,136,235,133,149]
$ lowentc --run decode_at bytes_not_chars.low [236,149,136,235,133,149] 3
decode_at([236,149,136,235,133,149], 3) = 45397
arg0 (written) = [236,149,136,235,133,149]
len 은 바이트 수다. “안녕” 은 6 바이트, 2 글자라서 lengths 가 62 를 낸다. 바이트 1 은 첫 글자의 가운데라 utf8.decode 가 none(여기서 0)을 주고, 둘째 글자 “녕”(U+B155, 45397)은 바이트 3 에서 시작한다. 글자를 세거나 옮겨 다닐 때는 utf8 의 op 을 쓰고, 화면에서 차지하는 칸 수는 또 다르다(37장).
33.7 이 장의 문법을 한눈에#
| 모양 | 뜻 | 왜 이렇게 |
|---|---|---|
strings.starts_with s. p. . · strings.find hay. needle. from. . | 묻기 · 찾기(option u64) | 뷰 위에서 — 복사가 없다 |
strings.remove_prefix s. p. . | 접두사를 뗀 뷰 — 없으면 원본 그대로 | 실패 없이 늘 쓸 수 있는 뷰 |
fmt.put_str buf. pos. s. . · fmt.put_u64 buf. pos. n. . | 호출자의 버퍼에 조립 — 다음 자리를 option 으로 | 전량 아니면 무 |
utf8.count_chars s. . · utf8.decode s. at. . | 글자 수 · 그 자리의 코드포인트 | 올바르지 않으면 치환 문자 대신 none |
codec.hex_enc src. dst. . · b64_enc | 옮겨 적고 쓴 수를 준다 | 결과는 subslice dst. 0 n. . — 암호화가 아니다 |
hash.bucket_of key. n. . · hash.crc data. . · hash.sha256 | 칸 고르기 · 손상 검출 · 변조 검출 | 물음마다 다른 해시 |
regex | 한 번 컴파일해 여러 입력에 맞춘다 | 역추적하지 않는다 — 시간이 입력 길이에 비례 |
표 33.2 — 글자 모듈의 모양 — 모양 · 뜻 · 왜 이렇게 생겼나
복습 정리
strings 는 복사 없는 뷰를, fmt 는 호출자의 버퍼에 조립하고 다음 자리를 option 으로 돌려주며, strbuf 는 소유 버퍼에 이어 붙인다. utf8·utf16 은 올바르지 않은 입력을 치환 문자 없이 none 으로 알리고, unicode 의 표는 기계적으로 뽑았다. codec 은 16진·base64 를 전량 아니면 무로 옮긴다. hash 는 칸 고르기·손상 검출·변조 검출에 각기 다른 해시를 주고, regex 는 역추적하지 않는다.