为什么主流的强静态OOP语言阻止继承基元?


53

为什么这样可以正常进行并且通常可以预期:

abstract type Shape
{
   abstract number Area();
}

concrete type Triangle : Shape
{
   concrete number Area()
   {
      //...
   }
}

...虽然这还不行,但是没有人抱怨:

concrete type Name : string
{
}

concrete type Index : int
{
}

concrete type Quantity : int
{
}

我的动机是最大限度地使用类型系统进行编译时正确性验证。

PS:是的,我已经读过这篇文章,并且包装是一个棘手的解决方法。


1
评论不作进一步讨论;此对话已转移至聊天
maple_shaft

这个问题上我有类似的动机,您可能会发现它很有趣。
default.kramer

我要添加一个回答证实了“你不希望继承”的想法,而包装非常强大的,包括给你无论你想暗示或明示铸造(或失败)的,尤其是与JIT的优化建议你将无论如何,它都能获得几乎相同的性能,但是您已经链接到该答案了:-)我只会添加,如果语言添加了一些功能来减少转发属性/方法所需的样板代码,那会很好,尤其是在只有一个值的情况下。
马克·赫德

Answers:


83

我假设您正在考虑Java和C#等语言?

在这些语言中,原语(如int)基本上是性能的折衷方案。它们不支持对象的所有功能,但是它们更快,开销更少。

为了使对象支持继承,每个实例都需要在运行时“知道”它是实例的哪个类。否则,覆盖的方法将无法在运行时解析。对于对象,这意味着实例数据与指向类对象的指针一起存储在内存中。如果此类信息也应与原始值一起存储,则内存需求将迅速增加。一个16位整数值需要16位整数值,并需要32或64位内存来指向其类。

