C++ decltype: auto와 다른 타입 추론 규칙, decltype(auto), 괄호 하나로 바뀌는 결과
이 글의 핵심
decltype(x)와 decltype((x))가 서로 다른 타입을 내놓는 것처럼, decltype은 이름과 표현식을 구분해 처리하기 때문에 규칙을 모르면 의도치 않은 참조를 반환하게 됩니다. C++11에서 C++14로 반환 타입 추론이 어떻게 바뀌었는지, Concepts 이후 SFINAE 대신 쓸 방법, 제네릭 래퍼 패턴까지 다룹니다.
decltype이란?
decltype 은 표현식의 타입을 추출하는 C++11 키워드입니다. auto와 달리 const와 참조를 유지합니다.
int x = 10;
decltype(x) y = 20; // int y = 20;
const int& ref = x;
decltype(ref) z = x; // const int& z = x;
왜 필요한가?:
- 정확한 타입: const, 참조 유지
- 템플릿: 반환 타입 추론
- 타입 안전: 컴파일 타임 타입 체크
- 제네릭 코드: 타입 독립적 코드
// ❌ auto: const, 참조 제거
const int& ref = x;
auto a = ref; // int (const, 참조 제거)
// ✅ decltype: 정확한 타입
decltype(ref) b = ref; // const int& (그대로 유지)
decltype의 동작 원리:
decltype은 컴파일 타임에 타입을 추론합니다. 표현식을 평가하지 않으며, 타입만 추출합니다.
int func() {
std::cout << "호출됨\n";
return 42;
}
decltype(func()) x; // int x; (func() 호출 안 됨!)
decltype의 괄호 안은 sizeof나 noexcept처럼 평가되지 않는 피연산자(unevaluated operand)입니다. 그래서 func()는 실제로 호출되지 않고, 선언만 있고 정의가 없는 함수도 쓸 수 있습니다. 뒤에 나오는 std::declval<T>()가 정의 없이 선언만 존재해도 동작하는 이유가 이것입니다. 다만 “평가하지 않는다”는 것이 “컴파일하지 않는다”는 뜻은 아닙니다. 표현식은 문법적으로 올바르고 타입 검사를 통과해야 하며, 그 과정에서 필요한 템플릿이 인스턴스화되어 그 안의 static_assert가 터질 수도 있습니다.
decltype 규칙:
| 표현식 | 타입 | 예시 |
|---|---|---|
| 변수명 | 선언된 타입 | decltype(x) → int |
| 표현식 (괄호) | 값 카테고리에 따라 | decltype((x)) → int& |
| 함수 호출 | 반환 타입 | decltype(func()) → int |
| 연산자 | 결과 타입 | decltype(x + y) → double |
int x = 10;
// 변수명: 선언된 타입
decltype(x) a; // int
// 표현식 (괄호): lvalue → 참조
decltype((x)) b = x; // int&
// 함수 호출: 반환 타입
int func();
decltype(func()) c; // int
// 연산자: 결과 타입
decltype(x + 1.0) d; // double
표의 규칙을 표준 문구에 가깝게 정리하면 두 갈래입니다. 첫째, 괄호로 감싸지 않은 변수 이름이나 멤버 접근(x, obj.member)이면 그 개체가 선언된 타입을 그대로 돌려줍니다. 둘째, 그 외 모든 표현식은 값 카테고리로 결정됩니다. lvalue면 T&, xvalue(std::move(x) 같은 것)면 T&&, prvalue(임시 값, x + 1)면 T입니다. (x)는 괄호 때문에 “이름”이 아니라 “lvalue 표현식”이 되어 int&가 나오고, *ptr이나 vec[0]처럼 lvalue를 돌려주는 연산도 같은 이유로 참조가 됩니다. 이 두 갈래만 기억하면 아래의 모든 예제를 설명할 수 있습니다.
auto vs decltype
int x = 10;
const int& ref = x;
auto a = ref; // int (const, 참조 제거)
decltype(ref) b = ref; // const int& (그대로 유지)
기본 사용법
int x = 10;
double y = 3.14;
// 변수 타입 추출
decltype(x) a = 20; // int
decltype(y) b = 2.71; // double
decltype(x + y) c = 0; // double
// 함수 반환 타입
int func() { return 42; }
decltype(func()) result = func(); // int
반환 타입 추론
// C++11: 후행 반환 타입
template<typename T, typename U>
auto add(T a, U b) -> decltype(a + b) {
return a + b;
}
// C++14: 자동 추론
template<typename T, typename U>
auto multiply(T a, U b) {
return a * b;
}
int main() {
auto result1 = add(10, 3.14); // double
auto result2 = multiply(5, 2.5); // double
}
C++11의 후행 반환 타입이 필요했던 이유는 선언 순서 때문입니다. decltype(a + b) add(T a, U b)처럼 앞쪽에 쓰면 반환 타입을 읽는 시점에 매개변수 a, b가 아직 선언되지 않아 컴파일 에러가 납니다. auto ... -> decltype(a + b)는 매개변수 목록 뒤로 반환 타입을 옮겨 이 문제를 해결합니다. C++14의 auto 반환은 더 간단하지만 본문을 봐야 타입이 정해지므로, 헤더에 선언만 두고 다른 파일에서 정의하는 식으로는 쓸 수 없고, 반환문이 여러 개면 모두 같은 타입이어야 합니다(return 1;과 return 2.0;을 섞으면 에러). 또 auto 반환은 템플릿 인자 추론 규칙을 따라 참조와 최상위 const를 떼므로, 컨테이너 원소의 참조를 돌려주려면 아래 예시처럼 decltype이 필요합니다.
실전 예시
예시 1: 템플릿 함수
template<typename Container>
auto getFirst(Container& c) -> decltype(c[0]) {
return c[0];
}
int main() {
vector<int> vec = {1, 2, 3};
auto& first = getFirst(vec); // int&
first = 10;
cout << vec[0] << endl; // 10
}
예시 2: 완벽한 전달
template<typename T>
auto forward_value(T&& value) -> decltype(std::forward<T>(value)) {
return std::forward<T>(value);
}
void process(int& x) {
cout << "lvalue: " << x << endl;
}
void process(int&& x) {
cout << "rvalue: " << x << endl;
}
int main() {
int x = 10;
process(forward_value(x)); // lvalue
process(forward_value(20)); // rvalue
}
예시 3: 타입 안전 매크로
#define MAX(a, b) ((a) > (b) ? (a) : (b))
// 문제: 타입 불일치
auto result1 = MAX(10, 3.14); // double
// decltype으로 해결
template<typename T, typename U>
auto max_value(T a, U b) -> decltype(a > b ? a : b) {
return a > b ? a : b;
}
auto result2 = max_value(10, 3.14); // double
이 예제에는 숨은 버그가 있습니다. T와 U가 다르면(int와 double) 조건 연산자의 결과가 공통 타입으로 변환된 prvalue라 decltype이 double을 주지만, 두 인자의 타입이 같으면 a > b ? a : b는 lvalue가 되어 decltype이 int&를 줍니다. 그러면 max_value(3, 5)는 값으로 받은 지역 매개변수에 대한 참조를 반환해 함수가 끝나는 순간 댕글링 참조가 됩니다. 컴파일러는 경고(-Wreturn-local-addr 등)를 낼 수도 있고 못 낼 수도 있어, 저는 이런 “타입이 같을 때만 터지는” 버그를 테스트에서 놓치기 쉽다고 봅니다. 값을 돌려주는 것이 목적이라면 -> std::common_type_t<T, U>나 C++14의 그냥 auto 반환을 쓰는 것이 맞습니다.
예시 4: 컨테이너 래퍼
template<typename Container>
class Wrapper {
private:
Container& container;
public:
explicit Wrapper(Container& c) : container(c) {}
auto operator[](std::size_t index) -> decltype(container[index]) {
return container[index];
}
auto size() const -> decltype(container.size()) {
return container.size();
}
};
int main() {
std::vector<int> vec = {1, 2, 3};
Wrapper<std::vector<int>> wrapper(vec);
wrapper[0] = 10;
std::cout << vec[0] << '\n';
}
decltype(auto) (C++14)
// decltype(auto): 초기화/반환 표현식에 대해 decltype 규칙으로 타입을 맞춤
int x = 10;
int& getRef() { return x; }
auto a = getRef(); // int (참조 제거)
decltype(auto) b = getRef(); // int& (참조 유지)
// 함수 반환: return 표현식의 정확한 타입을 유지
template<typename T>
decltype(auto) forward_return(T&& value) {
return std::forward<T>(value);
}
주의: decltype(auto) x = expr;에서 expr가 임시(prvalue)이면 참조가 붙지 않습니다. decltype(auto)는 “자동으로 참조를 붙인다”가 아니라 “decltype 규칙을 auto로 적용한다”에 가깝습니다.
decltype(auto) 반환에서 가장 위험한 한 글자는 괄호입니다.
decltype(auto) f1() { int x = 0; return x; } // int (이름 → 선언 타입)
decltype(auto) f2() { int x = 0; return (x); } // int& (lvalue 표현식) → 댕글링!
return x;와 return (x);는 보통 함수에서는 완전히 같은 의미지만, decltype(auto) 반환에서는 두 번째가 지역 변수에 대한 참조를 반환해 미정의 동작이 됩니다. 스타일상 괄호를 습관처럼 붙이는 코드베이스에서 decltype(auto)를 도입할 때 실제로 이런 버그가 들어옵니다. 그래서 decltype(auto)는 forward_return처럼 다른 함수의 반환을 그대로 전달하는 래퍼에 한정해 쓰고, 일반 함수는 auto를 기본으로 두는 것이 안전합니다.
decltype vs auto: 언제 무엇을 쓰나
| 구분 | auto | decltype / decltype(auto) |
|---|---|---|
| 초기화 우변에서 | 값 복사 위주(참조·const 종종 제거) | 표현식의 정확한 타입 |
| 변수 선언 | auto x = expr; | decltype(expr) x = expr; 등 |
| 반환 타입 | C++14부터 auto 함수 반환 추론 | 후행 반환 -> decltype(...) 또는 decltype(auto) |
| 용도 | 간결함, 구현 캡슐화 | 참조·const 유지, SFINAE, 타입 추출 |
실무 규칙(대략): “그냥 지역 변수는 auto”, “원본과 참조·const 관계를 유지해야 하면 decltype 또는 decltype(auto)”입니다.
반환 타입 추론의 진화 (C++11 → C++14)
| 표준 | 예시 | 비고 |
|---|---|---|
| C++11 | auto f(T a, U b) -> decltype(a + b) | 후행 반환으로 decltype에 의존 |
| C++14 | auto f(T a, U b) { return a + b; } | 반환형 auto 추론 |
| C++14 | decltype(auto) f(T&& x) { return x; } | 반환 표현식에 decltype 규칙 적용 |
decltype만으로는 함수 본문이 없을 때 반환 타입을 말해 줄 수 있어, C++11 템플릿에서 특히 유용했습니다. C++14 이후에는 decltype(auto)로 “반환을 그대로”를 더 짧게 쓸 수 있습니다.
SFINAE와 decltype
decltype은 표현식이 유효한지를 컴파일 타임에 검사하는 데 쓰일 수 있어, 오버로드 집합에서 후보를 걸러내는 SFINAE와 잘 맞습니다.
후행 반환 + 쉼표 연산자
decltype 안에서 쉼표 연산자를 쓰면, 왼쪽 표현식이 형식적으로 유효해야 전체 decltype이 성공합니다. 따라서 “size()가 있는 타입만” 같은 조건을 오버로드 후보를 걸러내는 데 씁니다.
template<typename T>
auto process(T value) -> decltype(value.size(), void()) {
// value.size() 가 있을 때만 이 오버로드가 선택됨
}
(실제 코드에서는 반환형을 void로 두고 본문을 채우면 됩니다.)
C++17 이후에는 std::void_t<decltype(std::declval<T>().size())> 같은 void_t 특수화로 같은 조건을 더 구조적으로 나누기도 합니다.
std::declval과 조합
객체가 없어도 가상의 값으로 표현식 타입을 검사합니다.
template<typename T, typename U>
using SumResult = decltype(std::declval<T>() + std::declval<U>());
SumResult<int, double>는 double이 됩니다. 컴파일 실패 시 해당 타입 조합에 operator+가 없다는 뜻입니다.
std::declval<T>()는 T&&(즉 xvalue)를 반환하는 것으로 선언만 된 함수라서, 기본 생성자가 없는 타입도 “그 타입의 값이 있다고 치고” 표현식을 만들어 볼 수 있습니다. 결과가 rvalue라는 점은 주의해야 하는데, decltype(std::declval<T>().foo())는 rvalue 객체에 대한 호출을 검사하므로 &로 한정된 멤버 함수(void foo() &;)는 없는 것으로 판정됩니다. lvalue 객체로 검사하고 싶다면 std::declval<T&>()를 씁니다. 또 평가되는 문맥에서 declval을 호출하면 정의가 없어 static_assert로 컴파일 에러가 나도록 되어 있습니다.
Concepts 이후
C++20 Concepts가 있으면 같은 조건을 더 읽기 쉽게 쓸 수 있지만, 기존 코드베이스와 라이브러리에서는 decltype 기반 SFINAE가 여전히 흔합니다.
// C++20: 같은 조건을 requires 식으로
template<typename T>
requires requires(T v) { v.size(); }
void process(T value) { /* ... */ }
decltype 기반 SFINAE의 가장 큰 단점은 에러 메시지입니다. 조건을 만족하는 오버로드가 하나도 없으면 컴파일러는 “no matching function”과 함께 각 후보가 왜 탈락했는지를 대체 실패 메시지로 길게 나열하는데, decltype(value.size(), void())가 실패했다는 사실을 이 메시지에서 읽어 내기가 쉽지 않습니다. requires 절을 쓰면 “제약 v.size()를 만족하지 않음”처럼 조건이 이름으로 드러납니다. 또 SFINAE는 즉시 문맥(immediate context)에서의 실패만 후보 탈락으로 처리하므로, decltype 안에서 호출한 함수 템플릿의 본문에서 생긴 에러는 탈락이 아니라 하드 에러가 됩니다. “SFINAE를 걸었는데 컴파일 에러가 그대로 난다”는 질문 대부분이 이 경우입니다.
decltype 규칙
int x = 10;
// 1. 변수명: 선언된 타입
decltype(x) a; // int
// 2. 표현식: 값 카테고리에 따라
decltype((x)) b = x; // int& (lvalue 표현식)
decltype(x + 1) c; // int (prvalue)
// 3. 함수 호출: 반환 타입
int func();
decltype(func()) d; // int
자주 발생하는 문제
문제 1: 괄호 주의
int x = 10;
// ❌ 괄호 하나 더
decltype((x)) y = x; // int& (참조!)
y = 20;
cout << x << endl; // 20
// ✅ 괄호 없이
decltype(x) z = x; // int (복사)
z = 30;
cout << x << endl; // 20
문제 2: 초기화되지 않은 변수
// ❌ 초기화 필요
decltype(10) x; // int x; (초기화 안 됨)
cout << x << endl; // 쓰레기 값
// ✅ 초기화
decltype(10) y = 0;
문제 3: 복잡한 표현식
int x = 10;
int* ptr = &x;
// 복잡한 타입
decltype(*ptr) a = x; // int& (역참조는 lvalue)
decltype(ptr[0]) b = x; // int& (배열 접근은 lvalue)
decltype vs auto
int x = 10;
const int& ref = x;
// auto: const, 참조 제거
auto a = ref; // int
// decltype: 정확한 타입
decltype(ref) b = ref; // const int&
// decltype(auto): 표현식의 정확한 타입
decltype(auto) c = ref; // const int&
실무에서 decltype을 직접 쓸 일은 생각보다 드뭅니다. 지역 변수는 auto(원본을 수정하거나 복사를 피할 때는 auto&, const auto&)로 충분하고, decltype이 꼭 필요한 곳은 템플릿에서 “어떤 표현식의 결과 타입”을 이름 붙여야 할 때(반환 타입, 타입 별칭, SFINAE 조건)입니다. 반대로 decltype(expr) y = expr;처럼 같은 식을 두 번 쓰는 코드는 식이 바뀔 때 한쪽만 고치는 실수를 부르므로, 이런 경우는 decltype(auto) y = expr;로 줄이는 편이 낫습니다.
실용 예시
예시 1: 제네릭 람다
// C++14: 제네릭 람다는 보통 auto 매개변수로 충분
auto lambda = [](auto x, auto y) { return x + y; };
std::cout << lambda(10, 3.14) << '\n';
// 후행 반환을 명시할 때는 decltype으로 연산 결과 타입을 고정
auto lambda2 = [](auto x, auto y) -> decltype(x + y) { return x + y; };
예시 2: SFINAE
#include <type_traits>
template<typename T>
auto process(T value) -> decltype(value.size(), void()) {
cout << "컨테이너: " << value.size() << endl;
}
template<typename T>
auto process(T value) -> decltype(value + 0, void()) {
cout << "숫자: " << value << endl;
}
int main() {
process(vector<int>{1, 2, 3}); // 컨테이너: 3
process(42); // 숫자: 42
}
예시 3: 타입 추출
template<typename T>
class TypeInfo {
public:
using value_type = T;
using reference = T&;
using const_reference = const T&;
using pointer = T*;
// decltype으로 멤버 타입 추출
template<typename U>
using result_type = decltype(std::declval<T>() + std::declval<U>());
};
int main() {
TypeInfo<int>::value_type x = 10;
TypeInfo<int>::reference ref = x;
TypeInfo<int>::result_type<double> result = 3.14;
}
실무 패턴
패턴 1: 제네릭 래퍼
template<typename Container>
class ContainerWrapper {
Container& container_;
public:
explicit ContainerWrapper(Container& c) : container_(c) {}
auto operator[](std::size_t index) -> decltype(container_[index]) {
return container_[index];
}
auto size() const -> decltype(container_.size()) {
return container_.size();
}
};
// 사용
std::vector<int> vec = {1, 2, 3};
ContainerWrapper<std::vector<int>> wrapper(vec);
wrapper[0] = 10; // 참조로 반환되어 수정 가능
std::cout << vec[0] << '\n'; // 10
패턴 2: 타입 안전 팩토리
template<typename T, typename... Args>
auto makeObject(Args&&... args) -> decltype(T(std::forward<Args>(args)...)) {
return T(std::forward<Args>(args)...);
}
// 사용
auto obj1 = makeObject<std::string>("Hello");
auto obj2 = makeObject<std::vector<int>>(10, 42);
패턴 3: 조건부 반환 타입
template<typename T>
auto getValue(T& container, size_t index)
-> decltype(container[index])
{
if (index < container.size()) {
return container[index];
}
throw std::out_of_range("Index out of range");
}
// 사용
std::vector<int> vec = {1, 2, 3};
auto& value = getValue(vec, 0); // int&
value = 10;
FAQ
Q1: decltype은 언제 사용하나요?
A:
- 정확한 타입 필요: const, 참조 유지
- 템플릿 반환 타입: 타입 독립적 코드
- 참조 유지: 원본 수정 가능
template<typename T>
auto getFirst(T& container) -> decltype(container[0]) {
return container[0];
}
Q2: auto vs decltype?
A:
- auto: 간결, const/참조 제거
- decltype: 정확한 타입 유지
const int& ref = x;
auto a = ref; // int (const, 참조 제거)
decltype(ref) b = ref; // const int& (그대로 유지)
Q3: decltype(auto)는 무엇인가요?
A: C++14에서 도입된 기능으로, 표현식의 정확한 타입을 자동 추론합니다.
int& getRef();
auto a = getRef(); // int (참조 제거)
decltype(auto) b = getRef(); // int& (참조 유지)
Q4: 괄호의 의미는?
A:
- decltype(x): 변수 타입
- decltype((x)): 표현식 타입 (참조)
int x = 10;
decltype(x) a; // int
decltype((x)) b = x; // int& (참조!)
Q5: 성능은?
A: 컴파일 타임에 타입을 추론하므로 런타임 오버헤드가 없습니다.
decltype(func()) x; // func() 호출 안 됨!
Q6: decltype은 표현식을 평가하나요?
A: 아니요. decltype은 타입만 추출하며, 표현식을 평가하지 않습니다.
int func() {
std::cout << "호출됨\n";
return 42;
}
decltype(func()) x; // "호출됨" 출력 안 됨
Q7: 템플릿에서 decltype 사용법은?
A: 후행 반환 타입으로 사용합니다.
// C++11: 후행 반환 타입
template<typename T, typename U>
auto add(T a, U b) -> decltype(a + b) {
return a + b;
}
// C++14: decltype(auto)
template<typename T, typename U>
decltype(auto) add(T a, U b) {
return a + b;
}
Q8: decltype 학습 리소스는?
A:
- “Effective Modern C++” by Scott Meyers (Item 3)
- cppreference.com - decltype
- “C++ Templates: The Complete Guide” by Vandevoorde & Josuttis
관련 글: auto, decltype(auto), trailing-return-type.
decltype은 표현식의 타입을 추출하는 C++11 키워드로, const와 참조를 유지합니다.
같이 보면 좋은 글
- C++ auto와 decltype: 타입 추론 규칙, decltype(auto), AAA 스타일
- C++ auto 타입 추론 에러
- C++ async & launch
- C++ atomic: 락 없는 카운터, 메모리 순서, 언제 mutex 대신 쓰나