본문 바로가기

진리는어디에/C++

[C++] r-value 레퍼런스(reference) 완벽 가이드

들어가며

C++11 부터 & 연산자로 표기 되는 참조 타입에 추가하여 && 연산자로 표기 되는 r-value 레퍼런스라는 새로운 개념이 추가 되었다. 이번 포스트에서는 r-value 란 무엇인지, 무슨 이유로 추가되었는지, 어떤 경우에 유용하게 사용할 수 있을지에 대해 자세히 살펴 보도록 하겠다.

r-value 란?

r-value 라는 용어가 생소한 분들을 위해 r-value가 무엇인지 먼저 알아 보도록 하자. r-value를 한줄로 요약하면 '오른쪽(right)에만 올 수 있는 값'이라고 정의할 수 있다. 아직 뜬 구름 잡는 소리 처럼 들리겠지만 잠시만 인내심을 가지고 아래 예제를 살펴 보자.

int main()
{
    int v1 = 0, v2 = 0;
    
    v1 = 10;    // ok
    10 = v1;    // error
    v2 = v1;    // ok
}

위 예제는 간단한 대입 표현식들을 나열하고 있다.

  • 5라인에서 v1이라는 int 타입의 변수에 10을 대입하고 있다. 우리가 일반적으로 자주 사용하는 아무런 문제 없이 컴파일 되는 코드다.
  • 6라인에서는 리터럴 상수 10에 v1의 값을 대입하고 있다. 당연히 이렇게하면 컴파일 에러가 발생한다. 10은 등호(=)의 오른쪽에는 올 수 있지만 왼쪽에는 올 수 없다.

이처럼 오른쪽에만 올 수 있는 값을 'r-value'라고 한다.

다시 5라인과 7라인을 살펴 보자. v1은 등호의 오른쪽에도 올 수 있고 왼쪽에도 올 수 있다. 이렇게 등호의 양쪽에 다 위치할 수 있는 값을 'l-value'라고 한다.

r-value : 등호의 오른쪽에만 올 수 있는 값
l-value : 등호의 오른쪽 왼쪽 모두에 올 수 있는 값

그렇다면 등호의 좌측에 놓였을 때 컴파일 에러가 발생하면 모두 r-value인가? 정확하게는 에러가 나면 r-value인것이 아니고 등호의 오른쪽 밖에 올수 없는 r-value를 왼쪽에 썼기 때문에 에러가 발생한 것이다. 다음 섹션에서 이에 대해 좀 더 자세히 알아 보도록 하자.

C++에서의 r-value

우리는 앞에서 일반적인 프로그래밍에서의 r-value의 정의. 즉, 표현식(expression)이 등호의 왼쪽에 놓일 수 있으면 l-value, 그렇지 못하면 r-value라는 정의를 살펴 보았다. 이번 섹션에서는 좀 더 깊이 들어가 C++에서 정의하고 있는 r-value의 규칙에 대해 살펴 보도록 한다. 초급자 분들에게는 아직 어려운 내용일 수 있으니 일단 읽어만 두고 다음에 생각 나면 한번 더 찾아 보아도 된다.

C++은 l-value와 r-value에 대해 아래와 같은 규칙을 정의하고 있다. 

l-value r-value
등호(=)의 왼쪽에 올 수 있다 등호(=)의 왼쪽에 올 수 없다
이름이 있고 단일식을 벗어나 사용 가능 이름이 없고, 단일식에서만 사용 가능
주소 연산자로 주소를 구할 수 있다 주소 연산자로 주소를 구할 수 없다
참조를 반환하는 함수
문자열 리터럴
값을 반환하는 함수
실수/정수 리터럴
임시 객체(함수 리턴 값)

아직 위 내용들이 이해하기 어려울 수 있다. 하지만 걱정 말라. 지금 부터 하나씩 하나씩 함께 살펴보도록 하겠다.

※ 프로그래밍 언어 마다 l-value와 r-value에 대한 각각의 정의를 가지고 있다. 만일 다른 언어를 공부할 기회가 있다면 아래 규칙과 어떤 부분이 다른지 살펴 보는 것도 좋은 공부가 될 것이다..

규칙 1. 이름과 단일식

등호의 왼쪽 오른쪽 이야기는 이전 섹션들에서 이미 충분히 살펴 보았으므로 '이름과 단일식' 부터 알아 보도록 하자.

