Devin.KR

값 범주와 완벽 전달 - lvalue·rvalue·forwarding reference

개발자KR 조회 11

이 장에서 배우는 것

작은 주문 처리 엔진은 주문을 접수하고, 필드를 검증하고, 체결 로그를 남긴다. 처음에는 주문을 복사해서 보관하는 것만으로 충분하다. 그러나 주문에 종목 문자열과 부가 정보가 붙으면, 호출자가 계속 사용할 주문과 처리를 마친 뒤 넘겨줄 주문을 구별할 필요가 생긴다. 두 경우를 같은 방식으로 받으면 불필요한 복사가 발생하거나, 호출자가 보존하려던 객체의 내용이 바뀔 수 있다.

이 장의 주제는 값 범주와 완벽 전달이다. 기본서에서 익힌 복사·이동 생성자에서 출발해, 어떤 표현식이 어느 생성자를 선택하게 하는지 살펴본다. 이어서 주문을 받는 함수가 호출자의 사용 의도를 다음 함수까지 전달하도록 만든다.

  • lvalue, prvalue, xvalue를 객체가 아니라 표현식의 성질로 구분한다.
  • std::move가 수행하는 변환과 실제 이동 연산을 구별한다.
  • 전달 참조와 std::forward로 복사와 이동의 선택을 유지한다.
  • emplace 계열의 직접 생성이 줄이는 작업과 남겨 두는 작업을 설명한다.
  • 이동 후 객체에 대해 보장된 상태만 사용하도록 코드를 작성한다.

문제 상황

접수 화면은 주문 초안을 엔진에 넘긴 뒤에도 화면에 표시한다. 따라서 엔진이 주문을 보관하더라도 초안의 내용은 유지되어야 한다. 반면 입력 처리기가 만든 전송용 주문은 엔진에 넘긴 뒤 더 이상 내용을 사용할 필요가 없다. 이 주문은 문자열 등의 자원을 옮길 수 있다. 두 경로 모두 접수 함수의 이름은 submit으로 유지하고 싶다.

Order draft{101, "ALPHA", 4};
engine.submit(draft);             // 접수 뒤에도 초안을 사용한다.

Order outgoing{102, "BETA", 7};
engine.submit(std::move(outgoing)); // 기존 내용을 넘겨도 된다.

접수 함수를 const Order&로만 받으면 두 호출 모두 저장 시 복사하게 된다. 반대로 Order&&로만 받으면 이름 있는 초안을 그대로 넘길 수 없다. 두 오버로드를 직접 작성하는 방법도 있지만, 이 장에서는 인자를 한 단계 더 전달할 때 필요한 규칙을 이해하는 데 초점을 맞춘다.

또 다른 입력 경로는 주문 객체 없이 번호, 종목, 수량만 제공한다. 이때는 완성된 주문을 함수 밖에서 만들었다가 컨테이너로 옮기는 대신, 컨테이너의 저장 위치에서 바로 생성할 수 있다. 이 차이를 관찰하기 위해 완성 코드에서는 주문의 복사 생성과 이동 생성 횟수를 센다.

값 범주는 표현식에 붙는다

타입은 표현식이 어떤 종류의 값을 다루는지 말한다. 값 범주(value category)는 그 표현식이 객체를 어떻게 나타내는지 구분한다. 같은 Order 객체도 이름으로 지칭할 때와 std::move를 적용한 표현식으로 지칭할 때의 범주가 다르다. 객체 자체가 별도의 이동용 객체로 바뀌는 것은 아니다.

lvalue는 객체나 함수의 정체성을 나타내며, 여기서 다루는 객체에 대해서는 보통 이름 있는 변수가 대표적이다. draft와 draft.symbol은 lvalue다. const 객체를 나타내는 표현식도 lvalue일 수 있으므로, 대입할 수 있는지 여부만으로 범주를 판별해서는 안 된다.

prvalue는 값을 계산하거나 초기화에 사용할 결과 객체를 만드는 표현식이다. 정수 리터럴 4, Order{103, "GAMMA", 2}, Order를 값으로 반환하는 함수의 호출 표현식이 이에 해당한다. 클래스 prvalue를 모두 “어딘가에 먼저 생긴 임시 객체”라고 생각하면 불필요한 이동을 예상하기 쉽다.

