C#新特性語法


C# 新特性

C# 6

一、字符串插值 (String Interpolation)

C# 6之前我們拼接字符串時需要這樣

 var Name = "Jack";
 var results = "Hello" + Name;

或者

 var Name = "Jack";
 var results = string.Format("Hello {0}", Name);

但是C#6里我們就可以使用新的字符串插值特性

  var Name = "Jack";
  var results = $"Hello {Name}";

上面只是一個簡單的例子,想想如果有多個值要替換的話,用C#6的這個新特性,代碼就會大大減小,而且可讀性比起之前大大增強

 Person p = new Person {FirstName = "Jack", LastName = "Wang", Age = 100};
 var results = string.Format("First Name: {0} LastName: {1} Age: { 2} ", p.FirstName, p.LastName, p.Age);

有了字符串插值后:

 var results = $"First Name: {p.FirstName} LastName: {p.LastName} Age: {p.Age}";

字符串插值不光是可以插簡單的字符串,還可以直接插入代碼

Console.WriteLine($"Jack is saying { new Tools().SayHello() }");

var info = $"Your discount is {await GetDiscount()}";

那么如何處理多語言呢?我們可以使用 IFormattable下面的代碼如何實現多語言?

 Double remain = 2000.5; 
 var results= $"your money is {remain:C}";  

輸出 your money is $2,000.50

使用IFormattable 多語言

class Program
{
    static void Main(string[] args)
    {

        Double remain = 2000.5; 

       var results= ChineseText($"your money is {remain:C}");

        Console.WriteLine(results);
        Console.Read();
    }

    public static string ChineseText(IFormattable formattable)
    {
        return formattable.ToString(null, new CultureInfo("zh-cn"));
    }
}

輸出 your money is ¥2,000.50

二、空操作符 ( ?. )

C# 6添加了一個 ?. 操作符,當一個對象或者屬性職為空時直接返回null, 就不再繼續執行后面的代碼,在之前我們的代碼里經常出現 NullException, 所以我們就需要加很多Null的判斷,比如

 if (user != null && user.Project != null && user.Project.Tasks != null && user.Project.Tasks.Count > 0)
 {
   Console.WriteLine(user.Project.Tasks.First().Name);
 }

現在我們可以不用寫 IF 直接寫成如下這樣

Console.WriteLine(user?.Project?.Tasks?.First()?.Name);

這個?. 特性不光是可以用於取值,也可以用於方法調用,如果對象為空將不進行任何操作,下面的代碼不會報錯,也不會有任何輸出。

class Program
{
    static void Main(string[] args)
    {
        User user = null;
        user?.SayHello();
        Console.Read();
    }
}

public class User
{
    public void SayHello()
    {
        Console.WriteLine("Ha Ha");
    }
}

還可以用於數組的索引器

class Program
{
    static void Main(string[] args)
    {
        User[] users = null;

        List<User> listUsers = null;

        // Console.WriteLine(users[1]?.Name); // 報錯
        // Console.WriteLine(listUsers[1]?.Name); //報錯

        Console.WriteLine(users?[1].Name); // 正常
        Console.WriteLine(listUsers?[1].Name); // 正常

        Console.ReadLine();
    }
}

注意: 上面的代碼雖然可以讓我們少些很多代碼,而且也減少了空異常,但是我們卻需要小心使用,因為有的時候我們確實是需要拋出空異常,那么使用這個特性反而隱藏了Bug

三、 NameOf

過去,我們有很多的地方需要些硬字符串,導致重構比較困難,而且一旦敲錯字母很難察覺出來,比如

if (role == "admin")
{
}

WPF 也經常有這樣的代碼

public string Name
{
  get { return name; }
  set
  {
      name= value;
      RaisePropertyChanged("Name");
  }
}

現在有了C#6 NameOf后,我們可以這樣

public string Name
{
  get { return name; }
  set
  {
      name= value;
      RaisePropertyChanged(NameOf(Name));
  }
}
static void Main(string[] args)
{
    Console.WriteLine(nameof(User.Name)); //  output: Name
    Console.WriteLine(nameof(System.Linq)); // output: Linq
    Console.WriteLine(nameof(List<User>)); // output: List
    Console.ReadLine();
}

注意: NameOf只會返回Member的字符串,如果前面有對象或者命名空間,NameOf只會返回 . 的最后一部分, 另外NameOf有很多情況是不支持的,比如方法,關鍵字,對象的實例以及字符串和表達式

四、在Catch和Finally里使用Await

在之前的版本里,C#開發團隊認為在Catch和Finally里使用Await是不可能,而現在他們在C#6里實現了它。

Resource res = null;
try
{
    res = await Resource.OpenAsync(); // You could always do this.  
}
catch (ResourceException e)
{
    await Resource.LogAsync(res, e); // Now you can do this … 
} 
finally
{
    if (res != null) await res.CloseAsync(); // … and this.
}

五、表達式方法體

