read

열두번째 열혈강의


radio

이전에 복사생성자에 대해 알아보고 있었다. 이어서 진행해 보도록 하자.


깊은 복사와 얕은 복사


꽤 재밌는 개념으로 넘어왔다. 보통 이런 문제에 있어서는 기초적으로 다루고 가야 후에 알고리즘이나 자료구조를 공부한다 할 때 도움이 될 것이다. (사실 포인터랑 크게 다른게 없다.)

디폴트 복사 생성자는 멤버 대 멤버의 복사를 진행한다. 그리고 이러한 방식의 복사를 가리켜 얕은 복사(shallow copy) 라고 하는데, 이는 멤버변수가 힙의 메모리 공간을 참조하는 경우에 문제가 된다. 그럼 하나씩 확인해 보자.


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


다음 예제는 얖서 생성자와 소멸자를 공부할 때 보았던 예제의 main을 조금 수정한 것이다. 컴파일은 되는데 실행을 하면 문제가 생긴다. 그럼 이 원인을 한번 유추해 보자.

#include <iostream>
#include <cstring>

using namespace std;

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 << endl;
        cout << "나이 : " << age << endl;
    }
    ~Person(){
        delete[] name;
        cout <<"called destructor!" << endl;
    }
};

int main()
{
    Person man1("Lee dong woo" , 29);
    Person man2 = man1;
    man1.ShowPersonInfo();
    man2.ShowPersonInfo();
    return 0;
}

위 예제가 컴파일러에 따라 똑바로 실행될 수도 있다. 그러나 일반적인 경우, 소멸자가 한번만 나오는 상태가 될 경우가 많다. (나는 똑바로 실행되었지만 책에서도 예제의 문제점에 대해 집중을 하길 바란다고 한다.)

Person man2 = man1;

문제점은 바로 이전 복사생성자에 있어서의 문제다. 얕은 복사가 진행되었기 때문에, 멤버 대 멤버를 단순히 복사만 하는 형태가 된다.

즉, 해당 서로의 주소를 공유하는 형태가 되어버리고, 복사의 결과로 하나의 문자열을 두 개의 객체가 동시에 참고하는 꼴이 되고 만다.

이러한 형태로 인해 소멸자의 과정에서 오류가 나오게 되는 것이다.

그럼 순차적으로 생각해볼때, 만약 man2가 먼저 소멸되었다고 생각하자. man2와 man1은 내용물의 주소를 공유하고 있기 때문에 man2가 소멸되면 man1역시 이미 소멸되어 있는 상태다. 그러므로 man1에서 delete []name이 실행될 때는 문제가 일어나기 마련이다.


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


그럼 여기서 깊은 복사(deep copy)의 형태가 실행이 될 수 있도록, 복사 생성자를 정의해 보도록 하자. 여기서 깊은 복사는 멤버 뿐만 아니라, 포인터로 참조하는 대상까지 깊게 복사하는 것이다.

밑 코드를 보기전에, 스스로 한번 도전해보고 보도록 하자.

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

위 생성자만 클래스 내에 추가해주면 된다.

여기서 생성자가 하는 일은 2가지 이다.

멤버 변수 age의 멤버 대 멤버 복사

메모리 공간 할당 후 문자열 복사, 그리고 할당된 메모리 주소 값을 멤버 name에 저장

크게 어렵지 않을 것이다. 또한 다양한 정의가 있는 것도 아니므로 쉽게 다가갈 수 있다.


복사 생성자의 호출시점


이제 우리는 복사 생성자에 대해 알게 되었다. 그럼 마지막으로 복사생성자가 호출되는 3가지에 대해 알아보도록 하자.

일단 다음의 경우에 대해서는 우리는 이미 알고 있다.

Persion man("Lee dong woo", 29);

Person man2 = man1;

그렇지만 이게 전부는 아니다. 이를 포함해서 복사 생성자가 호출되는 시점은 크게 3가지로 분류된다.

기존에 생성된 객체를 이용해서 새로운 객체를 초기화 하는 경우(앞선 경우)

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

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

