跨语言互操作性的挑战(2013)

2020-05-25 04:15:16

2013年11月19日第11卷第10期自从第二种编程语言发明以来,语言之间的互操作性一直是个问题。解决方案的范围从独立于语言的对象模型(如COM(组件对象模型)和CORBA(公共对象请求代理体系结构))到为集成语言而设计的VM(虚拟机),如JVM(Java虚拟机)和CLR(公共语言运行时)。随着软件变得越来越复杂,硬件变得不那么同质化,单一语言成为整个程序的正确工具的可能性比以往任何时候都要低。随着现代编译器变得更加模块化,有可能出现新一代有趣的解决方案。

1961年,英国公司Stantec发布了一款名为斑马的计算机,这款计算机之所以有趣,原因有很多,尤其是它的基于数据流的指令集。斑马很难使用其本机指令集的完整形式进行编程,因此它还包含了一个更传统的版本,称为简单代码(Simple Code)。该表单附带了一些限制,包括每个程序最多只能有150条指令。这本手册很有帮助地告诉用户,这并不是一个严重的限制,因为不可能有人会编写一个如此复杂的工作程序,以至于需要超过150条指令。

如今,这种说法似乎很可笑。即使是相对低级语言(如C)中的简单函数在编译后也有150多条指令,而且大多数程序都远远不止一个函数。从编写汇编代码到用高级语言编写的转变极大地增加了可能的程序的复杂性,各种软件工程实践也是如此。

软件复杂性增加的趋势丝毫没有减弱的迹象,现代硬件带来了新的挑战。20世纪90年代末,程序员不得不瞄准那些抽象型号很像快速PDP-11的低端PC。在高端,他们会遇到像非常快的PDP-11这样的抽象模型,可能有2到4个(相同的)处理器。现在,移动电话开始出现具有相同ISA(指令集体系结构)但速度不同的八个内核,一些针对不同工作负载(DSP、GPU)优化的其他流处理器,以及其他专用内核。

表示类的高级语言(类似于人类对问题领域的理解)和表示类的低级语言(类似于硬件)之间的传统划分不再适用。没有一种低级语言具有接近可编程数据流处理器、x86CPU、大规模多线程GPU和VLIW(超长指令字)DSP(数字信号处理器)的语义。想要从可用的硬件中获得最后一点性能的程序员不再有一种语言可以用于所有可能的目标。

类似地,在抽象光谱的另一端,特定于领域的语言正变得越来越流行。高级语言通常以通用性换取高效表示算法子集的能力。更通用的高级语言(如Java)牺牲了直接操作指针的能力,以换取为程序员提供更抽象的内存模型。SQL等专用语言使某些类别的算法无法实现,但使其领域内的常见任务可以用几行代码来表示。

您不能再期望一个重要的应用程序是用一种语言编写的。高级语言通常调用用低级语言编写的代码作为其标准库的一部分(例如,GUI呈现),但是添加调用可能很困难。

特别是,两种非C语言之间的接口通常很难构建。即使是相对简单的示例,如C++和Java之间的桥接,通常也不会自动处理,需要C接口。Kaffe Native Interface4确实提供了这样做的机制,但它没有被广泛采用,而且有局限性。

在接下来的几年里,语言之间的接口问题对编译器编写人员来说将变得越来越重要。它提出了许多挑战,在这里详细说明。

面向对象的语言将代码和数据的一些概念绑定在一起。艾伦·凯(Alan Kay)在施乐公司(Xerox Parc)时帮助开发了面向对象的编程,他将对象描述为通过消息传递进行通信的简单计算机。这个定义为不同语言填写细节留下了很大的回旋余地:

·一个对象是否应该有零个(如GO)、一个(如Smalltalk、Java、JavaScript、Objective-C)或多个(如C++、Self、Simula)超类或原型?

多重继承问题是最常见的焦点领域之一。单一继承很方便,因为它简化了实现的许多方面。只需追加字段即可扩展对象;对超类型的强制转换只涉及忽略结尾,而对子类型的强制转换仅涉及检查-指针值保持不变。C++中的向下转换需要通过运行时库函数复杂地搜索运行时类型信息中的继承图。

