모두의 코드 씹어먹는 C++ - <6 - 3. 가상함수와 상속에 관련한 잡다한 내용들>

YP J·2022년 6월 21일
0

모두의코드 C++

목록 보기
8/11
  • virtual 소멸자 (가상 소멸자)

  • 가상 함수 테이블 (virtual function table)

  • 다중 상속

  • 가상 상속

6-2 정리

  • Parent 클래스와 Child클래스에 모두 f라는 가상함수가 정의 되어 있고
  • Child클래스가 Parnet 를 상속받고
  • 그 다음 동일한 Parent* 타입의 포인터들도 각각 Parent 객체와 Child 객체를 가리킨다고 해봅시다.
    Parent* p = new Parent();
    Parent* c = new Child();

 - 컴퓨터 입장에서 p , c 모두 Parent 를 가리키는 포인터들이므로 당연히
 ```c
 p->f(); --1
 c->f(); --2
  • 위 두개 모두 Parent 의 f()가 호출 되어야 하지만
  • f가 가상함수이므로 1번은 Parent 의 f, 2번은 Child 의 f를 호출한다.

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 소멸자 호출
	std::cout << " ---평범한 Child 만들었을때 --- " <<std::endl;
	{ Child c; }
  • 생성자와 소멸자의 호출 순서를 살펴보면
  • Parent 생성자 -> 자식 생정자 -> 자식 소멸자 -> 부모 소멸자 순
  • 집을 만들고 내부를 만들어야 한다. 반대로 소멸할때는 내부를 부시고 외부를 부신다~
	std::cout << " ---Parent 포인터로 Child 가리켰을때 ---" << std::endl;
	{
		Parent *p = new Child();
		delete p;
	}
  • delete p 를 해도 , p 가 가리키는것은 부모가 아니라 자식 객체 이기 때문에

  • 보통의 Child 객체가 소멸되는 것과 같은 순서로 소멸자가 호출 되어야 하는데

  • 자식 소멸자가 호출 되지 않는다.

  • 이럴 경우 메모리 누수 가 생긴다.

  • 하지만 소멸자를 virtual 로 만들면 p 가 소멸자 호출할때 가리키는게 객체가 뭔지 한번더 체크한다.

class Parent
{
	public:
		Parent() {std::cout << " Parent 생성자 호출 "  << std::endl;}
		virtual ~Parent() { std::cout << " Parent 소멸자 호출 " << std::endl;}
};
>>
 ---평범한 Child 만들었을때 ---
 Parent 생성자 호출
Child 생성자 호출
child 소멸자 호출
 Parent 소멸자 호출
 ---Parent 포인터로 Child 가리켰을때 ---
 Parent 생성자 호출
Child 생성자 호출
child 소멸자 호출
 Parent 소멸자 호출
  • 제대로 자식 소멸자를 찾아서 호출됨을 알수있다.

  • Q: 왜 Parent 소멸자는 호출이 되었는가?

    • 자식은 부모를 상속 받았다는 것을 알고 있어서 자식 소멸자를 호출 하면서 , 자식 소멸자가 알아서 부모 소멸자도 호출해주기 때문이다.
  • 반면 부모 소멸자를 먼저 호출하면 , 부모는 자식객체가 있는지 없는지 모르므로 자식 소멸자를 호출할수 없다.

  • 따라서 상속될 여지가 있는 Base 클래스들은 반드시 소멸자를 virtual로 만들어 주어야 나중에 문제가 발생할 여지가 없다.

레퍼런스도 된다.

  • 지금까지 예시로 기반클래스에서 파생클래스의 함수에 접근할때 기반클래스의 포인터를 통해 접근했다
  • 근데 기반클래스의 레퍼런스여도 문제없이 작동한다.
#include <iostream>

class A
{
	public:
		virtual void show() { std::cout << " Parent ! " << std::endl;}
};

class B : public A
{
	public:
		void  show() override { std::cout << " Child !" <<std::endl; }
};


void test(A& a)
{
	a.show();
}

int main()
{
	A a;
	B b;
	test(a);
	test(b);

	return 0;
}
>>
 Parent !
 Child !

override 는 컴파일시 -std=c++11

void test(A& a) 
{
	a.show();
}
  • 이 함수를 보면 A 클래스의 레퍼런스를 받게 되지만

test(b)

  • 를 통해 B클래스의 객체를 전달 해도 B클래스의 함수가 잘 호출 되는것을 알수 있다.

  • 이는 B클래스가 A클래스를 상속 받고 있기 때문이다.

  • 즉 함수에 타입이 기반 클래스여도 그 파생 클래스는 타입 변환되어 전달 할 수있다.

  • 따라서 test함수에서 show() 호출 할때 인자로 b를 전달했다면

  • 비록 전달된 인자가 A의 객체라고 표현 되었지만

  • show 함수가 virtual 로 정의 되어 있기 때문에

  • 알아서 B의 show를 찾아서 호출한다.


가상함수 구현 원리

Q: 그냥 그럼 모든 함수를 virtual 로 만들면 안 되나?

  • 일단 모든 함수를 virtual로 만든다고 해서 문제될것은 없다.
  • 말이 virtual이지 실제 존재하는 함수이고 정상적으로 호출도 된다.
  • 실제 자바의 경우 모든 함수들이 디폴트로 virtual함수로 선언된다.

Q: 그럼 왜 C++에서 virtual 키워드를 이용해 사용자가 직접 virtual로 선언 하도록 했을까?

  • 가상함수를 사용하게 되면 약간의 오버헤드(overhead)가 존재하기 때문
  • 보통의 함수를 호출하는것보다 가상 함수를 호출하는데 걸리는 시간이 후자가 더 많다.

가상함수가 어떻게 구현되는지, 동적바인딩이 어떻게 구현되는지 살펴보자

class Parent {
 public:
  virtual void func1();
  virtual void func2();
};
class Child : public Parent {
 public:
  virtual void func1();
  void func3();
};
  • C++ 컴파일러는 가상함수가 하나라도 존재하는 클래스에 대해서

  • 가상함수테이블 (virtual function table; vtable)을 만들게 된다(전화번호부)

  • 함수 이름(가게명)과 실제로 어떤함수(그 가게 전화번호) 가 대응 되는지 테이블로 저장하고있다

  • 위의 경우 Parent 와 Child 모두 가상함수를 포함하고 있기 때문에 아래와 같이 구성된다

  • 가상함수와 비 가상함수의 차이는

  • Child 의 func3() 와 같이 그냥 vtable을 거치지 않고 , 바로 func3()을 호출하면 직접 실행 된다.

  • 하지만 가상함수를 호출할땐

  • 가상테이블을 한단계 더 거쳐서 실제로 어떤 함수를 고를지 결정한다.

예를 들어

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

컴파일러는

    1. p가 Parent 를 가리키는 포인터닌까 fun1() 을 Parent 클래스 에서 찾아봐야겠다
  • func1() 가상함수네? -> 가상테이블 거쳐야 겠다.
  • 가상함수 테이블에서 func1()에 해당하는 함수를 실행.
Parent *c = Child();
c->func1();
    1. p가 Parent 를 가리키는 포인터닌까 fun1() 을 Parent 클래스 에서 찾아봐야겠다
  • func1() 가상함수네? -> 가상테이블 거쳐야 겠다.
  • 가상함수 테이블에서 func1()에 해당하는 함수를 실행.
  • Child::func1() 호출
  • 따라서 성공적으로 Parent::func1() 을 오버라이드 할수있다.

이와 같이 두 단계에 걸쳐서 함수를 호출해서 동적바인딩을 구현 할 수 있게 된다.

  • 따라서 일반함수보다 시간이 더 오래 걸린다.

순수 가상함수(pure virtual function) , 추상 클래스(abstract class)

#include <iostream>

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

class Dog : public Animal
{
	public:
		Dog() : Animal() {}
		void speak() {std::cout << "wall wall" << std::endl;}
};


class Cat : public Animal
{
	public:
		Cat(): Animal() {}
		void speak() { std::cout << "mewow mewow" <<std::endl;}
};

int main()
{
	Animal* dog = new Dog();
	Animal* cat = new Cat();

	Dog d1;
	d1.speak();
	dog->speak();
	cat->speak();
}

여기서

virtual void speak() = 0;
  • Q:뭘 하는 함수일까?
    • 무엇을 하는지 정의되어 있지 않는 함수
    • 즉, 반드시 오버라이딩 되어야만 하는 함수
  • 이렇게 가상 함수에 =0; 을 붙여서 , 반드시 오버라이딩 되도록 하는 함수를 완전한 가상함수라 한다.
  • 순수 가상함수.

그런데

Animal a;
a.speak();
  • 에러 난다

  • 아예 Animal객체 생성 못하게 컴파일러가 막는다.

  • 따라서 Animal처럼 순수 가상함수를 최소 한개 이상 포함하고 있는 클래스는

  • 객체를 생성할 수 없다.

  • 인스턴스화 시키기 위해서는 이 클래스를 상속 받는 클래스를 만들어서 모든 순수 가상함수를 오버라이딩 해주어야만한다.

  • 이렇게 순수가상 함수를 최소 한개 포함하고 있는

  • 즉 반드시 상속 되어야 하는 클래스를 가리켜

  • 추상 클래스 라고 부른다.

    • 참고로 private 안에 순수 가상함수를 정의 해도 문제될것은 없다.
  • private에 정의 되어 있다고 해서 오버라이드 안 된다는 뜻은 아니기 때문

  • 근데 자식 클래스 에서 호출은 못함.

Q: 추상 클래스 는 왜 사용해?
A:

  • 추상클래스는 '설계도' 와 비슷
  • 예를 들어 강아지 짖는 소리와 개 짖는 소리를 부모 클래스에서 다 일반적으로 만들기 힘드니
  • 상속받은 Cat, Dog 에 따라 짖는 소리가 각자 클래스에서 맞추어서 만들어라.

추상클래스의 또 다른 특징

  • 객체는 못 만들지만
  • 추상클래스를 가리키는 포인터는 만 들수 있다
Animal* dog = new Dog();
Animal* cat = new Cat();

dog->speak();
cat->speak();
  • 비록 dog 와 cat 이 Animal* 타입 이지만,
  • Animal 의 speak 함수가 오버라이드 되어서, Dog 와 Cat 클래스의 speak 함수로 대체되서 실행이 됩니다.

다중상속 시 주의할점

class Human {
  // ...
};
class HandsomeHuman : public Human {
  // ...
};
class SmartHuman : public Human {
  // ...
};
class Me : public HandsomeHuman, public SmartHuman {
  // ...
};

  • 이 경우 Hunman에 name 이라는 멤버 변수가 있으면
  • HandsomeHuman 과 SmartHuman에 모두 name 이라는 변수가 들어간다.
  • 그런데 Me가 이 두클래스를 상속 받으면
  • name이라는 변수가 겹치게 된다.
  • 결과적으로 Human의 모든 내용이 중복된다 .

해결 방법은

class Human {
 public:
  // ...
};
class HandsomeHuman : public virtual Human {
  // ...
};
class SmartHuman : public virtual Human {
  // ...
};
class Me : public HandsomeHuman, public SmartHuman {
  // ...
};
  • 이렇게 하면 컴파일러가 Human을 한번만 포함하도록 지정할수있다.
  • 참고로, 가상 상속 시에, Me 의 생성자에서 HandsomeHuman 과 SmartHuman 의 생성자를 호출함은 당연하고, Human 의 생성자 또한 호출해주어야만 합니다.
profile
be pro

0개의 댓글