Devin.KR

템플릿 인자 추론과 오버로드 해석

개발자KR 조회 10

이 장에서 배우는 것

앞 장에서 파일 핸들과 잠금 가드의 수명을 타입으로 표현했다. 이제 주문 처리 엔진에 작은 템플릿 함수를 붙이려 한다. 주문 수량의 상한을 적용하고, 접수한 값을 보관하고, 종류에 맞는 로그를 출력하는 일이다. 이때 함수의 동작만큼 중요한 질문이 생긴다. 호출자가 타입을 쓰지 않았을 때 컴파일러는 무엇을 근거로 타입을 정하며, 이름이 같은 함수 중 어느 것을 선택하는가.

이 장에서는 템플릿 인자 추론(template argument deduction)을 타입 사이의 모양 맞추기로 살펴본다. 이어서 변수 선언의 타입 추론, 클래스 객체 생성 시의 추론, 오버로드 해석(overload resolution)을 연결한다. 규칙을 외우기보다 호출식에서 어떤 정보가 남고 사라지는지 추적하는 것이 목표다.

  • 값 매개변수와 참조 매개변수에서 템플릿 인자가 어떻게 달라지는지 설명한다.
  • auto와 decltype(auto)를 구별하고 복사와 참조 반환을 의도대로 선택한다.
  • 클래스 템플릿 인자 추론으로 생성되는 타입과 그 한계를 파악한다.
  • 후보 함수의 호출 가능성과 인자 변환을 비교하여 선택 결과를 예측한다.
  • 추론 충돌, 추론할 수 없는 위치, 모호한 호출의 진단을 구분한다.

문제 상황

주문 접수 코드를 여러 입력 경로가 공유하기 시작했다. 네트워크에서 읽은 주문 수량은 int인데, 설정 파일에서 가져온 상한은 long이다. 같은 타입 두 개를 받도록 만든 수량 제한 함수에 두 값을 넣자 컴파일 오류가 발생한다. 작성자는 정수를 다른 정수로 변환할 수 있다는 사실을 알고 있으므로 이 오류가 낯설다.

template<class T>
T capped(T requested, T maximum)
{
    return requested < maximum ? requested : maximum;
}

int requested = 12;
long maximum = 10;
// capped(requested, maximum); // T를 int와 long으로 동시에 추론할 수 없다.

로그 쪽에서도 예상과 다른 일이 생긴다. 기존에는 long을 받는 함수만 있었는데, 여러 메시지를 받기 위해 템플릿을 추가하자 int 인자가 새 템플릿으로 들어간다. 한편 종목명을 돌려주는 함수의 반환 타입을 auto로 바꾸었더니 호출자가 수정한 종목명이 원본 주문에 반영되지 않는다.

세 문제는 서로 다른 단계에 속한다. 첫 번째는 템플릿 인자를 정하는 문제이고, 두 번째는 만들어진 후보 중 함수를 고르는 문제이며, 세 번째는 선언한 변수나 반환값이 참조인지 결정하는 문제다. 이 단계를 섞어 생각하면 “컴파일러가 적당히 변환해 줄 것”이라는 기대에 의존하게 된다. 먼저 타입을 정하고, 그다음 호출 가능성과 선택 순서를 확인해야 한다.

함수 템플릿은 매개변수의 모양에서 추론한다

값과 참조가 남기는 정보

함수 템플릿은 매개변수 패턴과 실제 인자의 타입을 비교한다. T, T&, const T&는 서로 다른 패턴이다. 따라서 인자가 같아도 추론 결과는 달라질 수 있다. 여기서 추론한 T와 최종 매개변수 타입을 구별해야 한다. T가 int라고 해도 패턴이 const T&이면 매개변수 타입은 const int&다.

template<class T>
void by_value(T value);

template<class T>
void by_reference(T& value);

template<class T>
void by_const_reference(const T& value);

int quantity = 12;
const int frozen = quantity;

by_value(frozen);           // T = int
by_reference(frozen);       // T = const int
by_const_reference(frozen); // T = int

