用别名重构您的 C# 代码

Avatar
不若风吹尘
2024-06-20T17:27:03
175
0

在这个帖子中,我们将深入探讨 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目击:应用程序启动时,控制台显示介绍。

按下任意键(如空格键)后,程序随机生成有效坐标(经度和纬度),然后根据这些坐标获取相关地理位置信息。坐标用度分秒表示(包括方向)。运行期间,程序会计算并报告生成坐标的间距,作为 UFO 目击记录。

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 理解这些别名,当你悬停在上面时,可以看到它们实际代表的类型。例如,考虑下面的屏幕截图:

Visual Studio:悬停在Signal类型声明上,显示它是CancellationTokenSource。

在进入 try / catch 之前,会调用 WriteIntroduction 写入介绍到控制台。try 块包含一个 await foreach 循环,遍历一个 IAsyncEnumerableGetCoordinateStreamAsync 方法在另一个文件中定义。在编写顶层程序时,我更倾向于使用 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 类型没有被别名,但它是一个只读记录结构体,包含 CoordinatesGeoCode

namespace Alias.AnyType;
internal readonly record struct CoordinateGeoCodePair(
    Coordinates Coordinates,
    GeoCode GeoCode);

回到 Program 类,当我们迭代每个坐标地理编码对时,我们将元组解构为 CoordinatesGeoCode 实例。Coordinates 类型是两个 double 值表示纬度和经度的别名。再次在你的 IDE 上悬停这个类型,可以看到它是值元组( (double, double) ):

Visual Studio:悬停在 Coordinates 类型声明上,显示它是值元组 (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 扩展方法用于确定坐标的方向,返回 NSEWAbsSignFloor 方法都是 System.Math 命名空间中的静态成员,该命名空间在 _GlobalUsings.cs 文件中被别名。

除此之外,应用程序还会向控制台显示不明飞行物目击详情,包括坐标、地理位置元数据以及两次目击之间的距离。这个过程会重复进行,直到用户通过 + 键组合停止应用。

译自:https://devblogs.microsoft.com/dotnet/refactor-your-code-using-alias-any-type/

Last Modification : 9/20/2024 4:27:36 AM


In This Document