move, forwarding

MwG·2025년 11월 19일

C++

목록 보기
14/14

1. std::move란 뭐고, 진짜로 뭘 하는가?

1.1. std::move의 정체

#include <utility>

std::move(x);
template <typename T>
constexpr typename std::remove_reference<T>::type&& move(T&& x) noexcept {
    return static_cast<typename std::remove_reference<T>::type&&>(x);
} //실제 구현

std::move는 단지 x를 T&&로 static_cast 해주는 함수
“이 값은 이제 rvalue(xvalue)처럼 취급해도 좋다”는 의사를 알리는 역할

메모리 복사 X
자원 이동 X
내부 상태 변경 X

→ 진짜 “move” 작업은 move constructor / move assignment 안에서 직접 구현해야 한다

1.2. std::move 사용 예시

struct Buffer {
    int* data;
    std::size_t size;

    Buffer(std::size_t n)
        : data(new int[n]), size(n) {}

    ~Buffer() {
        delete[] data;
    }

    // copy constructor
    Buffer(const Buffer& other)
        : data(new int[other.size]), size(other.size) {
        std::copy(other.data, other.data + size, data);
    }

    // move constructor
    Buffer(Buffer&& other) noexcept
        : data(other.data), size(other.size) {
        other.data = nullptr;  // 원본 비우기
        other.size = 0;
    }
};

int main() {
    Buffer a(1000);
    Buffer b = std::move(a);  // 여기서 move ctor 호출

    // std::move는 a를 rvalue로 캐스팅만 해줌
    // 진짜 포인터 옮기기 / other 비우기는 Buffer(Buffer&&) 안에서 일어남
    //이제 a는  moved-from 객체이다.
    //moved-from 객체는 “valid but unspecified state” 여야 한다. 
}

2. Universal Reference (Forwarding Reference)

2.1. 보편적 레퍼런스

Scott Meyers가 Effective Modern C++에서 universal reference라고 부른 개념이고,
C++ 표준 용어로는 forwarding reference라고 부른다.

템플릿 파라미터 T에 대해 T&& 형태로,
그리고 T가 타입 추론(deduction)되는 위치에 있는 레퍼런스

함수 템플릿의 파라미터가 T&&이고,
호출 인자를 보고 T를 추론하는 그런 자리의 레퍼런스 → forwarding reference

ex) 대부분 클래스 템플릿은 vector<int.>식으로 타입이 명시되기 때문에(추론 x) T&&가 rvalue reference이다.

예:

template <typename T>
void f(T&& x);   // 여기서 x는 forwarding reference (universal reference)

여기서 x는 상황에 따라 자동으로 lvalue reference 또는 rvalue reference가 된다.

2.1.1. 어떻게 동작하는지 예시

void test() {
    int a = 10;
    const int ca = 20;

    f(a);          // T = int&      -> 매개변수 타입: int& &&  -> int&
    f(ca);         // T = const int&-> 매개변수 타입: const int& && -> const int&
    f(10);         // T = int       -> 매개변수 타입: int&&
}

lvalue를 넘기면 → T가 int&, const int& 이런 식으로 deduce

rvalue를 넘기면 → T가 그냥 int, std::string 같은 값 타입으로 deduce

그 다음 T&&에 reference collapsing 규칙이 적용됨.

2.2. Reference Collapsing Rule (레퍼런스 겹침 규칙)

C++에서는 &와 &&가 섞여서 겹칠 때(특히 템플릿에서) 어떤 reference 타입이 되는지에 대한 규칙이 필요하다.

규칙은 딱 하나 테이블로 기억하면 됨:

조합 ---> 결과
T& & ---> T&
T& && ---> T&
T&& & ---> T&
T&& && ---> T&&

즉,

“한 번이라도 &가 끼면 결국 &가 된다” & = 1, && = 0의 OR 연산이라고 생각하면된다.
(&& &&인 경우만 진짜 && 유지)

예를 들어:

template <typename T>
void f(T&& x);

int main() {
    int i = 0;
    f(i);
}

여기서:

i는 lvalue → T = int&

매개변수 타입: T&& → int& && → reference collapsing → int&

그래서 결국 x는 int&가 됨 (lvalue reference)

반면 rvalue인 f(0);은:

T = int

매개변수 타입 int&& && → int&&

진짜 rvalue reference로 받게 됨.

T&&가 상황에 따라 lvalue도, rvalue도 바인딩할 수 있어서 universal/forwarding reference라고 부르는 이유이다

3. Perfect Forwarding과 std::forward

3.1. 왜 Perfect Forwarding이 필요한가?

문제 상황을 먼저 보자:

void foo(const std::string& s) { /* lvalue 전용 */ }
void foo(std::string&& s)      { /* rvalue 전용 */ }

template <typename T>
void wrapper(T x) {
    foo(x);   // ??? 어떤 foo가 불릴까?
}

여기서 wrapper를 이렇게 쓰면:

std::string s = "hi";
wrapper(s);         // foo(const std::string&) 기대
wrapper(std::string("hi")); // 원래는 foo(std::string&&) 기대