값 매개변수의 추론에서는 인자 타입의 최상위 const가 사라진다. 독립적인 값을 받는 매개변수가 원본 변수의 수정 금지 여부까지 물려받을 필요는 없기 때문이다. 인자가 참조 변수여도 참조 대상의 타입을 바탕으로 추론한다. 다만 복사가 가능한지는 별도 문제다. 타입을 성공적으로 추론했더라도 복사할 수 없는 객체를 값으로 넘기면 호출이 성립하지 않을 수 있다.

참조 매개변수는 원본을 가리키므로 정보가 더 남는다. T&에 const int 객체를 넘기면 T 자체가 const int다. 반면 const T&의 const는 패턴에 이미 있다. 같은 인자를 넣어도 이 경우의 T는 int다. T&는 일반적인 임시 정수에 바인딩할 수 없지만 const T&는 가능하다.

매개변수 패턴에 따라 보존되는 타입 정보
인자 선언패턴추론한 T최종 매개변수 타입
const int q = 12;Tintint
const int q = 12;T&const intconst int&
const int q = 12;const T&intconst int&
const char tag[] = "BUY";Tconst char*const char*
const char tag[] = "BUY";T&const char[4]const char (&)[4]

값 패턴에서는 배열이 포인터로, 함수가 함수 포인터로 조정된다. 배열 길이를 추론에 사용하려면 배열 참조 패턴이 필요하다. 또한 최상위 const를 제거한다는 말은 모든 const를 지운다는 뜻이 아니다. const char*에서 문자에 붙은 const는 유지된다. 포인터로 받은 뒤 원래 문자열을 수정할 권한이 생기는 것은 아니다.

같은 const int 인자라도 매개변수 패턴에 따라 T와 최종 매개변수 타입이 달라진다

앞서 값 범주를 다루며 본 T&&에는 별도 규칙이 있다. 함수 템플릿에서 추론되는, 한정되지 않은 타입 매개변수 T에 붙은 &&라면 lvalue 인자로부터 T를 참조 타입으로 추론할 수 있다. 여기서는 그 전달 기법을 다시 전개하지 않는다. 다만 T&와 같은 규칙이라고 가정하거나, const T&&까지 같은 대상으로 묶으면 안 된다.

추론은 공통 타입을 찾아 주는 작업이 아니다

capped(T, T)는 두 인자에서 각각 얻은 T가 일치해야 한다. int와 long을 받았다고 해서 더 넓은 정수 타입을 자동으로 고르지 않는다. 추론 과정은 일반적인 암시적 변환을 두루 시도하여 타협안을 찾는 과정이 아니다. 두 타입을 허용할 것인지, 경계에서 하나의 타입으로 맞출 것인지는 함수 설계의 선택이다.

int requested = 12;
long maximum = 10;

auto result = capped<long>(requested, maximum);
// T는 이미 long으로 지정되었고, requested는 호출 때 long으로 변환된다.

명시적으로 T를 지정하면 그 자리의 타입을 인자로부터 알아낼 필요가 없어진다. 이후 정해진 매개변수 타입에 맞춰 호출 변환을 검사한다. 위 예제는 모든 int 값을 표현할 수 있는 long 방향으로 맞춘다. 반대 방향의 변환을 선택할 때는 범위 손실 여부를 먼저 판단해야 한다.

반환 타입만으로는 보통 함수 호출의 템플릿 인자를 추론하지 못한다. 예를 들어 template<class T> T read_limit();가 있을 때 long n = read_limit();의 왼쪽 long은 T를 정해 주지 않는다. 호출을 read_limit<long>()으로 작성해야 한다. 값을 어디에 저장할지와 호출할 함수의 타입을 정하는 일은 분리된다.

타입 이름의 일부에 나타난다고 해서 늘 추론에 참여하는 것도 아니다. typename T::value_type처럼 중첩 타입을 지정하는 표현의 T는 비추론 문맥에 놓인다. 하나의 원소 타입만 보고 그 타입을 멤버로 가진 바깥 클래스를 역으로 결정할 수 없기 때문이다. 다른 매개변수에서 T를 추론하거나 직접 지정해야 한다.

