본문 바로가기

프로그래밍 언어들/C++

CHAPTER05 - 복사 생성자(Copy Constructor)

1. 복사 생성자

복사 생성자란 생성자의 한 형태일 뿐이다. 그러나 생성자가 호출되는 시점이 일반 생성자와 차이가 있다.


(1) C++ 스타일의 초기화

우리는 지금까지 다음과 같은 방식으로 변수와 참조자를 선언 및 초기화해 왔다.

int num = 20;
int &ref= num;


하지만 C++에서는 다음의 방식으로 선언 및 초기화가 가능하다.

int num(20);
int &ref(num);


위 두 가지 방법은 결과적으로 동일하다. 그렇다면 객체로 넘어와서 다음의 소스코드를 주목해 보자.

First f1(10);
First f2 = f1;


위의 코드 중, 'First f2 = f1'는 객체의 생성 및 초기화를 연상시킨다.

즉, f1과 f2 사이의 멤버 대 멤버 사이에 복사가 일어난다.
따라서 아래의 문장이 각 동일한 의미로 해석된다.

    int num1 = num2;
    int num1(num2);

    First f2(f1);
    First f2 = f1;


그런데 앞서 학습한 내용 중에, "모든 객체는 생성자의 호출을 동반한다."라고 학습하였다.

그렇다면 위의 객체 생성에서 호출하고자 하는 생성자는 다음과 같을 것이다.

First(First &copy)
{
    //
}


그리고 사실, 'First f2 = f1'이라는 문장도, 'First f2(f1)'의 형태로 묵시적 변화이 되어 객체가 생성되는 것이다.
즉, 위와 같은 생성자를 가리켜 '복사 생성자(Copy Constructor)'라 부른다.

복사 생성자는 일반 생성자와 호출 시점에서 차이가 있기 때문에 별도로 지칭하는 것이다.

즉, 복사 생성자를 이해하기 위해서는 복사 생성자의 호출시점을 확실히 이해해야 한다.


(2) 자동으로 삽입되는 복사 생성자

복사 생성자를 정의하지 않으면, 멤버 대 멤버 복사를 진행하는 '디폴트 복사 생성자'가 자동으로 삽입된다.

즉, 생성자가 존재하더라도, 복사 생성자가 존재하지 않으면 디폴트 복사 생성자가 저절로 삽입된다.


(3) 묵시적 변환을 막기 위한 explicit

    First f2 = f1;
    First f2(f1);


첫 줄의 문장이, 묵시적으로 두 번째 줄의 문장으로 변형이 되어 복사 생성자가 호출이 된다 하였다.

이와같은 유형의 변한이 마음에 들지 않는다면 'explicit' 키워드로 허용하지 않을 수 있다.

explicit First(First &copy)
{
    //
}


※ 묵시적 변환이 많이 발생하는 코드일 수록 코드의 결과를 예측하기가 어려워지므로, 'explicit'은

코드의 명확함을 위해 자주 사용하는 키워드이다.
묵시적 변환은 복사 생성자에만 일어나는 것이 아니고, 전달 인자가 하나인 생성자가 있다면,

이 역시 묵시적 변환이 발생한다.

class AA
{
private:
    int num;
public:
    AA( const int n) : num(n) {}
}

int main( void)
{
    AA a    = 3; // AA a(3);
   
    return 0;
}


마찬가지로 'explicit' 키워드가 생성자에 선언되면, 묵시적인 변환을 허용하지 않기 때문에

대입 연산자를 통한 객체 생성이 불가능하다.


※ 복사 생성자의 매개변수는 왜 참조형(Reference)인가?

- 참조형으로 선언하지 않는다면, 무한루프에 빠져버린다.


2.얕은 복사(Shallow Copy)와 깊은 복사
복사 생성자는 멤버 대 멤버의 복사를 진행한다. 이러한 방식을 가르켜 '얕은 복사(Shallow Copy)'라고 한다.

이는 멤버변수가 힙의 메모리 공간을 참조하는 경우에 문제가 된다.


(1) 디폴트 복사 생성자의 문제점

