* 보편 참조(universal reference)와 중복적재(overloading)
- 중복적재
쓸데 없는 복사와 생성을 방지하기 위해 중복적재를 사용하는 경우가 있다. 다음과 같은 경우를 확인해보자.
std::unordered_multiset<std::string> dataSet;
void Process1(const std::string& input)
{
dataSet.emplace(input);
}
void Process1_Test(void)
{
// 왼값 (std::string)
std::string input1("Test1");
Process1(input1);
// 오른값 (std::string)
Process1(std::string("Test2"));
// 문자열 리터럴
Process1("Test3");
}
이와 같은 형식의 코드가 있다고 가정하자. 3가지 테스트에 대한 결과는 다음과 같이 분석할 수 있다.
- 왼값(std::string)을 넘겨주는 경우 : emplace를 수행하는 과정에서 input은 왼값이기 때문에 복사하게 된다. 이는 처음부터 이렇게 넘겨주었기 때문에 복사를 피할 수 없다.
- 오른값(std::string)을 넘겨주는 경우 : 그대로 한번의 이동으로 emplace를 수행할 수 있는 상황이지만 Process1의 매개변수 input에 오른값이 묶이고, input은 왼값이기 때문에 emplace 호출 과정에서 이동이 아닌 복사가 일어난다.
- 문자열 리터럴을 넘겨주는 경우 : emplace에 직접 전달했다면 복사, 이동 없이 data안에서 직접 생성할 수 있는 상황이지만 Process1의 매개변수 input에 오른값이 묶이고 문자열 리터럴이기 때문에 암묵적으로 생성된 임시 std::string 객체에 묶이게 된다. 결국 복사가 일어난다.
이러한 상황을 막기 위해 중복적재를 사용하면 다음과 같다.
(생각 나는대로 작성했습니다. 틀린 부분 있으면 지적해주세요.)
std::unordered_multiset<std::string> dataSet;
void Process1(const std::string& input)
{
dataSet.emplace(input);
}
void Process1(std::string&& input)
{
dataSet.emplace(std::move(input));
}
void Process1(const char* input)
{
dataSet.emplace(input);
}
중복 적재를 이 문제를 해결할 수 있지만 몇가지 단점이 존재한다.
- 작성하고 유지보수해야 할 소스 코드의 양이 늘어난다. 각 매개변수가 왼값일 수도 있고 오른값일 수도 있다면 중복적재의 수가 기하급수적으로 증가한다. n개일때 2^n개의 중복적재가 필요하다.
- 효율성이 떨어진다. 상황마다 추가 비용을 신경써서 작성해야한다.
- 보편 참조
위의 중복적재를 사용한 코드는 보편 참조를 사용하면 훨씬 간단하게 표현된다.
template<typename T>
class TD;
template<typename T>
void Process2(T&& input)
{
dataSet.emplace(std::forward<T>(input));
}
void Process2_Test(void)
{
std::string input2("Test1");
// 왼값 (std::string)
// T : std::string& / input : std::string& / forward rv : std::string&
Process2(input2);
//std::forward<std::string>(input2);
// 오른값 (std::string)
// T : std::string / input : std::string&& / forward rv : std::string&&
Process2(std::string("Test2"));
//std::forward<std::string>(std::string("Test2"));
// 문자열 리터럴
// T : const char(&)[6] / input : const char(&)[6] / forward rv : const char(&)[6]
Process2("Test3");
//decltype(auto) rv = std::forward<const char(&)[6]>("Test3");
//TD<decltype(rv)> typeDisplayer;
}
하지만 보편 참조를 사용할 때 주의해야할 점이 있는데, 중복적재와 결합하여 사용하게 되면 문제가 될 수 있다는 점이다.
template<typename T>
void Process2(T&& input)
{
dataSet.emplace(std::forward<T>(input));
}
void Process2(int input)
{
dataSet.emplace(GetData(input));
}
void Process2_Test2(void)
{
int index1 = 2;
// int 버전 중복적재인 void Process2(int input)버전이 호출된다.
Process2(index1);
// 보편 참조 중복적재 버전이 호출된다. emplace과정에서 컴파일 에러가 발생한다.
short index2 = 3;
Process2(index2);
}
중복적재의 해소 규칙에 따르면 정확한 부합이 승격을 통한 부합(여기서 승격은 short -> int을 뜻한다.)보다 우선시 되기 때문에 보편 참조 중복적재가 호출된다.
이러한 상황이 클래스에서 완벽전달을 통한 생성자로 구현되었을 경우 심각해진다.
class Person
{
public:
template<typename T>
explicit Person(T&& input)
: _name(std::forward<T>(input)) {}
// 컴파일러가 이동 생성자와 복사 생성자를 작성한다.
//Person(const Person& other) { ... }
//Person(Person&& other) { ... }
public:
std::string _name;
};
void Test(void)
{
Person p1("name1");
// 컴파일러가 작성한 복사 생성자가 아닌 완벽전달 생성자를 호출하게 된다.
// 복사 생성자가 호출되지 않는 이유는 복사 생성자는 const 왼값인데,
// 더 적합한 non-const 왼값 형태을 받는 형태를 인스턴스화할 수 있기 때문이다.
// 인스턴스화 하면 Person& 왼값을 std::string 생성자에 넘겨주게 되어 컴파일 오류가 생긴다.
Person p2(p1);
const Person p3("name1");
// const가 붙어있기 때문에 const Person 왼값을 받는 컴파일러가 생성한 복사 생성자에 부합한다.
// 템플릿 보다 보통 함수를 더 우선시하기 때문에 컴파일러가 작성한 복사 생성자를 호출하게 된다.
Person p4(p3);
// 만약 이 클래스가 상속에 관여하게 되다면 자식 클래스 부모 클래스의 생성자로 넘겨줄 때
// 자식 클래스 형식을 매개변수로 받는 생성자가 생성되어 문제가 더 커진다.
}
이를 방지하기 위해 보편 참조에서 중복적재 대신 사용할 수 있는 여러가지 기법이 존재한다.
* 보편 참조에 대한 중복적재 대신 사용할 수 있는 기법들
- 중복적재를 포기한다.
가장 단순하게 중복적재 버전에 각자 다른 이름을 붙이면 된다. 호출할 때마다 다른 이름으로 호출하게 되겠지만 확실한 방법이다. 예를 들어 보편 참조 버전의 함수 이름을 Process라고 한다면 새롭게 지원할 버전의 함수 이름을 ProcessByIndex와 같이 표현하면 된다.
- const T& 메개변수를 사용한다.
보편 참조 대신 const에 대한 왼값 참조 매개변수를 사용하는 방법이다. 다만 원하는 만큼 성능을 올릴 수 없다. 복사와 같은 추가 과정이 추가되기 때문이다.
- 값 전달 방식의 매개변수를 사용한다.
복사될 것이 확실한 객체는 값으로 전달하는 것도 방법이다. 이 방법을 사용하면 다음과 같은 효과를 얻을 수 있다.
예를 들어 std::string, int형을 받는 함수가 중복적재 되어있다고 가정할 때 int, int류 형식(std::size_t, short, long)을 전달하면 int형을 받는 중복적재가 선택된다.
- 꼬리표 배분을 사용한다.
중복잭재된 함수의 호출에 대해 컴파일러는 그 호출에 쓰인 인수들과 선택 가능한 중복적재 버전들의 모든 가능한 조합을 고려하여 가장 잘 부합하는 것을 선택한다.
template<typename T>
void ProcessImpl(T&& input, std::false_type) // false인 경우
{
std::cout << "not int" << '\n';
DataSet.emplace(std::forward<T>(input));
}
void ProcessImpl(int index, std::true_type) // true인 경우
{
std::cout << "int" << '\n';
DataSet.emplace(GetData(index));
}
template<typename T>
void Process(T&& input)
{
// is_integral : 정수값인지 확인하는 함수이다. 참조 값은 정수값이 아니기 때문에 참조를 제거한다.
ProcessImpl(std::forward<T>(input),
std::is_integral<std::remove_reference<T>::type>());
}
void TagDispatch(void)
{
// 보편 참조 중복적재 버전 호출
Process("test");
// int 중복적재 버전 호출
Process(3);
}
not int
int
여기서 포인트는 두번째 값을 추가로 넘겨서 어떤 중복적재 버전을 호출할지 결정한다는 점이다. 그리고 코드에서 true, false를 사용하면 실행 시간에서만 어떤 버전이 호출되는 지 파악할 수 있기 때문에 true, false에 해당하는 타입을 매개변수로 두었다. 이는 컴파일 시간에 어떤 버전이 호출될 지 결정해주는 역할을 수행한다. 여기서 std::true_type과 같은 형식을 꼬리표(tag)라고 부르며 이를 기반으로 호출을 결정한다.
- 보편 참조를 받는 템플릿을 제한한다.
꼬리표 배분 함수를 완벽 전달 생성자(보편 참조 생성자)에 적용하는 경우, 컴파일러가 자동으로 작성한 복사 생성자와 이동 생성자가 호출될 경우 꼬리표 배분이 적용되지 않을 수 있다. 반대로 복사 생성자가 호출되기를 기대하였지만 non-const형식이여서 완벽 전달 생성자가 선언될 수도 있다. 이와 같은 상황에서는 꼬리표 배분 설계가 적합하지 않다. 이러한 경우에는 함수 템플릿이 중복적재 해소의 후보가 되는 조건들을 적절히 제한할 수 있는 다른 기법이 필요하다.
std::enable_if를 사용하면 컴파일러가 특정 템플릿이 존재하지 않는 것처럼 동작하도록 만들 수 있다. 이러한 템플릿을 비활성화된(disabled) 템플릿이라고 하며 이는 std::enable_if에 지정된 조건이 만족하지 않을 때 비활성화된다. 반대로 지정된 조건을 만족했다면 활성화된다.
다음과 같은 클래스를 생각해보자. 우리는 복사 생성, 이동 생성에 대해서는 완벽 전달 생성자(보편 참조 생성자)를 호출하지 않으려고 한다.
class Person
{
public:
template<typename T>
explicit Person(T&& input)
: _name(std::forward<T>(input)) {}
// 컴파일러가 이동 생성자와 복사 생성자를 작성한다.
//Person(const Person& other) { ... }
//Person(Person&& other) { ... }
public:
std::string _name;
};
타입이 같은지 확인하는 std::is_same을 사용하고 참조나 const volatile을 무시할 수 있는 std::decay를 사용한다. 제거했을 때 Person타입이 되지 않아야 한다.
class Person
{
public:
template<typename T,
typename = typename std::enable_if<!std::is_same<Person, typename std::decay<T>::type>::value, void>::type>
explicit Person(T&& input)
: _name(std::forward<T>(input)) {}
// 컴파일러가 이동 생성자와 복사 생성자를 작성한다.
//Person(const Person& other) { /*...*/ }
//Person(Person&& other) { /*...*/ }
public:
std::string _name;
};
하지만 만약 Person클래스를 상속받은 클래스에서의 복사 생성자와 이동 생성자를 호출하는 과정에서 문제가 발생할 수 있다.
class SpecialPerson : public Person
{
public:
// ...
SpecialPerson(const SpecialPerson& other)
: Person(other) {}
SpecialPerson(SpecialPerson&& other)
: Person(std::move(other)) {}
};
Person의 템플릿에서 const SpecialPerson&와 SpecialPerson&& 에 부합하는 인스턴스가 만들어진다. 이는 Person 타입과 비교를 해서 그렇다.
이를 방지하기 위해 Person에서 파생된 형식이 아닐 때만 활성화되도록 코드를 수정해야한다. 이는 std::is_base_of<T1, T2>::value를 통해서 판별할 수 있으며 T2가 T1에서 파생된 형식이면 참을 반환한다.
class Person
{
public:
template<typename T,
typename = typename std::enable_if<!std::is_base_of<Person, typename std::decay<T>::type>::value, void>::type>
explicit Person(T&& input)
: _name(std::forward<T>(input)) {}
// 컴파일러가 이동 생성자와 복사 생성자를 작성한다.
//Person(const Person& other) { /*...*/ }
//Person(Person&& other) { /*...*/ }
public:
std::string _name;
};
int형을 받는 생성자가 추가된다고 가정하면 최종형태는 다음과 같이 정의된다.
class Person
{
public:
template<typename T,
typename = typename std::enable_if<!std::is_base_of<Person, typename std::decay<T>::type>::value
&& !std::is_integral<std::remove_reference<T>>::value, void>::type>
explicit Person(T&& input)
: _name(std::forward<T>(input)) {}
explicit Person(int index)
: _name(GetData(index)) {}
// 컴파일러가 이동 생성자와 복사 생성자를 작성한다.
//Person(const Person& other) { /*...*/ }
//Person(Person&& other) { /*...*/ }
public:
std::string _name;
};
* 비교
위의 방식을 나눠보면 다음과 같은 범주에서 나눌 수 있다.
- 매개 변수 형식을 지정하는 방법 : 중복적재 포기, const T& 전달, 값 전달
- 매개 변수 형식을 지정하지 않는 방법 : 꼬리표 배분, 템플릿 활성화 제한
위와 같이 완벽 전달 여부에 따라서 분류할 수 있다. 완벽 전달을 사용하면 선언된 매개변수 형식을 만족하기 위해 임시 객체를 생성하는 비효율성은 없다. 하지만 완벽 전달에도 단점이 있다.
- 완벽 전달이 불가능한 인수들이 존재할 수 있다.
- 유효하지 않는 인수를 전달했을 때의 오류 메시지가 난해하다. 보편 참조가 전달되는 횟수가 많을 수록 오류 메시지는 더 복잡해진다. 또한 최종 지점에 도착한 후 인수 형식들의 허용 여부 판정이 일어날 가능성도 커진다.
2번에서 미리 검사를 하기 위해 static_assert를 이용해서 점검할 수도 있다.
'C++' 카테고리의 다른 글
| 완벽 전달(Perfect forwarding)의 실패 (0) | 2022.03.31 |
|---|---|
| 참조 축약 (0) | 2022.03.31 |
| 보편 참조와 오른값 참조 (0) | 2022.03.27 |
| std::forward (0) | 2022.03.27 |
| std::move (0) | 2022.03.26 |