auto, decltype(auto), CTAD로 선언을 읽는다

짧아진 선언에서도 복사와 참조를 구별한다

일반적인 표현식으로 변수를 초기화할 때 auto의 추론은 함수 템플릿의 패턴 비교와 비슷하다. auto는 값 패턴, auto&는 참조 패턴, const auto&는 상수 참조 패턴으로 읽으면 된다. 초기화식이 참조를 돌려준다는 사실만으로 단독 auto 변수가 참조가 되지는 않는다.

std::string symbol = "ALPHA";
const std::string& observed = symbol;

auto copy = observed;         // std::string
auto& alias = observed;      // const std::string&
const auto& view = symbol;   // const std::string&

decltype(auto)는 같은 규칙을 사용하지 않는다. 초기화식에 decltype 규칙을 적용한 타입을 그대로 사용한다. 괄호 없는 이름이나 멤버 접근에는 그 대상의 선언된 타입을 얻는 특례가 있다. 그 밖의 식은 값 범주에 따라 참조 여부가 결정된다. 일반적인 lvalue 식이면 T&, xvalue 식이면 T&&, prvalue 식이면 T가 된다.

int quantity = 12;

decltype(auto) snapshot = quantity;  // int
decltype(auto) linked = (quantity);  // int&

linked = 9; // quantity도 9가 된다.

괄호 한 쌍이 단순한 가독성 차이가 아닐 수 있다. quantity는 괄호 없는 이름이므로 선언된 타입 int를 얻지만, (quantity)는 일반적인 lvalue 식으로 판정되어 int&를 얻는다. 함수의 반환 타입을 decltype(auto)로 쓸 때도 반환식에 같은 구분이 적용된다.

주문에서 종목명을 꺼내는 함수가 return order.symbol;을 사용하면 멤버의 선언된 타입인 std::string을 반환한다. return (order.symbol);을 사용하면 std::string&를 반환한다. 호출하는 쪽에서 다시 auto로 받으면 복사하고, decltype(auto)로 받으면 반환된 참조를 보존한다. 함수의 반환 선언과 호출자의 변수 선언을 모두 읽어야 한다.

참조를 보존하는 기능은 수명을 늘려 주는 기능이 아니다. 지역 변수에 괄호를 붙여 반환하면 함수가 끝난 뒤 사용할 수 없는 참조가 나올 수 있다. 또한 한 함수의 여러 반환문에서 추론되는 반환 타입은 같아야 한다. 한 경로에서는 값, 다른 경로에서는 참조를 반환하면서 컴파일러가 둘을 하나로 조정하리라 기대해서는 안 된다.

중괄호 초기화에는 별도 규칙이 있다

auto를 설명할 때는 초기화식이 일반 표현식인지 중괄호 목록인지 구분해야 한다. C++20에서 auto n{10};은 int지만, auto n = {10};은 std::initializer_list<int>다. 여러 원소를 쓰는 auto n{10, 20};은 성립하지 않는다. 복사 목록 초기화인 auto n = {10, 20};은 같은 타입의 원소로 된 목록을 만든다.

이 규칙을 함수 템플릿 호출에 그대로 옮길 수는 없다. template<class T> void accept(T);에 accept({10, 20});을 전달하면 T를 추론하지 못한다. 중괄호 목록 자체가 하나의 일반적인 표현식 타입을 가진 것은 아니기 때문이다. 이 호출을 의도했다면 std::initializer_list<T> 같은 대응 패턴이나 구체적인 컨테이너 객체가 필요하다.

클래스 템플릿 인자도 생성 시점에 추론한다

클래스 템플릿 인자 추론(class template argument deduction, CTAD)은 객체를 만들 때 클래스의 템플릿 인자를 생략할 수 있게 한다. 다음 Receipt는 접수한 값을 자기 멤버로 보관한다. 생성자의 값 매개변수 T에서 타입을 알아낼 수 있으므로 별도의 추론 지침 없이 사용할 수 있다.

template<class T>
struct Receipt {
    T payload;

    explicit Receipt(T value) : payload(std::move(value)) {}
};

