在Delphi中优化类大小。是否有类似“紧凑类”的东西?

5

我想尽可能地优化我的Delphi类的大小,使它们占用尽可能少的内存,因为我正在创建大量的类。

问题是,这些类本身很小,但它们占据的空间并不是我预期的。例如,如果我有:

type MyClass = class
  private
    mMember1 : integer;
    mMember2 : boolean;
    mMember3 : byte;
end;

我期望它使用6个字节,但由于对齐的原因,它最终使用了12个字节,这意味着布尔值使用了4个字节而不是1个字节...字节字段同样如此...
对于记录,您可以使用{$A1}指令或声明为紧凑记录,以使其只使用所需的内存。
有没有办法用类做同样的事情?(也许有关正确重写NewInstance类方法的一些教程?)
编辑:好吧,稍微解释一下我在做什么...
首先,真正的类大小大约是40个字节,包括VMT和接口指针占用的空间。
所有类都继承自一个基本的RefCounting类,其大小为8个字节(整数FRefCount和一些方法,以允许引用计数),并且它们必须支持接口(因此根本不使用紧凑记录)。
这些对象会被传递并转换为多种类型,处理程序不知道它们得到了什么。例如,我有一个类,接收TItems列表,并执行以下操作:
if Supports(List[i], IValuable, IValInstance) then
  Eval(IValInstance.Value);

那么,另一个处理程序可能会检查其他接口。
If Supports(List[i], IStringObject, IStringInstance) then
  Compose(IStringInstance.Value)

这样每个处理程序都会以不同的方式处理列表...

关于如何获取类的总大小,我使用了一个修改过的内存管理器,以便我可以跟踪“真正”内存管理器为该类使用了多少内存。通过这种方式,我非常有信心实例没有被打包。

最后,这是在Delphi 7中完成的。我尝试使用{$A1}预编译指令,但并没有成功,字段仍然按照任意方式对齐,并且最坏情况下我可能有几百万个实例,因此节省6个字节可以节省数MB。


编译器错误:P,您可以只使用记录打包。 - Jorge Córdoba
什么是伟大的数字?你现在的班级有多大了? - Harriv
你从哪里得到了“12字节”?这是MyClass.InstanceSize返回的吗?如果是这样,请记住类指针有4个字节的开销。在这种情况下,你只会因为压缩而失去2个字节,而不是6个字节。 - Mason Wheeler
1
我已经详细阐述了我为什么要做这个以及为什么打包记录不是一个选项。抱歉第一次让它变得混淆了。数字仅仅是为了阐述观点而举的例子,当然您还必须考虑虚方法表的空间以及类中存在的任何接口指针。 - Jorge Córdoba
1
Jorge,你误解了。如果你把所有的字段都放到一个紧凑记录中,这并不排除你使用接口。当编译器定义类的布局时,它会自动处理接口字段。记录字段不会干扰这个过程。此外,接口字段不影响对齐,因为它们本来就是四字节的值。你也不需要特殊的内存管理器来测量这个,只需在类上使用InstanceSize函数即可。 - Rob Kennedy
显示剩余4条评论
9个回答

10

你可以在对象中使用压缩记录作为字段:

type
  TMyRecord = packed record
    Member1 : integer;
    Member2 : boolean;
    Member3 : byte;
  end;

  TMyClass = class
  private
    FData : TMyRecord;
   function GetMember1 : Integer;
  public
    property Member1 : Integer read GetMember1;
    // Later versions of Delphi allow "read FData.Member1;", not sure when from
  end;

function TMyClass.GetMember1 : integer;
begin
  result := FData.Member1;
end;

2
+1,但如果原始问题(OP)并不需要所有单独数据元素的完整范围,他应该使用在https://dev59.com/aHVC5IYBdhLWcg3weBA-中概述的技术,并将布尔值折叠到另一个数据元素的一个位中,从而在过程中节省另一个字节。在现代处理器上,更少的内存消耗几乎每次都是性能优胜的代价。 - mghie
3
请注意,您可以将整个打包记录放入TMyClass中,创建一个私有的未命名类型(因为它是实现细节),以此改善内容的可读性和易懂性,但不应更改原始含义。 - mghie
Delphi的后续版本允许使用"read FData.Member1;",但不确定从哪个版本开始。D7已经可以实现此功能。 - Fr0sT

5
如果您非常担心几个字节的问题(您提到了6 vs 12),那么根本不应该使用class。相反,应该使用record。然后可以使用packed来消除对齐浪费;但是,请准备好承受性能损失,因为默认情况下,“非打包”对齐是为CPU最快速度访问而设置的。

4
如果他所说的“很多”是指几百万,那么节省其中的50%就很重要。 - Mason Wheeler
1
如果您需要“数百万”个类的实例,那么考虑到在第一时间不将这么多实例加载到内存中可能节省的开销也是值得考虑的。 - Rob Kennedy
1
我同意Rob的观点。如果你需要数百万个任何东西,那么可能有更好的方法来完成你想要做的事情。@Mason:当它是6个字节时,50%并不是什么大不了的事情。即使你谈论很多。这是一个整数和半个空间 - 删除一些未使用的变量或在几个地方使用Byte而不是integer,你就可以节省这么多。我相信你听说过“过早优化”的术语... - Ken White

