자습

부모 클래스의 소멸자에 가상함수를 쓰는 이유

hyrule 2022. 4. 27. 22:15
class CParent
{
public:
	CParent()
	{
		std::cout << "CParent 생성자" << std::endl;
	}

	~CParent()
	{
		std::cout << "CParent 소멸자" << std::endl;
	}
};


class CChild : public CParent
{
public:
	{
		std::cout << "CChild 생성자" << std::endl;
	}

	~CChild()
	{
		std::cout << "CChild 소멸자" << std::endl;
	}
};

▲ 이렇게 클래스를 상속받아 사용하면

 

CParent* Array[100];

Array[0] = new CChild;

▲ 이런 식으로 부모 클래스 포인터 형식의 배열에 자식 클래스를 저장할 수 았게 된다.

이를 업캐스팅(Upcasting)이라고 한다.

 

 

 

* 만약 업캐스팅되어 부모의 클래스 주소로 저장된 자식 클래스가 있을 때,

자식 클래스만의 고유 기능을 다시 사용하기 위해서는 '다운캐스팅'을 해야 한다.

형변환을 해준다고 생각하면 될 듯.

CParent* Array[100];

Array[0] = new CChild;

(CChild*)Array[0]->(클래스C고유함수);

 

 

 

 

 

 

* 문제점은, CParent 클래스를 상속받은 자식 클래스들은 CParent 클래스를 토대로 살을 붙여 만든 클래스이므로 그 크기가 CParent 클래스보다 클 수밖에 없는데, 이 클래스를 CParent* 타입의 배열에 집어넣는 데서 발생한다.

크기가 10인 그릇에 15, 20, 30 등 더 많은 양을 우겨넣는 것이다.

그래서 결국 포인터 공간을 초과하는 사이즈만큼은 접근이 불가능하다. 

 

▼ 업캐스팅으로 들어간 자식 클래스들은 이런 모양으로 배열에 들어가게 된다.

CParent* Array[100];

Array[0] = new CChild;

delete Array[0];

▲ 실제로 업캐스팅 방식으로 상속받은 자식 클래스를 생성하고 다시 제거를 해 보면

▲ CParent 클래스에는 없는, CChild 클래스의 소멸자가 호출되지 않은 것을 알 수 있다.

만약 클래스 내부에서 동적 할당을 하고 소멸자에서 할당을 해제해 주었다면, 이는 메모리 누수로 이어지게 된다.

 

 

 

▼ 그래서 필요한 것이 '가상 함수'이다.

사용할 함수 앞에 'virtual'이라는 문구를 붙여 주면 된다.

	virtual void VirtualFunc()
    {
    	std::cout << "이건 가상 함수에요" << std::endl;
    }

 

 

* 가상 함수를 선언하게 되면, 해당 클래스는 클래스 생성 시 vfptr(Virtual Function Pointer)이라는 함수 포인터 배열을 '무조건' 생성하고,

그 배열에 선언한 가상 함수의 주소를 등록한다.

* 그래서 가상 함수를 선언한 클래스는 빈 클래스더라도 vfptr 배열을 위한 추가적인 공간을 차지하게 된다.

class CVirtualFuncDefaultSizeTest
{
public:
	CVirtualFuncDefaultSizeTest()
	{}
	virtual ~CVirtualFuncDefaultSizeTest()
	{}
};
class CDefaultSizeTest
{
public:
	CDefaultSizeTest()
	{}
	~CDefaultSizeTest()
	{}
};
CDefaultSizeTest SizeTestDefault;
std::cout << "* Size of empty class: " << sizeof(SizeTestDefault) << std::endl;
CVirtualFuncDefaultSizeTest SizeTestVirtual;
std::cout << "* Size of empty class with virtual function: " << sizeof(SizeTestVirtual) << std::endl;

클래스에 아무것도 들어있지 않은데도 크기가 4로 잡힌 것을 확인할 수 있다.

*vfptr은 디버그 모드에서도 확인할 수 있다.

 

 

 

 

 

 

* 부모 클래스에서 가상함수를 선언하면, 자식 클래스에서도 같은 크기 만큼의 vfptr 배열을 생성하고, 우선 부모 클래스에서 정의한 가상 함수의 주소를 담아놓는다.

그러므로 건들지 않으면 밑의 세 클래스의 vfptr 배열에서 가르키는 함수의 주소는 동일하다.

이 상태에서 각 클래스별로  func()를 호출하면 각자의 vfptr 배열을 타고들어가서 주소에 있는 함수를 호출하게 된다.

지금은 모두 같은 주소를 가르키고 있으므로 똑같은 함수를 호출하고 있는 것이다.

 

 

 

 

 

 

 

* 가상함수는 '재정의'가 가능하다.

클래스 C에서 

class A
{
	virtual void func()
    {
	}
}

class C: public A
{
    void func()
    {
    }
};

▲처럼 함수를 이름과 인자 수가 동일하게 선언하면. 클래스 C만 쓸 수 있는 함수 func()로 재정의된다.

 

▼또한 이 함수의 주소가 vfptr 배열에 등록된다.

 

* 이렇게 귀찮게 재정의를 하는 데는 이유가 있다. 편리하기 때문이다.

재정의를 하게 되면, 부모 클래스 배열에 업캐스팅하여 클래스를 모아놓아도, 클래스 C만의 고유한 함수를 호출하여 사용할 수 있게 된다.

함수는 외부에 있지만, 함수 주소를 알기 때문에 접근이 가능해진 것이다.

 

▲ 일반 함수로 선언했을 때: 다운캐스팅하지 않고는 접근 불가

▼ 가상 함수로 선언했을 때: 다운캐스팅을 하지 않고도 접근 가능

 

 

 

 

 

 

 

 

 

 

 

* 이 '가상함수'와 '재정의'라는 기능을 이용하여, 우리는 Parent* 타입의 배열 그릇 바깥에 있는 소멸자 ~B()와 ~C()를 불러올 수 있다.

부모 클래스의 소멸자에 virtual을 붙여 주면, 클래스 B와 클래스 C는 알아서 자기 자신의 소멸자를 '오버라이딩'하여 vfptr에 가지고 있게 된다.

* 소멸자는 상속 시 모두 '같은 함수'로 취급된다. 때문에 알아서 오버라이딩이 된다.

class CParent
{
public:
	CParent()
	{
		std::cout << "CParent 생성자" << std::endl;
	}

	virtual ~CParent()
	{
		std::cout << "CParent 소멸자" << std::endl;
	}
};

 

 

class CParent
{
public:
	CParent()
	{
		std::cout << "CParent 생성자" << std::endl;
	}

	virtual ~CParent()
	{
		std::cout << "CParent 소멸자" << std::endl;
	}
};
class CChild:public CParent
{
public:
	CChild()
	{
		std::cout << "CChild 생성자" << std::endl;
	}

	~Child()
	{
		std::cout << "CChild 소멸자" << std::endl;
	}
};
CChild* Child = new CChild;
Array[0] = (CParent*)Child;
delete Array[0];

▲ CChild의 소멸자가 호출되지 않던 것이

▼ 정상적으로 호출 됨을 알 수 있다.