본문 바로가기
프로그래밍/C & C++

[C++] 템플릿에서 이동의미론과 enable_if<>

by 별준 2023. 1. 2.

References

  • Ch6, C++ Templates The Complete Guide

Contents

  • Perfect Forwarding
  • Special Member Function Templates
  • std::enable_if<>

C++11에서 도입된 중요한 특징 중 하나가 바로 이동의미론(move semantics) 입니다. 이를 통해 원본 객체 내의 리소스를 다른 객체로 복사하는 것이 아닌 이동시켜 복사 및 할당을 최적화할 수 있습니다. 이는 원본 객체의 내부 값이나 상태가 복사한 뒤 더 이상 필요하지 않을 때 사용할 수 있습니다.

 

이동의미론이 도입되면서 템플릿 디자인에 많은 영향을 미쳤는데, 제너릭 코드에서 이동의미론을 지원하기 위해 특별한 규칙들도 도입되었습니다. 이번 포스팅에서는 이에 대해서 살펴보도록 하겠습니다.

 


Perfect Forwarding

전달된 인자의 기본 속성(basic property)을 전달하는 제너릭 코드를 다음과 같은 조건으로 작성한다고 가정해봅시다.

  • 수정이 가능한 객체는 전달된 뒤에서 여전히 수정할 수 있는 상태이어야 한다
  • 상수 객체는 read-only 객체로 전달되어야 한다
  • 이동 가능한 객체(이동 후 사라질 객체)는 이동 가능한 객체로 전달되어야 한다

위와 같은 기능을 템플릿없이 구현하려면 세 가지 상황에 대한 코드를 각각 작성해야 합니다. 예를 들어, f()에 대한 호출을 g()라는 함수로 전달한다고 가정하고 구현하면 다음과 같습니다.

#include <iostream>
#include <utility>

class X {

};

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

// let f() forward argument val to g()
void f(X& val) {
    g(val); // val is non-const lvalue => calls g(X&)
}
void f(X const& val) {
    g(val); // val is const lvalue => calls g(X const&)
}
void f(X&& val) {
    g(std::move(val)); // val is non-const lvalue => needs std::move() to call g(X&&)
}

int main()
{
    X v;
    X const c;

    f(v);            // f() for non-constant object calls f(X&) => calls g(X&)
    f(c);            // f() for constant object calls f(X const&) => calls g(X const&)
    f(X());          // f() for temporary calls f(X&&) => calls g(X&&)
    f(std::move(v)); // f() for movable variable calls f(X&&) => calls g(X&&)
}

위 코드에서는 자신의 인자를 g()로 전달하는 세 가지 버전의 f()를 구현했습니다. f()를 호출할 때, 이동 가능한 객체(rvalue 참조자)를 위한 코드는 그 외의 코드와 모양이 다르다는 점에 유의합니다. 위 코드에서는 언어 규칙에 따라 이동의미론이 자동으로 적용되지 않기 때문에 std::move()를 사용하고 있습니다. 세 번째 f() 호출에서 전달되는 인자가 rvalue 참조자이지만, 표현식으로 사용될 때 이 값 카테고리(value category)는 non-constant lvalue이며, 첫 번째 f() 호출에서의 val처럼 동작합니다. 따라서, f(X&& val) 내에서 move()가 없다면 g(X&&) 대신 non-constant lvalue에 대해 호출되는 g(X&)가 호출됩니다.

 

이렇게 구현한 세 가지 버전의 f()를 템플릿을 사용하여 하나로 묶으려고 하면 문제가 발생합니다.

template<typename T>
void f(T val) {
    g(val);
}

위 코드는 처음 두 케이스에서는 잘 동작하지만, 이동 가능한 객체를 전달받은 세 번째(와 네 번째) 케이스에 대해서는 잘 동작하지 않습니다.

이 때문에 C++11에서는 이러한 파라미터를 완벽하게 전달하는 특별한 규칙을 만들었습니다. 이러한 완벽한 전달(perfect forwarding)을 적용하려면 다음과 같은 코드 패턴을 적용하면 됩니다.

template<typename T>
void f(T&& val) {
    g(std::forward<T>(val));
}

[C++] Perfect Forwarding

예전에 위 포스팅에서 완벽한 전달에 대해 부족하지만 알아본 포스팅이 있으니 필요하시다면 참조바랍니다.

 

std::move()는 템플릿 파라미터가 없으며, 전달받은 인자에 대해 이동의미론을 '트리거'합니다. 반면 std::forward()는 전달된 템플릿 인자에 따라 잠재적으로 이동의미론을 '트리거'시킨다는 점에 유의합시다.

