3  内存和分配器

在本章中,我们将讨论内存。Zig 如何控制内存?使用了哪些常用工具?Zig 的内存有哪些重要方面使其与众不同/特殊?您可以在这里找到答案。

计算机的运行从根本上依赖于内存。内存充当计算过程中生成的数据和值的临时存储空间。如果没有内存,编程语言中“变量”和“对象”这两个核心概念就无法实现。

3.1内存空间

您在 Zig 源代码中创建的每个对象都需要存储在计算机内存中的某个位置。根据您定义对象的位置和方式,Zig 将使用不同的“内存空间”或不同类型的内存来存储该对象。

每种类型的记忆通常都有不同的用途。在 Zig 中,我们关心 3 种类型的记忆(或 3 种不同的记忆空间)。它们是:

  • 全局数据寄存器(或“全局数据部分”);
  • 堆;
  • 堆;

3.1.1编译时已知与运行时已知

Zig 用于决定将声明的每个对象存储在何处的一种策略是查看该特定对象的值。更具体地说,是通过调查该值在“编译时”还是“运行时”已知。

当你用 Zig 编写程序时,程序中写入的某些对象的值_在编译时是已知的_。这意味着,当你编译 Zig 源代码时,在编译过程中,zig编译器可以找出源代码中存在的特定对象的确切值。了解每个对象的长度(或大小)也很重要。因此,在某些情况下,程序中写入的每个对象的长度(或大小)在编译时是已知的

编译zig器更关心的是特定对象的长度(或大小),而不是它的实际值。但是,如果zig编译器知道对象的值,那么它就自动知道该对象的大小。因为它可以通过查看值的大小来简单地计算出对象的大小。

因此,编译器的首要任务zig是发现源代码中每个对象的大小。如果在编译时已知该对象的值,那么zig编译器会自动知道该对象的大小/长度。但是,如果在编译时不知道该对象的值,那么只有当且仅当该对象的类型具有已知的固定大小时,编译器才能在编译时知道该对象的大小。

为了使类型具有已知的固定大小,该类型必须具有大小固定的数据成员。例如,如果此类型包含一个可变大小的数组,则该类型没有已知的固定大小。因为这个数组在运行时可以是任意大小(例如,它可以是一个包含 2 个元素的数组,或者 50 个元素的数组,或者 1000 个元素的数组,等等)。

例如,一个字符串对象,其内部是一个常量 u8 值数组([]const u8),其大小可变。它可以是一个包含 100 个或 500 个字符的字符串对象。如果我们在编译时不知道该字符串对象中存储的具体字符串,那么我们就无法在编译时计算该字符串对象的大小。因此,任何类型或任何结构体声明,如果包含一个没有明确固定大小的字符串数据成员,都会使该类型或您正在声明的这个新结构体成为编译时没有已知固定大小的类型。

相反,如果你声明的结构体类型包含一个数组数据成员,但该数组具有已知的固定大小,例如[60]u8(声明了一个包含 60 个u8值的数组),那么,该类型,或者说你声明的结构体,在编译时就变成了一个具有已知固定大小的类型。因此,在这种情况下,zig编译器在编译时不需要知道该类型任何对象的确切值。因为编译器可以通过查看其类型的大小来确定存储该对象所需的大小。

我们来看一个例子。在下面的源代码中,我们声明了两个常量对象(namearray)。由于这些特定对象的值在源代码本身("Pedro"以及从 1 到 4 的数字序列)中被记录下来,因此编译器可以在编译过程中轻松发现这些常量对象(和)zig的值。这就是“编译时已知”的含义。它指的是 Zig 源代码中任何可以在编译时识别其值的对象。name``array

fn input_length(input: []const u8) usize {
    const n = input.len;
    return n;
}

pub fn main() !void {
    const name = "Pedro";
    const array = [_]u8{1, 2, 3, 4};
    _ = name; _ = array;
}

