게임 개발/언리얼 C++

언리얼C++ - 언리얼 컨테이너 라이브러리 2 | 구조체와 Map

싹난 감자 2024. 11. 18. 22:02

언리얼 구조체 UStruct

언리얼 구조체(UStructs)는 프로퍼티를 체계화하고 조작할 수 있는 데이터 구조체. 즉, 데이터에 특화

커스텀 변수 타입을 생성할 수 있고 프로젝트를 체계화할 수 있음.

  • 데이터 저장/전송에 특화된 가벼운 객체
  • 대부분 GENERATED_BODY 매크로를 선언해준다.
    • 리플렉션, 직렬화와 같은 유용한 기능을 지원함
    • GENERATED_BODY를 선언한 구조체는 UScriptStruct 클래스로 구현됨.
    • 이 경우 제한적으로 리플렉션 지원.
      • 속성 UPROPERTY만 선언할 수 있고 함수 UFUNCTION은 선언할 수 없음. (언리얼 오브젝트와 큰 차이점)
  • 언리얼 엔진의 구조체 이름은 F로 시작.
    • 대부분 힙 메모리 할당(포인터 연산)없이 스택 내 데이터로 사용됨.
      • NewObject API를 사용할 수 없음.

구조체 구현

  1. 구조체를 정의하려는 헤더 파일 열기
  2. C++ 구조체를 정의하고 앞에 USTRUCT 매크로 추가. 필요하다면 UStruct지정자 포함
  3. 구조체 상단에 GENERATED_BODY 매크로 추가
  4. 필요하다면 구조체의 멤버 변수에 UPROPERTY로 태그
// BlueprintType키워드를 넣으면 블루프린트와 호환되는 데이터 성격을 가짐
USTRUCT(BlueprintType)
struct FMyStruct // 언리얼 오브젝트가 아니기 때문에 F 접두사
{
  //구조체가 가지고 있는 각종 속성들을 리플렉션을 통해 활용할 수 있는 기본 뼈대 제공
	GENERATED_BODY() 
	
	// UPROPERTY를 추가해 리플렉션 사용 가능, 블루프린트와 연동가능
	UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Test Variables") 
	int32 MyIntegerMemberVariable;
	
	// UPROPERTY를 추가하지 않으면 그냥 C++ 멤버 변수로 사용
	int32 NativeOnlyMemberVariable
	
	// 구조체에서 언리얼 오브젝트 포인터를 선언하게 되면
	// 반드시 UPROPERTY를 붙여야 자동으로 메모리 관리됨
	UPROPERTY()
	UObject* SafeObjectPointer;
}

UStructs는 언리얼 엔진의 포인터를 사용해 멤버 변수로 사용할 수 있는데 UPROPERTY를 반드시 지정해주어야 UObject가 제거되는 것을 방지하고 메모리 관리를 할 수 있음.

C++ 문법상 struct 클래스는 다른 것들과 별 차이가 없지만 언리얼 오브젝트와 언리얼 구조체는 용도가 다름.

언리얼 구조체는 단순한 데이터 타입에 적합. 보다 복잡한 인터렉션을 위해서는 UStructs 대신 UObject를 사용하는 것을 권장.

UStructs는 언리얼 엔진이 일반 객체처럼 취급하여 리플리케이션 기능을 사용하지 못하지만, 구조체에 선언된 UPROPERTY변수들은 언리얼 시스템을 사용할 수 있다.

언리얼 엔진에는 구조체를 위한 Make와 Break 함수 자동 생성 기능이 있음.


실습 예제

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

#pragma once

#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "MyGameInstance.generated.h"

// 별도의 헤더 파일을 추가하지 않고 바로 구조체 추가
// 따로 헤더 파일을 추가해 구조체를 만들 수도 있음

USTRUCT()
struct FStudentData 
{
	GENERATED_BODY() //	public으로 적용됨
	
	FStudentData() // 생성자
	{
		Name = TEXT("홍길동");
		Order = -1;
	}