一句話的方法體可以直接寫成箭頭函數,而不再需要大括號

 class Program
 {
    private static string SayHello() => "Hello World";
    private static string JackSayHello() => $"Jack {SayHello()}";

    static void Main(string[] args)
    {
        Console.WriteLine(SayHello());
        Console.WriteLine(JackSayHello());
        
        Console.ReadLine();
    }
}

六、自動屬性初始化器

之前我們需要賦初始化值,一般需要這樣

public class Person
{
    public int Age { get; set; }

    public Person()
    {
        Age = 100;
    }
}

但是C# 6的新特性里我們這樣賦值

public class Person
{
    public int Age { get; set; } = 100;
}

七、只讀自動屬性

C# 1里我們可以這樣實現只讀屬性

public class Person
{
    private int age=100;

    public int Age
    {
        get { return age; }
    }
}

但是當我們有自動屬性時,我們沒辦法實行只讀屬性,因為自動屬性不支持readonly關鍵字,所以我們只能縮小訪問權限

public class Person
{
    public  int Age { get; private set; }
   
}

但是 C#6里我們可以實現readonly的自動屬性了

public class Person
{
    public int Age { get; } = 100;
}

八、異常過濾器 Exception Filter

static void Main(string[] args)
{

    try
    {
        throw  new ArgumentException("Age");
    }
    catch (ArgumentException argumentException) when( argumentException.Message.Equals("Name"))
    {
        throw  new ArgumentException("Name Exception");

    }

    catch (ArgumentException argumentException) when( argumentException.Message.Equals("Age"))
    {
        throw new Exception("not handle");
        
    }
    catch  (Exception e)
    {
        
        throw;
    }
}

在之前,一種異常只能被Catch一次,現在有了Filter后可以對相同的異常進行過濾,至於有什么用,那就是見仁見智了,我覺得上面的例子,定義兩個具體的異常 NameArgumentException 和AgeArgumentException代碼更易讀。

九、 Index 初始化器

這個主要是用在Dictionary上,至於有什么用,我目前沒感覺到有一點用處,誰能知道很好的使用場景,歡迎補充:

var names = new Dictionary<int, string>
{
    [1] = "Jack",
    [2] = "Alex",
    [3] = "Eric",
    [4] = "Jo"
};

foreach (var item in names)
{
    Console.WriteLine($"{item.Key} = {item.Value}");
}

十、using 靜態類的方法可以使用 static using

這個功能在我看來,同樣是很沒有用的功能,也為去掉前綴有的時候我們不知道這個是來自哪里的,而且如果有一個同名方法不知道具體用哪個,當然經證實是使用類本身的覆蓋,但是容易搞混不是嗎?

using System;
using static System.Math;
namespace CSharp6NewFeatures
 {
  class Program
  {
      static void Main(string[] args)
    {
        Console.WriteLine(Log10(5)+PI);
    }
  }
}

C# 7

數字字面量

現在可以在數字中加下划線,增加數字的可讀性。編譯器或忽略所有數字中的下划線

int million = 1_000_000;