xvalue는 정체성이 있는 객체를 나타내면서 그 자원을 재사용할 수 있도록 취급하는 표현식이다. 이 장에서는 std::move(outgoing)이 대표적인 예다. prvalue와 xvalue를 합쳐 rvalue라고 한다. 또한 lvalue와 xvalue는 정체성을 나타낸다는 공통점으로 glvalue에 속한다.

주문 관련 표현식의 값 범주와 의미
표현식범주읽는 방법
draftlvalue이름으로 기존 주문을 지칭한다.
fixed, 선언이 const Order fixed{...};lvalue수정할 수 없어도 정체성이 있다.
Order{103, "GAMMA", 2}prvalue주문 결과 객체를 초기화한다.
std::move(outgoing)xvalue기존 주문을 자원 재사용 대상으로 나타낸다.
named, 선언이 Order&& named = ...;lvalue참조 타입과 별개로 이름 있는 표현식이다.
같은 주문을 지칭해도 이름은 lvalue이고 std::move를 적용한 표현식은 xvalue다

특히 마지막 행이 중요하다. 변수의 선언에 &&가 있다고 해서 그 변수를 사용하는 표현식까지 rvalue가 되지는 않는다. 함수 안에서 이름으로 접근하는 매개변수도 같은 규칙을 따른다. 이름을 붙여 여러 번 접근할 수 있는 표현식에서 자원이 저절로 빠져나가게 하지 않는 규칙이라고 이해하면 쓰임을 파악하기 쉽다.

결과 객체를 바로 초기화하는 경우

Order fresh = Order{103, "GAMMA", 2};

C++20에서 위 초기화는 같은 타입의 prvalue로 fresh를 직접 초기화한다. 별도의 주문을 만든 뒤 이동 생성자를 호출해야 하는 상황이 아니다. 반면 그 prvalue를 참조 매개변수에 바인딩하면 참조가 가리킬 임시 객체가 실체화된다. 그 임시 객체에서 컨테이너 원소를 생성하는 작업은 다시 별개다. 따라서 “임시 주문을 접수 함수에 넘겼으니 이동도 없다”는 결론은 성립하지 않는다.

이동을 허용하는 표현식과 이동 후 상태

std::move는 객체의 자원을 직접 옮기지 않는다. 주어진 객체를 xvalue로 나타내는 변환을 수행한다. 그 결과를 사용하는 생성자나 대입 연산자가 선택된 뒤에야 실제 복사 또는 이동이 일어난다. 반환된 표현식을 사용하지 않는다면 std::move만으로 원본의 문자열이 비워지지는 않는다.

Order first{201, "DELTA", 5};
Order second{std::move(first)};

두 번째 줄에서 std::move(first)는 이동 생성자가 선택될 수 있는 인자를 제공한다. 이어지는 second의 초기화가 실제 이동 생성자를 호출한다. 이동 생성자는 멤버별로 어떤 상태를 옮기고 원본에 무엇을 남길지 정한다. 사용자 정의 타입에서는 그 동작을 구현과 계약으로 확인해야 한다.

이동이 언제나 복사보다 싸다는 보장은 없다. 정수 멤버는 이동 생성자에서도 값을 복사할 수 있고, 짧은 문자열은 내부 저장 방식에 따라 문자들을 옮길 수 있다. 이 장에서 세는 것은 Order 생성자의 호출 횟수다. 문자열 내부의 문자 복사량이나 메모리 할당 횟수를 측정하는 것은 아니다.

const는 이동 요청으로 사라지지 않는다

std::move는 const를 제거하지 않는다. const Order에 적용하면 결과는 const 성질을 가진 xvalue다. 일반적인 이동 생성자의 매개변수 Order&&는 이를 받을 수 없다. 복사 생성자 Order(const Order&)가 있으면 복사가 선택될 수 있다.

