C♯ 接口的一个小“坑”

我正在转战 C++ 写密码学相关的代码。透露一下,我用了很多不同 fashion 的写程序的方法,包括 CRTP(用来做一些静态多态辅助性工作)、常规的面向对象技巧(设置虚拟方法等)、使用 STL、不使用 STL 等等。

写代码休息间隙,突然想温习一下多重继承、虚拟继承。实际上,我最开始接触面向对象编程、了解多重继承和虚拟继承的时候,还没有太想我会怎么利用这种功能,而且你懂的,很多教学资料会强烈地 discourage 这些“妖魔鬼怪”。当时我也了解过“接口”的概念,不过也不深入。(插一句,我从未接受正统 / 科班的面向对象程序设计训练,这方面是完全自学,而且可能很民科。)后来我用上了 C♯,对面向对象的理解愈加深入,然后有一天我就突然自己发现了:C♯ 的接口就是 C++ 里面没有成员变量的全都是纯虚函数(除了析构函数)的类,在 C♯ 里面一个类或接口实现一个接口,就等同于在 C++ 里公有虚拟继承之前那个类。这理应是 C++ 里实现“接口”语义的成语(idiom,惯用法)啊!我觉得所有人都应该这样做,包括(断断续续了解的 Microsoft 的一个 ABI 兼容的接口规范)COM 的生产者,而 QueryInterface 其实就是 dynamic_cast

沉浸在思考(和对过去这些“聪明”的回忆)中无法自拔,思想慢慢游荡到了 C♯ 的世界,CLR 不支持多重继承,但特殊照顾了接口,C♯ 还有一个为人称道的显式接口实现(有一半私有虚拟继承的感觉)……之后我的思考继续游荡,最终“每天学会一点新知识”

目录

提示:请务必跳过你熟悉的小标题,这篇文章比较冗长。

前情提要:多重继承、虚拟继承和接口

阅读本文需要基本的面向对象编程的知识。

C++ 中的面向对象编程有非常丰富的功能——继承的访问修饰(公有、私有、保护继承)、多重继承、虚拟继承。今天要谈的主要是多重继承和虚拟继承。

所谓多重继承,就是一个类可以有两个基类。显然当一个类可以有两个基类的时候,一个类就可以有任意多个基类。在 C++ 中,继承默认不是虚拟的,初学者绝对会接触所谓的“菱形继承”,这就是说:

struct Base { int base; };
struct Derived1 : Base { int derived1; };
struct Derived2 : Base { int derived2; };
struct Derived : Derived1, Derived2 { int derived; };

那么 Derived 会具有两个 Base 子对象,长这个样子:

Derived
Derived1 Derived2 int (derived)
Base int (derived1) Base int (derived2)
int (base) int (base)

有的时候这个效果可能是我们需要的,还有可能我们希望 Derived 只有一个 Base,比如语义上来说 Base 是“哺乳动物”,具有“心脏”,Derived1 是“马”,Derived2 是驴,显然 Derived(骡子)也应该只有一套哺乳动物的器官(只有一个“心脏”)。

用虚拟继承就可以做到这一点:

struct Mammal { int heart; };
struct HorseImpl { int horse; };
struct DonkeyImpl { int donkey; };
struct Horse : HorseImpl, virtual Mammal { };
struct Donkey : DonkeyImpl, virtual Mammal { };
struct Mule : Horse, Donkey { };

这次 Mule 就会只有一个 Mammal 子对象。注意这里 HorseImplDonkeyImpl 这样拆出来是为了更容易画图,HorseImpl 表达的是“Horse 中除了 Mammal 之外自己特有的部分”;在实际使用中,并不一定要把 HorseImpl 单拎出来。Mule 的结构可以想象为下图:

Mule
HorseImpl DonkeyImpl Mammal

考虑如下代码,可以发现(公有)虚拟继承实际上是对“接口”的描述。以迭代器为例(当然在 C++ 里面我们更多是用静态多态的方式使用迭代器)。

// 定义略

struct IForwardIterator;
struct IBidirectionalIterator : virtual IForwardIterator;
struct IRandomAccessIterator : virtual IBidirectionalIterator;

template <typename T> struct IReadableIterator;
template <typename T> struct IWritableIterator;

template <typename T>
struct IRFIt
    : virtual IForwardIterator,
      virtual IReadableIterator<T>
    ;

template <typename T>
struct IWFIt
    : virtual IForwardIterator,
      virtual IWritableIterator<T>
    ;

template <typename T>
struct IRWFIt
    : virtual IRFIt<T>,
      virtual IWFIt<T>
    ;

template <typename T>
struct IRBIt
    : virtual IBidirectionalIterator,
      virtual IRFIt<T>
    ;

template <typename T>
struct IWBIt
    : virtual IBidirectionalIterator,
      virtual IWFIt<T>
    ;

/* 注意这里的派生方式:
 * 虽然 IRBIt + IWBIt 已经
 * 覆盖了所有需要的功能,
 * 但仍然需要从 IRWFIt 派生
 * 才能正确 cast 下去。
 */
template <typename T>
struct IRWBIt
    : virtual IRWFIt<T>,
      virtual IRBIt<T>,
      virtual IWBIt<T>
    ;

/* 随机访问同理。规律是:
 * 功能更强的接口必须实现每一个
 *     只比这个接口缺少一个功能
 * 的接口,以便覆盖每个子集。
 */

C♯ 的接口

虚拟方法、常识性判断

在 C♯ 里,接口只能声明方法、事件、属性、索引器,实现接口的类必须实现所有的接口成员。考虑接口

interface IFoo
{
    void Bar();
}

它的(隐式)实现有好几种情况:

class SealedImpl : IFoo
{
    /* 这里 Bar 没有加 virtual */
    public void Bar() { }
}
class VirtualImpl : IFoo
{
    public virtual void Bar() { }
}
abstract class AbstractImpl : IFoo
{
    public abstract void Bar();
}

平时实现接口的时候似乎不怎么写 virtual,然后我就对这种情况感到有点儿不确定——SealedImpl.Bar 是虚拟方法吗?

常识告诉我们应该是虚拟的,因为要从 IFoo 的引用访问时得到正确的委派,基本上只能是虚拟方法表或者其等价物。

搜索“CLR interface virtual C#”可以看到 StackOverflow 上的这个问题,其中告诉我们:

如果实现接口的类没有将该方法标记为 virtual,则编译器在 IL 中将其标记为 virtualsealed;如果代码中标记为 virtual,则编译器将其标记为 virtual 但不是 sealed但是,派生类可以重新继承和实现这个接口。

隐式和显式实现

在更深入的考虑之前,我们先来一段插叙。在 C♯ 里,接口可以隐式显式实现。上一节已经举过隐式实现的例子了,这里举一个显式实现的例子:

class ExplicitImpl : IFoo
{
    void IFoo.Bar() { }
}
class Program
{
    static void Main()
    {
        var obj = new ExplicitImpl();
        var intf = (IFoo)obj;
        // 下一行取消注释将编译失败
        // obj.Bar();
        intf.Bar();
    }
}

隐式实现的定义和使用都是隐式的(不用提到接口的名字),而显式实现的定义和使用都是显式的(需要提到接口的名字)。显然,对于值类型来说显式实现可能带来的麻烦是访问接口成员时需要装箱;对于引用类型,没有这个问题。显式实现主要有如下几个好处或者说作用:

  • 当两个接口具有同名同形参列表的方法,或者同名属性或事件,或者同形参列表的索引器,的时候,可以通过显式实现来消歧义。
  • 希望给接口的成员换名字,比如文件流的类可能希望把 Dispose 改成 Close
  • 用聪明的方法借助 duck typing 提升效率;更一般地说,可以用来协变(或 duck typing 地协变)返回类型,逆变形参列表。

最后一个好处在此举一个例子:List<T> 实现了 IEnumerable<T>,which 实现了 IEnumerable

IEnumerable<T> 具有一个成员 IEnumerator<T> GetEnumerator()IEnumerable 具有一个成员 IEnumerator GetEnumerator(),虽然 C♯ 有协变,但是这个情况下这两个 GetEnumerator 并不能用协变统一起来,所以这里已经有使用显式实现的必要了。

此外,如果你仔细地用过 C♯,你会发现 (new List<T>()).GetEnumerator() 的返回类型是 List<T>.Enumerator,which 是一个结构,这样做是为了加速,因为 GetEnumerator 的一个常见场景是用于 foreach,而 List<T> 的枚举器是一个轻对象,为它分配托管堆空间并等待垃圾回收是不值得的(相比于结构复制一两下的开销)。因为 foreach 是 duck typing 的,所以如果显式实现 IEnumerable<T>IEnumerableGetEnumerator,就可以给 List<T> 自己的 GetEnumerator 腾出空间,实现更高效的遍历。

最后提一句,显式实现的接口成员其实是通过转发实现的,也是很常见的情况。

重新实现——以及所说的“小坑”和 lesson

好了,让我们关注一下

interface IFoo
{
    void Bar();
}
class SealedImpl : IFoo
{
    public void Bar() { }
}

这种情况吧,之前的内容已经说过它的实现是把 Bar 标记为 virtualsealed;但是之后又说“可以重新实现”。那么我们来试试

interface IFoo
{
    string Bar { get; }
}
class Base : IFoo
{
    public virtual string Bar { get { return "Base"; } }
}
class DerivedSealed : Base
{
    public sealed override string Bar
    {
        get { return "DerivedSealed"; }
    }
}
class DerivedNew : DerivedSealed, IFoo
{
    public new string Bar
    {
        get { return "DerivedNew"; }
    }
}

我们有如下结果:

(new Base()).Bar                     : "Base"
((IFoo)(new Base())).Bar             : "Base"

(new DerivedSealed()).Bar            : "DerivedSealed"
((Base)(new DerivedSealed())).Bar    : "DerivedSealed"
((IFoo)(new DerivedSealed())).Bar    : "DerivedSealed"

// 注意这 4 个的结果

(new DerivedNew()).Bar               : "DerivedNew"
((Base)(new DerivedNew())).Bar       : "DerivedSealed"
((IFoo)(new DerivedNew())).Bar       : "DerivedNew"
((IFoo)(Base)(new DerivedNew())).Bar : "DerivedNew"

意料之外,情理之中。不用看 IL 我都知道情况是怎么样的。注意:最后一个结果检验了接口继承的虚拟性(但也不能完全证明,总之就这么看看吧)。

情况总结

显然类的虚方法表和接口的虚方法表是分开的两份。对于上文所述的 BaseDerivedSealedDerivedNew 来说,其虚方法表长这样:

虚方法表的类或接口 虚方法表
Base Base Base.get_Bar
IFoo Base.get_Bar
DerivedSealed DerivedSealed DerivedSealed.get_Bar
Base DerivedSealed.get_Bar
IFoo DerivedSealed.get_Bar
DerivedNew DerivedNew DerivedSealed.get_Bar
DerivedSealed DerivedSealed.get_Bar
Base DerivedSealed.get_Bar
IFoo DerivedNew.get_Bar

注意 DerivedNew 的虚方法表里面存的是 DerivedSealed.get_Bar,但除非类型转换或者用 base,是访问不了它的,因为它被“新”方法 public new void Bar() 遮盖了;后面这个方法是非虚拟方法,当然不会进到虚方法表里面。

Lesson、用语法糖解释

不翻译 lesson 因为我觉得通常的翻译“教训”并不适用——我并没被“训”。

考虑如下代码:

interface IFoo
{
    void Bar();
}

class Base : IFoo
{
    public virtual void Bar()
    {
        Console.WriteLine("Base.Bar");
    }

    void IFoo.Bar()
    {
        Console.WriteLine("Base.IFoo.Bar");
        Bar();
    }
}

class DerivedSealed : Base
{
    public sealed override void Bar()
    {
        Console.WriteLine("DerivedSealed.Bar");
    }
}

class DerivedNew : DerivedSealed//, IFoo
{
    public new void Bar()
    {
        Console.WriteLine("DerivedNew.Bar");
    }
    /*
    void IFoo.Bar()
    {
        Console.WriteLine("DerivedNew.IFoo.Bar");
        Bar();
    }
    */
}

class BaseImplicit : IFoo
{
    public virtual void Bar()
    {
        Console.WriteLine("BaseImplicit.Bar");
    }
}

class DerivedExplicit : BaseImplicit, IFoo
{
    void IFoo.Bar()
    {
        Console.WriteLine("DerivedExplicit.Bar");
    }
}

你肯定已经知道这是怎么回事儿了!

先说后面一组,运行

var derivedExplicit = new DerivedExplicit();
BaseImplicit baseImplicit = derivedExplicit;
IFoo foo = baseImplicit;
baseImplicit.Bar();
foo.Bar();

得到的结果是

BaseImplicit.Bar
DerivedExplicit.Bar

再说后面的,如果不取消注释,运行 ((IFoo)new DerivedNew()).Bar() 得到的结果是

Base.IFoo.Bar
DerivedSealed.Bar

如果取消注释,得到的结果则是

DerivedNew.IFoo.Bar
DerivedNew.Bar

注意两个一起取消:如果只取消 //, IFoopublic new void Bar() 会成为新的隐式实现;如果只取消块注释,会得到编译错误。

此外,你会发现显式实现的时候,实现方法不能有任何修饰,效果将会永远是 private override。因此,可以做如下总结:

考虑接口 IFoo 的方法成员 TRet Bar(TArgs...args),如果类隐式实现为

class Foo : IFoo
{
    public [virtual]? TRet Bar(TArgs...args) { Impl; }
}

则效果上等同于这样的一个显式实现

class Foo : IFoo
{
    public [virtual]? TRet Bar(TArgs...args) { Impl; }
    TRet IFoo.Bar(TArgs...args) { return Bar(args...); }
}

如果原来的隐式实现有 virtual,则显式等效版本里面的公有方法是虚拟的,否则不是虚拟的,且显式等效版本里 IFoo.Bar 永远不是 sealed——除非类 Foo 本身是 sealed,此时它包含的 IFoo.Bar 是不是 sealed 已经无关紧要了。如果你稍后在已经密封了 public TRet Bar 的类的派生类里重新实现该接口,就等于说是 TRet IFoo.Bar 里面调用的 this.Bar 所指含义有所改变(是另一个同名的、用 new 关键字覆盖的方法,而不是 base.Bar)。

特别注意 上述变换以及实践告诉我们一个很重要的道理——当你拿到一个 Base 引用 b,你又知道 Base 隐式实现了 IFoo这不代表 b.Bar() 等价于 ((IFoo)b).Bar()——Ces deux invocations sont bien differentes ! 要切实地调用 b接口方法,你应该用 ((IFoo)b).Bar()。对于值类型则没有这个顾虑,因为值类型都是 sealed

哲理 隐式实现接口应该看成是显式实现的语法糖,应该认为引用类型上的所有接口都是显式实现,即使有对应的“隐式版本”。

我没看过 IL,但我想实际的情况应该要更高效,也就是实际实现应该是:

  • 如果 Foo 隐式实现 IFoo.Bar 没有 virtual,则以后派生不再更新 Foo 的虚方法表中 Bar 这一项;
  • 如果 FooDerived 继承 Foo,后者隐式实现 IFoo.Barvirtual,但 FooDerived 将其重写并密封,则以后派生不再更新 FooDerived 及其父类的虚方法表中 Bar 这一项;
  • 以上两种情况里 FooFooDerived 实例(不再更具体化)的 IFoo 虚方法表的 Bar 和当前实例 Foo 虚方法表的 Bar 一致;
  • 无论类的虚方法表是否可以更新(是否密封),接口的虚方法表总是可以被派生类更新

CLR 的接口和 C++ 的“接口”

对比 CLR 的接口和用 C++ 虚拟继承实现的接口,CLR 可以有一个非常聪明的优化。因为 CLR 接口不含成员字段,且 CLR 只支持单根继承,可以做得不需要“指针调整”(C++ 里同时上虚拟继承和多重继承的时候,调整指向子对象的指针数值是非常麻烦的),任何方法的 this 都可以统一传入对象开始的位置(注意,实际的实现不是这样而且可以变化),而且对象只需要一个 RTTI 指针;C++ 里每个子对象都需要一个 RTTI 指针,指向合适的虚函数表(以及如果有虚拟基类,还需要还需要储存实际发生的偏移量之类的数据),因为在 C++ 里面各种奇怪的继承姿势都可以出现,即使我们按照成语,只用语义上的单根继承外加虚拟继承实现接口,我想也不会有编译器把它的结构优化到 CLR 那种可选的实现。

请启用 JavaScript 来查看由 Disqus 驱动的评论。