read

스물다섯번째 열혈강의


passing by

벌써 스물다섯번째 포스팅이 되었다. 최근 iOnic, Flask등을 다루다보니까 포스팅을 등한시 했는데, 아무것도 안하고 있는 것은 아니고 Bitbucket이나 github에 많은 작업들을 진행중에 있다. 할 것은 많고 시작한 것도 많은 데 항상 마무리를 못한다는 게 짜증이나서, 이 포스팅만큼은 꼭 마무리 지을것이다.

하여튼 오늘은 지난번에 이어서 대입연산자 에 대해 알아보도록 하자.


대입연산자 오버로딩


아마, 대입연산자의 오버로딩은 복사 생성자와 성격이 비슷할 것이다. 그러니 복사생성자를 잘 생각해보면서 이해하도록 해보자.

먼저 복사 생성자를 복습해보자.

  • 정의하지 않으면 디폴트 복사 생성자가 삽입된다.
  • 디폴트 복사 생성자는 멤버 대 멤버의 복사(얕은 복사)를 한다.
  • 생성자 내에서 동적 할당을 한다면, 그리고 깊은 복사가 필요하다면 직접 정의해야 한다.

그리고 바로 대입 연산자의 대표 특성을 보자.

  • 정의하지 않으면 디폴트 대입 연산자가 삽입된다.
  • 디폴트 대입 연산자는 멤버 대 멤버의 복사(얕은 복사)를 한다.
  • 연산자 내에서 동적 할당을 한다면, 그리고 깊은 복사가 필요하다면 직접 정의해야 한다.

거의 비슷하다는 걸 볼수 있다. 다만, 호출 시점 에 차이가 있는데 그 차이를 보도록 하자. 먼저 복사 생성자다.


int main(){
  Point pos1(5, 7);
  Point pos2 = pos1;
  ....
}

여기서 알아야 할 것은 새로 생성하는 객체 pos2의 초기화에 기존에 생성된 객체 pos1이 사용됬다는 것이다. 그럼 바로 대입 연산자 호출을 보자.


int main(){
  Point pos1(5, 7);
  Point pos2(9, 22);
  pos2 = pos1;
  ....
}

여기서는 중요한 점이 pos2도, pos1도 이미 생성 및 초기화가 진행된 객체 라는 점이다. 즉, 기존에 생성된 두 객체간의 대입연산 시에는 대입 연산자가 호출이 된다. 그럼 우리가 배운 연산자 오버로딩을 생각해보면,

pos2 = pos1;

은 멤버함수 오버로딩 기준으로,

pos2.operator=(pos1);

가 된다. 그럼 지금까지 예시들을 토대로 예제를 하나 보자. 대입 연산자를 추가한 클래스와 대입 연산자를 추가하지 않은 클래스를 비교해 볼 것이다.


#include <iostream>

using namespace std;

class First{
private:
    int num1, num2;
public:
    First(int n1 = 0, int n2 = 0) : num1(n1), num2(n2){}
    void ShowData() { cout << num1 << ", " << num2 << endl;}
};

class Second{
private:
    int num3 , num4;
public:
    Second(int n3 = 0, int n4 = 0) : num3(n3), num4(n4){}
    void ShowData(){cout << num3 << ", " << num4 << endl;}
    Second& operator=(const Second& ref){
        cout << "Second& operator=()" << endl;
        num3 = ref.num3;
        num4 = ref.num4;
        return * this;
    }
};

int main(){
    First fsrc(111, 222);
    First fcpy;
    Second ssrc(333, 444);
    Second scpy;
    fcpy = fsrc;
    scpy = ssrc;
    fcpy.ShowData();
    scpy.ShowData();

    First fob1, fob2;
    Second sob1, sob2;
    fob1 = fob2 = fsrc;
    sob1 = sob2 = ssrc;

    fob1.ShowData();
    fob2.ShowData();
    sob1.ShowData();
    sob2.ShowData();
    return 0;
}

위 예제로 다음 사실을 알 수 있다. 디폴트 대입 연산자가 삽입되어 멤버 대 멤버 복사가 진행된다.

게다가 fob1 = fob2 = fsrc; 이 문장으로 인해, 반환형도 예상이 가능한데,


