CPP/씹어먹는 c++

[c++ 개념공부] virtual 소멸자,가상함수 테이블, 다중상속, 가상상속

뜨거운 개발자 2023. 2. 11. 16:07

virtual 소멸자

상속시에 소멸자 처리(중요!!)

💡
상속 시에, 소멸자를 가상함수로 만들어야 된다

잘못된 예시코드

#include <iostream>

class Parent {
 public:
  Parent() { std::cout << "Parent 생성자 호출" << std::endl; }
  ~Parent() { std::cout << "Parent 소멸자 호출" << std::endl; }
};
class Child : public Parent {
 public:
  Child() : Parent() { std::cout << "Child 생성자 호출" << std::endl; }
  ~Child() { std::cout << "Child 소멸자 호출" << std::endl; }
};
int main() {
  std::cout << "--- 평범한 Child 만들었을 때 ---" << std::endl;
  { Child c; }
  std::cout << "--- Parent 포인터로 Child 가리켰을 때 ---" << std::endl;
  {
    Parent *p = new Child();
    delete p;
  }
}

실행결과

--- 평범한 Child 만들었을 때 ---
Parent 생성자 호출
Child 생성자 호출
Child 소멸자 호출
Parent 소멸자 호출
--- Parent 포인터로 Child 가리켰을 때 ---
Parent 생성자 호출
Child 생성자 호출
Parent 소멸자 호출
  • 일반적인 객체를 만들때는 문제가 되지 않지만, 소멸자가 호출되지 않아서 문제(메모리 누수)가 발생 한다.

고친 코드

#include <iostream>

class Parent {
 public:
  Parent() { std::cout << "Parent 생성자 호출" << std::endl; }
  virtual ~Parent() { std::cout << "Parent 소멸자 호출" << std::endl; }
};
class Child : public Parent {
 public:
  Child() : Parent() { std::cout << "Child 생성자 호출" << std::endl; }
  ~Child() { std::cout << "Child 소멸자 호출" << std::endl; }
};
int main() {
  std::cout << "--- 평범한 Child 만들었을 때 ---" << std::endl;
  {
    // 이 {} 를 빠져나가면 c 가 소멸된다.
    Child c;
  }
  std::cout << "--- Parent 포인터로 Child 가리켰을 때 ---" << std::endl;
  {
    Parent *p = new Child();
    delete p;
  }
}
  • 상속될 여지가 있는 Base 클래스들은 (위 경우 Parent) 반드시 소멸자를 virtual 로 만들어주어야 나중에 문제가 발생할 여지가 없게 됩니다.
  • 여기서 왜 Parent 소멸자는 호출이 되었는가를 보면 Child 소멸자를 호출하면서, Child 소멸자가 '알아서' Parent 의 소멸자도 호출해주기 때문입니다. (Child 는 자신이 Parent 를 상속받는다는 것을 알고있기 때문에)
💡
지금까지 우리가 포인터로 이야기를 했지만 사실 레퍼런스로 해도 virtual 키워드 역시 사용이 가능합니다.

가상함수 구현 원리

그냥 그럼 모든 함수들을 virtual로 만들어버리면 안되나?

실제로 자바의 경우 모든 함수들이 디폴트로 virtual함수로 선언한다.

C++ 에서는 virtual키워드를 이용해 사용자가 직접 virtual 선언하도록 하였을까요? 그 이유는 가상 함수를 사용하게 되면 약간의 오버헤드 (overhead)가 존재하기 때문 (보통의 함수를 호출하는 것 보다 가상 함수를 호출하는 데 걸리는 시간이 조금 더 오래 걸립니다.)

가상함수 테이블

💡
C++ 컴파일러는 가상 함수가 하나라도 존재하는 클래스에 대해서, 가상 함수 테이블(virtual function table; vtable)을 만들게 됩니다.
💡
가상함수 테이블은 함수의 이름(전화번호부의 가게명) 과 실제로 어떤 함수 (그 가게의 전화번호) 가 대응되는지 테이블로 저장하고 있는 것이라 생각하면 된다.

예시

class Parent {
 public:
  virtual void func1();
  virtual void func2();
};
class Child : public Parent {
 public:
  virtual void func1();
  void func3();
};

