문과 코린이의, [C. C++] 기록/C++ 이론

[문과 코린이의 IT 기록장] C,C++ - 연산자 오버로딩 3 : 대입연산자 (대입연산자, 디폴트 대입 연산자의 문제점, 상속 구조에서의 대입 연산자 호출, 이니셜라이저)

벼리네 2021. 2. 24. 20:38
반응형

[문과 코린이의 IT 기록장] C,C++ - 연산자 오버로딩 3 : 대입연산자 

(대입연산자, 디폴트 대입 연산자의 문제점,  상속 구조에서의 대입 연산자 호출, 이니셜라이저)

 


 

 

 1. 대입연산자 

1) 대입 연산자 오버로딩은, 복사 생성자와 매우 유사하다.

 

복사생성자 참고 정리 자료

 

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


2) 복사생성자 vs 대입연산자

: 호출되는 시점에서 차이가 존재한다.

 

a. 복사생성자 호출 상황

int main(){

  Point pos1(5,7);

  Point pos2 = pos1; // 새로 생성하는 객체 pos2의 초기화에, 기존에 생성된 객체 pos1이 사용되었다.

  ....

}

 

b. 대입연산자 호출 상황 

int main(){

  Point pos1(5,7);

  Point pos2(2,3);

  pos2 = pos1;

// pos1과 pos2 둘 다, 이미 생성 및 초기화가 진행된 객체이다.

// 따라서 pos2.operator=(pos1); 으로 해석된다.

  ....

}

 

즉, 기존 생성된 두 객체 간의 대입연산시에는, 대입연산자가 호출된다.

 


ex )

 

 

#include<iostream>

using namespace std;

 

class First {  // 대입연산자 정의 X 클래스 (디폴트 대입 연산자가 삽입된다.)

private:

 int num1, num2;

public:

 First(int n1 = 0, int n2 = 0) :num1(n1), num2(n2) { } // 생성자

 void ShowData() { cout << num1 << ", " << num2 << endl; } // num1, num2 출력 함수

};

 

class Second { // 대입연산자 정의 O 클래스

private:

 int num3, num4;

public:

 Second(int n3 = 0, int n4 = 0) :num3(n3), num4(n4) { } // 생성자

 void ShowData() { cout << num3 << ", " << num4 << endl; } // num3, num4 출력 함수

 

 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; // 이미 생성된 객체에 대입하는 것이므로, 대입연산자 오버로딩. fcpy.operator=(fsrc)로 해석.

scpy = ssrc; // scpy.operator=(ssrc)로 해석

fcpy.ShowData();

scpy.ShowData();

 

First fob1, fob2;

Second sob1, sob2;

fob1 = fob2 = fsrc; // 대입 연산자는 오른쪽에서 왼쪽으로 진행됨. (디폴트)대입 연산자의 반환형이 참조형이기 때문에, 이 문장은 실행 가능. 즉 fob1.operator=(fob2.operator=(fsrc))가 실행된다는 것.

[ 자동으로 삽입된, 디폴트 대입 연산자 ]
First & operator=(const First &ref){
  num1=ref.num1;
  num2=ref.num2;
  return *this;
}

sob1 = sob2 = ssrc;

 

fob1.ShowData();

fob2.ShowData();

sob1.ShowData();

sob2.ShowData();

return 0;

}

 

 


 

 

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

: 복사생성자에서 보였던 문제와 유사하고, 해결책 또한 유사하다.

 

ex 1 )

 

#include <iostream>

#include <cstring>

using namespace std;

 

class Person {

private:

 char* name;

 int age;

public:

 Person(const char* myname, int myage) { // Person 생성자

  int len = strlen(myname) + 1;

  name = new char[len];

  strcpy(name, myname);

  age = myage;

 }

 void ShowPersonInfo() const { // 이름, 나이 출력 함수

  cout << "이름 : " << name << endl;

  cout << "나이 : " << age << endl;

 }

 ~Person() { // Person소멸자

  delete[]name;

  cout << "called destructor!" << endl;

 }

};

 

int main() {

Person man1("Lee dong woo", 29);

Person man2("Yoon ji yul", 22);

man2 = man1; // Person 클래스에서는 대입 연산자가 정의되어 있지 않으므로, man2.operator=(man1) 디폴트 대입 연산자가 호출된다.

man1.ShowPersonInfo();

man2.ShowPersonInfo();

return 0;

}

 

 

 

