CodeGym /행동 /C++ SELF /타입 추론과 동작하지 않는 경우들

타입 추론과 동작하지 않는 경우들

C++ SELF
레벨 55, 레슨 3
사용 가능

1. 타입 추론: 무엇이고 왜 필요한가

템플릿을 처음 보는 사람의 관점에서 보면 자연스러운 생각이 듭니다: «나는 template를 배웠는데 이제 여기저기 <int>를 쓰려고 배운 게 아니다». 맞습니다: 대부분의 경우 컴파일러는 실제로 호출 인자로부터 스스로 타입을 추론하여 T를 결정합니다.

하지만 컴파일러가 어쩔 수 없이 "어떤 T를 원하는지 모르겠다"라고 포기하는 상황도 있습니다. 바로 그때가 전형적인 골치거리의 시작입니다: 템플릿은 간단해 보이는데 오류 메시지는 10페이지 분량 소설처럼 보일 수 있습니다.

기술적으로는 이것을 template argument deduction — 템플릿 인자 추론이라 부릅니다. 표준 작업 그룹 보고서에서도 “class template deduction”과 “deduction failure” 관련한 논의가 자주 등장하는데, 즉 이 문제는 표준 수준에서도 오랫동안 다듬어져 온 중요한 주제입니다.

명확한 모델을 만들어 보겠습니다. 그러면 여러분은:

  • 미리 T가 추론되는지 예측할 수 있고;
  • “추론되지 않음” 문제를 빠르게 명시적 <T>로 고칠 수 있으며;
  • 왜 T&가 단순한 "참조"가 아니라 엄격한 계약인지 이해할 수 있습니다.

2. 기본 모델: 컴파일러는 어디서 T를 얻는가

컴파일러를 템플릿과 호출을 건네받은 탐정이라고 상상해 보세요. 그의 임무는 매개변수 타입과 인자가 "맞아떨어지도록" T를 선택하는 것입니다. 컴파일러는 의미를 "추측"하려 하지 않고 타입의 형태를 맞춰봅니다. 매칭이 유일하면 성공이고, 여러 가능한 타입이 나오면 충돌이 발생하며, 전혀 추론 근거가 없으면 명시적 타입을 요구합니다.

가장 친절한 경우부터 시작해 보겠습니다: 매개변수 하나에 인자 하나인 경우, 모든 것이 자명합니다.

#include <iostream>

template <typename T>
T twice(T x) {
    return x + x;
}

int main() {
    std::cout << twice(21) << '\n';     // 42
    std::cout << twice(1.5) << '\n';    // 3
}

여기서 컴파일러는 twice(21)를 보고 T = int로, twice(1.5)를 보고 T = double로 판단합니다.

중요한 점: 추론은 호출 인자에 따라 이루어지며, 결과를 어디에 할당하느냐에 따라 결정되지 않습니다. 흔한 함정: "결과를 double에 할당하니까 T도 double이어야 한다"는 생각은 맞지 않습니다.

3. 하나의 T지만 인자 타입이 다른 경우

이제 가장 흔한 "추론 실패"로 가봅니다. 문제는 하나의 T가 두 개의 매개변수에서 사용되는데 서로 다른 타입의 인자를 전달하는 상황입니다. 컴파일러는 다음과 같은 방정식을 풀려고 합니다:

  • 첫 번째 인자로부터는 T = int
  • 두 번째 인자로부터는 T = double
  • 하지만 T는 하나여야 합니다

결론: 컴파일러는 중재를 하지 않습니다.

#include <iostream>

template <typename T>
T add_same(T a, T b) {
    return a + b;
}

int main() {
    std::cout << add_same(1, 2) << '\n';      // 3

    // std::cout << add_same(1, 2.5) << '\n';
    // error: T를 추론할 수 없음 (하나는 int 다른 하나는 double)
}

이건 "컴파일러의 어리석음"이 아니라 애매모호성을 막기 위한 안전장치입니다. 만약 컴파일러가 그냥 double을 골랐다면 "왜 long double이 아닌가?", "왜 더 큰 타입을 고르지 않는가?" 같은 의문이 생깁니다. C++에서는 이런 결정을 추측에 맡기지 않습니다.

해결 방법은 두 가지 정직한 선택뿐입니다: 인자들을 같은 타입으로 변환하거나, 원하는 T를 명시적으로 지정합니다.

#include <iostream>

template <typename T>
T add_same(T a, T b) {
    return a + b;
}

int main() {
    std::cout << add_same<double>(1, 2.5) << '\n'; // 3.5
}

여기서는 명시적으로 double을 지정해 컴파일러가 1을 double로 변환하도록 허용합니다.

4. T를 추론할 근거가 전혀 없는 경우

매우 성가신 경우: 템플릿 함수의 매개변수들이 T를 포함하지 않을 때입니다. 컴파일러는 호출을 보고 매칭할 만한 것이 없으니 추론할 수 없습니다.

가장 직관적인 예:

#include <iostream>

template <typename T>
T make_zero() {
    return T{};
}

int main() {
    std::cout << make_zero<int>() << '\n';      // 0
    std::cout << make_zero<<double>() << '\n';   // 0
}

만약 make_zero()를 <int> 없이 호출하면 컴파일러는 어떤 타입을 원한다는 건지 알 수 없습니다: int, double, std::string, 아니면 std::vector<int> 같은 복합 타입일 수도 있습니다.

비슷한 함정이 하나 더 있습니다: T가 반환형에만 나타나고 파라미터에 전혀 나타나지 않는 경우입니다.

template <typename T>
T read_value(); // 예: cin에서 읽는 함수

int main() {
    // auto x = read_value(); // error: 호출만으로는 T가 추론되지 않음
}

실용 규칙을 기억하세요: 템플릿 타입은 호출 인자로부터 추론된다. 인자가 없으면 추론도 없습니다. 따라서 이런 함수들에서는 <T>를 보통 필수로 적어줘야 합니다.

5. 참조는 계약이다: 왜 T&가 임시값을 받지 않는가

지금까지는 어떤 T를 선택할지에 관한 이야기였습니다. 이제는 다른 계열의 문제로 넘어갑니다: T는 추론될 수 있지만, 매개변수의 형태 때문에 인자를 바인딩할 수 없는 경우입니다. 특히 T&를 쓴 경우가 그렇습니다.

핵심은 단순합니다. T&는 "수정 가능한 객체를 참조하겠다"는 의미입니다. 임시값(예: 1 또는 a + b)은 변경 가능한 객체가 아니라 계산 결과로 잠깐 존재하는 값입니다.

#include <iostream>

template <typename T>
void increment(T& x) {
    ++x;
}

int main() {
    int a = 10;
    increment(a);
    std::cout << a << '\n'; // 11

    // increment(1);
    // error: 임시값 1을 T&에 바인딩할 수 없음
}

많은 사람들이 컴파일 에러가 나오면 무작정 코드를 바꾸기 시작하지만, 더 좋은 사고 방식은 명확합니다: 함수가 인자를 변경하려 한다면 인자는 반드시 변수여야 합니다.

인자를 변경하지 않으려면 보통 매개변수를 const T&로 선언하는 것이 좋습니다. 이렇게 하면 변수와 임시값 모두 수용할 수 있습니다.

#include <iostream>

template <typename T>
void print_twice(const T& value) {
    std::cout << value << " | " << value << '\n';
}

int main() {
    print_twice(42);   // 42 | 42
}

이 차이는 사소해 보일 수 있지만 실무에서는 T&와 const T&의 선택이 템플릿 컴파일 오류의 절반을 결정합니다.

6. 함정이 있는 인자들 및 빠른 추론 진단

이제 오류가 특히 자주 발생하는 상황들을 살펴보겠습니다. 좋은 소식은, 이런 경우 대개 여러분 탓이 아니라 C++의 특정 인자들이 특별하게 동작하기 때문입니다.

리스트 초기화 {...}

중괄호 {1, 2, 3}는 항상 "vector 타입의 객체"를 의미하지 않습니다. 때로는 타입이 명확해질 때까지는 하나의 초기화 목록에 불과합니다.

#include <iostream>

template <typename T>
void print_one(const T& x) {
    std::cout << x << '\n';
}

int main() {
    // print_one({1, 2, 3});
    // error: 컴파일러가 {1,2,3}에 대해 어떤 T인지 알 수 없음
}

결론: {...}는 보통 명시적 컨텍스트(예: std::vector<int>{1,2,3})가 필요합니다. 그렇지 않으면 T는 "붙잡을 것"이 없습니다.

nullptr와 포인터 템플릿

nullptr는 어떤 포인터 타입으로도 할당될 수 있다는 것을 알고 계실 겁니다. 하지만 템플릿 인자 추론은 단순한 할당 규칙보다 엄격합니다.

#include <iostream>

template <typename T>
void reset_ptr(T* p) {
    p = nullptr; // 지역 포인터를 바꿈, 외부 포인터는 변경되지 않음
}

int main() {
    int* p = nullptr;

    reset_ptr(p);        // T = int, 정상 작동

    // reset_ptr(nullptr);
    // error: nullptr로부터는 T를 추론할 수 없음 (std::nullptr_t이지 T*가 아님)
}

nullptr로 호출하려면 포인터 변수를 전달하거나, 타입을 명시적으로 지정해야 합니다:

reset_ptr<int>(nullptr); // 이제 T가 지정되어 nullptr를 int*로 변환 가능

문자열 리터럴 "hello": 배열인가 포인터인가

문자열 리터럴은 std::string도 아니고 반드시 const char*도 아닙니다. 형식적으로는 문자 배열이고, 이후에 배열에서 포인터로의 decay 규칙이 적용됩니다.

따라서 같은 인자라도 매개변수의 형태에 따라 T가 다르게 추론될 수 있습니다.

#include <iostream>

template <typename T>
void show_type_by_value(T x) {
    (void)x;
    std::cout << "by value\n"; // by value
}

template <typename T>
void show_type_by_ref(T& x) {
    (void)x;
    std::cout << "by ref\n";   // by ref
}

int main() {
    show_type_by_value("hi"); // T는 decay되어 x가 포인터가 되도록 추론됨
    // show_type_by_ref("hi"); // error: 리터럴은 non-const T&에 바인딩할 수 없음
}

만약 const T&로 받으면 리터럴에 바인딩할 수 있고, T는 원래 인자 형태(배열 등)에 더 가깝게 추론됩니다. 배열과 리터럴은 "특수 효과"를 일으키는 인자라는 점을 알아두세요.

명시적 T 지정: 언제 괜찮은가

많은 초보자가 func<int>(...)를 "패배"로 여깁니다: "컴파일러가 추론하지 못했다면 템플릿 설계가 나쁘다"라고 생각하는 것이죠. 실제로는 명시적 타입 지정은 정상적인 설계 도구입니다. 어떤 경우는 타입을 정하는 것이 논리적이며, 다른 경우는 인자만으로는 정할 수 없습니다.

예를 들어 콘솔에서 값을 읽는 함수는 "어떤 타입을 읽을지"를 호출자가 지정하는 것이 자연스럽습니다. 이 경우 <T>를 명시하는 것은 설계의 일부입니다.

#include <iostream>
#include <string_view>

template <typename T>
T read_from_stdin(std::string_view prompt) {
    std::cout << prompt;
    T value{};
    std::cin >> value;
    return value;
}

int main() {
    int age = read_from_stdin<int>("Age? ");           // 입력: 20
    double price = read_from_stdin<double>("Price? "); // 입력: 19.99

    std::cout << age << '\n';    // 20
    std::cout << price << '\n';  // 19.99
}

중요한 점: 함수에 prompt 매개변수가 있더라도 그 매개변수에는 T가 없으므로 prompt만으로는 T를 추론할 수 없습니다. 따라서 <int>나 <double>를 적는 것은 '꼼수'가 아니라 인터페이스의 일부입니다.

빠른 체크리스트: "추론되지 않음"을 어떻게 파악할까

"no matching function" 또는 "couldn't deduce template parameter" 같은 컴파일 오류를 보면 당황하지 말고 짧은 멘탈 체크리스트를 따라가 보세요. 거의 흐름도처럼 생각하면 됩니다: "T가 어디서 나오며, 왜 거기서 나오지 않는가".

flowchart TD
    A[템플릿 함수 호출] --> B{T가 등장하는 인자가 있는가?}
    B -- 아니오 --> C["T를 추론할 수 없음: func<T>(...) 또는 시그니처 변경 필요"]
    B -- 예 --> D{모든 인자가 같은 T를 제공하는가?}
    D -- 아니오 --> E[충돌: 서로 다른 타입 -> 명시적 <T> 또는 인자 타입을 통일]
    D -- 예 --> F{매개변수가 T&라면 변경 가능한 객체가 필요한가?}
    F -- 예, 그런데 임시값 전달됨 --> G[const T&로 바꾸거나 변수를 전달]
    F -- 문제없음 --> H[추론 성공, 남은 문제는 템플릿 본문 내부]

7. 예제: "Expense Tracker"에 템플릿 유틸리티 적용

템플릿이 T에 관한 추상적 수학이 되지 않도록, 간단한 콘솔형 지출 트래커 예제로 적용해 보겠습니다: 항목을 추가하고 합계를 출력하는 최소한의 모델을 만듭니다.

복잡한 메뉴는 제외하고 템플릿 동작을 보여주는 최소한의 코드만 작성합니다.

#include <iostream>
#include <string>
#include <vector>

struct Expense {
    std::string title;
    double amount{};
};

int main() {
    std::vector<Expense> expenses;
    expenses.push_back(Expense{"Coffee", 3.50});

    std::cout << expenses[0].title << ": " << expenses[0].amount << '\n'; // Coffee: 3.5
}

이제 실무에서 자주 쓰이는 두 가지 템플릿 유틸리티를 추가해 보겠습니다.

