본문 바로가기
프로그래밍/C & C++

[C++] Value Categories (값 카테고리)

by 별준 2023. 1. 3.

References

Contents

  • lvalues(좌측값) and rvalues(우측값)
  • Value Categories Since C++11
  • Check Value Categories with decltype
  • Reference Types

표현식(expressions)은 C++ 언어의 기본 토대이며, 연산을 표현하는 기본적은 메커니즘입니다. 모든 표현식에는 타입이 있으며, 이 타입은 표현식의 연산이 생성하는 값의 정적 타입(static type)을 나타냅니다. 예를 들어, 표현식 7은 int 타입이고, 5+2도 마찬가지로 int 타입이며, x가 int형의 변수라면 표현식 x 또한 int 입니다.

 

이처럼 각 표현식은 값 카테고리(value category)를 갖는데, 이번 포스팅에서는 값 카테고리가 어떻게 형성되고 표현식이 어떻게 동작하는지에 대해서 살펴보도록 하겠습니다.

 


Traditional Lvalues and Rvalues

전통적으로 lvalue(좌측값)과 rvalue(우측값), 두 가지의 값 카테고리만 존재했습니다.

 

좌측값은 메모리나 레지스터에 저장된 실제 값을 가리키는 표현식인데, 예를 들면, x가 변수의 이름인 경우 표현식 x는 좌측값입니다. 이러한 표현식에서는 수정 가능하므로, 저장된 값을 변경할 수 있습니다. 예를 들어, x가 int 타입의 변수라면 아래와 같은 할당 연산으로 x의 값을 7로 변경할 수 있습니다.

x = 7;

좌측값(lvalue)라는 용어는 할당에서 표현식의 역할로부터 유래되었습니다. l은 'left-hand side'를 의미하는데, 오직 할당 연산에서 lvalue만이 왼쪽에 있을 수 있기 때문입니다.

 

반면 rvalue(여기서 r은 'right-hand side'에서 유래됨)는 할당문에서 오른쪽 위치에만 올 수 있습니다.

 

하지만, 1989년 C가 표준화될 때, 몇 가지가 변경되었습니다. int const는 여전히 값을 메모리에 저장하고 있지만, 할당문의 왼쪽에 위치할 수 없습니다.

int const x; // x is non-modifiable lvalue
x = 7;       // ERROR: modifiable lvalue required on the left

 

C++에서는 더 많은 것들이 바뀌었는데, 클래스 우측값(class rvalues)는 할당문의 왼쪽편에 나올 수 있게 되었습니다. 이러한 할당은 사실 단순히 스칼라 타입처럼 단순한 할당이 아닌, 클래스의 적절한 할당 연산자를 호출하는 함수 호출입니다. 따라서, 멤버 함수 호출이라는 분리된 규칙을 따릅니다.

 

이러한 변경 사항 때문에 이제 lvalue라는 용어는 localizable value라고 말하기도 합니다. 변수를 가리키는 표현식은 lvalue expression에만 국한되지 않습니다. 좌측값에는 포인터 역참조 (pointer dereference) 연산 (e.g., *p)과 클래그 객체의 멤버를 참조하는 표현식(e.g., p->data)도 포함됩니다. '&'로 선언되는 전통적인 lvalue reference 타입의 값을 반환하는 함수를 호출하는 것 또한 lvalue 입니다.

std::vector<int> v;
v.front(); // yields an lvalue because the return type is an lvalue reference

문자열 리터럴(string literal) 또한 (non-modifiable) lvalue 입니다.

 

 

우측값은 어떠한 저장소(storage)도 필요하지 않는 순수한 수학적인 값 (7이나 문자 'a'와 같은) 입니다. 연산을 하기 위해서는 필요하지만, 사용된 이후에는 다시 참조되지 않습니다. 특히 문자열 리터럴을 제외한 나머지 리터럴 값(e.g., 7, 'a', true, nullptr 등)은 rvalue 이며, 내장된 많은 수학 연산 (e.g., x + 5)의 결과와 결과를 값으로 반환하는 함수 호출은 rvalue 입니다. 즉, 모든 임시 값은 rvalue 입니다 (임시 값을 가리키는 이름이 있는 참조자에는 적용되지 않음).

 

Lvalue-to-Rvalue Conversions

우측값의 생애주기는 매우 짧기 때문에 반드시 할당문의 오른쪽에 놓여야 합니다. 여기서 말하는 할당문은 일반적인 간단한 할당을 의미합니다. '7 = 8'과 같은 할당은 수학적으로 7이 재정의될 수 없기 때문에 말이 되지 않습니다. 반면 lvalue에는 이러한 제약 조건이 없습니다. x와 y라는 변수의 타입이 서로 호환된다면 둘 다 lvalue라고 하더라도 'x = y'라는 할당문이 가능합니다.

 

x = y라는 할당문은 오른쪽에 있는 표현식인 y가 암묵적으로 좌측값에서 우측값으로 변환이라는 과정을 거치기 때문에 가능합니다. 좌측값에서 우측값으로의 변환은 lvalue를 받아서 lvalue와 관련된 저장소나 레지스터에서 값을 읽어 y와 같은 타입의 rvalue를 만듭니다. 따라서, 이 변환을 통해 아래의 두 가지를 성취할 수 있습니다.

  • rvalue가 있기를 기대하는 곳에 lvalue를 사용할 수 있음 (예를 들어, 할당문의 오른쪽이나 x+y와 같은 수학 표현식)
  • 프로그램 내에서 컴파일러가 (최적화하기 전에) 메모리에서 값을 읽도록 load 명령을 내보낼 위치를 식별

 


Value Categories Since C++11

C++11에서부터는 이동의미론(move semantics)를 지원하기 위해 우측값 참조자(rvalue reference)가 도입되면서, 위에서 설명한 전통적인 lvalue/rvalue 구분만으로 C++11 동작을 제대로 표현할 수 없게 되었습니다. 따라서, 표준 위원회에서는 3가지의 기본 카테고리와 2가지 합성 카테고리 시스템을 새로 디자인하였습니다.

기본 카테고리에는 lvalue, prvalue("pure rvalue"), xvalue 가 있으며, 합성 카테고리에는 glvalue("generalized lvalue")와 rvalue(xvalue와 prvalue의 합집합)이 있습니다.

 

결과적으로 모든 표현식은 여전히 lvalue 이거나 rvalue 이지만, rvalue 카테고리가 조금 더 세부적으로 나누어지게 되었습니다.

C++17에서는 카테고리의 성격이 조금 변경되었는데, 각 카테고리의 성격/특징은 다음과 같습니다.

  • glvalue : 객체(object), 비트필드(bit-field), 또는 함수의 식별(identity)를 결정하는 표현식 (주소를 갖는 실체를 결정하는)
  • prvalue : 객체나 비트필드를 초기화하거나 연산자의 피연산자의 값을 계산하는 표현식
  • xvalue (eXpiring  value) : 객체나 비트필드의 리소스가 재활용될 수 있는 glvalue
  • lvalue : xvalue가 아닌 glvalue
  • rvalue : xvalue나 prvalue인 표현식

정확히 해당 카테고리가 무엇을 나타내는지 보려면 아래 링크를 참조바랍니다.

 

Value categories - cppreference.com

Each C++ expression (an operator with its operands, a literal, a variable name, etc.) is characterized by two independent properties: a type and a value category. Each expression has some non-reference type, and each expression belongs to exactly one of th

en.cppreference.com

 

위의 정의에서 비트필드만 뺀다면 glvalue는 주소를 갖는 실체를 생성합니다. 이 주소는 자신을 둘러싸고 있는 더 큰 객체의 하위 객체일 수도 있습니다. 베이스 클래스의 하위 객체인 경우, glvalue의 타입은 static type이라고 부르고, 베이스 클래스가 파생된 객체의 일부인 파생 객체의 타입은 glvalue의  dynamic type이라고 부릅니다. glvalue가 베이스 클래스 하위 객체를 생성하지 않을 때에는 static type과 dynamic type이 완전히 동일합니다.

 

lvalue의 예는 다음과 같습니다.

  • 변수나 함수를 가리키는 표현식
  • 내장된 단항 * 연산자 (pointer indirection)의 활용
  • 문자열 리터럴만의 표현식
  • lvalue reference를 반환하는 함수의 호출

prvalue의 예는 다음과 같습니다.

  • 문자열 리터럴이나 사용자 정의 리터럴이 아닌 그 이외의 리터럴로 구성된 표현식
  • 내장 단항 & 연산자 (포인터의 주소를 취함)의 활용
  • 내장된 산술 연산자(built-in arithmetic operators)의 활용
  • 리턴 타입이 참조자가 아닌 함수에 대한 호출
  • 람다 표현식

xvalue의 예는 다음과 같습니다.

  • 객체 타입에 대한 우측값 참조자(e.g., std::move())를 반환하는 함수 호출
  • 객체 타입에 대한 우측값 참조자로의 캐스팅(형 변환)

참고로 함수 타입에 대한 우측값 참조자는 xvalue가 아닌 lvalue를 만든다는 것에 유의합니다.

 

강조하는 부분은 glvalue, prvalue, xvalue 등은 표현식이지 값이 아니라는 것 입니다. 예를 들어, 변수를 나타내는 표현식은 lvalue 이지만, 변수는 lvalue가 아닙니다.

