6 指针和可选参数
在我们的下一个项目中,我们将从头开始构建一个 HTTP 服务器。但为了做到这一点,我们需要更多地了解指针以及它们在 Zig 中的工作原理。Zig 中的指针与 C 语言中的指针类似。但它们在 Zig 中具有一些额外的优势。
指针是一个包含内存地址的对象。这个内存地址是特定值在内存中的存储地址。它可以是任何值。大多数情况下,它是来自代码中另一个对象(或变量)的值。
在下面的例子中,我创建了两个对象(number
和pointer
)。pointer
对象 包含存储对象值number
(数字 5)的内存地址。简而言之,这是一个指针。它是一个指向内存中特定现有值的内存地址。你也可以说,对象 指向存储对象的pointer
内存地址。number
const number: u8 = 5;
const pointer = &number;
_ = pointer;
我们使用&
运算符在 Zig 中创建一个指针对象。将此运算符放在现有对象的名称之前,结果将返回该对象的内存地址。将此内存地址存储在新对象中时,该新对象将成为指针对象。因为它存储的是内存地址。
人们通常使用指针作为访问特定值的替代方法。例如,我可以使用pointer
对象来访问对象存储的值number
。访问指针“指向”的值的操作通常称为_取消引用指针_。我们可以使用指针对象的方法在 Zig 中取消引用指针*
。如下例所示,我们取对象指向的数字 5 pointer
,并将其加倍。
const number: u8 = 5;
const pointer = &number;
const doubled = 2 * pointer.*;
std.debug.print("{d}\n", .{doubled});
10
这种取消引用指针的语法很棒。因为我们可以轻松地将它与指针指向的值的方法链接起来。我们可以使用在2.3 节User
中创建的结构体作为示例。如果您返回该部分,您将看到该结构体有一个名为 的方法。print_name()
举个例子,如果我们有一个用户对象,以及一个指向该用户对象的指针,我们可以使用该指针访问该用户对象,同时print_name()
通过将解引用方法(*
)与print_name()
方法链接起来,调用其上的方法。如下例所示:
const u = User.init(1, "pedro", "email@gmail.com");
const pointer = &u;
try pointer.*.print_name();
pedro
我们还可以使用指针来有效地修改对象的值。例如,我可以使用pointer
对象将对象的值设置number
为 6,如下例所示。
var number: u8 = 5;
const pointer = &number;
pointer.* = 6;
try stdout.print("{d}\n", .{number});
6
因此,正如我之前提到的,人们使用指针作为访问特定值的另一种方式。尤其是在他们不想“移动”这些值的时候,他们会使用指针。在某些情况下,你想在代码的不同作用域(即不同的位置)访问某个特定值,但又不想将它“移动”到你所在的新作用域(或位置)。
如果这个值很大,这一点尤其重要。因为如果很大,那么移动这个值就会变成一项昂贵的操作。计算机将不得不花费大量时间将该值复制到新的位置。
因此,许多程序员倾向于通过指针访问值,以避免这种繁琐的将值复制到新位置的操作。我们将在接下来的部分中详细讨论这种“移动操作”。现在,只需记住,避免这种“移动操作”是编程语言中使用指针的主要原因之一。
6.1常量对象与变量对象
你可以有一个指向常量对象的指针,或者一个指向变量对象的指针。但无论这个指针指向哪个对象,它都必须始终遵循其指向对象的特性。因此,如果指针指向一个常量对象,你就不能用它改变它指向的值。因为它指向的是一个常量值。正如我们在1.4 节中讨论过的,你无法改变一个常量值。
例如,如果我有一个number
常量对象,我无法执行下面的表达式,即尝试number
通过该pointer
对象将 的值更改为 6。如下所示,当你尝试执行类似的操作时,会收到编译时错误:
const number = 5;
const pointer = &number;
pointer.* = 6;
p.zig:6:12: error: cannot assign to constant
pointer.* = 6;
如果我number
通过引入关键字将对象更改为变量对象,var
那么我就可以成功地通过指针更改该对象的值,如下所示:
var number: u8 = 5;
const pointer = &number;
pointer.* = 6;
try stdout.print("{d}\n", .{number});
6
你可以在指针对象的数据类型上看到“常量与变量”之间的关系。换句话说,指针对象的数据类型已经为你提供了一些关于它指向的值是否为常量的线索。
当指针对象指向常量值时,该指针的数据类型为*const T
,即“指向 类型常量值的指针T
”。相反,如果指针指向变量值,则指针的类型通常为*T
,即“指向 类型值的指针T
”。因此,每当您看到数据类型为 格式的指针对象时*const T
,您就知道不能使用此指针更改其指向的值。因为该指针指向 类型的常量值T
。
我们已经讨论了指针指向的值是否为常量,以及由此产生的后果。但是,指针对象本身呢?我的意思是,如果指针对象本身是常量,会发生什么?想一想。我们可以有一个指向常量值的常量指针。但我们也可以有一个指向常量值的变量指针。反之亦然。
在此之前,该pointer
对象始终是常量,但这对我们来说意味着什么?对象pointer
为常量会带来什么后果?后果是,我们无法更改指针对象,因为它是常量。我们可以以多种方式使用指针对象,但无法更改其内部的内存地址。
但是,如果我们将pointer
对象标记为变量对象,那么我们就可以更改该对象指向的内存地址pointer
。下面的示例演示了这一点。请注意,对象指向的对象pointer
从c1
变为c2
。
const c1: u8 = 5;
const c2: u8 = 6;
var pointer = &c1;
try stdout.print("{d}\n", .{pointer.*});
pointer = &c2;
try stdout.print("{d}\n", .{pointer.*});
5
6
因此,通过将pointer
对象设置为var
或const
对象,您可以指定此指针对象中包含的内存地址是否可以在程序中更改。另一方面,当且仅当该值存储在变量对象中时,您才可以更改指针指向的值。如果该值存储在常量对象中,则您无法通过指针更改该值。
6.2指针的类型
在 Zig 中,有两种类型的指针(Zig Software Foundation 2024),分别是:
- 单项指针(
*
); - 多项指针(
[*]
);
单项指针对象是指数据类型为 的对象*T
。例如,如果一个对象的数据类型为*u32
,则表示该对象包含一个指向无符号 32 位整数值的单项指针。再例如,如果一个对象的类型为*User
,则表示它包含一个指向某个值的单项指针User
。
相比之下,多项指针是数据类型为 格式的对象[*]T
。请注意,星号 ( *
) 现在位于一对括号 ( []
) 内。如果星号位于一对括号内,则表明此对象是多项指针。
当你&
对一个对象应用该运算符时,你总是会得到一个单项指针。多项指针更像是语言的“内部类型”,与切片更密切相关。因此,当你特意使用该&
运算符创建一个指针时,你总是会得到一个单项指针。
6.3指针运算
Zig 中提供指针运算,其工作方式与 C 中的工作方式相同。当您有一个指向数组的指针时,该指针通常指向数组中的第一个元素,并且您可以使用指针运算来推进该指针并访问数组中的其他元素。
请注意,在下面的例子中,对象最初ptr
指向数组中的第一个元素ar
。但是,然后我开始遍历数组,通过使用简单的指针算法来推进指针。
const ar = [_]i32{ 1, 2, 3, 4 };
var ptr: [*]const i32 = &ar;
try stdout.print("{d}\n", .{ptr[0]});
ptr += 1;
try stdout.print("{d}\n", .{ptr[0]});
ptr += 1;
try stdout.print("{d}\n", .{ptr[0]});
1
2
3
虽然您可以创建一个指向这样的数组的指针,并开始使用指针算法遍历该数组,但在 Zig 中,我们更喜欢使用切片,如第 1.6 节中介绍的那样。
切片本身就是指针,并且还带有一个len
属性,用于指示切片中包含多少个元素。这很有用,因为zig
编译器可以使用它来检查潜在的缓冲区溢出以及其他类似的问题。
此外,您无需使用指针运算来遍历切片的元素。您只需使用slice[index]
语法即可直接访问切片中所需的任何元素。正如我在1.6 节中提到的,您可以使用括号内的范围选择器从数组中获取切片。在下面的示例中,我创建了一个sl
覆盖整个ar
数组的切片 ()。我可以从这个切片中访问 的任何元素ar
,并且切片本身在底层已经是一个指针了。
const ar = [_]i32{1,2,3,4};
const sl = ar[0..ar.len];
_ = sl;
6.4可选类型和可选指针
让我们讨论一下可选类型以及它们与 Zig 中的指针的关系。默认情况下,Zig 中的对象不可为空。这意味着,在 Zig 中,您可以安全地假设源代码中的任何对象都不为空。
与 C 语言的开发者体验相比,Zig 的这一特性非常强大。因为在 C 语言中,任何对象在任何时候都可能为空,因此,C 语言中的指针也可能指向空值。这是 C 语言中常见的未定义行为来源。程序员在 C 语言中使用指针时,必须不断检查指针是否指向空值。
如果由于某种原因,你的 Zig 代码在某个地方产生了一个空值,并且这个空值最终出现在一个不可空的对象中,那么你的 Zig 程序总是会引发运行时错误。以下面的程序为例。zig
编译器可以在编译时看到该null
值,因此会引发编译时错误。但是,如果null
在运行时产生了一个值,Zig 程序也会引发运行时错误,并显示“尝试使用空值”消息。
var number: u8 = 5;
number = null;
p5.zig:5:14: error: expected type 'u8',
found '@TypeOf(null)'
number = null;
^~~~
在 C 语言中,你无法获得这种安全性。在 C 语言中,你不会收到关于程序中产生空值的警告或错误。如果由于某种原因,你的代码在 C 语言中产生了空值,大多数情况下,你最终会得到一个段错误,这可能意味着很多事情。这就是为什么程序员必须不断检查 C 语言中是否存在空值。
默认情况下,Zig 中的指针也是不可空的。这是 Zig 的另一个令人惊叹的特性。因此,您可以放心地假设您在 Zig 代码中创建的任何指针都指向非空值。因此,您无需费力地检查在 Zig 中创建的指针是否指向空值。
6.4.1什么是可选项?
好的,我们现在知道在 Zig 中所有对象默认都是不可空的。但是,如果我们实际上需要使用一个可能接收空值的对象怎么办?这时可选值就派上用场了。
Zig 中的可选对象是指可以为空的对象。为了将对象标记为可选,我们使用?
运算符。当您将此?
运算符放在对象的数据类型之前时,会将此数据类型转换为可选数据类型,并且该对象也将成为可选对象。
以下面的代码片段为例。我们创建了一个名为 的新变量对象num
。该对象的数据类型为?i32
,这意味着,该对象要么包含一个有符号的 32 位整数(i32
),要么包含一个空值。这两种情况对于该num
对象来说都是有效的值。因此,我可以将该对象的值更改为空值,而编译器不会报错zig
,如下所示:
var num: ?i32 = 5;
num = null;
6.4.2可选指针
您还可以将指针对象标记为可选指针,这意味着该对象要么包含空值,要么包含指向值的指针。将指针标记为可选时,该指针对象的数据类型将变为?*const T
或?*T
,具体取决于指针指向的值是否为常量值。?
表示该对象为可选,而*
表示该对象为指针对象。
在下面的示例中,我们创建了一个名为 的变量对象num
,以及一个名为 的可选指针对象ptr
。请注意,对象的数据类型ptr
指示它要么是空值,要么是指向i32
值的指针。另请注意,即使指针对象不是可选的ptr
,也可以将其标记为可选。num
这段代码告诉我们,num
变量永远不会包含空值。该变量始终包含一个有效值i32
。但与之相反,ptr
对象可能包含空值,或者指向某个值的指针i32
。
var num: i32 = 5;
var ptr: ?*i32 = #
ptr = null;
num = 6;
但是,如果我们反过来,将num
对象而不是指针对象标记为可选,会发生什么呢?如果我们这样做,那么指针对象就不再是可选的了。这将是一个类似(尽管不同)的结果。因为这样一来,我们就会得到一个指向可选值的指针。换句话说,一个指向空值或非空值的指针。
在下面的例子中,我们重现了这个想法。现在,ptr
对象的数据类型是*?i32
,而不是?*i32
。注意,此时*
符号 位于 之前?
。所以现在,我们有一个指向 null 或有符号 32 位整数的指针。
var num: ?i32 = 5;
// ptr have type `*?i32`, instead of `?*i32`.
const ptr = #
_ = ptr;
6.4.3可选参数中的空值处理
当 Zig 代码中有一个可选对象时,必须明确处理该对象为空的可能性。这就像使用try
和进行错误处理一样catch
。在 Zig 中,您还必须像处理错误类型一样处理空值。
我们可以通过以下方式实现:
- 一个 if 语句,就像在 C 语言中所做的那样。
- 关键字
orelse
。 - 使用该方法解开可选值
?
。
使用 if 语句时,需要使用一对竖线符号来解包可选值,并在 if 语句块中使用这个“解包后的对象”。以下面的例子为例,如果对象num
为 null,则 if 语句中的代码不会被执行。否则,if 语句会将解包后的对象返回num
到该not_null_num
对象中。在 if 语句的范围内,该not_null_num
对象保证不为 null。
const num: ?i32 = 5;
if (num) |not_null_num| {
try stdout.print("{d}\n", .{not_null_num});
}
5
现在,orelse
关键字 的行为类似于二元运算符。您可以使用此关键字连接两个表达式。在 的左侧orelse
,提供可能导致空值的表达式;在 的右侧orelse
,提供另一个不会导致空值的表达式。
该关键字背后的想法orelse
是:如果左侧表达式的结果为非空值,则使用这个非空值。但是,如果左侧表达式的结果为空值,则使用右侧表达式的值。
看下面的例子,由于x
对象当前为空,因此orelse
决定使用替代值,即数字 15。
const x: ?i32 = null;
const dbl = (x orelse 15) * 2;
try stdout.print("{d}\n", .{dbl});
30
当你想要解决(或处理)这个空值时,可以使用 if 语句或orelse
关键字。但是,如果对于这个空值没有明确的解决方案,并且最合乎逻辑和理智的方法是简单地在遇到这个空值时在程序中发出 panic 并发出一个响亮的错误,那么你可以使用?
可选对象的方法。
本质上,当您使用此?
方法时,可选对象会被解包。如果在可选对象中找到非空值,则使用此非空值。否则,unreachable
使用关键字。您可以unreacheable
在官方文档1中阅读有关此关键字的更多信息。但本质上,当您使用构建模式ReleaseSafe
或构建 Zig 源代码时Debug
,此unreacheable
关键字会导致程序在运行时崩溃并引发错误,如下例所示:
const std = @import("std");
const stdout = std.io.getStdOut().writer();
fn return_null(n: i32) ?i32 {
if (n == 5) return null;
return n;
}
pub fn main() !void {
const x: i32 = 5;
const y: ?i32 = return_null(x);
try stdout.print("{d}\n", .{y.?});
}
thread 12767 panic: attempt to use null value
p7.zig:12:34: 0x103419d in main (p7):
try stdout.print("{d}\n", .{y.?});
^