【UE5】TMapに複合キーを指定する方法

プログラマーの尾関です。

今回はちょっとした小ネタとして、TMapに複合キーを指定する方法について紹介します。

TMapは通常1つのキーのみ指定可能

TMapは通常は1つのキーのみ指定可能です。

// int32をキー、FStringを値として扱う.
TMap<int32, FString> MyMap;
MyMap.Add(1, TEXT("Apple"));
MyMap.Add(2, TEXT("Banana"));

例えば上記コードは int32 をキー、FStringを値として扱います。

そして 1は “Apple” であり、2は “Banana” です。

複合キーが必要となるケース

上記の説明の通り、通常であればTMapを扱う場合、データのキーは1つですが、仕様上複数のキーを割り当てたいとします。

例えば敵データテーブルがあるとして、「カテゴリ」「ID」という複数のキーを割り当てる場合です。

Information

ここではカテゴリーにIDがぶら下がる、というデータとしましたが、ゲーム開発現場の経験から考えると通常、IDは複合キーとして扱わず通常はユニークなIDとなるようにするのが一般的です。

ただわかりやすい例として、今回はカテゴリーとIDの複合キーという形式とします。

行構造の定義

先程のデータを扱うために行構造を定義します。

// 敵のカテゴリーを定義する列挙型.
UENUM(BlueprintType)
enum class EEnemyCategory : uint8
{
  None,
  Slime,
  Dragon,
};

/**
 * @brief 敵データテーブル.
 */
USTRUCT(BlueprintType)
struct FEnemyData : public FTableRowBase
{
  GENERATED_BODY()

  // カテゴリー.
  UPROPERTY(EditAnywhere, BlueprintReadWrite)
  EEnemyCategory Category;

  // ID.
  UPROPERTY(EditAnywhere, BlueprintReadWrite)
  int32 ID;

  // 名前.
  UPROPERTY(EditAnywhere, BlueprintReadWrite)
  FName Name;

  // HP.
  UPROPERTY(EditAnywhere, BlueprintReadWrite)
  int32 HP;

  // 攻撃力.
  UPROPERTY(EditAnywhere, BlueprintReadWrite)
  int32 Attack;
};

複合キーの構造体を定義する

次に複合キーを扱うための構造体を定義します。

/**
 * @brief 敵の複合キー構造体.
 */
USTRUCT(BlueprintType)
struct FEnemyKey
{
  GENERATED_BODY()

  // カテゴリー.
  UPROPERTY(EditAnywhere, BlueprintReadWrite)
  EEnemyCategory Category;

  // ID.
  UPROPERTY(EditAnywhere, BlueprintReadWrite)
  int32 ID;

  // 構造体同士の比較演算子の定義が必要.
  bool operator==(const FEnemyKey& Other) const
  {
    // カテゴリとIDの2つが一致するかどうか.
    return Category == Other.Category && ID == Other.ID;
  }
};

// ハッシュ関数の定義が必要.
FORCEINLINE uint32 GetTypeHash(const FEnemyKey& Key)
{
  return HashCombine(GetTypeHash(Key.Category), GetTypeHash(Key.ID));
}

重要なのは、構造体を使って複合キーを扱うためには、以下の2つのコードが追加で必要ということです。

  1. 等価比較(==)演算子のオーバーロードを定義する
  2. ハッシュ関数を「グローバル関数」として定義する
Information

複合キーに限りませんがTMap の検索はキーのハッシュ値を元に行われており、高速に動作するためには適切なハッシュ関数が必要となります。
例えば数千件程度のデータでは通常問題になりませんが、データ件数が数万件を超えるような場合にハッシュ値の衝突が増えると検索速度が低下する可能性があります。
この場合には、複合キーを使用する場合でも GetTypeHash() を適切に実装し、キー全体の情報から偏りの少ないハッシュ値を生成する工夫が必要となります。

データテーブルを複合キーとしてTMapに格納する

例えばメンバ変数を以下のように定義して、データテーブルの参照とTMapを定義します。

// 敵データテーブルの参照.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "EnemyData")
UDataTable* EnemyDataTable;

// 敵データの検索を高速化するためのマップ.
UPROPERTY()
TMap<FEnemyKey, FEnemyData> EnemyDataMap;