class AA
{
private:
    char *name;
public:
    explicit AA(const char *name)
    {
        this->name  = new char[strlen(name)+1];
        strcpy( this->name, name);
    }
    ~AA(void)
    {
        cout << "called destructor!" << endl;
        delete []name;
    }
};

int main( void)
{
    AA a("LEE");
    AA b(a);

    return 0;
}


위의 소스를 보고 문제점을 파악해보자. 프로그램을 실행시켜 보면, 'called destructor!'라는 메시지가

한 번 출력될 것이다. 문제점은 바로 디폴트 복사 생성자는 멤버 대 멤버를 단순히 복사만 한다.

즉, 문제점을 정리하면 아래와 같다.

- a 객체가 힙 영역에 메모리를 할당받고 문자열을 저장한다.

- b 객체가 디폴트 복사 생성자를 통해 생성된다.

- 얕은 복사이기 때문에, 단순 멤버의 복사가 이루어진다. 즉, b 객체는 a 객체가 할당 받은 메모리를 가르킨다.

( 자신이 할당받은 것이 아니고, a가 받은 공간을 가르키는 것이다. )

- a 객체가 소멸하면서 해당 공간을 'delete' 해버린다. 즉 b 객체가 delete할 공간이 없어지는 것이다.


(2) 깊은 복사를 위한 복사 생성자의 정의

얕은 복사의 문제점을 해결하기 위한 방법으로 여러 가지를 생각해 볼 수 있다.

그 중 한 가지로, 힙 영역에 별도로 메모리 공간을 할당받는 방식으로 해결할 수 있다. 이를 '깊은 복사'라 한다.

    explicit AA(const AA &copy)
    {
        this->name  = new char[strlen(copy.name)+1];
        strcpy( this->name, copy.name);
    }


이 전 예제에 위의 생성자를 추가해 보면 문제없이 잘 동작하는 것을 확인할 수 있다.


3. 복사 생성자의 호출시점

이제 우리는 클래스 별로 필요한 복사 생성자를 정의할 수 있게 되었다.

마지막으로 복사 생성자가 호출되는 시점을 알아보자.

첫 번째, 기존에 생성된 객체를 이용해서 새로운 객체를 초기화 하는 경우(앞서 보인 경우)

두 번째, Call-by-value 방식의 함수호출 과정에서 객체를 인자로 전달하는 경우 

세 번째, 객체를 반환하되, 참조형으로 반환하지 않는 경우

위의 세 가지 상황에 복사 생성자가 호출된다고 할 수 있다.


(1) 메모리 공간의 할당과 초기화가 동시에 일어나는 상황

가장 대표적인 예로는 변수(객체)의 선언 및 초기화이다.

int num1 = num2;


다음은 함수가 호출되는 순간 매개변수가 할당과 동시에 초기화가 이루어진다.

void func(int n)
{
    //
}
int main( void)
{
    int num1 = 10;

    func(num1);

    return 0;
}


func 함수의 매개변수 n은 함수가 호출될 때 할당과 동시에 초기화가 이루어진다.
마지막으로 값을 반환하는 순간으로, 이해하기 가장 난해한 부분이다.

int func(int n)
{
    return n;
}


값을 반환하는데, "그 과정에서 메모리 공간에 할당된다고?", "새로운 변수를 선언하는 것도 아닌데?"

이와 같이 생각할 수도 있다. 하지만 해당 함수를 호출한 지점을 기준으로 본다면,

"반환되는 값이 따로 저장되어 있지 않은데, 어떻게 참조할 수 있겠는가?"

int func(int n)
{
    return n;
}
int main( void)
{
    int num1 = 10;
    int res;

    res = func(10);

    return 0;
}


위의 소스를 예로 보면, func 함수는 n의 값을 리턴한다. 그러나 main 함수로 넘어오면 매개변수 n의 공간은

없어지게 된다. 그렇다면 "n이 가지고 있는 값을 어떻게 main 함수에서 참조할까?"

즉, 함수가 값을 반환하면 별도의 메모리 공간이 할당되고, 해당 공간에 반환한 값이 저장된다.

여기서 별도의 메모리 공간이란 '임시 공간' 정도로 기억해 두자.


이로써 메모리 공간이 할당되면서 동시에 초기화되는 세 가지 상황을 정리하였다.

