C#13 和 .NET9 现代跨平台开发基础知识(五)

原文:zh.annas-archive.org/md5/5f122bf1150958c3b3ee735b37781de3

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:打包和分发.NET 类型

本章介绍了 C#关键字与.NET 类型之间的关系以及命名空间和程序集之间的关系。你将熟悉如何打包和发布你的.NET 应用程序和库以实现跨平台使用。

本章有几个在线部分,你将在章节末尾找到所有链接。

在仅在线的部分,反编译.NET 程序集,我们介绍了如何为了学习目的反编译.NET 程序集,以及为什么你无法阻止他人反编译你的代码。在另一个仅在线的部分,从.NET Framework 迁移到现代.NET,你可以学习如何在.NET 库中使用遗留的.NET Framework 库,以及如何将遗留的.NET Framework 代码库迁移到现代.NET。最后,在第三个仅在线的部分,创建源生成器,你将学习如何创建可以动态向你的项目添加源代码的源生成器——这是一个非常强大的功能。

本章涵盖了以下主题:

  • .NET 9 之路

  • 理解.NET 组件

  • 发布你的应用程序以进行部署

  • 原生提前编译

  • 打包你的库以进行 NuGet 分发

  • 使用预览功能

.NET 9 之路

这本书的这一部分介绍了.NET 提供的基类库BCL)API 中的功能,以及如何使用.NET Standard 跨所有不同的.NET 平台重用功能。

.NET Standard 兼容的框架

从.NET Core 2.0 开始,对至少.NET Standard 2.0 的支持很重要,因为它提供了许多在.NET Core 的第一个版本中缺失的 API。对于现代开发相关的 15 年积累的库和应用程序,.NET Framework 开发者现在可以迁移到.NET,并且它们可以在 macOS 和 Linux 变体以及 Windows 上跨平台运行。

.NET Standard 2.1 添加了大约 3,000 个新 API。其中一些 API 需要运行时更改,这可能会破坏向后兼容性。总结如下:

  • .NET Framework 4.8 仅实现了.NET Standard 2.0。

  • .NET Core 3.0、Xamarin、Mono 和 Unity 实现了.NET Standard 2.1。

.NET 5 移除了.NET Standard 的需求,因为所有项目类型现在都可以针对.NET 的单个版本。同样的情况也适用于.NET 6 及以后的版本。从.NET 5 开始的每个版本都与之前的版本向后兼容。这意味着一个针对.NET 5 的类库可以被任何类型的.NET 5 或更高版本的任何项目使用。现在,随着.NET 版本发布了对使用.NET MAUI 构建的移动和桌面应用程序的全面支持,对.NET Standard 的需求进一步减少。

由于你可能仍然需要为遗留的.NET Framework 项目或遗留的 Xamarin 移动应用程序创建类库,因此仍然需要创建.NET Standard 2.0 类库。

正式来说,即使它是一个相对较新的功能,您也必须使用 .NET Standard 2.0 类库来创建源生成器。

为了总结自 2016 年 .NET Core 首个版本以来 .NET 所取得的进展,我已将主要的 .NET Core 和现代 .NET 版本与以下列表中的等效 .NET Framework 版本进行了比较:

  • .NET Core 1.x:与 .NET Framework 4.6.1 相比,API 小得多,这是 2016 年 3 月的当前版本。

  • .NET Core 2.x:由于它们都实现了 .NET Standard 2.0,因此与现代 API 相比,API 与 .NET Framework 4.7.1 相等。

  • .NET Core 3.x:与 .NET Framework 相比,API 更大,因为 .NET Framework 4.8 没有实现 .NET Standard 2.1。

  • .NET 5:与 .NET Framework 4.8 相比,API 更大,特别是对于现代 API,性能有了显著提升。

  • .NET 6:继续改进性能和扩展 API,并在 2022 年 5 月添加了对 .NET MAUI 的可选支持。

  • .NET 7:与支持移动应用的支持最终统一,.NET MAUI 作为可选工作负载可用。本书不涵盖 .NET MAUI 开发。Packt 有多本专注于 .NET MAUI 的书籍,您可以通过搜索他们的网站找到它们。

  • .NET 8:继续改进平台,如果您需要长期支持,则应用于所有新的开发。

  • .NET 9:继续改进平台,特别是在性能方面,如果您对标准期限支持感到舒适,则应使用它。您可以在以下链接中查看每个预览版本的 .NET 9 发布说明:github.com/dotnet/core/discussions/9234

您可以在以下链接的 GitHub 仓库中阅读更多详细信息:github.com/markjprice/cs13net9/blob/main/docs/ch07-features.md

更多信息:比较两个 .NET 版本的一个有用方法是 .NET 网站的 API。例如,您可以在以下链接中比较 .NET 9 与 .NET 8 的新功能:apisof.net/catalog?diff=net8.0-vs-net9.0

检查您的 .NET SDKs 是否有更新

微软在 .NET 6 中引入了一个命令来检查您已安装的 .NET SDKs 和运行时的版本,如果任何需要更新,它会向您发出警告。例如,输入以下命令:

dotnet sdk check 

您将看到结果,包括可用更新的状态,如下所示的部分输出:

.NET SDKs:
Version            Status
-------------------------------------------------------
8.0.400            Patch 8.0.401 is available.
9.0.100            Up to date. 

良好实践:为了保持微软的支持,您必须确保您的 .NET SDKs 和 .NET 运行时始终更新到最新的补丁。

一个相关的第三方工具是 dotnet-outdated,它允许您快速报告 .NET 项目中任何过时的 NuGet 包。您可以在以下链接中了解更多信息:github.com/dotnet-outdated/dotnet-outdated

理解 .NET 组件

.NET 由几个部分组成,以下列表显示了这些部分:

  • 语言编译器:这些将您使用 C#、F# 和 Visual Basic 等语言编写的源代码转换为存储在组件中的中间语言IL)代码。从 C# 6 及以后版本开始,微软切换到了一个开源重写的编译器,称为 Roslyn,它也被 Visual Basic 使用。

  • 公共语言运行时CLR):此运行时加载组件,将存储在其中的 IL 代码编译成计算机 CPU 的本地代码指令,并在管理资源(如线程和内存)的环境中执行代码。

  • 基类库BCL):这些是使用 NuGet 打包和分发的预构建组件集合,用于在构建应用程序时执行常见任务。您可以使用它们快速构建任何您想要的东西,就像组合乐高™ 瓦片一样。

组件、NuGet 包和命名空间

组件是类型在文件系统中存储的地方。组件是部署代码的机制。例如,System.Data.dll 组件包含用于管理数据的类型。要使用其他组件中的类型,它们必须被引用。组件可以是静态的(预先创建)或动态的(在运行时生成)。动态组件是本书不会涉及的高级功能。组件可以编译为单个文件,作为 DLL(类库)或 EXE(控制台应用程序)。

组件以 NuGet 包的形式分发,这些是从公共在线源中可下载的文件,可以包含多个组件和其他资源。您还会听到关于 项目 SDK工作负载平台的内容,这些都是 NuGet 包的组合。

微软的 NuGet 源位于此处:www.nuget.org/

命名空间是什么?

命名空间是类型的地址。命名空间是一种机制,通过要求完整的地址来唯一标识一个类型,而不是仅仅一个短名称。在现实世界中,34 桉树街的鲍勃12 橡树巷的鲍勃是不同的。

在 .NET 中,System.Web.Mvc 命名空间中的 IActionFilter 接口与 System.Web.Http.Filters 命名空间中的 IActionFilter 接口不同。

依赖组件

如果一个组件被编译为类库并提供其他组件使用的类型,那么它具有文件扩展名 .dll动态链接库),并且不能独立执行。

同样,如果一个组件被编译为应用程序,那么它具有文件扩展名 .exe可执行文件)并且可以独立执行。在 .NET Core 3 之前,控制台应用程序被编译为 .dll 文件,并且必须通过 dotnet run 命令或宿主可执行文件来执行。

任何程序集都可以将一个或多个类库程序集作为依赖项引用,但不能有循环引用。因此,如果程序集 A 已经引用了程序集 B,则程序集 B 不能引用程序集 A。如果尝试添加会导致循环引用的依赖项引用,编译器将发出警告。循环引用通常是代码设计不佳的警告信号。如果你确定你需要循环引用,那么请使用接口来解决这个问题。

Microsoft .NET 项目 SDK

默认情况下,控制台应用程序依赖于 Microsoft .NET 项目 SDK。此平台包含成千上万的类型,这些类型几乎所有应用程序都需要,例如 System.Int32System.String 类型。

在使用 .NET 时,你需要在项目文件中引用应用程序需要的依赖程序集、NuGet 包和平台。

让我们探索程序集和命名空间之间的关系:

  1. 使用你偏好的代码编辑器创建一个新项目,如下列表所示:

    • 项目模板:控制台应用程序 / console

    • 项目文件和文件夹:AssembliesAndNamespaces

    • 解决方案文件和文件夹:Chapter07

  2. 打开 AssembliesAndNamespaces.csproj,注意它是一个典型的 .NET 应用程序项目文件,如下所示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net9.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
      </PropertyGroup>
    </Project> 
    
  3. <PropertyGroup> 部分之后,添加一个新的 <ItemGroup> 部分以静态导入所有 C# 文件中的 System.Console,使用 .NET SDK 的隐式使用功能,如下所示:

    <ItemGroup Label="To simplify use of Console methods.">
      <Using Include="System.Console" Static="true" />
    </ItemGroup> 
    

PropertyGroup 元素

PropertyGroup 元素用于定义控制构建过程的配置属性。这些属性可以是任何东西,从编译二进制的输出路径到版本信息。PropertyGroup 中的每个属性都定义为简单的名称-值对,如下所示:

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RootNamespace>Packt.Shared</RootNamespace>
</PropertyGroup> 

在前面的示例中:

  • OutputType 指定输出类型,例如可执行文件 (Exe) 或库 (Library)。

  • TargetFramework 指定项目的目标框架,例如 net9.0

  • RootNamespace 设置项目的默认命名空间。

ItemGroup 元素

ItemGroup 元素用于定义包含在构建过程中的项目集合。项目可以是源文件、对其他程序集的引用、包引用和其他资源。

你经常想为不同的目的定义多个 ItemGroup 元素。它们将在构建时合并。例如,你可能有一个 ItemGroup 用于所有项目引用,还有一个单独的 ItemGroup 用于所有包引用。

ItemGroup 元素可以有一个 Label 属性,以便你可以记录每个部分应该用于什么,如下所示:

<ItemGroup Label="Additional files to include during build.">
  <Compile Include="Utils.cs" />
  <None Include="readme.txt" />
</ItemGroup>
<ItemGroup Label="NuGet packages for this project.">
  <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup> 

标签和条件属性

PropertyGroupItemGroup 都可以有一个 Label 属性,可以用来记录元素的目的,以及一个 Condition 属性,可以用来定义它们何时应用。例如,一个典型的条件可能是构建 DEBUG 版本时,如下面的标记所示:

<PropertyGroup Condition="'$(CompileConfig)' == 'DEBUG'" >
  <Optimization>false</Optimization>
  <Obfuscate>false</Obfuscate>
  <OutputPath>$(OutputPath)\debug</OutputPath>
</PropertyGroup> 

警告! PropertyGroupItemGroup 元素都在根 Project 元素中的同一级别。不要在 PropertyGroup 元素内部嵌套 ItemGroup 元素,反之亦然!

程序集中的命名空间和类型

许多常见的 .NET 类型都包含在 System.Runtime.dll 程序集中。程序集和命名空间之间不一定是一对一映射。单个程序集可以包含多个命名空间,而一个命名空间也可以定义在多个程序集中。您可以在 表 7.1 中查看一些程序集及其为它们提供类型的命名空间之间的关系:

程序集示例命名空间示例类型
System.Runtime.dllSystem, System.Collections, System.Collections.GenericInt32, String, IEnumerable<T>
System.Console.dllSystemConsole
System.Threading.dllSystem.ThreadingInterlocked, Monitor, Mutex
System.Xml.XDocument.dllSystem.Xml.LinqXDocument, XElement, XNode

表 7.1:程序集及其命名空间的示例

NuGet 包

.NET 被分割成一系列包,使用名为 NuGet 的微软支持的包管理技术进行分发。这些包中的每一个都代表一个同名的单个程序集。例如,System.Collections 包包含 System.Collections.dll 程序集。

以下是一些包的好处:

  • 包可以轻松地分发到公共源。

  • 包可以被重用。

  • 包可以按照自己的时间表进行分发。

  • 包可以在不依赖其他包的情况下独立测试。

  • 包可以通过包含为不同操作系统和 CPU 构建的同一程序集的多个版本来支持不同的操作系统和 CPU。

  • 包可以具有仅针对单个库的特定依赖项。

  • 应用程序更小,因为未引用的包不是分发的一部分。表 7.2 列出了一些重要的包及其重要类型:

重要类型
System.RuntimeObject, String, Int32, Array
System.CollectionsList<T>, Dictionary<TKey, TValue>
System.Net.HttpHttpClient, HttpResponseMessage
System.IO.FileSystemFile, Directory
System.ReflectionAssembly, TypeInfo, MethodInfo

表 7.2:一些重要的包及其重要类型

包源

包源是 NuGet 可以查找包的位置,例如 www.nuget.org、本地目录或其他包存储库。nuget.config 文件允许您指定、优先级排序和配置这些源,以及其他相关设置。

nuget.config 文件使用 XML 定义包源,如下所示:

<?xml version="1.0" encoding="utf-8"?>
<configuration>
  <packageSources>
    <add key="nuget.org"
         value="https://api.nuget.org/v3/index.json" />
    <add key="MyCustomSource"
         value="https://mycustomsource.com/nuget" />
  </packageSources>
</configuration> 

在前面的配置中:

  • <packageSources>:此部分列出了 NuGet 将用于查找包的所有源。

  • <add key="sourceName" value="sourceUrl"/>:每个 <add> 元素定义一个包源。

NuGet 可以使用位于不同目录中的多个 nuget.config 文件。设置将按特定顺序应用,最具体的设置具有优先权:

  1. 项目目录:项目目录中的 nuget.config 文件。

  2. 解决方案目录:解决方案目录中的 nuget.config 文件。

  3. 用户目录:用户配置文件目录中的 nuget.config 文件——例如,Windows 上的 %AppData%\nuget\nuget.config

  4. 系统级配置:系统级设置中的 nuget.config 文件——例如,Windows 上的 %ProgramFiles(x86)%\nuget\config

NuGet 合并这些配置,更具体的文件中的设置将覆盖不那么具体的文件中的设置。

如果找不到 nuget.config 文件,NuGet 将使用默认的包源,这通常是官方的 NuGet.org 仓库。这意味着默认情况下,Visual Studio、dotnetnuget.exe 等工具将查找 NuGet.org 的包,除非进行其他配置。

理解框架

框架和包之间存在双向关系。包定义了 API,而框架将包分组。没有包的框架不会定义任何 API。

.NET 包支持一组框架。例如,System.IO.FileSystem 包版本 4.3.0 支持以下框架:

  • .NET 标准版,版本 1.3 或更高版本

  • .NET 框架,版本 4.6 或更高版本

  • 六个 Mono 和 Xamarin 平台(例如,Xamarin.iOS)

更多信息:您可以在以下链接中阅读详细信息:www.nuget.org/packages/System.IO.FileSystem/#supportedframeworks-body-tab

导入命名空间以使用类型

让我们探索命名空间与程序集和类型之间的关系:

  1. AssembliesAndNamespaces 项目中,在 Program.cs 中删除现有的语句,然后输入以下代码:

    XDocument doc = new(); 
    

近期版本的代码编辑器通常会自动添加一个命名空间导入语句来修复您想要看到的问题。请删除您的代码编辑器为您编写的 using 语句。

  1. 构建项目并注意编译器错误信息,如下所示:

    CS0246 The type or namespace name 'XDocument' could not be found (are you missing a using directive or an assembly reference?) 
    

XDocument 类型没有被识别,因为我们没有告诉编译器该类型的命名空间。尽管此项目已经引用了包含该类型的程序集,但我们还需要在类型名称前加上其命名空间(例如,System.Xml.Linq.XDocument)或导入命名空间。

  1. XDocument 类名内点击。您的代码编辑器显示一个灯泡,表示它识别了该类型,并且可以自动为您修复问题。

  2. 点击灯泡图标,从菜单中选择 using System.Xml.Linq;

这将通过在文件顶部添加一个 using 语句来导入命名空间。一旦在代码文件的顶部导入了命名空间,该命名空间内的所有类型都可以通过仅输入其名称来在该代码文件中使用,无需通过在其名称前加上命名空间前缀来完全限定类型名称。

我喜欢在导入命名空间后添加一条注释,以提醒我为什么需要导入它,如下所示:

using System.Xml.Linq; // To use XDocument. 

如果你不对你的命名空间进行注释,你或其他开发者将不知道为什么需要导入它们,可能会删除它们,导致代码出错。相反,他们可能永远不会删除导入的命名空间“以防万一”它们可能需要,这可能会不必要地使代码变得杂乱。这就是为什么大多数现代代码编辑器都有移除未使用命名空间的功能。这种技术也在你学习的同时无意识地训练你,记住你需要导入哪个命名空间来使用特定的类型或扩展方法。

将 C# 关键字关联到 .NET 类型

我经常收到新 C# 程序员提出的一个常见问题是,“小写 s 的 string 和大写 S 的 String 之间有什么区别?”

简短的回答是:没有。长一点的回答是,所有代表类型如 stringint 的 C# 关键字都是类库程序集中的一个 .NET 类型的别名。

当你使用 string 关键字时,编译器将其识别为 System.String 类型。当你使用 int 类型时,编译器将其识别为 System.Int32 类型。

让我们通过一些代码来看看这个操作:

  1. Program.cs 文件中,声明两个变量来存储 string 值,一个使用小写 string,一个使用大写 String,如下所示:

    string s1 = "Hello";
    String s2 = "World";
    WriteLine($"{s1} {s2}"); 
    
  2. 运行 AssembliesAndNamespaces 项目,并注意 stringString 都可以正常工作,并且字面上意味着同一件事。

  3. AssembliesAndNamespaces.csproj 文件中,添加一个条目以防止 System 命名空间被全局导入,如下所示:

    <ItemGroup>
      <Using Remove="System" />
    </ItemGroup> 
    
  4. Program.cs 文件中,以及 错误列表问题 窗口中,注意编译器错误信息,如下所示:

    CS0246 The type or namespace name 'String' could not be found (are you missing a using directive or an assembly reference?) 
    
  5. Program.cs 文件顶部,使用 using 语句导入 System 命名空间,这将修复错误,如下所示:

    using System; // To use String. 
    

良好实践:当你有选择时,使用 C# 关键字而不是实际类型,因为关键字不需要导入命名空间。

将 C# 别名映射到 .NET 类型

表 7.3 展示了 18 个 C# 类型关键字及其实际的 .NET 类型:

关键字.NET 类型关键字.NET 类型
stringSystem.StringcharSystem.Char
sbyteSystem.SBytebyteSystem.Byte
shortSystem.Int16ushortSystem.UInt16
intSystem.Int32uintSystem.UInt32
longSystem.Int64ulongSystem.UInt64
nintSystem.IntPtrnuintSystem.UIntPtr
floatSystem.SingledoubleSystem.Double
decimalSystem.DecimalboolSystem.Boolean
objectSystem.ObjectdynamicSystem.Dynamic.DynamicObject

表 7.3:C# 类型关键字及其实际的 .NET 类型

其他 .NET 编程语言编译器也可以做到同样的事情。例如,Visual Basic .NET 语言有一个名为Integer的类型,它是System.Int32的别名。

理解原生大小整数

C# 9 引入了nintnuint关键字别名,用于原生大小整数,这意味着整数值的存储大小是平台特定的。它们在 32 位进程中存储 32 位整数,sizeof()返回 4 字节;它们在 64 位进程中存储 64 位整数,sizeof()返回 8 字节。这些别名代表内存中整数值的指针,这就是为什么它们的 .NET 名称是IntPtrUIntPtr。实际的存储类型将是System.Int32System.Int64,具体取决于进程。

在 64 位进程中,以下代码:

WriteLine($"Environment.Is64BitProcess = {Environment.Is64BitProcess}");
WriteLine($"int.MaxValue = {int.MaxValue:N0}");
WriteLine($"nint.MaxValue = {nint.MaxValue:N0}"); 

产生以下输出:

Environment.Is64BitProcess = True
int.MaxValue = 2,147,483,647
nint.MaxValue = 9,223,372,036,854,775,807 

揭示类型的定位

大多数代码编辑器都为 .NET 类型提供了内置文档。让我们首先确保你通过检查是否正确设置了相关选项,来确保你在 Visual Studio 中的预期体验,然后探索:

  1. 如果你正在使用 Visual Studio,请确保你已经禁用了源链接功能:

    1. 导航到工具 | 选项

    2. 在搜索框中输入导航到源

    3. 文本编辑器 | C#部分中选择高级

    4. 清除启用导航到源链接和嵌入源复选框,然后点击确定

  2. XDocument上右键单击并选择转到定义

  3. 导航到代码文件顶部,展开折叠区域,并注意程序集文件名是System.Xml.XDocument.dll,但类在System.Xml.Linq命名空间中,如下面的代码和图 7.1所示:

    #region Assembly System.Runtime, Version=9.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a
    // C:\Program Files\dotnet\packs\Microsoft.NETCore.App.Ref\9.0.0\ref\net9.0\System.Runtime.dll
    #endregion 
    

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/csp13-dn9-mdn-xplat-dev-fund/img/B22322_07_01.png