첫째는 read_from_stdin<T>()입니다: 이 함수는 T가 추론되지 않는 경우의 예를 보여줍니다.

#include <iostream>
#include <string_view>

template <typename T>
T read_from_stdin(std::string_view prompt) {
    std::cout << prompt;
    T value{};
    std::cin >> value;
    return value;
}

둘째는 clamp_value입니다: 이 함수는 서로 다른 타입이 섞였을 때 T가 추론되지 않는 경우를 보여줍니다.

template <typename T>
T clamp_value(T x, T lo, T hi) {
    if (x < lo) return lo;
    if (hi < x) return hi;
    return x;
}

이제 이들을 main에서 사용해 보고 두 가지 범주의 문제와 해결책을 확인합니다.

#include <iostream>
#include <string>
#include <string_view>
#include <vector>

struct Expense {
    std::string title;
    double amount{};
};

template <typename T>
T read_from_stdin(std::string_view prompt) {
    std::cout << prompt;
    T value{};
    std::cin >> value;
    return value;
}

template <typename T>
T clamp_value(T x, T lo, T hi) {
    if (x < lo) return lo;
    if (hi < x) return hi;
    return x;
}

int main() {
    std::vector<Expense> expenses;

    std::string title;
    std::cout << "Title? ";
    std::cin >> title;

    double amount = read_from_stdin<double>("Amount? "); // 입력: 1000
    amount = clamp_value(amount, 0.0, 100000.0);

    expenses.push_back(Expense{title, amount});
    std::cout << "Added: " << expenses.back().title << '\n'; // Added: <title>
}

여기서 주의할 점은 read_from_stdin<double>를 명시적으로 적은 이유입니다: 매개변수에는 T가 없으므로 컴파일러는 호출만 보고는 T를 알 수 없습니다. 따라서 <double>를 적는 것이 필수입니다.

반면 clamp_value는 매개변수에 T가 등장하므로 인자들로부터 T를 추론할 수 있습니다.

만약 clamp_value(amount, 0, 100000.0)처럼 호출하면 충돌이 발생합니다: 하나의 인자는 int, 나머지는 double이므로 하나의 T가 두 가지가 될 수는 없습니다.

8. 전형적인 실수들

실수 #1: 컴파일러가 "왼쪽 변수 타입으로 T를 추론"할 것이라 기대하기.
예를 들어 double x = add_same(1, 2.5);라고 하면 "T가 double이 되어야 한다"고 생각하기 쉽습니다. 하지만 추론은 호출 인자에 기반하기 때문에 그런 기대는 틀립니다. 해결책은 인자를 캐스팅하거나 명시적으로 add_same<double>(...)를 사용하는 것입니다.

실수 #2: 습관적으로 T&를 쓰고 리터럴이나 임시값이 받아들여지지 않는 것을 보고 놀라기.
T&는 "값을 변경하겠다"는 계약이므로 임시값(예: 1, a + b, 문자열 리터럴 "hi")을 받을 수 없습니다. 만약 함수가 읽기 전용이라면 대체로 const T&가 더 적절합니다.

실수 #3: 인자 없이 템플릿을 호출하면서 <T>를 쓰지 않아 "couldn't deduce template parameter" 에러를 마주하기.
함수에 인자가 없거나 T가 반환형에만 나타나면 컴파일러는 T를 추론할 수 없습니다. 이런 경우에는 make_zero<int>(), read_from_stdin<double>(...)처럼 명시적으로 적어야 합니다.

실수 #4: 하나의 T가 여러 번 사용되는 함수에 서로 다른 타입의 인자를 섞어 넣고 자동 변환을 기대하기.
인간 관점에서는 add_same(1, 2.5)가 당연히 동작할 것 같지만, 템플릿 추론 관점에서는 모순입니다. 해결책은 인자를 같은 타입으로 맞추거나 명시적으로 T를 지정하는 것입니다.

실수 #5: T*를 받는 템플릿에서 nullptr를 전달하고 T가 추론되지 않는 것에 놀라기.
nullptr는 어떤 포인터 타입으로도 변환될 수 있지만, nullptr 자체로부터는 템플릿의 T를 추론할 수 없습니다. 이런 경우 포인터 변수를 전달하거나 func<int>(nullptr)처럼 명시적으로 타입을 적는 것이 일반적입니다.

1
과제
C++ SELF, 레벨 55, 레슨 3
잠금
값 두 배로 만들기
값 두 배로 만들기
1
과제
C++ SELF, 레벨 55, 레슨 3
잠금
double 덧셈
double 덧셈
1
과제
C++ SELF, 레벨 55, 레슨 3
잠금
타입 지정
타입 지정
1
과제
C++ SELF, 레벨 55, 레슨 3
잠금
레퍼런스 계약
레퍼런스 계약
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION