C++98에서 출발해 rvalue 참조와 함수 오버로딩만으로 이동 시맨틱스를 바닥부터 구축해 보며, std::move의 본질, 언어가 제공하는 지원, 관례, 그리고 한계와 대안까지 차근차근 설명합니다.
Home Written on 2022-01-14 오늘은 C++에서 이동 시맨틱스(move semantics)에 대한 직관을 세우기 위해, 그것이 존재하지 않던 세계를 가정하고 C++11 이전의 문제들에 대한 자연스러운 해법으로 어떻게 등장하는지 이야기해 보려 합니다. 목표는 손을 휘휘 저으며 얼버무리지 않고 이동 시맨틱스가 정확히 무엇인지 분명히 이해하도록 돕는 것입니다. C++98의 당시 상황과 한계를 시작으로, C++11이 근본적으로 무엇을 가져왔는지, 그것을 어떻게 사용할 수 있는지, 언어가 새로운 “이동 시맨틱스” 관용구를 어떻게 도와주는지, 그리고 가능한 대안은 무엇인지까지 살펴보겠습니다. 역사적 정확성이 목표는 아니지만, 오늘날 무엇이 가능한지와 우리가 왜/어떻게 거기에 도달했는지에 대한 좋은 감각을 얻을 수 있을 것입니다.
이 주제에 관한 자료는 많지만, 그중 상당수는 형편없습니다. 흐릿하거나, 맞지 않는 은유에 기대거나, 그냥 틀린 경우도 많습니다. 몇몇자료는 요령과 높은 수준의 설명을 제공하는 데에는 좋지만, 실제로 작동하는 메커니즘에 대한 근본적인 이해를 제공하지 못합니다. 게다가 곧 보겠지만, 저는 std::move의 사용을 교육 자료에서 극적으로 비중을 낮춰야 한다고 강하게 믿습니다. 또한 이동 시맨틱스는 위에서 아래로(top-down) 설명하는 것보다 아래에서 위로(bottom-up) 설명하는 편이 최선이라고 생각합니다.
그럼, 정말로 처음부터 시작해 봅시다.
이동 시맨틱스를 이해하는 열쇠는 “rvalue 참조”, 즉 타입 선언에 보일 수 있는 &&를 이해하는 것입니다. rvalue 참조는 “일반적인” & 참조(이를 lvalue 참조라고 부릅니다)와 정확히 같은 것인데, 단 하나의 차이점은 서로 호환되지 않는다는 점입니다. 이를 “색깔 있는” 참조로 생각해도 됩니다. lvalue 참조는 파란색, rvalue 참조는 초록색이라고요. 그 외에는 완전히 똑같이 동작합니다:
int x = 0;
int& lvalueRef = (int&)x; int&& rvalueRef = (int&&)x;
print(lvalueRef); // 0 print(rvalueRef); // 0
lvalueRef++;
print(lvalueRef); // 1 print(rvalueRef); // 1
rvalueRef++;
print(lvalueRef); // 2 print(rvalueRef); // 2
보시다시피 두 종류의 참조 모두를 통해 변수를 읽고 쓸 수 있습니다. 둘 사이에 차이는 없고 대수롭지 않게 여겨도 됩니다. 다만 앞서 말했듯이 이 참조들은 호환되지 않습니다. 즉, 한 색깔의 참조를 다른 색깔의 참조를 받는 함수에 넘길 수는 없습니다(예: int&를 int&&를 받는 함수에 넘길 수 없음). 하지만 동작은 동일하므로, 한쪽에서 다른 쪽으로 캐스팅할 수는 있습니다:
int x = 2; int& lvalueRef = x; // 컴파일되지 않습니다. 타입이 호환되지 않기 때문입니다. // int&& rvalueRef = lvalueRef; // 하지만 이렇게는 가능합니다: int&& rvalueRef = (int&&) lvalueRef;
이것들을 조금 가지고 놀아 보시길 강력히 권합니다. 보통 lvalue 참조를 쓰던 자리에 rvalue 참조를 써서 작은 프로그램을 작성해 보세요. 이미 &&로 선언했음에도 컴파일러가 계속 &&로 다시 캐스트하라고 요구할 수 있겠지만, 코드는 마치 오래된 lvalue 참조를 쓰는 것처럼 잘 동작한다는 걸 확인할 수 있을 겁니다. 작은 예시는 다음과 같습니다:
struct RefHolder { RefHolder(int&& x) : m_x((int&&)x) {} RefHolder(const RefHolder&& other) : m_x((int&&)other.m_x) {} const int&& m_x; };
int addOne(const int&& ref) { return ref + 1; }
int main() { int x = 2; const RefHolder refHolder((int&&)x); const RefHolder otherRefHolder((RefHolder&&)refHolder); return addOne((int&&)refHolder.m_x); }
모든 것이 예상대로 동작합니다. 복사 생성자를 포함해서요. 프로그램의 반환값은 3입니다.
이제 rvalue 참조가 무섭지도 특별하지도 않다는 점이 납득되었길 바랍니다. 여기서 핵심 통찰은, 두 참조 색깔이 호환되지 않는다는 사실을 이용해 둘을 구분하고 서로 다른 동작을 얻을 수 있다는 것입니다. 더 구체적으로, rvalue 참조를 받는 오버로드와 lvalue 참조를 받는 오버로드를 동시에 두고, 경우에 따라 다른 로직을 실행할 수 있습니다.
그 말이 끔찍한 설계처럼 들렸길 바랍니다. 일반적으로는 그렇기 때문입니다. 포인터나 참조로 요청을 받는다고 해서 handleUserRequest 함수가 다르게 동작하길 원치 않겠죠. 다른 종류/색깔의 참조에 대해서도 마찬가지여야 합니다! 반대로, 뭔가 다르게 하려는 의도가 아니라면 새로운 종류의 참조를 왜 도입했겠습니까?
한동안 참조를 잊고, 겉보기엔 관련 없어 보이는 주제인 컨테이너 위의 알고리즘을 생각해 봅시다. 여기서 컨테이너는 “힙에 할당된 데이터를 여러 개 보유하는 자료구조”라고 느슨하게 정의하겠습니다. 예로는 std::vector, std::map 등이 있고요. 이런 컨테이너는 “많은 데이터를 가리키는 포인터를 보유하는 작은 클래스”로 축약할 수 있습니다.
이러한 컨테이너에서 사람들이 직면하는 문제 중 하나는 복사 비용이 비싸다는 것입니다. 클래스 자체의 몇몇 멤버를 복사해야 할 뿐만 아니라, 그것이 가리키는 힙 할당 데이터 전체도 복사해야 합니다. 수백만 개의 원소를 가진 std::vector의 경우 auto v2 = v1;은 꽤나 값비싼 연산이 됩니다.
한 컨테이너에서 다른 컨테이너로 데이터를 옮기는 데 훨씬 더 효율적인 간단한 알고리즘이 있습니다. 메타데이터(크기와 용량)를 복사하고 같은 힙 메모리를 가리키게 만드는 것입니다. 이렇게 하면 O(n) 연산이 O(1)로 바뀝니다.
하지만 여기서 큰 문제가 생깁니다. 두 컨테이너가 같은 메모리를 가리키게 되면, 프로그램은 훨씬 복잡해집니다. 두 인스턴스 중 하나가 갱신될 때마다 공유 데이터는 수정될 수 있지만, 다른 인스턴스의 메타데이터는 그렇지 않기 때문입니다! 이는 실수하기 쉬운 위험한 상황입니다.
원래 벡터의 데이터를 nullptr로 설정하고 크기와 용량을 적당히 바꾸는 방법을 쓸 수 있습니다. 이제 원래 벡터는 더 이상 그 데이터에 접근할 수 없고, 새 벡터로 데이터를 성공적으로 옮긴 셈이 됩니다. 마치 훔쳐온 것처럼요. 이 트레이드오프는 copy-on-write처럼 더 복잡한 기법을 쓰지 않는 한 최선입니다. 이 개념은 연결 리스트, 트리, 해시맵 등 다양한 종류의 컨테이너에도 유사하게 적용될 수 있음을 덧붙입니다.
결론적으로, 한 컨테이너에서 다른 컨테이너로 데이터를 O(1)에 “복사”하는 마법은 찾지 못했지만, 대신 데이터를 “이전”할 수 있었습니다. 데이터가… 옮겨진(move) 것처럼요? 뭔가 감이 오기 시작합니다.
우리는 아주 간단한 범주의 알고리즘을 개략적으로 정의했습니다. 한 컨테이너에서 다른 컨테이너로 데이터를 훔치듯/옮기듯 이전하는 알고리즘입니다. 이를 해당 기능을 지원할 수 있는 모든 컨테이너의 메서드로 구현할 수도 있겠죠. 사실, 모든 컨테이너에 아예 구현해 두고, 어떤 컨테이너에는 별 의미가 없어도 단순 복사로 폴백하게 만들 수도 있습니다. 어차피 결국 목적지에 데이터는 있게 될 테니까요. 성능이 특별히 좋지 않을 수도 있지만요. 이는 치명적인 문제가 아닙니다. 이미 어떤 컨테이너를 검색하느냐에 따라 성능이 급변하는 std::find 같은 것도 있으니까요.
그런데 단순한 메서드는 기존 생성자나 operator=에 비하면 2등 시민처럼 느껴질 겁니다. 평범한 복사보다 훨씬 빠를 잠재력이 있는 이 “훔치기” 알고리즘을 가능한 한 쓰기 쉽게 만들고 싶고, 가능하면 언어에 새로운 문법을 도입하지 않는 편이 좋겠습니다.
여러 노출 방식을 생각해 보시되, 방향을 조금 유도해 보겠습니다. 복사의 특수한 경우로 취급하는 건 어떨까요? 그러면 생성자와 대입 연산자 문법을 재사용할 수 있을 것입니다. 그렇다면 값을 훔치듯/옮겨 오고 싶은지, 그냥 복사하고 싶은지를 어떻게 표현할까요? 생성자와 operator=에 오버로드를 두고, 하나는 참조를, 다른 하나는 const 참조를 받게 하면 되겠습니다:
class Container { public: Container() = default; Container(Container const&) { // 다른 컨테이너가 const이므로 건드릴 수 없습니다. // 그냥 데이터를 복사합시다. } Container(Container&) { // 다른 컨테이너가 const가 아니므로 뭐든 할 수 있습니다. // 데이터를 훔쳐 옵시다! } private: int* m_data; // ... }
꽤 그럴듯하죠. 이제 다음처럼 쓸 수 있습니다:
const Container c1 = makeContainer(); Container copy{c1}; // c1은 const이므로 복사만 할 수 있습니다. Container c2 = makeContainer(); Container steal{c1}; // c2는 const가 아니므로 데이터를 훔칠 수 있습니다!
하지만 실제로는 금방 복잡해집니다. 우선, 컨테이너에 뭔가를 넣으려면 non-const여야 할 때가 있고, 그 후에는 그것을 복사하길 원할 수 있습니다. 이 경우 const로 전달되도록 보일러플레이트가 필요합니다. 더 중요하게는, 그렇게 하지 않으면 데이터가 이동되어 버리는데, 이는 나중에 버그를 유발하기 쉽습니다(이를 use-after-move라고 합니다). 마지막으로, 이미 사용 중이거나 작성한 많은 코드가 이런 관례를 따르지 않을 것이므로 삶이 훨씬 힘들어집니다.
우리가 정말로 원하는 것은 참조처럼 동작하지만, 이동할지 복사할지를 쉽게 구분할 수 있어야 하고, 기존 코드와 충돌하지 않으며, 잘못된 곳에서 사용하면 컴파일조차 되지 않는 무언가입니다. 어디서 많이 본 이야기 같지 않나요…
바로 여기서 rvalue 참조가 등장합니다. 평소 lvalue 참조를 쓰던 곳에 rvalue 참조를 사용하고, 관례상 복사하던 곳에서는 데이터를 훔치듯 옮기려는 시도를 하는 것입니다:
class Container { public: Container() = default; Container(Container const&) { // 이것은 일반적인 lvalue 참조입니다. // 데이터를 복사합시다. } Container(Container&&) { // 이것이 바로 rvalue 참조입니다. // 데이터를 훔쳐 옵시다! } private: int* m_data; // ... }
다시 강조하지만, 이 참조들 사이에는 본질적인 차이가 없습니다. 단지 색깔이 다르고 서로 호환되지 않으며, 캐스팅 없이 서로를 대신 사용할 수 없을 뿐입니다. 따라서 의미를 뒤바꿔도 이론상 전혀 문제없습니다. rvalue 참조에서 복사하고 lvalue 참조에서 이동해도요. 다만 수십 년간 lvalue 참조에서 복사하는 관습을 지켜 왔고, 탄탄한 관례를 정립하려면 기존 코드와 호환되는 편이 낫기 때문에 그렇게 하지 않을 뿐입니다.
지금까지는 주로 컨테이너를 예로 들었지만, 데이터를 복사하기보다 옮기면 이득을 보는 클래스는 이외에도 많습니다. 예컨대 std::shared_ptr 같은 참조 카운팅 포인터를 생각해 보죠. 이는 본질적으로 포인터 하나와 그 포인터에 대한 참조 개수를 세는 정수 하나로 구성됩니다. 아직 누군가 사용 중이면 해제하지 않기 위함입니다[1]. 이를 복사(즉, lvalue 참조를 생성자나 operator=로 전달해 새 인스턴스를 생성)하면 카운트가 증가합니다. 다시 말하지만, const shared_ptr&로부터 shared_ptr을 생성하는 것이 복사를 의미하는 이유는 관례 때문일 뿐입니다. 구현체가 그것을 버리고 stdout에 모욕을 출력한 뒤 세그폴트가 나도록 만드는 것을 막을 무언가도 없습니다. “복사 생성자”와 “복사 대입”은 의도를 표현하는 축약어일 뿐이고, 실무에서 관례가 대체로 잘 지켜지기 때문에 잘 작동하는 것입니다.
shared_ptr로 돌아가 봅시다. 더 이상 핸들이 필요 없어서 다른 함수로 넘기고 싶다면 어떻게 할까요? lvalue 참조로 넘기면 참조 카운트가 증가합니다. 구현이 스레드 안전하다면 이는 비용이 클 수 있으며, 현재 shared_ptr의 스코프가 끝날 때 참조가 감소될 때도(역시 비용이 클 수 있습니다) 비용을 지불합니다.
대신 위의 Container에서 영감을 얻을 수 있습니다. 그 비싼 작업을 하지 말고, 참조 카운터는 그대로 둔 채 포인터만 넘기면 됩니다[2]. 다시 두 경우를 구분할 필요가 있고, 또다시 두 종류의 참조를 이용해 구분할 수 있습니다:
class MySharedPtr; void f(MySharedPtr);
MySharedPtr ptr = makeSharedPtr();
// f는 MySharedPtr을 필요로 하며, const& 오버로드로 생성될 것입니다. f((const MySharedPtr&)ptr); // f는 MySharedPtr을 필요로 하며, && 오버로드로 생성될 것입니다. f((MySharedPtr&&)ptr);
이렇게 하면 f에 공유 포인터를 어떻게 넘길지 우리가 제어할 수 있습니다. 복사가 바람직하지만 비용이 크고, 데이터를 훔쳐 옮김으로써 피할 수 있는 많은 자원에 이 관용구를 적용할 수 있습니다.
여기서 한 발 더 나아가 std::unique_ptr가 하는 방식도 있습니다. 이 클래스는 포인터를 감싼 작은 래퍼인데, 흥미로운 시맨틱스를 가집니다. 복사는 절대 허용하지 않지만, 다른 인스턴스로의 전달/이동/훔치기는 허용하여 오직 한 인스턴스만 그 포인터를 보유하도록 합니다. 관례에 따라 const& 오버로드는 삭제(delete)해 복사 불가임을 알리고, 대신 && 오버로드를 제공하여 기대한 대로 동작하게 합니다. 즉, 새 포인터를 설정하고 원본은 null로 만들어 오직 하나만 포인터를 보유하게 하죠. 다시 말하지만, 일반 lvalue(&) 참조를 사용해 “포인터를 이동”시키는 것을 막는 것은 아무것도 없습니다. 우리는 단지 관례를 따를 뿐입니다.
std::move이제 C++이 제공하는 두 색깔의 참조 덕분에 꽤나 유용한 관용구를 손에 넣었습니다. 하지만 실제 사용에서는 두 가지 점이 약간 성가십니다. 직접 실험해 보셨다면 금방 눈치채셨을 겁니다:
앞선 예시들에서(그리고 더 명확히 보이도록) 모든 참조 사용에 항상 명시적으로 캐스트를 붙였습니다. 그러나 이는 실무에서 끔찍합니다. 인체공학을 좀 개선해 봅시다. 1 때문에, 복사/즉 lvalue 참조 오버로드를 쓰고 싶을 때는 모든 캐스트를 그냥 제거하면 됩니다. 2 때문에, 항상 캐스트가 필요하긴 합니다. 다만 더 엄격하게 하려면 C 스타일 캐스트 대신 static_cast를 쓰고 싶겠죠. 이왕이면 그 캐스트를 헬퍼 함수로 감싸 의도를 더 명확히 표현하고, 타이핑을 줄이며, 타입을 명시하지 않고도 리팩터링을 쉽게 만들 수 있겠습니다:
template <typename T> T&& move(T& a) { return static_cast<T&&>(a); }
이것이 정확히 std::move가 하는 일입니다. 표준 라이브러리 코드가 대개 그렇듯 생김새는 조금 더 못생겼을 뿐이죠. std::move를 사용하는 것은 rvalue 참조로 캐스팅하는 것과 정확히, 그리고 오직 동일합니다. 이는 다시 말해 특정 함수나 생성자의 특정 오버로드를 호출할 뿐입니다. 마법은 없고, 모든 동작은 그 함수들이 어떻게 구현되어 있는지에 의해 결정됩니다. 관례적으로는 lvalue 오버로드는 복사, rvalue 오버로드는 “어떤 식으로든 훔치는 알고리즘”이며(적용 가능하다면), 그렇지 않은 경우에는 대개 첫 번째 “복사” 동작으로 폴백합니다.
여기서 잠깐 멈추고 정리해 봅시다:
std::move 헬퍼를 사용할 수도 있음)을 통해 rvalue 오버로드를 선택할 수 있음을 압니다.std::move도, 복사/이동 생성자/대입도, 그리고 이동 자체에도 마법 같은 것은 없다는 것을 압니다.이제 이것을 소화하고, 그 함의를 생각하고, 간단한 예제로 놀아 보며, 실제 세계의 적용을 살펴보세요. 이것이 여러분이 _알아야 하는 전부_입니다.
바닥부터 이동 시맨틱스를 구축했으니, 이제 우리가 쓰고 있는 코드가 왜 현재와 같은 모습과 동작을 갖는지 조금 더 깊이 들어가 보겠습니다. 아마 눈치챘을 다음과 같은 사실부터요:
operator=는 항상 const 매개변수를 가집니다.operator=는 절대 const 매개변수를 가지지 않습니다.이 모든 것은 lvalue 생성자는 복사를, rvalue 생성자는 이동을 의미한다는 관례에 귀결됩니다:
이동 시맨틱스(= 복사/이동의 관례)에 신경 쓰지 않는다면, 이런 것들을 신경 쓸 필요가 없습니다. &나 const&& 생성자를 두는 것도 전적으로 유효합니다. 하지만 이동 시맨틱스를 신경 쓴다면, 모든 lvalue 매개변수를 const로 만드는 것이 말이 됩니다. 구조체를 복사하는 데에는 결코 변경이 필요하지 않으니까요. 반면 rvalue 매개변수를 non-const로 만드는 것에는 더 논쟁의 여지가 있습니다. 원본 객체가 더 이상 데이터를 가리키지 않도록 비우는 데 유용하지만, 다음과 같은 반론도 있습니다:
실제로는, C++이 성능을 중시한다고 떠들어 대지만(기술적 가능성과 문화 모두에서), 대부분의 이동 생성자는 원본 객체를 비워 둡니다. 따라서 관례상 rvalue 참조 매개변수는 non-const로 받습니다. 이는 그에 맞춰 동작하길 기대하는 다른 코드들과 잘 맞물리기 위해 중요하지만, 기술적으로 필수는 아니라는 점을 기억하세요. 어쨌든, 사용 중인 코드가 이동 후의 상태를 구체적으로 보장하지 않는 한, 일반적으로 이동된 값을 이동 후에 다시 사용해서는 안 됩니다.
일반 함수의 경우에는 다음과 같은 점을 보셨을 겁니다:
이는 값 타입을 값으로 받으면(= by value) 생성자 호출이 필요하기 때문입니다. 반면 참조로 받으면(= by reference) 읽거나 쓸 수 있지만, 새 값을 만들 필요는 없습니다. 참조를 const로 할지 여부는 함수 내부에서의 사용법에 따라 정하면 됩니다.
rvalue 참조를 받는 이유는 아마 그 객체의 rvalue 참조 생성자를 나중에 호출하려는 것일 겁니다. 앞서 생성자는 rvalue 참조를 non-const로 받는 것이 타당하다고 했으니, 그 객체를 받는 함수도 non-const로 받는 것이 타당합니다. 만약 const로 받는다면, 그 아래 단계에서 할 수 있는 일이 많지 않을 테니까요.
하지만 잠시 멈추고, 의도를 생각해 보며 이것이 정말 올바른 방식인지 생각해 봅시다. 우리는 어떤 객체를 가능한 효율적으로 함수로 “이동”해 넘기고 싶습니다. 대신 우리는 이후에 그것을 다시 사용하지 않겠다는 가정에 기반합니다.
rvalue 참조로 받을 수 있을까요? 물론입니다. 이는 저렴합니다(참조 전달만 하면 되니까요). 호출자는 이동을 기대할 것이고, 함수 내부에서도 크게 할 일이 없습니다. 다만 함수 본문에서 실제로 그 객체를 이동하지 않으면, 호출자에게는 여전히 원본 객체가 남아 있게 되어 놀랄 수 있습니다(거대한 벡터를 함수로 이동시킨 뒤, 함수가 rvalue 참조로 아무것도 하지 않았기 때문에 벡터가 비어 있을 거라 기대하고 재사용했더니 실제로는 모든 데이터가 그대로인 상황을 상상해 보세요).
값으로 받을 수 있을까요? 물론입니다. 그러면 호출 위치에서 복사할지 이동할지를 결정할 수 있습니다. 다만 어쨌든 새로운 값을 생성해야 하므로 참조보다 비용이 클 수 있습니다. 적어도 값을 이동해 넘겼다면, 이제 함수로 넘겨진 값이 이동되어(보통 비어 있는) 상태라는 것을 확실히 알 수 있습니다. 이동하지 않았다면 새 값을 생성할 수 없었을 테니까요. 나중에 보겠지만 실제로는 약간 더 복잡하지만, 시작하기에는 괜찮은 멘탈 모델입니다.
lvalue 참조로도 할 수 있을까요? 안타깝게도 가능합니다. lvalue와 rvalue 참조는 캐스팅(= std::move)으로 쉽게 서로 전환할 수 있기 때문에, lvalue 참조를 받는 쪽에서도 데이터를 이동시켜 버릴 수 있습니다. 한편으로 이는 매우 예상 밖입니다. 이동 동작을 원했다면 관례상 rvalue 참조로 넘겼을 테니까요. 다른 한편으로, non-const lvalue 참조를 넘겼다면 “무슨 일이든 일어날 수 있음”에 동의했기 때문에, 그렇게 되어도 놀랍지 않다고 볼 수도 있습니다. 판단은 여러분께 맡깁니다.
보시다시피 C++에서는 값을 전달하는 방법이 다양하고 사용례도 다양하므로, 조합의 수가 매우 커집니다. 안타깝게도 어떤 클래스의 크기, 복사 전용인지 이동 전용인지 둘 다 가능한지, 함수와 호출자 중 누가 그 클래스를 어떻게 다룰지를 결정할지, 전형적인 이동 관례를 따를지 더 높은 성능을 위해 다르게 할지 등등에 따라 어떤 것을 해야 하는지 정확히 말해 주는 정답, 은탄환, 요령은 없습니다. 근본은 매우 단순합니다. 두 색깔의 참조와 함수 오버로딩. 하지만 경우의 수가 폭증하므로 간단한 가이드라인을 설계하기가 어렵습니다. 헷갈릴 때는 복사와 이동의 추상화를 걷어 내고, 다루는 타입과 함수로 돌아가 실제로 어떤 코드가 호출되고 실행되는지를 생각해 보세요.
지금까지 lvalue와 rvalue 참조는 동일하지만 호환되지 않는다고 주장해 왔습니다. 그럼에도 약간의 차이가 보였을지도 모릅니다. 예컨대, lvalue 참조로 캐스트할 일은 없지만, rvalue 참조로는 항상 캐스트해야 했습니다. 제가 거짓말을 한 걸까요? 꼭 그렇지는 않습니다. 동작은 비슷하지만, 두 경우가 모두 가능할 때 언어가 어느 쪽을 고를지 규칙을 정해 두었기 때문입니다. 오버로드를 써 본 적이 있다면 이는 그리 놀랍지 않습니다. 다음을 보세요:
void f(int); void f(int&);
이 두 오버로드는 모두 유효합니다. 하지만 어떤 int 변수를 들고 f를 호출하면 컴파일러는 모호하다고 불평할 것입니다. 두 오버로드가 모두 가능하지만 어느 쪽이 더 나은 선택인지 명확하지 않기 때문입니다.
참조의 경우에는, 언어가 특정 문맥에서 어느 쪽을 선택할지 정의해 둡니다. 대략 이렇습니다. 값이 임시(temporary)라면 rvalue 오버로드를, 그 외에는 lvalue 오버로드를 선택합니다. 임시 값이란 곧 소멸될 값, 즉 파괴될 값입니다. 함수가 반환한 값이나 리터럴 같은 것들이 예입니다. 다른 곳에서 참조되지 않으므로 그냥 이동해도 됩니다. 더 저렴하니까요. 반대로 임시가 아닌 값의 예로는 어떤 변수에 묶인 값이 있습니다. 몇 가지 예를 보겠습니다:
// g의 반환값은 어디에서도 참조되지 않으므로 임시 값입니다. // 따라서 f에는 rvalue 참조로 전달됩니다. f(g()); // 이것도 동일합니다. f(Foo{})
// foo는 임시가 아닙니다. f를 호출한 후에도 살아 있고, // 이후에도 오래 사용될 수 있습니다. lvalue 참조로 전달됩니다. Foo foo; f(foo);
// scopedFoo는 f를 호출한 직후 파괴되지만, // 임시로 간주되지 않으므로 lvalue 참조로 전달됩니다. { Foo scopedFoo; f(scopedFoo); }
// localFoo는 반환되고 있으므로 곧 소멸될 것임을 알고 있습니다. // 따라서 임시가 되며, rvalue 참조로 전달됩니다. Foo makeFoo() { Foo localFoo; return localFoo; }
여기서 임시에 대한 세부사항으로 들어가지는 않겠습니다. 관심이 있다면 값 범주(value category)에 대해 따로 읽어 보세요. lvalue와 rvalue 참조가 더 세분된 범주로 나뉜다는 사실을 알게 될지도 모릅니다. 그래도 여기서 제시한 멘탈 모델은 더 거칠지만 올바르고, 복잡한 라이브러리 작업을 포함해 실제 프로그래밍에 충분합니다.
C++이 어느 참조를 선택하는지 깊이 생각해 보면 중요한 관찰을 하게 됩니다. 복사나 이동이 lvalue와 rvalue 참조에 본질적으로 결부되어 있지는 않지만, 언어 자체가 사용 방식에 따라 어느 오버로드가 선택될지를 강하게 유도합니다. 다시 쓰이지 않을 값은 rvalue 참조로 전달되고, 다시 쓸 수도 있는 값은 lvalue 참조로 전달됩니다. 이를 알고 나면, 파괴적인 동작을 rvalue 오버로드에 맡기는 것이 더 말이 됩니다. 다른 곳에 영향을 주지 않을 테니까요. 이것이 이동 시맨틱스를 가능하게 하는 진짜 이유입니다. 언어 자체가 특정 상황에서 특정 오버로드가 선택되도록 독려합니다. 물론 여전히 lvalue/rvalue 오버로드를 다른 용도로 쓰거나 복사/이동 동작을 바꿔치기할 수도 있지만, 그러면 언어와 맞서 싸우게 됩니다. 기본 동작을 “고치기” 위해 값을 계속 캐스팅해야 할 겁니다.
마지막으로, C++에서 값을 by value로 전달하는 데 있는 미묘한 점을 알아 두세요. 비trivial한 생성자나 소멸자를 가진 타입을 함수에 값으로 전달할 때, C++은 실제로는 참조를 전달합니다. 이는 숨겨져 있지만 ABI 관점에서 직관적입니다. 값을 생성하는 쪽은 호출자이므로, 소멸하는 쪽도 호출자여야 합니다. 소멸하려면 객체가 호출자의 스택 프레임에 있어야 하니, 함수는 값을 받더라도 실제로는 참조가 전달되는 것입니다. 어쨌든 이 언어 “기능”은 참조로 전달할지 값으로 전달할지에 관한 멘탈 모델을 복잡하게 만들 수 있습니다. 이는 특히 std::unique_ptr 같은 이동 전용 타입에서 중요합니다. 이런 타입은 거의 항상 비trivial 소멸자를 갖습니다. 복사가 싸다면 값으로 전달하는 쪽을 선택할 수도 있겠지만, 어차피 참조로 전달될 것이라면 추가 생성/소멸을 피하고 rvalue 참조로 그냥 전달하는 편이 낫습니다(다시 강조하지만, 이것이 은탄환은 아닙니다. 피호출자가 예측할 수 없게 그 값을 실제로 소비하지 않을 수도 있기 때문입니다).
거의 다 왔습니다. 더 소개할 개념도, 멘탈 모델에 더할 복잡성도 없습니다. 이제 C++의 이동 시맨틱스가 갖는 몇 가지 함의와 대안들을 이야기하고 마치겠습니다. 지금까지의 내용을 모두 이해했다면, 이미 이동 시맨틱스를 다룰 준비가 잘 되어 있습니다.
먼저, C++에서 이동을 다루는 데 “잘 무장”되어 있어야 한다는 사실 자체가 엄청난 실패라고 생각합니다. 역설적이게도, 이 글이 rvalue 참조 자체는 쉽게 설명하고 이해할 수 있음을 보여 주었다고 믿지만, 이동 시맨틱스를 이해하는 일은 훨씬 더 수반적입니다. rvalue 참조는 이동 시맨틱스를 위한 도구에 불과한데도 말이죠. 다시 말해, 함수 오버로드와 두 색깔의 참조만으로는 누구나 쉽게 바닥부터 이동 시맨틱스를 스스로 구축할 수 있습니다. 하지만 현실 세계는 훨씬 더 복잡합니다. 이 복잡성이 “데이터를 복사할지 옮길지”라는 문제에 본질적으로 내재한 것이라면 받아들일 수 있겠지만, 제 생각에는 그렇지 않습니다.
C++의 복잡함을 다룰 때 저는 같은 문제를 C에서는 어떻게 다룰지를 생각해 보는 것을 좋아합니다. 이 경우 오버로드도, 서로 다른 참조도, 생성자조차 필요 없습니다. 그저 두 개의 간단한 함수면 됩니다. void copy_foo(Foo*, Foo*) 그리고
void move_foo(Foo*,
Foo*)
[3]. 물론 C가 갖는 안전성 부족, 코드 중복 등의 단점은 모두 따라옵니다. 하지만 믿을 수 없을 만큼 단순합니다. 또한 이동으로 이득을 보는 타입에 대해서만 이동을 생각하면 되지, 그 외의 모든 타입에 대해서는 어떠한 추가 복잡성도 떠안을 필요가 없습니다[4].
더 현대적이고 안전한 언어가 어떻게 하는지 보고 싶다면 Rust를 보세요. Rust의 모델도 매우 단순합니다. “이동만 존재한다.” 이는 기본값으로 말이 되는데, 실수로 복사하지 않게 해 주며 삶을 훨씬 쉽게 만듭니다. 무언가를 복사하고 싶다면, 언제든 전용 메서드로 명시적으로 하면 됩니다[5].
Rust의 또 다른 차이는, C++에서는 이동 후의 값을 “대체로” 사용하지 말아야 한다고만 하지만, Rust에서는 이동 후의 값을 아예 사용할 수 없다는 것입니다. Rust 컴파일러가 이를 실제로 강제합니다. 그 부산물로 const 값에서도 이동이 가능해지는데, 이동 후에는 그 값을 사용할 수 없으므로 굳이 비울 필요가 없고, 따라서 const여도 무방하기 때문입니다. 앞서 논의했듯이 이는 C++에서도 기술적으로 가능하지만, 현실에서는 rvalue 참조를 non-const로 주고받는 것이 관행입니다. 이동된 객체의 상태에 대한 보장이 없다는 사실과 맞물려, C++에서는 성능 손실(이동 후 사용 버그를 완화하기 위해 객체를 비워야 할 수도 있음)과 함께 use-after-move라는 독특한 범주의 버그가 새로이 생겨납니다.
이 글을 교정해 주고 정확성, 가독성, 유용성을 챙겨 준 Jean-François Marquis에게 특별히 감사를 전합니다. Merci JF!
[1] 실제로는 조금 더 복잡합니다. 특히 약한 참조(weak reference)도 지원하는 std::shared_ptr의 경우가 그렇습니다. 하지만 여기서는 이 정도면 충분합니다. Jump back
[2] 또한 우리 소멸자가 나중에 포인터를 해제하지 않도록, 우리 쪽 포인터를 null로 만들어야 합니다. 물론 더 미묘한 부분들이 있지만, 우리는 여기서 올바른 참조 카운팅 포인터를 설계하려는 게 아닙니다. Jump back
[3] 사실 비우지 않는(non-clearing) 이동을 선택하고 타입이 포인터 한 겹만 가진다면, C의 대입 연산자는 이미 기능적으로 이동이 됩니다. 이 경우 deep_copy_foo 하나만 있으면 됩니다! Jump back
[4] POD 타입에 대해서는 대부분 이동 시맨틱스를 무시해도 됩니다. 하지만 실제로 이동 시맨틱스가 의미를 가지는 컨테이너, 스마트 포인터, 소유권 토큰 같은 경우보다 이동 시맨틱스를 다뤄야 하는 경우가 더 자주 생기곤 합니다. Jump back
[5] 네, Copy 트레이트가 있죠. Jump back