First& operator=(const First& ref){
    num1 = ref.num1;
    num2 = ref.num2;
    return * this;
}

다음과 같은 디폴트 대입 연산자가 자동으로 삽입됨을 알 수 있다.

그러므로 직접 First클래스에 위 문장을 삽입해도 실행결과 차이는 없다.

여기서 구조체 변수간 대입연산의 결과와 비슷하다고 보기 쉬운데, 아예 본질적으로 다르다.

이건 단순 대입연산이 아니라 대입 연산자의 오버로딩 한 함수의 호출 이다.


디폴트 대입 연산자의 문제점


이 곳에도 물론 문제점이 있다. 이건 디폴트 복사 생성자의 문제와 유사(동일) 하고 그 해결책도 유사(약간의 차이가 있다)하므로 쉽게 접근할 수 있다. 그럼 문제점 부터 보자 이는 이전 디폴트 복사 생성자 문제점을 언급할 때 예시를 약간 수정한 것이다.


#include <iostream>

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("Yoon ji yul", 22);
    man2 = man1;
    man1.ShowPersonInfo();
    man2.ShowPersonInfo();
    return 0;
}


결과를 보면, 소멸자가 한번만 생성되었음을 볼 수 있다. (물론 이전에 언급했듯이, 컴파일러에 따라 두번 제대로 생성될 수 있다.)

이전에도 설명한 부분이지만 중요하므로 한번 더 언급해보겠다.

여기서의 문제점은 man2 = man1; 여기에서의 얕은 복사에 있다.

디폴트 대입 연산자는 멤버 대 멤버를 단순 복사하므로, 하나의 문자열을 두 개의 객체가 동시 참조 한다.

따라서 다음 문제가 나타난다.

  1. 문자열 “Yoon ji yul”을 가리키던 문자열의 주소 값을 잃게 된다.
  2. 얕은 복사로 인해, 객체 소멸과정에서 지워진 문자열을 중복 소멸하는 문제가 발생

먼저 첫번째 문제로 인해 소멸도 불가능한 상태가 되어 메모리 누수로 이어진다. 또한 man2 객체가 소멸되면서 delete []name을 한번 더 호출하게 되며 “Lee dong woo”가 함께 소멸되고 man1이 소멸될 때 이미 소멸된 문자열을 한번 더 소멸하게 된다.

즉, 생성자의 동적 할당을 하는 경우 디폴트 대입 연산자는 두 가지 문제를 일으키므로 다음을 확인해야 한다.

  1. 깊은 복사를 진행하도록 정의한다.
  2. 메모리 누수가 발생하지 않도록, 깊은 복사에 앞서 메모리 해제의 과정을 거친다.

그럼 대입연산자의 정의를 어떻게 가져가야 하는지 알게 됬으므로, 오버로딩을 한번 직접 구현해 보면 좋다. 밑은 좋은 예시중 하나이다.


Person& operator=(const Person& ref){
    delete []name;
    int len = strlen(ref.name)+1;
    name = new char[len];
    strcpy(name, ref.name);
    age = ref.age;
    return * this;
}


상속 구조에서의 대입 연산자 호출


대입 연산자는 생성자가 아니다. 이 말을 하는 이유는 유도 클래스의 생성자에는 아무런 명시를 하지 않아도 기초 클래스의 생성자가 호출되지만, 유도 클래스의 대입 연산자에는 아무런 명시를 하지 않으면, 기초 클래스의 대입 연산자가 호출되지 않는다. 는 사실을 말하기 위해서다. 그럼 예제를 보자.


#include <iostream>

using namespace std;

class First{
private:
    int num1, num2;
public:
    First(int n1 = 0, int n2 = 0) : num1(n1), num2(n2){}

    void ShowData() { cout << num1 << ", " << num2 << endl;}

    First& operator=(const First& ref){
        cout << "First& operator=()" << endl;
        num1 = ref.num1;
        num2 = ref.num2;
        return * this;
    }
};

class Second : public First{
private:
    int num3 , num4;
public:
    Second(int n1, int n2, int n3, int n4) : First(n1, n2), num3(n3), num4(n4){}
    void ShowData(){
        First::ShowData();
        cout << num3 << ", " << num4 << endl;
    }

