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);
}
}
该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";
}
当你在可靠的单元测试中运行这段代码时,一切都按预期工作:
[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);
}
}
测试通过,确认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();
}
}
运行此程序会产生预期的输出:
Foo
世界一片祥和。至少你是这么认为的……
会出什么问题?
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";
}
看起来似乎没什么害处,对吧?对吧?
你运行测试,测试通过。你部署到生产环境,然后……出乎意料!输出结果发生了变化:
IFoo
为什么会发生这种情况?
当类型加载到 CLR 中时,会创建并初始化一个方法表。运行时使用此表将方法调用解析为实际要执行的方法。您可以将其视为一个指南,它根据类型和方法签名告诉运行时要调用哪个方法。
在引入基类之前,该类Foo直接实现了接口。在这种情况下,只要方法签名匹配,接口的IFoo方法表就会将接口方法映射到类的实现。因此,当对类的实例调用`get` 方法时,运行时会在类中查找该方法,并将其解析为显式定义的方法。FooGetValue()FooFooGetValue()
Before
+---------------+ +--------------+
| IFoo.GetValue | ---> | Foo.GetValue |
+---------------+ +--------------+
然而,当你引入一个基类(` FooBaseA`)并Foo从中派生时,情况就变得有趣了。现在,`A`Foo不再直接实现 `A` 接口IFoo。相反,它继承自 `B` FooBase,IFoo而 `B` 实现了 `A` 接口。方法表会相应地更新,运行时会根据继承链解析方法调用。这意味着它会查看FooBase`B` 的方法表来解析调用。但关键在于:`B`FooBase没有实现 `A` 接口GetValue(),因此运行时会回退到 ` B` 的默认接口实现IFoo。
After
+---------------+ +--------------+
| IFoo.GetValue | | Foo.GetValue |
+---------------+ +--------------+
^ | +------------------------------------+
| +----------> | FooBase.GetValue (not implemented) |
+--(default)--- +------------------------------------+
简单来说,当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";
}
- 通过接口进行测试:修改测试用例,使其直接与接口交互,而不是通过具体的类或方法
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);
}
- 利用静态分析工具:使用 Roslyn 分析器等工具(例如C# 的 Roslyn 分析器或.NET 的 SonarAnalyzer)来捕获危险模式。
- 记录和审查变更:始终记录涉及默认方法的变更,并进行彻底审查。
结论
C# 中的默认接口实现是一把双刃剑。它们虽然提供了极大的灵活性并简化了 API 的演进,但也引入了一些复杂性,即使是经验丰富的开发人员也可能措手不及。正如本例所示,类层次结构的细微变化就可能导致意想不到的行为,而这些行为通常只有在生产环境中才会显现出来。
为了避免此类陷阱,应优先使用组合而非继承,以减少意外耦合并提高代码可维护性。始终通过接口进行测试,以确保实现行为正确,而与具体的类层次结构无关。此外,务必详细记录变更,以确保团队步调一致并避免意外情况的发生。
默认接口实现可能很强大,但通过深思熟虑的方法,您可以避免它们的陷阱,并确保继承不会再次给您带来麻烦。
文章来源:https://dev.to/hypercodeplace/default-interface-implementations-in-c-where-inheritance-goes-to-troll-you-2djf

