87 에러는 값이다
먼저 알아야 할 것
돌아보기
51장에서 C의 오류 통보 방식이 반환값·전역 상태·프로그램 중단 셋뿐이라 했고, 그중 반환값이 가장 정직하다고 했다. 그러면 반환값 하나로 “실패 했다”와 “결과는 이것이다”를 동시에 말하려면 어떻게 해야 하는가?
답. 방법은 셋이다. 결과 자리에 불가능한 값을 섞어 쓰거나(널, -1 — 85장에서 본 그 함정이다), 결과를 출력 매개변수로 빼고 반환값을 상태 전용으로 쓰거나, 둘을 하나의 구조체에 담아 함께 돌려주거나. proven은 둘째와 셋째를 함께 쓴다 — 그리고 셋째가 가능한 이유는 46장에서 배운 대로 C의 구조체가 값으로 반환될 수 있기 때문이다.
이 장의 필요성과 맥락
이 장이 끝나면
이 장에서 답할 질문
(void)로 무시할 수 있으면 강제가 아니지 않은가?- 그런데 86장의
proven_println에는 그 표시가 없었다. 왜 화면 출력만 예외인가? - 실패하는 할당자를 일부러 만드는 것이 실무에서도 쓸모가 있는가?
- 이 방식의 대가는 무엇인가?
87.1 두 가지 반환 모양
규칙은 단순하다. 실패할 수 있는 함수는 반드시 실패를 값으로 돌려준다.
- 돌려줄 결과가 없으면
proven_err_t하나를 돌려준다. - 돌려줄 결과가 있으면
{err, value}꾸러미를 돌려준다 —proven_result_u8str_t,proven_result_size_t처럼 타입마다 이름이 있다.
proven_err_t는 열거형이고 성공은 PROVEN_OK(0)다. 확인은 언제나 proven_is_ok(err) 하나로 한다 — err == 0이라고 적어도 되지만, 이름을 쓰면 나중에 표현이 바뀌어도 코드가 버틴다.
87.1.1 에러 코드 전수
실패는 종류마다 이름이 있고, 전부 열여섯 개다. 외울 것은 아니고 어떤 갈래가 있는지만 알아 두면 된다 — 대부분의 코드는 성공/실패만 가르고, 갈래를 보는 것은 복구를 시도할 때뿐이다.
| 코드 | 무슨 일이 있었나 | 주로 어디서 |
|---|---|---|
PROVEN_OK | 성공(0) | — |
ERR_NOMEM | 할당자가 기억을 내주지 못했다 | _create, _grow |
ERR_OUT_OF_BOUNDS | 그릇 밖이다 — 자르지 않고 거부했다 | append, slice, 배열 색인 |
ERR_INVALID_ENCODING | UTF-8/UTF-16이 깨졌다 | 문자열 변환, hex/base64 |
ERR_INVALID_ARG | 인자가 계약 밖이다(널, 0, 못 쓰는 할당자) | 거의 모든 진입점 |
ERR_IO | 바깥 세계가 실패했다 | 파일·스트림 |
ERR_NOT_FOUND | 찾는 것이 없다 | 맵 조회, 파일 열기 |
ERR_INVALID_STATE | 지금 상태에서는 할 수 없다 | 닫은 스트림, 파괴된 객체 |
ERR_NEED_MORE | 입력이 더 있어야 판단할 수 있다 | 파서·디코더 |
ERR_OVERFLOW | 크기 계산이 넘쳤다 | create, 컨테이너 성장 |
ERR_UNSUPPORTED | 이 환경에는 그 기능이 없다 | 프리스탠딩의 OS 기능 |
ERR_AGAIN | 지금은 안 되니 다시 시도하라 | 비차단 입출력 |
ERR_EOF | 끝에 닿았다 | 읽기 |
ERR_BUSY | 남이 쓰는 중이다 | 잠금·작업 큐 |
ERR_PERMISSION | 권한이 없다 | 파일 |
ERR_INVALID_FORMAT | 형식이 틀렸다 | 파싱, 서식 문자열 |
표 87.1
examples/ch87/codes.c
/* 에러는 값이다 — 그 값이 실제로 어떤 것들인지 눈으로 본다. */
#include <proven.h>
static const char *name_of(proven_err_t e)
{
switch (e) {
case PROVEN_OK: return "PROVEN_OK";
case PROVEN_ERR_NOMEM: return "NOMEM (out of memory)";
case PROVEN_ERR_OUT_OF_BOUNDS: return "OUT_OF_BOUNDS (outside the buffer)";
case PROVEN_ERR_INVALID_ENCODING: return "INVALID_ENCODING (broken encoding)";
case PROVEN_ERR_INVALID_ARG: return "INVALID_ARG (argument outside the contract)";
case PROVEN_ERR_IO: return "IO (the outside world failed)";
case PROVEN_ERR_NOT_FOUND: return "NOT_FOUND (not there)";
case PROVEN_ERR_INVALID_STATE: return "INVALID_STATE (cannot do that now)";
case PROVEN_ERR_NEED_MORE: return "NEED_MORE (more input needed)";
case PROVEN_ERR_OVERFLOW: return "OVERFLOW (the arithmetic overflowed)";
case PROVEN_ERR_UNSUPPORTED: return "UNSUPPORTED (not in this environment)";
case PROVEN_ERR_AGAIN: return "AGAIN (not now, try again)";
case PROVEN_ERR_EOF: return "EOF (reached the end)";
case PROVEN_ERR_BUSY: return "BUSY (someone else is using it)";
case PROVEN_ERR_PERMISSION: return "PERMISSION (not allowed)";
case PROVEN_ERR_INVALID_FORMAT: return "INVALID_FORMAT (wrong format)";
}
return "(unknown)";
}
int main(void)
{
proven_println("-- every error code --");
for (int i = PROVEN_OK; i <= PROVEN_ERR_INVALID_FORMAT; i++)
proven_println("{:>2} {}", PROVEN_ARG(i), PROVEN_ARG(name_of((proven_err_t)i)));
proven_println("");
proven_println("-- which codes actually come back --");
/* ① 그릇이 모자라다 → OUT_OF_BOUNDS */
proven_byte_t small[8];
proven_u8str_t s = proven_u8str_borrow(small, sizeof small);
proven_err_t e1 = proven_u8str_append(&s, PROVEN_LIT("Hello, world"));
proven_println("append 12 bytes into 8 -> {}", PROVEN_ARG(name_of(e1)));
/* 실패 원자성: 거부된 뒤에도 원본은 손대지 않은 상태다 */
proven_println(" after failure, len = {}",
PROVEN_ARG(proven_u8str_as_view(&s).size));
/* ② 계약을 어긴 인자 → INVALID_ARG.
쓸 수 없는 할당자(전부 0인 값)를 주면 만들기 전에 걸러낸다. */
proven_allocator_t nothing = (proven_allocator_t){0};
proven_result_u8str_t bad = proven_u8str_create(nothing, 16);
proven_println("create with a null allocator -> {}", PROVEN_ARG(name_of(bad.err)));
/* ③ 범위 밖 자르기 → OUT_OF_BOUNDS (있는 만큼 주지 않는다) */
proven_u8str_view_t v = PROVEN_LIT("abcdefgh");
proven_result_mem_view_t sl =
proven_mem_view_slice_checked(proven_mem_view_from_u8(v), 6, 4);
proven_println("slice 4 bytes from 6/8 -> {}", PROVEN_ARG(name_of(sl.err)));
/* ④ 성공은 언제나 0이고, 확인은 언제나 같은 한 줄이다 */
proven_err_t ok = proven_u8str_append(&s, PROVEN_LIT("hi"));
proven_println("append 2 bytes into 8 -> {} (is_ok={})",
PROVEN_ARG(name_of(ok)), PROVEN_ARG(proven_is_ok(ok)));
return 0;
}
실행 결과
-- every error code --
0 PROVEN_OK
1 NOMEM (out of memory)
2 OUT_OF_BOUNDS (outside the buffer)
3 INVALID_ENCODING (broken encoding)
4 INVALID_ARG (argument outside the contract)
5 IO (the outside world failed)
6 NOT_FOUND (not there)
7 INVALID_STATE (cannot do that now)
8 NEED_MORE (more input needed)
9 OVERFLOW (the arithmetic overflowed)
10 UNSUPPORTED (not in this environment)
11 AGAIN (not now, try again)
12 EOF (reached the end)
13 BUSY (someone else is using it)
14 PERMISSION (not allowed)
15 INVALID_FORMAT (wrong format)
-- which codes actually come back --
append 12 bytes into 8 -> OUT_OF_BOUNDS (outside the buffer)
after failure, len = 0
create with a null allocator -> INVALID_ARG (argument outside the contract)
slice 4 bytes from 6/8 -> OUT_OF_BOUNDS (outside the buffer)
append 2 bytes into 8 -> PROVEN_OK (is_ok=1)
예제의 뒷부분이 이 표를 실물로 보여 준다. 여덟 바이트 그릇에 열두 바이트를 넣으려 하면 OUT_OF_BOUNDS가 오고 — 그리고 원본은 손대지 않은 채 남는다(길이가 그대로 0이다). 쓸 수 없는 할당자를 주면 만들기 전에 INVALID_ARG로 걸린다. 범위를 벗어난 자르기도 “있는 만큼”이 아니라 거부다.
여기서 두 가지를 챙겨 두면 좋다. 첫째, ERR_INVALID_ARG는 대개 내 코드의 버그다 — 바깥 세계의 실패가 아니라 계약 위반이므로, 복구할 것이 아니라 고칠 것이다. 둘째, ERR_EOF와 ERR_AGAIN은 정상적인 흐름의 일부다. 읽기 루프에서 EOF는 오류가 아니라 종료 조건이다(93장).
87.1.2 결과 꾸러미의 갈래
돌려줄 값이 있는 함수는 타입마다 꾸러미가 하나씩 있다. 이름 규칙이 같으므로 목록을 외울 필요는 없다 — proven_result_XXX_t 안에는 언제나 err와 value 둘뿐이다.
| 꾸러미 | value의 타입 | 돌려주는 곳 |
|---|---|---|
proven_result_size_t | proven_size_t | 길이·개수·쓴 바이트 수 |
proven_result_mem_mut_t | proven_mem_mut_t | 할당자(88·89장) |
proven_result_mem_view_t | proven_mem_view_t | 자르기(88장) |
proven_result_u8str_t | proven_u8str_t | 문자열 만들기(90장) |
proven_result_buf_t | proven_buf_t | 버퍼 만들기 |
proven_result_cstr_t | const char * | C 문자열로 내보내기(90장) |
proven_fmt_result_t | (쓴 양·필요한 양) | 형식화(91장) |
표 87.2
마지막 줄만 결이 조금 다르다. 형식화는 “성공/실패”만으로는 부족해서 — 잘렸다면 얼마나 더 필요했는지를 알아야 하므로 — err 곁에 두 숫자를 함께 싣는다(91장에서 자세히 본다).
examples/ch87/errval.c
#include <proven.h>
#include <stdio.h>
/* 실패할 수 있는 일을 한다: 정해진 용량의 문자열에 두 조각을 붙인다.
에러는 값으로 돌아오고, 호출자는 그것을 그대로 위로 전달한다. */
static proven_err_t make_greeting(proven_allocator_t alloc,
const char *who,
proven_size_t cap,
proven_u8str_t *out)
{
proven_result_u8str_t made = proven_u8str_create(alloc, cap);
if (!proven_is_ok(made.err))
return made.err; /* 실패를 위로 넘긴다 */
proven_u8str_t s = made.value; /* 확인한 뒤에야 value 를 쓴다 */
proven_err_t e = proven_u8str_append(&s, proven_u8str_view_from_cstr("Hello, "));
if (proven_is_ok(e))
e = proven_u8str_append(&s, proven_u8str_view_from_cstr(who));
if (!proven_is_ok(e)) {
proven_u8str_destroy(alloc, &s); /* 실패했으면 뒷정리도 우리 몫 */
return e;
}
*out = s;
return PROVEN_OK;
}
static void try(proven_allocator_t alloc, const char *who, proven_size_t cap)
{
proven_u8str_t s;
proven_err_t e = make_greeting(alloc, who, cap, &s);
if (!proven_is_ok(e)) {
printf("cap=%2zu who=%-8s -> failed with error code %d\n",
cap, who, (int)e);
return;
}
proven_u8str_view_t v = proven_u8str_as_view(&s);
printf("cap=%2zu who=%-8s -> \"%.*s\"\n",
cap, who, (int)v.size, (const char *)v.ptr);
proven_u8str_destroy(alloc, &s);
}
int main(void)
{
proven_allocator_t alloc = proven_heap_allocator();
try(alloc, "world", 32); /* 넉넉하다 */
try(alloc, "world", 8); /* 자리가 모자란다 — 자르지 않고 거부한다 */
printf("PROVEN_OK is %d, and every failure is non-zero\n", (int)PROVEN_OK);
return 0;
}
실행 결과
cap=32 who=world -> "Hello, world"
cap= 8 who=world -> failed with error code 2
PROVEN_OK is 0, and every failure is non-zero
이 예제에 이 장의 문법이 다 들어 있다. make_greeting은 결과를 출력 매개변수(out)로 내보내고 반환값은 상태 전용으로 썼다 — 실패하면 위로 그대로 넘긴다. 그리고 proven_u8str_create는 꾸러미를 돌려주므로, 확인한 뒤에야 made.value를 꺼낸다. 순서가 뒤바뀌면 안 된다.
반례. 확인하기 전에 value를 꺼내기
proven_u8str_t s = proven_u8str_create(alloc, 64).value; /* 위험 */한 줄로 끝나서 깔끔해 보이지만, 실패했을 때 손에 들어오는 것은 의미 없는 값이다. 꾸러미의 계약은 “err가 PROVEN_OK일 때만 value가 뜻을 가진다”이므로, 이 코드는 계약을 건너뛴 것이다. 실패해도 대개 0으로 채워진 구조체가 와서 당장은 죽지 않는다는 점이 오히려 위험하다 — 사고가 한참 뒤로 미뤄진다(85장의 그 무늬다).
두 번째 호출의 출력이 이 부의 주제를 압축한다. 용량 8바이트에 "Hello, world"를 넣으려 하자 라이브러리는 들어가는 만큼만 넣고 성공을 말하는 대신, 아무것도 쓰지 않고 실패를 돌려주었다. 85장에서 본 snprintf의 조용한 잘림과 정확히 반대되는 선택이다.
87.2 버리면 컴파일러가 항의한다
에러를 값으로 돌려주는 것만으로는 부족하다. 85장에서 본 대로 반환값은 버릴 수 있기 때문이다. 그래서 실패가 의미 있는 함수에는 C23의 [[nodiscard]]가 붙어 있다(51장에서 이름을 보았다). 결과를 버리면 컴파일러가 실제로 이렇게 말한다.
warning: ignoring return value of ‘proven_u8str_append’,
declared with attribute ‘nodiscard’ [-Wunused-result]
5 | proven_u8str_append(&s, proven_u8str_view_from_cstr("hi"));
| ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
note: declared here
123 | [[nodiscard]] proven_err_t proven_u8str_append(...);17장에서 권한 -Werror를 켜 두었다면 이것은 경고가 아니라 빌드 실패가 된다. “확인은 선택”이던 것이 “확인하지 않으면 컴파일되지 않음”으로 바뀐 것이다.
물론 정말로 무시하고 싶을 때가 있다. 그때는 (void)를 앞에 붙인다.
(void)proven_u8str_append(&s, view); /* 알고도 무시한다 */문. (void)로 무시할 수 있으면 강제가 아니지 않은가?
답. 강제의 목적이 “무시를 막는 것”이 아니라 “무시를 눈에 보이게 만드는 것”이기 때문이다. 아무 표시 없이 버려진 에러는 코드 검토에서 보이지 않지만, (void)가 붙은 줄은 “이 실패는 의도적으로 무시한다”는 선언이 된다. 타이핑해야 한다는 사실 자체가 핵심이다 — 실수로는 못 하고, 일부러만 할 수 있다.
문. 그런데 86장의 proven_println에는 그 표시가 없었다. 왜 화면 출력만 예외인가?
답. 계약에도 등급이 있기 때문이다. 콘솔로 나가는 쓰기의 실패는 관례적으로 무시되어 왔고(printf의 반환값을 확인하는 코드를 본 적이 있는가?), 모든 출력 한 줄마다 (void)를 붙이게 만들면 코드가 소음으로 뒤덮인다. 그래서 이 라이브러리는 에러는 돌려주되 확인을 강요하지는 않는 등급을 출력에 두었다. 반대로 입력 쪽 함수들에는 표시가 붙어 있다 — 읽기의 실패를 무시하면 “읽지 않은 데이터”를 읽은 것처럼 다루게 되어, 85장의 둘째 버그가 그대로 재현되기 때문이다. 어디에 표시를 붙일지가 곧 설계 판단이다.
87.3 실패했을 때 남는 것 — 실패 원자성
에러를 돌려받은 뒤 자연스럽게 드는 물음이 있다. 실패한 함수가 건드리던 객체는 지금 어떤 상태인가?
라이브러리의 답은 실패 원자성(failure atomicity)이다 — 문서에 달리 적지 않은 한, 실패한 연산은 대상을 손대기 전 상태로 남긴다. 배열을 늘리다 기억이 모자라면 기존 원소는 그대로 살아 있고, 문자열에 덧붙이다 자리가 모자라면 원래 내용이 그대로다. 방금 예제의 두 번째 호출이 그 예다 — 실패했지만 반쯤 쓰인 문자열이 남지는 않았다.
이것이 왜 중요한가. 실패 원자성이 없으면 호출자는 실패한 뒤 객체를 버리는 것 말고는 할 수 있는 일이 없다. 있으면 “이번 추가는 포기하고 지금까지 모은 것으로 계속 간다”가 가능해진다.
흔한 오해. “실패하면 어차피 프로그램을 끝낼 텐데 상태가 무슨 상관인가”
짧게 도는 명령줄 도구라면 그럴 수 있다. 그러나 오래 도는 프로그램 — 서버, 편집기, 게임, 펌웨어 — 은 실패 하나로 죽으면 안 된다. 한 요청의 처리가 기억 부족으로 실패했다고 서버 전체가 내려간다면 그것이 더 큰 사고다. 실패 원자성은 “이 요청만 버리고 다음 요청을 받는” 복구를 가능하게 하는 최소 조건이다.87.4 실패를 위로 올리는 법 — 정리와 함께
에러를 값으로 받으면 곧바로 실무의 문제가 하나 생긴다. 중간에서 실패하면 그때까지 잡은 것을 누가 되돌리는가? 예외가 있는 언어라면 스택이 풀리며 소멸자가 처리하지만, C에는 그런 장치가 없다(75장). 그래서 관용구가 필요하다.
examples/ch87/cleanup.c
/* 실패 경로에서 자원을 흘리지 않는 법 — 한 곳에 모아 역순으로 정리한다.
75장에서 본 `goto cleanup` 관용구가 proven 의 에러 값과 만나는 자리다. */
#include <proven.h>
/* 실패를 흉내 내기 위한 할당자: n 번째 할당부터는 반드시 실패한다. */
typedef struct {
proven_allocator_t base;
int budget; /* 남은 성공 횟수 */
} failing_ctx_t;
static proven_result_mem_mut_t failing_alloc(void *ctx, proven_size_t size,
proven_size_t align)
{
failing_ctx_t *f = (failing_ctx_t *)ctx;
if (f->budget <= 0)
return (proven_result_mem_mut_t){ .err = PROVEN_ERR_NOMEM };
f->budget--;
return f->base.alloc_fn(f->base.ctx, size, align);
}
static proven_result_mem_mut_t failing_realloc(void *ctx, void *old_ptr,
proven_size_t old_size,
proven_size_t new_size,
proven_size_t align)
{
failing_ctx_t *f = (failing_ctx_t *)ctx;
if (f->budget <= 0)
return (proven_result_mem_mut_t){ .err = PROVEN_ERR_NOMEM };
f->budget--;
return f->base.realloc_fn(f->base.ctx, old_ptr, old_size, new_size, align);
}
static void failing_free(void *ctx, void *ptr)
{
failing_ctx_t *f = (failing_ctx_t *)ctx;
f->base.free_fn(f->base.ctx, ptr);
}
/* 자원 둘을 잡고 일하는 함수. 어디서 실패하든 잡은 것만 정확히 되돌린다. */
static proven_err_t join_two(proven_allocator_t alloc,
proven_u8str_view_t a,
proven_u8str_view_t b,
proven_u8str_t *out)
{
proven_err_t err = PROVEN_OK;
bool has_x = false;
proven_u8str_t x; /* 첫 번째 자원 */
proven_u8str_t y; /* 두 번째 자원 */
proven_result_u8str_t rx = proven_u8str_create_from_view(alloc, a);
if (!proven_is_ok(rx.err)) { err = rx.err; goto done; }
x = rx.value; has_x = true;
proven_result_u8str_t ry = proven_u8str_create_from_view(alloc, b);
if (!proven_is_ok(ry.err)) { err = ry.err; goto done; }
y = ry.value;
/* 두 조각을 이어 붙인다 — 모자라면 늘린다(할당자가 필요한 이유) */
err = proven_u8str_append_grow(alloc, &y, proven_u8str_as_view(&x));
if (!proven_is_ok(err)) { proven_u8str_destroy(alloc, &y); goto done; }
*out = y; /* 성공: y 의 소유권을 넘긴다 */
/* x 는 아래에서 반납된다 */
done:
if (has_x) proven_u8str_destroy(alloc, &x); /* 잡았으면 반드시 되돌린다 */
return err;
}
static void run(int budget)
{
proven_allocator_t heap = proven_heap_allocator();
failing_ctx_t ctx = { .base = heap, .budget = budget };
proven_allocator_t alloc = {
.ctx = &ctx, .alloc_fn = failing_alloc,
.realloc_fn = failing_realloc, .free_fn = failing_free,
};
proven_u8str_t out;
proven_err_t e = join_two(alloc, PROVEN_LIT("world"), PROVEN_LIT("hello, "), &out);
if (proven_is_ok(e)) {
proven_println("budget={} -> ok: \"{}\" (budget left {})",
PROVEN_ARG(budget), PROVEN_ARG(proven_u8str_as_view(&out)),
PROVEN_ARG(ctx.budget));
proven_u8str_destroy(alloc, &out);
} else {
proven_println("budget={} -> failed (code {}) - everything taken was given back",
PROVEN_ARG(budget), PROVEN_ARG((int)e));
}
}
int main(void)
{
run(0); /* 첫 할당부터 실패 */
run(1); /* 첫 자원은 잡고 두 번째에서 실패 → x 를 흘리면 누수다 */
run(9); /* 전부 성공 */
return 0;
}
실행 결과
budget=0 -> failed (code 1) - everything taken was given back
budget=1 -> failed (code 1) - everything taken was given back
budget=9 -> ok: "hello, world" (budget left 6)
이 예제는 일부러 실패하는 할당자를 끼워 넣어(예산이 떨어지면 반드시 NOMEM) 세 경우를 모두 돌려 본다 — 첫 할당부터 실패, 하나는 잡고 둘째에서 실패, 전부 성공. 가운데 경우가 핵심이다. x는 이미 잡혔는데 y에서 실패했으므로, 여기서 그냥 돌아가면 누수다.
무늬는 셋으로 정리된다.
- 가진 것을 깃발로 표시한다 —
has_x같은 불리언 하나. 자원이 여럿이면 깃발도 여럿이다. - 실패하면 한 곳으로 모인다 —
goto done. 75장에서 “절제된goto”라 부른 그 용법이다. - 정리는 잡은 역순으로 — 나중에 잡은 것부터 되돌린다.
성공 경로에서 *out = y;로 소유권을 넘기고 나면, 그 뒤로 y를 파괴하지 않는다는 점도 눈여겨볼 만하다. 소유권이 넘어간 자리를 코드에서 한 줄로 가리킬 수 있어야 한다 — 그러지 못하는 함수는 대개 설계가 흐린 것이다.
문. 실패하는 할당자를 일부러 만드는 것이 실무에서도 쓸모가 있는가?
답. 매우 쓸모가 있다. 기억 부족 경로는 실제 프로그램에서 거의 실행되지 않으므로, 대부분의 코드베이스에서 가장 시험되지 않은 경로다. 그런데 거기서 누수나 이중 해제가 나면 진단이 가장 어렵다.
89장에서 볼 대로 할당자가 그냥 값이기 때문에, 예제처럼 “n번째부터 실패하는” 껍데기를 열 줄로 만들어 끼워 넣을 수 있다. n을 1부터 올려 가며 시험을 돌리면 모든 실패 지점을 한 번씩 통과시킬 수 있고, ASan·Valgrind (17장)와 함께 돌리면 그 경로의 누수가 그대로 드러난다. 라이브러리가 malloc을 직접 부르지 않기로 한 결정이 시험 가능성으로 돌아오는 자리다.
87.5 돌려줄 상대가 없을 때 — 패닉
에러를 값으로 돌려주려면 돌려줄 상대가 있어야 한다. 그런데 계약 자체가 깨진 자리에서는 그 상대가 없다 — 예를 들어 널 포인터를 넘겨야 할 이유가 전혀 없는 자리에 널이 왔다면, 그것은 실패가 아니라 프로그램의 논리가 이미 틀렸다는 뜻이다.
이런 자리를 위해 라이브러리에는 패닉(panic) 경로가 있다. 51장의 assert와 같은 정신이고, 다른 점은 임베디드에서도 쓸 수 있도록 처리 방식을 갈아 끼울 수 있다는 것이다(94장).
문은 둘뿐이다.
void proven_panic(const char *msg); /* 패닉을 일으킨다 */
void proven_set_panic_handler(proven_panic_handler_t h); /* 처리기를 갈아 끼운다 */기본 처리기는 돌아오지 않는다 — 그 자리에서 프로그램을 세운다 (구현은 __builtin_trap()이다). 그리고 이 갈아 끼우기가 임베디드에서 값을 한다. 콘솔이 없는 보드에서는 메시지를 찍을 곳이 없으므로, LED를 켜거나 워치독을 물리거나 재부팅하는 처리기를 등록한다.
static void my_panic(const char *msg) {
(void)msg;
board_led_on(LED_FAULT);
for (;;) { } /* 돌아가지 않는다 */
}
/* 프로그램 시작 자리에서 */
proven_set_panic_handler(my_panic);처리기는 돌아가지 않아야 한다. 돌아가면 _or_panic 계열이 돌려준 값의 유효성이 보장되지 않는다 — 패닉은 “여기서부터는 프로그램의 전제가 깨졌다”는 선언이기 때문이다. 시험 코드에서 패닉 경로를 확인하려고 일부러 돌아오는 처리기를 쓰는 경우가 예외인데, 그때도 그 뒤의 값을 쓰지는 않는다.
라이브러리가 스스로 패닉을 부르는 자리는 손에 꼽는다 — 이름에 _or_panic이 붙은 함수들(89장의 아레나 할당이 대표다)과, 계약이 이미 깨진 것이 분명한 몇 곳뿐이다. 나머지는 전부 값으로 돌려준다.
구분은 이렇게 기억하면 된다.
복습 정리
| 상황 | 예 | 라이브러리의 처리 |
|---|---|---|
| 바깥 세계의 실패 | 기억 부족, 파일 없음, 자리 부족 | 에러를 값으로 반환 |
| 호출자의 계약 위반 | 있어서는 안 될 널, 파괴한 객체 재사용 | 패닉 (또는 미정의) |
| 무시해도 좋은 실패 | 콘솔 출력 실패 | 에러를 반환하되 강요하지 않음 |
표 87.3
실제 사례. 값으로 오는 에러를 택한 다른 언어들
이 설계는 C만의 발명이 아니라 최근 시스템 언어들의 공통된 흐름이다. Go는 함수가 결과와 에러를 나란히 돌려주고, Rust는Result 타입 하나로 성공과 실패를 감싸며 무시하면 경고한다. 둘 다 예외(exception)를 쓰지 않기로 한 언어이고, 이유도 같다 — 에러 경로가 코드의 모양에 드러나야 한다는 것. 예외는 편리하지만 “여기서 어떤 실패가 어디로 튀는가”를 시그니처에서 지운다. 85장 표의 둘째 줄에 대한 답을, 서로 다른 세 언어가 같은 방향에서 찾은 셈이다.문. 이 방식의 대가는 무엇인가?
답. if가 늘어난다. 예외처럼 깊은 곳의 실패를 한 번에 걷어 올려 주는 장치가 없으므로, 실패를 만나는 자리마다 확인하고 위로 넘기는 코드가 붙는다. 방금 예제의 make_greeting이 스무 줄 남짓 되는 이유의 절반이 그것이다. 그 대신 얻는 것이 하나 있다 — 어디서 무엇이 실패할 수 있는지가 코드에 그대로 보인다. 감춰진 실패가 없다는 것, 그것이 이 라이브러리가 파는 물건이다.
에러의 모양을 알았으니, 이제 그 에러들이 지키는 대상 — 기억 그 자체 — 으로 내려간다. 다음 장은 바이트와 뷰, 그리고 넘치지 않는 크기 계산이다.