另一种情况是,对象的值在编译时是未知的。函数参数就是一个典型的例子。因为每个函数参数的值都取决于调用函数时赋给该特定参数的值。

例如,函数input_length()包含一个名为 的参数input,它是一个由常量u8整数组成的数组([]const u8)。在编译时不可能知道这个特定参数的值。同样,也不可能知道这个特定参数的大小/长度。因为它是一个在参数类型注释中没有明确指定固定大小的数组。

所以,我们知道这个input参数是一个u8整数数组。但是在编译时,我们不知道它的值,也不知道它的大小。这些信息只有在运行时,也就是程序执行的时间段内才能知道。因此,表达式的值input.len也只有在运行时才知道。这是任何函数的固有特性。只需记住,函数参数的值通常不是“编译时已知的”。

然而,正如我之前提到的,编译器真正重要的是在编译时知道对象的大小,而不一定是它的值。所以,虽然我们在编译时不知道对象(即n表达式的结果)的值,input.len但我们知道它的大小。因为表达式input.len总是返回一个类型的值usize,而该类型usize具有已知的固定大小。

3.1.2全局数据寄存器

全局数据寄存器是 Zig 程序可执行文件的特定部分,负责存储编译时已知的任何值。

你在源代码中声明的每个常量对象(其值在编译时已知)都存储在全局数据寄存器中。此外,你在源代码中写入的每个字面值(例如字符串"this is a string"、整数10或布尔值true)也存储在全局数据寄存器中。

说实话,你不需要太在意这块内存空间。因为你无法控制它,你也无法故意访问它或将其用于你自己的目的。而且,这块内存空间不会影响你程序的逻辑。它只是存在于你的程序中。

3.1.3栈与堆

如果您熟悉系统编程,或者只是一般的低级编程,您可能听说过栈 (Stack) 与堆 (Heap) 之间的“决斗”。这是两种不同类型的内存,或者说是不同的内存空间,它们在 Zig 中都可以使用。

这两种内存实际上并不相互竞争。这是初学者在看到“x vs y”风格的小报标题时常犯的一个错误。这两种内存实际上是互补的。因此,在你编写的几乎每个 Zig 程序中,你都可能会同时使用这两种内存。我将在接下来的章节中详细描述每种内存空间。但现在,我只想明确这两种内存之间的主要区别。

本质上,栈内存通常用于存储长度固定且在编译时已知的值。相比之下,堆内存是一种_动态_类型的内存空间,这意味着它用于存储在程序执行期间(运行时)长度可能增长的值(Chen 和 Guo 2022)。

运行时增长的长度本质上与“运行时已知”类型的值相关。换句话说,如果你有一个对象,其长度可能在运行时增长,那么在编译时,该对象的长度将变得不可知。如果在编译时长度未知,那么在编译时,该对象的值也将变得不可知。这些类型的对象应该存储在堆内存空间中,这是一个动态内存空间,可以根据对象的大小进行增长或收缩。

3.1.4堆栈

栈是一种利用_栈数据结构_强大功能的内存,因此得名。栈是一种使用“后进先出”(LIFO)机制来存储传入值的_数据结构。我想你对这种数据结构应该很熟悉。但如果还不熟悉,维基百科_第 1Geeks For Geeks 第2页都是非常实用且易于理解的资源,可以帮助你全面了解这种数据结构的工作原理。

因此,堆栈内存空间是一种使用堆栈数据结构存储值的内存。它遵循“后进先出”(LIFO)原则在内存中添加和删除值。

每次在 Zig 中进行函数调用时,堆栈中都会为此次特定的函数调用保留一定大小的空间(Chen 和 Guo 2022Zig 软件基金会 2024)。此函数调用中传递给函数的每个函数参数的值都存储在此堆栈空间中。此外,在函数作用域内声明的每个局部对象通常也存储在同一个堆栈空间中。

