C++

C++ - 이동/복사 특수 맴버 함수

KimTY 2025. 5. 18.

1. 특수 멤버 함수

컴파일러가 자동으로 생성하는 클래스나 구조체의 멤버 함수
생성자/할당 연산자/소멸자로 구성된다

 

 특수 멤버 함수는 클래스나 구조체에서, 컴파일러가 자동으로 생성하는 멤버 함수(메서드)를 뜻한다.

특수 멤버 함수는 생성자, 할당 연산자, 소멸자로 구성되며 그 종류는 다음과 같다.

class X{
public:
    /* --- 생성자 --- */
    X(); //기본 생성자
    X(const X&); //복사 생성자
    X(X&&); //이동 생성자

    X(int a, int b); //이런 형태의 "일반적인 생성자"는 특수 멤버 함수가 아니다.

    /* --- 할당 연산자 --- */
    X& operator=(const X&); //복사 할당자
    X& operator=(X&&); //이동 할당자

    /* --- 소멸자 --- */
    ~X(); //소멸자
};

 

  • 생성자: 객체 생성 시 호출된다
  • 할당자: 이미 생성된 객체에 새로운 값을 할당한다
  • 소멸자: 객체 소멸 시 호출된다

 이러한 특수 멤버 함수는 개발자가 직접 구현할 수 있다. 만약 구현하지 않은 경우, 컴파일러는 함수가 필요할 경우 암묵적으로 함수를 선언한다(이를 기본 특수 멤버 함수라고 한다)

 

 이 중, 복사와 이동에 대해 설명한다.

 

2. 복사 생성자/할당자

기본 복사 생성자/할당자는 각 멤버 변수(필드)의 값을 복사한다
직접 선언하는 것으로 얕은 복사를 방지하고, 원하는 동작을 구현할 수 있다. 

 

 복사는 우측 객체의 변수 값을 복사하여, 좌측 객체를 초기화하거나 재설정한다.

 

1-1. 기본 복사 생성자/할당자

기본 복사는 멤버 변수의 값을 그대로 복사한다.
이는 얕은 복사등의 문제가 발생할 수 있다.

 

 다음은 "기본" 복사 생성자와 할당자의 동작모습이다.

class CopyTest{
private:
    int intData;
    float* ptr;

public:
    //생성자
    CopyTest() = default;
    CopyTest(int intInput, int sz): intData{intInput}, ptr{new float[sz]} {};

    /* --- !!!!기본 복사 동작의 명시적 선언!!!! --- */
    /* --- !!!!선언하지 않더라도 필요할 시 암묵적으로 선언된다!!!! --- */
    CopyTest(const CopyTest&) = default; //기본 복사 생성자의 명시적 선언
    CopyTest& operator=(const CopyTest&) = default;//기본 복사 할당자의 명시적 선언

    //정보 출력
    void Info(){
        cout << "intData: " << intData <<" / ptr: " << ptr << endl;
    }
};


int main(){
    CopyTest a{10, 5}; //생성자

    CopyTest b{a}; //기본 복사 생성자. 객체 a의 멤버들의 값을 복사해 b를 초기화

    CopyTest c{}; 
    c = a; //기본 복사 할당자. 객체 a의 멤버들의 값을 복사해 c에 덮어씌운다

    a.Info();
    b.Info();
    c.Info();
}

출력값

 기본 복사의 경우, 우측 객체(a)의 각 멤버 변수(intData와 ptr)의 값을 좌측 객체(b와 c)에 그대로 복사한다.

하지만 포인터의 값도 그대로 복사해, 얕은 복사로 인한 문제가 발생할 수 있다.

 

2-1. 복사 생성자/할당자 직접 정의

포인터를 통해 객체를 처리하는 경우 복사를 직접 구현하여 얕은 복사를 방지한다
이외에도 원하는 멤버 변수만 복사하는 등, 직접 원하는 동작을 구현할 수 있다.

 

 포인터를 통해 객체를 처리하는, 자원 핸들(resource handle)이나 컨테이너 클래스 등에서는 직접 복사를 구현해야 할 필요가 있다.

class IntContainer
{
private:
    int* data;
    int sz;

public:
    //기본 생성자
    IntContainer() = default;

    //일반적인 생성자
    IntContainer(int s): data{new int[s]}, sz{s} {}

    /* --- !!!!직접 정의한 복사 생성자!!!! --- */
    IntContainer(const IntContainer& obj): data{new int[obj.size()]}, sz{obj.size()}{
        for(int i = 0; i < obj.size(); i++){
            data[i] = obj[i];
        }
    }

