发布于 2026-01-06 6 阅读
0

C# 中的默认接口实现:继承如何戏弄你 DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

C# 中的默认接口实现:继承的陷阱

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

介绍

C# 是一种功能强大且不断发展的编程语言,因其稳健性和多功能性而深受开发者喜爱。每个新版本都会引入新的功能,以增强便利性并简化开发流程。

然而,就像彼得·帕克一样,开发者必须负责任地运用这种权力。稍有不慎,就可能陷入漏洞和混乱的泥潭。

强大的力量带来了强大的调试能力

默认接口实现(Default Interface Implementations)就是一个典型的例子,它于 C# 8.0 中引入。该特性允许开发者直接在接口中定义带有默认实现的方法。虽然它允许在不破坏现有实现的情况下添加方法,从而实现了更平滑的 API 演进,并改善了 C# 与 Android(Java)和 iOS(Swift)等平台的互操作性,但它也为一些难以察觉的隐蔽 bug 埋下了隐患,尤其是在与继承和依赖注入结合使用时。

本文将探讨一个看似简单的场景:默认接口实现 (AID) 启动后,却带来了意想不到的结果。这个主题源于我最近在实践中遇到的一个情况,它凸显了事情很容易出错。我们将深入剖析这种意外行为背后的技术原因,并分享一些技巧,以确保你的代码不会落入同样的陷阱。

一项简单的服务

我们先来看一个简单的例子。假设你正在开发一个服务,MyService它依赖于一个IFoo接口。没什么特别的,就是标准的依赖注入配置:

public class MyService
{
    private readonly IFoo _foo;

    // Constructor with an injected service.
    public MyService(IFoo foo)
    {
        _foo = foo;
    }

    // A method that prints a value.
    public void PrintValue()
    {
        // Get the value.
        var value = _foo.GetValue();

        // Print the value.
        Console.WriteLine(value);
    }
}
Enter fullscreen mode Exit fullscreen mode

IFoo接口只有一个方法,GetValue其中包含一个默认实现。以下是该接口及其实现的示例:

public interface IFoo
{
    // A method declaration with a default implementation.
    string GetValue() => "IFoo";
}

// An implementation of the interface.
public class Foo : IFoo
{
    // Overriding the method.
    public string GetValue() => "Foo";
}
Enter fullscreen mode Exit fullscreen mode

当你在可靠的单元测试中运行这段代码时,一切都按预期工作:

[TestFixture]
public class FooTests
{
    [Test]
    public void GetValueMustReturnFoo()
    {
        // Create an instance of the service.
        var foo = new Foo();

        // Get the value.
        var value = foo.GetValue();

        // Ensure the value is correct and equal to "Foo".
        Assert.AreEqual("Foo", value);
    }
}
Enter fullscreen mode Exit fullscreen mode

测试通过,确认Foo.GetValue()返回"Foo"

最后,您需要MyService在控制台应用程序中使用它。实际上,这是一个常见的场景,服务通常会从依赖注入 (DI) 容器中解析出来,但为了简单起见,我们使用控制台应用程序来演示这个概念。

class Program
{
    static void Main(string[] args)
    {
        // Create an instance of `Foo`.
        var foo = new Foo();

        // Create an instance of `MyService` using the `Foo` instance.
        var service = new MyService(foo);

        // Execute the method to get an output.
        service.PrintValue();
    }
}
Enter fullscreen mode Exit fullscreen mode

运行此程序会产生预期的输出:

Foo
Enter fullscreen mode Exit fullscreen mode

世界一片祥和。至少你是这么认为的……

会出什么问题?

Foo现在,假设你团队中的某个人决定通过引入一个基类来重构这个类FooBase

// Introduce a base class.
public class FooBase : IFoo
{
}

// Derive `Foo` from the base class.
public class Foo : FooBase
{
    // No other changes here.
    public string GetValue() => "Foo";
}
Enter fullscreen mode Exit fullscreen mode

看起来似乎没什么害处,对吧?对吧?

你运行测试,测试通过。你部署到生产环境,然后……出乎意料!输出结果发生了变化:

IFoo
Enter fullscreen mode Exit fullscreen mode

仅仅引入一个基类并非毫无后果。

为什么会发生这种情况?

当类型加载到 CLR 中时,会创建并初始化一个方法表。运行时使用此表将方法调用解析为实际要执行的方法。您可以将其视为一个指南,它根据类型和方法签名告诉运行时要调用哪个方法。

在引入基类之前,该类Foo直接实现了接口。在这种情况下,只要方法签名匹配,接口的IFoo方法表就会将接口方法映射到类的实现。因此,当对类的实例调用`get` 方法时,运行时会在类中查找该方法,并将其解析为显式定义的方法。FooGetValue()FooFooGetValue()

Before

+---------------+      +--------------+
| IFoo.GetValue | ---> | Foo.GetValue | 
+---------------+      +--------------+ 
Enter fullscreen mode Exit fullscreen mode

然而,当你引入一个基类(` FooBaseA`)并Foo从中派生时,情况就变得有趣了。现在,`A`Foo不再直接实现 `A` 接口IFoo。相反,它继承自 `B` FooBaseIFoo而 `B` 实现了 `A` 接口。方法表会相应地更新,运行时会根据继承链解析方法调用。这意味着它会查看FooBase`B` 的方法表来解析调用。但关键在于:`B`FooBase没有实现 `A` 接口GetValue(),因此运行时会回退到 ` B` 的默认接口实现IFoo

After

+---------------+      +--------------+
| IFoo.GetValue |      | Foo.GetValue | 
+---------------+      +--------------+ 
       ^  |            +------------------------------------+
       |  +----------> | FooBase.GetValue (not implemented) | 
       +--(default)--- +------------------------------------+
Enter fullscreen mode Exit fullscreen mode

简单来说,当Foo继承自某个接口时FooBase,方法表不再直接指向该接口Foo.GetValue()。由于Foo该接口不再显式实现该接口IFoo,运行时默认使用接口中的方法,这可能并非您所期望的,从而导致返回意外的值而不是预期的"IFoo""Foo"

如何预防这种情况?

不要因为使用默认接口实现而弄巧成拙。以下是如何明智地使用它们:

  • 理解行为:默认方法很方便,但与继承结合使用时可能会很棘手。
  • 避免不必要的继承:继承功能强大,但不要过度使用。如果需要共享逻辑,请优先选择组合而非继承
  • 显式地继承自接口:即使引入了一个实现接口的基类,派生类也应该显式地声明它本身实现了该接口,并根据需要提供方法重写。
// Explicitly implement the interface
// Yes, even if the class is derived from `FooBase`, which implements `IFoo`.
public class Foo : FooBase, IFoo
{
    // Override the method.
    public string GetValue() => "Foo";
}
Enter fullscreen mode Exit fullscreen mode
  • 通过接口进行测试:修改测试用例,使其直接与接口交互,而不是通过具体的类或方法var。这样可以确保被测行为符合接口定义的约定。直接使用var或引用具体类可能会无意中绕过接口行为,掩盖默认实现或继承方面的潜在问题。
// The improved test: Interact through the interface.
[Test]
public void GetValueMustReturnFoo()
{
    // Create an instance of the service using the interface, not `var`.
    IFoo foo = new Foo();

    // Get the value.
    var value = foo.GetValue();

    // Ensure the value is correct and equal to "Foo".
    Assert.AreEqual("Foo", value);
}
Enter fullscreen mode Exit fullscreen mode
  • 利用静态分析工具:使用 Roslyn 分析器等工具(例如C# 的 Roslyn 分析器.NET 的 SonarAnalyzer)来捕获危险模式。
  • 记录和审查变更:始终记录涉及默认方法的变更,并进行彻底审查。

结论

C# 中的默认接口实现是一把双刃剑。它们虽然提供了极大的灵活性并简化了 API 的演进,但也引入了一些复杂性,即使是经验丰富的开发人员也可能措手不及。正如本例所示,类层次结构的细微变化就可能导致意想不到的行为,而这些行为通常只有在生产环境中才会显现出来。

为了避免此类陷阱,应优先使用组合而非继承,以减少意外耦合并提高代码可维护性。始终通过接口进行测试,以确保实现行为正确,而与具体的类层次结构无关。此外,务必详细记录变更,以确保团队步调一致并避免意外情况的发生。

默认接口实现可能很强大,但通过深思熟虑的方法,您可以避免它们的陷阱,并确保继承不会再次给您带来麻烦。

文章来源:https://dev.to/hypercodeplace/default-interface-implementations-in-c-where-inheritance-goes-to-troll-you-2djf