-
[Effective Modern C++] 완벽 전달(Perfect forwarding)의 실패C++/Effective Modern C++ 2022. 8. 23. 13:55
완벽 전달이란, 함수가 다른 함수로 자신의 인수들을 전달하는것을 의미합니다.
이 때, 전달받는 함수가 받는 객체와 전달하는 함수의 객체가 동일하기 때문에 완벽이라는 수식어를 사용합니다.
완벽 전달은 객체의 형식, const, volatile등의 속성 또한 전달되어야 합니다.
따라서 전달 함수 std::forward<T>를 사용하게 되고, 전달 함수를 작성할 경우 아래와 같이 됩니다.
template<typename... Ts> void fwd(Ts&&... param) { f(std::forward<Ts>(param)...); }
fwd함수는 f로 인수를 전달합니다.
이 때 매개변수가 한개라는 보장이 없으므로, 가변 인수 템플릿을 사용합니다.
위와 같이 함수를 작성했을 때, fwd를 통한 f의 호출과 f를 직접 호출했을 때의 결과가 다르다면, 완벽 전달은 실패하는 것 입니다.
f( /* Expression */ ); fwd( /* Expression */ );
이번 글에서는 위 두 경우가 달라지는, 즉 완벽 전달이 실패하는 상황들을 살펴보도록 하겠습니다.
1. 중괄호 초기치
더보기f가 다음과 같다고 가정하도록 하겠습니다.
void f(const std::vector<int>& v);
이 경우, 중괄호 초기치를 사용한 경우의 결과가 다릅니다.
f({1, 2, 3}); fwd({1, 2, 3}); // Compile Error
구체적으로, 중괄호 초기치를 이용해 fwd를 호출했을 경우 컴파일 에러가 발생합니다.
C7627 : 'Initializer list'은(는) 'Ts'의 유효한 템플릿 인수가 아닙니다. C2672 : 'fwd': 일치하는 오버로드된 함수가 없습니다.
에러 메시지에서 볼 수 있듯이, 이는 형식 연역이 잘못되어 발생한 오류입니다.
중괄호 초기치로 f를 호출할 때는 f의 매개변수와 호출 인수를 호출 지점에서 확인하여 연역합니다.
하지만 fwd를 통해 간접 호출할때는 직접적인 비교가 불가합니다.
이 경우는 fwd에 전달되는 형식을 연역한 후, 그 형식을 f의 매개변수와 비교합니다.
이 때 다음 두 조건이 만족되지 않을 경우 완벽 전달이 실패합니다.
- fwd의 매개변수들 중 하나 이상에 대해 컴파일러가 형식을 연역하지 못할 때, 컴파일이 실패합니다.
- fwd의 매개변수들 중 하나 이상에 대해 컴파일러가 형식을 잘못 연역합니다. 이 경우는 컴파일이 될 수도 있지만, 잘못된 형식이기 때문에 함수의 행동이 달라질 수 있습니다.
위 경우는 템플릿 매개변수가 std::initializer_list가 될 수 없는 형태로 선언되어 있어 연역이 불가한 경우입니다.
표준에서는 이를 비연역 문맥(non-deduced context)이라고 합니다.
이 경우는 auto를 사용해서 우회할 경우 해결 가능합니다.
구체적으로 아래와 같습니다.
auto il = {1, 2, 3}; fwd(il);
이 경우 il은 std::initializer_list로 연역되고, il이 fwd에 전달됩니다.
2. 널 포인터를 뜻하는 0 또는 NULL
더보기C++에서는 널 포인터를 뜻하는 의미로 0 혹은 NULL을 사용할 수 있습니다.
하지만 엄밀히 따질 경우 이것은 널 포인터가 아니라 0입니다.
템플릿에 0 혹은 NULL이 널 포인터로 전달될 경우, 일반적으로 정수 형식(일반적으로 int)로 연역됩니다.
이 경우는 0 혹은 NULL이 아니라 nullptr을 사용하면 됩니다.
이에 관한 자세한 내용은 다른 글 (0과 NULL과 nullptr)에 기술되어 있습니다.
3. 선언만 된 정수 static const및 constexpr 자료 멤버
더보기일반적으로 정수 static const 자료 멤버와 정수 static constexpr 자료 멤버는 정의할 필요가 없습니다.
이러한 멤버는 컴파일러가 const propagation을 적용하여, 그 멤버의 값을 위한 메모리를 따로 준비할 필요가 없기 때문입니다.
구체적으로 아래와 같습니다.
class MyClass { public: static constexpr std::size_t MinVals = 28; ... }; ... std::vector<int> myData; myData.reserve(MyClass::MinVals);
컴파일러는 MinVals가 언급된 모든 곳에 28이라는 값을 배치합니다.
따라서 메모리를 사용하지 않는 것이 문제가 되지 않는 것 입니다.
하지만 이 경우, MinVals의 주소를 취하려 할 경우 정의가 없기 때문에 링크에 실패합니다.
이번 경우의 f는 다음과 같이 선언되었습니다.
void f(std::size_t val);
이 경우도 fwd에 대하여 전달이 실패할 수 있습니다.
f(MyClass::MinVals); fwd(MyClass::MinVals); // Link fail
구체적으로는 링크에 실패하는 것으로, 주소를 취할 수 없기 때문에 발생하는 문제입니다.
위의 표현에 실패할 수 있다고 작성했는데, 실패하지 않을 수 있기 때문입니다.
이는 컴파일러와 링커에 따라 다를 수 있기 때문입니다.
표준에 의하면 MinVals를 참조로 전달할 경우, MinVals를 정의해야 하지만, 이것이 강제되는 요구사항은 아닙니다.
따라서 링크까지 성공할 수 있습니다.
하지만 실패할 수 있다는 점에서 이식성이 낮다고 볼 수 있으며, 이러한 허점은 해결하는 것이 좋습니다.
해결법은 그저 해당 변수의 정의를 제공하면 됩니다.
constexpr std::size_t MyClass::MinVals;
초기치는 한 번만 지정해야 하기 때문에, 정의에서는 초기치를 지정하지 않았습니다.
4. 오버로딩 된 함수 및 템플릿
더보기f의 행동을 조정하기 위해, f의 매개변수에 함수를 지정해보도록 하겠습니다.
void f(int pf(int)); // pf : processing function
그리고, f에 넘겨줄 함수를 정의하겠습니다.
int processVal(int value); int processVal(int value, int priority);
이 때, processVal을 인자로 f를 호출하는 것이 가능합니다.
processVal이 오버로딩 되었지만, 컴파일러는 f의 매개변수와 맞는 버전을 선택합니다.
하지만 processVal로 fwd를 호출하는 것은 허용되지 않습니다.
f(processVal) // OK fwd(processVal) // Error
fwd에서는 어떤 processVal을 선택해야 하는지 알 수 없기 때문입니다.
오버로딩과 마찬가지로 함수 템플릿도 동일한 문제가 발생합니다
template<typename T> T workOnVal(T param) { ... } fwd(workOnVal) // Error
이 경우도 어떤 형식의 인스턴스인지 알 수 없기 때문에 발생하는 오류입니다.
위와 같은 문제는 전달할 내용을 구체화 하면 해결할 수 있습니다.
using ProcessFuncType = int (*)(int); ProcessFuncType processValPtr = processVal; fwd(processValPtr); fwd(static_cast<ProcessFuncType>(workOnVal);
위와 같은 방법으로 전달할 함수, 템플릿의 형식을 구체화 하면 오류를 해결할 수 있습니다.
5. 비트필드 (Bitfield)
더보기비트필드는 구조체에서 정수형 데이터를 비트 단위로 나누어 사용할 수 있는 기능입니다.
다음은 IPv4 헤더를 나타내는 구조체의 일부입니다.
struct IPv4Header { std::uint32_t version:4, IHL:4, DSCP:6, ECN:2, totalLength:16; ... };
위와 같이 {멤버명}:{비트수}의 형식으로 지정할 수 있습니다.
이번 예제에서 f는 std::size_t의 매개변수를 받습니다.
이 f를 IPv4Header 구조체의 totalLength필드로 호출하는 코드는 컴파일되지만, fwd를 통해 호출할 경우 오류가 발생합니다.
void f(std::size_t sz); IPv4Header h; ... f(h.totalLength); // OK fwd(h.totalLength); // Error
이는 표준에 "비 const참조는 절대로 비트필드에 묶이지 않아야 한다."라고 명시되어 있기 때문입니다.
이렇게 제한을 둔 이유는 비트를 직접적으로 지정할 방법이 없기 때문입니다.
구체적으로 포인터를 생성할 수 없으며, 따라서 참조를 임의의 비트에 묶을 수도 없습니다.
비트필드의 전달 또한 우회책이 존재합니다.
비트필드를 가리키는 포인터나 참조를 생성할 수는 없기 때문에, 비트필드를 인수로 받는 함수는 비트필드 값의 복사본을 받게 됩니다.
따라서 비트필드를 함수의 매개변수로 전달하는 방법은 두 가지 입니다.
- 값 전달
- const에 대한 참조 전달
const 참조의 경우, 표준에 따르면 그 참조는 실제로 어떤 표준 정수 형식의 객체에 저장된 비트필드 값의 복사본에 묶여야 합니다. 따라서, const참조는 비트필드 자체에 묶이는 것이 아니라, 비트필드의 값이 복사된 일반 객체에 묶이게 됩니다.
위 내용을 정리해서 fwd에 전달하는 우회책은 다음과 같습니다.
auto length = static_cast<std::uint16_t>(h.totalLength); fwd(length);
복사본을 직접 생성해서 전달하는 것으로 fwd를 호출할 수 있습니다.
완벽 전달은 대부분 알려진 그대로 작동합니다.
하지만 비 정상적으로 작동하는 경우 그것이 정상적으로 보일 수 있다는 점이 문제가 될 수 있습니다.
정상적으로 보이지만 비 정상적으로 작동하는 경우, 위와 같이 구체적인 이유를 아는 것이 중요할 수 있습니다.
감사합니다.
'C++ > Effective Modern C++' 카테고리의 다른 글
[Effective Modern C++] Lambda : Init capture (0) 2022.09.20 [Effective Modern C++] Lambda : Default capture의 위험성 (0) 2022.08.30 [Effective Modern C++] 이동 시맨틱의 맹점 (0) 2022.08.18 [Effective Modern C++] 참조 축약 (Reference collapsing) (0) 2022.08.10 [Effective Modern C++] Universal reference와 Overloading (2) (0) 2022.08.01