Delegate


델리게이트

C++의 델리게이트는

어떤 객체 인스턴스의 멤버 함수를 실행하려면, 어떤 객체인지(객체 주소), 어떤 함수인지(함수 주소)가 필요.

과거는 멤버 함수 포인터를 사용했음.

// 객체의 주소(myObject)와 함수의 주소(&MyClass::MyFunction)를 따로 엮어줘야 함
void (MyClass::*FuncPtr)() = &MyClass::MyFunction;
(myObject->*FuncPtr)();

모던 C++의 방식: std::function과 람다(Lambda)

C++11 표준 이후부터는 라이브러리가 추가되면서, 언리얼 델리게이트와 비슷한 기능을 아주 쉽게 쓸 수 있게 되었습니다.

#include <functional>

MyClass* myObject = new MyClass();
// std::bind나 람다(Lambda)를 이용해 특정 객체의 함수를 쉽게 묶어둘 수 있음
std::function<void()> myDelegate = myObject { myObject->MyFunction(); };

myDelegate(); // 호출!
#include <iostream>
#include <functional>

// 1. 이벤트를 발생시키는 클래스 (버튼)
class Button {
public:
    // 언리얼의 델리게이트와 같은 역할을 하는 std::function
    // "반환값이 없고(void) 매개변수도 없는 함수"를 담을 수 있는 변수입니다.
    std::function<void()> OnClicked;

    void Press() {
        std::cout << "버튼이 눌렸습니다." << std::endl;
        // 바인딩된 함수가 있다면 실행!
        if (OnClicked) {
            OnClicked(); 
        }
    }
};

// 2. 이벤트를 수신하는 클래스 (플레이어)
class Player {
public:
    void Jump() {
        std::cout << "플레이어가 점프합니다!" << std::endl;
    }
};

int main() {
    Button myButton;
    Player myPlayer;

    // [핵심] Button 클래스 안에 있는 OnClicked(델리게이트)에 
    // Player 클래스의 Jump 함수를 람다(Lambda)를 이용해 바인딩합니다!
    myButton.OnClicked = &myPlayer {
        myPlayer.Jump();
    };

    // 버튼을 누르면 Player의 Jump()가 실행됩니다.
    myButton.Press(); 

    return 0;
}

C++과 언리얼 방식 비교 (유사)

// C++
myButton.OnClicked = &myPlayer { myPlayer.Jump(); };
// Unreal Engine
myButton->OnClicked.AddDynamic(&myPlayer, &APlayer::Jump);

Unreal Engine의 델리게이트

Unreal Engine DELEGATE 뜯어보기

Delegate.h 파일을 보면 주석으로 설명이 쓰여 있다.

C++ DELEGATES

 시스템을 통해 제네릭(generic)하면서도 타입 안전성(type-safe) 유지하는 방식으로 C++ 객체의 멤버 함수를 호출할  있습니다. 델리게이트(Delegate) 사용하면 임의의 객체의 멤버 함수에 동적으로 바인딩할  있으며, 호출자가 해당 객체의 타입을 알지 못하더라도 객체의 함수를 호출할  있습니다.

 시스템은 델리게이트 타입을 선언할  사용할  있는 다양한 제네릭 함수 시그니처 조합을 미리 정의해 두고 있습니다. 따라서 반환값과 매개변수 자리에 필요한 타입을 자유롭게 지정하여 사용할  있습니다.

싱글캐스트(Single-cast)  멀티캐스트(Multi-cast) 델리게이트가 모두 지원되며, 디스크에 직렬화하여 저장하거나 블루프린트(Blueprint)에서 접근할  있는 "다이내믹(Dynamic)" 델리게이트도 지원됩니다. 추가로, 델리게이트는 바인딩된 함수에 직접 전달하고 보관할  있는 "페이로드(Payload)" 데이터를 정의할 수도 있습니다.
DELEGATE FEATURES

현재 우리는 다음 항목들을 조합하여 델리게이트 시그니처(delegate signature) 구성하는 것을 지원하고 있습니다:

- 값을 반환하는 함수
- 최대 4개의 "페이로드(payload)" 변수
- 매크로/템플릿 선언에 따라 여러 개의 함수 매개변수 허용
- 'const'로 선언된 함수

DECLARE_MULTICAST_DELEGATE... 매크로를 사용하는 멀티캐스트(Multi-cast) 델리게이트 또한 지원됩니다. 멀티캐스트 델리게이트를 사용하면 여러 개의 함수 델리게이트를 연결(attach) 다음, 단일 "Broadcast()" 함수를 호출하여 연결된 함수들을  번에 모두 실행할  있습니다. , 멀티캐스트 델리게이트 시그니처는 반환값을 사용할  없습니다.

다른 타입들과 달리, 다이내믹(dynamic) 델리게이트는 UObject 리플렉션 시스템에 통합되어 있어 블루프린트(blueprint) 구현된 함수에 바인딩되거나 디스크에 직렬화(serialize)  있습니다. 네이티브 함수도 바인딩할  있지만, 네이티브 함수는 반드시 UFUNCTION 마크업과 함께 선언되어야 합니다. 다른 타입의 델리게이트에 바인딩되는 함수에는 UFUNCTION 사용할 필요가 없습니다.

델리게이트에 "페이로드 데이터" 할당할  있습니다! 이는 임의의 변수들로, 델리게이트가 호출될  바인딩된 함수로 직접 전달됩니다. 바인딩 시점에 델리게이트 자체 내에 매개변수를 저장할  있게  주므로 매우 유용합니다. ("다이내믹" 제외한) 모든 델리게이트 타입은 페이로드 변수를 자동으로 지원합니다!

델리게이트에 바인딩할  페이로드 데이터를 함께 전달할  있습니다. 다음 예제는 bool int32라는  개의 커스텀 변수를 델리게이트에 전달합니다. 그런 다음 델리게이트가 호출될 ,  매개변수들은 바인딩된 여러분의 함수로 전달됩니다. 이러한 추가 변수 인수들은 항상 델리게이트 타입의 기본 매개변수 인수들보다 뒤에서 받아야(accepted) 합니다.

MyDelegate.BindStatic( &MyFunction, true, 20 );

 함수 시그니처 타입에 사용할 매크로 이름은  문서 주석 하단에 있는 시그니처 (signature table) 확인하시고, 바인딩에 대한 옵션  주의 사항은 바인딩 (binding table) 참조하시기 바랍니다.

