ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 가상 함수와 함수 호출 방식( 정적 디스패칭, 동적 디스패칭 )
    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)이라는 메커니즘을 통해 구현됩니다.

    1. vtable 생성: 가상 함수가 있는 클래스를 컴파일하면, 컴파일러는 그 클래스를 위한 vtable을 생성합니다. 이 테이블은 클래스의 모든 가상 함수에 대한 포인터(가상 함수 메모리 주소)를 담고 있습니다.
    2. vptr 추가: 가상 함수가 있는 클래스의 각 객체는 "vptr"(가상 포인터)이라는 숨겨진 멤버를 갖게 됩니다. 이 포인터는 해당 클래스의 vtable을 가리킵니다.
    3. 동적 디스패치(Dynamic Dispatch): 가상 함수를 호출할 때, 컴파일러는 다음과 같은 코드를 생성합니다.
      • 객체의 vptr을 따라 vtable에 접근
      • vtable에서 해당 함수의 포인터를 찾음
      • 그 포인터를 사용하여 함수 호출

    이 과정을 "동적 디스패치" 또는 "동적 바인딩"이라고 합니다.  vtable은 다음과 같은 방식으로 구성됩니다. 

    1. 별도의 vtable 생성
      • 컴파일러는 각 클래스 타입마다 별도의 vtable을 생성합니다
      • 자식 클래스의 vtable은 부모 클래스 vtable의 단순한 복사본이 아닌, 완전히 새로운 테이블입니다
    2. vtable 내용 구성 방식
      • 자식 클래스의 vtable은 기본적으로 부모 클래스의 가상 함수 배치 순서를 유지합니다
      • 자식 클래스가 오버라이드한 함수들은 같은 위치에 자식 클래스의 함수 주소로 대체됩니다
      • 자식 클래스에서 새롭게 추가된 가상 함수는 vtable의 끝부분에 추가됩니다
    3. 메모리 관점에서의 이점
      • 별도의 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는 가상 함수의 런타임 오버헤드 없이 다형성과 유사한 동작을 제공합니다.

    댓글

Designed by Tistory.