move 의미론과 rvalue 참조
lvalue와 rvalue를 구분하고 이동 생성·std::move·완벽 전달로 값비싼 자원을 복사 없이 이전하는 원리를 익힙니다.
이번 장에서는 복사 비용을 줄이기 위한 이동 시맨틱(Move Semantics)과 값 범주를 표현하는 Rvalue 참조(Rvalue References)를 다룹니다.
이동 시맨틱은 객체 내부의 값비싼 자원(동적 할당된 메모리, 파일 핸들 등)을 복사하는 대신 이동함으로써, 불필요한 복사 비용을 줄이고 프로그램의 성능을 크게 향상시키는 데 기여합니다.
이는 특히 대용량 데이터를 다루는 클래스에서 그 효과가 두드러집니다.
먼저 std::move가 실제 이동 함수가 아니라 값 범주를 바꾸는 신호라는 점을 전체 흐름으로 잡아봅니다.
Rvalue와 Lvalue의 재정의
이동 시맨틱을 이해하기 위해서는 먼저 C++의 값 범주(Value Categories) 개념을 명확히 알아야 합니다.
C++11 이전에는 모든 표현식을 lvalue 또는 rvalue로 분류했습니다.
C++11 이후에는 이 분류가 좀 더 세분화되었습니다.
-
Lvalue (Left Value): 객체나 함수의 정체성을 나타내며 xvalue가 아닌 표현식입니다. const 객체도 lvalue이므로 대입 가능 여부와는 다릅니다. (예: 변수,
*ptr,arr[index])Lvalue 예시 int x = 10; // x는 lvalue int* p = &x; // p는 lvalue, *p는 lvalue -
Rvalue (Right Value): prvalue와 xvalue를 묶는 값 범주입니다. “대입 오른쪽에만 오는 값”이나 “즉시 소멸할 값”으로 정의하지 않습니다. (예:
10,a + b, 함수 반환 값)Rvalue 예시 int y = 5; int z = x + y; // (x + y)는 rvalue int result = func(); // func()의 반환 값은 rvalue (임시 객체)
위 발췌의 func() 설명은 값을 반환하는 함수라는 조건에서 읽습니다. 반환 타입이 int인 호출은 prvalue, int&인 호출은 lvalue, int&&로 객체를 참조하는 호출은 xvalue입니다. 함수 호출이라는 문법만으로 rvalue라고 판단할 수 없습니다. 또한 이름이 붙은 rvalue 참조 변수의 표현식 자체는 lvalue이므로, 선언 타입과 표현식의 값 범주를 나눠 확인합니다. 함수 호출의 반환 타입별 값 범주에 이 구분이 명시되어 있습니다.
C++11은 lvalue와 rvalue 외에 다음과 같은 새로운 값 범주를 도입하여 이동 시맨틱의 기반을 마련했습니다.
- prvalue (Pure Rvalue): 임시 객체 또는 리터럴처럼, 값을 생산하지만 명확한 주소를 가지지 않는 표현식. (예:
10,a + b,func()의 반환 값) - xvalue (eXpiring Value): 소멸될 준비가 되어 있지만 여전히 주소를 가질 수 있는 표현식.
std::move의 결과나 rvalue 참조로 캐스팅된 lvalue 등이 여기에 해당합니다. - glvalue (Generalized Lvalue): 주소를 가질 수 있는 표현식 (
lvalue와xvalue를 포함). - rvalue (generalized Rvalue):
prvalue와xvalue를 포함하는 개념. 즉, 이동될 수 있는 값.
우리가 주로 다룰 rvalue는 prvalue와 xvalue를 통칭하며, 이동 연산의 후보로 사용할 수 있으나 실제 이전 가능성과 이후 상태는 타입·const·소유권 계약에 달린다는 의미를 가집니다.
Rvalue 참조 (&&)
C++11에서는 rvalue 참조(&&)라는 새로운 참조 타입이 도입되었습니다.
비const lvalue 참조는 일반적으로 lvalue에 바인딩합니다. const lvalue 참조는 rvalue에도 바인딩할 수 있습니다.
반면, rvalue reference는 rvalue에만 바인딩될 수 있습니다.
타입&& 변수이름 = rvalue_표현식;#include <utility>
#include <iostream>
void printValue(int& x) { // Lvalue 참조 (x는 Lvalue여야 함)
std::cout << "Lvalue reference: " << x << std::endl;
}
void printValue(int&& x) { // Rvalue 참조 (x는 Rvalue여야 함)
std::cout << "Rvalue reference: " << x << std::endl;
}
int main() {
int a = 10; // a는 lvalue
printValue(a); // Lvalue reference 호출
printValue(10); // Rvalue reference 호출
printValue(a + 5); // (a + 5)는 rvalue, Rvalue reference 호출
int b = 20;
// int& ref_b = b + 1; // 컴파일 오류: b+1은 rvalue
int&& ref_b_rvalue = b + 1; // Rvalue 참조는 rvalue에 바인딩 가능
std::cout << "ref_b_rvalue: " << ref_b_rvalue << std::endl; // 출력: 21
// ref_b_rvalue는 이제 Lvalue로 취급됩니다! (나중에 설명)
printValue(ref_b_rvalue); // Lvalue reference 호출
printValue(std::move(ref_b_rvalue)); // std::move 사용 시 Rvalue reference 호출
return 0;
}핵심: rvalue reference는 임시 객체나 명확한 주소가 없는 값(즉, rvalue)에 바인딩되어, 해당 rvalue의 자원을 이전할 수 있는 기회를 제공합니다.
이동 시맨틱 (Move Semantics)
아래 그림은 복사와 이동이 자원을 다루는 방식이 어떻게 다른지, 소유권 이전 관점에서 비교합니다.
std::move는 신호이고 move operation이 자원을 옮긴다
vec1의 H1 소유→std::move 후 소유 불변→이동 생성 뒤 vec3 소유와 vec1 null/0이 구체적으로 분리되어 있다.
C++ · MOVE SEMANTICS
std::move는 값 범주를 바꾸고 이동 연산이 자원을 옮긴다
cast와 자원 이전을 분리해 읽어야 const·이동 부재·moved-from 상태를 정확히 설명할 수 있다.
보조 설명 · 코드와 수명 조건
- 원본
이름 있는 객체 표현식은 lvalue다.
- std::move
xvalue로 cast하지만 자원은 아직 옮기지 않는다.
- move operation
선택된 생성자나 대입이 handle을 전달한다.
- moved-from
원본은 타입 계약에 따라 소멸·재대입 가능한 상태다.
본문 MyVector 이동은 원본 _data=nullptr, 크기·용량 0을 설정한다. 표준 라이브러리 타입은 별도 규정이 없으면 유효하지만 지정되지 않은 상태이다. const나 이동 overload 부재로 복사가 선택될 수도 있다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 이동 전 vec1이 힙 배열 H1을 소유하며 std::move 후보 표현식은 소유를 바꾸지 않는다. 이동 생성 후 vec3가 같은 H1을 소유하고 이 구현의 vec1은 null이며 크기는 0이다. 위아래 H1은 서로 다른 배열이 아니라 같은 자원의 시점별 그림이다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 이동 전 vec1이 H1과 크기 10을 소유한다. |
|
|
| std::move 값 범주를 바꿔 이동 생성에 사용할 수 있게 한다. |
|
|
| 이동 생성 완료 MyVector 이동 생성자가 소유를 vec3에 넘긴다. |
|
|
std::move는 신호이고 move operation이 자원을 옮긴다
vec1의 H1 소유→std::move 후 소유 불변→이동 생성 뒤 vec3 소유와 vec1 null/0이 구체적으로 분리되어 있다.
C++ · MOVE SEMANTICS
std::move는 값 범주를 바꾸고 이동 연산이 자원을 옮긴다
cast와 자원 이전을 분리해 읽어야 const·이동 부재·moved-from 상태를 정확히 설명할 수 있다.
보조 설명 · 코드와 수명 조건
- 원본
이름 있는 객체 표현식은 lvalue다.
- std::move
xvalue로 cast하지만 자원은 아직 옮기지 않는다.
- move operation
선택된 생성자나 대입이 handle을 전달한다.
- moved-from
원본은 타입 계약에 따라 소멸·재대입 가능한 상태다.
본문 MyVector 이동은 원본 _data=nullptr, 크기·용량 0을 설정한다. 표준 라이브러리 타입은 별도 규정이 없으면 유효하지만 지정되지 않은 상태이다. const나 이동 overload 부재로 복사가 선택될 수도 있다.
이 예의 조건
- 본문과 원 도표에 제시된 사례의 입력·조건을 사용합니다. 설명용 수치나 독립 비교 경로는 각 단계에 표시합니다.
- 현재 상태·값과 전환 근거의 위치는 모든 단계에서 같습니다. 조건이 맞지 않거나 실행되지 않은 경로를 성공한 동작으로 해석하지 않습니다.
관계도의 보충 설명
| 범위 | 원문 |
|---|---|
| 관계·조건 설명 | 이동 전 vec1이 힙 배열 H1을 소유하며 std::move 후보 표현식은 소유를 바꾸지 않는다. 이동 생성 후 vec3가 같은 H1을 소유하고 이 구현의 vec1은 null이며 크기는 0이다. 위아래 H1은 서로 다른 배열이 아니라 같은 자원의 시점별 그림이다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 이동 전 vec1이 H1과 크기 10을 소유한다. |
|
|
| std::move 값 범주를 바꿔 이동 생성에 사용할 수 있게 한다. |
|
|
| 이동 생성 완료 MyVector 이동 생성자가 소유를 vec3에 넘긴다. |
|
|
rvalue reference가 도입된 주된 이유는 이동 시맨틱을 구현하기 위함입니다.
이동 시맨틱은 객체를 복사하는 대신, 객체 내부의 자원을 이동시키는 개념입니다.
복사 vs 이동- 복사(Copy): 원본 객체의 모든 데이터를 새로운 메모리 공간에 복제합니다. 대용량 데이터의 경우 시간과 메모리 낭비가 심합니다. (복사 생성자, 복사 대입 연산자)
- 이동(Move): 이동 생성자나 이동 대입 연산자로 원본의 자원(예: 힙 메모리 포인터)을 다른 객체에 이전할 수 있습니다. 아래
MyVector는 원본의 포인터와 크기를nullptr·0으로 만들지만, 이동 후 상태와 비용은 타입의 계약에 따라 다릅니다.
이동 생성자(Move Constructor)와 이동 대입 연산자(Move Assignment Operator): 직접 소유 자원을 옮기는 클래스에서는 이동 특별 멤버를 설계합니다. 조건을 만족하면 컴파일러가 멤버별 이동을 생성할 수도 있습니다.
이들은 rvalue reference를 매개변수로 받습니다.
#include <iostream>
#include <cstddef>
#include <utility>
#include <vector>
class MyVector {
private:
int* _data;
std::size_t _size;
std::size_t _capacity;
public:
// 일반 생성자
MyVector(std::size_t size) : _data(new int[size]{}), _size(size), _capacity(size) {
std::cout << "Constructor: Allocated " << _size * sizeof(int) << " bytes.\n";
}
// 소멸자
~MyVector() {
if (_data) {
std::cout << "Destructor: Deallocating " << _size * sizeof(int) << " bytes.\n";
delete[] _data;
}
}
// 복사 생성자 (Deep Copy)
MyVector(const MyVector& other) : _size(other._size), _capacity(other._capacity) {
_data = new int[_capacity];
for (std::size_t i = 0; i < _size; ++i) {
_data[i] = other._data[i];
}
std::cout << "Copy Constructor: Copied " << _size * sizeof(int) << " bytes.\n";
}
// 복사 대입 연산자 (Deep Copy)
MyVector& operator=(const MyVector& other) {
if (this != &other) {
int* next = new int[other._capacity];
for (std::size_t i = 0; i < other._size; ++i) {
next[i] = other._data[i];
}
delete[] _data;
_data = next;
_size = other._size;
_capacity = other._capacity;
}
std::cout << "Copy Assignment: Copied " << _size * sizeof(int) << " bytes.\n";
return *this;
}
// *********** 이동 생성자 (Move Constructor) ***********
MyVector(MyVector&& other) noexcept
: _data(other._data), _size(other._size), _capacity(other._capacity) {
// 원본 객체의 자원 포인터를 nullptr로 설정하여 이중 해제 방지
other._data = nullptr;
other._size = 0;
other._capacity = 0;
std::cout << "Move Constructor: Moved " << _size * sizeof(int) << " bytes.\n";
}
// *********** 이동 대입 연산자 (Move Assignment Operator) ***********
MyVector& operator=(MyVector&& other) noexcept {
if (this != &other) {
if (_data) delete[] _data; // 기존 자원 해제 (RAII)
_data = other._data;
_size = other._size;
_capacity = other._capacity;
other._data = nullptr; // 원본 객체 자원 무효화
other._size = 0;
other._capacity = 0;
}
std::cout << "Move Assignment: Moved " << _size * sizeof(int) << " bytes.\n";
return *this;
}
// 편의를 위한 함수
void print_info() const {
std::cout << "MyVector Info: size=" << _size << ", capacity=" << _capacity
<< ", data_ptr=" << (void*)_data << std::endl;
}
};
// MyVector 객체를 반환하는 함수 (RVO/NRVO가 적용될 수 있음)
MyVector createVector(std::size_t size) {
return MyVector(size);
}
int main() {
std::cout << "--- Scenario 1: Copy vs Move ---" << std::endl;
MyVector vec1(10); // Constructor
vec1.print_info();
std::cout << "\nAttempting copy (Copy Constructor or Copy Assignment)\n";
MyVector vec2 = vec1; // Copy Constructor 호출 (복사 발생)
vec2.print_info();
vec1.print_info(); // 원본은 그대로 유지
std::cout << "\nAttempting move (Move Constructor or Move Assignment)\n";
MyVector vec3 = std::move(vec1); // Move Constructor 호출 (이동 발생)
vec3.print_info();
vec1.print_info(); // vec1은 자원을 잃었음을 확인 (nullptr)
std::cout << "\n--- Scenario 2: Function Return Value (RVO/NRVO vs Move) ---" << std::endl;
// C++ 컴파일러는 일반적으로 RVO/NRVO(Return Value Optimization/Named Return Value Optimization)를 수행하여
// 불필요한 복사/이동을 피하려고 시도합니다.
// 따라서 아래 코드에서는 이동 생성자가 호출되지 않을 수도 있습니다.
// 이를 명확히 보려면 컴파일러 최적화 옵션을 끄거나 (권장하지 않음),
// std::vector와 같은 컨테이너의 push_back 등에서 이동을 관찰하는 것이 좋습니다.
MyVector vec4(1); // Constructor
std::cout << "\nAssigning return value from function:\n";
vec4 = createVector(20); // createVector에서 MyVector(20) 생성 -> (RVO/NRVO) -> vec4로 이동 대입
// C++17 이후 반환 임시 객체를 직접 생성한 뒤 기존 vec4에 이동 대입합니다.
std::cout << "\n--- Scenario 3: std::vector::push_back with move ---" << std::endl;
std::vector<MyVector> vec_of_vectors;
vec_of_vectors.reserve(2); // 재할당으로 인한 복사/이동 방지
vec_of_vectors.push_back(MyVector(5)); // 임시 객체 (rvalue)이므로 이동 생성자 호출 (C++11 이후)
vec_of_vectors.push_back(std::move(vec_of_vectors[0])); // vec_of_vectors[0]의 자원을 이동.
// vec_of_vectors[0]는 이제 비어있는 상태
// reserve(2)로 재할당이 없으며 첫 원소는 이 클래스의 빈 상태로 남습니다.
vec_of_vectors[0].print_info();
vec_of_vectors[1].print_info();
std::cout << "End of main." << std::endl;
return 0;
} // 모든 MyVector 객체의 소멸자가 호출됨이동 생성자와 이동 대입 연산자는 noexcept로 선언하는 것이 일반적입니다.
이는 이동 작업 중 예외가 발생하지 않음을 컴파일러에게 알려주어, std::vector와 같은 컨테이너가 재할당 시 이동 대신 복사를 선택하는 것을 방지하고 성능상의 이점을 얻을 수 있게 합니다.
std::move 함수
std::move는 객체를 실제로 이동시키는 함수가 아닙니다.
std::move는 단지 주어진 lvalue를 rvalue reference로 캐스팅(static_cast)하는 함수입니다.
이렇게 캐스팅된 rvalue reference는 이동 생성자나 이동 대입 연산자가 오버로드되어 있다면, 해당 연산자를 호출하도록 유도합니다.
주의: 실제 이동 연산이 끝난 뒤 원본에 어떤 연산을 할 수 있는지는 해당 타입의 계약을 확인해야 합니다.
std::move 호출 자체는 상태를 바꾸지 않습니다. 실제 이동 후 표준 라이브러리 타입은 별도 규정이 없으면 유효하지만 지정되지 않은 상태를 유지하며, 사용자 타입은 자신의 계약을 따릅니다. 위 MyVector는 포인터 null·크기 0으로 명시합니다.
따라서 이동 후 원본을 사용할 때는 하려는 연산의 전제 조건을 확인합니다. 위 MyVector의 원본은 소멸하거나 새 값을 대입할 수 있지만, 이동 전 원소가 그대로 남아 있다고 가정해서는 안 됩니다.
MyVector vec1(10);
MyVector vec3 = std::move(vec1); // vec1을 rvalue로 간주하여 이동 생성자 호출이 예시에서 중요한 부분은 std::move 호출 자체보다, 이후 원본 객체가 어떤 상태로 남는지입니다.
완벽 전달과 std::forward (고급)
이동 시맨틱과 rvalue reference의 또 다른 중요한 활용은 완벽 전달(Perfect Forwarding)입니다.
이는 함수 템플릿의 매개변수를 다른 함수로 전달할 때, 원본 매개변수의 값 범주(lvalue/rvalue)와 const/volatile 한정자를 그대로 유지하여 전달하는 기법입니다.
완벽 전달을 구현하기 위해서는 T&& (Universal Reference / Forwarding Reference)와 std::forward를 사용합니다.
T&&: 함수 템플릿에서T가 추론될 때,T&&는lvalue가 오면Lvalue reference(T&)로,rvalue가 오면Rvalue reference(T&&)로 추론됩니다. 이를 Universal Reference 또는 Forwarding Reference라고 부릅니다.std::forward<T>(arg):arg를T의 값 범주에 맞게static_cast하여 전달합니다.
#include <iostream>
#include <utility> // std::forward, std::move
void process(int& value) { std::cout << "Processing Lvalue: " << value << std::endl; }
void process(int&& value) { std::cout << "Processing Rvalue: " << value << std::endl; }
template<typename T>
void wrapper(T&& arg) { // arg는 Universal Reference
std::cout << "Wrapper received: ";
process(std::forward<T>(arg)); // arg를 원래의 값 범주로 전달
}
int main() {
int lval = 10;
wrapper(lval); // lval은 Lvalue이므로 process(int&) 호출
wrapper(20); // 20은 Rvalue이므로 process(int&&) 호출
wrapper(std::move(lval)); // lval을 Rvalue로 캐스팅하여 전달, process(int&&) 호출
return 0;
}std::forward를 사용하면 wrapper 함수가 전달받은 arg의 lvalue/rvalue 속성을 유지하여 process 함수로 전달할 수 있습니다.
이는 라이브러리 개발에서 매우 중요합니다.
std::forward는 단순히 이동시키는 도구가 아니라, wrapper가 받은 값의 원래 범주를 보존해 다음 함수로 넘기는 도구입니다.
C++ · PERFECT FORWARDING
std::forward는 추론한 T로 호출자의 값 범주를 보존한다
본문 wrapper(T&& arg)의 T는 인자에서 추론된다. 이름 있는 arg 표현식은 두 경우 모두 lvalue이므로 전달할 때 원래 범주를 복원한다.
보조 설명 · 코드와 수명 조건
wrapper(lval) · lvalue
T=int& → 매개변수 int&. 이름 있는 arg는 lvalue이고 std::forward<T>(arg)도 lvalue이다. process(int&)를 호출한다.
wrapper(20) · rvalue
T=int → 매개변수 int&&. 이름 있는 arg는 lvalue지만 std::forward<T>(arg)는 xvalue이다. process(int&&)를 호출한다.
이 규칙은 타입 매개변수 T를 인수에서 추론하는 T&&의 forwarding reference에 적용한다. const T&&에는 같은 규칙을 적용하지 않는다. std::move(arg)는 두 경우 모두 rvalue 쪽으로 캐스팅하므로 역할이 다르다.
이동 시맨틱의 장점
- 성능 향상: 불필요한 복사 비용을 줄여 특히 대용량 데이터를 다루는 경우 프로그램의 실행 속도를 크게 높입니다.
- 자원 효율성: 이미 존재하는 자원을 재활용하므로 메모리 할당 및 해제 오버헤드를 줄입니다.
- 예외 안전성: 이동 작업이
noexcept로 선언될 수 있다면, 컨테이너 재할당 등에서 더 높은 수준의 예외 안전성을 기대할 수 있습니다. - 스마트 포인터의 효율성:
std::unique_ptr의move-only특성은 이동 시맨틱 덕분에 가능합니다.
이동 시맨틱을 적용할 때는 std::move와 std::forward의 목적을 구분해야 값 범주가 의도대로 전달됩니다.
마지막으로 move, 이동 생성자, noexcept, forward가 각각 어떤 역할을 맡는지 한 번에 정리합니다.
move 의미론은 lvalue, xvalue, prvalue 같은 값 범주와 std::move가 실제 이동이 아니라 캐스팅이라는 점을 함께 이해해야 안전하게 사용할 수 있습니다.
예시: 전달할 때 잃은 값 범주를 확인하기
앞의 MyVector에서 vec1이 갖고 있던 힙 배열을 H1이라고 부르면, 복사한 vec2는 별도 배열 H2를 소유합니다. 이동 생성한 vec3는 H1을 받아 소유하고 이 클래스의 vec1은 nullptr·크기 0이 됩니다. 이는 MyVector의 구현이 정한 상태입니다. 다른 타입의 이동 후 값까지 빈 값으로 단정하지 않습니다. 이동 대입에서는 목적지가 이미 가진 배열을 먼저 해제하는 단계도 추적해야 합니다.
다음 C++17 파일은 실제 자원 이동 없이 정수의 오버로드 선택만 비교합니다. std::move 자체가 값을 수정한다는 오해를 피하기 위한 실험입니다.
#include <iostream>
#include <utility>
void route(int&) { std::cout << "lvalue\n"; }
void route(int&&) { std::cout << "rvalue\n"; }
void route(const int&) { std::cout << "const lvalue\n"; }
template<class T> void plain(T&& arg) { route(arg); }
template<class T> void forwarded(T&& arg) { route(std::forward<T>(arg)); }
template<class T> void consumed(T&& arg) { route(std::move(arg)); }
int main() {
int n = 7;
const int fixed = 8;
plain(20);
forwarded(20);
forwarded(n);
consumed(n);
forwarded(fixed);
std::cout << n << '\n';
}출력은 차례로 lvalue, rvalue, lvalue, rvalue, const lvalue, 7입니다. plain의 이름 있는 arg는 원래 인수가 20이어도 lvalue입니다. forwarded는 인수에서 추론한 T를 이용해 원래 범주와 const를 전달합니다. consumed는 lvalue로 들어온 n도 이동 후보로 표시하지만 이 route에는 정수를 변경하는 연산이 없어 n은 7입니다. 여기서 T&&는 T를 인수에서 추론하는 forwarding reference입니다. const T&&나 추론하지 않는 클래스 타입의 T&&에 같은 규칙을 적용하지 않습니다.
독립 연습
위 프로그램에서 int&& named = 30;을 main 안에 선언한 뒤 plain(named); forwarded(named); forwarded(std::move(named));를 순서대로 호출합니다. 각 줄에서 선택되는 route를 적으세요. 이어 MyVector의 이동 대입으로 크기 2인 목적지에 크기 4인 원본을 넘긴다고 가정할 때, 목적지의 이전 배열과 원본 배열은 각각 어디로 가는지 설명하세요. 두 객체는 서로 다르며 해당 클래스의 구현을 그대로 사용합니다.
표현식과 자원을 분리하는 풀이
named라는 이름을 쓰는 두 호출은 lvalue이므로 plain과 forwarded 모두 lvalue route를 고릅니다. std::move(named)를 전달한 마지막 호출에서는 forwarded가 rvalue route를 고릅니다. MyVector 이동 대입은 목적지의 크기 2 배열을 delete[]로 해제한 뒤 원본의 크기 4 배열 포인터를 목적지에 넘깁니다. 원본은 nullptr·크기 0이 되어 같은 배열을 다시 해제하지 않습니다. std::move 표현식만 평가한 단계와 클래스의 이동 대입이 실행된 단계를 구분해야 합니다.