雖然編譯器允許在數字中任意位置添加任意個數的下划線,但顯然,遵循管理,下划線應該每三位使用一次,而且,不可以將下划線放在數字的開頭(1000)或結尾(1000

改進的out關鍵字

C#7支持了out關鍵字的即插即用

var a = 0;
int.TryParse("345", out a);

// 就地使用變量作為返回值
int.TryParse("345", int out b);

允許以_(下划線)形式“舍棄”某個out參數,方便你忽略不關系的參數。例如下面的例子中,獲得一個二維坐標的X可以重用獲得二維坐標的X,Y方法,並舍棄掉Y:

struct Point
{
	public int x;
	public int y;
	private void GetCoordinates(out int x, out int y)
	{
		x = this.x;
		y = this.y;
	}
	public void GetX()
	{
		// y被舍棄了,雖然GetCoordinates方法還是會傳入2個變量,且執行y=this.y
		// 但它會在返回之后丟失
		GetCoordinates(out int x, out _);
		WriteLine($"({x})");
	}
}

模式匹配

模式匹配(Pattern matching)是C#7中引入的重要概念,它是之前is和case關鍵字的擴展。目前,C#擁有三種模式:

  • 常量模式:簡單地判斷某個變量是否等於一個常量(包括null)
  • 類型模式:簡單地判斷某個變量是否為一個類型的實例
  • 變量模式:臨時引入一個新的某個類型的變量(C#7新增)

下面的例子簡單地演示了這三種模式:

class People
{
	public int TotalMoney { get; set; }
	public People(int a)
	{
		TotalMoney = a;
	}
}

class Program
{
	static void Main(string[] args)
	{
		var peopleList = new List<People>() {
			new People(1),
			new People(1_000_000)
		};
		foreach (var p in peopleList)
		{
			// 類型模式
			if (p is People) WriteLine("是人");
			// 常量模式
			if (p.TotalMoney > 500_000) WriteLine("有錢");
			// 變量模式
			// 加入你需要先判斷一個變量p是否為People,如果是,則再取它的TotalMoney字段
			// 那么在之前的版本中必須要分開寫
			if (p is People)
			{
				var temp = (People)p;
				if (temp.TotalMoney > 500_000) WriteLine("有錢");
			}
			// 變量模式允許你引入一個變量並立即使用它
			if (p is People ppl && ppl.TotalMoney > 500_000) WriteLine("有錢");
		}
		ReadKey();
	}
}

可以看出,變量模式引入的臨時變量ppl(稱為模式變量)的作用域也是整個if語句體,它的類型是People類型 case關鍵字也得到了改進。現在,case后面也允許模式變量,還允許when子句,代碼如下:

static void Main(string[] args)
{
	var a = 13;
	switch (a)
	{
		// 現在i就是a
		// 由於現在case后面可以跟when子句的表達式,不同的case有機會相交
		case int i when i % 2 == 1:
			WriteLine(i + " 是奇數");
			break;
		// 只會匹配第一個case,所以這個分支無法到達
		case int i when  i > 10:
			WriteLine(i + " 大於10");
			break;
		// 永遠在最后被檢查,即使它后面還有case子句
		default:
			break;
	}
	ReadKey();
}

上面的代碼運行的結果是打印出13是奇數,我們可以看到,現在case功能非常強大,可以匹配更具體、跟他特定的范圍。不過,多個case的范圍重疊,編譯器只會選擇第一個匹配上的分支

值類型元組

元組(Tuple)的概念早在C#4就提出來,它是一個任意類型變量的集合,並最多支持8個變量。在我們不打算手寫一個類型或結構體來盛放一個變量集合時(例如,它是臨時的且用完即棄),或者打算從一個方法中返回多個值,我們會考慮使用元組。不過相比C#7的元組,C#4的元組更像一個半成品,先看看C#4如何使用元組:

var beforeTuple = new Tuple<int, int>(2, 3);
var a = beforeTuple.Item1;

通過上面的代碼發現,C#4中元組最大的兩個問題是:

  • Tuple類將其屬性命名為Item1、Item2等,這些名稱是無法改變的,只會讓代碼可讀性變差
  • Tuple是引用類型,使用任一Tuple類意味着在堆上分配對象,因此,會對性能造成負面影響

C#7引入的新元組(ValueTuple)解決了上面兩個問題,它是一個結構體,並且你可以傳入描述性名稱(TupleElementNames屬性)以便更容易地調用他們

static void Main(string[] args)
{
	// 未命名的元組,訪問方式和之前的元組相同
	var unnamed = ("one", "two");
	var b = unnamed.Item1;
	// 帶有命名的元組
	var named = (first : "one", second : "two");
	b = named.first;
	ReadKey();
}

在背后,他們被編譯器隱式地轉化為:

ValueTuple<string, string> unnamed = new ValueTuple<string, string>() ("one", "two");
string b = unnamed.Item1;
ValueTuple<string, string> named = new ValueTuple<string, string>() ("one", "two");
b = named.Item1;

我們看到,編譯器將帶有命名元組的實名訪問轉換成對應的Item,轉換是使用特性實現的

元組的字段名稱

可以在元組定義時傳入變量。此時,元組的字段名稱為變量名。如果沒有指明字段名稱,又傳入了常量,則只能使用Item1、Item2等訪問元組的成員

static void Main(string[] args)
{
	var localVariableOne = 5;
	var localVariableTwo = "some text";
	// 顯示實現的字段名稱覆蓋變量名
	var tuple = (explicitFieldOne : localVariableOne, explicitFieldTwo : localVariableTwo);
	var a = tuple.explicitFieldOne;
	
	// 沒有指定字段名稱,又傳入了變量名(需要C#7.1版本)
	var tuple2 = (localVariableOne, localVariableTwo);
	var b = tuple.localVariableOne;
	
	// 如果沒有指明字段名稱,又傳入了常量,則只能使用Item1、Item2等訪問元組的成員
	var tuple3 = (5, "some text");
	var c = tuple3.Item1;
	ReadKey();
}

上面的代碼給出了元組字段名稱的優先級:

  • 首先是顯示實現
  • 其次是變量名(編譯器自動推斷的,需要C#7.1)
  • 最后是默認的Item1、Item2作為保留名稱

另外,如果變量名或顯示指定的描述名稱是C#的關鍵字,則C#會改用ItemX作為字段名稱(否則就會導致語法錯誤,例如將變量名為ToString的變量傳入元組)

var ToString = "1";
var Item1 = 2;
var tuple4 = (ToString, Item1);

// ToString不能用作元組字段名稱,強制改為Item1
var d = tuple4.Item1; // "1"
// Item1不能用作元組字段名,強制改為Item2
var e = tuple4.Item2; // 2
ReadKey();

元組作為方法的參數和返回值

因為元組實際上是一個結構體,所以它當然可以作為方法的參數和返回值。因此,我們就有了可以返回多個變量的最簡單、最優雅的方法(比使用out的可讀性好很多):

// 使用元組作方法的參數和返回值
(int, int) MultiplyAll(int multiplier, (int a, int b) members)
{
	// 元組沒有實現IEnumerator接口,不能foreach
	// foreach(var a in members)
	// 操作元組
	return (members.a * multiplier, members.b * multiplier);
}

上面代碼中的方法會將輸入中的a和b都乘以multiplier,然后返回結構。由於元組是結構體,所以即使含有引用類型,其值類型的部分也會在棧上進行分配,相比C#4的元組,C#7中的元組有着更好的性能和更友好的訪問方式

相同類型元組的賦值

如果它們的基數(即成員數)相同,且每個元素的類型要么相同,要么可以實現隱式轉換,則兩個元組被看作相同的類型:

static void Main(string[] args)
{
	var a = (first : "one", second : 1);
	WriteLine(a.GetType());
	var b = (a : "hello", b : 2);
	WriteLine(b.GetType());
	var c = (a : 3, b : "world");
	WriteLine(c.GetType());
	
	WriteLine(a.GetType() == b.GetType()); // True,兩個元組基數和類型相同
	WriteLine(a.GetType() == c.GetType()); // False,兩個元組基數相同但類型不同
	
	(string a, int b) d = a;
	// 屬性first,second消失了,取而代之的是a和b
	WriteLine(d.a);
	// 定義了一個新的元組,成員為string和object類型
	(string a, object b) e;
	// 由於int可以被隱式轉換為object,所以可以這樣賦值
	e = a;
	ReadKey();
}

解構

C#7允許你定義結構方法(Deconstructor),注意,它和C#誕生即存在的析構函數(Destructor)不同。解構函數和構造函數做的事情某種程度上是相對的——構造函數將若干個類型組合為一個大的類型,而結構方法將大類型拆散為一堆小類型,這些小類型可以是單個字段,也可以是元組。當類型成員很多而需要的部分通常較小時,解構方法會很有用,它可以防止類型傳參時復制的高昂代價

元組的解構

可以在括號內顯示地聲明每個字段的類型,為元組中的每個元素創建離散變量,也可以用var關鍵字

static void Main(string[] args)
{
	// 定義元組
	(int count, double sum, double sumOfSquares) tuple = (1, 2, 3);
	// 使用方差的計算公式得到方差
	var variance = tuple.sumOfSquares - tuple.sum * tuple.sum / tuple.count;

	// 將一個元組放在等號右邊,將對應的變量值和類型放在等號左邊,就會導致解構
	(int count, double sum, double sumOfSquares) = (1, 2, 3);
	// 解構之后的方差計算,代碼簡潔美觀
	variance = sumOfSquares - sum * sum / count;
	// 也可以這樣解構,這會導致編譯器推斷元組的類型為三個int
	var (a, b, c) = (1, 2, 3);
	ReadKey();
}

上面的代碼中,出現了兩次解構方法的隱式調用:左邊是一個沒有元組變量名的元組(只有一些成員變量名),右邊是元組的實例。解構方法所做的事情,就是將右邊元組的實例中每個成員,逐個指派給左邊元組的成員變量。例如:

(int count, double sum, double sumOfSquares) = (1, 2, 3);

就會使得count,sum和sumOfSquares的值分別為1,2,3。如果沒有這個功能,就需要定義3個變量,然后賦值3次,最終得到6行代碼,大大提高了代碼的可讀性。 對於元組,C#提供了內置的解構支持,因此不需要手動寫解構方法,如果需要對非元組類型進行解構,就需要定義自己的解構方法,顯而易見,上面的解構通過如下的簽名的函數完成:

public void Deconstruct(out int count, out double sum, out double sumOfSquares)

解構其他類型

解構函數的名稱必須為Deconstruct,下面的例子從一個較大的類型People中解構出我們想要的三項成員:

// 示例類型
public class People
{
	public int ID;
	public string FirstName;
	public string MiddleName;
	public string LastName;
	public int Age;
	public string CompanyName;
	// 解構全名,包括姓、名字和中間名
	public void Deconstruct(out string f, out string m, out string l)
	{
		f = FirstName;
		m = MiddleName;
		l = LastName;
	}
}

static void Main(string[] args)
{
	var  p = People();
	p.FirstName = "Test";
	var (fName, mName, lName) = p;
	WriteLine(fName);

	ReadKey();
}

解構方法不能有返回值,且要解構的每個成員必須以out標識出來。如果編譯器對一個類型的實例解構,卻沒發現對應的解構函數,就會發生編譯時異常。如果在解構時發生隱式類型轉換,則不會發生編譯時異常,例如將上述的解構函數的輸入參數類型都改為object類型,仍然可以完成解構,可以通過重載解構函數對類型實現不同方式的解構

忽略類型成員

為了少寫代碼,我們可以在解構時忽略類型成員。例如,我們如果只關系People的姓和名字,而不關心中間名,則不需要多寫一個解構函數,而是利用現有的:

var (fName, _, lName) = p;

通過使用下划線來忽略類型成員,此時仍然會調用帶有三個參數的解構函數,但是p將會只有fName和lName兩個成員元組也支持忽略類型成員的解構

使用擴展方法進行解構

即使類型並非由自己定義,仍然可以通過解構擴展方法來解構類型,例如解構.NET自帶的DateTime類型:

class Program
{
	static void Main(string[] args)
	{
		var d = DateTime.Now;
		(string s, DayOfWeek dow) = d;
		WriteLine($"今天是 {s}, 是 {d}");
		ReadKey();
	}
}
public static class ReflectionExtensions
{
	// 解構DateTime並獲得想要的值
	public static void Deconstruct(this DateTime dateTime, out string DateString, out DayOfWeek dayOfWeek)
	{
		DateString = dateTime.ToString("yyyy-MM-dd");
		dayOfWeek = dateTime.DayOfWeek;
	}
}

如果類型提供了解構方法,你又在擴展方法中定義了與簽名相同的解構方法,則編譯器會優先選用類型提供的解構方法

局部函數

局部函數(local functions)和匿名方法很像,當你有一個只會使用一次的函數(通常作為其他函數的輔助函數)時,可以使用局部函數或匿名方法。如下是一個利用局部函數和元組計算斐波那契數列的例子:

static void Main(string[] args)
{
	WriteLine(Fibonacci(10));
	ReadKey();
}
public static int Fibonacci(int x)
{
	if (x < 0) throw new ArgumentException("輸入正整數", nameof(x));
	return Fib(x).current;

	// 局部函數定義
	(int current, int previous) Fib(int i)
	{
		if (i == 1) return (1, 0);
		var (p, pp) = Fib(i - 1);
		return (p + pp, p);
	}
}

局部函數是屬於定義該函數的方法的,在上面的例子中,Fib函數只在Fibonacci方法中可用

  • 局部函數只能在方法體中使用
  • 不能在匿名方法中使用
  • 只能用async和unsafe修飾局部函數,不能使用訪問修飾符,默認是私有、靜態的
  • 局部函數和某普通方法簽名相同,局部函數會將普通方法隱藏,局部函數所在的外部方法調用時,只會調用到局部函數

更多的表達式體成員

C#6允許類型的定義中,字段后跟表達式作為默認值。C#7進一步允許了構造函數、getter、setter以及析構函數后跟表達式:

class CSharpSevenClass
{
	int a;
	// get, set使用表達式
	string b
	{
		get => b;
		set => b = "12345";	
	}
	// 構造函數
	CSharpSevenClass(int x) => a = x;
	// 析構函數
	~CSharpSevenClass() => a = 0;
}

上面的代碼演示了所有C#7中允許后跟表達式(但過去版本不允許)的類型實例成員

C# 8

可空引用類型

從此,引用類型將會區分是否可分,可以從根源上解決 NullReferenceException。但是由於這個特性會打破兼容性,因此沒有當作 error 來對待,而是使用 warning 折衷,而且開發人員需要手動 opt-in 才可以使用該特性(可以在項目層級或者文件層級進行設定)。 例如:

string s = null; // 產生警告: 對不可空引用類型賦值 null
string? s = null; // Ok

void M(string? s)
{
    Console.WriteLine(s.Length); // 產生警告:可能為 null
    if (s != null)
    {
        Console.WriteLine(s.Length); // Ok
    }
}

至此,媽媽再也不用擔心我的程序到處報 NullReferenceException 啦!

異步流(Async streams)

考慮到大部分 Api 以及函數實現都有了對應的 async版本,而 IEnumerable和 IEnumerator還不能方便的使用 async/await就顯得很麻煩了。 但是,現在引入了異步流,這些問題得到了解決。 我們通過新的 IAsyncEnumerable和 IAsyncEnumerator來實現這一點。同時,由於之前 foreach是基於IEnumerable和 IEnumerator實現的,因此引入了新的語法await foreach來擴展 foreach的適用性。 例如:

async Task<int> GetBigResultAsync()
{
    var result = await GetResultAsync();
    if (result > 20) return result; 
    else return -1;
}

async IAsyncEnumerable<int> GetBigResultsAsync()
{
    await foreach (var result in GetResultsAsync())
    {
        if (result > 20) yield return result; 
    }
}

范圍和下標類型

C# 8.0 引入了 Index 類型,可用作數組下標,並且使用 ^ 操作符表示倒數。 不過要注意的是,倒數是從 1 開始的。

Index i1 = 3;  // 下標為 3
Index i2 = ^4; // 倒數第 4 個元素
int[] a = { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 };
Console.WriteLine($"{a[i1]}, {a[i2]}"); // "3, 6"

除此之外,還引入了 “..” 操作符用來表示范圍(注意是左閉右開區間)。

var slice = a[i1..i2]; // { 3, 4, 5 }

關於這個下標從 0 開始,倒數從 1 開始,范圍左閉右開,筆者剛開始覺得很奇怪,但是發現 Python 等語言早已經做了這樣的實踐,並且效果不錯。因此這次微軟也采用了這種方式設計了 C# 8.0 的這個語法。

接口的默認實現方法

從此接口中可以包含實現了:

interface ILogger
{
    void Log(LogLevel level, string message);
    void Log(Exception ex) => Log(LogLevel.Error, ex.ToString()); // 這是一個默認實現重載
}

class ConsoleLogger : ILogger
{
    public void Log(LogLevel level, string message) { ... }
    // Log(Exception) 會得到執行的默認實現
}

在上面的例子中,Log(Exception)將會得到執行的默認實現。

模式匹配表達式和遞歸模式語句

現在可以這么寫了(patterns 里可以包含 patterns)

IEnumerable<string> GetEnrollees()
{
    foreach (var p in People)
    {
        if (p is Student { Graduated: false, Name: string name }) yield return name;
    }
}

Student { Graduated: false, Name: string name }檢查 p 是否為 Graduated = false且 Name為 string的 Student,並且迭代返回 name。 可以這樣寫之后是不是很爽?

更有:

var area = figure switch 
{
    Line _      => 0,
    Rectangle r => r.Width * r.Height,
    Circle c    => c.Radius * 2.0 * Math.PI,
    _           => throw new UnknownFigureException(figure)
};

典型的模式匹配語句,只不過沒有用“match”關鍵字,而是沿用了 了“switch”關鍵字。 但是不得不說,一個字,爽!

目標類型推導

以前我們寫下面這種變量/成員聲明的時候,大概最簡單的寫法就是:

var points = new [] { new Point(1, 4), new Point(2, 6) };

private List<int> _myList = new List<int>();

現在我們可以這么寫啦:

Point[] ps = { new (1, 4), new (3,-2), new (9, 5) };

private List<int> _myList = new ();

C# 9

僅可初始化的屬性

對象的初始化器非常了不起。它們為客戶端創建對象提供了一種非常靈活且易於閱讀的格式,而且特別適合嵌套對象的創建,我們可以通過嵌套對象一次性創建整個對象樹。下面是一個簡單的例子:

new Person
{
    FirstName = "Scott",
    LastName = "Hunter"
}

對象初始化器還可以讓程序員免於編寫大量類型的構造樣板代碼,他們只需編寫一些屬性即可!

public class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
}

目前的一大限制是,屬性必須是可變的,只有這樣對象初始化器才能起作用,因為它們需要首先調用對象的構造函數(在這種情況下調用的是默認的無參構造函數),然后分配給屬性設置器。 僅可初始化的屬性可以解決這個問題!它們引入了init訪問器。init訪問器是set訪問器的變體,它只能在對象初始化期間調用:

public class Person
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
}

在這種聲明下,上述客戶端代碼仍然合法,但是后續如果你想為FirstName和LastName屬性賦值就會出錯。

初始化訪問器和只讀字段

由於init訪問器只能在初始化期間被調用,所以它們可以修改所在類的只讀字段,就像構造函數一樣。

public class Person
{
    private readonly string firstName;
    private readonly string lastName;
    public string FirstName
    {
        get => firstName;
        init => firstName = (value ?? throw new ArgumentNullException(nameof(FirstName)));
    }
    public string LastName
    {
        get => lastName;
        init => lastName = (value ?? throw new ArgumentNullException(nameof(LastName)));
    }
}

記錄

如果你想保持某個屬性不變,那么僅可初始化的屬性非常有用。如果你希望整個對象都不可變,而且希望其行為宛如一個值,那么就應該考慮將其聲明為記錄:

public data class Person
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
}

