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

C++ ] lvalue와 rvalue 그리고 std::move()

by eteo 2026. 2. 22.
반응형

 

 

1. lvalue와 rvalue

  • lvalue : 메모리 주소를 가지고 있어 위치를 식별할 수 있는 객체로, 이름이 있고 계속 쓸 수 있는 값
  • rvalue : 잠깐 쓰이고 사라지는 임시 값
int a = 10;

 

위 코드에서 a는 lvalue이고, 10은 rvalue이다. 대입문의 왼쪽에 오면 lvalue, 오른쪽에 오면 rvalue라고 봐도 대부분의 경우 틀리진 않지만 더 정확히는 이름을 통해 메모리 주소를 참조할 수 있으면 lvalue, 표현식이 끝나면 사라지는 일시적인 값은 rvalue다.

 

 

 

lvalue와 rvalue의 구분이 중요한 이유는?

  • 언제 복사가 발생하는지
  • 언제 이동이 발생하는지
  • 어떤 API 설계가 불필요한 복사를 줄이는지

명확히 알 수 있기 때문이다.

 

 

 

 

 

 

2. lvalue와 rvalue의 reference

  • lvalue reference (T&) : lvalue에만 연결될 수 있는 참조 방식으로, 기존 객체에 별명을 붙이는 개념이다.
  • rvalue reference (T&&) : C++11에서 도입되었으며, 임시 객체(rvalue)를 붙잡아 수명을 연장하거나 그 자원을 재사용(이동)할 수 있는 근거가 마련되었다.

 

예를 들어 어떤 함수가 const 파라미터를 받는다면, 읽기 전용으로 사용하겠다는 의도를 함수 시그니처로 표현한 것이다. 마찬가지로 함수 시그니처에 붙은 &와 &&는 이 함수가 인자로 받은 데이터를 빌려 쓰는건지, 혹은 자원을 뺏어가는건지를 포함한 API 사용 의도를 드러낸다.

 

 

 

 

아래와 같이 lvalue reference를 인자로 받는 함수가 있으면, "lvalue인 x는 계속 살아있는 객체이므로, 이 함수는 x를 소유하지 않는다. x의 수명은 호출자가 책임진다."는 의미를 내포한다.

 

void foo(T& x);

 

 

 

반면, rvalue reference를 인자로 받는 함수가 있으면, “x는 곧 사라질 객체이므로, 함수가 그 자원을 뺏어도(move) 된다”는 의미를 내포한다.

 

void foo(T&& x);

 

 

 

 

 

 

 

 

3. std::move의 역할

std::move는 이름과 달리 실제로 데이터를 '이동'시키지 않는다. 정확히는 lvalue를 rvalue로 캐스팅하는 역할만 하고, 실제 이동은 그 이후 호출되는 이동 생성자나 이동 대입연산자를 통해 이루어진다.

 

std::string a = "hello";
std::string b = std::move(a);

 

위 코드에선 a 객체가 더 이상 필요 없으니, 그 객체가 가진 자원(메모리 등)을 다른 곳으로 이동(Move Semantics)시켜도 좋다는 신호를 컴파일러에 전달한다.

그 결과 복사 생성자 대신 이동 생성자가 호출되고, 불필요한 깊은 복사(Deep Copy)를 방지해서 성능을 최적화 할 수 있다.

 

 

 

std::move 이후의 객체 상태(moved from)

 

std::move로 자원을 옮긴 후, 원래의 객체는 유효하지만 내부 값은 보장되지 않는(valid but unspecified) 상태가 된다. 따라서 이동된 객체는 다시 초기화하기 전까지 사용하지 않도록 주의해야 한다.

 

#include <iostream>
#include <string>

int main() {
    std::string a = "hello";        // 생성자
    std::string b = a;              // 복사 생성자
    std::string c;
    c = b;                          // 복사 대입 연산자
    std::string d = std::move(c);   // 이동 생성자
    std::string e;
    e = std::move(d);               // 이동 대입 연산자

    std::cout << a << std::endl;
    std::cout << b << std::endl;
    std::cout << c << std::endl;    // valid but unspecified
    std::cout << d << std::endl;    // valid but unspecified
    std::cout << e << std::endl;
}

 

