Lowent 매뉴얼←↑→

33 글자와 부호화 — strings·fmt·utf8·codec·hash

먼저 알아야 할 것

9장 줄 · 문자열은 타입이 아니라 slice u8 이다
32장 표준 라이브러리의 지도 · 호출자의 버퍼·값으로 돌아오는 실패·전량 아니면 무

돌아보기

9장에서 “문자열은 따로 된 타입이 없는가” 라는 물음에 무엇이라 답했는가?

답. 문자열 리터럴은 slice u8 이고, 글자가 몇 개인지나 UTF-8 로 올바른지는 타입이 아니라 라이브러리의 op 이 답한다고 했다. 바이트와 글자를 같은 것으로 여기는 순간 생기는 결함을 피하려는 선택이다. 이 장이 그 라이브러리를 둘러본다.

이 장의 필요성과 맥락

거의 모든 프로그램이 문자열을 자르고, 찾고, 이어 붙이고, 숫자를 글자로 바꾸고, 바이트를 16진이나 base64 로 옮긴다. C 에서는 이 모든 일에 널 종단 문자열과 넘치는 버퍼가 따라다닌다. Lowent 의 글자 모듈들은 할당하지 않고, 결과를 뷰나 호출자의 버퍼로 돌려주고, 실패를 값으로 알린다. 32장의 공통 규약이 가장 자주 보이는 자리라서 라이브러리 둘러보기의 첫 모듈 묶음으로 고른다.

이 장이 끝나면

strings 로 문자열을 뷰로 다루고, fmt 로 호출자의 버퍼에 글자를 조립하는 법을 익힌다. utf8 이 올바르지 않은 바이트를 치환 문자 없이 none 으로 알린다는 것, codec 의 16진·base64 와 hash 의 세 가지 해시(자리 고르기·손상 검출·SHA-256)가 각각 무엇에 쓰이는지 알게 된다. 그 밖의 글자 모듈(strbuf·utf16·unicode·regex)이 어디에 있는지도 보게 된다.

이 장에서 답할 질문

  1. 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

버퍼를 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]

많은 라이브러리는 올바르지 않은 바이트를 만나면 치환 문자(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 도 같은 모양이다.

흔한 오해. 부호화는 암호화의 한 가지다

16진과 base64 는 바이트를 글자로 옮겨 적는 방법일 뿐 비밀을 지키지 않는다. 누구나 되돌린다. 비밀이 필요하면 봉인하는 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_maskFNV-1a — 빠르고 고르다
저장·전송 중에 손상됐나crc · crc_okCRC-32 — 우연한 비트 오류를 잡는다
누가 일부러 바꾸지 않았나digest · digest_okSHA-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 는 역추적하지 않는다.