또한, 템플릿 파라미터 T에 대한 T&&이 특정 타입 X에 대해서 항상 X&&로 동작한다고 오해하면 안됩니다. 문법적으로는 그렇게 보이지만 다른 규칙이 적용됩니다.

  • 특정 타입 X에 대한 X&&는 파라미터를 우측값 참조자(rvalue reference)로 선언합니다. 이는 이동 가능한 객체에만 사용될 수 있습니다 (임시 객체와 같은 prvalue, 또는 std::move()로 전달된 객체와 같은 xvalue). 이렇게 전달된 파라미터는 항상 수정될 수 있으며, 그 값이 이동될 수 있습니다.
  • 템플릿 파라미터 T에 대한 T&&은 forwarding reference(전달 참조자, 또는 universal reference라고도 부름)를 선언합니다. 이는 수정할 수 있거나, 수정할 수 없거나(const), 또는 이동 가능한 객체를 나타낼 수 있습니다. 즉, 함수 선언 내에서 파라미터를 수정할 수 있을 수도 있고, 없을 수도 있으며, 그 안의 값을 이동할 수 있는 값을 나타낼 수도 있습니다.

여기서 T는 반드시 템플릿 파라미터의 이름이어야 한다는 것에 주의합니다. 템플릿 파라미터에 의존하는 것만으로는 충분하지 않습니다. 만약 템플릿 파라미터 T가 있을 때, typename T::iterator&&와 같은 선언은 그냥 우측값 참조자일 뿐 전달 참조자(forwarding reference)가 아닙니다.

 


Special Member Function Templates

멤버 함수 템플릿(member function template)은 생성자를 포함한 특수 멤버 함수에서도 사용될 수 있습니다. 그러나, 동작 방식은 상당히 놀랍습니다. 예를 들어, 다음 예제 코드를 살펴봅시다.

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

class Person
{
private:
    std::string name;

public:
    // constructor for passed initial name
    explicit Person(std::string const& n) : name(n) {
        std::cout << "copying string-CONSTR for '" << name << "'\n";
    }
    explicit Person(std::string&& n) : name(std::move(n)) {
        std::cout << "moving string-CONSTR for '" << name << "'\n";
    }
    // copy and move constructor
    Person(Person const& p) : name(p.name) {
        std::cout << "COPY-CONSTR Person '" << name << "'\n";
    }
    Person(Person&& p) : name(std::move(p.name)) {
        std::cout << "MOVE-CONSTR Person '" << name << "'\n";
    }
};

int main()
{
    std::string s = "sname";
    Person p1(s);             // init with string object => calls copying string-CONSTR
    Person p2("tmp");         // init with string liternal => calls moveing string-CONSTR
    Person p3(p1);            // copy Person => calls COPY-CONSTR
    Person p4(std::move(p1)); // move Person => calls MOVE-CONSTR
}

위에서 정의한 Person 클래스는 멤버 변수로 name을 가지고 있으며, 생성자를 통해 name을 초기화합니다. 여기에서는 이동의미론을 지원하기 위해 두 가지 생성자를 구현했습니다.

  • name을 전달된 인자의 복사본으로 초기화하는 생성자
  • 이동 가능한 문자열 객체를 위해 std::move()를 사용하는 버전

위 코드를 실행하면 아래의 결과를 얻을 수 있습니다.

예상대로 사용 중인 문자열 객체(lvalue)를 전달하면 복사 버전의 생성자가 호출되고, 이동 가능한 객체(rvalue)를 전달하면 이동 버전의 생성자가 호출됩니다.

생성자 이외에도 Person 전체를 복사하고 이동할 때 사용할 수 있는 복사 생성자와 이동 생성자도 구현되어 있습니다.

 

이제 두 개의 생성자가 아닌 하나의 생성자를 통해 전달받은 인자를 name으로 완벽히 전달하는 제너릭 생성자를 구현해보겠습니다.

class Person
{
private:
    std::string name;

public:
    // generic constructor for passed initial name
    template<typename STR>
    explicit Person(STR&& n) : name(std::forward<STR>(n)) {
        std::cout << "TMPL-CONSTR for '" << name << "'\n";
    }
    // copy and move constructor
    Person(Person const& p) : name(p.name) {
        std::cout << "COPY-CONSTR Person '" << name << "'\n";
    }
    Person(Person&& p) : name(std::move(p.name)) {
        std::cout << "MOVE-CONSTR Person '" << name << "'\n";
    }
};

