부모 클래스의 소멸자에 가상함수를 쓰는 이유
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의 소멸자가 호출되지 않던 것이
▼ 정상적으로 호출 됨을 알 수 있다.