가상 함수(Virtual function)

가상 함수(Virtual function)

파생 클래스에서 같은 이름의 함수를 다시 정의해서 기반 클래스의 동작을 바꾸고 싶을 때가 있어요. 그런데 그 객체를 기반 클래스의 포인터나 참조로 다룰 때도 바뀐 동작이 그대로 적용되도록 하려면? 그럴 때 쓰는 것이 **가상 함수(virtual function)**예요. 가상 함수는 동적 디스패치(dynamic dispatch)를 지원하는 비정적 멤버 함수예요.

출처: cppreference

본문

가상 함수 지정자(virtual)는 비정적 멤버 함수의 초기 선언(즉 클래스 정의 안에서 선언)의 decl-specifier-seq에만 나타날 수 있어요.

동작 방식(Explanation)

가상 함수는 파생 클래스에서 동작을 오버라이드(override)할 수 있는 멤버 함수예요. 비가상 함수와 달리, 그 클래스의 실제 타입에 대한 컴파일 타임 정보가 없어도 오버라이드 동작이 보존돼요. 즉, 파생 클래스가 기반 클래스의 포인터나 참조로 다뤄져도, 오버라이드된 가상 함수를 호출하면 파생 클래스에 정의된 동작이 호출돼요. 이런 함수 호출을 가상 함수 호출(virtual function call) 또는 **가상 호출(virtual call)**이라고 불러요. 함수가 한정 이름 검색(qualified name lookup)으로 선택되면(즉 함수 이름이 스코프 연산자 ::의 오른쪽에 나타나면) 가상 함수 호출은 억제돼요.

#include <iostream>

struct Base
{
    virtual void f()
    {
        std::cout << "base\n";
    }
};

struct Derived : Base
{
    void f() override // 'override'는 선택 사항
    {
        std::cout << "derived\n";
    }
};

int main()
{
    Base b;
    Derived d;

    // 참조를 통한 가상 함수 호출
    Base& br = b; // br의 타입은 Base&
    Base& dr = d; // dr의 타입도 Base&
    br.f(); // "base" 출력
    dr.f(); // "derived" 출력

    // 포인터를 통한 가상 함수 호출
    Base* bp = &b; // bp의 타입은 Base*
    Base* dp = &d; // dp의 타입도 Base*
    bp->f(); // "base" 출력
    dp->f(); // "derived" 출력

    // 비가상 함수 호출
    br.Base::f(); // "base" 출력
    dr.Base::f(); // "base" 출력
}

자세히(In detail)

어떤 멤버 함수 vf가 클래스 Base에서 virtual로 선언됐고, Base에서 직간접적으로 파생된 어떤 클래스 Derived가 다음을 같은 멤버 함수 선언을 가진다고 해요.

  • 이름
  • 매개변수 타입 목록 (반환 타입은 제외)
  • cv-한정자
  • ref-한정자

그러면 Derived 클래스의 이 함수도 (선언에 virtual 키워드가 있는지와 무관하게) 가상이고, (선언에 override 지정자가 있는지와 무관하게) Base::vf를 오버라이드해요.

Base::vf는 오버라이드되기 위해 접근 가능하거나 보일 필요가 없어요. (Base::vf가 private으로 선언되거나, Base가 private 상속으로 상속될 수 있어요. Base를 상속하는 Derived의 기반 클래스에 같은 이름의 멤버가 있어도, 이름 검색에서 Base::vf를 가릴(hide)지언정 오버라이드 결정에는 영향이 없어요.)

class B
{
    virtual void do_f(); // private 멤버
public:
    void f() { do_f(); } // 공개 인터페이스
};

struct D : public B
{
    void do_f() override; // B::do_f 오버라이드
};

int main()
{
    D d;
    B* bp = &d;
    bp->f(); // 내부적으로 D::do_f() 호출
}

