c++을 이용해서 개발을 하다보면 복사 대신에 이동연산을 통해서 여러 이점을 얻는다는 정보를 확인할 수 있다. 그때 항상 등장하는 개념이 바로 move semanics이다. 그렇기 때문에 오늘은 이 개념에 대해서 알아보고 이동 연산자에 대해서 알아보도록 하겠다.
int x = 10;
int y = 20;
x // (이름 있음, &x 가능)
*p // (포인터를 역참조한 결과도 lvalue, &(*p) 가능)
arr[0] // (배열 원소도 lvalue)
5 // (그냥 숫자, 주소를 뜰 수 없음. &5는 에러)
x + y // (덧셈의 "결과"는 임시값, 이름이 없음)
MyString() // (생성자 호출로 갓 만들어진 임시 객체)
그런데 rvalue에 이름을 붙이는 방법이 있다.
T& ← lvalue 레퍼런스 (lvalue한테만 붙을 수 있는 별명)
T&& ← rvalue 레퍼런스 (rvalue한테만 붙을 수 있는 별명)
void f(int& x) { cout << "lvalue 버전\n"; }
void f(int&& x) { cout << "rvalue 버전\n"; }
int a = 10;
f(a); // "lvalue 버전" — a는 이름 있는 변수니까
f(10); // "rvalue 버전" — 10은 임시값이니까
f(a + 1); // "rvalue 버전" — a+1의 결과는 임시값
class MyString {
char* data;
size_t size;
public:
// 복사 생성자 — lvalue를 받음
MyString(const MyString& other) {
size = other.size;
data = new char[size];
memcpy(data, other.data, size); // 진짜로 메모리를 통째로 복사 (비용 O(n))
}
// 이동 생성자 — rvalue만 받음 (T&&)
MyString(MyString&& other) noexcept {
data = other.data; // 포인터만 슬쩍 가져옴 (비용 O(1)!)
size = other.size;
other.data = nullptr; // 원본은 비워둠 (원본이 소멸될 때 이중 해제 안 나게)
other.size = 0;
}
};
그러니까 move라는거는 그냥 데이터를 원래 가르키고 있던애를 가로채서 연결을 끊고 새로운 객체가 그 데이터를 가르키게 하는 것이다. (권한만 바꾼다.)
따라서 무언가를 실제로 옮기는 작업은 하지 않는다.
“이 값은 곧 버려질 거니까, rvalue처럼 취급해도 돼”라고 컴파일러에게 알려주는 것이다.
class MyString {
char* data;
public:
// 이동 생성자 — 여기가 "진짜 옮기는 작업"이 일어나는 곳
MyString(MyString&& other) noexcept : data(other.data) {
other.data = nullptr; // 원본은 비워둠 (안 그러면 둘이 같은 메모리를 가리키게 됨)
}
};
MyString a("hello");
MyString b = std::move(a);
// a는 여전히 존재하는 변수지만, 이제 data가 nullptr인 "빈 껍데기" 상태
// b가 원래 a가 갖고 있던 메모리를 그대로 가져감 (복사 없음!)
std::unique_ptr<MyString> a = std::make_unique<MyString>("hello");
std::unique_ptr<MyString> b = std::move(a);
// 이제 a는 "빈 unique_ptr" (내부적으로 nullptr을 들고 있는 상태)
// a에 대해 delete를 따로 호출할 필요가 전혀 없음!
⚠️ 참고로 move() 함수는 소유권을 옮기거나 뭔가를 옮기는 개념이 아니라 lvalue를 rvalue로 인식하게 해주는 캐스팅이다. 이걸 사용해야 컴파일러가 복사 연산자가 아니라 이동 연산자를 사용할 수 있기 때문이다.
소멸자를 직접 만들어야 할 정도로 자원을 수동 관리하는 클래스면, 아래 5가지를 세트로 고려해야 한다는 원칙이다.
class MyString {
char* data;
size_t size;
public:
MyString(const char* str) { /* ... */ }
~MyString() { delete[] data; }
// 복사 생성자
MyString(const MyString& other) {
size = other.size;
data = new char[size];
memcpy(data, other.data, size);
}
// 이동 생성자
MyString(MyString&& other) noexcept
: data(other.data), size(other.size) {
other.data = nullptr;
other.size = 0;
}
// 복사 대입 연산자
MyString& operator=(const MyString& other) {
if (this != &other) { // 자기 자신에게 대입하는 경우 방지
delete[] data; // 기존 자원부터 정리
size = other.size;
data = new char[size];
memcpy(data, other.data, size);
}
return *this;
}
// 이동 대입 연산자
MyString& operator=(MyString&& other) noexcept {
if (this != &other) {
delete[] data; // 기존 자원부터 정리! (여기 잊으면 누수)
data = other.data;
size = other.size;
other.data = nullptr;
other.size = 0;
}
return *this;
}
};
규칙 A: 소멸자, 복사 생성자, 복사 대입 연산자 중 하나라도 직접 정의하면, 컴파일러는 이동 생성자/이동 대입 연산자를 자동으로 안 만들어줍니다. (에러는 안 나요. 그냥 조용히 “이동” 기능이 없는 클래스가 되고,
std::move를 써도 그냥 복사로 처리돼요.)
규칙 B: 이동 생성자나 이동 대입 연산자 중 하나라도 직접 정의하면, 컴파일러는 복사 생성자/복사 대입 연산자를 암묵적으로 삭제(deleted)해버립니다.
⚠️ 그리고 함수의 리턴문에는 move 연산을 쓰면 안된다. (오류가 나는거는 아닌데 RVO라고 최적화 하는 기능을 못써서 오히려 손해를 본다)
MyString(MyString&& other) noexcept { ... } // "나는 절대 실패 안 해!" 약속 → vector가 이동을 씀
MyString(MyString&& other) { ... } // noexcept 없음 → vector가 못 믿고 복사를 씀 (이동 생성자를 만들어놨는데도!)
애초에 raw 포인터를 직접 들고있지 말고, unique_ptr, vector처럼 이미 RAII가 적용된 타입을 멤버로 쓰면, 5개 함수 중 아무것도 쓰지 않아도 컴파일러가 자동으로 만들어준다는 개념이다. (가장 실전적인 목표)
move()가 무조건 rvalue로 바꾸는 캐스팅이였다면, forward는 원래 뭐였는지 기억했다가 그대로 재현해주는 조건부 캐스팅이다.
void process(const std::string& s) { std::cout << "복사 버전\n"; }
void process(std::string&& s) { std::cout << "이동 버전\n"; }
template <typename T>
void relay(T&& arg) {
process(arg); // 문제: arg는 "이름이 있는 변수"라서 항상 lvalue로 취급됨
}
template <typename T>
void relay(T&& arg) {
process(std::forward<T>(arg));
}
std::string s = "hi";
relay(s); // lvalue 전달 → "복사 버전"
relay(std::move(s)); // rvalue 전달 → "이동 버전" (이제 제대로 전달됨!)
복사 생략은 리턴할때 복사를 생략해 최적화한다는 의미인데 대표적으로 RVO, NRVO가 존재한다.
class Trace {
public:
Trace() { std::cout << "기본 생성자\n"; }
Trace(const Trace&) { std::cout << "복사 생성자\n"; }
Trace(Trace&&) noexcept { std::cout << "이동 생성자\n"; }
};
Trace createRVO() {
return Trace(); // 임시 객체를 만들어서 반환
}
int main() {
Trace a = createRVO();
}
반환하는게 이름이 없는 변수
즉, 위의 복사 생략 코드를 보면 return을 어차피 Trace()만 하니까 무조건 이 객체인거를 알기 때문에 RVO이다.
반환하는게 이름이 있는 변수
Trace createSomething(bool flag) {
Trace t1;
Trace t2;
if (flag) return t1;
return t2;
}
Race Condition 서로 다른 Thread에서 같은 메모리를 공유할 때 같은 자원을 사용하면서 값이 이상하게 되는…
서론 C++에서 진짜 너무 많이 등장하는 개념이 바로 Pointer이다. 메모리의 주소를 관리하고~ 뭐시기~ 항상 등장하는데,…
서론 이번시간에는 Casting이라는 개념에 대해서 학습해 보도록 하겠다. 하지만 그 전에 Composition과 Aggregation의 차이에 대해서…
서론 c++ basic을 공부하기 위해서는 다른 언어들과의 기본적인 차이를 이해해야 한다. 그 차이는 바로 메모리…