Receipt receipt{Order{101, "ALPHA", 12}};
// receipt의 타입은 Receipt<Order>다.

CTAD에서는 생성자 선언 등으로부터 추론용 후보를 만들고, 초기화 인자로 클래스의 템플릿 인자를 정한다. 클래스 타입이 정해지면 실제 객체 초기화도 성립해야 한다. 위 생성자는 T를 값으로 받으므로 상수 주문을 넣어도 일반적으로 Receipt<Order>를 얻는다. 원본에 대한 참조를 보관하는 타입이 저절로 만들어지는 것은 아니다.

문자열 리터럴은 더 주의해야 한다. Receipt text{"BUY"};는 값 패턴의 배열 조정을 거쳐 Receipt<const char*>가 된다. 문자를 소유하는 std::string이 필요하다면 Receipt text{std::string{"BUY"}};처럼 소유 타입을 입력에 드러내면 된다. 추론은 입력 타입을 해석하며, 보관 정책까지 대신 설계하지 않는다.

생성자 형태만으로 원하는 타입을 얻기 어려운 클래스에는 사용자가 추론 지침(deduction guide)을 제공할 수도 있다. 지침은 추론 결과를 정하는 선언이며 생성자 구현을 대신하지 않는다. 이 장의 접수 상자는 생성자만으로 의도가 드러나므로 추가 지침이 필요하지 않다.

오버로드는 변환을 비교한 뒤 동률을 푼다

이름이 같은 함수가 여러 개라면 먼저 후보를 모은다. 함수 템플릿은 필요한 추론과 치환에 성공해야 후보로 참여할 수 있다. 이어서 인자 개수와 매개변수 바인딩 등을 검사하여 호출 가능한 후보를 남긴다. 그 후보들 사이에서 각 인자가 요구하는 암시적 변환을 비교한다.

일반적인 표준 변환에서는 정확한 일치가 승격보다 유리하고, 승격이 그 밖의 표준 변환보다 유리하다. 다만 정확한 일치는 글자 그대로 같은 타입만을 뜻하지 않으며 참조 바인딩 등에도 세부 규칙이 있다. 인자가 여럿이라면 한 후보가 모든 인자에서 다른 후보보다 나쁘지 않고 적어도 하나에서 더 나은지를 살핀다. 첫 번째 인자만 보고 승자를 정하지 않는다.

void trace(long quantity);

template<class T>
void trace(T quantity);

trace(12);  // trace<int>: int를 그대로 받는다.
trace(12L); // trace(long): 변환 비교가 같으면 비템플릿 쪽이 우선한다.

첫 호출에서 일반 함수는 int를 long으로 변환해야 하지만 템플릿은 T = int로 정확히 맞는다. 따라서 템플릿이 선택된다. 두 번째 호출에서는 두 후보 모두 long을 그대로 받는다. 이런 동률에서는 비템플릿 함수가 함수 템플릿의 특수화보다 우선한다. “일반 함수가 언제나 먼저다”라는 요약은 첫 호출을 설명하지 못한다.

함수 템플릿끼리도 동률을 가르는 규칙이 있다. 예를 들어 inspect(T)와 inspect(T*)에 int*를 전달하면 포인터 형태를 요구하는 두 번째 템플릿이 더 특수화되어 선택된다. 이는 선언 순서나 매개변수 이름으로 결정되지 않는다. 템플릿 부분 순서화가 각 패턴이 허용하는 형태를 비교한 결과다.

오버로드 선택은 호출 가능한 후보의 변환을 먼저 비교하고 동률일 때 추가 규칙을 적용한다

어느 후보도 다른 모든 후보보다 낫지 않으면 호출은 모호하다. pick(int, long)과 pick(long, int)에 두 개의 int를 넘기면 각각 한 자리에서만 유리하다. 컴파일러는 임의로 하나를 고르지 않는다. 호출 목적에 맞는 타입을 명시하거나 인터페이스를 구분해야 한다.

