9

有时我们有几个类,它们的某些方法具有相同的签名,但不对应于已声明的 Java 接口。例如,JTextFieldJButton(以及 中的其他几个 javax.swing.*)都有一个方法

public void addActionListener(ActionListener l)

现在,假设我希望对具有该方法的对象做一些事情;然后,我想要一个接口(或者自己定义它),例如

  public interface CanAddActionListener {
      public void addActionListener(ActionListener l);
  }

这样我就可以写:

  public void myMethod(CanAddActionListener aaa, ActionListener li) {
         aaa.addActionListener(li);
         ....

但是,可悲的是,我不能:

     JButton button;
     ActionListener li;
     ...
     this.myMethod((CanAddActionListener)button,li);

这种演员阵容是非法的。编译器知道JButton 不是a CanAddActionListener,因为该类尚未声明实现该接口......但是它“实际上”实现了它

这有时会带来不便——Java 本身已经修改了几个核心类来实现由旧方法(String implements CharSequence例如 )组成的新接口。

我的问题是:为什么会这样?我理解声明一个类实现一个接口的实用性。但是无论如何,看看我的例子,为什么编译器不能推断出类JButton“满足”接口声明(查看它的内部)并接受强制转换?是编译器效率的问题还是有更根本的问题?

我对答案的总结:在这种情况下,Java 可以允许一些“结构类型”(有点像鸭子类型 - 但在编译时检查)。它没有。除了一些(对我来说不清楚)性能和实现方面的困难之外,这里还有一个更基本的概念:在 Java 中,接口(通常是所有东西)的声明不仅仅是结构性的(具有这些签名)但语义:这些方法应该实现一些特定的行为/意图。因此,在结构上满足某些接口的类(即,它具有具有所需签名的方法)不一定在语义上满足它(一个极端的例子:回想一下“标记接口”,它甚至没有方法!)。因此,Java 可以断言一个类实现了一个接口,因为(并且仅仅是因为)它已被显式声明。其他语言(Go、Scala)有其他的哲学。

4

6 回答 6

8

Java 使实现类明确声明它们实现的接口的设计选择就是这样——一种设计选择。可以肯定的是,JVM 已经针对这种选择进行了优化,并且实现另一种选择(例如,Scala 的结构类型)现在可能需要额外的成本,除非并且直到添加一些新的 JVM 指令。

那么设计选择究竟是什么?这一切都归结为方法的语义。考虑:以下方法在语义上是否相同?

  • 绘制(字符串图形形状名称)
  • 绘制(字符串手枪名称)
  • 绘制(字符串播放卡名)

这三种方法都有签名draw(String)。人类可能会从参数名称或通过阅读一些文档推断出它们具有不同的语义。机器有什么方法可以告诉它们不同吗?

Java 的设计选择是要求类的开发人员明确声明方法符合预定义接口的语义:

interface GraphicalDisplay {
    ...
    void draw(String graphicalShapeName);
    ...
}

class JavascriptCanvas implements GraphicalDisplay {
    ...
    public void draw(String shape);
    ...
}

毫无疑问,其中的draw方法JavascriptCanvas旨在与draw图形显示的方法相匹配。如果有人试图通过一个要拔出手枪的物体,机器可以检测到错误。

Go 的设计选择更加自由,允许事后定义接口。一个具体的类不需要声明它实现了什么接口。相反,新纸牌游戏组件的设计者可以声明提供纸牌的对象必须具有与签名匹配的方法draw(String)。这样做的优点是可以使用具有该方法的任何现有类而无需更改其源代码,但缺点是该类可能会拿出手枪而不是扑克牌。

鸭子类型语言的设计选择是完全放弃正式的接口并简单地匹配方法签名。任何接口(或“协议”)的概念都是纯地道的,没有直接的语言支持。

这些只是许多可能的设计选择中的三个。三者可以概括如下:

Java:程序员必须明确声明他的意图,机器会检查它。假设是程序员可能会犯语义错误(图形/手枪/卡片)。

Go:程序员必须至少声明他的部分意图,但是机器在检查它时要进行的操作更少。假设是程序员可能会犯笔误(整数/字符串),但不太可能犯语义错误(图形/手枪/卡片)。

鸭式打字:程序员无需表达任何意图,机器无需检查。假设是程序员不太可能犯文书或语义错误。

解决接口和一般类型是否足以测试文书和语义错误超出了此答案的范围。完整的讨论必须考虑构建时编译器技术、自动化测试方法、运行时/热点编译和许多其他问题。

众所周知,该draw(String)示例被故意夸大以表明观点。实际示例将涉及更丰富的类型,这些类型将为消除方法的歧义提供更多线索。

于 2011-04-17T05:28:44.267 回答
5

为什么编译器不能推断出 JButton 类“满足”接口声明(查看它的内部)并接受强制转换?是编译器效率的问题还是有更根本的问题?

这是一个更根本的问题。

接口的重点是指定有许多类支持的通用 API/行为集。因此,当一个类被声明为 时implements SomeInterface,该类中任何其签名与接口中的方法签名匹配的方法都被假定为提供该行为的方法。

相比之下,如果语言只是简单地匹配基于签名的方法......而不考虑接口......那么我们很可能会得到错误的匹配,当两个具有相同签名的方法实际上意味着/做一些语义上不相关的事情时。

(后一种方法的名称是“duck typing”……Java 不支持它。)


关于类型系统的维基百科页面说鸭子类型既不是“主格类型”也不是“结构类型”。相比之下,皮尔斯甚至没有提到“鸭子类型”,但他将主格(或他称之为“名义”)类型和结构类型定义如下:

“像 Java 这样的类型系统,其中 [类型] 的名称是重要的并且子类型被显式声明,被称为名义系统。像本书中的大多数类型系统,其中名称是无关紧要的,子类型直接定义在类型,称为结构。”

因此,根据 Pierce 的定义,鸭子类型是结构类型的一种形式,尽管它通常使用运行时检查来实现。(Pierce 的定义独立于编译时与运行时检查。)

参考:

  • “类型和编程语言” - Benjamin C Pierce,麻省理工学院出版社,2002,ISBN 0-26216209-1。
于 2011-04-17T04:59:08.020 回答
2

我不能说我知道为什么 Java 开发团队会做出某些设计决定。我还警告我的回答是,这些人在软件开发和(尤其是)语言设计方面比我以往任何时候都聪明得多。但是,这是试图回答您的问题的一个技巧。

为了理解他们为什么没有选择使用像“CanAddActionListener”这样的接口,你必须看看不使用接口的优势,而是更喜欢抽象(最终是具体的)类。

您可能知道,在声明抽象功能时,您可以为子类提供默认功能。好吧……那又怎样?大不了,对吧?嗯,特别是在设计语言的情况下,这是一件大事。在设计语言时,您需要在语言的整个生命周期中维护这些基类(并且您可以确定随着语言的发展会发生变化)。如果您选择使用接口,而不是在抽象类中提供基本功能,那么任何实现该接口的类都会中断。这在发布后尤其重要 - 一旦客户(在这种情况下为开发人员)开始使用您的库,您就不能一时兴起更改界面,否则您将有很多开发人员生气!

所以,我的猜测是 Java 开发团队完全意识到他们的许多 AbstractJ* 类共享相同的方法名称,让它们共享一个公共接口是不利的,因为这会使他们的 API 变得僵化和不灵活。

总结一下(在这里感谢这个网站):

  • 抽象类可以很容易地通过添加新的(非抽象的)方法来扩展。
  • 如果不违反与实现它的类的约定,就无法修改接口。接口交付后,其成员集将永久固定。基于接口的 API 只能通过添加新接口来扩展。

当然,这并不是说您可以在自己的代码中执行类似的操作(扩展 AbstractJButton 并实现 CanAddActionListener 接口),但要注意这样做的缺陷。

于 2011-04-17T05:00:40.873 回答
2

很可能这是一个性能特征。

由于 Java 是静态类型的,因此编译器可以断言类与已识别接口的一致性。一旦经过验证,该断言可以在编译的类中表示为对符合接口定义的简单引用。

稍后,在运行时,当一个对象将其 Class 强制转换为接口类型时,运行时需要做的就是检查该类的元数据以查看它被强制转换的类是否也兼容(通过接口或继承层次结构)。

这是一个相当便宜的检查,因为编译器已经完成了大部分工作。

请注意,这不是权威。一个类可以说它符合一个接口,但这并不意味着即将执行的实际方法 send 将实际工作。符合标准的类很可能已经过时,并且该方法可能根本不存在。

但是 java 性能的一个关键组成部分是,虽然它实际上仍然必须在运行时执行某种形式的动态方法分派,但有一个约定,该方法不会在运行时后面突然消失。所以一旦方法被定位,它的位置就可以在以后被缓存。与动态语言相反,其中方法可能来来去去,每次调用时它们都必须继续尝试并寻找方法。显然,动态语言具有使其表现良好的机制。

现在,如果运行时要通过自己完成所有工作来确定对象是否符合接口,您可以看到这可能会增加多少成本,尤其是对于大型接口。例如,一个 JDBC ResultSet 有超过 140 种方法等等。

鸭子类型是有效的动态界面匹配。检查对象上调用了哪些方法,并在运行时映射它。

所有这些信息都可以被缓存,并在运行时构建,等等。所有这些都可以(并且在其他语言中),但是在编译时完成大部分工作实际上在运行时 CPU 和它的内存上都非常有效. 虽然我们将 Java 与多 GB 堆一起用于长时间运行的服务器,但它实际上非常适合小型部署和精简运行时。甚至在 J2ME 之外。因此,仍然有动力尝试保持运行时足迹尽可能精简。

于 2011-04-17T05:03:20.717 回答
2

由于 Stephen C 讨论过的原因,鸭子类型可能很危险,但破坏所有静态类型的不一定是邪恶。静态且更安全的鸭子类型版本是 Go 类型系统的核心,Scala 中提供了一个版本,称为“结构类型”。这些版本仍然执行编译时检查以确保对象符合要求,但存在潜在问题,因为它们破坏了实现接口始终是有意决策的设计范式。

http://markthomas.info/blog/?p=66http://programming-scala.labs.oreilly.com/ch12.htmlhttp://beust.com/weblog/2008/02/11/ structure-typing-vs-duck-typing/讨论 Scala 特性。

于 2011-04-17T05:12:54.310 回答
0

接口代表一种替代类的形式。可以将实现或继承自特定接口的类型的引用传递给期望该接口类型的方法。接口通常不仅会指定所有实现类必须具有具有特定名称和签名的方法,而且通常还会具有关联的契约,该契约指定所有合法的实现类必须具有具有特定名称和签名的方法,这些方法在特定指定的方式。即使两个接口包含具有相同名称和签名的成员,一个实现也完全有可能满足其中一个的约定,但不满足另一个。

举个简单的例子,如果从头开始设计一个框架,可能会从一个Enumerable<T>接口开始(可以根据需要经常使用它来创建一个枚举器,该枚举器将输出一系列 T,但不同的请求可能会产生不同的序列) , 但随后从它派生出一个接口,该接口ImmutableEnumerable<T>的行为与上述相同,但保证每个请求都将返回相同的序列。可变集合类型将支持 所需的所有成员ImmutableEnumerable<T>,但由于在突变后收到的枚举请求将报告与之前发出的请求不同的序列,因此它不会遵守ImmutableEnumerable合同。

接口被视为在其成员签名之外封装契约的能力是使基于接口的编程在语义上比简单的鸭式编程更强大的原因之一。

于 2014-06-13T19:10:32.430 回答