图 7.1:包含 XDocument 类的程序集和命名空间

  1. 关闭**XDocument [来自元数据]**选项卡。

  2. stringString内部右键单击并选择转到定义

  3. 导航到代码文件顶部,展开折叠区域,并注意程序集文件名是System.Runtime.dll,但类在System命名空间中。

你的代码编辑器实际上在对你撒谎。如果你记得我们在第二章,*讲 C#*中编写代码时,当我们揭示了 C# 词汇量的范围,我们发现System.Runtime.dll程序集包含零个类型。

System.Runtime.dll程序集包含的是类型前向器。这些是在程序集中看似存在但实际上在其他地方实现的特殊类型。在这种情况下,它们使用高度优化的代码在 .NET 运行时深处实现。

如果你需要将一个类型从其原始程序集移动到另一个程序集,你可能需要使用类型前向器。如果没有定义类型前向器,任何引用原始程序集的项目将无法在其中找到该类型,并且会抛出运行时异常。你可以在以下链接中了解更多关于这个虚构示例的信息:learn.microsoft.com/en-us/dotnet/standard/assembly/type-forwarding

使用.NET Standard 与遗留平台共享代码

在.NET Standard 之前,存在可移植类库PCLs)。使用 PCLs,你可以创建一个代码库,并明确指定你希望库支持的平台,例如 Xamarin、Silverlight 和 Windows 8。然后,你的库可以使用由指定平台支持的 API 交集。

微软意识到这是不可持续的,因此他们创建了.NET Standard——一个所有未来的.NET 平台都将支持的单一 API。有.NET Standard 的旧版本,但.NET Standard 2.0 是一次尝试统一所有重要的近期.NET 平台。.NET Standard 2.1 于 2019 年底发布,但只有.NET Core 3.0 和那年的 Xamarin 版本支持其新功能。在这本书的其余部分,我将使用.NET Standard 来指代.NET Standard 2.0。

.NET Standard 就像 HTML5 一样,它们都是平台应该支持的标准。就像谷歌的 Chrome 浏览器和微软的 Edge 浏览器实现了 HTML5 标准一样,.NET Core、.NET Framework 和 Xamarin 都实现了.NET Standard。如果你想创建一个可以在旧.NET 变体之间工作的类型库,你可以使用.NET Standard 最简单地做到这一点。

良好实践:由于.NET Standard 2.1 中的许多 API 新增都需要运行时更改,而.NET Framework 是微软的遗留平台,需要尽可能保持不变,因此.NET Framework 4.8 仍然停留在.NET Standard 2.0 上,而不是实现.NET Standard 2.1。如果你需要支持.NET Framework 客户,那么你应该在.NET Standard 2.0 上创建类库,即使它不是最新的,也不支持所有最新的语言和 BCL 新功能。

你选择的目标.NET Standard 版本取决于在最大化平台支持和可用功能之间的平衡。较旧的版本支持更多平台,但 API 集较小。较新的版本支持较少的平台,但 API 集较大。通常,你应该选择支持你所需所有 API 的最低版本。

理解不同 SDK 的类库默认设置

当使用dotnet SDK 工具创建类库时,了解默认将使用哪个目标框架可能很有用,如下表 7.4 所示:

SDK新类库的默认目标框架
.NET Core 3.1netstandard2.0
.NET 6net6.0
.NET 7net7.0
.NET 8net8.0
.NET 9net9.0

表 7.4:.NET SDK 及其针对新类库的默认目标框架

当然,一个类库默认针对特定的 .NET 版本并不意味着你不能在创建类库项目后更改它。使用默认模板创建类库项目后,你可以更改它。

你可以手动设置目标框架到一个支持需要引用该库的项目值的值,如 表 7.5 所示:

类库目标框架可以被以下项目使用
netstandard2.0.NET Framework 4.6.1 或更高版本,.NET Core 2 或更高版本,.NET 5 或更高版本,Mono 5.4 或更高版本,Xamarin.Android 8 或更高版本,以及 Xamarin.iOS 10.14 或更高版本
netstandard2.1.NET Core 3 或更高版本,.NET 5 或更高版本,Mono 6.4 或更高版本,Xamarin.Android 10 或更高版本,以及 Xamarin.iOS 12.16 或更高版本
net6.0.NET 6 或更高版本
net7.0.NET 7 或更高版本
net8.0.NET 8 或更高版本
net9.0.NET 9 或更高版本

表 7.5:类库目标框架及其可使用的项目

良好实践:始终检查类库的目标框架,并在必要时手动更改它到一个更合适的目标。有意识地决定它应该是什么,而不是接受默认值。

创建 .NET Standard 类库

我们将使用 .NET Standard 2.0 创建一个类库,以便它可以在所有重要的 .NET 遗留平台上使用,并在 Windows、macOS 和 Linux 操作系统上跨平台,同时还可以访问广泛的 .NET API:

  1. 使用你喜欢的代码编辑器添加一个新的 类库 / classlib 项目,命名为 SharedLibrary,针对 Chapter07 解决方案的目标为 .NET Standard 2.0:

    • 如果你使用 Visual Studio,当提示选择 目标框架 时,选择 .NET Standard 2.0,然后配置解决方案的启动项目为当前选择。

    • 如果你使用 VS Code,包括一个目标为 .NET Standard 2.0 的开关,如下所示命令:

      dotnet new classlib -f netstandard2.0 
      

良好实践:如果你需要创建使用 .NET 9 的新功能的类型,以及仅使用 .NET Standard 2.0 功能的类型,那么你可以创建两个独立的类库:一个针对 .NET Standard 2.0,另一个针对 .NET 0。

  1. 构建 SharedLibrary 项目。

手动创建两个类库的替代方法是创建一个支持 多目标 的类库。如果你希望我在下一版中添加关于多目标的内容,请告知我。你可以在这里了解多目标:learn.microsoft.com/en-us/dotnet/standard/library-guidance/cross-platform-targeting#multi-targeting

控制 .NET SDK

默认情况下,执行 dotnet 命令会使用已安装的最高版本的 .NET SDK。有时你可能想控制使用哪个 SDK。

例如,一旦 .NET 10 在 2025 年 2 月开始预览版,或者最终版本在 2025 年 11 月发布,您可能会安装它。但您可能希望您的体验与书中步骤相匹配,这些步骤使用 .NET 9 SDK。但一旦您安装了 .NET 10 SDK,它将默认使用。

您可以通过使用包含要使用版本的 global.json 文件来控制默认使用的 .NET SDK,dotnet 命令会依次搜索当前文件夹及其父文件夹,查找 global.json 文件,以确定是否应使用不同的 .NET SDK 版本。

您不需要完成以下步骤,但如果您想尝试并且尚未安装 .NET 8 SDK,则可以从以下链接安装它:

dotnet.microsoft.com/download/dotnet/8.0

  1. Chapter07 文件夹中创建一个名为 ControlSDK 的子目录/文件夹。

  2. 在 Windows 上,启动 命令提示符Windows 终端。在 macOS 上,启动 终端。如果您正在使用 VS Code,则可以使用集成终端。

  3. ControlSDK 文件夹中,在命令提示符或终端中输入命令以列出已安装的 .NET SDK,如下所示:

    dotnet --list-sdks 
    
  4. 注意以下输出中突出显示的最新安装的 .NET 8 SDK 的结果和版本号:

    **8.0.400** [C:\Program Files\dotnet\sdk]
    **9.0.100** [C:\Program Files\dotnet\sdk] 
    
  5. 创建一个 global.json 文件,强制使用您已安装的最新 .NET 8 SDK(可能晚于我的版本),如下所示:

    dotnet new globaljson --sdk-version 8.0.400 
    
  6. 注意以下输出中的结果:

    The template "global.json file" was created successfully. 
    
  7. 使用您首选的代码编辑器打开 global.json 文件并查看其内容,如下所示:

    {
      "sdk": {
        "version": "8.0.400"
      }
    } 
    

例如,要使用 VS Code 打开它,请输入命令 code global.json

  1. ControlSDK 文件夹中,在命令提示符或终端中输入命令以创建类库项目,如下所示:

    dotnet new classlib 
    
  2. 如果您没有安装 .NET 8 SDK,则将看到错误,如下所示:

    Could not execute because the application was not found or a compatible .NET SDK is not installed. 
    
  3. 如果您已安装 .NET 8 SDK,则将创建一个默认针对 .NET 8 的类库项目,如下所示:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        **<TargetFramework>net8****.0****</TargetFramework>**
        <ImplicitUsings>enable</ImplicitUsings>
        <Nullable>enable</Nullable>
      </PropertyGroup>
    </Project> 
    

混合 SDK 和框架目标

许多组织决定针对 .NET 的长期支持版本,以从微软获得长达三年的支持。这样做并不意味着您会失去在 .NET 运行时生命周期内 C# 语言改进的好处。

您可以在安装和使用未来的 C# 编译器的同时轻松地继续针对 .NET 9 运行时,如图 7.2 所示,以下列表中也有说明:

  1. 2024 年 11 月:安装 .NET SDK 9.0.100,并使用它来构建默认使用 C# 13 编译器的针对 .NET 9 的项目。每月,更新开发计算机上的 .NET 9 SDK 补丁,并更新任何部署计算机上的 .NET 9 运行时补丁。

  2. 2025 年 2 月:可选地安装 .NET SDK 10 预览版 1,以探索新的 C# 14 语言和 .NET 10 库功能。请注意,当针对 .NET 9 时,您将无法使用新的库功能。预览版每年 2 月至 10 月每月发布一次。请阅读每月公告,了解以下链接中该预览版的新功能:github.com/dotnet/Announcements/issues

  3. 2025 年 11 月:安装 .NET SDK 10.0.100,并使用它来构建继续针对 .NET 9 的项目,并使用 C# 14 编译器来利用其新功能。您将使用完全受支持的 SDK 和完全受支持的运行时。

  4. 2026 年 2 月:可选地安装 .NET 11 预览版,以探索新的 C# 15 语言和 .NET 11 库功能。当您准备好迁移时,开始规划任何新的库和 ASP.NET Core 功能,这些功能可以应用于您的 .NET 9 和 .NET 11 项目。

  5. 2026 年 11 月:安装 .NET 11.0.100 SDK,并使用它来构建针对 .NET 10 的项目,并使用 C# 15 编译器。

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/csp13-dn9-mdn-xplat-dev-fund/img/B22322_07_02.png

图 7.2:针对长期支持的 .NET 8 同时使用最新的 C# 编译器

当决定安装 .NET SDK 时,请记住,默认情况下使用最新版本来构建任何 .NET 项目。一旦您安装了 .NET 9 SDK 预览版,它将默认用于所有项目,除非您强制使用较旧且完全受支持的 SDK 版本,如 8.0.100 或更高版本的补丁。

为部署发布您的代码

如果您写了一本小说,并且希望其他人阅读它,您必须将其出版。

大多数开发者为其他开发者在他们自己的项目中使用或为用户作为应用程序运行而编写代码。为此,您必须将您的代码发布为打包的类库或可执行应用程序。

有三种方式可以发布和部署 .NET 应用程序。它们是:

  • 框架依赖部署FDD

  • 框架依赖可执行文件FDE

  • 自包含

如果您选择部署您的应用程序及其包依赖项,但不是 .NET 本身,那么您依赖于 .NET 已经在目标计算机上。这对于部署到服务器的 Web 应用程序来说效果很好,因为 .NET 和许多其他 Web 应用程序可能已经存在于服务器上。

FDD 表示您部署一个必须由 dotnet 命令行工具执行的 DLL。FDE 表示您部署一个可以直接从命令行运行的 EXE。两者都需要在系统上安装适当的 .NET 运行时版本。

有时,您可能希望能够给某人一个包含为他们的操作系统构建的应用程序,并知道它可以在他们的计算机上执行。您将想要执行自包含部署。虽然部署文件的尺寸会更大,但您会知道它将工作。

创建用于发布的控制台应用程序

让我们探索如何发布一个控制台应用程序:

  1. 使用您喜欢的代码编辑器将一个新的控制台应用程序/ console项目命名为DotNetEverywhere添加到Chapter07解决方案中。确保您针对.NET 9。

  2. 修改项目文件,将System.Console类静态导入到所有 C#文件中。

  3. Program.cs中删除现有的语句,然后添加一条输出消息的语句,说明控制台应用程序可以在任何地方运行,以及一些关于操作系统的信息,如下所示代码:

    WriteLine("I can run everywhere!");
    WriteLine($"OS Version is {Environment.OSVersion}.");
    if (OperatingSystem.IsMacOS())
    {
      WriteLine("I am macOS.");
    }
    else if (OperatingSystem.IsWindowsVersionAtLeast(
      major: 10, build: 22000))
    {
      WriteLine("I am Windows 11.");
    }
    else if (OperatingSystem.IsWindowsVersionAtLeast(major: 10))
    {
      WriteLine("I am Windows 10.");
    }
    else
    {
      WriteLine("I am some other mysterious OS.");
    }
    WriteLine("Press any key to stop me.");
    ReadKey(intercept: true); // Do not output the key that was pressed. 
    
  4. 运行DotNetEverywhere项目,并注意在 Windows 11 上运行的结果,如下所示输出:

    I can run everywhere!
    OS Version is Microsoft Windows NT 10.0.22000.0.
    I am Windows 11.
    Press any key to stop me. 
    
  5. DotNetEverywhere.csproj中,在<PropertyGroup>元素内添加运行时标识符RIDs),以针对三个操作系统进行目标定位,如下所示高亮显示的标记:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <OutputType>Exe</OutputType>
        <TargetFramework>net9.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
     **<RuntimeIdentifiers>**
     **win-x64;osx-arm64;linux-x64**
     **</RuntimeIdentifiers>**
      </PropertyGroup>
    </Project> 
    
    • win-x64 RID 值表示 x64 兼容 CPU 上的 Windows。使用win-arm64 RID 值将应用程序部署到 Windows ARM 设备。

    • osx-arm64 RID 值表示 Apple Silicon 上的 macOS。

    • linux-x64 RID 值表示大多数桌面 Linux 发行版,如 Ubuntu、CentOS、Debian 或 Fedora 在 x64 兼容 CPU 上。对于 Raspbian 或 Raspberry Pi OS 32 位,请使用linux-arm。对于运行 Ubuntu 64 位的 Raspberry Pi,请使用linux-arm64

您可以在以下链接中检查最新的允许的 RID 值:learn.microsoft.com/en-us/dotnet/core/rid-catalog#known-rids

**警告!**您可以使用两个元素来指定运行时标识符。如果您只需要指定一个,请使用<RuntimeIdentifier>。如果您需要指定多个,如前例所示,请使用<RuntimeIdentifiers>。如果您使用错误的一个,那么编译器将给出错误,并且仅凭一个字符的差异可能难以理解原因!

理解 dotnet 命令

当您安装.NET SDK 时,它包括一个名为dotnet命令行界面CLI)。

.NET CLI 有在当前文件夹中创建新项目的命令,使用模板:

  1. 在 Windows 上,启动命令提示符Windows 终端。在 macOS 上,启动终端。如果您更喜欢使用 Visual Studio 或 VS Code,则可以使用集成终端。

  2. 输入dotnet new list(或对于较旧的 SDK,使用dotnet new -ldotnet new --list)命令以列出您当前安装的模板,其中最常见的模板如表 7.6所示:

模板名称简称语言
.NET MAUI AppmauiC#
.NET MAUI Blazor Appmaui-blazorC#
ASP.NET Core EmptywebC#和 F#
ASP.NET Core gRPC ServicegrpcC#
ASP.NET Core Web APIwebapiC#和 F#
ASP.NET Core Web API (native AOT)webapiaotC#
ASP.NET Core Web App (Model-View-Controller)mvcC#和 F#
Blazor Web AppblazorC#
Class LibraryclasslibC#、F#和 VB
Console AppconsoleC#、F#和 VB
EditorConfig Fileeditorconfig
全局.json 文件globaljson
解决方案文件sln
xUnit 测试项目xunit

表 7.6:项目模板的全名和简称

.NET MAUI 项目不支持 Linux。团队表示,他们已将这项工作留给开源社区。如果您需要创建一个真正的跨平台图形应用程序,请查看以下链接中的 Avalonia:avaloniaui.net/

获取有关 .NET 及其环境的详细信息

查看当前安装的 .NET SDK 和运行时,以及有关操作系统的信息很有用,如下所示命令:

dotnet --info 

注意以下部分输出中的结果:

.NET SDK:
 Version:   9.0.100
 Commit:    81a714c6d3
 Workload version:  9.0.100-manifests.bbb3781c
 MSBuild version:   17.12.0
Runtime Environment:
 OS Name:     Windows
 OS Version:  10.0.26100
 OS Platform: Windows
 RID:         win-arm64
 Base Path:   C:\Program Files\dotnet\sdk\9.0.100\
.NET workloads installed:
Configured to use loose manifests when installing new manifests.
 [aspire]
   Installation Source: VS 17.12.35309.182, VS 17.11.35303.130
   Manifest Version:    9.0.0/0.0.100
   Manifest Path:       C:\Program Files\dotnet\sdk-manifests\9.0.100\microsoft.net.sdk.aspire\9.0.0\WorkloadManifest.json
   Install Type:        FileBased
Host (useful for support):
  Version: 9.0.0
  Architecture: arm64
  Commit:  static
.NET SDKs installed:
  8.0.400 [C:\Program Files\dotnet\sdk]
  9.0.100 [C:\Program Files\dotnet\sdk]
.NET runtimes installed:
  Microsoft.AspNetCore.App 8.0.8 [...\dotnet\shared\Microsoft.AspNetCore.App]
... 

使用 dotnet CLI 管理项目

.NET CLI 有以下命令在当前文件夹中的项目上工作,使您能够管理项目:

  • dotnet help: 这显示命令行帮助。

  • dotnet new: 这创建一个新的 .NET 项目或文件。

  • dotnet tool: 这安装或管理扩展 .NET 体验的工具。

  • dotnet workload: 这管理可选的工作负载,如 .NET MAUI。

  • dotnet restore: 这为项目下载依赖项。

  • dotnet build: 这将构建,即编译,一个 .NET 项目。.NET 8 中引入的新开关是 --tl(表示终端记录器),它提供了一种现代的输出方式。例如,它提供了有关构建正在做什么的实时信息。您可以在以下链接中了解更多信息:learn.microsoft.com/en-us/dotnet/core/tools/dotnet-build#options

  • dotnet build-server: 这与由构建启动的服务器交互。

  • dotnet msbuild: 这将运行 MS Build 引擎命令。

  • dotnet clean: 这从构建中删除临时输出。

  • dotnet test: 这将构建并运行项目的单元测试。

  • dotnet run: 这将构建并运行项目。

  • dotnet pack: 这为项目创建一个 NuGet 包。

  • dotnet publish: 这将构建并发布项目,可以是带依赖项的,也可以是自包含的应用程序。在 .NET 7 及更早版本中,默认发布 Debug 配置。在 .NET 8 及更高版本中,现在默认发布 Release 配置。

  • dotnet add: 这将包或类库的引用添加到项目中。

  • dotnet remove: 这将从项目中移除对包或类库的引用。

  • dotnet list: 这列出项目的包或类库引用。

  • dotnet package search: 这允许您搜索一个或多个包源,以查找与搜索词匹配的包。命令格式为 dotnet package search [search term] [options]。您可以在以下链接中了解更多信息:devblogs.microsoft.com/nuget/announcing-nuget-6-9/#support-for-dotnet-search-command

发布自包含应用程序

现在您已经看到了一些示例 dotnet 工具命令,我们可以发布我们的跨平台控制台应用程序:

  1. 在命令提示符或终端中,确保您位于 DotNetEverywhere 文件夹中。

  2. 输入命令以构建和发布 Windows 的控制台应用程序的单文件发布版本,如下所示:

    dotnet publish -c Release -r win-x64 --self-contained 
    

    发布过程可能需要一段时间,请耐心等待。

  3. 注意,构建引擎会恢复任何需要的包,将项目源代码编译成程序集 DLL,并创建一个 publish 文件夹,如下所示:

    MSBuild version 17.11.0+14c24b2d3 for .NET
      Determining projects to restore...
      All projects are up-to-date for restore.
      DotNetEverywhere -> C:\cs13net9\Chapter07\DotNetEverywhere\bin\Release\net9.0\win-x64\DotNetEverywhere.dll
      DotNetEverywhere -> C:\cs13net9\Chapter07\DotNetEverywhere\bin\Release\net9.0\win-x64\publish\ 
    
  4. 输入以下命令以构建和发布 macOS 和 Linux 变体的发布版本,如下所示:

    dotnet publish -c Release -r osx-arm64 --self-contained
    dotnet publish -c Release -r linux-x64 --self-contained 
    

良好实践:您可以使用 PowerShell 等脚本语言自动化这些命令,并在任何操作系统上执行脚本文件,使用跨平台的 PowerShell Core。我已经为您在以下链接中完成了这项工作:github.com/markjprice/cs13net9/tree/main/scripts/publish-scripts

  1. 打开 Windows 文件资源管理器或 macOS 查找器窗口,导航到 DotNetEverywhere\bin\Release\net9.0,并注意五个操作系统对应的输出文件夹。

  2. win-x64 文件夹中,打开 publish 文件夹,并注意所有支持程序集,如 Microsoft.CSharp.dll

  3. 选择 DotNetEverywhere 可执行文件,并注意它的大小为 154 KB,如图 7.3 所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/csp13-dn9-mdn-xplat-dev-fund/img/B22322_07_03.png

图 7.3:Windows 64 位 DotNetEverywhere 可执行文件

  1. 如果您使用的是具有 x64 兼容芯片的 Windows,则双击程序以执行,并注意结果,如下所示:

    I can run everywhere!
    OS Version is Microsoft Windows NT 10.0.26100.0.
    I am Windows 11.
    Press any key to stop me. 
    