선택된 템플릿의 함수 본문이 잘못되었다고 해서 다음 후보로 되돌아가는 것도 아니다. 예를 들어 범용 로그 함수 안에서 지원되지 않는 출력 연산을 사용하면, 그 함수가 선택된 뒤 본문을 구체화하는 과정에서 오류가 날 수 있다. 후보를 정하는 문제와 본문을 컴파일하는 문제를 구분하면 긴 진단을 읽기가 쉬워진다.

완성 코드

다음 프로그램은 접수 상자로 받은 주문을 목록에 넣고, 종목명 접근 결과를 복사와 참조로 각각 받아 본다. 이후 수량을 검증하고 상한을 적용하여 체결 로그를 남긴다. 유효한 주문을 곧바로 체결된 것으로 기록하는 작은 모형이며, 가격 비교나 외부 시스템 호출은 포함하지 않는다.

main.cpp

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

struct Order {
    int id;
    std::string symbol;
    int quantity;
};

template<class T>
struct Receipt {
    T payload;

    explicit Receipt(T value) : payload(std::move(value)) {}
};

template<class T>
T capped(T requested, T maximum)
{
    return requested < maximum ? requested : maximum;
}

decltype(auto) symbol_of(Order& order)
{
    return (order.symbol);
}

template<class T>
void write_log(const T& message)
{
    std::cout << "notice: " << message << '\n';
}

void write_log(const Order& order)
{
    std::cout << "filled: " << order.id << ' '
              << order.symbol << ' ' << order.quantity << '\n';
}

bool valid(const Order& order)
{
    return !order.symbol.empty() && order.quantity > 0;
}

int main()
{
    Receipt receipt{Order{101, "ALPHA", 12}};

    std::vector<Order> orders;
    orders.push_back(std::move(receipt.payload));
    orders.push_back(Order{102, "BETA", 0});

    write_log("engine ready");

    auto symbol_copy = symbol_of(orders.front());
    decltype(auto) symbol_ref = symbol_of(orders.front());
    symbol_copy = "LOCAL";
    symbol_ref = "ALPHA.X";

    std::cout << "copy: " << symbol_copy << '\n';
    std::cout << "stored: " << orders.front().symbol << '\n';

    int accepted = 0;
    for (auto& order : orders) {
        if (!valid(order)) {
            std::cout << "rejected: " << order.id << '\n';
            continue;
        }

        order.quantity = capped(order.quantity, 10);
        write_log(order);
        ++accepted;
    }

    std::cout << "accepted: " << accepted << '\n';
    return 0;
}

줄별 해설

네 개의 헤더는 각각 표준 출력, 소유 문자열, std::move, 주문 목록에 필요하다. 다른 헤더가 간접적으로 선언을 포함해 주리라 기대하지 않고 사용하는 기능의 헤더를 직접 포함한다. Order의 세 멤버는 주문 식별자, 종목명, 수량이다. 문자열은 주문 객체가 소유한다.

template<class T>는 뒤의 클래스가 타입 매개변수 하나를 가진다고 선언한다. T payload;는 선택된 타입의 객체를 멤버로 둔다. 생성자의 T value는 값을 받고, 멤버 초기화식의 std::move(value)는 그 매개변수에서 멤버로 이동할 수 있게 한다. CTAD로 타입을 정하는 일과 생성 과정에서 복사하거나 이동하는 일은 별개의 단계다.

capped의 두 매개변수에 같은 T를 사용했으므로 두 인자에서 일치하는 타입이 나와야 한다. 반환문의 조건 연산자는 요청량이 더 작으면 요청량을, 그렇지 않으면 상한을 선택한다. 반환 타입을 T로 선언했으므로 반환값은 값이다. 이 함수는 음수 검증까지 맡지 않으며, 호출 전에 주문 검증을 수행한다.

symbol_of의 Order&는 기존 주문을 참조한다. 반환문의 (order.symbol)은 lvalue 식이므로 decltype(auto)는 std::string&로 정해진다. 이 함수가 반환하는 것은 새 문자열이 아니라 호출자가 넘긴 주문의 멤버를 가리키는 참조다.