// std::string의 경우엔 이동 후 비워지도록 구현 되어있다.
hello
hello


hello

 

 

 

 

 

 

 

 

 

 

4. return 값은 rvalue (RVO: Return Value Optimization)

리턴 값은 함수가 끝나면 사라지는 존재이므로 이미 rvalue로 취급된다.

또한 리턴 값을 받아서 객체를 생성하는 경우라면 모던 C++ 컴파일러는 RVO를 통해 복사나 이동조차 생략하고 객체를 목적지에 바로 생성한다. 따라서 return 시에는 std::move를 쓰면 오히려 RVO 최적화를 방해할 수 있으므로 쓰지 않도록 한다.

 

#include <iostream>

struct Obj {
    Obj() {
        std::cout << "생성자\n";
    }
    Obj(const Obj&) {
        std::cout << "복사 생성자\n";
    }
    Obj(Obj&&) {
        std::cout << "이동 생성자\n";
    }
    ~Obj() {
        std::cout << "소멸자\n";
    }
};


Obj makeObj() {
    Obj o;
    return o;
}

int main() {
    Obj o = makeObj();	
}

 

생성자
소멸자

 

 

 

 

 

 

 

 

5. lvalue와 rvalue 이해가 API 설계로 이어지는 사례

 

rvalue reference를 사용하는 과거의 setter 설계

 

// 1. lvalue 버전
void setName(const std::string& name) {
    this->name = name; 
}

// 2. rvalue 버전
void setName(std::string&& name) {
    this->name = std::move(name);
}

 

  • lvalue를 넘겼을 때, setName(str) : 1번 버전 함수 호출,  내부에서 복사 1회
  • rvalue를 넘겼을 때, setName("John") : 2번 버전 함수 호출, 내부에서 이동 1회

이 설계는 복사든 이동이든 딱 필요한 작업만 1회 일어나지만, 오버로딩으로 코드량이 2배라서 setter가 많아지면 관리하기 힘들다는 단점이 있다.

 

 

 

 

모던 C++ 스타일 setter 설계

 

void setName(std::string name) {
    this->name = std::move(name);
}

 

데이터를 내부 멤버에 저장(Sinking)하는 목적의 setter 함수라면 값으로 받고 내부에서 std::move를 쓰는 것이 요즘의 C++의 설계 방식이다.

  • lvalue를 넘겼을 때, setName(str) : 지역 변수 name을 만들 때 복사 1회, 내부에서 이동 1회
  • rvalue를 넘겼을 때, setName("John") : 임시 객체를 받아 지역 변수 name을 만들 때 이동 1회, 내부에서 이동 1회

이 설계의 장점은 오버로딩 없이 함수 하나만으로 lvalue, rvalue 둘 다 준수한 성능을 낸다는 것이다. 과거 설계보다는 이동이 1회씩 많지만 이동의 비용(포인터 복사 및 null 처리 등)은 복사에 비하면 거의 공짜라서 성능 체감이 크지 않다.

 

 

https://www.cnblogs.com/5iedu/p/11318729.html

 

 

 

 

 

이처럼 API 설계에서 인자의 수명과 자원 소유에 대한 의도를 코드로 표현하기 위해서는, lvalue와 rvalue에 대한 이해가 필요하다.

 

 

 

※ 함수 시그니처가 주는 메시지

시그니처 형태 lvalue 허용  rvalue 허용 호출자에게 드러내는 설계 의도
void func(const T& val) O O 난 이 데이터를 구경만 할거임, 너가 가진 원본은 안전해
void func(T& val) O X 니가 준 원본 내가 수정할 수도 있다. 그러니 함수 호출이 끝나면 네 데이터는 변해 있을 수 있어
void func(T&& val) X O 어차피 너한테는 곧 버려질 대상이니, 내가 이 데이터 소유권을 가져갈 수도 있어
void func(T val) O O 니가 원본을 복사해 주든 임시 값을 주든 상관 없고, 이 데이터는 이제 내꺼니까 내가 알아서 할게

 

 

 

 

반응형