分类
Go 程序设计语言

Go 语言中的错误处理

学习 Go 语言之前,我认为为程序员提供错误与异常处理的最方便的语言是 Python;学习 Go 语言之后,我的看法没有任何改变……

@DGideas

本文参考了 @ethancai 的这篇文章@davecheney 在 Gocon Spring 2016 上的这篇演讲稿,以及若干位于 golang.org 上的博客文章和文档。

Go 语言有着异于其他程序设计语言的错误处理体验。要说 Go 程序设计语言的一大特色,恐怕就是它频繁出现的 if err != nil 语句块了。得益于 Go 语言函数的多返回值(multiple results)特性,一个不一定总能成功执行的函数可以依靠一个类型为 error 的返回值表示其执行过程是否处于异常状态,例如:

f, err := os.Open("filename.ext")
if err != nil {
	log.Fatal(err)
}

这种传递机制可以帮助 Go 语言程序员(尤其是函数的设计者)在不重载函数返回结果本身意义的情况下,额外向函数的调用者传达“函数调用是否符合预期”的信息。事实上,Go 语言的设计者认为使用类似 try-catch-finally 语句块进行错误与异常处理会导致代码层次繁琐。从语言层面要求程序员显式处理错误有助于提升程序的健壮性。

为了在 Go 语言中处理错误,我们需要了解有关 error 类型的更多信息。

error 是什么?

src/builtin/builtin.go 中的代码中,我们注意到语言本身是通过以下方式定义 error 类型的:

// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
	Error() string
}

这些语句代表什么?有经验的 Go 语言程序员告诉我们,它表明错误(errors)仅仅是个(value)。语言将 error 实现为接口(interface)类型,也就是说——任何实现 error 这个 interface 的类型本身都属于错误类型

标准库 errors 提供的 New() 方法容易让我们定义新的错误类型。例如,标准库 io包含自定义错误类型 io.EOF,表示读取时遇到文件结尾(End-Of-File,EOF)。io.EOF 的定义如下:

var EOF = errors.New("EOF")

有了自定义错误类型,我们拥有了能够区分一个错误是“何种错误”的能力,下面是根据错误类型判断的错误处理方法:

buf := make([]byte, 100)
n, err := r.Read(buf)
buf = buf[:n]
if err == io.EOF {
	log.Fatal("read failed:", err)
}

就像 Python 语言中的 except EOFError: 一样,上方的语句只捕获 io.EOF 一种错误。这种方法并不是很灵活。如果上游对 Read() 函数的逻辑进行了修改,使其返回的错误类型变成了 io.END 或者其他类型,则所有调用 r.Read() 函数下的错误处理逻辑均需修改。

说到这里,我们可能会冒出很多想法来变通地解决这个类型变化的问题。诸多方法中,最靠谱的一种方法是依靠 err.Error() 返回的文本来判断是何种错误:

f, err := os.Open("filename.ext")
if strings.Contains(err.Error(), "not found") {
	log.Fatal(err)
}

error 类型中 Error() 返回的字符串是为人类用户设计的,因为错误描述随时可能发生变化。在你的职业生涯中,永远不要依赖字符串判断错误类型,我们在下边将会看到更加优美的错误处理方法。

两条建议……

还有另外两种处理错误的更常用方法:作为函数的设计者,我们可以在函数中自定义错误类型,让上层函数的调用者通过类型断言判别错误类型;或者只检查是否发生错误(err != nil),即不知道被调用函数返回的错误任何细节。这些方法各有利弊,我们接下来讨论这些方法。

在被调函数中自定义错误类型的行为看起来跟上一节介绍的 io.EOF 的做法类似,不过由于我们自定义了错误类型,就可以向错误值中附加更多上下文信息:

type MyError struct {
	Msg string
	File string
	Line int
}

func (e *MyError) Error() string {
	return fmt.Sprintf("%s:%d: %s", e.File, e.Line. e.Msg)
}

return &MyError{"Something happened", "server.go", 42}

这段来自 @davecheney 的示例代码自定义了一种能保存错误上下文(文件名称,代码行数)信息的错误类型。当错误信息被传递时,即使顶层函数打印错误日志的时候也能够精确定位到是哪个文件的哪一部分内容遇到错误。Go 语言有一个类似的笑话:程序员在一个复杂依赖关系的网络访问程序里层层传递错误,终于在顶层,错误信息被打印出来:

No such file or directory.

显然,这种错误信息对调试没有任何帮助。不过,像 github.com/pkg/errors 这样的框架提供了 Wrap()Cause() 函数对,这些函数将错误层层封装后传递给顶层函数。这样,顶层函数打印的信息就包含了满足程序员调试需求的足够上下文:

func Wrap(cause error, message string) error
func Cause(err error) error

使用Wrap()Cause() 函数对后,错误打印信息类似这样显示:

could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory

对吧,看起来好多了!

程序员需要知道,所有根据错误类型推断错误的做法都会增加模块之间的依赖性。当我们处理错误时,如果只关心是否发生错误,而不关注错误类型,我们就可以使用 if err != nil 这种形式,这通常也是初学者最先学习到的错误处理方法。作为上层代码,你需要知道的就是被调用函数是否正常工作。如果你接受这个原则,将极大降低模块之间的耦合性。

有时候,我们并不关注错误的具体类型是什么。而是关注错误是否实现了某一个行为。比如,标准库 net 定义的错误类型 net.Error 是这样的:

type Error interface {
	error
	Timeout() bool   // Is the error a timeout?
	Temporary() bool // Is the error temporary?
}

在诸如网络通信等任务中,如果遇到错误,需要了解错误的属性,以决定是否需要重试等操作:

type temporary interface {
	Temporary() bool    // IsTemporary returns true if err is temporary.
}