위 코드는 전달받은 문자열로 생성하는 것은 예상한대로 잘 동작합니다.

std::string s = "sname";
Person p1(s);             // init with string object => calls TMPL-CONSTR
Person p2("tmp");         // init with string liternal => calls TMPL-CONSTR

이동 가능한 객체로 새로운 Person을 초기화하는 것도 여전히 잘 동작합니다.

Person p4(std::move(p1)); // OK: move Person => calls MOVE-CONSTR

하지만, 복사 생성자를 호출하려고 하면 컴파일 에러가 발생합니다.

Person p3(p1); // ERROR

단, 상수인 Person 객체를 복사하는 것은 잘 동작합니다.

Person const p2c("ctmp"); // init constant object with string literal
Person p3c(p2c);          // OK: copy constant Person => calls COPY-CONSTR

 

복사 생성자 호출에서의 에러는 C++의 overload resolution rules를 따라서 non-constant lvalue인 Person p에 대해서 아래의 멤버 템플릿이

template<typename STR>
Person(STR&& n)

아래와 같은 복사 생성자보다 더 일치한다고 판단하기 때문입니다.

Person(Person const& p)

STR은 Person&으로 치환하기만 하면 되는데, 복사 생성자를 쓰려면 const로 변환해야 하기 때문입니다.

 

이를 해결하기 위해서 다음과 같이 non-constant 복사 생성자를 제공하면 되지 않을까 생각해볼 수도 있습니다.

Person(Person& p)

하지만, 부분적으로만 해결될 뿐입니다. 파생 클래스의 객체에 대해서는 여전히 멤버 템플릿에 일치합니다. 실제로 필요한 것은 전달된 인자가 Person이거나 Person으로 변환될 수 있는 표현식인 경우에 대한 멤버 템플릿을 비활성화하는 것입니다.

 

이런 경우에는 아래에서 살펴볼 std::enable_if<>를 사용하면 해결할 수 있습니다.

 


std::enable_if<>

Diable Templates with enable_if<>

C++11에서부터는 특정 컴파일 조건에서 함수 템플릿을 무시하도록 해주는 헬퍼 템플릿인 std::enable_if<>를 표준 라이브러리에서 제공합니다. 예를 들어, 함수 템플릿 foo<>()가 다음와 같이 정의되었다고 가정해봅시다.

template<typename T>
typename std::enable_if<(sizeof(T) > 4)>::type
foo() {
}

만약 sizeof(T) > 4가 false라면, foo<>()의 정의는 무시됩니다. 반대로 true라면, 이 함수 템플릿 인스턴스를 아래와 같이 확장합니다.

void foo() {
}

 

즉, std::enable_if<>는 첫 번째 템플릿 인자로 주어진 compile-time expression을 평가하는 타입 특질(type trait)이며, 다음과 같이 동작합니다.

  • 주어진 표현식이 true라면, 자신의 타입 멤버인 type은 다음과 같은 타입을 도출합니다.
    • 두 번째 인자가 전달되지 않았다면 void
    • 그 외의 경우에는 두 번째 템플릿 인자의 타입
  • 표현식이 false라면, 멤버로서의 type이 정의되지 않는다. SFINAE(subsitution failure is not an error, 치환 실패는 에러가 아님)이라는 템플릿의 특징 때문에 enable_if 표현식을 가진 함수 템플릿은 무시된다.

C++14에서는 타입을 도출하는 모든 타입 특질에 대해 typename과 ::type를 생략할 수 있도록 해주는 std::enable_if_t<>라는 별칭 템플릿(alias template)이 제공되며, 따라서, 다음과 조금 더 짧게 작성할 수 있습니다.

template<typename T>
std::enable_if_t<(sizeof(T) > 4)>
foo() {
}

 

두 번째 인자가 전달되면,

template<typename T>
std::enable_if_t<(sizeof(T) > 4), T>
foo() {
    return T();
}

enable_if 표현식이 true인 경우에만 두 번째 인자로 확장됩니다.

 

함수 선언 중간에 enable_if 표현식을 사용하는 것은 그다지 세련된 코드는 아니며, std::enable_if<>를 사용할 때는 보통 기본값을 갖는 부가적인 함수 템플릿 인자를 사용합니다.

template<typename T,
    typename = std::enable_if_t<(sizeof(T) > 4)>>
void foo() {
}