看下面的例子,该对象result是一个在函数作用域内声明的局部对象add()。因此,该对象存储在为add()函数保留的栈空间中。r在函数作用域外声明的对象add()也存储在栈中。但由于它是在“外部”作用域中声明的,因此该对象存储在属于该外部作用域的栈空间中。

fn add(x: u8, y: u8) u8 {
    const result = x + y;
    return result;
}

pub fn main() !void {
    const r = add(5, 27);
    _ = r;
}

因此,在函数作用域内声明的任何对象始终存储在为该特定函数保留的堆栈内存空间中。main()例如,在函数作用域内声明的任何对象也是如此。正如您所料,在这种情况下,它们存储在为该函数保留的堆栈空间中main()

关于堆栈内存的一个非常重要的细节是它会自动释放。这一点非常重要,请记住。当对象存储在堆栈内存中时,您无需执行(或负责)释放/销毁这些对象。因为一旦函数作用域结束时堆栈空间被释放,它们就会被自动销毁。

因此,一旦函数调用返回(或者说结束,如果你愿意这样称呼它),栈中保留的空间就会被销毁,并且该空间中的所有对象也会随之消失。这种机制的存在是因为这个空间以及其中的对象不再需要了,因为函数“完成了它的任务”。以add()我们上面公开的函数为例,这意味着result一旦函数返回,对象就会被自动销毁。

重要的

存储在函数堆栈空间中的本地对象会在函数范围结束时自动释放/销毁。

同样的逻辑也适用于 Zig 中任何其他特殊结构,这些结构通过用花括号 ( {}) 括起来而拥有自己的作用域。例如,for 循环、while 循环、if else 语句等。例如,如果在 for 循环的范围内声明任何本地对象,则该本地对象只能在该特定 for 循环的范围内访问。因为一旦此 for 循环的作用域结束,堆栈中为此 for 循环保留的空间就会被释放。下面的示例演示了这个想法。

// This does not compile successfully!
const a = [_]u8{0, 1, 2, 3, 4};
for (0..a.len) |i| {
    const index = i;
    _ = index;
}
// Trying to use an object that was
// declared in the for loop scope,
// and that does not exist anymore.
std.debug.print("{d}\n", .{index});

这种机制的一个重要后果是,一旦函数返回,你就无法再访问堆栈中为该特定函数保留的空间内的任何内存地址。因为这个空间已经被销毁了。这意味着,如果这个本地对象存储在堆栈中,你就无法创建一个返回指向该对象的指针的函数。

想一想。如果栈中的所有局部对象在函数作用域结束时都被销毁,你为什么还要考虑返回指向这些对象之一的指针呢?这个指针充其量是无效的,或者更有可能是“未定义的”。

总而言之,编写一个返回本地对象本身作为结果的函数是完全没问题的,因为这样你返回的就是该对象的值。但是,如果这个本地对象存储在堆栈中,你永远不应该编写一个返回指向该本地对象的指针的函数。因为指针指向的内存地址已经不存在了。

因此,再次以该add()函数为例,如果你重写该函数,使其返回一个指向本地对象的指针resultzig编译器实际上会编译你的程序,而不会出现任何警告或错误。乍一看,这似乎是一段按预期运行的好代码。但这是个谎言!

如果您尝试查看对象内部的值r,或者尝试r在另一个表达式或函数调用中使用此对象,那么您将会出现未定义的行为,并且程序中会出现重大错误(Zig Software Foundation 2024,请参阅“生命周期和所有权” 3和“未定义行为” 4部分)。

fn add(x: u8, y: u8) *const u8 {
    const result = x + y;
    return &result;
}

pub fn main() !void {
    // This code compiles successfully. But it has
    // undefined behaviour. Never do this!!!
    // The `r` object is undefined!
    const r = add(5, 27); _ = r;
}

“指向堆栈变量的无效指针”问题在许多编程语言社区中都很常见。例如,如果你尝试在 C 或 C++ 程序中执行同样的操作(即返回存储在堆栈中的本地对象的地址),程序中也会出现未定义的行为。