	FStudentData(FString InName, int32 InOrder) : Name(InName), Order(InOrder) {}
	// New API를 사용해 생성될 일이 없기 때문에 언리얼 오브젝트와 달리
	// 인자를 가진 생성자를 만들어 자유롭게 사용하면 됨

	// UPROPERTY를 넣을 때는 
	// 리플렉션을 사용하거나 블루프린트와 호환시키는 등
	// 명확한 이유가 있어야함.
	// 다만 언리얼 오브젝트 포인터를 멤버 변수로 가질때는
	// UPROPERTY를 반드시 넣어줘야함.	
	UPROPERTY()
	FString Name;

	UPROPERTY()
	int32 Order;

};

/**
 * 
 */
UCLASS()
class UNREALCONTAINER_API UMyGameInstance : public UGameInstance
{
	GENERATED_BODY()
	
public:

	virtual void  Init() override;

private:
	// 값타입. 메모리 관리 필요없음. 굳이 UPROPERTY 쓸 필요 X
	TArray<FStudentData> StudentsData; 
	
};

MyGameInstance.h

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

#include "MyGameInstance.h"
#include "Algo/Accumulate.h"
// 언리얼은 여러 알고리즘 라이브러리를 언리얼 엔진 컨테이너에 맞게 제공하고 있음
// 대표적인 합을 구하는 알고리즘을 위한 헤더 Algo/Accumulate.h

FString MakeRandomName()
{
    TCHAR FirstChar[] = TEXT("김이박최");
    TCHAR MiddleChar[] = TEXT("상혜지성");
    TCHAR LastChar[] = TEXT("수은원연");

    TArray<TCHAR> RandArray;
    RandArray.SetNum(3); // 공간 3개 확보
    RandArray[0] = FirstChar[FMath::RandRange(0, 3)];
    RandArray[1] = MiddleChar[FMath::RandRange(0, 3)];
    RandArray[2] = LastChar[FMath::RandRange(0, 3)];

    return RandArray.GetData();
    // TArray는 TCHAR배열을 포함한 컨테이너.
    // 포인터 값을 넘겨주면 반환값을 FString으로 지정했기 때문에
    // 자동으로 FString이 만들어짐.
}

void UMyGameInstance::Init()
{
    Super::Init();

    const int32 StudentNum = 300;
    
    for (int32 ix = 1; ix <= StudentNum; ++ix)
    {
        // 구조체이기 때문에 복사 비용 발생, Add보단 Emplace
        StudentsData.Emplace(FStudentData(MakeRandomName(), ix)); 
    }

    TArray<FString> AllStudentsNames;

    // Algo::Transform 데이터 옮겨담기 함수
    // (inputData, outData, 람다식) 
    // TArray에 옮겨담기
    Algo::Transform(StudentsData, AllStudentsNames,
        [](const FStudentData& Val) // 첫번째 인자 : TArray에서 선언한 데이터 타입의 값
        {
            return Val.Name; // 리턴값 : 옮길 데이터 타입에 대한 FString 값
        }
    );
    // StudentsData의 TArray값을 String TArray로 함수 한번에 옮기기 가능
    UE_LOG(LogTemp, Log, TEXT("모든 학생 이름의 수: %d"), AllStudentsNames.Num());

    // TSet에 옮겨담기
    TSet<FString> AllUniqueNames;
    Algo::Transform(StudentsData, AllUniqueNames,
        [](const FStudentData& Val)
        {
            return Val.Name;
        }
    );
    UE_LOG(LogTemp, Log, TEXT("중복 없는 학생 이름의 수: %d"), AllUniqueNames.Num());
}

MyGameInstance.cpp

UCLASS()
class UNREALCONTAINER_API UMyGameInstance : public UGameInstance
{
	GENERATED_BODY()
	
public:

	virtual void  Init() override;

private:
	// 값타입. 메모리 관리 필요없음. 굳이 UPROPERTY 쓸 필요 X
	TArray<FStudentData> StudentsData; 
	