이것들은 모두 공통점이 있다. (이후에 좀더 설명할 것이다.)

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

따라서 위의 문장이 의미하는 상황에서 복사 생성자는 호출된다. 그런 이미 알고 있는 경우 외의 것들에 대해 알아보도록 하자. 이 부분에 대한 이해는 굉장히 중요하니 잘 알아두도록 한다.


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


먼저 메모리 공간이 할당과 동시에 초기화되는 상황을 나열해 보자.

int num1 = num2;

가장 대표적인 예이다. 이것은 num1이라는 이름의 메모리 공간을 할당과 동시에 num2에 저장된 값으로 초기화시키는 문장이다.

즉, 할당과 동시에 초기화가 이뤄진다. 그럼 다음의 경우도 같다

int SimpleFunc(int n){
...
}
int main(){
	int num = 10;
    SimpleFunc(num);
}

위 코드에서 함수가 호출되는 순간에 파라미터 n이 할당과 동시에 변수 num에 저장되어 있는 값으로 초기화 된다. 이렇든 파라미터도 함수가 호출되는 순간에 할당되므로, 이 상황도 그렇다고 할 수 있다. 마지막으로 다음 경우도 보자 (조금 난해할 수 있다.)

int SimpleFunc(int n){
...
	return n;
}
int main(){
	int num = 10;
    cout << SimpleFunc(num) << endl;
}

정수를 반환하는 데 메모리 공간이 할당되냐라는 질문이 있을 수 있다. 또한 반환되는 값을 새로운 변수에 저장하는 것도 아니기 때문이다.

하지만 반환되는 값을 저장하는 것과 별개로 값을 반환하면 반환된 값은 별도의 메모리 공간이 할당되어서 저장이 된다.

cout << SimpleFunc(num) << endl;

위 코드를 보면 반환되는 값을 메모리 공간의 어딘가에 저장되지 않았다면, 출력이 불가능 할 것이다. 값의 출력은 그 값을 참조할 수 있어야 하고 참조가 가능하려면 메모리 공간의 어딘가에 저장되어야 한다. 그럼 결론을 내료보자(C언어를 통해 이 결론이 이미 내려져 있다면 완벽하다.)

함수가 값을 반환하면, 별도의 메모리 공간이 할당되고, 이 공간에 반환 값이 저장된다.(반환값으로 초기화 된다.)

이렇게 메모리 공간이 할당되면서 동시에 초기화되는 세가지 상황을 정리했다. 이 4가지 경우는 객체를 대상으로 해도 변함이 없다.

SoSimple obj2 = obj1;

SoSimple SimpleFuncObj(SoSimple ob){
...
}

int main(){
	SoSimple obj;
    SimpleFuncObj(obj);
	....
}

이렇게 함수에서도 크게 차이가 없다. 단지 객체로 바꿨을 뿐이다.

그럼 다음경우도 마찬가지라는 것이 보일 것이다.

SoSimple SimpleFuncObj(SoSimple ob){
...
return ob;
}

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


wind

앞서 3가지 객체 생성과 초기화의 경우를 정리해 보았는데, 그럼 이 때 초기화는 어떻게 이뤄지는지 알아보자. 일단 상황적으로는 초기화는 멤버 대 멤버가 복사되는 형태로 이뤄져야 한다. 그래서 다음의 방식으로 초기화를 진행해 본다.

복사 생성자의 호출

디퐅트 복사 생성자는 멤버 대 멤버가 복사되도록 정의가 되니, 이는 매우 적절한 초기화의 방식이라고 할 수 있다. 그럼 에제를 통해서 조금더 정확히 이해해 보겠다. 먼저 다음 예제를 보자.

#include <iostream>
#include <cstring>

using namespace std;

class SoSimple{
private:
    int num;
public:
    SoSimple(int n) : num(n){};
    SoSimple(const SoSimple &copy) : num(copy.num){
        cout <<"Called SoSimple(const SoSimple& copy)" <<endl;
    }
    void ShowData(){
        cout << "num : " << num << endl;
    }
};