첫 번째 write_log는 const T&로 메시지를 받는다. 문자열 리터럴을 넘길 때 배열 크기와 문자 타입을 보존하여 추론하고, 본문에서는 출력 연산을 사용한다. 모든 타입을 출력할 수 있다는 뜻은 아니다. 실제로 선택된 타입에 해당 출력 연산이 있어야 한다.

두 번째 write_log는 주문 전용 함수다. Order 인자에 대해서는 템플릿의 T도 Order가 되어 두 후보의 매개변수 타입이 같아진다. 이 경우에는 비템플릿 함수가 선택되므로 주문을 필드별로 출력하는 본문이 사용된다.

valid는 종목명이 비어 있지 않고 수량이 양수인지 확인한다. const Order&는 주문을 복사하지 않고 읽는다. &&는 여기서 논리 연산자이며, 템플릿 매개변수 선언에 사용하는 참조 기호와 문맥이 다르다.

Receipt receipt{Order{...}};에서는 입력 객체로부터 Receipt<Order>가 추론된다. 첫 번째 push_back은 접수 상자의 주문을 목록으로 이동한다. 이후 이동된 receipt.payload의 문자열 값에 의존하지 않는다. 두 번째 주문은 수량이 0이므로 검증에서 거절될 입력이다.

write_log("engine ready")는 주문 전용 함수에 맞지 않으므로 메시지 템플릿을 선택한다. 다음 두 선언은 같은 함수를 호출하지만 결과가 다르다. symbol_copy는 std::string 값이고 symbol_ref는 std::string&다. 따라서 "LOCAL" 대입은 복사본만 바꾸고, "ALPHA.X" 대입은 목록 속 첫 주문을 바꾼다.

두 참조 관련 선언을 목록 추가 작업 뒤에 배치한 점도 중요하다. 프로그램은 그 뒤에 목록의 원소를 추가하거나 제거하지 않는다. 따라서 벡터 재할당으로 첫 주문의 참조가 무효화되는 경로가 없다. 참조 추론이 올바르더라도 컨테이너의 수명과 무효화 규칙은 계속 지켜야 한다.

반복문의 auto& order는 각 주문을 참조한다. 단독 auto로 바꾸면 반복마다 주문을 복사하여 그 복사본의 수량을 수정하게 된다. 검증 실패 시 식별자를 출력하고 continue로 다음 주문으로 넘어간다. 성공한 경우에는 int 수량과 int 리터럴 10으로 capped<int>를 호출한다.

수량을 갱신한 뒤의 write_log(order)는 주문 전용 함수를 선택한다. ++accepted는 유효한 주문을 처리한 횟수를 센다. 마지막 출력은 그 횟수이며, return 0;으로 프로그램의 정상 종료를 나타낸다.

실행 결과

파일을 main.cpp로 저장한 뒤 macOS 또는 Linux에서 다음 명령으로 빌드하고 실행한다. 프로그램은 C++20 표준 기능만 사용하며 제시한 경고 옵션에서 경고가 발생하지 않도록 작성했다.

c++ -std=c++20 -Wall -Wextra -pthread main.cpp -o order_deduction
./order_deduction

예상 출력은 다음과 같다. 첫 주문은 수량 12에서 상한 10으로 조정되고, 두 번째 주문은 수량 검증에서 거절된다.

notice: engine ready
copy: LOCAL
stored: ALPHA.X
filled: 101 ALPHA.X 10
rejected: 102
accepted: 1

실무에서 자주 틀리는 것

다른 타입을 넘겨도 하나의 T로 합쳐질 것이라 생각한다

다음 호출은 추론 단계에서 실패한다. 진단에 deduced conflicting types와 함께 int, long이 나타난다면 같은 템플릿 매개변수에 서로 다른 결론이 모였다는 뜻이다. 컴파일러마다 문구는 다르지만 충돌한 매개변수와 두 타입을 찾는 방법은 같다.

// 틀린 코드
int requested = 12;
long maximum = 10;
auto amount = capped(requested, maximum);
// 고친 코드
int requested = 12;
long maximum = 10;
auto amount = capped<long>(requested, maximum);