我在我的 Surface Laptop 7 上执行了程序,因此针对并构建了 win-arm64 架构。前面的输出显示了在我的计算机上的结果。

  1. 按任意键关闭控制台应用程序及其窗口。

  2. 注意,publish 文件夹及其所有文件的总大小为 68.3 MB。

  3. osx-arm64 文件夹中,选择 publish 文件夹,注意所有支持程序集,然后选择 DotNetEverywhere 可执行文件。请注意,可执行文件大小为 125 KB,而 publish 文件夹大约为 73.9 MB。在 macOS 上发布的应用程序没有 .exe 文件扩展名,因此文件名不会有扩展名。

如果您将任何这些 publish 文件夹复制到适当的 操作系统OS),控制台应用程序将运行;这是因为它是一个自包含、可部署的 .NET 应用程序。例如,在 macOS 上就是这样:

I can run everywhere!
OS Version is Unix 13.5.2
I am macOS.
Press any key to stop me. 

此示例使用的是控制台应用程序,但您同样可以轻松创建 ASP.NET Core 网站、网络服务或 Windows Forms 或 WPF 应用程序。当然,您只能将 Windows 桌面应用程序部署到 Windows 计算机,而不是 Linux 或 macOS。

发布单文件应用程序

如果你知道你想要运行你的应用的计算机上已经安装了 .NET,那么你可以在发布你的应用时使用额外的标志来说明它不需要自包含,并且你希望将其发布为单个文件(如果可能),如下所示(必须在单行中输入):

dotnet publish -r win-x64 -c Release --no-self-contained
/p:PublishSingleFile=true 

这将生成两个文件:DotNetEverywhere.exeDotNetEverywhere.pdb.exe 文件是可执行文件。.pdb 文件是一个 程序调试数据库 文件,用于存储调试信息。

如果你希望 .pdb 文件嵌入到 .exe 文件中(例如,以确保它与程序集一起部署),那么请将 <DebugType> 元素添加到你的 .csproj 文件中的 <PropertyGroup> 元素,并将其设置为 embedded,如下所示(以下标记已高亮显示):

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RuntimeIdentifiers>
win-x64;osx-arm64;linux-x64
</RuntimeIdentifiers>
**<****DebugType****>****embedded****</****DebugType****>**
</PropertyGroup> 

如果你不能假设计算机上已经安装了 .NET,那么尽管 Linux 也只生成两个文件,但对于 Windows,你还需要期待以下额外的文件:coreclr.dllclrjit.dllclrcompression.dllmscordaccore.dll

让我们以 Windows 为例:

  1. 在命令提示符或终端中,在 DotNetEverywhere 文件夹中,输入构建 Windows 控制台应用自包含发布版本的命令,如下所示:

    dotnet publish -c Release -r win-x64 --self-contained /p:PublishSingleFile=true 
    
  2. 导航到 DotNetEverywhere\bin\Release\net9.0\win-x64\publish 文件夹,并选择 DotNetEverywhere 可执行文件。请注意,可执行文件现在为 62.6 MB,还有一个 11 KB 的 .pdb 文件。这些文件在你的系统中的大小可能会有所不同。

使用应用裁剪减小应用大小

将 .NET 应用作为自包含应用部署的一个问题是 .NET 库占用大量空间。最大的需求之一是减少 Blazor WebAssembly 组件的大小,因为所有 .NET 库都需要下载到浏览器。

幸运的是,你可以通过不将未使用的程序集打包到你的部署中来减小这个大小。从 .NET Core 3 开始引入的应用裁剪系统可以识别你的代码需要的程序集,并删除那些不需要的程序集。这被称为 copyused 裁剪模式。

在 .NET 5 中,裁剪进一步扩展,通过删除未使用的单个类型,甚至成员(例如,方法),如果它们在程序集中未被使用。例如,对于一个 Hello World 控制台应用,System.Console.dll 程序集从 61.5 KB 裁剪到 31.5 KB。这被称为 link 裁剪模式,但默认情况下并未启用。

在 .NET 6 中,微软向他们的库添加了注释,以指示它们如何安全地进行裁剪,因此类型和成员的裁剪被设置为默认选项。

在 .NET 7 中,微软将 link 重命名为 full,将 copyused 重命名为 partial

但问题是裁剪如何准确地识别未使用的程序集、类型和成员。如果你的代码是动态的,可能使用了反射,那么它可能无法正确工作,因此微软也允许手动控制。

有两种方式来启用类型级别和成员级别,也就是所谓的full级别的修剪。由于这种修剪级别在.NET 6 或更高版本中是默认的,我们只需要启用修剪,而不需要设置修剪级别或模式。

第一种方式是在项目文件中添加一个元素,如下所示:

<PublishTrimmed>true</PublishTrimmed> <!--Enable trimming.--> 

第二种方式是在发布时添加一个标志,如下所示高亮显示的命令:

dotnet publish ... **-p:PublishTrimmed=True** 

有两种方式来启用程序集级别,也就是所谓的partial级别的修剪。

第一种方式是在项目文件中添加两个元素,如下所示:

<PublishTrimmed>true</PublishTrimmed> <!--Enable trimming.-->
<TrimMode>partial</TrimMode> <!--Set assembly-level trimming.--> 

第二种方式是在发布时添加两个标志,如下所示高亮显示的命令:

dotnet publish ... **-p:PublishTrimmed=True -p:TrimMode=partial** 

控制构建工件创建的位置

传统上,每个项目都有自己的binobj子文件夹,在构建过程中会创建临时文件。当你发布时,文件会创建在bin文件夹中。

你可能希望将这些临时文件和文件夹放在其他地方。随着.NET 8 的引入,现在可以控制构建工件创建的位置。让我们看看如何:

  1. Chapter07文件夹的命令提示符或终端中,输入以下命令:

    dotnet new buildprops --use-artifacts 
    
  2. 注意成功消息,如下所示输出:

    The template "MSBuild Directory.Build.props file" was created successfully. 
    

    我们可以在cs13net9文件夹中创建此文件,然后它将影响所有章节的所有项目。

  3. Chapter07文件夹中,打开Directory.Build.props文件,如下所示:

    <Project>
    <!-- See https://aka.ms/dotnet/msbuild/customize for more details on
    customizing your build -->
    <PropertyGroup>
        <ArtifactsPath>$(MSBuildThisFileDirectory)artifacts</ArtifactsPath>
    </PropertyGroup>
    </Project> 
    
  4. 构建任何项目或整个解决方案。

  5. Chapter07文件夹中,请注意现在有一个artifacts文件夹,其中包含任何最近构建项目的子文件夹。

  6. 你可以删除此文件,或者将其重命名为类似Directory.Build.props.disabled的名称,这样它就不会通过在你不期望的文件夹中构建这些工件来影响本章的其余部分。这是可选的,但建议这样做。

**警告!**如果你保持此构建配置启用,请记住你的构建工件现在将创建在这个新的文件夹结构中。

原生提前编译

原生 AOT 生成的程序集是:

  • 自包含,意味着它们可以在没有安装.NET 运行时的系统上运行。

  • 提前编译(AOT)为原生代码,这意味着启动时间更快,内存占用可能更小。

原生 AOT 在编写时将 IL 代码编译为原生代码,而不是在运行时使用**即时(JIT)**编译器。但原生 AOT 程序集必须针对特定的运行时环境,如 Windows x64 或 Linux Arm。

由于原生 AOT 发生在发布时,你应该记住,当你在你代码编辑器中调试和实时工作在项目上时,它仍然使用运行时 JIT 编译器,即使你在项目中启用了 AOT!

然而,一些与原生 AOT 不兼容的功能将被禁用或抛出异常,并启用源分析器来显示有关潜在代码不兼容性的警告。

本地 AOT 的限制

本地 AOT 对启用它的项目有一些限制,以下列出了一些:

  • 不允许动态加载程序集。

  • 不允许运行时代码生成,例如使用 System.Reflection.Emit

  • 这需要裁剪,正如我们在上一节中提到的,它有其自身的限制。

  • 项目必须是自包含的,因此它们必须嵌入它们所调用的任何库,这增加了它们的大小。

虽然您自己的程序集可能没有使用上面列出的功能,但 .NET 本身的主要部分确实使用了。例如,ASP.NET Core MVC(包括使用控制器的 Web API 服务)和 EF Core 都会进行运行时代码生成以实现其功能。

.NET 团队正在努力使尽可能多的 .NET 与本地 AOT 兼容,并且尽快实现。但如果你使用 Minimal APIs,.NET 9 只包括对 ASP.NET Core 的基本支持,并且没有对 EF Core 的支持。

我的猜测是,.NET 10 将包括对 ASP.NET Core MVC 和 EF Core 部分的支持,但可能需要到 .NET 11 或 .NET 12 我们才能自信地使用大多数 .NET,并且知道我们可以使用本地 AOT 构建我们的程序集以获得这些好处。

本地 AOT 发布过程包括代码分析器来警告您是否使用了不受支持的功能,但并非所有包都已注释以与这些分析器良好协作。

最常用的注释来指示类型或成员不支持 AOT 是 [RequiresDynamicCode] 属性。

更多信息:您可以在以下链接中了解更多关于 AOT 警告的信息:learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/fixing-warnings

反射和本地 AOT

反射常用于运行时检查类型 元数据、成员的动态调用和代码生成。

本地 AOT 允许一些反射功能,但在本地 AOT 编译过程中进行的裁剪无法静态确定类型是否有可能仅通过反射访问的成员。这些成员将被 AOT 移除,这会导致运行时异常。

良好实践:开发者必须使用 [DynamicallyAccessedMembers] 注释来标记仅通过反射动态访问的成员,因此应保留未裁剪。

本地 AOT 的要求

对于不同的操作系统,有一些额外的要求:

  • 在 Windows 上,您必须安装包含所有默认组件的 Visual Studio 桌面开发与 C++ 工作负载。

  • 在 Linux 上,您必须安装 .NET 运行时所依赖的库的编译器工具链和开发包。例如,对于 Ubuntu 18.04 或更高版本:sudo apt-get install clang zlib1g-dev

  • 警告! 不支持跨平台原生 AOT 发布。这意味着您必须在您将要部署的操作系统上运行发布操作。例如,您不能在 Linux 上发布用于在 Windows 上运行的本地 AOT 项目,反之亦然。

启用项目的原生 AOT

要在项目中启用原生 AOT 发布,请将 <PublishAot> 元素添加到项目文件中,如下所示标记突出显示:

 <PropertyGroup>
    <TargetFramework>net9.0</TargetFramework>
 **<PublishAot>****true****</PublishAot>** 

构建原生 AOT 项目

现在,让我们通过使用控制台应用程序的新 AOT 选项来查看一个实际示例:

  1. 在名为 Chapter07 的解决方案中,添加一个与以下列表定义的本地 AOT 兼容的控制台应用程序项目:

    • 项目模板:控制台应用程序 / console --aot

    • 解决方案文件和文件夹:Chapter07

    • 项目文件和文件夹:AotConsole

    • 不要使用顶层语句:已清除

    • 启用原生 AOT 发布:已选择

如果您的代码编辑器尚未提供 AOT 选项,请创建一个传统的控制台应用程序,然后您将需要手动启用 AOT,如步骤 2 所示,或使用 dotnet CLI。

  1. 在项目文件中,请注意已启用原生 AOT 发布以及不变全球化,如下所示标记突出显示:

    <Project Sdk="Microsoft.NET.Sdk.Web">
      <PropertyGroup>
        <TargetFramework>net9.0</TargetFramework>
        <Nullable>enable</Nullable>
        <ImplicitUsings>enable</ImplicitUsings>
     **<PublishAot>****true****</PublishAot>**
     **<InvariantGlobalization>****true****</InvariantGlobalization>**
      </PropertyGroup>
    </Project> 
    

在 .NET 8 的 控制台应用程序 项目模板中,显式设置不变全球化为 true 是新的。它旨在使控制台应用程序不受文化限制,以便可以在世界任何地方部署并具有相同的行为。如果您将此属性设置为 false,或者如果元素缺失,则控制台应用程序将默认为当前托管计算机的文化。您可以在以下链接中了解更多关于不变全球化模式的信息:github.com/dotnet/runtime/blob/main/docs/design/features/globalization-invariant-mode.md

  1. 修改项目文件以在所有 C# 文件中静态导入 System.Console 类。

  2. Program.cs 中,删除任何现有语句,然后添加语句以输出当前文化和操作系统版本,如下所示代码:

    using System.Globalization; // To use CultureInfo.
    WriteLine("This is an ahead-of-time (AOT) compiled console app.");
    WriteLine("Current culture: {0}", CultureInfo.CurrentCulture.DisplayName);
    WriteLine("OS version: {0}", Environment.OSVersion);
    Write("Press any key to exit.");
    ReadKey(intercept: true); // Do not output the key that was pressed. 
    
  3. 运行控制台应用程序项目,并注意文化是不变的,如下所示输出:

    This is an ahead-of-time (AOT) compiled console app.
    Current culture: Invariant Language (Invariant Country)
    OS version: Microsoft Windows NT 10.0.22621.0 
    

    警告! 实际上,控制台应用程序尚未进行 AOT 编译;它仍然是当前 JIT 编译的,因为我们尚未发布它。

发布原生 AOT 项目

在开发期间代码未裁剪且 JIT 编译时功能正确的控制台应用程序,在您使用原生 AOT 发布后仍可能失败,因为代码此时被裁剪并 JIT 编译,因此它是不同的代码,具有不同的行为。因此,在假设项目将正常工作之前,您应该执行发布操作。

如果在发布时您的项目没有产生任何 AOT 警告,那么您可以有信心在发布后 AOT 将正常工作。

让我们发布我们的控制台应用程序:

  1. AotConsole项目的命令提示符或终端中,使用原生 AOT 发布控制台应用程序,如下面的命令所示:

    dotnet publish 
    
  2. 注意关于生成原生代码的消息,如下面的输出所示:

    MSBuild version 17.8.0+4ce2ff1f8 for .NET
      Determining projects to restore...
      Restored C:\cs13net9\Chapter07\AotConsole\AotConsole.csproj (in 173 ms).
      AotConsole -> C:\cs13net9\Chapter07\AotConsole\bin\Release\net9.0\win-x64\AotConsole.dll
      Generating native code
      AotConsole -> C:\cs13net9\Chapter07\AotConsole\bin\Release\net9.0\win-x64\publish\ 
    
  3. 启动文件资源管理器,打开bin\Release\net9.0\win-x64\publish文件夹,并注意AotConsole.exe文件大约有 1.2 MB。AotConsole.pdb文件仅用于调试。

  4. 运行AotConsole.exe,注意控制台应用程序的行为与之前相同。

  5. Program.cs中,导入命名空间以处理动态代码程序集,如下面的代码所示:

    using System.Reflection; // To use AssemblyName.
    using System.Reflection.Emit; // To use AssemblyBuilder. 
    
  6. Program.cs中,创建一个动态程序集构建器,如下面的代码所示:

    AssemblyBuilder ab = AssemblyBuilder.DefineDynamicAssembly(
      new AssemblyName("MyAssembly"), AssemblyBuilderAccess.Run); 
    
  7. AotConsole项目的命令提示符或终端中,使用原生 AOT 发布控制台应用程序,如下面的命令所示:

    dotnet publish 
    
  8. 注意关于调用带有[RequiresDynamicCode]属性装饰的DefineDynamicAssembly方法的警告,如下面的输出所示:

    C:\cs13net9\Chapter07\AotConsole\Program.cs(9,22): warning IL3050: Using member 'System.Reflection.Emit.AssemblyBuilder.DefineDynamicAssembly(AssemblyName, AssemblyBuilderAccess)' which has 'RequiresDynamicCodeAttribute' can break functionality when AOT compiling. Defining a dynamic assembly requires dynamic code. [C:\cs13net9\Chapter07\AotConsole\AotConsole.csproj] 
    
  9. 将我们无法在 AOT 项目中使用的语句注释掉。

更多信息:你可以在以下链接中了解更多关于原生 AOT 的信息:learn.microsoft.com/en-us/dotnet/core/deploying/native-aot/

为 NuGet 分发打包你的库

在我们学习如何创建和打包自己的库之前,我们将回顾一个项目如何使用现有的包。

引用 NuGet 包

假设你想添加一个第三方开发者创建的包,例如,Newtonsoft.Json,这是一个用于处理JavaScript 对象表示法JSON)序列化格式的流行包:

  1. AssembliesAndNamespaces项目中,可以通过 Visual Studio 的 GUI 或使用 CLI 的dotnet add package命令添加对Newtonsoft.Json NuGet 包的引用。

在 C#开发工具包的 4 月版本中,你现在可以使用命令面板中的某些命令直接从 VS Code 管理 NuGet 包,如以下链接所述:devblogs.microsoft.com/nuget/announcing-nuget-commands-in-c-dev-kit/

  1. 打开AssembliesAndNamespaces.csproj文件,注意已经添加了一个包引用,如下面的标记所示:

    <ItemGroup>
      <PackageReference Include="Newtonsoft.Json"
                        Version="13.0.3" />
    </ItemGroup> 
    

如果你有一个更新的Newtonsoft.Json包版本,那么自本章编写以来它已经被更新。

解决依赖关系

为了一致地还原包并编写可靠的代码,解决依赖关系非常重要。解决依赖关系意味着你正在使用为特定版本的.NET 发布的同一系列包,例如,SQLite 用于.NET 9,如下面的标记所示:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Version="9.0.0"
      Include="Microsoft.EntityFrameworkCore.Sqlite" />
  </ItemGroup>
</Project> 

为了解决依赖关系,每个包都应该有一个单一的版本,没有额外的限定符。额外的限定符包括测试版(beta1)、发布候选版(rc4)和通配符(*)。

通配符允许自动引用和使用未来的版本,因为它们始终代表最新的发布。因此,通配符是危险的,因为它们可能导致使用未来不兼容的包,从而破坏您的代码。

在编写书籍时,这可能值得承担风险,因为每个月都会发布新的预览版本,您不想像我在 2024 年那样不断更新预览包引用,如下所示:

<PackageReference Version="9.0.0-preview.*"
  Include="Microsoft.EntityFrameworkCore.Sqlite" /> 

要自动使用每年 9 月和 10 月到达的发布候选版本,您可以使模式更加灵活,如下所示:

<PackageReference Version="9.0-*"
  Include="Microsoft.EntityFrameworkCore.Sqlite" /> 

如果您使用dotnet add package命令或 Visual Studio 的管理 NuGet 包,则默认情况下将使用包的最新特定版本。但如果您从博客文章中复制粘贴配置或手动添加引用,您可能会包含通配符限定符。

以下是一些 NuGet 包引用的示例,它们不是固定的,因此除非您知道其影响,否则应避免使用:

<PackageReference Include="System.Net.Http" Version="4.1.0-*" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.2-beta1" /> 

良好实践:Microsoft 保证,如果您将依赖项固定到与特定.NET 版本(例如9.0.0)一起提供的版本,那么所有这些包都将一起工作。几乎总是固定依赖项,尤其是在生产部署中。

打包库以用于 NuGet

现在,让我们打包您之前创建的SharedLibrary项目:

  1. SharedLibrary项目中,请注意类库的目标是.NET Standard 2.0,因此默认情况下使用 C# 7.3 编译器。如以下标记所示,明确指定 C# 12 编译器:

    <Project Sdk="Microsoft.NET.Sdk">
      <PropertyGroup>
        <TargetFramework>netstandard2.0</TargetFramework>
        <LangVersion>12</LangVersion>
      </PropertyGroup>
    </Project> 
    
  2. SharedLibrary项目中,将Class1.cs文件重命名为StringExtensions.cs

  3. 修改其内容,以提供一些有用的扩展方法来验证各种文本值,使用正则表达式,如下所示:

    using System.Text.RegularExpressions; // To use Regex.
    namespace Packt.Shared;
    public static class StringExtensions
    {
      public static bool IsValidXmlTag(this string input)
      {
        return Regex.IsMatch(input,
          @"^<([a-z]+)([^<]+)*(?:>(.*)<\/\1>|\s+\/>)$");
      }
      public static bool IsValidPassword(this string input)
      {
        // Minimum of eight valid characters.
        return Regex.IsMatch(input, "^[a-zA-Z0-9_-]{8,}$");
      }
      public static bool IsValidHex(this string input)
      {
        // Three or six valid hex number characters.
        return Regex.IsMatch(input,
          "^#?([a-fA-F0-9]{3}|[a-fA-F0-9]{6})$");
      }
    } 
    

您将在第八章使用常见.NET 类型中学习如何编写正则表达式。

  1. SharedLibrary.csproj中,修改其内容,如下所示,并注意以下内容:

    • PackageId必须是全局唯一的,因此如果您想将此 NuGet 包发布到www.nuget.org/公共源供他人引用和下载,您必须使用不同的值。

    • PackageLicenseExpression必须是来自spdx.org/licenses/的值,或者您可以指定自定义许可。

    警告!如果你依赖 IntelliSense 来编辑文件,那么它可能会误导你使用已弃用的标签名称。例如,<PackageIconUrl>已被<PackageIcon>取代。有时,你不能完全信任自动化工具来帮助你正确操作!推荐的标签名称在以下链接中找到的表格的MSBuild 属性列中有记录:learn.microsoft.com/en-us/nuget/reference/msbuild-targets#pack-target

    • 所有其他元素都是自解释的:

      <Project Sdk="Microsoft.NET.Sdk">
        <PropertyGroup>
          <TargetFramework>netstandard2.0</TargetFramework>
          <LangVersion>12</LangVersion>
          **<****GeneratePackageOnBuild****>****true****</****GeneratePackageOnBuild****>**
      **<****PackageId****>****Packt.CSdotnet.SharedLibrary****</****PackageId****>**
      **<****PackageVersion****>****9.0.0.0****</****PackageVersion****>**
      **<****Title****>****C# 13 and .NET 9 Shared Library****</****Title****>**
      **<****Authors****>****Mark J Price****</****Authors****>**
      **<****PackageLicenseExpression****>**
       **MS-PL**
      **</****PackageLicenseExpression****>**
      **<****PackageProjectUrl****>**
       **https://github.com/markjprice/cs13net9**
      **</****PackageProjectUrl****>**
      **<****PackageReadmeFile****>****readme.md****</****PackageReadmeFile****>**
      **<****PackageIcon****>****packt-csdotnet-sharedlibrary.png****</****PackageIcon****>**
          **<****PackageRequireLicenseAcceptance****>****true****</****PackageRequireLicenseAcceptance****>**
      **<****PackageReleaseNotes****>**
       **Example shared library packaged for NuGet.**
      **</****PackageReleaseNotes****>**
      **<****Description****>**
       **Three extension methods to validate a string value.**
      **</****Description****>**
      **<****Copyright****>**
       **Copyright © 2016-2023 Packt Publishing Limited**
      **</****Copyright****>**
      **<****PackageTags****>****string extensions packt csharp dotnet****</****PackageTags****>**
        </PropertyGroup>
      **<****ItemGroup****>**
      **<****None****Include****=****"packt-csdotnet-sharedlibrary.png"**
      **PackagePath****=****"\"****Pack****=****"true"** **/>**
      **<****None****Include****=****"readme.md"**
      **PackagePath****=****"\"****Pack****=****"true"** **/>**
      **</****ItemGroup****>**
      </Project> 
      

      <None>表示一个不参与构建过程的文件。Pack="true"表示该文件将被包含在指定包路径位置创建的 NuGet 包中。你可以通过以下链接了解更多信息:learn.microsoft.com/en-us/nuget/reference/msbuild-targets#packing-an-icon-image-file

良好实践:配置属性值如果是truefalse,则不能有任何空白字符。

  1. 从以下链接下载图标文件,并将其保存到SharedLibrary项目文件夹中:github.com/markjprice/cs13net9/blob/main/code/Chapter07/SharedLibrary/packt-csdotnet-sharedlibrary.png.

  2. SharedLibrary项目文件夹中,创建一个名为readme.md的文件,其中包含有关包的一些基本信息,如下所示:

    # README for C# 13 and .NET 9 Shared Library
    This is a shared library that readers build in the book,
    *C# 13 and .NET 9 - Modern Cross-Platform Development Fundamentals*. 
    
  3. 构建发布版本:

    • 在 Visual Studio 中,在工具栏中选择发布,然后导航到生成 | 生成 SharedLibrary

    • 在 VS Code 中的终端中,输入dotnet build -c Release

如果我们没有在项目文件中将<GeneratePackageOnBuild>设置为true,那么我们就必须手动使用以下额外步骤创建一个 NuGet 包:

  • 在 Visual Studio 中,导航到生成 | 打包 SharedLibrary

  • 在 VS Code 中的终端中,输入dotnet pack -c Release

将包发布到公共 NuGet 源

如果你希望每个人都能下载和使用你的 NuGet 包,那么你必须将其上传到公共 NuGet 源,如 Microsoft 的:

  1. 打开你喜欢的浏览器并导航到以下链接:www.nuget.org/packages/manage/upload

  2. 如果你想上传一个 NuGet 包供其他开发者作为依赖项引用,你需要注册并登录到www.nuget.org/的 Microsoft 账户。

  3. 点击**浏览…**按钮,选择由生成 NuGet 包创建的.nupkg文件。文件夹路径应该是cs13net9\Chapter07\SharedLibrary\bin\Release,文件名为Packt.CSdotnet.SharedLibrary.9.0.0.nupkg

  4. 确认您在SharedLibrary.csproj文件中输入的信息已正确填写,然后点击提交

  5. 等待几秒钟,然后您将看到一个成功消息,显示您的包已上传,如图7.4所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/csp13-dn9-mdn-xplat-dev-fund/img/B22322_07_04.png

图 7.4:NuGet 包上传消息

良好实践:如果您遇到错误,请检查项目文件中的错误,或阅读有关PackageReference格式的更多信息,请参阅learn.microsoft.com/en-us/nuget/reference/msbuild-targets

  1. 点击框架选项卡,并注意,因为我们针对.NET Standard 2.0,我们的类库可以被每个.NET 平台使用,如图7.5所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/csp13-dn9-mdn-xplat-dev-fund/img/B22322_07_05.png

图 7.5:.NET Standard 2.0 类库包可以被所有.NET 平台使用

将包发布到私有 NuGet 源

组织可以托管自己的私有 NuGet 源。这对于许多开发团队来说是一种方便的共享工作方式。您可以在以下链接中了解更多信息:learn.microsoft.com/en-us/nuget/hosting-packages/overview

使用工具探索 NuGet 包

一个名为NuGet 包浏览器的实用工具,用于打开和查看 NuGet 包的更多详细信息,由 Uno Platform 创建。它不仅是一个网站,还可以作为跨平台应用程序安装。让我们看看它能做什么:

  1. 启动您喜欢的浏览器并导航到以下链接:nuget.info

  2. 在搜索框中输入Packt.CSdotnet.SharedLibrary

  3. 选择由马克·J·普莱斯发布的v9.0.0版本包,然后点击打开按钮。

  4. 内容部分,展开lib文件夹和netstandard2.0文件夹。

  5. 选择SharedLibrary.dll,并注意详细信息,如图7.6所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/csp13-dn9-mdn-xplat-dev-fund/img/B22322_07_06.png

图 7.6:使用 Uno Platform 的 NuGet 包浏览器探索我的包

  1. 如果您想将来在本地使用此工具,请点击浏览器中的安装按钮。

  2. 关闭浏览器。

并非所有浏览器都支持安装此类 Web 应用。我建议使用 Chrome 进行测试和开发。

测试您的类库包

您现在将通过在AssembliesAndNamespaces项目中引用它来测试您上传的包:

  1. AssembliesAndNamespaces项目中,添加对您的(或我的)包的引用,如下所示,高亮显示的标记:

    <ItemGroup>
      <PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
     **<PackageReference Include=****"Packt.CSdotnet.SharedLibrary"**
     **Version=****"9.0.0"** **/>**
    </ItemGroup> 
    
  2. 构建项目AssembliesAndNamespaces

  3. Program.cs中,导入Packt.Shared命名空间。

  4. Program.cs中,提示用户输入一些string值,然后使用包中的扩展方法进行验证,如下所示:

    Write("Enter a color value in hex: ");
    string? hex = ReadLine();
    WriteLine("Is {0} a valid color value? {1}",
      arg0: hex, arg1: hex.IsValidHex());
    Write("Enter a XML element: ");
    string? xmlTag = ReadLine();
    WriteLine("Is {0} a valid XML element? {1}",
      arg0: xmlTag, arg1: xmlTag.IsValidXmlTag());
    Write("Enter a password: ");
    string? password = ReadLine();
    WriteLine("Is {0} a valid password? {1}",
      arg0: password, arg1: password.IsValidPassword()); 
    
  5. 运行AssembliesAndNamespaces项目,根据提示输入一些值,并查看结果,如下所示:

    Enter a color value in hex: 00ffc8
    Is 00ffc8 a valid color value? True
    Enter an XML element: <h1 class="<" />
    Is <h1 class="<" /> a valid XML element? False
    Enter a password: secretsauce
    Is secretsauce a valid password? True 
    