const Order fixed{202, "EPSILON", 6};
Order copy{std::move(fixed)}; // 이 장의 Order에서는 복사 생성이다.

이 때문에 호출부의 std::move만 보고 이동 횟수를 계산해서는 안 된다. 인자의 const 여부, 사용할 수 있는 생성자, 실제 초기화 위치를 함께 봐야 한다. 이동 전용 타입에 같은 시도를 하면 복사로 대체할 수도 없으므로 컴파일에 실패할 수 있다.

이동 후에는 무엇을 사용할 수 있는가

표준 라이브러리 타입은 별도의 규정이 없는 한 이동 후에도 유효하지만 구체적인 값은 지정되지 않은 상태를 유지한다. 여기서 유효하다는 말은 타입의 불변식이 유지되고, 전제조건을 충족하는 연산을 사용할 수 있다는 뜻이다. 이전 값을 유지한다거나 비어 있다는 뜻은 아니다.

예를 들어 이동된 std::string은 파괴하거나 새 문자열을 대입할 수 있고, empty()나 size()로 현재 상태를 확인할 수 있다. 그러나 이전 내용이 남아 있다고 가정하거나, 길이를 확인하지 않고 첫 문자에 접근해서는 안 된다.

사용자 정의 타입에는 별도의 계약이 필요하다. 완성 코드의 이동 생성자는 원본 주문 번호와 수량을 0으로 설정한다. 따라서 그 두 필드는 이동 생성 후에도 예측할 수 있다. 종목 문자열의 내용은 따로 규정하지 않는다. 번호와 수량이 0인 주문은 C++ 객체로서는 사용 가능한 상태지만, 엔진이 접수할 수 있는 업무상 유효한 주문은 아니다.

전달 참조로 호출자의 선택을 이어 간다

전달 참조(forwarding reference)는 템플릿 인자 추론과 결합해 lvalue와 rvalue를 모두 받는 참조다. 대표 형태는 함수 템플릿에서 추론되는, const로 한정되지 않은 타입 매개변수 T에 붙은 T&&다. 다음 함수의 order가 이에 해당한다.

template<class T>
bool submit(T&& order) {
    if (!valid(order.id, order.symbol, order.quantity)) {
        return false;
    }
    orders_.emplace_back(std::forward<T>(order));
    return true;
}

Order lvalue를 넘기면 T는 Order&로 추론된다. 이때 T&&에 나타나는 참조의 조합은 참조 축약(reference collapsing) 규칙에 따라 Order&가 된다. 참조끼리 결합할 때 어느 한쪽이라도 &이면 결과는 &이고, 둘 다 &&일 때만 &&가 남는다.

Order rvalue를 넘기면 T는 Order로 추론되고 매개변수 타입은 Order&&가 된다. 하지만 두 경우 모두 함수 본문의 표현식 order는 lvalue다. 따라서 이름만 다음 함수에 넘기면 rvalue로 들어왔다는 정보가 호출에 반영되지 않는다.

std::forward<T>(order)는 추론된 T를 이용한다. T가 lvalue 참조라면 lvalue로, 그렇지 않으면 xvalue로 전달한다. 이런 방식으로 호출자가 넘긴 인자의 lvalue 또는 rvalue 성질과 const 성질을 이어 가는 기법을 완벽 전달(perfect forwarding)이라고 한다.

이 용어가 원래 표현식의 모든 특징을 재현한다는 뜻은 아니다. prvalue로 들어온 주문도 참조에 바인딩되어 함수 안에서는 객체로 접근하며, std::forward 결과는 xvalue다. 여기서 유지하려는 핵심은 뒤쪽 함수가 복사와 이동을 선택하는 데 필요한 성질이다.

submit의 인자에 따라 달라지는 추론과 저장 방식
호출 인자추론된 Tforward 결과Order 저장
draftOrder&lvalue복사 생성
std::move(outgoing)Orderxvalue이동 생성
Order{...}Orderxvalue이동 생성
const 주문 변수const Order&const lvalue복사 생성
이름 있는 매개변수는 항상 lvalue이며 std::forward가 추론된 타입에 따라 전달 범주를 선택한다

