C++ 값 카테고리: lvalue, prvalue, xvalue를 구분하는 기준

이 글의 핵심

lvalue·prvalue·xvalue로 나뉘는 C++ 값 카테고리를 정리하고, 각 카테고리가 참조 바인딩과 이동 의미론에 어떤 영향을 주는지 설명합니다. decltype이 값 카테고리에 따라 다른 타입을 내는 규칙과 카테고리 변환, 자주 발생하는 문제도 다룹니다.

값 카테고리 분류

표현식
├─ glvalue (일반화된 lvalue)
│  ├─ lvalue
│  └─ xvalue
└─ rvalue
   ├─ prvalue (순수 rvalue)
   └─ xvalue

값 카테고리는 변수나 타입이 아니라 표현식에 붙는 속성입니다. int x;에서 x라는 변수에는 타입(int)만 있고, x, x + 1, std::move(x)처럼 식을 썼을 때 비로소 각각 lvalue, prvalue, xvalue라는 카테고리가 정해집니다. C++11 표준은 이 분류를 두 가지 질문으로 설명합니다.

  • 정체성(identity)이 있는가? 같은 객체를 가리키는지 비교할 수 있는가, 즉 주소나 이름으로 특정할 수 있는가. 있으면 glvalue입니다.
  • 이동해도 되는가? 이 표현식이 가리키는 자원을 훔쳐 가도 다른 코드가 곤란해지지 않는가. 그렇다면 rvalue입니다.

두 질문에 모두 “예”인 것이 xvalue입니다. 그래서 그림에서 xvalue는 glvalue와 rvalue 양쪽에 걸쳐 있습니다. 정체성은 있지만 이동하면 안 되는 것이 lvalue, 정체성 없이 값만 있는 것이 prvalue입니다. 이 구분이 실제 코드에 영향을 주는 곳은 어떤 참조에 묶일 수 있는지와 어떤 오버로드가 선택되는지 두 가지이고, 이동 의미론이 이 위에 만들어져 있습니다.

lvalue

정의: 정체성이 있고 이동 대상으로 취급되지 않는 표현식. 대부분 이름이 있거나 주소를 얻을 수 있습니다.

int x = 10;        // x는 lvalue
int* ptr = &x;     // OK: lvalue의 주소

int& ref = x;      // OK: lvalue 참조
// int& ref2 = 10; // 에러: rvalue를 lvalue 참조에 바인딩 불가

lvalue 예시:

  • 변수 이름: x, y
  • 배열 원소: arr[0]
  • 문자열 리터럴: "hello"
  • 함수 이름: func
  • lvalue 참조 반환: ++x

가장 헷갈리는 규칙은 이름이 있는 rvalue 참조 변수도 lvalue라는 점입니다. void f(std::string&& s) 안에서 s라는 표현식은 lvalue입니다. 타입은 std::string&&이지만 이름으로 여러 번 쓸 수 있으므로, 컴파일러가 첫 사용에서 멋대로 이동해 버리면 두 번째 사용이 깨지기 때문입니다. 그래서 함수 안에서 s를 다른 곳으로 넘기며 이동하려면 std::move(s)를 다시 써야 합니다. “rvalue 참조로 받았는데 복사 생성자가 호출된다”는 질문의 대부분이 이 규칙에서 나옵니다.

++x는 증가한 x 자체를 돌려주므로 lvalue지만, x++는 증가 전 값의 사본을 돌려주므로 prvalue입니다. 문자열 리터럴이 lvalue인 이유는 정적 저장소에 있는 const char[N] 배열이라 주소가 고정되어 있기 때문이며, &"hello"처럼 주소를 얻을 수 있습니다.

prvalue (순수 rvalue)

정의: 정체성이 없는 값. 리터럴이나 계산 결과처럼 주소로 특정할 대상이 없습니다.

int x = 42;        // 42는 prvalue
int y = x + 5;     // x+5는 prvalue

// int* ptr = &42; // 에러: prvalue의 주소

prvalue 예시:

  • 리터럴: 42, 3.14, true
  • 산술 연산: a + b, a * b
  • 논리 연산: a && b, !a
  • 함수 호출 (값 반환): func()
  • 람다 표현식: [] {}
  • 후위 증감: x++
  • this 포인터