수정한 코드는 결과 타입도 long으로 정한다. 단순히 오류를 없애기 위해 작은 타입으로 강제 변환하면 수량 범위가 달라질 수 있다. 먼저 이 함수 경계에서 사용할 수량 타입을 정하고 호출을 맞추는 편이 읽기 쉽다.

중첩 타입에서 바깥 클래스를 역으로 추론하려 한다

다음 템플릿은 원소 타입만 받는다. 인자가 int라고 해도 T가 std::vector<int>인지 다른 클래스인지 알 수 없다. 진단의 couldn't infer template argument 또는 couldn't deduce template parameter는 추론할 근거가 부족하다는 방향으로 읽는다.

// 틀린 코드
template<class T>
void inspect_element(const typename T::value_type&)
{
}

inspect_element(3);
// 고친 코드
inspect_element<std::vector<int>>(3);

실제로 컨테이너 정보가 필요 없는 함수라면 매개변수를 const T&로 다시 설계하는 편이 더 직접적이다. 명시적 타입 지정은 기존 설계를 유지하는 수정이고, 패턴 변경은 함수가 무엇을 알아야 하는지 다시 정하는 수정이다.

참조 반환과 auto 수신이 자동으로 연결된다고 생각한다

다음 코드는 컴파일되지만 원본 주문을 수정하지 않는다. 함수가 참조를 반환해도 단독 auto는 값 변수를 선언한다. 이 오류는 타입 진단이 아니라 상태가 예상과 다르게 남는 현상으로 드러난다.

// 틀린 코드: 원본 종목명을 바꾸려는 의도다.
auto symbol = symbol_of(order);
symbol = "GAMMA";
// 고친 코드: 참조가 필요하다는 의도를 선언에 드러낸다.
auto& symbol = symbol_of(order);
symbol = "GAMMA";

함수 반환 타입을 그대로 따르려는 코드라면 decltype(auto)도 선택할 수 있다. 그러나 여기서는 반드시 참조가 필요하므로 auto&가 계약을 더 직접적으로 표현한다. 나중에 함수가 값 반환으로 바뀌면 비상수 lvalue 참조를 묶을 수 없어 호출자에서 오류가 드러난다.

오버로드가 모호하면 선언 순서로 해결될 것이라 생각한다

다음 두 함수는 각각 다른 인자에서 유리하다. 진단에 ambiguous가 나타나면 후보 목록의 매개변수 타입을 나란히 놓고 각 인자의 변환을 비교한다. 선언 순서를 바꾸는 것은 해결책이 아니다.

// 틀린 코드
void choose(int, long) {}
void choose(long, int) {}

choose(1, 2);
// 고친 코드: 두 번째 인자를 long으로 받는 호출을 의도한다.
choose(1, 2L);

수정한 호출에서는 첫 번째 함수가 두 인자를 모두 그대로 받는다. 두 번째 함수는 두 자리에서 정수 변환을 요구하므로 선택되지 않는다. 이런 구분을 호출자가 매번 기억해야 한다면 인자 순서나 함수 이름을 바꾸는 설계도 검토할 수 있다.

긴 진단에서는 먼저 사용자 코드의 호출 위치를 찾고, 그 아래 후보별 탈락 이유를 읽는다. no matching function은 최종 요약일 수 있다. 그 뒤에 나오는 타입 충돌, 참조 바인딩 실패, 인자 개수 불일치가 실제 수정 지점을 알려 준다. 반대로 템플릿 본문의 출력 연산에서 오류가 시작되면 추론 자체보다 선택된 타입이 본문에서 요구하는 연산을 지원하는지 확인해야 한다.

한눈에 보기