int v1 = 0, v2 = 0;
v1 = 10;  // ok. v1 : l-value

위 예제에서 v1은 0이라는 값도 있지만 v1이라는 이름도 가지고 있다. C++에서 값에 이름이 있다는 것(이것을 우리는 변수라고 부른다)은 메모리를 차지하고 있으며 코드의 다음 부분에서 이름을 이용해 다시 접근할 수 있다는 것을 의미한다. 2라인에서 처럼 v1을 다른 표현식에서 다시 접근 가능하다는 뜻이다.

이는 "이름이 있고 단일식을 벗어나 사용 가능"하다는 l-value의 조건에 부합하므로 v1은 l-value다.

반면에 아래 10에 v1을 대입하는 예제를 살펴 보자.

10 = v1;  // error. 10 : r-value

리터럴 상수 10은 이름이 없는 단지 10이라는 값일 뿐이다. 이런 리터럴 상수의 경우 별도 데이터 메모리를 할당 받지 않고 코드 메모리의 어셈블리 레벨에서 10이라고 적혀있을 뿐이다. 데이터 메모리가 없으니 그에 따른 이름도 없고, 이름이 없으니 다른 표현식에서 다시 사용하는 것도 불가능하다. 위에서 10은 r-value의 조건에 부합한다.

규칙 2. 변수의 주소

int* p1 = &v1;  // ok
int* p2 = &10;  // error

이름이 있는 v1의 경우에는 데이터 메모리를 할당 받고 있으므로 주소를 구할 수 있다. 하지만 리터럴 상수 10의 경우에는 코드 메모리 어딘가 어셈블리 레벨에 위치하고 있으므로 할당 받은 메모리가 없고, 당연히 주소도 구할 수 없다. 그러므로 10은 r-value의 조건에 부합한다.

규칙 3. '참조를 반환' vs '값을 반환' 하는 함수

이번에는 변수 말고 함수의 경우를 살펴 보자. 함수 호출 표기법을 왼쪽에 놓을 수 있느냐를 확인 해보도록 하겠다.

int x = 10;  // 전역 변수 x

int  f1() { return x; } // 10을 리턴
int& f2() { return x; } // x의 참조(별명)을 반환

위 코드는 전역 변수 x를 리턴하는 두 함수를 보여주고 있다. f1의 경우는 x의 값을 리턴하고, f2의 경우는 x의 별명. 즉, 참조를 리턴한다. 

지금 부터 살펴 볼 것은 함수를 호출하고 그 함수를 등호의 왼쪽에 놓을 수 있느냐는 것인데 아래의 코드와 같이 f1, f2 함수를 호출하고 그 호출에서 리턴 되는 값에 20을 대입한다면 어떻게 될까?

int x = 10;  // 전역 변수 x

int  f1() { return x; } // 10을 리턴
int& f2() { return x; } // x의 참조(별명)을 반환

int main()
{
    f1() = 20;  // 10 = 20. error
    f2() = 20;  // x = 20
}

값을 리턴하는 f1 함수의 경우 전역 변수 x의 값을 복사한 이름 없는 임시 변수를 리턴한다, 이름이 없다는 것은 r-value라는 뜻이며 결과적으로 컴파일 에러를 발생 시킨다.

반면, f2 함수는 전역 변수 x의 참조를 반환하므로  x = 20과 같은 표현식이 된다. 이 코드는 아무런 문제 없이 컴파일 된다.

결론은 참조를 반환하면 등호의 왼쪽에 올 수 있는 l-value가 되고, 값을 반환하면 r-value가 된다.

상수는 r-value인가?

일반적으로 값을 바꿀 수 없는 상수는 등호의 왼쪽에 올 수 없으므로 r-value라고 오해하는 경우가 종종 있다.

const int c = 10;
c = 20; // error

위의 코드는 10으로 초기화된 상수 c, 다른 표현식에 20이라는 값을 다시 대입하다 에러를 발생 시키고 있다. 하지만 c의 경우에는 c라는 이름을 가지고 있으며, 다른 표현식에서 이름을 이용해 다시 사용이 가능하다(값을 변경하지는 못하지만 읽을 수는 있다). 심지어 변수의 주소도 구할 수 있다. 이런 것은 l-value의 특징이다.

