为什么Delphi记录不能继承?

19

我长期以来一直在想:为什么Delphi记录不能继承(因此也没有其他重要的面向对象编程特性)?

这基本上将使记录成为类的堆栈分配版本,就像C++类一样,并使“对象”(注意:不是实例)过时。我认为这没有任何问题。这也是一个很好的机会实现记录的前向声明(我仍然惊讶为什么它还缺失)。

你认为会有任何问题吗?


2
当你说原型时,我认为你真正想表达的术语是前向声明 - Rob Kennedy
是的,抱歉,短暂的失忆。:P 实际上这两个概念本质相同,只是在Delphi和C++世界中拼写方式略有不同。尽管我更喜欢Delphi的名称,因为它更加自说明。 - Cloud737
1
实际上,“原型”这个术语在C++中只用于函数。C++使用“前向声明”来表示类类型,就像Delphi一样。 - Rob Kennedy
它们对我来说仍然意味着相同的事情,只是在C++中后来决定使用另一个名称。或者有我不知道的更深刻的区别吗? - Cloud737
你可以看 JavaScript 或类似的语言,它没有对象继承和类,而是使用对象原型。因此,“原型”这个术语通常有几个含义,并且在类似“语言 X 缺乏原型”的问题中最好避免使用。因为在这样的问题中,任何一种原型的意思都可能适用。 - Arioch 'The
8个回答

25

与这个问题相关的,有两种继承方式:接口继承和实现继承。

接口继承通常意味着多态性。它意味着如果B是从A派生而来的,则类型为B的值可以存储在类型为A的位置中。对于值类型(如记录)而言,这是有问题的,因为会出现数据截取的情况。如果B比A大,那么将其存储在类型为A的位置中将截断该值——任何B在其定义中添加的字段都将丢失。

相对于这个问题,实现继承则不太有问题。如果Delphi具有记录的实现继承,但没有接口继承,那么情况就不会太糟糕。唯一的问题是将类型为A的值简单地作为类型为B的字段,即可获得实现继承的大部分功能。

另一个问题是虚方法。虚方法调度需要某种每个值标记以指示值的运行时类型,以便发现正确的重写方法。但是记录没有存储此类型的任何位置:记录的字段就是它拥有的所有字段。对象(旧版Turbo Pascal对象)可以具有虚方法,因为它们具有VMT:层次结构中定义虚方法的第一个对象隐式向对象定义的末尾添加VMT,从而使其增长。但是Turbo Pascal对象具有上述的数据截取问题,这使得它们成为了有问题的选择。对于值类型来说,虚方法实际上需要接口继承,这意味着会出现数据截取的问题。

因此,为了正确支持记录接口继承,我们需要一些解决数据截取问题的解决方案。装箱(Boxing)将是一种解决方案,但通常需要垃圾回收才能使用,并且它会在语言中引入模糊性,在这种情况下,可能不清楚您正在使用一个值还是一个引用——有点像Java中的Integer和int之类的自动装箱。至少在Java中,有针对装箱和未装箱“种类”的分别名称。另一种装箱的方式类似于Google Go中的接口,这是一种没有实现继承的接口继承,但需要单独定义接口,所有接口位置都是引用。当一个接口引用引用到值类型(如记录)时,这个值类型就被装箱了。当然,Go也有垃圾回收。


我现在明白了,基本上问题主要是切片。但是,难道装箱不会使记录与类完全相同,因为每个变量实际上都是对实际对象的引用吗?或者那个对象会以某种方式被实际堆栈分配吗? 顺便说一句,能够得到你的回答真是太荣幸了。看到一个著名的开发人员回答我的问题让我感到非常高兴。非常感谢! :) - Cloud737
1
盒装记录将通过引用进行引用;值存储的位置(无论是在堆上还是在栈上)取决于例如逃逸分析等实现细节(堆栈位置是否比引用存在更长时间)。但它们不一定像类一样;如果盒装和非盒装类型具有不同的名称或语法,或者像Go一样仅使用接口继承,则可以想象盒装类型(在引用时)具有多态性,但直接位置没有(因此避免切片)。有点像Java的int / Integer。 - Barry Kelly