上述類聲明中的data關鍵字表明這是一個記錄,因此它具備了其他一些類似於值的行為,后面我們將深入討論。一般而言,我們更應該將記錄視為“值”(數據),而非對象。它們不具備可變的封裝狀態。相反,你可以通過創建表示新狀態的新記錄來表示隨着時間發生的變化。記錄不是由標識確定,而是由其內容確定。

With表達式

處理不可變數據時,一種常見的模式是利用現有的值創建新值以表示新狀態。例如,如果想修改某人的姓氏,那么我們會用一個新對象來表示,這個對象除了姓氏之外和舊對象完全一樣。通常我們稱該技術為非破壞性修改。記錄代表的不是某段時間的某個人,而是給定時間點上這個人的狀態。 為了幫助大家習慣這種編程風格,記錄允許使用一種新的表達方式:with表達式:

var otherPerson = person with { LastName = "Hanselman" };

with表達式使用對象初始化的語法來說明新對象與舊對象之間的區別。你可以指定多個屬性。 記錄隱式地定義了一個protected“復制構造函數”,這種構造函數利用現有的記錄對象,將字段逐個復制到新的記錄對象中:

protected Person(Person original) { /* copy all the fields */ } // generated

with表達式會調用復制構造函數,然后在其上應用對象初始化器,以相應地更改屬性。 如果你不喜歡自動生成的復制構造函數,那么也可以自己定義,with表達式就會調用自定義的復制構造函數。