除了内存开销,您还希望能够覆盖诸如算术运算符之类的基元上的常见操作。无需子类型化,类似的运算符+就可以编译成简单的机器代码指令。如果它可以被覆盖,你将需要解决在运行时的方法,一个更昂贵的操作。(您可能知道C#支持运算符重载-但这并不相同。运算符重载是在编译时解决的,因此没有默认的运行时代价。)

字符串不是基元,但是它们在内存中的表示方式仍然是“特殊的”。例如,它们是“ interned”的,这意味着可以将相等的两个字符串文字优化为相同的引用。如果字符串实例也应该跟踪该类,则这是不可能的(或者至少效率要低得多)。

您所描述的内容肯定会很有用,但是支持它需要每次使用原语和字符串时都需要性能开销,即使它们没有利用继承。

Smalltalk 确实(我相信)允许整数的子类化。但是,在设计Java时,Smalltalk被认为太慢,而将所有内容作为对象的开销被认为是主要原因之一。Java牺牲了一些优雅和概念上的纯度来获得更好的性能。


12
@Den:string是密封的,因为它被设计为不可变的。如果可以从字符串继承,则可以创建可变的字符串,这将使其真正容易出错。包括.NET框架本身在内的大量代码都依赖于没有副作用的字符串。另请参阅此处,告诉您的是相同的内容:quora.com/Why-String-class-in-C-is-a-sealed-class
Doc Brown

5
@DocBrown这也是在Java中也进行String标记的原因final
2016年

47
“在设计Java时,Smalltalk被认为太慢[…]。Java为了获得更好的性能而牺牲了一些优雅和概念上的纯度。” –当然,具有讽刺意味的是,直到Sun收购了Smalltalk公司以访问Smalltalk VM技术之前,Java并没有真正获得这种性能,因为Sun自己的JVM速度很慢,并发布了HotSpot JVM,这是经过稍微修改的Smalltalk VM。
约尔格W¯¯米塔格

3
@underscore_d:您链接到答案非常明确地指出,C♯并不能有基本类型。当然,存在C♯实现的某些平台可能具有或可能没有原始类型,但这并不意味着C but具有原始类型。例如,为CLI提供了Ruby的实现,并且CLI具有原始类型,但这并不意味着Ruby具有原始类型。实现可能会或可能不会选择通过将值类型映射到平台的原始类型来实现值类型,但这是私有的内部实现细节,而不是规范的一部分。
约尔格W¯¯米塔格

10
这都是关于抽象的。我们必须保持头脑清醒,否则最终会胡说八道。例如:C♯是在.NET上实现的。.NET在Windows NT上实现。Windows NT在x86上实现。x86在二氧化硅上实现。SiO 2只是沙子。因此,stringC♯中的a只是沙子?不,当然不是,stringC♯规范中是这样说的。它的实现方式无关紧要。C♯的本机实现会将字符串实现为字节数组,ECMAScript实现会将它们映射到ECMAScript String,等等。
JörgW Mittag

20

一些语言提出的不是子类化,而是。例如,Ada允许您创建派生类型或子类型。在阿达编程/类型系统部分是值得一读,了解所有细节。您可以限制值的范围,这通常是您想要的:

 type Angle is range -10 .. 10;
 type Hours is range 0 .. 23; 

如果显式转换它们,则可以将这两种类型都用作整数。还要注意,即使范围在结构上是等效的(类型由名称检查),也不能用一个代替另一个。

 type Reference is Integer;
 type Count is Integer;

以上类型是不兼容的,即使它们表示相同的值范围。

(但是您可以使用Unchecked_Conversion;请不要告诉我我告诉过您的人)


2
实际上,我认为这更多是关于语义的。使用期望索引的数量可能会导致编译时错误
Marjan Venema

@MarjanVenema这样做,并且这样做是为了捕获逻辑错误。
coredump

我的观点是,并非所有需要语义的情况都需要范围。然后type Index is -MAXINT..MAXINT;,您将拥有某种对我不起作用的方式,因为所有整数都有效?因此,如果仅检查范围,我会通过哪种角度将角度传递给索引?
Marjan Venema

1
@MarjanVenema在第二个示例中,这两种类型都是Integer的子类型。但是,如果声明一个接受Count的函数,则不能传递引用,因为类型检查基于名称对等,这与“所有检查的都是范围”相反。这不限于整数,可以使用枚举类型或记录。(archive.adaic.com/standards/83rat/html/ratl-04-03.html
信息转储

1
@Marjan在Eric Lippert 关于在OCaml中实现Zorg系列文章中,可以找到一个很好的例子说明为什么标记类型会非常强大。这样做会使编译器捕获很多错误-另一方面,如果允许隐式转换类型,这似乎会使功能失效。.能够将PersonAge类型分配给PersonId类型只是没有语义因为它们碰巧具有相同的基础类型。
Voo

16

我认为这很可能是一个X / Y问题。问题的重点...

我的动机是最大限度地使用类型系统进行编译时正确性验证。

...并根据您的评论详细说明:

我不想隐式地用一个替代另一个。

如果我缺少某些东西,请原谅,但是...如果这些是您的目标,那么您为什么在地球上谈论继承?隐含的可替代性就像……它的全部。你知道,李斯科夫替代原则吗?

实际上,您似乎想要的是“ strong typedef”的概念-即int在范围和表示形式上某些“是” ,不能替换为期望an的上下文,int反之亦然。我建议您搜索有关该术语以及您选择的语言可能会调用的信息。再次,它实际上与继承相反。

对于那些可能不喜欢X / Y答案的人,我认为标题可能仍然可以参考LSP。原始类型之所以是原始的,是因为它们做的非常简单,这就是它们所做的全部。允许它们被继承,从而使它们可能产生的影响无限大,最好的情况将导致极大的惊奇,最坏的情况将导致致命的LSP违反。如果我乐观地认为泰勒斯·佩雷拉(Thales Pereira)不会介意我引述这一惊人的评论:

还有一个问题是,如果某人能够从Int继承,您将拥有无辜的代码,例如“ int x = y + 2”(其中Y是派生类),该代码现在将日志写入数据库,打开URL并以某种方式使猫王复活。原始类型应该是安全的,并且具有或多或少有保证的,明确定义的行为。

如果有人用理智的语言看到原始类型,他们会正确地认为它总是只会做一件小事,很好,不会感到惊讶。基本类型没有可用的类声明,这些类声明表明它们是否可以被继承,并覆盖其方法。如果确实如此,那确实是非常令人惊讶的(并且完全破坏了向后兼容性,但是我知道这是对“为什么X不设计Y的原因”的一个反向答案)。

……不过,正如Mooing Duck在回应中所指出的那样,允许运算符重载的语言使用户可以根据自己的意愿在相似或同等程度上混淆自己,因此最后一个参数是否成立还令人怀疑。我现在不再总结其他人的评论,呵呵。


4

为了允许使用虚拟分派8进行继承,这在应用程序设计中通常被认为是非常合乎需要的,因此需要一个运行时类型信息。对于每个对象,必须存储一些有关对象类型的数据。根据定义,原语缺少此信息。

有两种具有原语的(受管理,可在VM上运行)主流OOP语言:C#和Java。许多其他语言一开始就没有基元,或者使用类似的理由允许/使用它们。

基元是性能的折衷。对于每个对象,您需要为其对象标头(在Java中,通常为64位VM上通常为2 * 8字节)的空间,其字段以及最终的填充(在Hotspot中,每个对象占用的字节数是以下各项的倍数) 8)。因此,一个intas对象至少需要保留24个字节的内存,而不是4个字节(在Java中)。

因此,添加了原始类型以提高性能。它们使很多事情变得容易。a + b如果两者都是的子类型,那意味着什么int?必须添加某种令人沮丧的选项才能选择正确的添加项。这意味着虚拟调度。能够使用非常简单的操作码进行添加的功能要快得多,并且可以进行编译时优化。

String是另一种情况。在Java和C#中,String都是对象。但是在C#中是密封的,在Java中是最终的。那是因为Java和C#标准库都要求Strings是不可变的,而将它们子类化将打破这种不可变性。

在Java的情况下,VM可以(并且确实)执行内部字符串并“合并”它们,以实现更好的性能。这仅在字符串确实是不可变的时才有效。

另外,很少需要继承原始类型。只要不能对基元进行子类化,数学就会告诉我们很多整洁的东西。例如,我们可以确定加法是可交换的和关联的。这就是整数的数学定义所告诉我们的。此外,在许多情况下,我们可以通过归纳轻松地将不变量固定在循环上。如果我们允许对进行子类化int,则将失去数学提供的工具,因为我们无法再保证某些属性成立。因此,我想说不能继承基元类型的能力实际上是一件好事。某人可以破坏的事情更少,加上编译器通常可以证明他被允许进行某些优化。


1
这个答案很深……很窄。to allow inheritance, one needs runtime type information.假。For every object, some data regarding the type of the object has to be stored.假。There are two mainstream OOP languages that feature primitives: C# and Java.什么,C ++现在不是主流吗?我将其用作反驳,因为运行时类型信息 C ++术语。除非使用dynamic_cast或,否则绝对不需要typeid。而且即使 RTTI的上,继承仅仅消耗空间,如果一个类virtual,其必须为每个实例指出的方法,每类表方法
underscore_d

1
C ++中的继承工作原理与VM上运行的语言完全不同。虚拟分派需要RTTI,而这并不是C ++的一部分。没有虚拟调度的继承非常有限,我甚至不确定是否应该将其与具有虚拟调度的继承进行比较。此外,“对象”的概念在C ++中与在C#或Java中非常不同。没错,有些事情我可以说得更好,但是要想弄清所有涉及的要点,很快就不得不写一本关于语言设计的书。
Polygnome

3
同样,在C ++中也不是“虚拟调度需要RTTI”。再次,只有dynamic_cast并且typeinfo要求。虚拟分派实际上是使用指向对象的具体类的vtable的指针来实现的,因此可以调用正确的函数,但是它不需要RTTI固有的类型和关系的详细信息。编译器只需要知道对象的类是否是多态的,如果是,则实例的vptr是什么。可以使用编译琐碎的虚拟调度类-fno-rtti
underscore_d

2
实际上,RTTI需要虚拟调度。从字面上看,-C ++不允许dynamic_cast没有虚拟调度的类。实现原因是RTTI通常实现为vtable的隐藏成员。
MSalters

1
@MilesRout C ++具有语言对于OOP所需的一切,至少是较新的标准。有人可能会争辩说,较早的C ++标准缺少OOP语言需要的一些东西,但这甚至是一个难题。C ++不是高级 OOP语言,因为它允许对某些事物进行更直接,更底层的控制,但它仍然允许OOP。(这里的高层/低层是抽象,其他语言(例如托管语言)比C ++提取的系统更多,因此它们的抽象更高。)
Polygnome

4

在主流的强静态OOP语言中,子类型主要被视为扩展类型和覆盖类型当前方法的一种方式。

为此,“对象”包含一个指向其类型的指针。这是一项开销:在Shape首先使用实例的方法中的代码必须先访问该实例的类型信息,然后才能知道Area()要调用的正确方法。

一个原语倾向于只允许对其进行操作,这些操作可以翻译成单机器语言指令,并且不携带任何类型信息。使整数变慢以便某人可以将其子类化的吸引力不足以阻止任何使它成为主流的语言。

所以答案为:

为什么主流的强静态OOP语言阻止继承基元?

是:

  • 需求很少
  • 这会使语言变得太慢
  • 子类型化主要被视为扩展类型的一种方式,而不是获得更好的(用户定义的)静态类型检查的方式。

但是,我们开始获得允许基于除“类型”以外的变量属性进行静态检查的语言,例如F#具有“ dimension”和“ unit”,因此您不能例如在一个区域中添加长度。

还有一些语言允许“用户定义的类型”不改变(或交换)类型的作用,而只是帮助进行静态类型检查。参见coredump的答案。


F#度量单位是一个不错的功能,尽管不幸地命名错误。而且它仅是编译时,因此在使用已编译的NuGet包时没有超级用处。正确的方向。

可能有趣的是,“维度”不是“类型”以外的其他属性,它只是一种比您习惯的更为丰富的类型。
porglezomp '16

3

我不确定是否在这里忽略了某些内容,但是答案很简单:

  1. 基元的定义是:基元值不是对象,基元类型不是对象类型,基元不是对象系统的一部分。
  2. 继承是对象系统的功能。
  3. 因此,原语不能参与继承。

请注意,实际上只有两种强大的静态OOP语言,甚至具有原语,即AFAIK:Java和C ++。(实际上,我什至不确定后者,我对C ++不太了解,而且在搜索时发现的内容令人困惑。)

在C ++中,基元基本上是从C继承(双关语意)的遗产。因此,它们不参与对象系统(因此也没有继承),因为C既没有对象系统也没有继承。

在Java中,原语是错误地尝试提高性能的结果。基元也是系统中唯一的值类型,实际上,用Java编写值类型是不可能的,对象也不可能是值类型。因此,除了一个事实,即原语不参与对象系统并由此“继承”的理念,甚至没有感觉,甚至如果你可以从他们那里继承,你将无法维持“价值”。这不同于例如C♯,它确实具有值类型(structs),而值类型是对象。

另一件事是,不能继承实际上也不是图元独有的。在C♯中,structs隐式继承自s System.Object并且可以实现interfaces,但是它们既不能继承classes 也不能继承es或structs。另外,sealed classes不能继承。在Java中,final class无法继承es。

tl; dr

为什么主流的强静态OOP语言阻止继承基元?

  1. 基本体不是对象系统的一部分(根据定义,如果不是,则不是原始体),继承的思想与对象系统联系在一起,因此,ergo基本继承在术语上是一个矛盾
  2. 原语不是唯一的,很多其他类型也不能被继承(finalsealed在Java或C♯中,struct在C♯中是s,case class在Scala中是es)

3
埃姆...我知道这是明显的“升C”,但是,EHM
利斯特先生

我认为您在C ++方面非常误解。它根本不是纯粹的OO语言。默认情况下virtual,class方法不是not ,这意味着它们不遵循LSP。例如,std::string它不是原始的,但在很大程度上却只是另一个值。这样的值语义很普遍,C ++的整个STL部分都假定了它。
MSalters

2
“在Java中,基元是错误地尝试提高性能的结果。” 我认为您不知道将原语实现为用户可扩展对象类型会对性能造成的影响。用Java做出的决定既有意又有充分根据。试想一下,必须为每次int使用分配内存。每次分配大约需要100ns,再加上垃圾回收的开销。将其与通过添加两个原语ints 消耗的单个CPU周期进行比较。如果语言的设计者另有决定,您的Java代码将随之爬行。
cmaster

1
@cmaster:Scala没有基元,其数值性能与Java完全相同。因为它很好地将整数编译为JVM原语int,所以它们的性能完全相同。(Scala本机将其编译为原始机器寄存器,Scala.js将其编译为原始ECMAScript Number。)Ruby没有原始,但是YARV和Rubinius将整数编译为原始机器整数,JRuby将它们编译为JVM原语long。几乎每个Lisp,Smalltalk或Ruby实现都在VM中使用基元。这就是性能优化...
约尔格W¯¯米塔格

1
…属于:在编译器中,而不是语言中。
约尔格W¯¯米塔格

2

Joshua Bloch在“有效Java”中建议显式设计继承或禁止继承。原始类不是为继承而设计的,因为它们被设计为不可变的,并且允许继承可以改变子类中的继承,从而破坏了Liskov原理,并且它可能是许多错误的来源。

无论如何,为什么是一个棘手的解决方法?与继承相比,您真的应该更喜欢组合。如果原因是性能,那么您没有道理,而问题的答案是不可能将所有功能都放在Java中,因为分析添加功能的所有不同方面都需要时间。例如,Java在1.5之前没有泛型。

如果您有足够的耐心,那么您会很幸运,因为有计划在Java中添加值类,这将使您可以创建值类,这将有助于您提高性能,同时还可以为您提供更大的灵活性。


2

在抽象级别上,您可以使用您要设计的语言来包含所需的任何内容。

在实现级别上,不可避免的是其中某些事情将更易于实现,某些事情将变得复杂,某些事情将变得更快,某些事情势必会变得更慢,等等。为了解决这个问题,设计人员通常不得不做出艰难的决定和妥协。

在实现级别,我们找到变量的最快方法之一就是找出其地址并加载该地址的内容。大多数CPU中都有特定的指令用于从地址加载数据,这些指令通常需要知道它们需要加载多少个字节(一个,两个,四个,八个等),以及将它们加载的数据放在何处(单个寄存器,寄存器)。对,扩展寄存器,其他内存等)。通过知道变量的大小,编译器可以准确地知道要针对该变量的使用发出哪条指令。通过不知道变量的大小,编译器将需要诉诸更复杂甚至更慢的东西。