모든 T&&가 전달 참조인 것은 아니다. const T&&는 위 조건에 맞지 않는다. 또한 클래스 템플릿의 타입 매개변수가 이미 정해진 뒤 그 타입에 &&를 붙인 멤버 함수 매개변수도, 그 호출에서 타입을 추론하는 전달 참조가 아니다. 구체적인 타입으로 쓴 Order&&는 일반적인 rvalue 참조다.

emplace는 저장 위치에서 생성한다

push_back은 이미 만들어진 원소를 복사하거나 이동해서 추가한다. emplace_back은 전달받은 인자로 컨테이너 안의 원소를 생성한다. 주문 생성자가 번호, 문자열, 수량을 받는다면 그 인자들을 직접 전달할 수 있다.

orders.push_back(Order{301, "ZETA", 8});
orders.emplace_back(302, "ETA", 9);

재할당이 없다는 조건에서 첫 줄은 임시 Order를 생성한 뒤 벡터 원소로 이동 생성한다. 두 번째 줄은 벡터 원소를 해당 생성자로 직접 초기화한다. 생략되는 것은 중간 Order 객체와 그 객체에서의 이동이다. 문자열 인자의 변환이나 생성자 내부의 멤버 초기화까지 모두 사라지는 것은 아니다.

emplace_back(existing)처럼 기존 주문 lvalue를 주면 복사 생성자가 호출된다. 또한 벡터의 용량이 부족하면 기존 원소들을 새 저장 공간으로 옮기는 작업이 추가될 수 있다. 완성 코드는 네 개의 주문을 저장하기 전에 용량을 확보해, 새 주문을 넣는 방식에 따른 차이만 관찰한다.

완성 코드

다음 내용을 main.cpp에 저장한다. 주문 번호는 양수, 종목은 빈 문자열이 아닌 값, 수량은 양수여야 한다. 검증 실패는 false로 반환하고, 접수한 주문은 모두 전량 체결한 것으로 간주해 로그를 남긴다. 주석의 번호는 이어지는 해설에서 코드 위치를 찾는 기준이다.

#include <iostream>
#include <string>
#include <utility>
#include <vector>

struct Order {
    int id;
    std::string symbol;
    int quantity;

    // [01] Order의 생성자 호출만 센다.
    inline static int copy_constructs = 0;
    inline static int move_constructs = 0;

    // [02] 개별 필드로 주문을 만든다.
    Order(int number, std::string name, int amount)
        : id(number), symbol(std::move(name)), quantity(amount) {}

    // [03] 복사는 원본의 필드를 유지한다.
    Order(const Order& other)
        : id(other.id), symbol(other.symbol), quantity(other.quantity) {
        ++copy_constructs;
    }

    // [04] 이동 생성 후 원본 번호와 수량은 0이다.
    Order(Order&& other) noexcept
        : id(std::exchange(other.id, 0)),
          symbol(std::move(other.symbol)),
          quantity(std::exchange(other.quantity, 0)) {
        ++move_constructs;
    }

    // [05] 대입은 계측 대상에 포함하지 않는다.
    Order& operator=(const Order&) = default;
    Order& operator=(Order&&) noexcept = default;
    ~Order() = default;
};

class OrderEngine {
public:
    // [06] 이 예제에서 접수할 주문 네 개의 공간을 확보한다.
    OrderEngine() {
        orders_.reserve(4);
    }

    // [07] 이 인터페이스의 사용 대상은 Order다.
    template<class T>
    bool submit(T&& order) {
        if (!valid(order.id, order.symbol, order.quantity)) {
            return false;
        }

        // [08] 검증에 성공한 뒤에만 저장한다.
        orders_.emplace_back(std::forward<T>(order));
        return true;
    }

    // [09] Order 임시 객체 없이 필드로 직접 생성한다.
    bool emplace(int id, std::string symbol, int quantity) {
        if (!valid(id, symbol, quantity)) {
            return false;
        }

        orders_.emplace_back(id, std::move(symbol), quantity);
        return true;
    }

