[Unity] Unity 2021 LTS - C# 9.0 的新語法
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
的屬性不像 public
的 readonly
欄位或是只有 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 |
指定預設值 | 屬性可修改 |
---|---|---|---|---|---|
簡易宣告 | |||||
預設值 | 可宣告 | 需宣告 | |||
可變的屬性 | 可宣告 | 需宣告 |
功能
不管用什麼形式宣告 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
→ >=
、<
等關係運算子 → and
→ or
。所以可以用括號樣式(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