C++17부터 prvalue의 의미가 조금 바뀌었습니다. 이전에는 “임시 객체”였다면 지금은 “아직 객체가 아니라 객체를 초기화하는 방법”에 가깝습니다. Widget w = Widget();에서 오른쪽 Widget()은 임시 객체를 만든 뒤 복사·이동하는 것이 아니라 w 자체를 직접 초기화합니다. 이것이 보장된 복사 생략(guaranteed copy elision)이고, 그래서 복사·이동 생성자가 삭제된 타입도 prvalue로 초기화하거나 값으로 반환할 수 있습니다. prvalue가 실제 객체가 필요한 곳(참조에 묶이거나 멤버에 접근할 때)에 쓰이면 그 순간 임시 객체가 만들어지는데, 이를 임시 객체 구체화(temporary materialization)라고 하며 결과는 xvalue가 됩니다. 자세한 규칙은 복사 생략에서 다룹니다.

xvalue (만료되는 값)

정의: 정체성이 있으면서 자원을 이동해 가도 되는 값. “곧 수명이 끝나는(eXpiring) 값”이라는 뜻입니다.

vector<int> v = {1, 2, 3};

// move(v)는 xvalue
vector<int> v2 = move(v);  // v의 리소스 이동

// 함수 rvalue 참조 반환
vector<int>&& getVector() {
    static vector<int> v = {1, 2, 3};
    return move(v);
}

auto&& x = getVector();  // getVector() 호출식은 xvalue, 이름 붙은 x는 lvalue

xvalue 예시:

  • move(x)
  • rvalue 참조 반환 함수
  • 임시 객체의 멤버: Widget().member

std::move는 이름과 달리 아무것도 옮기지 않습니다. 구현은 사실상 static_cast<std::remove_reference_t<T>&&>(t)이고, 하는 일은 “이 표현식을 xvalue로 취급하라”는 표시뿐입니다. 실제 자원 이동은 그 xvalue를 받은 이동 생성자나 이동 대입 연산자가 합니다. 위 v2 = move(v)에서 v의 내부 버퍼 포인터가 v2로 넘어가고 v는 “유효하지만 지정되지 않은 상태”가 됩니다. 표준 컨테이너는 실제로 비게 되는 경우가 대부분이지만 표준이 보장하는 것은 아니므로, 이동한 객체는 다시 대입하거나 소멸시키는 것 외에는 쓰지 않는 것이 원칙입니다.

getVector() 예제는 xvalue를 만드는 두 번째 방법인 rvalue 참조를 반환하는 함수입니다. 이런 함수는 드물게 쓰는데, static 변수처럼 호출 후에도 살아 있는 객체가 아니면 댕글링 참조가 되기 때문입니다. 지역 변수를 return std::move(local);로 T&& 반환하면 함수가 끝나며 local이 사라지므로, 반환값은 거의 항상 T&&가 아니라 T로 선언해야 합니다.

참조 바인딩

int x = 10;

// lvalue 참조
int& lr = x;           // OK
// int& lr2 = 10;      // 에러

// const lvalue 참조 (모두 바인딩 가능)
const int& clr = x;    // OK: lvalue
const int& clr2 = 10;  // OK: rvalue

// rvalue 참조
// int&& rr = x;       // 에러: lvalue
int&& rr = 10;         // OK: rvalue
int&& rr2 = move(x);   // OK: xvalue

규칙을 표로 정리하면 이렇습니다. non-const lvalue 참조(T&)는 lvalue만, rvalue 참조(T&&)는 rvalue(prvalue·xvalue)만, const lvalue 참조(const T&)는 모두 받습니다. const T&가 모두 받을 수 있는 이유는 읽기만 하므로 임시 객체에 묶어도 위험이 없기 때문이고, C++11 이전에 복사 생성자가 const T&를 받는 형태로 굳어진 것도 이 덕분입니다.

const int& clr2 = 10;과 int&& rr = 10;에서는 임시 객체가 만들어지고 그 수명이 참조의 수명만큼 연장됩니다. 단, 연장은 prvalue를 참조에 직접 묶을 때만 일어납니다. 함수가 돌려준 참조(const auto& r = std::max(1, 2);)를 묶거나, 임시 객체의 멤버 함수가 돌려준 참조(const auto& s = getString().substr(0);는 괜찮지만 const auto& c = getVector().front();는 댕글링)를 묶으면 연장되지 않습니다. 컴파일러 경고 없이 지나가는 경우가 많아 실무에서 자주 문제를 일으키는 부분입니다.