여기서 페이로드 데이터란?

// 1. 바인딩할 때 데이터(true, 20)를 미리 쥐여줍니다. (이것이 페이로드)
MyDelegate.BindStatic( &MyFunction, true, 20 );

// 2. 나중에 실행할 때는 아무 값도 넣지 않지만...
MyDelegate.Execute(); 

// 3. 실제로는 MyFunction(true, 20)이 실행됩니다.

다이내믹 델리게이트가 “디스크에 직렬화(Serialized to disk)될 수 있다”는 것의 의미

일반 델리게이트(다이내믹이 아닌 것)는 C++의 메모리 주소(포인터)를 기반으로 작동합니다. 게임을 끄거나 에디터를 재시작하면 메모리 주소가 완전히 바뀌어버리기 때문에, “어떤 함수가 연결되어 있었는지” 디스크에 저장했다가 다시 불러올 수가 없습니다.

다이내믹 델리게이트는 언리얼 엔진의 리플렉션 시스템을 통해 메모리 주소가 아닌 “객체의 이름”과 “함수의 이름”으로 연결 상태를 기억합니다.

이는 그저 리플렉션 시스템을 활용했기에 블루프린트에서 사용할 수 있다!만을 의미하는 것이 아닙니다.

게임 플레이 도중 특정 이벤트에 어떤 함수가 연결되었는지 그 상태 자체를 세이브 파일(.sav)에 저장할 수 있습니다. 예를 들어, 플레이어의 선택에 따라 특정 NPC가 죽을 때 호출되어야 하는 이벤트가 달라진다고 가정해 봅시다. 다이내믹 델리게이트를 사용하면 현재 어떤 함수가 바인딩되어 있는지를 디스크에 저장하고, 다음 날 게임을 로드했을 때 그 연결 상태를 똑같이 복구할 수 있습니다. 일반 델리게이트로는 불가능한 작업입니다.

또한, C++ 코드를 다시 컴파일하지 않고도 외부 데이터 파일(JSON, CSV 등)을 읽어들여 런타임에 이벤트를 연결할 수 있다는 뜻입니다.

단, 문자열을 통해 리플렉션 시스템을 거쳐 함수를 찾아야 하므로 일반 델리게이트보다 실행 속도가 느리다는 단점이 있습니다.

델리게이트 반환값

DECLARE_DELEGATE_RetVal_OneParam(bool, FOnCheckPlayerReady, int32);

bool bReady = CheckReadyDelegate.Execute(1);

싱글캐스트 델리게이트는 하나의 함수를 호출하는 것과 같기에, 반환값을 받을 수 있습니다. 멀티캐스트 델리게이트는 바인딩된 여러 함수 중 어떤 반환값을 받을지 결정할 수 없기에 반환값을 받을 수 없습니다.

반환값이 있는 델리게이트는 ExecuteIfBound()를 사용할 수 없습니다. 만약 바인딩된 함수가 없을 때 기본값으로 무엇을 반환해야 할지 엔진이 알 수 없기 때문입니다. 따라서 바인딩되지 않은 상태에서 Execute()를 호출하면 엔진 크래시(Crash)가 발생하므로, 반드시 IsBound()를 통해 델리게이트에 연결된 함수가 있는지 먼저 확인하는 습관을 들여야 합니다.

DELEGATES EXAMPLE

어디서든 호출할  있도록 만들고 싶은 메서드가 있는 클래스가 있다고 가정해 보겠습니다:

class FLogWriter
{
    void WriteToLog( FString );
};

WriteToLog 함수를 호출하려면 해당 함수의 시그니처에 맞는 델리게이트 타입을 생성해야 합니다.
이를 위해 먼저 아래의 매크로  하나를 사용하여 델리게이트를 선언합니다. 예를 들어, 다음은 간단한 델리게이트 타입입니다:

DECLARE_DELEGATE_OneParam( FStringDelegate, FString );

 코드는 FString 타입의 단일 매개변수를 받는 FStringDelegate라는 델리게이트 타입을 생성합니다.

다음은 클래스에서  FStringDelegate 어떻게 사용하는지 보여주는 예시입니다:

class FMyClass
{
    FStringDelegate WriteToLogDelegate;
};

이를 통해 클래스는 임의의 클래스에 있는 메서드에 대한 포인터를 보유할  있습니다.  클래스가  델리게이트에 대해 실제로 아는 것은 함수의 시그니처뿐입니다.

이제 델리게이트를 할당하기 위해 델리게이트 클래스의 인스턴스를 생성하면 됩니다. 이때 객체의 인스턴스와 해당 메서드의 실제 함수 주소를 함께 전달합니다. 여기서는 FLogWriter 클래스의 인스턴스를 생성한 다음, 해당 객체 인스턴스의 WriteToLog 메서드에 대한 델리게이트를 바인딩해 보겠습니다:

FSharedRef< FLogWriter > LogWriter( new FLogWriter() );

WriteToLogDelegate.BindSP( LogWriter, &FLogWriter::WriteToLog );

방금 클래스의 메서드에 델리게이트를 동적으로 바인딩했습니다! 아주 간단하죠?

공유 포인터(shared pointer) 소유한 객체에 바인딩하기 때문에 BindSP 'SP'는 '공유 포인터'를 의미한다는 점에 유의하세요. BindRaw()  BindUObject() 같이 다양한 객체 타입에 맞는 버전도 존재합니다. BindStatic() 사용하면 전역 함수 포인터에 바인딩할 수도 있습니다.

이제 FMyClass FLogWriter 클래스에 대해 전혀 모르더라도 WriteToLog 메서드를 호출할  있습니다! 델리게이트를 호출하려면 Execute() 메서드를 사용하기만 하면 됩니다:

WriteToLogDelegate.Execute( TEXT( "Delegates are spiffy!" ) );

함수를 델리게이트에 바인딩하기 전에 Execute() 호출하면 어설션(assertion) 발생합니다. 따라서 많은 경우 대신 다음과 같이 사용하는 것이 좋습니다:

WriteToLogDelegate.ExecuteIfBound( TEXT( "Only executes if a function was bound!" ) );

기본적인 사용법은 이것이 전부입니다!! 조금  자세한 정보는 아래 내용을 계속 읽어보시기 바랍니다.
MORE INFORMATION

