본문 바로가기
개발/C++ (98,03,11,14,17,20,23)

Modern C++ : 복사 및 이동 의미론 (Copy and Move Semantics) (98, 11)

by snowoods 2025. 9. 22.

Modern C++

 

복사 및 이동 의미론 (Copy and Move Semantics)

 

개요

C++에서 객체를 다룰 때, 객체의 데이터를 복사하거나 소유권을 이전하는 방식은 매우 중요합니다. 특히 동적 할당된 메모리와 같은 리소스를 관리하는 클래스의 경우, 이러한 동작을 어떻게 정의하느냐에 따라 프로그램의 성능과 안정성이 크게 달라집니다.

  • 복사 의미론(Copy Semantics): 기존 객체의 내용을 그대로 복사하여 새로운 객체를 생성하거나 다른 객체에 대입합니다. 원본과 사본은 독립적인 리소스를 가집니다.
  • 이동 의미론(Move Semantics): 기존 객체(주로 임시 객체)의 리소스 소유권을 새로운 객체로 '이동'시킵니다. 불필요한 데이터 복사를 피할 수 있어 성능이 크게 향상됩니다. 원본 객체는 리소스를 잃고 비어 있는(valid but unspecified) 상태가 됩니다.

이러한 동작은 컴파일러가 자동으로 생성하는 특별 멤버 함수들에 의해 제어되며, 필요에 따라 사용자가 직접 정의할 수 있습니다. 이를 'Rule of Three', 'Rule of Five', 'Rule of Zero'와 같은 규칙으로 설명하기도 합니다.

 

C++ 버전별 주요 키워드 도입 시기

  • C++98/03: 복사 생성자, 복사 대입 연산자, 소멸자를 중심으로 한 'Rule of Three'가 일반적이었습니다.
  • C++11: Rvalue 참조(&&)와 std::move가 도입되면서 이동 의미론이 표준화되었습니다. 이로 인해 'Rule of Five'라는 개념이 등장했습니다.

 

내용 설명

The Rule of Five (5의 규칙)

클래스가 리소스를 관리할 때, 다음 다섯 가지 특별 멤버 함수 중 하나를 사용자가 직접 정의해야 한다면, 나머지 네 가지도 모두 직접 정의하거나 = default, = delete를 이용해 컴파일러의 동작을 명시적으로 제어해야 한다는 지침입니다.

  1. 소멸자 (Destructor): ~Matrix()
    • 객체가 소멸될 때 호출됩니다. 주로 동적 할당된 메모리 해제와 같은 리소스 정리 작업을 수행합니다.
  2. 복사 생성자 (Copy Constructor): Matrix(const Matrix &other)
    • 같은 타입의 다른 객체를 인자로 받아 새로운 객체를 생성할 때 호출됩니다. (예: Matrix m2 = m1;)
    • 깊은 복사(deep copy)를 통해 원본과 완전히 독립된 사본을 만듭니다.
  3. 복사 대입 연산자 (Copy Assignment Operator): Matrix &operator=(const Matrix &other)
    • 이미 생성된 객체에 다른 객체의 값을 대입할 때 호출됩니다. (예: m2 = m3;)
    • 자기 자신에게 대입하는 경우(this != &other)를 확인하고, 기존 리소스를 정리한 후 새로운 리소스를 복사합니다.
  4. 이동 생성자 (Move Constructor): Matrix(Matrix &&other) noexcept
    • 임시 객체(rvalue)를 사용해 새로운 객체를 생성할 때 호출됩니다.
    • 원본 객체의 리소스(포인터 등)를 그대로 가져와 자신의 멤버로 설정하고, 원본 객체는 리소스를 잃었음을 표시합니다(예: nullptr로 설정).
    • 리소스 포인터만 복사하므로 매우 빠릅니다. noexcept 키워드는 이 연산이 예외를 던지지 않음을 명시하여, STL 컨테이너 등이 더 효율적으로 이 함수를 사용할 수 있게 합니다.
  5. 이동 대입 연산자 (Move Assignment Operator): Matrix &operator=(Matrix &&other) noexcept
    • 이미 생성된 객체에 임시 객체의 값을 대입할 때 호출됩니다.
    • 자기 자신에게 대입하는 경우를 확인하고, 기존 리소스를 정리한 후 원본의 리소스를 '훔쳐옵니다'.

