🐷

ちょっとだけコードをきれいにする簡単なTips in Unity C#

2024/10/05に公開

はじめに

今回はTips的なものをたくさん紹介します。

UnityC#で、簡単に適用出来て少しだけコードが綺麗になるような、そういうテクニック集です。

早期return

手軽さ: ★★★★★
綺麗さ: ★★★★☆

もはや定番のテクニックですが、分かりやすく見た目が変わるので好きです。

public class Player : MonoBehaviour
{
    private void Update()
    {
        if (Input.GetKeyDown(KeyCode.Space))
        {
            if (Physics.Raycast(transform.position, transform.forward, out RaycastHit hit, 1f))
            {
                if (hit.collider.TryGetComponent(out Enemy enemy))
                {
                    enemy.Damage();
                }
            }
        }
    }
}

上記のコードはネストが深くなっているので、ちょっと読みづらいです。

早期returnを使って、ネストを減らしましょう。

考え方としては、条件に合うものを厳選するのではなく、条件に合わないものをはじくようにします。

結果、以下のようになります。

public class Sample : MonoBehaviour
{
    private void Update()
    {
        // 条件に合わないものをはじく
        if (!Input.GetKeyDown(KeyCode.Space)) return;
        if (!Physics.Raycast(transform.position, transform.forward, out RaycastHit hit, 1f)) return;
        if (!hit.collider.TryGetComponent(out Enemy enemy)) return;

        // 大丈夫ならDamage
        enemy.Damage();
    }
}

これでネストが減り、コードがすっきりしたのが分かります。基本的にネストは深くなるほど読みづらいと感じる傾向にあるので、早期returnを使ってネストを減らすことはコードの可読性を上げるために有効です。

また、早期returnを使うことで、条件に合わないものをはじくという考え方に変換することが出来ます。条件に合わないものをはじく部分と、大丈夫なら何かをする部分を分けることで、コードの意図が明確になります

このようにメソッドの先頭部にreturnを書いていくことをガード節とも呼びます。コンストラクタや初期化等でも使える考え方です。

確実に初期化する

手軽さ: ★★★★☆
綺麗さ: ★★★☆☆

実行中に取得する可能性があるオブジェクトを毎回nullチェックしていないでしょうか?

before
public class Sample : MonoBehaviour
{
    private SomeComponent _component;
    private SomeComponent2 _component2;

    /// <summary>
    /// この関数はどこかで初期化時に呼ばれることを期待している
    /// </summary>
    public void Init()
    {
        _component = GetComponent<SomeComponent>();
        _component2 = GetComponent<SomeComponent2>();
    }

    private void Update()
    {
        // UpdateがInitより先に呼ばれる可能性があるからnullチェックしないといけない
        if (_component != null)
        {
            _component.Foo();
        }

        if (_component2 != null)
        {
            _component2.Bar();
        }
    }

    public void Call()
    {
        // CallBarがInitより先に呼ばれる可能性があるからnullチェックしないといけない
        if (_component2 != null)
        {
            _component2.Foo2();
        }
    }
}

本当に実行中の良く分からないタイミングでnullチェックをする場合は根本的な修正が面倒ですが、Awake()Start(), その他自前の初期化関数などにて初期化されることが期待される場合は、まずはその処理が必ず呼ばれるようにしたいところです。

つまり、そもそもnullだとまずい場合は、最初に確実に初期化されるようにしましょう。

それが不可能な場合は、nullチェックを減らすために、_isInitializedなどのフラグを使って初期化されているかどうかを管理するという方法があります。

こっちの方が、初期化されたかどうかで判定しているという意図が明確になります。

after
public class Sample : MonoBehaviour
{
    private SomeComponent _component;
    private SomeComponent2 _component2;

    /// <summary>
    /// 初期化されたかどうか
    /// </summary>
    private bool _isInitialized; 

    public void Init()
    {
        _component = GetComponent<SomeComponent>();
        _component2 = GetComponent<SomeComponent2>();
        _isInitialized = true; // 初期化された
    }

    private void Update()
    {
        if (!_isInitialized) return; // 初期化されていないなら早期return

        _component.Foo();
        _component2.Bar();
    }

    public void Call()
    {
        if (!_isInitialized) return; // 初期化されていないなら早期return

        _component2.Foo2();
    }
}

null合体演算子

手軽さ: ★★★☆☆
綺麗さ: ★★★☆☆

??=を使えば初期化すらしなくてもいいかもしれません。

public class Sample : MonoBehaviour
{
    private SomeComponent _component; // 使わない
    private SomeComponent2 _component2; // 使わない

    private SomeComponent Component => _component ??= GetComponent<SomeComponent>(); // 使うのはこっち
    private SomeComponent2 Component2 => _component2 ??= GetComponent<SomeComponent2>(); // 使うのはこっち

    private void Update()
    {
        Component.Foo();
        Component2.Bar();
    }

    public void Call()
    {
        Component2.Foo2();
    }
}

??=は左辺がnullの場合に右辺を代入する演算子です。下側のプロパティしか使わないように注意すれば、この方法も使えます。

外部に公開し、外部のみが使用する場合はより相性がいいです。

public class PlayerRoot : MonoBehaviour
{
    private PlayerMovement _movement;
    public PlayerMovement Movement => _movement ??= GetComponent<PlayerMovement>();

    private PlayerAttack _attack;
    public PlayerAttack Attack => _attack ??= GetComponent<PlayerAttack>();
}

TryGetComponent

手軽さ: ★★★★☆
綺麗さ: ★☆☆☆☆