델리게이트 시스템은 특정 타입의 객체들을 이해하며, 이러한 객체들을 사용할  추가적인 기능들이 활성화됩니다. 델리게이트를 UObject 공유 포인터(shared pointer) 클래스의 멤버에 바인딩하면, 델리게이트 시스템은 해당 객체에 대한 약한 참조(weak reference) 유지할  있습니다. 따라서 델리게이트가 참조하던 객체가 도중에 파괴되더라도, IsBound() 또는 ExecuteIfBound() 함수를 호출하여 이러한 상황을 처리할  있습니다. 지원되는 다양한 타입의 객체에 따른 특수한 바인딩 문법에 유의하시기 바랍니다.

델리게이트 객체를 복사하는 것은 완전히 안전합니다. 델리게이트를 (by value)으로 전달할 수도 있지만, (heap) 메모리를 할당해야 하므로 일반적으로 권장되지 않습니다. 가능한  참조(by reference) 전달하세요!

델리게이트 시그니처 선언은 전역 스코프(global scope), 네임스페이스(namespace) 내부, 심지어 클래스 선언 내부에도 존재할  있습니다 (, 함수 본문 내부에는 선언할  없습니다).
FUNCTION SIGNATURES
dataasset
BINDING AND SAFETY

일단 델리게이트가 선언되면 다양한 곳에 저장된 함수에 바인딩할  있습니다.
델리게이트는 바인딩된  한참 뒤에 호출되는 경우가 많으므로 크래시(crash) 발생하지 않도록 각별한 주의가 필요합니다.  목록은 싱글캐스트(single-cast) 기준으로 작성되었으며, 멀티캐스트(multi-cast) 델리게이트의 경우 아래 표의 Bind Add 대체하여 사용하시면 됩니다. 또한 멀티캐스트 델리게이트에서 Add 나중에 바인딩을 제거할  사용할  있는 핸들(handle) 반환합니다. 모든 멀티캐스트 델리게이트는 동일한 시그니처의 싱글캐스트 버전을 정의하는 ::FDelegate 서브타입(subtype) 가지고 있으며, 이를 통해  곳에서 델리게이트를 생성(Create) 두고 나중에 추가(Add)  있습니다.
dataasset

BindUObject는 TWeakObjectPtr로 객체를 감싸서 보관하기 때문에, GC를 통해 객체가 이미 파괴됐는지 검사를 할 수 있다. 일반 C++ 클래스는 BindRaw를 하기보단, TSharedPtr로 감싼 뒤 BindSP를 사용하면 더 안전하게 사용할 수 있다.

때로는 어떤 함수를 당장 멀티캐스트에 바로 Add 하지 않고, 나중에 Add하고 싶을 수 있다. 멀티캐스트 델리게이트는 그 안에 싱글캐스트 타입을 가지고 있어서 이를 활용하면 된다.

변수로 관리할 수 있기에, 배열로 한 번에 추가할 것들 넣어놓으면 깔끔할 것 같다.

class UQuestManager : public UObject
{
public:
    void UpdateQuest(int32 MonsterID) { /* 퀘스트 갱신 로직 */ }

    void SetupDelegate()
    {
        // 1. 포장하기 (Create)
        // FOnMonsterDied::FDelegate는 'FOnMonsterDied'와 똑같은 매개변수를 가지는 1인용(싱글캐스트) 상자입니다.
        // AddUObject가 아니라 CreateUObject를 사용해서 상자에 담기만 합니다.
        FOnMonsterDied::FDelegate MyDelegate = FOnMonsterDied::FDelegate::CreateUObject(this, &UQuestManager::UpdateQuest);
        
        // ... (아직 몬스터에게 연결되지 않음. MyDelegate라는 변수로 들고만 있는 상태) ...
        
        // MyDelegate를 다른 함수의 매개변수로 넘겨주거나, 특정 조건이 만족될 때까지 기다릴 수 있습니다.
        WaitAndBind(MyDelegate); 
    }

    void WaitAndBind(FOnMonsterDied::FDelegate InDelegate)
    {
        AMonster* BossMonster = GetBossMonster();

        // 2. 나중에 추가하기 (Add)
        // 미리 포장해둔 델리게이트 상자(InDelegate)를 멀티캐스트 리스트에 쏙 집어넣습니다.
        BossMonster->OnDiedEvent.Add(InDelegate);
    }
};

핸들을 사용해서 바인딩 제거가 가능합니다.

  • 상황: 플레이어가 처음으로 보스를 잡았을 때 ‘첫 토벌 업적’을 달성하는 상황.
  • 해결: 보스가 죽는 이벤트(OnDied)에 업적 달성 함수를 Add 해둡니다. 보스가 죽어서 함수가 한 번 실행되면, 들고 있던 핸들을 이용해 즉시 Remove 합니다. 그러면 플레이어가 두 번째, 세 번째 보스를 잡아도 업적 달성 함수는 두 번 다시 호출되지 않습니다.
FDelegateHandle MyDelegateHandle;

MyDelegateHandle = TargetMonster->OnDiedEvent.AddUObject(this, &UMyObserver::OnMonsterDied);

TargetMonster->OnDiedEvent.Remove(MyDelegateHandle);
        
MyDelegateHandle.Reset();

파라미터 없는 델리게이트들이다.

/** Declares a delegate that can only bind to one native function at a time */
#define DECLARE_DELEGATE( DelegateName ) FUNC_DECLARE_DELEGATE( DelegateName, void )

/** Declares a broadcast delegate that can bind to multiple native functions simultaneously */
#define DECLARE_MULTICAST_DELEGATE( DelegateName ) FUNC_DECLARE_MULTICAST_DELEGATE( DelegateName, void )

/** Declares a broadcast thread-safe delegate that can bind to multiple native functions simultaneously */
#define DECLARE_TS_MULTICAST_DELEGATE( DelegateName ) FUNC_DECLARE_TS_MULTICAST_DELEGATE( DelegateName, void )

/** Declares a blueprint-accessible delegate that can only bind to one UFUNCTION at a time */
#define DECLARE_DYNAMIC_DELEGATE( DelegateName ) BODY_MACRO_COMBINE(CURRENT_FILE_ID,_,__LINE__,_DELEGATE) FUNC_DECLARE_DYNAMIC_DELEGATE( DelegateName, DelegateName##_DelegateWrapper, , FUNC_CONCAT( *this ), void )

/** Declares a blueprint-accessible broadcast delegate that can bind to multiple native UFUNCTIONs simultaneously */
#define DECLARE_DYNAMIC_MULTICAST_DELEGATE( DelegateName ) BODY_MACRO_COMBINE(CURRENT_FILE_ID,_,__LINE__,_DELEGATE) FUNC_DECLARE_DYNAMIC_MULTICAST_DELEGATE( DelegateName, DelegateName##_DelegateWrapper, , FUNC_CONCAT( *this ), void )

/** Declares a delegate with return value that can only bind to one native function at a time */
#define DECLARE_DELEGATE_RetVal( ReturnValueType, DelegateName ) FUNC_DECLARE_DELEGATE( DelegateName, ReturnValueType )

/** Declares a blueprint-accessible delegate with return value that can only bind to one UNFUNCTION at a time */
#define DECLARE_DYNAMIC_DELEGATE_RetVal( ReturnValueType, DelegateName ) BODY_MACRO_COMBINE(CURRENT_FILE_ID,_,__LINE__,_DELEGATE) FUNC_DECLARE_DYNAMIC_DELEGATE_RETVAL( DelegateName, DelegateName##_DelegateWrapper, ReturnValueType, , FUNC_CONCAT( *this ), ReturnValueType )
#define FUNC_DECLARE_DELEGATE( DelegateName, ReturnType, ... ) \
	typedef TDelegate<ReturnType(__VA_ARGS__)> DelegateName;
	
#define FUNC_DECLARE_MULTICAST_DELEGATE( MulticastDelegateName, ReturnType, ... ) \
	typedef TMulticastDelegate<ReturnType(__VA_ARGS__)> MulticastDelegateName;

ReturnType을 받는다.

__VA_ARGS__는 C++에서 가변 인자 매크로를 정의할 때 사용되는 특별한 매크로입니다.

로 들어온 가변 인자를 __VA_ARGS__에 대입합니다.

TDelegateDelegateSignatureImpl.inl 에 TDelegate가 있다.

inl 파일은 무엇인가? (접기/펼치기)
  • .inl 파일은 무엇인가?

    .inl 확장자 파일은 헤더 파일 가독성을 위한 파일이다.

    인라인 함수, 템플릿 코드들이 컴파일 특성상 헤더에 구현부가 있어야 하는데, 이를 .inl 파일에 따로 빼서 헤더 파일을 깔끔하게 관리하는 것이다.

    헤더파일의 아래쪽에서 #include 해서 사용된다. 그냥 밑에 코드를 붙여넣는다고 생각하면 된다.


본론으로 돌아와서,

template <typename InRetValType, typename... ParamTypes, typename UserPolicy>
class TDelegate<InRetValType(ParamTypes...), UserPolicy> : public TDelegateRegistration<InRetValType(ParamTypes...), UserPolicy>
#define DECLARE_DELEGATE_OneParam( DelegateName, Param1Type ) FUNC_DECLARE_DELEGATE( DelegateName, void, Param1Type )
#define DECLARE_DELEGATE_TwoParams( DelegateName, Param1Type, Param2Type ) FUNC_DECLARE_DELEGATE( DelegateName, void, Param1Type, Param2Type )

InRetValType(ParamTypes...)“ParamTypes…를 인자로 받고 InRetValType을 반환하는 함수 타입

C++에서는 반환타입(인자타입들) 형태가 유효한 타입(Type)으로 인정됩니다. 예를 들어 int(float, double)은 float와 double을 받고 int를 반환하는 함수 타입입니다.

싱글캐스트 델리게이트, 멀티캐스트 델리게이트와 달리 다이내믹 델리게이트만

#define DECLARE_DYNAMIC_DELEGATE( DelegateName ) BODY_MACRO_COMBINE(CURRENT_FILE_ID,_,__LINE__,_DELEGATE) FUNC_DECLARE_DYNAMIC_DELEGATE( DelegateName, DelegateName##_DelegateWrapper, , FUNC_CONCAT( *this ), void )
dataasset

싱글, 멀티 델리게이트는 템플릿을 사용해서 클래스를 만들고, 얘를 typedef로 타입 이름을 붙여줬는데

다이내믹은 템플릿을 사용하는 게 아니라, 매크로로 클래스 바디까지 만든다.

또한, BODY_MACRO_COMBINE과 FUNC_CONCAT 매크로를 사용한다.

  1. BODY_MACRO_COMBINE
#define BODY_MACRO_COMBINE(A,B,C,D) BODY_MACRO_COMBINE_INNER(A,B,C,D)

#define BODY_MACRO_COMBINE_INNER(A,B,C,D) A##B##C##D

C++ 매크로에서 ## 기호는 ‘토큰 병합 연산자(Token Pasting Operator)’라고 부릅니다.

간단히 말해 떨어져 있는 단어(토큰)들을 공백 없이 하나의 단어로 찰싹 붙여버리는 역할을 합니다.

즉, 내용물에 해당하는 A, B, C, D가 중요하겠군요.

(CURRENT_FILE_ID,_,LINE,_DELEGATE)

__LINE__ 은 현재 줄 번호를 숫자로 바꿔주는 표준 매크로입니다.

  • CURRENT_FILE_ID ➡️ 언리얼 헤더 툴(UHT)이 파일마다 몰래 만들어두는 고유 ID로 바뀝니다. (예: FID_MyProject_Source_MyActor_h)
  • _ ➡️ 그대로 _ (밑줄)
  • __LINE__ ➡️ 현재 줄 번호인 숫자로 바뀝니다. (예: 45)
  • _DELEGATE ➡️ 그대로 _DELEGATE

결과물 : FID_MyProject_Source_MyActor_h_45_DELEGATE

이렇게 줄 번호를 사용해서 고유한 이름을 가지며, 무엇인지 알아볼 수 있는 이름을 만듭니다.

  1. FUNC_CONCAT( *this ), void )
/** Helper macro that enables passing comma-separated arguments as a single macro parameter */
#define FUNC_CONCAT( ... ) __VA_ARGS__

이 매크로는 C/C++ 매크로 시스템의 콤마(,) 관련 문제를 해결하기 위한 Wrapper입니다.

쉼표로 구분된 인자들을 하나의 매크로 매개변수로 전달할 수 있게 해줍니다

#define DECLARE_MY_VAR(Type, Name) Type Name;

