3

(一)在这里,下面是(二)。

引用与生命期

写了一天的 Rust 代码下来,发现根本没写几行,万事开头难啊。仔细想来,多半功夫花在学习最佳实践和调试编译错误上了。说到这编译错误我就气不打一出来,放着好生生的 Python 不用跑这来糟心~~呵呵,开玩笑,Rust 的语法——尤其是对于内存管理——可谓是相当精密,在调好了许多编译错误之后,往往会发出“原来是这样”的感叹。对于初学者的我来说,着实不应该期望着一上来就能顺风顺水地写出一次性编译通过的代码——当然我相信对语言的熟悉程度最终会导致这一结果。

废话不多说了,聊一下碰到的一个问题和学习到的东西。之前大部分编译错误都能磕磕绊绊修过去学明白,直到碰到了这个问题(片段):

pub fn parse_uri(uri: &str) -> Result<(&str, &str), int> {
    match uri.find_str("://") {
        Some(pos) => {
            let protocol = uri.slice_to(pos);
            let address = uri.slice_from(pos + 3);
            if protocol.len() == 0 || address.len() == 0 {
                Err(consts::EINVAL)
            } else {
                Ok((protocol, address))
            }
        },
        None => Err(consts::EINVAL),
    }
}

这一段其实很简单,就是 ZMQ 里面解析一个 endpoint 的代码。比如给一个 endpoint tcp://127.0.0.1:8890 作为参数,正确解析的话就应该返回两个字符串:tcp127.0.0.1:8890。不得不用 Python 写一下示意:

def parse_uri(uri):
    return uri.split('://', 1)

回到 Rust。我期望做到的就是在不拷贝字符串的情况下,通过 borrowed pointer aka reference 来实现字符串的解析切分,即借进去一个字符串的引用,返回来该字符串上不同位置的两个不同的引用。理想和现实的差距见下:

...rs:19:28: 19:31 error: cannot infer an appropriate lifetime for region in type/impl due to conflicting requirements
...rs:19             let protocol = uri.slice_to(pos);
                                             ^~~

貌似意思是说,在这个函数里面,Rust 编译器不能确保返回回去的引用到时候还能用。

这下麻烦了。

怎么搞呢?还是得看文档。书上说,这种情况下应该给生命期起个名字。

原来,Rust 对于每一个引用的生命期都是严格控制的,在编译期就保证了引用的阳寿不会长过它引用的对象,从而确保了“野指针”的不可发生性。在刚才的代码中,对于新创建的两个引用 protocoladdress,编译器没有拿到足够的信息以说明它们俩不会变成“野指针”,故而出于安全报了错误。修好这个错误异常简单:

- pub fn parse_uri(uri: &str) -> Result<(&str, &str), int> {
+ pub fn parse_uri<'r>(uri: &'r str) -> Result<(&'r str, &'r str), int> {

这个长的像泛型的东西就叫做命名生命期,就是一个单引号 ' 加随便一个名字。尖括号里的可以认为是声明,就是说当前这个函数将会涉及到一个叫做 r 的生命期;参数里面出现的算是定义,比如说这个例子里面,uri: &'r str 的意思就是说,uri 是一个字符串引用,生命期 r 就刚好跟 uri 的一样;最后返回值类型里面就是使用这个生命期的地方,意思就是说,这个引用和这个引用的声明期跟 r 一致。这样一来,我们就把编译器缺失的信息补上了。

值得注意的是,并不是所有返回的引用都能像这样套用生命期定义,只有从参数里的引用派生出来的引用才可以——比如说这样是不行的:

fn test_ref<'r>(input: &'r str) -> &'r int {
    let num = ~123;
    &*num
}

编译结果:

t.rs:3:5: 3:10 error: borrowed value does not live long enough
t.rs:3     &*num
           ^~~~~
t.rs:1:44: 4:2 note: reference must be valid for the lifetime &'r  as defined on the block at 1:43...
t.rs:1 fn test_ref<'r>(input: &'r str) -> &'r int {
t.rs:2     let num = ~123;
t.rs:3     &*num
t.rs:4 }
t.rs:1:44: 4:2 note: ...but borrowed value is only valid for the block at 1:43
t.rs:1 fn test_ref<'r>(input: &'r str) -> &'r int {
t.rs:2     let num = ~123;
t.rs:3     &*num
t.rs:4 }

关于参数传递的更多

上次说到不用 box 或是引用的参数传递是内存拷贝,这确实是这样,但如果数据量比较小,官方还是建议直接做内存拷贝,而不是使用引用或 box ——为了细微的性能提升而增加代码的复杂性是不值得的;除非必要,如果怕影响性能,可以先做个性能测试。

相反,对于返回值,类似的“值传递”却能在强大的编译器的帮助下“变成”零拷贝。改自官方例子:

fn foo(x: ~int) -> int {
    return *x;
}

fn main() {
    let x = ~5;
    let y = ~foo(x);
}

main() 先创建一个 owned box,放进去一个 5,然后把这个 box 扔进 foo() 里,foo() 把 box 打开扔掉,把内容 5 返回,最后在 main() 里再给 5 套一个盒子 y

问:这个过程中,5 一共被拷贝了几次?

两次?foo() 里拆包一次,返回之后套盒子又一次?

只有一次!原来,在 foo() 返回之前,y 的盒子空间就已经准备好了,foo() 做的其实只是把 5 从一个盒子拷贝到另一个盒子。据说这都是编译器干的好事。

所以呢,官方文档建议大家,在返回数据结构的时候,大胆返回整个值就好了,不用考虑返回前先放到盒子里(用 ~),让调用的地方灵活地来选择是应该放盒子还是怎么样。

可爱的 result

想必大家都知道,Rust 没有提供空指针,也没有提供异常处理。那那那,这代码还怎么写啊,人家明天还要上班呢!

其实前面已经有一个例子了,就是 parse_uri——它会把一个 ZMQ 的地址拆成两部分,否则报错。这里我们看看怎么调用它:(摘自刚写的 zmq.rs 的 bind

    fn bind(&self, addr: &str) -> Result<(), int> {
        parse_uri(addr).and_then(|(protocol, address)| {
            match protocol {
                "tcp" => Ok(()),
                _ => Err(consts::EINVAL),
            }
        })
    }

哈哈,其实很简单明了嘛。and_then 这里起到了承上启下的作用,意思是“先解析 URI,然后返回(执行)下面的;除非解析 URI 失败了,跳过下面直接返回错误”,大大地体现了函数式编程的特点有没有。这个 and_then 其实就是平常有异常的语言中的顺序执行嘛,不去抓异常,依次执行,只要出错就跳出,没错就执行到底。

简单总结一下哈。

  • Result 枚举有两种值,OkErr,适合于可能出错的函数来返回。

  • Option 枚举也有两种值,SomeNone,适合于意义上需要返回“虚无”的函数。

  • Condition 好像给删掉了?

它们都有一堆很方便的小函数可以用,请参考上述文档链接。


fantix
1.7k 声望174 粉丝

Linux、Python 与开源爱好者一枚,GINO 项目作者,EdgeDB 团队成员。