게임 개발/언리얼 C++

언리얼C++ - 컴포지션(Composition)

싹난 감자 2024. 11. 15. 18:37

컴포지션(Composition)

  • 객체 지향 설계에서 상속이 가진 Is-A 관계에만 의존해서는 설계와 유지보수가 어려움
  • 컴포지션은 객체 지향 설계에서 Has-A 관계를 구현하는 설계 방법
  • 컴포지션은 복합적인 기능을 거대한 클래스를 효과적으로 설계하는데 유용하게 사용할 수 있음
class Card
{
public:
	Card(int InId) : Id(InId) {}
	int Id = 0;
};

class Person
{
public:
	Person(Card InCard) : InCard(InCard) {}

protected:
	Card IdCard;
}

 

모던 객체 설계 기법의 설계 핵심은 상속을 단순화하고, 단순한 기능을 가진 다수의 객체를 조합해 복잡한 객체를 구성하는데 있음.(SOLID)


언리얼 엔진에서의 컴포지션 구현

하나의 언리얼 오브젝트에는 항상 클래스 기본 오브젝트 CDO가 있다.

언리얼 오브젝트 간의 컴포지션은 어떻게 구현할 것인가?

언리얼 오브젝트에 다른 언리얼 오브젝트를 조합할 때 다음의 선택지가 존재함

    1. CDO에 미리 언리얼 오브젝트를 생성해 조합한다. (필수적 포함)
    CreateDefaultSubobject()는 생성자에서
    1. CDO에 빈 포인터만 넣고 런타임에서 언리얼 오브젝트를 생성해 조합한다. (선택적 포함)
  • NewObject()는 실제 게임을 제작할 때 동작하는 런타임 코드에서

두 방식의 코드 위치나 API도 다르다.

언리얼 오브젝트를 생성할 때 컴포지션 정보를 구축할 수 있다.

  • 내가 소유한 언리얼 오브젝트를 Subobject라고 한다.
  • 나를 소유한 오브젝트를 Outer라고 한다.

컴포지션 관계에서는 Subobject의 생명 주기가 Outer에 의해 관리된다.

Subobject는 독립적으로 존재하는 것이 아니라 Outer에 종속되어 있음

Subobject가 Outer의 일부로 포함되어 있는 것. (Has-A)

Outer가 사라질 때 Subobject도 함께 소멸됨.


컴포지션 설계 예시

  • 학교 구성원 시스템의 설계 예시
    • 학교 구성원을 위해 출입증을 만들기로 한다.
    • 출입증은 Person에서 구현해 상속시킬 것인가? 아니면 컴포지션으로 분리할 것인가
  • Person에서 직접 구현해 상속시키는 경우의 문제
    • 새로운 형태의 구성원이 등장한다면(출입증이 없는 외부 연수생) Person을 수정할 것인가?
    • 상위 클래스 Person을 수정하면, 하위 클래스들의 동작은 문제 없음을 보장할 수 있는가?
  • 따라서 설계적으로 출입증은 컴포지션으로 분리하는 것이 바람직
  • 그렇다면 컴포지션으로만 포함시키면 모든 것이 해결될 수 있는가?
    • 효과적인 설계를 위해 프로그래밍 언어가 제공하는 고급 기법 활용해햐 함

실습 예제

열거형

enum class ECardType : uint8
{
	Student = 1,
	Teacher,
	Staff,
	Invalid //기본값
};
  • 열거형 앞에는 E붙여서 구분
  • 열거형 기본타입은 8bit, 바이트 형태를 해주는 것이 일반적
  • 언리얼C++보다는 일반 C++방식
UENUM() //언리얼이 관리하게 함
enum class ECardType : uint8
{
	//언리얼 C++은 UMETA 매크로로 메타 정보를 넣어놓고 코드에서 사용할 수 있음
	Student = 1 UMETA(DisplayName = "For Student"),
	Teacher UMETA(DisplayName = "For Teacher"),
	Staff UMETA(DisplayName = "For Staff"),
	Invalid
};
  • 언리얼 C++ 방식 열거형
  • UENUM 매크로를 사용해 언리얼이 관리하게 함
  • UMETA 매크로를 사용해 메타 정보를 넣어서 사용할 수 있음

컴포지션 선언

UCard* Card;
  • 이런 식으로 선언할 때는 Card에 대한 헤더를 선언해 포함시켜주어야함
UPROPERTY()
class UCard* Card;
  • 컴포지션 관계에 있을 때는 전방 선언, 헤더를 포함하지 않아도 됨
  • 보통 오브젝트는 포인터로 관리하기 때문에 정확한 위치는 알 수 없어도 포인터 크기를 가지기 때문에 전방 선언을 통해 의존성을 최대한 줄일 수 있음

위의 방식은 언리얼4까지 정석이었으나 언리얼5부터는 방식이 바뀌었음

TObjectPtr

UPROPERTY()
TObjectPtr<class UCard> Card;
  • 언리얼5부터 포인터를 빼고TObjectPtr이라는 템플릿 클래스로 감싸서 선언하는 것을 권장
  • 선언이 아닌 구현부에서는 포인터를 사용해도 무방
  • 전방 선언은 기존대로 진행
