C++ Move 시맨틱스: 복사와 이동의 차이, rvalue 참조, std::move
이 글의 핵심
이동은 복사를 피해 성능을 얻는 수단이지만, const 객체에 std::move를 써도 복사가 일어나고 반환문에 std::move를 붙이면 오히려 RVO를 막는 등 의도와 다른 결과가 자주 나옵니다. 템플릿의 T&&가 항상 rvalue 참조가 아닌 이유와 팩토리 함수의 전형적인 전달 패턴을 이해하면 이런 실수를 코드 리뷰 단계에서 잡을 수 있습니다.
들어가며
Move 시맨틱스는 C++11에서 도입된 기능으로, 객체의 리소스를 복사하지 않고 이동할 수 있게 합니다. 복사 비용이 큰 객체(벡터, 문자열 등)를 효율적으로 전달할 수 있습니다.
왜 필요한가?:
- 성능: 복사 대신 이동으로 성능 향상
- 소유권 이전: 리소스 소유권을 명시적으로 이전
- 복사 불가 타입:
unique_ptr같은 복사 불가 타입 전달 - 임시 객체 최적화: 임시 객체의 리소스 재사용
// ❌ 복사: 느림 (깊은 복사)
std::vector<int> v1(1000000); // 100만 개 요소 할당
std::vector<int> v2 = v1; // 100만 개 요소 전부 복사
// 동작:
// 1. v2용 새 메모리 할당 (100만 * sizeof(int))
// 2. v1의 모든 요소를 v2로 복사
// 3. v1, v2 모두 독립적인 메모리 소유
// 비용: O(n) 시간, O(n) 메모리
// ✅ 이동: 빠름 (얕은 복사)
std::vector<int> v1(1000000); // 100만 개 요소 할당
std::vector<int> v2 = std::move(v1); // 포인터만 복사 (내부 버퍼 이동)
// 동작:
// 1. v1의 내부 포인터를 v2로 복사 (포인터 3개: data, size, capacity)
// 2. v1의 포인터를 nullptr로 설정 (무효화)
// 3. v2가 v1의 메모리를 소유
// 비용: O(1) 시간, 추가 메모리 없음
lvalue와 rvalue, rvalue 참조
이름이 있으면 lvalue
int x = 10; // x는 lvalue (이름이 있으며, 주소를 가짐)
// 여러 번 참조 가능, 메모리 위치 확정
int y = x + 5; // x+5는 rvalue (임시값, 주소 없음)
// 표현식 평가 후 바로 사라짐, 한 번만 사용
int* ptr = &x; // ✅ OK: lvalue의 주소를 가져올 수 있음
// x는 메모리에 저장되어 있음
// int* ptr2 = &(x+5); // ❌ 에러: rvalue의 주소를 가져올 수 없음
// x+5는 임시 레지스터 값, 메모리 주소 없음
핵심:
- lvalue: 이름이 있으며, 여러 번 사용 가능, 주소를 가짐
- rvalue: 임시값, 표현식 끝나면 사라짐, 주소를 가질 수 없음
lvalue와 rvalue 구분 예시
#include <string>
#include <iostream>
std::string getName() {
return "Alice";
}
int main() {
int x = 10; // x: lvalue
int y = x + 5; // x+5: rvalue
int z = std::move(x); // std::move(x): rvalue
std::string s1 = "hello";
std::string s2 = s1; // s1: lvalue
std::string s3 = s1 + " world"; // s1 + " world": rvalue
std::string name = getName(); // getName(): rvalue
return 0;
}
T&&로 rvalue에만 바인딩하기
int x = 10;
// lvalue 참조 (T&): lvalue만 바인딩 가능
int& ref1 = x; // ✅ OK: x는 lvalue
// int& ref2 = 10; // ❌ 에러: 10은 rvalue (임시값)
// lvalue 참조는 메모리 주소가 있는 값만 가능
// rvalue 참조 (T&&): rvalue만 바인딩 가능
// int&& ref3 = x; // ❌ 에러: x는 lvalue
// rvalue 참조는 임시값만 가능
int&& ref4 = 10; // ✅ OK: 10은 rvalue (임시값)
// rvalue 참조는 임시값의 수명을 연장
// const lvalue 참조 (const T&): 모두 가능 (특별 규칙)
const int& ref5 = x; // ✅ OK: lvalue 바인딩
const int& ref6 = 10; // ✅ OK: rvalue 바인딩 (임시값 수명 연장)
// const 참조는 임시값을 받을 수 있는 유일한 lvalue 참조
깊은 복사와 이동의 비용 차이
복사 생성자: 버퍼 전체를 새로 할당
#include <iostream>
#include <cstring>
class String {
private:
char* data;
size_t size;
public:
String(const char* str) {
size = strlen(str);
data = new char[size + 1];
strcpy(data, str);
std::cout << "생성자" << std::endl;
}
~String() {
delete[] data;
std::cout << "소멸자" << std::endl;
}
// 복사 생성자
String(const String& other) {
size = other.size;
data = new char[size + 1];
strcpy(data, other.data); // 깊은 복사
std::cout << "복사 생성자" << std::endl;
}
void print() const {
std::cout << data << std::endl;
}
};
int main() {
String s1("Hello");
String s2 = s1; // 복사 발생 (메모리 할당 + 복사)
s2.print();
return 0;
}
출력:
생성자
복사 생성자
Hello
소멸자
소멸자
이동 생성자: 포인터만 넘기고 원본을 비움
#include <iostream>
#include <cstring>
class String {
private:
char* data;
size_t size;
public:
String(const char* str) {
size = strlen(str);
data = new char[size + 1];
strcpy(data, str);
std::cout << "생성자" << std::endl;
}
~String() {
delete[] data;
std::cout << "소멸자" << std::endl;
}
// 복사 생성자
String(const String& other) {
size = other.size;
data = new char[size + 1];
strcpy(data, other.data);
std::cout << "복사 생성자" << std::endl;
}
// 이동 생성자: rvalue 참조(String&&)를 받음
// noexcept: 예외를 던지지 않음을 보장 (성능 최적화)
String(String&& other) noexcept {
// 1. 포인터만 복사 (얕은 복사)
data = other.data; // other의 메모리를 가져옴
size = other.size;
// 2. 원본 무효화 (중요!)
// other의 소멸자가 호출될 때 delete하지 않도록
other.data = nullptr; // 원본 포인터를 nullptr로
other.size = 0;
std::cout << "이동 생성자" << std::endl;
// 결과: 메모리 할당 없음, 포인터만 이동 (매우 빠름)
}
void print() const {
if (data) std::cout << data << std::endl;
else std::cout << "(empty)" << std::endl;
}
};
int main() {
String s1("Hello");
String s2 = std::move(s1); // 이동 (포인터만 복사, 빠름!)
s2.print(); // Hello
s1.print(); // (empty) - s1은 이제 사용 불가
return 0;
}
출력:
생성자
이동 생성자
Hello
(empty)
소멸자
소멸자
std::move는 캐스트일 뿐이다
std::move로 이동 생성자 선택하기
#include <utility>
#include <vector>
#include <iostream>
int main() {
std::vector<int> v1 = {1, 2, 3, 4, 5};
// 복사: 모든 요소를 새 메모리에 복사
std::vector<int> v2 = v1; // v1의 모든 요소 복사
// v1과 v2는 독립적인 메모리 소유
std::cout << "v1 크기: " << v1.size() << std::endl; // 5 (유지)
std::cout << "v2 크기: " << v2.size() << std::endl; // 5 (새로 할당)
// 이동: 내부 버퍼 포인터만 이동
// std::move(v1): v1을 rvalue로 캐스팅
// 이동 생성자 호출 → v1의 내부 버퍼를 v3로 이전
std::vector<int> v3 = std::move(v1); // v1의 내부 버퍼를 v3로 이동
// v1은 유효하지만 비어있는 상태 (moved-from state)
std::cout << "v1 크기: " << v1.size() << std::endl; // 0 (비어있음)
std::cout << "v3 크기: " << v3.size() << std::endl; // 5 (v1의 버퍼 소유)
// 주의: v1은 더 이상 사용하면 안 됨 (소멸자 호출은 안전)
return 0;
}
주의: std::move는 실제로 이동하지 않으며, rvalue로 캐스팅만 합니다!
std::move의 구현
// std::move의 개념적 구현
// 실제로는 단순한 캐스팅만 수행 (이동하지 않음!)
template<typename T>
typename std::remove_reference<T>::type&& move(T&& arg) noexcept {
// std::remove_reference<T>::type: T에서 참조 제거
// T가 int&이면 → int
// T가 int&&이면 → int
//
// static_cast<...&&>: rvalue 참조로 캐스팅
// 이 캐스팅이 이동 생성자를 호출하게 만듦
//
// 핵심: std::move는 이동하지 않으며, "이동 가능"하다고 표시만 함
// 실제 이동은 이동 생성자/대입 연산자가 수행
return static_cast<typename std::remove_reference<T>::type&&>(arg);
}
Rule of Five: 다섯 특수 멤버 함수
#include <iostream>
class Resource {
private:
int* data;
public:
// 1. 생성자
Resource(int value) : data(new int(value)) {
std::cout << "생성자: " << *data << std::endl;
}
// 2. 소멸자
~Resource() {
std::cout << "소멸자: " << (data ? std::to_string(*data) : "null") << std::endl;
delete data;
}
// 3. 복사 생성자
Resource(const Resource& other) {
data = new int(*other.data);
std::cout << "복사 생성자: " << *data << std::endl;
}
// 4. 복사 대입 연산자
Resource& operator=(const Resource& other) {
if (this != &other) {
delete data;
data = new int(*other.data);
std::cout << "복사 대입: " << *data << std::endl;
}
return *this;
}
// 5. 이동 생성자
Resource(Resource&& other) noexcept {
data = other.data;
other.data = nullptr;
std::cout << "이동 생성자" << std::endl;
}
// 6. 이동 대입 연산자
Resource& operator=(Resource&& other) noexcept {
if (this != &other) {
delete data;
data = other.data;
other.data = nullptr;
std::cout << "이동 대입" << std::endl;
}
return *this;
}
int get() const { return data ? *data : 0; }
};
int main() {
Resource r1(10);
Resource r2 = r1; // 복사 생성자
Resource r3 = std::move(r1); // 이동 생성자
Resource r4(20);
r4 = r2; // 복사 대입
Resource r5(30);
r5 = std::move(r4); // 이동 대입
return 0;
}
출력:
생성자: 10
복사 생성자: 10
이동 생성자
생성자: 20
복사 대입: 10
생성자: 30
이동 대입
소멸자: 10
소멸자: 10
소멸자: null
소멸자: 10
소멸자: null
이동 전용(move-only) 타입 설계
이동 전용 타입은 복사 생성자와 복사 대입 연산자를 삭제하거나 비가시적으로 막아 두며, 이동만 허용하는 타입입니다. std::unique_ptr, std::thread, std::ofstream 등이 대표적입니다. “소유권이 하나뿐인 리소스”를 표현할 때 복사 대신 이동만으로 의미를 유지하려는 설계입니다.
선언 패턴
struct Handle {
Handle() = default;
~Handle() = default;
Handle(const Handle&) = delete;
Handle& operator=(const Handle&) = delete;
Handle(Handle&&) noexcept = default;
Handle& operator=(Handle&&) noexcept = default;
};
- 복사 삭제: 동일 리소스를 둘이 나누어 갖는 모델을 컴파일 타임에 차단합니다.
- 이동 기본: 멤버가 이동 가능하면
= default로 충분한 경우가 많습니다. 수동으로 소유 포인터를 옮길 때는 Rule of Five를 이동 쪽에만 맞춥니다.
API 설계에서의 이점
- 함수가 소유권 이전만 표현하면 됨:
void consume(std::unique_ptr<Foo> p)— 호출자는std::move로 의도를 드러냄. - 컨테이너에 넣을 때 복사 비용 없이 이동으로만 처리(복사 불가 타입은 이동 또는 emplace).
- 다만 복사가 필요한 값 의미 모델과는 맞지 않으므로, “공유”가 필요하면
shared_ptr·내부 공유 버퍼 등 별도 모델을 택해야 합니다.
이동만으로 충분한지 검토할 질문
- 두 인스턴스가 동시에 같은 외부 리소스를 가리키면 안 되는가? → 이동 전용 후보.
- 값 복제가 도메인 의미인가? → 복사 허용 필요.
- 예외 안전과 noexcept 이동 요구가 있는가? →
vector재할당 등과 연계해 검토.
이동 대입과 자기 대입(Self-assignment)
복사 대입에서는 if (this == &other) 검사가 상식이지만, 이동 대입에서도 self-move는 현실적으로 발생합니다. 예: v = std::move(v), 또는 같은 객체를 참조하는 참조가 꼬인 경우.
왜 위험한가
이동 대입을 다음처럼 쓴다고 합니다(개념 코드).
Resource& operator=(Resource&& other) noexcept {
delete data; // 1. 기존 리소스 해제
data = other.data; // 2. 상대방 포인터를 가져옴
other.data = nullptr; // 3. 상대를 비움
return *this;
}
other가 곧 *this이면, 1번에서 자기 메모리를 먼저 해제해 버리고 2번에서 이미 해제된 포인터를 읽는 형태가 될 수 있어 정의되지 않은 동작입니다. 따라서 이동 대입에도 자기 대입 가드가 필요합니다.
표준 라이브러리의 메시지
std::vector 등 많은 표준 타입은 a = std::move(a) 이후 유효하지만 값이 미지정일 수 있다고 문서화합니다. 즉 “반드시 비어 있다”는 보장은 없으며, 소멸·재대입은 안전하다는 수준입니다. 사용자 정의 타입은 이보다 강한 불변식을 약속하려면, 자기 이동 대입을 명시적으로 설계해야 합니다.
권장 패턴: 동일 객체면 no-op, 또는 이동 후 std::swap
포인터만 갖는 단순 타입은 if (this != &other) 가드로 같은 객체면 대입 본문을 건너뛰기만 해도 됩니다.
copy-and-swap 스타일로 자기 대입까지 포함해 안전하게 쓰려면 다음처럼 임시로 이동한 뒤 포인터를 맞바꿀 수 있습니다(tmp가 기존 data를 소멸 시 정리).
#include <utility>
// Resource는 int* data; 를 갖는다고 가정
Resource& operator=(Resource&& other) noexcept {
if (this == &other) {
return *this;
}
Resource tmp(std::move(other));
std::swap(data, tmp.data);
return *this;
}
중요한 점은 “이동 대입의 상대가 항상 다른 객체는 아니다”를 전제에 넣는 것입니다.
rvalue 참조 축소(Reference collapsing)
rvalue 참조 축소는 “참조가 두 겹 쌓였을 때 최종적으로 어떤 한 겹 참조가 되는가”를 정하는 언어 규칙입니다. C++11 이후 T&&가 템플릿·별칭·일부 문맥에서 어떻게 실제 매개변수의 값 범주(lvalue / rvalue)와 결합하는지 이해하려면 이 규칙이 필수입니다.
네 가지 축소 규칙
임의의 타입 T에 대해, 다음과 같이 정리됩니다(가장 오른쪽이 “바깥” 참조에 해당한다고 읽어도 됩니다).
| 결합 | 결과 |
|---|---|
T& & | T& |
T& && | T& |
T&& & | T& |
T&& && | T&& |
한 줄로 기억하면 “lvalue 참조(&)가 하나라도 끼면 최종은 lvalue 참조(T&), 둘 다 rvalue 참조(&&)일 때만 T&&”입니다. 이 규칙은 typedef/using 별칭, decltype에 의해 만들어진 참조 타입, 템플릿 인자 치환 등에서 동일하게 적용됩니다.
왜 std::move는 “항상 rvalue 참조”로 보이게 만드는가
앞 절의 std::move는 remove_reference로 참조를 한 겹 벗긴 뒤 static_cast<U&&>만 합니다. 여기서 최종 타입이 U&&가 되는 것은 “이름 있는 객체를 rvalue로 대우하라”는 표식이며, 축소 규칙과 함께 벌어지는 일입니다. 예를 들어 템플릿 매개변수가 T&로 추론된 경우, T&&가 아니라 T&로 남는 것이 축소의 직접적인 결과입니다.
템플릿에서의 T&&는 “항상 rvalue 참조”가 아니다
아래처럼 타입이 템플릿 매개변수로 주어지고, 그 타입 추론이 일어나는 T&&는 전달 참조(forwarding reference)라고 부릅니다(구 표기: universal reference).
template <typename T>
void foo(T&& arg); // T가 추론되면 전달 참조 — rvalue 참조만이 아님
template <typename T>
class Bar {
void baz(T&& arg); // T가 고정된 클래스 매개변수면 일반 rvalue 참조
};
foo(x)처럼 lvalue를 넘기면T는int&처럼 참조로 추론되고,T&&는int& &&→int&로 축소되어arg는 lvalue 참조가 됩니다.foo(42)처럼 rvalue를 넘기면T는int,T&&는int&&로 남습니다.
즉 같은 T&& 문법이라도 “추론되는 T&&”인지 “고정된 T의 T&&”인지에 따라 의미가 갈립니다. 이 구분 없이 move 시맨틱스를 논하면 현장에서 설명이 자주 어긋납니다.
std::forward와 완벽 전달
완벽 전달은 호출자가 넘긴 인자의 값 범주(lvalue / rvalue)와 constness를 잃지 않으며, 내부에서 다른 함수(또는 생성자)로 그대로 넘기는 기법입니다. 이동 시맨틱스와 직결되는 이유는, “임시를 살려 이동 생성자까지 보낼 것인가, 이름 있는 객체를 복사 생성자로 보낼 것인가”를 한 번의 템플릿으로 표현할 수 있기 때문입니다.
std::forward의 역할: std::move와 대비
std::move(T&): 무조건 rvalue로 캐스팅 → 이동 후보로 만듦(소유권 이전 의도가 분명할 때).std::forward<T>(arg): 전달 참조로 받은arg가 원래 lvalue였으면 lvalue로, rvalue였으면 rvalue로 유지합니다. 구현은 대개 “조건부static_cast”이며, 인자를 두 번 넘기는 중간 계층에서 값 범주를 보존하는 데 사용됩니다.
#include <utility>
#include <iostream>
void sink(int&) { std::cout << "lvalue\n"; }
void sink(int&&) { std::cout << "rvalue\n"; }
template <typename T>
void wrap(T&& arg) {
sink(std::forward<T>(arg)); // 호출자가 넘긴 범주 유지
}
int main() {
int x = 1;
wrap(x); // T: int& → forward 후 lvalue
wrap(2); // T: int → forward 후 rvalue
}
팩토리에서의 전형적 패턴
template <typename T, typename... Args>
std::unique_ptr<T> make(Args&&... args) {
return std::unique_ptr<T>(new T(std::forward<Args>(args)...));
}
여기서 Args&&...는 각 인자에 대해 전달 참조이고, forward로 해당 인자가 원래 임시였는지가 T의 이동/복사 생성자 선택에 반영됩니다. std::move(args)...만 쓰면 lvalue 인자까지 전부 rvalue로 바꿔버려 의도와 다른 오버로드가 선택될 수 있습니다.
컴파일러 관점에서 한 줄
전달 참조 + 참조 축소로 T가 추론되고, std::forward<T>가 그 추론 결과에 맞춰 참조 타입 한 겹을 복원해 줍니다. 완벽 전달은 “문법 설탕”이 아니라 오버로드 결정과 이동/복사 분기를 템플릿 한 줄로 합성하는 메커니즘입니다.
moved-from 객체·const·예외·return std::move 함정
이동한 뒤 원본 객체를 사용함
#include <vector>
#include <iostream>
int main() {
// ❌ 위험한 코드
std::vector<int> v1 = {1, 2, 3};
std::vector<int> v2 = std::move(v1);
std::cout << v1.size() << std::endl; // 0 (또는 정의되지 않은 동작)
// v1[0]; // 정의되지 않은 동작!
// ✅ 올바른 코드
std::vector<int> v3 = {4, 5, 6};
std::vector<int> v4 = std::move(v3);
// v3은 더 이상 사용하지 않음
return 0;
}
const 객체는 이동되지 않고 복사됨
#include <vector>
#include <iostream>
int main() {
// ❌ 이동 안 됨
const std::vector<int> v1 = {1, 2, 3};
std::vector<int> v2 = std::move(v1); // 복사됨!
std::cout << "v1 크기: " << v1.size() << std::endl; // 3 (여전히 유효)
// ✅ const 없이
std::vector<int> v3 = {1, 2, 3};
std::vector<int> v4 = std::move(v3); // 이동됨
std::cout << "v3 크기: " << v3.size() << std::endl; // 0
return 0;
}
noexcept 없는 이동 생성자
#include <stdexcept>
#include <vector>
// ❌ 위험: noexcept 없음
class Bad {
public:
Bad(Bad&& other) { // noexcept 없음
throw std::runtime_error("에러");
}
};
// ✅ noexcept 추가
class Good {
std::vector<int> data;
public:
Good(Good&& other) noexcept : data(std::move(other.data)) {
// 예외 안던짐
}
};
int main() {
std::vector<Good> v;
v.reserve(10); // noexcept 이동 생성자 사용
return 0;
}
이유: std::vector는 reserve() 시 noexcept 이동 생성자가 있으면 이동, 없으면 복사합니다.
return std::move(x)로 RVO를 막음
#include <vector>
// ❌ move 사용: RVO 방해
std::vector<int> createVector1() {
std::vector<int> v = {1, 2, 3};
return std::move(v); // RVO 방해
}
// ✅ 그냥 반환: RVO 적용
std::vector<int> createVector2() {
std::vector<int> v = {1, 2, 3};
return v; // RVO
}
int main() {
auto v1 = createVector1();
auto v2 = createVector2();
return 0;
}
복사와 이동 성능 측정
#include <chrono>
#include <vector>
#include <iostream>
void testCopy() {
std::vector<int> v1(1000000, 42);
auto start = std::chrono::high_resolution_clock::now();
std::vector<int> v2 = v1; // 복사
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
std::cout << "복사: " << duration.count() << "μs" << std::endl;
}
void testMove() {
std::vector<int> v1(1000000, 42);
auto start = std::chrono::high_resolution_clock::now();
std::vector<int> v2 = std::move(v1); // 이동
auto end = std::chrono::high_resolution_clock::now();
auto duration = std::chrono::duration_cast<std::chrono::microseconds>(end - start);
std::cout << "이동: " << duration.count() << "μs" << std::endl;
}
int main() {
testCopy(); // ~1000μs
testMove(); // ~1μs
return 0;
}
결과:
- 복사: ~1000μs (메모리 할당 + 복사)
- 이동: ~1μs (포인터만 복사)
vector·unique_ptr·완벽 전달 예제
vector에 대용량 객체 넣기
#include <vector>
#include <iostream>
class BigObject {
private:
std::vector<int> data;
public:
BigObject(int size) : data(size, 0) {
std::cout << "생성자: " << size << "개 요소" << std::endl;
}
BigObject(const BigObject& other) : data(other.data) {
std::cout << "복사 생성자: " << data.size() << "개 요소" << std::endl;
}
BigObject(BigObject&& other) noexcept : data(std::move(other.data)) {
std::cout << "이동 생성자: " << data.size() << "개 요소" << std::endl;
}
};
std::vector<BigObject> createObjects() {
std::vector<BigObject> result;
result.push_back(BigObject(1000)); // 이동 생성자 호출
result.push_back(BigObject(2000));
return result; // RVO 또는 이동
}
int main() {
auto objects = createObjects();
std::cout << "완료" << std::endl;
return 0;
}
unique_ptr 소유권 이동
#include <memory>
#include <iostream>
void process(std::unique_ptr<int> ptr) {
std::cout << "값: " << *ptr << std::endl;
}
int main() {
auto ptr1 = std::make_unique<int>(42);
// process(ptr1); // 에러: unique_ptr은 복사 불가
process(std::move(ptr1)); // OK: 이동
// ptr1은 이제 nullptr
if (!ptr1) {
std::cout << "ptr1은 비어있음" << std::endl;
}
return 0;
}
출력:
값: 42
ptr1은 비어있음
완벽 전달하는 래퍼 함수
#include <iostream>
#include <utility>
void process(int& x) {
std::cout << "lvalue 버전: " << x << std::endl;
}
void process(int&& x) {
std::cout << "rvalue 버전: " << x << std::endl;
}
template<typename T>
void wrapper(T&& arg) {
process(std::forward<T>(arg)); // 완벽 전달
}
int main() {
int x = 10;
wrapper(x); // lvalue 버전 호출
wrapper(20); // rvalue 버전 호출
return 0;
}
출력:
lvalue 버전: 10
rvalue 버전: 20
팩토리·컨테이너·swap·sink 인자 패턴
값으로 반환하는 팩토리 함수
#include <vector>
#include <string>
class Database {
std::vector<std::string> data_;
public:
Database(std::vector<std::string> data)
: data_(std::move(data)) {} // 이동
size_t size() const { return data_.size(); }
};
// 팩토리 함수
Database createDatabase() {
std::vector<std::string> data;
data.push_back("record1");
data.push_back("record2");
data.push_back("record3");
return Database(std::move(data)); // 이동
}
int main() {
Database db = createDatabase(); // RVO 또는 이동
return 0;
}
컨테이너에 이동으로 넣기
#include <vector>
#include <string>
int main() {
std::vector<std::string> names;
// ❌ 복사
std::string name1 = "Alice";
names.push_back(name1); // 복사
// ✅ 이동
std::string name2 = "Bob";
names.push_back(std::move(name2)); // 이동
// ✅ emplace_back (더 좋음)
names.emplace_back("Charlie"); // 직접 생성
return 0;
}
이동 기반 swap
#include <vector>
#include <utility>
class Buffer {
std::vector<char> data_;
public:
Buffer(size_t size) : data_(size) {}
// 이동 기반 스왑
void swap(Buffer& other) noexcept {
data_.swap(other.data_); // O(1)
}
// 또는 std::swap 사용
friend void swap(Buffer& a, Buffer& b) noexcept {
using std::swap;
swap(a.data_, b.data_);
}
};
int main() {
Buffer b1(1000), b2(2000);
swap(b1, b2); // 빠른 이동
return 0;
}
Sink 인자와 이동 생성자 연계
API에서 “소유권을 받는다”는 의도를 분명히 하려면 값으로 받고 멤버에 이동하는 패턴이 자주 사용됩니다. 호출자는 필요 시 std::move로 비용을 한 번만 지불하며, 구현부는 복사/이동 오버로드를 나누지 않아도 됩니다.
class Session {
std::string label_;
public:
explicit Session(std::string label) : label_(std::move(label)) {}
};
컨테이너에 넣을 때는 emplace로 임시 생성까지 합치면 추가 이동 한 번을 줄일 수 있습니다.
noexcept 이동과 vector 재할당
std::vector는 재할당 시 요소를 이동할 수 있으면 이동을 택합니다. 이때 이동 생성자/이동 대입이 noexcept가 아니면 강한 예외 안전을 위해 복사로 되돌아가는 구현이 흔합니다. 즉 프로덕션에서 이동이 “실제로 호출되지 않는” 이유가 여기에 있습니다. 이동 연산은 예외를 던지지 않는다는 계약을 가능하면 noexcept로 밝히는 것이 실무적으로 중요합니다.
이동 후 상태와 std::exchange
이동 후 객체를 명시적으로 비우고 싶을 때 멤버 단위로 std::exchange를 쓰면, “옛 값 가져오기 + 상대를 안전한 값으로 설정”을 한 번에 쓸 수 있습니다.
#include <utility>
// 개념 예: 핸들 이동
struct Owned {
int* p = nullptr;
Owned(Owned&& o) noexcept : p(std::exchange(o.p, nullptr)) {}
};
문서화할 때는 “moved-from 객체에 어떤 연산을 허용하는가”(소멸만? 재대입 가능?)를 팀 규약으로 정하는 것이 좋습니다.
예외 안전한 업데이트(copy-and-swap)
강한 예외 보장이 필요한 대입에서는 임시를 만들고 swap으로 커밋하는 방식이 여전히 유효합니다. 이동 시맨틱스 시대에는 이동 생성자로 임시를 만든 뒤 스왑하는 형태로, 대형 버퍼를 한 번의 이동으로 옮기고 기존 상태는 임시 소멸에 맡깁니다.
로깅·디버그에서의 주의
릴리스 빌드가 아닐 때 이동 생성자에 로그를 넣으면 호출 빈도가 드러나 유용하지만, 이동이 예외를 던지는 것처럼 보이게 만드는 실수(숨은 할당 등)는 피해야 합니다. 또한 이동 후 객체를 읽는 어설션은 “미지정 상태” 전제를 깨지 않도록, 검사 대상을 불변식의 최소 집합으로 제한하는 편이 안전합니다.
이동 가능한 리소스 관리자 만들기
#include <iostream>
#include <vector>
#include <memory>
#include <string>
class ResourceManager {
std::vector<std::unique_ptr<std::string>> resources_;
public:
// 리소스 추가
void add(std::unique_ptr<std::string> resource) {
resources_.push_back(std::move(resource)); // 이동
}
// 리소스 가져오기
std::unique_ptr<std::string> take(size_t index) {
if (index >= resources_.size()) return nullptr;
auto resource = std::move(resources_[index]); // 이동
resources_.erase(resources_.begin() + index);
return resource;
}
// 리소스 개수
size_t count() const {
return resources_.size();
}
// 리소스 출력
void print() const {
std::cout << "Resources (" << resources_.size() << "):" << std::endl;
for (size_t i = 0; i < resources_.size(); ++i) {
if (resources_[i]) {
std::cout << " [" << i << "]: " << *resources_[i] << std::endl;
} else {
std::cout << " [" << i << "]: (moved)" << std::endl;
}
}
}
};
int main() {
ResourceManager mgr;
// 리소스 추가
mgr.add(std::make_unique<std::string>("Resource 1"));
mgr.add(std::make_unique<std::string>("Resource 2"));
mgr.add(std::make_unique<std::string>("Resource 3"));
mgr.print();
// 리소스 가져오기
auto r = mgr.take(1);
std::cout << "\n가져온 리소스: " << *r << std::endl;
std::cout << "\n남은 리소스:" << std::endl;
mgr.print();
return 0;
}
출력:
Resources (3):
[0]: Resource 1
[1]: Resource 2
[2]: Resource 3
가져온 리소스: Resource 2
남은 리소스:
Resources (2):
[0]: Resource 1
[1]: Resource 3
Move 시맨틱스 정리
핵심 요약
- Move 시맨틱스: 리소스를 복사 없이 이동
- rvalue 참조:
T&&로 임시 객체 바인딩 — 단, 템플릿 추론 시 전달 참조와 참조 축소로T&로 남을 수 있음 - std::move: rvalue로 캐스팅 (실제 이동 아님)
- 완벽 전달:
std::forward로 인자의 값 범주를 보존해 이동/복사 오버로드를 올바르게 선택 - 이동 전용 타입: 복사 삭제 + 이동으로 소유권 단일성을 컴파일 타임에 강제
- 이동 대입: 자기 이동 대입(
x = std::move(x))을 대비해 가드 또는 copy-and-swap - noexcept: 이동 생성자/대입에 필수(컨테이너 재할당 등)
- Rule of Five: 소멸자, 복사, 이동 모두 구현
복사와 이동 비교
| 특징 | 복사 | 이동 |
|---|---|---|
| 비용 | 높음 (메모리 할당 + 복사) | 낮음 (포인터만) |
| 원본 | 유지 | 무효화 |
| 문법 | T a = b; | T a = std::move(b); |
| 생성자 | 복사 생성자 | 이동 생성자 |
| 대입 | 복사 대입 | 이동 대입 |
| 용도 | 원본 유지 필요 | 원본 불필요 |
이어서 볼 글
같이 보면 좋은 글
- C++ lvalue와 rvalue: 값 범주 구분 기준과 이름 있는 rvalue 참조가 lvalue인 이유
- C++ Init Capture | “초기화 캡처” 가이드
- C++ Algorithm Copy
- C++ std::function vs 함수 포인터
- C++ move 에러
- C++ RVO·NRVO
자주 묻는 질문 (FAQ)
Q. 이동 생성자에서 std::exchange를 자주 쓰는 이유는 무엇인가요?
A. ptr_(std::exchange(other.ptr_, nullptr))처럼 쓰면 원본의 값을 가져오는 동작과 원본을 안전한 빈 상태로 바꾸는 동작을 한 줄에 표현할 수 있습니다. 원본을 비우는 코드를 빠뜨리면 두 객체가 같은 자원을 가리켜 소멸 시 이중 해제가 일어나는데, exchange를 쓰면 이런 누락이 구조적으로 어렵습니다. 멤버 이니셜라이저 목록에서도 쓸 수 있어, 본문의 “이동 후 상태와 std::exchange” 패턴처럼 이동 후 상태를 명시적으로 드러내는 데 적합합니다.