위와 같이 상수는 r-value가 아니라 l-value다. 하지만 단순한 l-value가 아니라 값을 변경할 수 없는 immutable l-value라고 한다. 이것을 r-value라고 정의하는 언어도 있지만 C++에서는 이런 경우 immutable l-value라는 이름으로 분류하고 있다.

r-value는 상수인가?

앞에서 상수는 r-value가 아니라는 것을 알아 보았다. 그렇다면 반대로 r-value는 상수일까?

r-value는 값을 변경할 수 없으므로 상수라고 오해하기 쉽다. 하지만 아래 코드 처럼 임시 객체의 멤버함수 등을 호출해 의미 없긴 하지만 값을 수정할 수 있다.

class Point
{
public :
    Point(int x, int y)
        : x(x), y(y)
    {
    }
    void set(int x, int y)
    {
        this.x = x;
        this.y = y;
    }
private :
    int x;
    int y;
};

Point CreatePoint(int x, int y)
{
    return Point(x, y);
}

CreatePoint(1, 2).set(10, 20);

r-value는 등호의 왼쪽에 값이 못 오는 것이지 상수는 아니다.

리터럴(literal)

실수나 정수의 리터럴은 r-value지만 문자열의 리터럴은 l-value다. 아래 코드는 두 라인 모두 에러가 발생한다. 하지만 에러의 이유가 각각 다르다. 지금 부터 살펴 보자.

10 = 20;        // error
"aa"[0] = 'x';  // error

첫번 째 라인. 10에 20을 대입하는 이유는 정수 리터럴 10은 r-value라 등호의 왼쪽에 올 수 없기 때문에 에러가 발생한다. 하지만 l-value 라고한 문자열 리터럴 "aa"는 왜 에러가 발생했을까?

이유는 위 "aa"의 정확한 데이터 타입은 const char[3]이다. 위에서 발생하는 에러는 l-value가 아니라서 발생한 것이 아니라 const 한정자 때문에 값을 수정할 수없는 immutable l-value라서 발생한 것이다.

위 코드를 컴파일하면 다음과 같이 각각 다른 원인의 에러를 출력한다.

1>Program.cpp(67,12): error C2106: '=': 왼쪽 피연산자는 l-value이어야 합니다.
1>Program.cpp(68,11): error C2166: l-value가 const 개체를 지정합니다.

표현식과 r-value

여기 까지 읽었으면 이제 l-vlaue와 r-value에 대해 오해할 수 있는 부분이, 값이라는 용어를 사용하다 보니 l-vlaue와 r-value가 변수나 객체에 부여 되는 속성이라 생각할 수 있다는 것이다.

하지만 l-value, r-value는 변수나 객체가 아닌 표현식에 부여 되는 속성이다. 표현식이라는 것은 '하나의 값'을 만들어 내는 코드의 집합이라고 정의할 수 있다.

표현식(expression) : 하나의 값을 만들어 내는 코드의 집합. a sequence of operaters and operands that specifies a computation

아래 코드의 3라인을 살펴 보자.

int n = 3;

n + 2 * 3 = 10

위에서 n은 3이다. 이것은 표현식이다. 그리고 n+2는 5라는 값이다. 이 역시 표현식이다. 다음 n + 2 * 3은 9라는 값이 된다. 이것도 표현식이다. 이렇게 표현식은 하나의 값을 만들어 내는 코드의 집합이다. 즉, 표현식이란 값과 동일한 의미를 갖는다.

표현식 = 값

우리가 l-value, r-value를 판단하는 것은 이런 표현식이 등호의 왼쪽에 올 수 있느냐 그렇지 않느냐는 것이다. 

int n = 3;

n = 10;     // ok
n + 2 = 10; // error

위 코드의 3라인에서 n이라는 표현식은 사용자가 할당한 메모리를 가리키고 있다. 이는 아무 문제 없이 컴파일 되는 l-value다. 하지만 n+2 라는 표현식은 5라는 결과를 만들어 내지만, 5는 사용자가 할당한 메모리는 아니고 연산중에 만들어진 이름 없는 어떤 값일 뿐이다. 이름이 없으므로 이것은 왼쪽에 올 수 없을 것이고, 이는 r-value가 된다.

r-value 레퍼런스

이제 드디어 r-value 레퍼런스에 대해 이야기 해볼 시간이 왔다.