    /*
    Second& operator=(const Second& ref){
        cout << "Second& operator=()" << endl;
        num3 = ref.num3;
        num4 = ref.num4;
        return * this;
    }
     */
};

int main(){
    Second ssrc(111, 222, 333, 444);
    Second scpy(0, 0, 0, 0);
    scpy = ssrc;
    scpy.ShowData();
    return 0;
}


First& operator=()
111, 222
333, 444

위 예제는 유도 클래스에 삽입된 디폴트 대입 연산자가 기초 클래스의 대입 연산자까지 호출한다는 사실을 보여준다. 그럼 주석을 제거하고 한번 보도록 하자.

결과가 조금 달라졌을 것이다.


Second& operator=()
0, 0
333, 444

그럼 다음 사실을 알 수 있다.

유도 클래스의 대입 연산자 정의에서, 명시적으로 기초 클래스의 대입 연산자 호출문을 삽입하지 않으면, 기초 클래스의 대입 연산자는 호출되지 않았서, 기초 클래스의 멤버변수는 멤버 대 멤버의 복사 대상에서 제외된다.

따라서 유도 클래스의 대입 연산자를 정의해야 한다면, 기초 클래스의 대입 연산자를 명시적으로 호출 해야 한다.


Second& operator=(const Second& ref){
    cout << "Second& operator=()" << endl;
    First::operator=(ref);
    num3 = ref.num3;
    num4 = ref.num4;
    return * this;
}

그런데 상속을 공부하고 좀 시간이 흘렀다면 다음과 같은 질문을 할 수 있다.

ref는 Second형 참조자인데, 이를 First형 참조자로 매개변수를 선언한 operator= 함수의 인자로 전달이 가능한가요?

그렇다면 한번 복습을 하는게 좋다.

앞서 우리는 C++에서 AAA형 참조자는 AAA 객체 또는 AAA를 직접 혹은 간접적으로 상속하는 모든 객체를 참조할 수 있다.는 것을 배웠다. 만일 기억나지 않는다면 앞선 포스팅이나 개인 공부자료를 다시 보도록 하자.


이니셜라이져의 성능향상


처음 이니셜라이져를 배울 때, 성능향상이 있다는 걸 언급했었다. 이제 복사 생성자와 대입 연산자를 이해했으니 그 이유를 알아보도록 하자.


#include <iostream>

using namespace std;

class AAA{
private:
    int num;
public:
    AAA(int n=0): num(n){
        cout << "AAA(int n = 0)" << endl;
    }
    AAA(const AAA& ref): num(ref.num){
        cout << "AAA(const AAA& ref"<< endl;
    }
    AAA& operator=(const AAA& ref){
        num = ref.num;
        cout << "operator=(const AAA& ref)" << endl;
        return *this;
    }
};

class BBB{
private:
    AAA mem;
public:
    BBB(const AAA& ref) : mem(ref){}
};

class CCC{
private:
    AAA mem;
public:
    CCC(const AAA& ref){mem = ref;}
};

int main(){
    AAA obj1(12);
    cout << "*****************" << endl;
    BBB obj2(obj1);
    cout << "*****************" << endl;
    CCC obj3(obj1);
    return 0;
}

실행결과를 보자.


AAA(int n = 0)
*****************
AAA(const AAA& ref
*****************
AAA(int n = 0)
operator=(const AAA& ref)

BBB 객체의 생성과정에서는 복사생성자만 출력되지만, CCC객체의 생성과정에서는 생성자와 대입 연산자까지 호출이 된다. 그 이유는

이니셜라이저를 이용하면 선언과 동시에 초기화가 이뤄지는 형태로 바이너리 코드가 생성되기 때문이다.

그러나 CCC의 경우는

생성자의 몸체부분에서 대입연사을 통한 초기화를 진행하면, 선언과 초기화를 각각 별도의 문장에서 진행하는 바이너리 코드가 생성되게 된다. 즉 이런 부분에 있어서 성능향상이 일어난다.

다음 포스팅에서는 배열의 인덱스 연산자 오버로딩을 알아보자.

Blog Logo

구찌


Published

Image

구찌의 나도한번 해블로그

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

Back to Overview