실전 예시

예시 1: 오버로딩

void process(int& x) {
    cout << "lvalue: " << x << endl;
}

void process(int&& x) {
    cout << "rvalue: " << x << endl;
}

int main() {
    int x = 10;
    
    process(x);      // lvalue 버전
    process(20);     // rvalue 버전
    process(move(x)); // rvalue 버전
}

process(20)에서 20은 prvalue이므로 int&에는 묶일 수 없고 int&& 버전만 후보가 됩니다. process(move(x))는 xvalue라 역시 int&& 버전이 선택됩니다. 이 오버로드 쌍이 이동 생성자(T(T&&))와 복사 생성자(T(const T&))가 공존하는 원리입니다. 인자가 rvalue면 “훔쳐도 되는” 쪽이, lvalue면 “복사해야 하는” 쪽이 선택됩니다.

만약 int&& 버전을 지우고 const int& 버전만 남기면 세 호출이 모두 그쪽으로 갑니다. 반대로 int& 버전을 const int&로 바꾸지 않은 채 int&& 버전만 지우면 process(20)은 “cannot bind non-const lvalue reference of type ‘int&’ to an rvalue of type ‘int’” 오류가 납니다.

예시 2: 반환값 최적화

vector<int> createVector() {
    vector<int> v = {1, 2, 3};
    return v;  // v는 lvalue지만 NRVO 대상 (안 되면 암시적 이동)
}

vector<int> createVector2() {
    vector<int> v = {1, 2, 3};
    return move(v);  // xvalue (RVO 방해!)
}

int main() {
    auto v1 = createVector();   // RVO (최적)
    auto v2 = createVector2();  // move (덜 최적)
}

return v;의 v는 이름이 있으니 lvalue입니다. 그런데도 복사가 일어나지 않는 이유는 두 단계의 규칙 때문입니다. 먼저 컴파일러는 지역 변수 v를 반환값 자리에 직접 만들어 복사와 이동을 모두 없애는 NRVO(Named Return Value Optimization)를 시도합니다. NRVO는 C++17에서도 보장이 아닌 허용이라, 반환 경로가 여러 개라 적용할 수 없으면 컴파일러는 return v;의 v를 rvalue처럼 취급해 이동 생성자를 먼저 찾습니다(암시적 이동). 결국 최악의 경우도 이동입니다.

return move(v);는 반환식이 더 이상 “지역 변수의 이름”이 아니라 함수 호출식이 되어 NRVO 조건에서 빠집니다. 그래서 항상 이동 생성자가 한 번 호출됩니다. vector의 이동은 포인터 세 개를 옮기는 정도라 비용이 작지만, 이동 비용이 큰 타입이나 이동이 없는 타입에서는 차이가 커집니다. GCC와 Clang은 -Wpessimizing-move 경고로 이 패턴을 알려 줍니다. 반환하는 것이 지역 변수가 아니라 매개변수나 멤버라면 이야기가 다르므로 RVO와 NRVO의 예외 목록을 확인하세요.

예시 3: 완벽 전달

template<typename T>
void wrapper(T&& arg) {
    // lvalue를 넘기면 T = int& , rvalue를 넘기면 T = int
    // arg라는 이름 자체는 어느 경우든 lvalue → forward로 원래 카테고리 복원
    
    if constexpr (is_lvalue_reference_v<T>) {
        cout << "lvalue 전달" << endl;
    } else {
        cout << "rvalue 전달" << endl;
    }
    
    process(forward<T>(arg));
}

int main() {
    int x = 10;
    wrapper(x);      // lvalue
    wrapper(20);     // rvalue
}

템플릿 매개변수에 T&&를 쓰면 일반 rvalue 참조가 아니라 전달 참조(forwarding reference)가 됩니다. lvalue x를 넘기면 T가 int&로 추론되고 참조 축약 규칙(int& && → int&)으로 arg의 타입은 int&가 됩니다. rvalue 20을 넘기면 T는 int, arg의 타입은 int&&입니다. 즉 호출자가 넘긴 값 카테고리 정보는 arg가 아니라 T에 기록됩니다.