重要的

如果函数中的局部对象存储在堆栈中,则永远不应从函数返回指向该局部对象的指针。因为函数返回后,该指针始终会变为 undefined,因为函数的堆栈空间在其作用域结束时会被销毁。

但是,如果函数返回后你确实需要以某种方式使用这个本地对象,该怎么办呢?该怎么做呢?答案是:“就像在 C 或 C++ 程序中一样。通过返回存储在堆中的对象的地址”。堆内存的生命周期更加灵活,并且允许你获取指向已从其作用域返回的函数的本地对象的有效指针。

3.1.5堆

栈的一个重要限制是,只有在编译时已知长度/大小的对象才能存储在其中。相比之下,堆是一种更加动态(且灵活)的内存类型。对于在程序执行过程中大小/长度可能增长的对象来说,堆是理想的内存类型。

几乎任何充当服务器的应用程序都是堆的经典用例。HTTP 服务器、SSH 服务器、DNS 服务器、LSP 服务器……任何类型的服务器。总而言之,服务器是一种长时间运行的应用程序,它负责处理(或“处理”)到达该特定服务器的任何传入请求。

对于这类系统来说,堆是一个不错的选择,主要是因为服务器在运行期间无法预先知道会收到多少用户请求。这些请求可能是单个请求,也可能是 5000 个请求,甚至可能是零个请求。服务器需要能够根据收到的请求数量来分配和管理内存。

栈和堆之间的另一个关键区别是,堆是一种由程序员完全控制的内存类型。这使得堆成为一种更灵活的内存类型,但也使其更难使用。因为程序员需要负责管理与之相关的一切,包括内存分配的位置、分配的内存量以及释放内存的位置。

与堆栈内存不同,堆内存由程序员明确分配,并且直到明确释放时才会被释放(Chen 和 Guo 2022)。

要将对象存储在堆中,程序员需要明确地告诉 Zig 这样做,方法是使用分配器在堆中分配一些空间。在第 3.3 节中,我将介绍如何在 Zig 中使用分配器分配内存。

重要的

您在堆中分配的每个内存都需要由程序员明确释放。

Zig 中的大多数分配器确实在堆上分配内存。但此规则的一些例外是ArenaAllocator()FixedBufferAllocator()ArenaAllocator()是一种特殊类型的分配器,可与第二种类型的分配器协同工作。另一方面,FixedBufferAllocator()是一种基于在堆栈上创建的缓冲区对象工作的分配器。这意味着FixedBufferAllocator()仅在堆栈上进行分配。

3.1.6总结

在讨论了所有这些无聊的细节之后,我们可以快速回顾一下我们所学到的内容。总而言之,Zig 编译器将使用以下规则来决定声明的每个对象的存储位置:

  1. 每个文字值(例如"this is string"10true)都存储在全局数据部分中。
  2. 每个在编译时已知的const常量对象( )也存储在全局数据部分中。
  3. 每个在编译时已知长度/大小的对象(无论是否为常量)都存储在当前范围的堆栈空间中。
  4. 如果使用分配器对象的方法alloc()或方法创建对象,则该对象将存储在该特定分配器对象使用的内存空间中。Zig 中大多数可用的分配器都使用堆内存,因此,该对象很可能存储在堆中(这是例外)。create()``FixedBufferAllocator()
  5. 堆只能通过分配器访问。如果你的对象不是通过分配器对象的alloc()create()方法创建的,那么它肯定不是存储在堆中的对象。

3.2堆栈溢出

在栈上分配内存通常比在堆上分配内存更快。但这种更好的性能也伴随着许多限制。我们已经在3.1.4 节中讨论过栈的许多限制。但还有一个更重要的限制我想谈谈,那就是栈本身的大小。

