26-2-나.복사 생성자

복사 생성자는 지금까지의 평이한 내용에 비해 약간 난이도가 있는 내용이므로 정신을 집중해서 읽을 필요가 있다. 변수를 선언할 때 = 구분자 다음에 상수로 초기값을 지정할 수 있으며 이미 생성되어 있는 같은 타입의 다른 변수로도 초기화할 수 있다. 다음은 가장 간단한 타입인 정수형의 예이다.

 

int a=3;

int b=a;

 

정수형 변수 a는 선언됨과 동시에 3으로 초기화되었다. 그리고 동일한 타입의 정수형 변수 b는 선언과 동시에 a로 초기화되었다. 결국 두 변수는 모두 3의 값을 가지게 될 것이다. 너무 너무 상식적인 코드이며 이런 초기화는 실수형이나 문자형, 구조체 등에 대해서도 똑같이 허용된다. 클래스가 int와 동일한 자격을 가지는 타입이 되기 위해서는 이미 생성되어 있는 같은 타입의 객체로부터 초기화될 수 있어야 한다. 객체에 대해서도 과연 이런 초기화가 성립할 수 있는지 Position 객체로 테스트해 보기 위해 Constructor 예제에 다음 코드를 작성해 보자.

 

Position Here(30,10,'A');

Position There=Here;

There.OutPosition();

 

Here 객체가 먼저 (30,10) 위치의 문자 'A'를 가리키도록 초기화되었으며 이어서 There객체는 선언과 동시에 Here객체로 초기화되었다. 이때 멤버별 복사에 의해 There는 Here의 모든 멤버값을 그대로 복사받으며 두 객체는 완전히 동일한 값을 가지게 된다. Position 객체가 내부에 모든 정보를 포함하고 있기 때문에 이런 초기화는 전혀 문제가 없다. 그렇다면 모든 객체에 대해 이런 초기화가 가능한지 Person 객체로도 테스트해 보자. Person1 예제의 main 함수에 다음 테스트 코드를 작성한다.

 

void main()

{

     Person Boy("강감찬",22);

     Person Young=Boy;

     Young.OutPerson();

}

 

이 코드는 정상적으로 컴파일되며 실행도 되지만 종료할 때 파괴자에서 실행중 에러가 발생하는데 왜 그런지 보자. Young 객체가 Boy객체로 초기화될 때 멤버별 복사가 발생하며 Young의 Name멤버가 Boy의 Name과 동일한 번지를 가리키고 있다. 정수형인 Age끼리 값이 복사되는 것은 아무 문제가 없지만 포인터끼리의 복사는 문제가 된다. Young이 초기화된 직후의 메모리 상황을 그림으로 그려보면 다음과 같으며 두 객체가 힙에 동적 할당된 메모리를 공유하고 있는 모양이다.

이런 상태에서 Young.OutPerson이나 Boy.OutPerson 함수 호출은 아주 정상적으로 실행된다. 그러나 두 객체가 같은 메모리를 공유하고 있기 때문에 한쪽에서 Name을 변경하면 다른 쪽도 영향을 받게 되어 서로 독립적이지 못하다. 이 객체들이 파괴될 때 문제가 발생하는데 각 객체의 파괴자가 Name 번지를 따로 해제하기 때문이다. new는 Boy의 생성자에서 한 번만 했고 delete는 각 객체의 파괴자에서 두 번 실행하기 때문에 이미 해제된 메모리를 다시 해제하려고 시도하므로 실행중 에러가 된다. 정수형은 어떤지 보자.

 

int a=3;

int b=a;

b=5;

 

b가 생성될 때 a의 값으로 초기화되어 a와 b는 같은 값을 가진다. 그러나 이는 어디까지나 초기화될 때 잠시만 같을 뿐이지 두 변수는 이후 완전히 독립적으로 동작한다. b에 5를 대입한다고 해서 a가 이 대입의 영향을 받지 않으며 a에 무슨 짓을 하더라도 b를 어찌할 수는 없다. 정수형의 복사 생성이 이처럼 독립적이므로 사용자 정의형도 이와 똑같이 복사 생성을 할 수 있어야 한다.

Person Young=Boy; 선언문에 의해 Young은 Boy의 멤버값을 복사받지만 이 때의 복사는 포인터를 그대로 복사하는 얕은 복사이다. 따라서 Young은 일시적으로 Boy와 같은 값을 가지지만 Boy의 Name을 빌려서 정보를 표현하는 불완전한 객체이며 독립적이지 못하다. 이 문제를 해결하려면 초기화할 때 얕은 복사를 해서는 안되며 깊은 복사를 해야 하는데 이때 복사 생성자가 필요하다. 얕은 복사가 문제의 원인이었으므로 깊은 복사를 하는 복사 생성자를 만들어 해결할 수 있다. 다음 예제는 Person1예제를 수정하여 Person 클래스에 복사 생성자를 추가한 것이다.

 

: Person2

#include <Turboc.h>

 

class Person

{

private:

     char *Name;

     int Age;

 

public:

     Person(const char *aName, int aAge) {

          Name=new char[strlen(aName)+1];

          strcpy(Name,aName);

          Age=aAge;

     }

    Person(const Person &Other)   {

        Name=new char[strlen(Other.Name)+1];

        strcpy(Name,Other.Name);

        Age=Other.Age;

    }

     ~Person() {

          delete [] Name;

     }

     void OutPerson() {

          printf("이름 : %s 나이 : %d\n",Name,Age);

     }

};

 

void main()

{

     Person Boy("강감찬",22);

     Person Young=Boy;

     Young.OutPerson();

}

 

복사 생성자는 자신과 같은 타입의 다른 객체에 대한 레퍼런스를 전달받아 이 레퍼런스로부터 자신을 초기화한다. Person복사 생성자는 동일한 타입의 Other를 인수로 전달받아 자신의 Name에 Other.Name의 길이만큼 버퍼를 새로 할당하여 복사한다. 새로 메모리를 할당해서 내용을 복사했으므로 이 메모리는 완전한 자기 것이며 안전하게 따로 관리할 수 있다. Age는 물론 단순 변수이므로 값만 대입받으면 된다.

컴파일러는 Person Young=Boy; 구문을 Person Young=Person(Boy);로 해석하는데 이 원형에 맞는 생성자인 복사 생성자를 호출한다. 실인수 Boy가 Person 객체이므로 Person을 인수로 받아들이는 생성자 함수를 호출할 것이다. 복사 생성자에 의해 Young은 깊은 복사를 하며 메모리에 다음과 같이 완전한 사본을 작성한다.

이제 Young과 Boy는 타입만 같을 뿐 완전히 다른 객체이고 메모리도 따로 소유하므로 각자의 Name을 마음대로 바꿀 수 있고 파괴자에서 메모리를 해제해도 문제가 없다. 복사 생성자에 의해 두 객체가 완전한 독립성을 얻은 것이다.

복사 생성자의 임무는 새로 생성되는 객체가 원본과 똑같으면서 완전한 독립성을 가지도록 하는 것이다. 만약 객체가 데이터 베이스를 사용한다면 이 클래스의 복사 생성자는 새 객체를 위한 별도의 데이터 베이스 연결을 해야 하며 독점적인 자원을 필요로 한다면 마찬가지로 별도의 자원을 할당해야 한다. 그래야 Class A=B; 선언문에 의해 A가 B에 대해 독립적으로 초기화된다.

객체가 인수로 전달될 때

같은 종류의 다른 객체로 새 객체를 선언하는 경우는 그리 흔하지 않다. 그러나 다음과 같이 함수의 인수로 객체를 넘기는 경우는 아주 흔한데 이때도 복사 생성자가 호출된다. 

 

void PrintAbout(Person AnyBody)

{

     AnyBody.OutPerson();

}

 

void main()

{

     Person Boy("강감찬",22);

     PrintAbout(Boy);

}

 

함수 호출 과정에서 형식 인수가 실인수로 전달되는 것은 일종의 복사생성이다. 함수 내부에서 새로 생성되는 형식인수 AnyBody가 실인수 Boy를 대입받으면서 초기화되는데 이때 복사 생성자가 없다면 AnyBody가 Boy를 얕은 복사하며 두 객체가 동적 버퍼를 공유하는 상황이 된다. AnyBody는 지역변수이므로 PrintAbout 함수가 리턴될 때 AnyBody의 파괴자가 호출되고 이때 동적 할당된 메모리가 해제된다. 이후 Boy가 메모리를 정리할 때는 이미 해제된 메모리를 참조하고 있으므로 에러가 발생할 것이다.

복사 생성자가 정의되어 있으면 AnyBody가 Boy를 깊은 복사하므로 아무런 문제가 없다. 객체가 인수로 전달될 때 뿐만 아니라 리턴값으로 돌려질 때도 복사 생성자가 호출된다. 위 테스트 코드를 Person2 예제에 작성해 놓고 실행하면 정상적으로 실행된다. 그러나 복사 생성자를 주석으로 묶어 버리면 다운된다. 함수의 인수로 사용되거나 리턴값으로 사용되는 객체는 반드시 복사 생성자를 제대로 정의해야 한다.

복사 생성자의 인수

복사 생성자의 인수는 반드시 객체의 레퍼런스여야 하며 객체를 인수로 취할 수는 없다. 만약 다음과 같이 Person형의 객체를 인수로 받아들인다고 해 보자.

 

Person(const Person Other)

{

     Name=new char[strlen(Other.Name)+1];

     strcpy(Name,Other.Name);

     Age=Other.Age;

}

 

복사 생성자 자신도 함수이므로 실인수를 전달할 때 값의 복사가 발생할 것이다. 객체 자체를 인수로 전달하면 복사 생성자로 인수를 넘기는 과정에서 다시 복사 생성자가 호출될 것이고 이 복사 생성자는 인수를 받기 위해 또 다시 복사 생성자를 호출한다. 결국 자기가 자신을 종료조건없이 호출해대는 무한 재귀 호출이 발생할 것이며 컴파일러는 이런 상황을 방관하지 않고 에러로 처리한다.

