CLR

.NET 4.5 基类库中的新增功能

Immo Landwerth

 

Microsoft .NET Framework 基类库 (BCL) 中包含的全都是基本类。 虽然有些基本结构很稳定而不会发生多大变化(如 System.Int32 和 System.String),但 Microsoft 还是在这方面投入了很多人力和物力。 本文介绍了 .NET Framework 4.5 中对 BCL 进行的重大改进(以及一些很小的改进)。

在阅读本文时,请记住本文以 .NET Framework 4.5 测试版为基础,而不是以最终产品和 API 为基础,因此,功能可能会有所变化。

如果您希望简要了解 .NET Framework 中的其他方面(如 Windows Communication Foundation (WCF) 或 Windows Presentation Foundation (WPF)),请参阅 MSDN 库页面“.NET Framework 4.5 测试版中的新增功能”(bit.ly/p6We9u)。

简化的异步编程

使用异步 I/O 有很多优点。 这有助于避免阻止 UI,并且可以减少操作系统需要使用的线程数。 然而,您很可能无法利用该功能,因为异步编程过去一直相当复杂。 最大的问题是,以前的异步编程模型 (APM) 是围绕 Begin/End 方法对设计的。 为了说明这种模式的工作原理,请考虑使用下面的简单同步方法来复制流:

public void CopyTo(Stream source, Stream destination)
{
  byte[] buffer = new byte[0x1000];
  int numRead;
  while ((numRead = source.Read(buffer, 0, buffer.Length)) != 0)
  {
    destination.Write(buffer, 0, numRead);
  }
}

要将这种方法变为使用较早 APM 的异步方法,您必须编写图 1 中所示的代码。

图 1 使用旧异步方法复制流

public void CopyToAsyncTheHardWay(Stream source, Stream destination)
{
  byte[] buffer = new byte[0x1000];
  Action<IAsyncResult> readWriteLoop = null;
  readWriteLoop = iar =>
  {
    for (bool isRead = (iar == null); ; isRead = !isRead)
    {
      switch (isRead)
      {
        case true:
          iar = source.BeginRead(buffer, 0, buffer.Length, 
            readResult =>
          {
            if (readResult.CompletedSynchronously) return;
            readWriteLoop(readResult);
          }, null);
          if (!iar.CompletedSynchronously) return;
          break;
        case false:
          int numRead = source.EndRead(iar);
          if (numRead == 0)
          {
            return;
          }
          iar = destination.BeginWrite(buffer, 0, numRead, 
            writeResult =>
          {
            if (writeResult.CompletedSynchronously) return;
            destination.EndWrite(writeResult);
            readWriteLoop(null);
          }, null);
          if (!iar.CompletedSynchronously) return;
          destination.EndWrite(iar);
          break;
        }
      }
  };
  readWriteLoop(null);
}

显然,异步版本并不像同步方法那样易于理解。 人们很难从样板代码中了解编程意图;在涉及委托时,需要使用该代码以使基本编程语言结构(如循环)能够正常工作。 如果您没有被困难所吓倒,请尝试添加异常处理和取消。

所幸的是,此 BCL 版本提供了基于 Task 和 Task<T> 的新异步编程模型。 通过添加 async 和 await 关键字,C# 和 Visual Basic 提供了极佳的语言支持(顺便说一下,F# 已通过异步工作流为其提供语言支持,事实上,此功能的灵感就来源于此)。 因此,编译器现在承担了样板代码的大多数工作(即便不是全部),而过去您必须得自己编写这些代码。 新的语言支持再加上 .NET Framework 中添加的某些 API,可以使编写异步方法差不多与编写同步代码一样简单。 您可以自己试试看,要将 CopyTo 方法变为异步方法,现在只需要进行下面突出显示的更改:

public async Task CopyToAsync(Stream source, Stream destination)
{
  byte[] buffer = new byte[0x1000];
  int numRead;
  while ((numRead = await 
     source.ReadAsync(buffer, 0, buffer.Length)) != 0)
  {
    await destination.WriteAsync(buffer, 0, numRead);
  }
}

