C, C++/C++ 언어 / / 2024. 5. 10.

[C++] 복사 생성자 (Copy Constructor)와 복사 생략

복사 생성자

복사 생성자란 같은 타입의 객체를 인자로 받아 그 객체의 데이터를 가지고 초기화를 수행하는 생성자를 말합니다.

 

이 글에서 계속 사용하게 될 클래스를 하나 만들어 보겠습니다.

이 클래스에는 어떤 생성자가 호출되는지 알기 위해서, 출력 코드를 달아두었습니다.

class CSomething{
    int m_nValue;

public:

    CSomething(){
        m_nValue = 0;       
        cout << "default constructor called.\n";
    }
    CSomething(int val) : m_nValue(val) {
        cout << "parameterized constructor called.\n";
    }
    
    CSomething(const CSomething& other){	// 복사 생성자
        m_nValue = other.m_nValue;
        cout << "copy constructor called.\n";
    }
};

int main(){
    
    CSomething other(10);
    CSomething obj(other);  // 복사 생성자 호출
    
    return 0;
}

위의 예제에서 가장 마지막 생성자가 복사 생성자입니다.

같은 타입의 객체   other   를 인자로 받아서,   other   가 가진   m_nValue   를 가지고 초기화를 수행합니다.

 

그런데, 만약 이 복사 생성자를 작성하지 않으면 어떻게 될까요?

  obj   객체는 기본 생성자를 갖고 있기 때문에   m_nValue   는 0일까요?

아니면, 해당 생성자가 없기 때문에 컴파일 오류가 발생할까요?

 

  obj   객체의   m_nValue   는 이 경우   10   의 값을 가지게 됩니다.

복사 생성자를 가지지 않는 경우, 컴파일러는 자동으로 기본 복사 생성자를 생성해서   other   로부터 값을 복사하기 때문입니다.

 

이 기본 복사 생성자는 대응되는 멤버별로 초기화를 수행합니다.

예를 들어, CSomething 클래스의 멤버로 m_nValue1, m_nValue2, m_nValue3... 이 있다고 합시다.

그럼, 기본 복사 생성자에서   other.m_nValue1   으로   obj.m_nValue1   을 초기화합니다.

그리고,   other.m_nValue2   로는   obj.m_nValue2   의 값을 초기화합니다...

이런 식으로, 대응되는 멤버별로 초기화를 수행하는 것을 멤버 단위 초기화( memberwise initialization )라고 합니다.

 

그럼 복사 생성자를 따로 작성할 필요는 없는 거 아닐까요?

 

기본 복사 생성자와 얕은 복사

기본 복사 생성자는, 객체가 복사 생성자를 제공하지 않을 경우 컴파일러가 자동으로 만드는 생성자입니다.

이 생성자의 기능은 객체의 각 멤버를 그대로 복사해서 초기화를 하는 것입니다.

그대로 복사하는 방식을 얕은 복사라고 부르고, 문제를 발생시키는 경우가 있습니다.

 

class CBuffer{
    int m_nSize;
    int* m_pBuffer;

public:
    CBuffer( int size ){
        m_nSize = size;
        m_pBuffer = NULL;
        
        if ( m_nSize > 0){
            m_pBuffer = new int[m_nSize];
        }
    }

    ~CBuffer(){
        if ( m_pBuffer != NULL)
            delete [] m_pBuffer;	// 예외 발생
    }
};

int main(){

    CBuffer other(10);
    CBuffer obj(other);
    
    return 0;
}

위의 클래스 CBuffer는 생성 시 크기를 입력받아 int 배열을 할당하고, 소멸 시 할당한 메모리를 스스로 해제하는 클래스입니다.

그런데, 이 예제의 코드를 실행하면 불행하게도 예외가 발생합니다.

 

CBuffer 클래스는 복사 생성자를 작성하지 않았기 때문에,   obj   객체는 얕은 복사 상태로 데이터를 복사했습니다.

  obj   객체는 실제로 메모리를 할당한 적이 없습니다.

  other   객체의 메모리 주소를 그대로 복사해서 갖고 있을 뿐이죠.

그리고   obj   객체가 소멸될 때, other 객체가 할당한 메모리를 해제해 버립니다.

그래서   other   객체가 자신이 할당한 메모리를 해제할 때 오류가 발생한 것입니다.

 

참고로, 스택 영역에   other   ,   obj   순서로 생성했기 때문에   obj   ,   other   순으로 개체를 삭제합니다.

 

원인을 제대로 알았으니 해결 방안은 눈에 보입니다.

기본 복사 생성자 대신에, 사용자 복사 생성자에서 메모리를 할당해서 실제 값들을 복사해 오는 것입니다.

그럼, 각각의 객체는 자신이 할당한 메모리를 삭제하고 소멸될 것입니다.