여기에 intfloat을 넣으면 잘 됩니다. 하지만 두 개 이상의 타입을 가지는 템플릿(예: TMap)을 넣으면 문제가 터집니다.

C++
// 에러 발생!
DECLARE_MY_VAR( TMap<int, float>, MyMap )

전처리기는 꺾쇠(< >)를 무시하고 콤마만 보기 때문에 인자가 3개 들어왔다고 착각하고 컴파일 에러를 뱉어냅니다.

전처리기가 꺾쇠 안의 콤마는 구분자로 오해하지만, 유일하게 소괄호 ( ) 안에 있는 콤마는 ‘아, 이건 아직 안 끝난 하나의 덩어리구나’ 하고 안전하게 통과시켜 주는 규칙이 있습니다.

그래서 템플릿 타입을 소괄호로 감싸서 전달하기 위해 FUNC_CONCAT을 사용하는 것입니다.

#define DECLARE_DYNAMIC_DELEGATE( DelegateName ) BODY_MACRO_COMBINE(CURRENT_FILE_ID,_,__LINE__,_DELEGATE) FUNC_DECLARE_DYNAMIC_DELEGATE( DelegateName, DelegateName##_DelegateWrapper, , FUNC_CONCAT( *this ), void )

#define DECLARE_DYNAMIC_DELEGATE_TwoParams( DelegateName, Param1Type, Param1Name, Param2Type, Param2Name ) BODY_MACRO_COMBINE(CURRENT_FILE_ID,_,__LINE__,_DELEGATE) FUNC_DECLARE_DYNAMIC_DELEGATE( DelegateName, DelegateName##_DelegateWrapper, FUNC_CONCAT( Param1Type InParam1, Param2Type InParam2 ), FUNC_CONCAT( *this, InParam1, InParam2 ), void, Param1Type, Param2Type )

즉, 이 코드는 이런 형태일텐데,

FID_MyProject_Source_MyActor_h_45_DELEGATE
class DelegateName : public TBaseDynamicDelegate<FNotThreadSafeDelegateMode, __VA_ARGS__> \
{ \
public: \
	/** Default constructor */ \
	DelegateName() \
	{ \
	} \
	\
	/** Construction from an FScriptDelegate must be explicit.  This is really only used by UObject system internals. */ \
	explicit DelegateName( const TScriptDelegate<>& InScriptDelegate ) \
		: TBaseDynamicDelegate<FNotThreadSafeDelegateMode, __VA_ARGS__>( InScriptDelegate ) \
	{ \
	} \
	\
	/** Execute the delegate.  If the function pointer is not valid, an error will occur. */ \
	inline void Execute( FuncParamList ) const \
	{ \
		/* Verify that the user object is still valid.  We only have a weak reference to it. */ \
		checkSlow( IsBound() ); \
		DelegateName_DelegateWrapper( *this ); \
	} \
	/** Execute the delegate, but only if the function pointer is still valid */ \
	inline bool ExecuteIfBound( FuncParamList ) const \
	{ \
		if( IsBound() ) \
		{ \
			DelegateName_DelegateWrapper( *this ); \
			return true; \
		} \
		return false; \
	} \
};
FID_MyProject_Source_MyActor_h_45_DELEGATE

이런 코드가 떡하니 있으면 컴파일 오류가 발생하는 것 아닌가?

아니다. 이 코드는 UHT이 만든 리플렉션용 코드이다.

엔진 코드에서 리플렉션용 코드 예시를 봅시다.

UserWidget.h 275번째 줄에 있는 코드입니다.

DECLARE_DYNAMIC_DELEGATE( FOnInputAction );

UHT가 만든 코드입니다.

UserWidget.generated.h

// ********** Begin Delegate FOnInputAction ********************************************************
#define FID_Engine_Source_Runtime_UMG_Public_Blueprint_UserWidget_h_275_DELEGATE \
UMG_API void FOnInputAction_DelegateWrapper(const FScriptDelegate& OnInputAction);

// ********** End Delegate FOnInputAction **********************************************************

FID_Engine_Source_Runtime_UMG_Public_Blueprint_UserWidget_h_275_DELEGATE 매크로가 있다.

이는 DelegateWrapper 함수를 선언하는 것이다.

UserWidget.gen.cpp를 보면 구현부가 있다.

void FOnInputAction_DelegateWrapper(const FScriptDelegate& OnInputAction)
{
	OnInputAction.ProcessDelegate<UObject>(NULL);
}
(접기/펼치기) (delegate 관련 코드 전체) UserWidget.gen.cpp
  • (delegate 관련 코드 전체) UserWidget.gen.cpp

      UMG_API UFunction* Z_Construct_UDelegateFunction_UMG_OnInputAction__DelegateSignature();
    
      // ********** Begin Delegate FOnInputAction ********************************************************
      struct Z_Construct_UDelegateFunction_UMG_OnInputAction__DelegateSignature_Statics
      {
      #if WITH_METADATA
      	static constexpr UECodeGen_Private::FMetaDataPairParam Function_MetaDataParams[] = {
      		{ "ModuleRelativePath", "Public/Blueprint/UserWidget.h" },
      	};
      #endif // WITH_METADATA
        
      // ********** Begin Delegate FOnInputAction constinit property declarations ************************
      // ********** End Delegate FOnInputAction constinit property declarations **************************
      	static const UECodeGen_Private::FDelegateFunctionParams FuncParams;
      };
      const UECodeGen_Private::FDelegateFunctionParams Z_Construct_UDelegateFunction_UMG_OnInputAction__DelegateSignature_Statics::FuncParams = { { (UObject*(*)())Z_Construct_UPackage__Script_UMG, nullptr, "OnInputAction__DelegateSignature", 	nullptr, 
      	0, 
      0,
      RF_Public|RF_Transient|RF_MarkAsNative, (EFunctionFlags)0x00120000, 0, 0, METADATA_PARAMS(UE_ARRAY_COUNT(Z_Construct_UDelegateFunction_UMG_OnInputAction__DelegateSignature_Statics::Function_MetaDataParams), Z_Construct_UDelegateFunction_UMG_OnInputAction__DelegateSignature_Statics::Function_MetaDataParams)},  };
      UFunction* Z_Construct_UDelegateFunction_UMG_OnInputAction__DelegateSignature()
      {
      	static UFunction* ReturnFunction = nullptr;
      	if (!ReturnFunction)
      	{
      		UECodeGen_Private::ConstructUDelegateFunction(&ReturnFunction, Z_Construct_UDelegateFunction_UMG_OnInputAction__DelegateSignature_Statics::FuncParams);
      	}
      	return ReturnFunction;
      }
      void FOnInputAction_DelegateWrapper(const FScriptDelegate& OnInputAction)
      {
      	OnInputAction.ProcessDelegate<UObject>(NULL);
      }
      // ********** End Delegate FOnInputAction **********************************************************
    
      DEFINE_FUNCTION(UUserWidget::execListenForInputAction)
      {
      	P_GET_PROPERTY(FNameProperty,Z_Param_ActionName);
      	P_GET_PROPERTY(FByteProperty,Z_Param_EventType);
      	P_GET_UBOOL(Z_Param_bConsume);
      	P_GET_PROPERTY(FDelegateProperty,Z_Param_Callback);
      	P_FINISH;
      	P_NATIVE_BEGIN;
      	P_THIS->ListenForInputAction(Z_Param_ActionName,EInputEvent(Z_Param_EventType),Z_Param_bConsume,FOnInputAction(Z_Param_Callback));
      	P_NATIVE_END;
      }
    


