了解 C# 8 中的默认接口方法

Avatar
不若风吹尘
2024-09-05T18:54:47
32
0

在本文中,我将介绍默认接口方法及其工作原理,并讨论其典型用途。最后,我将讨论该功能的一些棘手问题:需要注意的事项、可能遇到的编译器错误以及使用时的注意事项。在我的下一篇博客中,我将讨论一个使用默认接口方法提高 ASP.NET Core 性能的用例。

了解默认接口方法

默认接口方法 是在 C# 8 中引入的,主要是为了更方便地演进接口而不破坏已经实现了该接口的代码。这个特性允许你在定义接口上的方法时提供一个方法体,类似于你如何使用抽象类。

默认接口成员还允许更轻松地与存在于 AndroidiOS 中的类似特性进行互操作。它们还提供了一些特质(traits)的特性,这是 一种构建系统的方法,注重组合性而非继承。

通过一个例子最容易理解这个特性,因此我将使用微软文档中的简化场景。假设你在库中定义了以下接口:

public interface ICustomer
{
    DateTime DateJoined { get; }
    string Name { get; }
}

你作为功能的一部分提供了这个接口,并且客户正在使用这个接口。他们有几个实现了这个接口的类,其中一个如下所示:

public class SampleCustomer(Guid id, string name, DateTime dateJoined) : ICustomer
{
    public Guid Id { get; } = id;
    public string Name { get; } = name;
    public DateTime DateJoined { get; } = dateJoined;
}

上述代码使用主构造器来减少一些构造器的样板代码;

一切顺利,直到你意识到需要演进你的接口。

演进接口是一种破坏性变更

后来,你决定向你的接口添加另一个方法 GetLoyaltyDiscount(),该方法返回给定客户的折扣。最终你希望接口看起来像这样:

public interface ICustomer
{
    DateTime DateJoined { get; }
    string Name { get; }
    decimal GetLoyaltyDiscount(); // 👈 Adding this
}

不幸的是,进行这种更改将是一个破坏性更改。如果你进行了此更改,那么当你库的使用者更新时,他们的代码将无法编译,直到他们实现了新方法。

如果你遵循 语义化版本控制(semver) —— 作为一个库的作者,你应该这样做——那么你可以在主版本升级时进行这种更改。但这仍然是不理想的。它给你的库的使用者带来了麻烦,增加了一个他们为了更新你的库而必须执行的步骤。如果可以避免破坏性更改,那通常是一件好事。

Aaron Stannard 有一篇关于 “ 专业开源 ” 的系列文章,讨论了它与版本控制的关系。我特别喜欢他关于 “实用” 与 “严格” 语义化版本控制的文章,因为它处理了流行的开源库中破坏性更改的复杂性以及如何思考这些问题。

根据你的具体需求,你可以将 GetLoyaltyDiscount() 写成一个对 ICustomer 操作的扩展方法。但如果你需要让使用者能够覆盖此功能,那么你就有点陷入困境了。这就是默认接口方法发挥作用的地方。

默认接口方法来救援

默认接口方法使你能够在不破坏库使用者的情况下进行更改,通过为该方法提供具体的实现。例如:

public interface ICustomer
{
    DateTime DateJoined { get; }
    string Name { get; }
    // 👇 Provide a body for the new method
    decimal GetLoyaltyDiscount()
    {
        var twoYearsAgo = DateTime.UtcNow.AddYears(-2);
        if (DateJoined < twoYearsAgo)
        {
            // Customers who joined > 2 years ago get a 10% discount
            return 0.10m;
        }

        // Otherwise no discount
        return 0;
    }
}

让我们暂停一下,思考这有多 “奇怪”。

我们在这里定义的是一个 接口,而不是一个 结构体 。然而,我们却有一个方法体。

在 C# 8 之前,你不能这么做,更重要的是,这样做实际上也没有什么意义。接口实际上是类型必须实现的 API/接口的定义。如果你想提供一个“默认”的实现,那么你必须使用抽象类,这在其他方面限制了你。

我将默认接口方法与抽象类进行了简单的比较。

假设你在接口中添加了这个默认方法,并在新版本的库中发布了它。你不需要进行大版本更新,因为这不是一个破坏性变更。消费者的现有实现(如下所示)仍然是有效的,即使它没有定义GetLoyaltyDiscount()