실행 결과를 보면, 문자열 "called destructor" 즉, 소멸자가 한번 출력한 것을 볼 수 있다.

 

디폴트 대입 연산자의 문제점은, 디폴트 복사 생성자의 문제점과 많이 유사하다.

즉, 디폴트 대입 연산자가 호출되면, 다음 문장은 단순히 맴버 대 맴버를 복사만 하므로, 다음의 구조를 띠게 된다.

man2 = man1;

 

이와 같이 하나의 문자열을 두 개의 객체가 동시에 참조하게 되는 상황이 벌어지며, 다음 두 가지 문제가 발생한다.

 a. 문자열 "Yoon ji yul"을 가리키던 문자열의 주소값을 잃게 된다. // 메모리 누수 발생

 b. 얕은 복사로 인해, 객체 소멸과정에서 지워진 문자열을 중복 소멸하는 문제가 발생한다. // 즉 남아있는 man1 객체의, delete[]name 소멸자가 실행되지 못한다.

 

 

이러한 문제를 해결하기 위해, 생성자 내에서 동적할당을 하는 경우, 다음의 형태로 대입연산자를 지정해야 한다.

- 깊은 복사를 진행하도록 정의한다.

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

 

ex 2 )

 


 

 

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

- 생성자는 아무런 명시를 하지 않아도, 유도 클래스에서 기초 클래스의 생성자가 호출된다.

- 그러나, 유도 클래스의 대입 연산자에는 아무런 명시를 하지 않으면, 기초 클래스의 대입 연산자가 호출되지 않는다.

 

ex 1 )

#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; } // num1, num2 출력 함수

 

 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 &oeprator=()" << 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.operator=(ssrc);

scpy.ShowData();

return 0;

}

 

- 유도클래스에 삽입된, 디폴트 대입 연산자가, 기초 클래스의 대입 연산자까지 호출한다.


ex 2 ) 주석 해제

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

 

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

 

Second & operator=(const Second & ref){ // ref는 Second 참조형이지만, Second객체 혹은 Second를 직접 혹은 간접적으로 상속하는 모든 객체를 참조할 수 있다.

  cout<<"Second& operator=()"<<endl;

  First::operator=(ref); // 기초 클래스의 대입 연산자 호출을 명령

  num3 = ref.num3;

  num4 = ref.num4;

  return *this;

}


 

 

 4. 이니셜라이저

- 이니셜라이저를 사용하면, 성능이 향상된다. 그 이유에 대해 확인해보자.

 

ex )

#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) { } // 복사생성자

// BBB 클래스는, 이니셜라이저를 이용해서 맴버를 초기화하고 있다.

[ 이니셜라이저를 사용하면, 선언과 동시에 초기화가 이뤄지는 형태로 바이너리 형태가 생성된다. ]
 즉, AAA mem = ref; 와 같은 방식으로 초기화된다. 따라서, 복사생성자만 호출된 것이다.

};

 

class CCC {

private:

 AAA mem;

public:

 CCC(const AAA& ref) { mem = ref; } // 복사생성자

// CCC 클래스는, 대입연산을 이용해서 맴버를 초기화하고 있다. (BBB클래스와 CCC클래스의 초기화 방식 차이)

[ 생성자의 몸체부분에서 대입연산을 통한 초기화를 진행하면, 선언과 초기화를 각각 별도의 문장에서 진행하는 형태로 바이너리 코드가 생성된다. ]
따라서, 생성자와, 대입연산자가 각각 한번씩 호출된다.

AAA(int n=0)

operator=(const AAA &ref) 

};

 

 

int main() {

AAA obj1(12); // AAA클래스의, obj1객체 생성

cout << "*****************************" << endl;

BBB obj2(obj1); // BBB 클래스의 obj2 객체 생성 (obj1의 복사생성자)

cout << "*****************************" << endl;

CCC obj3(obj1); // CCC클래스의 obj3 객체 생성 (obj1의 복사생성자)

return 0;

}

 

 

 


* 유의사항
- 아직 공부하고 있는 문과생 코린이가, 정리해서 남겨놓은 정리 및 필기노트입니다.
- 정확하지 않거나, 틀린 점이 있을 수 있으니, 유의해서 봐주시면 감사하겠습니다.
- 혹시 잘못된 점을 발견하셨다면, 댓글로 친절하게 남겨주시면 감사하겠습니다 :)

 

반응형