이런 이유로 복사 생성자의 인수로 객체를 전달할 수는 없다. 그렇다면 포인터의 경우는 어떨까? 포인터는 어디까지나 객체를 가리키는 번지값이므로 한 번만 복사되며 무한 호출되지 않는다. 또한 객체가 아무리 거대해도 단 4바이트만 전달되므로 속도도 빠르다. 복사 생성자가 객체의 포인터를 전달받도록 다음과 같이 수정해 보자.

 

Person(const Person *Other) {

     Name=new char[strlen(Other->Name)+1];

     strcpy(Name,Other->Name);

     Age=Other->Age;

}

 

Other의 타입이 Person *로 바뀌었고 본체에서 Other의 멤버를 참조할 때 . 연산자 대신 -> 연산자를 사용하면 된다. 그러나 이렇게 하면 Person Young=Boy; 선언문이 암시적으로 호출하는 생성자인 Person(Boy)와 원형이 맞지 않다. 사실 포인터를 취하는 생성자는 복사 생성자로 인정되지도 않는다. 꼭 포인터로 객체를 복사하려면 main의 객체 선언문이 Person Young=&Boy;가 되어야 하는데 그래야 Person 복사 생성자로 Boy의 번지가 전달된다. main 함수까지 같이 수정하면 정상적으로 잘 동작한다.

그러나 이는 일반적인 변수 선언문과 형식이 일치하지 않는다. 기본 타입의 복사 생성문을 보면 int i=j; 라고 하지 int i=&j;라고 선언하지는 않는다. 즉 포인터를 통한 객체 복사 구문은 C 프로그래머가 알고 있는 상식적인 변수 선언문과는 틀리다. 클래스가 기본형과 완전히 같은 자격의 타입이 되려면 int i=j; 식으로 선언할 수 있어야 한다.

그래서 객체 이름에 대해 자동으로 &를 붙이고 함수 내부에서는 전달받은 포인터에 암시적으로 *연산자를 적용하는 레퍼런스라는 것이 필요해졌다. 복사 생성자가 객체의 레퍼런스를 받으면 Young=Boy라고 써도 실제로는 포인터인 &Boy가 전달되어 속도 저하나 무한 호출없이 기본 타입과 똑같은 형식의 선언이 가능하다. 이후 공부하게 될 연산자 오버로딩에도 똑같은 이유로 레퍼런스가 활용된다. C에서는 꼭 필요치 않았던 레퍼런스라는 개념이 C++에서는 필요해진 이유가 객체의 선언문, 연산문을 기본 타입과 완전히 일치시키기 위해서이다.

복사 생성자로 전달되는 인수는 상수일 수도 있고 아닐 수도 있는데 내부에서 읽기만 하므로 개념적으로 상수 속성을 주는 것이 옳다. int i=j; 연산 후 j의 값이 그대로 유지되어야 한다. 결론만 요약하자면 Class 클래스의 복사 생성자 원형은 Class(const Class &)여야 한다.

디폴트 복사 생성자

클래스가 복사 생성자를 정의하지 않으면 컴파일러가 디폴트 복사 생성자를 만든다. 컴파일러가 만드는 디폴트 복사 생성자는 멤버끼리 1:1로 복사함으로써 원본과 완전히 같은 사본을 만들기만 할 뿐 깊은 복사를 하지는 않는다. 만약 디폴트 복사 생성자만으로 충분하다면(Position 클래스의 경우) 굳이 복사 생성자를 따로 정의할 필요는 없다. 이때 만들어지는 디폴트 복사 생성자는 다음과 같을 것이다.

 

Position(const Position &Other) {

     x=Other.x;

     y=Other.y;

     ch=Other.ch;

}

 

대응되는 멤버끼리 그대로 대입하는데 전부 단순 타입이라 대입만 하면 잘 복사된다. 이런 디폴트 복사 생성자가 있기 때문에 별도의 조치가 없어도 Position There=Here가 잘 동작하는 것이다.

또한 Class A=B; 식의 선언을 하지 않거나 객체를 함수의 인수로 사용할 일이 전혀 없다는 것이 확실하다면 이때도 복사 생성자가 필요없다. 그러나 이런 가정은 무척 위험할 수 있다. 왜냐하면 클래스의 사용자는 클래스가 일반 타입과 동등하므로 int, double에서 가능한 일들은 클래스에 대해서도 모두 가능하다고 기대하며 실제로 그런 코드를 작성하기 때문이다. 이 기대에 부응하기 위해 클래스는 모든 면에서 기본 타입과 완전히 같아야 한다.

Person2 예제에서 복사 생성자를 정의함으로써 Person 클래스는 이미 생성된 객체로부터 새로운 객체를 선언할 수 있게 되었다. Person 클래스가 점점 기본 타입과 같아지고 있지만 이 클래스는 아직까지도 불완전하다. Person 클래스가 완전한 타입이 되려면 대입 연산자를 재정의해야 하는데 이 실습은 다음에 다시 해 보도록 하자.