使用预览功能

对于微软来说,交付一些具有跨.NET 多个部分影响的新功能是一个挑战,比如运行时、语言编译器和 API 库。这是一个典型的“先有鸡还是先有蛋”的问题。你先做什么?

从实际角度来看,这意味着尽管微软可能已经完成了实现一个功能所需的大部分工作,但整个功能可能直到他们现在每年一次的.NET 发布周期的最后阶段才完全准备好,这对于在“野外”进行适当的测试来说太晚了。

因此,从.NET 6 开始,微软将在通用可用性GA)发布中包含预览功能。开发者可以选择加入这些预览功能并向微软提供反馈。在后续的 GA 发布中,它们可以为所有人启用。

需要注意的是,这个主题是关于 预览功能。这与.NET 或 Visual Studio 的预览版不同。在开发过程中,微软会发布 Visual Studio 和.NET 的预览版以获取开发者的反馈,然后进行最终的 GA(通用可用性)发布。GA 发布后,功能对所有用户可用。在 GA 发布之前,获取新功能唯一的方式是安装预览版。"预览功能"不同之处在于它们与 GA 发布一起安装,并且必须选择性地启用。

例如,当微软在 2022 年 2 月发布.NET SDK 6.0.200 时,它将 C# 11 编译器作为一个预览功能包含在内。这意味着.NET 6 开发者可以选择将语言版本设置为preview,然后开始探索 C# 11 功能,如原始字符串字面量和required关键字。

一旦在 2022 年 11 月发布了.NET SDK 7.0.100,任何想要继续使用 C# 11 编译器的.NET 6 开发者就需要使用.NET 7 SDK 来处理他们的.NET 6 项目,并将目标框架设置为net6.0,同时设置<LangVersion>11。这样,他们就可以使用受支持的.NET 7 SDK 和受支持的 C# 11 编译器来构建.NET 6 项目。

到 2025 年 11 月,微软可能会发布带有 C# 14 编译器的.NET 10 SDK。然后你可以安装并使用.NET 10 SDK 来获得 C# 14 中任何新功能的益处,同时仍然针对.NET 9,如下面的Project文件所示:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
 **<TargetFramework>net9****.0****</TargetFramework>**
 **<LangVersion>****14****</LangVersion> <!--Requires .NET** **10** **SDK GA-->**
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>
</Project> 

良好实践:预览功能不支持在生产代码中使用。预览功能在最终发布前可能会出现破坏性更改。启用预览功能存在风险。切换到 GA 发布的未来 SDK,如.NET 11,以使用新的编译器功能,同时仍然针对旧版但长期支持的.NET 版本,如.NET 8 或 10。

需要预览功能

[RequiresPreviewFeatures] 属性用于指示使用预览功能的程序集、类型或成员,因此需要关于预览功能的警告。代码分析器可以扫描此属性,并在需要时生成警告。如果你的代码没有使用任何预览功能,你将不会看到任何警告。如果你的代码使用了任何预览功能,那么你将看到警告。你的代码也应该带有此属性,以警告其他开发者你的代码使用了预览功能。

启用预览功能

