Advanced C++

[Modern C++] (5-2) 오른값 참조, 이동 의미론, 완벽전달

로파이 2021. 4. 14. 15:03

이번 포스트에서는 보편 참조를 사용할 때 주의사항에 관해 정리해본다.

 

4. 보편 참조에 대한 중복적재(오버로딩)을 피해라

 

사람이름을 받아 현재 시간을 기록하고 그 이름을 전역 자료구조에 추가하는 코드를 보자.

std::mutliset<std::string> names;

void logAndAdd(const std::string& name)
{
	auto now = std::chrono::system_clock:now(); // 현재 시간 측정

	log(now, "logAndAdd"); // 로그를 기록

	name.emplace(name); // 이름을 추가
}

std::string petName("Darla");
logAndAdd(petName);       // 1. 왼값 std::string
logAndAdd(std::string("Persephone")); // 2. 오른값 std::string
logAndAdd("Patty Dog"); // 3. 문자열 리터럴을 넘겨줌

logAndAdd 함수의 3가지 호출에 대해 분석해보면 다음과 같다.

1. 왼값을 전달

petName은 함수 인자 name에 참조로 묶인다. 컨테이너에 추가하는 emplace는 name이 왼값이므로 복사를 진행한다.

2. 오른값을 전달

임시 std::string 객체는 name에 묶인다. 똑같이 emplace는 복사하지만 원본 임시 객체로부터 이동 생성하는 것이 더 효율적이다.

3. 문자열 리터럴을 전달

문자열 리터럴로부터 암묵적으로 임시 std::string 객체가 생성되고 name에 묶인다. 마찬가지로 복사가 일어난다. 하지만 문자열 리터럴을 emplace에 바로 전달하여 names에 string을 추가할 때 직접 생성하면, 임시 std::string 객체를 만들필요도 없고 이동 생성을 할 필요도 없다.

 

2,3 항목의 비효율성을 제거하기 위해 다음과 같이 개선된 함수를 작성할 수 있다.

template<typename T>
void logAndAdd(T&& name)
{
	auto now = std::chrono::system_clock:now(); // 현재 시간 측정

	log(now, "logAndAdd"); // 로그를 기록

	name.emplace(std::forward<T>(name)); // 이름을 추가
}

std::string petName("Darla");
logAndAdd(petName);       // 1. 왼값: 복사한다.
logAndAdd(std::string("Persephone")); // 2. 오른값: 이동한다.
logAndAdd("Patty Dog"); // 3. 컨테이너 안에서 직접 생성한다.

이제 모든 항목의 최적화가 완료되었다.

하지만 만약 사용자가 사람 id를 받아 이름값을 받아와 생성하는 logAndAdd의 int 매개변수 버전의 오버로딩을 정의한다 해보자.

 

- 보편참조와 오버로딩

std::string nameFromIdx(int idx);
void logAndAdd(int idx)
{
	auto now = std::chrono::system_clock:now(); // 현재 시간 측정

	log(now, "logAndAdd"); // 로그를 기록

	name.emplace(nameFromIdx(idx)); // 이름을 추가
}
logAndAdd(22); // 중복적재

위 함수 호출은 새로운 중복적재 버전을 호출할 것이다.

 

문제는 다음과 같은 호출에서 일어난다.

short nameIdx = 21; // nameIdx에 값 배정
//...
logAndAdd(nameIdx); // 오류

 위 함수 호출은 중복 적재 선택에서 보편 참조 버전을 선택하게 된다. 기본적으로 새로 정의된 오버로딩은 int 매개변수를 받고 있고 int로 암묵적 형변환이 필요하다. 보편 참조의 함수 템플릿이 선택된다면 T = short&로 연역되어 작성된 함수가 더 부합되기 때문에 보편 참조 버전의 함수가 오버로딩되어 short 변수로 string 객체를 생성하려 하여 오류가 나는 것이다.

 

이러한 설계 양상이 일반 클래스의 생성자에서 적용되면 더 다양한 오류를 접할 수 있다.

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

	explicit Person(int idx)
		: name(nameFromIdx(idx))
	{}
private:
	std::string name;
};