	// 언리얼 오브젝트 헤더에서 
	// 언리얼 오브젝트 포인터를 선언할 때는 TObjectPtr 사용
	// 전방 선언으로 의존성 최소화
	// TArray에 내부적으로 포인터를 사용하게 될 때는 
	// 자동으로 메모리를 관리할 수 있도록 UPROPERTY 필수
	UPROPERTY()
	TArray<TObjectPtr<class UStudent>> Students;
};

MyGameInstance.h

 

객체의 동적 배열 관리

구조체를 TArray로 관리하는 경우 별도로 UPROPERTY를 붙이거나 붙이지 않는 것은 선택 사항이지만, TArray에서 UStudent, 언리얼 오브젝트를 관리할 때는 반드시 컨테이너 선언에 UPROPERTY 선언을 해줘야 자동 메모리 관리가 된다.


언리얼 TMap

TMap의 특징

STL map과 TMap의 비교

STL map

  • STL set과 동일하게 이진 트리로 구성되어 있음.
  • 정렬은 지원하지만, 메모리 구성이 효율적이지 않으며, 데이터 삭제 시 재구축이 일어날 수 있음.
  • 그렇기 때문에 모든 자료를 순회하는데 적합하지 않음.

언리얼 TMap의 특징

  • 키, 밸류 구성의 튜플(Tuple) 데이터의 TSet 구조로 구현되어 있음.
  • 해시 테이블 형태로 구축되어 있어 빠른 검색이 가능함.
  • 동적 배열의 형태로 데이터가 모여있음.
  • 데이터를 빠르게 순회할 수 있음.
  • 데이터를 삭제해도 재구축이 일어나지 않음.
  • 비어있는 데이터가 있을 수 있음.
  • 중복을 허용하지 않지만 TMultiMap을 사용하면 중복 데이터를 관리할 수 있음.

동작 원리는 STL unordered_map과 유사함.

키, 밸류 쌍이 필요한 자료구조에 광범위하게 사용됨.

TMap의 내부 구조

기본적으로 TSet과 동일하나 키와 밸류 쌍으로 가진 TPair의 자료 구조를 기본으로 채택

TMap

언리얼 엔진에서 TMapTArray 다음으로 자주 사용되는 컨테이너. TSet과 비슷하게 해시 기반으로 구성되어 있음. TSet을 **TPair<Key, Value>**로 구성한 자료 구조가 TMap.

맵의 유형은 TMapTMultiMap 두 가지이며 TMap은 중복을 허용하지 않고 TMultiMap은 동일한 키, 중복을 허용한다.

일치하는 키 값을 TMap에 넣으면 기존 값이 대체되며 TMultiMap에 넣으면 새로 저장됨.

개요

키 값을 개별 오브젝트로 처리한다. 실제 내부에서는 TPair로 KeyType, Value Type 두 가지만 사용해서 TMap을 선언한다.

TArray와 같이 같은 타입만 들어있는 동질성 컨테이너로, 빠르게 접근 가능하다.

TSet과 동일한 해시 컨테이너이기 때문에 기본적으로 키 유형은 해시값을 가져올 GetTypeHash라는 함수와 ==연산자를 지원해야함.

별도의 옵션 얼로케이터를 지원함.

템플릿 파라미터로 KeyFuncs를 받음.

TSet의 성격을 가졌기 때문에 중간에 비어있는 데이터가 있을 수 있음.

맵 만들고 채우기

TMap<int32, FString> FruitMap;

TPair로 만드는 경우는 거의 없고 KeyType과 ElementType, 밸류 타입 두 가지만 선언해주면 된다.

선언 시 비어있는 자료 구조가 만들어짐.

표준 힙 할당되어 비교 작업을 수행하는데 대부분의 작업은 키 값을 사용해 진행되도록 설계되어 있음.

전반적인 함수 구성은 TSet과 유사.

Append로 마치 집합과 같이 다른 맵에서 요소를 가져와 삽입, 병합할 수 있다.

요소를 가져온 원래 맵은 비워짐.

반복

CreateIterator와 CreateConsIterators 함수를 지원하며 순회할 때 키와 밸류 타입을 가진 이터레이터로 순회하기 때문에 Elem.Key, *Elem.Value와 같이 각각 Key와 Value 값으로 가져오면 됨.