모든 가상 함수에는 **최종 오버라이더(final overrider)**가 있는데, 가상 함수 호출이 일어나면 그 최종 오버라이더가 실행돼요. 기반 클래스 Base의 가상 멤버 함수 vf는, 파생 클래스가 (다중 상속을 통해) vf를 오버라이드하는 다른 함수를 선언하거나 상속하지 않는 한 최종 오버라이더예요.

struct A { virtual void f(); };     // A::f는 가상
struct B : A { void f(); };         // B::f는 B에서 A::f를 오버라이드
struct C : virtual B { void f(); }; // C::f는 C에서 A::f를 오버라이드

struct D : virtual B {}; // D는 오버라이더를 도입하지 않음, D에서 B::f가 최종

struct E : C, D          // E는 오버라이더를 도입하지 않음, E에서 C::f가 최종
{
    using A::f; // 함수 선언이 아니라 A::f를 검색에 보이게 할 뿐
};

int main()
{
    E e;
    e.f();    // 가상 호출은 e에서 최종 오버라이더인 C::f 호출
    e.E::f(); // 비가상 호출은 E에서 보이는 A::f 호출
}

한 기반 클래스 하위 객체(subobject)에 가상 함수의 최종 오버라이더가 둘 이상이면 프로그램은 잘못된 형태(ill-formed)예요.

struct A
{
    virtual void f();
};

struct VB1 : virtual A
{
    void f(); // A::f를 오버라이드
};

struct VB2 : virtual A
{
    void f(); // A::f를 오버라이드
};

// struct Error : VB1, VB2
// {
//     // 오류: A::f가 Error에 두 개의 최종 오버라이더를 가짐
// };

struct Okay : VB1, VB2
{
    void f(); // OK: A::f의 최종 오버라이더는 이것
};

struct VB1a : virtual A {}; // 오버라이더를 선언하지 않음

struct Da : VB1a, VB2
{
    // Da에서 A::f의 최종 오버라이더는 VB2::f
};

같은 이름이지만 매개변수 목록이 다른 함수는 같은 이름의 기반 함수를 오버라이드하지 않고 가려요. 비한정 이름 검색이 파생 클래스의 스코프를 조사할 때, 그 선언을 찾고 기반 클래스를 조사하지 않아요.

struct B
{
    virtual void f();
};

struct D : B
{
    void f(int); // D::f는 B::f를 가림 (잘못된 매개변수 목록)
};

struct D2 : D
{
    void f(); // D2::f는 B::f를 오버라이드 (보이지 않아도 상관없음)
};

int main()
{
    B b;
    B& b_as_b = b;
    
    D d;
    B& d_as_b = d;
    D& d_as_d = d;
    
    D2 d2;
    B& d2_as_b = d2;
    D& d2_as_d = d2;
    
    b_as_b.f();  // B::f() 호출
    d_as_b.f();  // B::f() 호출
    d2_as_b.f(); // D2::f() 호출
    
    d_as_d.f();  // 오류: D에서의 검색은 f(int)만 찾음
    d2_as_d.f(); // 오류: D에서의 검색은 f(int)만 찾음
}

override 지정자로 선언됐는데 가상 함수를 오버라이드하지 않으면 프로그램은 잘못된 형태예요.

struct B
{
    virtual void f(int);
};

struct D : B
{
    virtual void f(int) override;  // OK, D::f(int)는 B::f(int)를 오버라이드
    virtual void f(long) override; // 오류: f(long)은 B::f(int)를 오버라이드하지 않음
};

final 지정자로 선언된 함수를 다른 함수가 오버라이드하려고 하면 프로그램은 잘못된 형태예요.

struct B
{
    virtual void f() const final;
};

struct D : B
{
    void f() const; // 오류: D::f가 final인 B::f를 오버라이드하려 함
};

(C++11부터)

비멤버 함수와 정적 멤버 함수는 가상일 수 없어요.

함수 템플릿은 가상으로 선언될 수 없어요. 이는 함수 자체가 템플릿인 경우에만 적용돼요. 클래스 템플릿의 일반 멤버 함수는 가상으로 선언될 수 있어요.

가상 함수(가상으로 선언됐거나 오버라이드하는 함수)는 연관된 제약(constraint)을 가질 수 없어요.

