4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

【C#】Unityでreadonly修飾子が使えない問題を解決する

Posted at

readonly修飾子

フィールドにreadonly修飾子を付けて宣言すると、__フィールド初期化子__もしくは__コンストラクタ__でしか値を代入できなくなるため、不用意に値を変更される心配がなくなって非常に安心できます。

public class TestClass
{
    private readonly int readonlyNum = 5; //フィールド初期化子での初期化可
    public TestClass()
    {
        readonlyNum = 3; //コンストラクタでの初期化可
    }
    public void SomeMethod()
    {
        //readonlyNum = 10; //代入できない
    }
}

ところがUnityでは(というかMonoBehaviourなクラスでは)、コンストラクタを定義できないため、readonlyなフィールドを初期化することができないのです。
これではいつどこで値が書き換えられるか不安で夜も眠れません。

Startメソッド内でreadonlyを初期化したいシチュエーション

別にフィールド初期化子で初期化すればいいじゃんと思うかもしれませんが、Startメソッド内で初期化したいシチュエーションは結構あります。
例えば、次のようにSerializeFieldで受け取った値を元にして別のクラスを作るような場合などが挙げられます。

public class TestMonoBehaviour : MonoBehaviour
{
    [SerializeField]
    private Component m_component1;
    [SerializeField]
    private Component m_component2;

    private readonly IReadOnlyDictionary<ComponentType, Component> m_componentTable;

    private void Start()
    {
        //エラー!Startはコンストラクタではないため値を代入できない
        m_componentTable = new Dictionary<ComponentType, Component>()
        {
            {ComponentType.Type1, m_component1 },
            {ComponentType.Type2, m_component2 }
        };
    }
}

SerializeFieldで受け取った値を元にしてDictionaryを作っているので、フィールド初期化子で値を初期化できません。
このような場合、どうしてもStart内で初期化する必要が出てきます。

しかし、かといってreadonlyをやめてしまえば、いつかの自分もしくは別の誰かが手が滑ってnullを代入してしまう可能性が残ってしまい、夜も眠れなくなります。

そこで、どうにかしてMonoBehaviourなクラスでreadonlyを実現する方法を考えました。

MonoBehaviourreadonlyを実現するUniReadOnly<T>クラス

通常の方法ではMonoBehaviourreadonlyを使用できないので、同等の機能を実現するUniReadOnly<T>クラスを作りました。
このクラスは以下の特徴を持ちます。

  • Tインスタンスを内部にラップする
  • 1回のみTで初期化可能
  • あとはTの読み取り専用プロパティのみを公開する

1回のみ初期化可能で、あとは読み取り専用として機能するので、readonlyの代わりとして利用できます。

実装

実装は次の通りです。
そのままコピペするだけで使えます。

/// <summary>
/// 一度しか初期化できない値です。
/// readonlyキーワードの代わりに使用します。
/// </summary>
/// <typeparam name="T">ラップする型</typeparam>
public class UniReadOnly<T>
{
    ///// <summary>
    ///// 値を取得します。
    ///// </summary>
    public T Value { get; private set; }
    /// <summary>
    /// 初期化されているかどうかを取得します。
    /// </summary>
    public bool IsInitialized { get; private set; }

    /// <summary>
    /// 値を初期化します。このメソッドは一度しか呼ぶことができません。
    /// </summary>
    /// <param name="value">初期化する値</param>
    /// <exception cref="AlreadyInitializedException">複数回初期化しようとしたときにスローされます。</exception>
    public void Initialize(T value)
    {
        if (IsInitialized) throw new AlreadyInitializedException();
        IsInitialized = true;
        Value = value;
    }

    public override string ToString() => Value.ToString();
    public static implicit operator T(in UniReadOnly<T> readOnly) => readOnly.Value;
}

internal class AlreadyInitializedException : Exception
{
    public AlreadyInitializedException() : base("すでに初期化されています。")
    {
    }
}

使い方

使い方は簡単です。以下のように使用します。

  1. readonlyにしたいオブジェクトをUniReadOnly<T>として読み取り専用フィールドに持つ
  2. Initializeメソッドで値を初期化する
  3. Valueプロパティで値を取り出す

具体的な使用例は以下のようになります。
※処理内容についてのツッコミは勘弁してください

public class TestMonoBehaviour : MonoBehaviour
{
    private readonly UniReadOnly<string> m_readonlyString = new UniReadOnly<string>();

    void Start()
    {
        //読み取り専用にする値で初期化する
        m_readonlyString.Initialize("初期化します");
    }

    void Update()
    {
        //値を読み取り専用で取り出す
        Debug.Log(m_readonlyString);
    }
}

Startメソッド内でInitializeメソッドを呼び出し、読み取り専用にする値を設定しています。
これ以降に再びInitializeメソッドを呼び出すと、自作例外クラスであるAlreadyInitializedExceptionがスローされるため、値が書き換わらないことが保証されます

