언리얼 대표 컨테이너 라이브러리 TArray, TSet
언리얼 컨테이너 라이브러리
언리얼 엔진이 자체 제작해 제공하는 자료구조 라이브러리
줄여서 ULC(Unreal Container Library)라고도 함
언리얼 오브젝트를 안정적으로 지원하며 다수 오브젝트 처리에 유용하게 사용됨
언리얼 C++은 다양한 자료구조 라이브러리를 직접 만들어 제공함
실제 게임 제작에 유용하게 사용되는 라이브러리 세 가지
TArray, TMap, TSet
접두사 T는 템플릿(Template) 라이브러리를 의미
C++ STL과 언리얼 컨테이너 라이브러리의 차이점
- C++ STL
- 범용적으로 설계되어 있음.
- 표준이기 때문에 호환성이 높음.
- 많은 기능이 엮여 있어 컴파일 시간이 오래걸림.
- 언리얼 컨테이너 라이브러리
- 언리얼 엔진에 특화되어 있음.
- 언리얼 오브젝트 구조를 안정적으로 지원함.
- 가볍고 게임 제작에 최적화되어 있음.
언리얼 C++ 주요 컨테이너 라이브러리
두 라이브러리의 이름과 용도는 유사하지만, 내부적으로 다르게 구현되어 있음(TSet,TMap)
- TArray: 오브젝트를 순서대로 담아 효율적으로 관리하는 용도로 사용
- TSet: 중복되지 않는 요소로 구성된 집합을 만드는 용도로 사용
- TMap: 키, 밸류 조합의 레코드를 관리하는 용도로 사용
언리얼 컨테이너 라이브러리는 요소의 수를 가져올 때 Count가 아닌 Num 함수를 사용함
TArray 개요
TArray는 가변 개열(Dynamic Array) 자료구조
STL의 vector와 동작 원리가 유사함.
게임 제작에서는 가변 배열 자료구조를 효과적으로 활용하는 것이 좋음.
- 데이터가 순차적으로 모여있기 때문에 메모리를 효과적으로 사용할 수 있고 캐시 효율이 높다.
- 컴퓨터 사양이 좋아지면서, 캐시 지역성(Locality)으로 인한 성능 향상은 굉장히 중요해짐.
- 임의 데이터의 접근이 빠르고, 고속으로 요소를 순회하는 것이 가능.
가변 배열의 단점
- 맨 끝에 데이터를 추가하는 것은 가볍지만, 중간에 요소를 추가하거나 삭제하는 작업은 비용이 큼
데이터가 많아질수록 검색, 삭제, 수정 작업이 느려지기 때문에, 많은 수의 데이터에서 검색 작업이 빈번하게 일어난다면 TArray대신 TSet을 사용하는 것이 좋음.
TArray의 내부 구조
GetData(): 시작하는 부분의 포인터 가져오기
Add/Emplace(), Append(), += 연산자: 끝에 추가하기
Insert(), Remove(): 중간에 있는 값을 수정하기 때문에 비용이 높음
[ ]연산자: 특정한 인덱스가 주어졌을 때 해당 인덱스를 빠르게 가져올 수 있음
균일한 데이터로 되어있기 때문에 바로 주소를 알 수 있어 한번에 가져올 수 있음
TArray
TArray는 연결되어 있기 때문에(시퀀스) 잘 정의된 순서를 갖는다.
같은 유형을 가지기 때문에 함수를 사용하여 해당 주소를 바로 가져올 수 있다.
TArray는 가장 자주 쓰이며 신속성, 메모리 효율성, 안전성을 염두에 두고 디자인 되었음.
TArray는 주로 엘리먼트 유형(타입), 부로 얼로케이터(allocator: 메모리를 어떻게 관리하는지에 대해 지정한 것) 두 가지 프로퍼티로 정의됨
엘리먼트 유형은 배열에 저장되는 오브젝트 유형.
TArray는 동질성 컨테이너(같은 사이즈를 가짐)로, 엘리먼트는 전부 엄격히 같은 유형. 유형이 다르면 하나의 TArray에 저장할 수 없음
얼로케이터는 대부분 기본값(1)을 사용하면 됨. 특수한 경우(직접 메모리 관리 등)에는 직접 작성하여 추가할 수 있음.
TArray는 값 유형 : 동적 할당하지 않음. new나 delete로 생성, 소멸시키는 것은 좋지 않음.
TArray는 자연스럽게 클래스 멤버 변수나 스택에서 소멸, 들어있는 엘리먼트 또한 소멸로 이어짐
다른 TArray 변수에서 새롭게 TArray 변수를 만들면 항상 복사해서 새롭게 생성함. 공유하는 상태는 없음
배열 만들고 채우기
TArray<int32> IntArray;
클래스들에 대한 값 유형이면 어떤 것이든 가능
얼로케이터가 지정되지 않으면 기본 힙 기반 얼로케이터를 사용
- 선언만 했을 때는 비어있는 변수로 할당된 메모리가 없는 상태
Add나 Emplace를 사용해 채울 수 있음
Add: 추가할 데이터를 밖에서 생성하고 TArry에 복사해서 넣는 방식
Emplace: TArray 자체에 바로 생성하는 방식, Add보다 조금 더 효율적
Emplace가 Add보다 효율이 떨어질 일은 없으나 가독성은 Add가 나을 수도.
Append는 다수의 엘리먼트를 한번에 추가할 때 사용
FString Arr[] = {TEXT("Three"), TEXT("four")}
StrArr.Append(Arr, ARRAY_COUNT(Arr));
//StrArr = ["One", "Two"]
AddUnique는 기존 컨테이너에 동일한 엘리먼트가 있는지 검색 후 존재하지 않는다면 추가.
이 경우 TArray보다는 TSet이 더 유용하기 때문에 효과적이지는 않음.
Insert는 주어진 인덱츠에 추가시킴. 전체적인 메모리 구조가 바뀌게 되어 비용 발생
Count를 사용하지 않고, SetNum 과 같이 Num함수 사용
SetNum 으로 배열의 크기를 조절할 수 있음. 현재 배열보다 작게 설정하는 경우 엘리먼트를 제거하기도 함.
반복처리
ranged for 구문과 일반 for로 반복처리 가능.
반복처리에 대해 읽기-쓰기인지, 읽기-전용인지, const Iterator를 사용할지 그냥 Iterator를 사용할지 지정할 수 있음.
CreateIterator : 읽기-쓰기
CreateConstIterator: 읽기-전용
for(auto It = StrArr.CreateConstIterator(); It; ++It)
ranged for 구문에서도 앞에 const를 쓰면 읽기 전용 순회를 함.
정렬
Sort 로 정렬가능. HeapSort , StableSort등 지원. StableSort는 병합 소트(Merge Sort)로 구현되어있음.
쿼리
Num함수를 사용해 배열에 엘리먼트가 몇 개인지 확인 가능.
int32 Count = StrArr.Num();
GetData 함수로 배열 내 엘리먼트에 대한 포인터를 반환할 수 있음.
FString* StrPtr = StrArr.GetData();
// StrPtr[0] == "One"
// StrPtr[1] == "Two"
...
// StrPtr[3] == "Four"
// StrPtr[4] : undefined behavior
컨테이너가 const인 경우 반환되는 포인터도 const.
IsValidIndex 함수를 사용해 특정 인덱스가 유효한지 확인할 수 있음.
bool bBalidM1 = StrArr.IsValidIndex(-1);
// bBalidM1 == false
[] 연산자는 레퍼런스를 반환, 배열이 const가 아니라면 요소를 수정할 수 있음.
StrArr[3] = StrArr[3].ToUpper();
Top, Last함수로 뒤에서부터 접근할 수 있음. Top과 Last는 동일
FString ElemEnd = StrArr.Last();
FString ElemEnd1 = StrArr.Last(1);
FString ElemEnd = StrArr.Top();
Contains로 특정 엘리먼트가 들어있는지 확인할 수 있음.
조건에 일치하는 요소가 있는지 세밀한 검색도 가능.
Find 함수로도 검색이 가능하지만 검색하는 종류의 함수는 효율이 좋지 않다.
Find로 엘리먼트를 찾지 못한 경우 특수 값인 INDEX_NONE이 반환됨.
제거
Insert와 마찬가지로 Remove도 전체적인 데이터 변동이 발생하기 때문에 효율이 좋지 않다.
엘리먼트가 제거되는 모든 경우에서, 그 뒤의 엘리먼트가 낮은 인덱스로 정리됨.
전체적으로 데이터가 재정렬되어 배열에 빈 공간이 생길 수 없음.
Empty로 배열에서 모든 것을 제거할 수 있음
연산자
할당 연산자로 값을 복사하여 넣을 수 있음. Emplace 함수가 아닌 Add 함수처럼 동작.
Append함수 대신 +=을 통해 배열을 연결시킬 수 있음
MoveTemp 함수를 사용해 배열을 이주시킬 수 있음. 이동 후 원본 배열은 공백으로 남음.
엘리먼트의 수와 순서가 같을 경우에만 **==**연산자가 true로 판정됨, 대소문자는 구분하지 않음
힙
이진 힙 데이터 구조체를 지원함.
Heapify함수를 사용해 기존 배열을 힙으로 변환시킬 수 있음.
슬랙(Slack여유분)
배열은 메모리 사용량이 가변적이라 보통 요청한 것보다 넉넉한 메모리를 확보해두는 것이 효과적. 그렇기 때문에 엘리먼트를 삭제해도 메모리가 바로 해지되지 않는다.
이러한 여유분을 제거해 정리할 수 있음.
원시 메모리
TArray는 궁극적으로는 할당된 메모리를 둘러싼 포장 단위일 뿐.
메모리를 직접 접근하여 작업할 수 있음.
AddUninitialized로 초기화하지 않고 빠르게 메모리 할당만 하거나
FMemory::Memcpy로 배열의 메모리가 있을때 바로 복사하여 TArray를 생성할 수 있음
TArray 실습 예제
// Fill out your copyright notice in the Description page of Project Settings.
#include "MyGameInstance.h"
#include "Algo/Accumulate.h"
// 언리얼은 여러 알고리즘 라이브러리를 언리얼 엔진 컨테이너에 맞게 제공하고 있음
// 대표적인 합을 구하는 알고리즘을 위한 헤더 Algo/Accumulate.h
void UMyGameInstance::Init()
{
Super::Init();
const int32 ArrayNum = 10;
TArray<int32> Int32Array; //메모리를 차지하지 않는 빈 상태
for (int32 ix = 1; ix <= ArrayNum; ++ix)
{
// 이 경우 작은 작업이라 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); //같음
}
MyGameInstance.cpp
에디터를 끄고 디버깅 실행 후 에디터의 플레이버튼을 누르면 브레이킹 포인트에서 멈춤
변수 내부를 보려고 디버깅을 하면 변수가 최적화되어 볼 수 없다는 메세지가 뜸
실행 흐름을 디버깅할 수 있지만 내부의 내용까지 추적할 수 있는 상세한 모드로 빌드되지 않았음.
빌드 모드 Development Editor는 추적은 가능하지만 최적화되어 있기 때문에 상세 내용을 볼 수 없음.
DebugGame Editor로 빌드하고 디버깅하면 내부 값을 확인할 수 있음
TSet
STL set과 언리얼 TSet의 비교
STL set
- 이진 트리로 구성되어 있어 정렬을 지원함.
- 메모리 구성이 효율적이지 않음.
- 요소가 삭제될 때 트리 구조의 균형을 위한 재구축이 일어날 수 있음.
- 모든 자료를 순회하는데 적합하지 않음.
언리얼 TSet
- 해시 테이블 형태로 키 데이터가 구축되어 있어 빠른 검색이 가능함.
- 동적 배열의 형태로 데이터가 모여있음.
- TSet의 데이터는 빠르게 순회할 수 있음.
- 데이터를 삭제해도 재구축이 일어나지 않음
- TSet의 자료에는 비어있는 데이터가 있을 수 있음.
따라서 STL set과 언리얼 TSet의 활용 방법은 서로 다르기 때문에 주의.
STL의 unordered_set과 유사하게 동작하지만 동일하진 않음.
TSet은 중복없는 데이터 집합을 구축하는데 유용하게 사용할 수 있음.
TSet의 내부 구조
TSet은 동적 가변 배열의 형태이며, 중간중간 빠져있을 수 있다는 특징을 가짐
비어 있는 부분을 빠르게 메꾸는 형태로 데이터를 추가할 수 있음.
중간이 비어있긴 하지만 데이터가 모여있기 때문에 빠르게 순회하는 것도 가능
TSet 활용
순서가 중요하지 않은 상황에서 고유 엘리먼트를 저장하는데 사용되는 고속 컨테이너 클래스. 대부분의 경우 딱 하나의 파라미터, 타입 정보만 필요함.
하지만 여러가지 템플릿 파라미터로 구성하여 작동 방식을 변경하거나 다용도로 만들 수 있음.
TArray와 동일하게 데이터 저장을 위한 커스텀 메모리 얼로케이터를 제공하며, 엘리먼트 전부가 엄격히 같은 유형임. TSet이 소멸되면 엘리먼트도 같이 소멸됨.
TArray와 다른 점은 TSet의 메모리 내 순서는 신뢰성이 있거나 안정적이지 않음.
TSet은 희소 배열, Sparse Array라는 자료 구조를 기반으로 함.
빈 공간이 있으면 그곳에 데이터가 들어가기 때문에 순서가 보장되지 않음.
TMap, TMultiMap과 비슷하지만 중요한 차이점이 있음.
TSet은 독립된 키로 데이터 값을 연결하기 보다 데이터 값 자체를 키로 사용해 엘리먼트 값을 평가함. 내부적으로 해시 테이블로 구성되어 있는데, 데이터 자체가 키를 가지고 있어 빠르게 검색할 수 있다는 의미.
엘리먼트 추가, 검색, 제거가 매우 빠르며 중복 키를 지원하지 않음.
생성 및 채우기
TSet<FString> FruitSet;
TArray와 유사하게 선언만으로는 비어있는 자료구조가 생성되어 메모리에 할당되지 않음.
TSet은 **==**연산자로 엘리먼트를 직접 비교하고, GetTypeHash로 해싱함.
언리얼 엔진은 FString이나 integer나 기본적인 UObject에 대한 해시 값들을 가지고 있는데, 만약 새로운 형태의 구조체를 사용해 TSet으로 만들고 싶다면 해당 자료형에 대한 GetTypeHash 함수를 직접 만들어주어야 함.
세트를 채우는 표준 방식은 Add를 사용해 하나씩 집어넣는 것.
Add 대신 Emplace를 사용하면 복사없는 데이터 추가를 할 수 있음.
FruitSet.Add(TEXT("Banana"));
FruitSet.Emplace(TEXT("Pear"));
기본적으로는 중복 키를 허용하지 않이 때문에 중복으로 값을 넣으려 시도해도 변화가 없음.
Append함수를 사용해 다른 세트와 병합할 수 있음.
FruitSet.Append(FruitSet);
세트:집합. 집합끼리의 연산을 하는 것
이터레이션
TArray와 비슷하게 for, ranged for문을 사용해 반복할 수 있다.
읽기-쓰기, 읽기-전용 이터레이터를 CreateIterator, CreateConstIterator 함수를 사용해 가져올 수 있다.
쿼리
Num함수로 몇개의 자료가 있는지 확인할 수 있다.
Contatins도 사용할 수 있는데, TArray와 달리 해시테이블로 구성되어 있어 검색 속도가 빠름. TSet의 장점.
내부적으로는 해시테이블로 찾은 아이디를 FSetElementId라는 구조체로 저장되어 있음.
내부 구조를 잘 알고 있다면 FSetElementId 값을 직접 지정해서 찾을 수도 있다.
세트에 키가 있는지 확실하지 않은 경우 Contains함수와 []연산자를 사용해 검사할 수 있으나 같은 키에 두 번 조회해야하기 때문에 이상적이지는 않음.
Find함수를 사용해 한 번으로 처리하는 것이 효과적.
FString* PtrBanana = FruitSet.Find(TEXT("Banana"));
FString* PtrLemon = FruitSet.Find(TEXT("Lemon"));
// *PtrBanana == "Banana"
// PtrLemon == nullptr
동적 배열로 구성되어 있어 TArray를 Array함수를 사용해 바로 반환할 수 있음.
TArray는 빈틈이 없기 때문에 꽉 차있는 데이터를 얻을 수 있음.
TArray<FString> FruitArray = FruitSet.Array();
제거
인덱스를 붙여 제거할 수 있긴 하지만, 순서를 정확히 보장하지 않기 때문에 순서를 파악해서 무언가 작업하기가 어려움.
그렇기 때문에 일반적으로 인덱스를 가지고 세트를 작업하는 것은 권장하지 않음.
엘리먼트를 제거하면 데이터 구조에 간극(빈 공간)을 남길 수 있음
정렬
TSet도 Sort를 사용한 정렬이 가능은 하다. 하지만 이후의 작업으로 인해 세트가 변경되면 순서를 보장하지 않는다.
연산자
TArray와 마찬가지로 할당 연산자를 통해 복사할 수 있다. 복사를 통해 사본을 갖는다.
슬랙
TArray와 유사하긴 하지만 기본적으로 데이터를 제거할 때 메모리에서 완전히 제거하는 것이 아닌 비어있는 상태로 놔둠. 그렇기 때문에 데이터의 뒷부분에 생기는 슬랙과 데이터 중간 중간에 생기는 빈 틈 슬랙 두 종류가 있다.
Shrink함수는 데이터 뒤쪽에 생성된 슬랙을 제거한다.
FruitSet.Shrink();
DefaultKeyFuncs
커스텀 구조체를 사용해 TSet을 만들 경우 ==연산자와 해당 타입에 대한 GetTypeHash함수 두 가지를 구현해주어야함.
특이한 경우지만 혹시라도 TSet에 대한 커스텀을 많이 해서 이러한 함수를 오버로드하지 않을 경우 별도의 커스텀DefaultKeyFuncs를 제공해주면 됨.
기타
CountBytes와 GetAllocatedSize 함수로 세트의 크기, 메모리 양을 측정할 수 있다.
실습 예제
// Fill out your copyright notice in the Description page of Project Settings.
#include "MyGameInstance.h"
void UMyGameInstance::Init()
{
Super::Init();
const int32 ArrayNum = 10;
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);
}
요소를 삭제하면 중간에 빈 공간이 생김
요소를 다시 추가하면 뒤쪽부터 거꾸로 빈 공간에 순차적으로 저장됨
정리
각 자료구조의 시간 복잡도
TArray는 가장 빠른 접근 성능을 가지나 검색, 삽입, 삭제에 관해서는 최악의 경우 요소의 수만큼 느려질 수 있다.
TSet은 모든 경우에 대해 빠른 효율을 제공하나 메모리의 형태에 빈틈이 있을 수 있기 때문에 빈틈없이 고속으로 빠르게 접근하려면 TArray가 더 좋은 선택일 수 있음.
'게임 개발 > 언리얼 C++' 카테고리의 다른 글
언리얼C++ - 언리얼 엔진의 메모리 관리 (0) | 2024.11.19 |
---|---|
언리얼C++ - 언리얼 컨테이너 라이브러리 2 | 구조체와 Map (0) | 2024.11.18 |
언리얼C++ - 델리게이트(Delegate) (5) | 2024.11.16 |
언리얼C++ - 컴포지션(Composition) (2) | 2024.11.15 |
언리얼C++ - 인터페이스(Interface) (2) | 2024.11.14 |