🍺

Unity プロジェクトに C# 8.0, 9.0 を導入してみた

2020/12/19に公開

「 Applibot Advent Calendar 2020 」 19 日目の記事になります。
前日は @kozu_pid さんの「 Master Memory事始め(個人開発編) 」という記事でした!

はじめに

つい先日の 2020/12/15 に Unity 2020.2 TECH ストリームがリリースされました。
https://blogs.unity3d.com/jp/2020/12/15/unity-2020-2-tech-stream-is-now-available-for-download/

400 を超える改善点が盛り込まれた今回のアップデートですが、特に注目するべきポイントは C# 8.0 の正式サポートでしょう。
C# 7.X がサポートされた Unity 2018.3 のリリースから 2 年の歳月が流れての待望のアップデートなので、新しいものが大好きなエンジニアとしてはワクワクが止まりませんね。
早速使ってみたいところですが、業務で開発しているプロダクトに導入するためには事前の検証と動作確認が必須ですので、それらも兼ねて個人的に趣味で開発しているゲームに C# 8.0 を導入してみました。
また、更に先を見据えて C# 9.0 も試してみたので、本稿ではそこから得られた知見を共有していこうと思います。
本稿では各機能の詳細な仕様の解説は控えめに、もっと詳しく記述されてる外部サイトの紹介を交えつつ、筆者のプロジェクトに実際に導入してみて改善されたコードなどの実用的な例をいくつか紹介していきます。

検証環境

Unity で開発しているリズムゲームのプロジェクトを使用します。

  • Unity 2020.1.17f1
    • 記事の冒頭で 2020.2 の紹介をしましたが、C# 9.0 を検証する都合で 2020.1 を使用しています。
  • JetBrains Rider 2020.3
    • 上記のバージョンから C# 9.0 が本格的にサポートされています。

C# 9.0 を検証するために以下のパッケージを使用しています。
https://github.com/mob-sakai/CSharpCompilerSettingsForUnity

  • CSharpCompilerSettingsForUnity 1.3.0
    • Package Name: Microsoft.Net.Compilers
    • Package Version: 3.8.0
    • Language Version: Preview
    • Nullable: Disable

C# 8.0 の新機能

null 許容参照型

C# 7.X においての参照型は null を許容するため、意図的に null を許容しているのか型を見ても判断できませんでした。
C# 8.0 からは null 許容参照型を有効にすることで、値型と同様に型の末尾に ? を記述するかどうかで null の許容を制御できるようになりました。

// C# 7.X
void Test(string text)
{
  // string は参照型なので null が入っている可能性があり、 null の場合に Length にアクセスすると例外が発生する
  Debug.Log(text.Length); // -> NullReferenceException の可能性

  // null が入っている可能性を考慮する必要がある
  Assert.IsNotNull(text);
  Debug.Log(text?.Length);
}
// C# 8.0

// null 許容参照型 を有効にする
#nullable enable

void Test(string text)
{
  // null 許容参照型が有効になっている場合は ? が付いていない参照型に null が入ることはないので安心してメンバにアクセスできる
  Debug.Log(text.Length);
}

null 許容参照型を有効にする

null 許容参照型を有効にする手段は 2 つあり、プロジェクトの設定で全てのスクリプトに適用にする方法と #nullable ディレクティブ を使用してファイルごとに個別で指定する方法があります。
ある程度規模が大きい既存のプロジェクト全体に対してこの機能を有効にするととんでもない量の警告が出て絶望してしまうでしょう。
ですので #nullable ディレクティブ を使用して少しづつ対応していくのが現実的な手段だと思います。

Unity で使用する際の注意点

null 許容参照型は Unity の [SerializeField] と相性が悪いかもしれません。
以下の画像は既存の MonoBehaviour に対して nullable ディレクティブを有効にした際のスクショなのですが、 SerializeField な参照型が初期化されておらず null が入る可能性があるため警告が出てしまっています。
Unity としては SerializeField なフィールドは null も許容するのが正しいので型の末尾に ? を付けて null を許容する対応が必要になりますが、既存のスクリプト全てを修正するのはなかなか骨が折れそうです。。


C# と Unity でゲーム開発をしているとアプリの動作が停止する不具合に遭遇してしまうことがありますが、多くの場合が null による例外が原因という印象があります。
null 許容参照型を使用することで堅牢なコードを書くことができるので、不具合のお問い合わせに怯えることなく安心してプロダクトをリリースできるのではないでしょうか。

