乍听之下,不无道理;仔细揣摩,胡说八道

0%

多重返回值的阵营九宫格

通常在糊业务代码的时候,不管是函数、方法,还是宏,都只会有一个返回值。比如在C语言用于检查一个字符是否为阿拉伯数字的isdigit函数就只会返回是(1)或否(0)

1
2
3
4
5
6
7
8
9
10
#include <ctype.h>
#include <stdio.h>

int
main(int argc, char *argv[])
{
char c = 'a';
printf("isdigit('%c') is %d\n", c, isdigit(c));
return 0;
}

但有时候如果一个函数、方法,或宏可以返回多个值的话会更加方便。例如,在Python中dict类型有一个实例方法get,它可以取得dict实例中与给定的键对应的值。但如果有一个键在字典中的值为None,那么光凭get的返回值无法准确判断这个键是否存在——除非你给它一个非None的默认值

1
2
3
4
5
6
7
8
9
10
11
# -*- coding: utf8 -*-
def test(d, key):
print("d.get('{0}') is {1}\t'{0}' in d is {2}".format(key, d.get(key), key in d))

if __name__ == '__main__':
d = {
'foo': 'bar',
'baz': None,
}
test(d, 'foo')
test(d, 'baz')

发展了这么多年的编程语言,又怎么会连一次调用、多值返回这么简单的事情都做不到呢。事实上,有各种各样、各显神通的返回多个值的方法,我给其中的一些做了个分类

Lisp的multiple-value-bind

Common Lisp(简称为CL)的多重返回值当之无愧是其中最正统、最好用的实现方式。以它的内置函数truncate为例,它的第一个返回值为第一个参数除以第二个参数的商,第二个返回值为对应的余数

1
2
3
CL-USER> (truncate 10 3)
3
1

如果不加修饰地调用truncate,就像其它只返回一个值的函数一样,也只会拿到一个返回值

1
2
3
CL-USER> (let ((q (truncate 10 3)))
(format t "q = ~D~%" q))
q = 3

除非用multiple-value-bind来捕获一个函数产生的所有返回值

1
2
3
4
CL-USER> (multiple-value-bind (q r)
(truncate 10 3)
(format t "q = ~D~8Tr = ~D~%" q r))
q = 3 r = 1

CL的方案的优点在于它十分灵活。即使将一个函数从返回单个值改为返回多个值,也不会导致原本调用该函数的位置要全部修改一遍——对修改封闭,对扩展开放(误)。

Go的多重返回值

踩在C语言肩膀上的Go也能够从函数中返回多个值。在io/ioutil包的官方文档中有大量的例子,比如用ReadAll方法从字符串衍生的流中读取全部内容,就会返回两个值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package main

import (
"fmt"
"io/ioutil"
"log"
"strings"
)

func main() {
s := "Hello, world!"
reader := strings.NewReader(s)
bytes, err := ioutil.ReadAll(reader)
if err != nil {
log.Fatal(err)
}
fmt.Printf("bytes is %s", bytes)
}

Go以这种方式取代了C语言中用返回值表达成功与否、再通过指针传出读到的数据的风格。由于这个模式在有用的Go程序中到处出现,因此Gopher们用的都是定制的键盘(误)

不同于前文的multiple-value-bind,如果一个函数或方法返回多个值,那么调用者必须捕获每一个值,否则编译无法通过

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
➜  try cat try_read_all_ignore_err.go
package main

import (
"fmt"
"io/ioutil"
"strings"
)

func main() {
s := "Hello, world!"
reader := strings.NewReader(s)
bytes := ioutil.ReadAll(reader)
fmt.Printf("bytes is %s", bytes)
}
➜ try go build try_read_all_ignore_err.go
# command-line-arguments
./try_read_all_ignore_err.go:12:8: assignment mismatch: 1 variable but ioutil.ReadAll returns 2 values

这一要求也是合理的,毕竟多重返回值机制主要用于向调用者传递出错原因——既然可能出错,那么就必须要检查一番。

Python和Rust的解构

就像CL的truncate函数一样,Python中的函数divmod也可以同时返回两个数相除的商和余数,并且咋看之下也是返回多个值的形式