class CBuffer{
    int m_nSize;
    int* m_pBuffer;

public:
    CBuffer( int size ){
        m_nSize = size;
        m_pBuffer = NULL;

        if ( m_nSize > 0){
            m_pBuffer = new int[m_nSize];
        }
    }

    CBuffer( const CBuffer& other){
        m_nSize = other.m_nSize;
        m_pBuffer = NULL;

        if ( m_nSize > 0){
            m_pBuffer = new int[m_nSize];   // 버퍼를 새로 할당한다
            memcpy( m_pBuffer, other.m_pBuffer, sizeof(int)*m_nSize);   // 데이터 복사
        }
    }

    ~CBuffer(){
        if ( m_pBuffer != NULL)
            delete [] m_pBuffer;
    }
};

이렇게 문맥에 맞도록 복사하는 것을, 얕은 복사의 반대 의미로 깊은 복사라고 합니다.

 

복사 생성자의 삭제

어떤 상항에서는 복사가 불가능한 객체를 원할 때가 있습니다.

그 예의 하나가 unique_ptr 객체입니다.

이 객체는 메모리를 할당한 객체를 관리하는 스마트 포인터 객체입니다.

그리고, 이 객체는 관리하는 메모리 객체에 대한 유일한 소유권을 갖고 있습니다.

그러므로, 이 객체는 복사 대신 이동( move semantic ) 기능만 갖춰야 합니다.

 

unique_ptr 객체에 관한 내용은 여기서 볼 수 있습니다.

 

[C++] unique_ptr에 대한 설명과 사용법

unique_ptr 이란unique_ptr은 할당된 메모리를 자동으로 관리할 목적으로 C++ 11에 도입된 유틸리티 클래스입니다. 참고로, 이와 비슷한 스마트 포인터( smart pointer ) 클래스로 auto_ptr이 있었습니다.그러

codingembers.tistory.com

 

이런 기능은 복사 생성자를 만들 수 없도록 하는 것으로 달성될 수 있습니다.

class CSomething{
    int m_nValue;

public:

    // ... 위와 같음
    
    // 복사 생성자 삭제
    CSomething(const CSomething& other) = delete; 
    
    // 복사 대입 연산자 삭제
    CSomething& operator=(const CSomething& other) = delete;
};

int main(){
    
    CSomething other(10);
    CSomething obj(other);  // 객체를 복사할 수 없음. error !
    
    obj = other;	// 대입 연산도 실패. 이 문장도 error !
    
    return 0;
}

위와 같이 "   = delete   " 구문을 사용해서 복사 생성자 함수가 자동으로 생성되는 것을 막을 수 있습니다.

그리고, 기본적으로 복사 생성자와 복사 대입 연산자 ( copy assignment operator )는 컴파일러에 의해 별개로 처리됩니다.

따라서, 복사 생성자를 삭제했다면, 복사 대입 연산자도 마찬가지로 삭제되어야 합니다.

( 어떤 식으로도 복사가 불가능하도록 )

 

참고로,   = delete    구문은 클래스 멤버뿐만 아니라, 일반 함수도 삭제할 수 있습니다.

int add( int a, int b ){ return a + b; }
int add( double a, double b ) = delete;

add( 5, 3);	// ok
add( 5.5, 3.1);	// error !

 

그리고, 이동 생성자( move constructor )나 이동 대입 연산자( move assignment operator )가 있는 경우, 컴파일러는 복사 생성자를 작성하지 않은 경우에도, 복사 생성자를 자동으로 생성하지 않습니다.

( 좀 더 자세히 말한다면, 기본 복사 생성자를 삭제합니다. )

그것은, 이동 생성자를 직접 구현했다는 것으로부터, 컴파일러가 자동으로 만드는 복사 생성자로써는 클래스의 데이터를 제대로 처리할 수 없다는 것을 추론할 수 있기 때문입니다.

 

 

복사 생략 (Copy Elision)

위의 CSomething 클래스로 설명하도록 하겠습니다.

class CSomething{	// 이전 항목과 같음
    int m_nValue;

public:

    CSomething(){
        m_nValue = 0;       
        cout << "default constructor called.\n";
    }
    CSomething(int val) : m_nValue(val) {
        cout << "parameterized constructor called.\n";
    }
    
    CSomething(const CSomething& other){	// 복사 생성자
        m_nValue = other.m_nValue;
        cout << "copy constructor called.\n";
    }
};

 

아래의 코드는 값 10으로 st 객체를 생성하고, 이 st 객체로부터 다시 obj 객체를 생성하는 과정을 보여줍니다.

int main(){

    CSomething st(10);	// 매개 변수 생성자 호출
    CSomething obj(st);	// 복사 생성자 호출
    
    return 0;
}

▼출력

parameterized constructor called.
copy constructor called.

위의 코드를 실행하면 두 가지의 생성자가 호출됩니다.

첫 줄에서 매개 변수 생성자가 호출되고, 다음 줄에선 복사 생성자가 호출됩니다.

 

그럼, 다음의 예제에선 어떤 생성자가 호출될까요?

