우측값(rvalue)과 이동 생성자

MwG·2025년 11월 19일

C++

목록 보기
12/14

1. Copy Elision (복사 생략)

1-1. Copy elision이란?

copy elision은 말 그대로 “복사(또는 이동)를 생략하는 최적화이다.

copy constructor 또는 move constructor가 호출되어야 하는 지점에서

컴파일러가 임시 객체를 만들지 않고, 최종 목적지에 바로 객체를 생성해서

복사/이동 생성자 호출 자체를 없애 버리는 최적화이다.

예시:

함수에서 지역 객체를 값으로 return 할 때
→ RVO(Return Value Optimization), NRVO(Named RVO)

임시 객체(prvalue)를 함수 인자로 넘길 때

C++17 이후: prvalue의 “temporary materialization” 자체를 생략하는 규칙이 들어감
→ 특정 상황에서는 copy/move ctor 호출이 아예 없다고 표준에 명시됨 (guaranteed copy elision)

1-2. 왜 필요한가?

성능

큰 객체를 복사/이동하면 메모리 할당, 메모리 복사 비용이 큼

copy/move 자체를 없애 버리면 훨씬 더 빠르게 동작

불필요한 함수 호출 제거

constructor 호출 자체도 함수 호출이니까, 이걸 안 부르면 call overhead 감소

표준 차원에서의 보장 (C++17)

“여기서는 copy/move를 부르지 않고 최종 위치에 바로 만들어도 된다”를 넘어서

“반드시 그렇게 해야 한다”로 규칙이 강화됨 (guaranteed copy elision)

#include <iostream>

struct MyString {
    MyString() {
        std::cout << "default ctor\n";
    }
    MyString(const MyString&) {
        std::cout << "copy ctor\n";
    }
    MyString(MyString&&) {
        std::cout << "move ctor\n";
    }
};

MyString make() {
    MyString s;       // 여기서 default ctor 한 번 호출
    return s;         // 원래라면 copy 또는 move ctor가 한 번 더 호출될 수 있는 지점
}

int main() {
    MyString x = make();
}

C++17 컴파일러에서 위 코드를 빌드하면, 보통 출력은:

default ctor

copy ctor / move ctor가 전혀 안 나옴 → copy elision

컴파일러 입장에서는:

make() 안에서 s라는 지역 변수를 따로 만들지 않고,

애초에 main()의 x가 놓일 메모리 위치에 직접 생성해버리는 것처럼 최적화함.

2.좌측값(Lvalue) /우측값(Rvalue)

2-1. Lvalue / Rvalue 개념

좌측값 lvalue (left value)

  1. “이름이 있고, 주소를 잡을 수 있는 것”에 가까운 개념

  2. 메모리 상에 지속적으로 존재하는 객체를 가리키는 표현식

  3. &obj처럼 주소를 얻을 수 있음

  4. 대입문의 왼쪽/오른쪽에 모두 올 수 있음

우측값 rvalue (right value)

  1. “임시 값, 계산 결과로만 잠깐 존재하는 값”

  2. 보통 다시 사용할 이름이 없음

  3. 대입문의 오른쪽에만 오는 값

간단한 예:

int x = 10;    // x는 lvalue, 10은 rvalue
int y = x;     // x는 lvalue, (x의 값) 10은 rvalue
x = x + 1;     // x는 lvalue, (x + 1)의 결과는 rvalue

x : 이름이 있고, 주소도 있고, 나중에 또 접근 가능 → lvalue

10, x + 1 : “값 그 자체” → rvalue

2-2. Reference와 Lvalue / Rvalue

C++11 이전엔 lvalue reference만 있었음:

int a = 10;
int& ref = a;    // OK, lvalue reference
int& ref2 = 10;  // ERROR, rvalue(10)에는 바인딩 불가

C++11에서 rvalue reference (T&&)가 추가됨:

int&& r = 10;   // OK, rvalue reference
int&& r2 = a;   // ERROR, a는 lvalue → rvalue ref에 못 바인딩

이걸 통해:

“이 값은 더 이상 안 쓸 테니, 자원 뺏어가도 된다”는 의미를 컴파일러에게 전달하고

그걸 기반으로 move semantics가 도입됨.

2-3. Rvalue reference와 Overload 예시

#include <iostream>

void foo(int&  x) { std::cout << "lvalue ref\n"; }
void foo(int&& x) { std::cout << "rvalue ref\n"; }

int main() {
    int a = 10;
    foo(a);    // lvalue → foo(int&) 호출
    foo(20);   // rvalue → foo(int&&) 호출
    foo(a + 1); // rvalue → foo(int&&) 호출
}

lvalue → “복사(copy) 기반”으로 다루고,

rvalue → “이동(move) 기반”으로 다룰 수 있게 됨.

예외적으로 좌측 레퍼런스 &는 const T& 일 경우 우측값레퍼런스를 받을 수 있다. const 레퍼런스이기 때문에 임시로 존재하는 객체의 값을 참조만하고 변경할 수 없기 때문이다.

