move 의미론과 rvalue 참조
lvalue와 rvalue를 구분하고 이동 생성·std::move·완벽 전달로 값비싼 자원을 복사 없이 이전하는 원리를 익힙니다.
원소가 백만 개인 std::vector를 함수에서 돌려받거나 다른 변수에 넘길 때마다 원소를 모두 복사한다면 큰 낭비입니다. 넘겨주는 쪽이 곧 사라지거나 더 이상 그 값을 쓰지 않는다면, 내부의 배열을 복사하지 않고 소유만 넘겨주면 됩니다. 이렇게 자원을 복사하지 않고 옮기는 것을 이동 의미론(move semantics)이라고 합니다.
Rule of Three, Five, Zero 실전에서 이동 생성자를 직접 구현해 보았다면, 이 글은 그 바탕이 되는 언어 규칙을 다룹니다. 값의 종류인 lvalue와 rvalue, 이동할 수 있는 값을 받는 rvalue 참조, std::move가 실제로 하는 일, 그리고 인자를 원래 종류 그대로 넘기는 완벽한 전달을 봅니다.
lvalue와 rvalue
C++의 식은 값 범주(value category)로 나뉩니다. 처음에는 두 가지로 생각하면 충분합니다. 이름이 있어 주소를 얻을 수 있고 식이 끝난 뒤에도 남는 것이 lvalue이고, 10이나 a + b의 결과, 값을 돌려주는 함수 호출처럼 식이 끝나면 사라지는 임시 값이 rvalue입니다.
int a = 10; // a는 lvalue, 10은 rvalue
int b = a + 5; // a + 5는 rvalue
int& r1 = a; // lvalue 참조는 lvalue에 묶임
// int& r2 = 5; // 컴파일 오류: rvalue에는 묶일 수 없음
const int& r3 = 5; // const lvalue 참조는 rvalue에도 묶임
int&& rr = 5; // rvalue 참조는 rvalue에 묶임
// int&& bad = a; // 컴파일 오류: lvalue에는 묶일 수 없음참조자에서 본 보통의 참조 int&는 lvalue 참조이며, C++11에서 더해진 int&&는 rvalue 참조(rvalue reference)입니다. rvalue 참조는 곧 사라질 값에만 묶이므로, rvalue 참조로 받은 객체는 그 내용을 가져가도 다른 누구에게도 영향이 없다고 볼 수 있습니다. 이것이 이동의 근본적인 아이디어입니다.
주의할 점은 이름이 붙은 rvalue 참조 자체는 lvalue라는 것입니다. 위의 rr은 rvalue에 묶였지만, rr이라는 이름으로 쓰는 순간 그 식은 주소를 가진 lvalue입니다. 그래서 받은 rvalue 참조를 다시 다른 함수에 이동할 수 있는 값으로 넘기려면 아래의 std::move나 std::forward가 필요합니다.
이동 생성자
클래스에 T(T&& other) 형태의 이동 생성자가 있으면, rvalue로 객체를 만들 때 복사 생성자 대신 이 생성자가 선택됩니다. 이동 생성자는 other의 자원을 가져오고, other는 정리해도 안전한 상태로 비워 둡니다.
#include <algorithm>
#include <cstddef>
#include <iostream>
#include <utility>
class MyVector {
private:
int* data = nullptr;
std::size_t size = 0;
public:
explicit MyVector(std::size_t n) : data(new int[n]{}), size(n) {}
~MyVector() { delete[] data; }
MyVector(const MyVector& other) : data(new int[other.size]), size(other.size) {
std::copy(other.data, other.data + size, data);
std::cout << "copy " << size << '\n';
}
MyVector(MyVector&& other) noexcept
: data(std::exchange(other.data, nullptr)), size(std::exchange(other.size, 0)) {
std::cout << "move " << size << '\n';
}
MyVector& operator=(const MyVector&) = delete;
MyVector& operator=(MyVector&&) = delete;
std::size_t length() const { return size; }
};
int main() {
MyVector vec1(10);
MyVector vec2 = vec1;
MyVector vec3 = std::move(vec1);
std::cout << vec1.length() << ' ' << vec2.length() << ' ' << vec3.length() << '\n';
}copy 10
move 10
0 10 10vec2 = vec1은 vec1이 lvalue이므로 복사 생성자가 선택되어 새 배열을 할당하고 원소 10개를 복사합니다. vec3 = std::move(vec1)은 이동 생성자가 선택되어, 아래 그림처럼 새로 할당하지 않고 vec1이 가진 배열 H1을 vec3가 그대로 넘겨받으며, vec1은 nullptr과 크기 0으로 비워집니다. 그래서 마지막 줄에서 vec1의 길이는 0입니다. 이 예제는 이동 생성만 보여 주려고 대입 연산자는 막아 두었습니다.
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에 넘긴다. |
|
|
std::move가 하는 일
<utility>의 std::move는 이름과 달리 아무것도 옮기지 않습니다. 인자를 rvalue 참조 타입으로 바꾸는 static_cast<T&&>와 같은 일만 해서, 이 값은 이동해도 된다는 표시를 붙일 뿐입니다. 위 그림의 가운데 단계처럼 std::move(vec1)을 계산한 순간에는 아직 소유가 바뀌지 않고, 그 결과로 이동 생성자가 선택되어 실행될 때 실제로 자원이 옮겨집니다.
그래서 이동 생성자가 없는 타입이나 const 객체에 std::move를 써도 이동은 일어나지 않습니다. const MyVector를 std::move하면 const MyVector&&가 되는데, 이는 이동 생성자의 MyVector&&에 묶일 수 없고 복사 생성자의 const MyVector&에 묶이므로 복사가 일어납니다. 이동은 원본을 바꾸는 일이어서 const 객체에서는 할 수 없기 때문입니다.
이동한 뒤의 원본은 소멸시키거나 새 값을 대입하는 것은 안전하지만, 어떤 값이 남는지는 타입마다 다릅니다. MyVector는 비워지도록 직접 구현했지만, 표준 라이브러리의 타입은 대부분 올바르지만 지정되지 않은 상태(valid but unspecified state)로 남는다고만 정해 둡니다. 이동한 std::string이 비어 있을 것이라고 기대하지 말고, 다시 쓰려면 먼저 값을 대입합니다.
이동이 일어나는 곳
이동은 std::move를 직접 쓰지 않아도 여러 곳에서 일어납니다. 함수가 지역 객체를 값으로 돌려주면 복사 대신 이동이 쓰이며, 많은 경우 컴파일러가 복사나 이동 자체를 없애는 복사 생략(copy elision)을 합니다. C++17부터는 return MyVector(10);처럼 임시 객체를 돌려주는 경우 복사 생략이 보장됩니다.
#include <iostream>
#include <string>
#include <utility>
#include <vector>
int main() {
std::vector<std::string> names;
std::string name = "a long name that would be expensive to copy";
names.push_back(name);
names.push_back(std::move(name));
std::cout << names.size() << ' ' << names[1].size() << '\n';
name = "reused";
std::cout << name << '\n';
}2 43
reused첫 push_back(name)은 name을 복사하고, 둘째 push_back(std::move(name))은 name의 내용을 컨테이너 안으로 옮깁니다. 이동한 뒤의 name에는 어떤 값이 남는지 정해져 있지 않으므로, 다시 쓰기 전에 "reused"를 대입했습니다. 마지막 원소는 이동으로 들어갔어도 내용은 원래 문자열과 같아 길이가 43입니다.
더 깊이: 다섯 가지 값 범주와 return std::move의 함정
본문의 lvalue와 rvalue는 표준의 분류를 단순하게 줄인 것입니다. 표준은 모든 식을 lvalue, xvalue, prvalue의 세 범주 가운데 하나로 나누고, 객체의 정체를 가리키는 lvalue와 xvalue를 묶어 glvalue, 자원을 넘겨줄 수 있는 xvalue와 prvalue를 묶어 rvalue라고 부릅니다. std::move(x)나 static_cast<A&&>(x)처럼 rvalue 참조를 돌려주는 식이 xvalue이고, 10이나 값을 돌려주는 함수 호출은 prvalue입니다.
C++17은 prvalue의 뜻을 바꾸어 복사 생략을 보장했습니다. prvalue는 필요할 때까지 객체로 만들어지지 않다가 최종적으로 초기화할 자리에 바로 만들어지므로, return MyVector(10);이나 MyVector v = MyVector(10);에서는 복사도 이동도 일어나지 않고 그 타입에 복사 생성자나 이동 생성자가 없어도 됩니다. 반면 이름 있는 지역 객체를 돌려줄 때의 이름 있는 반환값 최적화(NRVO, named return value optimization)는 컴파일러가 할 수 있는 최적화일 뿐 보장되지 않으며, 하지 못하면 이동 생성자가 쓰입니다.
그래서 지역 객체를 돌려주면서 return std::move(local);처럼 std::move를 붙이는 것은 대개 손해입니다. 반환식이 이름이 아닌 xvalue가 되어 NRVO의 조건을 벗어나므로, 생략될 수 있던 이동이 반드시 일어납니다. GCC는 -Wall에 포함된 -Wpessimizing-move로 이런 코드를 경고하고, 매개변수를 돌려줄 때처럼 std::move 없이도 이동이 일어나는 곳에 붙인 경우는 -Wextra의 -Wredundant-move로 알려 줍니다. 자세한 규칙은 cppreference의 복사 생략 문서에 있습니다.
완벽한 전달
인자를 받아 다른 함수에 그대로 넘기는 함수 템플릿을 생각해 봅니다. 받은 값이 lvalue였다면 lvalue로, rvalue였다면 rvalue로 넘겨야 받는 쪽이 복사와 이동을 올바르게 고를 수 있습니다. 이를 완벽한 전달(perfect forwarding)이라고 하며, 템플릿의 T&& 매개변수와 std::forward<T>로 합니다.
#include <iostream>
#include <utility>
void process(int& x) { std::cout << "lvalue " << x << '\n'; }
void process(int&& x) { std::cout << "rvalue " << x << '\n'; }
template <typename T>
void wrapper(T&& arg) {
process(std::forward<T>(arg));
}
template <typename T>
void badWrapper(T&& arg) {
process(arg);
}
int main() {
int lval = 10;
wrapper(lval);
wrapper(20);
badWrapper(20);
}lvalue 10
rvalue 20
lvalue 20템플릿 매개변수에 붙은 T&&는 rvalue 참조가 아니라, auto 키워드와 타입 추론에서 본 전달 참조입니다. 아래 그림처럼 wrapper(lval)에서는 T가 int&로 추론되어 매개변수가 int&가 되고, wrapper(20)에서는 T가 int로 추론되어 매개변수가 int&&가 됩니다. 두 경우 모두 함수 안의 arg는 이름이 있는 lvalue이지만, std::forward<T>(arg)가 추론한 T를 보고 원래의 종류로 되돌려 알맞은 process를 고르게 합니다.
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 쪽으로 캐스팅하므로 역할이 다르다.
badWrapper는 arg를 그대로 넘기므로, rvalue 20을 받았어도 이름 있는 arg는 lvalue여서 process(int&)가 선택됩니다. std::make_unique나 std::vector의 emplace_back이 생성자 인자를 받아 그대로 넘길 수 있는 것도 이 완벽한 전달 덕분입니다.
정리
- 이름이 있고 주소를 얻을 수 있는 식은 lvalue, 식이 끝나면 사라지는 임시 값은 rvalue이며, rvalue 참조
T&&는 rvalue에만 묶입니다. - 이름이 붙은 rvalue 참조는 그 자체로 lvalue입니다.
- 이동 생성자는 rvalue에서 객체를 만들 때 선택되어, 원본의 자원을 가져오고 원본을 정리해도 안전한 상태로 둡니다.
std::move는 값을 rvalue로 바꾸는 표시일 뿐이며, 실제 이동은 이동 생성자나 이동 대입이 하고const객체는 이동되지 않습니다.- 표준 타입은 이동한 뒤 올바르지만 지정되지 않은 상태로 남으므로, 다시 쓰기 전에 값을 대입합니다.
- 템플릿의
T&&는 전달 참조이며,std::forward<T>로 받은 인자를 원래의 값 범주 그대로 넘깁니다.
연습 문제
1. const 객체 옮기기
my_vector_move.cpp의 main에 const MyVector fixed(3); MyVector taken = std::move(fixed);를 추가하면 무엇이 출력되고, fixed의 길이는 얼마인가요?
해설 보기
std::move(fixed)는 const MyVector&&가 되어 이동 생성자에는 묶이지 못하고 복사 생성자에 묶이므로 copy 3이 출력됩니다. 복사였으므로 fixed는 바뀌지 않아 길이는 3 그대로입니다. std::move를 썼다고 이동이 보장되는 것은 아닙니다.
2. 이동한 문자열 다시 쓰기
move_into_container.cpp에서 name = "reused";를 지우고 그 자리에서 std::cout << name.size() << '\n';을 출력하면 어떤 값이 나오나요?
해설 보기
표준은 이동한 std::string이 올바르지만 지정되지 않은 상태라고만 정하므로, 어떤 값이 나온다고 말할 수 없습니다. 실제 구현에서는 대개 0이 나오지만 그것에 기대면 안 됩니다. 이동한 객체에는 size()처럼 전제 조건이 없는 함수를 부를 수는 있지만, 값을 다시 쓰려면 먼저 대입합니다.
3. 전달하지 않은 인자
perfect_forwarding.cpp에서 badWrapper(lval)을 추가하면 무엇이 출력되나요? badWrapper가 wrapper와 같은 결과를 내게 하려면 어떻게 고치나요?
해설 보기
lvalue 10이 출력됩니다. badWrapper는 lvalue를 받든 rvalue를 받든 이름 있는 arg를 그대로 넘기므로 언제나 process(int&)를 부릅니다. process(std::forward<T>(arg));로 바꾸면 추론한 T에 따라 원래의 값 범주로 넘겨 wrapper와 같아집니다.
다음 글 C++ 20 기능 소개에서는 콘셉트, 범위 라이브러리, 모듈, 코루틴, 세 방향 비교 같은 C++20의 주요 기능을 개관합니다.