[Modern C++] 항목 30: 완벽 전달이 실패하는 경우들을 잘 알아두라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역
가독성이 떨어지는 직역들을 수정하며 정리하였습니다. e.g. 연역 → 추론, 중복적재 → 오버로딩
📦 5. rvalue 참조, 이동 개념, 완벽 전달
👉🏻 항목 30: 완벽 전달이 실패하는 경우들을 잘 알아두라
🔍 완벽 전달이란?
- 전달(forwarding)은 한 함수가 자신의 인수들을 다른 함수에 넘겨주는 것이다. 목표는 두 번째 함수가 첫 함수가 받았던 것과 동일한 객체들을 받게 하는 것이다.
- 값 전달 매개변수는 복사본이라 이 목표를 달성할 수 없고, 포인터 매개변수는 호출자에게 포인터 전달을 강제하므로 탈락이다. 따라서 참조 매개변수가 필요하다.
- 완벽 전달은 객체뿐 아니라 타입, lvalue/rvalue 여부,
const/volatile여부까지 전달한다. 이를 위해서는 보편 참조(항목 24)가 필요하다.
template<typename T>
void fwd(T&& param) // 임의의 인수를 받는다
{
f(std::forward<T>(param)); // 그 인수를 f에 전달한다
}
// 실제 전달 함수는 가변 인수 템플릿이다
template<typename... Ts>
void fwd(Ts&&... params) // 임의의 인수들을 받는다
{
f(std::forward<Ts>(params)...); // 그것들을 f에 전달한다
}
emplace류 함수(항목 42),std::make_shared,std::make_unique(항목 21)에서 이런 형태를 볼 수 있다.- 완벽 전달 실패의 정의: 대상 함수
f와 전달 함수fwd가 있을 때, 어떤 인수로f를 호출했을 때와 같은 인수로fwd를 호출했을 때의 동작이 다르면 완벽 전달이 실패한 것이다.
f( 표현식 ); // 이 호출이 하는 일과
fwd( 표현식 ); // 이 호출이 하는 일이 다르다면, fwd는 표현식을 f에 완벽하게 전달하지 못한 것이다
- 실패의 원인은 두 가지다.
fwd의 매개변수 중 하나 이상에 대해 컴파일러가 타입을 추론하지 못한다 (컴파일되지 않음).- 컴파일러가 타입을 잘못 추론한다 (컴파일되지 않거나, 컴파일되더라도
f를 직접 호출했을 때와 다르게 행동함. 예:f가 오버로딩된 경우 다른 오버로딩이 선택됨).
🔍 실패 사례 ① — 중괄호 초기화 리스트
void f(const std::vector<int>& v);
f({ 1, 2, 3 }); // OK; "{1, 2, 3}"은 암묵적으로 std::vector<int>로 변환된다
fwd({ 1, 2, 3 }); // 오류! 컴파일되지 않음
f를 직접 호출하면 컴파일러가 인수와 매개변수 타입을 비교해{1, 2, 3}으로부터 임시std::vector<int>를 만들어 준다.fwd를 통하면 컴파일러는 인수 타입을 추론해서f의 매개변수와 비교하는데,fwd의 매개변수가std::initializer_list가 될 수 없는 타입으로 선언되어 있으면{1, 2, 3}의 타입 추론이 금지된다. 이를 비추론 문맥(non-deduced context)이라고 한다.- 우회책:
auto변수는 중괄호 초기화 리스트로 초기화해도std::initializer_list로 잘 추론된다(항목 2).auto로 지역 변수를 선언한 뒤 그 변수를 전달한다.
auto il = { 1, 2, 3 }; // il의 타입 추론 결과는 std::initializer_list<int>이다
fwd(il); // OK; il이 f로 완벽하게 전달된다
🔍 실패 사례 ② — 널 포인터를 뜻하는 0 또는 NULL
- 항목 8에서 설명했듯이,
0이나NULL을 널 포인터로서 템플릿에 넘기면 컴파일러가 포인터 타입이 아니라 정수 타입(보통int)으로 추론한다. 그래서 널 포인터로서 완벽하게 전달되지 못한다. - 우회책:
0이나NULL대신nullptr를 사용한다.
🔍 실패 사례 ③ — 선언만 된 정수 static const 및 constexpr 자료 멤버
- 일반적으로 정수
static const/static constexpr자료 멤버는 클래스 안에서 정의할 필요 없이 선언만 하면 된다. 컴파일러가 값에 대해 const 전파(const propagation)를 적용해서 그 값을 위한 메모리를 따로 마련할 필요가 없기 때문이다.
class Widget {
public:
static constexpr std::size_t MinVals = 28; // MinVals의 선언
…
};
… // MinVals의 정의는 없음
std::vector<int> widgetData;
widgetData.reserve(Widget::MinVals); // MinVals를 사용
MinVals의 주소를 취하는 코드가 있다면 저장소가 필요해져서, 컴파일은 되지만 정의가 없어서 링크에 실패한다.
void f(std::size_t val);
f(Widget::MinVals); // OK; 그냥 f(28)로 처리됨
fwd(Widget::MinVals); // 오류! 링크에 실패한다
- 소스 코드에 주소를 취하는 부분이 없어도,
fwd의 매개변수는 보편 참조이며 컴파일러가 산출한 코드에서 참조는 포인터처럼 취급되는 것이 보통이다. 즉MinVals를 참조로 전달하는 것은 사실상 포인터로 넘겨주는 것이므로 가리킬 대상, 곧 정의가 필요하다. - 표준에 따르면 참조로 전달하려면 정의가 필요하지만, 모든 구현이 이를 강제하지는 않는다. 그래서 컴파일러나 링커에 따라 성공할 수도 있으나 이식성을 기대해서는 안 된다.
- 우회책: 해당 멤버의 정의를 제공한다. 초기값은 다시 지정하지 않는다(두 곳에서 지정하면 컴파일러가 지적해 준다).
constexpr std::size_t Widget::MinVals; // Widget의 .cpp 파일에서
🔍 실패 사례 ④ — 오버로딩된 함수 이름과 템플릿 이름
f가 함수를 받아 호출한다고 하자. 다음 두 선언은 동일한 의미다.
void f(int (*pf)(int)); // pf는 processing function(처리 함수)을 뜻한다
void f(int pf(int)); // 위와 동일한 f를 선언한다
int processVal(int value);
int processVal(int value, int priority);
f(processVal); // OK
processVal은 함수 포인터도, 함수도 아닌 서로 다른 두 함수가 공유하는 이름이다.f를 직접 호출할 때는 컴파일러가f의 선언을 보고 매개변수 타입과 일치하는int하나를 받는 버전을 골라 준다.- 그러나 함수 템플릿
fwd에는 필요한 타입 정보가 없으므로 컴파일러가 어떤 오버로딩을 선택할지 결정하지 못한다.processVal자체에는 타입이 없고, 타입이 없으면 타입 추론도 없다.
fwd(processVal); // 오류! 어떤 processVal인지?
- 함수 템플릿도 하나가 다수의 함수를 대표하므로 같은 문제가 생긴다.
template<typename T>
T workOnVal(T param) // 값들을 처리하는 템플릿 함수
{ … }
fwd(workOnVal); // 오류! workOnVal의 어떤 인스턴스인지?
- 우회책: 전달하려는 오버로딩이나 템플릿 인스턴스를 명시적으로 지정한다.
f의 매개변수와 같은 타입의 함수 포인터를 만들어 초기화하거나static_cast한 뒤 전달한다.
using ProcessFuncType = int (*)(int); // typedef를 만든다 (항목 9 참고)
ProcessFuncType processValPtr = processVal; // processVal에 필요한 시그니처를 명시한다
fwd(processValPtr); // OK
fwd(static_cast<ProcessFuncType>(workOnVal)); // 역시 OK
- 이를 위해서는
fwd가 전달하는 함수 포인터의 타입을 알아야 하는데, 완벽 전달 함수의 문서화에 그 정보가 있다고 가정하는 것은 비합리적이지 않다. 무엇을 전달해야 하는지 알려주는 문서가 없으면 클라이언트가 제대로 사용할 수 없다.
🔍 실패 사례 ⑤ — 비트필드
- 마지막 경우는 비트필드(bitfield)가 함수 인수로 쓰일 때다. 다음은 IPv4 헤더를 나타내는 구조체다.
struct IPv4Header {
std::uint32_t version:4,
IHL:4,
DSCP:6,
ECN:2,
totalLength:16;
…
};
void f(std::size_t sz); // 호출할 함수
IPv4Header h;
…
f(h.totalLength); // OK
fwd(h.totalLength); // 오류!
- 문제는
fwd의 매개변수가 참조이고h.totalLength는 비const 비트필드라는 점이다. C++ 표준은 “비const 참조는 절대로 비트필드에 묶이지 않아야 한다(shall not)”고 선언한다. - 이 금지에는 이유가 있다. 비트필드는 컴퓨터 워드의 임의의 일부분(예: 32비트
int의 비트 3~5)으로 구성되는데, 그런 일부 비트를 직접 가리키는 방법이 없다. 참조와 포인터는 하드웨어 수준에서 같은 것인데 임의의 비트들을 가리키는 포인터를 만들 수 없으며(C++에서 직접 가리킬 수 있는 가장 작은 것은char), 따라서 참조를 임의의 비트들에 묶는 방법도 없다. - 비트필드를 매개변수에 전달하는 방법은 단 두 가지다. 값 전달, 그리고
const에 대한 lvalue 참조. 두 경우 모두 호출된 함수는 비트필드 값의 복사본을 받는다.const참조 매개변수의 경우 표준에 따르면 그 참조는 어떤 표준 정수 타입(예:int)의 객체에 저장된 비트필드 값의 복사본에 묶이는 것이지, 비트필드 자체에 묶이는 것이 아니다. - 우회책: 전달 대상 함수는 항상 비트필드 값의 복사본을 받게 된다는 점을 이용한다. 복사본을 직접 만들어서 그 복사본을 전달한다.
// 비트필드 값을 복사한다; 이런 초기화 구문에 관해서는 항목 6을 보라
auto length = static_cast<std::uint16_t>(h.totalLength);
fwd(length); // 복사본을 전달한다
🔍 결론
- 대부분의 경우 완벽 전달은 광고된 그대로 작동하며 고민할 필요는 거의 없다. 그러나 작동하지 않을 때, 즉 합당해 보이는 코드가 컴파일되지 않거나 컴파일은 되지만 예상과 다르게 행동할 때는 완벽 전달에서 완벽하지 않은 점을 아는 것이 중요하다. 또한 그런 ‘옥에 티’를 우회하는 방법을 아는 것도 그만큼 중요하다. 대부분의 경우 우회책은 간단하다.
🧐 정리
- 완벽 전달은 템플릿 타입 추론이 실패하거나 틀린 타입을 추론했을 때 실패한다.
- 인수가 중괄호 초기화 리스트이거나
0또는NULL로 표현된 널 포인터, 선언만 된 정수static const및constexpr자료 멤버, 템플릿 및 오버로딩된 함수 이름, 비트필드이면 완벽 전달이 실패한다.
댓글남기기