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: 언제 무엇을 쓰나

구분autodecltype / 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++11auto f(T a, U b) -> decltype(a + b)후행 반환으로 decltype에 의존
C++14auto f(T a, U b) { return a + b; }반환형 auto 추론
C++14decltype(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와 참조를 유지합니다.


같이 보면 좋은 글