栈的大小是有限制的。不​​同计算机的栈大小有所不同,并且取决于很多因素(例如计算机架构、操作系统等等)。不过,栈的大小通常不会很大。这就是为什么我们通常只使用栈来存储内存中的临时对象和小对象。

本质上,如果你尝试在堆栈上分配空间,而空间过大,超出了堆栈大小的限制,就会发生_堆栈溢出_,程序就会因此崩溃。换句话说,当你尝试使用超过堆栈可用空间时,就会发生堆栈溢出。

这类问题与_缓冲区溢出_非常相似,即你试图使用超出“缓冲区对象”可用空间的内存。然而,堆栈溢出总是会导致程序崩溃,而缓冲区溢出并不总是会导致程序崩溃(尽管它经常会崩溃)。

您可以在下面的示例中看到堆栈溢出的示例。我们尝试u64在堆栈上分配一个非常大的数组。您可以在下面看到该程序无法成功运行,因为它崩溃了,并出现了“段错误”错误消息。

var very_big_alloc: [1000 * 1000 * 24]u64 = undefined;
@memset(very_big_alloc[0..], 0);
Segmentation fault (core dumped)

这个段错误是由于在栈上分配了过大的内存来存储very_big_alloc对象,从而导致栈溢出而引起的。这就是为什么非常大的对象通常存储在堆上,而不是栈上。

3.3分配器

Zig 的一个关键方面是,Zig 中“没有隐藏的内存分配”。这实际上意味着“标准库中不会在你背后进行任何分配” ( Sobeston 2024 )。

这是一个已知问题,尤其是在 C++ 中。因为在 C++ 中,有些操作符会在后台分配内存,而你无法知道这一点,除非你真正阅读这些操作符的源代码,找到内存分配调用。许多程序员觉得这种行为很烦人,而且很难跟踪。

但是,在 Zig 中,如果函数、运算符或标准库中的任何内容在执行期间需要分配一些内存,那么该函数/运算符需要接收(作为输入)用户提供的分配器,才能真正分配所需的内存。

这就明确区分了“不分配”内存的函数和“实际分配”内存的函数。只需查看这个函数的参数即可。如果一个函数或运算符将分配器对象作为其输入/参数之一,那么你肯定知道这个函数/运算符在执行期间会分配一些内存。

一个例子是allocPrint()Zig 标准库中的函数。使用此函数,您可以使用格式说明符编写一个新字符串。因此,此函数与sprintf()C 语言中的函数非常相似。为了编写这样的新字符串,该allocPrint()函数需要分配一些内存来存储输出字符串。

这就是为什么,此函数的第一个参数是一个分配器对象,您(用户/程序员)将其作为函数的输入。在下面的示例中,我使用GeneralPurposeAllocator()作为我的分配器对象。但我可以轻松使用Zig标准库中的任何其他类型的分配器对象。

var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
const name = "Pedro";
const output = try std.fmt.allocPrint(
    allocator,
    "Hello {s}!!!",
    .{name}
);
try stdout.print("{s}\n", .{output});
Hello Pedro!!!

您可以很好地控制此函数可以分配的内存位置和大小。因为是您(用户/程序员)提供了该函数使用的分配器。这使得在 Zig 中更容易实现对内存管理的“完全控制”。

3.3.1什么是分配器?

Zig 中的分配器是可用于为程序分配内存的对象。它们类似于 C 语言中的内存分配函数,例如malloc()calloc()。因此,如果您需要使用比最初拥有的更多的内存,那么在程序执行期间,您只需使用分配器对象即可请求更多内存。

Zig 提供不同类型的分配器,它们通常可通过std.heap标准库模块获取。因此,只需将 Zig 标准库导入到您的 Zig 模块中(使用@import("std")),即可在代码中开始使用这些分配器。