TryGetComponent<T>(out T Component)GetComponent<T>()と違い、コンポーネントがアタッチされていない場合にnullではなくboolを返します。

これによって、nullチェックを省略することができます。

outを使った関数になじみがないと最初は少し使うのに苦労するかもしれませんが、動的にコンポーネントを取得する際にはTryGetComponentを使うのがかなり便利な時があるので覚えると少し楽になります。

とはいっても正直好みのレベルかなーとは思います。

GetComponent
public class Before : MonoBehaviour
{
    private void OnCollisionEnter(Collision collision)
    {
        var player = collision.gameObject.GetComponent<Player>();
        if (player == null) return;
        player.Damage();
    }
}
TryGetComponent
public class After : MonoBehaviour
{
    private void OnCollisionEnter(Collision collision)
    {
        // outでplayerに取得したコンポーネントが入る。返り値はbool
        if (collision.gameObject.TryGetComponent<Player>(out var player))
        {
            player.Damage(); // nullのときはfalseでこの中に入らないのでnullチェック不要!
        }
    }
}

使い方としては、取得したいオブジェクトに対してTryGetComponentを書くところまではGetComponentと同じですが、out var 変数名を使って変数を宣言すると、その変数に取得したコンポーネントが入ります(元々定義したフィールド等に代入することもできます)。

この関数自体の戻り値はboolで、取得できたかどうかを返します。なので、if文で取得できたかどうかを判定できます。

取得できたということはnullではないので、nullチェックを省略することができます。

Assertion等にも使えます。

using UnityEngine.Assertions;

public class Sample : MonoBehaviour
{
    private Player _player;

    private void Start()
    {
        Assert.IsTrue(TryGetComponent<Player>(out _player)); // 取得しながらもしnullだったらエラーを出す
    }
}

肥大化した変数をまとめる

手軽さ: ★★☆☆☆
綺麗さ: ★★★★☆

大きなクラスになると、変数が増えてきます。単純にコードの行数が増え、1ファイルの圧力が高い印象になっていきます。

平気なら何も問題はありませんが、個人的には若干気になる場合もあるので、そういった場合には変数をわけてまとめます。

特に、初期化時の情報については、ScriptableObjectでまとめると綺麗です。

public class Player : MonoBehaviour
{
    [SerializeField] private PlayerInitInfo _initInfo; // 初期化情報をまとめたScriptableObject
}

PlayerInitInfoScriptableObjectとして作成して、ここに初期化情報をまとめておきます。

[CreateAssetMenu(menuName = "CreatePlayerInitInfo")]
public class PlayerInitInfo : ScriptableObject
{
    public int Hp;
    public int Attack;
}

ScriptableObjectの内部の値を変更することはお作法に反するので、そういう意味でも初期化情報との相性がいいです。

カプセル化でget-onlyな部分を増やす

手軽さ: ★★★☆☆
綺麗さ: ★☆☆☆☆

先ほどのPlayerInitInfoHpはやろうと思えば書き換えることができてしまいます。

より丁寧に書くなら、フィールドの読み取りだけを公開する形にしましょう。

public class PlayerInitInfo : ScriptableObject
{
    [SerilaizeField] private int _hp;
    public int Hp => _hp; // get-onlyなプロパティ

    [SerilaizeField] private int _attack;
    public int Attack => _attack;
}

記述は増えますが、外部からの不要な書き換えをコンパイラが防いでくれるので、安全性が増します。

public class Player : MonoBehaviour
{
    [SerializeField] private PlayerInitInfo _initInfo;

    private int _hp;
    private int _attack;

    private void Start()
    {
        _hp = _initInfo.Hp;
        _attack = _initInfo.Attack;

        _initInfo.Hp = 100; // コンパイルエラー
    }
}

肥大化した引数をまとめる

手軽さ: ★★☆☆☆
綺麗さ: ★★★★☆

public class Player : MonoBehaviour
{
    private void Attack(int damage, int range, int speed, bool isCritical = false)
    {
        if (damage < 0 || range < 0 || speed < 0)
        {
            Debug.LogError("不正な値が入っています");
            return;
        }

        if (isCritical)
        {
            // 何か処理
        }
        else
        {
            // 何か処理
        }
    }
}

上記のようなメソッドは、引数が増えると見づらくなりますし、不正な値を検出するためのロジックが散逸してしまいがちです。

これをクラス化してみましょう。

public class AttackInfo
{
    public readonly int Damage;
    public readonly int Range;
    public readonly int Speed;
    public readonly bool IsCritical;

    public AttackInfo(int damage, int range, int speed, bool isCritical = false)
    {
        if (damage < 0 || range < 0 || speed < 0)
        {
            Debug.LogError("不正な値が入っています");
            return;
        }

        Damage = damage;
        Range = range;
        Speed = speed;
        IsCritical = isCritical;
    }
}


public class Player : MonoBehaviour
{
    private void Attack(AttackInfo info)
    {
        // 不正な値の検出はAttackInfoクラスのコンストラクタで行われるので、ここでは不要

        if (info.IsCritical)
        {
            // 何か処理
        }
        else
        {
            // 何か処理
        }
    }
}
public class PlayerAttackExecuter : MonoBehaviour
{
    private void Start()
    {
        var info = new AttackInfo
        {
            Damage = 10,
            Range = 5,
            Speed = 3,
            IsCritical = true
        };

        var player = GetComponent<Player>();
        player.Attack(info);
    }
}

これで、引数が増えても見やすくなり、意味も分かりやすくなりました。

知識ごとにまとめるという意識が付くと、クラスの責務をより明確に分離することにもつながります。

Discussion