ProcessDelegate 함수

template <class UObjectTemplate>
void ProcessDelegate( void* Parameters ) const
{
	UObjectTemplate* ObjectPtr;
	UFunction* Function;

	{	// to avoid MT access check if the delegate is deleted from inside of its callback, we don't cover the callback execution
		// by access protection scope
		// the `const` on the method is a lie
		FWriteAccessScope WriteScope = const_cast<TScriptDelegate*>(this)->GetWriteAccessScope();

		checkf( Object.IsValid() != false, TEXT( "ProcessDelegate() called with no object bound to delegate!" ) );
		checkf( FunctionName != NAME_None, TEXT( "ProcessDelegate() called with no function name set!" ) );

		// Object was pending kill, so we cannot execute the delegate.  Note that it's important to assert
		// here and not simply continue execution, as memory may be left uninitialized if the delegate is
		// not able to execute, resulting in much harder-to-detect code errors.  Users should always make
		// sure IsBound() returns true before calling ProcessDelegate()!
		ObjectPtr = static_cast<UObjectTemplate*>(Object.Get());	// Down-cast
		checkSlow( IsValid(ObjectPtr) );

		// Object *must* implement the specified function
		Function = ObjectPtr->FindFunctionChecked(FunctionName);
	}

	// Execute the delegate!
	ObjectPtr->ProcessEvent(Function, Parameters);
}

문득 든 생각인데, 매크로가 치환된 코드를 볼 수는 없을까?

요즘 강력한 C++ IDE들은 컴파일러를 돌리기 전에 에디터 상에서 매크로를 미리 펼쳐서 보여주는 훌륭한 기능을 가지고 있어.

  • JetBrains Rider (언리얼 개발자 강력 추천): 매크로에 마우스 커서를 두고 Alt + Enter를 누른 뒤, “Substitute macro call” (매크로 치환) 또는 “Substitute macro and all nested calls” (내포된 매크로까지 모두 치환)를 선택하면 에디터 창에서 그 긴 매크로가 실제 C++ 코드로 쫙 펼쳐지는 마법을 볼 수 있어.
  • Visual Studio 2022: 매크로 단어 위에 마우스를 가만히 올려두면(Hover) 툴팁으로 한 겹 벗겨진 코드가 보여. 좀 더 자세히 보려면 매크로를 우클릭하고 “매크로 확장(Expand Macro)” 관련 메뉴(또는 인라인 확장 힌트)를 사용하면 펼쳐진 코드를 확인할 수 있어. (ReSharper C++ 플러그인을 쓰면 Rider와 동일하게 펼쳐보기가 가능해!)
dataasset

여기서 인라인 확장을 누르면

dataasset

확장 시각화를 누르면

dataasset

단계별로 볼 수 있다.

…… 오늘이라도 알아서 좋은 거지 뭐


이렇게 DECLARE_DYNAMIC_DELEGATE로 만들어진 델리게이트는 TBaseDynamicDelegate를 상속 받고, TBaseDynamicDelegate는 TScriptDelegate를 상속 받는다.

TScriptDelegate는

/** The object bound to this delegate, or nullptr if no object is bound */
WeakPtrType Object;

/** Name of the function to call on the bound object */
FName FunctionName;

Object와 함수 이름을 갖는다.

void BindUFunction( UObject* InObject, const FName& InFunctionName )
{
	FWriteAccessScope WriteScope = GetWriteAccessScope();

	Object = InObject;
	FunctionName = InFunctionName;
}

(해당 클래스 함수들 보면, Scope Lock을 함수마다 철저히 한다. WriteScope, ReadScope. 다시 공부해야 겠다.)

IsBound는 이렇게 작동된다.

template <class UObjectTemplate>
inline bool IsBound_Internal() const
{
	if (FunctionName != NAME_None)
	{
		if (UObject* ObjectPtr = Object.Get())
		{
			return ((UObjectTemplate*)ObjectPtr)->FindFunction(FunctionName) != nullptr;
		}
	}

	return false;
}
UFunction* UObject::FindFunction( FName InName ) const
{
	return GetClass()->FindFunctionByName(InName);
}

FindFunctionByName()는 UClass에 있는 함수이다.

Result = FuncMap.FindRef(InName);

TMap에서 FName을 Key로 Value를 찾는다.

FName은 내부에 문자열을 직접 보관하지 않습니다. 생성될 때 글로벌 네임 테이블(Name Table)에 문자열을 등록하고, 그 문자열을 가리키는 인덱스(정수 ID)만을 가지고 있습니다. 따라서 TMap 내부에서 두 FName이 같은지 비교(==)할 때, 글자를 하나씩 대조하는 무거운 작업 대신 단순한 정수 비교 한 번으로 끝납니다.

델리게이트를 실행할 때마다 해당 객체의 클래스 정보(UClass)를 뒤져서 그 FName을 가진 UFunction 객체가 있는지 검색해야 합니다. FName 비교 자체는 빠르지만, 계층 구조를 따라가며 함수 테이블을 뒤지는 ‘검색 과정’ 자체가 일반 델리게이트에는 아예 없는 불필요한 비용입니다.

또한, ProcessEvent가 순수 C++ 함수 호출과 비교해서 오버헤드가 큽니다.

