假设你有一个方法,通过创建临时的List来收集某些数据,并根据这些数据来统计信息,然后销毁这个临时列表。这个方法被经常调用,导致大量内存分配和释放以及增加的内存碎片。此外,所有这些内存管理都需要时间,并且可能会影响性能。
对于这些情况,您可能希望将所有数据保留在堆栈(stack)中,并完全避免内存分配。我们向您展示了几种可以实现此目的的方法。
即使这些用例对你来说不适用,但你也可能会发现本文很有用,因为它使用了一些有趣的概念和Delphi语言功能。
堆栈与堆,值与参考类型(Stack vs Heap, Value vs Reference Types)
首先,让我们先了解一些术语。你可能已经知道本节中的所有内容,但无论如何我们都要回顾一下。
内部存储器有两种主要类型:堆栈和堆。堆栈被用于存储方法的局部变量和可能的其他数据(如在许多平台返回地址)。堆存储动态分配的内存,包括字符串,动态数组和对象。
当您了解了这些类型的内存时,您可能会在同一段落或章节中阅读有关值和引用类型的内容,因为这两者有些相关。值类型直接在存储器位置存储了一些值,而 参考类型存储的指针则指向位于别处(通常,但不是必须在一个值堆)。值类型的示例是整数,浮点数,布尔值,枚举,字符,记录和静态数组。引用类型的示例是字符串,对象,对象接口,动态数组和指针。
关于类和对象之间的区别存在争议,它们有时可以互换使用。我个人认为两者是不同的:一个类描述了契约(字段,方法,属性和事件),一个对象是一个类的特定实例。
只有值类型可以存储在堆栈中。在堆栈上声明引用类型时,只是其指针值存储在堆栈中 ; 实际值是在堆上分配的(如果没有,则为nil)。
procedure StackVsHeap; var A: Integer; B: TList<Integer>; begin A := 42; B := TList<Integer>.Create; B.Add(42); B.Add(100); end;
这导致以下内存布局(内存位置仅供参考):
正如你所见,在创建TList
并在至少分配两个堆存储器来增加一些项目的结果:一个存储列表对象的实例数据(FItems
,FCount
以及一些其他字段)和一个用于存储物品的动态数组。在此示例中,动态数组可容纳4个项目,其中2个项目使用。动态数组将根据需要增长以容纳新项目。
基于堆栈的集合
您可能会遇到这些堆内存分配不理想的情况。它们可能会增加内存碎片,从而导致内存使用量增加。此外,分配和释放动态内存并不是一种廉价的操作,每秒多次创建临时列表可能会影响性能。另一方面,堆栈上的“分配”内存通常是零成本操作。它只涉及在方法开始时调整堆栈指针,编译器在大多数情况下都会这样做。
如果您可以完全在堆栈上创建一个集合(也就是说,集合属性FCount
和实际项目都应该存在于堆栈中),则可以解决这些问题。实际上,您之前可能已经使用过这些类型的集合:
procedure StaticArray; var Items: array [0..9] of Integer; Count: Integer; begin ... end;
本地静态数组本质上是基于堆栈的集合,尽管不是用户友好的集合。为了使这个数组更像列表,我们可以用方法将它包装在一个记录中。
一个简单的基于堆栈的列表
一个简单的实现可能如下所示:
type TStackList<T: record> = record private type P = ^T; private FData: array [0..255] of Byte; FCapacity: Integer; FCount: Integer; function GetItem(const AIndex: Integer): T; public procedure Initialize; procedure Clear; procedure Add(const AItem: T); property Count: Integer read FCount; property Items[const AIndex: Integer]: T read GetItem; default; end;
您可以在AllocationFreeCollections目录中的GitHub上的JustAddCode存储库中找到此代码的更多文档版本(以及本文中的所有其他代码)。
你可能会发现这个名字TStackList
有点令人困惑。这里,单词“stack” 不是指类似堆栈的数据结构,而是指列表应该存在于内存堆栈中的事实。如果你要创建一个存在于内存堆栈中的类似堆栈的集合,那么就可以调用它TStackStack
。
这里有几点需要注意:
- 我选择在这里创建一个通用列表。如今,几乎不需要非通用列表。
- type参数
T
具有记录约束。这意味着列表只能保存值类型(暂时)。这在某种程度上简化了该列表的实现。 - 嵌套
type P = ^T;
声明对您来说可能是新的。它声明了一个指向列表中项目类型的类型指针。这在访问列表项的实现中很有用。 - 该列表将其项保存在256字节的静态数组中,这意味着它总是消耗256字节的(堆栈)内存,而不管类型如何
T
。因此,如果用于创建整数列表,则列表最多可以包含64个项目(因为整数大小为4个字节)。 - 您必须调用该
Initialize
方法来初始化或创建列表。此方法的作用类似于构造函数。由于记录不能没有参数的构造函数(至少在当前的Delphi版本中没有),我选择添加一个Initialize
方法。您也可以选择返回列表的静态方法(如class function Create: TStackList<T>; static;
),但从函数返回大型记录效率不高。 - 虽然你可以
TStackList
在一个对象中声明一个字段,但这并不是这个列表的目的而只是浪费内存。此类型旨在仅在方法中声明为局部变量。
实现非常简单。Initialize方法只计算集合可以容纳的项目数,并将该FCount
字段设置为0(因为在堆栈上声明时,记录中的字段未初始化为0)。
procedure TStackList<T>.Initialize; begin if IsManagedType(T) then raise EInvalidOperation.Create( 'A stack based collection cannot contain managed types'); FCapacity := SizeOf(FData) div SizeOf(T); FCount := 0; end;
它还检查类型参数T
是否为托管类型,如果是,则引发异常。这可能值得一些解释。尽管type参数T
具有记录约束,但这并不意味着T
不能包含托管类型。例如,记录约束阻止Delphi编译:
var List: TStackList<String>;
但不是从编译这个:
type TStringWrapper = record Value: String; end; var List: TStackList<TStringWrapper>;
当type参数T
是引用类型或托管类型时,我们需要一些额外的代码来防止内存泄漏。我们稍后会谈到这一点并且暂时保持简单,不允许这样做。
添加项目的工作方式如下:
procedure TStackList<T>.Add(const AItem: T); var Target: P; begin if (FCount >= FCapacity) then raise EInvalidOperation.Create('List is full'); Target := @FData[FCount * SizeOf(T)]; Target^ := AItem; Inc(FCount); end;
由于此列表的容量是固定的,因此我们需要在列表满时引发异常。我们稍后会看一个替代方案。
因为该FData
字段只是一个字节数组,所以我们需要一个技巧来将类型的项T
放入这个数组中。这是type P = ^T;
宣言派上用场的地方。我们计算FData
数组的偏移量并将其地址分配给Target
类型的变量P
。然后,我们可以取消引用此变量来分配值。检索项目的工作方式类似:
function TStackList<T>.GetItem(const AIndex: Integer): T; var Item: P; begin if (AIndex < 0) or (AIndex >= FCount) then raise EArgumentOutOfRangeException.Create('List index out of range'); Item := @FData[AIndex * SizeOf(T)]; Result := Item^; end;
您可以按如下方式使用此基于堆栈的列表(请参阅repo中的E01SimpleStackList示例):
procedure SimpleStackListExample; var List: TStackList<Integer>; Error: Boolean; I: Integer; begin List.Initialize; { TStackList<Integer> can contain up to 256 bytes of data. An Integer is 4 bytes in size, meaning the list can contain up to 64 Integers. } for I := 0 to 63 do List.Add(I); { Adding 64'th item should raise an exception. } Error := False; try List.Add(0); except Error := True; end; Assert(Error); { Check contents } Assert(List.Count = 64); for I := 0 to List.Count - 1 do Assert(List[I] = I); end;
具有可配置大小的堆栈列表
您可能会发现256字节的存储空间太少或太多。如果这个尺寸是可配置的,那将是很好的。如果Delphi更像C ++,我们可以使用这样的模板参数:
type TStackList<T: record; N: Integer> = record private FData: array [0..N - 1] of Byte; end; var List: TStackList<Double, 1024>;
但是Delphi不是C ++,而泛型不是模板。那么我们怎样才能完成类似的事情?我们可以使用另一个类型参数而不是模板参数,其唯一目的是为列表项提供存储:
type TStackList<T: record; TSize: record> = record private FData: TSize; ...
然后我们可以声明一个存储为1024字节的堆栈列表,如下所示:
type T1024Bytes = record Data: array [0..1023] of Byte end; var List: TStackList<Double, T1024Bytes>;
它的用法与前面介绍的固定大小列表相同。您可以在存储库中的E02StackListWithSize示例中找到此版本。
请注意,该T1024Bytes
类型声明包含在记录中的静态数组。需要记录包装器,因为TSize
由于记录约束,静态数组本身不能用作类型。
原文地址:https://blog.grijjy.com/2019/01/25/allocation-free-collections/