하지만 wrapper 안에서 x는 항상 lvalue이다. (x는 이름 있는 변수이기 때문에)
그래서 두 경우 모두 foo(const std::string&)이 호출된다.

“원래 인자가 lvalue면 lvalue로, rvalue면 rvalue로 그대로 전달하고 싶지만
wrapper 때문에 다 lvalue가 되어버림 → 이걸 해결하려는 기법이 perfect forwarding”

3.2. Forwarding Reference + std::forward

perfect forwarding을 위해 필요한 두 가지:

매개변수 타입: T&& (forwarding reference)

인자 전달: std::forward(arg)

template <typename T>
void wrapper(T&& x) {
    foo(std::forward<T>(x));
}

3.2.1. std::forward의 정체

개념적으로는:

template <typename T>
T&& forward(typename std::remove_reference<T>::type& x) {
    return static_cast<T&&>(x);
}

T가 lvalue reference인 경우 (T = U&):

std::forward(x) → static_cast<U&>(x) → lvalue로 캐스팅

T가 값 타입인 경우 (T = U):

std::forward(x) → static_cast<U&&>(x) → rvalue로 캐스팅

즉,

“T가 어떻게 deduce되었는지에 따라
x를 lvalue 또는 rvalue로 되돌려 보내는 캐스팅 함수”

그래서:

std::string s = "hi";

wrapper(s);
// T = std::string&  → std::forward<T>(x) == lvalue → foo(const std::string&)

wrapper(std::string("hi"));
// T = std::string   → std::forward<T>(x) == rvalue → foo(std::string&&)

→ 원래 인자의 value category(lvalue/rvalue)를 그대로 보존해서 forward하는 것
→ 그래서 perfect forwarding 이라고 부름.

3.3. Perfect Forwarding 패턴 템플릿

가장 흔한 패턴:

template <typename F, typename... Args>
decltype(auto) call_with_log(F&& f, Args&&... args) {
    std::cout << "calling...\n";
    return std::forward<F>(f)(
        std::forward<Args>(args)...   // 각각의 l/rvalue 성질 그대로 전달
    );
}

함수 객체 f와 인자 args...를 받아서

로깅을 찍고

원래대로 f(args...)를 호출하되,

각 인자가 lvalue인지 rvalue인지 그대로 유지해서 전달

std::function, 래퍼, 데코레이터, 이벤트 시스템, 커스텀 make_* 함수 등에서 많이 나오는 패턴이다

4. std::move vs std::forward 차이 정리

함수 하는 일 언제 쓰나
std::move(x) x를 무조건 rvalue(xvalue)로 캐스팅 “이 자원은 이제 이쪽으로 완전히 넘길게”
std::forward(x) T에 따라 lvalue 또는 rvalue로 조건부 캐스팅 템플릿에서 원래 인자의 value category 유지하여 전달

일반 코드(템플릿 아니거나, 항상 move하고 싶은 곳)에서는 → std::move

템플릿 래퍼, 팩토리, wrapper 함수에서 “원래 인자 상태 그대로 전달” 하고 싶으면 → std::forward + forwarding reference

5. 사용 예시 모음

5.1. my_swap 구현

template <typename T>
void my_swap(T& a, T& b) {
    T temp(std::move(a)); // a의 자원 통째로 temp로 이동
    a = std::move(b);     // b의 자원을 a로 이동
    b = std::move(temp);  // temp의 자원을 b로 이동
}

여기서는 a, b, temp가 전부 정확히 어떤 객체를 가리키는지 명확하고

“무조건 자원을 이동해도 된다”는 의도가 분명하므로 → std::move가 맞다

5.2. Factory 함수에서 perfect forwarding

template <typename T, typename... Args>
std::unique_ptr<T> make_my_unique(Args&&... args) {
    return std::unique_ptr<T>(
        new T(std::forward<Args>(args)...)
    );
}

std::make_unique랑 같은 패턴

인자가 lvalue면 lvalue 그대로, rvalue면 rvalue 그대로 T 생성자에 전달

5.3. Wrapper / Decorator 함수

template <typename Func, typename... Args>
decltype(auto) call_with_retry(Func&& f, Args&&... args) {
    for (int i = 0; i < 3; ++i) {
        try {
            return std::forward<Func>(f)(
                std::forward<Args>(args)...
            );
        } catch (...) {
            std::cout << "retry " << i << "\n";
        }
    }
    throw std::runtime_error("failed after 3 retries");
}

f가 함수 포인터든, 람다든 상관없이 받아서

args... 역시 lvalue/rvalue 그대로 유지해서 넘길 수 있음

<결론>
T&& (forwarding reference) + reference collapsing 규칙 + std::forward 조합 덕분에,
템플릿 함수는 인자의 lvalue/rvalue 성질을 그대로 유지해서 다른 함수로 전달할 수 있다.

만약 이 패턴이 없다면,
lvalue / rvalue / const / non-const 조합마다 별도의 오버로드를 전부 작성해야 해서
wrapper / decorator / factory / std::make_unique, emplace 같은 함수들은
사실상 구현 불가능에 가까운 수준으로 복잡해진다.

0개의 댓글