`ProcessEvent`의 오버헤드 (접기/펼치기)
  • ProcessEvent의 오버헤드

    함수를 찾고 난 후 실행하는 과정에서 가장 큰 성능 차이가 발생합니다. 다이내믹 델리게이트는 C++ 기반의 네이티브 호출 대신 언리얼 엔진의 가상 머신(VM)과 통신하기 위해 UObject::ProcessEvent라는 아주 무거운 함수를 거칩니다.

    • 매개변수(Parameter)들을 메모리에 동적으로 할당하고 패킹(Packing)합니다.
    • 블루프린트 가상 머신 환경에 맞춰 스택을 셋업합니다.
    • 실행이 끝나면 결괏값을 다시 언패킹(Unpacking)합니다.

    이 과정이 일반적인 C++ 함수 호출보다 수십 배 이상 느리기 때문에 “다이내믹 델리게이트는 느리다”는 말이 나온 것입니다.

    ### 1단계: 매개변수 패킹 (Parameter Packing)

    일반 C++ 함수는 CPU 레지스터나 하드웨어 스택을 이용해 초고속으로 매개변수를 전달합니다. 하지만 ProcessEvent는 컴파일러의 도움을 받을 수 없으므로, 메모리에 구조체를 만들고 매개변수들을 일일이 복사(Copy)해서 패킹해야 합니다.

    ### 2단계: 가상 스택 프레임 생성 (FFrame)

    언리얼의 블루프린트(과거 언리얼스크립트)는 자체적인 가상 머신(VM) 위에서 돌아갑니다. ProcessEvent는 이 VM이 코드를 읽고 실행할 수 있도록 FFrame이라는 논리적 스택 프레임을 메모리에 셋업합니다.

    ### 3단계: 분기 및 실행

    UFunction의 플래그를 검사하여 이 함수가 어디에 구현되어 있는지 확인하고 실행합니다.

    • C++에 구현된 경우 (Native): 연결된 C++ 함수 포인터를 찾아 앞서 만든 Parms 메모리 블록을 넘겨주며 호출합니다.
    • 블루프린트에 구현된 경우 (Script): 블루프린트 가상 머신을 가동하여 노드(바이트코드)를 하나씩 순회하며 실행합니다.

    ### 4단계: 언패킹 및 반환 (Unpacking)

    실행이 끝나면, 결과값(Return Value)이나 참조로 전달된(Out) 매개변수들의 값을 다시 원래의 메모리 위치로 안전하게 복사해 돌려놓습니다.

    ## 3. 언제 ProcessEvent가 호출될까?

    우리가 모르는 사이에 엔진 내부에서 수없이 호출되고 있습니다.

    • 다이내믹 델리게이트 호출: (이전 질문의 핵심) 블루프린트에 바인딩될 수 있도록 설계되었기 때문에 항상 이 경로를 탑니다.
    • Blueprint Implementable Event: C++에서 선언하고 블루프린트에서 구현한 이벤트를 C++에서 호출할 때.
    • 네트워크 RPC (Remote Procedure Call): Server_, Client_, NetMulticast 함수들이 네트워크를 통해 패킷으로 변환되고 원격에서 실행될 때.
    • 콘솔 커맨드: ~ 키를 눌러 콘솔 창에서 함수 이름을 타이핑해 실행할 때.

    ✅ 전혀 문제없는 경우 (권장): UI 버튼 클릭, 퀘스트 완료, 문 열기, 캐릭터 스폰 등 일회성이나 간헐적으로 발생하는 이벤트에서는 이 오버헤드가 사실상 체감되지 않아. 아주 훌륭하고 편리하게 사용할 수 있어.

    ⚠️ 주의해야 할 경우 (프레임 드랍 원인): Tick 함수 내부나, 매 프레임 수백 개의 파티클 및 투사체가 충돌 검사를 할 때 ProcessEvent를 남발하면 이야기가 달라져. 매 프레임(보통 1초에 60번 이상)마다 수천 번씩 문자열을 검색하고 데이터를 포장하면 심각한 성능 저하가 올 수 있어.



다이내믹 델리게이트가 아닌 다른 델리게이트들은 진짜 ProcessEvent를 호출 안 할까?

DelegateSignatureImpl.inl을 보면

Create, Bind로 묶을 때 생성되는 DelegateInstance는 IBaseDelegateInstance 인터페이스를 구현한다. 여기에 Execute()가 있다.

  • CreateUObject, BindUObject로 묶었다면: TBaseUObjectMethodDelegateInstance
  • CreateLambda, BindLambda로 묶었다면: TBaseFunctorDelegateInstance
  • CreateRaw, BindLambda로 묶었다면: TBaseRawMethodDelegateInstance
return this->Payload.ApplyAfter(MethodPtr, MutableUserObject, Forward<ParamTypes>(Params)...);

return this->Payload.ApplyAfter(Functor, Forward<ParamTypes>(Params)...);

return this->Payload.ApplyAfter(MethodPtr, MutableUserObject, Forward<ParamTypes>(Params)...);

근데 CreateUFunction, BindUFunctionTBaseUFunctionDelegateInstance 인데, ProcessEvent를 실행한다.