6
记录和类/对象是Delphi中两个非常不同的东西。基本上,Delphi记录是一个C结构——Delphi甚至支持语法来做一些事情,比如拥有一个可以作为4个16位整数或2个32位整数访问的记录。就像struct,record 追溯到面向对象编程进入该语言之前(Pascal时代)。
与结构体类似,记录也是内联内存块,而不是指向内存块的指针。这意味着当你将记录传递到函数中时,你传递的是一个副本,而不是指针/引用。它还意味着当你在代码中声明一个记录类型变量时,它在编译时确定其大小——在函数中使用的记录类型变量将被分配在堆栈上(不是作为指针在堆栈上,而是作为4、10、16等字节的结构)。这种固定大小与多态性不兼容。

我知道记录相当于结构体,但是...这并不一定排除了对它们进行扩展。 我不太确定,但我认为也可能有变体类,因此将变体记录作为类也不会带来太多问题。 我知道记录是堆栈分配的,而类是堆分配的,但C++类也可以堆栈分配,所以我在这里看不出太多问题。 - Cloud737
1
固定大小与多态性相容。Turbo Pascal 和 C++ 都有固定大小的值类型类,仍支持继承和虚函数。 - Rob Kennedy
@Rob Kennedy:你是指“对象”类型吗?它们仍然存在于Delphi中,只是没有操作符重载、虚函数(我不确定)并且在属性或之后引入的任何内容方面都存在一些问题。 - Cloud737
2
正确。它们有虚函数,但不支持编译器管理的类型,如接口或字符串作为它们的字段。它们在Delphi中基本上是有问题的,因此我不认为它们以任何有意义的方式存在于Delphi中。但是它们在Turbo Pascal中的存在证明了可以拥有支持继承的值类型。(向记录添加虚方法会更加棘手,但虚方法并不是继承的基本属性。) - Rob Kennedy
C++类不就是将默认可见性改变的结构体吗?至少据我所知,这是早期版本的C++的情况。 - Arioch 'The

5

我看到的唯一问题(可能我眼光短浅或错误)是目的。记录用于存储数据,而对象用于操作和使用该数据。为什么存储箱需要操作例程?