위 경우 Parent 와 Child 모두 가상 함수를 포함하고 있기 때문에 두 개 다 가상 함수 테이블을 생성하게 되지요. 그 결과 아래 그림과 같이 구성된다.

비 가상함수들은 그냥 단순히 특별한 단계를 걸치지 않고, func3() 을 호출하면 직접 실행됩니다.

하지만, 가상 함수를 호출하였을 때는 그 실행 과정이 다릅니다. 위에서도 보이다 싶이, 가상 함수 테이블을 한 단계 더 걸쳐서, 실제로 어떤 함수를 고를지 결정한다.

예시

Parent* p = Parent();
p->func1();

컴파일러는

p 가 Parent 를 가리키는 포인터 이니까, func1() 의 정의를 Parent 클래스에서 찾아봐야겠다.

func1() 이 가상함수네? 그렇다면 func1() 을 직접 실행하는게 아니라, 가상 함수 테이블에서 func1() 에 해당하는 함수를 실행해야겠다.

그리고 실제로 프로그램 실행시에, 가상 함수 테이블에서 func1() 에 해당하는 함수(Parent::func1()) 을 호출하게 됩니다.

Parent* c = Child();
c->func1();

위 처럼 똑같이 프로그램 실행시에 가상 함수 테이블에서 func1() 에 해당하는 함수를 호출하게 되는데, 이번에는 p 가 실제로는 Child 객체를 가리키고 있으므로, Child 객체의 가상 함수 테이블을 참조하여, Child::func1() 을 호출하게 됩니다. 따라서 성공적으로 Parent::func1() 를 오버라이드 할 수 있습니다.

순수 가상함수

순수가상함수란

💡
"무엇을 하는지 정의되어 있지 않는 함수" 입니다. 다시 말해 이 함수는 반드시 오버라이딩 되어야만 하는 함수를 우리는 순수 가상함수라고 부른다

순수 가상함수 예시코드

class Animal {
 public:
  Animal() {}
  virtual ~Animal() {}
  virtual void speak() = 0;
};

순수 가상함수 특징

💡
순수 가상 함수를 최소 한 개 이상 포함하고 있는 클래스는 객체를 생성할 수 없으며,
💡
인스턴스화 시키기 위해서는 이 클래스를 상속 받는 클래스를 만들어서 모든 순수 가상 함수를 오버라이딩 해주어야만한다.

추상클래스란 무엇인가

💡
순수 가상 함수를 최소 한개 포함하고 있는- 반드시 상속 되어야 하는 클래스를 가리켜 추상 클래스 (abstract class)라고 부른다!~!!
  • 추상클래스의 사용이유는 설계도라고 보면 된다. 상속을 다 하지만 즉 동물의 경우 우는 소리가 다 다른경우 다 다르게 구현을 해야하기 때문에 순수 가상함수를 사용하는 것이다.
  • 추상 클래스는 객체는 못 만들지만, 추상 클래스를 가리키는 포인터는 문제없이 만들 수 있다!!
  • (참고로, private 안에 순수 가상 함수를 정의하여도 문제 될 것이 없습니다. private 에 정의되어 있다고 해서 오버라이드 안된다는 뜻이 아니기 때문이죠. 다만 자식 클래스에서 호출을 못할 뿐입니다.)

다중상속(multiple inheritance)

예시

class A {
 public:
  int a;
};

class B {
 public:
  int b;
};

class C : public A, public B {
 public:
  int c;
};

다중상속의 생성자 호출순서!!

-무조건 상속 순서로 호출 된다.

호출순서 예시

class C : public A, public B : A생성자 호출 , B 생성자 호출, C 생성자 호출

class C : public B, public A : B생성자 호출 , A생성자 호출, C 생성자 호출

주의할점 : 모호성 문제

class A {
 public:
  int a;
};

class B {
 public:
  int a;
};

class C : public B, public A {
 public:
  int c;
};

위와 같은 문제에서

int main() {
  C c;
  c.a = 3;
}

복되는 멤버 변수에 접근한다면 모호성문제가 발생합니다.

주의할점 : 다이아몬드 상속(diamond inheritance)

다이아몬드 상속 문제