int x = 3; // 여기서 x는 변수이며, lvalue가 아님
           // 3은 변수 x를 초기화하는 prvalue
int y = x; // 여기서의 x는 lvalue이며, lvalue 표현식의 평가로 3이라는 값이 생성되는 것이 아닌
           // 3이라는 값을 갖는 객체를 가리키게 됨
           // 이 lvalue는 이제 prvalue로 변환되어 y를 초기화하는데 사용됨

 

Temporary Materialization

 

Implicit conversions - cppreference.com

Implicit conversions are performed whenever an expression of some type T1 is used in context that does not accept that type, but accepts some other type T2; in particular: when the expression is used as the argument when calling a function that is declared

en.cppreference.com

앞서 봤듯이 lvalue는 rvalue로 변환될 수 있는데, prvalue가 객체를 초기화하는 표현식이기 때문입니다.

C++17에서는 이 변환(lvalue-to-rvalue)과 쌍을 이루는 임시 실체화(temporary materialization)이 도입되었습니다 (prvalue-to-xvalue conversion이라고도 부름). glvalue가 있어야 할 자리라면 언제라도 prvalue가 유효하게 사용될 수 있으며, 임시 객체가 생성되어 prvalue로 초기화됩니다. 그리고 이 prvalue는 임시 값을 나타내는 xvalue로 바뀔 수 있습니다. 아래 예제 코드를 살펴봅시다.

int f(int const&);
int r = f(3);

위 코드에서 f()는 참조자를 파라미터로 받기 때문에 glvalue 인자를 받을 것이라고 예상됩니다. 하지만, 바로 아래에서 표현식 3은 prvalue 입니다. 바로 이러한 상황에서 temporary materialization 규칙이 적용됩니다. 즉, 표현식 3이 3으로 초기화된 임시 객체를 가리키는 xvalue로 변환되는 것 입니다.

 

조금 더 일반적으로 이야기하면, 임시 값은 아래의 상황에서 실체화되고 prvalue로 초기화됩니다.

  • prvalue가 참조자로 한정될 때 (위의 f(3)과 같이)
  • 클래스 prvalue의 멤버에 액세스
  • 배열인 prvalue에 첨자 연산을 사용
  • 배열인 prvalue를 첫 번째 요소에 대한 포인터로 변환
  • 어떤 타입 X에 대해 std::initializer_list<X> 타입의 객체를 초기화하는 중괄호 초기화자에서 나타난 prvalue
  • prvalue에 적용된 sizeof나 typeid 연산자
  • expr; 형태의 구문에서 top-level 표현식이거나 void로 캐스팅된 표현식
자세한 예시는 찾지 못했습니다 ㅠ

 

C++17에서 prvalue로 초기화된 객체는 항상 문맥에 따라 결정됩니다. 그 결과, 실제로 필요한 경우에만 임시 값이 생성됩니다. C++17 이전에는 prvalue(특히 클래스 타입)는 항상 임시 값을 의미했습니다. 이 후에 선택적으로 이러한 임시 값 복사를 제거할 수 있었지만, 컴파일러는 여전히 복사 연산에서 대부분의 이동의미론 제약을 강제합니다. 예를 들어, 복사 생성자가 호출 가능해야 합니다. 아래 코드를 통해 C++17이 이러한 제약을 어떻게 수정했는지 살펴보겠습니다.

class N
{
public:
    N() {};
    N(N const&) = delete; // this class is neither copyable
    N(N&&) = delete;      // nor movable
};

N make_N() {
    return N{}; // Always creates a conceptual temporary prior to C++17
                // In C++17, no temporary is created at this point
}

int main()
{
    auto n = make_N(); // ERROR prior to C++17 because the prvalue needs a
                       // conceptual copy.
                       // OK since C++17, because n is initialized directly
                       // from the prvalue.
}

위 코드를 C++17 이전 버전에서 컴파일하면 아래의 에러가 발생합니다.

즉, C++17 이전에서 prvalue N{}은 타입이 N인 임시 값을 생성합니다. 하지만 컴파일러가 복사를 생략하고 임시 값을 이동시킬 수 있는데, 실제로 항상 그렇게 동작합니다. 이 경우 make_N()을 호출하면 임시 결과가 n의 저장 공간에 직접 생성되기 때문에 복사나 이동 연산이 필요없습니다. 안타깝지만 C++17 이전의 컴파일러에서는 복사나 이동 연산이 가능한 지 검사해야 하는데, 위의 예제 코드에서는 N의 복사/이동 생성자가 삭제되어 있기 때문에 복사나 이동을 할 수 없고, C++17 이전의 컴파일러에서 위 코드를 컴파일하면 에러가 발생합니다.

 

 

