C++

상속과 가상 함수

종황이 2020. 10. 29. 17:34

1. virtual 소멸자

 

클래스의 상속을 사용함으로써 중요하게 처리해야 되는 부분이 있습니다. 바로 소멸자를 가상함수로 만들어야 된다는 점입니다.

 

#include <iostream>

class Parent {
public:
	Parent()
	{
		std::cout << "부모클래스 생성자" << std::endl;
	}
	~Parent()
	{
		std::cout << "부모클래스 소멸자" << std::endl;
	}
};

class Child : public Parent{
public:
	Child()
	{
		std::cout << "자식클래스 생성자" << std::endl;
	}
	~Child()
	{
		std::cout << "자식클래스 소멸자" << std::endl;
	}
};

int main()
{
	Parent* p = new Child();
	delete p;

	return 0;
}

위 코드를 실행하면

이와 같이 나옵니다. 부모클래스 생성자 -> 자식클래스 생성자 -> 자식클래스 소멸자 -> 부모클래스 소멸자 순으로 호출이 되어야 하는데 자식클래스 소멸자가 호출이 안됐습니다.

 

소멸자가 호출되지 않는다면 여러가지 문제가 생길 수 있습니다. 예를 들어서 자식클래스 객체에서 메모리를 동적으로 할당하고 소멸자에서 해제하는데, 소멸자가 호출이 안된다면 메모리 누수(memory leak)가 생깁니다.

 

이 때, virtual 키워드를 사용해서 Parent의 소멸자를 가상함수로 만든다면 해결됩니다.

 

 

2. 가상 함수의 구현 원리

 

이런 의문이 들 수 있습니다. 모든 함수들을 virtual로 만들어버리면 안되나?

 

모든 함수들을 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()을 오버라이드 할 수 있습니다.

 

이와 같이 두 단계에 걸쳐서 함수를 호출해서 소프트웨어적으로 동적 바인딩을 구현할 수 있게 됩니다. 이러한 이유로 가상함수를 호출하는 경우, 일반적인 함수보다 약간 더 시간이 오래 걸리게 됩니다. 물론 이 차이는 극히 미미하지만, 최적화가 매우 중요한 분야에서는 이를 감안할 필요가 있습니다. 따라서 C++에서는 멤버 함수가 디폴트로 가상함수가 되도록 설정하지는 않습니다.

 

 

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

 

#include <iostream>

class Parent {
public:
	Parent()
	{
		std::cout << "부모클래스 생성자" << std::endl;
	}
	virtual ~Parent()
	{
		std::cout << "부모클래스 소멸자" << std::endl;
	}
	virtual void test() = 0;
};

class Child : public Parent{
public:
	Child()
	{
		std::cout << "자식클래스 생성자" << std::endl;
	}
	~Child()
	{
		std::cout << "자식클래스 소멸자" << std::endl;
	}
	void test() override
	{
		std::cout << "test" << std::endl;
	}
};

int main()
{
	Parent* p = new Child();

	p->test();

	delete p;

	return 0;
}

위 코드를 실행하면

코드를 보면 한 가지 특이한 점을 볼 수 있습니다.

 

Parent 클래스의 test 함수를 보면, 다른 함수들과는 달리 함수의 몸통이 정의되어 있지 않고 단순히 = 0; 으로 처리되어 있는 가상함수 입니다.

 

이는 무엇을 하는지 정의되어 있지 않는 함수 입니다. 다시 말해 이 함수는 반드시 오버라이딩 되어야만 하는 함수입니다. 이렇게 가상함수에 = 0; 을 붙여서 반드시 오버라이딩 되도록 만든 함수를 완전한 가상 함수라해서, 순수 가상 함수(pure virtual function)라고 부릅니다.

 

당연하게도 순수 가상 함수는 본체가 없기 때문에, 이 함수를 호출하는 것도 불가능하고 해당 클래스 객체를 생성하는것 또한 불가능합니다. 순수 가상 함수를 최소 한 개 이상 포함하고 있는 클래스는 객체를 생성할 수 없으며 인스턴스화 시키기 위해서는 이 클래스를 상속 받는 클래스를 만들어서 모든 순수 가상함수를 오버라이딩 해주어야만 합니다.

 

이렇게 순수 가상 함수를 최소 한개 포함하고 있는, 반드시 상속 되어야 하는 클래스를 가리켜 추상 클래스(abstract class)라고 부릅니다.

 

추상 클래스를 도대체 왜 사용하는 것일까요?

 

추상 클래스 자체로는 인스턴스화 시킬 수 없고 (추상 클래스의 객체를 만들 수 없다) 사용하기 위해서는 반드시 다른 누군가가 상속 해줘야만 하기 때문입니다. 하지만 추상 클래스를 설계도라고 생각하면 좋습니다.

 

즉, 이 클래스를 상속받아서 사용하는 사람에게 "이 기능은 일반적인 상황에서 만들기 힘드니 너가 직접 특수화 되는 클래스에 맞추어서 만들어서 써라." 라고 말해주는 것입니다. 

 

추상 클래스의 또 한가지 특징은 비록 객체는 생성할 수 없지만, 추상 클래스를 가리키는 포인터는 문제 없이 만들 수 있다는 점입니다. 위 예에서도 보았듯, 아무런 문제 없이 Parent* 변수를 생성하였습니다.

 

 

4. 다중 상속(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 클래스의 생성자 순으로 호출됩니다.

 

 

5. 다중 상속 시 주의할 점

 

다중 상속은 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;
}

위 처럼 A, B를 모두 상속 받은 C의 입장에서 어떤 클래스의 멤버 변수에 접근해야하는지 확실하지 않습니다. 함수의 경우도 마찬가지입니다. 어떤 함수를 호출해야하는지 구분할 수 없습니다.

 

한가지 또 주의해야 할 점으로 다이아몬드 상속(diamond inheritance) 혹은 공포의 다이아몬드 상속(dreadful diamond of derivation)이라고 부르는 형태의 다중 상속이 있습니다.

상속이 되는 두 개의 클래스가 공통의 베이스 클래스를 포함하고 있는 형태를 다이아몬드 상속이라고 부릅니다. 딱 봐도 문제점이 명확합니다.

 

아무리 Me가 상속받고 있는 두 클래스를 안겹치게 만든다고 해도 최상위 클래스의 모든 내용이 중복되는 문제가 발생합니다. 이를 해결하는 방법은 최상위 클래스를 virtual로 상속 받으면 됩니다. 그러면 Me에서 다중 상속 시에도 컴파일러가 언제나 Human을 한번만 포함하도록 지정할 수 있게 됩니다.

 

상속과 가상함수는 C++에서 아주 중요한 개념이므로 확실히 숙지하고 넘어가는 것을 추천합니다.

'C++' 카테고리의 다른 글

가상 함수(virtual 키워드)와 다형성  (0) 2020.10.29
업 캐스팅과 다운 캐스팅  (0) 2020.10.29
상속 (Inheritance)  (0) 2020.10.27
C++ 에서의 캐스팅(형변환)  (0) 2020.10.27
연산자 오버로딩(전위/후위 증감 연산자)  (0) 2020.10.27