void SimpleFuncObj(SoSimple ob){
    ob.ShowData();
}

int main()
{
    SoSimple obj(7);
    cout << "함수 호출 전 " << endl;
    SimpleFuncObj(obj);
    cout << "함수호출 후 " << endl;
    return 0;
}

실행결과를 보면 함수에 인자를 전달하는 과정에서 복사 생성자가 호출됨을 확인할 수 있다. 그리고 멤버변수 num에 저장된 값이 복사 되는 것도 보인다. 그렇다면 복사 생성자의 호출주체가 누구인지는 보이는가? obj 객체의 복사 생성자가 호출 되는 것인지, 아니면 ob 객체의 복사 생성자가 호출되는 것인지 헤깔릴 것이다.

초기화의 대상은 obj 객체가 아닌 ob 객체다. 그리고 ob 객체는 obj 객체로 초기화 된다. 따라서 ob 객체의 복사 생성자가 호출되면서, obj 객체가 인자로 전달되어야 한다.

이로써 복사 생성자의 호출 2번째 경우까지 알게 되었다. 그럼 세번째로 넘어가 보도록 하자.

#include <iostream>
#include <cstring>

using namespace std;

class SoSimple{
private:
    int num;
public:
    SoSimple(int n) : num(n){};
    SoSimple(const SoSimple &copy) : num(copy.num){
        cout <<"Called SoSimple(const SoSimple& copy)" <<endl;
    }
    SoSimple& AddNum(int n){
        num += n;
        return *this;
    }
    void ShowData(){
        cout << "num : " << num << endl;
    }
};

SoSimple SimpleFuncObj(SoSimple ob){
    cout << "return 이전" << endl;
    return ob;
}

int main()
{
    SoSimple obj(7);
    SimpleFuncObj(obj).AddNum(30).ShowData();
    obj.ShowData();
    return 0;
}

먼저 다음 두 문장의 실행 결과를 차근차근 생각해보자.

int main()
{
    SoSimple obj(7);
    SimpleFuncObj(obj);
}

일단, 인자 전달과정에서의 복사 생성자 호출은 이미 설명되었으니 이 이후의 상황을 보자.

여기서 ob가 문제다. 파라미터로 선언된 ob가 반환되어야 하는데 이 때, 임시객체 라는 것이 생성되고(임시객체에 대해서는 잠시 후에 별도로 설명한다.) 이 객체의 복사 생성자가 호출되면서 return 문에 명시된 객체가 인자로 전달된다. 즉, 최종적으로 반환되는 객체는 새롭게 생성되는 임시 객체다. 따라서 함수호출이 완료되면 지역적으로 선언된 객체 ob는 소멸되며 obj와 임시객체만이 남는다. 이제 다음 문장을 보자.

SimpleFuncObj(obj).AddNum(30).ShowData()

위 함수의 호출결과로 반환된 임시객체를 대상으로 바로 AddNum함수를 호출한다. 따라서 임시객체 내의 num은 30이 증가가 되고 이어서 ShowData 함수로 출력이 이어진다. 그리고 마지막 출력이 된 값과는 다르다. (obj에 대한 출력) ShowData 함수의 호출 대상이 두객체가 서로 별개이므로 당연하다. 즉, 여기서 임시객체의 존재를 알 수 있었다.

그럼 반환할 때의 객체는 언제 사라질까?

임시객체의 등장이 아주 생소하진 않을 것이다. 이전에 임시변수라는 것을 한 번 써본적이 있다. 임시객체도 임시변수와 마찬가지로 임시로 생성되었다가 소멸한다.

그리고 임시객체는 우리가 임의로 만들 수 있다.

#include <iostream>

using namespace std;

class Temporary{
private:
    int num;
public:
    Temporary(int n) : num(n){
        cout << "create obj : " << num << endl;
    }
    ~Temporary(){
        cout << "destroy obj : " << num << endl;
    }
    void ShowTempInfo(){
        cout << "My num is " << num << endl;
    }
};