    // [10] 저장 순서대로 체결 로그를 출력한다.
    void print_execution_log() const {
        for (const Order& order : orders_) {
            std::cout << "체결 " << order.id << ' '
                      << order.symbol << ' '
                      << order.quantity << '\n';
        }
    }

private:
    static bool valid(int id, const std::string& symbol, int quantity) {
        return id > 0 && !symbol.empty() && quantity > 0;
    }

    std::vector<Order> orders_;
};

int main() {
    OrderEngine engine;

    // [11] 호출 후에도 사용할 초안은 lvalue로 전달한다.
    Order draft{101, "ALPHA", 4};
    if (!engine.submit(draft)) {
        return 1;
    }

    // [12] 기존 내용이 필요 없는 주문은 이동을 허용한다.
    Order outgoing{102, "BETA", 7};
    if (!engine.submit(std::move(outgoing))) {
        return 1;
    }

    // [13] 임시 주문도 접수 함수를 거쳐 이동 생성된다.
    if (!engine.submit(Order{103, "GAMMA", 2})) {
        return 1;
    }

    // [14] 필드만 있다면 저장 위치에서 주문을 만든다.
    if (!engine.emplace(104, "DELTA", 5)) {
        return 1;
    }

    // [15] 검증 실패 경로에서는 원본에서 이동하지 않는다.
    Order invalid{105, "OMEGA", 0};
    const bool accepted = engine.submit(std::move(invalid));

    engine.print_execution_log();

    // [16] 상태가 보장된 필드만 관찰한다.
    std::cout << "초안 " << draft.id << ' '
              << draft.symbol << ' ' << draft.quantity << '\n';
    std::cout << "이동 원본 " << outgoing.id << ' '
              << outgoing.quantity << '\n';
    std::cout << std::boolalpha << "거절 주문 접수 " << accepted << '\n';
    std::cout << "거절 원본 " << invalid.id << ' '
              << invalid.symbol << ' ' << invalid.quantity << '\n';
    std::cout << "복사 생성 " << Order::copy_constructs << '\n';
    std::cout << "이동 생성 " << Order::move_constructs << '\n';
}

줄별 해설

[01] 계측 범위를 정한다. 두 정적 멤버는 모든 주문이 공유하는 생성 횟수다. 프로그램이 시작할 때 0으로 초기화된다. 이 예제는 한 실행 흐름에서만 동작한다. 여러 스레드가 동시에 생성 횟수를 갱신하는 상황은 다루지 않는다.

[02] 필드 생성자는 문자열을 값으로 받는다. 매개변수 name은 함수가 소유한 문자열이며 멤버를 초기화한 뒤에는 내용이 필요 없다. 따라서 std::move(name)으로 멤버의 이동 초기화를 허용한다. 이 작업은 std::string의 이동이며 Order::move_constructs에는 포함되지 않는다.

[03] 복사 생성자는 세 필드를 복사한다. other를 const 참조로 받으므로 복사 중에 원본을 변경하지 않는다. 문자열 복사가 성공하고 생성자 본문에 들어왔을 때 복사 생성 횟수를 하나 늘린다.

[04] 이동 생성자는 원본 상태를 일부 명시한다. std::exchange(other.id, 0)는 기존 번호를 반환하면서 원본 번호를 0으로 바꾼다. 수량도 같은 방식으로 처리한다. 멤버는 선언 순서대로 초기화되며, 종목 문자열은 이동 생성한다. 여기서 사용하는 문자열 이동 생성과 정수 처리는 예외를 던지지 않으므로 이동 생성자를 noexcept로 선언한다.

[05] 대입 연산은 별도의 동작이다. 기본 정의된 대입 연산자는 생성 횟수를 늘리지 않는다. 특히 기본 정의된 이동 대입은 정수 원본을 0으로 만들지 않는다. 이 타입이 약속하는 “원본 번호와 수량이 0”이라는 상태는 [04]의 이동 생성에 관한 계약이다. 이동 생성과 이동 대입의 원본 상태를 동일하게 맞추려면 대입도 그 계약에 맞게 구현해야 한다.

