[C++] 05. 복사 생성자

kkado·2023년 10월 14일
0

열혈 C++

목록 보기
5/16
post-thumbnail

💬 윤성우 님의 <열혈 C++ 프로그래밍> 책을 혼자 공부하며 배운 내용을 정리합니다. 글의 모든 내용은 책에서 발췌하였습니다.


복사 생성자

우리는 이제껏 C와 C++을 배우면서 변수와 참조자를 다음과 같이 선언 및 초기화했다.

int num = 20;
int &ref = num;
// 또는 
int num(20);
int &ref(num);

그럼 다음과 같은 코드의 결과는 어떨까?

class Simple
{
private:
    int num1;
    int num2;
public:
    Simple(int n1, int n2) : num1(n1), num2(n2)
    {}

    void showData()
    {
        cout << num1 << num2 << "\n";
    }
};

int main(void)
{
    Simple obj1(30, 40);
    Simple obj2 = obj1;

    obj2.showData();
}

이 코드는 정상 작동한다. obj1와 같은 멤버 변수값을 가지는 또다른 객체가 만들어진다.
클래스라고 어렵게 생각하지 말고 다음 코드를 보면 이해가 쉽다.

int a = 10;
int b = a;
cout << b << "\n";

a의 값을 그대로 복사하여 새로운 주소에 같은 값을 만든 것이다.

그렇다면 obj2new Simple도 아니고 Simple obj2(30 ,40)도 아닌, obj1을 대입하고 있는데 어떻게 생성이 된 것일까.

Simple sim2 = sim1;

이라는 문장은 사실 다음과 같은 형태로 묵시적 변환이 되어 객체가 생성된다.

Simple sim2(sim1);

이 문장은 다음과 같이 진행된다.

  • Simple 객체를 생성한다.
  • 그 이름은 sim2로 한다.
  • Simple 클래스 안에 Simple을 인자로 받을 수 있는 생성자를 호출해서 객체 생성을 완료한다.

그런데 우리는 Simple을 인자로 받는 생성자를 만든 적이 없는데?

먼저 다음과 같은 코드를 확인해 보면

class Simple
{
private:
    int num1;
    int num2;

public:
    Simple(int n1, int n2) : num1(n1), num2(n2)
    {

    }

    Simple(Simple &copy) : num1(copy.num1), num2(copy.num2)
    {
        cout << "Called Simple(Simple &copy)" << "\n";
    }

    void showData()
    {
        cout << num1 << " " << num2 << "\n";
    }
};

int main(void)
{
    Simple obj1(30, 40);
    cout << "생성 및 초기화 전\n";
    Simple obj2 = obj1;
    cout << "생성 및 초기화 후\n";
    obj2.showData();
    /* 실행 결과 :
    생성 및 초기화 전
    Called Simple(Simple &copy)
    생성 및 초기화 후
    30 40
    */
}

main 함수에서의 Simple obj2 = obj1 은 묵시적으로 Simple obj2(obj1)로 변환되고, 두 번째 생성자가 호출된다.

그리고 이런 생성자를 가리켜 복사 생성자(copy constructor)라고 한다. 복사 생성자는 일반 생성자는 호출되는 시점이 차이가 있다.

좀 더 엄밀히 말하면 위 코드의 복사 생성자를 다음과 같이 수정해야 보다 일반적인 복사 생성자라고 할 수 있다.

Simple(const Simple &copy) : num1(copy.num1), num2(copy.num2)
    {
        cout << "Called Simple(Simple &copy)" << "\n";
    }

혹시나 실수로라도 복사의 원본으로서 사용되는 &copy의 원본을 바꾸는 일이 없도록 const 인자로 설정해 주는 것이 좋다.


디폴트 복사 생성자

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

앞서 우리는 Simple 클래스를 인자로 갖는 생성자를 정의한 적이 없었는데도 불구하고 잘 실행된 것을 볼 수 있는데, 이는 디폴트 복사 생성자가 자동으로 삽입되었기 때문이다.

class Simple
{
private:
    int num1;
    int num2;
public:
    Simple(int n1, int n2) : num1(n1), num2(n2)
    {}

    void showData()
    {
        cout << num1 << num2 << "\n";
    }
};

따라서 이런 클래스는,

