在Delphi中,使用旧式的“object”而不是“class”,是否有好处?

11
在Delphi中,理智的人使用class来定义对象。在Windows Turbo Pascal中,我们使用object,今天你仍然可以使用object来创建对象。区别在于object存在于堆栈上,而class存在于堆上。当然,object已经过时了。
抛开这些不谈:使用object是否有速度上的优势?
我知道在Delphi 2009中object已经失效了,但我有一个特殊的用例(1),在这个用例中速度很重要,我正在尝试找出是否使用object会使我的代码更快,但不会出现错误。这个代码库是在Delphi 7中的,但我可能会将其移植到Delphi 2007,还没有决定。

1)康威生命游戏

长注释
感谢大家指引我正确的方向。

让我再解释一下。我正在尝试更快地实现hashlife请参见此处这里查看简单源代码

目前的记录保持者是golly,但golly使用了Bill Gospher原始Lisp代码的直接翻译(作为算法非常出色,但在微观层面上没有进行优化)。Hashlife可以在O(log(n))时间内计算一代。

它通过使用空间/时间权衡来实现。因此,hashlife需要大量的内存,数十亿字节并不罕见。作为回报,您可以使用o(1)时间计算第2^127(170141183460469231731687303715880000000)代,从而计算第2^128(340282366920938463463374607431770000000)代。

由于hashlife需要为出现在较大模式中的所有子模式计算哈希值,因此对象的分配需要快速进行。

这就是我选择的解决方案:

分配优化
我会分配一个大块的物理内存(用户可设置),比如说512MB。在这个块中,我会分配我所谓的cheese stacks。这是一个普通的栈,在其中进行push和pop操作,但是pop也可以从栈的中间进行。如果发生这种情况,我会将其标记为free列表(这是一个普通的栈)。当进行push操作时,我首先检查free列表是否有空闲,如果没有空闲,我就按照正常方式进行push操作。我会使用记录来实现,因为这看起来是开销最小的解决方案。

由于hashlife的工作方式,很少进行pop操作,而进行了大量的push操作。我为不同大小的结构保留了单独的栈,确保内存访问对齐在4/8/16字节边界上。

其他优化

  • 递归消除
  • 缓存优化
  • 使用inline
  • 预计算哈希值(类似于彩虹表)
  • 检测病态情况并使用备用算法
  • 使用GPU

2
我会考虑使用记录而不是旧式对象类型。 - Jørn E. Angeltveit
1
我相信这个问题已经有了答案。使用基于堆栈的对象,您可以节省堆分配、释放的时间,只需改变堆栈指针即可完成对象操作。 - Sertac Akyuz
3
如果你用基于类的对象无法快速运行康威生命游戏,那么你做了很多错误的事情。我敢打赌,在甚至不分配堆上的一个对象的情况下,你就可以写出整个程序。例如,为每个方格分配一个对象是错误的。应该用一个二维数组来表示棋盘,如果愿意,可以将其封装在一个类中。一个实例就是整个“游戏棋盘”。正是这些优化(决策)需要花费你的时间。忘记“对象”,它已经死了。 - Warren P
@Warren,我已经拥有一个最优算法(hashlife)。所以我已经完成了那个阶段,请不要假设什么。 - Johan
我的运行时间中有90%的时间都在分配内存(目前是用c语言编写的,但由于我不擅长c语言,所以需要将其改写为Delphi语言,以便重新考虑内存分配)。内存分配是算法的基本属性,但现在我所有的时间都花在了calloc和malloc上。该算法每秒执行数十亿代,并且随着进展而呈指数级加速,但启动需要很长时间,因为它需要准备一些东西,这就是我想节省时间的地方。因此,我需要完全摆脱内存分配,只需分配1GB的内存并使用它来工作。 - Johan
显示剩余2条评论
4个回答

16

使用普通的面向对象编程,你应该总是使用class类型。在Delphi中,你将拥有最强大的对象模型,包括接口和泛型(在后续的Delphi版本中)。

1. Records, pointers and objects

记录可能会有问题(如果你忘记将参数声明为const,则会产生缓慢的隐藏副本,记录隐藏缓慢的清理代码,fillchar会使记录中的任何字符串成为内存泄漏......),但有时它们非常方便,以通过指针访问二进制结构(例如一些"小值")。