위 코드는 sizeof(T) > 4인 경우, 아래와 같이 확장됩니다.

template<typename T,
    typename = void>
void foo() {
}

 

위 코드도 싫고, 요구/제한 사항을 조금 더 명시적으로 드러내고 싶다면 using을 사용하여 다음과 같이 사용할 수도 있습니다.

template<typename T>
using EnableIfSizeGreater4 = std::enable_if_t<(sizeof(T) > 4)>;

template<typename T,
    tyepname = EnableIfSizeGreater4<T>>
void foo() {
}

 

Using enable_if<>

이제 enable_if<>를 사용하여 Person 클래스의 문제(복사 생성자 호출 문제)를 해결하는 방법에 대해서 살펴보겠습니다.

제너릭 생성자와 복사 생성자가 존재할 때 발생했던 이 문제는 근본적으로 생성자에 전달된 (non-const) Person 객체가 Person const&를 인자로 받는 복사 생성자가 아닌 STR을 인자로 받는 생성자에 더 잘 일치해서 발생한 문제였습니다.

 

이 문제는 전달된 STR이라는 인자의 타입에 제한을 두어서 해결할 수 있습니다. 예를 들면, 의도에 맞게 std::string이나 std::string으로 변환될 수 있는 타입으로 제한을 두어서 그 이외에 템플리 생성자의 선언을 비활성화합니다. 이렇게 하면 Person 클래스는 std::string으로 변환될 수 있는 타입이 아니기 때문에 STR로 Person을 받는 생성자의 선언이 비활성화됩니다.

 

이는 std::is_convertible<FROM, TO> 타입 특질을 사용하여 해결할 수 있습니다. C++17에서 다음과 같이 선언하면 됩니다.

(C++14에서는 _v 버전이 타입 특질이 제공되지 않으므로, std::is_convertible<>::value를 사용해야 합니다)

template<typename STR,
    typename = std::enable_if_t<
        std::is_convertible_v<STR, std::string>>>
Person(STR&& n);

만약 STR을 std::string으로 변환할 수 있다면, 전체 선언은 다음과 같이 확장됩니다.

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

만약 std::string으로 변환할 수 없다면 함수 템플릿 자체를 무시합니다.

 

여기에서도 별칭 템플릿을 사용하여 제약 사항에 대해 별도로 정의할 수 있습니다.

template<typename T>
using EnableIfString = std::enable_if_t<
    std::is_convertible_v<T, std::string>>>;
...
template<typename T, typename = EnableIfString<STR>>
Person(STR&& n);

 

결과적으로 다음과 같이 Person 클래스를 구현하면, 위에서 발생했던 문제를 해결하여 정상적으로 컴파일이 되고, 예상한 결과가 출력되는 것을 볼 수 있습니다.

#include <iostream>
#include <utility>
#include <string>
#include <type_traits>

template<typename T>
using EnableIfString = std::enable_if_t<std::is_convertible_v<T, std::string>>;

class Person
{
private:
    std::string name;

public:
    // generic constructor for passed initial name
    template<typename STR, typename = EnableIfString<STR>>
    explicit Person(STR&& n) : name(std::forward<STR>(n)) {
        std::cout << "TMPL-CONSTR for '" << name << "'\n";
    }
    // copy and move constructor
    Person(Person const& p) : name(p.name) {
        std::cout << "COPY-CONSTR Person '" << name << "'\n";
    }
    Person(Person&& p) : name(std::move(p.name)) {
        std::cout << "MOVE-CONSTR Person '" << name << "'\n";
    }
};

int main()
{
    std::string s = "sname";
    Person p1(s);             // init with string object => calls TMPL-CONSTR
    Person p2("tmp");         // init with string liternal => calls TMPL-CONSTR
    Person p3(p1);            // copy Person => calls COPY-CONSTR
    Person p4(std::move(p1)); // move Person => calls MOVE-CONSTR
}

 

참고로 EnableIfString은 아래 코드의 축약 버전입니다.

(아래 처럼 사용하면 C++11 에서도 사용 가능 합니다)

template<typename T>
using EnableIfString
    = typename std::enable_if<std::is_convertible<T, std::string>::value>::type;

 

암묵적인 형 변환을 위해서는 std::is_convertible이 아닌 std::is_constructible<>을 사용하면 되는데, 이때, 인자의 순서는 바꿔주어야 합니다.

template<typename T>
using EnableIfString = std::enable_if<
    std::is_constructible_v<std::string, T>>;

 

Disabling Special Member Functions

