接口默认方法是什么鬼

接口之所以成为接口,就在于它没有实现,只是声明。但后来一切都变了,Java 里出现了默认方法,C# 也出现了默认方法。接口已经不像传统意义上的接口,其概念开始向抽象类靠近,一个纯抽象的东西,突然出现了实体,于是开始傻傻分不清了。

世界已经变了,可他是怎么开始改变的呢?

1. 缘起

虽然本文有提到 Java,但是笔者近年主要还是在写 C# 程序,所以未明确语言的命名规范会更倾向 C# 的规范一些,敬请谅解。

曾经,我们定义了 IStringList 接口,它声明了一个列表:

这只是个例子,为了避免引入更多的技术概念,这里没有使用泛型举例。
interface IStringList {
    void Add(string o);    // 添加元素
    void Remove(int i);    // 删除元素
    string Get(int i);    // 获取元素
    int Length { get; }    // 获取列表长度
}

不管怎么说,这个列表已经拥有了基本的增删除改查功能,比如遍历,可以这样写

IStringList list = createList();
for (var i = 0; i < list.Length; i++) {
    string o = list.Get(i);
    // Do something with o
}

这个 IStringList 作为一个基础接口在类库中发布之后,大量的程序员使用了这个接口,实现了一堆各种各种各样的列表,像 StringArrayListLinkedStringListStringQueueStringStackSortedStringList……有抽象类,有扩展接口,也有各种实现类。总之,经过较长一段时间的积累,IStringList 的子孙遍布全球。

然后 IStringList 的发明者,决定为列表定义更多的方法,以适合在技术飞速发展下开发者们对 IStringList 使用便捷性的要求,于是

interface IStringList {
    int IndexOf(string o);            // 查找元素的索引,未找到返回 -1
    void Insert(string o, int i);   // 在指定位置插入元素
   
    // ------------------------------
    void Add(string o); // 添加元素
    void Remove(int i); // 删除元素
    string Get(int i);  // 获取元素
    int Length { get; } // 获取列表长度
}

当然,接口变化之外所有实现类都必须实现它,不然编译器会报错,基础库的抽象类 AbstractStringList 中实现了上述新增加的接口。整个基础库完美编译,发布了 2.0 版本。

然而,现实非常残酷!

基础库的用户们(开发者)发出了极大的报怨声,因为他们太多代码编译不过了!

是的,并不是所有用户都会直接继承 AbstractStringList,很多用户直接实现了 IStringList。还有不少用户甚至扩展了 IStringList,但他们没有定义 int IndexOf(string o) 而是定义的 int Find(string o)。由于基础库接口 IStringList 的变化,用户们需要花大量地时间去代码来实现 IStringList 中定义的新方法。

这个例子是提到了 IStringList,只添加了两个方法。这对用户造成的麻烦虽然已经不小,但工作量还算可以接受。但是想想 JDK 和 .NET Framework/Core 庞大的基础库,恐怕用户只能用“崩溃”来形容!

2. 办法

肯定不能让用户崩溃,得想办法解决这个问题。于是,Java 和 C# 的两个方案出现了

  • Java 提出了默认方法,即在接口中添加默认实现
  • C# 提出了扩展方法,即通过改变静态方法的调用形式来假装是对象调用

不得不说 C# 的扩展方法很聪明,但它毕竟不是真正对接口进行扩展,所以在 C# 8 中也加入了默认方法来解决接口扩展造成的问题。

接口扩展方法提出来之后,虽然解决了默认实现的问题,却又带出了新的问题。

  • 接口实现了默认方法,实现接口的类还需要实现吗?如果不实现会怎么样?
  • 无论 Java 还是 C# 都不允许类多继承,但是接口可以。而接口中的默认实现带来了类似于类多继承所产生的问题,怎么办?
  • 在复杂的实现和继承关系中,最终执行的到底会是哪一个方法?

3. 问题一,默认方法和类实现方法的关系

忽略上面 IStringList 接口中补充的 Insert(Object, int) 方法,我们把关注点放在 IndexOf(Object) 上。Java 和 C# 的语法异曲同工:

3.1. 先来看看默认方法的语法

  • Java 版
interface StringList {
    void add(Object s);
    void remove(int i);
    Object get(int i);
    int getLength();

    default int indexOf(Object s) {
        for (int i = 0; i < getLength(); i++) {
            if (get(i) == s) { return i; }
        }
        return -1;
    }
}
  • C# 版
interface IStringList
{
    public void Add(string s);
    void Remove(int i);
    string Get(int i);
    int Length { get; }
    int IndexOf(string s)
    {
        for (var i = 0; i < Length; i++)
        {
            if (Get(i) == s) { return i; }
        }
        return -1;
    }
}
这里把 C# 和 Java 的接口都写出来,主要是因为二者讲法和命名规范略有不同。接下来进行的研究 C# 和 Java 行为相似的地方,就主要以 C# 为例了。

怎么区分是 C# 示例还是 Java 示例?看代码规范,最明显的是 C# 方法用 Pascal 命名规则,Java 方法用 camel 命名规则。当然,还有 Lambda 的箭头也不一样。

接下来的实现,仅以 C# 为例:

class MyList : IStringList
{
    List<string> list = new List<string>();  // 偷懒用现成的

    public int Length => list.Count;
    public void Add(string o) => list.Add(o);
    public string Get(int i) => list[i];
    public void Remove(int i) => list.RemoveAt(i);
}

MyList 没有实现 IndexOf,但是使用起来不会有任何问题

class Program
{
    static void Main(string[] args)
    {
        IStringList myList = new MyList();
        myList.Add("First");
        myList.Add("Second");
        myList.Add("Third");
        
        Console.WriteLine(myList.IndexOf("Third"));  // 输出 2
        Console.WriteLine(myList.IndexOf("first"));  // 输出 -1,注意 first 大小写
    }
}

3.2. 在 MyList 中实现 IndexOf

现在,在 MyList 中添加 IndexOf,实现对字符串忽略大小写的查找:

// 这里用 partial class 表示是部分实现,
// 对不住 javaer,Java 没有部分类语法
partial class MyList
{
    public int IndexOf(string s)
    {
        return list.FindIndex(el =>
        {
            return el == s
                || (el != null && el.Equals(s, StringComparison.OrdinalIgnoreCase));
        });
    }
}

然后 Main 函数中输出的内容变了

Console.WriteLine(myList.IndexOf("Third")); // 还是返回 2
Console.WriteLine(myList.IndexOf("first")); // 返回 0,不是 -1

显然这里调用了 MyList.IndexOf()

3.3. 结论,以及 Java 和 C# 的不同之处

上面主要是以 C# 作为示例,其实 Java 也是一样的。上面的示例中是通过接口类型来调用的 IndexOf 方法。第一次调用的是 IStringList.IndexOf 默认实现,因为这时候 MyList 并没有实现 IndexOf;第二次调用的是 MyList.IndexOf 实现。笔者使用 Java 写了类似的代码,行为完全一致。

因此,对于默认方法,会优先调用类中的实现,如果类中没有实现具有默认方法的接口,才会去调用接口中的默认方法。

但是!!!前面的示例是使用的接口类型引用实现,如果换成实例类类型来引用实例呢?

如果 MyList 中实现了 IndexOf,那结果没什么区别。但是如果 MyList 中没有实现 IndexOf 的时候,Java 和 C# 在处理上有就区别了。

先看看 C# 的 Main 函数,编译不过(Compiler Error CS1929),因为 MyList 中没有定义 IndexOf

image.png

而 Java 呢?通过了,一如既往的运行出了结果!

image.png

从 C# 的角度来看,MyList 既然知道有 IndexOf 接口,那就应该实现它,而不能假装不知道。但是如果通过 IStringList 来调用 IndexOf,那么就可以认为 MyList 并不知道有 IndexOf 接口,因此允许调用默认接口。接口还是接口,不知道有新接口方法,没实现,不怪你;但是你明知道还不实现,那就是你的不对了。

但从 Java 的角度来看,MyList 的消费者并不一定是 MyList 的生产者。从消费者的角度来看,MyList 实现了 StringList 接口,而接口定义有 indexOf 方法,所以消费者调用 myList.indexOf 是合理的。

Java 的行为相对宽松,只要有实现你就用,不要管是什么实现。

而 C# 的行为更为严格,消费者在使用的时候可以通过编译器很容易了解到自己使用的是类实现,还是接口中的默认实现(虽然知道了也没多少用)。实际上,如果没在在类里面实现,接口文档中就不会写出来相关的接口,编辑器的智能提示也不会弹出来。实在要写,可以显示转换为接口来调用:

Console.WriteLine(((IStringList)myList).IndexOf("Third"));

而且根据上面的试验结果,将来 MyList 实现了 IndexOf 之后,这样的调用会直接切换到调用 MyList 中的实现,不会产生语义上的问题。

4. 问题二,关于多重继承的问题

无论 Java 还是 C# 都不允许类多继承,但是接口可以。而接口中的默认实现带来了类似于类多继承所产生的问题,怎么办?

举个例,人可以走,鸟也可以走,那么“云中君”该怎么走?

4.1. 先来看 C# 的

类中不实现默认接口的情况:

interface IPerson
{
    void Walk() => Console.WriteLine("IPerson.Walk()");
}

interface IBird
{
    void Walk() => Console.WriteLine("IBird.Walk()");
}

class BirdPerson : IPerson, IBird { }

调用结果:

BirdPerson birdPerson = new BirdPerson();
// birdPerson.Walk();           // CS1061,没有实现 Walk
((IPerson)birdPerson).Walk();   // 输出 IPerson.Walk()
((IBird)birdPerson).Walk();     // 输出 IBird.Walk()

不能直接使用 birdPerson.Walk(),道理前面已经讲过。不过通过不同的接口类型来调用,行为是不一致的,完全由接口的默认方法来决定。这也可以理解,既然类没有自己的实现,那么用什么接口来引用,说明开发者希望使用那个接口所规定的默认行为。

说得直白一点,你把云中君看作人,他就用人的走法;你把云中君看作鸟,它就用鸟的走法。

然而,如果类中有实现,情况就不一样了:

class BirdPerson : IPerson, IBird
{
    // 注意这里的 public 可不能少
    public void Walk() => Console.WriteLine("BirdPerson.Walk()");
}
BirdPerson birdPerson = new BirdPerson();
birdPerson.Test();              // 输出 BirdPerson.Walk()
((IPerson)birdPerson).Walk();   // 输出 BirdPerson.Walk()
((IBird)birdPerson).Walk();     // 输出 BirdPerson.Walk()

输出完全一致,接口中定义的默认行为,在类中有实现的时候,就当不存在!

云中君有个性:不管你怎么看,我就这么走。

这里唯一需要注意的是 BirdPerson 中实现的 Walk() 必须声明为 public,否则 C# 会把它当作类的内部行为,而不是实现的接口行为。这一点和 C# 对实现接口方法的要求是一致的:实现接口成员必须声明为 public

4.2. 接着看 Java 的不同

转到 Java 这边,情况就不同了,编译根本不让过

interface Person {
    default void walk() {
        out.println("IPerson.walk()");
    }
}

interface Bird {
    default void walk() {
        out.println("Bird.walk()");
    }
}

// Duplicate default methods named walk with the parameters () and ()
// are inherited from the types Bird and Person
class BirdPerson implements Person, Bird { }

这个意思就是,PersonBird 都为签名相同的 walk 方法定义了默认现,所以编译器不知道 BirdPerson 到底该怎么办了。那么如果只有一个 walk 有默认实现呢?

interface Person {
    default void walk() {
        out.println("IPerson.walk()");
    }
}

interface Bird {
    void walk();
}

// The default method walk() inherited from Person conflicts
// with another method inherited from Bird
class BirdPerson implements Person, Bird { }

这意思是,两个接口行为不一致,编译器还是不知道该怎么处理 BirdPerson

总之,不管怎么样,就是要 BirdPerson 必须实现自己的 walk()。既然 BirdPerson 自己实现了 walk(),那调用行为也就没有什么悬念了:

BirdPerson birdPerson = new BirdPerson();
birdPerson.walk();              // 输出 BirdPerson.walk()
((Person) birdPerson).walk();   // 输出 BirdPerson.walk()
((Bird) birdPerson).walk();     // 输出 BirdPerson.walk()

4.3. 结论,多继承没有问题

如果一个类实现的多个接口中定义了相同签名的方法,没有默认实现的情况下,当然不会有问题。

如果类中实现了这个签名的方法,那无论如何,调用的都是这个方法,也不会有问题。

但在接口有默认实现,而类中没有实现的情况下,C# 将实际行为交给引用类型去处理;Java 则直接报错,交给开发者去处理。笔者比较赞同 C# 的做法,毕竟默认方法的初衷就是为了不强制开发者去处理增加接口方法带来的麻烦。

5. 问题三,更复杂的情况怎么去分析

对于更复杂的情况,多数时候还是可以猜到会怎么去调用的,毕竟有个基本原则在那里。

5.1. 在类中的实现优先

比如,WalkBase 定义了 Walk() 方法,但没实现任何接口,BirdPersonWalkBase 继承,实现了 IPerson 接口,但没实现 Walk() 方法,那么该执行哪个 Walk 呢?

会执行 WalkBase.Walk()——不管什么情况下,类方法优先

class WalkBase
{
    public void Walk() => Console.WriteLine("WalkBase.Walk()");
}

class BirdPerson : WalkBase, IPerson { }

static void Main(string[] args)
{
    BirdPerson birdPerson = new BirdPerson();
    birdPerson.Walk();              // 输出 WalkBase.Walk()
    ((IPerson)birdPerson).Walk();   // 输出 WalkBase.Walk()
}

如果父类子类都有实现,但子类不是“重载”,而是“覆盖”实现,那要根据引用类型来找最近的类,比如

class WalkBase : IBird  // <== 注意这里实现了 IBird
{
    public void Walk() => Console.WriteLine("WalkBase.Walk()");
}

class BirdPerson : WalkBase, IPerson  // <== 这里_没有_实现 IBird
{
    // 注意:这里是 new,而不是 override
    public new void Walk() => Console.WriteLine("BirdPerson.Walk()");
}

static void Main(string[] args)
{
    BirdPerson birdPerson = new BirdPerson();
    birdPerson.Walk();              // 输出 BirdPerson.Walk()
    ((WalkBase)birdPerson).Walk();  // 输出 WalkBase.Walk()
    ((IPerson)birdPerson).Walk();   // 输出 BirdPerson.Walk()
    ((IBird)birdPerson).Walk();     // 输出 WalkBase.Walk()
}

如果 WalkBase 中以 virtual 定义 Walk(),而 BirdPerson 中以 override 定义 Walk(),那毫无悬念输出全都是 BirdPerson.Walk()

class WalkBase : IBird
{
    public virtual void Walk() => Console.WriteLine("WalkBase.Walk()");
}

class BirdPerson : WalkBase, IPerson
{
    public override void Walk() => Console.WriteLine("BirdPerson.Walk()");
}

static void Main(string[] args)
{
    BirdPerson birdPerson = new BirdPerson();
    birdPerson.Walk();              // 输出 BirdPerson.Walk()
    ((WalkBase)birdPerson).Walk();  // 输出 BirdPerson.Walk()
    ((IPerson)birdPerson).Walk();   // 输出 BirdPerson.Walk()
    ((IBird)birdPerson).Walk();     // 输出 BirdPerson.Walk()
}

上面示例中的候最后一句输出,是通过 IBird.Walk() 找到 WalkBase.Walk(),而 WalkBase.Walk() 又通过虚方法链找到 BirdPerson.Walk(),所以输出仍然是 BirdPerson.Walk()。学过 C++ 的同学这时候可能就会很有感觉了!

至于 Java,所有方法都是虚方法。虽然可以通过 final 让它非虚,但是在子类中不能定义相同签名的方法,所以 Java 的情况会更简单一些。

5.2. 类中无实现,根据引用类型找最近的默认实现

还是拿 WalkBaseBirdPerson 分别实现了 IBirdIPerson 的例子,

class WalkBase : IBird { }
class BirdPerson : WalkBase, IPerson { }

((IPerson)birdPerson).Walk();   // 输出 IPerson.Walk()
((IBird)birdPerson).Walk();     // 输出 IBird.Walk()

哦,当然 Java 中不存在,因为编译器会要求必须实现 BirdPerson.Walk()

5.3. 如何还有更复杂的情况

讲真,如果真的还有更复杂的情况,我建议还是做做实验吧!

6. 慎用默认方法

默认方法的出现有其历史原因,所以在设计一个新库的时候,最好不要过早考虑默认方法这个问题。如果真的有需要实现的默认行为,可能还是抽象的基类更适合一些。

但是,如果设计出来的类和接口关系确实非常复杂,甚至需要类似多重继承的关系,那么适当的考虑一下默认方法也未尝不可。

阅读 1k

推荐阅读
边城客栈
用户专栏

全栈技术专栏

3148 人关注
77 篇文章
专栏主页