Project 文件中,添加一个元素以启用预览功能,并添加一个元素以启用预览语言功能,如下所示,高亮显示的标记:

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net9.0</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
**<****EnablePreviewFeatures****>****true****</****EnablePreviewFeatures****>**
**<****LangVersion****>****preview****</****LangVersion****>**
  </PropertyGroup>
</Project> 

方法拦截器

拦截器是一种方法,它用一个对可拦截方法的调用替换成对自身的调用。这是一个在源生成器中最常用的高级功能。如果你感兴趣,我可能会在第 9 版中添加一个关于它们的章节。

更多信息:您可以在以下链接中了解更多关于拦截器的信息:learn.microsoft.com/en-us/dotnet/csharp/whats-new/csharp-12#interceptors.

练习和探索

通过回答一些问题、进行一些实际操作练习以及进一步研究本章的主题,来测试你的知识和理解。

练习 7.1 – 在线材料

在线材料可以是我为这本书编写的额外内容,也可以是引用 Microsoft 或第三方创建的内容。

实验性 MSBuild 编辑器

MSBuild 是 .NET 开发体验的一个基本组件,但对于新开发者和经验丰富的开发者来说,它可能很难导航。为了帮助开发者更好地理解他们的项目文件并利用需要直接编辑项目文件的先进功能,Microsoft 团队推出了实验性的 MSBuild 编辑器。这个新编辑器提供了比当前 XML 架构提供的更深入的理解 MSBuild 文件。

如果你想尝试这个扩展,请在 Visual Studio 扩展管理器中搜索MSBuild 编辑器。目前,该扩展仅适用于 Visual Studio,但正在进行将其移植到 VS Code 的工作。

你可以在以下链接中了解更多关于新的 MSBuild 编辑器体验:

devblogs.microsoft.com/visualstudio/experimental-msbuild-editor/

提高 .NET 性能

在过去几年中,Microsoft 对 .NET 性能进行了重大改进。你应该阅读 Stephen Toub 编写的博客文章,以了解团队做了哪些更改以及为什么。他的文章以其长篇、详细和精彩而闻名!

你可以在以下链接中找到关于改进的帖子:

反编译 .NET 程序集

要了解如何反编译 .NET 程序集,您可以阅读以下链接:

github.com/markjprice/cs13net9/blob/main/docs/ch07-decompiling.md

从 .NET Framework 迁移到现代 .NET

如果您对将遗留项目从 .NET Framework 迁移到现代 .NET 感兴趣,那么我在以下链接中编写了一个仅在线的部分:

github.com/markjprice/cs13net9/blob/main/docs/ch07-porting.md

练习 7.2 – 练习题

练习题以深入了解本章主题。

创建源生成器

如果您对创建源生成器感兴趣,那么我在以下链接中编写了一个仅在线的部分:

github.com/markjprice/cs13net9/blob/main/docs/ch07-source-generators.md

您可以在以下链接中找到源生成器的示例:

github.com/amis92/csharp-source-generators

探索 PowerShell

PowerShell 是 Microsoft 用于在所有操作系统上自动化任务的脚本语言。Microsoft 推荐使用带有 PowerShell 扩展的 VS Code 来编写 PowerShell 脚本。

由于 PowerShell 是一种广泛使用的语言,本书中没有足够的空间来涵盖它。您可以从以下链接中的 Microsoft 培训模块中了解一些关键概念:learn.microsoft.com/en-us/training/modules/introduction-to-powershell/.

你可以在以下链接中阅读官方文档:learn.microsoft.com/en-us/powershell/.

练习 7.3 – 测试你的知识

回答以下问题:

  1. 命名空间和程序集之间的区别是什么?

  2. 你如何在 .csproj 文件中引用另一个项目?

  3. 类似 ILSpy 这样的工具有什么好处?

  4. C# 中的 float 别名代表的是哪种 .NET 类型?

  5. <PropertyGroup><ItemGroup> 元素在 .NET 项目文件中用于什么?

  6. .NET 应用程序的框架依赖型部署和自包含部署之间的区别是什么?

  7. RID 是什么?

  8. dotnet packdotnet publish 命令之间的区别是什么?

  9. 你如何控制构建过程使用的源以下载 NuGet 包?

  10. 使用 AOT 编译有什么局限性?

练习 7.4 – 探索主题

使用以下页面上的链接了解本章涵盖主题的更多详细信息:

github.com/markjprice/cs13net9/blob/main/docs/book-links.md#chapter-7---packaging-and-distributing-net-types

摘要

在本章中,我们:

  • 回顾了 .NET 为 BCL 功能所经历的旅程。

  • 探索了程序集和命名空间之间的关系。

  • 看到了发布适用于多个操作系统的应用程序的选项。

  • 学会了如何发布原生 AOT 以实现更快的启动和更小的内存占用。

  • 打包并分发了一个类库。

  • 学会了如何激活预览功能。

在下一章中,你将了解一些现代 .NET 中包含的常见 BCL 类型。

第八章:操作常见的 .NET 类型

本章介绍了一些包含在 .NET 中的常见类型。这些类型包括用于操作数字、文本和集合的类型;改进对 spans、indexes 和 ranges 的处理;以及在可选的在线部分,处理网络资源。

本章涵盖了以下主题:

  • 操作数字

  • 操作文本

  • 使用正则表达式进行模式匹配

  • 在集合中存储多个对象

  • 操作 spans、indexes 和 ranges

操作数字

最常见的数据类型之一是数字。在 .NET 中用于操作数字的最常见类型如 表 8.1 所示:

命名空间示例类型描述
SystemSByte, Int16, Int32, Int64, Int128整数;即零和正负整数。
SystemByte, UInt16, UInt32, UInt64, UInt128基数;即零和正整数。
SystemHalf, Single, Double实数;即浮点数。
SystemDecimal准确的实数;即用于科学、工程或金融场景。
System.NumericsBigInteger, Complex, Quaternion随意大的整数、复数和四元数。

表 8.1:常见的 .NET 数字类型

自从 2002 年 .NET Framework 1.0 发布以来,.NET 就有了 32 位的 float 和 64 位的 double 类型。IEEE 754 规范还定义了一个 16 位的浮点数标准。机器学习和其他算法将受益于这种更小、精度更低的数字类型;因此,Microsoft 在 .NET 5 及以后的版本中引入了 System.Half 类型。目前,C# 语言没有定义 half 别名,所以你必须使用 .NET 类型 System.Half。这可能在将来会改变。

System.Int128System.UInt128 是在 .NET 7 中引入的,它们也没有 C# 别名关键字。

操作大整数

可以存储在 .NET 类型中的最大整数是大约十八亿五千万,使用无符号 64 位整数 ulong 存储。但如果你需要存储比这更大的数字怎么办?

让我们探索数值:

  1. 使用您首选的代码编辑器创建一个新项目,如下面的列表所示:

    • 项目模板:控制台应用程序 / console

    • 项目文件和文件夹:WorkingWithNumbers

    • 解决方案文件和文件夹:Chapter08

  2. 在项目文件中,添加一个元素以静态和全局导入 System.Console 类。

  3. Program.cs 中,删除现有的语句,然后添加一个语句来导入 System.Numerics,如下面的代码所示:

    using System.Numerics; // To use BigInteger. 
    
  4. 添加语句以输出 ulong 类型的最大值,以及使用 BigInteger 的 30 位数字,如下面的代码所示:

    const int width = 40;
    WriteLine("ulong.MaxValue vs a 30-digit BigInteger");
    WriteLine(new string('-', width));
    ulong big = ulong.MaxValue;
    WriteLine($"{big,width:N0}");
    BigInteger bigger =
      BigInteger.Parse("123456789012345678901234567890");
    WriteLine($"{bigger,width:N0}"); 
    

格式代码中值40width常量表示“右对齐 40 个字符”,因此两个数字都排列在右侧边缘。N0表示“使用千位分隔符和零小数位。”

  1. 运行代码并查看结果,如下所示输出:

    ulong.MaxValue vs a 30-digit BigInteger
    ----------------------------------------
                  18,446,744,073,709,551,615
     123,456,789,012,345,678,901,234,567,890 
    

BigInteger可以表示几乎无限长度的整数值。然而,在现实中,这受到可用内存和计算时间等因素的限制。如果生成的值过大,一些 API 也可能失败。因此,在.NET 9 及以后版本中,对BigInteger的最大长度进行了强制限制,限制为(2³¹) - 1位(约 21.4 亿位)。这相当于大约 256 MB 和约 646.5 百万位。这个限制确保 API 保持可靠和一致,同时仍然支持远大于大多数实际需求的数字。

乘以大整数

当你乘以两个 32 位整数时,结果可能会超出 32 位整数的范围。例如,32 位整数(int)的最大值是 2,147,483,647。如果你将这个值乘以另一个大整数,结果可能会超过这个限制,导致溢出,如果只使用 32 位来存储结果,可能会导致计算错误。

自从.NET 的最早版本以来,System.Math类有一个BigMul方法,它执行两个 32 位整数的乘法,并将完整的 64 位结果作为long返回。这个方法是必要的,因为使用*运算符乘以两个 32 位整数可能会导致 64 位整数,这可能会超出典型的 32 位整数类型。

自从.NET 5 以来,BigMul方法增加了对两个有符号或无符号long值进行乘法的重载。在.NET 9 及以后版本中,数字类型本身,如intlonguintulong,现在也获得了BigMul方法来乘以它们的值,并将结果以下一个最大的类型返回。例如,long.BigMul返回System.Int128

现在,让我们看看如何乘以大整数:

  1. Program.cs中,添加语句以使用旧的和新的方法乘以一些大整数,如下所示代码:

    WriteLine("Multiplying big integers");
    int number1 = 2_000_000_000;
    int number2 = 2;
    WriteLine($"number1: {number1:N0}");
    WriteLine($"number2: {number2:N0}");
    WriteLine($"number1 * number2: {number1 * number2:N0}");
    WriteLine($"Math.BigMul(number1, number2): {
      Math.BigMul(number1, number2):N0}");
    WriteLine($"int.BigMul(number1, number2): {
      int.BigMul(number1, number2):N0}"); 
    
  2. 运行代码并查看结果,如下所示输出:

    Multiplying big integers
    number1: 2,000,000,000
    number2: 2
    number1 * number2: -294,967,296
    Math.BigMul(number1, number2): 4,000,000,000
    int.BigMul(number1, number2): 4,000,000,000 
    

处理复数

一个复数可以表示为a + bi,其中ab是实数,i是虚数单位,其中i² = −1。如果实部a为零,它是一个纯虚数。如果虚部b为零,它是一个实数。

复数在许多科学、技术、工程和数学STEM)研究领域有实际应用。它们是通过分别添加加数的实部和虚部来相加的;考虑以下:

(a + bi) + (c + di) = (a + c) + (b + d)i 

让我们探索复数:

  1. Program.cs中,添加语句以添加两个复数,如下所示代码:

    Complex c1 = new(real: 4, imaginary: 2);
    Complex c2 = new(real: 3, imaginary: 7);
    Complex c3 = c1 + c2;
    // Output using the default ToString implementation.
    WriteLine($"{c1} added to {c2} is {c3}");
    // Output using a custom format.
    WriteLine("{0} + {1}i added to {2} + {3}i is {4} + {5}i",
      c1.Real, c1.Imaginary,
      c2.Real, c2.Imaginary,
      c3.Real, c3.Imaginary); 
    
  2. 运行代码并查看结果,如下所示输出:

    <4; 2> added to <3; 7> is <7; 9>
    4 + 2i added to 3 + 7i is 7 + 9i 
    

.NET 6 及更早版本使用不同的默认复数格式:(4, 2) 加上 (3, 7)(7, 9)。在 .NET 7 及以后版本中,默认格式已更改为使用尖括号和分号,因为某些文化使用圆括号表示负数,并使用逗号表示小数。在撰写本文时,官方文档尚未更新以使用新格式,如下列链接所示:learn.microsoft.com/en-us/dotnet/api/system.numerics.complex.tostring

与张量一起工作

张量是 人工智能AI)中的基本数据结构。你可以把它们看作是专门的多维数组。

张量用于表示和编码各种形式的数据,包括文本序列(标记)、图像、视频和音频。张量以最优效率对高维数据进行计算,并在神经网络中存储权重信息和中间计算。

.NET 9 引入了一个新的 Tensor<T> 类型,它使与 AI 库(如 ML.NET、TorchSharp 和 ONNX Runtime)的高效交互成为可能,并在可能的情况下最小化数据复制。它通过直观的索引和切片操作简化了数据操作,确保易用性和效率。

为游戏和类似应用程序生成随机数

在不需要真正随机数的情况,如游戏中,你可以创建一个 Random 类的实例,如下面的代码示例所示:

Random r = new(); 

Random 有一个构造函数,用于指定用于初始化其伪随机数生成器的种子值,如下面的代码所示:

Random r = new(Seed: 46378); 

如你在 第二章 讲 C# 中所学,参数名称应使用 驼峰命名法。定义 Random 类构造函数的开发者打破了这一惯例。参数名称应该是 seed,而不是 Seed

良好实践:共享的种子值充当密钥;因此,如果你在两个应用程序中使用相同的随机数生成算法和相同的种子值,那么它们可以生成相同的“随机”数字序列。有时这是必要的,例如,当同步 GPS 接收器与卫星时,或者当游戏需要随机生成相同关卡时。但通常,你希望保持你的种子值保密。

使用 Random 类生成随机数

为了避免分配比必要的更多内存,.NET 6 引入了一个共享的静态 Random 实例,你可以访问它而不是创建自己的实例。

Random 类有常用的生成随机数的方法,如下列所示:

  • Next:此方法返回一个随机int(整数),它接受两个参数,minValuemaxValue,但maxValue不是方法返回的最大值!它是一个排他性上限,意味着maxValue比返回的最大值多 1。使用NextInt64方法返回一个long整数。

  • NextDouble:此方法返回一个大于或等于0.0且小于且从不等于1.0的数字。使用NextSingle方法返回一个float

  • NextBytes:此方法使用随机byte0255)值填充任何大小的数组。通常将byte值格式化为十六进制,例如,00FF

让我们看看一些生成伪随机数的示例:

  1. Program.cs中,添加语句以访问共享的Random实例,然后调用其方法来生成随机数,如下面的代码所示:

    Random r = Random.Shared;
    // minValue is an inclusive lower bound i.e. 1 is a possible value.
    // maxValue is an exclusive upper bound i.e. 7 is not a possible value.
    int dieRoll = r.Next(minValue: 1, maxValue: 7); // Returns 1 to 6.
    WriteLine($"Random die roll: {dieRoll}");
    double randomReal = r.NextDouble(); // Returns 0.0 to less than 1.0.
    WriteLine($"Random double: {randomReal}");
    byte[] arrayOfBytes = new byte[256];
    r.NextBytes(arrayOfBytes); // Fills array with 256 random bytes.
    Write("Random bytes: ");
    for (int i = 0; i < arrayOfBytes.Length; i++)
    {
      Write($"{arrayOfBytes[i]:X2} ");
    }
    WriteLine(); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    Random die roll: 1
    Random double: 0.06735275453092382
    Random bytes: D9 38 CD F3 5B 40 2D F4 5B D0 48 DF F7 B6 67 C1 95 A1 2C 58 42 CF 70 6C C3 BE 82 D7 EC 61 0D D2 2D C4 49 7B C7 0F EA CC B3 41 F3 04 5D 29 25 B7 F7 99 8A 0F 56 20 A6 B3 57 C4 48 DA 94 2B 07 F1 15 64 EA 8D FF 79 E6 E4 9A C8 65 C5 D8 55 3D 3C C0 2B 0B 4C 3A 0E E6 A5 91 B7 59 6C 9A 94 97 43 B7 90 EE D8 9A C6 CA A1 8F DD 0A 23 3C 01 48 E0 45 E1 D6 BD 7C 41 C8 22 8A 81 82 DC 1F 2E AD 3F 93 68 0F B5 40 7B 2B 31 FC A6 BF BA 05 C0 76 EE 58 B3 41 63 88 E5 5C 8B B5 08 5C C3 52 FF 73 69 B0 97 78 B5 3B 87 2C 12 F3 C3 AE 96 43 7D 67 2F F8 C9 31 70 BD AD B3 9B 44 53 39 5F 19 73 C8 43 0E A5 5B 6B 5A 9D 2F DF DC A3 EE C5 CF AF A4 8C 0F F2 9C 78 19 48 CE 49 A8 28 06 A3 4E 7D F7 75 AA 49 E7 4E 20 AF B1 77 0A 90 CF C1 E0 62 BC 4F 79 76 64 98 BF 63 76 B4 F9 1D A4 C4 74 03 63 02 
    

在确实需要真正随机数的情况下,例如在密码学中,有专门的数据类型,比如RandomNumberGenerator。我在配套书籍《.NET 8 工具与技能》中涵盖了这一点以及其他密码学类型,在标题为《使用密码学保护数据和应用程序》的章节中进行了介绍,该章节于 2024 年 7 月出版。

.NET 8 及以后版本的新随机方法

.NET 8 引入了两个新的Random方法,如下列所示:

  • GetItems<T>:此方法接受一个类型为T的数组或只读 span 的选择,以及您想要生成的项目数量,然后从选择中随机返回这些数量的项目。

  • Shuffle<T>:此方法接受一个类型为T的数组或 span,并随机化项目的顺序。

让我们看看每个示例:

  1. Program.cs中,添加语句以访问共享的Random实例,然后调用其方法来生成随机数,如下面的代码所示:

    string[] beatles = r.GetItems(
      choices: new[] { "John", "Paul", "George", "Ringo" },
      length: 10);
    Write("Random ten beatles:");
    foreach (string beatle in beatles)
    {
      Write($" {beatle}");
    }
    WriteLine();
    r.Shuffle(beatles);
    Write("Shuffled beatles:");
    foreach (string beatle in beatles)
    {
      Write($" {beatle}");
    }
    WriteLine(); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    Random ten beatles: Paul Paul John John John John Paul John George Ringo
    Shuffled beatles: George John Paul Paul John John John Ringo Paul John 
    

生成 GUID

**全局唯一标识符(GUID)**是一个 128 位的文本字符串,它代表一个用于标识的唯一值。作为开发者,当需要生成用于标识信息的唯一引用时,您将需要生成 GUID。传统上,数据库和计算机系统可能使用递增的整数值,但 GUID 更有可能在多任务系统中避免冲突。

System.Guid类型是一个值类型(struct),它表示 GUID 值。它有ParseTryParse方法,可以将表示为string的现有 GUID 值转换为Guid类型。它有一个NewGuid方法来生成新的值。

NewGuid方法实现了**通用唯一标识符(UUID)**规范的第四版。您可以在以下链接中了解 UUID 规范:datatracker.ietf.org/doc/rfc9562/

.NET 9 引入了对 UUID 规范版本 7 的支持,这允许你控制自 Unix 纪元以来的前 48 位时间戳毫秒数,从而可以创建可排序的 GUID。这对于数据库的唯一 ID 来说要好得多。

让我们分解一个示例值:0191a398-12ec-7d5e-a8d3-8a629eae8be1

  • 前导 48 位是一个十六进制的时间戳:0191a39812ec

    • 0191a398:前 32 位代表时间戳的高位。

    • 12ec:接下来的 16 位继续时间戳。

  • 接下来的 12 位是十六进制的版本和变体:7d5e

    • 7表示这是一个版本 7 的 UUID。

    • 剩余的三位(d5e)结合了时间戳和变体的一部分,用于识别 UUID 的布局。

  • 最后的 68 位是一个十六进制的随机部分:a8d3-8a629eae8be1

    • 这确保了 GUID 的唯一性。

让我们看看我们如何生成 GUID 值并输出它们:

  1. Program.cs中添加语句以输出空Guid的值,生成一个新的随机Guid并输出其值,最后输出随机Guid值的每个单独的字节,如下面的代码所示:

    WriteLine($"Empty GUID: {Guid.Empty}.");
    Guid g = Guid.NewGuid();
    WriteLine($"Random GUID: {g}.");
    byte[] guidAsBytes = g.ToByteArray();
    Write("GUID as byte array: ");
    for (int i = 0; i < guidAsBytes.Length; i++)
    {
      Write($"{guidAsBytes[i]:X2} ");
    }
    WriteLine();
    WriteLine("Generating three v7 GUIDs:");
    for (int i = 0; i < 3; i++)
    {
      Guid g7 = Guid.CreateVersion7(DateTimeOffset.UtcNow);
      WriteLine($"  {g7}.");
    } 
    
  2. 运行代码,查看结果,并注意三个 GUID 值的前部分(0191a398-12ec等)是可排序的,因为它们基于相同的DateTimeOffset值,如下面的输出所示:

    Empty GUID: 00000000-0000-0000-0000-000000000000.
    Random GUID: c7a11eea-45a5-4619-964a-a9cce1e4220c.
    GUID as byte array: EA 1E A1 C7 A5 45 19 46 96 4A A9 CC E1 E4 22 0C
    Generating three v7 GUIDs:
      0191a398-12ec-7d5e-a8d3-8a629eae8be1.
      0191a398-12ed-7913-8096-c6f70b5edd8f.
      0191a398-12ed-7475-8284-0588b573080e. 
    

    EF Core 团队有一个提案,允许在数据模型中使用版本 7 的 GUID。您可以在以下链接中了解更多关于这个想法:github.com/dotnet/efcore/issues/34158

处理文本

变量数据类型中最常见的其他类型之一是文本。在.NET 中用于处理文本的最常见类型如表 8.2所示:

命名空间类型描述
SystemChar存储单个文本字符的存储空间
SystemString存储多个文本字符的存储空间
System.TextStringBuilder高效地操作字符串
System.Text.RegularExpressionsRegex高效地匹配字符串模式

表 8.2:用于处理文本的常见.NET 类型

获取字符串的长度

让我们探索一些处理文本时的常见任务;例如,有时你需要找出存储在string变量中的文本片段的长度:

  1. 使用您首选的代码编辑器,将一个新的控制台应用程序/ console项目命名为WorkingWithText添加到Chapter08解决方案中。

  2. WorkingWithText项目中,在Program.cs中删除现有的语句,然后添加语句来定义一个变量以存储城市名称伦敦,并将其名称和长度写入控制台,如下面的代码所示:

    string city = "London";
    WriteLine($"{city} is {city.Length} characters long."); 
    
  3. 运行代码并查看结果,如下面的输出所示:

    London is 6 characters long. 
    

获取字符串的字符

string 类内部使用 char 数组来存储文本。它还有一个索引器,这意味着我们可以使用数组语法来读取其字符。数组索引从 0 开始,因此第三个字符将在索引 2 处。

让我们看看这个动作:

  1. 添加语句写入 string 变量中的第一个和第四个位置的字符,如下所示:

    WriteLine($"First char is {city[0]} and fourth is {city[3]}."); 
    
  2. 运行代码并查看以下输出结果:

    First char is L and fourth is d. 
    

分割字符串

有时候,你需要将文本分割成任意字符,例如逗号:

  1. 添加语句定义一个包含以逗号分隔的城市名称的单一 string 变量,然后使用 Split 方法并指定你想要将逗号作为分隔符,然后枚举返回的 string 值数组,如下所示:

    string cities = "Paris,Tehran,Chennai,Sydney,New York,Medellín";
    string[] citiesArray = cities.Split(',');
    WriteLine($"There are {citiesArray.Length} items in the array:");
    foreach (string item in citiesArray)
    {
      WriteLine($"  {item}");
    } 
    
  2. 运行代码并查看以下输出结果:

    There are 6 items in the array:
      Paris
      Tehran
      Chennai
      Sydney
      New York
      Medellín 
    

在本章的后面部分,你将学习如何使用正则表达式处理更复杂的字符串分割场景。

获取字符串的一部分

有时候,你需要获取文本的一部分。IndexOf 方法有九种重载,可以返回指定 charstringstring 中的索引位置。Substring 方法有两种重载,如下所示:

  • Substring(startIndex, length): 这将返回从 startIndex 开始并包含下一个 length 个字符的字符串部分。

  • Substring(startIndex): 这将返回从 startIndex 开始并包含直到字符串末尾的所有字符的字符串部分。

让我们来看一个简单的例子:

  1. 添加语句将一个人的全名存储在一个以空格字符分隔的 string 变量中,第一个和最后一个名字之间,找到空格的位置,然后提取名字和姓氏作为两部分,以便它们可以以不同的顺序重新组合,如下所示:

    string fullName = "Alan Shore";
    int indexOfTheSpace = fullName.IndexOf(' ');
    string firstName = fullName.Substring(
      startIndex: 0, length: indexOfTheSpace);
    string lastName = fullName.Substring(
      startIndex: indexOfTheSpace + 1);
    WriteLine($"Original: {fullName}");
    WriteLine($"Swapped: {lastName}, {firstName}"); 
    
  2. 运行代码并查看以下输出结果:

    Original: Alan Shore
    Swapped: Shore, Alan 
    

如果初始全名的格式不同,例如 "LastName, FirstName",则代码需要不同。作为一个可选练习,尝试编写一些语句,将输入 "Shore, Alan" 转换为 "Alan Shore"

检查字符串内容

有时候,你需要检查一段文本是否以某些字符开始或结束,或者是否包含某些字符。你可以使用名为 StartsWithEndsWithContains 的方法来实现这一点:

  1. 添加语句存储一个 string 值,然后检查它是否以或包含几个不同的 char 值,如下所示:

    string company = "Microsoft";
    WriteLine($"Text: {company}");
    WriteLine("Starts with M: {0}, contains an N: {1}",
      arg0: company.StartsWith('M'),
      arg1: company.Contains('N')); 
    

    良好实践StartsWithEndsWithContains 可以传递 stringchar 值。单引号表示前述代码中的 char 值。搜索类似 'M'char 比搜索类似 "M"string 值更高效。

  2. 运行代码并查看以下输出结果:

    Text: Microsoft
    Starts with M: True, contains an N: False 
    

比较字符串值

两个常见的字符串值任务是排序(也称为整理)和比较。例如,当用户输入他们的用户名或密码时,你需要比较他们输入的内容与存储的内容。

string类实现了IComparable接口,这意味着你可以通过使用CompareTo实例方法轻松比较两个字符串值,它将返回-101,具体取决于值是“小于”、“等于”还是“大于”另一个。当你为Person类实现IComparable接口时,你看到了这个例子,如第六章实现接口和继承类

然而,字符的大小写可能会影响排序,文本的排序规则是文化相关的。例如,在传统的西班牙语中,双 L 被视为一个字符,如表 8.3所示:

文化描述示例字符串值
西班牙语在 1994 年,西班牙皇家学院发布了一条新的字母排序规则,将 LL 和 CH 视为拉丁字母字符,而不是单独的字符。现代:llegar排在lugar之前。传统:llegar排在lugar之后。
瑞典语在 2006 年,瑞典学院发布了一条新规则。在 2006 年之前,V 和 W 是同一个字符。自 2006 年以来,它们被视为不同的字符。瑞典语单词大多只使用 V。包含 W 的借词(从其他语言借来的单词)现在可以保留那些 W,而不是用 Vs 替换 W。
德语电话簿排序与字典排序不同,例如,重音符号被视为字母的组合。在电话簿排序中,MüllerMueller是同一个名字。
德语字符ß被排序为SS。这是地址中常见的问题,因为“街道”这个词是StraßeStraßeStrasse有相同的意思。

表 8.3:欧洲语言排序规则示例

为了保持一致性和性能,有时你想以文化不变的方式进行比较。因此,最好使用static方法Compare

让我们看看一些例子:

  1. Program.cs的顶部,导入用于处理文化的命名空间,并启用特殊字符,如欧元货币符号,如下面的代码所示:

    using System.Globalization; // To use CultureInfo.
    OutputEncoding = System.Text.Encoding.UTF8; // Enable Euro symbol. 
    
  2. Program.cs中,定义一些文本变量,并在不同的文化中进行比较,如下面的代码所示:

    CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("en-US");
    string text1 = "Mark";
    string text2 = "MARK";
    WriteLine($"text1: {text1}, text2: {text2}");
    WriteLine("Compare: {0}.", string.Compare(text1, text2));
    WriteLine("Compare (ignoreCase): {0}.",
      string.Compare(text1, text2, ignoreCase: true));
    WriteLine("Compare (InvariantCultureIgnoreCase): {0}.",
      string.Compare(text1, text2,
      StringComparison.InvariantCultureIgnoreCase));
    // German string comparisons
    CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("de-DE");
    text1 = "Strasse";
    text2 = "Straße";
    WriteLine($"text1: {text1}, text2: {text2}");
    WriteLine("Compare: {0}.", string.Compare(text1, text2,
      CultureInfo.CurrentCulture, CompareOptions.IgnoreNonSpace));
    WriteLine("Compare (IgnoreCase, IgnoreNonSpace): {0}.",
      string.Compare(text1, text2, CultureInfo.CurrentCulture,
      CompareOptions.IgnoreNonSpace | CompareOptions.IgnoreCase));
    WriteLine("Compare (InvariantCultureIgnoreCase): {0}.",
      string.Compare(text1, text2,
      StringComparison.InvariantCultureIgnoreCase)); 
    
  3. 运行代码,查看结果,并注意小写字母“a”是“小于”(-1)大写字母“A”,所以比较返回-1。但我们可以设置一个选项来忽略大小写,或者更好的是,进行文化和大小写不变的比较,将两个字符串值视为相等(0),如下面的输出所示:

    text1: Mark, text2: MARK
    Compare: -1.
    Compare (ignoreCase): 0.
    Compare (InvariantCultureIgnoreCase): 0.
    text1: Strasse, text2: Straße
    Compare: 0.
    Compare (IgnoreCase, IgnoreNonSpace): 0.
    Compare (InvariantCultureIgnoreCase): -1. 
    

更多信息:你可以在以下链接中了解更多关于string比较的信息:learn.microsoft.com/en-us/globalization/locale/sorting-and-string-comparison

连接、格式化和其他字符串成员

表 8.4所示,还有很多其他的string成员:

成员描述
Trim, TrimStart, TrimEnd这些方法从开始和/或结束处删除空白字符,如空格、制表符和回车符。
ToUpper, ToLower这些将所有字符转换为大写或小写。
Insert, Remove这些方法用于插入或删除一些文本。
Replace这将一些文本替换为其他文本。
string.Empty这可以用来代替每次使用空的双引号("")分配内存的文本字面量字符串值。
string.Concat这将两个字符串变量连接起来。当在字符串操作数之间使用时,+运算符执行等效操作。
string.Join这将一个或多个字符串变量连接起来,每个字符串之间用一个字符隔开。
string.IsNullOrEmpty这用于检查一个字符串变量是否为 null 或空。
string.IsNullOrWhiteSpace这用于检查一个字符串变量是否为 null 或空白;也就是说,是任意数量的水平或垂直间隔字符的混合,例如,制表符、空格、回车、换行等。
string.Format这是一个用于输出格式化字符串值的替代方法,它使用位置参数而不是命名参数进行字符串插值。

表 8.4:连接、格式化和其他字符串成员

前面的某些方法是static方法。这意味着该方法只能从类型调用,而不能从变量实例调用。在前面的表中,我通过在方法前加上string.前缀来指示静态方法,例如string.Format

让我们探索一些这些方法:

  1. 使用Join方法添加语句来接收一个string值数组,并使用分隔符将它们组合回单个string变量,如下面的代码所示:

    string recombined = string.Join(" => ", citiesArray);
    WriteLine(recombined); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    Paris => Tehran => Chennai => Sydney => New York => Medellín 
    
  3. 添加语句以使用位置参数和插值string格式化语法来输出相同的三个变量两次,如下面的代码所示:

    string fruit = "Apples";
    decimal price =  0.39M;
    DateTime when = DateTime.Today;
    WriteLine($"Interpolated:  {fruit} cost {price:C} on {when:dddd}.");
    WriteLine(string.Format("string.Format: {0} cost {1:C} on {2:dddd}.",
      arg0: fruit, arg1: price, arg2: when)); 
    

    一些代码编辑器,如 Rider,会警告你关于装箱操作。这些操作虽然慢,但在这种情况下并不是问题。为了避免装箱,请在pricewhen上调用ToString方法。

  4. 运行代码并查看结果,如下面的输出所示:

    Interpolated:  Apples cost $0.39 on Friday.
    string.Format: Apples cost $0.39 on Friday. 
    

注意,我们可以简化第二个语句,因为Console.WriteLine支持与string.Format相同的格式代码,如下面的代码所示:

WriteLine("WriteLine: {0} cost {1:C} on {2:dddd}.",
  arg0: fruit, arg1: price, arg2: when); 

高效构建字符串

你可以使用String.Concat方法或简单地使用+运算符将两个字符串连接起来以创建一个新的string。但是,当组合超过几个值时,这两种选择都是不好的做法,因为.NET 必须在内存中创建一个全新的string

如果你只添加两个 string 值,这可能不会引起注意,但如果你在循环中多次连接字符串,它可能会对性能和内存使用产生显著的负面影响。你可以使用 StringBuilder 类型更有效地连接 string 变量。

我为配套书籍 Apps and Services with .NET 8 编写了一个仅在网络上可用的部分,关于使用 string 连接作为主要示例的性能基准测试。你可以选择在以下链接完成该部分及其实际编码任务:github.com/markjprice/apps-services-net8/blob/main/docs/ch01-benchmarking.md

更多信息:你可以在以下链接中看到使用 StringBuilder 的示例:learn.microsoft.com/en-us/dotnet/api/system.text.stringbuilder#examples

处理字符

有时,你需要处理单个字符。System.Char 类型(也称为 char)有一些有用的方法,如下表 8.5 所示:

方法描述
char.IsDigit(char), char.IsDigit(string, int)如果字符是十进制数字,则返回 true
char.IsLetter(char), char.IsLetter(string, int)如果字符是字母,则返回 true
char.IsLower(char), char.IsLower(string, int)如果字符是小写字母,则返回 true
char.IsUpper(char), char.IsUpper(string, int)如果字符是大写字母,则返回 true
char.IsSeparator(char), char.IsSeparator(string, int)如果字符是分隔符,则返回 true
char.IsSymbol(char), char.IsSymbol(string, int)如果字符是符号,则返回 true
char.IsWhiteSpace(char), char.IsWhiteSpace(string, int)如果字符是空白字符,如空格或制表符,则返回 true

表 8.5:处理字符的方法

字符串中的搜索

.NET 8 引入了 SearchValues 类型,该类型实现了一种在 spans 中搜索字符或字节集合的优化方法。例如,SearchValues 有助于在文本值中找到第一个元音实例,如下面的代码所示:

string vowels = "AEIOUaeiou";
// .NET 8 and later.
SearchValues<char> vowelsSearchValues = SearchValues.Create(vowels);   
ReadOnlySpan<char> text = "Fred";
WriteLine($"vowels: {vowels}");
WriteLine($"text: {text}");
WriteLine($"text.IndexOfAny(vowelsSearchValues): {
  text.IndexOfAny(vowelsSearchValues)}"); 

文本 Fred 中的第一个元音位于索引位置 2,如下面的输出所示:

vowels: AEIOUaeiou
text: Fred
text.IndexOfAny(vowelsSearchValues): 2 

在 .NET 9 或更高版本中,SearchValues 现在不仅支持搜索子字符串,还支持在较长的字符串中搜索字符,如下面的代码所示:

string[] names = [ "Cassian", "Luthen", "Mon Mothma",
  "Dedra", "Syril", "Kino" ];
// .NET 9 and later.
SearchValues<string> namesSearchValues = SearchValues.Create(
  names, StringComparison.OrdinalIgnoreCase);
ReadOnlySpan<char> sentence = "In Andor, Diego Luna returns as the titular character, Cassian Andor, to whom audiences were first introduced in Rogue One.";
WriteLine($"names: {string.Join(' ', names)}");
WriteLine($"sentence: {sentence}");
WriteLine($"sentence.IndexOfAny(vowelsSearchValues): {
  sentence.IndexOfAny(namesSearchValues)}"); 

Andor 字符列表中的第一个名字从索引位置 55 开始,如下面的输出所示:

names: Cassian Luthen Mon Mothma Dedra Syril Kino
sentence: In Andor, Diego Luna returns as the titular character, Cassian Andor, to whom audiences were first introduced in Rogue One.
sentence.IndexOfAny(vowelsSearchValues): 55 

使用正则表达式进行模式匹配

正则表达式对于验证用户输入非常有用。它们非常强大,也可能变得非常复杂。几乎所有的编程语言都支持正则表达式,并使用一组常见的特殊字符来定义它们。

让我们尝试一些示例正则表达式:

  1. 使用你喜欢的代码编辑器添加一个名为WorkingWithRegularExpressions的新控制台应用程序/console项目到Chapter08解决方案中。

  2. Program.cs中,删除现有的语句,然后导入以下命名空间:

    using System.Text.RegularExpressions; // To use Regex. 
    

检查作为文本输入的数字

我们将首先实现验证数字输入的常见示例:

  1. Program.cs中,添加语句提示用户输入他们的年龄,然后使用查找数字字符的正则表达式来检查其有效性,如下所示:

    Write("Enter your age: ");
    string input = ReadLine()!; // Null-forgiving operator.
    Regex ageChecker = new(@"\d");
    WriteLine(ageChecker.IsMatch(input) ? "Thank you!" :
      $"This is not a valid age: {input}"); 
    

注意以下关于代码的说明:

  • @字符关闭了在string中使用转义字符的能力。转义字符以反斜杠为前缀。例如,\t表示制表符,\n表示换行符。在编写正则表达式时,我们需要禁用此功能。用电视剧《白宫风云》的话来说,“让反斜杠回归反斜杠。”

  • 一旦使用@禁用转义字符,它们就可以被正则表达式解释。例如,\d表示数字。你将在本主题的后面学习到更多以反斜杠为前缀的正则表达式符号。

  1. 运行代码,输入一个整数,例如34作为年龄,并查看以下输出结果:

    Enter your age: 34
    Thank you! 
    
  2. 再次运行代码,输入carrots,并查看以下输出结果:

    Enter your age: carrots
    This is not a valid age: carrots 
    
  3. 再次运行代码,输入bob30smith,并查看以下输出结果:

    Enter your age: bob30smith
    Thank you! 
    

我们使用的正则表达式是\d,它表示一个数字。然而,它并没有指定在这个数字前后可以输入什么。这个正则表达式可以用英语描述为:“输入任何你想要的字符,只要至少输入一个数字字符。”

在正则表达式中,你使用插入符^符号来指示输入的开始,使用美元符$来指示输入的结束。让我们使用这些符号来表示我们期望输入的开始和结束之间除了一个数字之外没有其他任何内容。

  1. 添加一个^和一个$将正则表达式更改为^\d$,如下所示(高亮显示):

    Regex ageChecker = new(@"**^**\d**$**"); 
    
  2. 再次运行代码并注意它拒绝除单个数字之外的所有输入。

  3. \d表达式后添加一个+来修改其含义为“一个或多个数字”,如下所示(高亮显示):

    Regex ageChecker = new(@"^\d**+**$"); 
    
  4. 再次运行代码并注意正则表达式只允许零个或多个任意长度的整数。

正则表达式性能改进

用于处理正则表达式的.NET 类型在整个.NET 平台以及许多使用它构建的应用程序中都被使用。因此,它们对性能有重大影响。然而,直到.NET 5,它们都没有得到微软太多的优化关注。

在 .NET 5 及更高版本中,System.Text.RegularExpressions 命名空间中的类型已重写实现以挤出最大性能。使用 IsMatch 等方法进行的常见正则表达式基准测试现在快五倍。最好的是,你不需要更改代码就能获得这些好处!

在 .NET 7 及更高版本中,Regex 类的 IsMatch 方法现在有一个针对 ReadOnlySpan<char> 的重载,作为其输入,这提供了更好的性能。

理解正则表达式的语法

你可以在正则表达式中使用的某些常见符号在 表 8.6 中显示:

符号含义符号含义
^输入的开始$输入的结束
\d单个数字\D单个 数字
\s空白字符\S空白字符
\w单词字符\W单词字符
[A-Za-z0-9]字符范围\^^ (上箭头) 字符
[aeiou]字符集合[^aeiou]不在字符集合中
.任意单个字符\.. (点) 字符

表 8.6:常见的正则表达式符号

此外,一些影响正则表达式中先前符号的常见正则表达式量词在 表 8.7 中显示:

符号含义符号含义
+一个或多个?一个或没有
{3}精确三个{3,5}三个到五个
{3,}至少三个{,3}最多三个

表 8.7:常见的正则表达式量词

正则表达式示例

一些正则表达式的示例及其含义的描述在 表 8.8 中显示:

表达式含义
\d输入中某处的单个数字
a输入中某处的字符a
Bob输入中某处的单词Bob
^Bob输入开头的单词“Bob”
Bob$输入末尾的单词“Bob”
^\d{2}$精确两个数字
^[0-9]{2}$精确两个数字
^[A-Z]{4,}$仅 ASCII 字符集中至少有四个大写英文字母
^[A-Za-z]{4,}$仅 ASCII 字符集中至少有四个大写或小写英文字母
^[A-Z]{2}\d{3}$仅 ASCII 字符集中的两个大写英文字母和三个数字
^[A-Za-z\u00c0-\u017e]+$至少有一个 ASCII 字符集中的大写或小写英文字母或 Unicode 字符集中的欧洲字母,如下所示:ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝ``Þßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿıŒœŠšŸŽž
^d.g$字母 d,然后是任意字符,然后是字母 g,因此它可以匹配 digdogdg 之间的任意单个字符
^d\.g$字母 d,然后是一个点 .,然后是字母 g,因此它只会匹配 d.g

表 8.8:带有其含义描述的正则表达式示例

良好实践:使用正则表达式验证用户输入。相同的正则表达式可以在其他语言中重用,例如 JavaScript 和 Python。

可以在以下链接找到一个方便的网站来构建和测试正则表达式:regex101.com/

分割复杂的以逗号分隔的字符串

在本章的早期部分,你学习了如何分割一个简单的以逗号分隔的字符串变量。但关于以下电影标题的例子又该如何处理呢?

"Monsters, Inc.","I, Tonya","Lock, Stock and Two Smoking Barrels" 

string值在每个电影标题周围使用双引号。我们可以使用这些引号来识别我们是否需要在逗号(或不是)上分割。Split方法不够强大,因此我们可以使用正则表达式。

良好实践:你可以在以下链接中阅读关于这个任务的 Stack Overflow 文章的更完整解释:stackoverflow.com/questions/18144431/regex-to-split-a-csv

为了在string值中包含双引号,我们在它们前面加上反斜杠,或者我们可以在 C# 11 或更高版本中使用原始字符串字面量功能。

  1. 添加语句以存储一个复杂的以逗号分隔的string变量,然后使用Split方法以愚蠢的方式分割它,如下面的代码所示:

    // C# 1 to 10: Use escaped double-quote characters \"
    // string films = "\"Monsters, Inc.\",\"I, Tonya\",\"Lock, Stock and Two Smoking Barrels\"";
    // C# 11 or later: Use """ to start and end a raw string literal
    string films = """
    "Monsters, Inc.","I, Tonya","Lock, Stock and Two Smoking Barrels"
    """;
    WriteLine($"Films to split: {films}");
    string[] filmsDumb = films.Split(',');
    WriteLine("Splitting with string.Split method:");
    foreach (string film in filmsDumb)
    {
      WriteLine($"  {film}");
    } 
    
  2. 添加语句以定义一个正则表达式来智能地分割并写入电影标题,如下面的代码所示:

    Regex csv = new(
      "(?:^|,)(?=[^\"]|(\")?)\"?((?(1)[^\"]*|[^,\"]*))\"?(?=,|$)");
    MatchCollection filmsSmart = csv.Matches(films);
    WriteLine("Splitting with regular expression:");
    foreach (Match film in filmsSmart)
    {
      WriteLine($"  {film.Groups[2].Value}");
    } 
    

在后面的章节中,你将看到如何获取一个源生成器来自动生成用于正则表达式的 XML 注释,以解释其工作原理。这对于你可能从网站上复制过来的正则表达式来说非常有用。

  1. 运行代码并查看结果,如下面的输出所示:

    Splitting with string.Split method:
      "Monsters
       Inc."
      "I
       Tonya"
      "Lock
       Stock and Two Smoking Barrels"
    Splitting with regular expression:
      Monsters, Inc.
      I, Tonya
      Lock, Stock and Two Smoking Barrels 
    

激活正则表达式语法着色

如果你使用 Visual Studio 作为代码编辑器,那么你可能已经注意到,当将string值传递给Regex构造函数时,你会看到颜色语法高亮显示,如图 8.1所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/csp13-dn9-mdn-xplat-dev-fund/img/B22322_08_01.png

图 8.1:使用 Regex 构造函数时正则表达式颜色语法高亮显示

这将是提醒打印书籍读者的大好时机,他们只能看到前面的灰度图,他们可以在以下链接中看到所有图的全彩 PDF:packt.link/gbp/9781837635870

为什么这个string在大多数string值中不会进行语法着色,而它会呢?让我们找出答案:

  1. 右键点击new构造函数,选择转到实现,注意名为patternstring参数被一个名为StringSyntax的属性装饰,该属性传递了具有Regex常量值的string,如下面的代码所示,高亮显示:

    public Regex(**[StringSyntax(StringSyntaxAttribute.Regex****)]** **string** **pattern**) :
      this(pattern, culture: null)
    {
    } 
    
  2. 右键点击StringSyntax属性,选择转到实现,注意有 12 种已识别的string语法格式可供选择,以及Regex,如下面的部分代码所示:

    [AttributeUsage(AttributeTargets.Property | AttributeTargets.Field | AttributeTargets.Parameter, AllowMultiple = false, Inherited = false)]
    public sealed class StringSyntaxAttribute : Attribute
    {
      public const string CompositeFormat = "CompositeFormat";
      public const string DateOnlyFormat = "DateOnlyFormat";
      public const string DateTimeFormat = "DateTimeFormat";
      public const string EnumFormat = "EnumFormat";
      public const string GuidFormat = "GuidFormat";
      public const string Json = "Json";
      public const string NumericFormat = "NumericFormat";
      public const string Regex = "Regex";
      public const string TimeOnlyFormat = "TimeOnlyFormat";
      public const string TimeSpanFormat = "TimeSpanFormat";
      public const string Uri = "Uri";
      public const string Xml = "Xml";} 
    
  3. WorkingWithRegularExpressions 项目中,添加一个名为 Program.Strings.cs 的新类文件,删除任何现有语句,然后在部分 Program 类中定义一些 string 常量,如下所示:

    partial class Program
    {
      private const string DigitsOnlyText = @"^\d+$";
      private const string CommaSeparatorText =
        "(?:^|,)(?=[^\"]|(\")?)\"?((?(1)[^\"]*|[^,\"]*))\"?(?=,|$)";
    } 
    

注意,这两个 string 常量还没有任何颜色语法高亮。

  1. Program.cs 文件中,将字面量 string 替换为仅包含数字的正则表达式的 string 常量,如下所示,代码中已高亮显示:

    Regex ageChecker = new(**DigitsOnlyText**); 
    
  2. Program.cs 文件中,将字面量 string 替换为逗号分隔的正则表达式的 string 常量,如下所示,代码中已高亮显示:

    Regex csv = new(**CommaSeparatorText**); 
    
  3. 运行 WorkingWithRegularExpressions 项目并确认正则表达式的行为与之前相同。

  4. Program.Strings.cs 文件中,导入 [StringSyntax] 属性的命名空间,然后将其装饰在两个 string 常量上,如下所示,代码中已高亮显示:

    **using** **System.Diagnostics.CodeAnalysis;** **// To use [StringSyntax].**
    partial class Program
    {
     **[****StringSyntax(StringSyntaxAttribute.Regex)****]**
      private const string DigitsOnlyText = @"^\d+$";
     **[****StringSyntax(StringSyntaxAttribute.Regex)****]**
      private const string CommaSeparatorText =
        "(?:^|,)(?=[^\"]|(\")?)\"?((?(1)[^\"]*|[^,\"]*))\"?(?=,|$)";
    } 
    
  5. Program.Strings.cs 文件中,添加另一个用于格式化日期的 string 常量,如下所示:

    [StringSyntax(StringSyntaxAttribute.DateTimeFormat)]
    private const string FullDateTime = ""; 
    
  6. 在空字符串中单击,输入字母 d,并注意 IntelliSense 说明,如图 8.2 所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/csp13-dn9-mdn-xplat-dev-fund/img/B22322_08_02.png