이러한 상황은 객체라 해도 달라질 것이 없다. 다음의 경우들에도 당연히 객체가 생성되면서 초기화 된다.

AA func(AA temp) // Second case-2
{
    return temp; // Third case
}

int main( void)
{
    AA a(10);
    AA b(a); // First case, AA b = a;

    func(b); // Second case-1

    return 0;
}


※ 즉, 위의 상황에 복사 생성자가 호출이 되는 것이다. 한 가지 주의할 점은 함수의 반환값을

저장하는 객체가 참조자형이면 복사 생성자가 호출되지 않는다.


(2) 반환 시 만들어지는 임시 객체의 소멸 시점

임의로 임시 객체를 만들 수 있다. 다음 소스를 먼저 주시해 보자.

class AA
{
private:
    int num;
public:
    explicit AA(const int n) : num(n)
    {
        cout << "constructor : " << num << endl;   
    }
    ~AA(void)
    {
        cout << "destructor : " << num << endl;
    }
    void shownum(void)
    {
        cout << "number : " << num << endl;
    }
};

int main( void)
{
    AA(100);
    cout << "after creation" << endl << endl;

    AA(200).shownum();
    cout << "after creation" << endl << endl;

    AA &ref = AA(300);
    cout << "after creation" << endl << endl;

    return 0;
}
constructor : 100
destructor : 100
after creation

constructor : 200
number : 200
destructor : 200
after creation

constructor : 300
after creation

destructor : 300


첫 번째 'AA(100);'의 결과로 알 수 있는 것처럼, 임시객체는 생성 후 다음 문장 전에 소멸된다.

그러나 두 번째 임시 객체부터는 추가적으로 설명이 필요하다.

'AA(200).shownum();' 을 설명하기 전에, 클래스 외부에서 객체의 멤버함수를 호출하기 위해

필요한 것은 다음 세 가지 중 한 가지이다.

- 객체에 붙여진 이름

- 객체의 참조 값(객체 참조에 사용되는 정보)

- 객체의 주소 값


임시 객체가 생성된 위치에는 임시객체의 '참조 값'이 반환된다. 즉, 위 문장의 경우 아래와 같다.

'(임시객체의 참조 값).shownum();'
따라서, 이어서 멤버함수의 호출이 가능한 것이다. 또한 이렇듯 '참조 값'이 반환되기 때문에

다음과 같은 문장의 구성도 가능한 것이다.

'AA &ref = AA(300);'
위 경우는 임시 객체 생성 시 반환되는 '참조 값'이 참조자 'ref'에 전달되어, ref가 임시 객체를 참조하게 된다.

그러나, 참조자에 의해 참조되는 임시객체는 바로 소멸되지 않는 것을 실행 결과를 통해 확인할 수 있다.
즉, 참조자를 통해 참조하면 다음 행에서도 접근이 가능하기 때문에 소멸을 시키지 않는 것이다.


마지막으로 다음 예제를 살펴보자.

class AA
{
private:
    int num;
public:
    explicit AA(const int n) : num(n) {}
    AA( const AA &ob) : num(ob.num)
    {
        cout << "copy object : " << this << endl;
    }
    ~AA(void) {}
};

AA func(AA ob)
{
    return ob;
}

int main( void)
{
    AA a(10);

    AA b    = func(a);
    cout << "return object : " << &b << endl;

    return 0;
}
copy object : 0093FCAC
copy object : 0093FDA4
return object : 0093FDA4


위의 예제는 복사 생성자에서 새롭게 생성되는 객체의 참조값을 출력하고 있다.
맨 처음 값은, func 함수를 호출할 때 매개변수 'AA ob'가 할당과 동시에 초기화 되면서 복사 생성자를 호출한 것이다.

그리고 두 번째 값은, func 함수에서 'ob' 값을 반환하면서, '임시 객체'를 할당과 동시에 초기화 된 것이다.
그런데 두 번째와 세 번째 값이 같다.

즉, 반환된 값을 '대입'하는 것처럼 보이지만, 할당 및 초기화된 임시 객체를 가르키는 구조이다.

객체의 생성 수를 하나 줄여서 효율성을 높이기 위한 것이다.


출처 : 윤성우 열혈 C++ 프로그래밍