struct A
{
    virtual void f() requires true; // 오류: 제약이 있는 가상 함수
};

consteval 가상 함수는 비-consteval 가상 함수를 오버라이드하거나 오버라이드당해서는 안 돼요. (C++20부터)

가상 함수의 기본 인자(default argument)는 컴파일 타임에 치환돼요.

공변 반환 타입(Covariant return types)

Derived::fBase::f를 오버라이드한다면, 그 반환 타입은 같거나 공변(covariant)이어야 해요. 두 타입이 다음 요구사항을 모두 만족하면 공변이에요.

  • 두 타입 모두 클래스에 대한 포인터 또는 참조(lvalue 또는 rvalue)예요. 다중 레벨 포인터나 참조는 허용되지 않아요.
  • Base::f()의 반환 타입이 가리키는/참조하는 클래스는 Derived::f()의 반환 타입이 가리키는/참조하는 클래스의 애매하지 않고 접근 가능한 직간접 기반 클래스여야 해요.
  • Derived::f()의 반환 타입은 Base::f()의 반환 타입과 같거나 더 적게 cv-한정되어야 해요.

Derived::f의 반환 타입의 클래스는 Derived 자신이거나, Derived::f의 선언 시점에 완전 타입(complete type)이어야 해요.

가상 함수 호출이 일어날 때, 최종 오버라이더가 반환한 타입은 호출된 오버라이드 함수의 반환 타입으로 암시적으로 변환돼요.

class B {};

struct Base
{
    virtual void vf1();
    virtual void vf2();
    virtual void vf3();
    virtual B* vf4();
    virtual B* vf5();
};

class D : private B
{
    friend struct Derived; // Derived에서 B는 D의 접근 가능한 기반
};

class A; // 전방 선언된 클래스는 불완전 타입

struct Derived : public Base
{
    void vf1();    // 가상, Base::vf1() 오버라이드
    void vf2(int); // 비가상, Base::vf2()를 가림
//  char vf3();    // 오류: Base::vf3를 오버라이드하지만 반환 타입이
                   // 다르고 공변도 아님
    D* vf4();      // Base::vf4()를 오버라이드하고 공변 반환 타입을 가짐
//  A* vf5();      // 오류: A는 불완전 타입
};

int main()
{
    Derived d;
    Base& br = d;
    Derived& dr = d;

    br.vf1(); // Derived::vf1() 호출
    br.vf2(); // Base::vf2() 호출
//  dr.vf2(); // 오류: vf2(int)가 vf2()를 가림

    B* p = br.vf4(); // Derived::vf4()를 호출하고 결과를 B*로 변환
    D* q = dr.vf4(); // Derived::vf4()를 호출하고 결과를 B*로 변환하지 않음
}

가상 소멸자(Virtual destructor)

소멸자는 상속되지 않지만, 기반 클래스가 소멸자를 가상으로 선언하면 파생 소멸자는 항상 그것을 오버라이드해요. 그 덕분에 다형성 타입의 동적 할당 객체를 기반 클래스 포인터로 delete할 수 있어요.

class Base
{
public:
    virtual ~Base() { /* Base의 자원 해제 */ }
};

class Derived : public Base
{
    ~Derived() { /* Derived의 자원 해제 */ }
};

int main()
{
    Base* b = new Derived;
    delete b; // Base::~Base()에 가상 함수 호출을 함
              // 가상이므로 Derived::~Derived()를 호출해
              // 파생 클래스의 자원을 해제하고, 평소 소멸 순서에 따라
              // Base::~Base()를 호출
}

게다가 기반 클래스의 소멸자가 가상이 아니면, 기반 클래스 포인터로 파생 클래스 객체를 delete하는 것은 정의되지 않은 동작(undefined behavior)이에요. 파생 소멸자가 호출되지 않아 새는 자원이 있는지와 무관해요. (선택된 할당 해제 함수가 파괴형 operator delete가 아닌 경우, C++20부터)

