C# 10 已與 .NET 6、VS2022 一起發行,本文按照.NET 的發行順序,根據微軟官方文件整理 C# 中一些有趣的語法特性。
註:基於不同.NET 平台建立的專案,預設支援的 C# 版本是不一樣的。下面介紹的語法特性,會說明引入 C# 的版本,在使用過程中,需要注意使用 C# 的版本是否支援對應的特性。C# 語言版本控制,可參考官方文件。
匿名函式
匿名函式是 C# 2 推出的功能,顧名思義,匿名函式只有方法本體,沒有名稱。匿名函式使用 delegate 建立,可轉換為委派。匿名函式不需要指定回傳值型別,它會根據 return 陳述式自動判斷回傳值型別。
註:C# 3 後推出了 lambda 運算式,使用 lambda 可以以更簡潔的方式建立匿名函式,應盡量使用 lambda 來建立匿名函式。與 lambda 不同的是,使用 delegate 建立匿名函式可以省略參數列表,可將其轉換為具有任何參數列表的委派型別。
// 使用delegate關鍵字建立,無需指定回傳值,可轉換為委派,可省略參數列表(與lambda不同)
Func<int, bool> func = delegate { return true; };
自動屬性
從 C# 3 開始,當屬性存取器中不需要其他邏輯時,可以使用自動屬性,以更簡潔的方式宣告屬性。編譯時,編譯器會為其建立一個僅可以透過 get、set 存取器存取的私用、匿名欄位。使用 VS 開發時,可以透過 snippet 程式碼片段 prop+2 次 tab 快速產生自動屬性。
// 屬性舊寫法
private string _name;
public string Name
{
get { return _name; }
set { _name = value; }
}
// 自動屬性
public string Name { get; set; }
另外,在 C# 6 以後,可以初始化自動屬性:
public string Name { get; set; } = "Louzi";
匿名型別
匿名型別是 C# 3 後推出的功能,它無需顯示定義型別,將一組唯讀屬性封裝到單一物件中。編譯器會自動推斷匿名型別的每個屬性的型別,並產生型別名稱。從 CLR 的角度看,匿名型別與其他參考型別沒什麼區別,匿名型別直接衍生自 object。如果兩個或多個匿名物件指定了順序、名稱、型別相同的屬性,編譯器會把它們視為相同型別的執行個體。在建立匿名型別時,如果不指定成員名稱,編譯器會把用於初始化屬性的名稱作為屬性名稱。
匿名型別多用於 LINQ 查詢的 select 查詢運算式。匿名型別使用 new 與初始化列表建立:
// 使用new與初始化列表建立匿名型別
var person = new { Name = "Louzi", Age = 18 };
Console.WriteLine($"Name: {person.Name}, Age: {person.Age}");
// 用於LINQ
var productQuery =
from prod in products
select new { prod.Color, prod.Price };
foreach (var v in productQuery)
{
Console.WriteLine("Color={0}, Price={1}", v.Color, v.Price);
}
LINQ
C# 3 推出了殺手鐧功能,查詢運算式,即語言整合查詢(LINQ)。查詢運算式以查詢語法表示查詢,由一組類似 SQL 的語法撰寫的子句組成。
查詢運算式必須以 from 子句開頭,必須以 select 或 group 子句結尾。在第一個 from 子句與最後一個 select 或 group 子句之間,可以包含:where、orderby、join、let、其他 from 子句等。
可以為 SQL 資料庫、XML 文件、ADO.NET 資料集及實作了 IEnumerable 或 IEnumerable 介面的集合物件進行 LINQ 查詢。
完整的查詢包括建立資料來源、定義查詢運算式、執行查詢。查詢運算式變數是儲存查詢而不是查詢結果,只有在迴圈存取查詢變數後,才會執行查詢。
可使用查詢語法表示的任何查詢都可以使用方法表示,建議使用更易讀的查詢語法。有些查詢作業(如 Count 或 Max)沒有對等的查詢運算式子句,必須使用方法呼叫。可以結合使用方法呼叫和查詢語法。
關於 LINQ 的詳細文件,參見微軟官方文件
// Data source.
int[] scores = { 90, 71, 82, 93, 75, 82 };
// Query Expression.
IEnumerable<int> scoreQuery = //query variable
from score in scores //required
where score > 80 // optional
orderby score descending // optional
select score; //must end with select or group
// Execute the query to produce the results
foreach (int testScore in scoreQuery)
{
Console.WriteLine(testScore);
}
Lambda
C# 3 推出了很多強大的功能,如自動屬性、擴充方法、隱含型別、LINQ,以及 Lambda 運算式。
建立 Lambda 運算式,需要在 => 左側指定輸入參數(空括號指定零個參數,一個參數可以省略括號),右側指定運算式或陳述式區塊(通常兩三條陳述式)。任何 Lambda 運算式都可以轉換為委派型別,運算式 Lambda 陳述式還可以轉換為運算式樹狀結構(陳述式 Lambda 不可以)。
匿名函式可以省略參數列表,Lambda 中不使用的參數可以使用捨棄指定(C# 9)。
使用 async 和 await,可以建立包含非同步處理的 Lambda 運算式和陳述式(C# 5)。
從 C# 10 開始,當編譯器無法推斷回傳型別時,可以在參數前面指定 Lambda 運算式的回傳型別,此時參數必須加括號。
// Lambda轉換為委派
Func<int, int> square = x => x * x;
// Lambda轉換為運算式樹狀結構
System.Linq.Expressions.Expression<Func<int, int>> e = x => x * x;
// 使用捨棄指定不使用的參數
Func<int, int, int> constant = (_, _) => 42;
// 非同步Lambda
var lambdaAsync = async () => await JustDelayAsync();
Console.WriteLine($"main thread id: {Thread.CurrentThread.ManagedThreadId}");
lambdaAsync();
static async Task JustDelayAsync()
{
await Task.Delay(1000);
Console.WriteLine($"JustDelayAsync thread id: {Thread.CurrentThread.ManagedThreadId}");
}
// 指定回傳型別,不指定回傳型別會報錯
var choose = object (bool b) => b ? 1 : "two";
擴充方法
擴充方法也是 C# 3 推出的功能,它能夠向現有型別新增方法,且無需修改原始型別。擴充方法是一種靜態方法,不過是透過執行個體物件語法進行呼叫,它的第一個參數指定方法操作的型別,用 this 修飾。編譯器在編譯為 IL 時會轉換為靜態方法的呼叫。
如果型別中具有與擴充方法相同名稱和簽章的方法,則編譯器會選擇型別中的方法。編譯器進行方法呼叫時,會先在該型別的執行個體方法中尋找,找不到再去搜尋該型別的擴充方法。
最常見的擴充方法是 LINQ,它將查詢功能新增到現有的 System.Collections.IEnumerable 和 System.Collections.Generic.IEnumerable 型別中。
為 struct 新增擴充方法時,由於是值傳遞,只能對 struct 物件的複本進行變更。從 C# 7.2 開始,可以為第一個參數新增 ref 修飾以進行參考傳遞,這樣就可以對 struct 物件本身進行修改了。
static class MyExtensions
{
public static void OutputStringExtension(this string s) => Console.WriteLine($"output: {s}");
public static void OutputPointExtension(this Point p)
{
p.X = 10;
p.Y = 10;
Console.WriteLine($"output: ({p.X}, {p.Y})");
}
public static void OutputPointWithRefExtension(ref this Point p)
{
p.X = 20;
p.Y = 20;
Console.WriteLine($"output: ({p.X}, {p.Y})");
}
}
// class擴充方法
"Louzi".OutputStringExtension();
// struct擴充方法
Point p = new Point(5, 5);
p.OutputPointExtension(); // output: (10, 10)
Console.WriteLine($"original point: ({p.X}, {p.Y})"); // output: (5, 5)
p.OutputPointWithRefExtension(); // output: (20, 20)
Console.WriteLine($"original point: ({p.X}, {p.Y})"); // output: (20, 20)
隱含型別(var)
從 C# 3 開始,在方法範圍內可以宣告隱含型別變數(var)。隱含型別為強型別,由編譯器決定型別。
var 常用於呼叫建構函式建立物件執行個體時,從 C# 9 開始,這種場景也可以使用確定型別的 new 運算式:
// 隱含型別
var s = new List<int>();
// new運算式
List<int> ss = new();
註:當回傳匿名型別時,只能使用 var。
物件、集合初始化列表
從 C# 3 開始,可以在單條陳述式中執行個體化物件或集閤並執行成員指派。
使用物件初始化列表,可以在建立物件時向物件的任何可存取欄位或屬性指派值,可以指定建構函式參數或忽略參數以及括號。
public class Person
{
// 自動屬性
public int Age { get; set; }
public string Name { get; set; }
public Person() { }
public Person(string name)
{
Name = name;
}
}
var p1 = new Person { Age = 18, Name = "Louzi" };
var p2 = new Person("Sherilyn") { Age = 18 };
從 C# 6 開始,物件初始化列表不僅可以初始化可存取欄位和屬性,還可以設定索引器。
public class MyIntArray
{
public int CurrentIndex { get; set; }
public int[] data = new int[3];
public int this[int index]
{
get => data[index];
set => data[index] = value;
}
}
var myArray = new MyIntArray { [0] = 1, [1] = 3, [2] = 5, CurrentIndex = 0 };
集合初始化列表可以指定一個或多個初始值:
var persons = new List<Person>
{
new Person { Age = 18, Name = "Louzi" },
new Person { Age = 18, Name = "Sherilyn" }
};
內建泛型委派
.NET Framework 3.5/4.0,分別提供了內建的 Action 和 Func 泛型委派型別。void 回傳型別的委派可以使用 Action 型別,Action 的變體最多有 16 個參數。有回傳值型別的委派可以使用 Func 型別,Func 型別的變體最多同樣 16 個參數,回傳型別為 Func 宣告中的最後一個型別參數。
Action<int> actionInstance = ActionInstance;
Func<int, string> funcInstance = FuncInstance;
static void ActionInstance(int n) => Console.WriteLine($"input: {n}");
static string FuncInstance(int n) => $"param: {n}";
dynamic
C# 4 主要的功能就是引入了 dynamic 關鍵字。dynamic 型別在變數使用及其成員參考時會繞過編譯時期型別檢查,在執行階段再進行解析。這便實現了與動態型別語言(如 JavaScript)類似的建構。
dynamic dyn = 1;
Console.WriteLine(dyn.GetType()); // output: System.Int32
dyn = dyn + 3; // 如果dyn是object型別,此句則會報錯
命名引數與選擇性引數
C# 4 引入了命名引數和選擇性引數。命名引數可為形式參數指定實際引數,方式是指定匹配的實際引數與形式參數,這時無需匹配參數列表中的位置。選擇性引數透過指定參數預設值,可以省略實際引數。選擇性引數需位於參數列表末尾,如果為一系列選擇性引數中的任意一個提供了實際引數,則必須為該參數前面的所有選擇性引數提供實際引數。
也可以使用 OptionalAttribute 特性宣告選擇性引數,此時無需為形式參數提供預設值。
// 命名引數與選擇性引數
PrintPerson(age: 18, name: "Louzi");
// static void PrintPerson(string name, int age, [Optional, DefaultParameterValue("男")] string sex)
static void PrintPerson(string name, int age, string sex = "男") =>
Console.WriteLine($"name: {name}, age: {age}, sex: {sex}");
靜態匯入
C# 6 中推出了靜態匯入功能,使用 using static 指令匯入型別,可以無需指定型別名稱即可存取其靜態成員和巢狀型別,這樣避免了重複輸入型別名稱導致的晦澀程式碼。
using static System.Console;
WriteLine("Hello CSharp");
例外狀況篩選器(when)
從 C# 6 開始,when 可用於 catch 陳述式中,用來指定為執行特定例外狀況處理常式必須為 true 的條件運算式,當運算式為 false 時,則不會執行例外狀況處理。
public static async Task<string> MakeRequest()
{
var client = new HttpClient();
var streamTask = client.GetStringAsync("https://localHost:10000");
try
{
var responseText = await streamTask;
return responseText;
}
catch (HttpRequestException e) when (e.Message.Contains("301"))
{
return "Site Moved";
}
catch (HttpRequestException e) when (e.Message.Contains("404"))
{
return "Page Not Found";
}
catch (HttpRequestException e)
{
return e.Message;
}
}
自動屬性初始化運算式
C# 6 開始,可以為自動屬性指定初始化值以使用型別預設值以外的值:
public class DefaultValueOfProperty
{
public string MyProperty { get; set; } = "Property";
}
運算式主體
從 C# 6 起,支援方法、運算子和唯讀屬性的運算式主體定義,自 C# 7.0 起,支援建構函式、完成項、屬性、索引器的運算式主體定義。
static void NewLine() => Console.WriteLine();
null 條件運算子
C# 6 起,推出了 null 條件運算子,僅當運算元的計算結果為非 null 時,null 條件運算子才會將成員存取?.或元素存取?[]運算應用於其運算元;否則,將回傳 null。
// null條件運算式
public class ConditionalNull
{
event EventHandler AEvent;
public void RaiseAEvent() => AEvent?.Invoke(this, EventArgs.Empty);
}
內插字串
從 C# 6 開始,可以使用$在字串中插入運算式,使程式碼可讀性更高也降低了字串串接出錯的機率。如果在內插字串中包含大括號,需使用兩個大括號(”{{“或””}}”)。如果內插運算式需使用條件運算子,需要將其放在括號內。從C# 8起,可以使用$@”…”或@\(”…”形式的內插逐字字串,在此之前的版本,必須使用\)@”…”形式。
Console.WriteLine($"{name} is {age} year{(age == 1 ? "" : "s")} old.");
nameof
C# 6 提供了 nameof 運算式,nameof 可產生變數、型別或成員名稱(非完整名稱)作為字串常數。
public string Name
{
get => name;
set => name = value ?? throw new ArgumentNullException(nameof(value), $"{nameof(Name)} cannot be null");
}
out 改進
C# 7.0 中對 out 語法進行了改進,可以直接在方法呼叫的參數列表中宣告 out 變數,無需再單獨撰寫一條宣告陳述式:
void Function(out int arg) { ... }
// 未改進前
int n;
Function(out n);
// 改進後
Function(out int n);
元組
C# 7.0 中引入了對元組的語言支援(之前版本也有元組但效率低下),可以使用元組表示包含多個資料的簡單結構,無需再專門寫一個 class 或 struct。元組是實值型別的,是包含多個公用欄位以表示資料成員的輕量級資料結構,無法為其定義方法。C# 7.3 後元組支援==與!=。
// 方式一,使用元組欄位的預設名稱:Item1、Item2、Item3等
(string, string) unnamedLetters = ("a", "b");
Console.WriteLine($"{unnamedLetters.Item1}, {unnamedLetters.Item2}");
// 方式二
(string Alpha, string Beta) namedLetters = ("a", "b");
Console.WriteLine($"{namedLetters.Alpha}, {namedLetters.Beta}");
// 方式三
var alphabetStart = (Alpha: "a", Beta: "b");
Console.WriteLine($"{alphabetStart.Alpha}, {alphabetStart.Beta}");
// 方式四,C# 7.1開始支援自動推斷變數名稱
int count = 5;
string label = "Colors used in the map";
var pair = (count, label); // 元組元素名為"count"和"label"
當某方法回傳元組時,如需提取元組成員,可透過為元組的每個值宣告單獨的變數來實現,稱為解構元組。使用元組作為方法回傳型別,可以替代定義 out 方法參數。
// 解構元組
var (first, last) = Range(numbers);
Console.WriteLine($"{first} to {last}");
(int max, int min) = Range(numbers);
Console.WriteLine($"{min} to {max}");
捨棄
從 C# 7.0 開始支援捨棄,捨棄是預留位置變數,相當於未賦值的變數,表示不想使用該變數,使用底線_表示捨棄變數。如下列舉了一些捨棄的使用場景:
// 場景一:丟棄元組值
(_, _, area) = city.GetCityInformation(cityName);
// 場景二:從C# 9開始,可以丟棄Lambda運算式中的參數
Func<int, int, int> constant = (_, _) => 42;
// 場景三,丟棄out參數
DiscardsOut(out _);
static void DiscardsOut(out string s)
{
s = "nothing";
Console.WriteLine($"input is {s}");
}
模式比對
C# 7.0 新增了模式比對功能,之後每個主要 C#版本都擴充了模式比對功能。模式比對用來測試運算式是否具有某些特徵,is 運算式、switch 陳述式和 switch 運算式均支援模式比對,可使用 when 關鍵字來指定模式的其他規則。
模式比對目前包含這些型別:宣告模式、型別模式、常數模式、關係模式、邏輯模式、屬性模式、位置模式、var 模式、捨棄模式,詳細內容可參考官方文件。
is 模式運算式改進了 is 運算子功能,可在一條指令指派結果:
// is模式比對
if (input is int count) do somthing... ;
// 舊寫法
if (input is int)
{
int count = (int)input;
do somthing... ;
}
// is模式進行空值檢查
string? message = "This is not the null string";
if (message is not null) Console.WriteLine(message);
default 文字運算式
預設值運算式產生型別的預設值,之前版本僅支援 default 運算子,C# 7.1 後增強了 default 運算式的功能,當編譯器可以推斷運算式型別時,可以使用 default 產生型別的預設值。
// 新寫法
Func<string, bool> whereClause = default;
// 舊寫法
Func<string, bool> whereClause = default(Func<string, bool>);
switch 運算式
從 C# 8 開始,可以使用 switch 運算式。switch 運算式相較於 switch 陳述式的改進之處在於:
- 變數在 switch 關鍵字之前;
- 使用=>取代 case :結構;
- 使用捨棄_取代 default 運算子;
- 使用運算式取代陳述式。
public enum Level
{
One,
Two,
Three
}
public static int LevelToScore(Level level) => level switch
{
Level.One => 1,
Level.Two => 5,
Level.Three => 10,
_ => throw new ArgumentOutOfRangeException(nameof(level), $"Not expected level value: {level}"),
};
using 宣告
C# 8 新增了 using 宣告功能,它指示編譯器宣告的變數應在程式碼區塊的末尾進行處理。using 宣告相比傳統的 using 陳述式程式碼更簡潔,這兩種寫法都會使編譯器在程式碼區塊末尾呼叫 Dispose()。
static void WriteLinesToFile(IEnumerable<string> lines)
{
using var file = new System.IO.StreamWriter("WriteLines.txt");
do somthing... ;
return;
// file is disposed here
}
索引和範圍
C# 8 中新增了索引和範圍功能,為存取序列中的單一元素或範圍提供了簡潔的語法。該語法依賴兩個新型別與兩個新運算子:
- System.Index 表示一個序列索引;
- System.Range 表示序列的子範圍;
- 末尾運算子^,使用該運算子加數字,指定倒數第幾個;
- 範圍運算子..,指定範圍的開始和末尾。
範圍運算子包括此範圍的開始,但不包括此範圍的末尾。
var words = new string[]
{ // 正常索引 索引對應的末尾運算子
"The", // 0 ^9
"quick", // 1 ^8
"brown", // 2 ^7
"fox", // 3 ^6
"jumped", // 4 ^5
"over", // 5 ^4
"the", // 6 ^3
"lazy", // 7 ^2
"dog" // 8 ^1
}; // 9 (words.Length) ^0
Console.WriteLine($"The last word is {words[^1]}"); // dog
var allWords = words[..]; // 包含所有值,等同於words[0..^0].
var firstPhrase = words[..4]; // 開始到words[4],不包含words[4]
var lastPhrase = words[6..]; // words[6]到末尾
// 宣告範圍變數
Range phrase = 1..4;
var text = words[phrase];
??與??=
??合併運算子:C# 6 後可用,如果左運算元的值不為 null,則??回傳該值;否則,它會計算右運算元並回傳其結果。如果左運算元的計算結果為非 null,則不會計算其右運算元。
??=合併指派運算子:C# 8 後可用,僅在左側運算元的求值結果為 null 時,才將右運算元的值指派給左運算元。否則,不會計算其右運算元。??=運算子的左運算元必須是變數、屬性或索引器元素。
// ??合併運算子
Console.WriteLine($"name is {OutputName(null)}");
static string OutputName(string name) => name ?? "some one";
// 使用??=指派運算子
variable ??= expression;
// 舊寫法
if (variable is null)
{
variable = expression;
}
最上層陳述式
C# 9 推出了最上層陳述式,它從應用程式中刪除了不必要的流程,應用程式中只有一個檔案可使用最上層陳述式。最上層陳述式使主程式更易讀,減少了不必要的模式:命名空間、class Program 和 static void Main()。
使用 VS 建立命令列專案,選擇.NET 5 及以上版本,就會使用最上層陳述式。
// 使用VS2022建立.NET 6.0平台的命令列程式預設產生的內容
// See https://aka.ms/new-console-template for more information
Console.WriteLine("Hello, World!");
global using
C# 10 新增了 global using 指令,當關鍵字 global 出現在 using 指令之前時,該 using 適用於整個專案,這樣可以减少每個檔案 using 指令的行數。global using 指令可以出現在任何原始程式碼檔案的開頭,但需新增在非全域 using 之前。
global 修飾詞可以與 static 修飾詞一起使用,也可以應用於 using 別名指令。在這兩種情况下,指令的範圍都是當前編譯中的所有檔案。
global using System;
global using static System.Console; // 全域靜態匯入
global using Env = System.Environment; // 全域別名
檔案範圍的命名空間
C# 10 引入了檔案範圍的命名空間,可將命名空間包含為陳述式,後加分號且無需新增大括號。一個程式碼檔案通常只包含一個命名空間,這樣簡化了程式碼且消除了一層巢狀。檔案範圍的命名空間不能宣告巢狀的命名空間或第二個檔案範圍的命名空間,且它必須在宣告任何型別之前,該檔案內的所有型別都屬於該命名空間。
using System;
namespace SampleFileScopedNamespace;
class SampleClass { }
interface ISampleInterface { }
struct SampleStruct { }
enum SampleEnum { a, b }
delegate void SampleDelegate(int i);
with 運算式
C# 9 開始引入了 with 運算式,它使用修改的特定屬性和欄位產生其操作物件的複本,未修改的值將保留與原物件相同的值。對於參考型別成員,在複製運算元時僅複製對該成員執行個體的參考,with 運算式產生的複本和原物件都具有對同一參考型別執行個體的存取權限。
在 C# 9 中,with 運算式的左運算元必須為 record 型別,C# 10 進行了改進,with 運算式的左運算元也可以是 struct 型別。
public record NamedPoint(string Name, int X, int Y);
var p1 = new NamedPoint("A", 0, 0);
var p2 = p1 with { Name = "B", X = 5 };