쿼리

FindOrAdd 함수로 인덱스 연산자에 키 값을 넣었을 때 해당 키에 데이터가 없으면 새로 추가하는 메카니즘을 사용할 수는 있으나 권장하지는 않음.

Find나 Add를 명확하게 구분해서 사용하면 됨. 기존 STL map에 익숙한 사람들을 위해 제공하는 함수라고 생각하는 것이 낫다.

FindKey 함수로 Value 값을 가지고 Key를 역조회할 수 있다. 키에 해시 값이 들어가 있고 Value 값은 해시가 없기 때문에 TArray와 같이 최악의 경우 모든 요소를 순회할 수 있음. 성능이 별로 좋지는 않고 편하다.

GenerateKeyArray, GenerateValueArray 함수로 키나 밸류 값을 TArray로 가져올 수 있다.

연산자

표준 복사 생성자나 할당 연산자를 통해 복사 생성 가능하다.

MoveTemp 함수를 사용해 이주도 가능하다.

슬랙

다른 것들과 마찬가지로 TSet과 동일

KeyFuncs

기본적으로 해시테이블 구조로 관리하기 때문에 커스텀 데이터 자료 구조를 만들 때는 그 구조체의 해시 값이 무엇인지 지정해줘야함. ==연산자GetTypeHash함수를 선언해 주어야 해시 값을 추출할 수 있음.

하지만 특이한 경우가 있는데,

기본적으로 멤버 변수에 UniqueID가 생성되도록 설계되어 있음.

만약 생성할 때 NewGuid를 통해 계속해서 다른 Unique값이 만들어지도록 설계하는 경우 ==연산자를 어떻게 처리할지에 대한 문제가 발생.

값이 같더라도 UniqueID가 다르면 같은 구조체라고 볼 수 없음.

하지만 ID가 다르더라도 실질적인 구조체의 값만 본다면 같은 구조체라고도 볼 수 있음.

이러한 애매한 상황이 발생했을 경우 보다 명확하게 해시 값이라던지, 같음에 대해 추가적으로 정의할 필요가 있음.

그것을 위해 MapKeyFuncs라는 구조체를 정의해주는데 이 구조체를 어떻게 선언하고 사용할지, 어떤 함수를 구현할지에 대해 언리얼에서 정의해 두었음.

이러한 상황의 경우 언리얼의 가이드에 맞춰 구현하면 됨.

https://dev.epicgames.com/documentation/ko-kr/unreal-engine/map-containers-in-unreal-engine?application_version=5.1

이 외에도 CountBytesGetAllocatedSize와 같은 메모리 통계에 대한 함수를 제공함.


TMap 실습 예제

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

#pragma once

#include "CoreMinimal.h"
#include "Engine/GameInstance.h"
#include "MyGameInstance.generated.h"

USTRUCT()
struct FStudentData 
{
	GENERATED_BODY()
	
	FStudentData()
	{
		Name = TEXT("홍길동");
		Order = -1;
	}

	FStudentData(FString InName, int32 InOrder) : Name(InName), Order(InOrder) {}
	
	UPROPERTY()
	FString Name;

	UPROPERTY()
	int32 Order;

};

/**
 * 
 */
UCLASS()
class UNREALCONTAINER_API UMyGameInstance : public UGameInstance
{
	GENERATED_BODY()
	
public:

	virtual void  Init() override;

private:
	// 값타입. 메모리 관리 필요없음. 굳이 UPROPERTY 쓸 필요 X
	TArray<FStudentData> StudentsData; 
	
	TArray<TObjectPtr<class UStudent>> Students;

	// Key나 Value에 언리얼 오브젝트 포인터가 들어가게 되면 반드시 UPROPERTY를 선언
	TMap<int32, FString> StudentsMap;
};

MyGameInstance.h

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

