Menu
Coddy logo textTech

가상 함수와 VTable

Coddy C++ 여정의 객체 지향 프로그래밍 섹션에 포함된 레슨 — 104개 중 52번째.

virtual 키워드는 이전 레슨에서 보았던 문제를 해결합니다. 메서드를 virtual로 선언하면, C++은 포인터 타입이 아니라 런타임 시점의 실제 객체 타입을 기반으로 어떤 버전을 호출할지 결정합니다.

class Animal {
public:
    virtual void speak() {
        std::cout << "Some sound" << std::endl;
    }
};

class Dog : public Animal {
public:
    void speak() override {
        std::cout << "Woof!" << std::endl;
    }
};

Dog d;
Animal* ptr = &d;
ptr->speak();  // 출력: Woof!

이제 올바른 메서드가 호출됩니다! override 키워드는 선택 사항이지만 권장됩니다. 이는 컴파일러에게 가상 함수를 재정의하려는 의도를 알려주며, 시그니처가 일치하지 않을 경우 오류를 잡아냅니다.

이것은 어떻게 작동하나요? 클래스에 가상 함수가 있을 때, 컴파일러는 가상 테이블(vtable)을 생성합니다. 이것은 클래스의 가상 함수에 대한 포인터를 포함하는 숨겨진 조회 테이블(lookup table)입니다. 가상 함수를 가진 각 객체는 해당 클래스의 vtable을 가리키는 숨겨진 포인터(vptr)를 저장합니다.

포인터를 통해 가상 함수(virtual function)를 호출할 때, 프로그램은 런타임에 vtable에서 올바른 함수 주소를 찾습니다. 이것을 동적 디스패치(dynamic dispatch)라고 합니다. 이는 일반적인 함수 호출에 비해 약간의 오버헤드를 추가하지만, 강력한 다형성 동작을 가능하게 합니다.

한 가지 중요한 규칙: 만약 class가 virtual 함수를 가지고 있고 base class로 사용될 예정이라면, 해당 destructor 또한 virtual이어야 합니다. 이는 base pointer를 통해 derived 객체를 삭제할 때 적절한 정리가 이루어지도록 보장합니다:

class Animal {
public:
    virtual ~Animal() {}  // 가상 소멸자
    virtual void speak() {}
};
challenge icon

챌린지

쉬움

가상 함수와 런타임 다형성의 강력함을 보여주는 미디어 플레이어 시스템을 구축해 보겠습니다. 각기 다르게 재생되는 미디어 유형의 계층 구조를 만들고, 기본 클래스 포인터를 통해 액세스할 때도 가상 함수가 어떻게 올바른 동작을 가능하게 하는지 확인해 볼 것입니다.

코드는 세 개의 파일로 구성됩니다:

  • Media.h: 재생 가능한 모든 미디어를 나타내는 기본 Media 클래스를 정의합니다:
    • protected std::string title 멤버
    • 제목을 받아 저장하는 생성자
    • Playing media: <title>를 출력하는 가상(virtual) play() 메서드
    • Media [<title>] destroyed를 출력하는 가상 소멸자(virtual destructor)
  • AudioTrack.h: Media를 공개 상속(publicly inherit)하는 AudioTrack 클래스를 정의합니다:
    • private std::string artist 멤버
    • 제목과 아티스트를 받아 제목은 기본 클래스로 전달하고 아티스트는 저장하는 생성자
    • override 키워드를 사용하여 play()를 오버라이드하고 Playing audio: <title> by <artist>를 출력합니다.
    • AudioTrack [<title>] destroyed를 출력하는 소멸자
  • main.cpp: 세 개의 입력(각각 별도의 줄)을 읽습니다:
    1. 오디오 트랙 제목 (문자열)
    2. 아티스트 이름 (문자열)
    3. 비디오 제목 (문자열)

    main.cpp에 직접 Media를 공개 상속하는 VideoClip 클래스를 정의합니다:

    • private int duration 멤버 (초 단위)
    • 제목과 기간을 받는 생성자 (기간의 기본값은 120)
    • play()를 오버라이드하여 Playing video: <title> (<duration>s)를 출력합니다.
    • VideoClip [<title>] destroyed를 출력하는 소멸자

    블록 스코프 내에 세 개의 Media* 포인터 배열을 만듭니다. 다음을 동적으로 할당합니다:

    • 제목이 "Generic Media"인 기본 Media 객체
    • 입력받은 제목과 아티스트를 가진 AudioTrack 객체
    • 입력받은 비디오 제목을 가진 VideoClip 객체

    배열을 루프하며 각 포인터에서 play()를 호출합니다. 그런 다음 모든 객체를 역순으로 삭제합니다. 블록이 끝난 후 Playback complete!를 출력합니다.