null 許容参照型について解説しているオススメのサイト様

仕様について
https://ufcpp.net/study/csharp/resource/nullablereferencetype/
既存のプロジェクトに導入する方法について
https://qiita.com/kojimadev/items/d2ee29e85c4c0859275a

switch 式

C# 7.X まではステートメントだった switch ですが、 C# 8.0 からは式として記述できるようになりました。
まずは以下の C# 7.X のコードを見てみましょう。

// C# 7.X
FlickDirection flickDirection;
switch (noteType)
{
    case "flick-l":
        flickDirection = FlickDirection.Left;
        break;
    case "flick-r":
        flickDirection = FlickDirection.Right;
        break;
    default:
        flickDirection = FlickDirection.None;
        break;
}

C# 7.X での switch は条件で値を返したいだけでも casebreak が頻出しています。
次に C# 8.0 で同じ挙動を実装する場合のコードを見てみましょう。

// C# 8.0
var flickDirection = noteType switch
{
    "flick-l" => FlickDirection.Left,
    "flick-r" => FlickDirection.Right,
    _ => FlickDirection.None
};

とてもスッキリしましたね。
今回はシンプルな例を紹介しましたが、条件の部分でパターンマッチングを使用することもできます。
C# 7.0 から実装されているパターンマッチングも C# 8.0, 9.0 では更に便利になっているので以下のオススメのサイトも要チェックです。

switch 式について解説しているオススメのサイト

仕様について
https://ufcpp.net/study/csharp/datatype/typeswitch/?p=5#switch-expression
パターンマッチングについて
https://ufcpp.net/study/csharp/datatype/patterns/
https://qiita.com/Zuishin/items/aac9f0dea33f96c265ac

C# 9.0 の新機能

Records

C# 9.0 から新しくレコード型が導入されました。
ざっくりと説明すると値を変更できない参照型を簡単に実装できる機能です。
本稿では以下の要件で実装していたクラスをレコードに置き換えてみました。

  • リズムゲームのノーツをタップして判定した結果を保持する JudgmentResult クラス
    • 判定結果, 対象のノーツの GUID, タップの誤差秒数を持っている
  • immutable である
  • 全てのプロパティが一致しているならば異なるインスタンスでも同一判定を行いたい
  • 生成済みの判定結果を更新する Upgrade メソッドが欲しい

まずは C# 7.X で実装した元々のコードを見てみましょう。

public enum JudgmentResultType { Bad, Good, Great, Perfect }

// C# 7.X
public class JudgmentResult
{
    public readonly JudgmentResultType Type;
    public readonly Guid TargetNoteGuid;
    public readonly float LagSeconds;

    public JudgmentResult(JudgmentResultType type, Guid targetNoteGuid, float lagSeconds)
    {
        Type = type;
        TargetNoteGuid = targetNoteGuid;
        LagSeconds = lagSeconds;
    }

    public JudgmentResult Upgrade()
    {
        return new JudgmentResult(Type + 1, TargetNoteGuid, LagSeconds);
    }

    // 以下の比較処理は rider で自動生成しました

    public bool Equals(JudgmentResult other)
    {
        if (ReferenceEquals(null, other))
        {
            return false;
        }

        if (ReferenceEquals(this, other))
        {
            return true;
        }

        return Type == other.Type && TargetNoteGuid.Equals(other.TargetNoteGuid) && LagSeconds.Equals(other.LagSeconds);
    }

    public static bool operator ==(JudgmentResult left, JudgmentResult right)
    {
        return Equals(left, right);
    }

    public static bool operator !=(JudgmentResult left, JudgmentResult right)
    {
        return !Equals(left, right);
    }

    public override bool Equals(object obj)
    {
        if (ReferenceEquals(null, obj))
        {
            return false;
        }

        if (ReferenceEquals(this, obj))
        {
            return true;
        }

        if (obj.GetType() != GetType())
        {
            return false;
        }

        return Equals((JudgmentResult)obj);
    }

    public override int GetHashCode()
    {
        unchecked
        {
            var hashCode = (int)Type;
            hashCode = (hashCode * 397) ^ TargetNoteGuid.GetHashCode();
            hashCode = (hashCode * 397) ^ LagSeconds.GetHashCode();
            return hashCode;
        }
    }
}