    /* --- !!!!직접 정의한 복사 할당자!!!! --- */
    IntContainer& operator=(const IntContainer& obj){
        int* ptr = new int[obj.size()];
        for(int i = 0; i < obj.size(); i++){
            ptr[i] = obj[i];
        }

        delete[] data;
        data = ptr;
        sz = obj.size();
        return *this;
    }

    //소멸자
    ~IntContainer() { delete[] data;}

    //기타 멤버 함수
    int size() const{return sz;}
    int& operator[](int idx) {return data[idx];}
    const int& operator[](int idx) const {return data[idx];}
    void info() { cout << "data ptr: " << data <<" / size: " << sz << endl; }
};

int main(){
    IntContainer a{10};
    IntContainer b{a};

    IntContainer c{};
    c = a;

    a.info();
    b.info();
    c.info();
}

출력값, 얕은 복사 문제를 해결하였다

 다음과 같이 직접 복사 생성자/할당자를 정의하는 것으로, 얕은 복사를 방지할 수 있으며 원하는 방식으로 구현이 가능하다.

 

3. 이동 생성자/할당자

이동은 RValue를 참조한다.
기본 이동은 실질적으로 기본 복사와 같다.
복사 과정의 비용을 없애고, 기존 객체의 자원의 소유권을 이전하는 것을 목적으로 구현한다. 

 

class Container
{
private:
    int* arrData;
    int sz;
}

 다음과 같은 컨테이너의 "arrData"를 다른 객체에도 저장하고자 한다. 각 방법과 그 문제점은 다음과 같을 것이다

  • "arrData"의 값 자체를 모두 복사(깊은 복사) - arrData의 크기가 클 수록 부담이 커진다
  • "arrData"의 포인터를 복사(얕은 복사) - 두 객체가 동일한 주소를 참조하는 문제가 발생한다

 이러한 복사는 생각보다 자주 일어날 수 있으며(관련 글), 이때 객체가 크면 상당히 곤란한 상황이 될 수 있다.

이를 해결하기 위해 포인터를 복사하되, 기존 객체의 소유권은 해제하는 것이 바로 이동이다.

 

X(X&&); //이동 생성자
X& operator=(X&&); //이동 할당자
//이때 &&는 Rvalue 참조 연산자이다

 

  이동 생성자/할당자는 다음과 같은 형태이며, Rvalue 참조를 인자로 받는다.

 

3-1. Rvalue

식별 가능함은, 값을 다시 가리킬 수 있는 이름이나 주소를 지닌 것이다.
Lvalue는 식별 가능한 값(할당문 왼쪽에 올 수 있는 값)이다
Rvalue는 식별 할 수 없으며, 메모리에 잠시 존재하는 임시 값이다(할당문 왼쪽에 올 수 없는 값)

 

 이동 연산은 Rvalue 참조를 인자로 받는다. 이때 Rvalue는 식별 불가능한 값을 의미한다.

int a = 10;
//a는 Lvalue
//10은 Rvalue

 여기서 a와 같이 이름이나 주소를 가져 추적 가능하며, 읽고 쓸수 있는 값을 Lvalue라고 한다.(변수, 참조 등)

그리고 10과 같이 메모리에는 존재하지만 추적할 수 없는, 임시 값을 Rvalue하고 한다.

 

 값을 다시 가리킬 수 있는 이름이나 주소를 가질 시 이를 식별 가능하다고 한다.

즉 Lvalue는 식별 가능한 값, Rvalue는 식별 불가능한 값이다.

 

 정말 간단하게는 Lvalue는 할당문 왼쪽에 올 수 "있는" 값, Rvalue는 왼쪽에 올 수 "없는" 값 정도로 이해하면 된다.

(a = 10, a = b는 가능해도 10 = a는 어떻게 봐도 이상하다)

이와 관련한 자세한 내용은 다른 글에서 설명한다.

 

3-2. std::move

move는 Lvalue를 Rvalue의 참조로 Casting한다

 

 이동 연산은 Rvalue의 참조(T&&)를 받는다

객체를 Rvalue 참조로 Casting하기 위해 <utility>의 std::move를 사용한다

Class MyClass
{
/* ... */

MyClass(X&&) = defalut; //기본 이동 생성자
MyClass& operator=(X&&) = defalut; //기본 이동 할당자

/* ... */
};