유용한 지침으로, delete 표현식이 관련될 때는(예: std::unique_ptr에서 암시적으로 쓰일 때, C++11부터) 모든 기반 클래스의 소멸자는 public이고 가상이거나, protected이고 비가상이어야 해요.

생성·파괴 중(During construction and destruction)

가상 함수가 생성자나 소멸자에서 직간접적으로 호출될 때(클래스의 비정적 데이터 멤버의 생성·파괴 중, 예: 멤버 초기화 목록에서), 그리고 그 호출이 적용되는 객체가 생성·파괴 중인 객체라면, 호출되는 함수는 생성자·소멸자의 클래스 안의 최종 오버라이더이고, 더 파생된 클래스에서 오버라이드한 것이 아니에요. 즉, 생성·파괴 중에는 더 파생된 클래스가 존재하지 않아요.

여러 갈래를 가진 복잡한 클래스를 생성할 때, 한 갈래에 속한 생성자 안에서는 다형성이 그 클래스와 그 기반들로 제한돼요. 그 서브계층 밖의 기반 하위 객체에 대한 포인터나 참조를 얻어 가상 함수 호출을 시도하면(예: 명시적 멤버 접근으로) 동작이 정의되지 않아요.

struct V
{
    virtual void f();
    virtual void g();
};

struct A : virtual V
{
    virtual void f(); // A에서 V::f의 최종 오버라이더는 A::f
};

struct B : virtual V
{
    virtual void g(); // B에서 V::g의 최종 오버라이더는 B::g
    B(V*, A*);
};

struct D : A, B
{
    virtual void f(); // D에서 V::f의 최종 오버라이더는 D::f
    virtual void g(); // D에서 V::g의 최종 오버라이더는 D::g

    // 참고: A는 B보다 먼저 초기화됨
    D() : B((A*) this, this) {}
};

// D의 생성자에서 호출되는 B의 생성자
B::B(V* v, A* a)
{
    f(); // V::f에 대한 가상 호출 (D가 최종 오버라이더지만, D는 존재하지 않음)
    g(); // B에서 최종 오버라이더인 B::g에 대한 가상 호출

    v->g(); // v의 타입 V는 B의 기반이므로, 가상 호출은 전과 같이 B::g 호출

    a->f(); // a의 타입 A는 B의 기반이 아님. 계층의 다른 갈래에 속함.
            // 그 갈래를 통해 가상 호출을 시도하면 정의되지 않은 동작이 됨.
            // A는 B보다 먼저 (D의 기반 목록에서 B 앞에 있으므로) 완전히
            // 생성됐지만 그래도 그렇다. 실제로는 B의 생성 중이라 B의
            // 가상 멤버 함수 테이블이 활성화되어 있기 때문에, 그 테이블로
            // A::f에 대한 가상 호출이 시도될 것임
}

키워드

virtual

주의할 점(Notes)

기능 테스트 매크로 표준 기능
__cpp_constexpr 201907L (C++20) 상수 표현식에서의 가상 함수 호출
__cpp_contracts 202606L (C++29) 가상 함수를 위한 계약

결함 보고(Defect reports)

이전에 발표된 C++ 표준들에 소급 적용된 동작 변경 결함 보고들이 있어요.

DR 적용 대상 발표 당시 동작 올바른 동작
CWG 258 C++98 파생 클래스의 비const 멤버 함수가 기반의 const 가상 멤버 함수 때문에 가상이 될 수 있었음 가상성도 cv-한정자가 같아야 함
CWG 477 C++98 friend 선언이 virtual 지정자를 포함할 수 있었음 허용되지 않음
CWG 1516 C++98 "가상 함수 호출"과 "가상 호출"의 정의가 제공되지 않았음 제공됨

더 알아보기

  • 상속과 파생 클래스: 기반·파생 클래스의 관계.
  • override 지정자: 다른 메서드를 오버라이드함을 명시적으로 선언.
  • final 지정자: 오버라이드를 막는 선언.
  • 추상 클래스와 순수 가상 함수: = 0으로 선언하는 가상 함수.