마찬가지로 short 변수를 매개변수로 생성자를 호출하면 똑같은 유형의 컴파일 오류를 발생시킬 것이다.

 

- 클래스의 생성자 오버로딩

더 기괴한 동작은 다음에서 발생할 수 있다.

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

	explicit Person(int idx)
		: name(nameFromIdx(idx))
	{}

	Person(const Person& rhs); // 복사 생성자
	Person(Person&& rhs); // 이동 생성자
private:
	std::string name;
};

Person 클래스에 복사 생성자와 이동 생성자를 정의하고 다음처럼 복사 생성자를 기대하는 코드를 작성해보자.

Person p("Nancy");
auto cloneOfP(p); // 오류 발생

위 코드를 실행하면 오류가 발생하는데, 그 이유를 좀 처럼 알기가 쉽지 않다. 겉으로 봐서는 복사 생성자를 호출할 것을 기대하기 때문이다.

 

p를 함수 매개변수로 전달하면 먼저 템플릿 함수에서 T = Person&(왼값 참조)로 연역한다.

	explicit Person(Person& n)
		: name(std::forward<T>(n))
	{}

따라서 작성된 템플릿 함수가 const가 포함된 복사 생성자보다 더 정확한 부합을 갖는다. Person 인스턴스로부터 string 객체를 생성하려하고 이는 오류를 발생시킨다.

 

const Person cp("Nancy"); // const
auto cloneOfP(cp); // 복사 생성자를 호출

위 코드는 복사 생성자를 호출하게 되는데, 템플릿 함수에 작성된 명세와 복사 생성자가 같아진다. C++ 컴파일러는 두개의 정확한 부합이 있다면 일반 함수를 우선시하는 규칙이 있고 결국 일반 복사 생성자가 선택된다.

 

- 파생 클래스에서 기반 클래스 생성자를 호출

class SpecialPerson : public Person
{
public:
	SpecialPerson(const SpecialPerson& rhs)
		:
		Person(rhs)
	{}
	SpecialPerson(SpecialPerson&& rhs)
		:
		Person(std::move(rhs))
	{}
};

이 경우에도 기반 클래스 생성자를 호출할 때 rhs의 인스턴스 타입은 SpecialPerson이라는 파생 클래스이다. 따라서 기반 클래스의 생성자 함수들의 명세중 부합되는 버전은 보편 참조(템플릿 함수)에 정확히 부합하고 선택되어 똑같은 오류를 발생시킨다.

 

정리

- 보편 참조에 대한 중복적재는 보편 참조 함수가 자주 선택되는 경향이 있다.

- 완벽 전달 생성자의 경우 문제의 여지가 있으며 작성되는 함수가 실제 구현 내용과 맞지 않는 타입으로 선택되기 때문이다.

 

5. 보편 참조에 대한 중복적재 대신 사용할 수 있는 기법

1) 중복적재를 포기한다.

중복적재를 포기하고 함수 이름을 바꾸어 명시적으로 다른 함수가 선택되도록 한다.

 

2) const 타입& 매개변수를 사용한다.

const string&과 같이 const 왼값 참조를 사용함으로 비효율성이 남아 있지만 적어도 예상치 못한 문제를 피할 수 있을 것이다.

 

3) 값 전달 방식의 매개변수를 사용한다.

Person(std::string n)과 Person(int idx)는 확실히 구별 가능해진다.

 

4) 꼬리표 배분을 사용한다.

주어진 매개변수 T에 대하여 오버로딩되는 함수를 선택하게 하는 것이다.

 

T가 리터럴 자료형일 때만 idx로 부터 이름일 받아 추가하는 함수를 구현해보면 다음과 같다.

 

template<typename T>
void logAndAdd(T&& name)
{
	logAndAddImpl(std::forward<T>(name), 
		std::is_integral<typename std::remove_reference<T>::type>()));
}

먼저 logAndAdd는 T 타입에 대하여 참조를 제거하고 리터럴 형인지 아닌지 결과를 두번째 인자로 사용하는 실제 구현 함수 logAndAddImpl에 전달하고 있다. 

