在这个帖子中,我们将深入探讨 C# 12 引入的 “别名” 功能。这个特性允许你使用 using
指令为任何类型创建别名。这种功能在以下情况下特别有用:
- 当处理长或复杂的类型名称时。
- 需要在类型之间区分或解决命名冲突时。
- 定义你打算在一个程序集中共享的价值元组类型。
- 通过使用更具描述性的名称来提高代码可读性。
尽管官方 C# 文档提供了大量使用此功能的例子,但在这里我不会重复那些示例,而是会展示一个名为 “UFO 目击” 的演示应用程序,它展示了这一功能的不同方面。请注意,这个功能不支持可空引用类型,即不能为 string?
这样的类型创建别名。
可用空引用类型
该特性支持大多数类型,唯一的例外是可空引用类型。也就是说,您不能为可空引用类型使用别名,C# 编译器会报告 CS9132
错误:使用别名不能是可空引用类型。
示例应用:UFO 目击器 🛸
演示应用程序可以在 GitHub 上找到,地址是 IEvangelist/alias-any-type。这是一个简单的控制台应用,模拟不明飞行物(UFO)的目击事件。如果你想本地运行,可以从你的工作目录中使用以下方法之一:
使用 Git CLI:
git clone https://github.com/IEvangelist/alias-any-type.git
使用 GitHub CLI:
gh repo clone IEvangelist/alias-any-type
下载 zip 文件: 访问 这里 下载源代码。
运行应用的命令如下:
dotnet run --project ./src/Alias.AnyType.csproj
启动后,程序会打印一个介绍,并等待用户输入。
按下任意键(如空格键)后,程序随机生成有效坐标(经度和纬度),然后根据这些坐标获取相关地理位置信息。坐标用度分秒表示(包括方向)。运行期间,程序会计算并报告生成坐标的间距,作为 UFO 目击记录。
停止应用,只需按 +
键。
虽然这个应用很简单,但它包含了与本文关注点不太相关的 C# 代码。我会尽量避免深入讨论周边话题,但在认为重要的时候会提及。
代码讲解 👀
接下来,我们逐个分析代码。代码库中有几个有趣的方面,包括项目文件、GlobalUsings.cs
、扩展方法和 Program.cs
文件。有些代码部分我们不会详细讲解,比如响应模型和一些辅助方法。
|-- src
| |-- Extensions
| | |-- CoordinateExtensions.cs
| |-- ResponseModels
| | |-- GeoCode.cs
| | |-- Informative.cs
| | |-- LocalityInfo.cs
| |-- Alias.AnyType.csproj
| |-- CoordinateGeoCodePair.cs
| |-- GlobalUsings.cs
| |-- Program.cs
| |-- Program.Http.cs
| |-- Program.Utils.cs
首先,我们看看项目文件:
<Exe net8.0 enable enable>
注意 ImplicitUsings
属性设置为 enable
,这是从 C# 10 开始的功能,它允许目标 SDK(如 Microsoft.NET.Sdk
)默认包含一组命名空间。不同 SDK 包含不同的默认命名空间,更多详情请参阅隐式使用指令文档。
隐式使用指令 📜
ImplicitUsing
元素是 MS Build 的一部分,而 global
关键字则是 C# 语言特性。由于我们启用了全局使用功能,我们也可以通过添加 Using
元素在 ItemGroup
内利用这个特性。其中一些 Using
元素设置了 Static
属性为 true
,这意味着它们的所有静态成员无需资格就可以使用。Alias
属性用于为类型创建别名,例如,我们为 System.Runtime.CompilerServices.EnumeratorCancellationAttribute
类型创建了别名 AsyncCancelable
。在代码中,我们可以直接使用 AsyncCancelable
代替 EnumeratorCancellation
。
一个新兴模式 🧩
现代.NET 代码库中,开发者通常会在一个单独的 GlobalUsings.cs
文件中定义所有(或大部分)的 using
指令。这个演示应用遵循了这个模式,让我们看看这个文件:
// 确保这些命名空间内的所有类型全局可用。
global using Alias.AnyType;
global using Alias.AnyType.Extensions;
global using Alias.AnyType.ResponseModels;
// 公开`System.Math`的所有静态成员。
global using static System.Math;
// 为坐标对象创建别名。
global using Coordinates = (double Latitude, double Longitude);
// 为度分秒(DMS)表示法创建别名。
global using DMS = (int Degree, int Minute, double Second);
// 为不同单位测量的各种距离创建别名。
global using Distance = (double Meters, double Kilometers, double Miles);
// 为异步流中的坐标创建别名。
global using CoordinateStream = System.Collections.Generic.IAsyncEnumerable;
// 为CancellationTokenSource创建别名,现在简称为"Signal"。
global using Signal = System.Threading.CancellationTokenSource;
文件中的每一行都是全局 using
指令,使别名类型、静态成员或命名空间在整个项目中可用。前三个指令针对常用的命名空间,它们在应用程序的多个地方被使用。下一个指令是 global using static
,使得 System.Math
命名空间的所有静态成员无需资格即可使用。其余的指令是为各种类型创建别名,包括元组、坐标流和 CancellationTokenSource
,现在可以直接通过 Signal
引用。
当定义元组别名类型时,你可以轻松地在未来添加行为或属性。例如,如果你后来决定为 Coordinates
类型添加功能,可以将其改为 record
类型:
namespace Alias.AnyType;
public readonly record struct Coordinates(double Latitude, double Longitude);
当你创建别名时,实际上并没有创建新的类型,而是为已有的类型定义了一个名称。对于元组,你定义的是一个值元组的结构。当你为数组类型创建别名时,你实际上是给类型起了一个更描述性的名字,而不是创建新的数组类型。例如,当返回 IAsyncEnumerable
时,写起来会很长。现在,我可以将返回类型称为 CoordinateStream
,并在代码库中到处使用。
引用别名 📚
项目文件和 GlobalUsings.cs
文件中定义了一些别名。让我们看看这些别名在代码库中的用法。首先看顶级的 Program.cs
文件:
using Signal signal = GetCancellationSignal();
WriteIntroduction();
try {
Coordinates? lastObservedCoordinates = null;
await foreach (var coordinate in GetCoordinateStreamAsync(signal.Token)) {
(Coordinates coordinates, GeoCode geoCode) = coordinate;
// 使用扩展方法,扩展别名类型。
var cardinalizedCoordinates = coordinates.ToCardinalizedString();
// 将UFO坐标细节写入控制台。
WriteUfoCoordinateDetails(coordinates, cardinalizedCoordinates, geoCode);
// 写旅行警告,包括旅行距离。
WriteUfoTravelAlertDetails(coordinates, lastObservedCoordinates);
await Task.Delay(UfoSightingInterval, signal.Token);
lastObservedCoordinates = coordinates;
}
}
catch (Exception ex) when (Debugger.IsAttached) {
// https://x.com/davidpine7/status/1415877304383950848
_ = ex;
Debugger.Break();
}
这段代码展示了如何使用 Signal
别名创建 CancellationTokenSource
实例。CancellationTokenSource
类实现了 IDisposable
接口,因此我们可以使用 using
语句确保在作用域结束时正确释放 Signal
实例。IDE 理解这些别名,当你悬停在上面时,可以看到它们实际代表的类型。例如,考虑下面的屏幕截图:
在进入 try / catch
之前,会调用 WriteIntroduction
写入介绍到控制台。try
块包含一个 await foreach
循环,遍历一个 IAsyncEnumerable
。GetCoordinateStreamAsync
方法在另一个文件中定义。在编写顶层程序时,我更倾向于使用 partial class
功能,因为它有助于分离关注点。所有的 HTTP 相关功能都在 Program.Http.cs
文件中,我们来看一下 GetCoordinateStreamAsync
方法:
static async CoordinateStream GetCoordinateStreamAsync([AsyncCancelable] CancellationToken token) {
token.ThrowIfCancellationRequested();
do {
var coordinates = GetRandomCoordinates();
if (await GetGeocodeAsync(coordinates, token) is not { } geoCode) {
break;
}
token.ThrowIfCancellationRequested();
yield return new CoordinateGeoCodePair(
Coordinates: coordinates,
GeoCode: geoCode
);
} while (!token.IsCancellationRequested);
}
该方法返回 CoordinateStream
别名,即 IAsyncEnumerable
。它接受一个 AsyncCancelable
属性,这是 EnumeratorCancellationAttribute
类型的别名。这个属性用于装饰取消令牌,以便在与 IAsyncEnumerable
结合使用时支持取消操作。在没有请求取消的情况下,方法会生成随机坐标,获取地理位置元数据,并生成一个新的 CoordinateGeoCodePair
实例。GetGeocodeAsync
方法会为给定的坐标请求地理位置元数据,如果成功,它将返回 GeoCode
响应模型。例如,微软校园的坐标如下:
GET /data/reverse-geocode-client?latitude=47.637&longitude=-122.124 HTTP/1.1
Host: api.bigdatacloud.net
Scheme: https
要查看 JSON,可以在浏览器中打开这个链接。CoordinateGeoCodePair
类型没有被别名,但它是一个只读记录结构体,包含 Coordinates
和 GeoCode
:
namespace Alias.AnyType;
internal readonly record struct CoordinateGeoCodePair(
Coordinates Coordinates,
GeoCode GeoCode);
回到 Program
类,当我们迭代每个坐标地理编码对时,我们将元组解构为 Coordinates
和 GeoCode
实例。Coordinates
类型是两个 double
值表示纬度和经度的别名。再次在你的 IDE 上悬停这个类型,可以看到它是值元组( (double, double)
):
GeoCode
类型是一个包含地理位置元数据信息的响应模型。然后我们使用扩展方法将 Coordinates
转换为卡度化字符串,这是一种用度-分-秒格式表示的坐标字符串。我喜欢在整个代码库中使用别名是多么方便。让我们看看一些扩展方法,它们扩展或返回别名类型:
internal static string ToCardinalizedString(this Coordinates coordinates)
{
var (latCardinalized, lonCardinalized) = (FormatCardinal(coordinates.Latitude, true),
FormatCardinal(coordinates.Longitude, false));
return $"{latCardinalized},{lonCardinalized}";
}
static string FormatCardinal(double degrees, bool isLat)
{
(int degree, int minute, double second) = degrees.ToDMS();
var cardinal = degrees.ToCardinal(isLat);
return $"{degree}°{minute}'{second % 60:F4}\"{cardinal}";
}
public static DMS ToDMS(this double coordinate)
{
var ts = TimeSpan.FromHours(Math.Abs(coordinate));
int degrees = (int)(Math.Sign(coordinate) * Math.Floor(ts.TotalHours));
int minutes = ts.Minutes;
double seconds = ts.TotalSeconds;
return new DMS(degrees, minutes, seconds);
}
这个扩展方法扩展了 Coordinates
别名类型并返回坐标表示的字符串。它使用 ToDMS
扩展方法将纬度和经度转换为度-分-秒格式。ToDMS
扩展方法接受一个 double
值并返回一个 DMS
元组。ToCardinal
扩展方法用于确定坐标的方向,返回 N
、S
、E
或 W
。Abs
、Sign
和 Floor
方法都是 System.Math
命名空间中的静态成员,该命名空间在 _GlobalUsings.cs
文件中被别名。
除此之外,应用程序还会向控制台显示不明飞行物目击详情,包括坐标、地理位置元数据以及两次目击之间的距离。这个过程会重复进行,直到用户通过 + 键组合停止应用。
译自:https://devblogs.microsoft.com/dotnet/refactor-your-code-using-alias-any-type/
Comments