Proven C BookEnglish GitHub

51 오류와 계약

먼저 알아야 할 것

33장 함수의 의미 · 함수가 실패를 알리는 방법
43장 안전한 입력 · 실패를 값으로 다루는 다섯 규율

돌아보기

33장 끝에서 fact 함수가 “암묵의 약속”(n은 0 이상, 13 이상이면 넘침) 위에 서 있다고 했다. 그런데 그 약속은 코드 어디에도 적혀 있지 않았다 — 그러면 그것은 존재하는가?

답. 존재하지만, 지켜지지 않는 상태로 존재한다 — 그것이 문제의 핵심이다. 모든 함수에는 암묵의 계약이 있다: 어떤 입력에 대해 제대로 동작하는가 (전제조건), 성공하면 무엇을 보장하는가(후조건). 그 계약이 문서와 코드 어디에도 없으면, 계약 위반은 조용히 지나가 나중에 엉뚱한 곳에서 터진다. 이 장은 계약을 드러내는 방법들의 이야기다.

이 장의 필요성과 맥락

33장에서 심은 계약의 씨앗과 43장에서 세운 다섯 규율이 여기서 하나의 원리로 모인다. 이 장이 52장 바로 앞인 것이 중요하다 — 「계약이란 무엇인가」를 정해 두어야 「계약을 어기면 무슨 일이 일어나는가」를 말할 수 있기 때문이다. 두 장이 한 쌍이다.

이 장이 끝나면

33장에서 심은 계약의 씨앗이 자란다 — 함수는 무엇을 요구하고 무엇을 약속하는가(전제조건·후조건), 실패를 어떻게 알리는가(C의 방식: 오류는 값이다), 그리고 그 값을 반드시 확인하게 만드는 장치들까지. 43장에서 세운 다섯 규율이 여기서 원리로 정리된다.

이 장에서 답할 질문

  1. 43장의 다섯 규율은 이 원리와 어떻게 이어지는가?

51.1 오류는 값이다 — C의 방식

많은 현대 언어가 실패를 위한 별도 통로(예외)를 갖고 있지만, C에는 없다. C에서 실패는 반환값으로 알린다 — 함수의 결과 자체가 “성공했는가”를 담고 나오는 것이다. 관행은 세 갈래다.

첫 갈래를 시연으로 본다. 28장에서 배운 “0 나누기는 계약 밖”이라는 사실을, 함수 계약으로 다스리는 모습이다.

examples/ch51/errval.c

#include <stdio.h>
#include <stdlib.h>

/* 계약: divisor가 0이 아니어야 한다. 실패는 반환값으로 알린다. */
[[nodiscard]] bool safe_div(int a, int b, int *out)
{
    if (b == 0) {
        return false;           /* 실패 — out은 건드리지 않는다 */
    }
    *out = a / b;
    return true;
}

int main(void)
{
    int result = 0;

    if (safe_div(7, 2, &result)) {
        printf("7 / 2 = %d\n", result);
    } else {
        printf("7 / 2: failed\n");
    }

    if (safe_div(7, 0, &result)) {
        printf("7 / 0 = %d\n", result);
    } else {
        printf("7 / 0: cannot divide by zero (reported as a value)\n");
    }
    return 0;
}

실행 결과

7 / 2 = 3
7 / 0: cannot divide by zero (reported as a value)

읽을 점 셋. 계약이 주석으로 적혀 있다 — 무엇을 요구하는지가 코드 곁에 있다. 실패해도 출력 인자를 건드리지 않는다 — “실패 시 아무것도 바꾸지 않는다”는 것도 계약의 일부다. 그리고 [[nodiscard]] — C23의 표기로, 이 반환값을 버리는 호출이 있으면 컴파일러가 경고한다. 21장에서 “반환값을 버리는 것은 합법”이라 했던 그 자유에, “이 값만은 버리지 말라”는 제동을 거는 장치다. 43장의 해석 함수가 이 표기를 두른 이유가 바로 이것이다.

흔한 오해. “오류 처리는 코드를 지저분하게 만드는 부수적인 일이다”

입문자가 흔히 갖는 인상이고, 실제로 C의 오류 처리는 눈에 띄게 장황하다 — 호출마다 if가 붙으니까. 그러나 관점을 뒤집으면 정확히 반대다: 오류 경로가 곧 프로그램의 절반이다. 파일이 없을 수 있고, 메모리가 부족할 수 있고, 입력이 엉터리일 수 있다는 것은 예외적 상황이 아니라 정상적인 현실이다. 실제 사고 분석에서 압도적으로 흔한 원인이 “반환값을 확인하지 않았다”인 것이 그 증거다 — 성공 경로만 적힌 코드는 절반만 쓰인 코드다. 장황함을 줄이는 법(공통 정리 지점으로 모으기, 실패를 감싸는 타입 쓰기)은 기술의 문제이고, 확인한다는 원칙은 타협의 문제가 아니다.

