220106, C++ Ch.05-2

05-2

'깊은 복사', '얇은 복사'

얕은 복사

앞서 학습한 "디폴트 복사 생성자"는 멤버 대 멤버의 복사를 진행한다. 그리고 이런 복사들을 "얇은 복사(shallow copy)"라고 하는데, 이는 멤버변수가 Heap의 메모리 공간을 참조하는 경우에 문제가 발생한다.

아래 예시를 보자.

/* example.cpp */

#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 name[len];
        strcpy(name, myname);
        age = myage;
    }

    void ShowPersonInfo() const {
        cout<<"Name : "<<name<<endl;
        cout<<"Age : "<<name<<endl;
    }

    ~Person() { // 소멸자
        delete []name; // 앞서 Person에서 할당한 리소스(메모리 공간)를 소멸시킨다!
        cout<<"DESTRUCTOR"<<endl;
    }

}

int main() {
    Person man1("Lee", 25);
    Person man2 = man1;
    man1.ShowPersonInfo();
    man2.ShowPersonInfo();

    return 0;
}
실행 결과
Name : Lee
Age : 25
Name : Lee
Age : 25
DESTRUCTOR

뭔가 이상하다?

우리 생각대로라면, 결과는 아래와 같이 나타나야한다.

Name : Lee
Age : 25
Name : Lee
Age : 25
DESTRUCTOR
DESTRUCTOR

근데 첫번째 정보가 출력되고 나오는 DESTRUCTOR가 실행결과를 보면 출력되지 않아있다는 것을 알 수 있다.

왜 그럴까..?

저기서 man2가 생기는 과정을 그림으로 보자.

이렇게, 디폴트 복사 생성자를 통해 "단순히 복사"만 되므로, 위의 그림과 같이 복사된다. 우리가 원하는건 man1이 "통째로" 복사되어서 man1.name과 man2.name의 주소와 가리키는 대상이 각각 달라야하는데, man1과 man2의 name은 "똑같은 주소 (즉, 완전 똑같은 대상)"를 가리키는 것이다. 즉, 하나의 대상을 두개의 객체가 동시에 참조해 버리는 것.

이런 문제가 불씨가 되어 객체의 소멸과정에서 화제가 일어난다. (?)

만약, man2의 객체의 소멸자가 호출되며 delete []name이 호출되면, 어떤 상황이 벌어질까.

소멸자가 호출되며 원래 name이 가리키던 주소 0xF6에 저장되어있던 Lee의 메모리 공간이 해제되었다.

그다음 man1의 소멸자가 호출되어야 하는데. 어라. man1의 소멸자에서 delete []name; 은 어떻게 부를까..

이렇듯, 이미 지워진 문자열을 대상으로 delete 연산을 하기에 문제가 되고, 위와 같은 결과가 발생할 것이다.

그래서, 복사 생성자를 정의할 때 이런 문제가 발생하지 않도록 해야한다.

깊은 복사

앞선 얇은 복사에서의 문제를 해결할 수 있는 방법이다. 그냥 아예 "통째로" 복사해버리기에, 깊은 복사(deep copy) 라고 한다. 멤버"만" 복사하는게 아니라, 포인터로 참조하는 그 대상까지 깊게 복사한다고 생각하면 된다.

이 깊은 복사를 사용하면, 위의 예시가 아래의 그림처럼, 우리가 원하는 대로 복사가 된다.

이렇게!

그러면, 이 깊은 복사가 이뤄지려면, 어떻게 되어야하냐.

정말 별거 없다..

디폴트 복사 생성자가 없도록, 그냥 복사 생성자를 하나 정의해주면 된다!

위의 example.cpp의 Person class에

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

이 복사생성자만 추가해주면 된다! 보다시피, 앞서 공부한 복사생성자의 정말 전형적인 예시다.

이 생성자가 하는 일은

  1. 멤버변수 age를 멤버 대 멤버로 복사하고
  2. 메모리 공간을 따로 할당해준뒤 strcpy를 통해 문자열 복사. 이후 이 주소값을 name에 저장!

과 같다. 그래서, 우리가 원하는 형태로 객체 복사가 이뤄지게 된다.

05-3

복사 생성자의 호출 시점

이제, 복사 생성자 정의에 대해서 어느정도 도가 텄는데, 이제 복사 생성자가 호출되는 시점에 대해서 알아보자.

일단, 우리가 알아본 "기초적인" 복사 생성자의 호출 예시는 아래와 같다.

ACLass exam1("Lee", 30);
ACLass exam2 = exam1;

하지만, 이게 복사생성자의 호출 시점에 관한 전부는 아니다. 이 경우를 포함하여 총 세 가지의 경우에 복사 생성자가 호출된다.

1. 기존에 생성된 객체를 이용하여 새로운 객체를 초기화할 때
2. Call-By-Value 방식의 함수호출 과정서, 객체를 인자로 전달할 때
3. 객체를 반환하지만, 참조형의 반환이 아닐 때

그리고, 이 세 경우는 "객체를 새로 생성해야 하고, 생성과 동시에 동일한 자료형의 객체로 초기화해야 한다"는 공통점이 있다.

각 case별로 설명을 하겠다.

그리고 "메모리 공간이 할당과 동시에 초기화되는 상황" 은 쉽게 표현하면 아래와 같다.

int num1 = num2;

case 1, "기존에 생성된 객체를 이용하여 새로운 객체를 초기화할 때"