此外,每个分配器对象都构建在 Zig 的接口之上Allocator。这意味着,您在 Zig 中找到的每个分配器对象都必须具有方法alloc()create()和。因此,您可以更改正在使用的分配器类型,但无需更改对执行程序内存分配(和释放内存操作)free()destroy()方法的函数调用。

3.3.2为什么需要分配器?

正如我们在3.1.4 节中所述,每次在 Zig 中进行函数调用时,堆栈中都会为该函数调用保留一个空间。但是堆栈有一个关键的限制:存储在堆栈中的每个对象都有已知的固定长度。

但实际上,有两种非常常见的情况,堆栈的这种“固定长度限制”会成为交易破坏因素:

  1. 您在函数内部创建的对象可能会在函数执行期间增大。
  2. 有时,我们不可能预先知道会收到多少输入,或者输入有多大。

另外,还有另一种情况可能需要使用分配器,那就是当您想要编写一个返回指向本地对象的指针的函数时。正如我在3.1.4 节中所述,如果此本地对象存储在栈中,则无法执行此操作。但是,如果此对象存储在堆中,则可以在函数末尾返回指向此对象的指针。因为您(程序员)控制着您分配的任何堆内存的生命周期。您可以决定何时销毁/释放这块内存。

这些是堆栈不适用的常见情况。因此,您需要一种不同的内存管理策略来在函数内部存储这些对象。您需要使用一种可以随对象一起增长的内存类型,或者可以控制该内存的生命周期。堆就符合这种描述。

在堆上分配内存通常称为动态内存管理。随着程序执行过程中创建的对象大小不断增长,您可以通过在堆中分配更多内存来存储这些对象,从而增加内存量。在 Zig 中,您可以使用分配器对象来实现这一点。

3.3.3不同类型的分配器

在撰写本书时,在 Zig 中,标准库中有 6 种不同的分配器可用:

  • GeneralPurposeAllocator()
  • page_allocator()
  • FixedBufferAllocator()ThreadSafeFixedBufferAllocator()
  • ArenaAllocator()
  • c_allocator()(需要您链接到 libc)。

每个分配器都有其自身的优势和局限性。除FixedBufferAllocator()和 之外的所有ArenaAllocator()分配器都使用堆内存。因此,使用这些分配器分配的任何内存都将放置在堆中。

3.3.4通用分配器

GeneralPurposeAllocator()顾名思义,它是一个“通用”分配器。你可以用它来完成各种类型的任务。在下面的例子中,我为对象分配了足够的空间来存储一个整数some_number

const std = @import("std");

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();
    const some_number = try allocator.create(u32);
    defer allocator.destroy(some_number);

    some_number.* = @as(u32, 45);
}

虽然有用,但您可能希望使用c_allocator(),它是 C 标准分配器 的别名malloc()。所以,是的,malloc()如果您愿意,您可以在 Zig 中使用。只需使用c_allocator()Zig 标准库中的 即可。但是,如果您确实使用了c_allocator(),则必须在使用编译器编译源代码时链接到 Libc zig,方法是在编译过程中包含该标志-lc。如果您不将源代码链接到 Libc,Zig 将无法malloc()在您的系统中找到该实现。

3.3.5页面分配器

是一个page_allocator()在堆中分配整页内存的分配器。换句话说,每次使用 分配内存时page_allocator(),都会分配堆中的整页内存,而不是其中的一小部分。

此页的大小取决于您使用的系统。大多数系统在堆中使用 4KB 的页大小,因此,这通常是每次调用时分配的内存量page_allocator()。这就是为什么page_allocator()在 Zig 中,它被认为是一个快速但“浪费”的分配器。因为它在每次调用中都会分配大量内存,而您的程序很可能不需要那么多内存。

3.3.6缓冲区分配器

FixedBufferAllocator()和是分配器ThreadSafeFixedBufferAllocator()对象,它们与后端固定大小的缓冲区对象协同工作。换句话说,它们使用固定大小的缓冲区对象作为内存的基础。当您请求这些分配器对象为您分配内存时,它们实际上是在这个固定大小的缓冲区对象中预留了一些空间供您使用。