func IsTemporary(err error) bool {
	te, ok := err.(temporary)
	return ok && te.Temporary()
}

这种实现方式的好处在于,不需要知道具体的错误类型,也就不需要引用定义了错误类型的额外库。如果你是底层代码的开发者,哪天你想更换一个实现更好的 error,也不用担心影响上层代码逻辑。如果你是上层代码的开发者,你只需要关注 error 是否实现了特定行为,不用担心引用的三方库升级后,程序逻辑失败。

分类
C++ 程序设计语言

为什么要有指针和引用类型?

……显然,我们能通过名字使用对象。然而在 C++ 中,大多数对象都“有身份”;也就是说对象位于内存的某个地址中,如果我们知道对象的地址和类型,就能访问它……

翻译自 Bjarne Stroustrup 《The C++ Programming Language》(Fourth Edition),Chapter 7.

@Lollipop9z 同学在上次与我讨论时提出了这个很有趣的问题。由于 lollipop 之前有学习 Python 程序设计语言的背景,所以对于 C++ 等语言中为何提供这些特性感到困惑。事实上,很多学习过包含指针和/或引用概念程序设计语言的同学也仍然对于为什么会存在这些语言元素的原因缺乏思考。下述代码以 C++ 为例。

感谢 @szouc 对于数组声明与数组类型隐式转换方面的指正。

为什么要有指针?

现代通用电子计算机在程序运行时将所需数据存储于内部存储器(内存)中。通过机器可理解的语言,我们能令电子计算机存取内存中某一指定位置数据,就比如编写一个统计字符串字面量中含有多少个英文小写字母 a 的程序一样,这个程序将用于计数的数据存储于内存空间中的某个位置中。像这样的操作使用是如此频繁,以至于高级程序设计语言专门为程序员提供一种被称为“变量”的语言概念。

变量(Variable)是一块具名(Named)地址空间。在高级程序设计语言中,我们不再令计算机程序访问某一个特定位置的数据,而只需指出我们需要一块在内存中名为 a 的,存储默认长度整型数据的空间。就像在大多数编程语言中我们的如下定义一样:

int a;

这样的名称 – 地址一对一的方式使程序员不再关注变量存储的具体位置,这也为兼容于不同内存寻址方式提供了更多方便。然而,并不是所有类型的变量都与计算机为存储该变量所分配的地址空间之间存在一对一的关系,就比如:

int b[10];

上述 C++ 代码声明了一组连续的,能够存储 10 个默认长度整型变量的空间块,其中标识符(Identifier) b 是数组名称。C++ 标准(conv.array、expr.unary.op 与 expr.sub)指导我们,在某些语境下,包含数组名称 b 的表达式中,标识符 b 可以隐式转换为所分配的空间块中第一块的地址。习惯上,我们使用类似 *b 的包含间接寻址运算符的表达式计算第一块地址所对应的内容

我们如何读取其余几块的内容?我们并没有一种方式能够直接访问这些空间的内容,但因为我们知道第一块的地址,(编译器)也知道每块默认长度整型的空间有多长,所以编程语言能够提供类似 *(b+2) 或者 b[2] 的方式,允许我们使用首块地址+偏移量(Offset)的方式间接地访问其他块的内容。

这种通过地址+偏移量间接地访问其他块内容的方式,被称为间接寻址(Indirection)与数组下标(Subscript)运算。像这种保存空间中某一个块的地址的变量,被称为指针。这样,我们很容易接受指针这个概念。

要注意,由于不同数据类型每块所占用的空间各有不同,所以指针是具有类型的。类型系统有助于指导程序以何种方式去解释内存中某块位置的数据,也能够正确处理类似 *(b+2) 的偏移量操作。

其他编程语言,比如 Python ,并没有提供指针数据类型。但也通过下标操作(类似 b[2] )提供访问非具名数据的能力。

那……引用呢?

想象我们有以下函数,完成一项具有超凡成就的工作:

void nicejob(int a)
{
	a = a * 2;
}

这段代码意义非凡。它尝试将传入的变量加倍后,将自身改为加倍后的新值。可惜,这段代码不能正常工作。当你尝试执行下列代码时,你就会发现它并不能如期运行:

#include <iostream>

int main()
{
	int x = 2;
	std::cout << x << std::endl;
	nicejob(x);
	std::cout << x << std::endl;
	return 0;
}
输出:
2
2

发生了什么?在 nicejob 中对于变量 a 的更改并不会影响 x 的值。包括 C/C++ 在内的多种语言中,当按值传递参数到函数时,函数中获得的变量 a 仅仅是 x 的拷贝——你一直在对拷贝进行操作,当然不会影响到原来的 x 值。

显然我们有两种方法解决这个问题。我们可以将变量 x地址传递给函数 nicejob ,在函数中修改内存对应位置的 x 值,简单且粗暴。我们还可以指示我们的程序将 x 值本身(而非拷贝!)传入函数 nicejob 中,这样在函数中操作 a 就相当于直接操作 x 一样—— ax 的别名。

void nicejob(int& a)
{
	a = a * 2;
}

我们只需在函数形式参数列表中将变量 a 的传递类型由 int 改为 int& —— 引用类型即可。这样我们的 nicejob 函数就能如期工作了。

当我们将一个很大的变量传递给函数时,为了避免在变量拷贝过程中的大量开销(比如,我们传递一个大小为 1GB 的图片给图片处理函数时),我们也使用引用类型。为防止函数对传入变量的误修改,我们可以将函数形参列表(Parameter-list)中的变量类型设为常量引用,就比如 const Image&

指针提供了一种(直接或间接)访问非具名数据的能力;引用是一种程序变量在构造过程中初始化的方式;

DGideas