基於值的相等

所有對象都會從object類繼承一個虛的Equals(object)方法。在調用靜態方法Object.Equals(object, object)且兩個參數均不為null時,該Equals(object)就會被調用。

結構體可以重載這個方法,獲得“基於值的相等性”,即遞歸調用Equals來比較結構的每個字段。記錄也一樣。 這意味着,如果兩個記錄對象的值一致,則二者相等,但兩者不一定是同一對象。例如,如果我們再次修改前面那個人的姓氏:

var originalPerson = otherPerson with { LastName = "Hunter" };

現在,ReferenceEquals(person, originalPerson) = false(它們不是同一個對象),但Equals(person, originalPerson) = true (它們擁有相同的值)。 如果你不喜歡自動生成的Equals覆蓋默認的逐字段比較的行為,則可以編寫自己的Equals重載。你只需要確保你理解基於值的相等性在記錄中的工作原理,尤其是在涉及繼承的情況下,具體的內容我們稍后再做介紹。 除了基於值的Equals之外,還有一個基於值的GetHashCode()重載方法。

數據成員

在絕大多數情況下,記錄都是不可變的,它們的僅可初始化的屬性是公開的,可以通過with表達式進行非破壞性修改。為了優化這種最常見的情況,我們改變了記錄中類似於string FirstName這種成員聲明的默認含義。在其他類和結構聲明中,這種聲明表示私有字段,但在記錄中,這相當於公開的、僅可初始化的自動屬性!因此,如下聲明:

