‘강하게 타입된 에러 코드’ 논의의 후속. ADT 기반 에러가 해피 패스를 악화시키는 이유와 세 가지 ABI 선택지(일반 반환, 레지스터 예약, 스택 언와인딩)를 비교하고, 언와인딩이 최적일 수 있음을 주장한다. 마지막으로 언어/컴파일러에서 에러 ABI를 특수 취급할 필요를 제안한다.
2025년 11월 9일
“strongly typed error codes” 글의 후속.
에러에 대수적 데이터 타입(ADT)을 쓰는 것에 대한 흔한 논거는 다음과 같다:
이 주장은 완전히 맞지 않다. ADT로 에러를 순진하게 조합하면 해피 패스를 악화시킨다. 열거형을 재귀적으로 조합해 만든 에러 객체는 대개 커지며, 이는 size_of<Result<T, E>>를 부풀리고, 콜스택 전반의 함수들을 “메모리를 통해 큰 구조체를 반환”하는 ABI로 밀어 넣는다. 여기서 핵심은 에러의 전이성이다 — 얼마나 드문 코드 경로에 있는 단 하나의 큰 에러라도 전체 곳곳에서 더 나쁜 코드 생성으로 이어진다.
이 때문에 성숙한 에러 처리 라이브러리들은 얇은 포인터 뒤에 에러를 숨긴다. 이 접근은 Rust의 failure가 개척했고, 생태계 전반에서 anyhow가 채택했다. 하지만 이는 전역 할당자를 필요로 하고, 이것 또한 완전히 제로 코스트는 아니다.
결과를 도대체 어떻게 반환할 것인가? 기본 옵션은 -> Result<T, E>를 다른 사용자 정의 타입과 동일하게 다루는 것이다: 작으면 레지스터로, 크면 스택 메모리로. 앞서 설명했듯, 이는 큰 콜드 에러 때문에 작은 핫 값까지 메모리로 흘러넘치게 만들어 비최적이다.
더 영리한 방법은 다음과 같이 말하는 것이다:
->
Result<T, E>
의 ABI는 T와 완전히 동일하되, E를 위해 레지스터 하나를 예약한다(이는 에러가 레지스터 크기여야 함을 요구한다). 상태 플래그가 있는 아키텍처라면, 예를 들어 오버플로 플래그로 에러의 존재를 신호할 수도 있다.
마지막으로, 또 다른 선택지는 이렇게 말하는 것이다:
-> Result<T,
E>
ABI 관점에서 -> T와 정확히 동일하게 동작하며, 에러를 위한 어떤 배려도 없다. 대신, _에러를 반환할 때_에는 리턴 주소로 점프하는 대신 사이드 테이블에서 해당 리턴 주소에 대응하는 에러 복구 주소를 찾아, 그리로 점프한다. 스택 언와인딩!
대담한 주장은 언와인딩이 최적이라는 것이다! 재현 가능한 좋은 벤치마크 모음은 모르지만, 아래 두 자료는 신뢰할 만하다고 본다:
async와 마찬가지로, 겉으로 보이는 프로그래밍 모델과 내부 구현 세부를 분리하라! Result<T, E>는 스택 언와인딩으로 구현할 수 있고, 예외는 리턴 값을 검사하는 방식으로 구현할 수 있다.
당신의 에러 ABI는 아마 특별한 취급을 원할 것이고, 따라서 컴파일러는 에러를 알아야 한다. 만약 당신의 언어가 유연한 사용자 정의 타입과 제어 흐름을 뛰어나게 지원한다면, 아마도 백엔드에서만 특수 처리를 하고, 그 외에는 평범한 사용자 정의 타입을 쓰고 싶을 것이다. 반대로 언어의 추상화 능력이 기껏해야 중간 수준이라면, 표면 의미론에서도 에러를 일급 시민으로 만드는 것이 타당할 수 있다.