int main(){

    CSomething obj2( CSomething(10) );
    
    return 0;
}

▼출력

parameterized constructor called.

여기도 위의 예제와 다를 게 없습니다.

단지, 위에선 객체가 두 개 있고, 아래에선 하나의 객체만 초기화하고 있을 뿐이죠. ( 이 예제엔 st 객체가 없습니다 )

그런데, 실제 컴파일 해서 코드를 실행해 보면 매개 변수 생성자만 호출되는 것을 보게 됩니다.

이게 어떻게 된 걸까요?

 

이것은 위의   obj2   개체를 초기화하는데 두 번의 과정을 거칠 필요가 없다는 것을 컴파일러가 알아버린 것이죠.

그래서 그 두 번의 연속의 과정 중에서 복사 생성자를 생략함으로써, 수행 능력을 향상시킵니다.

이를 복사 생략(Copy Elision)이라고 부릅니다.

 

우리가 모르는 생성자의 호출을 놓친 것이 아니고, 실제로 호출이 되지 않았기 때문에 매개 변수 생성자만 호출된 것으로 나타난 것입니다.

 

예를 하나 더 들어보겠습니다.

CSomething Func(int val){
    CSomething st(val);	// 매개변수 생성자를 통한 객체 생성
    return st;
}

int main(){

    CSomething st = Func(12);   // copy elision
    
    return 0;
}

▼출력

parameterized constructor called.

 

여기서도 Func 함수 내에서   st   객체를 초기화하기 위해서 매개 변수 생성자( parameter constructor )가 호출됩니다.

그리고, 객체를 반환하는 과정에서 객체가 복사되기 때문에 복사 생성자가 호출되어야 합니다.

그러나, 실제로는 매개 변수 생성자만 호출됩니다.

 

C++ 17 이후의 강제적인 복사 생략 

복사 생략 최적화가 이루어지지 않을 때 일어나는 과정을 보기 위해서, 컴파일 시에 다음과 같은 옵션을 추가하였습니다.
( C++ 17 이상, 컴파일러는 mcc 14.2.0을 사용했습니다.  )

"-fno-elide-constructors"  // copy elision 방지

이 옵션은 복사 생략을 하지 못하도록 최적화를 제한하는 기능입니다.

 

이렇게 하고 위의 예제를 다시 실행하면, 다음과 같은 결과를 출력하는 것을 볼 수 있습니다.

CSomething Func(int val){
    CSomething st(val);	// 매개변수 생성자를 통한 객체 생성
    return st;
}

int main(){

    CSomething st = Func(12);   // 복사 생성자를 통한 객체 생성
}

▼출력

parameterized constructor called.
copy constructor called.

위의 첫 번째 줄은 Func 함수 내에서 CSomething 객체를 생성할 때 출력됩니다.

그리고, 두 번째 줄은 main 함수 내에서   st   객체를 생성할 때 출력됩니다.

 

그런데,  이것을 보더라도 예전부터 C++를 사용해 오던 사람들은 뭔가 이상하다는 것을 느낄 것입니다.

Func 함수에서 st 객체를 반환하면, 이 객체가 임시 객체에 복사되고, 그 임시 객체가 main 함수의   st   객체를 생성하기 위해 다시 복사된다는 것을 알고 있기 때문입니다.

 

이것은 C++ 17 이후의 컴파일러에선, 최적화 옵션이 있다 하더라도 무조건 복사 생략( copy elision )을 하도록 변경되었기 때문입니다.

즉, Func 함수에서 반환하는 객체를 임시 객체를 거치지 않고, 바로 main 함수의 st 객체에 복사합니다.

 

이를 확인하기 위해서, 위의 예제를 C++ 14에서 컴파일해 실행해 보았습니다.

( 물론, 최적화 옵션은 제한한 상태여야 합니다. )

▼출력

parameterized constructor called.
copy constructor called.
copy constructor called.

그럼, 위와 같이 복사 과정이 두 번 실행되는 것을 볼 수 있습니다.

 

이러한 C++ 17 이후의 복사 생략을 강제적인 복사 생략( mandatory copy elision )이라고 합니다.

이것은 함수에서 객체를 반환하는데, 반드시 임시 객체를 거쳐야 하는가에 대한 성찰이라고 볼 수 있습니다.

 

 

정리

  • 복사 생성자란 같은 타입의 객체를 인자로 받아 그 객체의 데이터를 가지고 초기화를 수행하는 생성자입니다.
  • 일반적으로 깊은 복사를 위해서 복사 생성자를 작성해야 합니다.
  • 복사가 불가능한 객체를 만들기 위해 복사 생성자를 삭제할 수 있습니다.
  • 컴파일러는 최적화가 가능한 경우 복사 생략을 수행합니다.

 

 

이 글과 관련 있는 글들

함수에서 std::vector와 같은 객체를 반환하는 방법

 

 

 

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유