내일배움캠프/TIL

TIL260601 - CPP - Move Semantic

옆집히드라 2026. 6. 1. 19:41

1. 핵심 개념


  • Move Semantic

2. 상세 내용


2.1. C++ Value Categories

cpp에서의 식(expression)은 타입(type)과 식 범주(value category)라는 2개의 속성으로 특성을 부여할 수 있다. 식 범주는 lvalue, xvalue, prvalue 셋 중 하나로 나타난다.

  • identity: 식별 가능한가
  • moveable: 이동 가능한가(소유권)

위와 같이 2가지 기준을 바탕으로 다음과 같이 밸류를 나눌 수 있다.

  • lvalue: 식별 가능하며(Identity O), 이동 불가능한(Movable X) 값. 메모리 주소상에 명확한 실체가 존재함.
  • prvalue: 식별 불가능하며(Identity X), 이동 가능한(Movable O) 순수한 임시 값. 리터럴이나 연산 결과처럼 메모리상 고정된 실체가 없음.
  • xvalue: 식별 가능하면서(Identity O), 동시에 이동도 가능한(Movable O) 값. 메모리에 실체는 존재하지만 소유권을 이전시킬 수 있어 rvalue의 일종으로 취급됨; std::move(some_lvalue)의 결과
  • glvalue (lvalue ∪ xvalue): '식별 가능한' 모든 값의 통칭.
  • rvalue (prvalue ∪ xvalue): '이동 가능한' 모든 값의 통칭.

2.2. 사용 예시

생성자 정의

  1. 기본 생성자
  2. 복사 생성자: 복사할 원본 유지 + 깊은 복사
  3. 복사 대입 생성자: 기존 데이터 제거 + 복사할 원본 유지 + 깊은 복사
  4. 이동 생성자: 소유권 이전
  5. 이동 대입 생성자: 기존 데이터 제거 + 소유권이전
#include <iostream>
#include <utility> // std::move, std::swap  

class MemoryBlock
{
private:
    int* data;

    size_t size;

public:
    // 1. 기본 생성자
    MemoryBlock(size_t s = 0) : size(s), data(s ? new int[s]() : nullptr)
    {
        std::cout << "기본 생성자 호출\n";
    }  

    // 2. 소멸자
    ~MemoryBlock()
    {
        delete[] data;
        std::cout << "소멸자 호출\n";
    } 

    // 3. 복사 생성자 (원본 보존, 깊은 복사)
    MemoryBlock(const MemoryBlock& other) : size(other.size), data(new int[other.size])
    {
        for (size_t i = 0; i < size; ++i)
        {
            data[i] = other.data[i];
        }
        std::cout << "복사 생성자 호출 (Deep Copy)\n";
    } 

    // 4. 이동 생성자 (소유권 이전, 얕은 복사 후 원본 초기화)
    // noexcept: 이동 중에는 예외가 발생하지 않음을 컴파일러에 보증 (STL 최적화에 필수)
    // 만약 예외 나면 자비없이 std::terminate()의 대가를 치른다 ㄷㄷ
    MemoryBlock(MemoryBlock&& other) noexcept : size(other.size), data(other.data)
    {
        other.size = 0;
        other.data = nullptr; // 원본의 포인터를 끊어 이중 해제 방지
        std::cout << "이동 생성자 호출 (Move)\n";
    } 

    // 5. 복사 대입 연산자 (기존 자원 버리고, 깊은 복사)
    MemoryBlock& operator=(const MemoryBlock& other)
    {
        if (this != &other)
        { // 자기 자신에 대한 대입 방지 (A = A;)
            delete[] data;    // 내 기존 자원 버리기 

            size = other.size;
            data = new int[size];

            for (size_t i = 0; i < size; ++i)
            {
                data[i] = other.data[i];
            }
            std::cout << "복사 대입 연산자 호출\n";
        }

        return *this;
    } 

