KoreanFoodie's Study

C++ DevNote : 우측값과 좌측값 완벽 정리 (glvalue, rvalue, lvalue, xrvalue, prvalue) 본문

Tutorials/C++ : Expert

C++ DevNote : 우측값과 좌측값 완벽 정리 (glvalue, rvalue, lvalue, xrvalue, prvalue)

GoldGiver 2022. 12. 26. 17:28

C++ 에 대해 공부한 것과, 개발하면서 알게 된 것들을 다룹니다

우측값과 좌측값

우리는 C++03 까지, 우리는 우측값과 좌측값 두 가지의 개념을 사용하여 코딩을 했었다.

간단히 말하자면, 좌측값은 '식의 좌측에 있는 변수'를, 우측값은 '식의 우측에 있는 값'을 의미했다.

// a 는 좌측값, 5 는 우측값이다
int a = 5;

흔히들 좌측값을 '이름이 있는 녀석'으로 인식하기도 했다. 엄밀히 말하면 틀린 말은 아니다.

그런데 move semantics 가 도입되기 시작하면서, C++11 부터 대격변이 시작되는데... 여기서 많은 사람들이 좌측값과 우측값에 대한 개념을 헷갈려하기 시작한다(필자 포함). move semantics 가 뭔지 잘 모르겠다면, 이전 글(기초, 응용, 심화)을 꼭 읽고 오자. 기초만 읽어도 된다.

 

 

Value Categories

우측값과 좌측값의 개념은 조금 확장되어, 다음 그림과 같은 식으로 분화가 된다(MSDN 다이어그램이 핵심을 한눈에 보여주고 있다).

MSDN 글에서 제시한 다이어그램

좌측값은 glvalue, 우측값은 rvalue 가 되었다. 이때 lvalue 는 우리가 '전통적으로' 사용했던 좌측값이라고 보면 되며, prvalue(pure-rvalue 라는 뜻)은 우리가 '전통적으로' 사용했던 우측값이라고 보면 된다.

 

 

lvalue, xvalue, prvalue

lvalue, xvalue, prvalue 의 쓰임을 단적으로 드러내는 코드는 다음과 같다 :

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

 

lvalue 는 쉽게 말해 '이름을 가지는 객체'라고 볼 수 있다. 이는 표현식 이후에 사라지지 않고 지속되는 객체를 의미하며, 예시로는 변수, 배열, 비트 필드, union, 및 클래스 멤버를 반환하는 함수 호출과 const 한정자가 붙은 타입이 있다.

rvalue 는 메모리의 위치와 식별자를 특정할 수 없는 데이터들을 의미한다. 즉 프로그램에서 더 이상 엑세스할 수 없다는 뜻이다. 예시로는 리터럴, 참조가 아닌 형식을 반환하는 함수 호출, 혹은 컴파일러에서만 접근 가능한 임시 객체 들이 있다.

그렇다면 'rvalue reference' 라고 부르는 xvalue 는 어떤 녀석이길래 위의 다이어그램이 탄생했을까?

 

xvalue 는 "프로그램에서 더 이상 액세스할 수 없지만 식에 대한 액세스를 제공하는 rvalue 참조를 초기화하는데 사용할 수 있는 주소가 있다"고 한다(MSDN 의 설명을 빌리자면).

프로그램에서 더 이상 액세스할 수 없다는 것은 rvalue 라는 뜻인데, rvalue 참조를 초기화하는데 사용할 수 있는 주소가 있다는 것은 lvalue 의 특징도 갖고 있다는 뜻이다. 아래 예제 코드를 보자. 출처는 이 블로그이다.

 

MyClass { ... };

MyClass my_rvalue()
{
    /* 여러가지 작업 */ 
    return MyClass();
}

int main()
{
    MyClass my_lvalue;

    MyClass& a = my_lvalue;
    MyClass& b = my_rvalue();      // error C2440: 'initializing' : cannot convert from 'MyClass' to 'MyClass &'

    MyClass&& c = my_lvalue;       // error C2440: 'initializing' : cannot convert from 'MyClass' to 'MyClass &&'
    MyClass&& d = my_rvalue();
}

my_rvalue() 함수의 리턴값은 현재 참조가 아니므로, 우측값이다. 그리고 my_lvalue 변수의 경우 '이름을 가지고 있는 변수' 이므로, lvalue 이다.

b 에서 에러가 발생하는 이유는 prvalue 인 녀석에 대한 참조를 b 에 할당하려고 시도했기 때문이다. 

c 에서 에러가 발생하는 이유는 rvalue 참조 타입에 lvalue 를 대입하려고 시도했기 때문이다.

그렇다면 d 와 같은 경우가 왜 필요한 것일까?

 

우리는 move sematics 를 배울 때, 불필요한 복사를 방지하기 위해 move 를 사용한다고 배운 바 있다.

위에서 d 에 값을 대입하는 과정에서, 일반적으로 복사 대입 연산자가 호출될 것이다. 하지만 만약 MyClass 가 이동 대입 연산자를 갖고 있다면, '우측값'으로 반환된 값이 '불필요한 복사를 다시 일으키지 않고' 우리가 원하는 d 라는 변수에 잘 전달될 것이다(물론 my_rvalue() 앞에 std::move 나 std::forward 를 붙여야 하는 게 아니냐 할 수도 있지만 일단 넘어가자. 그리고 참고로, std::move 는 우측값으로 '캐스팅'을 해 주는 녀석이지 실제로 이동 연산을 해 주는 녀석이 아니다). 

예를 들어 string 의 경우, 복사 및 대입이 여러번 이루어지는 상황이 잦다고 가정하자. 임시적으로 생성된 문자열 우측값을 마치 포인터 연산을 하듯이 값을 이동시킴으로써 불필요한 복사를 하지 않게 된다면, 상당한 성능의 향상을 꾀할 수 있을 것이다.

 

 

마지막으로, '이름을 가지고 있는'  rvalue reference 는 lvalue 라는 것을 잊지 말자 :

void foo(int&& t) {
  // t 는 rvalue reference 로 초기화되지만
  // 실제로는 lvalue 표현식이다 (t 라는 이름이 있으니까)
}
Comments