10 错误处理和联合
在本章中,我想讨论如何在 Zig 中进行错误处理。我们已经简要了解了 Zig 中处理错误的可用策略之一,即1.2.3 节try
中介绍的关键字。但是我们还没有了解其他方法,例如关键字。在本章中,我还想讨论如何在 Zig 中创建联合类型。catch
10.1了解有关 Zig 中错误的更多信息
在了解如何处理错误之前,我们需要进一步了解 Zig 中的错误。错误实际上是 Zig 中的一个值(Zig Software Foundation 2024a)。换句话说,当 Zig 程序内部发生错误时,这意味着 Zig 代码库中的某个地方正在生成错误值。错误值类似于您在 Zig 代码中创建的任何整数值。您可以获取错误值并将其作为输入传递给函数,也可以将其强制转换为不同类型的错误值。
这与 C++ 和 Python 中的异常有一些相似之处。因为在 C++ 和 Python 中,当代码try
块内部发生异常时,你可以使用catch
代码块(在 C++ 中)或except
代码块(在 Python 中)来捕获代码块中产生的异常try
,并将其作为输入传递给函数。
然而,Zig 中的错误值处理方式与异常截然不同。首先,您不能忽略 Zig 代码中的错误值。这意味着,如果错误值出现在源代码中的某个位置,则必须以某种方式显式处理该错误值。这也意味着您不能像处理普通值和对象那样,通过将错误值赋给下划线来丢弃它们。
以下面的源代码为例。这里我们尝试打开一个我电脑中不存在的文件,结果函数FileNotFound
返回了一个明显的错误值openFile()
。但由于我将这个函数的结果赋值给了一个下划线,所以我最终尝试丢弃这个错误值。
编译zig
器检测到这个错误,并引发一个编译错误,告诉我我正在尝试丢弃一个错误值。它还添加了一条注释消息,建议使用try
、catch
或 if 语句来显式处理此错误值。这条注释强调了在 Zig 中必须显式处理每个可能的错误值。
const dir = std.fs.cwd();
_ = dir.openFile("doesnt_exist.txt", .{});
t.zig:8:17: error: error set is discarded
t.zig:8:17: note: consider using 'try', 'catch', or 'if'
10.1.1从函数返回错误
正如我们在1.2.3 节中所述,当一个函数可能返回错误值时,该函数的!
返回类型标注中通常会包含一个感叹号 ( )。感叹号的存在表明该函数可能返回错误值,并且zig
编译器会强制你始终显式处理该函数返回错误值的情况。
看一下print_name()
下面的函数。此函数可能在函数调用中返回错误stdout.print()
,因此其返回类型(!void
)中包含感叹号。
fn print_name() !void {
const stdout = std.getStdOut().writer();
try stdout.print("My name is Pedro!", .{});
}
在上面的例子中,我们使用感叹号来告诉zig
编译器这个函数可能会返回错误。但是这个函数究竟返回了什么错误呢?目前,我们还没有指定具体的错误值。目前,我们只知道可能会返回一些错误值(无论它是什么)。
但实际上,您可以(如果您愿意)清楚地指定此函数可能返回哪些确切的错误值。Zig 标准库中有很多这样的例子。fill()
以模块中的这个函数http.Client
为例。此函数返回 或 类型的错误ReadError
值void
。
pub fn fill(conn: *Connection) ReadError!void {
// The body of this function ...
}
指定函数预期返回的确切错误值的想法很有意思。因为它们会自动成为函数的某种文档,而且这允许zig
编译器对代码执行一些额外的检查。因为编译器可以检查函数内部是否生成了其他类型的错误值,并且这些错误值没有被包含在返回类型注释中。
无论如何,你可以将函数可能返回的错误类型列在感叹号左侧,而有效值则放在感叹号右侧。因此语法格式如下:
<error-value>!<valid-value>
10.1.2错误集
_但是,当我们有一个可能返回不同类型错误的函数时该怎么办呢?当您有这样一个函数时,您可以通过 Zig 中我们称之为错误集_的结构列出可以从该函数返回的所有不同类型的错误。
错误集是联合类型的一种特例。它是一个包含错误值的联合体。并非所有编程语言都提供“联合对象”的概念。但总而言之,联合只是一组数据类型。联合用于允许一个对象拥有多种数据类型。例如, 和 的联合x
表示y
一个z
对象可以是 类型x
、 类型y
或 类型z
。
我们将在10.3 节中更深入地讨论联合。但是,您可以通过error
在一对花括号前写入关键字来编写错误集,然后在这对花括号内列出可以从函数返回的错误值。
以resolvePath()
下面的函数为例,该函数来自introspect.zig
Zig 标准库的模块。我们可以从其返回类型注释中看到,该函数返回以下两种结果:1)一个有效的u8
值切片([]u8
);或 2)错误集中列出的三种不同类型的错误值之一(OutOfMemory
、Unexpected
等)。这是一个错误集的使用示例。
pub fn resolvePath(
ally: mem.Allocator,
p: []const u8,
) error{
OutOfMemory,
CurrentWorkingDirectoryUnlinked,
Unexpected,
}![]u8 {
// The body of the function ...
}
这是注释 Zig 函数返回值的有效方法。但是,如果您浏览组成 Zig 标准库的模块,您会注意到,在大多数情况下,程序员更喜欢为该错误集赋予一个描述性名称,然后在返回类型注释中使用该错误集的名称(或“标签”),而不是直接使用错误集。
ReadError
我们可以在之前展示的函数错误集中看到这一点fill()
,该错误集是在模块中定义的http.Client
。没错,我之前展示的错误集ReadError
好像只是一个标准的单一错误值,但实际上,它是在http.Client
模块中定义的错误集,因此,它实际上代表了函数内部可能发生的一组不同错误值fill()
。
看一下ReadError
下面重现的定义。注意,我们将所有这些不同的错误值分组到一个对象中,然后将这个对象用于函数的返回类型注释。就像fill()
我们之前展示的函数,或者readvDirect()
来自同一模块的函数(如下所示)。
pub const ReadError = error{
TlsFailure,
TlsAlert,
ConnectionTimedOut,
ConnectionResetByPeer,
UnexpectedReadFailure,
EndOfStream,
};
// Some lines of code
pub fn readvDirect(
conn: *Connection,
buffers: []std.posix.iovec
) ReadError!usize {
// The body of the function ...
}
因此,错误集只是将一组可能的错误值分组为单个对象或单个错误值类型的一种便捷方法。
10.1.3转换错误值
假设有两个不同的错误集,分别为A
和B
。如果错误集A
是错误集的超集B
,那么你可以将中的错误值强制转换B
为的错误值A
。
误差集只是一组误差值。因此,如果误差集A
包含误差集 中的所有误差值B
,则A
成为 的超集B
。也可以说误差集B
是误差集 的子集A
。
下面的例子演示了这个想法。因为A
包含来自的所有值B
,A
所以是的超集B
。用数学符号来说,我们会说一个⊃B。因此,我们可以将错误值作为函数B
的输入cast()
,并将此输入隐式转换为相同的错误值,但来自A
集合。
const std = @import("std");
const A = error{
ConnectionTimeoutError,
DatabaseNotFound,
OutOfMemory,
InvalidToken,
};
const B = error {
OutOfMemory,
};
fn cast(err: B) A {
return err;
}
test "coerce error value" {
const error_value = cast(B.OutOfMemory);
try std.testing.expect(
error_value == A.OutOfMemory
);
}
1/1 file826379a872a1.test.coerce error value...OKA
All 1 tests passed.
10.2如何处理错误
现在我们已经了解了 Zig 中的错误,让我们讨论一下处理这些错误的可用策略,这些策略包括:
try
关键词;catch
关键词;- if 语句;
errdefer
关键词;
10.2.1什么try
意思?
正如我在前面几节中所描述的,当我们说一个表达式可能返回错误时,我们基本上指的是具有以下格式的返回类型的表达式!T
。!
表示此表达式返回错误值或类型的值T
。
在1.2.3 节中,我介绍了该try
关键字以及它的用法。但我没有讨论这个关键字对你的代码到底有什么作用,或者换句话说,我还没有解释它try
在你的代码中意味着什么。
本质上,当你try
在表达式中使用关键字时,你是在告诉zig
编译器:“嘿!帮我执行这个表达式,如果这个表达式返回错误,请返回这个错误并停止程序的执行。但如果这个表达式返回一个有效值,那么就返回这个值,然后继续执行。”
换句话说,该try
关键字本质上是一种在发生错误时进入恐慌模式并停止程序执行的策略。使用该try
关键字,你是在告诉zig
编译器,如果特定表达式发生错误,停止程序执行是最合理的策略。
10.2.2关键字catch
好的,现在我们正确理解了它try
的含义,现在开始讨论catch
。这里的一个重要细节是,您可以使用try
或catch
来处理错误,但不能将和一起使用try``catch
。换句话说,try
和catch
在 Zig 语言中是完全不同的独立策略。
这种情况并不常见,与其他语言的情况不同。大多数采用_try catch_模式的编程语言(例如 C++、R、Python、JavaScript 等)通常将这两个关键字一起使用来构成完整的逻辑,以正确处理错误。无论如何,Zig 在_try catch_模式中尝试了一种不同的方法。
所以,我们已经了解了 的try
含义,并且我们也知道try
和catch
应该单独使用,彼此分开。但是catch
在 Zig 中究竟做什么呢?使用catch
,我们可以构建一个逻辑块来处理错误值,以防它在当前表达式中发生。
请看下面的代码示例。我们再次回到之前的示例,当时我们尝试打开一个电脑中不存在的文件。但这次,我catch
实际实现了一个逻辑来处理错误,而不是直接停止执行。
更具体地说,在这个例子中,我使用一个记录器对象在返回错误并停止程序执行之前将一些日志记录到系统中。例如,这可能是某个复杂系统代码库的某个部分,我无法完全控制它,我希望在程序崩溃之前记录这些日志,以便稍后进行调试(例如,我可能无法编译完整的程序,也无法用调试器正确地调试它。因此,记录这些日志可能是克服这一障碍的有效策略)。
const dir = std.fs.cwd();
const file = dir.openFile(
"doesnt_exist.txt", .{}
) catch |err| {
logger.record_context();
logger.log_error(err);
return err;
};
因此,我们catch
创建一个表达式块来处理错误。我可以像上例一样,从这个表达式块返回错误值,这将使程序进入恐慌模式并停止执行。但我也可以从这个代码块返回一个有效值,并将其存储在file
对象中。
请注意,我们不是像 那样将关键字写在可能返回错误的表达式之前,而是try
写catch
在表达式之后。我们可以打开竖线对 ( |
),它捕获表达式返回的错误值,并将该错误值catch
作为名为 的对象在代码块范围内可用err
。换句话说,因为我|err|
在代码中编写了 ,所以我可以使用 对象来访问表达式返回的错误值err
。
虽然这是 最常见的用法catch
,但您也可以使用此关键字以“默认值”的方式处理错误。也就是说,如果表达式返回错误,则使用默认值。否则,我们将使用表达式返回的有效值。
Zig 官方语言参考提供了一个很好的例子来说明这种“默认值”策略catch
。此示例如下所示。请注意,我们正在尝试从名为的字符串对象中解析一些无符号整数str
。换句话说,此函数正在尝试将 类型的对象[]const u8
(即字符数组、字符串等)转换为 类型的对象u64
。
但是函数执行的解析过程parseU64()
可能会失败,从而导致运行时错误。catch
本例中使用的关键字提供了一个备用值 (13),以便在函数引发错误时使用parseU64()
。因此,下面的表达式本质上意味着:“嘿!请帮u64
我将这个字符串解析为 ,并将结果存储到 对象 中number
。但是,如果发生错误,则改用 值13
。”
const number = parseU64(str, 10) catch 13;
因此,在此过程结束时,对象number
将包含u64
从输入字符串成功解析的整数str
,或者,如果在解析过程中发生错误,它将包含关键字提供的u64
值作为“默认”值或“替代”值。13``catch
10.2.3使用 if 语句
现在,您还可以使用 if 语句来处理 Zig 代码中的错误。在下面的示例中,我重现了前面的示例,我们尝试使用名为 的函数从输入字符串中解析整数值parseU64()
。
我们在“if”语句中执行表达式。如果此表达式返回错误值,则不会执行 if 语句的“if 分支”(或“true 分支”)。但是,如果此表达式返回有效值,则该值将被解包到number
对象中。
这意味着,如果parseU64()
表达式返回一个有效值,则该值在该“if 分支”(即“true 分支”)的范围内通过我们在管道字符对(|
)内列出的对象(即对象)变为可用number
。
如果发生错误,我们可以使用 if 语句的“else 分支”(或“false 分支”)来处理错误。在下面的示例中,我们else
在 if 语句中使用 将错误值(由 返回的parseU64()
)解包到err
对象中,并处理错误。
if (parseU64(str, 10)) |number| {
// do something with `number` here
} else |err| {
// handle the error value.
}
现在,如果您正在执行的表达式返回不同类型的错误值,并且您想要对每种类型的错误值采取不同的操作,则try
和catch
关键字以及 if 语句策略就会受到限制。
对于这种情况,该语言的官方文档建议将 switch 语句与 if 语句一起使用(Zig Software Foundation 2024b)。其基本思想是,使用 if 语句执行表达式,并使用“else 分支”将错误值传递给 switch 语句,在 switch 语句中,您可以为 if 语句中执行的表达式可能返回的每种错误值类型定义不同的操作。
下面的例子演示了这个想法。我们首先尝试将一组任务添加(或注册)到队列中。如果这个“注册过程”顺利完成,我们就会尝试将这些任务分配给系统的各个工作线程。但是,如果这个“注册过程”返回错误值,我们就会在“else分支”中使用switch语句来处理每个可能的错误值。
if (add_tasks_to_queue(&queue, tasks)) |_| {
distribute_tasks(&queue);
} else |err| switch (err) {
error.InvalidTaskName => {
// do something
},
error.TimeoutTooBig => {
// do something
},
error.QueueNotFound => {
// do something
},
// and all the other error options ...
}
10.2.4关键字errdefer
C 程序中常见的一种模式是在程序执行过程中发生错误时清理资源。换句话说,处理错误的一种常见方法是在退出程序之前执行“清理操作”。这可以保证运行时错误不会导致程序泄漏系统资源。
关键字errdefer
是在恶劣情况下执行此类“清理操作”的工具。此关键字通常用于在程序因生成错误值而停止执行之前清理(或释放)已分配的资源。
其基本思想是为关键字提供一个表达式errdefer
。然后,errdefer
当且仅当在当前作用域执行期间发生错误时,才执行此表达式。在下面的示例中,我们使用分配器对象(已在3.3 节中介绍过)来创建一个新User
对象。如果我们成功创建并注册了这个新用户,则此create_user()
函数将返回这个新User
对象作为其返回值。
errdefer
但是,如果由于某种原因,该行之后的某个表达式(例如,在表达式中)生成了错误值,则在函数返回错误值之前以及程序进入恐慌模式并停止当前执行之前,注册db.add(user)
的表达式就会得到执行。errdefer
fn create_user(db: Database, allocator: Allocator) !User {
const user = try allocator.create(User);
errdefer allocator.destroy(user);
// Register new user in the Database.
_ = try db.register_user(user);
return user;
}
通过使用errdefer
来销毁user
刚刚创建的对象,我们可以保证user
在程序停止执行之前释放为该对象分配的内存。因为如果表达式try db.add(user)
返回错误值,程序就会停止执行,并且我们会失去所有引用以及对为该user
对象分配的内存的控制。因此,如果我们user
在程序停止之前没有释放与该对象关联的内存,那么我们就无法再释放这部分内存。我们只是失去了做正确事情的机会。这就是为什么errdefer
在这种情况下 至关重要。
为了清楚地说明defer
和之间的区别errdefer
(我在2.1.3 节和2.1.4 节中描述过),可能值得进一步讨论一下。你可能仍然会问:“errdefer
既然可以用defer
,为什么还要用?”
尽管和 关键字相似,errdefer
但它们之间的关键区别defer
在于何时执行提供的表达式。defer
关键字始终在当前作用域的末尾执行提供的表达式,无论代码如何退出此作用域。相反,errdefer
仅当当前作用域中发生错误时才执行提供的表达式。
如果您在当前作用域中分配的资源稍后在代码中(在其他作用域中)被释放,这一点就变得非常重要。create_user()
函数就是一个例子。如果您仔细思考这个函数,您会注意到它返回的是user
对象作为结果。
换句话说,如果函数成功返回,则为该user
对象分配的内存不会在create_user()
函数内部释放。因此,如果此函数内部没有发生错误,则该user
对象将从函数中返回,并且很可能由该create_user()
函数之后运行的代码负责释放该user
对象的内存。
但是如果函数内部发生错误怎么办create_user()
?然后会发生什么?这意味着你的代码执行将在此create_user()
函数中停止,因此,此create_user()
函数之后运行的代码将无法运行,并且,user
在程序停止之前,对象的内存将无法释放。
这是完美的场景errdefer
。我们使用这个关键字来保证我们的程序将释放为对象分配的内存user
,即使create_user()
函数内部发生错误。
如果你在同一作用域内为某个对象分配并释放一些内存,那么你只需要使用即可defer
,也就是说,errdefer
在这种情况下,这些内存对你来说毫无用处。但是,如果你在作用域 A 中分配了一些内存,但之后才释放,例如在作用域 B 中,那么errdefer
在特殊情况下,这些内存对于避免内存泄漏就非常有用。
10.3 Zig 中的联合类型
联合类型定义了对象可以属于的一组类型。它就像一个选项列表。每个选项都是对象可以采用的类型。因此,Zig 中的联合与 C 语言中的联合具有相同的含义,或者说,具有相同的作用。它们的用途相同。您也可以说,Zig 中的联合产生的效果与Python 1中的联合typing.Union
类似。
例如,您可能正在创建一个 API,用于将数据发送到托管在某个私有云基础设施上的数据湖。假设您在代码库中创建了不同的结构体,用于存储所需的必要信息,以便连接到各个主流数据湖服务(Amazon S3、Azure Blob 等)。
现在,假设您还有一个名为 的函数send_event()
,它接收一个事件作为输入,以及一个目标数据湖,并将输入事件发送到目标数据湖参数中指定的数据湖。但这个目标数据湖可以是三大主流数据湖服务(Amazon S3、Azure Blob 等)中的任何一个。这时,联合就可以帮助您了。
LakeTarget
下面定义的联合允许 的lake_target
参数为 类型、 类型或 类型send_event()
的对象。此联合允许函数接收这三种类型中任意一种的对象作为参数的输入。AzureBlob``AmazonS3``GoogleGCP``send_event()``lake_target
请记住,这三种类型(AmazonS3
、GoogleGCP
和AzureBlob
)都是我们在源代码中定义的独立结构体。因此,乍一看,它们在源代码中是独立的数据类型。但是, 是一个union
关键字,将它们统一为一个名为 的数据类型LakeTarget
。
const LakeTarget = union {
azure: AzureBlob,
amazon: AmazonS3,
google: GoogleGCP,
};
fn send_event(
event: Event,
lake_target: LakeTarget
) bool {
// body of the function ...
}
联合体定义由一系列数据成员组成。每个数据成员都属于一种特定的数据类型。在上面的例子中,LakeTarget
联合体有三个数据成员(azure
、amazon
、google
)。实例化一个使用联合体类型的对象时,在本次实例中只能使用其中一个数据成员。
您也可以将其理解为:联合类型中一次只能激活一个数据成员,其他数据成员保持停用状态且不可访问。例如,如果您创建了一个LakeTarget
使用azure
数据成员的对象,则您将无法再使用或访问数据成员google
或amazon
。这就像这些其他数据成员在该类型中根本不存在一样LakeTarget
。
您可以在下面的示例中看到此逻辑。请注意,我们首先使用azure
数据成员实例化联合对象。因此,此target
对象仅包含azure
其内部的数据成员。只有此数据成员在此对象中处于活动状态。这就是为什么此代码示例中的最后一行无效的原因。因为我们尝试实例化google
当前对此target
对象处于非活动状态的数据成员,因此程序进入恐慌模式,并通过响亮的错误消息警告我们此错误。
var target = LakeTarget {
.azure = AzureBlob.init()
};
// Only the `azure` data member exist inside
// the `target` object, and, as a result, this
// line below is invalid:
target.google = GoogleGCP.init();
thread 2177312 panic: access of union field 'google' while
field 'azure' is active:
target.google = GoogleGCP.init();
^
因此,实例化联合对象时,必须选择联合类型中列出的数据类型之一(或数据成员之一)。在上面的示例中,我选择使用azure
数据成员,结果所有其他数据成员都自动停用,实例化对象后您将无法再使用它们。
您可以通过完全重新定义整个枚举对象来激活另一个数据成员。在下面的示例中,我最初使用了该azure
数据成员。但随后,我重新定义了该target
对象,以便使用一个LakeTarget
使用该google
数据成员的新对象。
var target = LakeTarget {
.azure = AzureBlob.init()
};
target = LakeTarget {
.google = GoogleGCP.init()
};
关于联合类型,有一个奇怪的事实:首先,你不能在 switch 语句中使用它们(这在2.1.2 节中介绍过)。换句话说,如果你有一个类型的对象LakeTarget
,你不能将它作为 switch 语句的输入。
但是如果你真的需要这样做呢?如果你真的需要为 switch 语句提供一个“联合对象”怎么办?这个问题的答案依赖于 Zig 中的另一种特殊类型,即带_标签的联合_。要创建带标签的联合,你所要做的就是在联合声明中添加一个枚举类型。
作为 Zig 中带标签联合的示例,请参考Registry
下面公开的类型。此类型来自Zig 存储库中的grammar.zig
模块2。(enum)
此联合类型列出了不同类型的注册表。但请注意,关键字后面使用了union
。这就是使此联合类型成为带标签联合的原因。由于是带标签联合,因此此类型的对象Registry
可以用作 switch 语句的输入。这就是您要做的全部。只需将其添加(enum)
到union
声明中,即可在 switch 语句中使用它。
pub const Registry = union(enum) {
core: CoreRegistry,
extension: ExtensionRegistry,
};