さらに、UniReadOnly<T>のインスタンス自体はフィールド初期化子でインスタンス化してしまえば良いので、readonly修飾子を付けることができます。
そのため、UniReadOnly<T>インスタンス自体を書き換えられる心配もなく、Initializeメソッドで設定したTインスタンスの不変性1がしっかりと担保されます。

以上から、UniReadOnly<T>クラスを使用することで、MonoBehaviourなクラスでもreadonly修飾子とほぼ同等の機能を実現することが可能となります。

implicit operator

ただ、通常のreadonly修飾子を使った方法とは違って、__毎回毎回Valueプロパティから中身にアクセスしなければならないのが面倒__です。
そこで、UniReadOnly<T>にはTへのimplicit operatorを実装してあります。

implicit operatorは聞き慣れない人も多いと思いますが、暗黙的な型変換を定義することができる演算子__です。
例えば、double型の変数にint型の値をそのまま入れられるのは、
doubleからintへ暗黙的な型変換が行われているから__です。

int i = 5;
double d = i; //intからdoubleへの型変換が暗黙的に行われている

このように、UniReadOnly<T>にも、UniReadOnly<T>からTへの暗黙的な変換を定義しました。これにより、以下のような記述が可能になり、ただのreadonlyフィールドと同じような扱いを可能にしています。

UniReadOnly<int> readonlyInt = new UniReadOnly<int>();
readonlyInt.Initialize(5);

int i = readonlyInt; //UniReadOnly<int>からintへの型変換が暗黙的に行われている

implicit operatorの適用範囲

ただし、UniReadOnly<T>からTへの暗黙的な型変換がいつも都合良く行われるというわけではありません。
以下のような場合、暗黙の型変換が行われず、素直にValueプロパティを使用する羽目になります。

Tインスタンス自体を直接使用しない場合

暗黙的な型変換が行われるのは、「変換先の型インスタンス(T)が求められる場面で、変換元のインスタンス(UniReadOnly<T>)が代入された場合」となります。
つまり、以下のようにTインスタンスを直接使用しない場合は暗黙的な型変換が行われず、エラーとなります。

UniReadOnly<string> readonlyStr = new UniReadOnly<string>();
readonlyStr.Initialize("初期値");

int stringLength = m_readonlyString.Length; //エラー!UniReadOnly<string>のLengthプロパティを見に行ってしまう

このようなことがしたい場合は、以下のようにきちんとValueプロパティを書かなければなりません。

UniReadOnly<string> readonlyStr = new UniReadOnly<string>();
readonlyStr.Initialize("初期値");

int stringLength = m_readonlyString.Value.Length;

インターフェイスが絡む場合

インターフェイスが絡む場合も何故か暗黙的な型変換が使用できないみたいです。以下のようなことはできません。

UniReadOnly<IEnumerable<int>> uniReadOnlyCollection = new UniReadOnly<IEnumerable<int>>();
uniReadOnlyCollection.Initialize(Enumerable.Range(1, 5));

IEnumerable<int> vs = uniReadOnlyCollection; //エラー!インターフェイスが絡んでいるので暗黙的な型変換が行われない

このようなことがしたい場合は、Valueプロパティを使うか、もしくはキャスト(明示的な型変換)を行う必要があります。

UniReadOnly<IEnumerable<int>> uniReadOnlyCollection = new UniReadOnly<IEnumerable<int>>();
uniReadOnlyCollection.Initialize(Enumerable.Range(1, 5));

IEnumerable<int> vs = uniReadOnlyCollection.Value;
IEnumerable<int> vs2 = (IEnumerable<int>)uniReadOnlyCollection;

なぜインターフェイスが絡むと暗黙的な型変換が使用できないか、いまいち理由がわかりません。誰か教えて下さい。

implicit operatorの是非

implicit operatorのおかげで、ある場面では通常の読み取り専用フィールドと同様の使用感を得られます。
しかし、また別の場面ではValueプロパティを介す必要がある場合もあります。

このように、統一した使い方ができないことから、読みづらくなる、可読性が下がると感じる方もいると思います。
僕自身は、そこまで問題とは感じない(むしろそこら中に.Valueがある方が読みづらいと感じる)のですが、implicit operatorの実装の是非は好みが分かれるところだと思いますので、好みに応じて調整してください(というか意見いただけると嬉しいです)。

最後に

readonlyのためにここまでする必要あるかって意見もあると思いますが、僕はどうしても気になるタチなので専用クラスを作ってしまいました。
賛否両論あると思いますが、よければ使ってみてください。ご意見ご感想あればコメントよろしくお願いします!

  1. 言うまでもありませんが、Tインスタンスがイミュータブルであることが保証されるという意味ではありません。

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?