std::ref와 std::cref, 그리고 std::reference_wrapper가 무엇인지와 언제 사용해야 하는지 설명합니다. 컨테이너에 참조를 저장하는 방법과 std::bind, std::thread, std::make_pair 등에 참조를 안전하게 전달하는 예제를 다룹니다.
std::ref와 std::cref를 들어보셨나요? std::reference_wrapper 타입의 객체를 만들어 주는 헬퍼 함수들 말이죠? 대답은 아마도 ‘예’일 겁니다. 그렇다면 이 글은 아마 당신을 위한 글이 아닐 수도 있습니다. 하지만 이런 것들을 들어본 적이 없거나, std::reference_wrapper를 벡터에 참조를 저장하려고 할 때만 써 본 것이 전부라면, 이 글을 계속 읽어 볼 가치가 있습니다.
이 글은 테스트를 통과시키기 위해 std::ref를 사용해야 했던 몇 가지 실패한 테스트들에서 영감을 받았습니다.
reference_wrapper는 무엇을 하나요?객체 T에 대한 참조(T&)는 복사 할당이 불가능합니다. 반면 T&를 에뮬레이션하는 std::reference_wrapper<T>는 복사 생성도, 복사 할당도 가능합니다. 심지어 trivial하게 복사 가능한(trivially copyable) 타입이라 바이트 단위 복사가 가능해 매우 효율적입니다.
그렇다면 이런 래퍼는 언제 써야 할까요?
벡터에 참조를 저장해 보려 한 적이 있다면, 이것이 컴파일되지 않는다는 것을 알고 계실 겁니다.
1
2
// 이것은 컴파일되지 않습니다
std::vector<std::string&> v;
원래의 근본 원인은 참조가 대입(assignable)될 수 없다는 데에 있었습니다. 한 번 초기화된 참조는 다른 객체를 가리키도록 바꿀 수 없습니다(포인터는 가능합니다). 따라서 const 객체 역시 재할당할 수 없으므로 저장할 수 없습니다.
1
2
// 이것도 컴파일되지 않습니다
std::vector<const std::string> v;
위와 같은 것은 여전히 불가능하지만, C++11 이후 규칙이 조금 바뀌었습니다. 더 이상 핵심은 복사 할당 가능성만이 아니라, 지울 수 있는지(Erasable) 여부입니다.
Erasable의 의미는 다음 식이 잘 형성(well-formed)되어야 한다는 것입니다:
1
allocator_traits<A>::destroy(m, p)
여기서 A는 컨테이너의 할당자 타입, m은 할당자 인스턴스, p는 *T 타입의 포인터입니다. _Erasable_의 정의는 여기를 참고하세요.
기본적으로 vector는 std::allocator<T>를 할당자로 사용합니다. 기본 할당자에서 이 요구 사항은 p->~T()의 유효성과 동치입니다(주의: T는 참조 타입이고 p는 참조에 대한 포인터입니다). 그러나 “참조에 대한 포인터”는 불법이므로 해당 식은 잘 형성되지 않습니다.
반면 std::reference_wrapper 인스턴스에 대한 포인터는 유효하므로, 다음과 같이 참조를 컨테이너에 저장할 수 있습니다:
1
2
3
4
5
6
std::string s1{"Hello"};
std::string s2{","};
std::string s3{"World!"};
std::vector<std::reference_wrapper<std::string>> v {
std::ref(s1), std::ref(s2), std::ref(s3)
};
하지만 std::ref와 std::cref는 컨테이너에서만 유용한 것이 아닙니다. std::bind, std::thread의 생성자, 또는 std::make_pair 같은 표준 헬퍼 함수에 참조를 전달해야 할 때도 매우 유용합니다.
이들의 공통점은, 참조를 전달하더라도 그 참조를 제거(decay)해 버리고, 전달된 값을 이동하거나 복사해 버린다는 점입니다. 그러므로 정말로 “참조를 전달한 것처럼” 동작하게 하고 싶다면, 참조 래퍼를 사용해야 합니다!
이런 상황에서 std::ref/std::cref가 어떻게 차이를 만드는지 간단한 예제를 보겠습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <functional>
#include <iostream>
void f(int& p1, int& p2, const int& p3) {
std::cout << "In function: " << p1 << ' ' << p2 << ' ' << p3 << '\n';
++p1; // 함수 객체에 저장된 n1의 복사본을 증가시킨다
++p2; // main()의 n2를 증가시킨다
// ++p3; // 컴파일 에러
}
int main() {
int n1 = 1, n2 = 2, n3 = 3;
std::function<void()> bound_f = std::bind(f, n1, std::ref(n2), std::cref(n3));
n1 = 10;
n2 = 11;
n3 = 12;
std::cout << "Before calling f() directly: " << n1 << ' ' << n2 << ' ' << n3 << '\n';
f(n1, n2, n3);
std::cout << "After calling f() directly: " << n1 << ' ' << n2 << ' ' << n3 << '\n';
std::cout << "==================\n";
std::cout << "Before calling bound_f(): " << n1 << ' ' << n2 << ' ' << n3 << '\n';
bound_f();
bound_f();
std::cout << "After calling bound_f(): " << n1 << ' ' << n2 << ' ' << n3 << '\n';
}
이 예제에서 f()는 모든 정수를 참조로 받습니다. 세 번째 인자는 const이기도 합니다. f()를 직접 호출하면 우리가 원하는 대로 n1과 n2가 “영구적으로” 수정됩니다.
반면 std::function의 도움을 받아 f()를 호출하면 동작이 달라집니다. 인자를 바인딩할 때 std::ref나 std::cref를 사용하지 않으면, 인자들은 그대로 복사됩니다. 그래서 n1을 겉보기에는 참조로 전달한 것 같아도, bound_f 안의 p1은 바인딩 시점의 값을 계속 갖습니다. 따라서 n1의 값은 증가하지 않습니다. 하지만 bound_f()를 두 번째로 호출하면 p1의 값이 변한 것을 볼 수 있습니다. 이는 std::function이 n1의 복사본을 보관하고 있고, 호출 사이에서 그 복사본이 업데이트되었음을 의미합니다.
p1이 실제로 n1을 참조하게 하려면, n2/p2와 n3/p3에서 했듯이 std::ref를 통해 전달해야 합니다.
std::ref와 std::cref는 std::reference_wrapper 객체를 만들기 위한 헬퍼 함수입니다. 이 도구들을 통해 표준 컨테이너에 참조를 저장할 수 있습니다.
또한 std::bind, std::thread, std::make_pair 같은 템플릿이나 여러 헬퍼 함수에 참조를 전달할 수도 있습니다. 이를 사용하지 않으면 겉보기에는 잘 동작하는 것처럼 보여도, 참조 대신 복사가 일어날 수 있습니다.
이 글이 마음에 드셨다면,