这意味着,为了使用这些分配器,您必须首先在代码中创建一个缓冲区对象,然后将该缓冲区对象作为输入提供给这些分配器。

这也意味着,这些分配器对象既可以在栈中分配内存,也可以在堆中分配内存。一切都取决于你提供的缓冲区对象的位置。如果这个缓冲区对象位于栈中,那么分配的内存就是“基于栈的”。但如果它位于堆中,那么分配的内存就是“基于堆的”。

在下面的例子中,我buffer在堆栈上创建了一个长度为 10 个元素的对象。注意,我将这个buffer对象赋给了FixedBufferAllocator()构造函数。由于这个buffer对象的长度为 10 个元素,这意味着我只能使用这个空间。我无法用这个分配器对象分配超过 10 个元素。如果我尝试分配超过 10 个元素,该alloc()方法将返回OutOfMemory错误值。

var buffer: [10]u8 = undefined;
for (0..buffer.len) |i| {
    buffer[i] = 0; // Initialize to zero
}

var fba = std.heap.FixedBufferAllocator.init(&buffer);
const allocator = fba.allocator();
const input = try allocator.alloc(u8, 5);
defer allocator.free(input);

请记住,这些分配器对象分配的内存可以来自堆栈,也可以来自堆。这完全取决于你提供的缓冲区对象所在的位置。在上面的例子中,对象buffer位于堆栈中,因此分配的内存基于堆栈。但如果它基于堆呢?

正如我们在3.2 节中所述,使用堆而不是栈的主要原因之一是需要分配大量空间来存储非常大的对象。因此,假设您想使用一个非常大的缓冲区对象作为分配器对象的基础。您必须在堆上分配这个非常大的缓冲区对象。下面的示例演示了这种情况。

const heap = std.heap.page_allocator;
const memory_buffer = try heap.alloc(
    u8, 100 * 1024 * 1024 // 100 MB memory
);
defer heap.free(memory_buffer);
var fba = std.heap.FixedBufferAllocator.init(
    memory_buffer
);
const allocator = fba.allocator();

const input = try allocator.alloc(u8, 1000);
defer allocator.free(input);

3.3.7竞技场分配器

ArenaAllocator()一个分配器对象,它接受一个子分配器作为输入。Zig 中 背后的理念ArenaAllocator()类似于编程语言 Go 5中“arena”的概念。它是一个分配器对象,允许您分配任意多次内存,但所有内存只能释放一次。换句话说,例如,如果您调用了 5 次alloc()某个对象的方法,那么只需调用同一对象的方法ArenaAllocator(),就可以一次性释放在这 5 次调用中分配的所有内存。deinit()``ArenaAllocator()

例如,如果你像下面的例子一样,将一个GeneralPurposeAllocator()对象作为构造函数的输入,那么你执行的分配操作实际上将由传入的底层对象进行。因此,使用竞技场分配器,你请求的任何新内存都由子分配器分配。竞技场分配器真正能做的唯一一件事就是帮助你用一个命令释放所有多次分配的内存。在下面的例子中,我调用了3 次。所以,如果我没有使用竞技场分配器,那么我需要调用3 次才能释放所有分配的内存。ArenaAllocator()``alloc()``GeneralPurposeAllocator()``alloc()``free()

var gpa = std.heap.GeneralPurposeAllocator(.{}){};
var aa = std.heap.ArenaAllocator.init(gpa.allocator());
defer aa.deinit();
const allocator = aa.allocator();

const in1 = try allocator.alloc(u8, 5);
const in2 = try allocator.alloc(u8, 10);
const in3 = try allocator.alloc(u8, 15);
_ = in1; _ = in2; _ = in3;

3.3.8和方法alloc()free()

在下面的代码示例中,我们访问stdin标准输入通道 ,以接收来自用户的输入。我们使用 方法来读取用户的输入readUntilDelimiterOrEof()

