만두네 민박

[UE5/C++] 순수 가상 함수

by pmj6541

흠.. 시험 당시에 찝찝한 답변을 남겼는데, 알고있던게 맞았네요. 이런걸 사전에 방지하고자 정리하고 갑시다.

가상 함수

말 그대로 "가상" 함수입니다. 실체가 없어요. 즉, 추상클래스에서 다형성을 목적으로 사용되는 함수를 뜻합니다. 사용법은 아래와 같아요. 주의점은 그냥 가상 함수는 아래처럼 실체가 있을수도, 없을수도 있습니다. 강제성이 없어요.

class Shape {
public:
    virtual void draw();  // ✅ 일반 가상 함수 (기본 구현 제공 X)
};
// or

class Base {
public:
    virtual void show() {  // ✅ 일반 가상 함수 (기본 구현 제공 O)
        std::cout << "Base 클래스의 show()" << std::endl;
    }
};

 

기본 구현을 제공해야하고, 혹여 추상 클래스의 인스턴스를 생성할 필요가 있을 경우에 가상 함수를 적절하게 사용하면 될 것 같네요.

순수 가상 함수

"가상"의 의미에 순수가 포함되어 더 강해졌다고 생각하시면 됩니다. 실체가 없어서 파생 클래스에 오버라이딩이 강제됩니다. 표현법은 virtual 과 뒤에 = 0을 같이 써주면 됩니다.

class Base {
public:
    virtual void show() = 0;  // ✅ 순수 가상 함수 (구현 X)
};

이렇게 되면 Base는 순수 가상 함수를 가지고 있기에 추상 클래스가 되고, Base에서 파생된 모든 클래스는 show 함수를 오버라이딩 해서 구현해주어야 합니다. 강제성을 지니고 있어요.

Unreal 에서의 순수 가상 함수

쓰고있는건 Unreal 이니 배운건 복습합시다. ActionInterface.h에 클래스를 만들어두었어요. IActionInterface. Action의 기능이 있는 클래스들은 이 인터페이스를 구현해야 합니다. 이때 순수 가상 함수를 만들어 두어 클래스들이 Begin/End Action A/B/C 들을 모두 구현해야 하도록 만들어준 것입니다.

class UE_CPP_2412_API IActionInterface
{
	GENERATED_BODY()

	// Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:
	virtual void BeginActionA() PURE_VIRTUAL(BeginActionA)
	virtual void BeginActionB() PURE_VIRTUAL(BeginActionB)
	virtual void BeginActionC() PURE_VIRTUAL(BeginActionC)
	virtual void EndActionA() PURE_VIRTUAL(EndActionA)
	virtual void EndActionB() PURE_VIRTUAL(EndActionB)
	virtual void EndActionC() PURE_VIRTUAL(EndActionC)
};

언리얼에서는 PURE_VIRTUAL이라는 매크로를 붙여서 사용해줍니다. virtual ~~~~ =0; 에서 =0이 필요 없어진거죠.

컴파일 관점

이론만 알아서 뭐하나. 왜쓰는지 뭐가 좋은지 알고 써야 좋지.

가상 함수의 용도는 오로지 다형성을 부여하기 위함입니다. 이게 무슨말이냐, 동작 방식에 있어서 일반 함수에 비교적 더 떨어진다는 뜻입니다.

가상 함수가 없는 경우

일반 함수의 형태로 Base, Base를 상속한 Derived 가 있고, 모두 show 라는 함수를 갖습니다.

Base* ptr = new Derived();
ptr->show();

Base* ptr = new Derived();

ptr->show();

이 경우, ptr->show(); 는 Base의 show를 호출하게 됩니다. 즉 일반 함수의 경우, 클래스의 주소에 직접 삽입된다는 것을 알 수 있습니다.

가상 함수가 있는 경우

하지만 Base가 가상함수 show를가지고, Derived에서 이를 오버라이딩 한다면

Base* ptr = new Derived();
ptr->show();

이 경우 Derived의 show를 호출하게 됩니다. 이유는 ptr->show(); 호출 시 vtable을 통해 Derived::show()를 찾게 된겁니다. 차이를 정리해보면 일반 함수의 경우 클래스의 주소에 직접 들어가게 되고, 클래스를 통해 직접 접근이 가능합니다. 반대로 가상 함수의 경우, C++ 에서 사용하게 되면 컴파일러가 자동의로 vtable과 vptr을 생성하여 관리하게 되고, 이것을 활용해 동작하게 됩니다.

vtagle과 vptr이란?

너무 갑자기 나왔죠. 여기서 짚고 갈게요.

vptr (가상 테이블 포인터)

  • 각 객체가 자신이 속한 클래스의 vtable을 가리키는 포인터
  • 클래스 내에 숨겨진 멤버 변수로 존재(보통 가장 앞에 배치됨)
  • 객체가 생성될 때, 해당 클래스의 vtable을 가리키도록 설정됨

클래스별로 하나씩 가지고 있는 저장소 ptr이라고 생각하면 됩니다. 그리고 이 ptr이 가리키는곳이 바로

vtable (가상 함수 테이블)

  • 각 클래스마다 하나씩 생성됨
  • 가상 함수의 주소를 저장하는 테이블
  • 파생 클래스에서 가상 함수를 재정의하면, 해당 함수의 주소로 변경됨

이곳에서 가상 함수들을 관리합니다. 클래스 별로 하나씩 가지고 있고, 만일 나의(Base) 가상함수를 다른 객체가(Derived) 구현했다면 구현한 객체의 함수 주소로 변경됩니다. 그 결과 ptr->show();는 Derived의 함수가 호출된것이죠.

이 과정을 더 자세히 보면

1. Base* ptr = new Derived(); 실행 시 Derived 객체가 생성되면서 vptr이 Derived의 vtable을 가리킴

2. ptr->show(); 호출 시 ptr은 Base* 타입이므로, 원래라면 Base::show()를 호출해야 하지만 vptr을 따라 vtable을 확인하고, 실제 Derived::show()의 주소를 찾아 실행

그리고 메모리 구조로 보자면

일반 함수

Base 객체:
[ 멤버 변수 | show() 주소 ]

가상 함수

1) 클래스별 vtable

Base vtable:
[ &Base::show ]

Derived vtable:
[ &Derived::show ]  // 오버라이딩된 함수로 변경됨

2) 객체의 메모리 구조 (vptr 포함)

Base 객체:
[ vptr → Base vtable | 멤버 변수 ]

Derived 객체:
[ vptr → Derived vtable | 멤버 변수 ]

이 구조로 되어있어, vptr을 따라 vtable을 확인하고, 실제 Derived::show()를 호출하게 됩니다.

단점

딱 봐도, 공간도 많이쓰고, 시간도 오래 걸려 보이죠. 가상함수에는 이런 단점이 있습니다.

1. 성능 오버헤드

2. 객체 크기 증가

3. 인라인(inline) 최적화 제한

1,2는 시간복잡도, 공간복잡도 이야기고, 3은 인라인 최적화가 불가능하다 입니다. 이 이유는 호출할 함수가 런타임에 결정되기 때문이지요.

정리

블로그의 정보

만두네민박

pmj6541

활동하기