public data class Person { string FirstName; string LastName; }

與之前提到過的下述聲明完全相同:

public data class Person
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
}

我們認為這種方式可以讓記錄更加優美而清晰。如果你需要私有字段,則可以明確添加private修飾符:

private string firstName;

位置記錄

有時,用參數位置來聲明記錄會很有用,內容可以根據構造函數參數的位置來指定,並且可以通過位置解構來提取。 你完全可以在記錄中指定自己的構造函數和析構函數:

public data class Person 
{ 
    string FirstName; 
    string LastName; 
    public Person(string firstName, string lastName) 
      => (FirstName, LastName) = (firstName, lastName);
    public void Deconstruct(out string firstName, out string lastName) 
      => (firstName, lastName) = (FirstName, LastName);
}

但是,我們可以用更短的語法表達完全相同的內容(使用成員變量的大小寫方式來命名參數):

public data class Person(string FirstName, string LastName);

上述聲明了僅可初始化的公開的自動屬性以及構造函數和析構函數,因此你可以這樣寫:

var person = new Person("Scott", "Hunter"); // positional construction
var (f, l) = person;                        // positional deconstruction

如果你不喜歡生成的自動屬性,則可以定義自己的同名屬性,這樣生成的構造函數和析構函數就會自動使用自己定義的屬性。