4
为什么不一开始就使用压缩记录呢?这样做可以避免继承自TObject所带来的开销(虽然很小)...

4
当然可以。你可以打包集合、数组、记录、对象和文件类型。需要注意的是,使用 packed 会在访问数据时导致速度变慢,并可能导致某些类型兼容性问题。
我在 Delphi 2006 中尝试了一下。编辑器的语法检查将其标记为错误,但实际上编译得很好。
根据 Delphi 文档,$A 开关适用于类类型和记录类型。
更新:
我也在 Delphi 6 中尝试了一下。它成功地编译了。如果 packed 类在 Delphi 7 中无法编译,那么您可能已经发现了一个 bug。如果这是一个 bug,除非它仍然存在于最新版本的 Delphi 中,否则 Embarcadero 不太可能采取任何措施,而这似乎并不是情况。

我会在 Delphi 7 中再试一次,但是已经尝试过一次后,我相当有信心 Delphi 7 只是忽略了类的预编译指令... 这真的很令人沮丧... - Jorge Córdoba
你可以拥有打包的对象类型,但不能拥有打包的类。编译器允许这种语法,但它没有任何效果。我们谈论的是类,而不是旧式对象。 - Rob Kennedy

3
也许有点跑题,但我之前曾为某些ORM框架(D2006之前,所以没有记录)而苦恼。假设“class”部分已经确定:
提示和建议:
  1. 我通过为字段设置getter和setter来解决打包问题,将它们存储在类的字节数组中。甚至可以进行位压缩。如果getter/setter是可内联的(对我来说不是一个选项,因为我使用的是D6),那么这可能是相当廉价的。
  2. 尝试通过自己初始化一块内存、设置VMT并调用其构造函数来收集堆分配(管理开销和松弛空间)开销。我IRC堆开销是8个字节,旧堆管理器的分配粒度是8字节,快速MM则是16字节。如果按大小对类进行排序,则可以使用位图作为分配结构。
  3. 如果你非常狡猾,请记住指针有2或3位松弛。我将这些位用作极其常用的分配类型的标识符,从而节省了堆保留的4个字节来存储大小。
  4. 注意你的索引。如果你有很多对象(我大约有600万个),你也必须小心你的索引类型。(请勿使用tstringlist)
  5. 始终将非混淆的内容放在ifdef下,以便更轻松地进行调试测试(*)。
  6. 永远不要使用字符串作为键。必要时使用哈希。规范化结构不仅对数据库有好处。
(*)后来我在64位FPC下重新编译了“干净”的版本,在一些小的sizeof(pointer())之后,尽管第1点和第2点很丑陋,但它确实起作用了。

重写NewInstance和FreeInstance可能有助于完成第二点。 - Rob Kennedy

2
如果您将拥有大量实例并想避免与单个分配相关的开销,可以利用紧凑记录并将它们从类本身外部维护,例如通过一个或多个大型数组的分配。
然后,在类中,您可以仅存储一个或两个字段以用于索引堆和偏移量。如果您只能使用单个大内存块,那么您可以将其缩小为仅偏移量。
TPackedRecord = packed record ... end;
PPackedRecord = ^TPackedRecord;
TPackedRecordHeap = class
  ...
  function  Add: PPackedRecord;
  procedure Release( entry: PPackedRecord );
end;

TUsableClass = class
private
  heap: TPackedRecordHeap;
  data: PPackedRecord;
public
  constructor Create( heap: TPackedRecordHeap );
  ...
end;

2

手动打包数据。 每取出4个字节,将它们放进一个单一的基数中。如果你有两个字符串长度不是4的倍数,那么把它们放在一个短字符串中,并分别读出它们的部分。

这需要你花费一些时间去手动排列数据,但通过使用getter和setter方法,类的外部行为将是透明的。你可以尽可能地接近编译器这样打包数据的结果。


1
我期望它使用6个字节,但由于对齐的原因,最终使用了12个字节。
即使你写了"TMyClass = class end;",这个类也会继承自具有虚方法的TObject。
这就是为什么。
  4 Bytes (VMT)
+ 4 Bytes (member1: Integer)
+ 1 Byte  (member2: Boolean)
+ 1 Byte  (member3: Byte);
+ 2 Bytes (alignment)
---------
 12 Bytes

所以如果你禁用了对齐,你只会节省2个字节。

通过按数据类型大小(在您提到的较大类中)对字段进行排序,可以消除一些对齐空隙。而 $A-(Delphi 5)或 $A1(更新版本)都不起作用。无论是在 Delphi 7 还是 Delphi 2009 中都是如此。

顺便说一下:在 Delphi 2009 中,您还可以为“Thread.Monitor”增加额外的4个字节,从而将总类大小增加到16个字节。


1

如果这是一个记录的话,它将会是8个字节,如果是压缩记录则为6个字节。因此,您需要考虑类指针的4个字节开销(假设您使用的是Delphi 2009之前的版本),如果使用压缩记录,则有可能回收2个字节。


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