在抽象级别上,子类型化的目的是能够使用一种类型的实例,其中期望使用相同或更多的通用类型。换句话说,可以编写期望特定类型对象或任何其他派生对象的代码,而无需事先知道确切的含义。显然,随着更多派生类型可以添加更多数据成员,派生类型不一定具有与其基本类型相同的内存要求。

在实现级别,没有一种简单的方法可以让预定大小的变量保存一个未知大小的实例,并以通常称为有效的方式对其进行访问。但是,有一种方法可以使事物稍微移动一点,并使用变量而不是存储对象,而是识别对象并将该对象存储在其他位置。这种方式就是引用(例如,内存地址)-额外的间接级别,可以确保变量只需要保存某种固定大小的信息,只要我们可以通过该信息找到对象即可。为此,我们只需要加载地址(固定大小),然后就可以使用我们知道有效的对象的偏移量照常工作,即使该对象在未知的偏移量处具有更多数据也是如此。我们可以这样做,因为我们不

在抽象级别,此方法允许您将a(对a的引用)存储stringobject变量中,而不会丢失使它成为a的信息string。所有类型都可以像这样正常工作,并且您可能还会说它在许多方面都很优雅。

尽管如此,在实现级别,间接的额外级别涉及更多指令,并且在大多数体系结构上,它使对对象的每次访问都稍微慢一些。如果您在语言中包含一些没有额外的间接级别(引用)的常用类型,则可以允许编译器从程序中挤出更多性能。但是通过消除这种间接级别,编译器将不再允许您以内存安全的方式来子类型化。这是因为,如果您向类型中添加更多数据成员并分配给更通用的类型,则将切掉不适合分配给目标变量的空间的所有其他数据成员。


1

一般来说

如果一个类是抽象的(隐喻:一个有洞的盒子),则可以(甚至需要有一些可用的东西!)来“填充洞”,这就是我们将抽象类子类化的原因。

如果一个类是具体的(隐喻:一个装满的盒子),则不能更改现有的,因为如果装满了,那就装满了。我们没有空间在框内添加更多内容,这就是为什么我们不应该对具体类进行子类化。

带基元

根据设计,基元是具体的类。它们代表着众所周知的,完全确定的东西(我从未见过带有抽象的原始类型,否则它不再是原始的)并且已在系统中广泛使用。允许对基本类型进行子类化并向依赖基本设计的行为的其他人提供您自己的实现会导致很多副作用和巨大损失!



该链接是一个有趣的设计意见。需要更多的思考给我。

1

通常,继承不是您想要的语义,因为您不能在需要原始图元的任何地方替换特殊类型。要从您的示例中借用,a Quantity + Index在语义上没有任何意义,因此继承关系是错误的关系。

但是,几种语言都有一个值类型的概念,它确实表达了您所描述的关系类型。 Scala就是一个例子。值类型使用基元作为基础表示形式,但在外部具有不同的类标识和操作。这具有扩展基本类型的效果,但更多的是组合而不是继承关系。

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.