4
在Delphi中添加到记录的方法只是静态方法的语法糖,就像C#中的扩展方法一样。由于没有使用虚拟方法表,因此没有多态性的空间。 - David
2
@Cloud737:想象一下,在堆栈上有一个使用RAII来管理关键部分或其他同步对象的对象。这将是很棒的,因为使用接口来实现此目的(就像我现在所做的)会引入太多开销。但是,为了使其有用,您需要能够精确指定它何时创建和销毁。在C++中可能是可行的,但在当前规则下,Delphi不行。 - mghie
2
Delphi已经具有带有继承、VMT和DMT的“记录”。它们是由旧的“object”关键字声明的。但我不建议人们使用旧的基于堆栈的对象,因为如果您尝试使用在TurboPascal 7.0之后引入的代码结构(例如属性),编译器会出现一些代码生成错误。 - Andreas Hausladen
1
@Cloud737:解引用并不是最大的问题(通常您不会调用任何方法,将工作留给构造函数和析构函数),而是访问内存管理器。创建基于堆栈的对象只涉及操作CPU寄存器以(de-)分配内存。在单线程程序中访问内存管理器更加昂贵,在多线程程序中可能会致命。至于丑陋的问题,它们会是什么呢? - mghie
@mghie:啊,所以你基本上是在构造函数中完成所有工作,然后销毁它。我原来认为这个对象的寿命可能更长,这样你就可以将其用于不同的情况,因为我从未使用过关键部分或同步对象。现在我完全理解问题所在了。我希望我能记得那些丑陋的问题,但不幸的是我从来没有太注意。 :( - Cloud737
显示剩余6条评论

5
您说得对,将继承添加到记录中基本上会使它们变成C ++类。这就是您的答案:不这样做,因为那将是一件可怕的事情。您可以拥有堆栈分配的值类型,也可以拥有类和对象,但混合两者是一个非常糟糕的想法。一旦你这样做了,你就会遇到各种生命周期管理问题,并且不得不在语言中构建丑陋的hack,比如C ++的RAII模式来处理它们。
底线:如果您想要一个可以继承和扩展的数据类型,请使用类。这就是它们存在的原因。
编辑:针对Cloud的问题,这不是通过单个简单的示例可以演示的内容。整个C ++对象模型都是一场灾难。从近处看可能看不出来;您必须理解几个相互关联的问题才能真正掌握大局。 RAII只是金字塔顶部的混乱。也许我会在本周晚些时候在我的博客上写一篇更详细的解释,如果我有时间的话。

我不太熟悉你所提到的C++问题。我还没有深入研究RAII。 请问您能否举个例子说明为什么它会成为一个问题? - Cloud737
5
RAII 被误解为一种必需但丑陋的 hack,这是一个错误的看法。 - mghie
@Mason:请在您的博客上写一篇详细的解释。如果您真的对事情持开放态度,请阅读Andrei Alexandrescu关于D语言的文章(他和D编程语言的创造者Walter Bright都是极其聪明的人,可能比大多数SO参与者都更擅长编码),然后反思一下D语言(虽然处理了C++的许多缺点)保留了RAII和C++中各种内存管理方式,并在此基础上添加了垃圾回收机制。也许,只是也许,你错了? - mghie
2
接口继承值类型,即使不同大小的值类型可以成为彼此的子类型,并进入分割问题,这是一个非常糟糕的想法。引入虚表,情况会变得更糟:你可能会得到一种类型,它太小了,无法引用其虚表中的一些方法,也可能会出现缓冲区溢出问题覆盖堆栈。接口继承值类型:坚决拒绝。 - Barry Kelly
1
@Mason:“D语言的论据”(http://www.ddj.com/hpc-high-performance-computing/217801225)和“The D Programming Language”的概述和目录页(http://my.safaribooksonline.com/9780321659538)。 - mghie
显示剩余2条评论

5

因为记录(records)没有虚方法表(virtual method table)。


4

过去,我曾使用对象(而不是类!)作为具有继承的记录。

与这里的某些人所说的不同,这样做有合理的理由。我这样做的情况涉及来自外部源(API,而不是磁盘上的任何内容-我需要完整形式的记录在内存中),第二个结构仅扩展了第一个结构。

这种情况非常罕见,虽然如此。


这些情况确实非常普遍,例如在使用像标题+不定长度数据区之类的对象时。 - Zoltán Bíró

3
你可以尝试使用Delphi中的object关键字来实现。这些关键字基本上是可继承的,但行为更像记录而不是类。
请参见线程描述

1
啊!不要使用object关键字!它已经被弃用多年了,而且自D2010以来,甚至没有得到适当的支持。 - Nick Hodges

0

这与您的问题相关,涉及通过类和记录助手扩展记录和类类型的功能。根据Embarcadero关于此的文档,您可以扩展类或记录(但助手不支持操作符重载)。因此,在成员方法方面基本上可以扩展功能,但不能扩展成员数据。虽然我没有测试过,但它们支持类字段,您可以以通常的方式通过getter和setter访问它们。如果您想要接口访问添加了助手的类或记录的数据,则可能可以实现此操作(即在更改原始类或记录的成员数据时触发事件或信号)。但您无法实现数据隐藏,但它确实允许您覆盖原始类的现有成员函数。

例如:此示例适用于Delphi XE4。创建一个新的VCL表单应用程序,并使用以下代码替换Unit1中的代码:

interface

uses
  Winapi.Windows, Winapi.Messages, System.SysUtils, System.Variants, System.Classes,
  Vcl.Graphics, Vcl.Controls, Vcl.Forms, Vcl.Dialogs, System.Types;

type

  TMyArray2D = array [0..1] of single;

  TMyVector2D = record
  public
    function Len: single;
    case Integer of
      0: (P: TMyArray2D);
      1: (X: single;
          Y: single;);
  end;

  TMyHelper = record helper for TMyVector2D
    function Len: single;
  end;


  TForm1 = class(TForm)
    procedure FormCreate(Sender: TObject);
  private
    { Private declarations }
  public
    { Public declarations }
  end;


implementation

function TMyVector2D.Len: Single;
begin
  Result := X + Y;
end;

function TMyHelper.Len: single;
begin
  Result := Sqrt(Sqr(X) + Sqr(Y));
end;

procedure TestHelper;
var
  Vec: TMyVector2D;
begin
  Vec.X := 5;
  Vec.Y := 6;
  ShowMessage(Format('The Length of Vec is %2.4f',[Vec.Len]));
end;

procedure TForm1.Form1Create(Sender: TObject);
begin
  TestHelper;
end;

请注意,结果为7.8102而不是11。这表明您可以使用类或记录助手隐藏原始类或记录的成员方法。
因此,在某种程度上,您只需像在更改通过属性而不是直接更改字段来自声明类所在单元中的值一样对待访问原始数据成员,以便该数据的getter和setter执行适当的操作。
感谢提出问题。我在尝试找到答案时确实学到了很多,并且它也对我有很大帮助。
Brian Joseph Johns

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接