C++26 Contracts: pre·post·contract_assert로 사전조건·사후조건 표현하기와 평가 의미
이 글의 핵심
사전조건을 assert로 검사하면 릴리스 빌드에서 사라지고, 예외로 검사하면 호출자 버그와 런타임 오류가 섞입니다. C++26 Contracts는 조건을 함수 선언에 드러내고 검사 방식(평가 의미)을 구현·빌드 설정으로 정하게 합니다. 이 글은 채택된 P2900 기준으로 세 가지 문법, 평가 의미와 위반 핸들러, 이전 값 참조(old)가 없을 때의 우회법, 도입 시 주의점을 코드와 함께 정리합니다.
들어가며
C++26의 Contracts는 함수의 사전조건(precondition)과 사후조건(postcondition), 그리고 함수 본문 안의 단언(assertion)을 언어 문법으로 표현하는 기능입니다. 2025년 2월 WG21 회의에서 P2900 “Contracts for C++“가 C++26 작업 초안에 채택되었고, 이 글의 문법과 동작은 모두 그 설계를 기준으로 합니다.
기존에는 assert, 주석, 수동 검증 코드로 처리하던 것을 함수 선언에 드러내고, 실제로 검사할지와 위반 시 어떻게 할지는 구현(컴파일러)과 빌드 설정이 정합니다. 이 글은 기본 문법(pre, post, contract_assert), 네 가지 평가 의미(evaluation semantics), 위반 핸들러, 실전 패턴, 그리고 기존 방식과의 비교를 코드 예제와 함께 설명합니다.
먼저 인터넷의 예제를 볼 때 주의할 점이 있습니다. Contracts는 C++20에 한 번 들어갔다가 빠졌고 그 뒤 여러 설계안이 오갔기 때문에, old(x) 같은 이전 값 참조, [[expects: ...]] 속성 문법, “클래스 불변식(invariant)” 선언, -fcontracts=enforce 같은 옵션을 쓰는 예제가 많습니다. 채택된 C++26 Contracts에는 이 중 어느 것도 없습니다. 이 글은 그런 부분을 C++26에서 실제로 쓸 수 있는 방식으로 바꿔 설명합니다.
학습 전제 조건:
- C++ 함수 기본
- 예외 처리 이해 (예외 가이드)
- 디버깅 경험
Contracts란?
Design by Contract와 C++26의 범위
Contracts는 Bertrand Meyer의 “Design by Contract” 개념에서 출발했습니다. 전통적인 DbC는 세 가지를 다룹니다.
함수 = 계약
- 사전조건 (Precondition): 호출자가 보장해야 할 것
- 사후조건 (Postcondition): 함수가 보장할 것
- 클래스 불변식 (Class invariant): 객체가 항상 만족해야 할 것
C++26은 이 중 사전조건과 사후조건만 함수 선언의 일부로 지원하고, 여기에 본문 안에서 쓰는 contract_assert 문을 더했습니다. 클래스 불변식을 선언하는 문법은 없습니다. 불변식을 검사하고 싶다면 bool is_valid() const 같은 멤버 함수를 만들어 각 멤버 함수의 post나 contract_assert에서 직접 호출해야 합니다(아래 예제에서 이 방식을 씁니다).
기존 방식의 한계
assert 사용:
#include <cassert>
int divide(int a, int b) {
assert(b != 0); // NDEBUG가 정의되면 사라짐
return a / b;
}
- 릴리스 빌드(
NDEBUG)에서는 사라지고, 켜져 있으면 무조건abort() - 사전조건인지 내부 검사인지 코드만 봐서는 구분이 안 됨
- 조건이 함수 본문에 있어서 선언(헤더)만 보는 호출자에게 보이지 않음
수동 검증:
int divide(int a, int b) {
if (b == 0) {
throw std::invalid_argument("Division by zero");
}
return a / b;
}
- 항상 검사 비용을 냄
- “호출자가 버그를 냈다”와 “복구 가능한 런타임 오류”가 같은 예외 경로로 섞임
C++26 Contracts:
int divide(int a, int b)
pre(b != 0) // 사전조건을 선언에 명시
{
return a / b;
}
- 조건이 선언의 일부라서 헤더만 봐도 계약이 보임
- 검사 여부와 위반 시 동작은 평가 의미로 선택
- 위반 시 교체 가능한 위반 핸들러로 로깅 방식을 통일할 수 있음
기본 문법
pre와 post는 함수 계약 지정자(function contract specifier)로, 함수 선언자 뒤(매개변수 목록, const/noexcept, 후행 반환 타입 뒤)이자 함수 본문이나 생성자의 멤버 초기화 목록 앞에 옵니다. 여러 개를 나열할 수 있고, 적힌 순서대로 평가됩니다. contract_assert는 함수 본문 안에서 쓰는 문(statement)입니다.
사전조건: pre
#include <cmath>
int sqrt_int(int x)
pre(x >= 0) // x는 음수가 아니어야 함
{
return static_cast<int>(std::sqrt(x));
}
// 여러 조건
void process(int* ptr, int size)
pre(ptr != nullptr)
pre(size > 0)
{
for (int i = 0; i < size; i++) {
ptr[i] *= 2;
}
}
사후조건: post
반환값을 조건에서 쓰려면 post(이름: 조건) 형태로 반환값에 이름을 붙입니다. 이름은 r, result 등 자유롭게 정할 수 있습니다.
#include <algorithm>
#include <vector>
int factorial(int n)
pre(n >= 0)
post(r: r > 0) // 반환값 r은 양수여야 함
{
if (n == 0) return 1;
return n * factorial(n - 1);
}
std::vector<int> sorted(std::vector<int> v)
post(result: std::is_sorted(result.begin(), result.end()))
{
std::sort(v.begin(), v.end());
return v;
}
중간 검사: contract_assert
contract_assert는 assert를 대체하는 언어 차원의 단언문입니다. 함수 계약(호출자와의 약속)이 아니라 본문의 특정 지점에서 성립해야 하는 조건을 검사합니다. 루프 안에서 매 반복마다 성립해야 하는 조건(루프 불변 조건)을 확인하는 데 쓸 수 있지만, 이것은 그 지점에서 조건을 검사하는 것일 뿐 클래스 불변식 기능은 아닙니다.
#include <algorithm>
#include <vector>
bool contains(const std::vector<int>& arr, int target)
pre(std::is_sorted(arr.begin(), arr.end()))
{
int left = 0, right = static_cast<int>(arr.size()) - 1;
while (left <= right) {
contract_assert(left >= 0 && right < static_cast<int>(arr.size()));
int mid = left + (right - left) / 2;
if (arr[mid] == target) {
return true;
} else if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return false;
}
조건식에 적용되는 규칙
계약 조건식(predicate) 안에서는 몇 가지 규칙이 일반 코드와 다릅니다. 처음 써 볼 때 컴파일 에러가 나는 대부분의 원인이 여기 있습니다.
- 암묵적 const화: 조건식 안에서 매개변수, 지역 변수,
this를 통한 멤버는const로 취급됩니다. 그래서 조건식에서 non-const 멤버 함수를 호출하거나 값을 바꾸면 컴파일되지 않습니다. 검사용 멤버 함수는const로 선언하세요. - 사후조건에서 참조하는 값 매개변수는
const여야 함: 함수 본문이 매개변수를 바꾼 뒤에 사후조건이 평가되면 호출자가 넘긴 값과 다른 값을 검사하게 되므로,post에서 참조하는 참조가 아닌 매개변수는 (모든 선언에서)const로 선언해야 합니다. 참조 매개변수는 이 제약을 받지 않습니다. - 부수 효과를 넣지 말 것: 평가 의미에 따라 조건식은 아예 평가되지 않을 수도 있고, 구현이 같은 조건을 여러 번 평가하는 것도 허용됩니다. 로그 출력, 카운터 증가 같은 부수 효과가 있으면 빌드 설정에 따라 동작이 달라집니다.
- 조건식이 예외를 던지면 그 자체가 계약 위반으로 처리됩니다.
두 번째 규칙 때문에 사후조건에서 매개변수를 쓰는 함수는 선언을 이렇게 바꿔야 합니다.
#include <vector>
// ❌ start, end가 const가 아니라 post에서 참조할 수 없음
// std::vector<int> create_range(int start, int end) post(r: r.front() == start) ...
// ✅ 값 매개변수를 const로 선언
std::vector<int> create_range(const int start, const int end)
pre(start <= end)
post(r: r.size() == static_cast<std::size_t>(end - start + 1))
post(r: r.front() == start)
post(r: r.back() == end)
{
std::vector<int> v;
for (int i = start; i <= end; i++) {
v.push_back(i);
}
return v;
}
평가 의미와 위반 처리
네 가지 평가 의미
P2900은 각 계약 검사가 다음 네 가지 평가 의미(evaluation semantic) 중 하나로 평가된다고 정합니다. 어떤 의미를 쓸지는 구현이 정하는 사항이고, 보통은 컴파일러 옵션으로 고르게 됩니다. 옵션 이름은 컴파일러마다 다르고 아직 실험 단계인 구현도 있으므로 사용하는 컴파일러의 문서를 확인하세요.
| 평가 의미 | 조건 평가 | 위반 시 동작 |
|---|---|---|
ignore | 하지 않음 | 없음 |
observe | 함 | 위반 핸들러 호출 후, 핸들러가 정상 반환하면 실행 계속 |
enforce | 함 | 위반 핸들러 호출 후 프로그램 종료(종료 방식은 구현 정의) |
quick_enforce | 함 | 위반 핸들러를 호출하지 않고 즉시 프로그램 종료 |
몇 가지 자주 오해하는 부분이 있습니다.
quick_enforce는 “간단한 조건만 검사”하는 모드가 아닙니다. 조건은 똑같이 평가하되, 위반 시 핸들러 호출과 진단 정보 구성을 건너뛰고 바로 종료해서 코드 크기와 오버헤드를 줄이는 모드입니다.- 위반은 예외로 던져지지 않습니다.
enforce에서 프로그램이 끝나는 방식(std::terminate,std::abort등)은 구현이 정합니다. ignore에서도 조건식은 문법적으로 올바른 코드여야 하고 이름 조회·타입 검사를 거칩니다. 그리고ignore라고 해서 컴파일러가 조건을 참이라고 가정해 최적화하지 않습니다. 계약은[[assume]]과 다릅니다. 가정으로 쓰이면 검사를 끈 빌드에서 위반이 곧바로 정의되지 않은 동작의 증폭으로 이어지기 때문에, P2900은 이를 의도적으로 배제했습니다.
모드별 동작 비교
#include <iostream>
int divide(int a, int b)
pre(b != 0)
{
return a / b;
}
int main() {
int result = divide(10, 0); // 계약 위반
std::cout << result << '\n';
}
| 평가 의미 | 이 프로그램에서 일어나는 일 |
|---|---|
| ignore | 검사하지 않고 a / b 실행 → 0으로 나누기는 원래대로 정의되지 않은 동작 |
| observe | 위반 핸들러가 진단을 출력한 뒤 계속 실행 → 역시 a / b에 도달 |
| enforce | 위반 핸들러 호출 후 종료, a / b에 도달하지 않음 |
| quick_enforce | 핸들러 없이 즉시 종료 |
observe는 계약을 새로 도입할 때 유용하지만, 위 예처럼 위반 뒤의 코드가 조건을 전제로 하고 있다면 계속 실행하는 것 자체가 위험할 수 있다는 점도 기억해야 합니다.
위반 핸들러
위반이 감지되면(observe, enforce) 계약 위반 핸들러가 호출됩니다. <contracts> 헤더의 std::contracts::contract_violation 객체로 위반 정보를 받으며, 여기에는 위반 위치(location(), std::source_location), 조건식을 설명하는 문자열(comment()), 계약 종류(kind(): 사전·사후조건 또는 단언), 평가 의미 등이 들어 있습니다.
전역 함수 handle_contract_violation을 정의해 기본 핸들러를 교체할 수 있습니다. 다만 교체를 지원할지는 구현 정의이므로, 사용하는 툴체인이 이를 지원하는지 확인해야 합니다.
#include <contracts>
#include <cstdio>
// 전역 네임스페이스에 정의하면 기본 위반 핸들러를 대체 (구현이 지원하는 경우)
void handle_contract_violation(const std::contracts::contract_violation& v) {
std::fprintf(stderr, "contract violation at %s:%u: %s\n",
v.location().file_name(),
static_cast<unsigned>(v.location().line()),
v.comment());
// 기본 핸들러의 출력도 함께 원하면:
// std::contracts::invoke_default_contract_violation_handler(v);
}
observe 단계에서 이 핸들러가 기존 로깅 시스템으로 위반을 보내게 해 두면, 실제로 어떤 계약이 얼마나 자주 깨지는지 운영 데이터로 확인한 뒤 enforce로 올릴지 판단할 수 있습니다.
컴파일러 지원 현황
2026년 9월 기준으로 GCC에서 P2900 기반 실험적 구현이 진행 중이며, 흔히 -std=c++26 -fcontracts로 활성화합니다. Clang과 MSVC도 구현 작업이 진행 중인 단계입니다. 평가 의미를 선택하는 옵션 이름, 위반 핸들러 교체 지원 여부는 구현과 버전에 따라 다르니 릴리스 노트를 확인하세요. 예전 GCC 실험 구현(C++20 초안 기반)의 옵션과 문법을 소개하는 글이 많으므로, 그 옵션을 그대로 따라 하지 않는 것이 좋습니다.
사전조건 (Precondition)
기본 사용
#include <cstddef>
#include <iostream>
#include <vector>
// 배열 인덱스 접근
int get_element(const std::vector<int>& vec, std::size_t index)
pre(index < vec.size())
{
return vec[index];
}
// 포인터 검증
void process_data(const int* data, std::size_t size)
pre(data != nullptr)
pre(size > 0)
{
for (std::size_t i = 0; i < size; i++) {
std::cout << data[i] << ' ';
}
}
// 범위 검증
double calculate_percentage(int part, int total)
pre(total > 0)
pre(part >= 0 && part <= total)
{
return (static_cast<double>(part) / total) * 100.0;
}
사전조건을 하나의 pre에 &&로 묶는 대신 여러 pre로 나누면, 위반 시 핸들러가 받는 정보(comment(), 위치)가 어느 조건이 깨졌는지 더 정확하게 알려 줍니다.
복잡한 조건
#include <algorithm>
#include <string>
#include <vector>
// 정렬 여부 확인 — O(N) 검사라는 점에 주의
int find_index(const std::vector<int>& arr, int target)
pre(std::is_sorted(arr.begin(), arr.end()))
{
auto it = std::lower_bound(arr.begin(), arr.end(), target);
return (it != arr.end() && *it == target)
? static_cast<int>(std::distance(arr.begin(), it))
: -1;
}
// 검증 함수를 조건식에서 호출
bool is_valid_email(const std::string& email) {
return email.find('@') != std::string::npos;
}
void send_email(const std::string& email, const std::string& message)
pre(is_valid_email(email))
pre(!message.empty())
{
// 이메일 전송 로직
}
send_email의 이메일 형식 검사는 “호출자가 이미 검증했어야 한다”는 설계일 때만 사전조건이 맞습니다. 사용자 입력을 그대로 받는 함수라면 형식 오류는 버그가 아니라 예상 가능한 입력이므로, 반환값이나 예외로 처리하는 편이 맞습니다(아래 비교 절 참고).
클래스 멤버 함수
멤버 함수의 조건식에서는 멤버를 직접 참조할 수 있습니다. 이때 멤버는 const로 취급되므로 const 멤버 함수만 호출할 수 있습니다. 생성자의 pre는 멤버 초기화 목록 앞에 옵니다.
class BankAccount {
private:
double balance;
public:
explicit BankAccount(double initial)
pre(initial >= 0)
: balance(initial) {}
void deposit(double amount)
pre(amount > 0)
post(balance > 0)
{
balance += amount;
}
void withdraw(double amount)
pre(amount > 0)
pre(amount <= balance)
post(balance >= 0)
{
balance -= amount;
}
double get_balance() const
post(r: r >= 0) // 잔액은 음수가 아님
{
return balance;
}
};
“출금 후 잔액이 정확히 amount만큼 줄었다”처럼 호출 전 값과 비교하는 사후조건은 C++26 Contracts로 직접 쓸 수 없습니다. 이 경우의 우회법은 다음 절에서 다룹니다.
사후조건 (Postcondition)
반환값 검증
#include <limits>
int abs_value(int x)
pre(x != std::numeric_limits<int>::min()) // -INT_MIN은 오버플로우
post(result: result >= 0)
{
return (x < 0) ? -x : x;
}
abs_value의 사전조건은 사후조건을 쓰다가 발견하게 되는 전형적인 예입니다. post(result: result >= 0)만 적어 두면 INT_MIN을 넘겼을 때 -x가 오버플로우한다는 사실이 가려지는데, 사후조건을 참으로 만들려면 어떤 입력이 필요한지 따져 보면 빠진 사전조건이 드러납니다.
이전 값(old)은 참조할 수 없다
다른 DbC 언어(Eiffel의 old, D의 out 계약 등)나 C++ 초기 제안에는 “함수 시작 시점의 값”을 사후조건에서 참조하는 기능이 있었지만, 채택된 C++26 Contracts에는 이 기능이 없습니다. 인터넷에 보이는 post(count == old(count) + 1) 같은 코드는 컴파일되지 않습니다. 복사 비용, 복사할 수 없는 타입, 평가 시점 같은 문제 때문에 최소 기능(MVP)에서 빠졌고 후속 제안의 논의 대상입니다.
실무에서는 두 가지 방법으로 우회합니다.
방법 1: 시작 시점 값을 지역 변수로 복사하고 반환 직전에 contract_assert
class Counter {
private:
int count_ = 0;
public:
int count() const { return count_; }
void increment()
{
const int before = count_;
++count_;
contract_assert(count_ == before + 1); // 정확히 1 증가
}
void add(int n)
pre(n >= 0)
{
const int before = count_;
count_ += n;
contract_assert(count_ == before + n);
}
void reset()
post(count() == 0)
{
count_ = 0;
}
};
이 방식은 before 복사가 평가 의미와 상관없이 항상 실행된다는 단점이 있습니다. int 하나라면 최적화기가 ignore 빌드에서 쓰이지 않는 복사를 지우겠지만, 큰 객체를 복사한다면 비용이 남을 수 있습니다. 또 반환 경로가 여러 개인 함수라면 각 return 앞에 검사를 넣어야 하므로, 검사할 조건을 작은 함수로 묶어 두면 편합니다.
방법 2: 사후조건이 반환값과 수정되지 않는 값만 참조하도록 설계 바꾸기
// 새 값을 반환하도록 바꾸면 "결과가 입력보다 n 크다"를 post로 표현할 수 있음
int incremented(const int value, const int n)
pre(n >= 0)
post(r: r == value + n)
{
return value + n;
}
상태를 바꾸는 멤버 함수에서는 방법 1이, 계산을 순수 함수로 분리할 수 있는 곳에서는 방법 2가 자연스럽습니다.
복잡한 사후조건
#include <algorithm>
#include <vector>
// 정렬 + 중복 제거 보장
std::vector<int> sort_and_deduplicate(std::vector<int> v)
post(r: std::is_sorted(r.begin(), r.end()))
post(r: std::adjacent_find(r.begin(), r.end()) == r.end()) // 인접 중복 없음
{
std::sort(v.begin(), v.end());
v.erase(std::unique(v.begin(), v.end()), v.end());
return v;
}
// 모든 원소가 양수
std::vector<int> filter_positive(const std::vector<int>& v)
post(r: std::all_of(r.begin(), r.end(), [](int x) { return x > 0; }))
{
std::vector<int> result;
std::copy_if(v.begin(), v.end(), std::back_inserter(result),
[](int x) { return x > 0; });
return result;
}
sort_and_deduplicate에서 “결과 크기가 입력 크기 이하”라는 조건을 post(r: r.size() <= v.size())로 쓰고 싶어질 수 있지만, v는 본문에서 수정되는 값 매개변수라 const로 선언할 수 없고, 따라서 사후조건에서 참조할 수 없습니다. 이런 조건은 본문에서 입력 크기를 먼저 저장해 두고 contract_assert로 검사해야 합니다.
중간 검사 (contract_assert)
루프와 중간 상태 검사
void process_array(int* arr, int size)
pre(arr != nullptr)
pre(size > 0)
{
for (int i = 0; i < size; i++) {
arr[i] *= 2;
contract_assert(arr[i] % 2 == 0); // 이 지점에서 성립해야 하는 조건
}
}
자료구조 불변 조건을 직접 검사하기
C++26에는 클래스 불변식 선언이 없으므로, 자료구조가 지켜야 할 조건은 const 검사 함수로 만들고 필요한 지점에서 호출합니다.
#include <climits>
class BinarySearchTree {
private:
struct Node {
int value;
Node* left;
Node* right;
};
Node* root = nullptr;
bool is_valid_bst(const Node* node, long long min_val, long long max_val) const {
if (!node) return true;
if (node->value <= min_val || node->value >= max_val) return false;
return is_valid_bst(node->left, min_val, node->value) &&
is_valid_bst(node->right, node->value, max_val);
}
public:
void insert(int value)
{
// 삽입 로직
contract_assert(is_valid_bst(root, LLONG_MIN, LLONG_MAX)); // BST 속성 유지
}
};
트리 전체를 도는 검사는 O(N)이라 삽입마다 돌리면 삽입이 O(N)이 됩니다. 이런 검사는 디버그·테스트 빌드에서만 켜 두는 것이 현실적인데, C++26 Contracts의 평가 의미는 개별 검사마다 지정하는 문법이 표준에 없으므로 “비싼 검사만 끄기”는 구현이 제공하는 옵션이나 별도 매크로에 기대야 합니다. 이 점은 도입 전에 팀에서 정해 두어야 할 부분입니다.
실전 패턴
배열/벡터 안전 접근
#include <cstddef>
#include <vector>
template<typename T>
class SafeVector {
private:
std::vector<T> data;
public:
void push_back(const T& value) {
data.push_back(value);
}
T& at(std::size_t index)
pre(index < data.size())
{
return data[index];
}
const T& at(std::size_t index) const
pre(index < data.size())
{
return data[index];
}
std::size_t size() const
{
return data.size();
}
};
std::vector::at은 범위를 벗어나면 std::out_of_range를 던지지만, 위 at은 범위 밖 인덱스를 호출자의 버그로 규정합니다. 둘 중 무엇이 맞는지는 이 인덱스가 어디서 오는지(내부 계산인지, 외부 입력인지)에 달려 있습니다.
리소스 관리
#include <cstdio>
#include <stdexcept>
class FileHandle {
private:
FILE* file = nullptr;
public:
bool is_open() const {
return file != nullptr;
}
void open(const char* filename)
pre(filename != nullptr)
pre(!is_open())
post(is_open())
{
file = std::fopen(filename, "r");
if (!file) {
throw std::runtime_error("Failed to open file");
}
}
void close()
pre(is_open())
post(!is_open())
{
std::fclose(file);
file = nullptr;
}
std::size_t read(char* buffer, const std::size_t size)
pre(is_open())
pre(buffer != nullptr)
pre(size > 0)
post(r: r <= size)
{
return std::fread(buffer, 1, size, file);
}
~FileHandle() {
if (is_open()) {
close();
}
}
};
open에서 파일을 못 여는 것은 사전조건 위반이 아니라 런타임 오류이므로 예외로 처리했습니다. 사후조건은 함수가 정상 반환할 때만 검사되고 예외로 빠져나갈 때는 검사되지 않으므로, post(is_open())과 예외 경로는 충돌하지 않습니다. read의 size는 사후조건에서 참조하므로 const로 선언했습니다.
수학 함수
#include <climits>
#include <cmath>
double safe_sqrt(const double x)
pre(x >= 0)
post(r: r >= 0)
post(r: std::abs(r * r - x) <= 1e-9 * (x + 1)) // 상대 오차 허용
{
return std::sqrt(x);
}
double safe_log(double x)
pre(x > 0)
pre(std::isfinite(x))
post(r: std::isfinite(r))
{
return std::log(x);
}
int safe_factorial(int n)
pre(n >= 0)
pre(n <= 12) // 13!은 32비트 int 범위를 넘음
post(r: r > 0)
{
int result = 1;
for (int i = 2; i <= n; i++) {
contract_assert(result <= INT_MAX / i); // 곱하기 전에 오버플로우 검사
result *= i;
}
return result;
}
부동소수점 사후조건은 고정 오차(< 0.0001)로 쓰면 큰 입력에서 거짓 위반이 납니다. 입력 크기에 비례하는 허용 오차를 쓰거나, 정확도 검사는 테스트로 옮기고 계약에는 부호·유한성 정도만 남기는 편이 안전합니다.
문자열 처리
#include <algorithm>
#include <cctype>
#include <string>
std::string substring(const std::string& str, const std::size_t pos, const std::size_t len)
pre(pos < str.size())
pre(len > 0)
post(r: r.size() <= len)
post(r: r.size() <= str.size() - pos)
{
return str.substr(pos, len);
}
std::string to_uppercase(std::string str)
post(r: std::none_of(r.begin(), r.end(),
[](unsigned char c) { return std::islower(c); }))
{
std::transform(str.begin(), str.end(), str.begin(),
[](unsigned char c) { return static_cast<char>(std::toupper(c)); });
return str;
}
to_uppercase는 “길이가 같다”는 조건을 사후조건으로 쓸 수 없습니다. str은 본문에서 수정되는 값 매개변수라 const로 만들 수 없기 때문입니다. 필요하면 시작 시 길이를 저장해 contract_assert로 비교합니다.
컨테이너와 상태 검사
#include <cstddef>
#include <vector>
template<typename T>
class Stack {
private:
std::vector<T> data;
public:
void push(const T& value)
post(!empty())
post(top() == value)
{
const std::size_t before = data.size();
data.push_back(value);
contract_assert(data.size() == before + 1);
}
T pop()
pre(!empty())
{
const std::size_t before = data.size();
T value = data.back();
data.pop_back();
contract_assert(data.size() == before - 1);
return value;
}
const T& top() const
pre(!empty())
{
return data.back();
}
bool empty() const
{
return data.empty();
}
std::size_t size() const
{
return data.size();
}
};
push의 value는 참조 매개변수라 사후조건에서 참조할 수 있습니다. 단, 호출자가 스택 안의 원소를 가리키는 참조를 넘기면(s.push(s.top())) push_back의 재할당 뒤에 그 참조가 무효화될 수 있으므로, 참조 매개변수를 사후조건에서 쓸 때는 별칭(aliasing) 문제를 한 번 생각해 봐야 합니다.
성능 영향
비용은 조건식 자체의 비용
Contracts가 추가하는 런타임 비용은 기본적으로 조건식을 평가하는 비용과 위반 시 경로의 비용입니다. pre(b != 0) 같은 조건은 분기 하나이고, pre(std::is_sorted(...))는 호출할 때마다 O(N)입니다. 따라서 “Contracts가 몇 % 느리다”는 일반적인 수치는 의미가 없고, 어떤 조건을 어떤 평가 의미로 켜는지에 따라 달라집니다.
ignore: 조건을 평가하지 않습니다. 다만 앞서 말했듯 컴파일러가 조건을 가정으로 쓰지 않으므로[[assume]]같은 최적화 이득도 없습니다.quick_enforce: 조건은 평가하지만 위반 시 핸들러 호출과 진단 정보 구성을 생략하므로, 위반 경로의 코드 크기가 작습니다.observe/enforce: 조건 평가 비용에 더해, 위반 시 핸들러를 호출하는 코드가 들어갑니다. 위반이 없는 정상 경로의 비용은 대부분 조건식 비용입니다.
비용 관리 전략
1. 비싼 조건은 함수 계약에서 빼기
#include <algorithm>
#include <vector>
// ❌ 호출마다 O(N) 검사 두 번
void process(const std::vector<int>& v)
pre(std::is_sorted(v.begin(), v.end()))
pre(std::all_of(v.begin(), v.end(), [](int x) { return x > 0; }))
{
// ...
}
// ✅ 저렴한 조건만 계약에 두고, 비싼 검사는 테스트나 경계 지점으로 옮기기
void process(const std::vector<int>& v)
pre(!v.empty()) // O(1)
{
// ...
}
비싼 검사도 가치가 있다면, 데이터가 처음 들어오는 경계 함수에서 한 번만 검사하고 내부 함수들은 그 결과를 믿는 구조가 비용과 안전성의 균형이 좋습니다.
2. 평가 의미는 빌드 설정 단위로 정하기
C++26 표준에는 개별 계약마다 평가 의미를 지정하는 문법이 없습니다(예전 제안의 레벨 지정이나 [[likely_ignore]] 같은 속성은 존재하지 않습니다). 평가 의미는 구현이 정하므로, 보통 번역 단위나 빌드 구성 단위로 컴파일러 옵션을 달리해서 조절하게 됩니다. 핫 패스가 모인 라이브러리와 경계 코드를 다른 옵션으로 빌드하는 식의 구성은 가능하지만, 인라인 함수가 여러 번역 단위에서 다른 평가 의미로 컴파일될 때의 동작은 구현에 맡겨진 부분이 있으니 신중하게 적용하세요.
기존 방식과 비교
assert vs Contracts
assert:
#include <cassert>
int divide(int a, int b) {
assert(b != 0); // NDEBUG 정의 시 무시
return a / b;
}
NDEBUG하나로 전부 켜거나 전부 끔- 켜져 있으면 위반 시
abort(), 동작을 바꿀 방법이 없음 - 사전/사후조건 구분 없음, 선언에 드러나지 않음
Contracts:
int divide(int a, int b)
pre(b != 0)
{
return a / b;
}
- 평가 의미로 검사 여부와 위반 시 동작을 선택
- 사전/사후조건과 중간 검사가 구분되고, 사전·사후조건은 선언에 드러남
- 교체 가능한 위반 핸들러로 진단 방식을 통일
예외 vs Contracts
예외 처리:
#include <iostream>
#include <stdexcept>
int divide(int a, int b) {
if (b == 0) {
throw std::invalid_argument("Division by zero");
}
return a / b;
}
// 호출부
try {
int result = divide(10, 0);
} catch (const std::exception& e) {
std::cerr << e.what() << '\n';
}
- 항상 검사
- 호출자가 잡아서 복구할 수 있음
Contracts:
int divide(int a, int b)
pre(b != 0)
{
return a / b;
}
// 호출부
int result = divide(10, 0); // enforce면 핸들러 호출 후 종료, catch로 잡을 수 없음
- 평가 의미에 따라 검사
- “여기서 조건이 거짓이면 프로그램에 버그가 있다”는 선언
언제 무엇을 사용?
| 상황 | 권장 |
|---|---|
| 프로그래머 오류 (호출 규약 위반, 내부 버그) | Contracts |
| 외부 입력 검증 (사용자 입력, 파일, 네트워크) | 반환값 또는 예외 |
| 복구 가능한 에러 | 반환값 또는 예외 |
| 테스트에서만 확인할 비싼 속성 | 단위 테스트 |
핵심 기준은 “조건이 거짓일 때 그게 버그인가, 예상 가능한 상황인가”입니다. 예상 가능한 상황을 사전조건으로 만들면 ignore 빌드에서 그 검사가 사라지고, 정상적인 잘못된 입력이 곧바로 정의되지 않은 동작으로 이어질 수 있습니다.
고급 패턴
클래스 불변 조건을 수동으로 검사하기
클래스 불변식 문법이 없으므로, 불변 조건을 is_valid() const로 모아 두고 생성자와 상태를 바꾸는 멤버 함수의 post에서 호출하는 패턴을 씁니다.
#include <cstddef>
#include <vector>
class CircularBuffer {
private:
std::vector<int> buffer;
std::size_t capacity;
std::size_t head = 0;
std::size_t tail = 0;
std::size_t count = 0;
bool is_valid() const {
return count <= capacity &&
head < capacity &&
tail < capacity &&
buffer.size() == capacity;
}
public:
explicit CircularBuffer(std::size_t cap)
pre(cap > 0)
post(is_valid())
: buffer(cap), capacity(cap) {}
void push(int value)
pre(!full())
post(!empty())
post(is_valid())
{
buffer[tail] = value;
tail = (tail + 1) % capacity;
count++;
}
int pop()
pre(!empty())
post(!full())
post(is_valid())
{
int value = buffer[head];
head = (head + 1) % capacity;
count--;
return value;
}
bool empty() const { return count == 0; }
bool full() const { return count == capacity; }
std::size_t size() const { return count; }
};
is_valid()는 private이지만 멤버 함수의 계약 조건식은 그 클래스의 접근 권한으로 평가되므로 호출할 수 있습니다. 다만 선언에 드러난 조건을 호출자가 이해할 수 없다면 문서로서의 가치는 줄어든다는 점도 고려하세요.
알고리즘 검사
#include <algorithm>
#include <utility>
#include <vector>
void quicksort(std::vector<int>& arr, const int low, const int high)
pre(low >= 0)
pre(high < static_cast<int>(arr.size()))
post(low >= high || std::is_sorted(arr.begin() + low, arr.begin() + high + 1))
{
if (low >= high) return;
int pivot = arr[high];
int i = low - 1;
for (int j = low; j < high; j++) {
if (arr[j] < pivot) {
i++;
std::swap(arr[i], arr[j]);
}
}
std::swap(arr[i + 1], arr[high]);
int pi = i + 1;
contract_assert(pi >= low && pi <= high);
quicksort(arr, low, pi - 1);
quicksort(arr, pi + 1, high);
}
int binary_search(const std::vector<int>& arr, const int target)
pre(std::is_sorted(arr.begin(), arr.end()))
post(r: r == -1 ||
(r >= 0 && r < static_cast<int>(arr.size()) && arr[r] == target))
{
int left = 0, right = static_cast<int>(arr.size()) - 1;
while (left <= right) {
contract_assert(left >= 0 && right < static_cast<int>(arr.size()));
int mid = left + (right - left) / 2;
if (arr[mid] == target) {
return mid;
} else if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}
low, high, target은 사후조건에서 참조하므로 const로 선언했습니다. quicksort의 사후조건은 재귀 호출마다 구간 정렬 여부를 검사하므로 전체 비용이 정렬 자체와 같은 수준으로 커집니다. 테스트 빌드용 검사로는 좋지만 운영 빌드에서 켤 조건은 아닙니다. 또 binary_search의 pre(std::is_sorted(...))는 O(log N) 알고리즘에 O(N) 검사를 붙이는 셈이라, 이진 탐색을 쓰는 이유를 상쇄한다는 점도 기억해 두세요.
동시성 코드에서의 주의점
여러 스레드가 공유하는 멤버를 사전·사후조건에서 읽으면, 그 조건식은 락 바깥에서 평가됩니다. pre는 함수 본문이 락을 잡기 전에, post는 본문이 락을 풀고 반환한 뒤에 평가되므로, 공유 상태를 읽는 조건식은 데이터 레이스가 됩니다. 공유 상태 검사는 락을 잡은 구간 안의 contract_assert로 옮기세요.
#include <mutex>
class ThreadSafeCounter {
private:
mutable std::mutex mtx;
int count = 0;
public:
void increment()
{
std::lock_guard<std::mutex> lock(mtx);
const int before = count;
++count;
contract_assert(count == before + 1); // 락 안에서만 공유 상태 검사
}
int get() const
{
std::lock_guard<std::mutex> lock(mtx);
return count;
}
};
널이 아닌 포인터 래퍼
template<typename T>
class NonNullPtr {
private:
T* ptr;
public:
explicit NonNullPtr(T* p)
pre(p != nullptr)
post(ptr != nullptr)
: ptr(p) {}
T& operator*() const
{
return *ptr;
}
T* operator->() const
post(r: r != nullptr)
{
return ptr;
}
T* get() const
post(r: r != nullptr)
{
return ptr;
}
};
생성자 pre가 널을 막고 나면 이후 멤버 함수마다 pre(ptr != nullptr)를 반복할 필요는 없습니다. 같은 조건을 여기저기 반복하면 검사 비용만 늘고, 진짜 불변 조건이 어디서 보장되는지가 오히려 흐려집니다.
실무 적용 가이드
단계별 도입
1단계: 공개 API에 사전조건 추가
#include <cstddef>
void process_data(const char* data, std::size_t size)
pre(data != nullptr)
pre(size > 0)
{
// 구현
}
기존 코드의 함수 첫머리에 있는 assert는 대부분 그대로 pre로 옮길 수 있는 후보입니다. 옮기면서 그 조건이 정말 호출자 책임인지, 외부 입력 검증을 assert로 대충 해 온 것인지를 구분하게 되는 것이 이 단계의 실질적인 이득입니다.
2단계: 중요 함수에 사후조건 추가
#include <algorithm>
#include <vector>
std::vector<int> merge_sorted(const std::vector<int>& a,
const std::vector<int>& b)
pre(std::is_sorted(a.begin(), a.end()))
pre(std::is_sorted(b.begin(), b.end()))
post(r: std::is_sorted(r.begin(), r.end()))
post(r: r.size() == a.size() + b.size())
{
std::vector<int> out;
out.reserve(a.size() + b.size());
std::merge(a.begin(), a.end(), b.begin(), b.end(), std::back_inserter(out));
return out;
}
a, b는 참조 매개변수라 const를 따로 붙이지 않아도 사후조건에서 참조할 수 있습니다.
3단계: 복잡한 로직에 중간 검사 추가
void complex_algorithm() {
contract_assert(is_valid_state()); // 초기 상태 검증
step1();
contract_assert(is_valid_state());
step2();
contract_assert(is_valid_state());
step3();
contract_assert(is_valid_state());
}
빌드 구성별 평가 의미 정하기
평가 의미를 고르는 옵션 이름은 구현마다 다르므로 특정 플래그 대신 정책만 정리하면 다음과 같습니다.
| 빌드 구성 | 권장 평가 의미 | 이유 |
|---|---|---|
| Debug / 단위 테스트 | enforce | 위반을 바로 실패로 드러냄 |
| 스테이징 / 카나리 | observe | 새로 넣은 계약이 실제로 깨지는지 로그로 확인 |
| Release | enforce 또는 quick_enforce (핫 패스는 팀 판단에 따라 ignore) | 위반 뒤에 잘못된 상태로 계속 도는 것을 막음 |
CMake에서는 구성별 컴파일 옵션($<CONFIG:Debug> 같은 생성기 표현식)에 사용하는 컴파일러의 평가 의미 옵션을 넣는 식으로 구성합니다.
트러블슈팅
문제 1: 계약 위반으로 프로그램이 종료됨
enforce에서 위반이 발생하면 위반 핸들러가 진단 메시지를 출력한 뒤 프로그램이 끝납니다. 메시지 형식은 구현마다 다르며, 위반은 예외가 아니므로 catch로 잡히지 않습니다.
해결 순서:
- 핸들러가 출력한 위치(
location())와 조건식 설명으로 어느 계약이 깨졌는지 확인합니다. - 호출자가 계약을 어겼다면 호출부를 고칩니다.
if (b != 0) {
result = divide(a, b);
}
- 계약 자체가 너무 강했다면(예상 가능한 입력을 사전조건으로 막고 있었다면) 조건을 사전조건에서 빼고 반환값이나 예외로 처리합니다.
- 이미 운영 중인 코드에 계약을 새로 넣은 직후라면, 한동안
observe로 돌리며 위반 빈도를 보는 것도 방법입니다.
조건식 안에 로그 출력을 넣어 “경고만 하고 넘어가게” 하는 방식(pre(b != 0 || (std::cerr << "...", false)))은 쓰지 마세요. 조건식의 부수 효과는 평가 의미에 따라 실행되지 않거나 여러 번 실행될 수 있습니다. 경고를 남기고 싶다면 observe와 위반 핸들러로 처리하는 것이 설계 의도에 맞습니다.
문제 2: 조건식에서 컴파일 에러
증상: 멤버 함수를 조건식에서 호출하면 “const 객체에서 non-const 멤버 함수를 호출할 수 없다”는 에러가 나거나, 사후조건에서 매개변수를 참조할 때 에러가 남.
원인과 해결:
- 조건식 안의 멤버와 매개변수는 const로 취급됩니다. 검사용 멤버 함수(
is_open(),size()등)를const로 선언하세요. - 사후조건에서 참조하는 값 매개변수는
const로 선언해야 합니다. 본문에서 그 매개변수를 수정해야 한다면 사후조건 대신 시작 시 값을 저장해contract_assert로 검사하세요.
문제 3: old()나 불변식 문법을 쓰는 예제가 컴파일되지 않음
post(x == old(x) + 1), [[expects: ...]], [[ensures: ...]], 클래스 불변식 선언 같은 예제는 C++20 초안이나 채택되지 않은 제안의 문법입니다. C++26에서는 앞의 “이전 값(old)은 참조할 수 없다” 절의 우회법과 is_valid() const + post/contract_assert 패턴으로 바꿔야 합니다.
마무리
C++26 Contracts는 Design by Contract 중 사전조건·사후조건을 함수 선언에 올리고, 본문 안의 검사를 contract_assert로 표준화한 기능입니다. 채택된 설계는 의도적으로 최소 기능에 가깝습니다. 이전 값 참조, 클래스 불변식, 계약별 평가 의미 지정은 없고, 대신 무엇이 표준이고 무엇이 구현 정의인지를 분명히 나눠 두었습니다.
핵심 요약:
- pre: 호출자가 지켜야 할 조건, 선언에 드러남
- post: 함수가 정상 반환할 때 보장하는 조건, 반환값은
post(r: ...)로 참조 - contract_assert: 본문의 특정 지점에서 성립해야 하는 조건
- 평가 의미: ignore / observe / enforce / quick_enforce, 선택은 구현·빌드 설정
- 위반 핸들러:
handle_contract_violation으로 진단 방식을 통일 (교체 지원은 구현 정의) - 없는 것:
old(), 클래스 불변식, 가정(assume)으로서의 계약
도입 전략:
- 공개 API의 사전조건부터 시작 (기존 함수 첫머리
assert가 후보) - 중요한 함수에 반환값 기반 사후조건 추가
- 상태 변화 검사는 지역 복사 +
contract_assert로 - observe로 위반을 관찰한 뒤 enforce로 전환
다음 학습:
참고 자료:
자주 묻는 질문 (FAQ)
Q. observe 모드는 어떤 상황에서 쓰나요?
A. observe는 계약 위반을 감지하면 위반 핸들러를 호출하고, 핸들러가 정상 반환하면 프로그램을 계속 실행합니다. 이미 운영 중인 코드에 사전조건과 사후조건을 새로 추가할 때, 위반 시 곧바로 종료되는 enforce로 시작하면 서비스 장애로 이어질 수 있으므로 먼저 observe로 실제 위반이 어디서 얼마나 발생하는지 확인하는 용도로 적합합니다. 위반 로그가 정리되고 나면 enforce로 전환해 계약을 강제하는 순서로 도입합니다. 다만 위반 뒤에도 실행이 계속되므로, 위반 후의 코드가 정의되지 않은 동작에 빠질 수 있다는 점은 감안해야 합니다.
같이 보면 좋은 글
- noexcept로 C++ 이동 연산 최적화하기: 예외 계약 설계 패턴
- C++ 예외 처리, try-catch와 Error Code 중 뭐가 빠를까
- C++26 리플렉션 기초: ^^ 연산자, std::meta::info, template for로 멤버 순회하기
- C++26 표준 확정: 리플렉션·Contracts·std::execution 등 주요 변경점