この記事はUnity Advent Calendar 2023 10日目の記事です。
前日は@ppengotsuさんのUnityにテクスチャなどインポートする時に最低限の設定を自動化すると、@up-hashさんのFileStreamの正体でした!
同日には@ruccho_vectorさんのSource Generator の使いみちが投稿されています!
明日は@monryさんと@Cova8bitdotさんの記事です!!
Enumを継承したい!
エンジニアなら誰しも一度は「Enumを継承したい!」と思うものです。そうですよね?
しかしいくら調べても継承する方法はなく、
EnumっぽいStructを作る内容など眺めては、「これなのか…?」と首をひねる日々。
この記事は、私たちが求めているものを明確にし、
なるだけ近い機能をもったStructを作ってみようという試みです!
Enumの魅力に迫る
継承できなくて不便だと思いながらもEnumを使ってしまう。
Enumの何が我々をそんなに惹きつけるのでしょうか?
何と言っても
- 要素編集が圧倒的に簡単
- UnitySerializeとの相性の良さ
継承して何をしたいんだ?
では、そんな魅力的なEnumを継承して、やりたいこととは何でしょう?
ずばり
- 異なるEnumを既定クラスで指定して同質に扱いたい
- 同質のEnumを引数に持つメソッドをまとめたい
のです!
え?そんなことはないって?
ごめんなさい、そんな方にはこの記事は参考にならないかもしれません……
Enumを継承したい!と思ったときに僕らが求めているもの
話は簡単です。以下の条件を満たせば良いのです!
- Enumの圧倒的作りやすさは保つ
- EnumのUnitySerializeとの相性の良さもそのままに
- 同質のEnumを引数に持つメソッドをまとることができる
1番と2番を達成するためには、Enumをそのまま使うしかなさそうです。
そこに、3番を加えます。
Intにキャストすれば、まとめること自体は簡単です。
ただし、今回は、同質のEnumだけをまとめたいわけなので、
同質のEnum間のキャストだけを受け付けるStructを作ってみようと思います!!
同質性を整理する
ここからは、具体的なケースをもとに考えていきます。
まずは、まとめようとしているEnumがどういった点で同質なのかを整理します。
今回は、プレイヤーと敵の二者について、状態を司るEnumが以下のようにあるとします。
public enum PlayerState
{
Normal,
Angry,
Smile
}
public enum EnemyState
{
Normal,
Angry,
Sad
}
まとめたい処理を整理する
そしてこれらのStateに応じた処理が、他のクラスからリクエストされるものとします。
そのため、プレイヤーと敵のクラスはそれぞれ以下のメソッドを実装しています。
public void ChangePlayerState ( PlayerState state){ //変更処理 }
public void ChangeEnemyState( EnemyState state ){ //変更処理 }
ここで言うPlayerStateとEnemyStateは同質のものを扱っていて、
変更処理の内部で行われることもほとんど同一だとします。
そこで、以下のように処理をまとめることができると、Interfaceをまとめたり、
基底クラスを利用したりする余地が生まれそうです。
public void ChangeCharacterState( int stateNo ){ //変更処理 }
指定のEnumからしか生成できないStructを作る
しかしこれでは、もともとPlayerEnumだったIntを
誤ってEnemyStateにキャストするなどのリスクがありそうです。
また、そもそも全く関係の無いStateからキャストされたIntや、
元々Intとして作られたデータも受け入れ可能です。
無作為にどんなIntでも受け付けてしまう形は、いかにもバギーな感じがします。
そこで、まずこのIntをValueObjectにしてみます。
public struct StateInt
{
public readonly int Value { get; private set; }
public StateInt( int value )
{
Value = value;
}
}
先程よりだいぶマシになりましたが、あらゆるIntを受け付けることに変わりはありません。
そこで、このStateIntを、同質のEnum間のキャストだけを受け付けるStructにしてみます。
public struct StateInt
{
public readonly int Value { get; private set; }
public StateInt( PlayerState value )
{
Value = (int)value;
}
public StateInt( EnemyState value )
{
Value = (int)value;
}
}
生成時と同じ型へのキャスト以外はエラーを出す
さらに、もともとどのタイプからキャストされたかを示すEnumを作成し、
生成したときに、どのタイプからキャストされたかを保持するようにします。
public struct StateInt
{
public readonly int Value { get; private set; }
public readonly StateType Type { get; private set; }
public StateInt( PlayerState value )
{
Value = (int)value;
Type = StateType.Player;
}
public StateInt( EnemyState value )
{
Value = (int)value;
Type = StateType.Enemy;
}
private enum StateType
{
Player,
Enemy,
}
}
保持されたTypeを使って、キャスト元のEnumに戻すメソッドと、
それ以外の型にキャストされた時にエラーを出すメソッドを実装します。
public struct StateInt
{
public readonly int Value { get; private set; }
public readonly StateType Type { get; private set; }
//~中略~
public static implicit operator PlayerState (StateInt stateInt)
{
if(stateInt.Type != StateType.Player)
{
throw new InvalidOperationException( "生成時とは異なる型にキャストされようとしています。生成時の型:" + Type + "キャスト型" + CharacterType.Player );
}
return (PlayerState)stateInt.Value;
}
public static implicit operator EnemyState (StateInt stateInt)
{
if(stateInt.Type != StateType.Enemy)
{
throw new InvalidOperationException( "生成時とは異なる型にキャストされようとしています。生成時の型:" + stateInt.Type + "キャスト型" + StateType.Enemy );
}
return (EnemyState)stateInt.Value;
}
}
メソッドをまとめてみる
これで、不正なキャストがあれば明確に例外が発生するようになりました!
では、先程のメソッドをもったクラスをまとめてみましょう。
public abstract class CharacterStateControllerBase: Monobehaviour
{
[SerializeField]
private SpriteRenderer _spriteRenderer;
public void ChangeCharacterState(StateInt state)
{
Sprite stateSprite = GetSprite(state);
_spriteRenderer.sprite = stateSprite;
}
protected abstract Sprite GetSprite(StateInt state);
}
public class PlayerStateController : CharacterStateControllerBase
{
//実際はSerializedDictionaryなどを使ってください。
//EnumをKeyにしたときのブロック化の問題は今回は割愛します。
[SerializeField]
private Dictionary<PlayerState,Sprite> _stateStandPictures;
protected override Sprite GetSprite(StateInt state)
{
return _stateStandPictures[(PlayerState)state];
}
}
public class EnemyStateController : CharacterStateControllerBase
{
//実際はSerializedDictionaryなどを使ってください。
//EnumをKeyにしたときのブロック化の問題は今回は割愛します。
[SerializeField]
private Dictionary<EnemyState,Sprite> _stateStandPictures;
protected override Sprite GetSprite(StateInt state)
{
return _stateStandPictures[(EnemyState)state];
}
}
これで同質のEnumだけを安全にまとめながら、
基底クラスを作ることで異なるクラス間の処理を共通化することができました!
また、SerializeFieldには引き続きEnumをもたせることができるので、
要素追加、削除の容易さ、Serializeとの相性の良さも損ないません!
まとめ
以上で、Enumを継承したい!と思ったときに僕らが求めているものを実現することができました!
同質の処理をBaseクラスにまとめて、
データとして異なる部分だけを派生クラスに持たせることで、 保守のしやすい構造になったかと思います。
Enumを継承したい!と思ったときに思い出していただけると幸いです!