Matrix 예제에서는 std::cout을 통해 각 함수가 언제 호출되는지 명확히 보여줍니다.

 

예제 코드

Matrix.h

#pragma once

#include <iostream>
#include <utility> // for std::move

template <typename T>
class Matrix
{
public:
    Matrix();
    Matrix(const T &A, const T &B, const T &C, const T &D);

    /****************/
    /* RULE OF FIVE */
    /****************/
    ~Matrix() noexcept;
    Matrix(const Matrix &other); // copy constructor
    Matrix &operator=(const Matrix &other); // copy assignment operator
    Matrix(Matrix &&other) noexcept; // move constructor
    Matrix &operator=(Matrix &&other) noexcept; // move assignment operator;

    Matrix operator+(const Matrix &rhs);
    Matrix &operator+=(const Matrix &rhs);
    Matrix operator-(const Matrix &rhs);
    Matrix &operator-=(const Matrix &rhs);

    void print_matrix() const;

    T get_A() const;
    T get_B() const;
    T get_C() const;
    T get_D() const;

    void set_A(const T &new_A);
    void set_B(const T &new_B);
    void set_C(const T &new_C);
    void set_D(const T &new_D);

private:
    T m_A;
    T m_B;
    T m_C;
    T m_D;
};

template <typename T>
Matrix<T>::Matrix() : m_A(0.0), m_B(0.0), m_C(0.0), m_D(0.0)
{
    std::cout << "Calling Cstr\n";
}

template <typename T>
Matrix<T>::Matrix(const T &A, const T &B, const T &C, const T &D)
    : m_A(A), m_B(B), m_C(C), m_D(D)
{
    std::cout << "Calling Cstr\n";
}

template <typename T>
Matrix<T>::~Matrix() noexcept
{
    std::cout << "Calling Dstr\n";
}

template <typename T>
Matrix<T>::Matrix(const Matrix<T> &other)
    : m_A(other.get_A()), m_B(other.get_B()), m_C(other.get_C()),
      m_D(other.get_D())
{
    std::cout << "Copy constructor\n";
}

template <typename T>
Matrix<T> &Matrix<T>::operator=(const Matrix<T> &other)
{
    if (this != &other)
    {
        m_A = other.get_A();
        m_B = other.get_B();
        m_C = other.get_C();
        m_D = other.get_D();
    }

    std::cout << "Copy assignment\n";

    return *this;
}

template <typename T>
Matrix<T>::Matrix(Matrix<T> &&other) noexcept
    : m_A(std::move(other.m_A)), m_B(std::move(other.m_B)),
      m_C(std::move(other.m_C)), m_D(std::move(other.m_D))
{
    // 원본 객체의 멤버들을 기본값으로 초기화하여 리소스 소유권이 이전되었음을 명확히 함
    other.m_A = T{};
    other.m_B = T{};
    other.m_C = T{};
    other.m_D = T{};

    std::cout << "Move constructor\n";
}

template <typename T>
Matrix<T> &Matrix<T>::operator=(Matrix<T> &&other) noexcept
{
    if (this != &other)
    {
        m_A = std::move(other.m_A);
        m_B = std::move(other.m_B);
        m_C = std::move(other.m_C);
        m_D = std::move(other.m_D);

        other.m_A = T{};
        other.m_B = T{};
        other.m_C = T{};
        other.m_D = T{};
    }

    std::cout << "Move assignment\n";

    return *this;
}

template <typename T>
Matrix<T> Matrix<T>::operator+(const Matrix<T> &rhs)
{
    auto result = Matrix{};

    result.set_A(this->get_A() + rhs.get_A());
    result.set_B(this->get_B() + rhs.get_B());
    result.set_C(this->get_C() + rhs.get_C());
    result.set_D(this->get_D() + rhs.get_D());

    return result;
}

template <typename T>
Matrix<T> &Matrix<T>::operator+=(const Matrix<T> &rhs)
{
    this->set_A(this->get_A() + rhs.get_A());
    this->set_B(this->get_B() + rhs.get_B());
    this->set_C(this->get_C() + rhs.get_C());
    this->set_D(this->get_D() + rhs.get_D());

    return *this;
}

template <typename T>
Matrix<T> Matrix<T>::operator-(const Matrix<T> &rhs)
{
    auto result = Matrix{};

    result.set_A(get_A() - rhs.get_A());
    result.set_B(get_B() - rhs.get_B());
    result.set_C(get_C() - rhs.get_C());
    result.set_D(get_D() - rhs.get_D());

    return result;
}

