s-nova 님의 블로그
[C++] lvalue, rvalue, Perfect forwarding 본문
C++에서 성능 최적화를 이야기할 때 빠질 수 없는 개념이 바로 lvalue / rvalue와 이동 의미론$^{\text{Move Semantics}}$입니다. 불필요한 복사를 줄이고 자원의 소유권을 효율적으로 전달하는 원리를 차근차근 살펴보겠습니다.
1. lvalue와 rvalue란?
C++의 모든 표현식은 lvalue$^{\text{left value}}$와 rvalue$^{\text{right value}}$ 중 하나로 분류됩니다.
| 구분 | lvalue | rvalue |
| 정의 | 이름이 있는 변수 | 임시값, 리터럴, 계산 결과 |
| 주소 취득 | 가능 $($&x$)$ |
불가능 $($&10 에러$)$ |
| 대입 연산자 | 왼쪽에 올 수 있음 | 오른쪽에만 올 수 있음 |
| 수명 | 선언된 스코프까지 유지 | 표현식이 끝나면 소멸 |
int a = 5; // a: lvalue, 5: rvalue
int b = a; // b: lvalue, a: lvalue (이름이 있으므로)
int c = a + b; // c: lvalue, (a+b): rvalue (임시 계산 결과)
&a; // OK - lvalue는 주소 취득 가능
// &5; // 에러 - rvalue는 주소 취득 불가
// &(a+b); // 에러 - rvalue는 주소 취득 불가
a = 10; // OK - lvalue는 대입 가능
// 5 = 10; // 에러 - rvalue는 대입 불가
2. 복사 생성자 vs 이동 생성자
lvalue와 rvalue를 구별하는 실질적인 이유는 바로 여기에 있습니다.
- 복사$^{\text{Copy}}$: 새로운 메모리를 할당하고 데이터를 전부 복제합니다. 느리고 비용이 큽니다.
- 이동$^{\text{Move}}$: 원본의 포인터를 그대로 가져오고 원본은 비웁니다. 포인터 교환만 일어나므로 빠릅니다.
C++은 인자가 lvalue이면 복사 생성자를, rvalue이면 이동 생성자를 자동으로 선택합니다.
이동 생성자는 && $($rvalue 참조$^{\text{rvalue reference}}$$)$로 선언합니다.
class BigData
{
int* data;
int size;
public:
// 복사 생성자 (lvalue용) - 데이터 전체를 새로 할당
BigData(const BigData& other)
{
size = other.size;
data = new int[size];
for (int i = 0; i < size; i++)
data[i] = other.data[i]; // 느림
}
// 이동 생성자 (rvalue용) - 포인터만 가져옴
BigData(BigData&& other)
{
data = other.data; // 소유권 이전
size = other.size;
other.data = nullptr; // 원본은 비워서 이중 해제 방지
}
};
BigData createData() { return BigData(); }
// rvalue → 이동 생성자 선택 (빠름)
BigData d1 = createData();
// lvalue → 복사 생성자 선택 (느림)
BigData temp = createData();
BigData d2 = temp;
💡 std::move: lvalue를 강제로 rvalue로 캐스팅하고 싶을 때는
std::move()를 사용합니다. 단, 이후 원본 객체는 유효하지 않은 상태가 되므로 사용에 주의가 필요합니다.
3. 래퍼 함수의 문제 - 속성 손실
함수에 인자를 전달하는 순간 문제가 발생합니다. 인자가 함수의 매개변수 이름에 바인딩되는 순간, 그 인자는 이름이 생기므로 무조건 lvalue가 됩니다.
template
void wrap(T t)
{
use(t); // t는 이름이 있으므로 lvalue → 항상 복사 생성자 선택 (느림)
}
wrap(std::string("tmp")); // 원래는 rvalue였지만, wrap 안에서 lvalue가 됨
rvalue를 넘겨줘도 래퍼 함수 안에서 lvalue로 바뀌어 버리므로, 이동이 아닌 복사가 호출됩니다. 래퍼 함수가 깊어질수록 이 문제는 더욱 심각해집니다.
4. Perfect Forwarding - 속성 보존 전달
이 문제를 해결하는 것이 Perfect Forwarding$^{\text{완벽한 전달}}$입니다.
T&& $($forwarding reference$^{\text{전달 참조}}$$)$와 std::forward<T>()를 함께 사용하면
원래 인자가 lvalue였으면 lvalue로, rvalue였으면 rvalue로 그대로 전달됩니다.
📌 참고: 일반 함수에서 쓰이는
T&&는 rvalue 참조이지만, 템플릿에서 쓰이는T&&는 forwarding reference가 됩니다. 두 가지는 문법이 같지만 의미가 다릅니다.
template
void wrap(T&& x) // forwarding reference
{
callee(std::forward(x)); // lvalue면 lvalue로, rvalue면 rvalue로 전달
}
wrap(std::string("tmp")); // rvalue → callee에 rvalue로 전달 (이동 선택)
게임 엔진의 팩토리 함수처럼 가변 인자$^{\text{variadic arguments}}$가 필요한 경우에도 동일하게 적용할 수 있습니다.
template
T* Spawn(Args&&... args)
{
return new T(std::forward(args)...); // 각 인자의 lvalue/rvalue 속성 그대로 전달
}
Spawn(100, 200, std::string("name")); // rvalue → 이동 생성자 선택
std::string n = "boss";
Spawn(100, 200, n); // lvalue → 복사 생성자 선택
💡 게임 개발 관점: 게임 엔진의 오브젝트 생성 함수 $($
Spawn,CreateObject등$)$에 Perfect Forwarding을 적용하면, 호출부에서 전달한 인자의 성격을 그대로 내부 생성자까지 전달할 수 있어 불필요한 복사를 완전히 제거할 수 있습니다.
5. 핵심 정리
| 개념 | 문법 | 역할 |
| rvalue 참조 | T&& $($일반 함수$)$ |
임시값을 바인딩, 이동 생성자/이동 대입 정의에 사용 |
| forwarding reference | T&& $($템플릿$)$ |
lvalue/rvalue 모두 받아 속성 보존 |
| std::move | std::move(x) |
lvalue를 rvalue로 캐스팅 $($소유권 포기$)$ |
| std::forward | std::forward<T>(x) |
원래 속성 $($lvalue/rvalue$)$을 유지하여 전달 |
'C++' 카테고리의 다른 글
| [C++] C++ 캐스팅 (0) | 2025.09.28 |
|---|---|
| [C++] 스레드, CPU 캐시 (0) | 2025.09.18 |
| [C++] 스마트 포인터 (0) | 2025.09.08 |
| [C++] 얕은 복사 vs 깊은 복사 (0) | 2025.08.31 |
| [C++] 다형성, virtual, override (0) | 2025.07.27 |