그런데 arg는 이름이 있으므로 두 경우 모두 표현식으로서는 lvalue입니다. process(arg)라고 쓰면 항상 process(int&)가 호출됩니다. std::forward<T>(arg)는 T를 보고 T가 lvalue 참조면 lvalue로, 아니면 xvalue로 캐스팅해 원래 카테고리를 복원합니다. std::move가 무조건 rvalue로 바꾸는 것과 달리 forward는 조건부 캐스트라는 점이 차이입니다. 전달 참조에 std::move를 쓰면 호출자가 넘긴 lvalue까지 이동해 버려 호출자의 객체가 비는 버그가 생깁니다. 규칙 전체는 완벽 전달에서 다룹니다.

예시 4: 값 카테고리 판별

#include <type_traits>

template<typename T>
void printCategory(T&& x) {
    if constexpr (is_lvalue_reference_v<T>) {
        cout << "lvalue" << endl;
    } else {
        cout << "rvalue" << endl;
    }
}

int main() {
    int x = 10;
    
    printCategory(x);       // lvalue
    printCategory(10);      // rvalue
    printCategory(move(x)); // rvalue
}

이 방법은 인자가 lvalue인지 rvalue인지만 알려 주고, rvalue 안에서 prvalue와 xvalue는 구분하지 못합니다. 둘 다 T가 참조가 아닌 타입으로 추론되기 때문입니다. 세 카테고리를 모두 구분하려면 다음 절의 decltype((expr)) 규칙을 씁니다.

decltype과 값 카테고리

int x = 10;

decltype(x)       // int (lvalue)
decltype((x))     // int& (lvalue)
decltype(10)      // int (prvalue)
decltype(move(x)) // int&& (xvalue)

decltype은 두 가지 모드로 동작합니다. 괄호 없는 변수 이름(decltype(x))이면 그 변수가 선언된 타입을 돌려주고 값 카테고리를 보지 않습니다. 그 밖의 표현식이면 값 카테고리에 따라 lvalue는 T&, xvalue는 T&&, prvalue는 T를 돌려줍니다. (x)는 괄호 때문에 “변수 이름”이 아니라 “lvalue 표현식”으로 취급되어 int&가 됩니다.

이 차이가 실제 버그로 이어지는 곳이 decltype(auto) 반환입니다. decltype(auto) get() { return (member_); }처럼 괄호를 하나 더 쓰면 반환 타입이 int에서 int&로 바뀌고, 지역 변수에 괄호를 쓰면 지역 변수에 대한 참조를 반환하는 댕글링이 됩니다. 괄호 하나로 의미가 바뀌는 드문 규칙이라 코드 리뷰에서 놓치기 쉽습니다. decltype 자체의 규칙은 decltype을 참고하세요.

자주 발생하는 문제

문제 1: move 오해

// ❌ move가 실제로 이동하지 않음
int x = 10;
int y = move(x);  // 복사됨! (int는 이동 의미 없음)

// ✅ 이동 가능한 타입
vector<int> v1 = {1, 2, 3};
vector<int> v2 = move(v1);  // 이동됨

int 같은 스칼라 타입은 이동과 복사가 같은 동작이라 move를 해도 x는 그대로 10입니다. 같은 이유로 이동 생성자를 정의하지 않은 클래스, 혹은 사용자가 복사 생성자나 소멸자를 직접 선언해 이동 생성자가 암시적으로 만들어지지 않은 클래스에 move를 써도 복사 생성자가 호출됩니다. const 객체도 마찬가지입니다. const std::string s; auto t = std::move(s);에서 std::move(s)의 타입은 const std::string&&이라 std::string&&를 받는 이동 생성자에 묶일 수 없고, const std::string&을 받는 복사 생성자가 선택됩니다. 오류도 경고도 없이 복사가 되므로, 이동이 일어날 것으로 기대했던 곳이 프로파일러에서 복사로 드러나 당황하는 일이 흔합니다. clang-tidy의 performance-move-const-arg 검사가 이 경우를 잡아 줍니다.

문제 2: 불필요한 move

// ❌ RVO 방해
vector<int> createVector() {
    vector<int> v = {1, 2, 3};
    return move(v);  // 불필요!
}

// ✅ RVO 허용
vector<int> createVector() {
    vector<int> v = {1, 2, 3};
    return v;  // RVO 발생
}