#include "MyGameInstance.h"
#include "Algo/Accumulate.h"
// 언리얼은 여러 알고리즘 라이브러리를 언리얼 엔진 컨테이너에 맞게 제공하고 있음
// 대표적인 합을 구하는 알고리즘을 위한 헤더 Algo/Accumulate.h

FString MakeRandomName()
{
    TCHAR FirstChar[] = TEXT("김이박최");
    TCHAR MiddleChar[] = TEXT("상혜지성");
    TCHAR LastChar[] = TEXT("수은원연");

    TArray<TCHAR> RandArray;
    RandArray.SetNum(3); // 공간 3개 확보
    RandArray[0] = FirstChar[FMath::RandRange(0, 3)];
    RandArray[1] = MiddleChar[FMath::RandRange(0, 3)];
    RandArray[2] = LastChar[FMath::RandRange(0, 3)];

    return RandArray.GetData();
    // TArray는 TCHAR배열을 포함한 컨테이너.
    // 포인터 값을 넘겨주면 반환값을 FString으로 지정했기 때문에
    // 자동으로 FString이 만들어짐.
}

void UMyGameInstance::Init()
{
    Super::Init();

    const int32 ArrayNum = 10;
    TArray<int32> Int32Array; //메모리를 차지하지 않는 빈 상태

    for (int32 ix = 1; ix <= ArrayNum; ++ix)
    {
        // Primitive Type(기본 타입)의 경우 Emplace나 Add나 큰 차이는 없음
        // 가독성을 위해 Add가 낫긴 하지만 성능에 정말 신경쓰고 싶다면 Emplace 사용해도 무방
        Int32Array.Add(ix);
    }

    // 조건에 해당하는 구문을 람다식으로 적는 것이 일반적
    Int32Array.RemoveAll(
        [](int32 Val)
        {
            return Val % 2 == 0; // 짝수 제거
        }
    );

    Int32Array += { 2, 4, 6, 8, 10};
    // 짝수를 제거하고 다시 넣었기 때문에 {1,2,3,4,5,2,4,...,10}으로 채워짐

    // C스타일 low한 접근
    TArray<int32> Int32ArrayCompare;
    int32 CArray[] = { 1, 3, 5, 7, 9, 2, 4, 6, 8, 10 };
    
    // CArray를 TArray에 넣어야함
    Int32ArrayCompare.AddUninitialized(ArrayNum);
    // 초기화되지 않는 데이터 빠르게 넣기
    FMemory::Memcpy(Int32ArrayCompare.GetData(), CArray, sizeof(int32) * ArrayNum);
    // Int32ArrayCompare의 포인터를 가져와 CArray의 값을 sizeof(int32) * ArrayNum만큼 복제해서 넣기
    
    ensure(Int32Array == Int32ArrayCompare);
    // 같음, 에러 발생하지 않음

    // 배열 요소의 합을 구하는 방법
    // for반복
    int32 Sum = 0;
    for(const int32& Int32Elem : Int32Array)
    {
        Sum += Int32Elem;
    }
    ensure(Sum == 55);

    // Accumulate사용
    int32 SumByAlgo = Algo::Accumulate(Int32Array, 0); //Accumulate(배열, 시작값)
    ensure(Sum == SumByAlgo); //같음

    TSet<int32> Int32Set; //비어있는 상태
    for (int32 ix = 1; ix <= ArrayNum; ++ix)
    {
        Int32Set.Add(ix);
    }

    Int32Set.Remove(2); //RemoveAll 같은건 없음
    Int32Set.Remove(4);
    Int32Set.Remove(6);
    Int32Set.Remove(8);
    Int32Set.Remove(10);
    Int32Set.Add(2);
    Int32Set.Add(4);
    Int32Set.Add(6);
    Int32Set.Add(8);
    Int32Set.Add(10);
    
    const int32 StudentNum = 300;
    for (int32 ix = 1; ix <= StudentNum; ++ix)
    {
        // 구조체이기 때문에 복사 비용 발생, Add보단 Emplace
        StudentsData.Emplace(FStudentData(MakeRandomName(), ix)); 
    }

    TArray<FString> AllStudentsNames;

    // Algo::Transform 데이터 옮겨담기 함수
    // (inputData, outData, 람다식) 
    // TArray에 옮겨담기
    Algo::Transform(StudentsData, AllStudentsNames,
        // 첫번째 인자 : TArray에서 선언한 데이터 타입의 값
        // TArray<FStudentData> StudentsData;
        [](const FStudentData& Val) 
        {
            return Val.Name; // 리턴값 : 옮길 데이터 타입에 대한 FString 값
        }
    );
    // StudentsData의 TArray값을 String TArray로 함수 한번에 옮기기 가능
    UE_LOG(LogTemp, Log, TEXT("모든 학생 이름의 수: %d"), AllStudentsNames.Num());
    
    
    // 순번을 Key로 만든 학생 Map
    Algo::Transform(StudentsData, StudentsMap,
        [](const FStudentData& Val)
        {
            // TPair로 반환해줘야 TMap에 들어감
            return TPair<int32, FString>(Val.Order, Val.Name);
        }
    );

    UE_LOG(LogTemp, Log, TEXT("순번에 따른 학생 맵의 레코드 수: %d"), StudentsMap.Num());

    // 이름을 Key로
    TMap<FString, int32> StudentsMapByUniqueName; // TMap은 중복을 허용하지 않음
    Algo::Transform(StudentsData, StudentsMapByUniqueName,
        [](const FStudentData& Val)
        {
            return TPair<FString, int32>(Val.Name, Val.Order);
        }
    );
    UE_LOG(LogTemp, Log, TEXT("이름에 따른 학생 맵의 레코드 수: %d"), StudentsMapByUniqueName.Num());

    TMultiMap<FString, int32> StudentsMapByName;
    Algo::Transform(StudentsData, StudentsMapByName,
        [](const FStudentData& Val) 
        {
            return TPair<FString, int32>(Val.Name, Val.Order);
        }
    );
    UE_LOG(LogTemp, Log, TEXT("이름에 따른 학생 멀티맵의 레코드 수: %d"), StudentsMapByName.Num());
}