图 8.2:由于 StringSyntax 属性激活的 IntelliSense

  1. 完成输入日期格式,并随着您输入,注意 IntelliSense 值:dddd, d MMMM yyyy

  2. 在内部,在 DigitsOnlyText 字面量字符串的末尾输入 \,并注意 IntelliSense 帮助您编写有效的正则表达式,如图 8.3 所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/csp13-dn9-mdn-xplat-dev-fund/img/B22322_08_03.png

图 8.3:编写正则表达式的 IntelliSense

  1. 删除您输入的 \ 以触发 IntelliSense。

[StringSyntax] 属性是在 .NET 7 中引入的功能。它是否被识别取决于您的代码编辑器。.NET BCL 现在有超过 350 个参数、属性和字段被这个属性装饰。

使用源生成器提高正则表达式性能

当您将字符串字面量或字符串常量传递给 Regex 构造函数时,该类会解析字符串并将其转换为内部树结构,以优化方式表示表达式,以便正则表达式解释器可以高效地执行。

您也可以通过指定 RegexOptions 来编译正则表达式,如下所示:

Regex ageChecker = new(DigitsOnlyText, RegexOptions.Compiled); 

不幸的是,编译会有负面影响,会减慢正则表达式初始创建的速度。在创建出将被解释器执行的树结构之后,编译器必须将树转换为 IL 代码,然后该 IL 代码需要被 JIT 编译成原生代码。如果你只运行正则表达式几次,那么编译它并不值得,这也是为什么它不是默认行为的原因。如果你运行正则表达式超过几次,例如,因为它将被用来验证网站每个传入的 HTTP 请求的 URL,那么编译它是有意义的。但即使如此,你也应该只在必须使用.NET 6 或更早版本的情况下使用编译。

.NET 7 引入了一个用于正则表达式的源生成器,它可以识别你是否使用[GeneratedRegex]属性装饰了一个返回Regex的部分方法。它生成一个实现该方法的实现,该实现实现了正则表达式的逻辑。.NET 9 允许你使用部分属性,它具有更自然的语法。

让我们看看实际效果:

  1. WorkingWithRegularExpressions项目中,添加一个名为Program.Regexs.cs的新类文件,并修改其内容以定义一些partial属性,如下面的代码所示:

    using System.Text.RegularExpressions; // To use [GeneratedRegex].
    partial class Program
    {
      [GeneratedRegex(DigitsOnlyText, RegexOptions.IgnoreCase)]
      private static partial Regex DigitsOnly { get; }
      [GeneratedRegex(CommaSeparatorText, RegexOptions.IgnoreCase)]
      private static partial Regex CommaSeparator { get; }
    } 
    
  2. Program.cs中,将new构造函数替换为调用返回仅数字的正则表达式的partial方法,如下面高亮显示的代码所示:

    Regex ageChecker = **DigitsOnly**; 
    
  3. Program.cs中,将新的构造函数替换为调用返回逗号分隔正则表达式的partial方法,如下面高亮显示的代码所示:

    Regex csv = **CommaSeparator**; 
    
  4. 将鼠标指针悬停在partial方法上,注意工具提示描述了正则表达式的行为,如图图 8.4所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/csp13-dn9-mdn-xplat-dev-fund/img/B22322_08_04.png

图 8.4:部分方法的工具提示显示了正则表达式的描述

  1. 右键点击DigitsOnly partial方法,选择转到定义,并注意你可以查看自动生成的部分方法的实现,如图图 8.5所示:

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/csp13-dn9-mdn-xplat-dev-fund/img/B22322_08_05.png

图 8.5:正则表达式的自动生成源代码

  1. 运行项目并确认功能与之前相同。

你可以在以下链接中了解更多关于.NET 7 对正则表达式改进的信息:devblogs.microsoft.com/dotnet/regular-expression-improvements-in-dotnet-7

在集合中存储多个对象

另一种最常见的数据类型是集合。如果你需要在一个变量中存储多个值,那么你可以使用集合。

集合是在内存中可以以不同方式管理多个项的数据结构,尽管所有集合都有一些共享的功能。

在.NET 中用于处理集合的最常见类型如表表 8.9所示:

命名空间示例类型描述
System .CollectionsIEnumerable,IEnumerable<T>集合使用的接口和基类。
System .Collections .GenericList<T>,Dictionary<T>,Queue<T>,Stack<T>在 C# 2.0 和 .NET Framework 2.0 中引入。这些集合允许你使用泛型类型参数指定你想要存储的类型(这更安全、更快、更高效)。
System .Collections .ConcurrentBlockingCollection,ConcurrentDictionary,ConcurrentQueue这些集合在多线程场景中是安全的。
System .Collections .ImmutableImmutableArray,ImmutableDictionary,ImmutableList,ImmutableQueue设计用于原始集合的内容永远不会更改的场景,尽管它们可以创建作为新实例的修改后的集合。

表 8.9:常见的 .NET 集合类型

所有集合的共同特性

所有集合都实现了 ICollection 接口;这意味着它们必须有一个 Count 属性来告诉你它们中有多少对象,以及三个其他成员,如下面的代码所示:

namespace System.Collections;
public interface ICollection : IEnumerable
{
  int Count { get; }
  bool IsSynchronized { get; }
  object SyncRoot { get; }
  void CopyTo(Array array, int index);
} 

例如,如果我们有一个名为 passengers 的集合,我们可以这样做:

int howMany = passengers.Count; 

如你可能推测的那样,CopyTo 将集合复制到数组中。IsSynchronizedSyncRoot 用于多线程场景,因此我在这本书中没有涵盖它们。

所有集合都实现了 IEnumerable 接口,这意味着它们可以使用 foreach 语句进行迭代。它们必须有一个 GetEnumerator 方法,该方法返回一个实现 IEnumerator 的对象;这意味着返回的 object 必须有 MoveNextReset 方法来遍历集合,以及一个包含集合中当前项目的 Current 属性,如下面的代码所示:

namespace System.Collections;
public interface IEnumerable
{
  IEnumerator GetEnumerator();
}
public interface IEnumerator
{
  object Current { get; }
  bool MoveNext();
  void Reset();
} 

