代数的データ型 in Unity
はじめに
代数的データ型というものを知り、少し使えるようになってきたので、そのメモ・Unityでの使いどころを考えてみます。
代数的データ型
代数的データ型は以下の2種類を同時に表現できるものになります。
- 直積型(Product Type)
- 直和型(Sum Type)
直積型
直積型は複数の型を組み合わせた型です。C#で何も考えずにクラス・構造体を作成すると直積型になります。
基本的にどのフィールドも値を持つことを期待して最初は作りますが、徐々に値がない場合もあることに気づくこともあるでしょう。
その場合は、nullを入れるかdefaultを入れるか等で対応します。
class Point
{
public int X { get; }
public int Y { get; }
public Point(int x, int y) // どっちも値を持つことを期待
{
X = x;
Y = y;
}
}
全部のフィールドが値を持つことを期待していること、つまりAND条件であることが特徴です。
ちなみに積と言われるのは、ANDが論理積と言われるからだと思います。真偽はちょっと不明ですが…。
直和型
直和型は、複数の型のうちどれか1つを持つ型です。C#ではenum
がこれに当たります。
enum Result // どっちかを持つ
{
Success,
Failure
}
どれか1つを持つことがOR条件(論理和)なので直和型と呼ばれる、というイメージです。
代数的データ型
以上の2つを組み合わせたものが代数的データ型です。
まずは実装を見ていきましょう。
public abstract record Result;
public sealed record Success(int Value) : Result;
public sealed record Failure : Result;
こんな感じ。Result
を継承してSuccess
とFailure
を作っています。
つまり、
-
enum
のようにResult
はSuccess
かFailure
のどちらかに分岐され、- その中でも
Success
はint Value
を持っている -
Failure
は何も持っていない
- その中でも
という構造になっています。これによってクラス・構造体のような値の持ち方(int Value
)と、enum
のような分岐の持ち方(Success
, Failure
)を同時に表現できるわけです。
使用例
例1. エラー処理
最も単純なのは、先述のResult
のようなものです。
以下のように何らかのグリッド座標計算処理Calculate()
を行い、
- 成功したら座標をもらう
- 失敗したら失敗したことを知らせる
という処理を考えてみます。
通常だと以下のように書くと思います。
public class SomeClass : MonoBehaviour
{
private void Start()
{
var result = Calculate();
if (result != Vector2Int.zero)
{
Debug.Log(result);
}
else
{
Debug.Log("失敗!");
}
}
public Vector2Int Calculate()
{
var result = Vector2Int.zero;
// 計算処理...
// ...
if (/* 何かしらの条件で失敗したら */)
{
return Vector2Int.zero;
}
return result;
}
}
Vector2Int.zero
を返すことで失敗を表現しているのが少しだけ直感的ではないですね。本来Vector2Int.zero
は失敗を表す値ではないので、一瞬読むときに意味を考えなければいけません。
また、もしこのゲームが普通にVector2Int.zero
を座標として使っている場合、Vector2Int.zero
が全て「失敗!」と出力され、このコードはバグってしまいます。
これは、Vector2Int.zero
が多義的に使われてしまっていることが原因です。
一応これを避けるために別のbool
型変数を用意することもできますが、少し冗長になってしまいます。
public class SomeClass : MonoBehaviour
{
private void Start()
{
var result = Calculate();
if (result.IsSuccess)
{
Debug.Log(result.Point);
}
else
{
Debug.Log("失敗!");
}
}
public (Vector2Int Point, bool IsSuccess) Calculate()
{
var result = Vector2Int.zero;
// 計算処理...
// ...
if (/* 何かしらの条件で失敗したら */)
{
return (Vector2Int.zero, false);
}
return (result, true);
}
}
これを代数的データ型を使って書くと以下のようになります。
public abstract record VectorResult;
public sealed record Success(Vector2Int Point) : VectorResult;
public sealed record Failure : VectorResult;
public class SomeClass : MonoBehaviour
{
private void Start()
{
var result = Calculate();
switch (result)
{
case Success success:
Debug.Log(success.Point);
break;
case Failure:
Debug.Log("失敗!");
break;
default:
throw new ArgumentOutOfRangeException();
}
}
public VectorResult Calculate()
{
var result = Vector2Int.zero;
// 計算処理...
// ...
if (/* 何かしらの条件で失敗したら */)
{
return new Failure();
}
return new Success(result);
}
}
switch
文を使って見事に全パターンを網羅し、その中でもSuccess
の場合は専用の値を取り出すことが出来ます。
意味も分かりやすいです。
例2. 分岐によって違う意味の変数が欲しい処理
例えば、以下のような処理を考えてみます。
- プレイヤーがアイテムを拾ったら、そのアイテムによってプレイヤーのステータスが変わる
- アイテムは複数種類あり、それぞれ効果が異なる
もしかしたら以下のように書くかもしれません。
public class Item
{
public ItemType Type { get; }
public int MagicNumber { get; } // 何かしらの効果
public int MagicNumber2 { get; } // 何かしらの効果2
public Item(ItemType type, int magicNumber, int magicNumber2)
{
Type = type;
MagicNumber = magicNumber;
MagicNumber2 = magicNumber2;
}
}
public enum ItemType
{
HealthPotion,
ManaPotion,
PowerUp,
}
public class SomeClass : MonoBehaviour
{
private void Start()
{
var player = new Player();
var item = GetItem();
switch (item.Type)
{
case ItemType.HealthPotion:
player.Hp += item.MagicNumber;
break;
case ItemType.ManaPotion:
player.Mp += item.MagicNumber;
break;
case ItemType.PowerUp:
player.Hp += item.MagicNumber;
player.Mp += item.MagicNumber2;
break;
default:
throw new ArgumentOutOfRangeException();
}
}
public Item GetItem()
{
// 何かしらの処理...
// ...
return null; // 何もない場合
}
}
これは、ItemType
がenum
であるため、switch
文で全パターンを網羅しています。
しかし、MagicNumber
やMagicNumber2
がItemType
によって異なる意味を持っていて、MagicNumber2
が使われないことがあったりと、非本質さ・怖さもあります。
これを代数的データ型を使って書くと以下のようになります。
public abstract record Item;
public sealed record HealthPotion(int Value) : Item;
public sealed record ManaPotion(int Value) : Item;
public sealed record PowerUp(int HealthValue, int ManaValue) : Item;
public class SomeClass : MonoBehaviour
{
private void Start()
{
var player = new Player();
var item = GetItem();
switch (item)
{
case HealthPotion healthPotion:
player.Hp += healthPotion.Value;
break;
case ManaPotion manaPotion:
player.Mp += manaPotion.Value;
break;
case PowerUp powerUp:
player.Hp += powerUp.HealthValue;
player.Mp += powerUp.ManaValue;
break;
default:
throw new ArgumentOutOfRangeException();
}
}
public Item GetItem()
{
// 何かしらの処理...
// ...
return null; // 何もない場合
}
}
正味これはそれ以前の設計段階を直した方が良い気もしますが、まあ一例として。
class
自体でenum
のような分岐を持ち、その中でもrecord
で値を持つことで、ItemType
によって異なる意味を持つ変数を持つことが出来ています。
値はそれぞれのクラスで最低限、意味の通った名前で定義できるため、より分かりやすくなりました。
まとめ
代数的データ型はC#9.0からは簡単に定義できるうえ、色々な場面で使えるので自分も積極的に使っていきたいと思います。
基準としては、中身入りのenum
を使いたくなったら検討の価値ありです。
Discussion