SW 그리고 아빠 엔지니어링 중...

아는 만큼 보이고, 경험해봐야 알 수 있고, 자꾸 써야 내 것이 된다.

L C++

남들 쓰듯이 쓰는 virtual이라면 고민할 필요 없는 알쓸 virtual 정보 📖

보리남편 김 주부 2024. 10. 6. 17:46

virtual은 컴파일 타임과 실행 타임 동작이 다르다.


가상함수는 기본 값 설정을 하지 말자. (제발 ㅜㅜ)

 

가상함수에 기본 값을 세팅하는 데 기저 클래스와 파생 클래스에서 기본 값이 다르면 어떻게 될까? 🤔

아래 예시는 이를 확인해 볼 수 있는 코드인데 동작 결과를 예측해 보자.

example
#include<iostream>
class Base
{
public:
 virtual void foo(int a = 10) { std::cout << "Base : " << a << std::endl; }
};
class Derived : public Base
{
public:
 virtual void foo(int a = 20) { std::cout << "Derived : " << a << std::endl; }
};
int main()
{
 Base* p = new Derived;
 p->foo(); //1.
}
result
Derived : 10

 

예상했던 결과 값인가?

💬 그냥, '제발 가상함수는 기본 값을 설정하지 말자'로 끝내고 싶지만 발생할 수 있는 문제점은 이해하고 넘어가는 게 좋을 테니 정리를 해보자.

 

우선 가상함수 컴파일 과정은 다음과 같다.

  1. 컴파일러는 컴파일 시점에 vtable을 만들어서 가상함수들의 주소를 가진다.(저장)
  2. 그리고 객체가 생성될 때 vptr이 가상 함수 테이블을 가리키도록 초기화된다.

그리고 컴파일 타임 실행 타임에 동작되는 것을 구분해서 이해를 해야 한다.

컴파일 타임

주석 1. 'p->foo()'의 코드는 컴파일 시점에 vptr가 연결되어 있지 않기에 'p->foo(10)'처럼 기반 클래스의 가상함수에 기본 값인 10으로 설정된다.

실행 타임

객체가 생성되고 vptr이 초기화된 이후 p->foo() 호출은 Derived::foo()의 주소를 가리키고 있으니 Derived::foo 가 호출되어 실행결과가 Derived : 10이 된 것이다.

💡 정리하면
가상함수는 컴파일 타임실행 타임 동작이 다르기에 정 써야 한다면 같은 값을 기본 값으로 설정하자.

 

클래스의 소멸자를 virtual(가상함수)로 하는 이유를 아시나요?


이는 OOP(객체지향프로그래밍)에서 가장 중요한 요소인 다형성과 관련이 있다.

 

아래 예시와 같이 기반클래스를 상속받은 파생클래스가 있을 때 아래 1. 2. 의 결과를 한 번 생각해 보자.

sample
#include <iostream>
class Base
{
public:
 Base() { std::cout << "Base()" << std::endl; }
 virtual ~Base() { std::cout << "~Base()" << std::endl; }
};
class Derived : public Base
{
public:
 Derived() { std::cout << "Derived()" << std::endl; }
 ~Derived() { std::cout << "~Derived()" << std::endl; }
};
int main()
{
 Derived* p = new Derived;
 delete p;   // 1.
 Base* p = new Derived;
 delete p;   // 2.
}

 

주석 1. 은 Derived 클래스를 동적할당하고 메모리 해제를 하는 코드이고 결과는 다음과 같다.

Result
Base()
Derived()
~Derived()
~Base()

 

생성자는 기저클래스부터 호출되고 소멸자는 역순으로 호출된다.

주석 2. 는 1. 과 달리 파생클래스의 메모리를 기저클래스로 받아서 메모리를 해제하는 코드로 1. 의 결과와 같다.

만약 클래스의 소멸자를 가상함수가 아니게 변경하면 결과가 어떻게 될까?

//virtual ~Base() { std::cout << "~Base()" << std::endl; }
            ~Base() { std::cout << "~Base()" << std::endl; }
Result
Base()
Derived()
~Derived()
~Base()
Base()
Derived()
~Base()

 

이전과 달라진 결과는 파생클래스의 소멸자 ~Derived가 호출이 안된다.

 

왜 소멸자가 가상함수 일 때만 호출이 되는 걸까?🤔

예를 들어 가상함수가 없으면 아래의 경우 컴파일러는 할당되는 클래스의 타입(Base 클래스 타입)만 알고 있기에 메모리 해제 시 그 클래스의 소멸자만 호출된다.

ex)  Base* p = new Derived; 

 

하지만 소멸자가 가상함수(virtual)이면 컴파일러는 vtable에 파생 클래스의 소멸자주소를 저장하기에 기저 클래스 포인터를 통해 삭제가 될 때 파생 클래스의 소멸자가 호출될 수 있다. 이것은 다형성을 구현하는 핵심 메커니즘이기도 하다.

 

💡 그래서 기반 클래스로 사용되는 모든 클래스는 반드시 "소멸자를 가상함수"로 해야 한다.

 

기반 클래스의 소멸자가 virtual 이 아닌 경우에는 어떻게?

📜 C++ core guidelines에서는 이렇게 가이드해주고 있다.
C.35: 상위 클래스의 소멸자는 공개된 가상 소멸자 혹은 상속되는 비-가상 함수여야 한다.     
말이 좀 어려운데 영어로 보면 좀 더 해석하기 쉽다.
C.35: A base class destructor should be either public and virtual, or protected and non-virtual   
즉 '기반 클래스의 소멸자는 virtual 이거나 protected 이어야 한다.'는 의미다.

 

 

기반 클래스 소멸자가 virtual인 이유는 정리했기에 기반 클래스의 소멸자가 virtual이 아닌 경우에는 왜 protected여야 하는지 알아보자.

💬 경험적으로 기저 클래스의 소멸자가 가상함수가 아닌 사례를 본 적이 없지만, 누군가의 고민일지도 모르기에 왜 protected를 권장하는지 예시를 통해 한 번 알아보자.
sample
#include <iostream>
class Base
{
public:
 Base() { std::cout << "Base()" << std::endl; }
protected: //1.
 ~Base() { std::cout << "~Base()" << std::endl; }
};
class Derived : public Base
{
public:
 Derived() { std::cout << "Derived()" << std::endl; }
 ~Derived() { std::cout << "~Derived()" << std::endl; }
};
int main()
{
 Base* p = new Derived;
 delete p;  // 2.
 Derived* p2 = new Derived;
 delete p2; // 3.
}
result
error: 'Base::~Base()' is protected within this context
27 | delete p;
   | ^
14:9: note: declared protected here
14 | ~Base() { std::cout << "Base()" << std::endl; }

 

💡 Base 클래스의 소멸자가 가상함수가 아닐 때 protected로 해두면 이 처럼 컴파일 에러가 발생된다. 이는 메모리 정리 시 Derived 소멸자가 호출되지 않아 발생할 수 있는 메모리 누수를 원천적으로 막기 위한 방법이다.
주석 1. Base* 인 상태로는 delete 하지 못하게 하려는 것
주석 2. Base가 protected 이므로 접근 권한 문제로 컴파일 에러가 발생
주석 3. ~Derived()는 public 이므로 문제없이 호출됨

 

💬 특이 케이스의 예제들이지만 이로 인해 좀 더 virtual 함수에 대해 이해하는 계기가 되었으면 한다.

 

참고


C++ 핵심 가이드라인 한글화 프로젝트 링크

C++ Core Guidelines 원문 링크

 

 

728x90