언리얼 C++ 인터페이스
인터페이스란
객체가 반드시 구현해야할 행동을 지정하는데 활용하는 타입을 의미한다. 다형성의 구현과 의존성이 분리된 설계에 매우 유용하게 활용된다.
언리얼에서는 월드에 배치되는 모든 오브젝트중 안 움직이는 오브젝트를 포함 할 때 액터라는 클래스를 상속받는다.
만약 움직이는 오브젝트라면 Pawn을 상속받게 되는데 이때, Pawn은 반드시 움직임을 구현해야하기 때문에 움직임 관련 인터페이스를 상속받게 해 반드시 구현하게 제작해야한다.
이전 코드에서 작성한 것을 수정해서 교직원이 추가되었는데 교직원을 제외한 나머지는 반드시 구현해야하는 기능을 가지고 있다고 해보자.
언리얼 C++ 인터페이스 특징
언리얼에서 인터페이스를 생성하면 2개의 클래스가 한 파일에 생성된다.
- U로 시작하는 타입 클래스 (타입을 제공)
- I로 시작하는 인터페이스 클래스 (실질적 설계와 구현)
우리가 실제적으로 관리할 인터페이스는 I로 시작하는 클래스이다.
C#이나 JAVA와 다르게 추상 타입만으로 선언할 필요 없이 인터페이스 내부에서도 구현이 가능하다.
인터페이스 구현하기
GameInstance에서는 기본적으로 다음과 같이 작성한다.
#include "MyGameInstance.h"
#include "Student.h"
#include "Teacher.h"
#include "Staff.h"
void UMyGameInstance::Init() {
Super::Init();
TArray<UPerson*> Persons = { NewObject<UStudent>(), NewObject<UTeacher>(), NewObject<UStaff>() };
UE_LOG(LogTemp, Log, TEXT("============================="));
for (const auto Person : Persons)
{
UE_LOG(LogTemp, Log, TEXT("구성원 이름 : %s"), *Person->GetName());
}
UE_LOG(LogTemp, Log, TEXT("============================="));
}
UMyGameInstance::UMyGameInstance() {
SchoolName = TEXT("기본학교");
}
여기에 나오는 TArray는 추후 자료구조를 다룰때 한번 더 포스팅 하겠다.
자 이제 인터페이스를 만들어보자.
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "LessonInterface.generated.h"
// This class does not need to be modified.
// 인터페이스에 관련한 정보를 기록하기 위해서 다음과 같은 매크로가 지정이 되어있다.
UINTERFACE(MinimalAPI)
//타입 정보를 관리하기 위해서 만들어 놓은 클래스. (딱히 뭘 하지는 않을 거라네요)
class ULessonInterface : public UInterface
{
GENERATED_BODY()
};
/**
*
*/
//실제적으로 구현을 해야하는 공간이 바로 여기다.
//기능과 함수를 구현할 수 있다.
class UNREALINTERFACE_API ILessonInterface
{
GENERATED_BODY()
// Add interface functions to this class. This is the class that will be inherited to implement this interface.
public:
};
처음 생성하면 위와같은 파일이 만들어진다.
virtual void DoLesson() = 0;
이제 내부에 이렇게 함수를 = 0을 통해 abstract하게 만들어주면 이 클래스를 상속 받은 클래스는 DoLesson을 반드시 구현해야만한다.
따라서 이걸 상속받는 student를 보면 이렇게 작성할 수 있다.
#pragma once
#include "CoreMinimal.h"
#include "Person.h"
#include "LessonInterface.h"
#include "Student.generated.h"
/**
*
*/
UCLASS()
class UNREALINTERFACE_API UStudent: public UPerson, public ILessonInterface
{
GENERATED_BODY()
public :
UStudent();
virtual void DoLesson() override;
};
void UStudent::DoLesson()
{
// Super 키워드는 다중상속 일 때 첫번째 상속 클래스만 사용이 가능하다는 점에 유의하자.
ILessonInterface::DoLesson();
UE_LOG(LogTemp, Log, TEXT("%s님은 공부합니다"));
}
GameInstance에서 인터페이스를 상속받았는지 확인하기 위해 캐스팅 형변환을 하고 올바르게 변환되었다면 인터페이스를 가지고 있다는 것을 의미하기에 DoLesson을 호출해준다.
ILessonInterface* LessonInterface = Cast<ILessonInterface>(Person);
if(LessonInterface) {
UE_LOG(LogTemp, Log, TEXT("%s님은 수업에 참여할 수 있습니다."), *Person->GetName());
LessonInterface->DoLesson();
}
else {
UE_LOG(LogTemp, Log, TEXT("%s님은 수업에 참여할 수 없습니다."), *Person->GetName());
}
이는 언리얼에서 안전한 Cast함수를 제공하기 때문에 가능한 것이다.
언리얼 C++ 컴포지션
여기서의 주 목적은 언리얼 컴포지션 기법을 사용해 복잡한 언리얼 오브젝트를 효과적으로 생성하고 포함관계를 설계하는 방법을 아는 것이 중요하다.
객체 지향 설계에서는 상속과 컴포지션이 있는데,
- 상속 : 부모와 자식으로 이루어져 있어서, 특성을 가지고 있다. 종속의 개념이기 때문에, Is-A 관계이다.
- 컴포지션 : 객체 지향 설계에서 어느 한 객체가 다른 객체를 소유하고 있는 Has-A 관계를 의미한다.
SOLID 원칙에 따르면 상속을 단순화 하고, 단순한 기능을 가진 객체를 조합해서 복잡한 객체를 구성하는 것이 원칙이자 목적이다.
이러한 원칙들이 추구하는 내용을 적용하기 위해선 컴포지션을 적극 활용하게 될 것이다.
이번에는 위의 학교에서 구성원들이 자유로운 출입을 하기 위해 출입증을 만들 것인데, 만약 Person 클래스에 출입증과 관련된 내용이 있다고 해보자.만약, 출입증이 아닌 1회용 입장권을 이용하는 외부 관계자가 학교에 왔다. 이때 우리는 Person 클래스를 상속받아 분명 외부 관계자를 만들텐데 이때 출입증에 따로 예외 처리를 해야할 것이다. 그리고 그렇게 하면서 다른 나머지 구성원도 영향을 받을 수 있기 때문에 이러한 경우는 올바르게 설계한 것이 아니다 라고 볼 수 있다.
언리얼 엔진에서의 컴포지션 구현 방법
언리얼 오브젝트 간의 컴포지션은 두가지 방식으로 구현할 수 있다. C#이나 JAVA와 같은경우는 그냥 그 클래스를 가지고 있으면 되겠지만 언리얼 오브젝트는 그렇지 않다.
- 방법 1 : CDO에 미리 언리얼 오브젝트를 생성해 조합한다 ( 필수적인 포함 )
- 방법 2 : CDO에 빈 포인터만 넣고 런타임에서 언리얼 오브젝트를 생성해 조합한다 ( 선택적 포함 )
헤더파일에서 가지고자 하는 서브 오브젝트를 선언하고 CDO를 생성할 때 이를 같이 만들어 주는 방식이 첫번째 방식이다.
이는 CDO가 생성될때 같이 생성되기 때문에 필수적으로 생성된다. 예를 들어 플레이어가 있고 움직이는 입력을 받는 클래스가 서브 오브젝트 인 경우가 그렇다.
두번째 방법은 일단 서브 오브젝트를 빈 포인터로 생성한 후에 동적으로 생성해서 넣어주는 방식이다. 이는 필수적으로 필요가 없는 부분에서 사용이 가능하다.
출입증 Card라는 클래스로 만들고 이를 Person과 각자 구성원에 적용해보자.
// Fill out your copyright notice in the Description page of Project Settings.
#pragma once
#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "Card.generated.h"
/**
*
*/
//만약 UENUM이 없다면 일반적인 C++과 다를 바가 없다.
//이 객체에 대한 정보를 언리얼이 파악해서 유용한 정보를 가져오고,
//필드마다 메타 정보를 집어 넣을 수 있다 (그냥 한 마디로, 추가적 정보를 넣을 수 있게 된다는 것., 매크로로..)
UENUM()
enum class ECardType : uint8 {
Student = 1 UMETA(DisplayName = "For Student"),
Teacher UMETA(DisplayName = "For Teacher"),
Staff UMETA(DisplayName = "For Staff"),
Invalid
};
UCLASS()
class UNREALCOMPOSITION_API UCard : public UObject
{
GENERATED_BODY()
public :
UCard();
ECardType GetCardType() const { return CardType; }
void SetCardType(ECardType InCardType) { CardType = InCardType; }
private :
UPROPERTY()
ECardType CardType;
UPROPERTY()
uint32 Id;
};
Person 클래스에서 이제 카드를 선언해 주어야하는데
이 방식은 UE4까지는 정석적인 방법이었지만 UE5로 넘어오면서 추천하지 않는 방식이 되었다.
UPROPERTY()
class UCard* Card;
이게 이제 표준적인 방식이다.
UPROPERTY()
TObjectPtr<class UCard> Card;
컴포지션 생성
#include "Person.h"
#include "Card.h"
UPerson::UPerson()
{
Name = TEXT("홍길동");
Card = CreateDefaultSubobject<UCard>(TEXT("NAME_Card"));
}
CreateDefaultSubobject<>()를 통해 오브젝트를 생성할 수 있다. 이는 CDO에서만 가능한 방식이다. 안에 인자 TEXT 같은 경우 중복을 방지하기 위해 사용된다는 점만 알면 된다.
어차피 생성자는 부모 생성자가 먼저 실행된 다음 자식 생성자가 실행되기 때문에 자식에서 Create할 필요는 없다.
// Fill out your copyright notice in the Description page of Project Settings.
#include "Staff.h"
#include "Card.h"
UStaff::UStaff()
{
Name = TEXT("김직원");
Card->SetCardType(ECardType::Staff);
}
요런 방식으로 구현을 진행하면 된다.
그리고 아까 enum class에서 메타 데이터를 같이 넣어 줬는데 이렇게 꺼내서 사용이 가능하다.
// Fill out your copyright notice in the Description page of Project Settings.
#include "MyGameInstance.h"
#include "Student.h"
#include "Teacher.h"
#include "Staff.h"
#include "Card.h"
void UMyGameInstance::Init() {
Super::Init();
TArray<UPerson*> Persons = { NewObject<UStudent>(), NewObject<UTeacher>(), NewObject<UStaff>() };
UE_LOG(LogTemp, Log, TEXT("============================="));
for (const auto Person : Persons)
{
const UCard* OwnCard = Person->GetCard();
check(OwnCard);
ECardType CardType = OwnCard->GetCardType();
UE_LOG(LogTemp, Log, TEXT("%s님이 소유한 카드의 종류는 %d"), *Person->GetName(), CardType);
// 여기서 /Script/ ... 는 모듈의 이름이 들어간다.
const UEnum* CardEnumType = FindObject<UEnum>(nullptr, TEXT("/Script/UnrealComposition.ECardType"));
if (CardEnumType)
{
FString CardMetaData = CardEnumType->GetDisplayNameTextByValue((int64)CardType).ToString();
UE_LOG(LogTemp, Log, TEXT("%s님이 소유한 카드의 종류는 %s"), *Person->GetName(), *CardMetaData);
}
}
UE_LOG(LogTemp, Log, TEXT("============================="));
}
언리얼 C++ 델리게이트
강한 결합과 느슨한 결합을 알아야한다.
강한 결합
- 클래스들이 서로 의존성을 가지는 경우를 의미한다.
- 앞선 예제에서 Person이 Card를 멤버로 가지게 되는데, Person은 카드에 대한 의존성을 가지는데, 핸드폰에서도 인증할 수 있는 새로운 카드가 도입된다면?
- 이렇게 되는 경우에는 일일히 수정을 거쳐줘야 함.
느슨한 결합
- 실물에 의존하지 않고 추상적 설계에 의존하는 방법 ⇒ DIP 원칙
- 카드를 통해서 뭐를 할까? ⇒ 출입을 해야 하기 때문.
- 그래서 출입에 관한 설계를 하고 카드가 이를 구현하게 하는 것이 좋음.
- 그렇다면 의존성이 떨어지게 될 것임.
- 인터페이스를 선언해서 해결하는 방법이 있음.
일반적으로 어떤 행동 양상 하나가 생길때마다, 인터페이스를 선언한다는 것은 귀찮을 뿐만 아니라 효율적이지 않다.
느슨한 결합의 간단한 구현은 델리게이트를 통해 할 수 있다.
함수를 하나의 오브젝트처럼 사용하는 것이다.
C#에서는 delegate라는 키워드와 Action이라는 키워드로 많이 사용한다.
당연히 C++에는 이러한 기능은 존재하지 않고 언리얼 자체적으로 이러한 기능을 개발해서 사용한다.
단, 여기서 주의해야할점은 언리얼에서 델리게이트를 사용할 때는 항상 참조 형태로 전달해야한다고 한다.
이것을 연습해보기 위해 다음과 같은 시나리오를 생각해보자.
학사 정보와 3명의 학생이 있고
시스템에서 학사 정보를 변경했을 때
학사 정보가 변경된 점을 알아채 알림 구독을 한 학생들에게 변경된 내용을 자동으로 전달한다.
언리얼 델리게이트 선언법
언리얼에서 델리게이트의 선언 방법은 매우 특이하고 어렵다.
델리게이트의 고려사항.
- 어떤 데이터를 전달하고 받을래? 인자의 수와 각각의 타입을 설계해야 함
- 몇개를 전달할래? 어떻게 받을래? 1대1? 1대다?
- 프로그래밍 환경설정
- C++ 프로그래밍에서만 사용할 것인가?
- UFUNCTION으로 지정된 블루프린트 함수와 사용할 것인가?
- 어떤 함수와 연결?
- 클래스 외부에 설계된 C++ 함수와 연결
- 전역에 설계된 정적 함수와 연결
- 언리얼 오브젝트의 멤버 함수와 연결
이러한 고려사항에 따라 사용되는 매크로가 다르다.
DECLARE_{델리게이트유형}DELEGATE{함수정보}
// 여기서는 델리게이트 유형과 함수정보만 지정을 두면 된다.
의 꼴을 따르는데
델리게이트 유형 : 어떤 유형의 델리게이트인지 구상함.
- 일대일 형태, C++ 만 지원한다면, DECLARE_DELEGATE
- 일대다 형태, C++ 만 지원한다면, DECLARE_MULTICAST_DELEGATE
- 일대일 형태 + 블루프린트 = DECLARE_DYNAMIC
- 일대다 형태 + 블루프린트 = DECLARE_DYNAMIC_MULTICAST
함수 정보 : 연동될 함수 형태 지정.
- 인자도 없고 반환도 없음 (void() 형태) = 공란으로 둔다. = DECLARE_DELEGATE
- 인자가 하나고 반환값이 없다면? (void (int num) ) = OneParam으로 지정 ⇒ DECLARE_DELEGATE_OneParam
- 인자가 세 개고 반환값이 있다면? RetVal_ThreeParams로 지정 → DECLARE_DELEGATE_RetVal_ThreeParams (1대1이고 세개의 인자를 가지며 값을 반환하는 함수로 지정할 것임)
우리는 학사 정보가 변경되면 알림의 주체와 내용을 학생에게 전달할 것이다.
=> 두개의 인자를 가진다.
변경된 학사정보는 다수의 인원에게 발송된다.
=> 일대다 형태이다.
블루프린터가 아닌 오직 C++에서만 사용할 것이다
=> Non Blueprint
위의 조건을 합쳐사 나온 매크로는
DECLARE_MULTICAST_DELEGATE_TwoParams
이다.
델리게이트 구현
DECLARE_MULTICAST_DELEGATE_TwoParams(FCourseInfoOnChangedSignature, const FString&, const FString&);
이렇게 선언할 수 있고 첫번째 인자가 이름이 된다. 일반적으로 델리게이트를 사용할 때, Signautre를 붙여주는 것이 관행이라고 한다.
UCLASS()
class UNREALCOMPOSITION_API UCourseInfo : public UObject
{
GENERATED_BODY()
public :
UCourseInfo();
FCourseInfoOnChangedSignature OnChanged;
void ChangeCourseInfo(const FString& InSchoolName, const FString& InNewContents);
private :
FString Contents;
};
void UCourseInfo::ChangeCourseInfo(const FString& InSchoolName, const FString& InNewContents)
{
Contents = InNewContents;
UE_LOG(LogTemp, Log, TEXT("[CourseInfo] 학사 정보가 변경되어 알림을 발송합니다."));
OnChanged.Broadcast(InSchoolName, Contents);
}
Broadcast를 통해 OnChanged를 구독하고 있는 모든 이에게 전달해 주게된다.
그리고 전달 받았을 때 실행할 함수는 다음과 같다.
void UStudent::GetNotification(const FString& School, const FString& NewCourseInfo)
{
UE_LOG(LogTemp, Log, TEXT("[Student] %s님이 %s로부터 받은 메시지 : %s"), *Name, *School, *NewCourseInfo);
}
GameInstance에서 이제 각 Student를 학사 정보에 구독하고 학사 정보를 수정해보자.
UCLASS()
class UNREALCOMPOSITION_API UMyGameInstance : public UGameInstance
{
GENERATED_BODY()
public :
UMyGameInstance();
virtual void Init() override;
private :
UPROPERTY()
TObjectPtr<class UCourseInfo> CourseInfo;
UPROPERTY()
FString SchoolName;
};
이번에 컴포지션을 생성할 때에는 두번째 방식으로 동적으로 생성해보자.
void UMyGameInstance::Init() {
Super::Init();
CourseInfo = NewObject<UCourseInfo>(this);
UE_LOG(LogTemp, Log, TEXT("============================="));
UStudent* Student1 = NewObject<UStudent>();
Student1->SetName(TEXT("학생1"));
UStudent* Student2 = NewObject<UStudent>();
Student2->SetName(TEXT("학생2"));
UStudent* Student3 = NewObject<UStudent>();
Student2->SetName(TEXT("학생3"));
CourseInfo->OnChanged.AddUObject(Student1, &UStudent::GetNotification);
CourseInfo->OnChanged.AddUObject(Student2, &UStudent::GetNotification);
CourseInfo->OnChanged.AddUObject(Student3, &UStudent::GetNotification);
CourseInfo->ChangeCourseInfo(SchoolName, TEXT("변경된 학사 정보"));
UE_LOG(LogTemp, Log, TEXT("============================="));
}
간단하게 AddUObject를 통해 어떠한 객체가 통보 받았을 때 어떤 함수를 실행할 것인지 등록하면 된다.
C# 같은 경우는 단순하기 +=나 -=로 구독 정보를 추가 취소 할 수 있었지만 조금 더 길어진 모습이다.
CourseInfo->OnChanged.AddUObject(Student1, &UStudent::GetNotification);
'Unreal Engine 5 > 강의' 카테고리의 다른 글
[UE5] 언리얼 컨테이너 라이브러리 - Struct와 TMap, 언리얼 메모리 관리 (0) | 2024.11.25 |
---|---|
[UE5] 언리얼 컨테이너 라이브러리 - TArray, TSet (0) | 2024.11.22 |
[UE5] 언리얼 오브젝트의 이해 - 리플렉션 시스템 (3) | 2024.11.20 |
[UE5] 언리얼 오브젝트의 이해 - 코딩 표준, 기본 타입과 문자열, 언리얼 오브젝트란? (0) | 2024.11.19 |
[UE5] 언리얼 오브젝트의 이해 - 프로젝트 세팅 및 로그 찍기 (0) | 2024.11.18 |