一个动态数组的小记录(例如一个整数和一个双精度字段)将比小类的TList 快得多;使用我们的TDynArray封装器, 您将可以高级地访问记录,包括序列化、排序、哈希等。

如果使用指针,你必须知道自己在做什么。最好坚持使用类,并使用TPersistent,如果你想使用神奇的"VCL组件所有权模型"。

记录类型不允许继承。您需要使用“变体记录”(在其类型定义中使用case关键字),或者使用嵌套记录。当使用类C API时,有时必须使用面向对象的结构。使用嵌套记录或变体记录比传统的“对象”继承模型要不清晰得多。

2.何时使用对象

但是,在某些情况下,对象是访问已经存在的数据的好方法。

即使对象模型比新的记录模型更好,因为它处理简单的继承。

去年夏天的博客文章中,我发布了一些仍然使用对象的可能性:

  • 一个内存映射文件,我想要快速解析: 指向这样一个对象的指针非常好,你仍然可以使用方法;我在SynZip.pas中使用它来映射.zip头的TFileHeader或TFileInfo;

  • 一个Win32结构,由API调用定义,在其中我放置了方便访问数据的实用方法(为此,您可以使用记录,但如果结构中有一些面向对象的内容-这是非常常见的-您将不得不嵌套记录,这不是非常方便);

  • 在堆栈上定义的临时结构,仅在过程期间使用:我在SynZip.pas中使用它来处理TZStream,或者用于我们与RTTI相关的类,这些类以面向对象的方式映射Delphi生成的RTTI,而不是像TypeInfo那样以函数/过程为导向。通过直接映射RTTI内存内容,我们的代码比使用在堆上创建的新RTTI类更快。我们没有实例化任何内存,对于像我们这样的ORM框架来说,这对其速度很有好处。我们需要大量的RTTI信息,但我们需要它快速,直接。

3. 现代Delphi中对象实现的问题

在我看来,现代Delphi中对象的问题非常令人遗憾。

通常,如果您在堆栈上定义一个记录,其中包含一些引用计数变量(如字符串),它将由某些编译器魔术代码在方法/函数的开始级别进行初始化:

type TObj = object Int: integer; Str: string; end;
procedure Test;
var O: TObj
begin // here, an _InitializeRecord(@O,TypeInfo(TObj)) call is made
  O.Str := 'test';
  (...)
end;  // here, a _FinalizeRecord(@O,TypeInfo(TObj)) call is made

_InitializeRecord_FinalizeRecord会“准备”然后“释放”O.Str变量。

在Delphi 2010中,我发现有时并不总是调用_InitializeRecord()函数。 如果记录只有一些非公共字段,则编译器有时不生成隐藏的调用。

只需重新构建源代码,问题就会解决...

我找到的唯一解决方案是使用关键字 record 而不是 object。

因此,以下是最终代码的样子:

/// used to store and retrieve Words in a sorted array
// - is defined either as an object either as a record, due to a bug
// in Delphi 2010 compiler (at least): this structure is not initialized
// if defined as a record on the stack, but will be as an object
TSortedWordArray = {$ifdef UNICODE}record{$else}object{$endif}
public
  Values: TWordDynArray;
  Count: integer;
  /// add a value into the sorted array
  // - return the index of the new inserted value into the Values[] array
  // - return -(foundindex+1) if this value is already in the Values[] array
  function Add(aValue: Word): PtrInt;
  /// return the index if the supplied value in the Values[] array
  // - return -1 if not found
  function IndexOf(aValue: Word): PtrInt; {$ifdef HASINLINE}inline;{$endif}
end;
< p > {$ifdef UNICODE}记录{$else}对象{$endif} 很糟糕......但代码生成错误没有发生,因此...