    // 6. 이동 대입 연산자 (기존 자원 버리고, 소유권 뺏어오기)
    MemoryBlock& operator=(MemoryBlock&& other) noexcept
    {
        if (this != &other)
        {
            delete[] data;     // 1. 내 기존 자원 버리기 

            data = other.data; // 2. 자원 가로채기
            size = other.size; 

            other.data = nullptr; // 3. 원본 무력화
            other.size = 0;
            std::cout << "이동 대입 연산자 호출\n";
        }
        return *this;
    }
};

int main()

{

    MemoryBlock mb = MemoryBlock(); // 기본 생성자 호출
    MemoryBlock mb2 = MemoryBlock(mb); // 복사 생성자 호출 - 원본 유지 깊은 복사
    MemoryBlock mb3 = std::move(mb2); // 이동 생성자 호출 - 소유권 이전

    mb3 = mb; // 복사 대입 생성자 호출 - 기존 자원 제거 + 원본 유지 깊은복사

    mb3 = std::move(mb); // 이동 대입 생성자 호출 - 기존 자원 제거 + 소유권 이전



    return 0;

}

&& 타입으로 다루는 예시

&&intfloat처럼 독립적인 타입이 아니라, 포인터(*)나 좌측값 참조(&)처럼 기존 타입에 붙어서 특성을 부여하는 한정자이다.(rvalue만 가리킬 수 있는 변수)

오버로딩 길찾기 (가장 일반적인 용도)

컴파일러가 인자의 식 범주(Value Category)를 보고 알아서 최적의 함수를 찾아가게 만드는 용도

#include <iostream>
#include <string>

// lvalue를 받는 함수 (복사가 일어날 수 있음)
void process(const std::string& str) {
    std::cout << "lvalue 처리 (복사용): " << str << "\n";
}

// rvalue를 받는 함수 (소유권을 탈취할 수 있음)
void process(std::string&& str) {
    std::cout << "rvalue 처리 (이동용): " << str << "\n";
}

int main() {
    std::string text = "Hello";

    process(text);             // text는 lvalue -> const std::string& 버전 호출
    process(text + " World");  // 연산 결과는 임시값(prvalue) -> std::string&& 버전 호출
    process(std::move(text));  // 강제로 xvalue로 캐스팅 -> std::string&& 버전 호출

    return 0;
}

지역 변수로서의 && 타입 (수명 연장)

우측값 참조 타입으로 지역 변수를 선언하면, 곧 사라질 임시 객체(prvalue)의 수명을 해당 변수의 스코프가 끝날 때까지 억지로 연장할 수 있다.

#include <iostream>

class Temp {
public:
    Temp() { std::cout << "생성\n"; }
    ~Temp() { std::cout << "소멸\n"; }
};

Temp getTemp() {
    return Temp(); // prvalue 반환
}

int main() {
    std::cout << "--- 일반 호출 ---\n";
    getTemp(); // 생성 -> 이 줄이 끝나자마자 즉시 소멸

    std::cout << "--- && 타입으로 받기 ---\n";
    // rRef라는 우측값 참조 '타입' 변수에 임시 객체를 바인딩
    Temp&& rRef = getTemp(); 

    std::cout << "아직 소멸 안됨!\n";
    // main 함수가 끝날 때 비로소 rRef가 가리키는 객체가 소멸됨

    return 0;
}

템플릿에서의 && (보편 참조 / Forwarding Reference)

템플릿 매개변수 T&&가 붙으면, 이는 단순한 우측값 참조가 아니라 들어오는 값의 카테고리(lvalue인지 rvalue인지)를 그대로 보존하는 특수한 타입으로 바뀐다. (Perfect Forwarding)

예외적인 문법으로 템플릿에서 인자를 추론할 때 lvalue가 들어오면 T를 Type&(참조형)로, rvalue로 들어오면 T를 Type(일반형)으로 추론한다.

#include <iostream>
#include <utility>