template<typename T>
void logAndAddImpl(T&& name, std::false_type)
{
	auto now = std::chrono::system_clock:now();
	log(now, "logAndAdd");
	name.emplace(std::forward<T>(name));
}

void logAndAddImpl(int idx, std::true_type)
{
	auto now = std::chrono::system_clock:now(); // 현재 시간 측정
	log(now, "logAndAdd"); // 로그를 기록
	name.emplace(nameFromIdx(idx)); // 이름을 추가
}

실제 두 구현 함수는 T 타입에 따라 다른 함수가 선택되기 때문에 우리가 원하는 구현을 실행할 수 있을 것이다. 여기서 std::true_type과 std::false_type은 컴파일 시점에서 평가되는 값으로 상위 함수에서 템플릿 인수 T에 따라 결정되기 때문에 컴파일 시점에서 평가 가능하다. 일반 bool 변수의 true/false는 실행시점의 값이다.

 

std::true_type, std::false_type와 같은 꼬리표로 함수를 배분하는 의미에서 꼬리표 배분이라는 용어를 사용할 수 있다.

 

- 보편 참조를 받는 템플릿을 제한한다.

std::enable_if를 사용하여 템플릿 인수 T가 특정 조건을 만족할 때만 템플릿을 활성화 시킨다. 이로써 보편 참조를 받는 템플릿을 제한할 수 있다.

class Person
{
public:
  template<typename T, typename = typename std::enable_if<조건>::type>
  explicit Person(T&& n);
};

만약 T가 Person 타입아 아닐때만 활성화 시키고 싶다면 다음과 같이 작성한다.

class Person
{
public:
  template<typename T, typename = typename std::enable_if<
  				!std::is_same<Person, typename std::decay<T>::type>::value>>::type>
  explicit Person(T&& n);
};

typename std::decay<T>::type은 T인자로 부터 참조를 제거하고 cv한정사(const,volatile)를 제거한다. 또한 배열은 포인터로 바꾸어 준다. 따라서 위 템플릿 연역으로 Person 타입아 아닐때만 활성화된다.

 

Person 기반 클래스가 아닌 경우를 조건으로 추가하면 다음과 같다.

class Person
{
public:
  template<typename T, typename = typename std::enable_if_t<
  				!std::is_base_of<Person, std::decay_t<T>>::value
                && !std::is_integral<std::decay_t<T>>::value>>
  explicit Person(T&& n)
  	:
    name(std::forward<T>(n))
  {}
  explicit Person(int idx)
    :
    name(nameFromIdx(idx))
  {}
private:
	std::string name;
};

최종적으로 T가 Person 클래스 계통이 아니고 리터럴 형이 아닐 경우에만 Person 완벽 전달 생성자가 선택되도록 한다.

 

6. 참조 축약

auto와 template인수 T에 대하여 참조 축약이 일어난다.

 

forward의 구현 내용

template<typename T>
T&& forward(typename remove_reference<T>::type& param)
{
   return static_cast<T&&>(param);
}

forward는 std::move와 다르게 참조를 제거하지 않은 T&&로 캐스팅하고 있다.

참조 축약이란 왼값을 가지고 forward를 호출하면 다음과 같다.

 

아래처럼 왼값에 대하여 std::forward를 호출하면 템플릿 연역과정에서 참조 축약이 일어난다.

Widget w = Widget();
std::forward<Widget>(w);

template<typename Widget&>
Widget& && forward(typename remove_reference<Widget&>::type& param)
{
   return static_cast<Widget& &&>(param);
}

Widget& && -> Widget&가 되며 실제로 forward는 왼값인 경우 왼값으로 캐스팅하고 오른값인 경우 오른값으로 캐스팅하는 동작원리을 설명한다. 

 

오른값을 전달할 경우 오른값으로 캐스팅된다.

std::forward<Widget>(Widget());

template<typename T = Widget>
Widget&& forward(typename remove_reference<Widget>::type& param)
{
   return static_cast<Widget&&>(param);
}

 

7. 이동 연산은 존재하지 않을 수 있고 저렴하지도 않으며 적용되지 않을 수 있다.

이동 연산이 효율적인 경우는 포인터를 이동시킬 수 있는 경우가 대부분이다.