MyGameInstance.cpp

// 특정 이름을 가진 학생이 몇명 있는지에 대한 정보 뽑기
const FString TargetName(TEXT("이혜은"));
TArray<int32> AllOrders;
StudentsMapByName.MultiFind(TargetName, AllOrders);

UE_LOG(LogTemp, Log, TEXT("이름이 %s인 학생 수: %d"), *TargetName, AllOrders.Num());

 

언리얼 에디터를 끄고 컴파일해보면 커스텀 구조체(FStudentData)에 대한 GetTypeHash 함수가 지정되어 있지 않아서 에러 발생.

==연산자와 GetTypeHash함수를 지정해주어야함.

USTRUCT()
struct FStudentData 
{
	GENERATED_BODY() //	public으로 적용됨
	
	FStudentData() // 생성자
	{
		Name = TEXT("홍길동");
		Order = -1;
	}

	// 생성자
	FStudentData(FString InName, int32 InOrder) : Name(InName), Order(InOrder) {}
	
	// 커스텀 구조체(FStudentData)에 대한 GetTypeHash 함수가 지정되어 있지 않아서 에러 발생
	// == 연산자 지정
	bool operator==(const FStudentData& InOther) const
	{
		// Order가 같으면 같은 것으로 간주
		return Order == InOther.Order;
	}

	// GetTypeHash 지정
	// 전역 함수로 선언할 수도 있고
	// friend 함수를 사용해 안쪽에 선언하면 더 깔끔
	// 해시 값은 uint32로 반환
	// 인자로 레퍼런스 넣어줌
	friend FORCEINLINE uint32 GetTypeHash(const FStudentData& InStudentData)
	{
		// 가지고 있는 integer Order 값을 해시로 지정해 리턴
		return GetTypeHash(InStudentData.Order);
	}

	UPROPERTY()
	FString Name;

	UPROPERTY()
	int32 Order;

};

정리

자료구조의 시간 복잡도 비교

 

 

 


이득우의 언리얼 프로그래밍 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