Notice
Recent Posts
Recent Comments
«   2024/12   »
1 2 3 4 5 6 7
8 9 10 11 12 13 14
15 16 17 18 19 20 21
22 23 24 25 26 27 28
29 30 31
Archives
Today
Total
관리 메뉴

SYDev

C++ Chapter 11-1 : 대입 연산자의 오버로딩 본문

Programming Lang/C++

C++ Chapter 11-1 : 대입 연산자의 오버로딩

시데브 2023. 7. 25. 21:20

대입 연산자

 대입 연산자(=)의 특성은 복사 생성자의 특성과 비슷하므로, 함께 나열하며 설명하겠다.

 

 우선 복사 생성자의 대표적 특성이다.

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

다음은 대입 연산자의 대표적 특성이다.

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

 또한, 다음 상황에서는 복사 생성자가 호출되지만,

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

 다음과 같이 두 객체 모두 이미 생성 및 초기화된 상태이면 대입 연산자가 호출된다.

int main(void)
{
    Point pos1(5, 7);
    Point pos2(9, 10);
    pos2 = 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)	//연속된 연산을 위해서 반환형은 Second의 객체
    {
        cout<<"Second& operator=()"<<endl;
        num3 = ref.num3;
        num4 = ref.num4;
        return *this;
    }
};

int main(void)
{
    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;	//operator= 함수의 반환형이 Second 객체이므로 가능

    fob1.ShowData();
    fob2.ShowData();
    sob1.ShowData();
    sob2.ShowData();

    return 0;
}
Second& operator=()
111, 222
333, 444
Second& operator=()
Second& operator=()
111, 222
111, 222
333, 444
333, 444

 위 예제를 통해서 디폴트 대입 연산자는 클래스 Second에 포함된 대입 연산자와 같다는 것을 알 수 있다.

 

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

 디폴트 대입 연산자의 문제점을 파악하기 위해 다음 예제를 살펴보자.

#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(void)
{
    Person man1("Lee dong woo", 29);
    Person man2("Yoon ji yul", 22);
    man2 = man1;
    man1.ShowPersonInfo();
    man2.ShowPersonInfo();

    return 0;
}
이름 : Lee dong woo
나이 : 29
이름 : Lee dong woo
나이 : 29
called destructor!

 위 예제에서 발생한 문제점은 두 가지가 있다.

  • 문자열 "Yoon ji yul"을 가리키던 문자열의 주소 값을 잃게 된다. -> 해당 문자열은 소멸이 불가능한 상태가 되어 메모리 누수로 이어진다.
  • 얕은 복사로 인해서, 객체 소멸 과정에서 지워진 문자열을 중복 소멸하는 문제가 발생한다.

얕은 복사의 결과
man2 객체의 소멸

 다음과 같이 직접 대입 연산자를 설정하면 이런 문제를 막을 수 있다.

#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;
    }

    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;
    }

    void ShowPersonInfo() const
    {
        cout<<"이름 : "<<name<<endl;
        cout<<"나이 : "<<age<<endl;
    }
    ~Person()
    {
        delete []name;
        cout<<"called destructor!"<<endl;
    }
};

int main(void)
{
    Person man1("Lee dong woo", 29);
    Person man2("Yoon ji yul", 22);
    man2 = man1;
    man1.ShowPersonInfo();
    man2.ShowPersonInfo();

    return 0;
}
이름 : Lee dong woo
나이 : 29
이름 : Lee dong woo
나이 : 29
called destructor!
called destructor!

 

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

  • 대입 연산자는 생성자와 달리 유도 클래스의 대입 연산자에 아무런 명시를 하지 않으면, 기초 클래스의 대입 연산자가 호출되지 않는다.

 다음 예제를 통해 자세히 살펴보자.

#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(void)
{
    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 클래스의 연산자 정의는 다음과 같이 바꿔줘야 한다.

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

 

성능 향상을 위한 이니셜라이저

 이니셜라이저를 사용하면 성능이 조금 향상되는데, 다음 예제를 살펴보며 이를 알아보자.

#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(void)
{
    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 객체의 생성과정 -> 생성자, 대입 연산자 호출

 이니셜라이저를 이용하면 선언과 동시에 초기화가 이뤄지는 형태로 바이너리 코드가 생성된다. 반면에, 생성자의 몸체 부분에서 대입연산을 통한 초기화를 진행하면, 선언과 초기화를 각각 별도의 문장에서 진행하는 형태로 바이너리 코드가 생성된다. 

 

 이처럼 이니셜라이저를 이용한 초기화는 함수의 호출 횟수를 줄일 수 있고, 초기화의 과정을 단순화시킬 수 있어서 약간의 성능향상을 기대할 수 있다.

 


출처 : 윤성우, <윤성우의 열혈 C++ 프로그래밍>, 오렌지미디어, 2010.05.12