91 proven이란 무엇인가 — 그리고 시작하기
먼저 알아야 할 것
돌아보기
56장에서 여러 파일 프로그램을 만들며 헤더와 목적 파일, 링크를 배웠고, 17장에서는 컴파일 릴레이의 네 주자를 보았다. 그러면 “라이브러리를 쓴다”는 것은 그 그림에서 정확히 무엇을 하는 일인가?
답. 둘 중 하나다. 함께 컴파일하거나, 따로 컴파일된 것을 링크하거나. 앞의 길은 남의 소스를 내 소스와 같이 컴파일러에 넘기는 것이고, 뒤의 길은 이미 목적 코드가 된 덩어리(정적 .a나 공유 .so·.dll)를 링커에게 넘기는 것이다. 어느 쪽이든 컴파일러는 선언(헤더)을 보고 호출을 만들고, 링커가 정의를 찾아 잇는다 — 56장의 그림 그대로다. proven은 앞의 길을 택했고, 다음 절이 그 이유다.
이 장의 필요성과 맥락
이 장이 끝나면
configure도, 패키지 관리자도, 링크할 공유 라이브러리도 없는 이유 — 그리고 그 선택이 무엇을 주고 무엇을 앗아가는지 — 를 먼저 보고, 첫 프로그램을 돌린다. 90장에서 본 세 번째 버그(서식 불일치)가 이 첫 프로그램에서 이미 사라진다. 이어서 객체 하나의 일생(만들기 → 쓰기 → 돌려주기)을 끝까지 따라가며, 이 부의 나머지 장을 읽는 데 필요한 세 가지 규칙을 세운다.이 장에서 답할 질문
- 왜 패키지로 배포하지 않는가? 설치가 더 편할 텐데.
- 객체를 꼭
_create로 만들어야 하는가? 힙이 없는 자리에서는? PROVEN_ARG는 어떻게 타입을 알아내는가? C에는 함수 오버로딩이 없지 않은가?
91.1 왜 이 라이브러리가 있는가#
앞 장이 반세기째 되풀이되는 다섯 가지 결함을 실제로 도는 코드로 보였다. 이 장부터 그 각각에 대한 답을 본다. 그런데 도구를 꺼내기 전에 그 도구의 출처와 한계를 먼저 밝히는 것이 순서다. 이 절은 네 가지를 차례로 적는다 — 왜 만들었는가, 무엇을 다르게 하려는가, 무엇이 검증되었는가, 그리고 아직 무엇이 검증되지 않았는가.
왜 만들었는가. 저자가 C 로 프로그램을 만들며 같은 자리에서 반복해서 미끄러졌기 때문이다. 그 자리들은 이 책이 이미 하나씩 짚어 온 것들이다 — 표현의 한계(6·28장), 소리 없이 일어나는 변환(29장), 기억의 수명(36·44장), 값으로 오지 않는 오류(38·79장), 그리고 계약이 문서에만 있는 표준 라이브러리 함수들(69–70장). 한 번 데면 요령이 생기지만, 요령은 사람이 지치면 무너진다. 그래서 요령 대신 부품으로 만들어 두려 한 것이 시작이다.
무엇을 다르게 하려는가. 최근 시스템 프로그래밍이 향해 온 방향 — 오류를 값으로 다루기, 경계와 수명을 타입에 적기, 할당자를 인자로 받기, 문자열을 UTF-8 로 못박기, 부품을 작게 잘라 검사 가능하게 두기 — 를 C23 로 구현해 보려는 시도다. 새 언어로 옮겨 가면 얻는 것들을, 옮겨 갈 수 없는 자리에서도 얼마나 가져올 수 있는지 재 보는 실험에 가깝다.
책 안에서의 위치. proven 은 표준 C 를 대신하지 않고, 이 책이 앞의 여든아홉 장에서 가르친 것을 무르지도 않는다. 문제는 앞에서 proven 없이 설명했고, 여기서는 그중 하나의 해법을 볼 뿐이다. 다른 해법을 고르거나 직접 짜도 좋다 — 그때도 앞의 지식은 그대로 쓰인다.
무엇이 검증되었는가. 이 책이 지면에 인쇄한 범위는 좁고 분명하다. 이 부의 예제 29 개를 포함해 책의 예제 223 개 전부가 매 빌드마다 컴파일·실행되고, 그 출력이 지면에 그대로 실린다(GCC 기준, Clang 으로 교차 검증). 함께 실린 판은 vendor/proven/ 의 스냅샷 하나다. 곧 여기 실린 코드가 이 환경에서 돈다는 사실까지다.
아직 검증되지 않은 것. 그 밖은 검증되지 않았다. proven 은 거대한 프로젝트에 쓰여 본 적도, 여러 해 운영된 적도, 여러 독립 사용자를 거친 적도, 독립적인 보안 감사를 받은 적도 없다. API 도 ABI(application binary interface) 도 아직 약속하지 않는다. 「현대적」· 「안전」·「안정적」 같은 낱말이 이 부에 나온다면, 그것은 설계가 겨냥한 방향을 가리키는 말이지 성숙도를 증명하는 말이 아니다. 실무에 쓸지 판단할 자료는 99장에 표로 정리해 두었다 — 무엇이 확인됐고 무엇이 아직 아닌지, 그리고 무엇이 바뀔 수 있는지.
91.2 이름이 말하는 것 — 유래(provenance)#
이름은 미술 쪽 낱말에서 왔다. 프로버넌스(provenance)는 작품의 내력을 뜻한다 — 어디서 나와 누구를 거쳐 여기 왔는가. 내력을 못 대는 그림은 진품이어도 진품임을 증명하지 못한다.
C 의 기억 모형이 이 낱말을 거의 같은 뜻으로 쓴다는 것을 38장에서 이미 보았다. 포인터에는 주소만이 아니라 어떤 객체에서 유래했는가라는 보이지 않는 딱지가 붙어 있고, 최적화기는 그 딱지를 실재로 취급한다. 그래서 두 포인터가 비트까지 같은 주소를 담고도 서로 다른 것을 가리킬 수 있다.
이 이름을 택한 까닭은 라이브러리의 답이 바로 그 자리를 향하기 때문이다. 유래를 지키는 코드를 「조심해서」 쓰는 것은 사람이 안정적으로 해낼 수 있는 일이 아니다 — 규칙이 눈에 보이지 않고, 어긋난 결과는 최적화 수준을 올릴 때에야 나타난다. 그래서 날 포인터 산술을 좁은 자리 하나에 가두는 쪽을 택했다. 뷰는 포인터와 길이를 함께 지니고, 컨테이너는 제 범위를 안다. 그러면 「두 버퍼를 하나처럼 훑는다」 같은 문장을 실수로는 쓸 수 없게 된다.
91.3 무엇을 대신하려는가#
한 줄로 줄이면 이렇다 — 표준 라이브러리의 낡은 자리를 아래에서부터 갈아 끼우되, 표준 라이브러리를 몰아내지는 않는다.
이 책이 제11부에서 하나씩 짚은 함수들 — strcpy·strtok·sprintf· errno·qsort·rand·atoi — 은 저마다 크기를 모르거나, 숨은 상태를 지니거나, 실패를 값으로 주지 않는다. proven 은 그 각각에 대해 크기를 알고, 검사하고, 드러내는 짝을 하나씩 둔다.
그러나 libc 를 대신하는 물건은 아니다. 이 라이브러리는 당신의 main 을 갖고 싶어 하지 않는다.
| 하지 않는 것 | 뜻 |
|---|---|
| 전역 상태를 두지 않는다 | 숨은 값이 없다 — errno 같은 자리가 없다 |
| 스레드를 띄우지 않는다 | 부르지 않은 일은 일어나지 않는다 |
atexit 를 걸지 않는다 | 프로그램의 끝을 가로채지 않는다 |
| 할당자를 주지 않으면 할당하지 않는다 | 기억을 쓰는 자리가 시그니처에 다 있다 |
표 91.1 — proven 이 하지 않는 것
한 모듈만 써도 되고 전부 써도 된다. 이미 링크하고 있는 것들 옆에 그대로 놓인다.
91.4 설계를 결정한 약속들#
이 부의 아홉 장이 하나씩 풀어 볼 것을 미리 목록으로 둔다. 각 줄이 곧 한 장의 주제다.
- 소유가 시그니처에 있다. 누가 할당하고 누가 돌려주는지가 인자 목록과 반환 타입에 적혀 있다(94장).
- 실패는 반환 타입에 있다. 실패할 수 있는 함수는 결과를 값으로 돌려주고, 중요한 것들은
[[nodiscard]]라 무시하면 컴파일러가 막는다(92장). - 자르지 않고 거부한다. 들어가지 않는 경로는 짧아진 경로가 아니라 오류다 — 70장의
strlcpy논쟁이 바로 이 자리였다(95장). - 실패해도 원자적이다. 문서가 그렇게 적은 자리에서는, 늘리기에 실패해도 원래 자료가 남는다(97장).
- 빌린 뷰는 소유하는 것과 다른 타입이다. 수명을 타입이 말한다(93장).
- 플랫폼은 한 겹에 모여 있다. 운영체제를 부르는 코드가
platform/밖으로 나가지 않아, 프리스탠딩 빌드에서 통째로 떨어져 나간다(99장).
실제 사례. 실제로 써 본 자리 — 무엇을 얻고 무엇을 치렀나
저자가 이 라이브러리 위에 터미널 텍스트 편집기를 하나 만들었다. 판매 문구가 아니라 자료 하나로 적는다.
얻은 것. 가장 컸던 것은 시험 가능성이다. 할당자를 모든 모듈에 꿰어 두었더니 편집기 핵심을 통째로 ASan·UBSan 과 누수 검사 아래에서 돌릴 수 있었다. 무시된 오류와 문자열 사고가 줄었고([[nodiscard]] 와 경계 있는 문자열), 편집기 핵심은 main 을 빼면 libc 를 거의 부르지 않게 됐다. 플랫폼 층 덕분에 리눅스 세 아키텍처와 윈도우가 #ifdef 없이 한 소스로 빌드됐다.
치른 것. 성능은 얻지 못했다 — libc 의 memmove 를 갈아 끼운 것은 벤치마크상 무승부였고, 편집기의 속도 향상은 전부 제 자료구조에서 나왔다. 할당자와 결과 타입을 어디에나 넘기는 것은 결합이고, 오래 사는 여러 플랫폼 코드에는 값하지만 한 번 쓰고 버릴 코드에는 과하다. 그리고 어린 라이브러리에는 구멍이 있다 — 없는 부품을 만나 상류에 보고하게 된다.
91.5 무엇이 아닌가#
가지 않기로 한 자리를 먼저 적어 두는 편이 서로에게 낫다.
- libc 대체품이 아니다. 가비지 컬렉터도, 프레임워크도 아니다. 프로세스도 빌드 그래프도 오류 정책도 가져가지 않는다.
- 암호 라이브러리가 아니다. 해시(SHA-256 포함)와 OS 강도의 난수는 있지만, 서명·키 교환·비밀번호 해싱(KDF)·인증 암호화·TLS 는 일부러 없다.
- 플랫폼 층이 모든 것을 덮지 않는다. 기억·파일·시간·매핑·환경 변수·콘솔 입출력·스레드까지다. 프로세스 제어(
fork·exec·파이프), 터미널 제어, 네트워크는 덮지 않는다 — 그 일이 본체인 프로그램은 POSIX 나 Win32 를 직접 부르게 되고, 「플랫폼#ifdef가 없다」는 성질도 거기까지는 가지 않는다. - 메모리 안전 언어가 아니다. C 다. 계약을 어기면 어긴 대로 된다. 다만 어기기 어렵게, 어겼을 때 드러나게 만들어 두었을 뿐이다.
91.6 설치할 것이 없다는 선택#
proven에는 설치 절차가 없다. 받아 둔 소스를 프로그램과 함께 컴파일하면 그만이다. 소스는 이 책의 저장소 github.com/rubidus-api/proven_c_book 의 vendor/proven/ 에 함께 들어 있다. 중요한 디렉터리는 둘뿐이다.
src/proven/— 이식 가능한 본체. 여기서는 운영체제를 부르지 않는다.platform/— 시스템 호출을 하는 얇은 층. 새 기계로 옮길 때 바꿔야 할 유일한 부분이다.
운영체제가 없는 환경(임베디드)에서는 platform/을 빼고 빌드한다. 이 분리가 라이브러리 전체의 모양을 결정했다 — “어디서나 돌아야 한다”는 요구가 곧 “숨은 할당도, 숨은 전역 상태도 두지 않는다”는 규율이 된다.
문. 왜 패키지로 배포하지 않는가? 설치가 더 편할 텐데.
답. 대가와 이득을 맞바꾼 것이다. 잃는 것은 편의다 — 시스템 패키지로 받을 수 없고, 갱신은 “버전을 올리는 일”이 아니라 “새 소스를 받아오는 일”이 된다. 얻는 것은 통제권이다. 라이브러리가 지금 보고 있는 소스와 다를 수 없고, 내가 고르지 않은 컴파일 선택지가 딸려 오지 않으며, 배포판이 다른 설정으로 빌드했다는 이유로 링크가 깨지는 일이 없다. 무엇보다 호스트 환경과 베어메탈 양쪽에서 통하는 유일한 모델이다 — 임베디드에는 애초에 패키지 관리자가 없다.
91.7 이 책의 예제는 어떻게 빌드되는가#
정직하게 밝혀 두면, 이 책의 proven 예제들은 다음과 같이 컴파일된다. 라이브러리 소스를 한 번 목적 파일로 만들어 두고, 예제와 함께 링크한다.
$ cc -std=c23 -O1 -Ivendor/proven/include -c vendor/proven/src/proven/*.c
$ cc -std=c23 -Wall -Wextra -Werror -Ivendor/proven/include \
hello.c vendor-obj/*.o -lm -o hello첫 줄이 본체를, 둘째 줄이 내 프로그램을 다룬다. -I는 헤더를 찾을 자리를 알려 주는 것(56장), -lm은 수학 함수를 잇는 것이다. 이 두 줄이 이 책의 검증 스크립트가 실제로 매번 돌리는 명령이고, 지면에 인쇄된 모든 실행 결과는 그렇게 만들어진 프로그램의 출력이다.
91.8 첫 프로그램#
#include <proven.h> 하나면 라이브러리 전체가 열린다.
examples/ch86/hello.c
#include <proven.h>
int main(void)
{
const char *name = "world";
int count = 3;
double ratio = 0.75;
bool ready = true;
char grade = 'A';
/* {} 는 자리표시자일 뿐 타입이 없다. 타입은 인자에서 온다 */
proven_println("Hello, {}! You have {} messages.",
PROVEN_ARG(name), PROVEN_ARG(count));
/* 어떤 타입이든 같은 자리표시자로 간다 */
proven_println("ratio={} ready={} grade={}",
PROVEN_ARG(ratio), PROVEN_ARG(ready), PROVEN_ARG(grade));
/* 서식 지정은 콜론 뒤에 붙인다 — 폭, 정렬, 자릿수 */
proven_println("|{:>8}|{:<8}|{:.3}|",
PROVEN_ARG(name), PROVEN_ARG(name), PROVEN_ARG(ratio));
return 0;
}
실행 결과
Hello, world! You have 3 messages.
ratio=0.750000 ready=true grade=A
| world|world |0.750|
한 줄씩 읽는다. proven_println은 서식과 인자를 받아 표준 출력에 한 줄을 찍는다 — 여기까지는 printf와 같다. 다른 것은 자리표시자다.
{}에는 타입이 들어 있지 않다.%d도%s도 아니고 그냥{}다.- 타입은 인자에서 온다.
PROVEN_ARG(x)가x의 타입을 보고 알맞은 꼬리표를 붙여 값을 감싼다. - 그래서 90장의 셋째 버그 — 서식과 인자의 불일치 — 가 구조적으로 일어날 수 없다. 타입을 두 번 적지 않으니 어긋날 자리가 없다.
{:>8}처럼 콜론 뒤에 적는 것은 66장에서 본 폭·정렬·정밀도에 해당한다. >는 오른쪽 정렬, <는 왼쪽 정렬, .3은 소수 셋째 자리까지다. 정렬 기호가 앞에 오는 것이 printf와 다른 점이다.
91.9 세 가지 규칙 — 이 부 전체를 읽는 열쇠#
앞으로 만날 함수가 백 개를 넘지만, 시그니처를 읽는 규칙은 셋뿐이다. 이 셋만 손에 익히면 처음 보는 함수도 문서 없이 절반은 읽어 낼 수 있다.
- 할당자를 인자로 받는 함수만 기억을 잡는다. 시그니처에
proven_allocator_t가 보이면 “이 함수는 할당할 수 있다”는 뜻이고, 없으면 한 바이트도 잡지 않는다. 그래서 임베디드에서 쓸 수 있는 함수와 아닌 함수가 눈으로 갈린다(94장). - 실패는 값으로 온다. 돌려줄 결과가 없으면
proven_err_t하나를, 있으면{err, value}꾸러미를 돌려준다.err를 확인하기 전에는value를 보지 않는다(92장). - 만든 것은 만들 때 준 할당자로 돌려준다.
_create로 얻었으면_destroy로 놓고, 이름에view가 있으면 빌린 것이라 파괴하지 않는다(93–94장).
이름 규칙도 거의 예외가 없다.
| 이름의 꼴 | 뜻 | 보기 |
|---|---|---|
_create | 할당자에게 새 객체를 얻는다 — 꾸러미 반환 | proven_u8str_create |
_borrow | 남의 기억 위에 객체를 얹는다 — 할당 없음 | proven_u8str_borrow |
_destroy | 만들 때 준 할당자로 되돌린다 | proven_u8str_destroy |
_as_ | 같은 것을 다른 눈으로 본다 — 복사 없음 | proven_u8str_as_view |
_view | 빌린 것. 파괴하지 않는다 | proven_u8str_view_t |
_checked | 경계를 검사하고 어기면 에러 | ..._slice_checked |
_unchecked | 검사를 생략한다 — 호출자가 이미 확인한 자리 | ..._slice_unchecked |
_grow | 모자라면 늘린다 — 그래서 할당자를 받는다 | proven_u8str_append_grow |
_or_panic | 실패하면 패닉. 되돌릴 상대가 없는 자리용 | proven_arena_alloc_or_panic |
표 91.2 — proven 이름의 꼴과 뜻
91.10 객체 하나의 일생#
규칙을 말로 세 줄 읽는 것보다 실물 하나를 끝까지 따라가는 편이 빠르다. 다음 프로그램은 문자열 객체를 만들고, 쓰고, 돌려주기까지 전 과정을 한 화면에 담았다.
examples/ch86/first.c
/* 첫 실전 프로그램 — 객체를 만들고, 쓰고, 돌려주는 전 과정.
이 파일 하나에 proven 프로그램의 뼈대가 다 들어 있다. */
#include <proven.h>
/* ① 기억은 어디서 오는가 — 호출자가 정한다(할당자 매개변수).
② 실패는 값으로 온다 — err 를 확인하기 전에는 value 를 보지 않는다.
③ 만든 것은 만들 때 준 할당자로 돌려준다. */
static proven_err_t build_line(proven_allocator_t alloc,
proven_u8str_view_t who,
int count,
proven_u8str_t *out)
{
/* 용량 64바이트짜리 문자열을 alloc 에서 얻는다 */
proven_result_u8str_t made = proven_u8str_create(alloc, 64);
if (!proven_is_ok(made.err))
return made.err;
proven_u8str_t line = made.value; /* 확인 뒤에야 꺼낸다 */
/* 서식으로 이어 붙인다. 실패하면 원본은 손대지 않은 채 남는다.
형식화는 err 와 함께 "쓴 바이트/필요한 바이트"까지 돌려준다 */
proven_fmt_result_t r = proven_u8str_append_fmt(&line, "{} has {} message(s)",
PROVEN_ARG(who), PROVEN_ARG(count));
if (!proven_is_ok(r.err)) {
proven_u8str_destroy(alloc, &line); /* 실패 경로에서도 반납한다 */
return r.err;
}
*out = line; /* 소유권이 호출자에게 넘어간다 */
return PROVEN_OK;
}
int main(void)
{
/* 힙 할당자 — 표준 malloc 을 라이브러리 인터페이스로 감싼 것 */
proven_allocator_t alloc = proven_heap_allocator();
proven_u8str_t line;
proven_err_t e = build_line(alloc, PROVEN_LIT("alice"), 3, &line);
if (!proven_is_ok(e)) {
proven_println("build failed: {}", PROVEN_ARG((int)e));
return 1;
}
/* 소유 문자열 → 빌린 뷰. 뷰는 원본이 살아 있는 동안만 유효하다 */
proven_u8str_view_t v = proven_u8str_as_view(&line);
proven_println("line = {}", PROVEN_ARG(v));
proven_println("length = {} bytes", PROVEN_ARG(v.size));
/* NUL 종단이 필요한 옛 API 와 만나는 자리 (복사·할당 없음) */
proven_println("as C string = {}", PROVEN_ARG(proven_u8str_as_cstr(&line)));
proven_u8str_destroy(alloc, &line); /* 만들 때 준 그 할당자로 */
/* 파괴는 구조체를 0으로 비운다 — 되돌린 버퍼를 다시 가리키지 않도록.
그래서 파괴 뒤의 길이는 0이고, 이 객체는 더 쓰지 않는 것이 계약이다. */
proven_println("after destroy, length = {}",
PROVEN_ARG(proven_u8str_as_view(&line).size));
return 0;
}
실행 결과
line = alice has 3 message(s)
length = 22 bytes
as C string = alice has 3 message(s)
after destroy, length = 0
여섯 자리를 짚는다.
① 할당자를 인자로 받았다. build_line의 첫 인자가 할당자라는 사실이 “이 함수는 기억을 잡을 수 있다”는 선언이다. 호출자가 힙을 줄지 아레나를 줄지 정한다(94장).
② 만들기는 꾸러미로 돌아온다. proven_u8str_create는 proven_result_u8str_t(즉 {err, value})를 준다. err를 확인하기 전에는 value를 꺼내지 않는다 — 이 순서가 92장의 전부다.
③ 용량은 “내용 기준”이다. create(alloc, 64)의 64는 담을 내용의 바이트 수이고, 라이브러리는 내부적으로 NUL 자리 한 바이트를 더 잡는다. 그래서 as_cstr이 복사 없이 C 문자열을 내줄 수 있다.
④ 실패 경로에서도 반납한다. 형식화가 실패하면 그때까지 잡은 문자열을 destroy로 되돌린 뒤 에러를 올린다. 이 패턴이 커지면 92장의 goto 정리 관용구가 된다.
⑤ 소유가 넘어가는 자리가 명시적이다. *out = line;이 그 자리다. 이 줄 뒤로 문자열의 주인은 호출자이고, 파괴할 책임도 호출자에게 있다.
⑥ 파괴는 구조체를 비운다. destroy 뒤 길이가 0으로 찍히는 것이 그 증거다. 되돌린 버퍼를 계속 가리키지 않게 하려는 것이며, 파괴한 객체는 다시 쓰지 않는다는 계약은 그대로다.
반례. 초보자가 첫날 만나는 네 가지 실수
/* ① 확인 없이 value 꺼내기 */
proven_u8str_t s = proven_u8str_create(alloc, 64).value; /* 실패면 쓰레기 */
/* ② 다른 할당자로 파괴하기 */
proven_u8str_destroy(other_alloc, &s); /* 계약 위반 */
/* ③ 뷰를 원본보다 오래 들고 있기 */
proven_u8str_view_t v = proven_u8str_as_view(&s);
proven_u8str_destroy(alloc, &s);
proven_println("{}", PROVEN_ARG(v)); /* 죽은 자리를 읽는다 */
/* ④ PROVEN_ARG 를 빼먹기 */
proven_println("count={}", count); /* 컴파일되지 않는다 */넷 중 ④만 컴파일러가 잡아 준다. 나머지 셋은 사람이 규칙으로 막는 것이고, 그래서 앞 절의 세 규칙을 손에 익히라고 한 것이다. 특히 ③은 95장에서 다시, 그리고 아레나를 리셋할 때 한 번 더 만난다.
문. 객체를 꼭 _create로 만들어야 하는가? 힙이 없는 자리에서는?
답. 아니다. 대부분의 객체에는 빌리는 판이 함께 있다. proven_u8str_borrow(buf, sizeof buf)는 스택이나 정적 배열 위에 문자열을 얹는다 — 할당자를 받지 않으므로 한 바이트도 잡지 않고, 그래서 destroy도 필요 없다(호출자가 이미 주인이다). 임베디드 코드가 이 방식으로 문자열을 다루고(95장), 이 책의 여러 예제도 그렇게 돈다.
중간 형태도 있다. 기억은 한 번만 크게 잡아 두고 그 위에 아레나를 얹은 뒤 객체들에게 나눠 주는 방식인데(94장), 이러면 malloc은 한 번도 불리지 않으면서 _create 계열을 그대로 쓸 수 있다.
문. PROVEN_ARG는 어떻게 타입을 알아내는가? C에는 함수 오버로딩이 없지 않은가?
답. C11에 들어온 _Generic이라는 장치를 쓴다 — 표현식의 타입에 따라 여러 가지 중 하나를 컴파일 시간에 고르는 문법이다. PROVEN_ARG(x)는 x가 int면 정수 꼬리표를, double이면 실수 꼬리표를, const char * 면 문자열 꼬리표를 붙인 작은 구조체를 만든다. 실행 시간에 타입을 판별하는 것이 아니라 컴파일러가 이미 아는 것을 그대로 쓰는 것이라 비용이 없다. 문법과 전체 서식 규칙은 96장에서 정면으로 다룬다.
흔한 오해. “라이브러리를 쓰면 프로그램이 무거워진다”
실제 사례. 소스로 배포하는 관행 — 한 파일짜리 SQLite
91.11 내 프로젝트에 붙이기 — 최소 Makefile#
앞의 두 줄을 매번 손으로 치지 않으려면 101장의 make를 쓴다. 라이브러리를 vendor/proven에 통째로 넣어 두었다고 할 때, 이 정도면 충분하다.
CC = cc
CFLAGS = -std=c23 -Wall -Wextra -Werror -O2 -Ivendor/proven/include
VSRC = $(wildcard vendor/proven/src/proven/*.c) \
$(wildcard vendor/proven/platform/*.c)
VOBJ = $(VSRC:.c=.o)
app: app.o $(VOBJ)
$(CC) $^ -lm -o $@
clean:
rm -f app app.o $(VOBJ)세 가지만 알아 두면 된다. -I 는 <proven.h>를 찾을 자리를 알려 준다 (56장). platform/ 은 운영체제를 부르는 얇은 층이라, 베어메탈로 갈 때는 이 줄만 빼면 된다(99장). -lm 은 실수 형식화가 쓰는 수학 함수를 잇는다 — 형식화에서 실수를 빼면(99장의 PROVEN_FMT_NO_FLOAT) 이것도 필요 없다.
플랫폼 노트. 윈도우와 임베디드에서
MSVC — 이 라이브러리는 C23을 요구한다. Visual Studio 2022의 최신 업데이트가 /std:clatest로 상당 부분을 지원하지만, 가장 확실한 길은 윈도우에서도 clang-cl이나 MinGW-w64(GCC)를 쓰는 것이다(19장의 지형).
MinGW-w64 — 위 Makefile 의 링크 줄에 -lbcrypt 를 더한다. platform/ 이 운영체제의 난수를 BCryptGenRandom 으로 받는데, MSVC 는 소스 안의 #pragma 로 그 라이브러리를 찾아 잇지만 GCC 계열은 그 지시를 모른다.
임베디드 — platform/을 빼고 src/proven/*.c만 컴파일한다. 힙이 없으므로 proven_heap_allocator()는 쓸 수 없는 값(전부 0)을 돌려주고, 대신 정적 배열 위에 아레나를 얹어 쓴다(94장). 자세한 절차는 99장이다.
복습 정리
이 장의 요약.
| 무엇 | 어떻게 |
|---|---|
| 헤더 | #include <proven.h> 하나 |
| 빌드 | src/proven/*.c를 프로그램과 함께 컴파일 (-I로 헤더 경로, -lm) |
| OS 의존 | platform/에만 있다 (없으면 빼고 빌드) |
| 규칙 ① | 할당자를 받는 함수만 기억을 잡는다 |
| 규칙 ② | 실패는 값으로 온다 — err 확인 뒤에 value |
| 규칙 ③ | 만들 때 준 할당자로 파괴한다. view는 파괴하지 않는다 |
| 만들기 | _create(할당) / _borrow(남의 버퍼 위, 할당 없음) |
| 출력 | proven_println("... {} ...", PROVEN_ARG(x)) |
| 서식 지정 | {:>8} {:<8} {:.3} — 콜론 뒤 |
| 대가 | 인자마다 PROVEN_ARG, 익숙한 %d와 다른 문법 |
표 91.3 — proven 을 빌드에 넣는 법
첫 프로그램이 돌았다. 그런데 방금 쓴 proven_println도 사실은 실패할 수 있다 — 화면으로 가는 띠가 끊길 수 있기 때문이다(9장). 이 함수는 에러를 돌려주되 확인을 강요하지는 않는데, 그 선택 자체가 이 라이브러리의 에러 모델을 이해하는 좋은 입구다. 다음 장이 그것이다.
주
- SQLite. The SQLite Amalgamation.
sqlite.org/amalgamation.html↩ - Sean Barrett 외. stb.
github.com/nothings/stb↩