Unity 2021 LTS 也出來好一陣子了,在 2021.2 版後就開始導入 C# 9.0。使用了半年後,發覺 C# 9.0 當中新增的語法可以讓程式碼更加簡潔易讀,整理成本篇來介紹個人常用的語法。

C# 9.0 的支援

Unity 2021 LTS 並非完全支援 C# 9.0 的語法[1],只有其中一部份可以使用,而如果要使用下面介紹的 init-only setter 跟 record 類型的話,則要再自行新增一個程式碼,檔名我會命名為 IsExternalInit.cs

namespace System.Runtime.CompilerServices
{
    public class IsExternalInit
    {
    }
}

否則會出現編譯錯誤:

error CS0518: Predefined type 'System.Runtime.CompilerServices.IsExternalInit' is not defined or imported

這是因為 Unity 是使用 .net 4.8,但 IsExternalInit 是在 .net 5.0 之後才出現的。

new 的目標型別

如果變數已知型別的話,就可以不用在 new 再寫一次型別了:

public class Foo
{
    private Dictionary<int, string> _idStrDict = new ();
}

在回傳值的建立也可以:

public SomeData GetSomeData()
{
    return new ();
}

但是 Rider 建議在回傳值上還是要指定型別,因為距離宣告有點遠,可能會不能一眼看出回傳型別是什麼。

Lambda 的捨棄參數

以往可以用 _ 來捨棄 lambda 或匿名函式的參數,但如果有多個參數要捨棄就不能重複宣告,只能 _____ 這樣疊下去。不過在 C# 9.0 後,就都可以用 _ 來捨棄所有參數了。

public void SomeFunc(Action<WorldData, LocalData> onFinish)
{
    ...
    onFinish(worldData, localData);
}

SomeFunc((_, _) => Debug.Log("Called"));

Init-Only Setter

Init-only setter 能讓屬性(property)在建構子中被設定之外,也能在 object initializer 中被設定,就像建立 struct 物件那樣。在建構子中:

public class Foo
{
    public uint MaxHP { get; init; }

    public Foo(uint maxHP)
    {
        MaxHP = maxHP;
    }
}

var foo = new Foo(100);
foo.MaxHP = 200;    // Error

或是 object initializer 中:

public class Foo
{
    public uint MaxHP { get; init; }
}

var foo = new Foo {
    MaxHP = 100     // OK
};
foo.MaxHP = 200;    // Error

init 的屬性不像 publicreadonly 欄位或是只有 get 的屬性那樣,只能在建構子中初始化。

應該不難發現使用 init-only setter 可以做出 reference type 的資料封裝物件,C# 就提供了下面的 record 類型來快速定義這類物件。

record 類型

record 類型主要是用來製作 reference type 的不可變物件,可以當作是 reference type 的 struct,有幾個特性:

  • 其物件是 reference type(struct 是 value type)。也就是說物件在傳遞的時候是整個物件被傳遞,而不是複製一份
  • 可以繼承(strcut 不行)
  • 其屬性預設是不可變的(immutable),但是也可以宣告可變(mutable)的屬性(strcut 的成員都是不可變的)

宣告

簡易宣告

要宣告 record 類型,最簡單的形式是用 positional syntax:

public record PlayerData(string Name, uint MaxHP);

用 positional syntax 宣告 record 類型,編譯器會:

  • 生成對應的 init-only 屬性
  • 生成對應的建構子
  • 生成 Deconstruct 函式,並對應宣告的屬性提供 out 參數
var data = new PlayerData("Player1", 100);
data.Name = "Player2";      // Error
var (name, maxHp) = data;   // Deconstruct 函式

要注意 positional syntax 的參數大小寫,如果宣告時是小寫的話,屬性的名稱也會是小寫。

預設值

如果要預設值就可以像宣告一般屬性的形式那樣:

public record PlayerData
{
    public string Name { get; init; } = default!;
    public uint MaxHP { get; init; } = 100;
}

不過這樣宣告的話,編譯器就不會自動生成建構子跟 Deconstruct 函式了,得要自行定義,但是可以透過 object initializer 來初始化成員,沒有被初始化的成員則會擁有預設值:

var data = new PlayerData {
    Name = "Player1"
};
data.Name = "Player2";  // Error
Debug.Log(data.MaxHP);  // 100

另外,預設值也可以用 static 函式設置:

public record PlayerData
{
    public uint Id { get; init; } = GetNextId();
    public string Name { get; init; }
    public uint MaxHP { get; init; }

    private static uint _curId;
    private static uint GetNextId() => ++_curId;
}

如果連 Id 都不想被外部初始化的話,就把 init 去掉。

可變的屬性

record 類型並沒有限制屬性不能修改,如果要宣告成可修改的屬性,就用 set

public record PlayerData
{
    public string Name { get; set; } = default!;
    public uint MaxHP { get; set; } = 100;
}

var data = new PlayerData();
data.MaxHp = 200;   // OK

特性整理

以上三種宣告方式會讓 record 在初始化跟存取屬性上有點變化,整理成下表:

宣告方式 有參數建構子 Object Initializer Deconstruct 指定預設值 屬性可修改
簡易宣告 :heavy_check_mark: :x: :heavy_check_mark: :x: :x:
預設值 可宣告 :heavy_check_mark: 需宣告 :heavy_check_mark: :x:
可變的屬性 可宣告 :heavy_check_mark: 需宣告 :heavy_check_mark: :heavy_check_mark:

功能

不管用什麼形式宣告 record 類型,都會有以下功能:

值相等

編譯器會自動生成 Equals 函式。不同於以 class 做為資料類別,還要複寫 Equals 函式來比較兩個物件是否相等。如果兩個 record 物件的型別一樣且所有成員的「值」相等,那這兩個物件就是相等,以 PlayerData 為例:

var data1 = new PlayerData("PlayerB", 200);
var data2 = new PlayerData("PlayerB", 200);

Debug.Log(data1 == data2);  // True

另外,不同於 value type 是用 reflection 來比較值,record 類型是對屬性取值來比較,對於較大的資料結構會比較快速。

但要注意的是,如果 record 的屬性有 reference type 的話,那還是要提供 Equals 函式(建議是在該 reference type 的定義中提供)來比較值是否相等,因為 reference type 預設兩個物件要是同一個物件才是相等。然而 record 類型並沒有 Equals 函式供複寫,如果要讓 ==!= 等運算子正確回傳結果的話,會讓 record 失去可被繼承的能力

非破壞性更動

使用 with 可以複製既有的 record 物件,即使有屬性是 init-only,還是可以更改部份的值,稱為非破壞性更動(Nondestructive Mutation):

var data1 = new PlayerData("PlayerA", 100);
var data2 = data1 with { Name = "PlayerB" };
var data3 = data1 with { };

Debug.Log(data1 == data2);                  // False
Debug.Log(ReferenceEquals(data1, data2));   // False
Debug.Log(data1 == data3);                  // True
Debug.Log(ReferenceEquals(data1, data3));   // False

或許稱為非破壞性是因為不會影響原本的物件吧

內建 ToString

編譯器會自動生成 ToString 輸出 類別名稱 { 屬性名 = 值, 屬性名 = 值, ... } 字串:

var data = new PlayerData("PlayerA", 100);
Debug.Log(data);    // PlayerData { Name = PlayerA, MaxHP = 100 }

當然如果屬性中有 reference type 或是 struct 的話,要複寫 ToString 函式(可以在 record 宣告中複寫,不像 Equals)來生成正確的字串內容。

繼承

record 類型可以被繼承,也可以宣告為 abstract

public abstract record ItemData(string Name, uint MaxNum);
public record HPItemData(string Name, uint MaxNum, uint RecoveryAmount)
    : ItemData(string Name, uint MaxNum);

var data = new HPItemData("SmallHPRecovery", 100, 50);

或是:

public record EnemyData
{
    public string Name { get; init; }
    public uint MaxHP { get; init; }
}
public record RangedEmemyData : EnemyData
{
    public uint Range { get; init; }
}

var data = new RangedEnemyData {
    Name = "RangedEnemy1",
    MaxHP = 200,
    Range = 10
};

Pattern Matching

邏輯與關係樣式

C# 9.0 強化了 pattern matching,加入新的邏輯(logical patterns)和關係樣式(relational patterns)。

對於一個數值的多重比較原本要寫成:

public static bool IsLetter(this char c) =>
    (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');

透過新樣式可以寫成:

public static bool IsLetter(this char c) =>
    c is >= 'a' and <= 'z' or >= 'A' and <= 'Z';

and 的優先度跟 && 一樣,比 or|| 高。

與 switch expression 配合

原本比較賦值要寫成:

public Grade GetGrade(int score)
{
    if (score >= 90 && score <= 100)
        return Grade.S;
    if (score >= 80 && score < 90)
        return Grade.A;
    if (score >= 70 && score < 80)
        return Grade.B;
    if (score >= 0 && score < 70)
        return Grade.C;

    return Grade.Invalid;
}

邏輯跟關係樣式搭配 C# 8.0 的 switch expression ,就可以寫出簡潔的比較賦值:

public Grade GetGrade(int score) =>
    score switch {
        >= 90 and <= 100 => Grade.S,
        >= 80 and < 90 => Grade.A,
        >= 70 and < 80 => Grade.B,
        >= 0 and < 70 => Grade.C,
        _ => Grade.Invalid
    };

否定樣式

另外,也加入了 not,最常用在 null checking:

if (data is not null)
    Debug.Log("Valid data")

is 或是 is not 來檢查變數是否是 null(就是單純看有沒有指定物件),可以避免該類別有複寫 ==!= 運算子,造成判定 null 上出現問題。

括號樣式

要注意的是 not 的優先度上面提到的樣式高,優先度是:not>=< 等關係運算子 → andor。所以可以用括號樣式(parenthesized patterns)來確保運算正確:

public bool IsOutOfRange(int value) =>
    value is not (>= 0 and <= 100);

如果沒有加括號的話,結果就會是錯誤的:

value is not >= 0 and <= 100
... value is (not >= 0) and <= 100
... value is < 0 and <= 100
... value is < 0

限制

pattern matching 是用變數對定值做比較,而不能對變數,否則會出錯:

public bool IsOutOfRange(int value, int min, int max) =>
    value is not (>= min and <= max);   // error CS0150: A constant value is expected

參考資料

  1. C# Compiler - Unity 2021.3 Manual
  2. What’s new in C# 9.0 - Microsoft Learn
  3. Deconstructing tuples and other types - Microsoft Learn
  4. Custom equality check for C# 9 records - StackOverflow
  5. Switch Expression - Microsoft Learn