이번 포스팅에서는 이동 의미론(move semantics)과 관련된 템플릿 문법과 enable_if를 활용한 조건부 템플릿 활성화를 다룰 예정이다.

완벽한 전달

전달된 인자의 기본 속성을 전달하는 일반 코드를 작성하고자 한다.

  • 수정 가능한 상태 (참조)
  • 읽기 전용 객체 (상수)
  • 이동 가능한 객체 (우측값 참조)

이때 인자를 전달하는 함수 f()를 템플릿 없이 구현할 경우 다음과 같이 작성할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <utility>
#include <iostream>

class X{
    //...
};

void g(X&) { std::cout << "g() for variable\n"; }
void g(const X&) { std::cout << "g() for constant\n"; }
void g(X&&) { std::cout << "g() for movable object\n"; }

// 기본 속성을 g()로 전달한다.
void f(X& val) { g(val);}
void f(const X& val) { g(val);}
void f(X&& val) { g(std::move(val));}   

int main() {
    X v;
    X const c;
    f(v);   // f(X&) -> g(X&)
    f(c);   // f(X const&) -> g(X const&)
    f(X()); // f(X&&) -> g(X&&)
    f(std::move(v)); // f(X&&) -> g(X&&)
}

이때 f(X&& val)val이 우측값 참조자로 선언되어 있지만, 해당 변수가 표현식으로 쓰일 때의 값 카테고리(value category)는 비상수 lvalue로 분류된다. 그러므로 std::move()를 사용하지 않으면 g(X&)를 호출하게 된다.

위 코드에서 f()함수를 템플릿화 시킬 경우 다음과 같이 작성해야 한다.

1
2
3
4
5
6
7
8
9
10
11
// OK, 인자에 따라 세 가지 버전의 g()이 호출된다
template<typename T>
void f(T&& val) {  
    g(std::forward<T>(val));
}

// 잘못된 코드, g(X&&)를 호출할 수 없다.
template<typename T>
void f(T val) { 
    g(T);
}

std::move()는 인자에 대한 이동을 ‘촉발’시키는데 반해, std::forward<>는 전달받은 인자에 따라 잠재적인 이동을 ‘전달’시킨다.

이때 템플릿 파라미터 T에 대한 T&&는 문법적으로는 X&&와 동일하나, 이는 우측값 참조자가 아닌 전달 참조자(forwarding reference)이다. 이는 수정할 수 있는 객체나 수정할 수 없는(const) 객체, 혹은 이동 가능한 객체를 나타낼 수 있다(만능이다).

이때 T가 템플릿 파라미터의 진짜 이름이어야 하며, 템플릿 파라미터에 종속되는 것만으로는 충분하지 않다. 예를 들어 템플릿 파라미터 T가 있을 경우 다음 선언은 우측값 참조일뿐 전달 참조자가 아니다.

1
typename T::iterator&& iter;    // rvalue reference

물론 가변 인자 템플릿에서도 완벽한 전달(perfect forwarding)을 수행할 수 있다.

특수 멤버 함수 템플릿

멤버 함수 템플릿은 생성자를 포함한 특수 멤버 함수에 쓰일 수 있다. 다음 코드를 보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <utility>
#include <string>
#include <iostream>

class Person {

private:
    std::string name;

public:
    // 생성자
    explicit Person(const std::string& n): name(n) {}
    explicit Person(std::string&& n): name(std::move(n)) {}

    // 복사, 이동 생성자 
    Person(const Person& p): name(p.name) {}
    Person(Person&& p): name(std::move(p.name)) {}
};

int main() {
    std::string s = "name";
    Person p1(s);               // Person(const std::string&)
    Person p2("tmp");           // Person(std::string&&) 
    Person p3(p1);              // Person(const Person&)
    Person p4(std::move(p1));   // Person(Person&&)
    return 0;
}

여기에서 앞서 설명한 전달 참조자(T&&)를 통해 두 개의 문자열 생성자 대신 전달받은 인자를 멤버 name의 초기값으로 전달하는 일반 생성자를 만들 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
class Person {

private:
    std::string name;

public:
    template<typename STR>
    explicit Person(STR&& n): name(std::forward<STR>(n)) {}

    Person(const Person& p): name(p.name) {}
    Person(Person&& p): name(std::move(p.name)) {}
};

하지만 위 코드의 경우 치명적인 오류가 있다. 다음 코드를 보자.

1
2
3
Person p1("name");  // OK
Person p2(p1);  // 컴파일 에러, 복사생성자 대신 Person(STR&&)를 호출한다.
Person p3(std::move(p1));   // OK

c++ 오버로딩 해석 규칙에 따라 비상수 lvalue인 Person p에 대해는 복사 생성자보다 전달 참조자를 사용한 생성자에 우선순위가 간다.

1
2
3
4
template<typename STR>
Person(STR&& n);    // 우선순위가 더 높다

Person(const Person& p);

STRPerson&로 치환하기만 하면 되지만, 복사 생성자를 사용하려면 const로 변환해야하기 때문이다.

이와 같은 상황을 해결하기 위해 다음과 같이 비상수 복사 생성자를 제공할 수도 있지만,

1
Person(Person& p);

이렇게 하더라도 파생 클래스의 객체에 대해서는 여전히 멤버 템플릿이 우선순위가 더 높다.

가장 좋은 방법은 인자가 Person 인스턴스일 경우 문제가 되는 멤버 템플릿을 비활성화 시키는 것이다.

enable_if (c++11)

std::enable_if<>는 c++11에서 타입 트레잇에 추가된 템플릿이다. 컴파일 시간에 특정 조건에 따라 함수 템플릿을 활성화(혹은 비활성화) 시킬 수 있도록 해준다.

