Computer Science/C & C++

[C++] Virtual Method | 가상 멤버 함수

lww7438 2020. 3. 6. 14:00

\(\texttt{virtual}\) Method

가상 멤버 함수

- C++에서 \(\texttt{public}\) Polymorphic Inheritance(\(\texttt{public}\) 다형 상속)*을 구현할 수 있게 하는 수단이다.
- \(\texttt{virtual}\) 키워드는 Base Class(기초 클래스)에 정의되어 있는 멤버함수를 Derived Class(파생 클래스)에서 상황에 맞게 재정의할 수 있게 한다.
- 가상 멤버 함수는 기초 클래스 객체에 대한 연산과 파생 클래스 객체에 대한 연산이 거시적인 관점에서는 같으나, 세부 구현 사항에서는 차이가 있어야 할 때 적절한 구현 수단이다.
- 파생 클래스에서 재정의되지 않은 함수는 기초 클래스에서 정의된 버전의 함수를 사용하게 되며, 상속 계층이 깊은 경우에는, 가장 인접한 상위 계층의 클래스에서 정의된 함수를 사용하게 된다.
(단, 기초 클래스 버전이 은닉되어 있는 경우는 예외이다.)

* \(\texttt{public}\) Polymorphic Inheritance(\(\texttt{public}\) 다형 상속)
- 기초 클래스의 메서드가 파생 클래스 객체의 상황에 100% 부합되지 않는 경우에 해결책으로써 \(\texttt{public}\) 다형 상속을 구현하는 방법으로는 아래 두 가지 방법이 있다.
1. 기초 클래스의 메서드를 파생 클래스에서 다시 정의한다.
2. 가상 멤버 함수를 이용한다.

※ 재정의된 메서드를 포인터형/참조형 객체가 호출했을 때, \(\texttt{virtual}\) 키워드의 유무에 따른 작동방식의 차이

1. 메서드가 \(\texttt{virtual}\) 키워드 없이 재정의된 경우
- 이 경우에서 포인터/참조형 객체가 해당 메서드를 호출했을 경우, 컴파일러는 호출한 객체의 데이터형(클래스)에 맞춰서 호출한다.
- 함수 재정의시에는 \(\texttt{virtual}\) 키워드를 이용할 것이 권장된다. 그렇기 때문에 \(\texttt{virtual}\) 키워드가 없는 메서드는 프로그래머가 재정의를 원치 않는다는 뜻으로 받아들일 수도 있다.

2. 메서드가 \(\texttt{virtual}\) 키워드를 통해 재정의된 경우 (권고되는 방식)
- 이 경우세 포인터/참조형 객체가 해당 메서드를 호출했을 경우, 컴파일러는 호출한 객체가 지시하는 객체의 데이터형(클래스)에 맞춰서 호출한다.

// Ex. virtual 키워드 유무에 따른 차이

Father smith;         // Father은 기초 클래스이다.
Son Jack;             // Son은 파생 클래스이다.

Father& F_ref = smith;      // 기초 클래스 객체를 지시, 객체형은 기초 클래스
Father& S_ref = Jack;       // 파생 클래스 객체를 지시, 객체형은 기초 클래스

// reset();           // reset()은 virtual 키워드 없이 두 클래스에 각각 다른 형태로 정의되어 있다.
// virtual show();    // show()는 virtual 키워드를 통해 두 클래스에 각각 다른 형태로 정의되어 있다.

F_ref.reset();        // Father::reset();   객체형에 맞춰서 호출 (Non-virtual)
S_ref.reset();        // Father::reset();   객체형에 맞춰서 호출 (Non-virtual)

F_ref.show();         // Father::show();    지시하는 객체형에 맞춰서 호출 (virtual)
S_ref.show();         // Son::show();       지시하는 객체형에 맞춰서 호출 (virtual)


// 참조형(&) 대신, 포인터형(*)으로 수행해도 결과는 동일하다.

 - 가상함수의 편의성으로 인해, 메서드를 재정의할 때는 \(\texttt{virtual}\) 키워드를 이용하는 것이 일반적인 관행이다.


