다형성과 가상 함수
기반 클래스 포인터와 가상 함수가 실제 객체의 재정의 메서드를 선택하는 동적 바인딩과 슬라이싱 문제를 이해합니다.
원, 사각형, 삼각형을 그리는 프로그램에서 도형마다 따로 반복문을 만들면 도형의 종류가 늘 때마다 코드도 늘어납니다. 모든 도형을 Shape라는 하나의 타입으로 다루고, draw()를 부르면 각자 자기 모양을 그리게 할 수 있다면 훨씬 간단합니다. 같은 호출이 실제 객체의 종류에 따라 다르게 동작하는 성질을 다형성(polymorphism)이라고 합니다.
이 글은 C++에서 다형성을 만드는 가상 함수(virtual function), 함수가 정해지는 시점인 정적 바인딩과 동적 바인딩, 기반 클래스의 소멸자를 가상으로 만들어야 하는 이유, 그리고 객체를 값으로 복사할 때 생기는 객체 슬라이싱을 다룹니다. 여기서 다루는 것은 실행 중에 함수를 고르는 다형성이며, 템플릿을 이용해 컴파일할 때 고르는 방식은 11장에서 봅니다.
정적 바인딩과 동적 바인딩
호출할 함수를 정하는 것을 바인딩(binding)이라고 합니다. 보통의 멤버 함수는 호출식에 쓰인 타입, 즉 정적 타입(static type)을 보고 컴파일할 때 함수를 정하며, 이를 정적 바인딩이라고 합니다. 그래서 Shape*로 Circle 객체를 가리켜도, 가상 함수가 아니라면 Shape의 함수가 호출됩니다.
기반 클래스에서 virtual로 선언한 함수는 다릅니다. 포인터나 참조가 실제로 가리키는 객체의 타입, 즉 동적 타입(dynamic type)을 보고 실행 중에 함수를 정하며, 이를 동적 바인딩이라고 합니다. 파생 클래스에서 같은 서명으로 재정의한 함수가 있으면 그 함수가 호출됩니다.
#include <iostream>
#include <memory>
#include <vector>
class Shape {
public:
virtual void draw() const { std::cout << "shape\n"; }
virtual ~Shape() = default;
};
class Circle : public Shape {
public:
void draw() const override { std::cout << "circle\n"; }
};
class Rectangle : public Shape {
public:
void draw() const override { std::cout << "rectangle\n"; }
};
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>());
shapes.push_back(std::make_unique<Rectangle>());
shapes.push_back(std::make_unique<Shape>());
for (const auto& shape : shapes) {
shape->draw();
}
}circle
rectangle
shapeshapes는 Shape를 가리키는 스마트 포인터의 목록이지만, shape->draw()는 각 원소가 실제로 가리키는 객체에 맞는 함수를 부릅니다. 새 도형을 추가해도 반복문은 바꿀 필요가 없고, draw()를 재정의한 새 클래스를 만들어 목록에 넣기만 하면 됩니다. 원소를 std::unique_ptr로 소유했으므로 동적 메모리 할당에서 본 것처럼 따로 delete할 필요도 없습니다.
파생 클래스에서 재정의할 때 virtual은 다시 적지 않아도 되며, 대신 override를 붙여 서명이 맞는지 컴파일러가 검사하게 합니다. 가상 함수는 많은 컴파일러가 클래스마다 가상 함수의 주소 표(vtable)를 두고 객체에 그 표를 가리키는 포인터를 넣는 방식으로 구현하지만, 표준이 이런 구조를 정해 둔 것은 아닙니다.
더 깊이: vtable은 어느 목적 파일에 만들어질까
GCC와 Clang이 따르는 Itanium C++ ABI는 가상 함수가 있는 클래스의 vtable을 어느 번역 단위에 만들지 정해 둡니다. 클래스 템플릿에서 만든 클래스나 모듈에 속한 클래스가 아니고 키 함수(key function)가 있는 클래스라면, vtable은 그 함수의 정의가 들어 있는 번역 단위의 목적 파일에만 만들어지는데, 키 함수는 클래스를 정의하는 시점에 인라인이 아닌 첫 번째 가상 함수 가운데 순수 가상이 아닌 것입니다. 키 함수가 없으면 vtable을 쓰는 모든 목적 파일에 만들어 두고 링크할 때 하나만 남깁니다.
이 규칙은 흔한 링크 오류를 설명해 줍니다. 헤더에 virtual void draw();처럼 가상 함수를 선언만 하고 어느 .cpp에도 정의하지 않았는데 그 함수가 인라인이 아닌 첫 번째 가상 함수라면, 이 함수가 키 함수이므로 vtable을 만드는 번역 단위가 하나도 없게 됩니다. 그러면 그 클래스의 객체를 만드는 곳에서 정의하지 않은 함수가 아니라 그 클래스의 vtable을 찾지 못했다는 링크 오류가 날 수 있습니다. 이런 오류가 보이면 그 클래스에서 선언만 하고 정의하지 않은 가상 함수가 있는지부터 찾아봅니다.
가상 소멸자
기반 클래스의 포인터로 파생 클래스 객체를 정리하려면, 기반 클래스의 소멸자가 가상이어야 합니다. 위 Shape의 virtual ~Shape() = default;가 그 역할을 합니다.
#include <iostream>
#include <memory>
class Shape {
public:
virtual ~Shape() { std::cout << "~Shape\n"; }
};
class Circle : public Shape {
public:
~Circle() override { std::cout << "~Circle\n"; }
};
int main() {
std::unique_ptr<Shape> shape = std::make_unique<Circle>();
}~Circle
~Shapeshape는 Shape의 포인터로 Circle을 소유하고, main이 끝날 때 delete로 정리합니다. 소멸자가 가상이므로 실제 타입인 Circle의 소멸자가 먼저 실행되고, 이어서 기반 부분의 Shape 소멸자가 실행됩니다. 소멸자가 가상이 아니라면 기반 포인터로 파생 객체를 delete하는 것은 정의되지 않은 동작이며, 단순히 기반 소멸자만 실행된다고 기대할 수도 없습니다.
그래서 다른 클래스가 상속받아 기반 포인터로 다룰 클래스라면, 소멸자를 virtual로 선언합니다. GCC는 -Wall에 포함된 -Wdelete-non-virtual-dtor로 가상 함수가 있는데 소멸자는 가상이 아닌 클래스를 delete하는 코드를 경고합니다. 반대로 상속받지 않게 하려는 클래스에는 class Circle final처럼 final을 붙여 상속을 막을 수 있습니다.
객체 슬라이싱
다형성은 포인터나 참조로 객체를 다룰 때만 동작합니다. 기반 클래스 타입의 값으로 복사하면, 기반 클래스 부분만 복사된 새 객체가 만들어지고 파생 클래스 부분은 잘려 나갑니다. 이를 객체 슬라이싱(object slicing)이라고 합니다.
#include <iostream>
#include <string>
class Animal {
public:
virtual std::string sound() const { return "Animal sound"; }
virtual ~Animal() = default;
};
class Dog : public Animal {
public:
std::string sound() const override { return "Woof"; }
};
void printByValue(Animal a) {
std::cout << "by value: " << a.sound() << '\n';
}
void printByRef(const Animal& a) {
std::cout << "by ref: " << a.sound() << '\n';
}
int main() {
Dog d;
printByValue(d);
printByRef(d);
}by value: Animal sound
by ref: WoofC++ · POLYMORPHISM & SLICING
같은 virtual 호출도 객체 전달 방식에 따라 달라진다
본문의 sound()는 virtual입니다. 참조는 Dog 객체를 유지하지만, Animal 값으로 복사하면 파생 부분이 잘린 새 Animal 객체가 됩니다.
참조 전달 → Dog::sound()
const Animal& a = d: d의 실제 타입 Dog가 유지되어 "Woof"를 출력합니다.
값 전달 → Animal::sound()
Animal a = d: 기반 부분만 복사된 Animal 객체에서 "Animal sound"를 출력합니다.
호출 계약: 비가상 함수는 정적 타입의 구현을 사용합니다. 다형적 삭제를 허용한다면 기반 소멸자도 virtual이어야 합니다. vtable·vptr은 흔한 구현 방식입니다.
그림처럼 printByValue의 매개변수 a는 d에서 기반 부분만 복사한 새 Animal 객체이므로, sound()가 가상이어도 Animal의 함수가 호출됩니다. printByRef의 a는 원래의 Dog 객체를 참조하므로 동적 타입이 Dog로 유지되어 Woof를 출력합니다. 어느 경우에도 원래의 d는 바뀌지 않으며, 슬라이싱은 복사본에서 일어나는 일입니다.
그래서 다형성이 필요한 함수는 기반 클래스를 값이 아니라 const Base&, Base&, Base*로 받고, 여러 객체를 담을 때는 std::vector<Shape>가 아니라 std::vector<std::unique_ptr<Shape>>처럼 포인터를 담습니다. 참조와 원시 포인터는 객체를 빌려 쓸 뿐이므로, 호출하는 동안 원래 객체가 살아 있어야 합니다.
기반 클래스의 함수를 직접 부르기
가상 함수라도 Animal::sound()처럼 클래스 이름을 붙여 부르면, 동적 바인딩을 쓰지 않고 그 클래스의 함수를 직접 부릅니다. 상속의 개념과 구현의 Dog::displayInfo가 Animal::displayInfo()로 기반 구현을 먼저 부른 것이 이 방식입니다.
생성자와 소멸자 안에서 가상 함수를 부를 때도 주의해야 합니다. 기반 클래스의 생성자가 실행되는 동안에는 파생 클래스 부분이 아직 만들어지지 않았으므로, 그 안의 가상 호출은 파생 클래스의 재정의가 아니라 기반 클래스의 함수로 정해집니다. 소멸자 안에서도 파생 부분은 이미 정리되었으므로 마찬가지입니다. 그래서 생성자와 소멸자에서는 파생 클래스의 동작을 기대하고 가상 함수를 부르지 않습니다.
정리
- 다형성은 같은 호출이 실제 객체의 타입에 따라 다르게 동작하는 성질이며, C++에서는 가상 함수로 만듭니다.
- 보통의 멤버 함수는 정적 타입으로 컴파일할 때, 가상 함수는 포인터나 참조가 가리키는 동적 타입으로 실행 중에 정해집니다.
- 기반 포인터로 파생 객체를 정리할 클래스는 소멸자를
virtual로 선언하며, 그렇지 않으면 정의되지 않은 동작입니다. - 기반 클래스의 값으로 복사하면 파생 부분이 잘리는 객체 슬라이싱이 일어나므로, 다형성이 필요하면 참조나 포인터로 다룹니다.
- 여러 다형 객체는
std::vector<std::unique_ptr<Base>>처럼 스마트 포인터로 담습니다. Base::f()처럼 이름을 붙이면 기반 구현을 직접 부르며, 생성자와 소멸자 안의 가상 호출은 파생 클래스로 가지 않습니다.
연습 문제
1. 세 가지 호출 경로
slicing.cpp의 main을 다음으로 바꾸면 세 줄은 각각 무엇을 출력하나요? copied와 viewed의 선언에 &가 있는지, 마지막 줄의 Animal::이 호출을 어떻게 바꾸는지를 보고 답해 보세요.
int main() {
Dog d;
Animal copied = d;
const Animal& viewed = d;
std::cout << copied.sound() << '\n';
std::cout << viewed.sound() << '\n';
std::cout << viewed.Animal::sound() << '\n';
}해설 보기
Animal sound, Woof, Animal sound입니다. copied는 새 Animal 객체여서 Animal의 함수를, viewed는 원래의 Dog를 참조하므로 Dog의 재정의를 부릅니다. 마지막 줄은 Animal::로 기반 구현을 직접 지정해 동적 바인딩을 쓰지 않습니다. d가 Animal로 바뀌거나 파생 부분이 지워진 것은 아닙니다.
2. virtual을 지우면
shapes.cpp에서 Shape::draw의 virtual과 두 파생 클래스의 override를 모두 지우면 출력은 어떻게 되나요? Circle c; c.draw();는 무엇을 출력하나요? virtual만 지우고 override는 남기면 어떻게 되나요?
해설 보기
draw가 가상이 아니므로 shape->draw()는 정적 타입 Shape의 함수로 정해져 세 줄 모두 shape를 출력합니다. c.draw()는 정적 타입이 Circle이므로 circle을 출력하며, 항상 기반 함수가 불리는 것이 아니라 호출식의 타입으로 정해진다는 뜻입니다. virtual만 지우면 재정의할 가상 함수가 없어 override가 붙은 선언이 컴파일 오류가 됩니다.
3. 생성자 안의 가상 호출
다음 프로그램은 무엇을 출력하나요? describe()는 Base의 생성자 안과 main의 마지막 줄에서 두 번 불리므로, 각 호출 시점에 d가 어디까지 만들어져 있는지를 따져 보세요.
#include <iostream>
class Base {
public:
Base() { describe(); }
virtual void describe() const { std::cout << "base\n"; }
virtual ~Base() = default;
};
class Derived : public Base {
public:
void describe() const override { std::cout << "derived\n"; }
};
int main() {
Derived d;
d.describe();
}해설 보기
첫 줄은 base, 둘째 줄은 derived입니다. Base의 생성자가 실행되는 동안에는 Derived 부분이 아직 만들어지지 않았으므로, 그 안의 describe()는 Base의 함수로 정해집니다. 객체가 완성된 뒤의 d.describe()는 Derived의 재정의를 부릅니다.
다음 글 추상 클래스와 순수 가상 함수에서는 구현 없이 인터페이스만 정하는 순수 가상 함수와, 그것을 가진 추상 클래스를 다룹니다.