요약
C++26 계약은 함수가 기대하는 조건을 선언에 적고, 검사 방식은 컴파일 설정에 맡깁니다.
C++ Stories의 바르트워미에이 필리페크가 표준 라이브러리 강화(hardening)를 다룬 글에 이어 C++26의 계약(contracts, 함수의 조건을 코드로 적는 기능)을 소개했습니다. assert 하나에서 출발해 조건을 함수 선언으로 옮기고, 반환값까지 검사하는 과정을 예제로 보여 줍니다.
왜 중요한가
- 함수 본문의 assert에 숨어 있던 요구 사항이 헤더의 선언에 드러납니다. API를 쓰는 사람은 구현을 열지 않아도 조건을 알 수 있습니다.
- NDEBUG 매크로 하나로 켜고 끄던 검사를 여러 방식 가운데 골라 돌릴 수 있게 됩니다.
- 다만 검사가 꼭 실행된다는 보장은 없습니다. 그래서 글은 계약을 오류 처리나 입력 검증에 쓰면 안 된다고 강조합니다.
핵심 내용
- pre는 호출하는 쪽이 지켜야 할 사전 조건을, post는 정상 반환 때 함수가 보장하는 사후 조건을 적습니다.
- 검사 방식은 무시·관찰·강제·빠른 강제 네 가지이고, 어느 것을 쓸지는 컴파일러 설정이 정합니다.
- 무시나 관찰 모드에서는 잘못된 인덱스가 그대로 통과해 정의되지 않은 동작(결과를 보장할 수 없는 동작)이 생길 수 있습니다.
- post 하나로 모든 return 경로를 검사하지만, 예외를 던지며 빠져나갈 때는 검사하지 않습니다.
- 2026년 10월 기준 GCC 16이 -fcontracts 옵션으로 지원하며, 위반 때 부르는 처리 함수도 바꿔 끼울 수 있습니다.
HN 반응
- 실행 중 오류만 늘리고 검사를 늘 돌게 할 방법도 없어 assert보다 못하다는 비판과, 끌 수 있는 것은 의도된 기능이라는 반론이 맞섰습니다.
- 입력 검증과 '기대하는 조건'의 경계가 모호하다는 지적에는, 공개 API에서 입력을 검증하고 그 안쪽에서 계약을 쓰라는 답이 이어졌습니다.
저는 마음에 안 들어요.
런타임 에러를 컴파일 타임 에러로 바꿔 줄 수 있다면 계약(contract)에 서명하는 것도 대환영이에요. 그런데 이건 그냥 런타임 에러만 더 늘리는 것처럼 보여요. 그러면 (아마도) 진짜로 검사하겠다고 약속한다는 점 말고는
assert보다 나을 게 없죠.기사도 큰 문제를 하나 짚고 있어요. "중요한 점은, 계약의 이런 면 때문에 계약 단언(contract assertion)을 오류 처리나 입력 검증에 써서는 안 된다는 것이다. 함수의 계약상 요구 사항에 특정 사건을 오류로 보고하는 일이 들어 있다면, 그 처리는 선택 사항이 아닌 표준 C++ 제어문으로 해야 하며 절대 계약 단언으로 해서는 안 된다."
계약을 무엇에 써야 하는지가 이렇게 흐릿하면 거의 확실히 엉망이 될 거예요. 입력 검증은 아니고 매개변수 검증인데... 사실 검증이라기보다 에러를 내며 멈추는 검사지만, 우리가 에러를 만드는 건 아니라는 거잖아요.
이게 계약의 가장 큰 문제예요. "이 검사는 항상 실행해 주세요"라고 말할 방법이 아예 없거든요. 답글 단 분들 중에는 계약 기반 프로그래밍이라는 개념 자체를 비판한다고 생각하는 사람이 많은 것 같은데, 문제는 C++ 버전 계약이 그냥 이상하다는 거예요.
저자들과 명세가 설명하는 C++ 계약의 프로그래밍 모델은 계약이 '유령 코드(ghost code)'라는 거예요. 컴파일러에 계약 검사를 실행하라고 시켰더라도, 계약 검사가 실제로 실행된다고 절대 가정해선 안 돼요. ABI/ODR 문제 때문에 계약 검사가 삭제될 수 있고 실제로 삭제되기도 하거든요. 게다가 'observe' 계약 모드라는 이상한 것도 있고요.
그러니 결국 용도가 뭔지 알기 어려워요. 실행되지 않는다고 가정해야 하니 안전 검사에는 쓸 수 없고요. 웃기게도 assert와 비교하면 안전 문제를 오히려 새로 만들어요. 예를 들어 코드 두 개를 보세요.
vs
사람들은 1번을 무조건 2번으로 바꿔도 되는 줄 아는데, 그건 사실 계약 기능을 잘못 쓰는 거예요. assert로는 완벽하게 정의된 동작인데, 이쪽은 미정의 동작으로 이어질 수 있고 실제로 그렇게 될 거예요.
10년 뒤에는 안전이 필요한 코드에는 계약을 쓰지 말라는 조언이 나올 것 같아요. 별다른 이점도 없이 assert보다 대체로 덜 안전하니까요. 그럼 대체 용도가 뭐죠?
그건 assert도 마찬가지입니다. 컴파일 단계에서 빠질 수 있으니 반드시 실행되어야 하는 일에는 쓰면 안 됩니다.
NDEBUG를 통해 제 assert가 항상 실행되도록 보장하는 건 아주 쉬워요. 계약은 같은 보장을 할 수 없고요.
NDEBUG가 하는 일은 정반대입니다. https://en.cppreference.com/cpp/error/assert
핵심 부분:
게다가 프로그램을 컴파일하는 사람이 본인 혼자가 아니라면 그런 보장은 불가능합니다. 다른 사람이 빌드할 수 있는 라이브러리를 만들면서 라이브러리가 올바르게 동작하려면 assert가 실행되어야 하도록 해 놨다면, 나쁜 라이브러리를 쓰신 겁니다.
EDIT: assert든 이번 C++26 계약 기능이든 반드시 실행되어야 하는 방식으로 쓰면 안 됩니다. 그렇게 쓰면 잘못 쓰는 겁니다. 다른 사람이 런타임 검사로서 이를 끄는 것도 유효하니까요. 코드가 반드시 실행되어야 한다면 다른 방법을 써야 하고, 저는 if 문을 권합니다.
물론이죠? 라이브러리에서 NDEBUG를 undefine하면 되잖아요.
assert가 항상 실행되도록 보장하는 건 아주 쉽고, 명세상으로도 잘 정의된 동작이에요. assert가 항상 발동하도록 쓰고 싶다면 그렇게 하지 않을 이유가 없고, 지원되는 사용 방식이에요. 흔한 관행이기도 하고요.
계약에는 그에 해당하는 게 없어요.
흥미롭게도 Java는 아주 초기 버전(2002년)에서
assert키워드를 도입할 때 정확히 이런 용도를 염두에 뒀습니다. 런타임에 AssertionException을 던질 수 있는if와 같지만, 프로그램을-enableassertions플래그로 실행했을 때만 그렇습니다.-enableassertions는 개발 중에만 쓰고, 프로그램은 거기에 절대 의존하면 안 된다는 발상입니다. 그래도 IDE와 정적 분석기가 코드에 대한 계약을 파악하는 데 도움이 됩니다.D에는
debug라는 키워드 접두사가 있습니다. 그래서 이런 코드를 쓸 수 있습니다.그러니까 컴파일러에 -debug 스위치를 주면 디버그 코드가 활성화됩니다. assert의 경우에는
이렇게 쓰고, -debug 스위치를 줬을 때만 검사가 실행됩니다. 아주 잘 동작합니다.
debug의 또 다른 흥미로운 특성은, 뒤따르는 대상 문장의 안전 검사를 꺼 준다는 겁니다. 코드가 어디서 잘못됐는지 알아내려면 가끔은 안전하지 않은 꼼수를 써야 하니까요.사전·사후 조건을 다루는 표준적인 방식입니다. Clojure의 사전·사후 조건도 이렇게 동작합니다. 이런 조건을 컴파일 타임 검사로 강제하면, 결국 언어에 정제 타입(refinement type)을 갖춘 두 번째 타입 시스템을 들이는 셈입니다. 그러면 타입 검사에도 시간이 오래 걸리겠죠. 누군가 함수의 정수 인자 x가 collatz(x)==1을 만족해야 한다는 사전 조건을 쓴다고 상상해 보세요. 컴파일러가 컴파일할 때마다 콜라츠 추측을 증명하거나 모든 64비트 정수에 대해 확인해 주길 기대하시겠습니까?
Clojure에서 사전·사후 조건을 써 본 경험으로는 정말 마음에 듭니다. 평범한 옛 단언(assertion)보다 작지만 의미 있는 개선입니다. 그리고 끌 수 있다는 건 버그가 아니라 기능입니다. LiquidHaskell 같은 걸 기대하지는 마세요.
주제에서 벗어난 얘기지만, 모든 양의 64비트 정수에서 collatz(x) == 1이 성립합니다(실제로는 2^71까지 검증되었습니다. https://link.springer.com/article/10.1007/s11227-025-07337-0 ). 1,000자리 정수나 범위로 시험해 보고 싶은 분이 있다면 쓰시라고 최근에 이 도구를 만들었습니다. https://www.proofatlas.ai/explore/collatz/ .
SPARK(Ada)에는 컴파일 타임 사전·사후 조건이 있고 잘 동작합니다. 사후 조건을 Collatz (n) = 1로 선언하면 컴파일러가 도와주지 못하고 컴파일이 실패합니다. 조건이 어렵다면 정리 증명기(theorem prover)를 도와줘야 하는데, 보통 "ghost" 함수를 도입하거나 그냥 조건을 완화하는 식입니다. 후자의 경우 완전 정확성(total correctness)은 증명할 수 없지만, 런타임 에러가 없다는 것 같은 건 여전히 증명할 수 있는 경우가 많습니다.
그냥 궁금한데, collatz(long)의 정의는 어떻게 생겼나요?
https://en.wikipedia.org/wiki/Collatz_conjecture
제가 말한 건 사전 조건을 검증해 줄 C++로 쓴 collatz 정의예요.
시도해 보진 않았고 지금 자리에 없지만, collatz를 구현하는 constexpr 함수를 정의해서 계약에 쓰면 될 것 같아요.
(수정: 입력 자체가 constexpr이라면요.) 템플릿 몇 개를 특수화해서 하는 방법도 쉽게 떠올릴 수 있어요. C++ 템플릿으로 피보나치 수열을 만드는 예제를 찾아보면 대강 감이 올 거예요.
코드를 보면 배울 게 있을 것 같아요.
그냥 constexpr 평가 깊이 에러가 나면서 -fconstexpr-depth를 설정하라는 제안이 뜨지 않을까요? 이게 왜 사람들 말처럼 대단한 함정인지 모르겠어요.
그런 일이 생길 거라고 예상해요. 다만 부모 스레드에서 언급했듯이, 이 시나리오 전체가 컴파일 타임에 실행되는 가상의 다른 계약 구현을 전제로 한다는 점은 다시 짚어 둘 만해요.
그래도 collatz에 대한 C++26 사전 조건을 원하신다면 여기 있어요.
링크된 기사에서 말하듯 assert로 한다면 쓸 코드와 기본적으로 같고, 이제 그걸 선언에 붙이는 것뿐이에요.
계약은 호어 논리(Hoare logic), 즉 사전 조건, 사후 조건, 불변식을 문서화하려는 것입니다.
컴파일러가 정적으로 검증할 수 있다면 컴파일 타임에 처리될 수 있고(이 경우 아무 동작도 하지 않게 됩니다), 그렇지 않으면 런타임에 검사하며, 정적으로 증명할 수 없지만 오버헤드 비용도 원치 않는다면 아예 꺼 둘 수도 있습니다.
현실적으로 계약은 도구가 개선되면서 점차 정적/컴파일 타임 assert 쪽으로 발전할 겁니다.
그리고 블로그 글에서 말하듯, 계약은 명시적으로 입력 검증이 아닙니다. 사전·사후·불변 조건을 주석으로 문서화하는 데 그치지 않고, 코드 내부에서 무엇을 기대하고 무엇을 기대하지 않는지를 코드에 담아 두기 위한 것입니다.
"입력 검증"과 "기대하는 바"의 경계는 아주 흐릿해요. 입력 검증이란 "이게 내가 기대하는 입력이다"라고 말하는 거잖아요.
문자열이 최소 몇 글자여야 한다고 하는 건 입력 검증인가요? 그 문자열이 비밀번호라면요? MD5 해시라면요?
입력 검증에 쓰면 안 되겠지만 쓰게 될 거예요. 그리고 입력 검증에 쓴 사람은 십중팔구 그냥 기대하는 바를 적었을 뿐이라고 주장할 거고요.
이렇게 생각해 보세요. 사전 조건은 함수가 호출될 때 참이라고 가정하는 것입니다. 함수를 호출하는 지점에서 그 가정을 해도 된다는 근거가 없다면, 그 조건이 성립하는지 런타임에 확인하는 것은 호출하는 쪽의 책임입니다. 그래서 함수 수준의 사전 조건과, 호출하는 쪽에서 똑같은 조건을 명시적으로 검사하는 코드가 둘 다 있을 수도 있습니다. 조건이 지켜지게 하려는 것이죠. 호출자가 조건이 충족되도록 보장하지 못해 사전 조건에 걸린다면 그건 프로그래밍 오류입니다.
네, 바로 그거예요. 불변식을 강제하는 일종의 건전성 검사라서, 다른 사람이 그 불변식을 어겼는데 뭐가 잘못됐는지 모를 때 쉽게 디버그할 수 있게 해 줘요.
네, 맞아요. 입력 검증은 공개 API에서 하고, 계약은 그 뒤에 쓰는 거예요.
계약은 명시적으로, 절대 일어나길 원치 않는 일을 위한 거예요. "뭔가 잘못됐다"는 신호죠.
그러니 API는 입력 검증을 처리하는 함수를 받고, 그 함수가 명시적으로 처리하지 않는 것에 대해서는 자체 계약을 둘 수도 있어요. 하지만 내부에서는 계약을 써서 문자열이 이미 입력 검증을 거쳤다는 걸 강제하니까, 엣지 케이스를 일일이 처리하지 않아도 되는 거예요.
이건 상수 평가(constant evaluation) 중에만 일어납니다. 예를 들어 아래 코드는 정적으로 판단할 수 있는데도 gcc에서 오류 없이 컴파일됩니다.
int add_nums(int x, int y)
{
}
int result_rt = add_nums(-1,-1); // 컴파일 오류 없음
현재 gcc 버전(적어도 시험해 보신 버전)에서는 컴파일된다는 거죠. 앞으로 나올 gcc는 이걸 에러로 만들 수도 있습니다.
네, 하지만 그러면 표준과 맞지 않을 거예요.
표준은 컴파일러가 그런 경우를 전부 찾아내라고 요구하지 않습니다. 허용은 하지만 요구하지는 않아요. 컴파일러가 못 찾는 경우(적어도 합리적인 시간 안에는 못 찾는 경우)도 많이 알려져 있고요.
표준이 요구하지 않는다는 건 컴파일러가 구현하지 않을 수도 있고, 구현하더라도 구현마다 동작이나 기능이 다를 가능성이 높다는 뜻이에요.
-Wall로 컴파일하는 거랑 마찬가지예요. 이것저것 계속 제멋대로 깨지니까 결국 다들 꺼 버리죠.
일부는 그렇죠. 제가 아는 대부분의 프로젝트는 -Wall로 빌드하고 에러를 전부 고칩니다. 개발자들은 모두 -Werror로 돌리고요. 30년 전에는 미친 짓이었지만, 컴파일러가 오탐(false positive)을 잘 고쳐 줘서 이제는 합리적입니다.
다운스트림(예: Debian)이 경고를 고쳐 주길 기대하는 건 합리적이지 않지만, 개발자들은 보통 자기가 유지보수하는 코드는 고칩니다.
https://news.ycombinator.com/item?id=50108112