对于使用新语言功能的异步编程,还有很多有趣的事情值得一说,但我还是希望把重点放在对 BCL 和您(BCL 使用者)的影响上。 不过,如果您特别好奇,请阅读 MSDN 库页面“使用 Async 和 Await 进行异步编程(C# 和 Visual Basic)”(bit.ly/nXerAc)。

要创建您自己的异步操作,您需要较低级别的构造块。 根据这些构造块,您可以构建更复杂的方法,如前面介绍的 CopyToAsync 方法。 该方法只需要将 Stream 类的 ReadAsync 和 WriteAsync 方法作为构造块。 图 2 列出了 BCL 中添加的一些最重要的异步 API。

图 2 BCL 中的异步方法

类型 方法
System.IO.Stream

ReadAsync

WriteAsync

FlushAsync

CopyToAsync

System.IO.TextReader

ReadAsync

ReadBlockAsync

ReadLineAsync

ReadToEndAsync

System.IO.TextWriter

WriteAsync

WriteLineAsync

FlushAsync

请注意,我们没有添加具有很小粒度的 API 的异步版本,如 TextReader.Peek。 原因是,异步 API 也会增加一些开销,我们希望防止开发人员误入歧途。 这也意味着,我们明确禁止为 BinaryReader 或 BinaryWriter 上的方法提供异步版本。 要使用这些 API,我们建议使用 Task.Run 开始新的异步操作,然后从该操作中使用同步 API,而不是为每个方法调用都执行此操作。 一般准则是: 尽可能批量使用异步操作。 例如,如果要使用 BinaryReader 从流中读取 1,000 个 Int32,最好运行一个任务并等待其同步读取所有 1,000 个 Int32,而不是分别运行并等待 1,000 个任务(每个任务仅读取一个 Int32)。

如果要了解有关编写异步代码的详细信息,我建议您阅读“使用 .NET 进行并行编程”博客文章 (blogs.msdn.com/b/pfxteam)。

只读集合接口

长期以来,人们要求提供的 BCL 功能之一是只读集合接口(不要与不可变的集合相混淆;有关详细信息,请参阅第 22 页上的 “不同的只读集合概念”)。 我们的观点一直是,可以通过可选的功能模式最好地模拟该特定方面(只读)。 在这种模式下,将提供一个 API 以允许使用者测试是否不支持给定功能和引发 NotSupportedException。

这种模式的好处是,它需要较少的类型,因为您不需要模拟功能组合。 例如,Stream 类提供了一些功能,所有这些功能都是通过布尔 get 取值函数(CanSeek、CanRead、CanWrite 和 CanTimeout)表示的。 这样,BCL 仅为流提供一种类型,但仍支持每种流功能组合。

经过很多年的研究,我们得出结论,添加只读集合接口尽管会增加复杂性,但仍然是值得的。 首先,我希望向您介绍这些接口,然后介绍它们提供的功能。 图 3 显示了现有(可变)集合接口的 Visual Studio 类图;图 4 显示了相应的只读接口。

Mutable Collection Interfaces
图 3 可变的集合接口

Read-Only Collection Interfaces
图 4 只读集合接口

请注意,IReadOnlyCollection<T> 未添加到 .NET Framework 4.5 测试版中,因此,如果找不到它,您也不必感到奇怪。 在测试版中,IReadOnlyList<T> 和 IReadOnlyDictionary<TKey,TValue> 是从 IEnumerable<T> 直接派生的,每个接口定义了自己的 Count 属性。

IEnumerable<T> 是协变的。 这意味着,如果一个方法接受 IEnumerable<Shape>,您可以使用 IEnumerable<Circle> 调用该方法(假定 Circle 是从 Shape 派生的)。 如果类型层次结构和算法可以应用于特定的类型(例如,可绘制不同形状的应用程序),这是非常有用的。 对于大多数涉及类型集合的情况,使用 IEnumerable<T> 就足够了,但有时您需要比它提供的更强大的功能:

  1. Materialization.IEnumerable<T> 不允许您表示集合是否已经可用(“具体化”),或每次迭代访问集合时是否都进行计算(例如,如果它表示 LINQ 查询)。 如果算法要求多次迭代访问集合,并且计算序列的开销较大,这可能会导致性能下降;这还可能会产生不易察觉的错误,因为后续迭代再次生成对象时会出现标识不匹配问题。
  2. Count.IEnumerable<T> 不提供计数。 实际上,它可能根本没有计数,因为这可能是一个无穷序列。 在很多情况下,使用静态扩展方法 Enumerable.Count 就足够了。 首先,它将已知集合类型(如 ICollection<T>)作为特例以避免迭代访问整个序列;其次,在很多情况下,计算结果的开销并不是很大。 不过,根据集合的大小,情况可能会有所不同。
  3. Indexing.IEnumerable<T> 不允许随机访问项目。 有些算法(如快速排序)取决于能否通过其索引访问项目。 再者,如果 IList<T> 支持给定的可枚举项,静态扩展方法 (Enumerable.ElementAt) 将使用优化的代码路径。 不过,如果在循环中使用索引,线性扫描可能会对性能产生灾难性的影响,因为它将开销不大的 O(n) 算法变为 O(n2) 算法。 因此,如果您需要随机访问,您就确实需要随机进行访问。

为什么不直接使用 ICollection<T>/IList<T> 而不是 IEnumerable<T> 呢? 这是因为将会失去协变性,并且无法再区分仅读取集合的方法和还会修改集合的方法;通常,在使用异步编程或多线程时,此功能将变得益发重要。 换句话说,您希望鱼与熊掌兼得。

输入 IReadOnlyCollection<T> 和 IReadOnlyList<T>。 IReadOnlyCollection<T> 与 IEnumerable<T> 基本相同,但它添加了 Count 属性。 这样,就可以创建算法以表示具体化的集合或具有已知有限大小的集合的需求。 IReadOnlyList<T> 通过添加索引器来为其提供补充。 这两个接口是协变的,这意味着,如果一个方法接受 IReadOnlyList<Shape>,您可以使用 List<Circle> 调用该方法:

class Shape { /*...*/ }
class Circle : Shape { /*...*/ }
void LayoutShapes(IReadOnlyList<Shape> shapes) { /*...*/ }
void LayoutCircles()
{
  List<Circle> circles = GetCircles();
  LayoutShapes(circles);
}

糟糕的是,我们的类型系统不允许生成 T 协变类型,除非它没有将 T 作为输入的方法。 因此,我们无法将 IndexOf 方法添加到 IReadOnlyList<T> 中。 我们认为,与没有协变支持相比,这点牺牲不算什么。

我们的所有内置集合实现(如数组、List<T>、Collection<T> 和 ReadOnlyCollection<T>)也会实现只读集合接口。 由于现在可以将任何集合视为只读集合,算法可以更准确地声明其意图,而不会限制其重用级别,可以在所有集合类型中使用它们。 在前面的示例中,LayoutShapes 使用者可以传入 List<Circle>,但 LayoutShapes 还会接受 Circle 数组或 Collection<Circle>。

这些集合类型的另一个好处是,它们为使用 Windows 运行时 (WinRT) 提供了极佳的体验。 WinRT 提供了自己的集合类型,如 Windows.Founda­tion.Collections.IIterable<T> 和 Windows.Foundation.Collections.IVector<T>。 CLR 元数据层将这些类型直接映射到相应的 BCL 数据类型。 例如,从 .NET Framework 中使用 WinRT 时,IIterable<T> 将变为 IEnumerable<T>,而 IVector<T> 变为 IList<T>。 事实上,使用 Visual Studio 和 IntelliSense 的开发人员甚至不能觉察到 WinRT 具有不同的集合类型。 由于 WinRT 还提供了只读版本(如 IVectorView<T>),新的只读接口提供了全部功能,因此,可以方便地在 .NET Framework 和 WinRT 之间共享所有集合类型。

不同的只读集合概念

  • 可变(或非只读) - .NET 世界中的最常见集合类型。 这些是允许读取以及添加、删除和更改项目的集合,例如,List<T>。
  • 只读 - 这些是无法从外部修改的集合。 不过,此集合概念不保证其内容从不改变。 例如,无法直接更新字典的键和值集合,但添加到字典中将间接更新键和值集合。
  • 不可变 - 这些是在创建后保证永不改变的集合。 对于多线程来说,这是一个很好的属性。 如果复杂的数据结构是完全不可变的,则可以始终安全地将其传递给后台工作线程。 您不必担心有人会同时修改它。 目前,这种集合类型并不是由 Microsoft .NET Framework 基类库 (BCL) 提供的。
  • 可冻结 - 这些集合在冻结之前类似于可变集合。 在冻结后,它们类似于不可变集合。 虽然 BCL 未定义这些集合,但您可以在 Windows Presentation Foundation 中找到它们。

Andrew Arnott 写了一篇极好的博客文章,其中更详细地介绍了不同的集合概念 (bit.ly/pDNNdM)。

.zip 存档支持

多年以来,另一个常见的需求是常规 .zip 存档读取和写入支持。 .NET Framework 3.0 和更高版本支持依照开放打包约定 (bit.ly/ddsfZ7) 读取和写入存档。 不过,System.IO.Packaging 进行了量身定制以支持该特定规范,通常不能用于处理普通 .zip 存档。

此版本通过 System.IO.Compression.ZipArchive 添加了极佳的压缩支持。 此外,我们还在 DeflateStream 实现中解决了长期存在的性能和压缩质量问题。 从 .NET Framework 4.5 开始,DeflateStream 类使用常见的 zlib 库。 因此,它提供了更好的压缩算法实现;大多数情况下,压缩文件比早期版本的 .NET Framework 小。

要提取磁盘上的整个存档,只需要编写一行代码:

ZipFile.ExtractToDirectory(@"P:\files.zip", @"P:\files");

我们还确保典型操作不需要将整个存档都读取到内存中。 例如,从较大存档中提取单个文件可能如下所示:

using (ZipArchive zipArchive = 
  ZipFile.Open(@"P:\files.zip", ZipArchiveMode.Read))
{
  foreach (ZipArchiveEntry entry in zipArchive.Entries)
  {
    if (entry.Name == "file.txt")
    {
      using (Stream stream = entry.Open())
      {
        ProcessFile(stream);
      }
    }
  }     
}

在这种情况下,加载到内存中的唯一部分是 .zip 存档的目录。 提取的文件是完全流式传输的;即,它不需要加载到内存中。 这样,即使内存很有限,也可以处理任意大的 .zip 存档。

创建 .zip 存档的工作原理是类似的。 要从目录中创建 .zip 存档,您只需编写一行代码:

ZipFile.CreateFromDirectory(@"P:\files", @"P:\files.zip");

当然啦,您也可以手动构造 .zip 存档,从而使您可以完全控制内部结构。 下面的代码说明了如何创建仅添加 C# 源代码文件的 .zip 存档(此外,.zip 存档现在还包含一个名为 SourceCode 的子目录):

IEnumerable<string> files = 
  Directory.EnumerateFiles(@"P:\files", "*.cs");
using (ZipArchive zipArchive = 
  ZipFile.Open(@"P:\files.zip", ZipArchiveMode.Create))
{
  foreach (string file in files)
  {
    var entryName = Path.Combine("SourceCode", Path.GetFileName(file));
    zipArchive.CreateEntryFromFile(file, entryName);
  }
}

通过使用流,您还可以构造不通过实际文件支持输入的 .zip 存档。 这同样适用于 .zip 存档本身。 例如,您可以使用将流作为输入的 ZipArchive 构造函数,而不是使用 ZipFile.Open。 例如,您可以使用此构造函数构建 Web 服务器,以便通过数据库中存储的内容动态创建 .zip 存档,以及将结果直接写入到响应流中,而不是写入到磁盘中。

需要解释一下一个有关 API 设计的细节。 您可能已注意到,静态简便方法是在 ZipFile 类上定义的,而实际实例具有 ZipArchive 类型。 为什么会这样?

从 .NET Framework 4.5 开始,我们在设计 API 时考虑了可移植性问题。 某些 .NET 平台不支持文件路径,如用于 Windows 8 上的 Metro 风格应用程序的 .NET。 在此平台上,将通过代理进行文件系统访问以启用基于功能的模型。 要读取或写入文件,您无法再使用常规 Win32 API;而必须使用 WinRT API。

正如我向您介绍的一样,.zip 功能本身根本不需要文件路径。 因此,我们将该功能分成两个部分:

  1. System.IO.Compression.dll。 此程序集包含通用 .zip 功能。 它不支持文件路径。 此程序集中的主类是 ZipArchive。
  2. System.IO.Compression.FileSystem.dll。 此程序集提供 ZipFile 静态类以定义扩展方法和静态帮助程序。 在支持文件系统路径的 .NET 平台上,这些 API 通过简便方法增强了 .zip 功能。

要了解有关编写可移植的 .NET 代码的详细信息,请参阅 MSDN 库中的“可移植的类库”页面 (bit.ly/z2r3eM)。

其他改进

当然啦,要介绍的内容还有很多。 在使用一个版本很长时间后,命名最重要的功能有时感觉就像给喜爱的孩子起个名字一样。 下面,我希望重点介绍几个值得一提的方面,但限于本文的篇幅而不能详细进行介绍:

AssemblyMetadataAttribute - 我们在 .NET Framework 中注意到一个有关属性的问题,那就是无论有多少个属性,您都嫌不够多(顺便说一下,.NET Framework 4 已包含 300 多个程序集级属性)。 AssemblyMetadataAttribute 是一个通用程序集属性,可用于将基于字符串的键值对与程序集相关联。 这可用于指向产品主页或与构建程序集时使用的源文件对应的版本控制标签。

WeakReference<T> - 现有的非泛型 WeakReference 类型存在以下两个问题: 首先,无论何时需要访问目标,它都会强制使用者进行转换。 更重要的是,它有一个设计缺陷,而导致在使用它时很容易出现争用问题: 它公开一个 API 以检查对象是否处于活动状态 (IsAlive),并公开一个单独的 API 以访问实际对象 (Target)。 WeakReference<T> 通过提供单个 API TryGetTarget 来解决该问题,它以原子方式执行这两个操作。

Comparer<T>.Create(Comparison<T>) - BCL 提供了两种方法以实现集合比较器。 一种方法是通过接口 IComparer<T>;另一种方法是委派 Comparison<T>。 将 IComparer<T> 转换为 Comparison<T> 是非常简单的。 大多数语言提供从方法到委派类型的隐式转换,因此,您可以轻松通过 IComparer<T> Compare 方法构造 Comparison<T>。 不过,逆转换要求您自行实现 IComparer<T>。 在 .NET Framework 4.5 中,我们在 Comparer<T> 上添加了静态 Create 方法,在给定 Comparison<T> 的情况下,它为您提供 IComparer<T> 实现。

ArraySegment<T> - 在 .NET Framework 2.0 中,我们添加了结构 ArraySegment<T>,可用于表示给定数组的子集,而无需进行复制。 糟糕的是,ArraySegment<T> 未实现任何集合接口,您无法将其传递给应用于集合接口的方法。 在此版本中,我们解决了该问题,ArraySegment<T> 现在实现了 IEnumerable、IEnumerable<T>、ICollection<T> 和 IList<T> 以及 IReadOnlyCollection<T> 和 IReadOnlyList<T>。

SemaphoreSlim.WaitAsync - 这是唯一支持等待锁定的同步基元。 如果要了解有关基本原理的详细信息,请阅读“.NET 4.5 测试版中的并行新增功能”(bit.ly/AmAUIF)。

ReadOnlyDictionary<TKey,TValue> - 自 .NET Framework 2.0 以来,BCL 就提供了 ReadOnlyCollection<T>,它可作为给定集合实例的只读包装。 这样,对象模型实现者就可以公开使用者无法更改的集合。 在此版本中,我们为字典添加了相同的概念。

BinaryReader、BinaryWriter、StreamReader、StreamWriter: 不释放基础流的选项 - 高级读取器和写入器类在其构造函数中均接受流实例。 在以前的版本中,这意味着该流的所有权将传递给读取器/写入器实例,这表明释放读取器/写入器还会释放基础流。 如果只有一个读取器/写入器,这具有不错的效果并且是非常方便的,但如果需要编写几个全部应用于流的不同 API,并需要将读取器/写入器作为其实现的一部分,则是非常麻烦的。 在以前的版本中,最好的办法是不释放读取器/写入器,并在源文件中添加注释以说明该问题(这种方法还会强制您手动刷新写入器以避免数据丢失)。 在 .NET Framework 4.5 中,您可以使用将 leaveOpen 作为参数的读取器/写入器构造函数表示此约定,您可以在其中明确指定读取器/写入器不能释放基础流。

Console.IsInputRedirected、Console.IsOutputRedirected 和 Console.IsErrorRedirected - 命令行程序支持重定向输入和输出。 对于大多数应用程序来说,这是透明的。 不过,在重定向处于活动状态时,您有时需要使用不同的行为。 例如,彩色控制台输出没有什么用处,并且无法成功设置光标位置。 通过使用这些属性,可以查询是否重定向了任何标准流。

Console.OutputEncoding 和 Console.InputEncoding 现在可以设置为 Encoding.Unicode - 通过将 Console.OutputEncoding 设置为 Encoding.Unicode,程序可以写入无法在与控制台关联的 OEM 代码页中表示的字符。 此功能还可以简化在控制台上显示多个脚本中的文本的过程。 MSDN 库中即将发布的 Console 类修订文档将提供更多详细信息。

ExceptionDispatchInfo - 错误处理是构建框架组件的一个重要方面。 有时,重新引发异常(在 C# 中,通过“throw;”)还远远不够,因为它只能发生在异常处理程序内部。 有些框架组件(如 Task 基础结构)必须在以后重新引发异常(例如,在重新封送到原始线程后)。 过去,这意味着原来的堆栈跟踪和 Windows 错误报告 (WER) 分类(又称为 Watson 存储桶)将会丢失,因为再次引发相同的异常对象会直接覆盖该信息。 ExceptionDispatchInfo 允许捕获现有的异常对象并再次引发该对象,而不会丢失异常对象中记录的任何有价值的信息。

Regex.Timeout - 正则表达式是一种验证输入的极佳方法。 不过,人们大多不知道某些正则表达式在应用于特定文本输入时可能会产生极高的计算开销,也就是说,这些表达式具有指数时间复杂性。 在需要配置实际正则表达式的服务器环境中,尤其容易出现该问题。 由于很难(即便不是不可能)预测给定正则表达式的运行时行为,在这些情况下,最保守的方法是对 Regex 引擎尝试匹配给定输入的时间长度应用限制。 因此,Regex 现在包含几个接受超时的 API: Regex.IsMatch、Regex.Match、Regex.Matches、Regex.Split 和 Regex.Replace。

参与讨论

Visual Studio 11 测试版中提供了本文介绍的功能。 该版本达到了我们的预发行软件所具有的较高标准,因此,我们在生产环境中支持该版本。 您可以从 bit.ly/9JWDT9 下载该测试版。 由于这是预发行软件,无论您是遇到任何问题 (connect.microsoft.com/visualstudio) 还是对新功能有什么看法或意见 (visualstudio.uservoice.com),我们都欢迎您提供反馈意见。 我还建议您订阅我的团队博客 (blogs.msdn.com/b/bclteam),以使您及时了解即将发布的任何更改或公告。

Immo Landwerth 是 Microsoft CLR 团队的项目经理,负责 Microsoft .NET Framework 基类库 (BCL)、API 设计和可移植类库方面的工作。 您可以通过 BCL 团队博客与他取得联系:blogs.msdn.com/b/bclteam

衷心感谢以下技术专家对本文的审阅: Nicholas BlumhardtGreg Paperin、Daniel PlaistedEvgeny RoubinchteinAlok ShriramChris SzurgotStephen ToubMircea Trofin