例如,要对 passengers 集合中的每个对象执行操作,我们可以编写以下代码:

foreach (Passenger p in passengers)
{
  // Perform an action on each passenger.
} 

除了基于 object 的集合接口之外,还有一个泛型集合接口,其中泛型类型定义了集合中存储的类型。它具有额外的成员,如 IsReadOnlyAddClearContainsRemove,如下面的代码所示:

namespace System.Collections.Generic;
public interface ICollection<T> : IEnumerable<T>, IEnumerable
{
  int Count { get; }
  bool IsReadOnly { get; }
  void Add(T item);
  void Clear();
  bool Contains(T item);
  void CopyTo(T[] array, int index);
  bool Remove(T item);
} 

与列表一起工作

列表,即实现 IList<T> 的类型,是有序集合,有一个 int 索引来显示项目在列表中的位置,如下面的代码所示:

namespace System.Collections.Generic;
[DefaultMember("Item")] // aka "this" indexer.
public interface IList<T> : ICollection<T>, IEnumerable<T>, IEnumerable
{
  T this[int index] { get; set; }
  int IndexOf(T item);
  void Insert(int index, T item);
  void RemoveAt(int index);
} 

[DefaultMember] 属性允许你在未指定成员名称时指定哪个成员被默认访问。要将 IndexOf 设置为默认成员,你可以使用 [DefaultMember("IndexOf")]。要指定索引器,你使用 [DefaultMember("Item")]

IList<T>ICollection<T> 派生,因此它有一个 Count 属性,一个将项目添加到集合末尾的 Add 方法,以及一个将项目插入到列表中指定位置的 Insert 方法,还有一个从指定位置删除项目的 RemoveAt 方法。

当你想要手动控制集合中项目的顺序时,列表是一个不错的选择。列表中的每个项目都有一个唯一的索引(或位置),它是自动分配的。项目可以是T定义的任何类型,并且项目可以重复。索引是int类型,从0开始,因此列表中的第一个项目位于索引0,如表 8.10所示:

索引项目
0伦敦
1巴黎
2伦敦
3悉尼

表 8.10:具有索引的列表中的城市

如果在伦敦和悉尼之间插入一个新的项目(例如,圣地亚哥),那么悉尼的索引将自动增加。因此,你必须意识到在插入或删除项目后,项目的索引可能会发生变化,如表 8.11所示:

索引项目
0伦敦
1巴黎
2伦敦
3圣地亚哥
4悉尼

表 8.11:插入项目后的城市列表

良好实践:一些开发者可能会养成在应该使用数组时使用List<T>和其他集合的坏习惯。如果数据在实例化后不会改变大小,请使用数组而不是集合。在你添加和删除项目时,也应该最初使用列表,但一旦你完成对项目的操作,就将其转换为数组。

让我们探索列表:

  1. 使用你喜欢的代码编辑器将一个新的控制台应用程序/ console 项目命名为WorkingWithCollections添加到Chapter08解决方案中。

  2. 添加一个名为Program.Helpers.cs的新类文件。

  3. Program.Helpers.cs中,定义一个部分Program类,其中包含一个泛型方法,用于输出带有标题的T值集合,如下面的代码所示:

    partial class Program
    {
      private static void OutputCollection<T>(
        string title, IEnumerable<T> collection)
      {
        WriteLine($"{title}:");
        foreach (T item in collection)
        {
          WriteLine($"  {item}");
        }
      }
    } 
    
  4. Program.cs中,删除现有的语句,然后添加一些语句来展示一些定义和操作列表的常见方法,如下面的代码所示:

    // Simple syntax for creating a list and adding three items.
    List<string> cities = new();
    cities.Add("London");
    cities.Add("Paris");
    cities.Add("Milan");
    /* Alternative syntax that is converted by the compiler into
       the three Add method calls above.
    List<string> cities = new()
      { "London", "Paris", "Milan" }; */
    /* Alternative syntax that passes an array
       of string values to AddRange method.
    List<string> cities = new();
    cities.AddRange(new[] { "London", "Paris", "Milan" }); */
    OutputCollection("Initial list", cities);
    WriteLine($"The first city is {cities[0]}.");
    WriteLine($"The last city is {cities[cities.Count - 1]}.");
    WriteLine($"Are all cities longer than four characters? {
      cities.TrueForAll(city => city.Length > 4)}.");
    WriteLine($"Do all cities contain the character 'e'? {
      cities.TrueForAll(city => city.Contains('e'))}.");
    cities.Insert(0, "Sydney");
    OutputCollection("After inserting Sydney at index 0", cities);
    cities.RemoveAt(1);
    cities.Remove("Milan");
    OutputCollection("After removing two cities", cities); 
    
  5. 运行代码并查看结果,如下面的输出所示:

    Initial list:
      London
      Paris
      Milan
    The first city is London.
    The last city is Milan.
    Are all cities longer than four characters? True.
    Do all cities contain the character 'e'? False.
    After inserting Sydney at index 0:
      Sydney
      London
      Paris
      Milan
    After removing two cities:
      Sydney
      Paris 
    

良好实践:LINQ 有名为All()Count()的扩展方法,分别像List<T>类的TrueForAll()方法和Count属性一样工作。通常,使用类提供的方法而不是更通用的 LINQ 方法可以获得更好的性能。

使用字典

当每个(或对象)都有一个唯一的子值(或一个虚构的值)可以用作来快速在集合中查找值时,字典是一个不错的选择。键必须是唯一的。例如,如果你正在存储人员列表,你可以选择使用政府颁发的身份证号码作为键。在其他语言如 Python 和 Java 中,字典被称为哈希表

将键想象成现实世界字典中的索引条目。它允许你快速找到单词的定义,因为单词(换句话说,键)是按顺序排列的;如果我们知道我们正在寻找海牛的定义,我们将跳到字典的中间开始查找,因为字母m在字母表的中间。

在编程中,字典在查找内容时同样智能。它们必须实现IDictionary<TKey, TValue>接口,如下面的代码所示:

namespace System.Collections.Generic;
[DefaultMember("Item")] // aka "this" indexer.
public interface IDictionary<TKey, TValue>
  : ICollection<KeyValuePair<TKey, TValue>>,
    IEnumerable<KeyValuePair<TKey, TValue>>, IEnumerable
{
  TValue this[TKey key] { get; set; }
  ICollection<TKey> Keys { get; }
  ICollection<TValue> Values { get; }
  void Add(TKey key, TValue value);
  bool ContainsKey(TKey key);
  bool Remove(TKey key);
  bool TryGetValue(TKey key, [MaybeNullWhen(false)] out TValue value);
} 

字典中的项目是struct类型的实例,也称为值类型KeyValuePair<TKey, TValue>,其中TKey是键的类型,TValue是值的类型,如下面的代码所示:

namespace System.Collections.Generic;
public readonly struct KeyValuePair<TKey, TValue>
{
  public KeyValuePair(TKey key, TValue value);
  public TKey Key { get; }
  public TValue Value { get; }
  [EditorBrowsable(EditorBrowsableState.Never)]
  public void Deconstruct(out TKey key, out TValue value);
  public override string ToString();
} 

一个Dictionary<string, Person>示例使用string作为键,Person实例作为值。Dictionary<string, string>使用string作为两个值,如下表 8.12 所示:

BSABob Smith
MWMax Williams
BSBBob Smith
AMAmir Mohammed

表 8.12:具有键和值的字典

让我们探索字典:

  1. Program.cs的顶部,为Dictionary<TKey, TValue>类定义一个别名,其中TKeyTValue都是string,如下面的代码所示:

    // Define an alias for a dictionary with string key and string value.
    using StringDictionary = System.Collections.Generic.Dictionary<string, string>; 
    
  2. Program.cs中添加一些语句来展示一些与字典一起工作的常见方式,例如查找单词定义,如下面的代码所示:

    // Declare a dictionary without the alias.
    // Dictionary<string, string> keywords = new();
    // Use the alias to declare the dictionary.
    StringDictionary keywords = new();
    // Add using named parameters.
    keywords.Add(key: "int", value: "32-bit integer data type");
    // Add using positional parameters.
    keywords.Add("long", "64-bit integer data type");
    keywords.Add("float", "Single precision floating point number");
    /* Alternative syntax; compiler converts this to calls to Add method.
    Dictionary<string, string> keywords = new()
    {
      { "int", "32-bit integer data type" },
      { "long", "64-bit integer data type" },
      { "float", "Single precision floating point number" },
    }; */
    /* Alternative syntax; compiler converts this to calls to Add method.
    Dictionary<string, string> keywords = new()
    {
      ["int"] = "32-bit integer data type",
      ["long"] = "64-bit integer data type",
      ["float"] = "Single precision floating point number",
    }; */
    OutputCollection("Dictionary keys", keywords.Keys);
    OutputCollection("Dictionary values", keywords.Values);
    WriteLine("Keywords and their definitions:");
    foreach (KeyValuePair<string, string> item in keywords)
    {
      WriteLine($"  {item.Key}: {item.Value}");
    }
    // Look up a value using a key.
    string key = "long";
    WriteLine($"The definition of {key} is {keywords[key]}."); 
    

在字典中添加到第三个项目的尾随逗号是可选的,编译器不会对此提出异议。这样做很方便,这样你就可以在不删除和添加逗号的情况下更改三个项目的顺序。

  1. 运行代码并查看结果,如下面的输出所示:

    Dictionary keys:
      int
      long
      float
    Dictionary values:
      32-bit integer data type
      64-bit integer data type
      Single precision floating point number
    Keywords and their definitions:
      int: 32-bit integer data type
      long: 64-bit integer data type
      float: Single precision floating point number
    The definition of long is 64-bit integer data type 
    

在第十一章中,你将学习如何使用 LINQ 方法,如ToDictionaryToLookup,从现有数据源创建字典和查找,例如数据库中的表。这比手动向字典中添加项目更为常见,如本节所示。

集合、栈和队列

集合是在你想要在两个集合之间执行集合操作时的一个好选择。例如,你可能有两个城市名称的集合,并且想知道哪些名称同时出现在两个集合中(称为集合的交集)。集合中的元素必须是唯一的。

常见集合方法如表 8.13 所示:

方法描述
Add如果项目尚未存在于集合中,则将其添加。如果项目已添加,则返回true,如果项目已在集合中,则返回false
ExceptWith这将从参数传递的集合中删除项目。
IntersectWith这将从参数传递的集合和集合中删除不在集合中的项目。
IsProperSubsetOf, IsProperSupersetOf, IsSubsetOf, IsSupersetOf子集是一个包含其他集合中所有元素的集合。真子集是一个包含其他集合中所有元素但至少有一个元素不在该集合中的集合。超集是一个包含其他集合中所有元素的集合。真超集是一个包含其他集合中所有元素并且至少有一个元素不在其他集合中的集合。
Overlaps该集合与另一个集合至少共享一个共同的项目。
SetEquals该集合与另一个集合包含完全相同的项。
SymmetricExceptWith这将从集合中移除作为参数传递的集合中不存在的项,并添加任何缺失的项。
UnionWith这会将作为参数传递给集合的任何不在集合中的项添加到集合中。

表 8.13:集合方法

让我们探索集合的示例代码:

  1. Program.cs中,添加一些语句来向集合中添加项,如下面的代码所示:

    HashSet<string> names = new();
    foreach (string name in
      new[] { "Adam", "Barry", "Charlie", "Barry" })
    {
      bool added = names.Add(name);
      WriteLine($"{name} was added: {added}.");
    }
    WriteLine($"names set: {string.Join(',', names)}."); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    Adam was added: True.
    Barry was added: True.
    Charlie was added: True.
    Barry was added: False.
    names set: Adam,Barry,Charlie. 
    

你将在第十一章中看到更多的集合操作,查询和操作数据使用 LINQ

是当你想要实现后进先出LIFO)行为时的一个不错的选择。使用栈,你只能直接访问或移除栈顶的一个项,尽管你可以枚举来读取整个栈中的项。例如,你不能直接访问栈中的第二个项。

例如,文字处理器使用栈来记住你最近执行的操作序列,然后,当你按下Ctrl + Z时,它将撤销栈中的最后一个操作,然后是倒数第二个操作,依此类推。

队列是当你想要实现先进先出FIFO)行为时的一个不错的选择。使用队列,你只能直接访问或移除队列前端的项,尽管你可以枚举来读取整个队列中的项。例如,你不能直接访问队列中的第二个项。

例如,后台进程使用队列按到达顺序处理工作项,就像人们在邮局排队一样。

.NET 6 引入了PriorityQueue,其中队列中的每个项都有一个优先级值,以及其在队列中的位置。

让我们探索队列的示例代码:

  1. Program.cs中,添加一些语句来展示一些与队列一起工作的常见方式,例如处理咖啡店中的排队顾客,如下面的代码所示:

    Queue<string> coffee = new();
    coffee.Enqueue("Damir"); // Front of the queue.
    coffee.Enqueue("Andrea");
    coffee.Enqueue("Ronald");
    coffee.Enqueue("Amin");
    coffee.Enqueue("Irina"); // Back of the queue.
    OutputCollection("Initial queue from front to back", coffee);
    // Server handles next person in queue.
    string served = coffee.Dequeue();
    WriteLine($"Served: {served}.");
    // Server handles next person in queue.
    served = coffee.Dequeue();
    WriteLine($"Served: {served}.");
    OutputCollection("Current queue from front to back", coffee);
    WriteLine($"{coffee.Peek()} is next in line.");
    OutputCollection("Current queue from front to back", coffee); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    Initial queue from front to back:
      Damir
      Andrea
      Ronald
      Amin
      Irina
    Served: Damir.
    Served: Andrea.
    Current queue from front to back:
      Ronald
      Amin
      Irina
    Ronald is next in line.
    Current queue from front to back:
      Ronald
      Amin
      Irina 
    
  3. Program.Helpers.cs中,在部分Program类中,添加一个名为OutputPQ的静态方法,如下面的代码所示:

    private static void OutputPQ<TElement, TPriority>(string title,
      IEnumerable<(TElement Element, TPriority Priority)> collection)
    {
      WriteLine($"{title}:");
      foreach ((TElement, TPriority) item in collection)
      {
        WriteLine($"  {item.Item1}: {item.Item2}");
      }
    } 
    

注意,OutputPQ方法是一个泛型方法。你可以指定作为collection传入的元组中使用的两个类型。

  1. Program.cs中,添加一些语句来展示一些与优先队列一起工作的常见方式,如下面的代码所示:

    PriorityQueue<string, int> vaccine = new();
    // Add some people.
    // 1 = High priority people in their 70s or poor health.
    // 2 = Medium priority e.g. middle-aged.
    // 3 = Low priority e.g. teens and twenties.
    vaccine.Enqueue("Pamela", 1);
    vaccine.Enqueue("Rebecca", 3);
    vaccine.Enqueue("Juliet", 2);
    vaccine.Enqueue("Ian", 1);
    OutputPQ("Current queue for vaccination", vaccine.UnorderedItems);
    WriteLine($"{vaccine.Dequeue()} has been vaccinated.");
    WriteLine($"{vaccine.Dequeue()} has been vaccinated.");
    OutputPQ("Current queue for vaccination", vaccine.UnorderedItems);
    WriteLine($"{vaccine.Dequeue()} has been vaccinated.");
    WriteLine("Adding Mark to queue with priority 2.");
    vaccine.Enqueue("Mark", 2);
    WriteLine($"{vaccine.Peek()} will be next to be vaccinated.");
    OutputPQ("Current queue for vaccination", vaccine.UnorderedItems); 
    
  2. 运行代码并查看结果,如下面的输出所示:

    Current queue for vaccination:
      Pamela: 1
      Rebecca: 3
      Juliet: 2
      Ian: 1
    Pamela has been vaccinated.
    Ian has been vaccinated.
    Current queue for vaccination:
      Juliet: 2
      Rebecca: 3
    Juliet has been vaccinated.
    Adding Mark to queue with priority 2
    Mark will be next to be vaccinated.
    Current queue for vaccination:
      Mark: 2
      Rebecca: 3 
    

.NET 9 引入了一个Remove方法到PriorityQueue。该方法在堆中的每个元素上执行线性时间扫描,移除与指定元素参数匹配的第一个出现。如果有重复条目,该方法将以非确定性的方式移除其中一个,而不考虑任何优先级。

集合添加和移除方法

每个集合都有不同的方法来“添加”和“移除”项目,如表 8.14 所示:

集合“添加”方法“移除”方法描述
ListAdd, InsertRemove, RemoveAt列表是有序的,因此项目具有整数索引位置。Add将在列表末尾添加新项目。Insert将在指定的索引位置添加新项目。
DictionaryAddRemove字典是无序的,因此项目没有整数索引位置。您可以通过调用ContainsKey方法来检查是否使用了键。
StackPushPop栈始终使用Push方法在栈顶添加新项目。第一个项目位于栈底。始终使用Pop方法从栈顶移除项目。调用Peek方法可以查看此值而不移除它。栈是 LIFO(后进先出)。
QueueEnqueueDequeue队列始终使用Enqueue方法在队列末尾添加新项目。第一个项目位于队列前端。始终使用Dequeue方法从队列前端移除项目。调用Peek方法可以查看此值而不移除它。队列是 FIFO(先进先出)。

表 8.14:集合“添加”和“移除”方法

排序集合

List<T>类可以通过手动调用其Sort方法进行排序(但请记住,每个项目的索引将发生变化)。手动对字符串值或其他内置类型的列表进行排序不需要您额外努力,但如果您创建了自己的类型集合,那么该类型必须实现名为IComparable的接口。您在第六章实现接口和继承类中学到了如何做到这一点。

Stack<T>Queue<T>集合无法排序,因为您通常不希望有这种功能;例如,您可能永远不会对酒店入住的客人队列进行排序。但有时,您可能想要对字典或集合进行排序。

有时,拥有一个自动排序的集合会很有用,即一个在添加和移除项目时保持排序顺序的集合。

可供选择的自动排序集合有多种。这些排序集合之间的差异通常很微妙,但可能会影响您应用程序的内存需求和性能,因此值得花精力选择最适合您需求的选项。

自从.NET 的早期版本以来,OrderedDictionary类型就支持这一点,但它不是泛型的,键和值的数据类型为System.Object。.NET 9 引入了备受期待的OrderedDictionary<TKey, TValue>集合,为这些场景提供了一个高效且泛型的解决方案。

一些其他常见的自动排序集合如表 8.15 所示:

集合描述
SortedDictionary<TKey, TValue>这代表了一个按键排序的键值对集合。内部,它维护一个二叉树来存储项目。
SortedList<TKey, TValue>这代表了一个按键排序的键值对集合。名称具有误导性,因为这实际上不是一个列表。与 SortedDictionary<TKey, TValue> 相比,检索性能相似,它使用的内存更少,对于未排序数据,插入和删除操作较慢。如果它从排序数据中填充,则速度更快。内部,它使用二分搜索来维护一个排序数组以查找元素。
SortedSet<T>这代表了一个按顺序维护的唯一对象集合。

表 8.15:常见的自动排序集合

专用集合

对于一些特殊的情况,还有一些其他的集合。

System.Collections.BitArray 集合管理一个紧凑的位值数组,这些位值以布尔值表示,其中 true 表示位处于开启状态(值为 1),而 false 表示位处于关闭状态(值为 0)。

System.Collections.Generics.LinkedList<T> 集合表示一个双向链表,其中每个项目都有一个对其前一个和下一个项目的引用。与 List<T> 相比,在需要频繁从列表中间插入和删除项目的情况下,它们提供了更好的性能。在 LinkedList<T> 中,项目不需要在内存中进行重新排列。

只读、不可变和冻结的集合

当我们查看泛型集合接口时,我们看到它有一个名为 IsReadOnly 的属性。当我们想要将一个集合传递给一个方法但不允许它进行更改时,这很有用。

例如,我们可能定义一个如下所示的方法:

void ReadCollection<T>(ICollection<T> collection)
{
  // We can check if the collection is read-only.
  if (collection.IsReadOnly)
  {
    // Read the collection.
  }
  else
  {
    WriteLine("You have given me a collection that I could change!");
  }
} 

泛型集合,如 List<T>Dictionary<TKey, TValue>,有一个 AsReadOnly 方法来创建一个 ReadOnlyCollection<T>,该集合引用原始集合。尽管 ReadOnlyCollection<T> 必须实现 ICollection<T> 接口,因此必须有一个 Add 和一个 Remove 方法,但它抛出一个 NotImplementedException 以防止更改。

如果原始集合有项目被添加或删除,ReadOnlyCollection<T> 将看到这些更改。你可以将 ReadOnlyCollection<T> 视为一个集合的保护视图。