별거 없다. 바로 위에서 말한

int num1 = num2;

얘를 생각해라.

case 2, "Call-By-Value 방식의 함수호출 과정서, 객체를 인자로 전달할 때"

예시를 보자.

int ExampleFunc(int n) {
    . . . .
}

int main() {
    int num = 10;
    ExampleFunc(num); // 호출과 동시에 매개변수 n이 할당 + 초기화!
    . . .
}

ExampleFunc(num)문장을 보면, num이 호출됨과 동시에 int n = num; 으로 초기화된다. ExampleFunc을 보면 Call-By-Value로 함수의 외부에 선언된 변수에 접근할 수 없게 되어있다. 객체 num을 인자로 전달하며, case 2에 부합하다는 걸 알수있다.

case 3, "객체를 반환하지만, 참조형의 반환이 아닐 때"

이것도 예시를 보자.

int ExampleFunc(int n) {
    . . . .
    return n; // 반환과 동시에 메모리 공간 할당 + 초기화.
}

int main() {
    int num = 10;
    cout<<ExampleFunc(num)<<endl;
    . . .
}

자 우선, ExampleFunc를 보면 객체인 n값(정수)을 반환한다.

그리고, 메인함수의 cout<<ExampleFunc(num)<<endl;을 보면 ExampleFunc함수를 호출하여 반환받은 n값을 출력한다.

만약 ExampleFunc(num)을 통해 반환된 n값이 따로 메모리공간에 저장되어있지 않았다면, cout을 통해 출력이 가능했을까?

값을 출력하는 과정을 보면, 해당 값 참조해야함 -> 참조하기위해 메모리 공간의 어딘가에 저장된 값이 존재해야함. 그래서, case 3번은 이 예시로 설명할 수 있다.

"함수가 값을 반환하면 별도의 메모리 공간이 할당되고, 이 공간에 반환 값이 초기화됨과 동시에 저장된다" 라는 사실을 알아두면 된다.

요정-도

자. 여기까지 복사 생성자가 호출되는 시점의 세가지 상황을 훑어봤다.

이걸 설명하며 int형 변수로 예를 들었는데, 이걸 우리가 앞에서 흔히 배운 class 객체를 고대로 갖다붙여도 가능하다.

case 1 )

SoSimple obj2 = obj1;

case 2 )

SoSimple Func(SoSimple ob) {
    . . . . 
}
int main() {
    SoSimple obj;
    Func(obj);
    . . . . 
}

여기서 Func이 호출되며, 매개변수로 선언된 객체 ob가 생성된다. 그리고 인자로 전달된 obj객체로 초기화 된다.

즉, SoSimple ob = obj가 된다고 생각하면 된다는 것이다.

case 3 )

위의 Func 함수를

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

이렇게 바꿔주면 된다.

할당 이후, 복사 생성자를 통해 진행되는 초기화

객체가 생성되고 초기화 되는 세가지 경우가 있었다. 각 경우에 초기화는 언제 이루어질까?

초기화는 멤버 대 멤버로 복사가 이뤄진다.

그럼 뻔하다.

"복사 생성자가 호출될 때"

디폴트 복사 생성자를 보면, 멤버 대 멤버 복사가 되며 초기화가 이뤄진다.

이번엔, 연관있는 예시로 설명을 해보겠다.

class Simple {
private :
    int num;

public :
    Simple(int n) : num(n) { }
    Simple(const Simple& copy) : num(copy.num) {
        cout<<"check point"<<endl;
    }
    void Show() {
        cout<<num<<endl;
    }
};

void Func(Simple a2) {
    a2.Show();
}

int main() {
    Simple a1(5);
    Func(a1);
    return 0;
}
실행 결과
check point
5

분석해 보면, 실행 과정은 아래와 같다.

조금 난해한데, 눈으로 따라가면 이해가 될거다.

핵심은, 초기화의 대상이 "a2"라는 것이다. a2 객체가 a1으로 초기화 되며, a2의 복사 생성자가 호출되고, a1이 인자로 전달된다.

이번엔, 다음 예시다.

class Simple {
private :
    int num;

public :
    Simple(int n) : num(n) { }
    Simple(const Simple& copy) : num(copy.num) {
        cout<<"check point"<<endl;
    }
    Simple& Add(int n) {
        num += n;
        return *this;
    }
    void Show() {
        cout<<num<<endl;
    }
};

Simple Func(Simple a2) {
    cout<<"hi"<<endl;
    return a2;
}

int main() {
    Simple a1(5);
    Func(a1).Add(22).Show();
    a1.Show()
    return 0;
}
실행 결과
check point
hi
check point
27
5

좀 난해하다..

여기서 임시 객체라는 개념이 등장하는데, 조금 복잡할 수도 있다. 일단 잠시 넘기고. 왜 27이랑 5가 따로 출력되는지를 보자.

Func(a1).Add(22).Show();

여기에서 Func(a1)으로 반환된 임시객체를 대상으로(임의의 a3라고 생각하자), Add 함수를 호출한다. 그로 인해 임시 객체 a3에 저장된 값이 22 증가한다. 그리고 이어서 Show를 통해 임시객체에 저장된 값을 출력한다.

즉, 27이 출력.

그리고, a1.show()를 통해 a1에 저장되어있던 5를 출력한다.

임시객체가 궁금할텐데, 다음 시간에 알려주겠다. (아직 공부 안함)

여기까지.

좋은 웹페이지 즐겨찾기