class Human {
  // ...
};
class HandsomeHuman : public Human {// 여기서도 휴먼 겹침
  // ...
};
class SmartHuman : public Human {//여기서 휴먼 겹침
  // ...
};
class Me : public HandsomeHuman, public SmartHuman {
  // ...
};

사진

이렇게 생긴 그림에서 Human의 멤버를 호출하게 된다면 어떤 Human을 호출해야하는지 찾기가 어려워서 모호성이 발생합니다.

해결방법 :가상상속

가상 상속시에 Me생성자를 통해 HandsomeHuman 과 smartHuman 의 생성자를 호출하는 건 당연하지만 반드시 Human생성자도 호출해야만 한다.

놀랍게도 아래 예시코드로 실행을 하면 자동으로 human의 생성자가 먼저 생성이 되고 그 다음 handsome smart me 의 생성자가 자동으로 생성이 된다. (신기하다!!!)

	class Human {
	 public:
	  // ...
	};
	class HandsomeHuman : public virtual Human {//가상상속
	  // ...
	};
	class SmartHuman : public virtual Human {
	  // ...
	};
	class Me : public HandsomeHuman, public SmartHuman {
	  // ...
	};

Human 을 virtual 로 상속 받는다면, Me 에서 다중 상속 시에도, 컴파일러가 언제나 Human 을 한 번만 포함하도록 지정할 수 있게 됩니다. 참고로, 가상 상속 시에, Me 의 생성자에서 HandsomeHuman 과 SmartHuman 의 생성자를 호출함은 당연하고, Human 의 생성자 또한 호출해주어야만 합니다.

다중상속을 언제 사용할까

c++공식 웹사이트의 FAQ : https://isocpp.org/wiki/faq/multiple-inheritance#virtual-inheritance-where 여기에 가이드 라인이 존재한다.

  • 우리는 3가지 방법으로 클래스 디자인이 가능하다.

브리지패턴 (bridge pattern)

  1. 한가지 카테고리를 멤버 포인터로 설정하고 환경이 1개씩 추가되면 클래스를 한개 더 만들면 된다.
  1. 클래스의 총 갯수가 N + M개가 된다. 단, 오버라이딩 가지수가 N+M 개라서 가능한 알고리즘의 갯수도 N+ M개라 섬세한 제어가 어렵다.
  1. 예시로 자동차의 파생클래스로 엔진 엔진의 파생클래스로 가스연료 이런식으로 파생해서 계속해서 생기는 클래스의 패턴을 의미

중첩된 일반화(nested generalization)

  1. 한가지 계층을 골라 파생클래스 생성한다.
  1. 각각의 클래스들에 대해서 파생 클래스를 생성한다. M *N 가지의 파생 클래스를 만들 수 있어서 섬세한 제어가 가능하다.
  1. 최대 N개의 파생 클래스를 만들어줘야하고 만약 공통적으로 사용되는 코드가 있어도 매번 새로 작성해줘야하는 단점이 있다.
  1. 예를들어 탈것의 파생클래스로 땅에 탈것, 물에 탈것 이렇게 설정. 이후 다른 클래스들에 대해 다른계층에 해당하는 파생클래스 생성한다. 조금씩 중첩시키는 것이다. 땅에타는데 에너지가 가스인것, 땅에 타는데 에너지가 수소연료인것 이런식으로 점점 더 중첩시켜서 구체화 시키는 것이다.

다중상속

  1. 브리지패턴처럼 각 카테고리에 해당하는 파생클래스를 생성한다.
  1. 하지만 멤버변수를 없애고 상속을 받는 형식이다.
  1. 섬세한 제어를 수행하고 모든걸 가리키기가 쉽다는 장점이있다.
  1. 이건 멤버 변수들을 없애고 동력원과 환경에 해당하는 클래스들을 상속받는 방식이다.

결론

위 3 가지 방식 중에서 절대적으로 우월한 방식은 없다 는 것입니다. 상황에 맞게 최선의 방식을 골라서 사용해야 합니다.

💡
다중상속은 만능이 아니다!! 적절한 상황에 사용하도록 하자.

참고 : https://modoocode.com/211


Uploaded by N2T

728x90