문제 3: 문자열 리터럴

// 문자열 리터럴은 lvalue!
const char* str = "hello";  // OK

// 배열도 lvalue
int arr[] = {1, 2, 3};
int* ptr = arr;  // OK

값 카테고리 변환

int x = 10;

// lvalue → xvalue
move(x)

// prvalue → xvalue
// (임시 객체 구체화)

// glvalue(lvalue·xvalue) → prvalue
// (lvalue-to-rvalue 변환: 값을 읽을 때)
int y = move(x);  // xvalue를 읽어 prvalue 값으로 초기화

카테고리 사이의 변환은 대부분 암시적으로 일어납니다. int y = x;처럼 lvalue에서 값을 읽을 때는 lvalue-to-rvalue 변환이 적용되어 prvalue가 되고, 같은 변환이 xvalue에도 적용됩니다. 반대로 prvalue가 참조에 묶이거나 .member로 접근되면 임시 객체가 구체화되어 xvalue가 됩니다. 명시적으로 할 수 있는 것은 std::move나 static_cast<T&&>로 lvalue를 xvalue로 바꾸는 것 정도이고, rvalue를 lvalue로 되돌리는 표준 변환은 없습니다. 다만 rvalue 참조에 이름을 붙이면 그 이름이 lvalue가 되므로 실질적으로는 그렇게 “되돌립니다”.

실무 가이드라인

함수 매개변수

// 복사 필요 없으면 const lvalue 참조
void read(const Widget& w);

// 소유권 이전이면 값 또는 rvalue 참조
void take(Widget w);
void take(Widget&& w);

// 완벽 전달이면 유니버설 참조
template<typename T>
void forward(T&& arg);

Widget w(값으로 받기)와 Widget&& w(rvalue 참조로 받기)는 둘 다 소유권을 가져오지만 호출하는 쪽 부담이 다릅니다. 값으로 받으면 호출자가 lvalue를 넘길 때는 복사, rvalue를 넘길 때는 이동이 일어나고 함수는 하나만 작성하면 됩니다. rvalue 참조로 받으면 호출자가 반드시 std::move나 임시 객체를 넘겨야 해서 “소유권을 넘긴다”는 의도가 호출 지점에 드러나는 대신, lvalue를 넘기려면 복사본을 명시적으로 만들어야 합니다. 생성자에서 멤버로 저장하는 인자라면 값으로 받은 뒤 std::move로 멤버에 넘기는 방식이 간결하고 성능 차이도 이동 한 번 정도입니다.

함수 반환

// 값 반환 (RVO)
Widget createWidget() {
    Widget w;
    return w;  // move 쓰지 말 것!
}

// 참조 반환 (주의)
Widget& getWidget() {
    static Widget w;
    return w;  // static만 반환
}

move 사용

// move는 마지막에만
void process(Widget w) {
    use(w);           // 여러 번 사용
    store(move(w));   // 마지막에 이동
}

FAQ

Q1: 값 카테고리를 왜 알아야 하나요?

A:

  • move 시맨틱스 이해
  • 완벽 전달 이해
  • 오버로딩 해결 이해
  • 성능 최적화

Q2: glvalue vs rvalue?

A:

  • glvalue: 정체성이 있음 (특정 객체나 함수를 가리킴). lvalue와 xvalue
  • rvalue: 이동 대상이 될 수 있음. prvalue와 xvalue
  • 두 분류가 겹치는 xvalue가 “정체성이 있지만 이동해도 되는 값”입니다.

Q3: xvalue는 언제 생기나요?

A:

  • move() 호출
  • rvalue 참조 반환
  • 임시 객체의 멤버 접근

Q4: 값 카테고리는 타입인가요?

A: 아니요. 표현식의 속성입니다. 같은 변수도 문맥에 따라 다른 카테고리가 될 수 있습니다.

Q5: 실무에서 자주 사용하나요?

A: 직접적으로는 드물지만, move 시맨틱스와 완벽 전달을 이해하는 데 필수입니다.

Q6: 값 카테고리 학습 리소스는?

A:

  • cppreference.com
  • “Effective Modern C++” (Scott Meyers)
  • CppCon 발표: “Back to Basics: Move Semantics”

같이 보면 좋은 글