C에서 제네릭 result 타입을 설계하면 함수의 반환값을 무시할 때 컴파일러 경고를 내고 오류 메시지도 함께 전달할 수 있다. 마틴 우에커(Martin Uecker)는 GCC의 컴파일러 최적화를 활용해 잘못된 값 사용과 오류 처리의 안전성을 확인하는 방법도 살펴본다.
마틴 우에커는 앞서 span 타입, 벡터 타입, maybe라는 선택적 타입(설명), variadic이라는 변형 타입(설명)을 다뤘다. variadic은 이후 variant로 이름을 바꿨다. 그는 noplate 라이브러리에서 이런 타입을 실험하고 있다. 또 몇 달 전에는 GCC 최적화기와 새니타이저를 조합해 컴파일 시점에 안전성 속성을 증명하는 방법을 살펴봤다. 앤스로픽도 새니타이저를 이용해 C 코드를 검증하는데, 이 경우에는 생성된 RISC-V 코드에 적용한다.
이번에 다루는 제네릭 result 타입은 maybe와 비슷하게 동작하면서도, 반환값을 무시하면 경고가 나오고 오류 메시지도 담는다.
static result(int) divide(int a, int b)
{
if (b == -1 && a == INT_MIN)
return result_fail(int, "overflow in division");
if (b == 0)
return result_fail(int, "division by zero");
return result_success(int, a / b);
}
우에커는 결과값 타입과 문자열을 감싸는 오류 타입을 대수적 sum 타입으로 묶어 result를 정의한다.
typedef struct { const char *error; } error_t;
#define result(T) struct [[nodiscard]] result_##T { sum(T, error_t) sum; }
[[nodiscard]] 속성은 함수가 반환한 값을 무시할 때 컴파일러가 경고하도록 한다. 매크로 기반 제네릭에는 타입 인자로 typedef 이름만 쓸 수 있다는 한계가 있다. 우에커는 이를 작은 불편으로 보면서도, 토큰 이어 붙이기(##)로 새 태그를 만들 필요가 없는 태그 없는 구조체 방식은 임의의 타입 이름을 쓸 수 있다고 설명한다. 다만 그 방식은 사용 전에 각 타입을 한 번씩 고유하게 선언해야 한다. 여러 타입에 쓸 제네릭 result(T)와 sum(T, error_T) 선언을 어디에 둘지가 문제다.
이어서 성공과 실패를 나타내는 생성자와 접근자 매크로를 추가한다.
#define result_fail(T, e) (result(T)){ sum_init(T, error_t, (error_t){ (e) }) }
#define result_success(T, v) (result(T)){ sum_init(T, error_t, (v)) }
#define result_ok(T, r) (!(r).sum.sel)
#define result_value(T, r) sum_car(r.sum)
#define result_error(T, r) sum_cdr(r.sum)
sum 타입은 태그가 있는 유니온이며 maybe와 비슷하게 동작한다. 비활성 상태인 멤버에 접근하면 트랩을 일으킨다. 우에커는 대수적 타입의 설계가 아직 끝나지 않아 이 부분은 나중에 다루겠다고 밝혔다.
반환값을 무시하는 사례를 Compiler Explorer 예제에서 확인할 수 있다.
int main()
{
int a = 2;
int b = 1;
divide(a, b);
}
: In function 'main':
:34:5: warning: ignoring return value of type 'struct result_int', declared with attribute 'nodiscard' [-Wunused-result]
34 | divide(a, b);
| ^~~~~~~~~~~~~~~~~
:17:20: note: in call to 'divide', declared here
17 | static result(int) divide(int a, int b)
divide는 반환값을 무시해도 문제가 없는 예라서 이 기능을 설명하기에 적절하지는 않지만, 다른 함수에서는 유용할 수 있다. 반환값을 확인하고 성공 여부에 따라 결과값이나 오류 메시지를 사용하는 전체 예제는 Compiler Explorer에 있다.
int main()
{
int a = 2;
int b = 1;
result(int) r = divide(a, b);
if (result_ok(int, r)) {
printf("The result is %d.\n", result_value(int, r));
} else {
puts(result_error(int, r).str);
}
}
실패한 경우에도 결과값을 사용하도록 코드를 바꾸면, b를 0으로 설정했을 때 실행 중 오류가 발생한다. 실행 중 오류를 피하려면 앞서 설명한 트릭, 즉 __builtin_trap을 [[warning("UNSAFE")]] 속성으로 다시 선언하는 방법을 적용할 수 있다. 우에커는 Compiler Explorer 예제에서 #if 0을 #if 1로 바꿔 확인해 보라고 안내한다. noplate가 내부적으로 __builtin_trap>도 사용하기 때문에 작동한다.
타입 설계에는 잠재적인 문제가 하나 더 있다. divide 함수에 [[gnu::noinline,gnu::noipa]]를 붙여 컴파일러가 함수 내부를 들여다보지 못하게 하면, 컴파일러는 puts에 전달하는 인자가 널일 수 있다고 경고한다. 우에커는 이를 실행 중 문제가 생길 가능성을 알려주는 유용한 경고로 본다. 속성을 붙이지 않은 예제에서는 컴파일러가 오류 문자열을 가리키는 포인터가 널이 아님을 알 수 있지만, divide를 라이브러리로 옮기면 최적화기가 그 포인터 정보를 얻지 못할 수 있다.
이를 피하려면 오류 문자열이 항상 초기화돼 있다고 최적화기가 알 수 있도록 error_t를 바꿀 수 있다. 예를 들어 일반 C 문자열 대신 noplate 라이브러리의 strv 문자열 뷰 타입을 사용하면 이 문제가 생기지 않는다고 설명한다. 해당 예제 링크는 noplate의 README에 있다. 우에커는 우선 널 여부 검사를 추가했으며, 부호 있는 정수 연산과 널 포인터, 오류 처리 측면에서 컴파일러가 정적으로 안전하다고 증명한 전체 예제를 Compiler Explorer에 공개했다.
GCC 최적화기를 이용한 컴파일 시점 검사는 해킹에 가깝지만, 이미 컴파일러에 포함된 기술로 비교적 쉽게 할 수 있는 일을 보여준다는 것이 우에커의 설명이다. 그는 더 나은 인터페이스가 필요하다고 보고 있으며, C에 compiler_assert 기능을 추가하자는 제안서(PDF)가 나왔지만 WG14는 아직 이를 받아들이지 않았다고 덧붙였다.
부록: 원문 예시
} else {
printf("The result is %d.\n", result_value(int, r));
puts(result_error(int, r).str);
}
댓글 (0)
로그인하면 이 기사에 내 생각을 남길 수 있어요