そして読み込み部分を以下のように実装してで、複合キーを使ってデータテーブルへ高速アクセスすることが可能なります。

void ACPP_TestMultiKey::BeginPlay()
{
  Super::BeginPlay();
  
  // 敵データテーブルをロードする.
  for(const auto& LRowName : EnemyDataTable->GetRowNames()) {
    // 敵データを取得する.
    FEnemyData* LEnemyData = EnemyDataTable->FindRow<FEnemyData>(LRowName, TEXT(""));
    if(LEnemyData) {
      // 敵データのカテゴリーとIDをキーにして、マップに登録する.
      FEnemyKey LKey;
      LKey.Category = LEnemyData->Category;
      LKey.ID = LEnemyData->ID;
      // マップに登録.
      EnemyDataMap.Add(LKey, *LEnemyData);
    }
  }
}

// 敵データを取得する関数.
bool ACPP_TestMultiKey::GetEnemyData(EEnemyCategory Category, int32 ID, FEnemyData& OutEnemy)
{
  FEnemyKey LKey;
  LKey.Category = Category;
  LKey.ID = ID;
  if(EnemyDataMap.Contains(LKey) == false) {
    return false; // 存在しない.
  }
  // マップから敵データを取得.
  OutEnemy = EnemyDataMap[LKey];
  return true;
}

動作確認

まずはデータテーブルを作成します。

次にテスト用に作ったActorクラスを継承してBPクラスを作成します。

そして 敵データテーブルを登録。

確認する方法は何でも良いですが、以下のようなブループリントを書いてみました。

このアクターをレベルに配置して動作確認…。

敵データの取得が問題なくできることを確認しました。

おしまい

以上、TMapに複合キーを登録する方法でした。

ゲーム開発の現場では頻繁に使われる方法ではないですが、ピンポイントでデータアクセスを高速化したいときに役立つかもしれないと思って紹介しました。

以上、Unreal Engineでのゲーム開発に役立てる情報となれば幸いです。

追記 (2025/12/18):キーを動的に変化させたときの動作は未定義

例えば、キーにオブジェクト情報を持たせて、オブジェクトが無効だったらGetTypeHash()で "0" を返すという実装をしてしまうと、TMapが管理しているハッシュ値が不整合となるため、動作が安定しないということがあるようです。

そのため、TMapでオブジェクトをキーにしたい場合には、以下に記載した FObjectKey を使うと良いです。

追記 (2025/12/18):インスタンスを TMap のキーにしたい場合

TMapのキーに関連して、例えば「あるオブジェクトが特定のインスタンスを内包しているかどうか」という情報を管理したい場合があるとします。

その場合には、オブジェクトのポインタをキーにしたり、今回の方法は使わずに FObjectKey を使うと便利です。以下は UNiagaraComponentのアドレスが有効・無効であっても、内包しているかどうかを判定できます。

#include "NiagaraComponent.h"
#include "UObject/ObjectKey.h" // FObjectKeyを使うために必要.

// FObjectKeyをキーとして使う.
// TWeakObjectPtrをつけることで参照カウンタを増やさない.
TMap<FObjectKey, TWeakObjectPtr<UNiagaraComponent>> Map;

// 内包するオブジェクト.
void Add(UNiagaraComponent* Comp)
{
    if (!IsValid(Comp)) return;
    Map.Add(FObjectKey(Comp), Comp);
}

// 指定のオブジェクトを内包しているか?
bool Contains(UNiagaraComponent* Comp) const
{
    if (!IsValid(Comp)) return false;
    return Map.Contains(FObjectKey(Comp));
}

// 無効化された要素をクリーンアップ.
void Compact()
{
    for (auto It = Map.CreateIterator(); It; ++It)
    {
        if (!It->Value.IsValid()) // 無効なオブジェクト.
        {
            It.RemoveCurrent(); // 削除.
        }
    }
}

ただ、アドレスが無効になっても自動で削除することはできないので、クリーンアップ用の関数を用意して、定期的に無効なキーを取り除くようにしています。

Information

FObjectKeyは USTRUCT() ではなく普通の struct のため、FObjectKeyをキーとした TMap に UPROPERTY() を指定することはできません。そのため保持しているインスタンスをガベコレされたくない場合は、FObjectKeyを使わず別の方法を検討する必要があります。

\ 最新情報をチェック /