C++의 Concept: 컴파일 타임에 깨지는 제약 조건을 잡자.
by
gg582 · 2026-06-29 06:37:45 · 47 views
Concept에 대해 이해하기
지난 시간에 우리는 C++의 템플릿에 대해 알아 보았다.
마지막에 간단하게 템플릿에 대해 알기 위해, 템플릿 함수를 만들었다.
그러나 제시된 템플릿 함수인 Max의 경우, 두 변수 간의 크기 비교가 항상 가능해야 한다는 맹점이 있었다.
그리고, 당장 비교하는 데에는 문제가 되지 않지만, 타입이 다른 두 변수를 비교할 시 반환값이 암묵적으로 더 큰 범위의 표현형을 가진 쪽을 변환되는 문제도 있다.
이것은 명시적으로 타입을 추론 가능한 코드에 비해 유지 보수 측면에서 불리하다.
따라서, 둘을 차례대로 Concept 직접 구현으로 비교 조건 소화, requires 절과 표준 라이브러리의 std::totally_ordered<T>를 이용한 실전적이고 안정적인 코딩, 이후 같은 방식으로 반환값의 암묵적 변환을 금지하기까지 하겠다.
컨셉트의 직접 구현
C++의 표준 라이브러리에는 어떤 값이 특정 타입으로 변환 가능한지 검사하는 std::convertible_to가 있다.
그리고 우리가 생각한 Max 함수 역시 C++적인 비교라는 개념으로 분해해 보면 비교 결과를 bool으로 환산 가능한지 묻게 된다.
따라서 아래와 같은 개념을 사용하게 된다.
#include <iostream>
// T 타입 객체 a, b에 대해 a > b 연산이 가능하고, 그 결과가 bool로 변환 가능한지 검사
template <typename T>
concept Comparable = requires(T a, T b) {
{ a > b } -> std::convertible_to<bool>;
};
// 직접 만든 Comparable 개념으로 제약 조건 적용
template <Comparable T>
T Max(T a, T b) {
return (a > b) ? a : b;
}
이렇게 비교에 대한 컨셉트가 적용된 템플릿 함수는 아래의 두 main을 가질 때 둘 중 하나일 때만 컴파일된다.
컴파일 실패하는 예시
// 비교 연산자가 없는 사용자 정의 구조체
struct Dummy {};
int main() {
Dummy d1, d2;
// Max(d1, d2);
// 컴파일 에러 발생: 'Comparable' 제약 조건을 만족하지 않음
}
이 Dummy라는 구조체는 속이 텅 빈 강정이고, 비교가 불가하기 때문에 컴파일이 안 된다.
그러나 정수를 비교하기 위해 아래와 같이 하면 컴파일이 된다.
정수 비교
int main() {
int x = Max(10, 20);
std::cout << "10과 20 중 더 큰 것은 " << x << "입니다." << std::endl;
}
이것이 컨셉트를 이용해서 컴파일되기 전에 진작 위험한 코드를 방어해 내는 패턴이다.
그리고 이런 자주 쓰이는 컨셉트는 C++를 뒤져 보면 웬만한 게 있다. 따라서 우리는 이런 것을 직접 작성할 필요가 없다.
표준 컨셉트를 이용한 구현
#include <iostream>
#include <concepts>
// T는 반드시 std::totally_ordered 조건을 만족해야 함
template <typename T>
requires std::totally_ordered<T>
T Max(T a, T b) {
return (a > b) ? a : b;
}
놀라울 정도로 짧아진 이 코드의 핵심은 std::totally_ordered에 있다. 이 조건은 모든 ordered 비교 조건 <. >, <=, >=가 성립하는지 충족하게 하는 검사이다. 이러한 사전 정의된 컨셉트를 이용하면, requires 절 뒤에 바로 표준 속의 조건만 걸어 주면 된다.
표준 컨셉트를 이용한 반환값의 암묵적 변환 금지
이 함수에서 반환값이 암묵적으로 변환된다는 뜻은 함수에 들어 오는 두 매개변수의 타입이 다르다는 것을 뜻한다.
가령 auto x = Max(10.2f, 20)라고 하면 타입은 암묵적으로 float로 확장된다. 그 까닭은 float는 실수부를 표현 가능하며, 표현 범위가 더 넓기 때문이다.
그러나, 만약 프로그래머가 x가 int x = 10;과 완전히 동일할 것으로 예상하고 비트 연산을 한다면 예상하지 못한 결과를 얻을 수 있다. 이러한 상황을 가정한 비교 조건인 std::same_as 역시 C++에 있다. C에서 모든 것을 직접 해야 했던 나의 경우 이것이 매우 놀랍게 느껴진다.
그리고 이 둘을 연결하기 위해서는 requires 절을 두 번 쓰는 것이 아니고, 조건을 연결해 주어야 한다. 그리고, 가능하면 더 짧은 조건 검사를 할 수 있게 고민해야 한다.
#include <iostream>
#include <concepts>
template <typename T1, typename T2>
// 두 조건은 모두 만족해야 하므로 logical and 연산인 &&를 쓴다.
// 또한 T1의 타입이 T2와 같다면 자연히 std::totally_ordered<T2> 역시 참이다.
// 따라서 이 조건에 대해서는 std::totally_ordered<T2>를 연산할 필요가 없다.
requires std::totally_ordered<T1> && std::same_as<T1, T2>
auto Max(T1 a, T2 b) {
return (a > b) ? a : b;
}
그리고 이 템플릿 함수 역시
int main() {
std::cout << Max(10, 20) << std::endl; // OK
// std::cout << Max(10, 12.5) << std::endl; // Error: 10은 int, 12.5는 float
}
로 다른 타입 간의 비교 후 암묵적 형변환을 원천 차단하게 된다.
어떻게 연습하면 좋을까?
단순한 데몬형 알림 서비스 등은 설정 파일 파싱, 잠들기, 깨어나서 버스에 신호 보내기 등을 하기 때문에, 에러 자료형을 정의할 때 빼고는 크게 쓸 일이 없다. 그러나, ML 등을 연습하게 될 때는 이런 자료형에 대한 문제를 지루하게 방치할 수도 없는 노릇이다.
따라서, CLI로 계산기 앱을 만들어 보면서 연습하는 것도 좋을 것이다.
단계별로 진도를 쭉쭉 뺄 줄 알았더니, 계산기 앱이라니 무슨 소리냐고 생각할 수 있겠지만, 우리는 간단한 사칙 연산, 그리고 괄호 파싱, 형 변환 기능 추가를 통해서 ./cppcal a=10;b=10.2;c=a+b;print c는 해석에 실패하고, ./cppcal a=float(10);b=10.2;c=a+b;print c는 해석할 수 있는 계산기를 만들 것이다.
그리고, 이 앱을 만드는 과정에서 필요한 개념들을 Rainer Grimm의 C++20 교재를 참고해서 추려 볼 것이다.