?:의 결과 타입은 무엇일까? C++Now 2026 Lightning Talk 정리
흔히 삼항 연산자라고 부르지만 표준에서의 이름은 **조건 표현식(conditional expression)**이다. cond ? a : b는 cond가 참이면 a를, 거짓이면 b를 평가한다. 여기까지는 누구나 안다. 문제는 결과의 타입이 무엇이냐다.
Jonathan Müller가 C++Now 2026에서 5분짜리 라이트닝 토크로 이 질문을 퀴즈 형식으로 던졌다. 슬라이드에는 식이 나오고, 청중이 타입을 맞히면 답이 공개된다. 이 글은 발표의 흐름을 따라가되, 모든 예제를 실제로 컴파일해서 확인한 결과로 정리한다.
- 발표: Lightning Talk: ?: - Jonathan Müller - C++Now 2026
- 검증 환경: Apple clang 21,
-std=c++17,c++20,c++23. 아래static_assert는 모두 통과했다. GCC와 MSVC에서는 확인하지 않았다.
아래 예제는 공통으로 다음 도우미를 쓴다. cond는 컴파일 타임에 알 수 없는 bool이다.
#include <type_traits>
template <class T, class U>
inline constexpr bool same = std::is_same_v<T, U>;
bool cond = argc > 1; // 런타임 값먼저 용어 정리: promotion과 conversion
이 글에서 가장 자주 나오는 단어가 promotion(승격)과 conversion(변환)이다. 둘의 관계를 먼저 잡아 두면 이후 예제가 훨씬 쉽게 읽힌다. promotion은 conversion의 한 종류이고, 값이 절대 바뀌지 않는다는 점이 다르다.
Implicit conversion (암묵적 변환)
├─ Standard conversion (표준 변환)
│ ├─ Exact match 계열 (lvalue→rvalue, 배열→포인터, const 추가 등)
│ ├─ Promotion ← integral promotion, floating-point promotion
│ └─ Conversion ← 정수↔정수, 정수↔부동소수점, 포인터 변환, bool 변환 등
└─ User-defined conversion (변환 생성자, operator T())
| Promotion | 일반 Conversion | |
|---|---|---|
| 값 보존 | 항상 보존 | 바뀔 수 있음 (잘림, 부호 변경, 정밀도 손실) |
| 방향 | 작은 타입 → int(또는 unsigned), float → double | 임의 방향 |
| 예 | short → int, char → int, bool → int, 열거형 → int | int → unsigned, int → short, double → int |
| overload resolution 순위 | Promotion (중간) | Conversion (낮음) |
overload resolution의 순위는 Exact Match > Promotion > Conversion이다. f(int)와 f(long)이 있을 때 short 인자가 f(int)로 가는 이유다.
?:가 두 피연산자의 타입을 맞출 때는 이항 연산자와 같은 usual arithmetic conversions를 쓴다. 이 절차는 두 단계다.
- 양쪽에 integral promotion을 적용한다.
- 그래도 타입이 다르면 일반 conversion으로 공통 타입을 찾는다. (rank가 높은 쪽, unsigned 우선 등)
즉 안전한 1단계(promotion) 뒤에 값이 바뀔 수 있는 2단계(conversion)가 따라온다. 이 글의 함정은 대부분 2단계에서 생긴다. 이 틀을 기억하고 예제를 보자.
1. 정수
static_assert(same<decltype(cond ? -1 : 1), int>);
static_assert(same<decltype(cond ? -1 : 87654321), int>);
static_assert(same<decltype(cond ? -1 : 0x87654321), unsigned int>); // 함정앞의 두 개는 int다. 세 번째가 문제다. 결과가 unsigned int다.
원인은 리터럴 타입 규칙이다.
| 리터럴 | 후보 타입 (들어가는 첫 번째) |
|---|---|
10진 (87654321) | int, long, long long |
16진 (0x87654321) | int, unsigned int, long, unsigned long, … |
10진 리터럴은 부호 있는 타입만 후보지만, 16진 리터럴은 unsigned도 후보다. 0x87654321은 32비트 int에 들어가지 않으므로 unsigned int가 된다. 이후 usual arithmetic conversions를 적용한다. 1단계(promotion)에서는 int와 unsigned int 모두 이미 int 이상이라 바뀌는 것이 없다. 2단계(conversion)에서 int와 unsigned가 만나면 unsigned가 되고, 이때 -1이 4294967295로 바뀐다. promotion이 아니라 부호가 바뀌는 conversion이 일으키는 함정이다.
std::printf("%u\n", (unsigned)(true ? -1 : 0x87654321)); // 4294967295발표에서는 청중이 “int가 16비트라면?”이라고 묻는 장면이 있었다. 이 경우 후보 타입의 폭이 달라지므로 위 표에서 결과가 달라질 수 있다. 16비트 int 환경은 직접 확인하지 못했다.
승격은 타입이 다를 때만 일어난다
발표 슬라이드의 규칙은 “정수는 먼저 integral promotion을 하고 공통 타입을 찾는다”이다. 위에서 정리한 usual arithmetic conversions의 두 단계와 같은 내용이다. 그런데 직접 확인해 보니 두 피연산자가 같은 타입이면 이 절차 자체가 적용되지 않고, 그 타입이 그대로 결과다.
// 같은 타입: 승격 없이 그대로
static_assert(same<decltype(cond ? (unsigned char)1 : (unsigned char)2), unsigned char>);
// 다른 타입: 승격 후 공통 타입 -> int
static_assert(same<decltype(cond ? (short)1 : (char)2), int>);
static_assert(same<decltype(cond ? (unsigned char)1 : (signed char)2), int>);변수는 한 단계 더 있다. 같은 타입의 lvalue끼리면 결과가 참조다.
uint8_t x = 1, y = 2;
static_assert(same<decltype(cond ? x : y), uint8_t&>); // 같은 타입 lvalue
static_assert(same<decltype(cond ? x : (uint16_t)2), int>); // 타입이 다르면 int따라서 “작은 정수는 ?:를 거치면 int가 된다”는 말은 타입이 서로 다를 때로 한정해야 정확하다.
2. 열거형
enum A { a1, a2 };
enum B { b1 };
enum class S1 { x };
static_assert(same<decltype(cond ? a1 : a2), A>); // 같은 enum
static_assert(same<decltype(cond ? S1::x : S1::x), S1>); // scoped도 마찬가지
static_assert(same<decltype(cond ? a1 : b1), int>); // 다른 enum
static_assert(same<decltype(cond ? a1 : 0), int>); // enum과 정수규칙은 단순하다. 양쪽이 같은 enum 타입이면 그 타입이 결과다. 그렇지 않으면 usual arithmetic conversions와 integral promotion을 거쳐 underlying type 쪽(여기서는 int)이 된다.
참고로 서로 다른 enum을 ?:로 비교하는 코드는 C++20부터 deprecated다. clang은 -Wdeprecated-enum-compare-conditional 경고를 낸다. 위 예제도 -std=c++20 이상에서 컴파일하면 이 경고가 나온다. scoped enum끼리 다른 타입을 섞는 것은 변환 규칙이 없어서 아예 컴파일되지 않는다.
3. 클래스
타입 안전을 위해 정수 상수를 클래스로 감싸는 패턴이 있다. 발표에서는 constant<a1> 같은 형태가 나왔고, 여기서는 이를 다음과 같이 재구성했다.
template <auto V>
struct constant {
constexpr operator decltype(V)() const { return V; } // A로 변환
};
static_assert(same<decltype(cond ? constant<a1>{} : constant<a1>{}), constant<a1>>);
static_assert(same<decltype(cond ? constant<a1>{} : constant<a2>{}), int>);같은 타입끼리면 그 타입이 결과다. 다른 constant<>끼리는 둘 다 A로 변환되니 결과가 A일 것 같지만, **int**가 나왔다.
발표자의 설명은 이렇다. 피연산자 중 클래스 타입이 있으면 overload resolution으로 변환 대상의 후보 집합을 정한다. constant<a1>과 constant<a2>는 둘 다 A로 변환되고, A는 다시 int로 변환될 수 있다. 후보 중에서는 int 쪽이 더 낫다고 판정되어 결과가 int다. 변환 경로를 위의 분류로 보면 constant<a1> → A는 사용자 정의 변환(conversion operator)이고, A → int는 표준 변환 중 promotion이다. 같은 사용자 정의 변환 뒤에 붙는 두 번째 표준 변환을 비교하면 promotion이 순위에서 유리하다는 것이 발표자의 설명이다. 어떤 변환 후보가 어떤 순위로 비교되는지는 표준의 해당 절([expr.cond])을 직접 확인하지 않았다. 이 글에서는 컴파일 결과(int)만 검증했다.
타입 안전을 위해 만든 래퍼가 ?:를 거치면 결과 타입 정보가 사라진다는 점이 실무에서 의미가 있다.
4. 참조
int i = 0, j = 0;
const int ci = 0;
static_assert(same<decltype(cond ? i : j), int&>);
static_assert(same<decltype(cond ? i : ci), const int&>); // const가 전파
static_assert(same<decltype(cond ? std::move(i) : std::move(j)), int&&>);
static_assert(same<decltype(cond ? std::move(i) : std::move(ci)), const int&&>);양쪽이 같은 값 범주(lvalue끼리 또는 xvalue끼리)의 같은 타입이면 결과도 그 참조 타입이다. 한쪽이 const면 결과도 const가 된다. 발표 슬라이드의 설명과 일치한다.
값 범주가 섞이거나 prvalue가 끼면 참조가 사라지고 값이 된다.
static_assert(same<decltype(cond ? i : std::move(j)), int>); // lvalue + xvalue
static_assert(same<decltype(cond ? i : 1), int>); // lvalue + prvaluesame-ish: 기반 클래스
슬라이드에는 “same-ish 타입”이라는 표현이 있다. 기반 클래스와 파생 클래스를 섞는 경우를 가리킨다고 발표자가 설명했다.
struct Base {};
struct Derived : Base {};
Base b;
Derived d;
const Base cb{};
static_assert(same<decltype(cond ? d : b), Base&>); // 파생 -> 기반
static_assert(same<decltype(cond ? d : cb), const Base&>); // const 전파파생 클래스 쪽이 기반 클래스로 변환되어 결과가 Base&다. 발표에서 청중이 “표준에 same-ish라는 말은 없다”고 지적하자 발표자는 표준 문구가 슬라이드에 안 들어간다고 답했다.
주의할 점이 하나 있다. const Derived와 non-const Base를 섞으면 컴파일 에러였다.
const Derived cd{};
Base b;
// auto&& r = cond ? cd : b; // error: incompatible operand typesconst Derived에서 non-const Base로의 변환은 const를 버려야 해서 불가능하고, 반대 방향(Base → const Derived)은 기반에서 파생으로의 변환이라 불가능하다. 양방향이 모두 막혀서 컴파일되지 않는다. 이 이유 설명은 컴파일 에러 메시지를 보고 추론한 것이고, 표준 문구로 확인하지는 않았다.
규칙 요약
| 피연산자 | 결과 타입 | 예 |
|---|---|---|
| 같은 타입의 lvalue | T& | cond ? x : y (uint8_t&) |
| 같은 타입의 prvalue | T (승격 없음) | unsigned char끼리는 unsigned char |
| 서로 다른 정수 타입 | 승격 후 공통 타입 | short, char → int |
| 10진 리터럴과 부호 있는 정수 | int 이상 | cond ? -1 : 87654321 → int |
16진 리터럴(int에 안 들어가는 값)과 -1 | unsigned | cond ? -1 : 0x87654321 |
| 같은 enum | 그 enum | cond ? a1 : a2 → A |
| 다른 enum, enum과 정수 | int | cond ? a1 : 0 |
| 변환 가능한 클래스들 | 변환 후보 중 선택 (int가 될 수 있음) | constant<a1> / constant<a2> → int |
| 같은 계열 lvalue 참조 | const가 전파된 lvalue 참조 | int&, const int& → const int& |
| 같은 계열 xvalue(rvalue 참조) | const가 전파된 rvalue 참조 | int&&, const int&& → const int&& |
| 값 범주가 섞인 참조, prvalue 포함 | 값 | cond ? i : 1 → int |
Takeaway
발표의 마지막 결론은 한 문장이었다. 다른 타입이면 ?:를 쓰지 말자. 퀴즈에서 청중 대부분이 틀린 것이 증거다. 규칙을 외우기보다 규칙에 의존하지 않는 코드를 쓰는 것이 목표다.
실무에서는 이렇게 옮길 수 있다.
- 위험한 쪽은 promotion이 아니라 그 뒤의 conversion이다. 값이 보존되는 promotion은 대개 문제가 되지 않고, 부호가 바뀌는
int→unsigned같은 conversion에서 값이 달라진다. - 양쪽 타입을 맞춘다. 한쪽에
static_cast<T>를 붙이거나 두 값 모두 같은 타입의 변수에 담아서 쓴다. - 부호 있는 값과 16진 상수를 섞지 않는다. 비트마스크나 레지스터 값처럼 16진 상수를 쓰는 코드에서
cond ? -1 : 0x...는unsigned로 바뀌는 대표적인 함정이다. - 타입이 다르면
if/else로 푼다. 결과를 변수에 대입해야 한다면 변수를 먼저 선언하고 분기에서 대입한다. - 래퍼 타입을
?:로 섞지 않는다.constant<>처럼 변환 가능한 클래스를 섞으면int로 풀릴 수 있다.
컴파일러 경고가 이런 문제를 막아 줄지도 확인했다. unsigned u = c ? -1 : 0x87654321; 한 줄을 -Wall -Wsign-conversion -Wconversion으로 컴파일했을 때 clang은 경고를 내지 않았다. 예제가 이 한 줄뿐이라 일반화할 수는 없지만, 경고에만 의존하면 안 된다는 신호다.
출처
- 발표: Lightning Talk: ?: - Jonathan Müller - C++Now 2026 (CppNow 채널)
- 규칙의 정확한 문구는 C++ 표준의 [expr.cond]를 확인하자.