\(\texttt{virtual}\) Method Syntax

- \(\texttt{virtual}\) 멤버 함수 선언

// Ex. virtual 멤버 함수 선언 예시

class Father {
private:
    int A;
    double B;
public:
    virtual void show() const;
    ...
};


class Son : public Father {
private:
    long C;
public:
    virtual void show() const;     // 가상 멤버 함수는 기초 클래스와 파생 클래스에 두 번에 걸쳐서 선언/정의된다.
    ...
};

\(\texttt{virtual}\) 키워드는 클래스 선언부의 함수 원형에만 명시하고, 함수 정의 부분에서는 명시하지 않는다.
※ 기초 클래스에서 메서드를 \(\texttt{virtual}\)로 선언하면, 하위 모든 파생 클래스에 굳이 \(\texttt{virtual}\) 키워드를 사용하지 않아도, 자동으로 가상함수로 정의되지만, 파생 클래스에도 명시적으로 \(\texttt{virtual}\) 키워드를 사용함으로써, 어떤 함수가 가상 함수인지를 표시하는 것이 바람직하다.
 기초 클래스에서만 가상 함수를 정의해야만 하는 것은 아니며, 파생 클래스에서도 새로운 가상 함수를 선언할 수 있다.

- \(\texttt{virtual}\) 멤버 함수의 Qualfied Name Notation(검증된 이름 표기법)
(프로그램은 호출한 객체가 속한 클래스를 파악해서 어느 버전을 호출할 것인지를 판가름한다.)

// Ex. 가상 멤버 함수의 검증된 이름 표기법

// Father class(기초 클래스) 가상 멤버 함수의 검증된 이름
Father::show()

// Son class(파생 클래스) 가상 멤버 함수의 검증된 이름
Son::show()


// 일반형
baseClass::v_Func()       // Qualified Name of Base Class
derivedClass::v_Func()    // Qualified Name of Derived Class


// 이와 같이 파생 클래스 메서드 안에서, 기초 클래스 메서드를 검증된 표기법을 통해 호출해서
// 기초 클래스의 private 멤버에 간접적으로 접근하는 방법 중 하나이다.


- 기초 클래스에서 오버로딩된 메서드가 파생 클래스에도 필요하다면, 파생 클래스에서도 모든 버전에 대한 오버로딩을 거쳐야 한다.
(여기서 오버로딩된 함수의 한 가지 버전만 재정의하면, 나머지 버전은 Hide되어 파생 클래스 객체가 접근할 수 없게 된다.)


\(\texttt{virtual}\) Destructor (가상 파괴자)
- 가상 파괴자는 파생 클래스 객체가 파괴될 때, 파괴자들이 올바른 순서대로 호출되도록 한다.
(파생 객체가 파괴될 경우, 파생 클래스의 파괴자가 먼저 호출된 후, 기초 클래스의 파괴자가 후에 호출된다.)
- 파괴자들이 \(\texttt{virtual}\) 키워드를 통해 가상 멤버 함수로 선언되지 않으면, 포인터/참조 객체가 실질적으로 지시하는 객체에 매칭되는 파괴자가 아닌, 데이터형에 해당하는 파괴자만 호출될것이기 때문에 이를 방지하는 차원에서 파괴자를 가상 멤버 함수로 선언하는 것이다.
- 기초 클래스에서는 파괴자가 필요 없더라도, 하위 파생 클래스에서 파괴자가 필요한 경우가 생길 수 있으므로, 기초 클래스에서는 아무 일을 하지 않더라도, 가상 파괴자를 정의해놓아야 한다.

※ 파괴자와 달리, 생성자는 가상 함수로 정의할 수 없다.
- 파생 클래스 객체가 생성되면, 파생 클래스의 생성자가 먼저 호출되어 연쇄적으로 기초 클래스의 생성자까지 호출시킨다. 