同一判定を実装するために EqualsGetHashCode をゴリゴリ書かないといけないのがしんどいですね。
rider などの IDE で自動生成することはできるのですが、生成されるコード量が多く見通しが悪い印象を受けます。
Upgrade メソッドのように自身の一部のプロパティを書き換えるだけでもコンストラクタに全てのプロパティを渡す必要があるのもしんどいです。

次に C# 9.0 で実装したコードを見てみましょう。

// C# 9.0
public record JudgmentResult(JudgmentResultType Type, Guid TargetNoteGuid, float LagSeconds)
{
    public JudgmentResult Upgrade()
    {
        return this with { Type = Type + 1 };
    }
}

とても短くなりました。ほんとですか?
確認のために SharpLab で C# デコンパイルを試してみたら確かに比較関数が生成されていました。

C# 9.0 のコードを C# デコンパイルした結果
public class JudgmentResult : IEquatable<JudgmentResult>
{
    [CompilerGenerated]
    private readonly JudgmentResultType <Type>k__BackingField;

    [CompilerGenerated]
    private readonly Guid <TargetNoteGuid>k__BackingField;

    [CompilerGenerated]
    private readonly float <LagSeconds>k__BackingField;

    [System.Runtime.CompilerServices.Nullable(1)]
    protected virtual Type EqualityContract
    {
        [System.Runtime.CompilerServices.NullableContext(1)]
        [CompilerGenerated]
        get
        {
            return typeof(JudgmentResult);
        }
    }

    public JudgmentResultType Type
    {
        [CompilerGenerated]
        get
        {
            return <Type>k__BackingField;
        }
        [CompilerGenerated]
        set
        {
            <Type>k__BackingField = value;
        }
    }

    public Guid TargetNoteGuid
    {
        [CompilerGenerated]
        get
        {
            return <TargetNoteGuid>k__BackingField;
        }
        [CompilerGenerated]
        set
        {
            <TargetNoteGuid>k__BackingField = value;
        }
    }

    public float LagSeconds
    {
        [CompilerGenerated]
        get
        {
            return <LagSeconds>k__BackingField;
        }
        [CompilerGenerated]
        set
        {
            <LagSeconds>k__BackingField = value;
        }
    }

    public JudgmentResult(JudgmentResultType Type, Guid TargetNoteGuid, float LagSeconds)
    {
        <Type>k__BackingField = Type;
        <TargetNoteGuid>k__BackingField = TargetNoteGuid;
        <LagSeconds>k__BackingField = LagSeconds;
        base..ctor();
    }

    public JudgmentResult Upgrade()
    {
        JudgmentResult judgmentResult = <Clone>$();
        judgmentResult.Type = Type + 1;
        return judgmentResult;
    }

    public override string ToString()
    {
        StringBuilder stringBuilder = new StringBuilder();
        stringBuilder.Append("JudgmentResult");
        stringBuilder.Append(" { ");
        if (PrintMembers(stringBuilder))
        {
            stringBuilder.Append(" ");
        }
        stringBuilder.Append("}");
        return stringBuilder.ToString();
    }

    [System.Runtime.CompilerServices.NullableContext(1)]
    protected virtual bool PrintMembers(StringBuilder builder)
    {
        builder.Append("Type");
        builder.Append(" = ");
        builder.Append(Type.ToString());
        builder.Append(", ");
        builder.Append("TargetNoteGuid");
        builder.Append(" = ");
        builder.Append(TargetNoteGuid.ToString());
        builder.Append(", ");
        builder.Append("LagSeconds");
        builder.Append(" = ");
        builder.Append(LagSeconds.ToString());
        return true;
    }

    [System.Runtime.CompilerServices.NullableContext(2)]
    public static bool operator !=(JudgmentResult r1, JudgmentResult r2)
    {
        return !(r1 == r2);
    }

    [System.Runtime.CompilerServices.NullableContext(2)]
    public static bool operator ==(JudgmentResult r1, JudgmentResult r2)
    {
        if ((object)r1 != r2)
        {
            if ((object)r1 != null)
            {
                return r1.Equals(r2);
            }
            return false;
        }
        return true;
    }

