新版C#高效率编程指南,这10个技巧你应该知道!

玩转GitHub

共 12042字,需浏览 25分钟

 ·

2020-11-08 09:47



转自:hez2010
cnblogs.com/hez2010/p/13724904.html

前言


C# 从 7 版本开始一直到如今的 9 版本,加入了非常多的特性,其中不乏改善性能、增加程序健壮性和代码简洁性、可读性的改进,这里我整理一些使用新版 C# 的时候个人推荐的写法,可能不适用于所有的人,但是还是希望对你们有所帮助。


注意:本指南适用于 .NET 5 或以上版本。


1.使用 ref struct 做到 0 GC


C# 7 开始引入了一种叫做 ref struct 的结构,这种结构本质是 struct ,结构存储在栈内存。但是与 struct 不同的是,该结构不允许实现任何接口,并由编译器保证该结构永远不会被装箱,因此不会给 GC 带来任何的压力。相对的,使用中就会有不能逃逸出栈的强制限制。


Span 就是利用 ref struct 的产物,成功的封装出了安全且高性能的内存访问操作,且可在大多数情况下代替指针而不损失任何的性能。


ref struct MyStruct{
public int Value { get; set; }
}
class RefStructGuide
{
static void Test()
{
MyStruct x = new MyStruct();
x.Value = 100;
Foo(x); // ok
Bar(x); // error, x cannot be boxed
}
static void Foo(MyStruct x) { }
static void Bar(object x) { }
}


2.使用 stackalloc 在栈上分配连续内存


对于部分性能敏感却需要使用少量的连续内存的情况,不必使用数组,而可以通过 stackalloc 直接在栈上分配内存,并使用 Span 来安全的访问,同样的,这么做可以做到 0 GC 压力。


stackalloc 允许任何的值类型结构,但是要注意,Span 目前不支持 ref struct 作为泛型参数,因此在使用 ref struct 时需要直接使用指针。


ref 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;
}
}
}


3.使用Span操作连续内存


C# 7 开始引入了 Span,它封装了一种安全且高性能的内存访问操作方法,可用于在大多数情况下代替指针操作。


static 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]);
}
}


4.性能敏感时对于频繁调用的函数使用 SkipLocalsInit


C# 为了确保代码的安全会将所有的局部变量在声明时就进行初始化,无论是否必要。一般情况下这对性能并没有太大影响,但是如果你的函数在操作很多栈上分配的内存,并且该函数还是被频繁调用的,那么这一消耗的副作用将会被放大变成不可忽略的损失。


因此你可以使用 SkipLocalsInit 这一特性禁用自动初始化局部变量的行为。


[SkipLocalsInit]
unsafe static void Main()
{
Guid g;
Console.WriteLine(*&g);
}


上述代码将输出不可预期的结果,因为 g 并没有被初始化为 0。另外,访问未初始化的变量需要在 unsafe 上下文中使用指针进行访问。


5.使用函数指针代替 Marshal 进行互操作


C# 9 带来了函数指针功能,该特性支持 managed 和 unmanaged 的函数,在进行 native interop 时,使用函数指针将能显著改善性能。


例如,你有如下 C++ 代码:


#define UNICODE
#define WIN32
#include
extern "C" __declspec(dllexport) char* __cdecl InvokeFun
(char* (*foo)(int))
{
return foo(5);
}


并且你编写了如下 C# 代码进行互操作:


[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 方法作为函数指针传给了 C++ 的 InvokeFun 函数,然后 C++ 用参数 5 调用该函数并返回其返回值到 C# 的调用方。


注意到上述代码还用了 UnmanagedCallersOnly 这一特性,这样可以告诉编译器该方法只会从 unmanaged 的代码被调用,因此编译器可以做一些额外的优化。


使用函数指针产生的 IL 指令非常高效:


ldftn native int Test.Program::Foo(int32)
stloc.0
ldloc.0
call string Test.Program::InvokeFun(method native int *(int32))


除了 unmanaged 的情况外,managed 函数也是可以使用函数指针的:


static void Foo(int v) { }
unsafe static void Main(string[] args)
{
delegate* managed<int, void> fun = &Foo;
fun(4);
}


产生的代码相对于原本的 Delegate 来说更加高效:


ldftn void Test.Program::Foo(int32)
stloc.0
ldc.i4.4
ldloc.0
calli void(int32)


6.使用模式匹配


有了if-else、as和强制类型转换,为什么要使用模式匹配呢?有三方面原因:性能、鲁棒性和可读性。


为什么说性能也是一个原因呢?因为 C# 编译器会根据你的模式编译出最优的匹配路径。


考虑一下以下代码(代码 1):


int 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 表达式写法则变成(代码 2):


int Match(int v)
{
return v switch
{
> 3 => 5,
< 3 and > 1 => 6,
< 3 and > -5 => 7,
< 3 => 8,
_ => 9
};
}


以上代码会被编译器编译为:


int 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;
}


我们计算一下平均比较次数:



可以看到使用模式匹配时,编译器选择了更优的比较方案,你在编写的时候无需考虑如何组织判断语句,心智负担降低,并且代码 2 可读性和简洁程度显然比代码 1 更好,有哪些条件分支一目了然。


甚至遇到类似以下的情况时:


int Match(int v)
{
return v switch
{
1 => 5,
2 => 6,
3 => 7,
4 => 8,
_ => 9
};
}


编译器会直接将代码从条件判断语句编译成 switch 语句:


int Match(int v)
{
switch (v)
{
case 1:
return 5;
case 2:
return 6;
case 3:
return 7;
case 4:
return 8;
default:
return 9;
}
}


如此一来所有的判断都不需要比较(因为 switch 可根据 HashCode 直接跳转)。


编译器非常智能地为你选择了最佳的方案。


那鲁棒性从何谈起呢?假设你漏掉了一个分支:


int v = 5;
var x = v switch
{
> 3 => 1,
< 3 => 2
};


此时编译的话,编译器就会警告你漏掉了 v 可能为 3 的情况,帮助减少程序出错的可能性。


最后一点,可读性。


假设你现在有这样的东西:


abstract 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 则查询的时候直接返回空字符串
public bool CanRead { get; set; }
}


现在有接收类型为 Entry 的参数的一个函数,该函数根据不同类型的 Entry 去数据库查询对应的 Content,那么只需要写:


string 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,
EventEntry { CanRead: false } => "",
_ => throw new InvalidArgumentException("无效的参数")
};
}


更进一步,假如 Entry.Id 分布在了数据库 1 和 2 中,如果在数据库 1 当中找不到则需要去数据库 2 进行查询,如果 2 也找不到才返回空字符串,由于 C# 的模式匹配支持递归模式,因此只需要这样写:


string 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("无效的参数")
};
}


就全部搞定了,代码非常简洁,而且数据的流向一眼就能看清楚,就算是没有接触过这部分代码的人看一下模式匹配的过程,也能一眼就立刻掌握各分支的情况,而不需要在一堆的 if-else 当中梳理这段代码到底干了什么。


7.使用记录类型和不可变数据


record 作为 C# 9 的新工具,配合 init 仅可初始化属性,为我们带来了高效的数据交互能力和不可变性。


消除可变性意味着无副作用,一个无副作用的函数无需担心数据同步互斥问题,因此在无锁的并行编程中非常有用。


record Point(int X, int Y);


简单的一句话等价于我们写了如下代码,帮我们解决了 ToString() 格式化输出、基于值的 GetHashCode() 和相等判断等等各种问题:


internal class Point : IEquatable
{
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.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 的,因此一旦实例创建了就不可变,如果想要变更可以通过 with 创建一份副本,于是这种方式彻底消除了任何的副作用。


var p1 = new Point(1, 2);
var p2 = p1 with { Y = 3 }; // (1, 3)


当然,你也可以自己使用 init 属性表示这个属性只能在初始化时被赋值:


class Point
{
public int X { get; init; }
public int Y { get; init; }
}


这样一来,一旦 Point 被创建,则 X 和 Y 的值就不会被修改了,可以放心地在并行编程模型中使用,而不需要加锁。


var p1 = new Point { X = 1, Y = 2 };
p1.Y = 3; // error
var p2 = p1 with { Y = 3 }; //ok


使用 readonly 类型


上面说到了不可变性的重要性,当然,struct 也可以是只读的:

readonly struct Foo
{
public int X { get; set; } // error
}


上面的代码会报错,因为违反了 X 只读的约束。


如果改成:


readonly struct Foo
{
public int X { get; }
}



readonly struct Foo
{
public int X { get; init; }
}


则不会存在问题。


Span 本身是一个 readonly ref struct,通过这样做保证了 Span 里的东西不会被意外的修改,确保不变性和安全。


8.使用局部函数而不是 lambda 创建临时委托


在使用 Expression> 作为参数的 API 时,使用 lambda 表达式是非常正确的,因为编译器会把我们写的 lambda 表达式编译成 Expression Tree,而非直观上的函数委托。


而在单纯只是 Func<>、Action<> 时,使用 lambda 表达式恐怕不是一个好的决定,因为这样做必定会引入一个新的闭包,造成额外的开销和 GC 压力。从 C# 8 开始,我们可以使用局部函数很好的替换掉 lambda:


int 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);
}


以上代码便不会导致一个多余的闭包开销。


9.使用 ValueTask 代替 Task


我们在遇到 Task 时,大多数情况下只是需要简单的对其进行 await 而已,而并不需要将其保存下来以后再 await,那么 Task 提供的很多的功能则并没有被使用,反而在高并发下,由于反复分配 Task 导致 GC 压力增加。


这种情况下,我们可以使用 ValueTask 代替 Task


async ValueTask<int> Foo()
{
await Task.Delay(5000);
return 5;
}
async ValueTask Caller()
{
await Foo();
}


由于 ValueTask 是值类型结构,因此不会在堆上分配内存,于是可以做到 0 GC。


10.实现解构函数代替创建元组


如果我们想要把一个类型中的数据提取出来,我们可以选择返回一个元组,其中包含我们需要的数据:


class 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}");
}
}


上述代码会导致一个 ValueTuple 的开销,如果我们将代码改成实现解构方法:


class 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() 的调用,同时还没有任何的额外开销。你可以看到实现 Deconstruct 函数并不需要让你的类型实现任何的接口,从根本上杜绝了装箱的可能性,这是一种 0 开销抽象。另外,解构函数还能用于做模式匹配,你可以像使用元组一样地使用解构函数(下面代码的意思是,当 x 为 3 时取 y,否则取 x + y):


void Bar(Foo v)
{
var result = v switch
{
Foo (3, var y) => y,
Foo (var x, var y) => x + y,
_ => 0
};
Console.WriteLine(result);
}


总结


在合适的时候使用C#的新特性,不但可以提升开发效率,同时还能兼顾代码质量和运行效率的提升。


但是切忌滥用。新特性的引入对于我们写高质量的代码无疑有很大的帮助,但是如果不分时宜地使用,可能会带来反效果。


希望本文能对各位开发者使用新版C#时带来一定的帮助,感谢阅读。



如果你也有好的开源项目,欢迎推荐!

微信号联系:westbrook12000(ps:加好友请备注“开源”)

回复 【小程序】获取15套小程序源码【学习+实战+赚钱】
回复 【关闭】学关闭微信朋友圈广告
回复 【实战】获取20套实战源码
回复 【福利】获取最新微信支付有奖励
回复 【被删】学查看你哪个好友删除了你巧
回复 【访客】学微信查看朋友圈访客记录
回复 【python】学微获取全套0基础Python知识手册

淘宝自动刷喵币!靠这一个脚本就够了


最近超火的微信表情辫子!


浏览 11
点赞
评论
收藏
分享

手机扫一扫分享

举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

举报