template <typename T>
Matrix<T> &Matrix<T>::operator-=(const Matrix<T> &rhs)
{
    set_A(get_A() - rhs.get_A());
    set_B(get_B() - rhs.get_B());
    set_C(get_C() - rhs.get_C());
    set_D(get_D() - rhs.get_D());

    return *this;
}

template <typename T>
void Matrix<T>::print_matrix() const
{
    std::cout << m_A << " " << m_B << '\n';
    std::cout << m_C << " " << m_D << "\n\n";
}

template <typename T>
T Matrix<T>::get_A() const { return m_A; }

template <typename T>
T Matrix<T>::get_B() const { return m_B; }

template <typename T>
T Matrix<T>::get_C() const { return m_C; }

template <typename T>
T Matrix<T>::get_D() const { return m_D; }

template <typename T>
void Matrix<T>::set_A(const T &new_A) { m_A = new_A; }

template <typename T>
void Matrix<T>::set_B(const T &new_B) { m_B = new_B; }

template <typename T>
void Matrix<T>::set_C(const T &new_C) { m_C = new_C; }

template <typename T>
void Matrix<T>::set_D(const T &new_D) { m_D = new_D; }

 

main.cpp

#include <iostream>
#include <vector>

#include "Matrix.h"

int main()
{
    // // Copy Example
    // auto m1 = Matrix<float>{-1.0, -2.0, -3.0, -4.0};
    // auto m2 = m1; // copy constructor
    // auto m3 = Matrix<float>{1.0, -2.0, -3.0, -4.0};
    // m2 = m3; // copy assignm operator

    // Move Example
    auto vec = std::vector<Matrix<float>>{};

    // 1. 임시 Matrix 객체(rvalue) 생성
    // 2. push_back은 이 rvalue를 받아 vector 내부에서 이동 생성자를 호출하여 새 원소를 생성
    vec.push_back(Matrix<float>{-1.0, -2.0, -3.0, -4.0});

    vec[0].print_matrix();

    return 0;
}

 

실행 결과

Calling Cstr
Move constructor
Calling Dstr
-1 -2
-3 -4

Calling Dstr

 

결과 분석:

  1. Calling Cstr: Matrix<float>{-1.0, ...}에 의해 임시 객체가 생성되며 일반 생성자가 호출됩니다.
  2. Move constructor: vec.push_back()이 임시 객체(rvalue)를 인자로 받습니다. vector는 내부 공간에 새로운 Matrix 객체를 생성하기 위해 복사 대신 이동 생성자를 호출하여 리소스를 '훔쳐옵니다'.
  3. Calling Dstr: push_back 호출이 끝난 후, 리소스를 빼앗긴 임시 객체가 소멸됩니다.
  4. -1 -2 ...: vec[0].print_matrix()가 호출되어 vector 내부에 저장된 객체의 내용이 올바르게 출력됩니다.
  5. Calling Dstr: main 함수가 종료되면서 vector가 소멸되고, vector가 관리하던 Matrix 객체도 소멸됩니다.

 

활용팁

  • 성능 최적화: 동적 메모리, 파일 핸들러, 소켓 등 무거운 리소스를 관리하는 클래스에서 이동 의미론을 구현하면 불필요한 깊은 복사를 방지하여 성능을 크게 향상시킬 수 있습니다. std::vector, std::string, std::unique_ptr와 같은 표준 라이브러리 컨테이너와 스마트 포인터들은 이미 이동 의미론을 완벽하게 지원합니다.
  • std::move의 사용: std::move는 객체를 rvalue로 캐스팅하는 역할을 합니다. 이를 통해 lvalue(이름이 있는 객체)에 대해서도 강제로 이동 시킬 수 있습니다. 단, std::move를 사용한 후에는 원본 객체가 비어 있는 상태가 되므로 더 이상 사용하면 안 됩니다.
  • 함수 반환: 함수에서 객체를 값으로 반환할 때, 컴파일러는 RVO(Return Value Optimization)나 NRVO(Named Return Value Optimization)를 통해 불필요한 복사/이동을 생략하는 최적화를 수행합니다. 이것이 불가능한 경우, 이동 생성자가 호출되어 효율적으로 값을 반환합니다.