源代码中的修改并不是很大,但有点令人失望。我发现旧版本的IDE(例如Delphi 6/7)无法解析这样的声明,因此类层次结构将在编辑器中被破坏... :(

向后兼容性应包括回归测试。许多Delphi用户继续使用该产品,因为存在代码。对于Delphi未来来说,打破功能非常棘手,我认为:如果您必须重写大量代码,为什么不将项目切换到C#或Java?


@Bouchez,非常感谢您提供的帮助。 对于我的用例(没有字符串),Object似乎并没有出现问题,但是我决定不再使用堆栈,而是采用记录 - Johan
据我所知,在Free Pascal中它们现在已经被初始化。 - Marco van de Voort

7

Object不是Delphi 1设置对象的方法,它是短暂的Turbo Pascal设置对象的方法,并在Delphi 1中被Delphi TObject模型所取代。虽然为了向后兼容而保留了它,但应该避免使用它,因为有以下几个原因:

  1. 正如您所指出的,它在更近期的版本中已经失效了。据我所知,也没有修复它的计划。
  2. 这是一个概念上错误的对象模型。面向对象编程的整个重点,真正区别于过程式编程的一件事,就是Liskov替换(继承和多态性),而继承和值类型并不相容。
  3. 您会失去需要TObject后代支持的许多功能。
  4. 如果您真的需要不需要动态分配和初始化的值类型,则可以使用记录。虽然您不能从它们继承,但您也不能很好地使用object,因此在这里您不会失去任何东西。

至于问题的其余部分,实际上并没有太多速度优势。TObject模型已经足够快,特别是如果您使用FastMM内存管理器来加速对象的创建和销毁,并且如果您的对象包含许多字段,它们甚至在许多情况下可能比记录更快,因为它们是通过引用传递的,并且不必为每个函数调用复制它们。


1
我认为,如果您为记录/对象参数放置一个const,则其内容不会在每个函数调用时被复制。只有当您将记录/对象用作函数结果时,副本才可用。在这种情况下,当您使用记录/对象时,最好使用var参数而不是函数结果。 - Arnaud Bouchez
我同意你所说的大部分内容,但请注意任何“记录”或“对象”也是按引用传递的。如果该项未作为const传递,则在函数的prolog中会进行复制。 - Rudy Velthuis

6

在“快速但可能存在问题”和“快速且正确”的选择之间,总是选择后者。

旧式对象相对于普通记录没有速度上的优势,因此,无论何时你想使用旧式对象,都可以使用记录来代替,而不会存在未初始化的编译器管理类型或损坏的虚方法的风险。如果您的 Delphi 版本不支持带有方法的记录,则可以使用独立的过程来替代。


1
快速和正确的操作是最重要的,性能总是次要的考虑因素。快速制造混乱和错误永远不如正确地完成任务。 - Warren P
1
记录的问题在于它不能处理继承。许多普通的公共 API 结构,甚至一些自定义结构都是面向对象的,因此需要这种继承。变体记录(带有“case integer”)不太优雅,嵌套记录也不是一个好选择。 - Arnaud Bouchez
@A.Bouchez,自从90年代以来,旧式对象中的继承一直存在缺陷——编译器会默默地生成错误的代码。如果不优雅是为了正确编译代码所付出的代价,我每次都会乐意支付。 - Rob Kennedy
通过简单的对象继承,我从未遇到过这样的问题。据我所知,虚方法存在缺陷,但对于静态方法,从未生成过错误代码。你说得对,有时候不太优雅的解决方案也是值得的……为了规避编译器问题……所以在这种情况下我们没有责任! ;) - Arnaud Bouchez

1

在早期不支持带有方法的记录的 Delphi 版本中,使用 object 是将对象分配到堆栈上的方法。偶尔这会带来可观的性能优势。现在更好的选择是使用 record。唯一缺少的功能是从另一个 record 继承。

当你从 class 切换到 record 时,你会失去很多东西,所以只有在性能优势非常明显的情况下才考虑切换。


@David,一个naive实现的hashlife在启动阶段会花费90%的时间在内存密集型的分配内存上。 - Johan
记录继承不是一个缺失的功能,而是一个非功能性的特性,它会对事情造成更多的伤害而不是帮助。继承和值类型不搭配,因为当派生类型添加新字段时,你会遇到各种参数传递问题。只需看看C++的“复制构造函数”就可以看出这个模型有多丑陋。 - Mason Wheeler
2
@Mason 我不同意。我确实同意C++模型非常复杂,但是他们能够为他们的对象(例如字符串)进行堆栈分配,而我们其他人则必须使用堆,并付出代价。我在非常少的地方成功地使用了“object”继承。 - David Heffernan
使用堆栈来处理字符串的代价也并非总是“免费”的。TANSTAAFL。 - Warren P
@warren 我猜你放弃了写时复制。 - David Heffernan
有趣的是,使用Delphi字符串类型,您可以在堆栈上名义上声明一个字符串,但存储并不在堆栈上,因此变量长度是可变的。当然,C++中的堆栈字符串也是不可变的,对吧?这样的例子还有很多... - Warren P

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