개발/C++

C++ std::move 그리고 std::forward

bohlee 2025. 3. 4. 00:08

이 글은 이재범 님의 '모두의 코드'에서 '씹어먹는 C++ 12장'을 참고하여 공부한 내용입니다.


C에서 사용하던 값은 크게 2가지로 분류할 수 있었습니다.

int a = 3;

 

대입 연산자를 기준으로 좌측에 잇는 값을 좌측값, 우측에 있는 값을 우측값으로 구분하였습니다. 좌측값은 보이는 그대로 값을 담을 수 있는 공간을 가진 변수로 주소를 가지고 있는 게 특징입니다. 우측값은 위 3과 같이 위 표현식이 끝나면 사라지는 값으로, 주소를 가질 수 없는 값을 말합니다. 하지만 현대의 C++의 값에는 위 범주를 포함한 5가지 분류가 있습니다.

 

Generalized Lvalue, glvalue

이 유형은 주소를 구할 수 있는 값으로, 주소연산자 &를 통해 주소를 알 수 있는 특징이 있습니다. 그리고 아래에서 설명할 xvalue와 lvalue를 포함하는 개념입니다.

 

Left Value, lvalue

int a = 42;
a = 10;
int& ref = a
int b[5];

 

이 유형은 표현식이 끝난 후에도 계속 사용될 수 있는 특징이 있습니다. glvalue의 개념에 포함되기에 주소값을 가질 수 있고 레퍼런스로 받을 수 있습니다.

 

Right Value, rvlaue

이 유형은 오직 우측에서만 사용될 수 있는 값입니다. 위에서 말한 대로 주소를 가질 수 없으며, 표현식이 끝나면 소멸되기에 다시 사용할 수 없습니다. 아래에서 나올 prvalue와 xvalue를 포함하는 개념입니다.

 

Pure Rvalue, prvalue

int x = 10;  
int y = x + 5;
std::string s = "Hello" + std::string("World");

이 유형은 순수한 rvalue를 뜻하며, 리터럴(10, "Hello "), 연산의 결괏값(x + 5), 반환값(std::string("World"))이 해당됩니다.


eXpiring Value,
xvalue

#include <iostream>

int&& getRValue() {
    return 10;
}

int main() {
    int&& r = getRValue();
    std::cout << r << "\n";
}

 

이 유형은 lvalue와 rlavue의 특징을 모두 가지고 있는 값입니다. 그래서 glvalue이지만 표현식이 끝나면 사라질 수 있고, rvlaue이지만 주소를 가질 수도 있습니다. 위 예시에서는 rvalue 10을 int&&로 반환하여 xvalue로 변환하는 코드입니다. 여기서 T&& 형태를 가지고 있는 참조가 바로 우측값 레퍼런스입니다. 

 

우측값 레퍼런스, rvalue reference

위 내용을 이해하신다면 우측값 레퍼런스는 모순된 느낌을 받으실 겁니다. 주소가 없는 우측값을 가리킬 수 있는 레퍼런스, 이 레퍼런스는 아래와 같이 사용할 수 있습니다.

int&& rref = 10;

 

위 xvalue에서 말한 T&&형태를 사용해서 표현할 수 있습니다. 이때 우측값 레퍼런스는 이름 그대로 우측값만을 가르킬 수 있습니다. 즉 lvalue 값은 가리킬 수 없습니다.

int a = 3;

int&& rref = a;  // error

 

그렇다면 이 우측값 레퍼런스를 가지고 무엇을 할 수 있을까요?

 

std::move

우측값 레퍼런스를 사용하게 되면 불필요한 복사를 피하며 할당받은 리소스를 효율적으로 이동할 수 있습니다. 예를 들어 곧 사라질 객체의 내부 값을 깊은 복사를 통해 가져오는 것이 아닌, 얕은 복사를 통해 내부 값의 주소를 가져오고 사라질 객체는 내부 값의 주소와 연결을 끊으며, 값을 이동시켜 불필요한 연산을 회피하는 것입니다.

#include <iostream>
#include <string>

class MyClass {
public:
    std::string data;

    MyClass()
    {
        std::cout << "default constructor called" << "\n";
    }

    MyClass(const MyClass& other) {
        std::cout << "copy constructor called" << "\n";
        data = other.data;
    }

    MyClass(MyClass&& other) noexcept {
        std::cout << "move constructor called" << "\n";
        data = std::move(other.data);
    }
};

int main() {
    MyClass obj1;
    obj1.data = "Hello";
    MyClass obj2 = std::move(obj1);
    std::cout << "obj1.data: " << obj1.data << "\n";
    std::cout << "obj2.data: " << obj2.data << "\n";
}

 