일반적으로 pre-defined copy/move 생성자와 할당 연산자를 enable_if<>로 비활성화시킬 수 없습니다. 멤버 함수 템플릿은 특수 멤버 함수로 간주되지 않으며, 예를 들어, 복사 생성자가 필요하더라도 무시됩니다.

다음의 클래스 정의를 살펴봅시다.

class C
{
public:
    C() {}
    template<typename T>
    C(T const&) {
        std::cout << "tmpl copy constructor\n";
    }
};

위 코드에서 복사 생성자를 호출하도록 하면, 템플릿 복사 생성자가 아닌 선정의된 복사 생성자가 호출됩니다.

C x;
C y{x}; // still uses the predefined copy constructor (not the member template)

즉, 위 코드를 실행시켜도 "tmpl copy constructor"가 출력되지 않는 것을 확인할 수 있습니다.

 

선정의된 복사 생성자를 delete하는 것도 정답은 아닙니다. delete하면 컴파일 에러가 발생하게 됩니다.

편법으로 이를 해결할 수 있는데 const volatile을 받는 복사 생성자를 '= delete'로 정의하면 됩니다. 이렇게 하면 다른 복사 생성자가 암묵적으로 생성되지 않습니다.

class C
{
public:
    C() {}
    C(C const volatile&) = delete;
    template<typename T>
    C(T const&) {
        std::cout << "tmpl copy construction\n";
    }
};

 

그러면 이제 enable_if<>를 특수 멤버 함수에 대해서도 적용할 수 있습니다.

template<typename T>
class C
{
public:
    ...
    // user-define the predefined coph constructor as deleted
    // (with conversion to volatile to enable better mathces)
    C(C const volatile&) = delete;
    
    // if T is no integral type, provide copy constructor template with better match
    template<typename U,
             typename = std::enable_if_t<!std::is_integral<U>::value>>
    C(C<U> const&) {
        ...
    }
    ...
};

 

Using Concepts to Simplify enable_if<> Expressions

using을 사용하여 깔끔하게 하더라도 enable_if 문법은 상당히 지저분합니다. 원하는 효과를 위해서는 템플릿 파라미터를 추가해야 하고, 특정 요구 사항을 적용하기 위해 해당 파라미터를 사용합니다. 따라서, 코드가 읽기도 어렵고 이해하기 어려울 수도 있습니다.

 

원칙대로 하자면 함수에 대한 요구 사항이나 제약 사항을 표현할 수 있는 언어적 기능만 있으면 되는데, 이것이 바로 C++20에서 도입된 컨셉(concept)이라는 기능입니다. 이를 사용하면 문법적으로 템플릿에 부가된 요구 사항이나 조건을 명시할 수 있습니다.

 

이를 적용하면 다음과 같습니다.

#include <iostream>
#include <utility>
#include <string>
#include <type_traits>

template<typename T>
concept ConvertibleToString = std::is_convertible_v<T, std::string>;

class Person
{
private:
    std::string name;

public:
    // generic constructor for passed initial name
    template<typename STR>
    requires ConvertibleToString<STR>
    explicit Person(STR&& n) : name(std::forward<STR>(n)) {
        std::cout << "TMPL-CONSTR for '" << name << "'\n";
    }
    // copy and move constructor
    Person(Person const& p) : name(p.name) {
        std::cout << "COPY-CONSTR Person '" << name << "'\n";
    }
    Person(Person&& p) : name(std::move(p.name)) {
        std::cout << "MOVE-CONSTR Person '" << name << "'\n";
    }
};

int main()
{
    std::string s = "sname";
    Person p1(s);             // init with string object => calls TMPL-CONSTR
    Person p2("tmp");         // init with string liternal => calls TMPL-CONSTR
    Person p3(p1);            // copy Person => calls COPY-CONSTR
    Person p4(std::move(p1)); // move Person => calls MOVE-CONSTR
}

위 코드는 C++20 이상에서만 컴파일됩니다.

concept라는 키워드를 통해 제약 사항을 정의했고, 이를 템플릿에 적용하기 위해 requires 키워드를 사용했습니다.

'프로그래밍 > C & C++' 카테고리의 다른 글

[C++] Templates in Practice  (0) 2023.01.05
[C++] Value Categories (값 카테고리)  (0) 2023.01.03
[C++] Variadic Templates (가변 템플릿)  (0) 2022.12.31
[C++] Nontype Template Parameters  (0) 2022.12.30
[C++] Class Templates  (0) 2022.12.29

댓글