孤立地说,这两种类型的继承都是可以实现的,但是如果您想要将一个C++对象公开到Java中,该怎么办呢?您也许可以遵循.NET或Kaffe方法,并且仅支持与C++的子集(托管C++或C++/CLI)的直接互操作性,该子集仅支持将在屏障的Java端公开的类的单一继承。

一般来说,这是一个很好的解决方案:定义一种语言的子集,该子集清晰地映射到另一种语言,但可以理解另一种语言的全部功能。这就是务实的Smalltalk中采用的方法:5允许Objective-C++对象(可以将C++对象作为实例变量并调用它们的方法)直接公开,就像它们是Smalltalk对象一样,共享相同的底层表示。

然而,这种方法仍然存在认知障碍。如果您想要直接使用C++框架,例如务实的Smalltalk或.NET中的LLVM,那么您将需要编写单继承类来封装该库用于其大部分核心类型的多重继承类。

另一种可能的方法是避免公开对象中的任何字段,而只将每个C++类公开为接口。然而,如果没有特殊的编译器支持,就不可能从桥接的类继承,从而无法理解某些接口是随实现一起来的。

虽然复杂,但与在方法查找含义不同的语言之间交互相比,这是一个更简单的系统。例如,Java和Smalltalk具有几乎相同的对象和内存模型,但是Java将方法分派的概念绑定到类层次结构,而在Smalltalk中,如果两个对象实现同名的方法,则它们可以互换使用。

这是Redline Smalltalk遇到的问题,1它编译Smalltalk以便在JVM上运行。它实现Smalltalk方法分派的机制包括为每个方法生成一个Java接口,然后在分派之前执行接收器到相关接口类型的强制转换。向Java类发送消息需要额外的信息,因为现有的Java类没有实现这一点;因此,Redline Smalltalk必须退回到使用Java的反射API。

Smalltalk(和Objective-C)的方法查找更为复杂,因为有许多在其他语言中缺失或受限的第二次机会分派机制。在将Objective-C编译为JavaScript时,而不是使用JavaScript方法调用,您必须将发送的每个Objective-C消息包装在一个小函数中,该函数首先检查该方法是否实际存在,如果不存在,则调用一些查找代码。

这在JavaScript中相对简单,因为它以一种方便的方式处理各种函数:如果调用一个函数或方法时使用的参数比它预期的多,那么它会将剩余的参数作为它可以预期的数组来接收。围棋也会做类似的事情。类C语言只是将它们放在堆栈上,并期望程序员在不进行错误检查的情况下进行写入。

内存模型中明显的二分法是在自动释放和手动释放之间。一个稍微重要一点的问题是确定性破坏和非确定性破坏之间的区别。

在许多情况下,使用Boehm-Demers-Weiser垃圾收集器3运行C是没有问题的(除非内存用完并且有很多看起来像指针的整数)。对于C++来说,做同样的事情要困难得多,因为对象释放是一个可观察到的事件。请考虑以下代码:

LockHolder类定义了一个非常简单的对象;互斥体传入对象,然后在其构造函数中锁定互斥体,并在析构函数中解锁。现在,想象一下在完全垃圾回收的环境中运行相同的代码-析构函数的运行时间没有定义。

这个例子相对简单,很容易做对。此时需要垃圾收集的C++实现来运行析构函数,但不需要释放对象。这个习惯用法在从一开始就支持垃圾收集的语言中是不可用的。混合它们的基本问题不是确定谁负责释放内存;相反,为一个模型编写的代码需要确定性操作,而为另一个模型编写的代码不需要确定性操作。

有两种简单的方法来实现C++的垃圾收集:第一种是让删除操作符调用析构函数,但不回收底层存储;另一种是将删除设为无操作,并在检测到对象不可访问时调用析构函数。

只调用delete的析构函数在这两种情况下是相同的:它们实际上是无操作的。释放其他资源的析构函数是不同的。在第一种情况下,它们可以确定地运行,但如果程序员不显式删除相关对象,它们将无法运行。在第二种情况下,可以保证它们最终会运行,但不一定会在底层资源耗尽时运行。

此外,在许多语言中,一个相当常见的习惯用法是自有对象,它等待某个事件或执行一个长时间运行的任务,然后触发回调。然后,回调的接收方负责清理通知器。当它处于活动状态时,它与对象图的其余部分断开连接,因此看起来是垃圾。必须明确告诉收集器它不是。这与没有自动垃圾回收的语言中的模式相反,在没有自动垃圾回收的语言中,除非系统另行通知,否则假定对象是活动的。(汉斯·博姆(Hans Boehm)在1996年的一篇论文中更详细地讨论了其中一些问题。2)。

所有这些问题都出现在苹果将垃圾收集添加到Objective-C的失败(谢天谢地,不再支持)尝试中。很多Objective-C代码依赖于-dealloc方法中的运行代码。另一个问题与互操作性问题密切相关。该实现同时支持跟踪内存和未跟踪内存,但不在类型系统中公开此信息。请考虑以下代码片段:

void allocateSomeObjects(id*buffer,int count){for(int i=0;i<;count;i++)for(int i=0;i<;count;i++)for(id allocateSomeObjects(id*buffer,int count)){0}{0}。

在垃圾收集模式下,无法判断此代码是否正确。它的正确与否取决于呼叫者。如果调用方传递使用NSAllocateCollectable()分配的缓冲区、将NSScannedOption作为第二个参数分配的缓冲区,或者使用堆栈上分配的缓冲区或使用垃圾收集支持编译的编译单元中的全局缓冲区分配的缓冲区,则对象将(至少)与缓冲区一样长。如果调用方传递使用malloc()分配的缓冲区,或者作为C或C++编译单元中的全局缓冲区,那么对象将(可能)在缓冲区之前被释放。这句话中的“潜在”使这成为一个更大的问题:因为它是不确定的,所以很难调试。

Objective-C的ARC(自动引用计数)扩展不提供完整的垃圾收集(它们仍然允许垃圾周期泄漏),但它们确实扩展了类型系统以定义此类缓冲区的所有权类型。将对象指针复制到C需要插入包含所有权转移的显式强制转换。

引用计数还解决了非循环数据的确定性问题。此外,它还提供了一种与手动内存管理互操作的有趣方式:通过设置free()来递减引用计数。循环(或潜在循环)数据结构需要添加循环检测器。大卫·F·培根(David F.Bacon)在IBM的团队已经为周期检测器8设计了许多产品,只要指针能够被准确识别,引用计数就可以成为一种完整的垃圾收集机制。

不幸的是,循环检测涉及从潜在的循环对象遍历整个对象图。可以采取一些简单的步骤来降低这一成本。最明显的一个就是推迟它。如果对象的引用计数递减但未释放,则该对象仅可能是周期的一部分。如果它后来递增,那么它就不是垃圾循环的一部分(它可能仍然是一个循环的一部分,但您现在还不在乎)。如果它稍后被释放,则它是非循环的。

周期检测延迟的时间越长,得到的不确定性就越大,但是周期检测器要做的工作就越少。

如今,大多数人认为异常是由C++普及的:大致等同于C中的setjmp()和long jmp(),尽管可能有不同的机制。

已经提出了一些其他例外机制。在Smalltalk-80中,异常完全在库中实现。该语言提供的唯一原语是,当您显式地从闭包返回时,您将从声明闭包的作用域返回。如果在堆栈中向下传递闭包,则返回将隐式展开堆栈。

当Smalltalk异常发生时,它会调用堆栈顶部的处理程序块。然后,这可能会返回,迫使堆栈展开,或者它可能会进行一些清理。堆栈本身是激活记录(即对象)的列表,因此可能会执行更复杂的操作。Common Lisp还提供了一组丰富的异常,包括那些支持立即恢复或重新启动的异常。

即使在具有类似异常模型的语言中,异常互操作性也很困难。例如,C++和Objective-C都有类似的异常概念,但是期望捕捉void*的C++catch块在遇到Objective-C对象指针时应该怎么做呢?在GNUstep Objective-C运行时6中,在决定不模仿Apple的分段错误行为后,我们选择不捕获它。OSX的最新版本都采用了这种行为,但这个决定有点武断。

即使您确实从C++捕获了对象指针,这也不意味着您可以使用它做任何事情。当它被捕获时,您已经丢失了所有类型信息,并且无法确定它是Objective-C对象。

当您开始考虑性能时,更微妙的问题就会悄悄出现。VMKit7的早期版本(在LLVM之上实现Java和CLRVM)使用为C++设计的零成本异常模型。这是零成本,因为进入try块不需要任何费用。然而,当抛出异常时,您必须解析一些描述如何展开堆栈的表,然后调用每个堆栈帧的个性函数来决定是否(以及在哪里)应该捕获异常。

这种机制非常适合C++,因为在C++中很少出现异常,但是Java使用异常来报告大量相当常见的错误情况。在基准测试中,放卷机的性能是一个限制因素。为了避免这种情况,针对可能引发异常的方法修改了调用约定。这些函数将异常作为第二个返回值返回(通常在不同的寄存器中),每个调用只需检查该寄存器是否包含0,如果不包含,则跳转到异常处理块。

当您控制每个调用方的代码生成器时,这很好,但在跨语言场景中情况并非如此。您可以通过向C添加另一个反映此行为的调用约定或提供类似于GO中通常用于返回错误条件的多返回值机制来解决此问题,但这将要求每个C调用者都了解外语语义。

当您开始将函数式语言包括在您希望与之互操作的集合中时,可变性的概念就变得很重要。像Haskell这样的语言没有可变类型。就地修改数据结构是编译器可以作为优化来做的事情,但这并不是在语言中公开的事情。

这是F#遇到的问题,它作为OCaml的一种方言出售,可以与其他.NET语言集成,使用C#编写的类,等等。C#已经有了可变和不可变类型的概念。这是一个非常强大的抽象,但不可变类只是一个不公开任何非只读字段的类,并且只读字段可以包含对对象的引用(通过任意引用链),这些对象引用的可变对象的状态可以在函数代码下更改。在其他语言(如C++或Objective-C)中,可变性通常是通过定义一些不可变的类在类系统中实现的,但是没有语言支持,也没有确定对象是否可变的简单方法。

在语言提供的类型系统中,C和C++具有非常不同的可变性概念:对对象的特定引用可能会修改它,也可能不会修改它,但这并不意味着对象本身不会改变。这一点,再加上深度复制问题,使得函数式语言和面向对象语言的接口成为一个难题。

单体为界面提供了一些诱人的可能性。单体是计算步骤的有序序列。在面向对象的世界中,这是一系列消息发送或方法调用。具有Const(常量)的C++概念的方法(即,不修改对象的状态)可以在Monad外部调用,因此服从推测性执行和回溯,而其他方法应该按照Monad定义的严格顺序调用。

可变性和并行性是密切相关的。编写可维护、可伸缩、并行代码的基本规则是,任何对象都不能既是可变的,又是别名的。在纯函数式语言中实施这一点是微不足道的:根本没有对象是可变的。Erlang以流程字典的形式对可变性做出了让步-一个仅可从当前Erlang进程访问的可变字典,因此永远不能共享。

将具有不同共享内容概念的语言连接起来会带来一些独特的问题。这对于以大规模并行系统或GPU为目标的语言来说很有趣,在这些系统或GPU中,语言的模型与底层硬件密切相关。

这是试图提取部分C/C++/Fortran程序以转换为OpenCL时遇到的问题。源语言通常将就地修改作为实现算法的最快方式,而OpenCL则鼓励对源缓冲区进行处理以生成输出b的模型。

..