91 형식화와 파싱 — 타입을 두 번 적지 않기
먼저 알아야 할 것
돌아보기
58장에서 가변 인자에는 타입 정보가 실려 가지 않는다고 했고, 61장에서는 그래서 서식 문자열이 “스택을 어떻게 읽을지”를 혼자 정한다고 했다. 그러면 인자에서 타입을 가져오려면 무엇이 필요한가?
답. 호출 자리에서 타입을 붙잡아 값과 함께 실어 보내면 된다. 즉 인자를 그대로 넘기지 않고 {타입 꼬리표, 값} 꾸러미로 감싸는 것이다. 남은 문제는 “타입 꼬리표를 어떻게 자동으로 붙이는가”인데, 그 답이 C11의 _Generic이다 — 표현식의 타입에 따라 컴파일 시간에 서로 다른 코드를 고르는 장치. 실행 시간 비용은 0이고, 29장에서 배운 암묵적 변환 규칙과 달리 여기서는 타입이 보존된다.
이 장의 필요성과 맥락
이 장이 끝나면
{}라는 타입 없는 자리표시자가 어떻게 타입 안전을 얻는지, 그 밑에서 _Generic이 무엇을 하는지, 그리고 반대 방향인 파싱에서 실패가 어떻게 값으로 드러나는지를 본다. 61장에서 뜯어본 printf·scanf의 대안이다.이 장에서 답할 질문
PROVEN_ARG가 정확히 무엇을 하는가?- 스캐너에도 서식 문자열이 있는가?
91.1 {} — 타입이 없는 자리표시자
규칙은 셋뿐이다.
{}— 여기에 다음 인자를 넣는다. 타입은 적지 않는다.{:...}— 콜론 뒤에 서식 지정(폭·정렬·자릿수)을 적는다.{{}}— 중괄호 글자 자체.
%d가 없으니 %d와 double이 어긋날 자리도 없다. 61장에서 본 불일치의 가능성이 문법 차원에서 제거된 것이다.
91.1.1 콜론 뒤의 문법 전부
지정자의 순서는 정해져 있고, 전부 생략할 수 있다.
| 자리 | 쓸 수 있는 것 | 뜻 |
|---|---|---|
| 채움 | 아무 글자 | 빈자리를 채울 글자. 정렬 기호 앞에 온다 |
| 정렬 | < > ^ | 왼쪽·오른쪽·가운데. 기본은 수는 오른쪽, 그 밖은 왼쪽 |
| 부호 | + | 양수에도 부호를 붙인다 |
| 대체 형식 | # | 0x·0b·0 접두사를 붙인다 |
| 0 채움 | 0 | 폭 앞에 붙이면 0으로 채운다(부호 뒤에 들어간다) |
| 폭 | 수 | 최소 글자 수. 넘쳐도 자르지 않는다 |
| 정밀도 | .수 | 실수의 소수 자릿수 |
| 꼴 | x X o b f e g | 16진(소·대)·8진·2진·고정·지수·짧은 쪽 |
표 91.1
61장의 printf 서식과 대응하지만 세 가지가 다르다. 정렬 기호가 앞에 오고(%-10s 대신 {:<10}), 채움 글자를 고를 수 있고({:*>8}), 타입 글자가 없다(%d의 d가 사라졌다 — 타입은 인자에서 온다).
examples/ch91/spec.c
/* 자리표시자 문법 전부 — 채움·정렬·폭·정밀도·꼴, 그리고 사용자 타입.
{} 하나로 시작해 표 하나를 그리는 데까지 간다. */
#include <proven.h>
/* 라이브러리가 모르는 타입을 찍는 법: 그리는 함수를 하나 주면 된다 */
typedef struct { int num; int den; } frac_t;
static proven_err_t render_frac(proven_fmt_sink_t out, const void *obj)
{
const frac_t *f = (const frac_t *)obj;
proven_byte_t buf[32];
proven_u8str_t s = proven_u8str_borrow(buf, sizeof buf);
proven_fmt_result_t r = proven_u8str_append_fmt(&s, "{}/{}",
PROVEN_ARG(f->num),
PROVEN_ARG(f->den));
if (!proven_is_ok(r.err)) return r.err;
return proven_fmt_put(out, proven_u8str_as_view(&s));
}
int main(void)
{
proven_byte_t buf[256];
/* ── ① 정렬과 채움 ───────────────────────────────────────── */
proven_println("|{:>8}|{:<8}|{:^8}| (right, left, centre)",
PROVEN_ARG("ab"), PROVEN_ARG("ab"), PROVEN_ARG("ab"));
proven_println("|{:*>8}|{:-<8}|{:.^8}| (the fill character goes first)",
PROVEN_ARG("ab"), PROVEN_ARG("ab"), PROVEN_ARG("ab"));
proven_println("|{:08}|{:+}|{:+}| (zero fill, forced sign)",
PROVEN_ARG(42), PROVEN_ARG(42), PROVEN_ARG(-42));
/* ── ② 진법과 대체 형식 ──────────────────────────────────── */
proven_println("{:x} {:X} {:#x} {:o} {:b} {:#b}",
PROVEN_ARG(255), PROVEN_ARG(255), PROVEN_ARG(255),
PROVEN_ARG(8), PROVEN_ARG(5), PROVEN_ARG(5));
/* ── ③ 실수 — 자릿수와 꼴 ────────────────────────────────── */
proven_println("{} {:.2} {:.0} {:f} {:e} {:g}",
PROVEN_ARG(3.14159), PROVEN_ARG(3.14159), PROVEN_ARG(3.14159),
PROVEN_ARG(3.14159), PROVEN_ARG(3.14159), PROVEN_ARG(3.14159));
proven_println("very large and very small: {} {}",
PROVEN_ARG(1e20), PROVEN_ARG(5e-7));
/* ── ④ 중괄호 자체 ───────────────────────────────────────── */
proven_println("braces are written {{ and }}");
/* ── ⑤ 사용자 타입 ───────────────────────────────────────── */
frac_t half = { .num = 1, .den = 2 };
proven_arg_t a = proven_arg_custom(&half, render_frac);
proven_println("a user type: {} (it honours the width too: |{:>8}|)",
PROVEN_ARG(a), PROVEN_ARG(a));
/* ── ⑥ 세 갈래의 형식화 — 거부 / 자름 / 성장 ─────────────── */
proven_u8str_t small = proven_u8str_borrow(buf, 8); /* 내용 7바이트까지 */
proven_fmt_result_t r1 = proven_u8str_append_fmt(&small, "{}", PROVEN_ARG("abcdefgh"));
proven_println("append_fmt err={} written={} required={}",
PROVEN_ARG((int)r1.err), PROVEN_ARG(r1.written), PROVEN_ARG(r1.required));
proven_fmt_result_t r2 = proven_u8str_append_fmt_trunc(&small, "{}", PROVEN_ARG("abcdefghij"));
proven_println("append_fmt_trunc err={} written={} required={} contents=\"{}\"",
PROVEN_ARG((int)r2.err), PROVEN_ARG(r2.written), PROVEN_ARG(r2.required),
PROVEN_ARG(proven_u8str_as_view(&small)));
proven_allocator_t alloc = proven_heap_allocator();
proven_result_u8str_t made = proven_u8str_create(alloc, 4);
if (proven_is_ok(made.err)) {
proven_u8str_t g = made.value;
proven_fmt_result_t r3 = proven_u8str_append_fmt_grow(alloc, &g, "{} {} {}",
PROVEN_ARG("it grows"),
PROVEN_ARG(2026),
PROVEN_ARG(true));
proven_println("append_fmt_grow err={} contents=\"{}\"",
PROVEN_ARG((int)r3.err), PROVEN_ARG(proven_u8str_as_view(&g)));
proven_u8str_destroy(alloc, &g);
}
/* ── ⑦ 표 그리기 — 폭 지정의 실제 쓸모 ───────────────────── */
proven_println("");
proven_println("{:<10}{:>6}{:>9}", PROVEN_ARG("name"), PROVEN_ARG("count"), PROVEN_ARG("ratio"));
proven_println("{:-<25}", PROVEN_ARG(""));
const char *names[] = { "alpha", "beta", "gamma" };
int counts[] = { 7, 128, 3 };
for (int i = 0; i < 3; i++)
proven_println("{:<10}{:>6}{:>9.2}", PROVEN_ARG(names[i]),
PROVEN_ARG(counts[i]), PROVEN_ARG(counts[i] / 138.0 * 100));
return 0;
}
실행 결과
| ab|ab | ab | (right, left, centre)
|******ab|ab------|...ab...| (the fill character goes first)
|00000042|+42|-42| (zero fill, forced sign)
ff FF 0xff 10 101 0b101
3.141590 3.14 3 3.141590 3.141590e+00 3.14159
very large and very small: 1.000000e+20 5.000000e-07
braces are written { and }
a user type: 1/2 (it honours the width too: | 1/2|)
append_fmt err=2 written=0 required=8
append_fmt_trunc err=2 written=7 required=10 contents="abcdefg"
append_fmt_grow err=0 contents="it grows 2026 true"
name count ratio
-------------------------
alpha 7 5.07
beta 128 92.75
gamma 3 2.17
예제가 이 표를 한 줄씩 실물로 보여 준다. 몇 가지만 짚는다.
① 채움은 정렬 앞에 적는다. {:*>8}은 “별표로 채워 오른쪽 정렬”이다. 순서를 바꾸면({:>*8}) 뜻이 되지 않는다.
② {:08}의 0은 부호 뒤에 들어간다. -42를 폭 8로 0 채움하면 -0000042이지 000000-42가 아니다 — 61장에서 본 %08d와 같은 규칙이다.
③ 실수의 기본 표기가 printf와 다르다. 아주 큰 수와 아주 작은 수를 printf("%f")는 100000000000000000000.000000이나 0.000000으로 찍지만, 이 라이브러리는 1.000000e+20과 5.000000e-07로 지수 표기를 쓴다. 정보를 잃지 않는 쪽을 기본으로 삼은 것인데, 두 로그를 비교하기 전에 알아 두어야 할 차이다. 고정 표기가 필요하면 {:f}로 강제한다.
④ {:g}는 왕복 가능한 가장 짧은 표기를 준다. 3.14159가 그대로 나오는 것이 그 결과다 — 8장에서 본 “읽었다 쓰면 같은 값” 성질을 지키는 표기다.
91.1.2 라이브러리가 모르는 타입 찍기
{}에 넣을 수 있는 것은 _Generic 목록에 있는 타입뿐이다. 그러면 내 구조체는 어떻게 찍는가 — 그리는 함수를 하나 주면 된다.
static proven_err_t render_frac(proven_fmt_sink_t out, const void *obj)
{
const frac_t *f = obj;
/* ... 만들어서 ... */
return proven_fmt_put(out, view); /* 내보낸다 */
}
proven_arg_t a = proven_arg_custom(&half, render_frac);
proven_println("{} 그리고 |{:>8}|", PROVEN_ARG(a), PROVEN_ARG(a));proven_fmt_sink_t는 “바이트를 받는 구멍”이고, proven_fmt_put으로 내보낸다. 예제의 출력에서 보듯 폭과 정렬이 사용자 타입에도 그대로 적용된다.
여기에 알아 둘 계약이 하나 있다. 그리는 함수는 {} 하나당 두 번 불린다 — 한 번은 길이를 세는 가짜 구멍으로(폭·정렬을 계산해야 하므로), 한 번은 진짜로. 그래서 이 함수는 결정적이어야 하고 대상을 고쳐서는 안 된다. 두 번의 결과가 다르면 라이브러리는 어긋난 칸을 찍는 대신 INVALID_ARG를 돌려준다. 할당 없이 정렬을 맞추기 위해 치른 대가다.
examples/ch91/fmt.c
#include <proven.h>
#include <stdio.h>
int main(void)
{
/* 화면이 아니라 문자열로 형식화한다 — 버퍼는 스택에서 빌린다 */
proven_byte_t buf[64];
proven_u8str_t out = proven_u8str_borrow(buf, sizeof buf);
int port = 8080;
double load = 0.4237;
const char *host = "example.org";
proven_fmt_result_t r = proven_u8str_append_fmt(&out, "{}:{} load={:.2}",
PROVEN_ARG(host), PROVEN_ARG(port),
PROVEN_ARG(load));
if (proven_is_ok(r.err)) {
proven_u8str_view_t v = proven_u8str_as_view(&out);
printf("formatted : %.*s\n", (int)v.size, (const char *)v.ptr);
}
/* 그릇이 모자라면? 자르지 않고 거부한다 */
proven_byte_t small_buf[8];
proven_u8str_t small = proven_u8str_borrow(small_buf, sizeof small_buf);
proven_fmt_result_t r2 = proven_u8str_append_fmt(&small, "{}:{}",
PROVEN_ARG(host), PROVEN_ARG(port));
printf("into 8 bytes: %s (err=%d)\n",
proven_is_ok(r2.err) ? "ok" : "refused", (int)r2.err);
/* 정렬과 자릿수 — 52장의 폭/정밀도에 해당한다 */
proven_u8str_t line = proven_u8str_borrow(buf, sizeof buf);
(void)proven_u8str_reset(&line);
proven_fmt_result_t r3 = proven_u8str_append_fmt(&line, "|{:>10}|{:<10}|{:.3}|",
PROVEN_ARG(host), PROVEN_ARG(host),
PROVEN_ARG(load));
if (proven_is_ok(r3.err)) {
proven_u8str_view_t v = proven_u8str_as_view(&line);
printf("aligned : %.*s\n", (int)v.size, (const char *)v.ptr);
}
return 0;
}
실행 결과
formatted : example.org:8080 load=0.42
into 8 bytes: refused (err=2)
aligned : |example.org|example.org|0.424|
이 예제는 화면이 아니라 문자열로 형식화한다 — 86장에서 본 proven_println이 이 기계를 표준 출력에 연결한 판이다. 세 가지를 짚을 수 있다.
첫째, 서식화도 실패할 수 있다. 8바이트 그릇에 example.org:8080을 넣을 수 없으므로 거부됐다. 90장의 원칙이 여기서도 그대로다 — 자르느니 실패를 돌려준다. snprintf와 정반대의 기본값이다.
둘째, 서식 지정 문법이 조금 다르다. {:>10}의 >는 오른쪽 정렬, {:<10}은 왼쪽 정렬, {:.3}은 소수 셋째 자리다. 61장의 %10s·%-10s·%.3f와 대응하지만, 타입 글자가 없다는 점이 다르다.
셋째, 반올림은 하되 자릿수는 지킨다. load=0.42는 {:.2}가 만든 것이다.
91.2 어느 형식화 함수를 쓸 것인가
90장의 세 갈래가 형식화에도 그대로 있다. 표로 정리하면 고를 일이 없다.
| 함수 | 모자랄 때 | 할당자 | 쓰는 자리 |
|---|---|---|---|
proven_println(fmt, …) | — | 필요 없음 | 화면에 한 줄 |
proven_print(fmt, …) | — | 필요 없음 | 줄바꿈 없이 |
proven_eprint(fmt, …) | — | 필요 없음 | 표준 오류로 |
proven_u8str_append_fmt | 거부(원본 보존) | 필요 없음 | 고정 버퍼. 기본값 |
proven_u8str_append_fmt_trunc | 들어가는 만큼 | 필요 없음 | 로그 한 줄처럼 잘려도 되는 자리 |
proven_u8str_append_fmt_grow | 늘린다 | 필요 | 길이를 모를 때 |
proven_u8str_append_fmt_with_scratch | 늘린다 | 필요(+임시용) | 임시 기억을 따로 주고 싶을 때 |
표 91.2
세 함수 모두 proven_fmt_result_t를 돌려주는데, 이 꾸러미에는 err 말고 숫자가 둘 더 있다.
typedef struct {
proven_err_t err;
proven_size_t written; /* 실제로 쓴 바이트 수 */
proven_size_t required; /* 전부 쓰려면 필요했던 바이트 수 */
} proven_fmt_result_t;required가 61장에서 본 snprintf의 반환값과 같은 정보다. 다른 점은 그것이 이름 있는 칸에 들어 있다는 것 — snprintf는 “반환값이 버퍼 크기 이상이면 잘린 것”이라는 규약을 사람이 기억해야 했지만, 여기서는 err가 이미 그것을 말해 주고 required는 “그래서 얼마나 필요했나”에 답한다. 예제 spec.c의 출력에서 written=7 required=10이 그 쓰임이다 — 버퍼를 얼마나 키워야 하는지 그대로 알 수 있다.
문. PROVEN_ARG가 정확히 무엇을 하는가?
답. _Generic으로 인자의 타입을 골라, 그 타입에 맞는 꼬리표를 붙인 작은 구조체를 만든다. 개념만 옮기면 이런 모양이다.
#define PROVEN_ARG(x) _Generic((x), \
int: proven_arg_i32, \
double: proven_arg_f64, \
const char *: proven_arg_cstr, \
bool: proven_arg_bool \
/* ... */ )(x)_Generic은 컴파일 시간에 가지를 고르므로 실행 시간에 타입을 판별하는 비용이 없다. 그리고 목록에 없는 타입을 넘기면 컴파일 오류가 난다 — 61장의 printf가 무엇이든 받아 주던 것과 정반대다.
반례. PROVEN_ARG 없이 값을 그냥 넘기기
proven_println("count={}", count); /* 컴파일되지 않는다 */꾸러미가 아닌 날값을 넘겼으므로 타입이 맞지 않아 빌드가 실패한다. 성가시게 느껴질 수 있지만 이것이 설계의 요점이다 — 감싸는 일을 잊으면 프로그램이 만들어지지 않는다. 잊어도 컴파일되고 실행 중에 이상해지는 printf와의 차이가 여기에 있다.
흔한 오해. “자리표시자에 타입이 없으니 느릴 것이다”
반대다.printf는 실행 중에 서식 문자열을 한 글자씩 해석하며 다음에 무엇을 꺼낼지 결정한다. {}도 서식을 훑는 것은 같지만, 각 인자의 타입은 이미 꼬리표로 정해져 있어 추측이 없다. 무엇보다 타입 불일치로 인한 UB가 없으므로 방어 코드도 필요 없다. 비용의 차이는 대개 무시할 만하고, 이 라이브러리의 실수 형식화는 정확성 쪽에 더 신경을 쓴 편이다 — 61장에서 본 %f의 반올림 규칙을 그대로 재현하는 것이 오히려 까다로운 일이다.91.3 반대 방향 — 스캐너
파싱은 형식화의 거울이지만, 실패의 성격이 다르다. 형식화는 그릇이 모자랄 때만 실패하는데, 파싱은 입력이 기대와 다를 때마다 실패한다. 그리고 61장에서 본 대로 sscanf는 “몇 개나 성공했는가”만 알려 준다 — 어디서 왜 멈췄는지는 말해 주지 않는다.
proven의 스캐너는 커서를 가진 객체다. 뷰 위에 놓고, 하나씩 읽어 나간다. 읽을 때마다 결과가 꾸러미로 온다.
typedef struct {
proven_u8str_view_t view; /* 읽고 있는 입력(빌린 것) */
proven_size_t cursor; /* 어디까지 읽었는가 */
} proven_scan_t;두 칸뿐이라는 사실이 두 가지를 말해 준다. 첫째, 스캐너는 입력을 소유하지 않는다 — 뷰 위에 얹힌 커서일 뿐이라 만들 때 할당이 없고 파괴도 없다. 둘째, 커서를 손으로 저장하고 되돌릴 수 있다. 구조체를 통째로 복사해 두었다가 실패하면 되돌리면 되므로, “몇 글자 미리 보고 판단하는” 파서를 어렵지 않게 짤 수 있다.
proven_scan_t save = sc; /* 되돌리기 위한 표시 */
proven_result_i64_t n = proven_scan_i64(&sc);
if (!proven_is_ok(n.err)) sc = save; /* 실패했으니 없던 일로 */읽는 함수는 여섯이고, 전부 커서를 앞으로 민다.
| 함수 | 읽는 것 | 돌려주는 것 |
|---|---|---|
proven_scan_i64(&sc) | 부호 있는 정수 | {err, val} |
proven_scan_u64(&sc) | 부호 없는 정수 | {err, val} |
proven_scan_f64(&sc) | 실수 | {err, val} |
proven_scan_str(&sc) | 공백 전까지의 낱말 | {err, view} — 원본을 가리키는 뷰 |
proven_scan_skip_whitespace(&sc) | 공백을 건너뛴다 | — |
proven_scan_skip_until(&sc, t) | t가 나올 때까지 건너뛴다 | err(없으면 커서 그대로) |
표 91.3
proven_scan_str이 복사하지 않고 뷰를 돌려준다는 점이 중요하다 — 90장의 “복사 없는 텍스트 처리”가 파싱에서도 그대로다. 대신 그 뷰는 원본 입력이 살아 있는 동안만 유효하다(88장).
examples/ch91/lines.c
#include <proven.h>
#include <stdio.h>
/* 한 줄에서 "이름 나이" 꼴을 해석한다 — 실패는 값으로 드러난다. */
static void parse_line(const char *line)
{
proven_scan_t sc = proven_scan_init(proven_u8str_view_from_cstr(line));
proven_result_u8str_view_t name = proven_scan_str(&sc);
if (!proven_is_ok(name.err)) {
printf("[%s] -> no name found\n", line);
return;
}
proven_result_i64_t age = proven_scan_i64(&sc);
if (!proven_is_ok(age.err)) {
printf("[%s] -> age is not a number\n", line);
return;
}
printf("[%s] -> name %.*s, age %lld\n", line,
(int)name.val.size, (const char *)name.val.ptr, (long long)age.val);
}
int main(void)
{
parse_line("alice 33");
parse_line("bob thirty");
parse_line(" ");
return 0;
}
실행 결과
[alice 33] -> name alice, age 33
[bob thirty] -> age is not a number
[ ] -> no name found
세 입력이 세 갈래로 갈린 것이 핵심이다. bob thirty는 이름은 읽혔지만 나이에서 멈췄고, 공백뿐인 줄은 이름부터 실패했다. sscanf라면 둘 다 “항목 하나 성공” 또는 “0”으로 뭉뚱그려졌을 것이다.
커서를 가진다는 것에는 또 다른 이점이 있다. 어디까지 읽었는지를 알 수 있어서, 남은 부분을 다른 방식으로 처리하거나 오류 메시지에 위치를 실을 수 있다. 25장에서 sscanf로 줄을 해석할 때 “몇 글자를 소비했는지 알 수 없다”고 했던 문제의 답이다.
문. 스캐너에도 서식 문자열이 있는가?
답. 있다. 이렇게 쓴다.
proven_scan_fmt_cursor(&sc, "{}:{}",
PROVEN_SCAN_ARG(&host), PROVEN_SCAN_ARG(&port));형식화와 대칭이고, 인자는 담을 곳의 주소를 감싼 것이다. 다만 헤더가 정직하게 밝혀 두는 주의사항이 하나 있다 — 서식 중간에서 실패하면 그 전까지 채워진 값은 이미 바뀌어 있다는 것이다. 87장에서 배운 실패 원자성이 여기서는 보장되지 않으므로, 꼭 필요하면 커서와 목적지를 미리 저장해 두었다가 되돌려야 한다. 계약을 숨기지 않고 문서에 적어 두는 것이 이 라이브러리의 방식이다.
실제 사례. 파서가 관대하면 무슨 일이 생기는가
HTTP 요청 처리에서 반복적으로 드러난 문제가 있다. 서버와 프록시가 같은 요청을 조금씩 다르게 해석하면, 공격자가 그 틈으로 두 번째 요청을 몰래 끼워 넣을 수 있다(request smuggling). 한쪽은 헤더의 이상한 공백을 너그럽게 넘기고 다른 쪽은 엄격히 거부하는 식의 차이가 원인이었다. 교훈은 90장의 인코딩 이야기와 정확히 같다 — 애매한 입력은 고쳐 읽지 말고 거부하라. 실패를 값으로 돌려주는 파서는 그 거부를 표현할 수단을 갖춘 파서이기도 하다.복습 정리
형식화·파싱 요약.
| 하는 일 | API | 메모 |
|---|---|---|
| 화면에 한 줄 | proven_println(fmt, ARG…) | 에러 반환하되 강요 안 함 |
| 문자열로 형식화 | proven_u8str_append_fmt(&s, …) | 모자라면 거부 |
| 자를 것을 허용 | …_append_fmt_trunc | 이름으로 의도를 밝힘 |
| 늘려 가며 | …_append_fmt_grow(alloc, …) | 할당자 필요 |
| 인자 감싸기 | PROVEN_ARG(x) | _Generic — 없는 타입은 컴파일 오류 |
| 스캐너 시작 | proven_scan_init(view) | 커서를 가진 객체 |
| 하나씩 읽기 | proven_scan_i64/f64/str | 꾸러미로 결과와 실패 |
| 서식으로 읽기 | proven_scan_fmt_cursor(…) | 중간 실패 시 부분 변경 주의 |
표 91.4
문자열을 담고, 만들고, 되읽는 어휘가 갖춰졌다. 다음은 여럿을 담는 도구 들이다 — 자라는 배열, 리스트, 링 버퍼, 해시 맵, 그리고 85장에서 예고한 “최악에도 보장이 있는” 알고리즘들.