1
2
3
4
# -*- coding: utf8 -*-
if __name__ == '__main__':
q, r = divmod(10, 3)
print('q = {}\tr = {}'.format(q, r))

但本质上,这是因为Python支持解构,同时divmod返回的是一个由商和余数组成的元组。这样的做法与CL的真·奥义·多重返回值的差异在于,如果只想要divmod的第一个值,那么等号左侧也要写成对应的结构

1
2
3
4
# -*- coding: utf8 -*-
if __name__ == '__main__':
q, _ = divmod(10, 3)
print('q = {}'.format(q))

在支持解构的语言中都可以模仿出多重返回值,例如Rust

1
2
3
4
5
6
7
8
fn divmod(a: u32, b: u32) -> (u32, u32) {
(a / b, a % b)
}

fn main() {
let (q, r) = divmod(10, 3);
println!("q = {}\tr = {}", q, r);
}

Prolog的归一

到了Prolog这里,画风就有点不一样了。首先Prolog既没有函数,也没有方法,更没有宏。在Prolog中,像length/2member/2这样的东西叫做functor,它们之于Prolog中的列表,就犹如CL的lengthmember之于列表、Python的len函数和in操作符之于列表,JavaScript的length属性和indexOf方法之于数组……

其次,Prolog并不“返回”一个functor的“调用结果”,它只是判断输入的查询是否成立,以及给出使查询成立的变量值。在第一个查询中,length/2的第二个参数为变量L,因此Prolog给出了使这个查询成立的L的值4;第二个查询中没有变量,Prolog只是简单地给出查询是否成立;第三个查询中,Prolog给出了四个能够使查询成立的变量X的值。

由于Prolog会给出查询中每一个变量的值,可以用这个特性来模拟多重返回值。例如,可以让Prolog一次性给出两个数字的和、差、积,和商

麻烦之处在于就算只想要得到两数之和,也必须用占位符填在后三个参数上:jjcc(10, 3, S, _, _, _)

作弊的指针与全局变量

尽管在开篇的时候提到了C语言中的函数无法返回多个值,但如果像上文的Prolog那般允许修改参数的话,C语言也是可以做到的,谁让它有指针这个强力特性呢。例如,stat(2)函数就会将关于一个文件的信息填充到参数中所指向的结构体的内存中

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include <sys/stat.h>

int
main(int argc, char *argv[])
{
char *path = "./try_stat.c";
struct stat buf;
stat(path, &buf);
printf("inode's number of %s is %llu\n", path, buf.st_ino);
return 0;
}

查看man 2 stat可以知道struct stat类型中有非常多的内容,这显然也是一种多重返回值。同样的手法,在Go中也可以运用,例如用于把从数据库中读取出来的行的数据写入目标数据结构的Scan方法

最后,如果只要能让调用者感知就行,那么全局变量未尝不是一种通用的多重返回值机制。例如在C语言中的strtol函数,就会在无法转换出任何数字的时候返回0并设置errno,因此检查errno是必须的步骤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <stdio.h>
#include <stdlib.h>
#include <sys/errno.h>

void try_conversion(const char *str)
{
long num = strtol(str, NULL, 10);
if (errno == EINVAL || errno == ERANGE)
{
char message[64];
snprintf(message, sizeof(message), "strtol(\"%s\")", str);
perror(message);
return;
}
printf("strtol(\"%s\") is %ld\n", str, num);
}

int
main(int argc, char *argv[])
{
try_conversion("233");
try_conversion("0");
try_conversion("lisp");
return 0;
}

鉴于errno是一个全局变量,strtol的使用者完全有可能忘记要检查。相比之下,Go的strconv包的函数都将转换过程中的错误以第二个参数的形式返回给调用者,用起来更安全。

后记

按照《代码写得不好,不要总觉得是自己抽象得不好》这篇文章的说法,代码写成什么样子完全是由产品经理决定的。但产品经理又怎么会在意你用的技术是怎么实现多重返回值的呢。综上所述,这个特性没用(误)。

全文完。

Liutos wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
你的一点心意,我的十分动力。