1. C++ lvalue, rvalue
C++에서 lvalue와 rvalue는 표현식(expression)을 구분하는 중요한 카테고리입니다. 모든 표현식은 lvalue 또는 rvalue (또는 그 변형) 중 하나에 속합니다. 이 구분은 C++11에서 이동 의미론(move semantics)과 완벽 전달(perfect forwarding)이 도입되면서 더욱 중요해졌습니다.
- lvalue (locator value):
- 개념: lvalue는 식별 가능한(identifiable) 메모리 위치를 차지하는 표현식을 의미합니다. 이름이 있는 변수, 배열의 원소, 멤버 변수 등이 해당됩니다.
- 특징:
- 주소 연산자(
&)를 적용하여 주소를 가져올 수 있습니다. - 대입 연산자(
=)의 왼쪽에 올 수 있습니다. 즉, 값을 대입받을 수 있습니다. - 일반적으로 "지속적인(persistent)" 상태를 가집니다. 즉, 표현식이 평가된 후에도 그 값이 메모리에 남아있습니다.
- 주소 연산자(
- 예시:
int x = 10;,x는 lvalue.int arr[5];,arr[0]은 lvalue.std::string s;,s는 lvalue.
- rvalue (read value):
- 개념: rvalue는 lvalue가 아닌 모든 것을 의미합니다. 주로 임시적인(temporary) 값이나 리터럴(literal)을 가리킵니다.
- 특징:
- 식별 가능한 메모리 위치를 가지지 않습니다. (예외: string literal)
- 주소 연산자(
&)를 적용할 수 없습니다. - 대입 연산자(
=)의 왼쪽에 올 수 없습니다. - 표현식이 평가되는 시점에만 존재하고, 그 이후에는 사라지는 임시 값입니다.
- 예시:
10(리터럴),x + 5,get_value()(값을 반환하는 함수 호출) 등.
C++11 이후 rvalue는 두 가지로 더 세분화됩니다.
- prvalue (pure rvalue): 순수한 rvalue. 리터럴(e.g.,
42,true), 임시 객체 등이 해당됩니다. - xvalue (eXpiring value): "곧 소멸될 값"을 의미합니다. 대표적으로
std::move()의 결과가 xvalue입니다. xvalue는 lvalue처럼 식별 가능한 위치를 가지지만, rvalue처럼 "이동(move)해도 되는" 자원으로 취급됩니다.
1.1 rvalue 참조와 이동 의미론 (Move Semantics)
C++11에서는 rvalue 참조(&&)가 도입되었습니다. 이는 rvalue를 바인딩할 수 있는 참조입니다.
int x = 10;
int& lref = x; // OK: lvalue 참조는 lvalue를 바인딩
// int& lref2 = 10; // Error: lvalue 참조는 rvalue를 바인딩할 수 없음
const int& lref3 = 10; // 예외: const lvalue 참조는 rvalue를 바인딩할 수 있음
int&& rref = 10; // OK: rvalue 참조는 rvalue를 바인딩
// int&& rref2 = x; // Error: rvalue 참조는 lvalue를 바인딩할 수 없음
int&& rref3 = std::move(x); // OK: std::move가 lvalue를 rvalue처럼 취급하게 함
rvalue 참조의 핵심 목적은 이동 의미론(Move Semantics)을 구현하는 것입니다. 이동 의미론은 임시 객체(rvalue)의 자원(예: 동적 할당된 메모리, 파일 핸들 등)을 불필요하게 복사하는 대신, "훔쳐오는" (이동하는) 최적화 기법입니다.
내부 구조 (이동 생성자 예시):
class MyString {
public:
// 복사 생성자 (깊은 복사)
MyString(const MyString& other) {
_size = other._size;
_data = new char[_size];
std::copy(other._data, other._data + _size, _data);
}
// 이동 생성자 (자원 이동)
MyString(MyString&& other) noexcept { // rvalue 참조를 인자로 받음
// 1. 자원을 훔쳐온다 (얕은 복사)
_data = other._data;
_size = other._size;
// 2. 원본 객체(other)를 안전한 상태로 만든다.
// (소멸자가 훔쳐간 자원을 해제하지 않도록)
other._data = nullptr;
other._size = 0;
}
// ...
private:
char* _data;
size_t _size;
};
MyString create_string() { return MyString("hello"); }
// 사용
MyString s1 = create_string(); // create_string()이 반환하는 임시 객체(rvalue)
// s1을 초기화할 때 이동 생성자가 호출됨.
create_string()이 반환하는 임시 MyString 객체는 rvalue입니다. 이 rvalue로 s1을 초기화할 때, 오버로딩 규칙에 따라 MyString(MyString&&) 이동 생성자가 선택됩니다. 이동 생성자는 임시 객체의 _data 포인터만 복사하고, 임시 객체의 포인터는 nullptr로 만들어버립니다. 비싼 메모리 복사 없이 자원의 소유권만 이전되므로 매우 효율적입니다.
2. 계층 구조별 동작
- 사용자 영역 (User Code): 개발자는
std::move를 사용하여 lvalue를 rvalue로 캐스팅함으로써 명시적으로 이동을 지시할 수 있습니다. 함수에서 값을 반환할 때 컴파일러는 종종 자동으로 이동을 수행합니다 (RVO - Return Value Optimization). - 컴파일러 영역 (Compiler):
- 표현식 분석: 모든 표현식을 lvalue, rvalue(prvalue, xvalue)로 분류합니다.
- 오버로드 확인 (Overload Resolution): 함수 호출 시 인자의 값 카테고리(lvalue/rvalue)에 가장 잘 맞는 오버로딩된 함수를 선택합니다. 예를 들어, 인자가 rvalue이면 rvalue 참조(
&&)를 받는 버전이 우선적으로 선택됩니다. - 최적화: RVO/NRVO(Named Return Value Optimization)를 통해 함수 반환 시 불필요한 복사나 이동을 아예 생략하기도 합니다.
- 실행 영역 (Runtime):
- lvalue는 일반적으로 스택이나 힙에 명확한 주소를 가지고 존재합니다.
- rvalue는 보통 CPU 레지스터에 임시로 저장되거나, 스택의 임시 공간에 잠시 존재했다가 사라집니다. 이동 의미론이 적용되면, 이 임시 객체의 내부 포인터나 핸들 값이 새로운 객체로 "이동" (단순 값 복사) 됩니다.
3. 요약
C++에서 모든 표현식은 lvalue 아니면 rvalue로 나뉩니다. 간단히 말해, lvalue는 이름이 있고 주소를 가질 수 있는, 계속해서 사용될 변수이고, rvalue는 리터럴이나 함수 반환 값처럼 곧 사라질 임시적인 값입니다. 예를 들어 int a = 10;에서 a는 lvalue, 10은 rvalue입니다.
C++11부터는 rvalue 참조라는 개념이 도입되어 이런 임시 값(rvalue)을 효율적으로 처리할 수 있게 되었습니다. 이를 통해 이동 의미론(Move Semantics)을 구현하는데, 이것은 임시 객체가 가진 자원을 비싸게 복사하는 대신, 그 자원의 소유권만 '훔쳐오는' 최적화입니다. 예를 들어, 함수가 큰 벡터를 반환할 때, 벡터의 내용을 전부 복사하는 게 아니라, 내부 데이터 포인터만 새 벡터 객체로 넘겨주어 성능을 크게 향상시킬 수 있습니다. std::move는 lvalue를 강제로 rvalue처럼 취급하게 만들어, 이러한 이동 최적화를 수동으로 유도할 때 사용합니다.
4. 실습 코드
#include <iostream>
#include <string>
#include <utility> // std::move를 위해 필요
// 데미지 정보를 담는 간단한 구조체
struct DamageInfo {
int amount;
std::string type;
// 생성자
DamageInfo(int amt, std::string t) : amount(amt), type(std::move(t)) {
std::cout << "DamageInfo 일반 생성자 호출 (" << amount << ", " << type << ")" << std::endl;
}
// 복사 생성자: lvalue가 전달될 때 호출됨
DamageInfo(const DamageInfo& other) : amount(other.amount), type(other.type) {
std::cout << "DamageInfo 복사 생성자 호출!" << std::endl;
}
// 이동 생성자: rvalue가 전달될 때 호출됨
DamageInfo(DamageInfo&& other) noexcept : amount(other.amount), type(std::move(other.type)) {
std::cout << "DamageInfo 이동 생성자 호출!" << std::endl;
other.amount = 0; // 원본 객체의 자원을 무효화
}
};
// 게임 캐릭터 클래스
class Character {
public:
std::string name;
int health;
// 캐릭터 생성자
Character(std::string n, int h) : name(std::move(n)), health(h) {}
// lvalue 참조를 받는 ApplyDamage 함수
void ApplyDamage(const DamageInfo& damage) {
health -= damage.amount;
std::cout << name << "이(가) " << damage.type << " 데미지를 " << damage.amount << " 받았습니다. (lvalue 참조 버전) 남은 체력: " << health << std::endl;
}
// rvalue 참조를 받는 ApplyDamage 함수
void ApplyDamage(DamageInfo&& damage) {
health -= damage.amount;
std::cout << name << "이(가) " << damage.type << " 데미지를 " << damage.amount << " 받았습니다. (rvalue 참조 버전) 남은 체력: " << health << std::endl;
}
};
// 임시 DamageInfo 객체를 생성하여 반환하는 함수
DamageInfo CreateFireballDamage() {
return DamageInfo(30, "화염");
}
int main() {
// 플레이어 캐릭터 생성
Character player("용사", 100);
// 이름이 있는 lvalue 객체 생성
DamageInfo poison_damage(10, "독");
player.ApplyDamage(poison_damage);
std::cout << "---" << std::endl;
player.ApplyDamage(CreateFireballDamage());
std::cout << "---" << std::endl;
player.ApplyDamage(std::move(poison_damage));
// std::move 이후 poison_damage의 상태 확인
std::cout << "std::move 이후 poison_damage의 데미지 양: " << poison_damage.amount << std::endl;
return 0;
}'C++' 카테고리의 다른 글
| 객체 복사를 막는 이유와 객체 복사를 막는 방법 (0) | 2025.12.05 |
|---|---|
| std::list가 sort를 멤버 함수로 제공하는 이유 (0) | 2025.12.03 |
| 전위 증가(++it)와 후위 증가(it++)의 차이 (0) | 2025.11.27 |
| 템플릿(Template)과 매크로(Macro) (0) | 2025.11.21 |
| 객체 지향 프로그래밍 (0) | 2025.11.20 |
