ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective Modern C++] Type deduction : Template
    C++/Effective Modern C++ 2021. 12. 21. 09:27

    형식 연역 (Type deduction)은 소스코드에서 특정한 코드가 어떤 것으로 바뀌는 것 입니다.

    여기서 '특정한 코드'란 template, auto, decltype 등 이 있으며

    바뀌게 되는 '어떤 것' 에는 자료형부터, 함수, 람다식 등이 있습니다.

     

    형식 연역은 특정한 규칙에 의거하여 이루어집니다.

    개발자가 추론한 것과 컴파일러가 실제로 연역한 것이 다를 수 있기 때문에, 이 규칙을 확실히 이해해야 합니다.

    귀찮고, 복잡하다고 안 쓰기에는 template과 auto는 굉장히 유용한 기능이기 때문입니다.

     

    이번 글에서는 template의 형식 연역에 대하여 살펴보도록 하겠습니다.

     


     

    함수 템플릿은 보통 아래와 같은 형태로 선언되고, 호출됩니다.

    //Declaration
    template<typename T>
    void function(ParamType param);
    
    //Call
    function(expr);

    위와 같이 선언된 함수 템플릿을 호출하는 코드를 컴파일 하게 될 경우

    컴파일러는 expr을 이용해 T와, ParamType 두 가지의 형식을 연역하게 됩니다.

    T와 ParamType이 다른 이유는, ParamType에는 const, &, &&등의 수식어가 붙기 때문입니다.

     

    예를 들어 아래와 같은 경우에서

    //Declaration
    template<typename T>
    void function(const T& param);
    
    //Call
    int x = 0;
    function(x);

    T는 int로 연역되지만, ParamType은 const int&로 연역됩니다.

    여기까지 본다면, T는 expr의 형식과 같게 되는 것이라고 기대할 수 있습니다.

    하지만, T는 expr뿐만 아니라 ParamType의 형태와도 연관이 있습니다.

     

    이 경우는 ParamType에 따라 3가지의 경우로 나뉩니다.

    1. ParamType이 포인터 혹은 참조 형식이지만 보편 참조(Universal reference)는 아닐 경우
    2. ParamType이 보편 참조일 경우
    3. ParamType이 포인터도 아니고 참조도 아닐 경우

    여기서 보편 참조란, r-value 혹은 l-value참조와는 다른 것 입니다.

    이에 관해서는 차후 다른 글에서 다루도록 하겠습니다.

    위와 같은 세 경우에 대하여, 템플릿의 형식 연역을 살펴보도록 하겠습니다.

     

    1. ParamType이 포인터 혹은 참조 형식이지만 보편 참조는 아닐 경우

     

    더보기

    형식 연역은 다음과 같이 진행됩니다.

    1. expr이 참조 형식이면 참조 부분을 무시합니다.
    2. 이후 expr의 형식을 ParamType에 대하여 Pattern-matching방식으로 대응시켜서 T의 형식을 결정합니다.

    예제를 살펴보도록 하겠습니다.

    //Declaration
    template<typename T>
    void function(T& param);
    
    //Variable
    int x = 27;
    const int cx = x;
    const int& rx = x;
    
    //Call
    function(x);
    function(cx);
    function(rx);

    위와 같은 변수와 호출에서, 각각 T와 ParamType은 아래 표와 같이 연역됩니다.

    Call T ParamType
    function(x) int int&
    function(cx) const int const int&
    function(rx) const int const int&

    위 진행 방식을 숙지했다면, 크게 어려울 점은 없습니다.

    주목해야 할 부분은 3번째 rx를 이용한 호출에서, T가 const int로 연역되었다는 부분입니다.

    이 또한 위 진행 방식에 맞게, expr의 참조 부분을 무시하는 과정에서 연역된 결과입니다.

     

    이번에는 param을 포인터로 바꿔서 살펴보겠습니다.

    //Declaration
    template<typename T>
    void function(T* param);
    
    //Variable
    int x = 27;
    const int *px = &x;
    
    //Call
    function(&x);
    function(px);

    위와 같은 변수와 함수 호출에서, 각각 T와 ParamType은 아래 표와 같이 연역됩니다.

    Call T ParamType
    function(&x) int int*
    function(px) const int const int*

    특별하게 주목할 부분이 없이, 형식 연역 과정을 잘 따라가면 나올 수 있는 결과입니다.

    모든 형식 연역 과정이 자명함을 볼 수 있습니다.

     

    2. ParamType이 보편 참조일 경우

     

    더보기

    템플릿이 보편 참조일 경우의 형식 연역은 다음과 같이 진행됩니다.

    • expr이 l-value일 경우, T와 ParamType 모두 l-value 참조로 연역됩니다.
      위 문단에서 T를 연역할 때 참조성을 제거한 것과 대조적입니다.
      또한, ParamType의 형태가 r-value 참조이지만, 연역되는 형식은 l-value 참조입니다.
    • expr이 r-value일 경우, 위 문단의 형식 연역 과정이 적용됩니다.

    예제를 살펴보도록 하겠습니다.

    //Declaration
    template<typename T>
    void function(T&& param);
    
    //Variable
    int x = 27;
    const int cx = x;
    const int& rx = x;
    
    //Call
    function(x); //l-value
    function(cx); //l-value
    function(rx); //l-value
    function(27); //r-value

     위와 같은 변수 선언과 함수 호출에서, T와 ParamType은 각각 아래 표와 같이 연역됩니다.

    Call T ParamType
    function(x) int& int&
    function(cx) const int& const int&
    function(rx) const int& const int&
    function(27) int int&&

    보편 참조가 관여하는 경우, 위의 표와 같이 r-value, l-value에 대하여 각각 다른 연역 규칙이 적용됩니다.

    그 외의 경우에는 위와 같은 일이 발생하지 않습니다.

     

    3. ParamType이 포인터도 아니고 참조도 아닐 경우

     

    더보기

    포인터도, 참조도 아닌 경우면 Pass by value의 상황입니다.

    값이 전달되는 경우의 형식 연역은 다음과 같이 진행됩니다.

    1. expr이 참조 형식이면 참조 부분을 무시합니다.
    2. 이후 expr이 const일 경우 const 또한 무시합니다.
    3. 이후 expr이 volatile일 경우 volatile 또한 무시합니다.

    예제를 살펴보도록 하겠습니다.

    //Declaration
    template<typename T>
    void function(T param);
    
    //Variable
    int x = 27;
    const int cx = x;
    const int& rx = x;
    
    //Call
    function(x);
    function(cx);
    function(rx);

     위와 같은 변수 선언과 함수 호출에서, T와 ParamType은 각각 아래 표와 같이 연역됩니다.

    Call T ParamType
    function(x) int int
    function(cx) int int
    function(rx) int int

    참조, const가 모두 무시되었음을 볼 수 있습니다.

    param이 원본 매개변수와 관련이 없는 복사된 변수이기 때문에, 원본 번수의 상수성과는 관계가 없습니다.

    여기서 주의할 점은, const가 매개변수에 대해서만 무시된다는 점 입니다.

     

    예제를 살펴보도록 하겠습니다.

    //Declaration
    template<typename T>
    void function(T param);
    
    //Variable
    const char* const ptr = "Pointer";
    
    //Call
    function(ptr);

    위처럼 const객체를 가리키는 const포인터의 경우, 아래와 같이 연역됩니다.

    Call T ParamType
    function(ptr) const char* const char*

    ptr을 function에 전달하면, ptr의 비트들이 param에 복사되어 전달됩니다.

    이후 Pass by value 방식의 형식 연역이 진행되어 param의 상수성이 제거, param은 const char*을 가리키는 수정 가능한 포인터가 되는 것 입니다.

    변수를 구성하는 모든 const가 사라지는 것이 아님에 유의합시다.

     

    4. 예외 : 배열에 대하여

     

    더보기

    배열의 경우에는 형식 연역이 조금 다르게 됩니다.

    이러한 예외는 많은 상황에서 배열이 포인터로 붕괴 (Decay)되는 것에서 기인합니다. 

    const char arr[] = "Array";
    const char* ptrArr = arr;

    위와 같은 경우가 대표적인 예 입니다.

    arr과 ptrArr의 형식은 다르지만, 배열이 포인터로 붕괴하면서 형식 불일치가 발생하지 않습니다.

    이 상황을 생각하면서, 다음 예제를 살펴보겠습니다.

    //Declaration
    template<typename T>
    void function(T param);
    
    //Variable
    const char arr[] = "Array";
    const char* ptrArr = arr;
    
    //Call
    function(arr);
    function(ptrArr);

    배열과 포인터에 대하여, Pass by value 방식의 템플릿에 연역되는 형식은 다음과 같습니다.

    Call T ParamType
    function(arr) const char* const char*
    function(ptrArr) const char* const char*

    배열 매개변수 선언은 포인터 매개변수로 취급되어 발생하는 결과입니다.

     

    하지만 매개변수가 참조 형식일 경우 즉, 다음과 같은 형식일 경우

    //Declaration
    template<typename T>
    void function(T& param);
    
    //Variable
    const char arr[] = "Array";
    
    //Call
    function(arr);

    T와 ParamType에 연역되는 형식은 다음과 같습니다.

    Call T ParamType
    function(arr) const arr[6] const char(&)[6]

     

    이것을 응용하면, 다음과 같이 배열의 원소의 개수를 연역하는 템플릿을 만들 수 있습니다.

    #include <iostream>
    #include <array>
    
    template<typename T, std::size_t N>
    constexpr std::size_t arraySize(T(&)[N]) noexcept {
        return N;
    }
    
    int main() {
        const char str[] = "Array";
    
        std::array<int, arraySize(str)> arr;
        std::cout << arr.size() << "\n";
    }
    6

     

     

    5. 예외 : 함수에 대하여

     

    더보기

    함수 또한 배열처럼 포인터로 연역되는 타입입니다.

    예제를 살펴보도록 하겠습니다.

    //Declaration
    template<typename T>
    void function1(T param);
    template<typename T>
    void function2(T& param);
    
    //Function
    void foo(int, double);
    
    //Call
    function1(foo);
    function2(foo);

    다음처럼 Pass by value, reference 방식의 템플릿이 존재할 때

    함수를 파라미터로 전달했을 경우 연역되는 형식은 아래와 같습니다.

    Call T ParamType
    function1(foo) void (*)(int, double) void (*)(int, double)
    function2(foo) void (&)(int, double) void (&)(int, double)

     

     


     

    전체적으로 특정한 규칙에 의거하여 연역이 이루어지는 것을 볼 수 있었습니다.

    서론에 언급한 컴파일러가 연역한 형식과 실제 형식이 다른 부분은 Universal reference부분과

    배열, 함수에 대한 포인터 연역 부분입니다.

    위 특정한 경우 외에는 직관적으로 연역됨을 볼 수 있었습니다.

     

    템플릿은 간편하고 강력한 기능입니다.

    하지만, 간편하다고 대충 알고 넘어가면 문제가 발생할 수 있습니다.

    이 글이 템플릿을 이해하는 데 조금이나마 도움이 되었으면 좋겠습니다.

    감사합니다.

    댓글

Designed by Tistory.