51.2 계약을 코드로 — assert와 방어

전제조건을 문서가 아니라 코드로 적는 도구가 있다 — <assert.h>assert(조건)다. 조건이 거짓이면 프로그램을 즉시 세우고 위치를 알린다. 용도를 정확히 구별하는 것이 중요하다:

이 구별이 곧 계약의 두 면이다 — 안쪽(내가 지켜야 할 불변식)은 assert로, 바깥(상대가 지킬지 모르는 약속)은 검사와 오류 값으로.

51.3 const — 가장 값싼 계약

계약을 코드로 적는 도구가 하나 더 있다. 23장에서 “바꾸지 않겠다는 문서”로 소개하고, 46장에서 “이 함수는 원본을 건드리지 않는다”는 표시로 써 온 const다. 이 장의 관점에서 다시 보면, const는 가장 값싸게 적을 수 있는 계약 조항이다 — 함수 서명 한 곳에 한 낱말을 더하는 것으로, 호출하는 쪽에게 “당신의 데이터는 안전하다”를 약속하고 컴파일러에게 그 약속의 감시를 맡긴다.

효과는 세 층에 걸친다.

① 사람에게 — 읽는 부담이 준다. void render(const struct scene *s) 라는 서명을 보는 순간, 이 함수가 scene을 바꾸지 않는다는 것이 확정 된다. 함수 몸통을 읽지 않아도 되는 것 — 큰 코드에서 이보다 값진 절약은 드물다. 23장에서 “바뀌지 않는 것이 많은 코드일수록 읽기 쉽다”고 한 것의 실체다.

② 컴파일러에게 — 최적화의 근거가 된다. 13장에서 편집자가 값을 레지스터에 붙잡아 두는 장면을 보았고, 그 판단의 관건이 “이 값이 그 사이에 바뀔 수 있는가”였다. const는 그 판단을 돕는 신호다 — 다만 정확히 말해야 한다: const 자체가 마법의 최적화 스위치는 아니다. 포인터를 통해 들어온 데이터는 다른 경로로 바뀔 여지가 남아 있어서 (앨리어싱), const 하나로 컴파일러가 모든 걸 확신하지는 못한다. 확실한 이득은 const로 선언된 실제 객체(전역 상수, static const 테이블) 쪽이다 — 컴파일러가 값을 코드에 직접 심거나(상수 전파), 읽기 전용 구역에 배치해 아예 수정 불가로 만든다(42장의 문자열 리터럴이 그 자리에 살았다).

③ 기억의 층에게 — 공유가 안전해진다. 바뀌지 않는 데이터는 복사할 이유가 없다. 여러 곳에서 같은 것을 함께 읽어도 되고, 11장의 캐시 관점에서는 여러 코어가 같은 캐시 라인을 나눠 읽어도 아무 다툼이 없다 — 12장에서 본 false sharing은 쓰기가 있어야 생기는 사고이기 때문 이다. 큰 데이터를 값 복사 대신 const 포인터로 넘기는 관행(46장)이 안전하면서 빠른 이유가 이것이다.

그래서 현대적 관행은 단순하다 — 기본을 const로 두고, 바꿔야 하는 것만 예외로 푼다. 계약을 넓히는 데는 비용이 들지만, 좁히는 데는 낱말 하나면 된다.

문. 43장의 다섯 규율은 이 원리와 어떻게 이어지는가?

답. 셋이 이 장의 원리 그대로다. 첫째, 실패가 타입에 드러난다 — 성공 여부와 값을 갈라 담은 꾸러미를 돌려주므로, “성공했는지 묻지 않고 값을 꺼내는” 코드를 쓰기가 오히려 어색해진다. 둘째, [[nodiscard]]로 확인을 강제한다 — 버리면 컴파일러가 경고한다. 셋째, 경계와 크기를 계약의 일부로 받는다 — 37장~42장에서 본 경계 침범의 뿌리를 API 차원에서 막는 것이다. 요약하면: 계약을 문서가 아니라 타입과 시그니처로 적는 설계이고, 1장에서 본 Rust·Zig의 문제의식과 같은 방향을 C 안에서 구현한 것이다. 제12부가 그 설계를 라이브러리 하나로 끝까지 밀고 간다.

계약과 오류를 다루는 법을 갖췄다. 그런데 이 책이 곳곳에서 “계약 밖”, “정의되지 않은 동작”이라 부르며 미뤄 온 세계가 아직 남아 있다. 다음 장에서 그 세계를 정면으로 본다 — C에서 가장 오해가 많고, 가장 값비싼 주제다.