이동의미론이란?
성능최적화에 관심이 많은 개발자라면 한번쯤은 들어봤을 이동의미론.
C++11에 도입되고서 C++17에서 더욱 개선된 이동의미론의 의미는 뭘까?
불필요한 객체 복사를 줄이고, 리소스 소유권을 효율적으로 이전하는 매커니즘.
기존에는 객체를 전달할 때 깊은 복사(deep copy)가 발생했지만,
이동의미론 사용시에는 리소스의 소유권만 이전하여 성능을 크게 향상 시킬 수 있다.
1.1 이동 의미론의 핵심 개념
1.1.1 우측값 참조(Rvalue References)
우측값 참조는 &&를 사용하여 표현하며, 임시 객체나 곧 소멸될 객체를 참조한다.
즉, 임시 객체에 대한 우측값을 참조한다는 것.
string&& rvalue_ref = string ("Hello");
1.1.2 이동 생성자와 이동 대입 연산자
클래스에 이동 생성자와 이동 대입 연산자를 구현하여 이동 의미론을 지원할 수 있다.
class MyClass {
public:
// 이동 생성자
MyClass(MyClass&& other) noexcept;
// 이동 대입 연산자
MyClass& operator=(MyClass&& other) noexcept;
};
1.1.3 std::move
move는 좌측 값(lvalue)을 우측값(rvalue)로 캐스팅하여 이동 연산을 활성화한다.
실제로 객체를 이동시키지는 않고 이동 가능한 상태로 만들어 두는 것.
string original = "hello";
string target = move(original);
즉, original의 내용을 target으로 이동,
이동 후에는 original은 정의 되지 않은 비어있는 상태가 되어버린다.
1.2 C++17에서의 향상된 기능
1.2.1 보장된 복사/이동 생략(Guaranteed Copy/Move Elision)
C++17부터는 특정 상황에서 컴파일러가 복사/이동 연산을 완전히 생략가능하게 만들었다.
이는 반환값 최적화(Return Value Optimaization, RVO)라고도 불린다.
MyClass CreateObject()
{
return MyClass(); // C++17에서는 복사/이동이 무조건 생략됨.
}
MyClass obj = createObject(); // 복사나 이동없이 직접 생성
2. R-value와 L-value의 개념
C++에서 모든 표현식은 특정 "값 카테고리"에 속한다.
가장 기본적인 구분은 좌측값(L-value)과 우측값(R-value).
2.1 L-value (Left Value)
- 정의: 메모리 주소를 가지며 지속적으로 참조할 수 있는 표현식
- 특징: 등호의 왼쪽 또는 오른쪽에 올 수 있음
- 예시: 변수, 배열 요소, 참조 등
2.2 R-value (Right Value)
- 정의: 일시적이며 지속적으로 참조할 수 없는 표현식
- 특징: 주로 등호의 오른쪽에만 올 수 있음
- 예시: 리터럴 값, 임시 객체, 계산 결과 등
2.3 간단한 예시
int x = 10; // x는 L-value, 10은 R-value
int& ref = x; // ref는 L-value 참조, x는 L-value
int y = x + 5; // x + 5는 R-value (계산 결과)
int* ptr = &x; // &x는 R-value (주소값)
x = 20; // O: L-value에 R-value 대입
// 10 = x; // X: R-value에 값 대입 불가
2.4 R-value 참조와 이동 의미론
C++11부터 도입된 R-value 참조는 이동 의미론의 핵심이다.
int&& rref = 10; // R-value 참조
// int&& rref2 = x; // X: L-value를 R-value 참조에 직접 대입 불가
int&& rref3 = std::move(x); // O: std::move로 L-value를 R-value로 변환
3. 이동 연산 구현 방법
3.1 이동 생성자
이동 생성자는 다른 객체의 리소스를 "훔쳐오는" 방식으로 객체를 초기화 한다.
class MyString {
private:
char* data;
size_t length;
public:
// 이동 생성자
MyString(MyString&& other) noexcept :
data(other.data), length(other.length) {
// 원본 객체의 리소스를 무효화
other.data = nullptr;
other.length = 0;
}
};
3.2 이동 대입 연산자
이동 대입 연산자는 기존 리소스를 해제하고서 다른 객체의 리소스를 이전 받는다.
class MyString {
private:
char* data;
size_t length;
public:
// 이동 생성자
MyString(MyString&& other) noexcept :
data(other.data), length(other.length) {
// 원본 객체의 리소스를 무효화
other.data = nullptr;
other.length = 0;
}
MyString& operator=(MyString&& other) noexcept
{
if(this != & other)
{
delete[] data;
//리소스 이동
data = other.data;
length = other.length;
//원본 객체 무효화
other.data = nullptr;
other.length = 0;
}
return *this;
}
};
4. 전달 참조와 완벽 전달
4.1 전달 참조(Forwarding Reference)
템플릿에서 T&&은 일반적인 R-value 참조가 아닌 "전달 참조"로 작동하며,
L-Value와 R-Value 모두를 받을 수 있다.
template<typename T>
void wrapper(T&& arg) {
// ...
}
4.2 완벽 전달(Perfect Forwarding)
std::forward를 사용하면 인자의 원래 값 카테고리(L-value 또는 R-value)를 유지하여 다음 함수로 전달할 수 있다.
template<typename T>
void wrapper(T&& arg) {
// std::forward로 인자의 값 카테고리 보존
process(std::forward<T>(arg));
}
int main() {
int x = 10;
wrapper(x); // x는 L-value로 전달됨
wrapper(10); // 10은 R-value로 전달됨
}
4.3 작동 원리 이해하기
#include <iostream>
#include <utility>
using namespace std;
//Lvalue 받는 함수
void process(int & x)
{
cout<< "L-value process : " << x << endl;
}
//Rvalue 받는 함수
void process(int && x)
{
cout<< "R-value process : " << x << endl;
}
template<typename T>
void wrapper(T&&arg)
{
cout << "wrapper 내부에서 전달 : ";
process(forward<T>(arg));
}
int main()
{
int x = 10;
cout << " 직접 호출 : " <<endl;
process(x); //LValue 버전 호출
process(20); //RValue 버전 호출
cout << "wrapper 사용 : " << endl;
wrapper(x); // x는 LValue, wrapper 내부에서도 LValue로 전달 됨.
wrapper(20); // 20은 R-value, wrapper 내부에서도 RValue로 전달 됨.
return 0;
}
실행 결과:
직접 호출:
L-value process: 10
R-value process: 20
wrapper 사용:
wrapper 내부에서 전달: L-value process: 10
wrapper 내부에서 전달: R-value process: 20
wrapper에서 x는 LValue, wrapper 내부에서도 LValue로 전달 되고,
20은 R-value, wrapper 내부에서도 RValue로 전달 된다.
5. 실제 활용 예제
5.1 GameItem과 Inventory 클래스를 이용한 예제
이제 한번 실습을 해보자. GameItem 클래스는 아이템을 표현,
큰텍스쳐 데이터를 가지고 있다고 가정하고, 이동의미론을 활용해서
불필요한 복사를 줄여보는 방법을 한번 구현해보자.
#include <iostream>
#include <vector>
#include <string>
using namespace std;
class GameItem
{
private:
string name_;
int damage_;
vector<char> textureData_; // 큰 데이터
public:
// 생성자
GameItem(const string& name, int damage, size_t textureSize = 1000000):
name_(name), damage_(damage), textureData_(textureSize, 'T')
{
cout << "Constructed " << name_ << endl;
}
// 복사 생성자
GameItem(const GameItem& other):
name_(other.name_), damage_(other.damage_), textureData_(other.textureData_)
{
cout << "Copy constructed " << name_ << endl;
}
// 이동 생성자
GameItem(GameItem&& other) noexcept:
name_(std::move(other.name_)),
damage_(other.damage_),
textureData_(std::move(other.textureData_))
{
other.damage_ = 0;
cout << "Move constructed " << name_ << endl;
}
// 복사 대입 연산자
GameItem& operator=(const GameItem& other) {
if (this != &other) { // 자기 자신에 대입하는 경우 방지
name_ = other.name_;
damage_ = other.damage_;
textureData_ = other.textureData_; // 벡터 복사
cout << "Copy assigned " << name_ << endl;
}
return *this;
}
// 이동 대입 연산자
GameItem& operator=(GameItem&& other) noexcept {
if (this != &other) {
name_ = std::move(other.name_);
damage_ = other.damage_;
textureData_ = std::move(other.textureData_);
// 이동 후 원본 객체 무효화
other.damage_ = 0;
cout << "Move assigned " << name_ << endl;
}
return *this;
}
~GameItem() {
cout << "Destroyed " << (name_.empty() ? "unnamed item" : name_) << endl;
}
void print() const {
cout << "Item: " << name_ << ", Damage: " << damage_
<< ", Texture size: " << textureData_.size() << endl;
}
const string& getName() const {
return name_;
}
};
class Inventory
{
private:
vector<GameItem> items_;
public:
void AddItem(const GameItem& item) {
cout << "Adding item by copy: " << item.getName() << endl;
items_.push_back(item);
}
void AddItem(GameItem&& item) {
cout << "Adding item by move: " << item.getName() << endl;
items_.push_back(std::move(item));
}
void print() const {
cout << "Inventory contains " << items_.size() << " items:" << endl;
for (const auto& item : items_) {
cout << " ";
item.print();
}
}
};
int main()
{
cout << "==Creating a sword ===\\n";
GameItem sword("Legendary Sword", 1000);
cout << "\\n === Adding sword to inventory (move) ====\\n";
Inventory playerInventory;
playerInventory.AddItem(move(sword));
cout << "\\n === Player inventory ===\\n";
playerInventory.print();
cout << "\\n === Sword after move (should be in invalid state) ===\\n";
sword.print(); // 이동 후 상태 확인
cout << "\\nCreating another item for comparison ===\\n";
GameItem shield("Iron Shield", 50);
cout << "\\n === Adding shield with copy (for comparison) ===\\n";
Inventory anotherInventory;
anotherInventory.AddItem(shield);
cout << "\\n === Shield after copy (should be unchanged) ===\\n";
shield.print(); // 복사 후 상태 확인
cout << "\\n====Another inventory ===\\n";
anotherInventory.print();
return 0;
}
결과
==Creating a sword ===
Constructed Legendary Sword
=== Adding sword to inventory (move) ====
Adding item by move : Legendary Sword
Move constructed Legendary Sword
=== Player inventory ===
Inventory contains 1items :
Item : Legendary Sword, Damage : 1000, Texture size : 1000000
=== Sword after move (should be in invalid state) ===
Item : , Damage : 0, Texture size : 0
Creating another item for comparison ===
Constructed Iron Shield
=== Adding shield with copy (for comparison) ===
Adding item by copy : Iron Shield
Copey Constructed Iron Shield
=== Shield after copy (should be unchanged) ===
Item : Iron Shield, Damage : 50, Texture size : 1000000
====Another inventory ===
Inventory contains 1items :
Item : Iron Shield, Damage : 50, Texture size : 1000000
Destroyed Iron Shield
Destroyed Iron Shield
Destroyed Legendary Sword
Destroyed unnamed item
Program ended with exit code: 0
해당 예제에서는 GameItem은 큰 텍스쳐를 가지고 있어 복사 비용이 많이든다.
Inventory::Additem은 복사와 이동 두가지 버전을 오버로딩하고 있으며,
sword는 std::move를 사용하여 이동되고, shield는 복사 된다.
이동후 swrod의 상태는 정의되지 않게된다(비어있게 됨.)
하지만 이 예제에서는 damage_를 0으로 설정을 해둬서 초기화를 해뒀다.
5.2 다른 실용적 활용 예시
효율적인 벡터 삽입
std::vector<std::string> names;
std::string name = "John";
names.push_back(name); // 복사 발생
names.push_back(std::move(name)); // 이동 발생 (name은 이후 사용 불가)
names.emplace_back("Emily"); // 내부 직접 생성 (가장 효율적)
반환 값 최적화
std::vector<int> createVector() {
std::vector<int> result;
// ... 벡터 채우기
return result; // C++17에서는 반환 값 최적화 보장
}
// 사용
std::vector<int> vec = createVector(); // 이동 또는 RVO로 최적화됨
6. 주의사항 및 최적화 팁
6.1 주의사항
1. std::move 이후 사용금지 : Move를 한 후에는 원본 객체 상태가 정의되지 않고 비어있게 되므로 사용금지.
2. const 객체는 이동 불가 : const 객체는 수정이 불가능 하므로 애초에 불가능.
3. noexcept 키워드 사용 : 이동 생성자와 이동 대입 연산자는 가능하면 noexcept로 선언하도록 해야한다.
4. 기본 타입에는 불필요 : int, double등의 기본 타입은 이동과 복사가 동일한 비용이므로 std::move가 불필요.
6.2 최적화 팁: std::move 언제 사용해야 할까?
std::move는 다음과 같은 경우에 의미가 있다.
1. 동적 메모리를 관리하는 클래스 객체(std::string, std::vector)등.
2. 포인터 멤버를 소유하는 클래스 객체.
3. 자원을 소유하는 모든 객체.
GameItem 예제를 보면,
- name_(std::string): 동적 메모리를 관리하므로 std::move 사용 의미 있음
- textureData_(std::vector): 동적 메모리를 관리하므로 std::move 사용 의미 있음
- damage_(int): 기본 타입이므로 std::move 불필요 (같은 비용의 단순 복사)
6.3 Rules of Zero (영의 규칙)
"Rules of Zero"**는 현대 C++ 설계 원칙으로,
가능하면 특수 멤버 함수(생성자, 소멸자, 복사/이동 생성자, 복사/이동 대입 연산자)를 직접 정의하지 않는 것이 좋다는 원칙.
대신 표준 라이브러리 컨테이너와 스마트 포인터를 사용하여 리소스를 관리하면,
컴파일러가 자동으로 올바른 동작의 특수 멤버 함수를 생성해 준다.
// Rules of Zero를 따른 클래스
class BetterGameItem {
private:
std::string name_;
int damage_;
std::vector<char> textureData_;
// 특수 멤버 함수를 정의하지 않음 - 컴파일러가 자동 생성
};
즉, 그냥 라이브러리나 이용하라는 뜻.(개념만 알고 있자 ^^7)
'Game DevTip > C++' 카테고리의 다른 글
16. C++ 순수 가상함수(추상 클래스 & 인터페이스)에 대해서 (5) | 2025.07.26 |
---|---|
15. C++ 예외 처리 Try Catch문 (0) | 2025.07.25 |
13. C++ 스마트 포인터에 대해서 (1) | 2025.07.19 |
12. C++ 배열에 대해서 (1) | 2025.07.16 |
11. C++ 참조자에 대하여 (0) | 2025.07.15 |
댓글