TPlacementNewer<FParmsWithPayload> PayloadAndParams;
this->Payload.ApplyAfter(PayloadAndParams, Forward<ParamTypes>(Params)...);
UserObjectPtr->ProcessEvent(CachedFunction, &PayloadAndParams);
return PayloadAndParams->GetResult();
접기/펼치기 (자세히)
  • (자세히)

    TBaseUFunctionDelegateInstance일반 C++ 델리게이트에 블루프린트 함수(UFunction)를 억지로 엮었을 때(CreateUFunction) 생성되는 특수한 어댑터(Adapter) 객체야.

    이 코드가 어떻게 파라미터들을 포장해서 블루프린트 세계로 던져주는지 4단계로 쪼개서 설명해 줄게.

    ### 1. 매개변수 포장 상자 만들기 (Memory Allocation)

    C++

      TPlacementNewer<FParmsWithPayload> PayloadAndParams;
    

    블루프린트를 실행하는 ProcessEvent는 각각의 매개변수를 따로따로 받을 수 없어. 무조건 모든 변수가 한 덩어리로 뭉쳐진 ‘거대한 메모리 블록(포장 상자)’ 하나만 받도록 설계되어 있어. 그래서 들어온 파라미터와 델리게이트에 묶인 페이로드(Payload)를 모두 담을 수 있는 빈 상자(PayloadAndParams)를 메모리에 미리 준비하는 과정이야.

    ### 2. ApplyAfter로 데이터 쓸어 담기 (Data Packing)

    C++

      this->Payload.ApplyAfter(PayloadAndParams, Forward<ParamTypes>(Params)...);
    

    앞서 우리가 분석했던 ApplyAfter의 마법이 여기서 다시 등장해! 하지만 이번엔 진짜 함수를 펑 터뜨리는(실행하는) 게 아니야. 방금 만든 빈 상자(PayloadAndParams) 안에다가, 새로 들어온 Params와 델리게이트가 쥐고 있던 Payload 변수들을 단 1바이트의 손실 없이 차곡차곡 예쁘게 구겨 넣는(Packing) 작업을 수행해.

    ### 3. 무거운 통신망 가동 (ProcessEvent)

    C++

      UserObjectPtr->ProcessEvent(CachedFunction, &PayloadAndParams);
    

    상자 포장이 끝났으니, 드디어 엔진의 무거운 통신망을 가동할 차례야. UserObjectPtr(타겟 객체)에게 CachedFunction(미리 찾아둔 UFunction 이름표)과 함께 방금 모든 데이터를 꾹꾹 눌러 담은 &PayloadAndParams 상자의 메모리 주소를 넘겨줘. 그러면 가상 머신이 이 상자를 열어서 데이터를 읽고 실제 블루프린트 노드를 실행해.

    ### 4. 결과물 꺼내기 (Unpacking)

    C++

      return PayloadAndParams->GetResult();
    

    블루프린트 실행이 끝나면, 엔진은 반환값(Return Value)을 다시 저 상자(PayloadAndParams) 안에 몰래 넣어두고 돌아와. 그래서 최종적으로 상자에서 결과값만 쏙 빼서 C++ 코드로 돌려주는(Unpacking) 완벽한 마무리를 하는 거지.

    ### 💡 요약하자면

    일반 델리게이트는 원래 ProcessEvent를 안 쓰지만, 개발자가 굳이 CreateUFunction을 써서 블루프린트 함수를 묶어버리면, 엔진은 “어쩔 수 없네, C++ 변수들을 전부 튜플로 묶고 메모리로 포장해서 ProcessEvent로 던져주자!”라고 판단하여 이 복잡한 코드를 실행하게 되는 거야.


Payload는 TTuple 타입이고, 델리게이트에 넣은 페이로드 데이터들이다.

template <typename FuncType, typename... ArgTypes> decltype(auto) ApplyAfter(FuncType&& Func, ArgTypes&&... Args)               &  { return ::Invoke(Func, Forward<ArgTypes>(Args)..., static_cast<               TTupleBase& >(*this).template Get<Indices>()...); }
dataasset

::Invoke(...): 언리얼 엔진이 자체 구현한 범용 함수 실행기야 (C++17의 std::invoke와 같음). C++ 멤버 함수 포인터, 일반 함수, 람다 등 어떤 형태의 Func이 들어와도 내부적으로 가장 알맞은 호출 방식을 찾아 실행해 줘.

template Get<Indices>()...: 이 코드의 진짜 꽃이야! 델리게이트에 묶어둔 페이로드 변수들은 TTupleBase라는 튜플(데이터 가방) 안에 들어있어. 컴파일러가 템플릿 메타 프로그래밍으로 만들어낸 인덱스 배열(Indices, 예: 0, 1, 2)을 팩 확장(Pack Expansion, ...) 문법과 결합하여, 가방 안의 변수들을 하나씩 순서대로 쫙 펼쳐서(Unpacking) 타겟 함수에 꽂아 넣어.

접기/펼치기 (템플릿 메타 프로그래밍 부분 자세히)
  • (템플릿 메타 프로그래밍 부분 자세히)

    ### 1. 템플릿 메타 프로그래밍의 목표

    우리가 튜플 안에 (10, 3.14, "Hello")를 담아두었다면, 타겟 함수에게 이걸 전달할 때 Func(Get<0>(), Get<1>(), Get<2>()) 형태로 코드를 전개해야만 해. 하지만 매개변수의 개수(N)는 델리게이트마다 다르기 때문에 0, 1, 2…라는 숫자를 우리가 미리 하드코딩할 수가 없어.

    ### 2. 마법의 배열 만들기: index_sequence

    이 문제를 해결하기 위해 모던 C++는 std::index_sequence(언리얼 내부에서는 TIntegerSequence)라는 기능을 사용해. 튜플의 원소가 3개라면 컴파일러에게 make_index_sequence<3>이라고 명령을 내려.

    • 재귀적 템플릿 (과거 방식): 초창기에는 템플릿이 자기 자신을 계속 호출(Recursion)하면서 N에서 1씩 빼가며 <0, 1, 2>라는 타입 리스트를 블록 조립하듯 만들어냈어.
    • 컴파일러 내장 기능 (현대 방식): 요즘 컴파일러들은 이 작업이 너무 자주 일어나니까 아예 엔진 내부(Intrinsic) 마법으로 처리해서, 컴파일 타임에 눈 깜짝할 새 <0, 1, 2>라는 숫자의 나열(Sequence)을 템플릿 타입으로 뱉어내.

    ### 3. 팩 확장(Pack Expansion)으로 폭발시키기

    이제 방금 만든 <0, 1, 2> 리스트가 Indices라는 이름의 템플릿 팩(Pack)으로 넘어오게 돼.

    • 코드에 Get<Indices>(Tuple)...라고 적고 끝에 ... (팩 확장 연산자)를 붙여.
    • ... 기호를 본 컴파일러는 “아! Indices 안에 있는 0, 1, 2를 하나씩 꺼내면서 쉼표(,)를 기준으로 코드를 복사 복붙하라는 뜻이구나!”라고 이해해.
    • 최종 변환: 결국 컴파일러가 소스 코드를 Get<0>(Tuple), Get<1>(Tuple), Get<2>(Tuple) 형태로 가로로 쫙 펼쳐서(Unpacking) 완성해 줘.

    결과적으로 개발자는 Get<Indices>()...라는 단 한 줄만 적었지만, 컴파일러가 매개변수 개수에 맞춰 완벽한 C++ 호출 코드를 대신 쳐주는 셈이야.



Leave a comment