std::vector는 내부에 동적할당한 메모리를 가리키는 포인터가 있고 std::move로 포인터를 이동시키는 것이 가능하다.

한편 std::array는 그런 포인터가 없기때문에, 원소마다 이동이 일어나므로 선형 시간이 걸린다.

 

보통 이동이 도움이 되지 않는 경우는 다음과 같다.

1. 이동 연산이 없다.

2. 이동이 더 빠르지 않다.

3. 이동을 사용할 수 없다. 이동 연산에 포함된 모든 연산은 noexcept가 포함되어야 한다.

 

8. 완벽 전달이 실패하는 경우

 

- 중괄호 초기치

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

template<typename... Ts>
void fwd(Ts&&... params)
{
	f(std::forward<Ts>(params)...);
}

일반 보편 참조 함수와 파라미터 팩을 보편 참조로 사용하는 두 함수가 있다.

void f(const std::vector<int>& v);

f({1,2,3});  // 암묵적으로 std::vector<int>로 변환된다.
fwd({1,2,3}); // 컴파일 오류

f라는 함수가 정의되어있고 중괄호 초기치를 사용하여 f와 fwd를 호출하면 fwd는 컴파일 오류가 발생한다. std::initializer_list는 fwd의 템플릿 인자로 연역될 수 없기 때문에 오류가 발생하게 된다.

 

- 널 포인터를 뜻하는 0 또는 NULL

0과 NULL은 정수형으로 연역하기 때문에 nullptr를 사용한다.

 

- 선언만 된 정수 static const 및 constexpr 자료 멤버

class Widget{
public:
	static constexpr std::size_t MinVals = 28;  // MinVals의 선언
    //...
};

std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals); // MinVals를 사용

위와 같이 클래스 상수 전역 변수를 사용할 수 있는데, 다음과 같이 보편 참조 함수에 전달할 경우 오류가 발생한다.

void f(std::size_t val);

f(Widget::MinVals); // OK, f(28);
fwd(Widget::MinVals); // 오류 링크 실패

보편 참조는 "참조" 포인터를 쓰는 함수이므로 실제 주소가 있는 이름이어야 한다. MinVals는 클래스 정의 파일에 따로 정의를 하지않고 쓸 수 있지만 (매개변수 전파로) 보편 참조 함수 호출 시 선언이 안 되어 있다면 링크에 실패하게 된다.

 

- 중복적재된 함수이름과 템플릿 이름

void f(int pf(int)); // f 선언

int processVal(int value);
int processVal(int value, int priority);

f(processVal); // ok
fwd(processVal); // 어떤 processVal 인가?

중복 적재된 함수의 경우 이름만 보고 원형을 파악할 수 없기 때문에 컴파일 오류가 발생한다.

template<typename T>
T workOnVal(T param)
{//...}

fwd(workOnVal); // workOnVal의 어떤 인스턴스인지 알 수 없다.

템플릿 함수도 마찬가지로 결정되지 않은 함수 특성을 보편 참조 함수로 전달할 수 없다.

using ProcessFuncType = int (*)(int);

ProcessFuncType processValptr = processVal;

fwd(processValPtr); // ok
fwd(static_cast<ProcesFuncType>(workOnVal)); // ok

함수 타입이 특정된 경우 fwd에 전달할 수 있다.

 

- 비트 필드

마지막 경우는 비트필드가 함수 인수로 쓰일 때 이다.

struct IPv4Header
{
	std::uint32_t version:4,
                  IHL:4,
                  DSCP:6,
                  ECN:2,
                  totalLength:16;
};

IPv4 헤더 정의를 나타내는 구조체가 있다.

void f(std::size_t sz);
IPv4Header h;
//...
f(h.totalLength);   // OK
fwd(h.totalLength); // 오류

h.totalLength는 비const 비트필드 타입이다. 해당 타입은 비트 단위의 구성으로 고유 참조 주소를 가질 수 없다. 따라서 보편 참조를 매개변수로 사용하는 fwd는 호출할 수 없다. 이 값을 사용하려면 값을 복사하여 이름있는 주소에 할당해서 사용한다.

auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length);