void UMyGameInstance::Init()
{
	Super::Init(); //원래 로직 실행

	UE_LOG(LogTemp, Log, TEXT("======================================"));
	//UPerson 부모 객체 포인터의 배열
	TArray<UPerson*> Persons = { NewObject<UStudent>(), NewObject<UTeacher>(), NewObject<UStaff>() };

	for (const auto Person : Persons)
	{

		const UCard* OwnCard = Person->GetCard();
		//if (OwnCard) {} 포함 관계이기 때문에 당연히 있음, 코드가 복잡해짐
		check(OwnCard); //if문 대신 check를 써줘도 됨

		ECardType CardType = OwnCard->GetCardType();
		UE_LOG(LogTemp, Log, TEXT("%s님이 소유한 카드 종류 %d"), *Person->GetName(), CardType);
	
	}

각 클래스가 가지고 있는 CardType을 출력

	for (const auto Person : Persons)
	{

		const UCard* OwnCard = Person->GetCard();
		//if (OwnCard) {} 포함 관계이기 때문에 당연히 있음, 코드가 복잡해짐
		check(OwnCard); //if문 대신 check를 써줘도 됨

		ECardType CardType = OwnCard->GetCardType();
		
		//CardType이 가지고 있는 메타 데이터를 가져오기		
		const UEnum* CardEnumType = FindObject<UEnum>(nullptr, TEXT("/Script/UnrealComposition.ECardType")); 
		//두번째 인자 TEXT, TEXT에 들어가는 절대 주소값을 사용해 원하는 타입 정보를 가져올 수 있음
		// /Script(절대 주소값)/모듈이름.타입이름
		//보통 C++에 생성된 언리얼 객체들은 Script라고 하는 절대 주소를 가짐
		//UnrealComposition(프로젝트 이름)이 모듈 이름이 됨

		if (CardEnumType)
		{
			//타입 정보가 있다면 그것을 활용해서 GetDisplayNameTextByValue으로 메타데이터 추출
			//GetDisplayNameTextByValue은 int64만 받음
			FString CardMetaData = CardEnumType->GetDisplayNameTextByValue((int64)CardType).ToString();
			//int64로 형변환해서 넣어줌, FText로 반환됨
			//다국어 지원 문자열이기 때문에 출력할 때 String으로 변환
			UE_LOG(LogTemp, Log, TEXT("%s님이 소유한 카드 종류 %s"), *Person->GetName(), *CardMetaData);
		}
	}

각 클래스가 가지고 있는 CardType의 메타 데이터를 출력

// 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"

/*
//열거형 앞에는 E붙여서 구분
//열거형 기본타입은 8bit, 바이트 형태를 해주는 것이 일반적
//언리얼C++보다는 일반 C++방식
enum class ECardType : uint8
{
	Student = 1,
	Teacher,
	Staff,
	Invalid //기본값
};
*/

//언리얼 C++ 방식
UENUM() //언리얼이 관리하게 함
enum class ECardType : uint8
{
	//언리얼 C++은 UMETA 매크로로 메타 정보를 넣어놓고 코드에서 사용할 수 있음
	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();

	//getter,setter
	ECardType GetCardType() const { return CardType; }
	void SetCardType(ECardType InCardType) { CardType = InCardType; }

private:

	UPROPERTY()
	ECardType CardType;

	UPROPERTY()
	uint32 Id;

};
// Fill out your copyright notice in the Description page of Project Settings.

#include "Card.h"

UCard::UCard()
{
    CardType = ECardType::Invalid;
    Id = 0;
}
// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"
#include "UObject/NoExportTypes.h"
#include "Person.generated.h"

/**
 * 
 */
UCLASS()
class UNREALCOMPOSITION_API UPerson : public UObject
{
	GENERATED_BODY()
public:
	UPerson();

	//FORCEINLINE: 가능한 인라인 함수가 되도록 함. 100%는 아님
	FORCEINLINE const FString& GetName() const { return Name; }
	//getter를 만들 때는 const를 붙여주는 것이 좋음
	//레퍼런스를 반환할 때는 받은 쪽에서는 값을 변경할 수 있기 때문에
	//const로 지시할 때 반환값에도 const를 붙여주어야 올바르게 동작함
	FORCEINLINE void SetName(const FString InName) { Name = InName; }

	FORCEINLINE class UCard* GetCard() const { return Card; }
	FORCEINLINE void SetCard(class UCard* InCard) { Card = InCard; }

protected:
	UPROPERTY()
	FString Name;

	/*
	//UCard* Card; 이런 식으로 선언할 때는 Card에 대한 헤더를 선언해 포함시켜주어야함
	//컴포지션 관계에 있을 때는 전방 선언, 헤더를 포함하지 않아도 됨
	//보틍 오브젝트는 포인터로 관리하기 때문에 정확한 위치는 알 수 없지만
	//포인터 크기를 가지기 때문에 전방 선언을 통해 의존성을 최대한 줄일 수 있음
	UPROPERTY()
	class UCard* Card;
	//위의 방식은 언리얼4까지 정석이었으나 언리얼5부터는 방식이 바뀜
	*/

	//언리얼5부터는 포인터를 빼고 TObjectPrt이라는 템플릿 클래스로 감싸서 선언
	//전방 선언은 기존대로
	UPROPERTY()
	TObjectPtr<class UCard> Card;
	
};

// Fill out your copyright notice in the Description page of Project Settings.

#include "Person.h"
#include "Card.h"

UPerson::UPerson()
{
    Name = TEXT("홍길동");
    //CDO에서 구현할 때는 CreateDefaultSubobject라는 API를 사용
    //첫번째 인자는 반드시 FName 식별자, 고유한 이름이면 됨
    //일반적인 String이 아니기 때문에 FName인 것을 명시적으로 알리고 싶다면
    //보통 앞에 NAME 접두사를 넣어줌
    Card = CreateDefaultSubobject<UCard>(TEXT("NAME_Card"));
}

// Fill out your copyright notice in the Description page of Project Settings.

#include "Student.h"
#include "Card.h"

UStudent::UStudent()
{
    Name = TEXT("이학생");
    Card->SetCardType(ECardType::Student);
}

void UStudent::DoLesson()
{
    //상위 클래스가 ILessonInterface가 아닌 Person으로 되어 있기 때문에 Super를 사용할 수 없음
    //단일 상속만 지원
    ILessonInterface::DoLesson(); 
    UE_LOG(LogTemp, Log, TEXT("%s님은 공부합니다."), *Name);
}

// Fill out your copyright notice in the Description page of Project Settings.

#include "Teacher.h"
#include "Card.h"

//부모클래스의 생성자가 호출된 이후에 호출되기 때문에 CreateDefaultSubobject를 할 필요는 없다
//하위 클래스에서 다시하면 중복됨
UTeacher::UTeacher()
{
    Name = TEXT("이선생");
    Card->SetCardType(ECardType::Teacher);
}

void UTeacher::DoLesson()
{
    ILessonInterface::DoLesson();
    UE_LOG(LogTemp, Log, TEXT("%s님은 가르칩니다."), *Name);
}

// 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);
}
// Fill out your copyright notice in the Description page of Project Settings.