class Simple
{
private:
    int num1;
    int num2;
public:
    Simple(int n1, int n2) : num1(n1), num2(n2)
    {}
    
    Simple(const Simple& copy) : num1(copy.num1), num2(copy.num2)
    {}

    void showData()
    {
        cout << num1 << num2 << "\n";
    }
};

와 동일하다.


explicit

앞서, Simple sim2 = sim1 이라는 문장이 묵시적으로 Simple sim2(sim1) 로 변환되어 복사 생성자가 호출된다고 하였는데 이러한 묵시적 변환이 마음에 들지 않는다면 explicit 키워드를 사용하면 된다.

explicit Simple(Simple &copy) : num1(copy.num1), num2(copy.num2) {}

이렇게 만들어주면 더이상 묵시적 형변환이 일어나지 않는다.

묵시적 형변환은 복사 생성자에서뿐만 아니라 인자가 하나인 생성자가 있을 때도 일어난다.

class Simple
{
private:
    int num1;

public:
    Simple(int n1) : num1(n1)
    {}
};

int main(void)
{
    Simple A = 3;
}

Simple A = 3;Simple A(3) 으로 묵시적 형변환이 이루어진다.

이것 역시 explicit 키워드를 통해 막을 수 있다.


깊은 복사, 얕은 복사

다음의 코드는 문제가 발생한다. 이유를 생각해 보자.

class Person
{
private:
    char* name;
    int age;

public:
    Person(char* myName, int myAge)
    {
        int len = strlen(myName) + 1;
        name = new char[len];
        strcpy(name, myName);
        age = myAge;
    }

    void showPersonInfo() const
    {
        cout << name << age << "\n";
    }

    ~Person()
    {
        delete []name;
        cout << "called destructor\n";
    }

};

int main()
{
    Person man1("Lee", 21);
    Person man2 = man1;
    
    man1.showPersonInfo();
    man2.showPersonInfo();
}

실행 결과는 다음과 같다.

Lee 21
Lee 21
called destructor

클래스 객체는 두 개가 있는데 왜 소멸자가 한 번만 호출되었을까.

문제점은 main 함수의 Person man2 = man1; 에서 진행되는 얕은 복사에 있다. 이 문장에 의해 디폴트 복사 생성자가 호출된다.

그런데 디폴트 복사 생성자는 멤버를 단순 복사만 하는데 따라서 다음의 구조를 띤다.

아마 아래와 같은 결과를 예상했을 수도 있다.

하나의 문자열을 두 개의 객체가 동시에 참조하는 꼴이 된다.

여기서 어느 하나의 객가 먼저 소멸하여 소멸자의 delete []name이 실행되면 문자열이 소멸된다.
이어 나머지 다른 객체의 소멸자가 name을 소멸시키려고 했더니 이미 소멸되어 있는 문제가 발생한다. 그래서 아래와 같은 double free 문제가 발생했다고 뜬다.

따라서 복사 생성자를 정의할 때는 이와 같은 문제가 발생하지 않게 조심해야 한다.


깊은 복사를 위한 복사 생성자

포인터만을 복사하는 복사를 얕은 복사라고 하고, 포인터로 참조하는 대상까지 복사하는 것을 깊은 복사라고 한다.

Person(const Person& copy)
{
      name = new char[strlen(copy.name) + 1];
      strcpy(name, copy.name);
      age = copy.age;
}

복사 생성자를 직접 만들어서 새롭게 new char을 만들고 거기다가 strcpy 하면 된다. 이렇듯 깊은 복사를 할 수 있는 복사 생성자의 정의는 어렵지 않다.


복사 생성자의 호출 시점

앞서 복사 생성자는 일반 생성자는 호출되는 시점이 차이가 있다고 하였다. 복사 생성자가 호출되는 시점은 3가지가 있다. 우선 다음의 상황에서 복사 생성자가 호출된다는 것을 이미 알고 있다.

Person man1 = ("Park", 25);
Person man2 = man1;

이 외에도 두 가지의 경우가 더 있다.

복사 생성자가 호출되는 시점은 3가지로 구분할 수 있다.

  • 기존에 생성된 객체를 이용해 새로운 객체를 초기화하는 경우
  • call by value 방식의 함수 호출 과정에서 객체를 인자로 전달하는 경우
  • 객체를 반환하되 참조형으로 반환하지 않는 경우

