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 .
proc main input out cap io . input al cap allocator . output u8 . effects io alloc .
do
let line slice u8 be "GET /users/42 HTTP/1.1" .
guard strings.starts_with line "GET " . else return 1 .
let rest slice u8 be strings.remove_prefix line "GET " .
let sp option u64 be strings.find rest " " 0 .
guard is_some sp . else return 2 .
let path slice u8 be subslice rest 0 (some_value sp) .
let g option mut slice u8 be alloc_bytes al capacity 64 .
guard is_some g . else return 3 .
let buf mut slice u8 be some_value g .
let p1 option u64 be fmt.put_str buf 0 "path=" .
guard is_some p1 . else return 4 .
let p2 option u64 be fmt.put_str buf (some_value p1) path .
guard is_some p2 . else return 4 .
let p3 option u64 be fmt.put_str buf (some_value p2) " len=" .
guard is_some p3 . else return 4 .
let p4 option u64 be fmt.put_u64 buf (some_value p3) (len path) .
guard is_some p4 . else return 4 .
let p5 option u64 be fmt.put_nl buf (some_value p4) .
guard is_some p5 . else return 4 .
let w u64 be write_out 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 u64 .
do
let n option u64 be 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 be 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 .
proc main input out cap io . input al cap allocator . output u8 . effects io alloc .
do
let g option mut slice u8 be alloc_bytes al capacity 16 .
guard is_some g . else return 1 .
let dst mut slice u8 be some_value g .
let n option u64 be codec.hex_enc "Lowent" dst .
guard is_some n . else return 2 .
let w u64 be write_out out 1 (subslice dst 0 (some_value n)) .
let nl u64 be write_out out 1 "\n" .
let small option u64 be codec.hex_enc "too long for eight" (subslice dst 0 8) .
guard not (is_some 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 — 우연한 비트 오류를 잡는다 |
| 누가 일부러 바꾸지 않았나 | digest · digest_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 u64 .
do
let rest slice u8 be 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 u64 .
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 u64 . effects alloc .
do
let g option mut slice u8 be alloc_bytes al capacity 16 .
guard is_some g . else return 1 .
let dst mut slice u8 be some_value g .
let n option u64 be 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 u64 .
do
let chars option u64 be 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 u64 . output u64 .
do
let c option u64 be 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.digest | 칸 고르기 · 손상 검출 · 변조 검출 | 물음마다 다른 해시 |
regex | 한 번 컴파일해 여러 입력에 맞춘다 | 역추적하지 않는다 — 시간이 입력 길이에 비례 |
표 33.2 — 글자 모듈의 모양 — 모양 · 뜻 · 왜 이렇게 생겼나
복습 정리
strings 는 복사 없는 뷰를, fmt 는 호출자의 버퍼에 조립하고 다음 자리를 option 으로 돌려주며, strbuf 는 소유 버퍼에 이어 붙인다. utf8·utf16 은 올바르지 않은 입력을 치환 문자 없이 none 으로 알리고, unicode 의 표는 기계적으로 뽑았다. codec 은 16진·base64 를 전량 아니면 무로 옮긴다. hash 는 칸 고르기·손상 검출·변조 검출에 각기 다른 해시를 주고, regex 는 역추적하지 않는다.