template <typename T>
void wrapper(T&& arg) { // 여기서 T&&는 lvalue와 rvalue를 모두 받을 수 있는 마법의 타입입니다.
    // 들어온 형태 그대로 다른 함수로 넘겨줌 (std::forward 사용)
    process(std::forward<T>(arg)); 
}

[!NOTE] 학습 결론
생성자는 기본, 복사, 이동, 복사 대입, 이동 대입 5가지가 있고 이동생성자는 소유권의 이전을 나타내는 문법이다. &&를 받도록 정의된 메서드는 rvalue로 가져온 객체의 소유권을 받을 수 있음을 의미한다.

3. 질문 및 해결 (Q&A)


  • 왜 move semantic 개념이 필요할까? 기존의 포인터를 명시적으로 이전하는 것으로도 충분히 move semantic의 개념을 수행할 수 있는데 문법적인 특성으로까지 분화한 이유가 뭘까?
    -> 단순히 포인터의 할당과 해제를 떠나서 소유권의 이전 같은 구현을 언어 문법 차원에서 강제해서 그머가 포인터를 직접 다루지 않고 이동 생성자만(Move-Only) 열어두는 설계를 해서 안전하게 자원의 소권을 이동할 수 있게 함. 또한 컴파일러가 lvalue, rvalue를 구분함으로써 반환값의 깊은 복사 같은 문제를 컴파일러 차원에서 최적화할 수 있게 됨.

3.1. 참고

(1) lvalue와 prvalue의 구분 (복사 vs 자동 이동)

#include <iostream>
#include <string>

int main() {
    std::string name = "C++"; // "C++"은 prvalue(리터럴), name은 lvalue(이름과 실체가 있음)

    // 1. lvalue의 전달 -> '복사(Copy)' 발생
    std::string copy_name = name; 
    // name은 이 줄 이후에도 계속 살아서 쓰여야 하므로(Movable X), 깊은 복사가 일어남.

    // 2. prvalue의 전달 -> '이동(Move)' 발생
    std::string temp_name = name + "11"; 
    // (name + "11")의 연산 결과는 이름 없는 임시 객체(prvalue).
    // 어차피 식을 넘어가면 소멸할 값이므로(Movable O), 컴파일러가 알아서 자원을 훔쳐옴.

    return 0;
}

(2) 함수 반환 시의 응용 (값 반환의 최적화)

#include <vector>

std::vector<int> makeHugeData() {
    std::vector<int> temp(1000000, 1); // 100만 개짜리 벡터 생성 (lvalue)

    return temp; 
    // 반환되는 순간 temp는 스코프를 벗어나며 소멸할 운명임.
    // 최신 C++ 컴파일러는 이를 인지하고(temp를 xvalue처럼 취급) 100만 번의 깊은 복사 대신, 
    // 내부 데이터의 소유권(포인터)만 호출부로 던지는 '이동'을 수행해 성능 저하를 막아냄.
}

int main() {
    std::vector<int> result = makeHugeData(); // 성능 저하 없이 100만 개 데이터 수신 완료
    return 0;
}
  1. STL 알고리즘 개선
// 두 객체의 값을 바꿀 때 (std::swap의 내부 구현)
template <typename T>
void swap(T& a, T& b) {
    T temp = a;  // C++98: 깊은 복사 발생
    a = b;       // C++98: 깊은 복사 발생
    b = temp;    // C++98: 깊은 복사 발생
}

위 예시에서는 깊은 복사가 3번에 걸쳐 발생

template <typename T>
void swap(T& a, T& b) {
    T temp = std::move(a); // 포인터 3번 이동으로 끝.
    a = std::move(b);      // 메모리 할당 0, 복사 0
    b = std::move(temp); 
}

4. 관련 문서 (Links)


'내일배움캠프 > TIL' 카테고리의 다른 글

TIL260604 - Unreal  (0) 2026.06.04
TIL260602 - 개인  (0) 2026.06.02
TIL260529  (0) 2026.05.29
TIL260528 - 챕터2 프로젝트 - 2  (0) 2026.05.28
TIL260526 - CPP  (0) 2026.05.26