멈추지 않고 끈질기게
[C++] 가상 함수(virtual function) 본문
※ 해당 포스팅은 개인의 공부 정리용 글입니다. 틀린 내용이 있다면 추후 수정될 수 있습니다.
※ 해당 포스팅은 C++ 14 버전을 기준으로 작성되었습니다.
이번 포스팅에서는 가상 함수에 대하여 알아보겠습니다.
1. 가상 함수(virtual function)
가상 함수(virtual function)란 상속 받은 클래스에서 재정의할 멤버 함수를 말합니다. 함수 이름 앞에 virtual 키워드를 작성하여 선언할 수 있습니다. 가상 함수는 상속 받은 클래스에서 재정의할 수 있으며, 재정의된 함수는 부모 타입의 포인터로 호출하더라도 해당 객체에 맞는 함수가 호출됩니다(반드시 재정의 해야하는 것은 아닙니다). 일반 멤버 함수 또한 하위 클래스에서 동일한 함수를 선언할 수 있으나, 이 경우 가상 함수와는 달리 부모 타입의 포인터로 호출할 경우 부모의 함수를 호출하게 됩니다.
참고로 가상 함수 재정의 시 virtual 키워드를 다시 붙일 필요는 없습니다. 가상 함수는 상속받은 자식 클래스에서도 계속 가상 함수로 존재하게 됩니다.
// 부모 클래스
class Animal
{
public:
void Test()
{
cout << "Animal의 Test입니다." << endl;
}
virtual void VirtualTest()
{
cout << "Animal의 VirtualTest입니다." << endl;
}
};
// Animal 상속 클래스
class Dog : public Animal
{
public:
// 가상 함수 재선언
void VirtualTest()
{
cout << "Dog의 VirtualTest입니다." << endl;
}
};
// Animal 상속 클래스
class Cat : public Animal
{
// 일반 멤버 함수 재선언
void Test()
{
cout << "Cat의 Test입니다." << endl;
}
};
int main()
{
// 일반 함수 호출
Animal* a1 = new Cat();
a1->Test();
// 가상 함수 호출
Animal* a2 = new Dog();
a2->VirtualTest();
delete a1;
delete a2;
}
상기 예제 코드는 Animal 클래스 내에 Test() 함수를 가상 함수로 선언하였으며, Animal을 상속받는 Dog, Cat 클래스를 선언하였습니다. Dog의 경우 VirtualTest() 함수를 재정의했고 Cat의 경우 Test()를 재정의했으며, 각각 객체 동적 할당 후 Animal 타입의 포인터로 가리키게 하였습니다. 자식 클래스를 가리키는 부모 타입의 포인터로 호출 시 일반 함수는 부모 클래스의 함수가, 가상 함수는 재정의한 자식 클래스의 함수가 호출됨을 알 수 있습니다. 이렇듯 가상 함수를 활용하면 부모 타입의 포인터를 사용해도 객체에 맞는 함수가 호출되도록 할 수 있습니다.
일반 멤버 함수의 호출 코드는 컴파일 시점에서 고정된 주소로 연결되나(정적 바인딩, static binding), 가상 함수의 경우 객체에 따라 호출 시점에 어느 주소로 가야할지 정해야 합니다(동적 바인딩, dynamic binding). 가상 함수를 포함하는 클래스의 객체 생성 시 메모리에 가상 함수 테이블(virtual function table)이 올라가며, 이를 참조하여 어느 클래스의 함수를 호출할지 런타임 상에 결정하게 됩니다. 가상 함수 테이블은 상속관계가 있는 클래스 간의 형변환에 사용하는 dynamic_cast<T> 연산자의 호출 조건이기도 합니다.
2. 부모 클래스의 소멸자
기본적으로 부모 클래스를 상속 받은 자식 클래스의 객체 생성 / 해제 시 다음과 같이 진행됩니다.
부모의 생성자 호출 -> 자식의 생성자 호출 -> (...) -> 자식의 소멸자 호출 -> 부모의 소멸자 호출
다만 특정 경로로 객체 소멸 시 자식의 소멸자가 호출되지 않는 상황이 발생할 수 있습니다.
// 부모 클래스
class Animal
{
public:
Animal()
{
cout << "Animal의 생성자 호출" << endl;
}
~Animal()
{
cout << "Animal의 소멸자 호출" << endl;
}
};
// Animal 상속 클래스
class Dog : public Animal
{
public:
Dog()
{
cout << "Dog의 생성자 호출" << endl;
}
~Dog()
{
cout << "Dog의 소멸자 호출" << endl;
}
};
// Animal 상속 클래스
class Cat : public Animal
{
public:
Cat()
{
cout << "Cat의 생성자 호출" << endl;
}
~Cat()
{
cout << "Cat의 소멸자 호출" << endl;
}
};
int main()
{
Animal* animal = new Dog();
delete animal;
}
상기 코드에서는 자식 객체를 동적 할당하여 부모 클래스인 Animal 타입의 포인터 가리키게 하였고, 해당 포인터를 통해 힙에서 해제하였습니다. 실행 결과를 보면 자식의 소멸자가 호출되지 않았음을 알 수 있습니다. 만약에 자식 클래스의 소멸자에서 동적 할당한 객체의 해제를 담당하고 있었다면, 이러한 코드는 메모리 누수를 유발하게 됩니다. 이를 막으려면 부모 클래스의 소멸자를 가상 함수로 선언하면 됩니다.
// 부모 클래스
class Animal
{
public:
Animal()
{
cout << "Animal의 생성자 호출" << endl;
}
//소멸자를 가상함수로 선언
virtual ~Animal()
{
cout << "Animal의 소멸자 호출" << endl;
}
};
// Animal 상속 클래스
class Dog : public Animal
{
public:
Dog()
{
cout << "Dog의 생성자 호출" << endl;
}
~Dog()
{
cout << "Dog의 소멸자 호출" << endl;
}
};
// Animal 상속 클래스
class Cat : public Animal
{
public:
Cat()
{
cout << "Cat의 생성자 호출" << endl;
}
~Cat()
{
cout << "Cat의 소멸자 호출" << endl;
}
};
int main()
{
Animal* animal = new Dog();
delete animal;
}
기존의 코드에서 부모의 소멸자에 virtual을 붙여 가상 함수로 선언하면 자식의 소멸자가 호출됨을 알 수 있습니다. Animal 타입의 포인터로 delete 했으므로 부모의 소멸자를 호출하려고 하나, 해당 함수가 가상함수이기 때문에 가상 함수 테이블 참조 후 자식 클래스의 소멸자를 호출하게 됩니다. 그리고 자식 클래스의 소멸자의 후처리 영역에서 부모 클래스의 소멸자를 호출하므로, 자연스럽게 부모-자식 클래스의 모든 소멸자가 호출됩니다. 따라서 부모 클래스의 포인터를 통한 할당 해제(delete)도 문제 없이 사용하기 위해, C++에서는 부모 클래스의 소멸자는 가상 함수로 선언하는 것이 좋습니다.
'C++' 카테고리의 다른 글
[C++] std::endl vs "\n" (2) | 2024.07.23 |
---|---|
[C++] 람다 표현식(lambda expression) (0) | 2023.12.05 |
[C++] 스마트 포인터(Smart Pointer) (0) | 2023.11.23 |
[C++] 타입 변환 연산자(Type Casting Operator) (0) | 2023.03.19 |
[C++] 포인터(Pointer) (0) | 2023.03.08 |