新版 C# 高效率编程指南
前言
C# 從 7 版本開(kāi)始一直到如今的 9 版本,加入了非常多的特性,其中不乏改善性能、增加程序健壯性和代碼簡(jiǎn)潔性、可讀性的改進(jìn),這里我整理一些使用新版 C# 的時(shí)候個(gè)人推薦的寫(xiě)法,可能不適用于所有的人,但是還是希望對(duì)你們有所幫助。
注意:本指南適用于 .NET 5 或以上版本。
使用 ref struct 做到 0 GC
C# 7 開(kāi)始引入了一種叫做?ref struct?的結(jié)構(gòu),這種結(jié)構(gòu)本質(zhì)是?struct?,結(jié)構(gòu)存儲(chǔ)在棧內(nèi)存。但是與?struct?不同的是,該結(jié)構(gòu)不允許實(shí)現(xiàn)任何接口,并由編譯器保證該結(jié)構(gòu)永遠(yuǎn)不會(huì)被裝箱,因此不會(huì)給 GC 帶來(lái)任何的壓力。相對(duì)的,使用中就會(huì)有不能逃逸出棧的強(qiáng)制限制。
Span<T>?就是利用?ref struct?的產(chǎn)物,成功的封裝出了安全且高性能的內(nèi)存訪問(wèn)操作,且可在大多數(shù)情況下代替指針而不損失任何的性能。
Copyref struct MyStruct {public int Value { get; set; } }class RefStructGuide {static void Test(){MyStruct x = new MyStruct();x.Value = 100;Foo(x); // okBar(x); // error, x cannot be boxed}static void Foo(MyStruct x) { }static void Bar(object x) { } }使用 in 關(guān)鍵字傳遞不可修改的引用
當(dāng)參數(shù)以?ref?傳遞時(shí),雖然傳遞的是引用但是無(wú)法確保引用值不被對(duì)方修改,這個(gè)時(shí)候只需要將?ref?改為?in,便能確保安全性:
CopySomeBigReadonlyStruct x = ...; Foo(x);void Foo(in SomeBigReadonlyStruct v) {v = ...; // error }在使用大的?readonly struct?時(shí)收益非常明顯。
使用 stackalloc 在棧上分配連續(xù)內(nèi)存
對(duì)于部分性能敏感卻需要使用少量的連續(xù)內(nèi)存的情況,不必使用數(shù)組,而可以通過(guò)?stackalloc?直接在棧上分配內(nèi)存,并使用?Span<T>?來(lái)安全的訪問(wèn),同樣的,這么做可以做到 0 GC 壓力。
stackalloc?允許任何的值類型結(jié)構(gòu),但是要注意,Span<T>?目前不支持?ref struct?作為泛型參數(shù),因此在使用?ref struct?時(shí)需要直接使用指針。
Copyref struct MyStruct {public int Value { get; set; } }class AllocGuide {static unsafe void RefStructAlloc(){MyStruct* x = stackalloc MyStruct[10];for (int i = 0; i < 10; i++){*(x + i) = new MyStruct { Value = i };}}static void StructAlloc(){Span<int> x = stackalloc int[10];for (int i = 0; i < x.Length; i++){x[i] = i;}} }使用 Span?操作連續(xù)內(nèi)存
C# 7 開(kāi)始引入了?Span<T>,它封裝了一種安全且高性能的內(nèi)存訪問(wèn)操作方法,可用于在大多數(shù)情況下代替指針操作。
Copystatic void SpanTest() {Span<int> x = stackalloc int[10];for (int i = 0; i < x.Length; i++){x[i] = i;}ReadOnlySpan<char> str = "12345".AsSpan();for (int i = 0; i < str.Length; i++){Console.WriteLine(str[i]);} }性能敏感時(shí)對(duì)于頻繁調(diào)用的函數(shù)使用 SkipLocalsInit
C# 為了確保代碼的安全會(huì)將所有的局部變量在聲明時(shí)就進(jìn)行初始化,無(wú)論是否必要。一般情況下這對(duì)性能并沒(méi)有太大影響,但是如果你的函數(shù)在操作很多棧上分配的內(nèi)存,并且該函數(shù)還是被頻繁調(diào)用的,那么這一消耗的副作用將會(huì)被放大變成不可忽略的損失。
因此你可以使用?SkipLocalsInit?這一特性禁用自動(dòng)初始化局部變量的行為。
Copy[SkipLocalsInit] unsafe static void Main() {Guid g;Console.WriteLine(*&g); }上述代碼將輸出不可預(yù)期的結(jié)果,因?yàn)?g?并沒(méi)有被初始化為 0。另外,訪問(wèn)未初始化的變量需要在?unsafe?上下文中使用指針進(jìn)行訪問(wèn)。
使用函數(shù)指針代替 Marshal 進(jìn)行互操作
C# 9 帶來(lái)了函數(shù)指針功能,該特性支持 managed 和 unmanaged 的函數(shù),在進(jìn)行 native interop 時(shí),使用函數(shù)指針將能顯著改善性能。
例如,你有如下 C++ 代碼:
Copy#define UNICODE #define WIN32 #include <cstring>extern "C" __declspec(dllexport) char* __cdecl InvokeFun(char* (*foo)(int)) {return foo(5); }并且你編寫(xiě)了如下 C# 代碼進(jìn)行互操作:
Copy[DllImport("./Test.dll")] static extern string InvokeFun(delegate* unmanaged[Cdecl]<int, IntPtr> fun);[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] public static IntPtr Foo(int x) {var str = Enumerable.Repeat("x", x).Aggregate((a, b) => $"{a}{b}");return Marshal.StringToHGlobalAnsi(str); }static void Main(string[] args) {var callback = (delegate* unmanaged[Cdecl]<int, nint>)(delegate*<int, nint>)&Foo;Console.WriteLine(InvokeFun(callback)); }上述代碼中,首先 C# 將自己的?Foo?方法作為函數(shù)指針傳給了 C++ 的?InvokeFun?函數(shù),然后 C++ 用參數(shù) 5 調(diào)用該函數(shù)并返回其返回值到 C# 的調(diào)用方。
注意到上述代碼還用了?UnmanagedCallersOnly?這一特性,這樣可以告訴編譯器該方法只會(huì)從 unmanaged 的代碼被調(diào)用,因此編譯器可以做一些額外的優(yōu)化。
使用函數(shù)指針產(chǎn)生的 IL 指令非常高效:
Copyldftn native int Test.Program::Foo(int32) stloc.0 ldloc.0 call string Test.Program::InvokeFun(method native int *(int32))除了 unmanaged 的情況外,managed 函數(shù)也是可以使用函數(shù)指針的:
Copystatic void Foo(int v) { } unsafe static void Main(string[] args) {delegate* managed<int, void> fun = &Foo;fun(4); }產(chǎn)生的代碼相對(duì)于原本的 Delegate 來(lái)說(shuō)更加高效:
Copyldftn void Test.Program::Foo(int32) stloc.0 ldc.i4.4 ldloc.0 calli void(int32)使用模式匹配
有了if-else、as和強(qiáng)制類型轉(zhuǎn)換,為什么要使用模式匹配呢?有三方面原因:性能、魯棒性和可讀性。
為什么說(shuō)性能也是一個(gè)原因呢?因?yàn)?C# 編譯器會(huì)根據(jù)你的模式編譯出最優(yōu)的匹配路徑。
考慮一下以下代碼(代碼 1):
Copyint Match(int v) {if (v > 3){return 5;}if (v < 3){if (v > 1){return 6;}if (v > -5){return 7;}else{return 8;}}return 9; }如果改用模式匹配,配合?switch?表達(dá)式寫(xiě)法則變成(代碼 2):
Copyint Match(int v) {return v switch{> 3 => 5,< 3 and > 1 => 6,< 3 and > -5 => 7,< 3 => 8,_ => 9}; }以上代碼會(huì)被編譯器編譯為:
Copyint Match(int v) {if (v > 1){if (v <= 3){if (v < 3){return 6;}return 9;}return 5;}if (v > -5){return 7;}return 8; }我們計(jì)算一下平均比較次數(shù):
| 代碼 1 | 1 | 3 | 4 | 4 | 2 | 14 | 2.8 |
| 代碼 2 | 2 | 3 | 2 | 2 | 3 | 12 | 2.4 |
可以看到使用模式匹配時(shí),編譯器選擇了更優(yōu)的比較方案,你在編寫(xiě)的時(shí)候無(wú)需考慮如何組織判斷語(yǔ)句,心智負(fù)擔(dān)降低,并且代碼 2 可讀性和簡(jiǎn)潔程度顯然比代碼 1 更好,有哪些條件分支一目了然。
甚至遇到類似以下的情況時(shí):
Copyint Match(int v) {return v switch{1 => 5,2 => 6,3 => 7,4 => 8,_ => 9}; }編譯器會(huì)直接將代碼從條件判斷語(yǔ)句編譯成?switch?語(yǔ)句:
Copyint Match(int v) {switch (v){case 1:return 5;case 2:return 6;case 3:return 7;case 4:return 8;default:return 9;} }如此一來(lái)所有的判斷都不需要比較(因?yàn)?switch?可根據(jù) HashCode 直接跳轉(zhuǎn))。
編譯器非常智能地為你選擇了最佳的方案。
那魯棒性從何談起呢?假設(shè)你漏掉了一個(gè)分支:
Copyint v = 5; var x = v switch {> 3 => 1,< 3 => 2 };此時(shí)編譯的話,編譯器就會(huì)警告你漏掉了?v?可能為 3 的情況,幫助減少程序出錯(cuò)的可能性。
最后一點(diǎn),可讀性。
假設(shè)你現(xiàn)在有這樣的東西:
Copyabstract class Entry { }class UserEntry : Entry {public int UserId { get; set; } }class DataEntry : Entry {public int DataId { get; set; } }class EventEntry : Entry {public int EventId { get; set; }// 如果 CanRead 為 false 則查詢的時(shí)候直接返回空字符串public bool CanRead { get; set; } }現(xiàn)在有接收類型為?Entry?的參數(shù)的一個(gè)函數(shù),該函數(shù)根據(jù)不同類型的?Entry?去數(shù)據(jù)庫(kù)查詢對(duì)應(yīng)的?Content,那么只需要寫(xiě):
Copystring QueryMessage(Entry entry) {return entry switch{UserEntry u => dbContext1.User.FirstOrDefault(i => i.Id == u.UserId).Content,DataEntry d => dbContext1.Data.FirstOrDefault(i => i.Id == d.DataId).Content,EventEntry { EventId: var eventId, CanRead: true } => dbContext1.Event.FirstOrDefault(i => i.Id == eventId).Content,_ => throw new InvalidArgumentException("無(wú)效的參數(shù)")}; }更進(jìn)一步,假如?Entry.Id?分布在了數(shù)據(jù)庫(kù) 1 和 2 中,如果在數(shù)據(jù)庫(kù) 1 當(dāng)中找不到則需要去數(shù)據(jù)庫(kù) 2 進(jìn)行查詢,如果 2 也找不到才返回空字符串,由于 C# 的模式匹配支持遞歸模式,因此只需要這樣寫(xiě):
Copystring QueryMessage(Entry entry) {return entry switch{UserEntry u => dbContext1.User.FirstOrDefault(i => i.Id == u.UserId) switch{null => dbContext2.User.FirstOrDefault(i => i.Id == u.UserId)?.Content ?? "",var found => found.Content},DataEntry d => dbContext1.Data.FirstOrDefault(i => i.Id == d.DataId) switch{null => dbContext2.Data.FirstOrDefault(i => i.Id == u.DataId)?.Content ?? "",var found => found.Content},EventEntry { EventId: var eventId, CanRead: true } => dbContext1.Event.FirstOrDefault(i => i.Id == eventId) switch{null => dbContext2.Event.FirstOrDefault(i => i.Id == eventId)?.Content ?? "",var found => found.Content},EventEntry { CanRead: false } => "",_ => throw new InvalidArgumentException("無(wú)效的參數(shù)")}; }就全部搞定了,代碼非常簡(jiǎn)潔,而且數(shù)據(jù)的流向一眼就能看清楚,就算是沒(méi)有接觸過(guò)這部分代碼的人看一下模式匹配的過(guò)程,也能一眼就立刻掌握各分支的情況,而不需要在一堆的?if-else?當(dāng)中梳理這段代碼到底干了什么。
使用記錄類型和不可變數(shù)據(jù)
record?作為 C# 9 的新工具,配合?init?僅可初始化屬性,為我們帶來(lái)了高效的數(shù)據(jù)交互能力和不可變性。
消除可變性意味著無(wú)副作用,一個(gè)無(wú)副作用的函數(shù)無(wú)需擔(dān)心數(shù)據(jù)同步互斥問(wèn)題,因此在無(wú)鎖的并行編程中非常有用。
Copyrecord Point(int X, int Y);簡(jiǎn)單的一句話等價(jià)于我們寫(xiě)了如下代碼,幫我們解決了?ToString()?格式化輸出、基于值的?GetHashCode()?和相等判斷等等各種問(wèn)題:
Copyinternal class Point : IEquatable<Point> {private readonly int x;private readonly int y;protected virtual Type EqualityContract => typeof(Point);public int X{get => x;set => x = value;}public int Y{get => y;set => y = value;}public Point(int X, int Y){x = X;y = Y;}public override string ToString(){StringBuilder stringBuilder = new StringBuilder();stringBuilder.Append("Point");stringBuilder.Append(" { ");if (PrintMembers(stringBuilder)){stringBuilder.Append(" ");}stringBuilder.Append("}");return stringBuilder.ToString();}protected virtual bool PrintMembers(StringBuilder builder){builder.Append("X");builder.Append(" = ");builder.Append(X.ToString());builder.Append(", ");builder.Append("Y");builder.Append(" = ");builder.Append(Y.ToString());return true;}public static bool operator !=(Point r1, Point r2){return !(r1 == r2);}public static bool operator ==(Point r1, Point r2){if ((object)r1 != r2){if ((object)r1 != null){return r1.Equals(r2);}return false;}return true;}public override int GetHashCode(){return (EqualityComparer<Type>.Default.GetHashCode(EqualityContract) * -1521134295 + EqualityComparer<int>.Default.GetHashCode(x)) * -1521134295 + EqualityComparer<int>.Default.GetHashCode(y);}public override bool Equals(object obj){return Equals(obj as Point);}public virtual bool Equals(Point other){if ((object)other != null && EqualityContract == other.EqualityContract && EqualityComparer<int>.Default.Equals(x, other.x)){return EqualityComparer<int>.Default.Equals(y, other.y);}return false;}public virtual Point Clone(){return new Point(this);}protected Point(Point original){x = original.x;y = original.y;}public void Deconstruct(out int X, out int Y){X = this.X;Y = this.Y;} }注意到?x?與?y?都是?readonly?的,因此一旦實(shí)例創(chuàng)建了就不可變,如果想要變更可以通過(guò)?with?創(chuàng)建一份副本,于是這種方式徹底消除了任何的副作用。
Copyvar p1 = new Point(1, 2); var p2 = p1 with { Y = 3 }; // (1, 3)當(dāng)然,你也可以自己使用?init?屬性表示這個(gè)屬性只能在初始化時(shí)被賦值:
Copyclass Point {public int X { get; init; }public int Y { get; init; } }這樣一來(lái),一旦?Point?被創(chuàng)建,則?X?和?Y?的值就不會(huì)被修改了,可以放心地在并行編程模型中使用,而不需要加鎖。
Copyvar p1 = new Point { X = 1, Y = 2 }; p1.Y = 3; // error var p2 = p1 with { Y = 3 }; //ok使用 readonly 類型
上面說(shuō)到了不可變性的重要性,當(dāng)然,struct?也可以是只讀的:
Copyreadonly struct Foo {public int X { get; set; } // error }上面的代碼會(huì)報(bào)錯(cuò),因?yàn)檫`反了?X?只讀的約束。
如果改成:
Copyreadonly struct Foo {public int X { get; } }或
Copyreadonly struct Foo {public int X { get; init; } }則不會(huì)存在問(wèn)題。
Span<T>?本身是一個(gè)?readonly ref struct,通過(guò)這樣做保證了?Span<T>?里的東西不會(huì)被意外的修改,確保不變性和安全。
使用局部函數(shù)而不是 lambda 創(chuàng)建臨時(shí)委托
在使用?Expression<Func<>>?作為參數(shù)的 API 時(shí),使用 lambda 表達(dá)式是非常正確的,因?yàn)榫幾g器會(huì)把我們寫(xiě)的 lambda 表達(dá)式編譯成 Expression Tree,而非直觀上的函數(shù)委托。
而在單純只是?Func<>、Action<>?時(shí),使用 lambda 表達(dá)式恐怕不是一個(gè)好的決定,因?yàn)檫@樣做必定會(huì)引入一個(gè)新的閉包,造成額外的開(kāi)銷和 GC 壓力。從 C# 8 開(kāi)始,我們可以使用局部函數(shù)很好的替換掉 lambda:
Copyint SomeMethod(Func<int, int> fun) {if (fun(3) > 3) return 3;else return fun(5); }void Caller() {int Foo(int v) => v + 1;var result = SomeMethod(Foo);Console.WriteLine(result); }以上代碼便不會(huì)導(dǎo)致一個(gè)多余的閉包開(kāi)銷。
使用 ValueTask 代替 Task
我們?cè)谟龅?Task<T>?時(shí),大多數(shù)情況下只是需要簡(jiǎn)單的對(duì)其進(jìn)行?await?而已,而并不需要將其保存下來(lái)以后再?await,那么?Task<T>?提供的很多的功能則并沒(méi)有被使用,反而在高并發(fā)下,由于反復(fù)分配?Task?導(dǎo)致 GC 壓力增加。
這種情況下,我們可以使用?ValueTask<T>?代替?Task<T>:
CopyValueTask<int> Foo() {return ValueTask.FromResult(1); }async ValueTask Caller() {await Foo(); }由于?ValueTask<T>?是值類型結(jié)構(gòu),因此該對(duì)象本身不會(huì)在堆上分配內(nèi)存,于是可以減輕 GC 壓力。
實(shí)現(xiàn)解構(gòu)函數(shù)代替創(chuàng)建元組
如果我們想要把一個(gè)類型中的數(shù)據(jù)提取出來(lái),我們可以選擇返回一個(gè)元組,其中包含我們需要的數(shù)據(jù):
Copyclass Foo {private int x;private int y;public Foo(int x, int y){this.x = x;this.y = y;}public (int, int) Deconstruct(){return (x, y);} }class Program {static void Bar(Foo v){var (x, y) = v.Deconstruct();Console.WriteLine($"X = {x}, Y = {y}");} }上述代碼會(huì)導(dǎo)致一個(gè)?ValueTuple<int, int>?的開(kāi)銷,如果我們將代碼改成實(shí)現(xiàn)解構(gòu)方法:
Copyclass Foo {private int x;private int y;public Foo(int x, int y){this.x = x;this.y = y;}public void Deconstruct(out int x, out int y){x = this.x;y = this.y;} }class Program {static void Bar(Foo v){var (x, y) = v;Console.WriteLine($"X = {x}, Y = {y}");} }則不僅省掉了?Deconstruct()?的調(diào)用,同時(shí)還沒(méi)有任何的額外開(kāi)銷。你可以看到實(shí)現(xiàn) Deconstruct 函數(shù)并不需要讓你的類型實(shí)現(xiàn)任何的接口,從根本上杜絕了裝箱的可能性,這是一種 0 開(kāi)銷抽象。另外,解構(gòu)函數(shù)還能用于做模式匹配,你可以像使用元組一樣地使用解構(gòu)函數(shù)(下面代碼的意思是,當(dāng)?x?為 3 時(shí)取?y,否則取?x + y):
Copyvoid Bar(Foo v) {var result = v switch{Foo (3, var y) => y,Foo (var x, var y) => x + y,_ => 0};Console.WriteLine(result); }Null 安全
在項(xiàng)目屬性文件 csproj 中啟用 null 安全后即可對(duì)整個(gè)項(xiàng)目的代碼啟用 null 安全靜態(tài)分析:
Copy<PropertyGroup><Nullable>enable</Nullable> </PropertyGroup>這樣便可以在編譯的時(shí)候檢查一切潛在的導(dǎo)致 NRE 的問(wèn)題。例如如下代碼:
Copyvar list = new List<Entry>(); var value = list.FirstOrDefault(i => i.Id == 3).Value; Console.WriteLine(value);list.FirstOrDefault()?可能返回?null,因此啟用 null 安全之后編譯器將會(huì)給出警告,這有助于避免不必要的 NRE 異常發(fā)生。
另外,啟用 null 安全之后,對(duì)于可空引用類型,也可以通過(guò)在類型后加一個(gè)???來(lái)表示可為?null:
Copystring? x = null;總結(jié)
在合適的時(shí)候使用 C# 的新特性,不但可以提升開(kāi)發(fā)效率,同時(shí)還能兼顧代碼質(zhì)量和運(yùn)行效率的提升。
但是切忌濫用。新特性的引入對(duì)于我們寫(xiě)高質(zhì)量的代碼無(wú)疑有很大的幫助,但是如果不分時(shí)宜地使用,可能會(huì)帶來(lái)反效果。
希望本文能對(duì)各位開(kāi)發(fā)者使用新版 C# 時(shí)帶來(lái)一定的幫助,感謝閱讀。
總結(jié)
以上是生活随笔為你收集整理的新版 C# 高效率编程指南的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 跟我一起学.NetCore之文件系统应用
- 下一篇: c# char unsigned_dll