2-4. Move semantics, std::move, move constructor의 도입 이유

C++11에서 move semantics가 도입된 핵심 이유는:

이미 있는 메모리를 그대로 재사용하고 싶은데,

C++98 스타일의 copy semantics로는 항상 “복사”를 해야 했기 때문이다.

대표 예: std::vector, std::string 같이 내부에 큰 heap 메모리를 들고 있는 타입.

#include <iostream>
#include <vector>

struct Buffer {
    std::vector<int> data;

    Buffer(size_t n) : data(n) {}

    // copy constructor
    Buffer(const Buffer& other) : data(other.data) {
        std::cout << "copy ctor\n";
    }

    // move constructor
    Buffer(Buffer&& other) noexcept : data(std::move(other.data)) {
        std::cout << "move ctor\n";
    }
};

int main() {
    Buffer a(1000);           // 큰 버퍼
    Buffer b = a;             // copy ctor: data 전체 복사
    Buffer c = std::move(a);  // move ctor: a.data의 자원을 c.data로 '옮김'
}

Buffer b = a;

a는 lvalue → Buffer(const Buffer&) 호출

a.data 내용을 통째로 복사

Buffer c = std::move(a);

std::move(a)는 a를 rvalue로 캐스팅

Buffer(Buffer&&)가 호출되고

내부적으로 c.data는 a.data의 소유권을 가져오고, a.data는 빈 상태로 만듦

여기서 핵심:

std::move는 진짜 “move”를 하지 않고,

“이 객체를 rvalue처럼 취급해라”라고 캐스팅만 해준다.

진짜 move 작업은 move constructor / move assignment operator 안에서 구현하는 것.

3. 왜 Move Constructor에 noexcept를 붙여야 할까?

3-1. 표준 컨테이너의 전략

std::vector, std::string 같은 표준 컨테이너는 내부에서 재할당(reallocate)을 할 때 다음과 같다.

타입 T의 move constructor가 noexcept 이면:

요소를 옮길 때 move를 사용해도 예외가 안 난다고 믿을 수 있음

→ 빠르고, 불필요한 copy를 안 해도 됨

만약 T의 move constructor가 noexcept가 아니면:

move 도중 예외가 나면, 컨테이너의 내부 상태가 중간에 애매하게 깨질 수 있음

strong exception guarantee를 지키기 어렵기 때문에

차라리 copy constructor를 쓰거나,
move를 사용하지 않는 방향을 선택하기도 한다

즉,

“move가 예외를 던지지 않는 타입” 이면 컨테이너가 더 공격적으로 move를 사용해서 성능 최적화를 할 수 있다

move constructor / move assignment operator 에는
→ 웬만하면 noexcept를 붙이는 게 좋다

3-2. 간단 예시

#include <iostream>
#include <vector>

struct A {
    A() = default;
    A(const A&) {
        std::cout << "A copy ctor\n";
    }
    A(A&&) noexcept {
        std::cout << "A move ctor (noexcept)\n";
    }
};

struct B {
    B() = default;
    B(const B&) {
        std::cout << "B copy ctor\n";
    }
    B(B&&) {   // noexcept 없음
        std::cout << "B move ctor (can throw)\n";
    }
};

int main() {
    std::vector<A> va;
    va.reserve(1);
    va.emplace_back();
    va.push_back(A());  // 재할당 상황을 유도해볼 수 있음 (구체적인 capacity에 따라 다름)

    std::vector<B> vb;
    vb.reserve(1);
    vb.emplace_back();
    vb.push_back(B());
}

실제 출력은 구현/최적화에 따라 다르지만, 개념적으로:

A의 경우:

A(A&&) noexcept라서
std::vector< A>는 재할당 시 move를 마음껏 사용 가능

B의 경우:

B(B&&)가 noexcept가 아니므로
std::vector는 strong exception guarantee를 지키기 위해
copy를 더 선호하거나, move를 조심스럽게 쓸 수밖에 없다

3-3. 언제 noexcept를 붙여도 안전한지

raw pointer만 이동하는 경우

std::unique_ptr, std::vector, std::string 등,
자체 move가 이미 noexcept인 멤버들만 이동하는 경우

예:

struct MyString {
    char* data;
    std::size_t len;

    MyString(const char* s);
    ~MyString();

    MyString(const MyString& other);            // copy ctor
    MyString& operator=(const MyString& other); // copy assignment

    MyString(MyString&& other) noexcept         // move ctor
        : data(other.data), len(other.len) {
        other.data = nullptr;
        other.len = 0;
    }

    MyString& operator=(MyString&& other) noexcept {
        if (this != &other) {
            delete[] data;
            data = other.data;
            len  = other.len;
            other.data = nullptr;
            other.len  = 0;
        }
        return *this;
    }
};

여기서 move ctor / move assignment는:

delete[]는 예외를 던지지 않고,

포인터와 숫자만 옮기는 단순 작업이기 때문에

실제로 예외가 발생할 여지가 거의 없음 → noexcept를 붙인다

0개의 댓글