컴파일 타임에 함수의 반환 타입을 알아내는 방법

템플릿을 이용하여 컴파일 타임에 어떤 함수(std::function이나 람다와 같은 것들 모두 포함)의 반환 타입을 알아내고 싶으면, std::invoke_result 를 쓰면 됩니다. 그런데, 이 invoke_result는 어떤 원리로 작동하는 것일까요? 이 글에서는 std::invoke_result가 어떤 원리로 작동하는지 알아보도록 하겠습니다. 이 글을 읽으며 한 가지 주의해야 할 점은, 이 글을 작성하면서 <type_traits>(std::invoke_result의 정의가 들어있는 헤더) 헤더를 참고하지 않았기 때문에, 이 글에서 설명한 방법과 실제 std::invoke_result의 구현 방법이 다를 수 있다는 것입니다.

차례


  1. std::invoke_result
  2. 함수 역할을 하는 객체의 타입들
  3. decltype
  4. 함수(객체)의 타입만 알고 있는 경우
  5. 함수(객체)가 여러 개의 인자를 받아들이는 경우
  6. 지금까지 알아본 것들을 하나의 클래스 템플릿으로 만들기
  7. 멤버 함수 포인터에 대한 ReturnType의 특수화
  8. 마무리


차례 접기


  1. std::invoke_result

    std::invoke_result는 C++17에서 추가되었는데, 기본적인 사용 예제를 보고 넘어가도록 합시다.

    std::invoke_result 예제 코드 보기

  2. 함수 역할을 하는 객체의 타입들

    함수 역할을 하는 객체의 타입들은 여러가지가 있습니다.

    1. 함수 타입
      함수 포인터가 아니라는 점을 주의하시기 바랍니다. 다음 예제 코드를 보세요.

      함수 타입 예제 코드 보기

      예제 코드에서 볼 수 있듯, 함수 타입은 "반환타입(인자타입들)" 로 표현될 수 있습니다. 함수 타입을 함수 포인터 타입과 구분하는 이유는 "3. 함수(객체)의 타입만 알고 있는 경우" 에서 설명드리도록 하겠습니다.
    2. 함수 포인터 타입
      함수 포인터 타입은 "반환타입(*)(인자타입들)"로 표현될 수 있습니다.
    3. 함수 참조 타입
      함수 참조 타입은 "반환타입(&)(인자타입들)"로 표현될 수 있습니다.
    4. 함수 객체 타입
      operator () 연산자를 오버로딩한 클래스들을 말합니다.
    5. 표준 함수 객체 타입
      std::function 클래스 템플릿을 말합니다. 사실 이것은 바로 위의 함수 객체 타입의 일종이라고 볼 수 있습니다.
    6. 람다
      람다의 타입을 알아내려면 그 타입의 클로저가 하나 필요합니다. 람다의 타입은 "decltype(클로저)"로 표현될 수 있습니다. 람다와 클로저에 대한 자세한 설명은 Effective Modern C++6장을 참고하세요.
    7. 멤버 함수 포인터 타입
      멤버 함수 포인터 타입은 "반환타입(SomeType::*)(인자타입들) 한정자들" 로 표현될 수 있습니다.

    이 모든 것들의 반환 타입을 컴파일 타임에 알아낼 수 있는 템플릿 ReturnType(std::invoke_result와 같은 일을 함)을 구현하면서 어떤 원리로 작동하는지 알아보도록 하겠습니다.
  3. decltype

    decltype은 어떤 객체(또는 변수)의 식별자로부터 그 객체의 타입을 컴파일 타임에 알아내는 역할을 합니다(decltype에 대한 자세한 설명은 Effective Modern C++의 항목 3을 참고하세요).

    그리고 decltype은 sizeof와 같은 어떤 특성을 가지고 있습니다. 무슨 특성인가 하면 decltype/sizeof 다음에 오는 괄호 안에 들어 있는 표현식은 런타임에 실행되지 않으면서, 컴파일 타임에 그 결과를 반환해 준다는 특성입니다(이 특성을 이용한 사례를 Modern C++ Design2.7 섹션에서 확인할 수 있습니다).

    이 특성을 활용하여 함수를 (런타임에) 호출하지 않으면서, 함수 반환값의 타입을 알아 낼 수 있습니다. 다음 예제를 보세요.

    decltype 예제 코드 보기

    이처럼 인자를 받지 않는 함수(객체)를 가지고 있는 경우에는, decltype만 쓰면 쉽게 그 함수(객체)의 반환 타입을 알아낼 수 있습니다. 이것을 기반으로 조금씩 살을 붙여봅시다.
  4. 함수(객체)의 타입만 알고 있는 경우

    바로 위에서 우리는 decltype(함수호출 표현식)을 사용하면 해당 표현식에 사용된 함수의 반환 타입을 구할 수 있다는 사실을 알았습니다. 하지만 함수(객체)를 가지고 있는 것이 아니라 함수(객체)의 타입만 가지고 있는 경우라면 어떨까요? 함수 객체의 타입만 가지고는 호출 표현식을 만들 수 없습니다. 즉, 우리가 아는 함수 객체의 타입으로부터 함수 객체를 만들어 내야 비로소 함수 호출 문장을 작성할 수 있습니다(std::less<int>()(A, B) 와 같이 기본생성자를 위한 괄호가 인자 목록과 타입 식별자 사이에 들어간다는 점을 생각해 보세요).

    문제는 모든 함수 객체 타입이 기본 생성자를 지원하지 않는다는 것입니다. 대표적인 예로 함수의 참조자를 들 수 있습니다(생각해 보면 당연합니다. 참조자의 기본 생성자라뇨... 아무것도 참조하지 않는 참조자를 어디다 쓴단 말입니까?). 물론 사용자가 정의한, operator ()를 오버로딩한 클래스에 기본 생성자가 없을 수도 있습니다. 이 외에도 있겠지만 굳이 전부를 언급하지는 않겠습니다.

    이러한 문제를 해결하는 예시를 Modern C++ Design2.7 섹션에서도 찾을 수 있습니다. 바로 "2. decltype"에서 언급한 decltype의 특성을 활용하는 것입니다. 다음 예제를 보세요.

    함수(객체)의 타입만 알고 있는 경우 예제 코드 보기

    아직 한 가지 문제가 남아 있습니다. "1. 함수 역할을 하는 객체의 타입들"에서 함수 타입과 함수 포인터 타입이 다르다는 점을 강조했다는 사실을 기억하십니까? 다른 모든 타입들은 MakeT와 같은 함수 템플릿을 활용하는 방법이 통하지만 함수 타입에 대해서는 이 방법이 통하지 않습니다(나중에 보게 되겠지만, 멤버 함수 포인터에 대해서도 통하지 않습니다. 멤버 함수를 호출하는 문법 자체가 일반 함수를 호출하는 문법과 다르기 때문이죠).

    따라서 우리가 궁극적으로 만들고자 하는 ReturnType 클래스 템플릿은, 템플릿 인자로 함수 타입을 받는 경우에 대해 특수화 버전을 만들어 줘야 합니다.
  5. 함수(객체)가 여러 개의 인자를 받아들이는 경우

    지금까지는 인자를 하나도 받지 않는 함수(객체)가 반환하는 값의 타입만을 구해 보았습니다. 이번에는 여러 개의, 다양한 타입의 인자를 받아들이는 함수(객체)에 대한 반환 타입을 구해 봅시다.

    함수를 호출하려면, 호출하려는 함수가 받아들이는 인자의 타입에 맞는 값(객체)를 넘겨줄 필요가 있습니다. 여기서도 "3. 함수(객체)의 타입만 알고 있는 경우"에서와 같이 모든 종류의 객체를, 인자를 받아들이지 않는 기본 생성자로만 생성할 수 없다는 문제에 직면하게 됩니다. 따라서 해결방법도 비슷합니다. "3. 함수(객체)의 타입만 알고 있는 경우" 에서 만들었던 MakeT 함수 템플릿을 재활용 하는 것입니다. 다음 예제 코드를 보세요.

    함수(객체)가 여러 개의 인자를 받아들이는 경우 예제 코드 보기

    이렇게 여러 개의 인자를 받아들이는 경우, 인자들의 타입도 모두 알고 있어야 합니다. 이 때문에, std::invoke_result(우리가 만들어볼 ReturnType 클래스 템플릿도 마찬가지 입니다)는 함수 객체 타입 외에도 인자들의 타입을 별도로 받아들이는 것이죠.
  6. 지금까지 알아본 것들을 하나의 클래스 템플릿으로 만들기

    지금까지 알아낸 것들을 하나의 클래스 템플릿 ReturnType으로 만들어 봅시다. 다음 예제 코드를 보세요.

    ReturnType 예제 코드 보기

    하지만 아직 문제가 남아 있습니다. "3. 함수(객체)의 타입만 알고 있는 경우"의 마지막에 살짝 언급했던 것을 기억하십니까? MakeT 함수 템플릿을 함수 타입(함수 포인터 타입이 아님에 주의)에 적용할 수 없다는 것입니다. 그래서, 함수 타입을 위한 ReturnType의 특수화 버전을 만들어 줘야 합니다. 다음 예제를 보세요.

    함수 타입에 대한 ReturnType의 특수화 버전 예제 코드 보기

  7. 멤버 함수 포인터에 대한 ReturnType의 특수화

    이제 멤버 함수 포인터를 생각해 보도록 합시다. 기존의 함수(객체)들은 호출할 때, 넘겨줄 인자들을 담는 괄호만 붙여줘서 호출을 할 수 있습니다. 하지만, 멤버 함수 포인터는 대상 객체가 필요합니다. 따라서 이 경우도 템플릿 특수화 버전을 만들어 줘야 합니다. 다음 예제를 보세요.

    예제 코드 보기

    그런데 여기서 한 가지 문제가 더 생깁니다. 바로 멤버 함수에는 한정사가 있다는 것입니다. const, volatile 뿐이라면 조금 낫겠지만, C++11부터 추가된 참조 한정사 &, &&와 noexcept 까지 고려하면 경우의 수가 매우 많아집니다. 그래서, 이 글을 쓸 때 표준 라이브러리 헤더를 열어보지 않으려고 했었는데, 결국 표준 라이브러리 헤더 파일을 열어서 이 문제를 어떻게 해결했는지 찾아봤습니다. 결론부터 말씀드리자면, 매크로 함수를 이용하여 해결을 했습니다. 표준 라이브러리에는 호출 규약 등에도 적용하여 좀 더 복잡하게 되어 있었는데 여기서는 간단하게 const, volatile과 참조 한정사, 그리고 noexcept 들에만 적용시켜 보겠습니다. 다음 예제를 보세요.

    멤버 함수의 한정사 해결 예제 코드 보기

  8. 마무리

    이제 위에서 보았던 예제들을 하나로 통합해 보겠습니다.

    ReturnType 예제 코드 보기

    눈치채신 분들도 있겠지만 사실, 함수 타입, 함수 포인터 타입, 함수 참조자 타입, 그리고 멤버 함수 타입에 대해서는 인자들의 타입 목록을 넘겨주지 않도록 구현하는 것도 가능합니다. 다음 예제를 보세요.

    예제 코드 보기

    이렇게 만들 수도 있는데 왜 std::invoke_result는 항상 인자들의 타입을 넘겨주게 만들었을까요? 아마도 인터페이스의 일관성을 유지하기 위해서일 것입니다. 람다나 operator ()을 오버로딩한 클래스 등과 같은 경우에는 인자들의 타입 목록을 생략할 수 있는 방법이 없기 때문이죠. 만약 함수 (포인터/참조자) 타입과 멤버 함수 포인터 타입만 특별 취급을 하게 된다면, 나중에 소스 코드에서 함수 타입을 사용하다가 그것을 람다로 바꾸었을 때 소스 코드의 많은 부분을 고치는 노동을 해야 할 수도 있습니다. 또한 템플릿 함수에 함수 객체를 넘겨줄때, 함수 (포인터/참조자) 타입에 대해서는 항상 특수화 코드를 짜 주어야 하는 번거로움도 생길 수 있겠죠. 이렇게 인터페이스에 일관성이 없으면 득보다 실이 많다고 판단되었기에, std::invoke_result는 약간의 불편을 감수하더라도 인터페이스가 일관성을 가지도록 만들었을 것입니다.




다른 글들의 목록을 보려면 이 이미지를 클릭해 주세요.