추론과 선택에서 먼저 확인할 질문
구문 또는 상황핵심 규칙확인할 점
f(T)최상위 const를 제거하고 배열·함수를 포인터로 조정한다.값 전달과 복사가 의도한 동작인가.
f(T&)참조 대상의 const와 배열 정보를 보존한다.바인딩할 수 있는 인자인가.
f(T, T)각 인자에서 얻은 T가 일치해야 한다.공통 타입을 자동 선택한다고 기대하지 않았는가.
auto x = expr;일반 표현식에서는 값 패턴으로 추론한다.참조가 아니라 복사본을 만드는가.
decltype(auto)식에 decltype 규칙을 적용한다.괄호와 참조 대상의 수명이 올바른가.
Receipt r{value};초기화 인자로 클래스 템플릿 인자를 추론한다.추론된 멤버 타입이 필요한 소유권을 가지는가.
오버로드 선택변환을 먼저 비교하고 동률 규칙을 적용한다.각 인자에서 어느 후보가 유리한가.
후보 탈락 또는 본문 오류추론 실패와 본문 구체화 실패는 다른 단계다.진단이 호출 후보를 가리키는가, 본문 연산을 가리키는가.

현재의 범용 로그 함수는 출력 연산이 있는 타입만 본문을 성공적으로 컴파일할 수 있다. 이러한 사용 조건을 선언에 표현하는 방법은 다음에 다룰 concepts의 주제다. 여기서는 그 조건을 검사하기 전에 어떤 타입이 추론되고 어떤 후보가 선택되는지를 분명히 해 두면 된다.

연습 문제

  1. 다음 선언에서 a, b, c, d의 타입을 적고, 원본 수량을 직접 바꿀 수 있는 변수가 있는지 설명하라.

    const int quantity = 7;
    auto a = quantity;
    auto& b = quantity;
    decltype(auto) c = quantity;
    decltype(auto) d = (quantity);
  2. 완성 코드의 symbol_of에서 반환문의 괄호를 제거하면 반환 타입은 어떻게 변하는가. 나머지 코드를 그대로 두었을 때 전체 출력을 적어라.

  3. 아래 호출에서 선택되는 함수를 각각 적어라. 템플릿이 선택된다면 추론한 T도 적어라.

    void record(long);
    
    template<class T>
    void record(T);
    
    template<class T>
    void record(T*);
    
    int quantity = 7;
    record(quantity);
    record(7L);
    record(&quantity);
  4. Receipt receipt{"SELL"};의 타입을 적어라. 이어서 상자 안에 문자를 소유하는 문자열을 보관하도록 초기화식을 수정하라. 원래 선언이 문자열 리터럴을 받는 경우와 지역 문자 배열을 받는 경우의 수명 차이도 설명하라.

정답과 해설

  1. a는 int, b는 const int&, c는 const int, d는 const int&다. c는 괄호 없는 이름의 선언된 타입을 얻으므로 const가 남은 별도 객체다. b와 d는 원본을 참조하지만 상수 참조이며, 원본도 상수다. 따라서 어느 변수로도 원본 수량을 직접 바꿀 수 없다. a만 자기 값을 수정할 수 있다.

  2. 반환 타입은 멤버의 선언된 타입인 std::string이 된다. 함수는 값을 반환하고 symbol_ref도 이름과 달리 std::string 값이 된다. 두 대입은 각각 별도 문자열을 바꾸므로 목록 속 종목명은 ALPHA로 남는다. 출력은 다음과 같다.

    notice: engine ready
    copy: LOCAL
    stored: ALPHA
    filled: 101 ALPHA 10
    rejected: 102
    accepted: 1
  3. record(quantity)는 T = int인 값 매개변수 템플릿을 선택한다. 일반 함수는 long으로 변환해야 하기 때문이다. record(7L)은 변환이 동률이어서 비템플릿 record(long)을 선택한다. record(&quantity)는 T = int인 포인터 매개변수 템플릿을 선택한다. 값 매개변수 템플릿도 T = int*로 맞지만, 두 템플릿 중 포인터 패턴이 더 특수화되어 있다.

  4. 타입은 Receipt<const char*>다. 소유 문자열이 필요하면 Receipt receipt{std::string{"SELL"}};로 작성하여 Receipt<std::string>을 만든다. 원래 코드의 문자열 리터럴은 정적 저장 기간을 가지므로 그 문자를 가리키는 포인터는 프로그램 실행 동안 유효하다. 지역 문자 배열에서 같은 방식으로 추론하면 배열의 문자를 복사하지 않고 포인터를 보관하므로, 그 배열의 수명이 끝난 뒤에는 해당 문자를 접근할 수 없다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.