記錄和修改

記錄的語義是基於值的,因此在可變的狀態中無法很好地使用。想象一下,如果我們將記錄對象放入字典,那么就只能通過Equals和GethashCode找到了。但是,如果記錄更改了狀態,那么在判斷相等時它代表的值也會發生改變!可能我們就找不到它了!在哈希表的實現中,這個性質甚至可能破壞數據結構,因為數據的存放位置是根據它“到達”哈希表時的哈希值決定的! 而且,記錄也可能有一些使用內部可變狀態的高級方法,這些方法完全是合理的,例如緩存。但是可以考慮通過手工重載默認的行為來忽略這些狀態。

with表達式和繼承

眾所周知,考慮繼承時基於值的相等性和非破壞性修改是一個難題。下面我們在示例中添加一個繼承的記錄類Student:

public data class Person { string FirstName; string LastName; }
public data class Student : Person { int ID; }

在如下with表達式的示例中,我們實際創建一個Student,然后將其存儲到Person變量中:

Person person = new Student { FirstName = "Scott", LastName = "Hunter", ID = GetNewId() };
otherPerson = person with { LastName = "Hanselman" };

在最后一行的with表達式中,編譯器並不知道person實際上包含一個Student。而且,即使otherPerson不是Student對象,它也不是合法的副本,因為它包含了與第一個對象相同的ID屬性。 C#解決了這個問題。記錄有一個隱藏的虛方法,能夠確保“克隆”整個對象。每個繼承的記錄類型都會通過重載這個方法來調用該類型的復制構造函數,而繼承記錄的復制構造函數會調用基類的復制構造函數。with表達式只需調用這個隱藏“clone”方法,然后在結果上應用對象初始化器即可。

基於值的相等性與繼承

與with表達式的支持類似,基於值的相等性也必須是“虛的”,即兩個Student對象比較時需要比較所有字段,即使在比較時,能夠靜態地得知類型是基類,比如Person。這一點通過重寫已經是虛方法的Equals方法可以輕松實現。 然而,相等性還有另外一個難題:如果需要比較兩個不同類型的Person怎么辦?我們不能簡單地選擇其中一個來決定是否相等:相等性應該是對稱的,因此無論兩個對象中的哪個首先出現,結果都應該相同。換句話說,二者之間必須就相等性達成一致! 我們來舉例說明這個問題:

Person person1 = new Person { FirstName = "Scott", LastName = "Hunter" };
Person person2 = new Student { FirstName = "Scott", LastName = "Hunter", ID = GetNewId() };

