NuGet 上有很多大而全的 C# 扩展库。它们通常会为各种常用的类型提供相当多的扩展方法,从而辅助我们的开发。最常见的功能有:
- 字符串处理
- 集合处理
- 文件操作
- 序列化与反序列化
- 网络请求
- 日期时间处理
等等。但我的建议是,不要轻易使用这些大而全的扩展库。我们今天就来探讨一下这个问题。为了避免高级黑或者拉踩之类的嫌疑,这次我不会点名具体的库,但是会通过一些例子教大家如何判断一个库是否适合使用,以及该如何正确地学习和利用它们。
原因一:功能大多数用不上,而且污染基本类型 ¶
首先最重要的一个原因就是,这些扩展库往往会提供上百个扩展方法,但是我们实际用得到的通常只有 10 个以内。而为了用这几个功能,我们却不得不引入整个库,从而导致我们的项目变得臃肿。
更糟糕的是,这些扩展库因为充分扩展了常见的数据类型(比如 string、IEnumerable<T>、DateTime 等等),会导致代码补全时出现大量无关的扩展方法,影响开发效率。如果我们将这个库引入到团队项目中,那就更麻烦了,团队成员在编写代码时也会被这些无关的扩展方法干扰。
可能有人会说,这些扩展库都会将方法放在特定的命名空间下,我们只需要不引入这个命名空间就行了。理论上是这样没错,但实际上现在的 IDE 都相当智能。即便你没有引入命名空间,通常也会看到这些方法的提示,并且如果你不慎使用,IDE 还会自动帮你添加 using 语句,从而引入了整个扩展库。所以只要你的项目添加了这个扩展库,就很难避免污染。
原因二:扩展库可能引入不必要的依赖 ¶
有些扩展库为了充分向前兼容,往往会使用一些比较老旧的实现方式,甚至现在已经被标记为过时的 .NET 内置方法。比如为了实现序列化反序列化,它可能会引入 Newtonsoft.Json,而不是使用现在更推荐的 System.Text.Json,甚至有的可能还在使用 System.Runtime.Serialization.Json 命名空间下的老旧方法;又比如网络请求,它可能会使用 HttpWebRequest,而不是现在更推荐的 HttpClient。
这些都会导致我们的项目引入一些不必要的依赖,从而增加项目的复杂度和体积。
原因三:使用过时的标准库方法 ¶
这些扩展库可能因为要兼容更早版本的 .NET,或因为过于庞杂难以更新,往往会使用一些已经过时的标准库方法,从而影响我们的代码质量和性能。
案例一:哈希 ¶
我见过一些情形,比如计算 MD5,它虽然使用了 .NET 内置的 MD5 类,但却没有使用现在更推荐的方式:
| |
这看起来可能没什么,但实际上这两种方式的性能差别还是很可观的。传统的 MD5.Create() 会在托管堆上创建一个加密算法实例,而 .NET 5 新增的 MD5.HashData 是静态方法,内部通常会复用或通过更底层(往往是无状态的)的方式直接调用操作系统或运行时的加密库。
不仅如此,HashData 提供支持 ReadOnlySpan<byte> 的重载。必要的情况下,我们可以将哈希值直接写入预先分配好的缓冲区(如 stackalloc 分配的栈内存),从而实现完全不分配堆内存。
简单跑一个分,可以看到新方法不仅效率高,而且几乎没有 GC 开销:
| Method | Mean | Error | StdDev | Gen0 | Gen1 | Allocated |
|---|---|---|---|---|---|---|
| ComputeHash | 335.7 ns | 35.04 ns | 1.92 ns | 0.0172 | 0.0005 | 216 B |
| HashData | 192.1 ns | 11.67 ns | 0.64 ns | 0.0031 | 0.0002 | 40 B |
因此,如果我们使用这些大而全的扩展库,可能会无意中引入一些过时的实现方式,影响我们的代码质量和性能。
案例二:字符串处理 ¶
字符串处理也是一个重灾区。因为在最近几年,Span 和 Memory 的引入,.NET 在字符串处理方面有了很大的改进。很多以前需要分配堆内存的操作,现在都可以通过 Span<char> 来实现零分配的高效处理。除此之外,现在的 .NET 底层还会利用 SIMD 指令集来加速字符串处理操作。
比如我见过有一个库提供了 ContainsAll 方法,用于检查一个字符串是否包含多个子串。这个方法的实现是这样的:
| |
这是一个在现在看来比较糟糕的实现。对于判断是否包含子字符串这样的需求,用 Contains 的效率是显著高于 IndexOf 的,因为 Contains 方法在底层已经做了很多优化,比如借助 Span,以及利用 SIMD 指令集来加速搜索过程。这里就不贴跑分了,我在自己的设备上测出了超过 40 倍的性能提升。
所以一个更加高效的实现方式可以是:
| |
是的,LINQ 在最近几个版本的 .NET 中也迎来了相当多的更新。许多常用的方法现在都可以做到没有 GC 开销。但遗憾的是,这些新变化在那些扩展库诞生的年代可能还没有出现,所以它们的实现方式往往比较落后。
我的建议 ¶
虽然我上面提到了这些问题,而且我确实不建议(至少是在现在)使用这些大而全的扩展库,但我并不是说它们完全没有价值。我们可以用下面几种方式来充分利用或学习它们:
- 我们要面对的是很老的项目(例如 .NET Framework 4.x)。这种情况下,.NET 的后续很多优化和新的标准库方法都还没有出现,所以就更谈不上性能问题了。我们可以直接使用这些扩展库来提升开发效率,和弥补旧标准库缺失的一些功能。
- 我们可以学习和借鉴这些扩展库的设计思路和实现方式。虽然它们可能在性能上不够理想,但在功能设计和 API 设计上,还是有很多值得我们学习的地方的。
- 我们可以从这些扩展库中挑选出我们真正需要的功能,然后自己实现一个轻量级的版本,或者只是简单地复制粘贴到我们自己的项目中。这样既能满足我们的需求,又能避免引入不必要的依赖和性能问题。
总之,不要轻易使用大而全的扩展库,我们应该根据自己的实际需求来选择和使用这些库,同时也要注意它们可能带来的性能和依赖问题。