让我们看看我们如何确保一个集合是只读的:

  1. WorkingWithCollections 项目中,在 Program.Helpers.cs 文件中,添加一个方法,该方法应仅接受一个只读字典,其键和值的类型为 string,但这个淘气的函数试图调用 Add,如下面的代码所示:

    private static void UseDictionary(
      IDictionary<string, string> dictionary)
    {
      WriteLine($"Count before is {dictionary.Count}.");
      try
      {
        WriteLine("Adding new item with GUID values.");
        // Add method with return type of void.
        dictionary.Add(
          key: Guid.NewGuid().ToString(),
          value: Guid.NewGuid().ToString());
      }
      catch (NotSupportedException)
      {
        WriteLine("This dictionary does not support the Add method.");
      }
      WriteLine($"Count after is {dictionary.Count}.");
    } 
    

    注意参数的类型是 IDictionary<TKey, TValue>。使用接口提供了更多的灵活性,因为我们可以传递 Dictionary<TKey, TValue>ReadOnlyDictionary<TKey, TValue> 或实现该接口的任何其他东西。

  2. Program.cs 中,添加语句将 keywords 字典传递给这个淘气的函数,如下面的代码所示:

    UseDictionary(keywords); 
    
  3. 运行代码,查看结果,并注意这次,淘气的方法能够添加一个新的键值对,所以计数增加了,如下所示输出:

    Count before is 3.
    Adding new item with GUID values.
    Count after is 4. 
    
  4. Program.cs 中,注释掉 UseDictionary 语句,然后添加一个语句将转换后的字典传递给只读集合,如下所示代码:

    //UseDictionary(keywords);
    UseDictionary(keywords.AsReadOnly()); 
    
  5. 运行代码,查看结果,并注意这次,方法无法添加项目,所以计数没有变化,如下所示输出:

    Count before is 3.
    Adding new item with GUID values.
    This dictionary does not support the Add method.
    Count after is 3. 
    
  6. Program.cs 的顶部,导入 System.Collections.Immutable 命名空间,如下所示代码:

    using System.Collections.Immutable; // To use ImmutableDictionary<T, T>. 
    
  7. Program.cs 中,注释掉 AsReadOnly 语句,然后添加一个语句将转换后的关键字转换为不可变字典,如下所示代码高亮显示:

    //UseDictionary(keywords.AsReadOnly());
    UseDictionary(keywords.ToImmutableDictionary()); 
    
  8. 运行代码,查看结果,并注意这次,方法也无法添加默认值,所以计数没有变化——这与使用只读集合的行为相同,那么不可变集合有什么用呢?

如果你导入 System.Collections.Immutable 命名空间,那么任何实现 IEnumerable<T> 的集合都将获得六个扩展方法来将其转换为不可变集合,如列表、字典、集合等。

虽然不可变集合将有一个名为 Add 的方法,但它不会将项目添加到原始不可变集合中!相反,它返回一个新的包含新项目的不可变集合。原始不可变集合仍然只包含原始项目。

让我们看看一个例子:

  1. Program.cs 中,添加语句将关键字字典转换为不可变字典,然后通过随机生成 GUID 值向其中添加新的关键字定义,如下所示代码:

    ImmutableDictionary<string, string> immutableKeywords =
      keywords.ToImmutableDictionary();
    // Call the Add method with a return value.
    ImmutableDictionary<string, string> newDictionary =
      immutableKeywords.Add(
        key: Guid.NewGuid().ToString(),
        value: Guid.NewGuid().ToString());
    OutputCollection("Immutable keywords dictionary", immutableKeywords);
    OutputCollection("New keywords dictionary", newDictionary); 
    
  2. 运行代码,查看结果,并注意不可变关键字字典在调用其上的 Add 方法时不会被修改;相反,它返回一个新的字典,包含所有现有关键字加上新添加的关键字,如下所示输出:

    Immutable keywords dictionary:
      [float, Single precision floating point number]
      [long, 64-bit integer data type]
      [int, 32-bit integer data type]
    New keywords dictionary:
      [d0e099ff-995f-4463-ae7f-7b59ed3c8d1d, 3f8e4c38-c7a3-4b20-acb3-01b2e3c86e8c]
      [float, Single precision floating point number]
      [long, 64-bit integer data type]
      [int, 32-bit integer data type] 
    

新增的项目并不总是出现在字典的顶部,如上输出所示。内部,顺序是由键的哈希值定义的。这就是为什么字典有时被称为哈希表。

良好实践:为了提高性能,许多应用程序在中央缓存中存储常用对象的共享副本。为了安全地允许多个线程使用这些对象,并知道它们不会改变,你应该使它们不可变或使用并发集合类型,你可以在以下链接中了解更多信息:learn.microsoft.com/en-us/dotnet/api/system.collections.concurrent

泛型集合在设计上存在一些潜在的性能问题。

首先,由于是泛型的,字典中项的类型或用于键和值的类型对性能有很大影响,具体取决于它们是什么。由于它们可以是任何类型,.NET 团队无法优化算法。在现实生活中,stringint 类型是最常用的。如果 .NET 团队能够依赖这些类型始终被使用,那么他们可以大大提高性能。

其次,集合是动态的,这意味着可以在任何时候添加新项,也可以删除现有项。如果 .NET 团队知道集合将不再进行更改,那么可以进行更多的优化。

.NET 8 引入了一个新概念:冻结集合。嗯,我们已经有不可变集合了,那么冻结集合有什么不同呢?它们像冰淇淋一样美味吗?想法是,95% 的时间,集合被填充后就不会再改变。所以,如果我们能在创建时优化它们,那么这些优化就可以实现,这需要一些前期的时间和精力,但之后,读取集合的性能可以得到显著提高。

在 .NET 8 中,只有两种冻结集合:FrozenDictionary<TKey, TValue>FrozenSet<T>。未来版本的 .NET 可能会有更多,但这些都是最常见且能从冻结概念中受益的场景。

让我们开始:

  1. Program.cs 的顶部,导入 System.Collections.Frozen 命名空间,如下所示:

    using System.Collections.Frozen; // To use FrozenDictionary<T, T>. 
    
  2. Program.cs 的底部,添加语句将关键字字典转换为冻结字典,输出其项,然后查找 long 的定义,如下所示:

    // Creating a frozen collection has an overhead to perform the
    // sometimes complex optimizations.
    FrozenDictionary<string, string> frozenKeywords =
      keywords.ToFrozenDictionary();
    OutputCollection("Frozen keywords dictionary", frozenKeywords);
    // Lookups are faster in a frozen dictionary.
    WriteLine($"Define long: {frozenKeywords["long"]}"); 
    
  3. 运行代码并查看结果,如下所示:

    Frozen keywords dictionary:
      [int, 32-bit integer data type]
      [long, 64-bit integer data type]
      [float, Single precision floating point number]
    Define long: 64-bit integer data type 
    

Add 方法的行为取决于类型,以下列表进行了总结:

  • List<T>: 这会在现有列表的末尾添加一个新项。

  • Dictionary<TKey, TValue>: 这会在由其内部结构确定的位位置向现有字典中添加一个新项。

  • ReadOnlyCollection<T>: 这会抛出一个不支持异常。

  • ImmutableList<T>: 这会返回一个新的列表,其中包含新项。这不会影响原始列表。

  • ImmutableDictionary<TKey, TValue>: 这会返回一个新的字典,其中包含新项。这不会影响原始字典。

  • FrozenDictionary<TKey, TValue>: 这不存在。

更多信息:有关冻结集合的文档可以在以下链接找到:learn.microsoft.com/en-us/dotnet/api/system.collections.frozen.

你已经看到你可以创建只读列表和字典,但集合呢?在 .NET 9 中,现在有一个 ReadOnlySet<T> 来解决这个问题。

使用集合表达式初始化集合

C# 12 引入了一种新的初始化数组、集合和 span 变量的统一语法。

在 C# 11 及之前版本中,您必须使用以下代码声明并初始化一个int值的数组、集合或跨度:

int[] numbersArray11 = { 1, 3, 5 };
List<int> numbersList11 = new() { 1, 3, 5 };
Span<int> numbersSpan11 = stackalloc int[] { 1, 3, 5 }; 

从 C# 12 开始,您现在可以一致地使用方括号,编译器将执行正确的事,如下面的代码所示:

int[] numbersArray12 = [ 1, 3, 5 ];
List<int> numbersList12 = [ 1, 3, 5 ];
Span<int> numbersSpan12 = [ 1, 3, 5 ]; 

更多信息:您可以在以下链接中了解更多关于集合表达式的信息:learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-12.0/collection-expressions

集合表达式不适用于所有集合。例如,它们不适用于字典或多维数组。文档列出了集合表达式可以转换的类型:learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-12.0/collection-expressions#conversions

使用展开元素

展开元素..可以加在任何可枚举的表达式之前,以便在集合表达式中对其进行评估。例如,任何可以使用foreach枚举的类型,如数组或集合,都可以使用展开元素进行评估。

在集合表达式中使用展开元素..将它的参数替换为该集合中的元素。您可以将展开元素与集合表达式中的单个元素组合使用。

微软官方文档同时使用展开元素展开运算符来指代同一语言特性。我更喜欢元素,因为它在集合表达式中用于表示定义的集合内的元素。

让我们通过以下代码示例看看如何使用展开元素:

int[] row0 = [1, 2, 3];
int[] row1 = [4, 5];
int[] row2 = [6, 7, 8, 9];
// Use the spread element to combine the three arrays and an integer into a single array.
int[] combinedRows = [..row0, ..row1, ..row2, 10];
foreach (int number in combinedRows)
{
  Console.Write($"{number}, ");
} 

输出结果如下:

1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 

更多信息:您可以在以下链接中了解更多关于展开元素的信息:learn.microsoft.com/en-us/dotnet/csharp/language-reference/operators/collection-expressions#spread-element

**警告!**请注意不要混淆必须应用于可枚举表达式之前的展开元素..与用于定义Range的范围运算符..。您将在本章下一节中学习关于范围的内容,处理跨度、索引和范围。关于展开元素的设计决策的讨论可以在以下链接中找到:learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-12.0/collection-expressions#drawbacks

集合的良好实践

让我们通过回顾一些你应该遵循的良好实践来结束关于集合的这一部分。

预先调整集合大小

自从.NET 1.1 以来,像StringBuilder这样的类型就有一个名为EnsureCapacity的方法,可以将内部存储数组预先调整为string预期最终大小的数组。这提高了性能,因为它不需要在追加更多字符时反复增加数组的大小。

自从.NET Core 2.1 以来,像Dictionary<T>HashSet<T>这样的类型也拥有了EnsureCapacity

在.NET 6 及以后的版本中,像List<T>Queue<T>Stack<T>这样的集合现在也有一个EnsureCapacity方法,如下面的代码所示:

List<string> names = new();
names.EnsureCapacity(10_000);
// Load ten thousand names into the list. 

将集合传递给方法

假设你需要创建一个处理集合的方法。为了最大灵活性,你可以声明输入参数为IEnumerable<T>并使方法泛型,如下面的代码所示:

void ProcessCollection<T>(IEnumerable<T> collection)
{
  // Process the items in the collection,
  // perhaps using a foreach statement.
} 

我可以将包含任何类型(如intstringPerson或任何实现了IEnumerable<T>的其他类型)的数组、列表、队列或堆栈传递到这个方法中,并且它将处理这些项。然而,将任何集合传递到这个方法的灵活性是以性能成本为代价的。

IEnumerable<T>的性能问题之一也是它的一个优点:延迟执行,也称为懒加载。实现此接口的类型不必实现延迟执行,但许多类型确实实现了。

IEnumerable<T>最糟糕的性能问题是迭代必须在堆上分配一个对象。为了避免这种内存分配,你应该使用具体类型定义你的方法,如下面高亮显示的代码所示:

void ProcessCollection<T>(**List<T>** collection)
{
  // Process the items in the collection,
  // perhaps using a foreach statement.
} 

这将使用List<T>.Enumerator GetEnumerator()方法,它返回一个struct,而不是返回引用类型的IEnumerator<T> GetEnumerator()方法。你的代码将快两到三倍,并且需要的内存更少。与所有与性能相关的建议一样,你应该通过在实际的产品环境中运行性能测试来确认这种好处。

从成员返回集合

集合是引用类型,这意味着它们可以是null。你可能会定义返回null的方法或属性,如下面的代码所示:

public class Vehicle
{
  public List<Person>? GetPassengers()
  {
    ICollection<Person> passengers = GetFromDatabase();
    if (passengers.Count > 0)
    {
      return passengers;
    }
    else
    {
      return null;
    }
  }
  public List<Person>? Passengers
  {
    get
    {
      ICollection<Person> passengers = GetFromDatabase();
      if (passengers.Count > 0)
      {
        return passengers;
      }
      else
      {
        return null;
      }
    }
  }
} 

如果开发者调用返回集合的方法和属性而没有检查null,这可能会导致问题,如下面的代码所示:

var people = car.GetPassengers();
// Accessing people could throw a NullReferenceException!
WriteLine($"There are {people.Count} people.");
foreach (Person p in car.Passengers)
{
  // Process each person.
} 

在实现返回集合的方法和属性时,返回一个空集合或数组,而不是null,如下面的代码所示:

// Return an empty sequence instead.
return Enumerable.Empty<Person>();
// Or an empty array.
return Array,Empty<Person>();
// Or an empty collection expression.
return []; 

与 span、索引和范围一起工作

微软在.NET Core 2.1 中的一个目标是通过Span<T>类型来提高性能和资源使用。

使用 span 有效地使用内存

当操作数组时,你通常会创建现有数组的新的副本或子集,以便只处理子集。这并不高效,因为必须在内存中创建重复的对象。

如果你需要处理数组的子集,请使用 span,因为它就像原始数组的一个窗口。这在内存使用方面更加高效,并提高了性能。范围仅适用于数组,不适用于集合,因为内存必须是连续的。

在我们更详细地查看范围之前,我们需要了解一些相关对象:索引和范围。

使用索引类型识别位置

C# 8 引入了两个特性,用于使用两个索引来识别数组中项的索引位置和项的范围。

你在上一个章节中了解到,列表中的对象可以通过传递一个整数到它们的索引器来访问,如下面的代码所示:

int index = 3;
Person p = people[index]; // Fourth person in array.
char letter = name[index]; // Fourth letter in name. 

Index 值类型是识别位置的一种更正式的方式,它支持从末尾开始计数,如下面的代码所示:

// Two ways to define the same index, 3 in from the start.
Index i1 = new(value: 3); // Counts from the start
Index i2 = 3; // Using implicit int conversion operator.
// Two ways to define the same index, 5 in from the end.
Index i3 = new(value: 5, fromEnd: true);
Index i4 =; // Using the caret ^ operator. 

使用范围类型识别范围

Range 值类型使用 Index 值来指示其范围的起始和结束,使用其构造函数、C# 语法或其静态方法,如下面的代码所示:

Range r1 = new(start: new Index(3), end: new Index(7));
Range r2 = new(start: 3, end: 7); // Using implicit int conversion.
Range r3 = 3..7; // Using C# 8.0 or later syntax.
Range r4 = Range.StartAt(3); // From index 3 to last index.
Range r5 = 3..; // From index 3 to last index.
Range r6 = Range.EndAt(3); // From index 0 to index 3.
Range r7 = ..3; // From index 0 to index 3. 

已经为 string 值(内部使用 char 数组)、int 数组和范围添加了扩展方法,以便更容易地处理范围。这些扩展方法接受一个范围作为参数,并返回一个 Span<T>。这使得它们非常节省内存。

警告! 范围运算符 ..(随 C# 8 引入)和扩展元素 ..(随 C# 12 引入)看起来相同。在阅读代码时要小心区分它们。范围运算符应始终出现在整数旁边,无论是之前、之后还是两者都有。扩展元素应始终出现在可枚举的命名变量之前。

使用索引、范围和范围

让我们探索使用索引和范围来返回范围:

  1. 使用你喜欢的代码编辑器,向 Chapter08 解决方案中添加一个名为 WorkingWithRanges 的新 Console App / console 项目。

  2. Program.cs 中,删除现有的语句,然后添加语句使用 string 类型的 Substring 方法与范围来提取某人姓名的部分,如下面的代码所示:

    string name = "Samantha Jones";
    // Getting the lengths of the first and last names.
    int lengthOfFirst = name.IndexOf(' ');
    int lengthOfLast = name.Length - lengthOfFirst - 1;
    // Using Substring.
    string firstName = name.Substring(
      startIndex: 0,
      length: lengthOfFirst);
    string lastName = name.Substring(
      startIndex: name.Length - lengthOfLast,
      length: lengthOfLast);
    WriteLine($"First: {firstName}, Last: {lastName}");
    // Using spans.
    ReadOnlySpan<char> nameAsSpan = name.AsSpan();
    ReadOnlySpan<char> firstNameSpan = nameAsSpan[0..lengthOfFirst];
    ReadOnlySpan<char> lastNameSpan = nameAsSpan[^lengthOfLast..];
    WriteLine($"First: {firstNameSpan}, Last: {lastNameSpan}"); 
    
  3. 运行代码并查看结果,如下面的输出所示:

    First: Samantha, Last: Jones
    First: Samantha, Last: Jones 
    

使用范围进行高效的文本处理

在处理文本时,常见的一个问题是 string 值的副本在处理过程中经常在内存中分配。如果可能的话,重用原始值将更加高效。

范围通过创建指向字符串原始部分的指针数据结构来实现这一点。

例如,考虑一个包含整数并用加号分隔的 string 值,需要将其求和:

在.NET 9 及更高版本中,我们现在可以创建一个跨越string值的跨度,然后使用范围来有效地分割整数值并将它们传递给int.Parse方法,如下所示代码:

ReadOnlySpan<char> text = "12+23+456".AsSpan();
int sum = 0;
foreach (Range r in text.Split('+'))
{
  sum += int.Parse(text[r]);
}
WriteLine($"Sum using Split: {sum}"); 

如果你在循环中设置断点,那么请注意名为r的范围对象首先为[0..2],如图8.6所示,然后为{3..5},最后为{6..9}

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/csp13-dn9-mdn-xplat-dev-fund/img/B22322_08_06.png

图 8.6:用于高效分割字符串的跨度范围

范围值有效地定义了进入原始字符串的窗口,而不分配新的内存资源。

练习和探索

通过回答一些问题、进行一些动手实践和探索,以及更深入地研究本章的主题来测试你的知识和理解。

练习 8.1 – 在线材料

在线材料可能由 Microsoft 或第三方创建,或为本书的额外内容。

与网络资源一起工作

如果你感兴趣于一些用于处理网络资源的低级类型,那么你可以阅读以下链接中找到的在线仅有的部分:

github.com/markjprice/cs13net9/blob/main/docs/ch08-network-resources.md

有一个有用的在线书籍,由 Chris Woodruff 编写,名为超越边界 – 使用 C# 12 和.NET 8 进行网络编程,你可以通过以下链接找到:csharp-networking.com

集合表达式

你可以在以下链接中了解更多关于如何重构现有代码以充分利用集合表达式的信息:

devblogs.microsoft.com/dotnet/refactor-your-code-with-collection-expressions/

练习 8.2 – 练习题

练习题深入探讨了本章的主题。

正则表达式

第八章解决方案中,创建一个名为Exercise_RegularExpressions的控制台应用程序,提示用户输入一个正则表达式,然后提示用户输入一些输入,并比较这两个以匹配,直到用户按下Esc,如下所示输出:

The default regular expression checks for at least one digit.
Enter a regular expression (or press ENTER to use the default): ^[a-z]+$
Enter some input: apples
apples matches ^[a-z]+$? True
Press ESC to end or any key to try again.
Enter a regular expression (or press ENTER to use the default): ^[a-z]+$
Enter some input: abc123xyz
abc123xyz matches ^[a-z]+$? False
Press ESC to end or any key to try again. 

扩展方法

第八章解决方案中,创建一个名为Exercise_NumbersAsWordsLib的类库和测试它的项目。它应该定义扩展方法,这些方法通过名为ToWords的方法扩展数字类型,如BigIntegerint,该方法返回一个描述数字的string

例如,18,000,000将是十八百万,而18,456,002,032,011,000,007将是十八千兆,四百五十六千兆,两万亿,三十二亿,一千一百万和七。

你可以在以下链接中了解更多关于大数命名的内容:en.wikipedia.org/wiki/Names_of_large_numbers

创建项目以使用 xUnit 测试你的类库,并将其作为一个交互式控制台应用程序。

练习 8.3 – 测试你的知识

使用网络回答以下问题:

  1. 可以在 string 变量中存储的最大字符数是多少?

  2. 在什么情况下以及为什么你应该使用 SecureString 类型?

  3. 在什么情况下使用 StringBuilder 类是合适的?

  4. 你应该在什么情况下使用 LinkedList<T> 类?

  5. 你应该在什么情况下使用 SortedDictionary<T> 类而不是 SortedList<T> 类?

  6. 在正则表达式中,$ 代表什么?

  7. 在正则表达式中,如何表示数字?

  8. 为什么你不应该使用官方电子邮件地址标准来创建一个用于验证用户电子邮件地址的正则表达式?

  9. 当以下代码运行时,会输出哪些字符?

    string city = "Aberdeen";
    ReadOnlySpan<char> citySpan = city.AsSpan()[..];
    WriteLine(citySpan.ToString()); 
    
  10. 你如何在调用它之前检查一个网络服务是否可用?

练习 8.4 – 探索主题

使用以下链接了解本章涵盖主题的更多详细信息:

github.com/markjprice/cs13net9/blob/main/docs/book-links.md#chapter-8---working-with-common-net-types

摘要

在本章中,您探索了:

  • 存储和操作数字的类型选择。

  • 处理文本,包括使用正则表达式验证输入。

  • 用于存储多个项目的集合。

  • 与索引、范围和跨度一起工作。

在下一章中,我们将管理文件和流,编码和解码文本,以及执行序列化。

在 Discord 上了解更多

要加入本书的 Discord 社区——在那里您可以分享反馈、向作者提问,并了解新发布——请扫描下面的二维码:

packt.link/csharp13dotnet9

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/csp13-dn9-mdn-xplat-dev-fund/img/QR_Code281224304227278796.png

留下评论!

感谢您从 Packt Publishing 购买本书——我们希望您喜欢它!您的反馈对我们来说是无价的,它帮助我们改进和成长。一旦您阅读完毕,请花点时间留下亚马逊评论;这只需一分钟,但对像您这样的读者来说意义重大。

扫描二维码或访问链接,以获得您选择的免费电子书。

packt.link/NzOWQ

https://github.com/OpenDocCN/freelearn-csharp-zh/raw/master/docs/csp13-dn9-mdn-xplat-dev-fund/img/review1.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值