int main(){
    Temporary(100);
    cout << "********************** after make!" << endl << endl;

    Temporary(200).ShowTempInfo();
    cout << "********************** after make!" << endl << endl;

    const Temporary &ref = Temporary(300);
    cout << "********************** end of main!" << endl << endl;
    return 0;
}

실행 결과를 가지고 얘기하기 전에

Temporary(200).ShowTempInfo();

클래스 외부에서 객체의 멤버함수를 호출하기 위해 필요한 것은 다음 세가지 중 하나다.

객체에 붙여진 이름

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

객체의 주소 값

그런데 임시 객체가 생성된 위치에는 임시객체의 참조 값이 반환된다. 즉, 위 문장의 경우 먼저 임시객체가 생성되면서 다음의 형태가 된다.

(임시객체 참조 값).ShowTempInfo();

그래서 이어서 멤버함수의 호출이 가능한 것이다.

const Temporary &ref = Temporary(300);

위의 경우는 임시객체 생성시 반환되는 참조 값이 참조자 ref에 전달되어 ref가 임시객체를 참조하게 된다.

즉, 반환을 위해서 임시객체가 생성은 되지만 이 객체는 메모리 공간에 존재하고, 이 객체의 참조 값이 반환되어서 추가 함수 호출이 진행된 것이다.

이 예제의 실행을 통해서 (특히 소멸자 실행을 통해) 내린 결론 2가지를 보자.

임시객체는 다음 행으로 넘어가면 바로 소멸된다.

참조자의 참조되는 임시객체는 바로 소멸되지 않는다.

마지막 참조자 ref를 선언했을 때는 바로 소멸자가 실행되지 않고 다음 행도 접근이 된다. 그럼 이제, 반환을 할 때 만들어지는 임시객체의 소멸시기를 확인하기 위한 예제를 보자.

#include <iostream>

using namespace std;

class SoSimple{
private:
    int num;
public:
    SoSimple(int n) : num(n){
        cout << "New Object : " << this << endl;
    }
    SoSimple(const SoSimple& copy) : num(copy.num){
        cout << "New Copy obj :" << this << endl;
    }
    ~SoSimple(){
        cout << "Destroy obj : " << this << endl;
    }
};

SoSimple SimpleFuncObj(SoSimple ob){
    cout << "Parm ADR: " << &ob << endl;
    return ob;
}

int main(){
    SoSimple obj(7);
    SimpleFuncObj(obj);

    cout << endl;
    SoSimple tempRef = SimpleFuncObj(obj);
    cout << "return obj " << &tempRef << endl;
    return 0;
}

이 사항은 중요하기 때문에 특별히 출력값도 적어두겠다. 이전에는 출력값은 직접 입력해서 확인해보라는 의미로 적지 않았었다. 하나하나 주소값을 보면서 이해하길 바란다.

New Object : 0x28fef0 //SoSimple obj(7); 생성
New Copy obj :0x28fef8 //SimpleFuncObj(obj);  인해 함수 파라미터 ob 생성
Parm ADR: 0x28fef8 //함수 내부 실행
New Copy obj :0x28fef4 // 함수 반환으로 인한 임시 객체 생성
Destroy obj : 0x28fef4 // 반환된 임시객체 소멸
Destroy obj : 0x28fef8 //파라미터 ob 소멸

New Copy obj :0x28fefc // SoSimple tempRef = SimpleFuncObj(obj); 함수 파라미터 생성
Parm ADR: 0x28fefc // 함수 내부 실행
New Copy obj :0x28feec //반환으로 인해 임시객체 생성
Destroy obj : 0x28fefc //파라미터 ob 소멸
return obj 0x28feec //실행결과 임시객체의 주소값과 동일함에 주목!!!
Destroy obj : 0x28feec //tempRef 참조하는 임시객체 소멸
Destroy obj : 0x28fef0 //obj 소멸

이 후는 다음 포스팅에 진행하겠다.

Blog Logo

구찌


Published

Image

구찌의 나도한번 해블로그

구찌의 개발 블로그 입니다.

Back to Overview