상속만으로는 충분하지 않은 이유
이전 페이지에서 클래스 계층 구조를 만들었습니다. 파생 클래스는 기반 클래스로부터 멤버를 상속받습니다. 그런데 함정이 있습니다. 기반 클래스 포인터를 통해 메서드를 호출하면, C++는 객체의 실제 타입이 아니라 포인터의 타입을 기준으로 어떤 함수를 실행할지 결정합니다. 그래서 실제로는 Dog를 가리키는 Animal*이라도 여전히 Animal의 메서드 버전을 호출합니다.
이것은 거의 원하는 동작이 아닙니다. 대부분의 경우 기반 클래스 포인터들의 모음이 있고, 각 포인터는 서로 다른 파생 객체를 가리키며, 각각이 자기 자신처럼 동작하기를 원합니다. 가상 함수가 바로 그것을 가능하게 합니다.
객체는 분명히 Dog인데도 a->speak()는 Animal::speak()를 실행했습니다. speak가 가상이 아니기 때문에 컴파일러는 정적 타입 Animal*을 기준으로 컴파일 타임에 함수를 골랐습니다. 바로 이 버그를 해결하기 위해 가상 함수가 존재합니다.
함수를 가상으로 만들기
기반 클래스 메서드에 virtual 키워드를 추가합니다. 이제 호출은 객체의 실제 타입을 기준으로 런타임 에 결정됩니다. 이것이 동적 디스패치입니다.
Animal*에 대한 하나의 루프로 세 가지 다른 동작이 나옵니다. 기반 포인터는 런타임에 실제 타입을 "알고" 그에 맞게 디스패치합니다. 이 하나의 메커니즘, 즉 하나의 인터페이스에 여러 구현이라는 개념이 바로 C++에서 다형성 이 의미하는 것입니다.
virtual은 기반 클래스 선언에만 나타나면 된다는 점에 유의하세요. 일단 함수가 가상이 되면, 모든 파생 클래스에서 자동으로 가상으로 유지됩니다. 파생 클래스에서 다시 쓰는 것은 선택 사항이며 중복입니다.
항상 override 키워드를 사용하세요
위 예제에서 각 파생 메서드에는 override가 표시되어 있습니다. 코드가 동작하는 데는 선택 사항이지만, 필수로 다루어야 합니다. override(C++11)는 일치하는 시그니처로 기반 가상 함수를 실제로 오버라이드하고 있는지 컴파일러가 검증하도록 요청합니다. 시그니처를 미묘하게 틀리면, 조용한 버그 대신 명확한 오류를 얻게 됩니다.
struct Animal {
virtual void speak() const { } // 참고: const
};
struct Dog : Animal {
void speak() { } // const가 아님 - 이것은 오버라이드가 아니라 새로운 함수!
void speak() override { } // 오류: 'speak'는 오버라이드하지 않음 - 즉시 알려준다
};
override가 없으면 첫 번째 speak()는 문제없이 컴파일되지만, 시그니처가 기반 클래스와 다르기 때문에(const가 빠짐) Animal*을 통해 절대 호출되지 않습니다. 오버라이드가 왜 아무 일도 하지 않는지 알아내느라 한나절을 보내게 될 것입니다. override를 쓰면 컴파일러가 그 자리에서 불일치를 잡아냅니다. 오버라이드하는 모든 함수에 추가하세요.
순수 가상 함수와 추상 클래스
때로는 기반 클래스에 합리적인 기본값이 없습니다. 일반적인 "Animal"은 어떤 소리를 낼까요? 그런 경우에는 = 0을 할당하여 함수를 순수 가상 으로 선언합니다. 이렇게 하면 함수에 본문이 없게 되고, 클래스는 그 자체로는 인스턴스화할 수 없는 추상 클래스가 됩니다. 추상 클래스는 파생 클래스가 반드시 충족해야 하는 인터페이스를 정의하기 위해서만 존재합니다.
모든 구체적 하위 클래스는 area()를 반드시 구현해야 하며, 그렇지 않으면 그 클래스도 추상으로 남습니다. 이것이 C++가 "인터페이스"를 표현하는 방식입니다. 순수 가상 함수만 가진 추상 클래스는 Java 같은 언어의 인터페이스에 해당하는 C++의 개념입니다.
가상 소멸자 규칙
이것은 누구나 적어도 한 번은 걸려드는 함정입니다. 기반 클래스 포인터를 통해 객체를 delete하면, C++는 찾은 소멸자를 호출합니다. 그리고 그 소멸자가 가상이 아니면 기반 소멸자만 실행합니다. 파생 부분은 결코 소멸되지 않아 보유하던 모든 것을 누수시킵니다. 표준은 이를 미정의 동작이라고 부릅니다.
해결책은 단 한 단어입니다. 기반 소멸자를 virtual로 만드세요. 그러면 delete p는 마땅히 그래야 하듯이 먼저 ~Derived를 실행한 다음 ~Base를 실행합니다.
struct Base {
virtual ~Base() { cout << "~Base\n"; } // 올바름
};
// 이제: ~Derived 그다음 ~Base
경험칙: 클래스에 가상 함수가 하나라도 생기는 순간, 가상 소멸자도 함께 부여하세요. 클래스가 포인터를 통해 사용되는 기반 클래스로 의도되었다면, 그 소멸자는 반드시 가상이어야 합니다.
흔한 실수와 함정
가상 함수에 익숙해지면 주의해야 할 몇 가지 함정이 더 있습니다.
객체 슬라이싱(object slicing). 파생 객체를 값으로 기반 변수에 전달하거나 저장하면, 파생 부분이 "잘려 나가고" 순수한 기반 객체만 남습니다. 이렇게 되면 가상 디스패치가 더 이상 오버라이드에 도달하지 못합니다. 다형성에는 항상 포인터나 참조를 사용하세요.
Dog d;
Animal a = d; // 슬라이싱됨: a는 이제 그냥 Animal이고 Dog 부분은 사라졌다
a.speak(); // 가상이지만 Animal::speak를 실행한다
Animal& ref = d; // OK: 참조는 실제 타입을 유지한다
ref.speak(); // Dog::speak를 실행한다
생성자나 소멸자에서 가상 함수를 호출하지 마세요. 생성 중에는 파생 부분이 아직 존재하지 않으므로, 가상 호출은 파생 오버라이드가 아니라 현재 클래스의 버전으로 해결됩니다. 이는 의도한 바인 경우가 거의 없습니다.
가상 디스패치에는 작은 비용이 있습니다. 각 가상 호출은 함수 포인터의 숨겨진 테이블("vtable")을 거치며, 호출마다 한 번의 간접 참조가 발생합니다. 저렴하지만 공짜는 아니므로, 오버라이드가 정말로 필요한 경우가 아니라면 함수를 가상으로 만들지 마세요.
의도적으로 기반 버전 호출하기. 오버라이드 내부에서도 Base::method()로 기반 구현을 명시적으로 호출할 수 있습니다. 파생 동작이 기반을 대체 하는 것이 아니라 확장 할 때 유용합니다.
다음: 연산자 오버로딩
가상 함수는 객체가 공유 인터페이스를 통해 자신의 동작 을 사용자 정의하도록 해줍니다. 다음 페이지에서는 객체에 작용하는 연산자 를 사용자 정의하는 방법을 보여줍니다. 연산자 오버로딩을 사용하면 자신의 타입에 +, ==, << 등에 응답하는 방법을 가르칠 수 있어, Vector + Vector나 cout << myObject가 내장 타입만큼이나 자연스럽게 읽힙니다.
자주 묻는 질문
C++에서 가상 함수란 무엇인가요?
가상 함수는 기반 클래스에서 virtual 키워드로 선언한 멤버 함수입니다. 기반 클래스 포인터나 참조를 통해 호출하면 C++는 기반 버전 대신 파생 클래스의 오버라이드를 실행합니다. 이 런타임 선택을 동적 디스패치(dynamic dispatch)라고 하며, 다형성의 기초가 됩니다.
가상 함수와 순수 가상 함수의 차이는 무엇인가요?
가상 함수는 본문이 있으며 오버라이드할 수 있습니다. 순수 가상 함수는 = 0으로 선언하며 기반 클래스에 본문이 없습니다. 모든 구체적 파생 클래스가 반드시 구현을 제공하도록 강제합니다. 순수 가상 함수를 하나라도 가진 클래스는 추상 클래스이며 인스턴스화할 수 없습니다.
C++에서 기반 클래스에 가상 소멸자가 필요한 이유는 무엇인가요?
기반 클래스 포인터를 통해 파생 객체를 delete할 때 기반 소멸자가 가상이 아니면 기반 소멸자만 실행됩니다. 파생 부분은 결코 정리되지 않아 리소스가 누수되며, 이는 미정의 동작입니다. 다형적으로 사용할 의도가 있는 클래스의 소멸자는 반드시 virtual로 만드세요.