86 proven 시작하기
먼저 알아야 할 것
돌아보기
54장에서 여러 파일 프로그램을 만들며 헤더와 목적 파일, 링크를 배웠고, 16장에서는 컴파일 릴레이의 네 주자를 보았다. 그러면 “라이브러리를 쓴다”는 것은 그 그림에서 정확히 무엇을 하는 일인가?
답. 둘 중 하나다. 함께 컴파일하거나, 따로 컴파일된 것을 링크하거나. 앞의 길은 남의 소스를 내 소스와 같이 컴파일러에 넘기는 것이고, 뒤의 길은 이미 목적 코드가 된 덩어리(정적 .a나 공유 .so·.dll)를 링커에게 넘기는 것이다. 어느 쪽이든 컴파일러는 선언(헤더)을 보고 호출을 만들고, 링커가 정의를 찾아 잇는다 — 54장의 그림 그대로다. proven은 앞의 길을 택했고, 다음 절이 그 이유다.
이 장의 필요성과 맥락
이 장이 끝나면
configure도, 패키지 관리자도, 링크할 공유 라이브러리도 없는 이유 — 그리고 그 선택이 무엇을 주고 무엇을 앗아가는지 — 를 먼저 보고, 첫 프로그램을 돌린다. 85장에서 본 세 번째 버그(서식 불일치)가 이 첫 프로그램에서 이미 사라진다. 이어서 객체 하나의 일생(만들기 → 쓰기 → 돌려주기)을 끝까지 따라가며, 이 부의 나머지 장을 읽는 데 필요한 세 가지 규칙을 세운다.이 장에서 답할 질문
- 왜 패키지로 배포하지 않는가? 설치가 더 편할 텐데.
- 객체를 꼭
_create로 만들어야 하는가? 힙이 없는 자리에서는? PROVEN_ARG는 어떻게 타입을 알아내는가? C에는 함수 오버로딩이 없지 않은가?
86.1 설치할 것이 없다는 선택
proven에는 설치 절차가 없다. 받아 둔 소스를 프로그램과 함께 컴파일하면 그만이다. 소스는 이 책의 저장소 github.com/rubidus-api/proven_c_book 의 vendor/proven/ 에 함께 들어 있다. 중요한 디렉터리는 둘뿐이다.
src/proven/— 이식 가능한 본체. 여기서는 운영체제를 부르지 않는다.platform/— 시스템 호출을 하는 얇은 층. 새 기계로 옮길 때 바꿔야 할 유일한 부분이다.
운영체제가 없는 환경(임베디드)에서는 platform/을 빼고 빌드한다. 이 분리가 라이브러리 전체의 모양을 결정했다 — “어디서나 돌아야 한다”는 요구가 곧 “숨은 할당도, 숨은 전역 상태도 두지 않는다”는 규율이 된다.
문. 왜 패키지로 배포하지 않는가? 설치가 더 편할 텐데.
답. 대가와 이득을 맞바꾼 것이다. 잃는 것은 편의다 — 시스템 패키지로 받을 수 없고, 갱신은 “버전을 올리는 일”이 아니라 “새 소스를 받아오는 일”이 된다. 얻는 것은 통제권이다. 라이브러리가 지금 보고 있는 소스와 다를 수 없고, 내가 고르지 않은 컴파일 선택지가 딸려 오지 않으며, 배포판이 다른 설정으로 빌드했다는 이유로 링크가 깨지는 일이 없다. 무엇보다 호스트 환경과 베어메탈 양쪽에서 통하는 유일한 모델이다 — 임베디드에는 애초에 패키지 관리자가 없다.
86.2 이 책의 예제는 어떻게 빌드되는가
정직하게 밝혀 두면, 이 책의 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는 헤더를 찾을 자리를 알려 주는 것(54장), -lm은 수학 함수를 잇는 것이다. 이 두 줄이 이 책의 검증 스크립트가 실제로 매번 돌리는 명령이고, 지면에 인쇄된 모든 실행 결과는 그렇게 만들어진 프로그램의 출력이다.
86.3 첫 프로그램
#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의 타입을 보고 알맞은 꼬리표를 붙여 값을 감싼다. - 그래서 85장의 셋째 버그 — 서식과 인자의 불일치 — 가 구조적으로 일어날 수 없다. 타입을 두 번 적지 않으니 어긋날 자리가 없다.
{:>8}처럼 콜론 뒤에 적는 것은 61장에서 본 폭·정렬·정밀도에 해당한다. >는 오른쪽 정렬, <는 왼쪽 정렬, .3은 소수 셋째 자리까지다. 정렬 기호가 앞에 오는 것이 printf와 다른 점이다.
86.4 세 가지 규칙 — 이 부 전체를 읽는 열쇠
앞으로 만날 함수가 백 개를 넘지만, 시그니처를 읽는 규칙은 셋뿐이다. 이 셋만 손에 익히면 처음 보는 함수도 문서 없이 절반은 읽어 낼 수 있다.
- 할당자를 인자로 받는 함수만 기억을 잡는다. 시그니처에
proven_allocator_t가 보이면 “이 함수는 할당할 수 있다”는 뜻이고, 없으면 한 바이트도 잡지 않는다. 그래서 임베디드에서 쓸 수 있는 함수와 아닌 함수가 눈으로 갈린다(89장). - 실패는 값으로 온다. 돌려줄 결과가 없으면
proven_err_t하나를, 있으면{err, value}꾸러미를 돌려준다.err를 확인하기 전에는value를 보지 않는다(87장). - 만든 것은 만들 때 준 할당자로 돌려준다.
_create로 얻었으면_destroy로 놓고, 이름에view가 있으면 빌린 것이라 파괴하지 않는다(88·89장).
이름 규칙도 거의 예외가 없다.
| 이름의 꼴 | 뜻 | 보기 |
|---|---|---|
_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 |
표 86.1
86.5 객체 하나의 일생
규칙을 말로 세 줄 읽는 것보다 실물 하나를 끝까지 따라가는 편이 빠르다. 다음 프로그램은 문자열 객체를 만들고, 쓰고, 돌려주기까지 전 과정을 한 화면에 담았다.
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의 첫 인자가 할당자라는 사실이 “이 함수는 기억을 잡을 수 있다”는 선언이다. 호출자가 힙을 줄지 아레나를 줄지 정한다(89장).
② 만들기는 꾸러미로 돌아온다. proven_u8str_create는 proven_result_u8str_t(즉 {err, value})를 준다. err를 확인하기 전에는 value를 꺼내지 않는다 — 이 순서가 87장의 전부다.
③ 용량은 “내용 기준”이다. create(alloc, 64)의 64는 담을 내용의 바이트 수이고, 라이브러리는 내부적으로 NUL 자리 한 바이트를 더 잡는다. 그래서 as_cstr이 복사 없이 C 문자열을 내줄 수 있다.
④ 실패 경로에서도 반납한다. 형식화가 실패하면 그때까지 잡은 문자열을 destroy로 되돌린 뒤 에러를 올린다. 이 무늬가 커지면 87장의 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); /* 컴파일되지 않는다 */넷 중 ④만 컴파일러가 잡아 준다. 나머지 셋은 사람이 규칙으로 막는 것이고, 그래서 앞 절의 세 규칙을 손에 익히라고 한 것이다. 특히 ③은 90장에서 다시, 그리고 아레나를 리셋할 때 한 번 더 만난다.
문. 객체를 꼭 _create로 만들어야 하는가? 힙이 없는 자리에서는?
답. 아니다. 대부분의 객체에는 빌리는 판이 함께 있다. proven_u8str_borrow(buf, sizeof buf)는 스택이나 정적 배열 위에 문자열을 얹는다 — 할당자를 받지 않으므로 한 바이트도 잡지 않고, 그래서 destroy도 필요 없다(호출자가 이미 주인이다). 임베디드 코드가 이 방식으로 문자열을 다루고(90장), 이 책의 여러 예제도 그렇게 돈다.
중간 형태도 있다. 기억은 한 번만 크게 잡아 두고 그 위에 아레나를 얹은 뒤 객체들에게 나눠 주는 방식인데(89장), 이러면 malloc은 한 번도 불리지 않으면서 _create 계열을 그대로 쓸 수 있다.
문. PROVEN_ARG는 어떻게 타입을 알아내는가? C에는 함수 오버로딩이 없지 않은가?
답. C11에 들어온 _Generic이라는 장치를 쓴다 — 표현식의 타입에 따라 여러 가지 중 하나를 컴파일 시간에 고르는 문법이다. PROVEN_ARG(x)는 x가 int면 정수 꼬리표를, double이면 실수 꼬리표를, const char * 면 문자열 꼬리표를 붙인 작은 구조체를 만든다. 실행 시간에 타입을 판별하는 것이 아니라 컴파일러가 이미 아는 것을 그대로 쓰는 것이라 비용이 없다. 문법과 전체 서식 규칙은 91장에서 정면으로 다룬다.
흔한 오해. “라이브러리를 쓰면 프로그램이 무거워진다”
자주 듣는 걱정이고, 언어와 라이브러리의 성격에 따라 다르다. C에서 소스로 함께 컴파일하는 라이브러리는 쓰지 않은 것이 실행 파일에 남지 않는다 — 링커가 참조되지 않은 목적 파일을 아예 넣지 않기 때문이다 (16장의 링크 단계). 게다가 proven에는 시작할 때 도는 초기화 코드도, 등록되는 전역 상태도, 몰래 뜨는 스레드도 없다. 무거워지는 것은 라이브러리를 쓴 대가가 아니라, 프레임워크가 프로그램의 구조를 가져갈 때 생기는 일이다.실제 사례. 소스로 배포하는 관행 — 한 파일짜리 SQLite
이 배포 모델은 proven만의 별난 선택이 아니다. 세계에서 가장 널리 쓰이는 데이터베이스 엔진 SQLite는 수십 개 소스 파일을 하나의 거대한.c 파일로 합친 amalgamation을 공식 배포 형태로 제공한다 — 받아서 프로그램과 함께 컴파일하면 끝이다. 이미지·폰트 처리로 유명한 stb 계열 라이브러리는 아예 헤더 파일 하나가 전부다. 이유는 모두 같다. 빌드 환경이 제각각인 세상에서 가장 이식성 높은 배포 단위는 소스이기 때문이다.86.6 내 프로젝트에 붙이기 — 최소 Makefile
앞의 두 줄을 매번 손으로 치지 않으려면 96장의 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>를 찾을 자리를 알려 준다 (54장). platform/ 은 운영체제를 부르는 얇은 층이라, 베어메탈로 갈 때는 이 줄만 빼면 된다(94장). -lm 은 실수 형식화가 쓰는 수학 함수를 잇는다 — 형식화에서 실수를 빼면(94장의 PROVEN_FMT_NO_FLOAT) 이것도 필요 없다.
플랫폼 노트. 윈도우와 임베디드에서
MSVC — 이 라이브러리는 C23을 요구한다. Visual Studio 2022의 최신 업데이트가 /std:clatest로 상당 부분을 지원하지만, 가장 확실한 길은 윈도우에서도 clang-cl이나 MinGW-w64(GCC)를 쓰는 것이다(18장의 지형).
임베디드 — platform/을 빼고 src/proven/*.c만 컴파일한다. 힙이 없으므로 proven_heap_allocator()는 쓸 수 없는 값(전부 0)을 돌려주고, 대신 정적 배열 위에 아레나를 얹어 쓴다(89장). 자세한 절차는 94장이다.
복습 정리
이 장의 요약.
| 무엇 | 어떻게 |
|---|---|
| 헤더 | #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와 다른 문법 |
표 86.2
첫 프로그램이 돌았다. 그런데 방금 쓴 proven_println도 사실은 실패할 수 있다 — 화면으로 가는 띠가 끊길 수 있기 때문이다(10장). 이 함수는 에러를 돌려주되 확인을 강요하지는 않는데, 그 선택 자체가 이 라이브러리의 에러 모델을 이해하는 좋은 입구다. 다음 장이 그것이다.