예를 들어, 입력값이 Bohemian Rhapsody, Queen, Nature Documentary인 경우:

Playing media: Generic Media
Playing audio: Bohemian Rhapsody by Queen
Playing video: Nature Documentary (120s)
VideoClip [Nature Documentary] destroyed
Media [Nature Documentary] destroyed
AudioTrack [Bohemian Rhapsody] destroyed
Media [Bohemian Rhapsody] destroyed
Media [Generic Media] destroyed
Playback complete!

Media* 포인터를 통해 play()를 호출할 때 가상 함수 덕분에 올바른 파생 클래스의 메서드가 어떻게 호출되는지 확인해 보세요. 또한 가상 소멸자가 어떻게 적절한 정리를 보장하는지 관찰하세요. 기본 클래스 포인터를 통해 삭제할 때 파생 클래스와 기본 클래스의 소멸자가 모두 실행됩니다.

치트 시트

virtual 키워드는 포인터 타입이 아닌 실제 객체 타입을 기반으로 호출할 메서드를 결정함으로써 런타임 다형성을 가능하게 합니다.

class Animal {
public:
    virtual void speak() {
        std::cout << "Some sound" << std::endl;
    }
};

class Dog : public Animal {
public:
    void speak() override {
        std::cout << "Woof!" << std::endl;
    }
};

Dog d;
Animal* ptr = &d;
ptr->speak();  // Output: Woof!

override 키워드는 선택 사항이지만 권장됩니다. 이는 컴파일러에게 가상 함수를 오버라이드하려는 의도를 알려주며, 시그니처가 일치하지 않을 경우 오류를 잡아냅니다.

가상 함수의 작동 원리: 컴파일러는 클래스의 가상 함수에 대한 포인터를 포함하는 가상 테이블(vtable)을 생성합니다. 가상 함수를 가진 각 객체는 해당 클래스의 vtable을 가리키는 숨겨진 포인터(vptr)를 저장합니다. 포인터를 통해 가상 함수를 호출할 때, 프로그램은 런타임에 vtable에서 올바른 함수 주소를 찾아냅니다. 이를 동적 디스패치(dynamic dispatch)라고 합니다.

가상 소멸자: 클래스에 가상 함수가 있고 기본 클래스로 사용될 예정이라면, 소멸자 또한 가상이어야 합니다. 이는 기본 클래스 포인터를 통해 파생 객체를 삭제할 때 적절한 정리가 이루어지도록 보장합니다.

class Animal {
public:
    virtual ~Animal() {}  // Virtual destructor
    virtual void speak() {}
};

직접 해보기

#include <iostream>
#include <string>
#include "Media.h"
#include "AudioTrack.h"

using namespace std;

// TODO: 여기서 Media를 퍼블릭 상속받는 VideoClip 클래스를 정의하세요
// - 프라이빗(Private) int duration 멤버 (초 단위)
// - 생성자는 title과 duration을 매개변수로 받음 (duration의 기본값은 120)
// - play()를 오버라이드하여 다음을 출력: Playing video: <title> (<duration>s)
// - 소멸자는 다음을 출력: VideoClip [<title>] destroyed

class VideoClip : public Media {
private:
    int duration;

public:
    // TODO: 생성자를 구현하세요
    VideoClip(const std::string& t, int d = 120) : Media(t) {
        // TODO: duration을 저장하세요
    }

    // TODO: play() 메서드를 오버라이드하세요
    void play() override {
        // TODO: 이 메서드를 구현하세요
    }

    // TODO: 소멸자를 구현하세요
    ~VideoClip() {
        // TODO: 이 소멸자를 구현하세요
    }
};

int main() {
    // 입력 읽기
    string audioTitle;
    string artist;
    string videoTitle;
    
    getline(cin, audioTitle);
    getline(cin, artist);
    getline(cin, videoTitle);

    // TODO: 중괄호를 사용하여 블록 스코프를 생성하세요
    // 블록 내부에서:
    // 1. 세 개의 Media* 포인터 배열을 생성하세요
    // 2. 동적으로 할당하세요:
    //    - 제목이 "Generic Media"인 기본 Media 객체
    //    - 입력받은 제목과 아티스트를 가진 AudioTrack 객체
    //    - 입력받은 비디오 제목을 가진 VideoClip 객체
    // 3. 배열을 순회하며 각 포인터에 대해 play()를 호출하세요
    // 4. 모든 객체를 역순으로 삭제하세요

    // TODO: 블록이 끝난 후, 다음을 출력하세요: Playback complete!
    
    return 0;
}
quiz icon실력 점검

이 레슨에는 짧은 퀴즈가 포함되어 있습니다. 레슨을 시작해 문제를 풀고 진행 상황을 기록하세요.

객체 지향 프로그래밍의 모든 레슨