가상함수
가상 함수(virtual function)에 대해 초보자 관점에서 설명해 드리겠습니다.
1. 가상함수는 왜 쓰는 건가요?
가상함수는 다형성(polymorphism)을 구현하기 위해 사용합니다. 쉽게 말하면, 같은 함수 이름으로 다른 동작을 할 수 있게 해주는 기능입니다.
예를 들어봅시다. 동물이라는 부모 클래스가 있고, 강아지와 고양이라는 자식 클래스가 있다고 생각해보세요. 모든 동물은 "소리를 낸다"는 기능이 있지만, 강아지는 "멍멍", 고양이는 "야옹"하고 다른 소리를 냅니다. 가상함수를 사용하면 동물이라는 공통 타입으로 강아지나 고양이를 참조하더라도, 각자 자신에게 맞는 소리를 낼 수 있게 됩니다.
2. 가상함수는 언제 쓰는 건가요?
가상함수는 주로 다음과 같은 상황에서 사용합니다:
- 상속 관계에서 부모 클래스의 함수를 자식 클래스에서 재정의(오버라이딩)할 때
- 다양한 타입의 객체를 같은 인터페이스로 다룰 필요가 있을 때
- 실행 시간에 어떤 함수를 호출할지 결정해야 할 때
예를 들어, 여러 도형(사각형, 원, 삼각형 등)을 관리하는 프로그램에서 각 도형의 넓이를 계산하는 함수가 필요하다면, 가상함수를 사용하여 모든 도형을 같은 방식으로 다루면서도 각각에 맞는 넓이 계산 방식을 적용할 수 있습니다.
3. "미래를 생각해서 쓴다"는 말의 의미는 무엇인가요?
이 말은 확장성과 유연성을 의미합니다. 가상함수를 사용하면:
- 나중에 새로운 클래스를 추가하기 쉬워집니다. 예를 들어, 위의 동물 예시에서 나중에 "새" 클래스를 추가해도 기존 코드를 수정할 필요 없이 "소리 내기" 함수만 구현하면 됩니다.
- 코드 재사용성이 높아집니다. 공통 기능은 부모 클래스에 구현하고, 달라지는 부분만 자식 클래스에서 재정의할 수 있습니다.
- 유지보수가 쉬워집니다. 기능 변경이 필요할 때 특정 클래스만 수정하면 되므로 관리가 용이합니다.
쉽게 말해, "지금은 필요 없더라도 나중에 확장될 가능성을 고려해서 설계한다"는 의미입니다. 이것은 소프트웨어 설계의 중요한 원칙인 "개방-폐쇄 원칙(Open-Closed Principle)"을 따르는 것이기도 합니다 - 기존 코드를 수정하지 않고도 기능을 확장할 수 있어야 한다는 원칙입니다.
class Animal {
public:
virtual void makeSound() {
std::cout << "동물 소리" << std::endl;
}
};
class Dog : public Animal {
public:
void makeSound() override {
std::cout << "멍멍!" << std::endl;
}
};
class Cat : public Animal {
public:
void makeSound() override {
std::cout << "야옹~" << std::endl;
}
};
int main() {
Animal* animals[2];
animals[0] = new Dog();
animals[1] = new Cat();
// 동일한 호출 방식이지만 각 객체에 맞는 소리를 냄
for(int i = 0; i < 2; i++) {
animals[i]->makeSound(); // Dog: "멍멍!", Cat: "야옹~"
}
return 0;
}
이번에는 가상함수(virtual)를 사용하지 않으면 어떻게 되는지 예시를 통해 설명해 드리겠습니다.
기존 예시에서 virtual 키워드를 제거해 봅시다:
class Animal {
public:
void makeSound() { // virtual 키워드 제거됨
std::cout << "동물 소리" << std::endl;
}
};
class Dog : public Animal {
public:
void makeSound() { // 여전히 오버라이딩 하고 있음
std::cout << "멍멍!" << std::endl;
}
};
class Cat : public Animal {
public:
void makeSound() { // 여전히 오버라이딩 하고 있음
std::cout << "야옹~" << std::endl;
}
};
int main() {
Animal* animals[2];
animals[0] = new Dog();
animals[1] = new Cat();
for(int i = 0; i < 2; i++) {
animals[i]->makeSound(); // 예상과 다른 결과가 나옵니다!
}
return 0;
}
동물 소리
동물 소리
잘못된 동작이 나오는 이유는 C++가 함수 호출을 결정하는 방식과 관련이 있습니다. 이를 쉽게 이해하기 위해 예시와 함께 설명해 드리겠습니다.
함수 결정 메커니즘
C++에서 함수 호출을 결정하는 방식은 두 가지가 있습니다:
- 정적 바인딩 (Static Binding): 컴파일 시간에 호출할 함수를 결정
- 동적 바인딩 (Dynamic Binding): 실행 시간에 호출할 함수를 결정
다음과 같은 함수가 있다고 생각해봐요.
class Animal {
public:
void makeSound() { // virtual 키워드 없음
std::cout << "동물 소리" << std::endl;
}
};
class Dog : public Animal {
public:
void makeSound() {
std::cout << "멍멍!" << std::endl;
}
};
여기서 컴파일러는 animal의 선언된 타입이 Animal*임을 봅니다. 따라서 Animal 클래스의 makeSound()를 호출합니다.
컴파일러는 이렇게 판단합니다: "이 포인터는 Animal 타입으로 선언되었으니, Animal의 함수를 호출해야겠다."
즉, 포인터 변수의 선언 타입에 따라 함수가 호출되는 것입니다. 실제로 그 포인터가 가리키는 객체가 Dog이더라도, Animal 타입으로 접근했기 때문에 Animal의 함수가 호출됩니다.
가상 함수를 사용하는 경우 (동적 바인딩)
class Animal {
public:
virtual void makeSound() { // virtual 키워드 추가
std::cout << "동물 소리" << std::endl;
}
};
class Dog : public Animal {
public:
void makeSound() override {
std::cout << "멍멍!" << std::endl;
}
};
Animal* animal = new Dog();
animal->makeSound();
이번에는 virtual 키워드 때문에 컴파일러가 다르게 동작합니다. 이제 컴파일러는 실행 시간에 실제 객체의 타입을 확인하여 함수를 호출합니다.
컴파일러는 이렇게 판단합니다: "이 포인터는 Animal 타입으로 선언되었지만, makeSound는 가상 함수이니 실행 시 이 포인터가 실제로 가리키는 객체의 타입을 확인해서 그에 맞는 함수를 호출해야겠다."
실행 시간에 animal이 실제로 가리키는 객체는 Dog 타입이므로, Dog::makeSound()가 호출됩니다.
내부 작동 방식
이는 C++가 가상 함수를 구현하는 방식인 **가상 함수 테이블(vtable)**과 관련이 있습니다.
- 가상 함수가 있는 클래스의 객체는 vtable 포인터를 가집니다.
- 이 포인터는 클래스의 가상 함수들의 주소가 저장된 테이블을 가리킵니다.
- 실행 시간에 함수 호출 시, 이 테이블을 통해 실제 객체 타입에 맞는 함수를 찾아 호출합니다.
가상 함수가 없는 경우에는 이러한 메커니즘이 작동하지 않으므로, 선언된 포인터 타입에 따라 함수가 호출됩니다.
결론
가상 함수를 사용하지 않으면 컴파일러는 객체의 실제 타입을 고려하지 않고, 변수의 선언 타입만을 기준으로 함수를 호출합니다. 따라서 다형성을 위해서는 가상 함수가 필수적입니다.
적 바인딩과 동적 바인딩을 설명할 때 "접근형식"과 "실형식"이라는 용어를 알면 편하다
- 접근형식(Reference Type): 포인터나 참조 변수의 선언된 타입을 의미합니다. 정적 바인딩에서는 이 접근형식에 따라 함수 호출이 결정됩니다.
- 실형식(Actual Type): 포인터나 참조가 실제로 가리키는 객체의 타입을 의미합니다. 동적 바인딩에서는 이 실형식에 따라 함수 호출이 결정됩니다.
예를 들어:
Animal* animal = new Dog();
여기서:
- 접근형식은 Animal*입니다.
- 실형식은 Dog입니다.
정적 바인딩(가상함수가 아닌 경우)에서는 접근형식인 Animal*에 기반하여 함수가 호출됩니다. 동적 바인딩(가상함수인 경우)에서는 실형식인 Dog에 기반하여 함수가 호출됩니다.
이 용어들은 C++ 객체 지향 프로그래밍에서 다형성의 개념을 설명할 때 자주 사용되는 전문용어입니다.
소멸자는 가상으로
객체 지향 프로그래밍에서 상속 관계가 있을 때, 기본 클래스(여기서는 Animal)의 소멸자는 반드시 virtual로 선언되어야 합니다. 그렇지 않으면 파생 클래스(Dog, Cat)의 객체를 기본 클래스의 포인터로 가리킬 때, 실제 객체의 타입에 맞는 소멸자가 호출되지 않고, 기본 클래스의 소멸자만 호출됩니다.
예를 들어 다음과 같은 코드가 있다고 가정해 봅시다:
Animal* animal = new Dog();
delete animal;
- delete animal을 실행할 때, 컴파일러는 animal의 선언 타입인 Animal*만 보고 소멸자를 호출합니다.
- Animal 클래스의 소멸자가 virtual이 아니라면, 컴파일러는 Animal::~Animal()만 호출합니다.
- 실제 객체가 Dog 타입이더라도 Dog::~Dog()는 호출되지 않습니다.
이 현상이 발생하는 이유는 C++의 함수 호출 결정 메커니즘과 관련이 있습니다.
C++에서 함수 호출은 기본적으로 정적 바인딩(Static Binding) 방식으로 이루어집니다. 즉, 포인터나 참조 변수의 선언된 타입(접근형식)에 따라 어떤 함수를 호출할지 컴파일 시점에 결정됩니다.
반면, virtual 키워드를 사용하면 동적 바인딩(Dynamic Binding) 방식으로 함수 호출이 결정됩니다. 실행 시간에 객체의 실제 타입(실형식)을 확인하여 해당 타입의 소멸자를 호출합니다: