본문으로 건너뛰기
0%

Modern C++의 L-Value, R-Value & Move Semantics 이해하기

카테고리

Modern C++의 L-Value, R-Value & Move Semantics 이해하기

0. 서문

본격적으로 들어가기 전에, 참조(&)와 포인터(*)에 대한 이해를 빠르게 되짚어봅시다.

참조(&)는 포인터(*)처럼 메모리 어딘가에 위치한 객체의 주소를 저장합니다. 하지만 참조는 한 번 초기화되면 다른 객체를 참조하도록 변경할 수 없고 null로 설정할 수도 없습니다.

값은 두 가지 타입으로 분류할 수 있습니다: l-value와 r-value. l-value와 r-value(좌측값과 우측값)의 정의는 C 언어에서 유래했습니다.

좌측값은 할당문의 왼쪽이나 오른쪽 양쪽에 나타날 수 있는 표현식이고, 우측값은 할당문의 오른쪽에만 나타날 수 있는 표현식입니다.

int a = 0;  
a; // l-value
0; // r-value
Player player;
player; // l-value
Player(); // r-value

이 정의는 C++에서 다소 다르게 발전했습니다.

L-value는 메모리 위치를 참조하며 & 연산자로 참조할 수 있습니다. R-value는 l-value가 아닌 모든 것입니다. L-value는 그들이 나타나는 표현식을 넘어서 지속됩니다 R-value는 그들이 나타나는 표현식을 넘어서 지속되지 않습니다 L-value는 명명된 변수를 참조합니다 (&) R-value는 임시 객체를 참조합니다 (&&)

솔직히, 이 정의조차도 꽤 모호해서 많은 사람들을 혼란스럽게 합니다.

l-value와 r-value를 단계별로 자세히 살펴보겠습니다…

1. L-Values

L-value는 상대적으로 간단합니다.

다음은 l-value로 분류되는 것들입니다:

  • 단일 표현식을 넘어서 지속되는 객체들!
    • 주소를 가짐
    • 명명된 변수
    • const 변수
    • 배열 변수
    • 비트 필드
    • 공용체
    • 클래스 멤버
    • l-value 참조(&)를 반환하는 함수 호출
    • 문자열 리터럴

그렇다면 l-value 할당과 참조는 어떻게 작동할까요?

아래 코드와 주석을 살펴봅시다.

// 모두 l-value입니다
int a = 1;                    // 전역 변수 a -> l-value
int& function(){              // l-value function은 반환된 a의 주소로 초기화됨
    a = 3;                    // a-> l-value, 할당 가능  
    return a;
}

int main()
{
    int i = 3;                // i는 l-value
    i = 4;                    // i는 l-value, 할당 가능
                              // i -> 4
    int *ptr = &i;            // i는 l-value, & 연산자로 참조 가능, ptr은 i의 주소를 가리킴
                              // *ptr은 i의 값 -> 4
    ptr = &a;                 // ptr은 int * 타입 포인터이므로 참조하는 것을 변경 가능
                              // *ptr은 a의 값 -> 1
    int & r = i;              // r은 l-value i의 주소로 초기화됨, 따라서 r은 l-value로 할당된 l-value
    r = 5;                    // r이 가리키는 주소에 값을 씀, i의 값을 변경 (r과 i 둘 다 5)
                              // r과 i 둘 다 같은 주소를 가리킴 -> 5
    int c = function();       // l-value 참조는 초기화 중에만 주소를 저장
                              // c -> 내부 스택 연산 후 function의 반환값, 변경된 a 값 3
    function() = 50;          // function은 l-value, r-value 50을 할당 가능
                              // a의 값이 50으로 변경됨. 
                              // 그리고 ptr은 a의 주소를 가리키므로 *ptr -> 50이 할당됨.
    int d = a;                // l-value d에 a의 값 50이 할당됨
    int *ptr_2 = &function(); // int * 타입 ptr_2는 &function 사용 가능 (function은 l-value)
                              // 내부 스택 연산 후 function의 반환값, 변경된 a 값 3
                              // * ptr_2 -> 변경된 a의 값 -> 3
                              // c의 값도 변경됨 -> 3
                              // * ptr의 값도 변경됨 -> 3
    return 0;
}

위에서 보듯이 L-value 할당은 상대적으로 명확하고 이해하기 쉽습니다.

2. R-Values

하지만 r-value는 정의하기가 다소 어렵습니다…

다음과 같이 말할 수 있습니다:

  • l-value가 아닌 객체들… (대우명제 ㅎㅎ..)
  • 사용되는 단일 표현식을 넘어서 지속되지 않는 임시 값들!
    • 주소가 없는 객체
    • 리터럴 (문자열 리터럴 제외)
    • 참조로 반환하지 않는 함수 호출 (예: int function())
    • i++과 i— (하지만 ++i와 —i 연산자는 l-value)
    • 기본 산술, 논리, 비교 표현식 (+,-,*,=, <,> 등, 연산자 오버로드에 따라 다를 수 있음)
    • 열거형 (enum)
    • 람다 (컴파일러는 람다/익명 함수를 임시적인 것으로 처리)