default constructor called
move constructor called
obj1.data:
obj2.data: Hello

 

위 코드는 std::move를 사용하여 lvalue를 rvalue로 변환하여 이동 생성자를 호출하는 코드입니다. std::move는 lvalue값을 rvalue로 변환하여 반환하는 함수입니다.

_EXPORT_STD template <class _Ty>
_NODISCARD _MSVC_INTRINSIC constexpr remove_reference_t<_Ty>&& move(_Ty&& _Arg) noexcept {
    return static_cast<remove_reference_t<_Ty>&&>(_Arg);
}

 

위 코드는 std::move 함수의 원형입니다. 여기서 remove_reference_t는 해당 객체의 참조를 제거하는 요소입니다. 본문을 보면 반환값을 remove_reference_t<T>&& 형태로 변환하여 반환하는 것을 확인할 수 있습니다. 그래서 복사 생성자가 아닌 이동 생성자가 호출된 것입니다.

 

하지만 주의해야 할 점이 있습니다. 우측값 레퍼런스는 값을 담은 시점에서 본 객체는 주소를 가진 lvalue로 취급되기 때문에 매개변수로 전달된 우측값 레퍼런스는 lvalue로 구분됩니다.

 

그리고 이동 생성자의 옆에 있는 noexcept 키워드는 예외를 던지지 않는 것을 표시하는 키워드로, 이 키워드를 사용하면 컴파일러는 해당 함수의 예외를 받기 위한 스택 되감기를 준비하지 않아 성능의 이점도 있습니다. 이 키워드를 사용해야 벡터를 포함한 STL라이브러리의 컨테이너들이 복사 생성자 대신 이동 생성자를 사용할 수 있습니다.

 

std::forward

이 함수를 사용하게 되면 값의 참조형태를 그대로 전달할 수 있습니다. 주로 wrapper 함수 매개변수의 참조유형을 완벽하게 전달(Perfect Forwarding)하기 위해 사용합니다.

#include <iostream>

void process(int& x) { std::cout << "lvalue reference" << std::endl; }
void process(const int& x) { std::cout << "const lvalue reference" << std::endl; }
void process(int&& x) { std::cout << "rvalue reference" << std::endl; }

template <typename T>
void forwarder(T&& arg) {
    process(std::forward<T>(arg));
}

int main() {
    int a = 5;
    const int b = 3;

    forwarder(a);
    forwarder(b);
    forwarder(10);    
}

 

lvalue reference
const lvalue reference
rvalue reference

 

위 예시는 std::forward 함수를 사용해 오버로드 함수들을 구분하여 호출하는 코드입니다. 템플릿 함수인 forwarder 함수의 매개변수가 T&& 인 것이 인상적입니다. 이것은 레퍼런스 겹침 법칙을 이용한 것으로 참조 유형을 유지하기 위합니다.

  1.  T& &T&
  2. T& &&T&
  3. T&& &T&
  4. T&& &&T&&

그래서 첫 번째는 주소값이 있는 lvalue가 들어갔기 때문에 T& &&으로 T&이 되어 lvalue reference가 출력됩니다. 두 번째는 동일한 경우이고, 세 번째는 rvalue int로 들어갔기 때문에 T &&으로 T&&이 되어 rvalue reference가 출력됩니다.

 

emplace_back

벡터의 emplace_back은 push_back처럼 객체를 맨 뒤에 추가하는 메서드입니다. 이때 emplace_back은 객체의 생성자를 사용하여 복사가 아닌 생성으로 객체를 추가합니다. 이때 전달 되는 인수는 새롭게 생성되는 객체의 생성자 매개변수로 사용되기 때문에 std::forward를 사용하여 매개변수의 참조유형을 완벽히 전달합니다.

#include <iostream>
#include <vector>

struct Player {
    int id;
    std::string name;

    Player(int i, std::string n) : id(i), name(n) {
        std::cout << "Player constructor: " << name << "\n";
    }
};

int main() {
    std::vector<Player> players;

    players.push_back(Player(1, "Alice"));
    players.emplace_back(2, "Charlie");

    return 0;
}

 

Player constructor: Alice
Player constructor: Charlie

 

위 예시는 push_back과 emplace_back 인자의 차이를 보여주는 코드입니다. push_back은 객체를 생성하여 전달하지만, emplace_back은 생성자에 필요한 매개변수를 인자로 전달하여 내부에서 객체를 생성하여 추가합니다.