[06] 용량과 원소 수를 구별한다. reserve(4)는 네 주문을 생성하지 않는다. 원소 수가 0인 벡터에 저장 공간을 확보한다. 이후 성공하는 네 번의 접수에서는 재할당이 일어나지 않으므로 기존 원소의 이동이 계측 결과에 섞이지 않는다.

[07] 매개변수 이름으로 검증한다. order는 어떤 범주로 들어왔든 본문에서는 lvalue다. 여기서는 그 성질을 그대로 이용해 필드를 읽는다. 이 함수는 Order용으로 작성했으며, 임의의 타입에 대해 사용할 수 있는 범용 인터페이스를 약속하지 않는다.

[08] 실제 저장 지점에서 전달한다. lvalue로 받은 주문은 복사 생성하고, 수정 가능한 rvalue로 받은 주문은 이동 생성한다. std::forward를 검증 전에 적용할 필요는 없다. 값을 읽는 단계와 자원을 넘길 수 있는 단계를 코드에서 구별하는 편이 호출 후 상태를 이해하기 쉽다.

[09] 필드 접수는 다른 생성 경로다. 지역 문자열 symbol을 검증하고 나서 생성자 인자로 전달한다. 컨테이너 안에서 Order(int, std::string, int)가 직접 호출되므로 주문의 복사·이동 생성 횟수는 늘지 않는다. 문자열 매개변수와 멤버를 준비하는 작업은 여전히 존재한다.

[10] 로그 출력은 참조로 순회한다. 반복 변수는 const Order&이므로 로그를 출력할 때 주문을 복사하지 않는다. 이 예제의 체결 로그는 저장된 주문 전체를 표시하는 단순한 모형이며, 출력할 때 주문을 제거하거나 체결 상태를 갱신하지는 않는다.

[11]부터 [14]까지 네 경로를 비교한다. 초안은 복사 한 번, 전송용 주문은 이동 한 번, 임시 주문도 이동 한 번을 발생시킨다. 필드 직접 접수는 둘 다 발생시키지 않는다. 초안과 전송용 주문 자체를 처음 만드는 일은 필드 생성자의 호출이므로 이 두 계수에 들어가지 않는다.

[15] 이동 허용과 실제 이동을 구별한다. 호출부는 이동을 허용했지만 수량 검증에서 실패하므로 저장 구문에 도달하지 않는다. invalid는 수정되지 않는다. 다만 이 함수의 false는 필드 검증 실패만 뜻한다. 저장 과정의 메모리 할당이나 문자열 복사에서 발생하는 예외까지 false로 변환하는 인터페이스는 아니다.

[16] 출력은 계약에 맞춰 선택한다. 이동된 outgoing의 종목 문자열은 출력하지 않고, 명시적으로 0을 대입한 번호와 수량만 출력한다. 반면 검증에서 거절된 주문은 이동되지 않았으므로 세 필드를 모두 원래 값으로 관찰할 수 있다.

실행 결과

macOS 또는 Linux에서 C++20을 지원하는 컴파일러로 다음 명령을 사용한다. 제시한 프로그램은 지정한 경고 옵션에서 경고 없이 컴파일되도록 작성했다.

c++ -std=c++20 -Wall -Wextra -pthread main.cpp -o order_engine
./order_engine

정상적으로 실행되면 다음을 출력한다.

체결 101 ALPHA 4
체결 102 BETA 7
체결 103 GAMMA 2
체결 104 DELTA 5
초안 101 ALPHA 4
이동 원본 0 0
거절 주문 접수 false
거절 원본 105 OMEGA 0
복사 생성 1
이동 생성 2

이동 생성 두 번 중 하나는 이름 있는 전송용 주문에서, 다른 하나는 접수 함수의 참조 매개변수에 바인딩된 임시 주문에서 발생한다. 임시 주문을 쓰는 것과 컨테이너 원소를 직접 생성하는 것은 서로 다른 경로다. reserve를 제거하면 용량 증가 과정에서 추가 생성이 발생할 수 있으므로 이 출력의 횟수를 그대로 기대해서는 안 된다.

실무에서 자주 틀리는 것

전달 참조에 무조건 std::move를 적용한다

