C++ Virtual functions and inheritance.
1.C++ Visual destructor
사실 클래스의 상속을 사용함으로써 중요하게 처리해야 되는 부분이 있다. 바로, 소멸자를 가상함수로 만들어야 된다는 점이다.
Child 객체를 만든 부분을 살펴본다.
cout << "--- 평범한 Child 만들었을 때 ---" << endl;
{
Child c;
}
생성자와 소멸자의 호출 순서를 살펴보자면, Parent 생성자 → Child 생성자 → Child 소멸자 → Parent 소멸자 순으로 호출됨을 알 수 있다. 이유는 객체를 만들고 소멸시키는 일을 집을 짓고 철거하는 일로 비유할 수 있다. 집을 지을 때에는 큰 틀, 즉기초공사를 하고 건물을 세운 다음에 (Parent 생성자 호출), 집 내부 공사 - 인테리어, 가구 배치 등을 하게 된다 (Child 생성자 호출). 그리고 역으로 집을 철거할 때에는 안에 있는 내용물들을 모두 제거한 뒤에 (Child 소멸자 호출), 집 구조물을 철거 할 것이다 (Parent 소멸자 호출).
문제는 그 아래 Parent 포인터가 Child 객체를 가리킬 때 발생한다.
cout << "--- Parent 포인터로 Child 가리켰을 때 ---" << endl;
{
Parent *p = new Child();
delete p;
}
delete p를 하더라도, p가 가리키는 것은 Parent 객체가 아닌 Child 객체 이기 때문에, 위에서 보통의 Child 객체가 소멸되는 것과 같은 순서로 생성자와 소멸자들이 호출되어야만 한다. 그런데 실제로는, Child소멸자가 호출되지 않는다.
소멸자가 호출되지 않는다면 여러가지 문제가 생길 수 있다. 예를 들어서, Child 객체에서 메모리를 동적으로 할당하고 소멸자에서 해제하는데, 소멸자가 호출 안됐다면 메모리 누수(memory leak)가 생길 것이고, 하지만 virtual 키워드를 배운 이상뭘 해야될지 감이 올 것이다. 단순히 Parent의 소멸자를 virtual 로 만들어버리면 된다. Parent의 소멸자를 virtual로 만들면, p가 소멸자를 호출할 때, Child의 소멸자를 성공적으로 호출할 수 있게 된다.
여기서 한 가지 질문을 하자면, 그렇다면 왜 Parent 소멸자는 호출이 되었는가 인데, 이는 Child 소멸자를 호출하면서, Child 소멸자가 '알아서' Parent 의 소멸자도 호출해주기 때문이다 (Child 는 자신이 Parent 를 상속받는다는 것을 알고 있다).
반면에 Parent 소멸자를 먼저 호출하게 되면, Parent 는 Child 가 있는지 없는지 모르므로, Child 소멸자를 호출해줄 수 없다 (Parent 는 자신이 누구에서 상속해주는지 알 수 X).
즉, 상속될 여지가 있는 Base 클래스들은 (위 경우 Parent), 반드시 소멸자를 virtual 로 만들어주어야 나중에 문제가 발생할 여지가 없게 된다.
즉, 부모 클래스에서 자식 클래스의 함수에 접근할 때 항상 부모 클래스의 포인터를 통해서 접근하였다. 하지만, 사실 부모 클래스의 레퍼런스여도 문제 없이 작동한다.
즉, 함수에 타입이 부모 클래스여도 그 자식 클래스는 타입 변환되어 전달 할 수 있다.
2.C++ How virtual functions are implemented
간혹 '가상' 이라는 이름 때문에 혼동하는 사람들이 있는데, virtual 키워드를 붙여서 가상 함수로 만들었다 해도 실제로 존재하는 함수이고 정상적으로 호출도 할 수 있다. 또한 모든 함수들을 디폴트로 가상 함수로 만듬으로써, 언제나 동적 바인딩이 제대로 동작하게 만들 수 있다.
실제로 자바의 경우 모든 함수들이 디폴트로 virtual 함수로 선언된다.
그렇다면 왜 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() 같이 비 가상함수들은 그냥 단순히 특별한 단계를 걸치지 않고, 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() 을 오버라이드 한다.
이와 같이 두 단계에 걸쳐서 함수를 호출함을 통해 소프트웨어적으로 동적 바인딩을 구현할 수 있게 된다. 이러한 이유로 가상 함수를 호출하는 경우, 일반적인 함수 보다 약간 더 시간이 오래 걸리게 된다. 물론 눈부시게 CPU 의 속도가 빨라짐에 ]따라서 차이는 미미해졌으나, 최적화가 매우 중요한 분야에서는 이를 감안할 필요가 있다. 아무튼 이러한 이유로 인해, 다른 언어들과는 다르게, C++ 에서는 멤버 함수가 디폴트로 가상함수가 되도록 설정하지는 않는다.
3.C++Pure virtual functions and abstract classes
class Animal
{
public:
Animal() {}
virtual ~Animal() {}
virtual void speak() = 0;
};
위 소스코드를 보면, Animal 클래스의 speak 함수를 봐 보자. 다른 함수들과는 달리, 함수의 몸통이 정의되어 있지 않고 단순히 = 0; 으로 처리되어 있는 가상 함수다. 그 답은, "무엇을 하는지 정의되어 있지 않는 함수" 다.
다시 말해 이 함수는 "반드시 오버라이딩 되어야만 하는 함수" 다. 이렇게, 가상 함수에 = 0; 을 붙여서, 반드시 오버라이딩 되도록 만든 함수를 완전한 가상 함수라 해서, 순수 가상 함수(pure virtual function)라고 부른다.
당연하게도, 순수 가상 함수는 본체가 없기 때문에, 이 함수를 호출하는 것은 불가능하다. 그렇기 때문에, Animal 객체를 생성하는것 또한 불가능이다.
Animal a;
a.speak();
왜냐하면, 위 소스코드가 안되기 때문이다. 물론, speak() 함수를 호출하는 것을 컴파일러 상에서 금지하면 되지 않냐고 물을 수 있는데, C++ 개발자들은 이러한 방법 대신에 아예 Animal 의 객체 생성을 금지시키는 것으로 택하였다.
즉 Animal의 인스턴스를 생성할 수 없다.
따라서 Animal 처럼, 순수 가상 함수를 최소 한 개 이상 포함하고 있는 클래스는 객체를 생성할 수 없으며, 인스턴스화 시키기 위해서는 이 클래스를 상속 받는 클래스를 만들어서 모든 순수 가상 함수를 오버라이딩 해주어야만 한다.
이렇게 순수 가상 함수를 최소 한개 포함하고 있는 - 반드시 상속 되어야 하는 클래스를 가리켜 추상 클래스 (abstract class) 라고 부른다. 이런 이유 때문에 순수 가상 함수는 반드시 public 이나 protected 가 되어야 한다. private 으로 정의될 경우오버라이드 될 수 가 없기 때문이다.
class Dog : public Animal
{
public:
Dog() : Animal() {}
void speak() {
cout << "왈왈" << endl;
}
};
위처럼 speak () 를 오버라이딩 함으로써 (- 정확히 말하면 Animal 의 모든 순수 가상 함수를 오버라이딩 함으로써) Dog 클래스의 객체를 생성할 수 있게 된다. Cat 클래스도 마찬가지다.
그렇다면 추상 클래스를 도대체 왜 사용하는 것일까? 추상 클래스 자체로는 인스턴스화 시킬 수도 없고 (추상 클래스의 객체를 만들 수 없다) 사용하기 위해서는 반드시 상속 해줘야만 하기 때문이다. 하지만, 추상 클래스를 설계도라고 생각하면 좋다. 즉, 이 클래스를 상속받아서 사용하는 사람에게 "이 기능은 일반적인 상황에서 만들기 힘드니 너가 직접 특수화 되는 클래스에 맞추어서 만들어서 써라." 라고 말해주는 것이다.
class Animal
{
public:
Animal() {}
virtual ~Animal() {}
virtual void speak() = 0;
};
동물들이 소리를 내는 것은 맞으므로 Animal 클래스에 speak 함수가 필요하다. 하지만 어떤 소리를 내는지는 동물 마다 다르기 때문에 speak 함수를 가상 함수로 만들기는 불가능하다.
따라서 speak 함수를 순수 가상 함수로 만들게 되면 모든 Animal 들은 speak() 한다라는 의미 전달과 함께, 사용자가 Animal 클래스를 상속 받아서 (위 경우 Dog 와 Cat) speak() 를 상황에 맞게 구현하면 된다.
추상 클래스의 또 한가지 특징은 비록 객체는 생성할 수 없지만, 추상 클래스를 가리키는 포인터는 문제 없이 만들 수 있다는 것이다. 위 예에서도 살펴보았듯이, 아무런 문제 없이 Animal* 의 변수를 생성하였다.
Animal* dog = new Dog();
Animal* cat = new Cat();
dog->speak();
cat->speak();
dog 와 cat 의 speak 함수를 호출하였는데, 비록 dog 와 cat 이 Animal* 타입 이지만, Animal 의 speak 함수가 오버라이드 되어서, Dog 와 Cat 클래스의 speak 함수로 대체되서 실행이 된다.
4.C++ Multiple inheritance
C++ 에서의 상속의 또 다른 특징인 다중 상속에 대해 알아보도록 한다. C++ 에서는 한 클래스가 다른 여러 개의 클래스들을 상속 받는 것을 허용하다.
이를 가리켜서 다중 상속 (multiple inheritance) 라고 부른다.
class A
{
public:
int a;
};
class B
{
public:
int b;
};
class C : public A, public B
{
public:
int c;
};
위 경우, 클래스 C 가 A 와 B 로 부터 동시에 같이 상속 받고 있다.
이를 그림으로 표현하자면 위 같은 모양이 된다. 사실 다중 상속은 보통의 상속 하고 똑같이 생각하면 된다.
단순히 그냥 A 와 B 의 내용이 모두 C 에 들어간다고 생각하면 된다.
C c;
c.a = 3;
c.b = 2;
c.c = 4;
다중 상속에서 한 가지 재미있는 점은 생성자들의 호출 순서이다.
사실 다중 상속은 실제 프로그래밍에서 많이 쓰이지는 않다. 왜냐하면 위험이 언제나 도사리고 있기 때문이다.
다중 상속은 코드 구조를 매우 복잡하게 만드는 경향이 있기 때문에 C++ 이외에 많은 언어들 (자바, C# 등) 에서는 다중 상속 기능을 지원하고 있지 않다.
다중 상속의 또 다른 문제는 일명 '다이아몬드 상속(diamond inheritance)' 혹은 '공포의 다이아몬드 상속(dreadful diamond of derivation)' 이라고 부르는 형태의 다중 상속에 있다.
class Human
{
// ...
};
class HandsomeHuman : public Human
{
// ...
};
class SmartHuman : public Human
{
// ...
};
class Me : public HandsomeHuman, public SmartHuman
{
// ...
};
베이스 클래스로 Human 이라는 클래스가 있고, HandsomeHuman 과 SmartHuman 클래스는 Human 클래스를 모두 상속 받는다. 그리고 두 가지 특성을 모두 보유한 나(Me) 라는 클래스는, HandsomeHuman 과 SmartHuman 클래스를 둘 다
상속 받는다.
상속이 되는 두 개의 클래스가 공통의 베이스 클래스를 포함하고 있는 형태를 가리켜서 다이아몬드 상속이라고 부른다. 만일 Human 에 name 이라는 멤버 변수가 있다고 가정한다. 그러면 HandsomeHuman 과 SmartHuman 은 모두 Human 을
상속 받고 있으므로, 여기에도 name 이라는 변수가 들어가게 된다. 그런데 Me 가 이 두 개의 클래스를 상속 받으니 Me 에서는 name 이라는 변수가 겹치게 되는 것이다.
결과적으로 볼 때 Handsome 과 SmartHuman 을 아무리 안겹치게 만든다고 해도, Human 의 모든 내용이 중복되는 문제가 발생하게 된다.
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 의 생성자 또한 호출해주어야만 한다.