[C++ Basic] Move Semantics, Lvalue, Rvalue

Introduction

c++을 이용해서 개발을 하다보면 복사 대신에 이동연산을 통해서 여러 이점을 얻는다는 정보를 확인할 수 있다. 그때 항상 등장하는 개념이 바로 move semanics이다. 그렇기 때문에 오늘은 이 개념에 대해서 알아보고 이동 연산자에 대해서 알아보도록 하겠다.

Lvalue, Rvalue

int x = 10;
int y = 20;

x            // (이름 있음, &x 가능)
*p           // (포인터를 역참조한 결과도 lvalue, &(*p) 가능)
arr[0]       // (배열 원소도 lvalue)

5            // (그냥 숫자, 주소를 뜰 수 없음. &5는 에러)
x + y        // (덧셈의 "결과"는 임시값, 이름이 없음)
MyString()   // (생성자 호출로 갓 만들어진 임시 객체)
  • lvalue는 이름이 있고 &를 붙일 수 있는거라고 보면 된다.
  • rvalue는 이름이 없고 &를 붙일 수 없다.

그런데 rvalue에 이름을 붙이는 방법이 있다.

T&   ← lvalue 레퍼런스 (lvalue한테만 붙을 수 있는 별명)
T&&  ← rvalue 레퍼런스 (rvalue한테만 붙을 수 있는 별명)
  • 바로 rvalue 레퍼런스 (&&)를 사용하면 된다. → c++ 11에 생긴 문법
void f(int& x)        { cout << "lvalue 버전\n"; }
void f(int&& x)       { cout << "rvalue 버전\n"; }

int a = 10;
f(a);         // "lvalue 버전" — a는 이름 있는 변수니까
f(10);        // "rvalue 버전" — 10은 임시값이니까
f(a + 1);     // "rvalue 버전" — a+1의 결과는 임시값
  • f라는 함수에 레퍼런스와 rvalue 레퍼런스를 오버로딩해두면 인자를 보고 이름이 있는건지, 곧 사라질 애인지를 알아서 보고 골라준다.
  • 이 선택 매커니즘이 move semantics라고 부른다.
class MyString {
    char* data;
    size_t size;
public:
    // 복사 생성자 — lvalue를 받음
    MyString(const MyString& other) {
        size = other.size;
        data = new char[size];
        memcpy(data, other.data, size);   // 진짜로 메모리를 통째로 복사 (비용 O(n))
    }

    // 이동 생성자 — rvalue만 받음 (T&&)
    MyString(MyString&& other) noexcept {
        data = other.data;      // 포인터만 슬쩍 가져옴 (비용 O(1)!)
        size = other.size;
        other.data = nullptr;   // 원본은 비워둠 (원본이 소멸될 때 이중 해제 안 나게)
        other.size = 0;
    }
};
  • 이렇게 rvalue를 받는 레퍼런스 함수를 만들게 되면, 임시값이 들어오게 됐을때 복사를 하지 않고 그 other 데이터를 그냥 넣은다음에 빈껍데기로 만드는걸 볼 수 있다.
  • 여기서 other.data를 nullptr로 밀어주는 이유는, this→data와 같은 주소를 가지기 때문에 하나의 데이터를 2 곳에서 보게 되면 나중에 해제할때 더블 free 문제가 발생할 수 있기 때문이다.

Move Semantics

그러니까 move라는거는 그냥 데이터를 원래 가르키고 있던애를 가로채서 연결을 끊고 새로운 객체가 그 데이터를 가르키게 하는 것이다. (권한만 바꾼다.)

따라서 무언가를 실제로 옮기는 작업은 하지 않는다.

“이 값은 곧 버려질 거니까, rvalue처럼 취급해도 돼”라고 컴파일러에게 알려주는 것이다.

class MyString {
    char* data;
public:
    // 이동 생성자 — 여기가 "진짜 옮기는 작업"이 일어나는 곳
    MyString(MyString&& other) noexcept : data(other.data) {
        other.data = nullptr;   // 원본은 비워둠 (안 그러면 둘이 같은 메모리를 가리키게 됨)
    }
};