public class SampleCustomer(Guid id, string name, DateTime dateJoined) : ICustomer
{
    public Guid Id { get; } = id;
    public string Name { get; } = name;
    public DateTime DateJoined { get; } = dateJoined;
    // GetLoyaltyDiscount() is not implemented
}

消费者的代码仍然可以编译,并且当库需要使用 ICustom.GetLoyaltyDiscount() 方法时,它会使用默认实现。

之后,如果消费者决定他们确实想要实现 GetLoyaltyDiscount() ,他们可以这样做:

public class SampleCustomer(Guid id, string name, DateTime dateJoined) : ICustomer
{
    public Guid Id { get; } = id;
    public string Name { get; } = name;
    public DateTime DateJoined { get; } = dateJoined;
    public decimal GetLoyaltyDiscount() => 0; // Never give a discount
}

所以,如果你可以将对接口的更改实现为默认接口方法,那么你可能会获得两全其美的效果:一个不断演进的接口,却不会导致破坏性变更。

默认接口方法功能不仅实现了上述特性。现在你甚至可以在字段上定义私有成员,这些成员可以用作默认接口方法实现的一部分,同时也可以定义静态方法。文档在这里展示了 这些特性的示例

如果这一切听起来好得令人难以置信,那么当你在使用默认方法时要注意一些棘手的问题也就不足为奇了。在接下来的部分中我将讨论一些需要注意的事情和要留意的问题。

使用默认接口方法时应注意的棘手问题

这一节讨论了在使用默认接口方法时需要考虑的一些问题。

默认接口方法不会被继承

尽管使用默认接口方法的 interface 看起来很像抽象类,但它们本质上是不同的。一个很大的区别就是,默认接口方法并不会被接口的实现者继承。

例如,假设你在添加自定义 GetLoyaltyDiscount() 方法之前正在处理 SampleCustomer

public class SampleCustomer(Guid id, string name, DateTime dateJoined) : ICustomer
{
    public Guid Id { get; } = id;
    public string Name { get; } = name;
    public DateTime DateJoined { get; } = dateJoined;
    // GetLoyaltyDiscount() is not implemented
}

以下代码使用了默认接口实现的 GetLoyaltyDiscount(),这段代码可以编译,并打印出 0.10

// Create a customer that joined 3 years ago
SampleCustomer customer = new SampleCustomer(Guid.Empty, "me", DateTime.Now.AddYears(-3));
PrintDiscount(customer); // Prints "0.10"

static void PrintDiscount(ICustomer customer)
  => Console.WriteLine(customer.GetLoyaltyDiscount()); // Use the default interface method

但是,以下代码不能编译:

// Create a customer that joined 3 years ago
SampleCustomer customer = new SampleCustomer(Guid.Empty, "me", DateTime.Now.AddYears(-3));

Console.WriteLine(customer.GetLoyaltyDiscount()); // 💥 The GetLoyaltyDiscount() method does not exist!

问题在于 GetLoyaltyDiscount() 方法并没有被 SampleCustomer 继承。该方法在 SampleCustomer 中不存在,因此你无法调用它。

但是,以下这种方式中,我们将 SampleCustomer 转换为 ICustomer,确实可以编译,并且如预期地打印出 0.10

// Create a customer that joined 3 years ago
ICustomer customer = new SampleCustomer(Guid.Empty, "me", DateTime.Now.AddYears(-3));
// 👆 cast to ICustomer

Console.WriteLine(customer.GetLoyaltyDiscount()); // Prints 0.10

所以这里的教训是,你可能需要转换为 ICustomer 来调用默认接口方法。但如果你开始在层次结构中添加更多类型,事情会变得更加复杂...

理解将会调用哪个方法是...复杂的

好吧,一个快速的谜题,这个程序会打印什么?

using System;

// 👇 casting to IShape
IShape shape = new Square();
Console.WriteLine(shape.GetName()); // 👈 What does this print?

public interface IShape
{
    string GetName() => "IShape"; // Default implementation
}

public class Rectangle : IShape
{
}

public class Square : Rectangle
{
    public string GetName() => "Square"; // Specific implementation
}

答案是,这会打印 IShape,即使 Square 已经提供了对 GetName() 方法的重写实现!在层次结构中的 Rectangle 类型实现了 IShape,并且它 没有 重写 GetName() 的行为,因此编译器使用了默认实现。

要 “修复” 这种行为的最好方法是确保你 显式地Square 中实现 IShape 接口,尽管通过继承自 Rectangle 它已经隐式实现了:

// Expicitly implement the interface 👇
public class Square : Rectangle, IShape
{
    public string GetName() => "Square"; // This now overrides the default implementation
}

现在,如果你运行上述程序,代码将打印出 Square,这可能是你最初期望的结果!依我看来,这是默认接口方法中最棘手的问题之一,因为你实际上并没有从编译器那里得到关于这种非直观行为的任何帮助。

菱形继承问题

值得庆幸的是,对于某些默认接口方法的问题,编译器确实会给你一些提示。其中一个问题是菱形继承问题,这是与多重继承相关的一个众所周知的问题。

下面的例子展示了一个简单的问题演示:

interface IShape
{
    string GetName() => "IShape"; // Default implementation
}

interface IHasStraightEdges : IShape
{
    string IShape.GetName() => "IHasStraightEdges"; // Override the default
}

interface IHasCurvedEdges : IShape
{
    string IShape.GetName() => "IHasCurvedEdges"; // Override the default
}

// inherits both interfaces
public class MySemiCircle : IHasStraightEdges, IHasCurvedEdges {}

这段代码不会编译,相反,你会收到一个错误:

Interface member `IShape.GetName()` does not have a most specific
implementation. Neither `IHasStraightEdges.IShape.GetName()` or
`IHasCurvedEdges.IShape.GetName()` is most specific.

要直观地理解这个问题,可以思考以下代码会打印什么结果:

IShape shape = new MySemiCircle(); // cast to IShape
Console.WriteLine(shape.GetName()); // What should this print?!

编译器没有一个 “最佳” 的方法可以调用,所以它放弃了。这就是经典的 “死亡菱形” 问题。编译器迫使你明确地覆盖该方法,例如:

// Optionally Implement IShape explicitly  👇
public class MySemiCircle : IHasStraightEdges, IHasCurvedEdges, IShape
{
    public string GetName() => "MySemiCircle"; // 👈 Provide an implementation (required)
}

实现接口的结构体存在问题

实现一个 interface 并不仅限于 class,你也可以在 struct 中实现它。不幸的是,struct 在使用默认接口方法时表现不佳:

  • 要使用默认接口方法,必须将其实例转换为接口类型,例如 (IShape)myStruct。然而,将 struct 转换为接口需要对其装箱(分配到堆上),这可能使使用 struct 的主要原因之一(减少分配)变得无效。
  • 如果默认接口方法修改了对象的状态,那么它将对装箱后的 struct 副本进行操作,因此不会修改原始对象。这很可能不是预期的行为。

遗憾的是,对于这些限制,你无能为力。一个很好的经验法则是:不要使用 struct 来实现带有默认接口方法的 interface

默认接口方法需要运行时支持

实现默认接口方法不仅需要 C# 编译器进行更改,还需要.NET 运行时进行更改。当 C# 8 发布时,这些更改被添加到了 .NET Core 3.0 中的 CoreCLR(和 mono)运行时中,因此你不能在 .NET Core 1.x 或 .NET Core 2.x 中使用它们(是的,人们仍在使用这些版本 😅)。

Matt Warren 有一篇很好的文章,深入探讨了该特性在运行时中的实现,如果你想了解更多细节的话!

更重要的是,你也无法在 .NET Framework 中使用默认接口成员。这意味着如果你的开源项目仍然针对.NET Framework,则无法使用默认接口成员。

总结

在本文中,我描述了 C# 8 的默认接口方法功能。通过给接口成员提供一个 “主体”,此功能可以让你实现在不破坏接口使用者的情况下启用 interface 实现。这提供了一种全新的机制来在多个类型之间共享功能,但也会带来一些棘手的问题,因为默认接口实现并不会被接口的实现者继承。在下一篇博文中,我将讨论一个使用默认接口方法来提高 ASP.NET Core 性能的应用案例。

原文: https://andrewlock.net/understanding-default-interface-methods/

Last Modification : 9/18/2024 11:33:29 PM


In This Document