l-value와 r-value의 개념을 알았으니 레퍼런스와 연관 지어 보도록하자. C++에는 '레퍼런스'라는 '&'연산자를 이용해 또 다른 이름으로 같은 변수를 참조하도록할 수 있다. 하지만 아래 예제와 같이 레퍼런스는 r-value를 참조하게 되면 컴파일 타임 에러를 발생 시킨다. 레퍼런스는 r-value를 참조할 수 없다.

int main()
{
    int v = 0;
    
    int& r1 = v;  // ok
    int& r2 = 10; // error
}

그럼 이제 위 코드를 단순 레퍼런스에서 아래 처럼 const 레퍼런스 타입으로 변경해 보자.

int main()
{
    int v = 0;
    
    int& r1 = v;  // ok
    int& r2 = 10; // error
    
    const int& r3 = v;  // ok
    const int& r4 = 10; // ok
}

const 레퍼런스의 경우에는 l-value와 r-value 모두 가리킬 수 있다.

C++11 이전에는 이렇게 l-value만을 참조할 수 있는 것과, l-value와 r-value 모두를 가리킬 수 있는 레퍼런스는 있지만 r-value 만을 가리킬 수 있는 무엇인가는 없었다. 그래서 C++11 부터는 r-value만을 가리킬 수 있는 r-value 레퍼런스(&&)가 추가 되었다.

int main()
{
    int v = 0;
    
    int& r1 = v;        // ok. l-vlaue 참조
    int& r2 = 10;       // error. r-value 참조
    
    const int& r3 = v;  // ok. l-vlaue 참조
    const int& r4 = 10; // ok. r-value 참조
    
    int&& r5 = v;       // error. l-value 참조
    int&& r6 = 10;      // ok. r-value 참조
}

r-value 레퍼런스의 특징은 일반 레퍼런스(정확하게는 l-value 레퍼런스)와는 달리 r-value를 가리킬 때는 아무런 문제가 없지만 l-value를 가리키게 되면 컴파일 타임 에러를 발생 시킨다.

레퍼런스 타입 설명
l-value reference l-value만 가리킬 수 있다
const reference l-value와 r-value 모두 가리킬 수 있다.
r-value reference r-value만 가리킬 수 있다.
사족 : C++11 부터 r-value 레퍼런스가 추가 되면서 공식적으로 레퍼런스라는 의미는 l-value 레퍼런스, r-value 레퍼런스 모두를 의미하고 개별적으로 l-value 레퍼런스와 r-value 레퍼런스라는 명칭으로 구분 되게 되었다. 일반적으로 현업에서는 레퍼런스라고 말하면 l-value 레퍼런스를 의미하고, r-value 레퍼런스는 따로 r-value 레퍼런스라고 부르는 것이 일반적이다.

마치며

이번 포스트에서 살펴본 내용을 요약해보자.

  • l-value, r-value는 표현식이 등호의 왼쪽에 올 수 있는지 없는지를 의미한다.
  • C++11 이전까지 l-value 레퍼런스와 const 레퍼런스가 있었다.
  • C++11 부터 r-value만을 가리킬 수 있는 r-value 레퍼런스가 추가 되었다.

l-value 레퍼런스는 함수의 인자를 넘길 때 오버헤드를 줄이기 위해서 이용한다던지, 그 용도와 의미에 익숙하신 분들이 많을 것이다. 하지만 r-value 레퍼런스는 과연 무엇을 위해 존재하는지 의미를 찾지 못한 분들이 있을 것이다.

r-value 레퍼런스는move semantics와 perfect forwarding. 딱 요 두가지를 위해서 만들어 졌다. 이번 포스트는 r-value 레퍼런스의 개념을 위한 포스트이므로 여기까지 마무리 하도록 하고, 다음 포스트는 move semantics와 perfect forwarding 주제를 다루기 위한 사전 지식인 [C++] 레퍼런스 콜랩싱(reference collapsing)에 대해 간단히 살펴 보고 넘어 가도록 하겠다.

이름이 생소해서 그렇지 어렵지 않은 내용이므로 스쳐지나가듯 읽어 보자. 어렵지 않다고 했지 중요하지 않다고는 안했다.

부록 1. 같이 읽으면 좋은 글

유익한 글이었다면 공감(❤) 버튼 꾹!! 추가 문의 사항은 댓글로!!