이 시리즈는 이득우의 언리얼 C++ 게임 개발의 정석을 바탕으로 작성되었습니다.
UE5 길라잡이 세 번째 이야기에서 알아본 C++ 클래스의 상속 구조와 프로그래밍 기법에 대해 좀 더 자세히 알아볼 것이다. Fountain.h
파일로 가보자.
// Fountain.h
#pragma once
#include "UE5Practice.h"
#include "GameFramework/Actor.h"
#include "Fountain.generated.h"
UCLASS()
class UE5PRACTICE_API AFountain : public AActor
{
GENERATED_BODY()
public:
// Sets default values for this actor's properties
AFountain();
protected:
// Called when the game starts or when spawned
virtual void BeginPlay() override;
virtual void EndPlay(EEndPlayReason::Type) override; // +
virtual void PostInitializeComponents() override; // +
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
...
};
윗줄부터 천천히 살펴보자.
#pragma once
pragma once
는 현재 소스 파일이 단일 컴파일에 한 번만 포함되도록 설계된 널리 지원되는 전 처리기 지시문이다. 만약 다른 여러 파일이 Fountain.h
를 포함하는 구문을 가지고 있다면 컴파일러는 이 헤더를 여러 차례 반복하여 읽는 작업으로 시간이 낭비되겠지만, pragma once
처리기를 포함하면 이를 미연에 방지해 1회만 읽고 이후엔 읽지 않는다.
다만 이 처리기는 표준이 아니기 때문에 모든 C++ 스크립팅에 사용할 수 있는 기법은 아니라는 것을 유의하자. 언리얼 엔진에선 해당 처리기를 지원한다.
...
#include "Fountain.generated.h"
UCLASS()
class UE5PRACTICE_API AFountain : public AActor
{
GENERATED_BODY()
...
}
언리얼 컴포넌트 시스템인 리플렉션 기능을 UCLASS()
, GENERATED_BODY()
매크로로 할성화해준다. 해당 구문들이 없다면 액터 C++ 클래스는 BeginPlay()
와 같은 언리얼 이벤트 함수나 UE_LOG()
등의 로그 출력 매크로 등 언리얼 시스템에 참여하는 기능을 일절 사용할 수 없다.
또한, 아래에서 살펴볼 이벤트 함수에서 AActor에 선언된 가상 이벤트 함수를 호출할 때 Super::
키워드를 사용해 부모의 함수를 불러오는데, 이는 표준이 아니라 리플렉션으로 UObject가 된 클래스만 사용할 수 있도록 typedef 키워드를 생성해놓은 것이다.
...
protected:
virtual void BeginPlay() override;
virtual void EndPlay(EEndPlayReason::Type) override; // +
virtual void PostInitializeComponents() override; // +
public:
// Called every frame
virtual void Tick(float DeltaTime) override;
...
언리얼 엔진에서 액터와 같은 게임 구성 요소에 특정한 이벤트가 발생했을 때 자동으로 호출되는 함수들을 말한다. 언리얼 게임 프레임워크가 정상적으로 동작하기 위해 반드시 거쳐야 하는 로직을 실행해 액터 동작의 뼈대가 되어 주는 함수이다. 이외에도 다른 이벤트 함수가 많이 있지만, 위 이벤트 함수들을 주로 사용한다.
위 세 함수들이 protected
접근지시자로 분류된 이유는 액터가 '직접' 이 함수들을 호출하기 때문이다. 반면에 Tick()
함수는 엔진에서 리스트를 관리하여 호출하기 때문에 외부에서 접근할 수 있는 public
접근지시자에 포함되어야 한다.
// Fountain.cpp
void AFountain::Tick(float DeltaTime)
{
Super::Tick(DeltaTime);
UE_LOG(UE5Practice, Warning, TEXT("%d"), count++);
}
만약 위와 같이 Tick()
함수를 작성하였을 때 public
이 아닌 protected
접근지시자에 선언하여 게임을 실행한다면 아래와 같이 로그가 표시되지 않
지 않는다.
...?
엔진에서 자체적으로 처리가 되는 것인지 특정 틱 그룹에 소속시키지 않아서 그런 것인지 왜 이렇게 나오는지는 모르겠지만, 하여튼 public
에 Tick()
함수가 선언된 이유는 위와 같다.
위 코드에서 처음 보는 이벤트 함수를 발견할 수 있다.
virtual void EndPlay(EEndPlayReason::Type) override; // +
virtual void PostInitializeComponents() override; // +
둘다 이름에서 호출 시기를 쉽게 유추할 수 있다. 이때 EndPlay()
함수는 호출될 때 인자를 받아오게 되는데,
namespace EEndPlayReason
{
enum Type
{
Destroyed,
LevelTransition,
EndPlayInEditor,
RemovedFromWorld,
Quit,
}
}
와 같은 타입으로 분류되며, 필요에 따라 각 상황에 대한 예외처리가 가능하다.
PostInitializeComponents()
함수는 게임이 실행됐을 때 레벨에 배치된 액터가 작동하기 위한 컴포넌트 세팅이 완료됐을 때 호출하는 함수로, 이 함수가 호출된 시점 이후에 액터가 스폰되고, 액터가 스폰된 후 BeginPlay()
를 호출한다.
C++에는 가상 함수라는 개념이 있다. 상속 관계에 있는 클래스들이 공통적으로 사용할 만한 함수를 정의해둔 것을 말한다. 해당 함수가 기본적으로 해야 할 일을 기초 클래스에 구현하고 파생 클래스에서 추가적으로 해야 할 일들을 추가 구현해준다.
class Base {
public:
Base() { }
virtual void print() {
std::cout << "Base\n";
}
};
class Derived : public Base {
public:
Derived() { }
void print() override {
std::cout << "Derived\n";
}
};
int main() {
Derived derived;
Derived* pDerived = &derived;
Base* pBase = pDerived;
pBase->print();
}
일반 C++의 가상 함수 문법을 똑같이 사용하여 기초 클래스에는 virtual
키워드를, 파생 클래스에는 override
키워드를 추가해준다. 일반 C++ 가상 함수에 대한 조금 더 자세한 설명은 이 포스팅에서 확인할 수 있다.
AFountain
클래스는 AActor
클래스를 상속받고 있고, AActor
클래스에 위 코드에 있는 BeginPlay()
, Tick()
등의 이벤트 함수가 가상 함수로 정의되어 있다. 따라서 AFountain
클래스에서 해당 함수들을 오버라이딩해주었다. 파생 클래스에서 virtual
키워드까지 포함하는 이유는 혹시 파생 클래스인 AFountain
을 또다른 클래스가 상속하게 되었을 때 해당 가상 이벤트 함수를 호출하는 것을 대비하기 위함이다.
파생 클래스가 이벤트 함수를 호출하는 것에 대비해야 하는 이유는 언리얼 오브젝트의 C++ 클래스는 이벤트 함수를 오버라이딩하여 사용할 때 반드시 기초 클래스의 가상 함수 Super::
키워드로 호출해줘야 하기 때문이다.
// Fountain.cpp
void AFountain::BeginPlay()
{
Super::BeginPlay();
...
}
일반적인 C++는 특별히 설계한 것이 아니라면 파생 클래스에서 오버라이딩한 가상 함수를 호출할 때 기초 클래스의 가상 함수 원형을 호출하지 않아도 되는 것과 다른 점이다.
앞서 말한 것처럼 이벤트 함수는 언리얼 게임 프레임워크를 위한 중요한 로직이 구현되어 있는데, 이러한 이벤트 함수의 원형을 가지고 있는 것이 AActor
클래스와 같은 언리얼 오브젝트들의 기초 클래스들이다. 해당 중요한 로직을 실행하기 위해 부모의 가상 함수 원형을 실행하고 추가적으로 필요한 내용을 구현해준다.
대표적인 예시로 Super::BeginPlay()
를 호출해야만 액터의 생명주기에서 BeginPlay 단계를 정상적으로 실행할 수 있다. 또한 액터의 Super::BeginPlay()
는 액터가 가진 컴포넌트의 BeginPlay()
를 호출해주고 액터의 주요 Tick()
함수와 컴포넌트의 Tick()
함수를 등록해주는 등 핵심적인 역할을 한다. 기초 클래스의 가상 함수 원형 호출을 반드시 기억하자.
▶ [MS Document] once pragma
▶ [UE Forum] Why is BeginPlay protected and Tick public?
▶ [UE Document] Actor Ticking / (KOR)