이들은 모두 다음의 공통점을 지닌다.

객체를 새로 생성해야 하고, 생성과 동시에 동일한 자료형의 객체를 가지고 초기화해야 한다.

중요한 개념이니까 잘 알아두자 ...


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

메모리 공간의 할당과 동시에 초기화되는 대표적인 예는 다음과 같다.

int num1 = num2;

num1이라는 이름의 메모리 공간을 할당함과 동시에 num2에 저장된 값으로 초기화시키고 있다.

또한 이런 경우도 있다.

void someFunc(int n) 
{
}

int main()
{
    int num = 10;
    someFunc(num);
}

함수가 호출되는 순간 매개변수 n이 할당되면서 초기화된다.

호출되는 순간 말고 반환되는 순간에도 할당과 동시에 초기화된다.

int someFunc(int n) 
{
	return n;
}

int main()
{
    int num = 10;
    cout << someFunc(num) << "\n";
}

반환하는 순간 값이 할당 및 초기화된다니 조금 의아할 수 있으나, 반환받은 곳에서 값을 사용할 수 있어야 하므로 새로운 공간에 값을 할당해야 한다. 위의 cout 부분에서 할당이 되지 않았다면 값을 불러와서 출력할 수 없을 것이다.

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

할당 이후 복사 생성자를 통한 초기화

복사 생성자가 호출되면서 초기화가 이루어지는 과정을 보기 위해 다음의 코드를 확인하자.

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

    Simple(const Simple& copy) : num(copy.num)
    {
        cout << "called Simple(const Simple& copy)\n";
    }

    void showData()
    {
        cout << "num: " << num << "\n";
    }
};

void simpleFunc(Simple ob)
{
    ob.showData();
}

int main()
{
    Simple obj(7);
    cout << "함수호출 전\n";
    simpleFunc(obj);
    cout << "함수호출 후\n"; 
}

실행결과는 아래와 같다

함수호출 전
called Simple(const Simple& copy)
num: 7
함수호출 후

복사 생성자가 호출된 것을 확인하였다. 복사 생성자의 호출 주체는 누구일까, ob일까 obj일까?

ob이다. simpleFunc에서 ob 객체가 생성되었고 동시에 obj의 멤버 변수를 가지고 복사 생성자가 호출되었다.


이제 마지막 경우이다. 중요하니까 잘 봐야한다.

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

    Simple(const Simple& copy) : num(copy.num)
    {
        cout << "called Simple(const Simple& copy)\n";
    }

    Simple& addNum(int n)
    {
        num += n;
        return *this;
    }

    void showData()
    {
        cout << "num: " << num << "\n";
    }
};

Simple simpleFunc(Simple ob)
{
    cout << "return 이전\n";
    return ob;
}

int main()
{
    Simple obj(7);
    simpleFunc(obj).addNum(30).showData();
    obj.showData();
}

실행결과 :

called Simple(const Simple& copy)
return 이전
called Simple(const Simple& copy)
num: 37
num: 7

이 예제를 이해하기 위해서는 참조형을 반환하는 addNum 함수를 잘 이해해야 한다. addNum 함수는 자기 자신을 반환하고 있는데, 반환형이 참조형이므로 참조 값이 반환된다.

그리고 simpleFunc 함수의 매개변수 ob 객체의 생성 및 초기화 과정에서 실행결과의 첫 줄에 해당하는 복사 생성자가 호출 및 출력되었다.

이후 return 이전 이 출력되고, ob를 반환하는 과정에서 또다른 임시 객체가 생성 및 ob 객체로 초기화 된다. 그리고 마찬가지로 복사 생성자가 호출된다. 그리고 함수가 종료되었으므로 ob 객체는 사라진다.

이 임시 객체가 addNum 함수를 실행해서 멤버 변수를 30 증가시켰고 임시 객체를 그대로 반환했으며 이를 이용해 showData 함수를 호출했다.

그럼 showData() 함수를 호출한 객체는 임시 객체이고, 여기서 37이 출력되었다.

그리고 obj 객체는 값이 증가되지 않았으므로 여전히 obj.num은 7이다.


