不要轻易使用大而全的扩展库

在 NuGet 上有很多大而全的 C# 扩展库,虽然它们提供了丰富的功能,但也可能带来性能和依赖问题。本文将探讨为什么不建议轻易使用这些库,并提供一些建议来正确利用它们。

NuGet 上有很多大而全的 C# 扩展库。它们通常会为各种常用的类型提供相当多的扩展方法,从而辅助我们的开发。最常见的功能有:

  • 字符串处理
  • 集合处理
  • 文件操作
  • 序列化与反序列化
  • 网络请求
  • 日期时间处理

等等。但我的建议是,不要轻易使用这些大而全的扩展库。我们今天就来探讨一下这个问题。为了避免高级黑或者拉踩之类的嫌疑,这次我不会点名具体的库,但是会通过一些例子教大家如何判断一个库是否适合使用,以及该如何正确地学习和利用它们。

原因一:功能大多数用不上,而且污染基本类型

首先最重要的一个原因就是,这些扩展库往往会提供上百个扩展方法,但是我们实际用得到的通常只有 10 个以内。而为了用这几个功能,我们却不得不引入整个库,从而导致我们的项目变得臃肿。

更糟糕的是,这些扩展库因为充分扩展了常见的数据类型(比如 stringIEnumerable<T>DateTime 等等),会导致代码补全时出现大量无关的扩展方法,影响开发效率。如果我们将这个库引入到团队项目中,那就更麻烦了,团队成员在编写代码时也会被这些无关的扩展方法干扰。

可能有人会说,这些扩展库都会将方法放在特定的命名空间下,我们只需要不引入这个命名空间就行了。理论上是这样没错,但实际上现在的 IDE 都相当智能。即便你没有引入命名空间,通常也会看到这些方法的提示,并且如果你不慎使用,IDE 还会自动帮你添加 using 语句,从而引入了整个扩展库。所以只要你的项目添加了这个扩展库,就很难避免污染。

原因二:扩展库可能引入不必要的依赖

有些扩展库为了充分向前兼容,往往会使用一些比较老旧的实现方式,甚至现在已经被标记为过时的 .NET 内置方法。比如为了实现序列化反序列化,它可能会引入 Newtonsoft.Json,而不是使用现在更推荐的 System.Text.Json,甚至有的可能还在使用 System.Runtime.Serialization.Json 命名空间下的老旧方法;又比如网络请求,它可能会使用 HttpWebRequest,而不是现在更推荐的 HttpClient

这些都会导致我们的项目引入一些不必要的依赖,从而增加项目的复杂度和体积。

原因三:使用过时的标准库方法

这些扩展库可能因为要兼容更早版本的 .NET,或因为过于庞杂难以更新,往往会使用一些已经过时的标准库方法,从而影响我们的代码质量和性能。

案例一:哈希

我见过一些情形,比如计算 MD5,它虽然使用了 .NET 内置的 MD5 类,但却没有使用现在更推荐的方式:

1
2
3
4
5
6
7
8
// 过时的用法
using (var md5 = MD5.Create())
{
    var hash = md5.ComputeHash(data);
}

// 现代的用法
var hash = MD5.HashData(data);

这看起来可能没什么,但实际上这两种方式的性能差别还是很可观的。传统的 MD5.Create() 会在托管堆上创建一个加密算法实例,而 .NET 5 新增的 MD5.HashData 是静态方法,内部通常会复用或通过更底层(往往是无状态的)的方式直接调用操作系统或运行时的加密库。

不仅如此,HashData 提供支持 ReadOnlySpan<byte> 的重载。必要的情况下,我们可以将哈希值直接写入预先分配好的缓冲区(如 stackalloc 分配的栈内存),从而实现完全不分配堆内存。

简单跑一个分,可以看到新方法不仅效率高,而且几乎没有 GC 开销:

MethodMeanErrorStdDevGen0Gen1Allocated
ComputeHash335.7 ns35.04 ns1.92 ns0.01720.0005216 B
HashData192.1 ns11.67 ns0.64 ns0.00310.000240 B

因此,如果我们使用这些大而全的扩展库,可能会无意中引入一些过时的实现方式,影响我们的代码质量和性能。

案例二:字符串处理

字符串处理也是一个重灾区。因为在最近几年,SpanMemory 的引入,.NET 在字符串处理方面有了很大的改进。很多以前需要分配堆内存的操作,现在都可以通过 Span<char> 来实现零分配的高效处理。除此之外,现在的 .NET 底层还会利用 SIMD 指令集来加速字符串处理操作。

比如我见过有一个库提供了 ContainsAll 方法,用于检查一个字符串是否包含多个子串。这个方法的实现是这样的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public static bool ContainsAll(this string source, IEnumerable<string> values)
{
    foreach (var value in values)
    {
        if (source.IndexOf(value) < 0)
        {
            return false;
        }
    }
    return true;
}

这是一个在现在看来比较糟糕的实现。对于判断是否包含子字符串这样的需求,用 Contains 的效率是显著高于 IndexOf 的,因为 Contains 方法在底层已经做了很多优化,比如借助 Span,以及利用 SIMD 指令集来加速搜索过程。这里就不贴跑分了,我在自己的设备上测出了超过 40 倍的性能提升。

所以一个更加高效的实现方式可以是:

1
2
3
4
public static bool ContainsAll(this string source, IEnumerable<string> values)
{
    return values.All(value => source.Contains(value));
}

是的,LINQ 在最近几个版本的 .NET 中也迎来了相当多的更新。许多常用的方法现在都可以做到没有 GC 开销。但遗憾的是,这些新变化在那些扩展库诞生的年代可能还没有出现,所以它们的实现方式往往比较落后。

我的建议

虽然我上面提到了这些问题,而且我确实不建议(至少是在现在)使用这些大而全的扩展库,但我并不是说它们完全没有价值。我们可以用下面几种方式来充分利用或学习它们:

  1. 我们要面对的是很老的项目(例如 .NET Framework 4.x)。这种情况下,.NET 的后续很多优化和新的标准库方法都还没有出现,所以就更谈不上性能问题了。我们可以直接使用这些扩展库来提升开发效率,和弥补旧标准库缺失的一些功能。
  2. 我们可以学习和借鉴这些扩展库的设计思路和实现方式。虽然它们可能在性能上不够理想,但在功能设计和 API 设计上,还是有很多值得我们学习的地方的。
  3. 我们可以从这些扩展库中挑选出我们真正需要的功能,然后自己实现一个轻量级的版本,或者只是简单地复制粘贴到我们自己的项目中。这样既能满足我们的需求,又能避免引入不必要的依赖和性能问题。

总之,不要轻易使用大而全的扩展库,我们应该根据自己的实际需求来选择和使用这些库,同时也要注意它们可能带来的性能和依赖问题。

使用 Hugo 构建
主题 StackJimmy 设计