다음 코드를 보자

1
2
3
4
5
template<typename T>
typename std::enable_if<(sizeof(T)>4)>::type
foo() {
    // ...
}

만일 sizeof(T)가 4보다 같거나 작을경우 해당 함수 템플릿의 정의는 무시한다. 만일 해당 조건에 부합할 경우 이 함수 템플릿은 다음과 같이 인스턴스화 된다.

1
2
3
void foo(){
    // ...
}

std::enable_if<>는 컴파일 시간에 다음과 같은 작업을 수행한다.

  • 표현식이 참일 경우 타입 멤버 type은 두 번째 템플릿 인자의 타입이 된다.
    • 두 번째 템플릿 인자가 전달되지 않았을 경우 void타입이 된다.
  • 표현식이 거짓일경우 멤버 type은 정의되지 않는다.
    • SFINAE에 의해 enable_if표현식을 가진 함수 템플릿이 무시된다.

참고로 c++14에서는 타입을 도출하는 모든 타입 트레잇에 대해 typename::type을 생략할 수 있게 되었으며 위 코드는 다음과 같이 바꿀 수 있다.

1
2
3
4
5
template<typename T>
std::enable_if_t<(sizeof(T)>4)>
foo() {
    // ...
}

선언 중간에 반환형으로써 enable_if 표현식을 사용하는 것은 그다지 보기 좋은 코드는 아니다.
그래서 보통은 다음과 같이 기본값을 갖는 부가적인 함수 템플릿 인자를 사용한다.

1
2
3
4
5
template<typename T,
    typename = std::enable_if_t<(sizeof(T) > 4)>>
void foo() {
    // ...
}

다음과 같이 별칭 템플릿을 사용하면 활성화 조건을 보다 구체적으로 표현할 수 있다

1
2
3
4
5
6
7
template<typename T>
using EnableIfSizeGreater4 = std::enable_if_t<(sizeof(T)>4)>;

template<tyupename T, typename = EnableIfSizeGreater4<T>>
void foo() {
    // ...
}

Person(STR&&) 생성자 문제 개선

enable_if<>를 통해 앞서 보았던 Person 클래스 생성자 오버로딩 문제를 해결할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Person {

private:
    std::string name;

public:
    template<typename STR,
        typename = std::enable_if_t<
            std::is_convertible_v<STR, std::string>>>
    explicit Person(STR&& n): name(std::forward<STR>(n)) {}

    Person(const Person& p): name(p.name) {}
    Person(Person&& p): name(std::move(p.name)) {}
};

STRstd::string으로 변환할 수 있다면 선언 전체가 다음과 같이 확장된다.

1
2
template<typename STR, typename = void>
Person(STR&& n);

만일 인자를 std::string으로 변환할 수 없을 경우 해당 함수 템플릿을 무시한다.

1
2
3
4
5
6
int main() {
    Person p1("name");  // OK
    Person p2(p1);      // OK, Person(STR&&) 무시, 복사 생성자 호출
    Person p3(std::move(p1));   // OK
    return 0;
}

마찬가지로 별칭 템플릿을 활용해 해당 enable_if문을 보다 명시적으로 변경할 수 있다.

1
2
3
4
5
6
7
template<typename T>
using EnableIfString = std::enable_if_t<
    std::is_convertible_v<STR, std::string>>;

template<typename STR,
    typename = EnableIfString<STR>>
explicit Person(STR&& n): name(std::forward<STR>(n)) {}

특수 멤버 함수 비활성화

일반적으로 사전 정의된 복사/이동 생성자와 할당 연산자는 enable_if<>를 사용해 비활성화시킬 수 없다.
기본적으로 멤버 함수 템플릿은 특수 멤버 함수로 간주하지 않는다. 다음 코드를 보자

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class C{
public:
    template<typename T>
    C(const T&) {
        std::cout << "Template Copy Constructor\n";
    }
    //...
}

int main() {
    C x;
    C y{x}; // 사전 정의된 복사 생성자를 사용하며, 멤버 템플릿은 사용하지 않음
    return 0;
}

이와 같은 경우 사전 정의된 복사 생성자 대신 멤버 템플릿을 사용하고자 할 경우 가전 정의된 복사 생성자가 삭제되었음을 명시하는 방법을 사용할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
class C{
public:
    C(const volatile C&) = delete;  // 사전 정의 복사 생성자
    // 최대한 구체적으로 명시하기 위해 volatile 키워드를 붙여준다 

    template<typename T>
    C(const T&) {
        std::cout << "Template Copy Constructor\n";
    }
    //...
}

concept (c++20)

별칭 템플릿을 사용하더라도 enable_if<> 문법은 상당히 가독성이 떨어진다.

이에 c++20에서 새로 추가된 concept를 통해 템플릿 인자에 대한 요구 사항(제약 사항)을 표현할 수 있다.

앞서 보았던 Person(STR&&)을 다음과 같이 수정할 수 있다.

1
2
3
template<typename STR>
requires std::is_convertible_v<STR, std::string>
Person(STR&& n): name(std::forward<STR>(n)) {}

또한 해당 요구 사항을 일반적인 개념(Concepts)으로 명시할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 네임스페이스 스코프에서 정의해야 한다. (클래스 내부 X)
template<typename STR>
concept ConvertibleToString = std::is_convertible_v<STR, std::string>;

class Person {

public:
    template<typename STR>
    requires ConvertibleToString<STR>
    Person(STR&& n): name(std::forward<STR>(n)) {}

    // ...
}

requires구문 대신 다음과 같은 형태로도 사용 가능하다.

1
2
template<ConvertibleToString STR>
Person(STR&& n): name(std::forward<STR>(n)) {}

Constraints and concepts