몇 가지 예제를 살펴봅시다.

R-value 예제 1:

int num1 = 10;
int num2 = 15;

if (num1 < num2) // (num1 < num2)는 r-value
{
    ...
}

R-value 예제 2:

int function();         // function은 호출 후 지속되지 않음 -> r-value 
function();             // r-value
int i = 0;
i = function();         // i는 r-value function의 반환값으로 할당된 l-value
int *ptr = &function(); // 에러!!! r-value의 주소를 참조할 수 없음

원래 C/C++ 설계자들은 r-value를 임시 객체로 정의했으므로, 기본적으로 r-value는 할당될 수 없습니다.

하지만 이는 Modern C++ (C++11)에서 r-value “move semantics”의 도입으로 변경되었습니다.

3. Move Semantics

다음 시나리오를 고려해봅시다:

//Math.cpp
std::vector<float> Math::ConvertToPercentage(const std::vector<float>& scores){
    std:vector<float> percentages;
    for (auto& score : scores){
        //...
    }
    return percentages;
}
// main.cpp
#include "Math.h"
int main(){
    std::vector<float> scores;
    //...
    scores = ConvertToPercentage(scores); 
    //...
}

main에서 ConvertToPercentage(scores)가 호출될 때, Math 클래스의 이 함수는 percentages라는 std::vector<int> 타입의 임시 값인 r-value를 생성합니다. 그런 다음 할당 연산자 ”=“에 의해 scores에 할당됩니다.

C++11 이전(Modern C++ 이전)에는 어떻게 작동했을까요?

percentages의 임시 값이 scores 초기화 중에 생성된 메모리 영역에 할당될 때, 복사됩니다. 그런 다음 Math::ConvertToPercentage 함수 스택을 빠져나가면서 임시 r-value는 사라집니다.

여기에 꽤 불필요한 과정이 있습니다 - percentages 임시 값이 복사되는 순간입니다.

논리적으로 생각해봅시다.

임시 percentages 결과(r-value)를 저장하는 메모리 영역을 scores 초기화 중에 생성된 메모리 영역과 단순히 교체한다면, 스택을 빠져나갈 때 임시로 생성된 percentages 메모리 영역은 어차피 사라질 것이므로, “복사” 과정이 불필요해집니다.

불필요한 복사를 방지하는 이 방법이 Modern C++의 r-value 참조와 Move Semantics의 핵심입니다.

사실, 요즘 최신 컴파일러들은 이에 대해 꽤 똑똑합니다… 때로는 r-value 참조를 과도하게 사용하면 실제로 시스템이 느려질 수 있습니다. 😅

4. R-Value 참조 (&&)와 std::move

R-value 참조는 Modern C++ (C++11)에서 처음 도입되었습니다. & 연산자와 유사하게 작동합니다.

아래 예제는 move semantics를 사용하여 기본 타입을 참조하는 것을 보여줍니다:

#include <memory>
#include <utility>
float CalculateAverage(){
    float average=3;
    // ...
    return average;
}

int main(){
    int num = 10;                           // num -> l-value
    //int && rNum = num;                      // 에러!!! num은 l-value -> int & lnum이 가능
    int && rNum = std::move(num);           // move는 l-value num을 참조할 수 있게 함
    num = 30;                               // num을 변경하면 rNum도 변경됨 (참조값이므로)
    
    int && rNum1 = 10;                       // OK, 10은 r-value
    
    float && rAverage = CalculateAverage();   // OK CalculateAverage는 r-value
    float tmp_1 = std::move(rAverage);       // tmp_1은 l-value이므로 할당됨
    float tmp_2 = rAverage;                  // tmp_2는 l-value이므로 할당됨
    float && tmp_3 = std::move(rAverage);    // tmp_3는 l-value이지만 r-value 참조값임
    rAverage = 1.0f;                         // rAverage를 변경하면 tmp_3만 변경됨, tmp_1과 tmp_2는 할당된 값
}

부록.A 참고자료

  1. https://m.blog.naver.com/yoochansong/222082508401
  2. https://docs.microsoft.com/ko-kr/cpp/cpp/references-cpp?view=msvc-160&viewFallbackFrom=vs-2019
  3. https://skstormdummy.tistory.com/entry/%EC%9A%B0%EC%B8%A1-%EA%B0%92-%EC%B0%B8%EC%A1%B0-RValue-Reference
  4. https://modoocode.com/189

댓글 남기기

여러분의 생각을 들려주세요

댓글

GitHub 계정으로 로그인하여 댓글을 남겨보세요. 건설적인 의견과 질문을 환영합니다!

댓글을 불러오는 중...