chapter 02에서 상수의 참조 부분을 다룰 때 임시변수라는 개념이 소개되었는데 따라서 임시 객체라는 개념도 그렇게 낯선 개념은 아니다.

위의 코드에서 두 번의 showData 함수의 결과값이 서로 다름을 통해 임시 객체의 개념을 보다 잘 이해할 수 있다.


반환에 만들어진 임시 객체는 언제 사라지나

이렇게 만들어진 임시 객체가 언제 사라지는지도 궁금하다. 소멸자에다 출력문을 넣어서 사라지는 시점을 확인해 보자.

class T
{
private:
    int num;
public:
    T(int n) : num(n)
    {
        cout << "create\n";
    }

    ~T()
    {
        cout << "destroy\n";
    }

    void showInfo()
    {
        cout << "num : " << num << "\n";
    }
};

int main()
{
    T(100);
    cout << "------------------------\n";

    T(200).showInfo();
    cout << "------------------------\n";

    const T &ref = T(300);
    cout << "------------------------\n";
}

첫번째 부분에서는 객체의 선언 없이 임시 객체만을 만들었고, 두 번째 부분에서는 임시 객체를 만들고 이를 이용해 showInfo를 실행한다. 세 번째 부분에서는 임시 객체를 만들고 이를 ref 객체 참조자로 참조시킨다.

실행 결과는 다음과 같다.

create
destroy
------------------------
create
num : 200
destroy
------------------------
create
------------------------
destroy

임시 객체는 생성 시에 참조 값 이라는 것을 반환한다.
따라서 두 번째 부분에서 T(200) 으로 임시 객체를 만들고, 이 부분에서 참조 값을 반환하기 때문에 .showInfo() 를 실행할 수 있다.
또한 세 번째 부분과 같이, 반환한 참조 값을 객체 참조자 ref로 참조할 수도 있다.

위의 실행 결과를 보고 다음과 같은 결론을 내릴 수 있다.

임시 객체는 다음 행으로 넘어가면 바로 소멸된다. 그러나 참조자에 의해 참조되고 있는 임시 객체는 바로 소멸되지 않는다.

임시 객체는 이름이 없기 때문에, 다음 행으로 넘어가면 더이상 접근이 불가능해지고 따라서 바로 소멸된다. 그러나 임시 객체를 참조자로 참조하면, 이 참조자를 통해 접근이 가능하므로 바로 소멸시키지 않는다.


이제 아래 예제를 확인하여 객체의 생성과 소멸을 정리하자.

class T
{
private:
    int num;
public:
    T(int n) : num(n)
    {
        cout << "create new obj : " << this << "\n";
    }

    T(const T& ref) : num(ref.num)
    {
        cout << "new copy obj : " << this << "\n";
    }

    ~T()
    {
        cout << "destroy obj : " << this << "\n";
    }
    
};

T simpleFunc(T ob)
{
    cout << "param addr : " << &ob << "\n";
    return ob;
}

int main()
{
    T obj(7);
    simpleFunc(obj);

    cout << "\n";
    T tempRef = T(obj);
    cout << "return obj " << &tempRef << "\n";
}

this 포인터를 이용해 주소를 확인하면서 어떤 식으로 복사 및 소멸이 이루어지고 있는지 확인하자. 실행 결과는 아래와 같다.

create new obj : 0x16d7df0dc // obj 생성
new copy obj : 0x16d7df0d4 // simpleFunc(obj)에서 ob 생성 (주소값이 다르다)
param addr : 0x16d7df0d4 // ob의 주소값 (obj와 다름)
new copy obj : 0x16d7df0d8 // ob를 반환하면서 만들어진 임시 객체 (ob와 주소값이 다르다)
destroy obj : 0x16d7df0d8 // 반환값을 받는 부분이 없으므로 바로 소멸
destroy obj : 0x16d7df0d4 // ob 역시 함수가 종료되어 소멸

new copy obj : 0x16d7df0c0 // tempRef 복사 생성 (주소값 다름)
return obj 0x16d7df0c0
destroy obj : 0x16d7df0c0 
destroy obj : 0x16d7df0dc

소멸 시점이 같을 때는 늦게 할당된 객체가 먼저 소멸한다.


profile
베이비 게임 개발자

0개의 댓글