MyString a("hello");
MyString b = std::move(a);
// a는 여전히 존재하는 변수지만, 이제 data가 nullptr인 "빈 껍데기" 상태
// b가 원래 a가 갖고 있던 메모리를 그대로 가져감 (복사 없음!)
  • 이렇게 이동 생성자를 통해서 복사 연산없이 바로 b에게 넘겨주는 듯한 느낌으로 사용하면 된다.
  • 여기서 a는 사라지지않고 남아있는다(빈 껍데기).
  • 하지만 여기서 보는 것 처럼, a는 계속해서 남아있게 될 것이다. 물론 지금은 new로 동적할당하지 않아서 스택에 존재하기 때문에 클래스가 종료되면 알아서 지워질 것이다.
std::unique_ptr<MyString> a = std::make_unique<MyString>("hello");
std::unique_ptr<MyString> b = std::move(a);

// 이제 a는 "빈 unique_ptr" (내부적으로 nullptr을 들고 있는 상태)
// a에 대해 delete를 따로 호출할 필요가 전혀 없음!
  • 만약에 동적생성을 해서 힙에 있다고 하면 차라리 unique_ptr처럼 스마트 포인터를 쓰는것도 방법이다.
  • 그렇게 되면 스코프를 벗어날 때 알아서 내부 카운팅이 없으면 삭제할 것이다.

⚠️ 참고로 move() 함수는 소유권을 옮기거나 뭔가를 옮기는 개념이 아니라 lvalue를 rvalue로 인식하게 해주는 캐스팅이다. 이걸 사용해야 컴파일러가 복사 연산자가 아니라 이동 연산자를 사용할 수 있기 때문이다.

Rule of 5

소멸자를 직접 만들어야 할 정도로 자원을 수동 관리하는 클래스면, 아래 5가지를 세트로 고려해야 한다는 원칙이다.

  • 소멸자
  • 복사 생성자, 복사 대입 연산자
  • 이동 생성자, 이동 대입 연산자
class MyString {
    char* data;
    size_t size;
public:
    MyString(const char* str) { /* ... */ }
    ~MyString() { delete[] data; }

    // 복사 생성자
    MyString(const MyString& other) {
        size = other.size;
        data = new char[size];
        memcpy(data, other.data, size);
    }
    // 이동 생성자
    MyString(MyString&& other) noexcept
        : data(other.data), size(other.size) {
        other.data = nullptr;
        other.size = 0;
    }

    // 복사 대입 연산자
    MyString& operator=(const MyString& other) {
        if (this != &other) {                 // 자기 자신에게 대입하는 경우 방지
            delete[] data;                     // 기존 자원부터 정리
            size = other.size;
            data = new char[size];
            memcpy(data, other.data, size);
        }
        return *this;
    }
    // 이동 대입 연산자
    MyString& operator=(MyString&& other) noexcept {
        if (this != &other) {
            delete[] data;                     // 기존 자원부터 정리! (여기 잊으면 누수)
            data = other.data;
            size = other.size;
            other.data = nullptr;
            other.size = 0;
        }
        return *this;
    }
};

규칙 A: 소멸자, 복사 생성자, 복사 대입 연산자 중 하나라도 직접 정의하면, 컴파일러는 이동 생성자/이동 대입 연산자를 자동으로 안 만들어줍니다. (에러는 안 나요. 그냥 조용히 “이동” 기능이 없는 클래스가 되고, std::move를 써도 그냥 복사로 처리돼요.)

규칙 B: 이동 생성자나 이동 대입 연산자 중 하나라도 직접 정의하면, 컴파일러는 복사 생성자/복사 대입 연산자를 암묵적으로 삭제(deleted)해버립니다.

  • 그래서 만약에 우리가 직접 대입연산자를 만드려고 하면 이동 관련된 애들이 자동 생성이 안되고, 이동 관련 연산을 만들면 대입 이런것들이 안생긴다고 보면 된다.
  • 그래서 신기하게도 개발자가 5개를 다 정의하는 경우가 아니라면 컴파일러가 자동으로 생성하는 방법으로는 이동 관련된 애들이랑 기본 대입, 복사랑 공존하지 않는다.
  • 혹은 소멸자도 개발자가 아예 만들지 않으면 기본으로 5개를 다 만들어주긴한다.