现在,读取用户的输入后,我们需要将其存储在程序的某个位置。因此,我在本例中使用了分配器。我使用它来分配一定量的内存来存储用户提供的输入。更具体地说,alloc()分配器对象的方法用于分配一个可存储 50 个u8值的数组。

请注意,此alloc()方法接收两个输入。第一个参数是类型。它定义了分配的数组将存储什么类型的值。在下面的示例中,我们分配了一个无符号 8 位整数数组(u8)。但您可以创建一个数组来存储任何类型的值。接下来,在第二个参数中,我们通过指定此数组将包含多少个元素来定义分配数组的大小。在下面的示例中,我们分配了一个包含 50 个元素的数组。

在1.8 节中,我们描述了 Zig 中的字符串只是字符数组。每个字符都由一个u8值表示。因此,这意味着在对象中分配的数组input能够存储长度为 50 个字符的字符串。

因此,本质上,该表达式var input: [50]u8 = undefined将在当前作用域的堆栈中创建一个包含 50 个值的数组u8。但是,您可以使用表达式在堆中分配相同的数组var input = try allocator.alloc(u8, 50)

const std = @import("std");
const stdin = std.io.getStdIn();

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();
    var input = try allocator.alloc(u8, 50);
    defer allocator.free(input);
    for (0..input.len) |i| {
        input[i] = 0; // initialize all fields to zero.
    }
    // read user input
    const input_reader = stdin.reader();
    _ = try input_reader.readUntilDelimiterOrEof(
        input,
        '\n'
    );
    std.debug.print("{s}\n", .{input});
}

另外,请注意,在此示例中,我们使用defer关键字(我在2.1.3 节中描述过)在当前作用域的末尾运行一小段代码,即表达式allocator.free(input)。执行此表达式时,分配器将释放它为input对象分配的内存。

我们在3.1.5 节中讨论过这个问题。你应该始终明确地释放使用分配器分配的任何内存!你可以使用free()分配此内存时使用的分配器对象的方法来执行此操作。defer本例中使用关键字只是为了帮助我们在当前作用域的末尾执行此释放操作。

3.3.9和方法create()destroy()

使用alloc()free()方法,你可以分配内存来一次存储多个元素。换句话说,使用这些方法,我们总是分配一个数组来一次存储多个元素。但是,如果你只需要足够的空间来存储单个元素怎么办?你应该通过 分配一个包含单个元素的数组吗alloc()

答案是否定的!在这种情况下,您应该使用create()分配器对象的方法。每个分配器对象都提供create()destroy()方法,分别用于为单个项目分配和释放内存。

因此,本质上,如果您想分配内存来存储元素数组,则应该使用alloc()free()。但是,如果您只需要存储单个项目,那么create()destroy()方法是理想的选择。

在下面的例子中,我定义了一个结构体来表示某种类型的用户。它可以是游戏的用户,也可以是管理资源的软件,都可以。请注意,这次我使用了方法,在程序中create()存储单个对象。还要注意,我使用了方法在作用域结束时释放此对象占用的内存。User``destroy()

const std = @import("std");
const User = struct {
    id: usize,
    name: []const u8,

    pub fn init(id: usize, name: []const u8) User {
        return .{ .id = id, .name = name };
    }
};

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    const allocator = gpa.allocator();
    const user = try allocator.create(User);
    defer allocator.destroy(user);

    user.* = User.init(0, "Pedro");
}

  1. https://en.wikipedia.org/wiki/Stack_(abstract_data_type) ↩︎

  2. https://www.geeksforgeeks.org/stack-data-struct/ ↩︎

  3. https://ziglang.org/documentation/master/#Lifetime-and-Ownership ↩︎

  4. https://ziglang.org/documentation/master/#Undefined-Behavior ↩︎

  5. https://go.dev/src/arena/arena.go ↩︎