int main{
	MyClass a{};
    MyClass b{move(a)}; //이동 생성자
    
    MyClass c{};
	c = move(b); //이동 할당자
    
    /* ... */
}

 

 함수 move()는 이름과 달리, 그 자체로는 무엇도 이동시키지 않는다. 이 함수는 받은 인자를 Rvalue 참조로 캐스팅하는 역할을 하며 이를 이동 연산에 사용하게 된다.

 

3-3. 이동 생성자/할당자

이동 연산은 객체의 리소스를 새로운 객체로 이전하고, 원래 객체는 자원을 더 이상 보유하지 않도록 초기화한다.

 

 앞서 말했듯 이동은 복사에 사용되는 자원을 줄이고, 기존 소유권을 해제한다. 다음은 '2-1. 복사 생성자/할당자 직접 정의'의 IntContainer에 이동 연산을 구현한다.

class IntContainer
{
public:
/* ... */

    //이동 생성자
    IntContainer(IntContainer&& obj): data{obj.data}, sz{obj.size()}{
    	obj.data = nullptr;//기존 객체의 소유권 초기화
        obj.sz = 0;
    }
    
    //이동 할당자
    IntContainer& operator= (IntContainer&& obj){
    	data = obj.data;
        sz = obj.sz;
        
        obj.data = nullptr; //기존 객체의 소유권 초기화
        obj.sz = 0;
        
        return *this;
    }

/*...*/

};

 다음과 같이 기존 객체의 포인터를 null로 설정해주는 것으로, 새로운 객체만이 주소를 통해 접근하는 것이 가능해진다.

 

4. default/delete

"= delete"를 통해 함수를 생성을 막는다.
"= default"를 통해 기본 함수를 명시적으로 생성할 수 있다.

 

 복사 혹은 이동을 막아야 하는 경우, "= delete"를 통해 함수를 금지할 수 있다.

다음은 복사를 금지하고 이동을 허용한 예시이다.

class X{
public:
    /* --- 생성자 --- */
    X() = default; //기본 생성자
    X(const X&) = delete; //복사 불가
    X(X&&) = default; //기본 이동 생성자

    X(int a, int b); //이런 형태의 "일반적인 생성자"는 특수 멤버 함수가 아니다.

    /* --- 할당 연산자 --- */
    X& operator=(const X&) = delete; //복사 불가
    X& operator=(X&&) = default; //기본 이동 할당자

    /* --- 소멸자 --- */
    ~X(); //소멸자
};

 

 "= delete"를 통해 복사 생성자/할당자를 삭제하는 것으로 복사를 방지한다.

이외의 기본 함수의 경우는, 컴파일러가 필요하면 암묵적으로 생성하지만 "= default"를 통해 명시적으로 생성할 수도 있다.

5. Rule of Zero/Five

자원 관리에 대한 규칙
Rule of Zero: 특수 멤버 함수를 하나도 정의하지 않음
Rule of Five(Six): 특수 멤버 함수를 모두 정의

 

클래스 작성 시 자원 관리에 대한 규칙이 존재한다.

간단히 요약하면 "필수 연산을 전부 정의하지 않거나, 전부 정의하는 것이다"

 

5-1.Rule of Zero

특수 멤버 함수를 전부 정의하지 않고, 자원 관리는 멤버에게 맡긴다

 

struct Student {
    std::string name;              // 복사/이동은 std::string이 처리
    std::unique_ptr<int> ptr;      // 소유권은 unique_ptr이 처리
};

자원은 RAII 멤버가 각자 관리하며, 특수 맴버 함수를 하나도 정의하지 않는 것을 의미한다.

(RAII - Resource acquisition is initializtion: 생성자가 모든 자원을 확보&소멸자가 모든 자원을 해제해 자원 관리를 암묵적으로 보장)

 

5-2. Rule of Five(Six)

모든 특수 멤버 함수, 소멸자/복사 생성자/복사 할당자/이동 생성자/이동 할당자 (+ 기본 생성자)를 전부 정의해 자원을 직접 관리

 

 직접 자원을 관리하는 경우, 모든 특수 멤버 함수를 전부 정의하는 것이다.(위의 IntContainer가 그 예이다)

 소멸자/복사 생성자/복사 할당자/이동 생성자/이동 할당자를 정의하는 것은 Rule of Five, 여기에 기본 생성자를 포함해 Rule of Six로 부르기도 한다.

 


잘못된 정보 등 지적 환영

 

참고 자료

- C++ Refernce

- A Tour of C++ 

- Microsoft Learn

댓글