這兩個對象彼此相等嗎?person1可能會認為相等,因為person2擁有Person的所有字段,但person2可能會有不同的看法!我們需要確保二者都認同它們是不同的對象。 C#可以自動為你解決這個問題。具體的實現方式是:記錄擁有一個名為EqualityContract的受保護虛屬性。每個繼承的記錄都會重載這個屬性,而且為了比較相等,兩個對象必須具有相同的EqualityContract。

頂級程序

使用C#編寫一個簡單的程序需要大量的樣板代碼:

using System;
class Program
{
    static void Main()
    {
        Console.WriteLine("Hello World!");
    }
}

這不僅對初學者來說難度太高,而且代碼混亂,縮進級別也太多。 在C# 9.0中,你只需編寫頂層的主程序:

using System;

Console.WriteLine("Hello World!");

任何語句都可以。程序必須位於using之后,文件中的任何類型或名稱空間聲明之前,而且只能在一個文件中,就像只有一個Main方法一樣。 如果你想返回狀態代碼,則可以利用這種寫法。如果你想await,那么也可以這么寫。此外,如果你想訪問命令行參數,則args可作為“魔術”參數使用。 局部函數是語句的一種形式,而且也可以在頂層程序中使用。在頂層語句之外的任何地方調用局部函數都會報錯。

改進后的模式匹配

C# 9.0中添加了幾種新的模式。下面我們通過如下模式匹配教程的代碼片段來看看這些新模式:

public static decimal CalculateToll(object vehicle) =>
    vehicle switch
    {
       ...
       
        DeliveryTruck t when t.GrossWeightClass > 5000 => 10.00m + 5.00m,
        DeliveryTruck t when t.GrossWeightClass < 3000 => 10.00m - 2.00m,
        DeliveryTruck _ => 10.00m,

        _ => throw new ArgumentException("Not a known vehicle type", nameof(vehicle))
    };

簡單類型模式

當前,類型模式需要在類型匹配時聲明一個標識符,即使該標識符是表示放棄的_也可以,如上面的DeliveryTruck _。而如今你可以像下面這樣編寫類型:

DeliveryTruck => 10.00m,

關系模式

C# 9.0中引入了與關系運算符<、<=等相對應的模式。因此,你可以將上述模式的DeliveryTruck寫成嵌套的switch表達式:

DeliveryTruck t when t.GrossWeightClass switch
{
    > 5000 => 10.00m + 5.00m,
    < 3000 => 10.00m - 2.00m,
    _ => 10.00m,
},

這的 > 5000 和 < 3000是關系模式。

邏輯模式

最后,你還可以將模式與邏輯運算符(and、or和not)組合在一起,它們以英文單詞的形式出現,以避免與表達式中使用的運算符混淆。例如,上述嵌套的switch表達式可以按照升序寫成下面這樣:

DeliveryTruck t when t.GrossWeightClass switch
{
    < 3000 => 10.00m - 2.00m,
    >= 3000 and <= 5000 => 10.00m,
    > 5000 => 10.00m + 5.00m,
},

中間一行通過and將兩個關系模式組合到一起,形成了表示間隔的模式。 not模式的常見用法也可應用於null常量模式,比如not null。例如,我們可以根據是否為null來拆分未知情況的處理方式:

not null => throw new ArgumentException($"Not a known vehicle type: {vehicle}", nameof(vehicle)),
null => throw new ArgumentNullException(nameof(vehicle))

此外,如果if條件中包含is表達式,那么使用not也很方便,可以避免笨拙的雙括號:

if (!(e is Customer)) { ... }

你可以這樣寫:

if (e is not Customer) { ... }

改進后的目標類型推斷

“目標類型推斷”指的是表達式從所在的上下文中獲取類型。例如,null和lambda表達式始終是目標類型推斷。 在C# 9.0中,有些以前不是目標類型推斷的表達式也可以通過上下文來判斷類型。

支持目標類型推斷的new表達式

C# 中的new表達式始終要求指定類型(隱式類型的數組表達式除外)。現在, 如果有明確的類型可以分配給表達式,則可以省去指定類型。

Point p = new (3, 5);

目標類型的??與?:

有時,條件判斷表達式中??與?:的各個分支之間並不是很明顯的同一種類型。現在這種情況會出錯,但在C# 9.0中,如果兩個分支都可以轉換為目標類型,就沒有問題:

Person person = student ?? customer; // Shared base type
int? result = b ? 0 : null; // nullable value type

支持協變的返回值

有時,我們需要表示出繼承類中重載的某個方法的返回類型要比基類中的類型更具體。C# 9.0允許以下寫法:

abstract class Animal
{
    public abstract Food GetFood();
    ...
}
class Tiger : Animal
{
    public override Meat GetFood() => ...;
}

總結 上面80%我認為都是比較有用的新特性,后面的幾個我覺得用處不大,當然如果找到合適的使用場景應該有用,歡迎大家補充。 最后,祝大家編程愉快。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM