将基本类型(如int)实现为类的注意事项是什么?


27

设计和implenting面向对象的编程语言时,在某些时候你必须做出有关实现基本类型的选择(例如intfloatdouble或等价物)类或其他什么东西。显然,C系列语言倾向于将其定义为类(Java具有特殊的原始类型,C#将其实现为不可变的结构,等等)。

当基本类型实现为类时(在具有统一层次结构的类型系统中),我可以想到一个非常重要的优势:这些类型可以是根类型的适当Liskov子类型。因此,我们避免使用装箱/拆箱(显式或隐式),包装器类型,特殊方差规则,特殊行为等使语言复杂化。

当然,我可以部分理解语言设计者为什么要决定他们的工作方式:类实例往往会有一些空间开销(因为实例可能在其内存布局中包含vtable或其他元数据),因此原语/结构不需要拥有(如果语言不允许继承这些语言)。

空间效率(以及改善的空间局部性,尤其是在大型阵列中)是基本类型通常不是类的唯一原因吗?

我通常认为答案是肯定的,但是编译器具有转义分析算法,因此当实例(任何实例,不仅仅是基本类型)被证明是严格的时,它们可以推断出是否可以(有选择地)忽略空间开销本地。

以上是错误的,还是我还缺少其他东西?


Answers:


19

是的,这几乎取决于效率。但是您似乎低估了影响(或高估了各种优化的效果)。

首先,不只是“空间开销”。使基元装箱/堆分配也具有性能成本。GC分配和收集这些对象的压力更大。如果“原始对象”是应该不变的,那么这会变得更加困难。然后会有更多的高速缓存未命中(这都是由于间接的原因,因为给定数量的高速缓存中容纳的数据较少)。加上“加载对象的地址,然后从该地址加载实际值”这一事实,比“直接加载值”需要更多的指令。

其次,逃脱分析并不是更快的方法。它仅适用于不会逃脱的值。优化局部计算(例如循环计数器和计算的中间结果)当然很不错,并且可以带来可观的收益。但是,大多数值存在于对象和数组的字段中。当然,它们本身也可以进行转义分析,但是由于它们通常是可变的引用类型,因此它们的任何别名都会给转义分析带来重大挑战,现在必须证明那些别名(1)不会转义,以及(2)不会为消除分配而有所作为。

鉴于调用任何方法(包括getter)或将对象作为参数传递给任何其他方法都可以帮助对象逃逸,除了最琐碎的情况之外,在所有情况下都需要进行过程间分析。这更加昂贵和复杂。

在某些情况下,事情确实确实存在,并且无法合理优化。实际上,如果考虑C程序员多久经历一次分配堆内存的麻烦,其中就有很多。当包含int的对象转义时,转义分析也将不再适用于int。告别有效的原始字段

这与另外一点联系在一起:所需的分析和优化非常复杂,并且是一个活跃的研究领域。是否有任何一种语言实现是否达到了您建议的优化程度是有争议的,即使是这样,这也是一次罕见而艰苦的努力。当然,站在这些巨人的肩膀上比自己成为一个巨人要容易,但距离微不足道。如果有的话,不要在头几年的任何时候都表现出竞争优势。

但这并不是说这些语言不可行。显然他们是。只是不要以为它会像使用专用原语的语言那样行对行。换句话说,不要以足够聪明的编译器来迷惑自己。


在谈到转义分析时,我还指分配给自动存储(它不能解决所有问题,但是正如您所说的,它可以解决某些问题)。我也承认我低估了字段和别名会使逃逸分析失败的可能性更大。在谈到空间效率时,我最关心的是缓存未命中,因此感谢您解决这个问题。
Theodoros Chatzigiannakis 2015年

@TheodorosChatzigiannakis我在转义分析中包括更改分配策略(因为坦白地说,这似乎是它曾经用于的唯一方法)。

关于您的第二段:对象不一定总是需要进行堆分配或引用类型。实际上,如果不是这样,则使必要的优化变得相当容易。有关早期示例,请参见C ++的堆栈分配对象;有关将逃逸分析直接烘焙到该语言中的方法,请参见Rust的所有权系统。
阿蒙2015年

@amon我知道,也许我应该更清楚一点,但是OP似乎只对类似于Java和C#的语言感兴趣,因为引用语义和子类型之间的无损转换,堆分配几乎是强制性的(隐式的)。关于Rust的要点是,尽管使用了逃避分析的方法!

@delnan的确,我对提取存储详细信息的语言最感兴趣,但是请随意包括您认为相关的任何内容,即使不适用于这些语言。
Theodoros Chatzigiannakis 2015年

27

空间效率(以及改善的空间局部性,尤其是在大型阵列中)是基本类型通常不是类的唯一原因吗?

没有。

另一个问题是基本类型倾向于由基本操作使用。编译器需要知道,int + int它不会被编译为函数调用,而是会被编译为一些基本的CPU指令(或等效的字节码)。那时,如果您将int用作常规对象,则无论如何都必须有效地拆箱。

这些操作在子类型化方面也不能很好地发挥作用。您无法分派到CPU指令。您不能 CPU指令中分派。我的意思是子类型化的全部要点是,因此您可以D在可以使用的地方使用B。CPU指令不是多态的。为了使原语能够做到这一点,您必须使用分派逻辑将其操作包装起来,而分派逻辑的操作量是简单加法(或其他操作)的数倍。int成为类型层次结构的一部分的好处在密封/最终化后变得毫无意义。这就忽略了二进制运算符的调度逻辑带来的所有麻烦...

基本上,基本类型将需要有许多特殊规则,这些规则涉及编译器如何处理它们以及用户无论如何都可以使用它们的类型,因此,将它们完全区分通常会更简单。


4
检验任何处理整数和对象的动态类型语言的实现。最终的原始CPU指令可以很好地隐藏在运行时库中仅有某种特权的类实现中的方法中(操作符重载)。静态类型系统和编译器的细节看起来会有所不同,但这不是根本问题。在最坏的情况下,它只会使事情变得更慢。

3
int + int可以是常规语言级别的运算符,用于调用保证编译为(或表现为)本地CPU整数加法运算的内部指令。从中int继承的好处object不仅是从中继承另一种类型int的可能性,而且还包括int无框行为的可能性object。考虑C#泛型:您可以具有协方差和相反方差,但是它们仅适用于类类型-结构类型被自动排除,因为它们只能object通过(隐式,由编译器生成)装箱。
Theodoros Chatzigiannakis 2015年

3
@delnan-当然,尽管以我对静态类型实现的经验来说,由于每个非系统调用都归结为原始操作,因此开销会极大地影响性能,进而对采用产生更大的影响。
Telastyn

@TheodorosChatzigiannakis-太好了,因此您可以在没有有用的子/超类型的类型上获得差异和矛盾。实现该特殊运算符以调用CPU指令仍然使其具有特殊性。我并不反对这个想法-我在玩具语言中做过非常相似的事情,但是我发现在实施过程中存在一些实际的陷阱,并不能使这些事情像您期望的那样干净。
Telastyn

1
@TheodorosChatzigiannakis当然可以跨库界限进行内联,尽管它是“我想要的高端优化”购物清单中的另一项内容。我感到有必要指出,尽管完全正确而又不至于过于保守以至于毫无用处,这是非常棘手的。

4

在极少数情况下,您需要“基本类型”作为完整的对象(此处,对象是包含指向调度机制的指针或标记有可以由调度机制使用的类型的数据):

  • 您希望用户定义的类型能够从基本类型继承。通常不希望这样做,因为它会带来与性能和安全性相关的麻烦。这是一个性能问题,因为编译不能假定an int将具有特定的固定大小或没有重写任何方法,并且这是安全问题,因为ints的语义可能会被颠覆(考虑一个等于任意数字的整数,或者改变其价值,而不是一成不变)。

  • 您的基本类型具有超类型,并且您想要具有基本类型超类型的变量。例如,假设您的ints是Hashable,并且您想要声明一个带有Hashable参数的函数,该参数可能会接收常规对象,但还会接收ints。

    可以通过将此类类型设为非法来“解决”:摆脱子类型,并确定接口不是类型而是类型约束。显然,这降低了类型系统的表现力,并且这样的类型系统不再称为面向对象。有关使用此策略的语言,请参见Haskell。C ++处于中间位置,因为原始类型没有超类型。

    替代方法是对基本类型进行全部或部分装箱。装箱类型不必是用户可见的。本质上,您可以为每个基本类型定义内部装箱类型,并在装箱和基本类型之间进行隐式转换。如果装箱的类型具有不同的语义,这可能会很尴尬。Java存在两个问题:装箱的类型具有标识的概念,而基元仅具有值等效的概念,装箱的类型可为空,而基元始终有效。通过不提供值类型的身份概念,提供运算符重载以及默认情况下不使所有对象都为空,可以完全避免这些问题。

  • 您没有静态类型。变量可以包含任何值,包括原始类型或对象。因此,所有原始类型都必须始终装箱以保证强类型。

确实具有静态类型的语言非常适合在尽可能使用原始类型的情况下使用,并且只能退后使用盒装类型。尽管许多程序对性能不是很敏感,但是在某些情况下,原始类型的大小和组成极为相关:考虑大规模数字运算,您需要在内存中容纳数十亿个数据点。从切换doublefloat可能是C语言中可行的空间优化策略,但如果所有数字类型都始终装箱(因此至少将其一半的内存浪费在分派机制指针上),则几乎没有效果。当在本地使用盒装基本类型时,通过使用编译器内部函数来删除装箱是相当简单的,但是将您的语言的整体性能押注在“足够高级的编译器”上是短视的。


int在所有语言中,an 几乎都是不变的。
斯科特·惠特洛克

6
@ScottWhitlock我明白了您为什么会这么认为,但是通常原始类型是不可变的值类型。没有理智的语言可以更改数字7的值。但是,许多语言的确允许您将保存原始类型值的变量重新分配给其他值。在类似C的语言中,变量是命名的存储位置,其作用类似于指针。变量与其指向的值不同。一个int值是不可变的,但是一个int变量则不是。
阿蒙(Amon)2015年

1
@amon:没有理智的语言;只是Java:thedailywtf.com/articles/Disgruntled-Bomb-Java-Edition
Mason Wheeler'1

get rid of subtyping and decide that interfaces aren't types but type constraints.... such a type system wouldn't be called object-oriented any longer 但这听起来像是基于原型的编程,这绝对是OOP。
迈克尔

1
@ScottWhitlock的问题是,如果您随后拥有int b = a,则可以对b做一些改变a值的操作。在某些语言实现中,这是可能的,但通常认为它是病理性的和不期望的,与对数组执行的操作不同。
Random832

2

我知道,大多数实现都对此类施加了三个限制,这些限制使编译器可以在绝大多数时间有效地将原始类型用作基础表示。这些限制是:

  • 不变性
  • 最终性(无法得出)
  • 静态打字

编译器需要将原语放入基础表示形式的对象中的情况相对很少见,例如当Object引用指向它时。

这在编译器中添加了很多特殊情况处理,但不仅限于某些神话般的超高级编译器。这种优化是在主要语言的实际生产编译器中进行的。Scala甚至允许您定义自己的值类。


1

在Smalltalk中,它们(int,float等)都是一流的对象。该唯一的特例是,SmallIntegers的编纂,并由虚拟机的效率的原因区别对待,因此SmallInteger类不会承认的子类(这是不实际的限制。)请注意,这并不需要任何特殊考虑在程序员方面,区别在于自动例程,例如代码生成或垃圾回收。

Smalltalk编译器(源代码-> VM字节代码)和VM nativizer(字节代码->机器代码)都优化了生成的代码(JIT),从而减少了使用这些基本对象进行基本操作的代价。


1

我正在设计OO语言和运行时(由于一组完全不同的原因而失败)。

像int true class这样的东西本质上没有错。实际上,这使GC更易于设计,因为现在只有2种堆头(类和数组),而不是3种(类,数组和基元)[事实上,我们可以在此之后合并类和数组,这一点无关紧要。 ]。

真正重要的情况是基本类型应该主要具有final / sealed方法(+确实很重要,ToString没那么重要)。这使编译器可以静态解析几乎所有对函数本身的调用并内联它们。在大多数情况下,这与复制行为无关(我选择使嵌入在语言级别可用(.NET也是如此)),但是在某些情况下,如果未密封方法,则编译器将被迫生成对用于实现int + int的函数。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.