    public override int GetHashCode()
    {
        return ((EqualityComparer<System.Type>.Default.GetHashCode(EqualityContract) * -1521134295 + EqualityComparer<JudgmentResultType>.Default.GetHashCode(<Type>k__BackingField)) * -1521134295 + EqualityComparer<Guid>.Default.GetHashCode(<TargetNoteGuid>k__BackingField)) * -1521134295 + EqualityComparer<float>.Default.GetHashCode(<LagSeconds>k__BackingField);
    }

    [System.Runtime.CompilerServices.NullableContext(2)]
    public override bool Equals(object obj)
    {
        return Equals(obj as JudgmentResult);
    }

    [System.Runtime.CompilerServices.NullableContext(2)]
    public virtual bool Equals(JudgmentResult other)
    {
        if ((object)other != null && EqualityContract == other.EqualityContract && EqualityComparer<JudgmentResultType>.Default.Equals(<Type>k__BackingField, other.<Type>k__BackingField) && EqualityComparer<Guid>.Default.Equals(<TargetNoteGuid>k__BackingField, other.<TargetNoteGuid>k__BackingField))
        {
            return EqualityComparer<float>.Default.Equals(<LagSeconds>k__BackingField, other.<LagSeconds>k__BackingField);
        }
        return false;
    }

    [System.Runtime.CompilerServices.NullableContext(1)]
    public virtual JudgmentResult <Clone>$()
    {
        return new JudgmentResult(this);
    }

    protected JudgmentResult([System.Runtime.CompilerServices.Nullable(1)] JudgmentResult original)
    {
        <Type>k__BackingField = original.<Type>k__BackingField;
        <TargetNoteGuid>k__BackingField = original.<TargetNoteGuid>k__BackingField;
        <LagSeconds>k__BackingField = original.<LagSeconds>k__BackingField;
    }

    public void Deconstruct(out JudgmentResultType Type, out Guid TargetNoteGuid, out float LagSeconds)
    {
        Type = this.Type;
        TargetNoteGuid = this.TargetNoteGuid;
        LagSeconds = this.LagSeconds;
    }
}

with 式 でレコードの一部のプロパティを変更したインスタンスを生成できるのも便利ですね。
また、デコンパイルしたコードを見てみると ToString が override されているところも注目するべきポイントです。
class のインスタンスを ToString すると型名だけが返ってきますが、 record の場合はプロパティも含めた文字列が返ってくるのでログを出力するときに中身がわかって便利です。

Debug.Log(JudgmentResultClassInstance);
// class の場合: JudgmentResult

Debug.Log(JudgmentResultRecordInstance);
// record の場合: JudgmentResult { Type = Great, TargetNoteGuid = e8f90526-d4a2-4736-9760-93bc44abe87b, LagSeconds = 0 }

このように Records を使用すると簡単に immuatble で比較処理がある ValueObject のようなクラスを実装できるのは楽でいいですね。
ただ C# 9.0 環境でパフォーマンスやシリアライズを考慮した ValueObject を実装する場合は以下のようなライブラリを使った方がいいかもしれません。
https://github.com/Cysharp/UnitGenerator

Records について解説しているオススメのサイト

仕様について
https://docs.microsoft.com/ja-jp/dotnet/csharp/whats-new/csharp-9#record-types
class と record の比較
https://qiita.com/shimamura_io/items/80982b11ce41eca03e10

おわりに

本稿では趣味で開発している Unity のプロジェクトに C# 8.0, 9.0 を導入してみて便利だった機能を紹介しました。
Unity 2020.2 から正式サポートされた C# 8.0 は積極的に利用していきたいですし、 C# 9.0 のサポートも楽しみですね(何年後になるかわからないですが。。)
C# 8.0, 9.0 には今回紹介できなかった新機能もたくさんあるので、それらも実際に使ってみて知見を共有していこうと思います。

ちなみに筆者はフリーランスのエンジニアなのですが、現在契約しているアプリボットさんからアドベントカレンダーに参加する機会をいただいて本稿を執筆しました。嬉しい。。
アプリボットはアウトプットの意欲が高いエンジニアが集まっているとても素敵な環境なので、筆者も一緒に切磋琢磨していければいいなと思います。

以上、 「 Applibot Advent Calendar 2020 」 19 日目の記事でした!
明日は @dears31 さんです!

Discussion