\(\texttt{virtual}\) Function Table (VTBL; 가상 함수 테이블)

- 해당 클래스의 객체들이 사용 가능한 가상 함수들의 주소가 저장되어 있는 테이블이다.
- 클래스 객체는 해당 클래스를 위한 모든 가상 함수들의 주소를 저장한 VTBL을 지시하는 하나의 포인터를 각 객체마다 가진다.
- 파생 클래스에서 가상 함수를 재정의하면, VTBL에도 재정의된 가상 함수의 주소가 저장되며, 따로 가상 함수를 재정의하지 않으면 그 함수의 오리지널 버전(기초 클래스에서 정의된 함수)의 주소를 저장한다.
- 파생 클래스에서 새로 정의된 가상 함수의 주소 또한 VTBL에 저장된다.
- VTBL을 지시하는 주소 멤버는 모든 클래스 객체가 단 하나만 갖고 있으며, VTBL의 크기가 유동적으로 변하는 구조이다.
- 각 객체는 주소 멤버를 저장할 수 있는 만큼의 메모리를 추가적으로 차지하고, 함수 호출시에는 VTBL에 접근하는 과정이 필요하기 때문에, 가상 함수의 사용은 메모리와 실행 속도 측면에서 약간의 부담이 따르는 작업이다.


\(\texttt{friend}\) Function and \(\texttt{virtual}\) Function (\(\texttt{friend}\) 함수와 \(\texttt{virtual}\) 함수)

- 클래스의 멤버 함수에 한해서 가상 함수임을 선언할 수 있기 때문에 원칙적으로는, \(\texttt{friend}\) 함수는 가상 함수가 될 수 없다.


Covariance (공변)

- 파생 클래스에서 기초 클래스의 메서드를 재정의하면 해당 함수를 호출할 때, 기초 클래스의 메서드는 Hide되고, 파생 클래스에서 재정의된 메서드가 호출된다.
- 재정의된 메서드가 원래의 메서드와 함수 Signature가 다른 경우, 매개 변수를 달리하여 기초 클래스의 메서드를 호출할 수 없다. 즉, Hides된 기초 클래스의 메서드를 오버로딩된 함수처럼 사용할 수 없다.
- 위와 같은 이유로, 상속된 메서드를 재정의할 때는, 오리지널 메서드의 원형과 함수 Signature를 정확히 일치시킬 필요가 있다.
- 단, 예외로 리턴형이 기초 클래스의 포인터/참조일 경우에는 파생 클래스의 포인터/참조를 리턴할 수 있도록 대체하는 것을 허용하는데, 이 기능을 Covariance(공변)이라 말하며 이렇게 수정된 리턴형을 Covariance of Return Type(공변 리턴형)이라 표현한다.


Pure \(\texttt{virtual}\) Function (순수 가상 함수)

- 주로, Abstract Base Class(ABC; 추상화 기초 클래스 참조)에서 메서드를 순수 가상 함수로 구현한다.
- 클래스 선언에 순수 가상 함수가 포함되어 있으면, 그 클래스는 객체를 생성할 수 없다.
(해당 클래스가 기초 클래스의 역할을 수행하기 때문이다. = 오직 상속을 해주기 위해 존재하는 클래스)
- 클래스 선언부의 함수 원형의 세미콜론 바로 앞에 \(\texttt{= 0}\) 을 붙여서 구현한다.
- 순수 가상 함수를 정의하여, 클래스를 ABC로 만드는 순간, 해당 순수 가상 함수 정의를 구현할 필요는 없지만, 함수 정의가 불가능한 것은 아니다.

// Ex. 순수 가상 함수 원형 예시

class Father {                        // 순수 가상 함수를 포함하고 있으므로, Father 클래스는 객체를 생성할 수 없다.
    ...                               // 오직, 상속만을 위한 클래스이다.
    virtual int Func_1() = 0;         // Func_1은 순수 가상 함수이다.
    virtual void Func_2() const = 0;  // Func_2 역시 순수 가상 함수이다.
    ...
};