⚠️ 그리고 함수의 리턴문에는 move 연산을 쓰면 안된다. (오류가 나는거는 아닌데 RVO라고 최적화 하는 기능을 못써서 오히려 손해를 본다)

MyString(MyString&& other) noexcept { ... }   // "나는 절대 실패 안 해!" 약속 → vector가 이동을 씀
MyString(MyString&& other) { ... }            // noexcept 없음 → vector가 못 믿고 복사를 씀 (이동 생성자를 만들어놨는데도!)
  • noexcept를 사용하면 예외를 안할꺼니까 vector나 그런애들한테 “복사”해서 값을 옮기지 말고, “이동”해서 처리하라고 하는 것이다. 그러니까 성능이 좀 좋아짐.

Rule of 0

애초에 raw 포인터를 직접 들고있지 말고, unique_ptr, vector처럼 이미 RAII가 적용된 타입을 멤버로 쓰면, 5개 함수 중 아무것도 쓰지 않아도 컴파일러가 자동으로 만들어준다는 개념이다. (가장 실전적인 목표)

Forward

move()가 무조건 rvalue로 바꾸는 캐스팅이였다면, forward는 원래 뭐였는지 기억했다가 그대로 재현해주는 조건부 캐스팅이다.

void process(const std::string& s) { std::cout << "복사 버전\n"; }
void process(std::string&& s)      { std::cout << "이동 버전\n"; }

template <typename T>
void relay(T&& arg) {
    process(arg);   // 문제: arg는 "이름이 있는 변수"라서 항상 lvalue로 취급됨
}
  • arg자체는 이름이 있는 변수기 때문에 process를 호출하면 항상 복사버전만 불리게 된다.
template <typename T>
void relay(T&& arg) {
    process(std::forward<T>(arg));
}

std::string s = "hi";
relay(s);              // lvalue 전달 → "복사 버전"
relay(std::move(s));   // rvalue 전달 → "이동 버전" (이제 제대로 전달됨!)
  • 이걸 해결하기 위해 forward를 사용하게 된다.
  • 템플릿을 이용해서 타입을 추론하게 된다. forward는 이 T를 보고 판단하게 된다. 그래서 참조타입(&)이면 Ivalue구나! 라고 인식하고 참조가 아니라면 원래 rvalue구나! 라고 인식하게 된다.

Copy Elision(복사 생략)

복사 생략은 리턴할때 복사를 생략해 최적화한다는 의미인데 대표적으로 RVO, NRVO가 존재한다.

class Trace {
public:
    Trace() { std::cout << "기본 생성자\n"; }
    Trace(const Trace&) { std::cout << "복사 생성자\n"; }
    Trace(Trace&&) noexcept { std::cout << "이동 생성자\n"; }
};

Trace createRVO() {
    return Trace();   // 임시 객체를 만들어서 반환
}

int main() {
    Trace a = createRVO();
}
  • 이 코드를 보면 createRVO에서 Trace라는 임시 객체를 생성하니까 이게 main으로 전달이 되기 위해서는 어딘가에 저장이 되어야 한다. 그래서 rvalue로 복사든 이동이든 일어날것 같지만 실제로는 기본 생성자만 호출이 된다.
  • 하지만 실제로는 컴파일러가 “뭐하러 중간에 임시로 만들었다가 옮기고 또 옮겨?”라고 생각하고 함수 내부에서 Trace()를 만들면서 이미 그 위치에 a를 두는 방식으로 한다.

RVO(Return Value Optimization)

반환하는게 이름이 없는 변수

즉, 위의 복사 생략 코드를 보면 return을 어차피 Trace()만 하니까 무조건 이 객체인거를 알기 때문에 RVO이다.

NRVO(Named RVO)

반환하는게 이름이 있는 변수

Trace createSomething(bool flag) {
    Trace t1;
    Trace t2;
    if (flag) return t1;
    return t2;
}
  • 쉽게 말하면 t1,t2처럼 이름이 붙은 지역변수를 반환하기 때문에 이는 NRVO가 되는 것이다.

Leave a Comment