5장: 리전 이해하기
지금까지의 모든 장은 "몇 가지 더 엄격한 규칙과 추가 키워드가 있는 C"였습니다. 이번 장에서 그것이 더 이상 사실이 아니게 됩니다. 리전은 SafeC의 진정으로 새로운 유일한 아이디어입니다 — SafeC를 C를 위한 린터가 아니라 다른 언어로 만들어 주는 것이며, 지금부터 이후의 모든 내용은 여러분이 이 개념을 이해하고 있다는 전제로 진행됩니다.
리전이 해결하는 문제
C는 메모리에 대한 완전한 자유를 주는 대신, 그것을 올바르게 사용할 완전한 책임도 함께 줍니다. 포인터는 그 뒤에 있는 메모리가 얼마나 오래 유효한지에 대한 정보를 전혀 담고 있지 않습니다 — 컴파일러는 다음과 같은 코드를 아무렇지 않게 허용합니다.
/* 순수 C, SafeC 아님 -- 이것이 우리가 지금부터 막으려는 버그입니다 */
int *dangling(void) {
int local = 10;
return &local; /* dangling()이 반환되는 순간 local의 스택 프레임은 사라집니다 */
}local은 dangling의 스택 프레임에 존재합니다. 함수가 반환되는 순간 그 프레임은 사라지지만, 여러분이 반환한 포인터는 여전히 예전에 그것이 있던 곳을 가리키고 있습니다. 나중에 그것을 읽으면, 현재 그 스택 슬롯을 차지하고 있는 무언가를 읽게 됩니다 — 여전히 10일 수도 있고, 다음 함수 호출에서 남은 쓰레기 값일 수도 있고, 운이 나쁘면 공격자가 제어하는 값일 수도 있습니다. 이 버그 유형 — 메모리를 소유했던 무언가가 더 이상 그 유효성을 보장하지 않게 된 뒤에도 그 메모리를 사용하는 것 — 은 댕글링 포인터, use-after-free, 그리고 C의 역사적 보안 취약점의 상당 부분을 아우릅니다.
SafeC의 답은 값의 메모리가 어디에 사는지를 그 타입의 일부로 만들어, 컴파일 타임에 검사하고, 런타임에는 비용이 전혀 들지 않게 하는 것입니다.
&stack int dangling() {
int local = 10;
return &local;
}error: cannot return '&stack int': stack reference escapes function scope같은 버그, 같은 모양 — 하지만 이번에는 컴파일되지 않습니다. 컴파일러는 local이 dangling 함수의 스택 스코프에 존재한다는 것을 추적했고, &stack int 반환 타입이 호출자에게 최소한 자신이 그것을 사용하는 동안 유효한 참조를 약속한다는 것을 확인했으며, 프로그램을 한 번도 실행하지 않고도 이 불일치를 거부했습니다.
네 가지 리전
SafeC의 모든 참조는 리전을 갖습니다 — 그 뒤의 데이터가 어디에 사는지, 그리고 그로 인해 얼마나 오래 유효한지를 나타냅니다.
| 리전 | 라이프타임 | 해제 주체 |
|---|---|---|
&stack T | 감싸고 있는 렉시컬 스코프 | 스코프 종료 시 자동 |
&heap T | 명시적으로 해제될 때까지 | 여러분(직접), free()를 통해(또는 defer free(...)) |
&arena<R> T | 아레나 R이 리셋되거나 파괴될 때까지 | 한 번에 일괄 |
&static T | 프로그램 전체 | 결코 해제되지 않음 |
void print_x(&stack int ref) {
printf("%d\n", *ref);
}
int main() {
int v = 7;
print_x(&v); // &v는 &stack int -- main()의 스코프 전체에서 유효
return 0;
}&stack은 가장 자주 손이 가는 기본값입니다 — &로 주소를 가져올 때 평범한 지역 변수의 주소가 되는 것이 바로 이것입니다. 앞 절의 이스케이프 규칙은 사실 "&stack 참조는 그것을 만든 스코프보다 오래 살아남을 수 없다"는 것을 의미하며, 그 스코프가 현재 블록이든, 감싸는 블록이든, (dangling()에서처럼) 함수 전체이든 마찬가지로 강제됩니다.
&heap: 여러분의 책임, 여러분의 규칙
힙 메모리는 C와 똑같이 동작합니다: malloc으로 할당하고 free로 해제하며, 그 사이에 자동으로 이루어지는 것은 없습니다. SafeC의 &heap T 참조 타입은 그저 포인터에 "힙 메모리를 가리킨다"는 표시를 붙여서 컴파일러가 그 주변의 리전 규칙을 강제할 수 있게 할 뿐입니다 (예를 들어 &heap이 필요한 곳에 &stack 참조를 슬쩍 대입할 수는 없습니다 — 아래에서 더 다룹니다). 메모리 자체를 관리해 주지는 않습니다.
void heap_demo() {
&heap int p = (int*)malloc(sizeof(int));
*p = 42;
defer free(p);
printf("heap: %d\n", *p);
} // free(p)가 defer를 통해 여기서 실행됨defer(8장에서 제대로 소개합니다)는 감싸고 있는 스코프가 어떤 경로로 종료되든 상관없이 그 종료 시점에 실행될 문을 예약합니다 — 어느 종료 경로에서든 free를 추가하는 것을 잊지 않기를 바라며 반복하는 대신, 할당과 그에 대응하는 free를 소스 코드에서 바로 옆에 짝지어 두는 관용적인 방법입니다.
&static: 영원히 산다
&static T는 프로그램 전체 동안 사는 데이터 — 전역 변수와 그에 대한 참조 — 를 위한 것입니다.
static int counter = 0;
&static int get_counter() {
return &counter; // OK: counter는 영원히 살고, 이 참조도 마찬가지
}&static 참조는 네 리전 중 가장 관대합니다: 결코 무효화되지 않으므로 어디에든 저장하고 어디로든 전달할 수 있으며, (4장의 함수 값 구문에서 보았듯이) 평범한 함수의 주소가 갖는 타입이 바로 이것입니다.
&arena<R>: 일괄 할당, 일괄 해제
네 번째 리전은 C에 진짜로 대응하는 것이 없는 리전입니다. 아레나는 미리 선언해 두고, 범프 포인터 속도로(그저 오프셋을 증가시키는 것뿐, 할당마다 별도의 장부 기록이 없습니다) 할당해 나가다가, 한 번에 전부 해제하거나 명시적인 체크포인트 단위로 해제할 수 있는 리전입니다.
region Pool { capacity: 1024 }
void arena_demo() {
&arena<Pool> struct Point p = new<Pool> struct Point;
p->x = 1.0;
p->y = 2.0;
printf("arena point: %f %f\n", p->x, p->y);
// 아레나 할당의 *꼬리 부분*만 해제하고 체크포인트 이전 것은 모두
// 유지하기 위한 체크포인트/되감기 쌍:
unsigned long mark = arena_mark<Pool>();
&arena<Pool> int scratch = new<Pool> int;
*scratch = 99;
arena_free_to<Pool>(mark); // 'scratch'는 해제하고, 'p'는 유지
arena_reset<Pool>(); // Pool 안의 모든 것을 한 번에 해제
}region Pool { capacity: 1024 }는 반드시 파일 스코프에 선언해야 합니다(절대 함수 안에 선언할 수 없습니다) — 프로그램의 나머지 부분이 할당해 넣을 수 있는 이름 붙은 메모리 풀을 선언한다고 생각하면 됩니다. new<Pool> T는 그 풀에서 T 하나를 할당합니다. arena_reset<Pool>()은 아레나 전체를 한 단계에 빈 상태로 되감습니다(소멸자는 실행되지 않으므로 — 아레나에 할당된 값은 정리가 필요한 외부 자원을 가지고 있으면 안 됩니다). arena_free_to<Pool>(mark)는 좀 더 정교한 버전으로, arena_mark<Pool>()에서 얻은 체크포인트까지만 되감으므로, "이번 루프 반복보다 오래 살아야 할 큰 테이블"과 "그래서는 안 될 스크래치 공간"이 하나의 아레나를 공유할 수 있습니다. 그리고 결정적으로: 컴파일러는 아레나가 리셋되거나 파괴된 뒤에는 그 참조를 건드릴 수 없도록 강제합니다 —
region Pool { capacity: 1024 }
int main() {
&arena<Pool> int p = new<Pool> int;
arena_reset<Pool>();
*p = 42;
return 0;
}error: use of 'p' (&arena<Pool> reference) after arena_reset<Pool>(),
arena_destroy<Pool>(), or arena_free_to<Pool>() invalidated it— 이 장 맨 위에서 dangling()이 보여준 것과 같은 부류의 버그가 같은 방식으로 잡힙니다: 런타임 use-after-free가 되기 전에 컴파일 타임에서요. (이 검사는 if/else 분기에 대해 흐름에 민감합니다 — 한 분기의 리셋은 다른 분기에 영향을 주지 않습니다 — 그리고 루프 본문에 대해서도 그렇습니다 — 루프 어딘가의 리셋은 그 루프의 모든 문에 대해 이미 일어났을 수도 있는 것으로 취급됩니다. 이전 반복의 리셋 이후 다음 반복이 본문의 맨 위부터 다시 실행되기 때문입니다 — 하지만 이는 안전한 근사치이지 완전한 제어 흐름 데이터플로 분석은 아니므로, 흔치 않은 형태에서는 실제로는 런타임에 안전한 코드에도 가끔 플래그를 붙일 수 있습니다. 정확히 어떤 것이 커버되는지, 그리고 플래그가 붙은 참조가 정말로 여전히 유효하다고 확신할 때 쓰는 unsafe 우회법에 대해서는 메모리와 리전을 참고하세요.)
탈출구: 리전이 없는 &T
때로는 위의 네 리전 중 어느 것도 정답이 아닐 때가 있습니다. "어느 리전인가?"라는 질문 자체가 여러분이 하려는 일에 중요하지 않기 때문입니다. 리전 한정자를 아예 빼면 — &T(널이 될 수 없음) 또는 ?&T(널이 될 수 있음) — 선언되거나 추적되는 리전이 전혀 없는 참조를 얻게 됩니다.
struct Point { double x; double y; }
// 리전을 정하지 않음 -- 호출자는 &stack, &heap, &static, &arena<R> 중
// 무엇이든 전달할 수 있고, 이 함수는 어느 쪽인지 신경 쓸 필요가 없음
double sum_point(&Point p) {
return p.x + p.y;
}이는 이 장에서 다룬 이스케이프 분석 기법에 다섯 번째 리전이 추가되는 것이 아닙니다 — 리전이 필요 없다는 선택지일 뿐입니다. 이것이 필요한 상황은 두 가지입니다.
- 호출이 지속되는 동안만 참조를 통해 읽는 매개변수. 특정 리전 하나(예:
&stack Point)를 고르면Point가 마침 다른 곳에 사는 호출자를 안전상의 이득도 없이 거부하게 될 뿐입니다 — 위의sum_point는 자신의 반환 이후까지p를 유지하지 않으므로, 신경 쓸 이유가 없습니다. - C를 넘나드는 포인터.
extern함수의T*매개변수나 반환값은 때로는 호출이 반환된 이후까지 유지되어야 하는 경우가 있습니다 — 등록된 콜백의 컨텍스트, 불투명 핸들 등 — 그리고&stack(호출과 함께 죽음),&heap,&static(둘 다 SafeC가 제어할 수 없는 ABI 경계를 넘어서까지는 검증할 수 없는 라이프타임 보장을 요구함) 중 어느 것도 이를 정직하게 표현하지 못합니다.&T/?&T는 양방향 모두unsafe없이 원시 포인터로 변환되고 그로부터 변환되는데, 바로 이 점이 그 경계에 딱 맞는 이유입니다 — 자세한 내용은 C 연동을 참고하세요.
네 리전 어느 것의 참조든 &T/?&T로 캐스트도, unsafe도 없이 암묵적으로 변환됩니다.
int main() {
struct Point local;
local.x = 1.0; local.y = 2.0;
&stack Point p = &local;
double total = sum_point(p); // &stack Point -> &Point: 암묵적
printf("%f\n", total);
return 0;
}별칭: 하나의 쓰기, 또는 여러 읽기 — 절대 섞이지 않음
리전 추적은 "이것이 얼마나 오래 사는가"라는 질문에 답합니다. 별개의 규칙이 다른 질문에 답합니다: "지금 이 순간 이 메모리를 또 누가 볼 수 있는가." SafeC는 Rust가 보로우 체커라고 부르는 것과 같은 규율을 강제하며, 단순하게 표현하면 이렇습니다: 프로그램의 어느 시점에서든, 값은 정확히 하나의 가변 참조를 갖거나, 몇 개든 상관없는 수의 불변 참조를 갖습니다 — 결코 둘이 섞이지 않습니다.
int x = 42;
&stack const int a = &x; // 불변 대여
&stack const int b = &x; // OK: 여러 불변 대여는 공존할 수 있음
printf("borrows: %d %d\n", *a, *b);같은 변수에 대한 두 개의 불변(const) 대여는 괜찮습니다 — 둘 다 그것을 통해 쓸 수 없으므로 서로 충돌할 것이 없기 때문입니다. 하지만 가변 대여는 배타성을 요구합니다.
int x = 42;
&stack int a = &x; // 가변 대여
&stack int b = &x; // 오류: x는 이미 가변으로 대여됨error: cannot borrow 'x' as mutable: already borrowed as mutable이 규칙이 없다면, b를 통해 무언가가 읽는 도중 a가 x에 쓸 수 있고, 읽는 쪽은 읽는 도중에 값이 자기도 모르게 바뀌는 것을 보게 됩니다 — 단일 스레드 코드에서조차 발생하는 데이터 레이스이며, "보통은 동작하지만 가끔은 안 되는" 부류의 C 버그의 근본 원인입니다. SafeC의 별칭 검사는 이를 하이젠버그 대신 컴파일 타임 오류로 바꿔 줍니다.
초반에 알아 둘 만한 구문 함정 하나
&stack const int를 눈여겨보세요 — const가 리전 한정자 뒤에 쓰였습니다. 앞에 쓰면(const &stack int) 파서는 지금 이를 받아들이긴 하지만, 조용히 가변 대여를 만들어 버립니다. 알려진 파서 버그입니다. const를 리전 한정자 뒤에 쓰는 습관을 들이면(&stack const int, &heap const int, ...) 절대 이 문제를 겪지 않습니다 — 자세한 내용은 안전성을 참고하세요.
대여도 스코프에 따라 추적됩니다: 내부 블록 안에서 가져온 가변 대여는 그 블록이 종료되면 해제되어, 바깥 스코프의 대여를 다시 사용할 수 있게 풀어 줍니다 — "이제 다 썼다"고 수동으로 알릴 필요 없이, 컴파일러가 대여 자신의 스코프가 끝나는 시점으로부터 이를 추론합니다.
널 허용 참조
평범한 참조(&stack T, &heap T, ...)는 기본적으로 널이 될 수 없습니다 — 타입 시스템이 애초에 평범한 참조가 널이 되도록 허용하지 않으므로, 널 포인터 역참조 버그 부류를 걱정할 필요가 없습니다. 부재가 실제로 가능한 경우에는 타입 앞에 ?를 붙여 그렇게 표시합니다.
void nullable_demo(?&stack struct Point maybe) {
match (maybe) {
case null: printf("no point\n");
case some(p): printf("point: %f\n", p.x);
}
}match는 이런 값을 읽는 공인된 방법입니다 — 빈 경우를 반드시 처리하게 강제하며, some(p) 분기 안에서 p는 여러분이 원했던 그대로의 평범하고 널이 아닌, 별다른 의식 없이 역참조 가능한 struct Point로 바인딩됩니다. 7장에서 match를 완전히 다루고, 8장에서는 ?T 옵셔널(?®ion T의 참조가 아닌 형제)을 SafeC의 주된 에러 처리 메커니즘으로 다룹니다.
unsafe: 아껴 쓰는 탈출구
때로는 리전 시스템이 검증할 수 있는 범위 밖으로 정말로 벗어나야 할 때가 있습니다 — 원시 포인터를 넘겨주는 C 라이브러리를 호출하거나, 수동으로 포인터 산술을 하거나, (위의 arena_demo에서처럼) 컴파일러의 보수적인 근사보다 이번만은 여러분이 더 잘 안다고 알려주는 경우입니다. 바로 이럴 때 쓰는 것이 unsafe { ... }입니다.
unsafe {
int *raw = (int*)malloc(10UL * sizeof(int));
raw[0] = 42; // unsafe 안에서는 경계 검사가 없음
free(raw);
}unsafe 안에서는 경계 검사가 억제되고, 리전 이스케이프 분석이 완화되며, 별칭 규칙이 강제되지 않습니다 — 여러분이 그것을 선택한 바로 그 블록으로 범위가 한정된 채, 순수 C의 "알아서 하세요" 모델로 돌아갑니다. unsafe는 전파되지 않습니다: unsafe 블록 안에서 안전한 함수를 호출한다고 해서 그 피호출 함수까지 unsafe해지는 것은 아니며, 블록 바깥의 코드는 여전히 예전과 똑같이 엄격하게 검사됩니다. unsafe 블록은 그것이 필요한 구체적인 연산만큼 작게 유지하세요 — 함수 전체를 unsafe로 표시하면 "이 부분은 손으로 감사하라"는 정밀하고 그렙하기 쉬운 신호로서 마커가 가지는 의미가 사라집니다.
이미 아는 것과의 비교
Rust를 써 봤다면 이번 장의 모양이 익숙하게 느껴졌을 것입니다 — 리전 한정 참조와 대여 검사는 Rust의 소유권 시스템이 해결하는 것과 같은 문제에 대한 SafeC의 답입니다. 다만 이 비교는 정확히 짚고 넘어갈 가치가 있습니다: SafeC의 리전은 Rust의 완전한 라이프타임 시스템보다 단순합니다(외워야 할 라이프타임 매개변수도, 생략 규칙도 없습니다). 하지만 그 단순함에는 단순한 구문 차이 이상의 진짜 비용이 따릅니다. Rust는 대여한 참조를 함수에서 반환할 수 있습니다, 그 라이프타임이 입력 매개변수의 라이프타임에 묶여 있는 한요. SafeC의 &stack 참조는 함수에서 절대, 예외 없이 반환될 수 없습니다 — arena 리전이 존재하는 주된 이유는 진짜 스택 참조로는 안 되는 경우를 위해 "데이터가 이 함수 호출 하나보다 오래 산다"는 유연성 일부를 돌려주기 위해서입니다. 이 간극이 정확히 어디서 드러나고 아레나가 그것을 어떻게 메워 주는지 실제 예제로 살펴보려면 비교를 참고하세요.
다음: 구조체와 메서드 — 이제 데이터가 어디에 사는지 추론할 수 있게 되었으니, 그것을 중심으로 여러분만의 데이터 타입을 만들어 볼 차례입니다.