C++17에서는 prvalue N 자체는 임시 값을 생성하지 않습니다. 대신 문맥에 따라 결정된 객체를 초기화합니다. 위 예제 코드에서 그 객체가 바로 n 입니다. 여기에서 복사나 이동 연산이 고려되지 않으며 (최적화가 아닌 언어 측면에서 보장된 동작), C++17에서는 위 예제 코드가 정상적으로 컴파일됩니다.

 

값 카테고리의 다양한 상황을 아래 예제 코드에서 보여주고 있습니다.

class X {
};

void f(X const&) {}; // accepts an expression of any value category
void f(X&&) {};      // accepts prvalues and xvalues only but is a better match
                     // for those than the previous declaration

int main()
{
    X v;
    X const c;

    f(v);            // passes a modifiable lvalue to the first f()
    f(c);            // passes a non-modifiable lvalue to the first f()
    f(X());          // passes a prvalue (since C++17 materialized as xvalue) to the second f()
    f(std::move(v)); // passes an xvalue to the second f()
}

 


Checking Value Categories with decltype

decltype 키워드를 사용하면 어떤 C++ 표현식에 대해서 값 카테고리를 검사할 수 있습니다. 표현식 x가 있을 때, decltype((x))에서 얻을 수 있는 결과는 다음과 같습니다. 이때, decltype에서 괄호는 두 개 이어야 합니다.

  • x가 prvalue라면 type
  • x가 lvalue라면 type&
  • x가 xvalue라면 type&&

표현식 x가 실체를 가리키는 이름인 경우, 우리는 이름의 타입이 아닌 표현식 x의 타입을 얻고자 하기 때문에 decltype((x))에 괄호가 두 개이어야 합니다. 예를 들어, 표현식 x가 단순히 변수 v를 나타낸다면, decltype(v)는 변수 v를 가리키는 표현식 x의 값 카테고리를 반영하는 타입이 아닌 변수 v의 타입을 반환합니다.

 

어떤 표현식 e에 대해 타입 특질을 다음과 같이 사용하여 해당 값 카테고리를 검사할 수 있습니다.

if constexpr (std::is_lvalue_reference<decltype((e))>::value) {
    std::cout << "expression is lvalue\n";
}
else if constexpr (std::is_rvalue_reference<decltype>((e))::value) {
    std::cout << "expression is xvalue\n";
}
else {
    std::cout << "expression is prvalue\n";
}

 


Reference Types

C++에서 int&와 같은 참조자 타입은 두 가지의 중요한 방식으로 값 카테고리와 상호작용합니다.

첫 번째로 참조자는 표현식이 가질 수 있는 값 카테고리를 한정할 수 있습니다. 예를 들어, int& 타입에 대한 non-const lvalue는 오직 int 타입의 lvalue인 표현식으로만 초기화될 수 있습니다. 이와 유사하게 int&& 타입의 rvalue 참조자는 int 타입의 rvalue인 표현식으로만 초기화될 수 있습니다.

 

두 번째는 함수의 리턴 타입에 관한 것인데, 참조자 타입을 반환형으로 사용하면 그 함수에 대한 호출의 값 카테고리에 영향을 미칩니다.

  • 리턴 타입이 lvalue reference인 함수를 호출하면 lvalue가 생성된다
  • 리턴 타입이 객체 타입에 대한 rvalue reference인 함수를 호출하면 xvalue가 생성된다 (함수 타입에 대한 rvalue reference는 항상 lvalue를 생성함)
  • 리턴 타입이 reference가 아닌 타입을 반환하는 함수를 호출하면 prvalue가 생성된다

 

참조자 타입과 값 카테고리 간의 관계를 아래의 예제 코드를 통해 살펴보겠습니다.

먼저, 아래의 코드가 주어졌을 때,

int&  lvalue();
int&& xvalue();
int   prvalue();

주어진 표현식의 값 카테고리와 타입은 decltype을 통해 알아낼 수 있습니다.

std::is_same_v<decltype(lvalue()), int&>  // yields true because result is lvalue
std::is_same_v<decltype(xvalue()), int&&> // yields true because result is xvalue
std::is_same_v<decltype(prvalue()), int>  // yields true because result is prvalue

따라서, 아래와 같이 호출할 수 있습니다.

int& lref1 = lvalue();  // OK: lvalue reference can bind to an lvalue
int& lref2 = prvalue(); // ERROR: lvalue reference cannot bind to a prvalue
int& lref3 = xvalue();  // ERROR: lvalue reference cannot bind to an xvalue

int&& rref1 = lvalue();  // ERROR: rvalue reference cannot bind to an lvalue
int&& rref2 = prvalue(); // OK: rvalue reference can bind to a prvalue
int&& rref3 = xvalue();  // OK: rvalue reference can bind to an xvalue

 

댓글