#include "MyGameInstance.h"
#include "Person.h"
#include "Student.h"
#include "Teacher.h"
#include "Staff.h"
#include "Card.h"

UMyGameInstance::UMyGameInstance() //생성자
{
	SchoolName = TEXT("기본학교"); //CDO 기본값 설정
}

void UMyGameInstance::Init()
{
	Super::Init(); //원래 로직 실행

	UE_LOG(LogTemp, Log, TEXT("======================================"));
	//UPerson 부모 객체 포인터의 배열
	TArray<UPerson*> Persons = { NewObject<UStudent>(), NewObject<UTeacher>(), NewObject<UStaff>() };
	for (const auto Person : Persons)
	{
		const UCard* OwnCard = Person->GetCard();
		//if (OwnCard) {} 포함 관계이기 때문에 당연히 있음, 코드가 복잡해짐
		check(OwnCard); //if문 대신 check를 써줘도 됨
		ECardType CardType = OwnCard->GetCardType();
		UE_LOG(LogTemp, Log, TEXT("%s님이 소유한 카드 종류 %d"), *Person->GetName(), CardType);

		
	}

	UE_LOG(LogTemp, Log, TEXT("======================================"));
}

컴포지션을 활용한 언리얼 오브젝트 설계

  • 언리얼 C++은 컴포지션을 구현하는 독특한 패턴이 있다.
  • 클래스 기본 객체를 생성하는 생성자 코드를 사용해 복잡한 언리얼 오브젝트를 생성할 수 있음
  • 언리얼 C++ 컴포지션의 Has-A 관계에 사용되는 용어
    • 내가 소유한 하위 오브젝트 : Subobject
    • 나를 소유한 상위 오브젝트 : Outer
  • 언리얼 C++이 제공하는 확장 열거형을 사용해 다양한 메타 정보를 넣고 활용할 수 있다.

언리얼 C++ 컴포지션 기법은 게임의 복잡한 객체를 설계하고 생성할 때 유용하게 사용된다.

 


이득우의 언리얼 프로그래밍 Part1 | 인프런

https://www.inflearn.com/course/%EC%9D%B4%EB%93%9D%EC%9A%B0-%EC%96%B8%EB%A6%AC%EC%96%BC-%ED%94%84%EB%A1%9C%EA%B7%B8%EB%9E%98%EB%B0%8D-part-1/dashboard

 

이득우의 언리얼 프로그래밍 Part1 - 언리얼 C++의 이해 강의 | 이득우 - 인프런

이득우 | 대기업 현업자들이 수강하는 언리얼 C++ 프로그래밍 전문 과정입니다. 언리얼 엔진 프로그래머라면 게임 개발전에 반드시 알아야 하는 언리얼 C++ 기초에 대해 알려드립니다., [사진] 언

www.inflearn.com