-
가상 함수와 함수 호출 방식( 정적 디스패칭, 동적 디스패칭 )Programming🧑💻/Cpp 2025. 4. 7. 16:38
가상 함수(Virtual Function)의 개념과 작동 원리
가상 함수는 객체 지향 프로그래밍의 정수인 다형성(polymorphism)을 구현하는 핵심 메커니즘입니다. 쉽게 말해서, 가상 함수는 부모 클래스에서 선언되고 자식 클래스에서 재정의(override)될 수 있는 함수입니다.
1. 가상 함수의 기본 개념
C++에서 함수 앞에 virtual 키워드를 붙이면 그 함수는 가상 함수가 됩니다.
class Parent { public: virtual void show() { std::cout << "부모 클래스의 show 함수" << std::endl; } }; class Child : public Parent { public: void show() override { std::cout << "자식 클래스의 show 함수" << std::endl; } };
여기서 Parent 클래스의 show() 함수는 virtual 키워드로 선언되었고, Child 클래스에서 이를 재정의했습니다. override 키워드는 C++11에서 추가된 것으로, 이 함수가 부모 클래스의 가상 함수를 재정의하고 있음을 명시적으로 표시합니다.
2. 가상 함수가 필요한 이유: 문제 상황
가상함수는 왜 필요할까요? 만약에 가상 함수가 없다면 어떻게 될까요?
// 가상 함수가 없는 경우 Parent* ptr = new Child(); ptr->show(); // 결과: "부모 클래스의 show 함수"
가상 함수가 없으면, 포인터의 정적 타입(컴파일 시점에 결정됨)에 따라 호출될 함수가 결정됩니다. 여기서 ptr은 Parent* 타입이므로, Parent::show()가 호출됩니다.
하지만 일반적으로 우리는 실제 객체의 타입(런타임에 결정됨)에 따라 함수가 호출되기를 원합니다. 즉, Child 객체의 show() 함수가 호출되길 원합니다. 이것이 가상 함수의 필요성입니다.
// 가상 함수를 사용한 경우 Parent* ptr = new Child(); ptr->show(); // 결과: "자식 클래스의 show 함수"
3. 가상 함수의 작동 원리: 가상 함수 테이블(vtable)
가상 함수는 "가상 함수 테이블"(vtable)이라는 메커니즘을 통해 구현됩니다.
- vtable 생성: 가상 함수가 있는 클래스를 컴파일하면, 컴파일러는 그 클래스를 위한 vtable을 생성합니다. 이 테이블은 클래스의 모든 가상 함수에 대한 포인터(가상 함수 메모리 주소)를 담고 있습니다.
- vptr 추가: 가상 함수가 있는 클래스의 각 객체는 "vptr"(가상 포인터)이라는 숨겨진 멤버를 갖게 됩니다. 이 포인터는 해당 클래스의 vtable을 가리킵니다.
- 동적 디스패치(Dynamic Dispatch): 가상 함수를 호출할 때, 컴파일러는 다음과 같은 코드를 생성합니다.
- 객체의 vptr을 따라 vtable에 접근
- vtable에서 해당 함수의 포인터를 찾음
- 그 포인터를 사용하여 함수 호출
이 과정을 "동적 디스패치" 또는 "동적 바인딩"이라고 합니다. vtable은 다음과 같은 방식으로 구성됩니다.
- 별도의 vtable 생성
- 컴파일러는 각 클래스 타입마다 별도의 vtable을 생성합니다
- 자식 클래스의 vtable은 부모 클래스 vtable의 단순한 복사본이 아닌, 완전히 새로운 테이블입니다
- vtable 내용 구성 방식
- 자식 클래스의 vtable은 기본적으로 부모 클래스의 가상 함수 배치 순서를 유지합니다
- 자식 클래스가 오버라이드한 함수들은 같은 위치에 자식 클래스의 함수 주소로 대체됩니다
- 자식 클래스에서 새롭게 추가된 가상 함수는 vtable의 끝부분에 추가됩니다
- 메모리 관점에서의 이점
- 별도의 vtable을 사용함으로써 객체마다 vtable을 저장할 필요가 없습니다
- 같은 클래스 타입의 객체들은 모두 동일한 vtable을 공유합니다 (메모리 효율)
- vtable 자체는 프로그램의 읽기 전용 영역에 한 번만 저장됩니다
4. 순수 가상 함수와 추상 클래스
가상 함수의 특별한 형태로 "순수 가상 함수"가 있습니다. 이는 구현이 없고 파생 클래스에서 반드시 구현해야 하는 함수입니다.
class AbstractShape { public: // 순수 가상 함수 virtual double area() const = 0; virtual void draw() const = 0; virtual ~AbstractShape() {} };
순수 가상 함수를 하나 이상 포함하는 클래스를 "추상 클래스"라고 합니다. 추상 클래스의 객체는 직접 생성할 수 없으며, 이를 상속받는 클래스에서 모든 순수 가상 함수를 구현해야만 객체를 생성할 수 있습니다.
5. 가상 함수의 장단점
장점:
- 다형성 구현: 객체 지향 프로그래밍의 핵심 개념인 다형성을 구현할 수 있습니다.
- 유연한 코드: 새로운 파생 클래스를 추가하기 쉽고, 기존 코드를 수정할 필요가 적습니다.
- 코드 재사용: 공통 인터페이스를 통해 다양한 객체를 처리할 수 있습니다.
단점:
- 성능 오버헤드: vtable 검색과 간접 함수 호출로 인한 약간의 성능 저하가 있습니다.
- 메모리 사용 증가: 각 객체에 vptr이 추가되고, 각 클래스마다 vtable이 필요합니다.
- 컴파일 시간 최적화 제한: 가상 함수 호출은 컴파일러의 인라인화와 같은 최적화를 제한할 수 있습니다.
6. 가상 함수 관련 주요 개념
가상 소멸자
기본 클래스의 소멸자는 항상 가상으로 선언하는 것이 좋습니다.
class Base { public: virtual ~Base() { std::cout << "Base 소멸자" << std::endl; } }; class Derived : public Base { public: ~Derived() override { std::cout << "Derived 소멸자" << std::endl; } }; // 다음과 같이 사용 시: Base* ptr = new Derived(); delete ptr; // Derived와 Base 소멸자 모두 호출
가상 소멸자가 없으면 파생 클래스의 소멸자가 호출되지 않아 메모리 누수가 발생할 수 있습니다.
정적 디스패칭과 동적 디스패칭: 함수 호출의 두 가지 방식
함수의 디스패칭은 어떤 함수를 호출할지를 결정하는 것을 말합니다.
정적 디스패칭(Static Dispatching)
정적 디스패칭은 컴파일 시간(compile time)에 어떤 함수가 호출될지 결정되는 메커니즘입니다.
- 컴파일 시점에 함수 호출이 결정됨
- 함수 호출 주소가 코드에 직접 하드코딩됨
- 일반 함수, 오버로드된 함수, 템플릿 함수 호출 등에 사용됨
- 실행 속도가 빠름 (런타임 오버헤드 없음)
void print(int x) { std::cout << "정수: " << x << std::endl; } void print(double x) { std::cout << "실수: " << x << std::endl; } int main() { print(5); // 컴파일 시간에 'print(int)'로 결정됨 print(5.5); // 컴파일 시간에 'print(double)'로 결정됨 return 0; }
여기서 컴파일러는 인자 타입을 기반으로 어떤 print() 함수를 호출할지 컴파일 시간에 결정합니다. 이것이 정적 디스패칭입니다.
동적 디스패칭(Dynamic Dispatching)
동적 디스패칭은 런타임(runtime)에 어떤 함수가 호출될지 결정되는 메커니즘입니다. 가상 함수를 통해 구현됩니다.
- 런타임에 객체의 실제 타입에 기반하여 함수 호출이 결정됨
- vtable(가상 함수 테이블)과 vptr(가상 함수 포인터)을 사용하여 구현
- 다형성(polymorphism)을 가능하게 함
- 약간의 실행 속도 오버헤드가 있음 (포인터 추적 필요)
class Base { public: virtual void show() { std::cout << "Base 클래스" << std::endl; } }; class Derived : public Base { public: void show() override { std::cout << "Derived 클래스" << std::endl; } }; int main() { Base* ptr; // 런타임에 결정되는 객체 타입 if(std::rand() % 2 == 0) { ptr = new Base(); } else { ptr = new Derived(); } // 동적 디스패칭: 실행 시점에 ptr이 가리키는 객체의 // 실제 타입에 따라 호출될 함수가 결정됨 ptr->show(); delete ptr; return 0; }
이 예제에서 ptr->show()가 Base::show()를 호출할지 Derived::show()를 호출할지는 컴파일 시간에 알 수 없고, 런타임에 ptr이 실제로 가리키는 객체의 타입에 따라 결정됩니다.
두 방식의 비교
정적 디스패칭
- 수행 시점: 컴파일 시간
- 결정 요소: 컴파일러가 보는 변수의 정적 타입
- 구현 방식: 직접 함수 주소 호출 (direct call)
- 성능: 매우 빠름 (오버헤드 없음)
- 유연성: 제한적 (코드 변경 시 재컴파일 필요)
- 코드 크기: 인라인화 가능성으로 때로는 커질 수 있음
동적 디스패칭
- 수행 시점: 런타임
- 결정 요소: 객체의 실제 타입
- 구현 방식: vtable을 통한 간접 호출 (indirect call)
- 성능: 약간의 오버헤드 존재
- 유연성: 높음 (새 파생 클래스 추가 용이)
- 코드 크기: 일반적으로 작음 (공통 코드 재사용)
내부 작동 원리 비교
정적 디스패칭의 내부 작동
컴파일러가 생성하는 어셈블리 코드 관점에서, 정적 디스패칭은 단순한 CALL 명령어로 변환됩니다.
; 정적 디스패칭에 대한 의사 어셈블리 CALL function_address ; 직접 함수 주소 호출
동적 디스패칭의 내부 작동
동적 디스패칭은 더 복잡한 과정을 거칩니다.
; 동적 디스패칭에 대한 의사 어셈블리 MOV eax, [object_ptr] ; 객체 포인터 가져오기 MOV ebx, [eax] ; 객체의 vtable 포인터 가져오기 CALL [ebx + function_offset] ; vtable에서 함수 포인터 찾아 호출
실제 사용 시나리오
정적 디스패칭이 적합한 경우
- 성능이 중요한 상황 (게임 엔진 코어, 실시간 시스템)
- 컴파일 시간에 모든 타입이 알려져 있는 경우
- 템플릿 메타프로그래밍을 통한 확장
// 템플릿을 사용한 컴파일 시간 다형성 (정적 디스패칭) template <typename T> void process(const T& obj) { obj.doSomething(); // 컴파일 시간에 결정됨 }
동적 디스패칭이 적합한 경우
- 플러그인 아키텍처
- 사용자 확장 가능한 프레임워크
- 런타임에 객체 타입이 결정되는 상황
// 인터페이스를 통한 런타임 다형성 (동적 디스패칭) class Plugin { public: virtual void execute() = 0; }; void runPlugin(Plugin* p) { p->execute(); // 런타임에 결정됨 }
C++의 특별한 경우: CRTP
C++에는 정적 다형성을 구현하는 특별한 기법인 CRTP(Curiously Recurring Template Pattern)가 있습니다.
// CRTP: 정적 디스패칭을 사용한 다형성 구현 template <typename Derived> class Base { public: void interface() { // 정적 디스패칭: 컴파일 시간에 결정됨 static_cast<Derived*>(this)->implementation(); } // 기본 구현 제공 void implementation() { std::cout << "Base 구현" << std::endl; } }; class Derived1 : public Base<Derived1> { public: // 특화된 구현 void implementation() { std::cout << "Derived1 구현" << std::endl; } }; class Derived2 : public Base<Derived2> { // Base의 기본 구현 사용 };
CRTP는 가상 함수의 런타임 오버헤드 없이 다형성과 유사한 동작을 제공합니다.
'Programming🧑💻 > Cpp' 카테고리의 다른 글
const 키워드 맥락에 따라 구분하기 (2) 2025.04.06 inline 키워드 알아보기 (0) 2025.04.05 헤더 전용 라이브러리는 뭐고 언제 쓸까? (0) 2025.04.04 상태기계와 코루틴 프레임 (0) 2025.02.14 서브 루틴과 코루틴 (0) 2025.02.13