다음은 호출자가 초안을 lvalue로 넘겨도 원본에서 이동할 수 있는 잘못된 구현이다. 아래 예시는 접수 함수의 저장 구문만 비교한다.

// 잘못된 코드
template<class T>
void store(T&& order) {
    orders_.emplace_back(std::move(order));
}

// 고친 코드
template<class T>
void store(T&& order) {
    orders_.emplace_back(std::forward<T>(order));
}

전달 참조에서 std::move를 쓰면 추론에 기록된 호출자의 범주를 무시한다. 호출자가 원본 보존을 기대할 수 있는 인터페이스라면 std::forward<T>로 선택을 이어 가야 한다. 반대로 함수가 소유한 지역 객체를 더 이상 사용하지 않는 지점에서는 std::move가 알맞을 수 있다.

이름 있는 rvalue 참조를 그대로 넘기면 이동한다고 생각한다

아래 함수는 일반적인 rvalue 참조를 받지만, 잘못된 구현의 저장 구문에서는 복사가 일어난다. order라는 이름을 사용하는 표현식은 lvalue이기 때문이다.

// 잘못된 코드: 이동해서 저장하려는 의도와 다르다.
void store_owned(Order&& order) {
    orders_.push_back(order);
}

// 고친 코드
void store_owned(Order&& order) {
    orders_.push_back(std::move(order));
}

이 함수의 매개변수는 전달 참조가 아니다. 함수가 이동 가능한 주문만 받아 소비하도록 설계되었으므로, 저장 지점에서 이동 의도를 명시한다. &&를 발견했을 때 선언의 타입과 본문 표현식의 범주를 나누어 읽어야 한다.

emplace에 임시 주문을 전달하고 직접 생성했다고 판단한다

다음 코드는 실행할 수 있지만, 주문의 중간 생성을 없애려는 목적에는 맞지 않는다.

// 잘못된 코드: 임시 Order에서 다시 이동 생성한다.
orders.emplace_back(Order{401, "THETA", 3});

// 고친 코드: 생성자 인자를 직접 넘긴다.
orders.emplace_back(401, "THETA", 3);

emplace라는 이름 자체가 복사나 이동을 없애는 것은 아니다. 어떤 인자를 전달했는지가 중요하다. 기존 주문 객체를 보관하려는 경우에는 push_back도 의도를 잘 드러내며, 생성자 인자가 손에 있을 때 직접 생성의 차이가 나타난다.

이동된 문자열이 비어 있다고 단정한다

다음 조건문은 이동된 원본이 빈 문자열일 것이라는 가정에 의존한다. 문자열이 비어 있지 않은 유효한 상태로 남아도 이동 실패로 잘못 보고한다.

// 잘못된 코드
std::string source = "IOTA";
std::string destination = std::move(source);
if (!source.empty()) {
    std::cout << "이동 실패\n";
}

// 고친 코드: 목적지의 값과 원본 재사용을 따로 다룬다.
std::string next_source = "IOTA";
std::string next_destination = std::move(next_source);
std::cout << next_destination << '\n';
next_source = "KAPPA";
std::cout << next_source << '\n';

이동의 결과를 확인할 때는 목적지에 보장되는 값을 확인한다. 원본을 재사용할 때는 새 값을 대입하는 등 해당 타입이 허용하는 연산을 수행한다. 원본을 의도적으로 빈 상태로 만들 필요가 있다면 그 요구를 별도의 연산이나 사용자 정의 타입의 계약으로 표현해야 한다.

한눈에 보기

표현식과 저장 의도에 맞는 도구 선택
상황표현 또는 도구확인할 점
이름 있는 주문을 계속 사용한다.lvalue로 전달완성 코드에서는 저장 시 복사한다.
주문의 기존 내용을 넘겨도 된다.std::move(order)변환만 수행하며 const를 제거하지 않는다.
추론된 참조 매개변수를 다시 전달한다.std::forward<T>(order)이름 있는 매개변수 자체는 lvalue다.
원소의 생성자 인자를 가지고 있다.emplace_back(args...)기존 원소의 재할당 비용은 별개다.
이동된 객체를 다시 사용한다.계약 확인 후 대입·관찰유효한 상태가 이전 값의 유지를 뜻하지 않는다.
생성 횟수로 동작을 확인한다.복사·이동 생성자 계측내부 할당량이나 실행 시간과 구별한다.

주문을 넘기는 코드를 읽을 때는 세 지점을 차례로 확인한다. 호출 인자는 어떤 범주인가, 함수 안에서 어떤 표현식으로 다시 전달하는가, 최종 저장 위치에서 어떤 생성자가 선택되는가를 확인한다. 이 흐름을 따라가면 &&나 함수 이름만으로 동작을 추측하는 일을 줄일 수 있다.

다음 장에서는 직접 만드는 RAII 타입으로 파일 핸들과 잠금 가드를 다룬다. 그때도 자원을 가진 객체의 수명과, 그 객체를 이동 대상으로 나타내는 표현식은 구별해야 한다. 이동 가능성은 소유권 이전을 구현하는 수단이며, 실제 자원 해제 책임은 타입이 정하는 계약이다.

연습 문제

  1. 다음 코드에서 표현식 base, alias, std::move(alias), Order{502, "LAMBDA", 2}의 값 범주를 각각 적는다. alias를 선언하는 줄에서 이동 생성자가 호출되는지도 설명한다.

    Order base{501, "KAPPA", 1};
    Order&& alias = std::move(base);
  2. 완성 코드의 outgoing 선언에 const를 붙이고 나머지를 그대로 둔다. 복사·이동 생성 횟수와 이동 원본 출력이 어떻게 바뀌는지 예측한다.

  3. 완성 코드의 임시 주문 접수를 engine.emplace(103, "GAMMA", 2)로 바꾼다. 체결 로그와 생성 횟수 중 무엇이 바뀌는지 설명한다. 이 결과만으로 문자열 할당이 없다고 결론 내릴 수 있는지도 답한다.

  4. 검증을 먼저 하는 현재 구현에서 std::move(invalid)를 넘겨도 원본이 유지되는 이유를 설명한다. 이어서 검증 전에 Order candidate(std::forward<T>(order));를 생성하는 구현으로 바꾸면, 수정 가능한 rvalue 주문이 거절될 때 어떤 차이가 생기는지 설명한다.

정답과 해설

  1. base와 alias는 lvalue, std::move(alias)는 xvalue, Order{502, "LAMBDA", 2}는 prvalue다. alias의 선언은 기존 base에 참조를 바인딩하므로 새 주문 객체를 만들지 않는다. 이동 생성자도 호출하지 않는다. alias를 통해 접근하는 객체는 여전히 base다.

  2. 복사 생성은 2회, 이동 생성은 1회가 된다. 전달 참조의 T는 const Order로 추론되고 std::forward 결과도 const 성질을 유지한다. Order&&를 받는 이동 생성자를 사용할 수 없어 복사 생성자가 선택된다. 원본은 수정되지 않으므로 출력은 이동 원본 102 7이 된다. 그 밖의 출력은 같다.

  3. 체결 로그는 그대로이며 복사 생성 1회, 이동 생성 1회가 된다. 103번 주문을 임시 객체에서 이동하는 단계가 사라졌기 때문이다. 문자열은 여전히 생성자 인자로 준비되고 멤버에 저장된다. 계측 대상이 주문 생성자뿐이므로 문자열 할당의 유무나 횟수를 이 결과로 판단할 수는 없다.

  4. 현재 구현은 필드만 읽어 검증한 뒤, 실패하면 전달과 저장을 수행하지 않고 반환한다. 호출부의 std::move 자체는 원본을 수정하지 않는다. 반면 검증 전에 candidate를 생성하면 수정 가능한 rvalue 인자에서 실제 이동 생성이 먼저 일어난다. 이후 검증에 실패하더라도 원본의 번호와 수량은 이미 0이다. “검증에서 거절된 주문은 그대로 남는다”라는 현재 구현의 성질을 유지하려면 원본을 소비하는 작업보다 검증을 앞에 두어야 한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.