简而言之,开放递归与面向对象编程实际上无关,而更为一般化。与面向对象编程的关系在于许多典型的“面向对象”程序语言具有这样的特性,但它本质上并没有与任何区别于面向对象编程的独特特征联系起来。因此,在同一“面向对象”语言中可能存在不同的含义。我将在后面进行阐述。
词源学方面,正如
这里所提到的,这个术语很可能是由著名的
BCP的TAPL所创造的,通过具体的面向对象语言来说明其含义。
TAPL没有正式定义“开放递归”。相反,它指出了“self”(或“this”)的“特殊行为是它是“晚期绑定”的,允许在一个类中定义的方法调用另一个稍后在某个子类中定义的方法”的事实。
尽管如此,“open”和“递归”都不是面向对象语言的基础。事实上,这与静态类型也无关。因此,在那个资源中的解释(或者如果有的话,非正式定义)具有过度规定性质。
歧义
TAPL中的提及清楚地表明,“递归”是关于“方法调用”的。然而,在真实语言中这并不是那么简单的,通常没有原始的语义规则来处理递归自身的调用。真实语言(包括被认为是面向对象语言的语言)通常会针对方法调用符号的符号表示指定此类调用的语义。作为语法设备,这些调用受到某种表达式的计算的影响,该表达式依赖于其子表达式的计算。这些计算意味着方法名称的解析,根据一些独立规则。具体而言,这些规则涉及名称解析,即确定子表达式中名称(通常是符号、标识符或某些“限定”名称表达式)的指称。名称解析通常遵守作用域规则。
另一方面,“后期绑定”属性强调如何找到命名方法的目标实现。这是特定调用表达式评估的快捷方式,但它并不够通用,因为除了方法之外的实体也可以具有这种“特殊”行为,甚至可以使这种行为变得不那么特殊。
这种不充分的处理带来了一个值得注意的歧义。也就是,“绑定”是什么意思。传统上,绑定可以被建模为一个(作用域)名称和其绑定值的对,即一个变量绑定。在“后期绑定”中的特殊处理中,允许的实体集更小:方法而不是所有命名实体。除了在元级别的语言规范中严重削弱语言规则的抽象能力之外,它并没有消除传统绑定含义的必要性(因为还有其他非方法实体),因此容易引起混淆。“后期绑定”的使用至少是一个糟糕命名的例子。比起“绑定”,更合适的名称应该是“分派”。
更糟糕的是,在TAPL中使用时,直接混淆了两个意义当处理“递归”。 “递归”行为涉及查找某个名称所表示的实体,而不仅限于方法调用(即使在那些面向对象的语言中也是如此)。
本章标题(案例研究:命令式对象)也暗示了一些不一致之处。显然,所谓的方法调用的后期绑定与命令式状态无关,因为分派的解析不需要可变元数据的调用。(在某些流行的实现方式中,虚拟方法表不需要可修改性。)
开放性
这里使用“开放”看起来像是模仿“开放”(lambda)术语。一个开放的术语有一些尚未绑定的名称,因此这样一个术语的缩减必须进行一些名称解析(以计算表达式的值),否则该术语不会被规范化(在评估中永远不会终止)。对于原始的演算法来说,“晚”或“早”的区别没有区别,因为它们是纯粹的,并且具有Church-Rosser属性,因此无论是否“晚”,都不会改变结果(如果它是规范化的)。
这在具有不同派发路径的语言中是不同的。即使派发本身所隐含的隐式评估是纯粹的,它也对其他具有副作用的评估之间的顺序敏感,这些评估可能依赖于具体的调用目标(例如,一个覆盖者可能会改变一些全局状态,而另一个则不能)。当然,在严格纯净的语言中,即使针对任何根本不同的调用目标,也不会有任何可观察到的差异,因此排除所有这些调用目标的语言是没有用的。
然后还有另一个问题:为什么它是面向对象特定的(如TAPL所述)?考虑到开放性是限定“绑定”而不是“方法调用分派”,肯定有其他手段可以获得开放性。
一个值得注意的例子是传统Lisp方言中过程体的评估。在过程体中可能存在未绑定的符号,它们只能在调用过程时(而不是定义时)解析。由于Lisp在PL历史上非常重要,并且它们与λ演算密切相关,将“开放”专门归因于面向对象语言(而不是Lisp)从PL传统上更为奇怪。(这也是上面提到的“使它们一点也不特别”的情况:函数体中的每个名称默认都是“开放”的。)
有人认为,面向对象编程中的self
/this
参数风格等价于从(隐式)过程的环境进行某些闭包转换的结果。将这些特性视为语言语义中的原始特性是值得质疑的。
(值得注意的是,在其他表达式中的符号解析调用的特殊处理是由Lisp-2方言开创的,而不是任何典型的面向对象编程语言。)
更多案例
如上所述,“开放递归”的不同含义可以在同一个“面向对象”语言中共存。
C++是第一个实例,因为有充分的理由使它们共存。
在C++中,名称解析都是静态的,规范化为“名称查找”。名称查找的规则因不同作用域而异。其中大部分规则与C语言中的标识符查找规则一致(除了C语言中允许隐式声明而C++中不允许),即必须先声明名称,然后才能在源代码中(词法上)稍后查找该名称,否则程序就是非法的(并且在语言实现中必须发出错误)。这种依赖关系的严格要求相当“封闭”,因为没有后来的机会从错误中恢复,所以你不能直接在不同声明之间互相引用名称。
为了绕过这个限制,可以有一些额外的声明,其唯一职责是打破循环依赖关系。这些声明称为“前向”声明。使用前向声明仍然不需要“开放”递归,因为每个良好形成的用法必须静态地看到该名称的先前声明,因此每个名称查找不需要额外的“后期”绑定。
但是,C++类具有特殊的名称查找规则:类作用域中的某些实体可以在其声明之前的上下文中引用。这使得在不需要任何额外的“前向”声明来打破循环的情况下,可以跨不同声明相互递归使用名称。这正是TAPL意义上的“开放递归”,只是它与方法调用无关。
此外,根据TAPL中的描述,C++确实具有“开放递归”:即
this
指针和
virtual
函数。确定虚函数目标(覆盖者)的规则与名称查找规则无关。派生类中定义的非静态成员通常只是将与基类中相同名称的实体“隐藏”。调度规则仅在虚函数
调用之后的名称查找之后启动(顺序得到保证,因为C++函数调用的评估是
严格的或
应用的)。还可以通过
using
声明轻松引入基类名称,而不必担心实体类型。
这种设计可以看作是关注点分离的一个实例。名称查找规则允许在语言实现中进行一些通用的静态分析,而不需要对函数调用进行特殊处理。
另一方面,Java有一些更复杂的规则来混合名称查找和其他规则,包括如何识别覆盖者。在Java子类中的名称遮蔽是特定于实体类型的。对于不同种类的覆盖、重载、遮蔽和模糊,区分覆盖变得更加复杂。在子类的定义中也不能使用C++中的using声明技术。无论如何,这种复杂性并不会使Java比C++更或更少“面向对象”。
其他后果
将名称解析和方法调用的派发绑定合并导致了不仅是歧义、复杂性和混乱,而且还有元级别上的更多困难。这里的元指的是名称绑定可以暴露的属性不仅可用于源语言语义,而且还受到元语言的影响:要么是语言的形式语义,要么是其实现(例如实现解释器或编译器的代码)。
例如,就像传统Lisp中一样,可以区分
绑定时间和
评估时间,因为在绑定时间(在立即上下文中的值绑定)中揭示的程序属性与评估时间属性(如任意对象的具体值)相比更接近元属性。优化编译器可以根据绑定时间分析立即部署代码生成,要么静态地在编译时(当主体需要评估多次时),要么在运行时推迟(当编译过于昂贵时)。对于那些盲目假设所有封闭递归都比开放递归更快速(甚至在第一次语法上就使它们不同)的语言,没有这样的选项。在这种意义上,面向对象编程特定的开放递归不仅不像TAPL中所宣传的那样方便,而且是一种过早的优化:在语言设计中过早地放弃了
元编译,而不是在语言实现中。