V2EX = way to explore
V2EX 是一个关于分享和探索的地方
现在注册
已注册用户请  登录
caobug
V2EX  ›  Rust

RUST 所有权移动问题

  •  
  •   caobug · 209 天前 · 1888 次点击
    这是一个创建于 209 天前的主题,其中的信息可能已经有所发展或是发生改变。

    刚随手写了一个示例,尝试探测编译器检测机制。以下代码在 JAVA/C/C++等众多语言中没有任何问题,即使按照 RUST 的所有权原则,也应该正常。但实际上编译失败了。

    struct Dog {
        name: String,
    }
    
    impl Dog {
        fn release(self) {
            println!("{}", self.name)
        }
    }
    
    struct Person {
        dog: Dog,
        name: String,
    }
    
    fn main() {
        let mut person = Person {
            dog: Dog {
                name: String::from("Hamel"),
            },
            name: String::from("Zoe"),
        };
    
        person.dog.release();
    
        for _ in 0..1 {
            person.dog = Dog {
                name: String::from("Hogge"),
            };
        }
    
        println!("{} got a new dog: {}", person.name, person.dog.name);
    }
    

    以上代码我先通过 Person 关联了 Dog ,随后释放 Person 中的 Dog ,到此 Dog 不再有效,若后续直接调用会编译失败。

    我跟着创建一个百分比会执行的 for 循环重新赋值,按理说指针已发生了变更,后续在循环外使用的 Dog 是新建的对象不会有问题。

    但事上上编译失败。Rust 的检测器似乎没有认真评估 for 中的条件,认为 loop 至少执行一次,而 for 却不确定。当我把 for 改成 loop ,它确实正常了。

    loop {
        person.dog = Dog {
            name: String::from("Hogge"),
        };
        break
    }
    

    我接着在 loop 中加一个百分比会执行的条件判断,最终仍然失败了。

    loop {
        if true {
            person.dog = Dog {
                name: String::from("Hogge"),
            };
        }
        break
    }
    

    如果说写 C/C++满脑袋都是寄存器、堆、栈、指针...,那么 RUST 必然是所有权检查器。

    24 条回复    2023-10-11 15:37:51 +08:00
    DianQK
        1
    DianQK  
       209 天前 via Android
    (似乎是 bug ,感觉可以提个 issue
    binhb
        2
    binhb  
       209 天前
    因为是静态所有权检查,条件不确定,可能不被执行
    qdwang
        3
    qdwang  
       209 天前 via iPad
    rust 编译过程中,除非是特殊指定在编译器运算的代码(比如 const fn ),其他代码都是不会运算的,只能根据一些特殊情况做一些处理。比如 loop 内非条件语句下认定为会执行到。for 里不一定必然执行。还有例如有些循环情况算必然执行多次,这样就不可以用 FnOnce 这样。
    Leviathann
        4
    Leviathann  
       209 天前
    所有权检查实际上是一种证明
    你要做的是向编译器提供证据
    rrfeng
        5
    rrfeng  
       209 天前 via Android
    这跟不定长数组越界访问一样啊,编译器不会检查条件的。
    lance6716
        6
    lance6716  
       209 天前 via Android
    只是有个度不想继续细化了,毕竟停机问题
    Kaiv2
        7
    Kaiv2  
       208 天前
    loop {
    if true {
    person.dog = Dog {
    name: String::from("Hogge"),
    };
    break;
    }
    }
    编译成功
    caobug
        8
    caobug  
    OP
       208 天前
    @Kaiv2 RUST 编译检查默认会检查完整的分支,即 if 和 else
    swordcoming9527
        9
    swordcoming9527  
       207 天前
    硬要解决这个问题只能等待以后编译器在各种 edge case 优化的更好,但实践中可能没啥意义。
    这个问题,是 People 里面的 dog 可能被 Move ,dog 被 Move 时,People 其实是处于一种“不可用”的状态,在 dog 被填充回来之前,对 People 的其他操作都是不允许的。如果在真实的实践中,只需要提前设计好 dog 被 Move 的状态,典型的就是 dog: Option<Dog>。
    caobug
        10
    caobug  
    OP
       207 天前
    @swordcoming9527 确实如此。不过编译器应该要检查 in 0..1 才对,甚至编译后应该直接展开这种硬编码。
    buxiuxi
        11
    buxiuxi  
       206 天前
    为啥这里 release 函数会释放 dog?
    owtotwo
        12
    owtotwo  
       202 天前
    直接简化问题,此问题本质上,与下面代码无法编译通过的原因是一致的:


    **即在编译期 Borrow Checker 是不进行具体值计算的。**

    如上面的 if 分支,在生命周期检查期间,并不知道 1 == 0 总为 false 且永不执行此代码块。故而在使用 name 时,无法得知所有权是否已被转移。
    甚至将 1 == 0 直接换成 false ,此期间 Borrow Checker 依旧不知道 false 总不执行代码块,即认为是**有可能**调用 drop(name)触发所有权转移的。
    owtotwo
        13
    owtotwo  
       202 天前
    回到原问题,person.dog 的各种赋值写法:


    0. 对于 Code 0 ,即上一条回复提到的本质问题,此处尽管写着 if true ,然而 Borrow Checker 并不默认此代码总是执行,而是认为可能执行也可能不执行。(当然,当 build release 编译优化阶段时,if true 就会被优化掉了,而所有权检查阶段并不会,或许是太耗时了)

    1. 对于 Code 1 ,与 Code 0 本质一样,Borrow Checker 会认为此 for 语句可能不会执行,所以“Rust 的检测器似乎没有认真评估 for 中的条件”确实是对的,并不会在此阶段评估。

    2. 对于 Code 2 ,因为有 else 分支且调用 unreachable!(),所以理论上后续使用 person 时必然已经经过 if 部分的赋值语句(因为 else 部分会 panic ,即不会执行后面代码)
    owtotwo
        14
    owtotwo  
       202 天前
    续上

    3. 对于 Code 3 ,即楼主的第一次修改尝试,实际上等价于将 loop 和 break 去掉(因为此控制流总是执行),所以必然会执行赋值语句,故而编译通过(Ok)。

    4. 对于 Code 4 ,即楼主的第二次修改尝试,依然可以将 loop 和 break 去掉,此时情况等价于 Code 0 ,所以一样是编译不通过(Error)。

    5. 对于 Code 5 ,即 @Kaiv2 提到的编译成功的写法,因为 break 进去 if 里了,情况就不一样了。此时控制流的逻辑只有两种情况:情况一,若不进入 if 语句里,则无限循环,那么就不会执行后面的使用 person 及 person.dog 的代码了,就没问题;情况二,进入 if 语句,则成功执行赋值,且最后必定 break 出去,执行后面的代码,依然没问题。所以这种不涉及对具体求值有依赖的控制流是能编译通过的。
    owtotwo
        15
    owtotwo  
       202 天前   ❤️ 2
    综上

    6. 楼主的代码符合 Rust 的所有权规则吗?符合的,因为问题并不在所有权转移上,而是在编译期所有权检查时是否会进行具体求值的判断上。

    7. @DianQK 所以**目前**而言不是 bug ,或许以后编译器更聪明效率更高了就支持此优化了。

    8. @binhb @qdwang @rrfeng 的说法是对的。

    9. 为啥这里 release 函数会释放 dog ?因为 fn release(self)的参数是 self ,跟 std::mem::drop()一样,调用时会获取其所有权,并在此函数结束后 drop 掉。
    owtotwo
        16
    owtotwo  
       202 天前
    FIX: @buxiuxi 第 9 项忘 at 了
    kerwincsc
        18
    kerwincsc  
       201 天前
    @buxiuxi 因为 release 方法签名是 self
    PTLin
        19
    PTLin  
       201 天前
    ```
    loop {
    if true {
    person.dog = Dog {
    name: String::from("Hogge"),
    };
    break;
    }
    }
    ```
    你这把 break 写在 if 里面不就可以了吗,这种 edge case 也算是满脑子都是的东西?
    owtotwo
        20
    owtotwo  
       200 天前
    @DianQK #17
    或许有些区别
    简单点概况,NLL(Non-Lexical Lifetimes)的迭代进化(即下一代的 Polonius)应该依然并不能解决此问题。

    因为楼主这问题本质上是 rustc 编译流程的限制。如图: https://blog.rust-lang.org/images/2016-04-MIR/flow.svg
    根据**目前**的编译流程,Borrow Checking 发生在 MIR 阶段,此刻的 CFG(Control-Flow Graph)仅将`if true {}`识别为`if some_cond {}`。
    故而`if true { <code> }`无法等价于`loop { <code>; break }`或`{ <code> }`,因为它们的 CFG 是不一致的。(参考引用 数据流分析中的 CFG )
    最后将类似`if false {}`这样的死代码消除的优化行为,是在 LLVM 的 codegen 优化阶段进行的,所以此前 borrowck 并不认识"true"或"false"。

    在 Rust 2018(Rust 1.31)引入 NLL 后,生命周期的推断精度更高了,而下一代的 Polonius 会支持更复杂的控制流(Control Flow)。但是如上所述的原由,依然不能在此阶段进行条件求值,所以问题依在。

    * NLL: https://blog.rust-lang.org/2018/12/06/Rust-1.31-and-rust-2018.html#non-lexical-lifetimes
    * 编译流程中的 MIR: https://blog.rust-lang.org/2016/04/19/MIR.html
    * 数据流分析中的 CFG: https://github.com/rust-lang/rustc-dev-guide/blob/master/src/appendix/background.md#what-is-a-dataflow-analysis
    * rustc 概览(包含各编译阶段): https://rustc-dev-guide.rust-lang.org/overview.html


    我希望能由浅入深解释问题,但无法太深。前面的回答并无涉及到更多的编译器部分的具体内容,是因太冗长容易导致阅读阻力大,很少人愿意认真看(完)。(但即使现在这长度,似乎大家也习惯 tl;dr 了)

    希望我有解释清楚。 : )
    DianQK
        21
    DianQK  
       200 天前 via Android
    @owtotwo #20
    除了 RFC 和具体代码我还没看到,剩下的我应该基本了解。如果先做一些常量传播,再做借用检查就可以了(但是常量传播可能也需要先有一个合格的借用检查?)
    caobug
        22
    caobug  
    OP
       199 天前
    @owtotwo 非常感谢,你解释的非常清楚远超 RUST 指南,恳请抽空出些 RUST 文章。
    owtotwo
        23
    owtotwo  
       199 天前   ❤️ 1
    @DianQK #21
    嗯呐 这个确实就是关键所在

    若我没记错的话 似乎 rustc 的编译流程会有两次的常量折叠/传播 一次是在前端的 MIR 中 另一次是在后端如 LLVM 中
    (好像是前端优化一次能降低给后端的 IR 代码复杂度)

    MIR 中支持常量传播应该是比较早前的事了(或许有相关公告) 似乎是支持控制流的(代码可能在 mir 部分的 const_prop.rs ?文件名应该长得差不多)
    但并不知道是否支持“消掉 if const_expr”的行为
    (我不知道这种分支优化的术语应该是什么 死代码消除 Dead Code Elim ?或者是叫 Sparse Cond Const Prop ?中文可能是 稀疏条件常量传播 之类的 或许也不准确)

    但比较尴尬的是 常量传播是在 MIR 的优化阶段进行的 而 borrowck 是在 mir-opt 之前进行的(如果我没记错的话)

    所以正如老哥你所说的 常量传播时应该已经有借用检查了
    (以及我感觉理论上应该确实是能在借用检查前算 const 的 就是不知道最终会不会增加 MIR 部分编译的总耗时)

    编译流程层面的改动影响对 rustc 而言还是挺大的(如 Polonius 也只是 borrowck 部分的平替) 所以短期内可能不会有相应优化了(个人感觉 不知道目前有没有人提对应的 RFC )


    以上的话并不严谨 我也暂时没能去进行校验 或许会有些错漏或过时(记忆有点旧了)

    有条件的朋友或许可以补充下相关链接~
    owtotwo
        24
    owtotwo  
       199 天前   ❤️ 1
    @caobug #22
    有讲清楚了就好 能对别人有帮助还是很开心的 : )

    Rust 文章的话 不太好写 写起来时很难兼顾到不同熟练度的 Rust 小伙伴(主要还是我自己能力有限)

    举个例子
    就像一开始其实我想**直接**用 `while true {}`和`loop {}`为什么不一样 来解释这问题的(前者迭代 0+次 后者 1+次)
    但是这样会引入题目中没提到的 while 语句 以及它们另外的差异( loop-break <value>能返回值而 while 恒为`()`之类的) 最后还得再迁移到 for 语句来解释

    这样就有可能将问题复杂化了 而如果跳过中间例子直接说“const_expr 在 borrowck 阶段不求值” 不熟悉的小伙伴有可能一下子转不过来

    且问题涉及 if 语句 所以最后决定用`if false {}`作例子来渐进地解释 比较好理解

    Rust 文章同理 讲一个点 从多浅讲到多深 我就有点犯难了
    有的知识点实在涉及太多 比如 Pin 感觉没十几页纸实在讲不清楚 一想就头都大了 0.o
    关于   ·   帮助文档   ·   博客   ·   API   ·   FAQ   ·   我们的愿景   ·   实用小工具   ·   1285 人在线   最高记录 6543   ·     Select Language
    创意工作者们的社区
    World is powered by solitude
    VERSION: 3.9.8.5 · 33ms · UTC 17:42 · PVG 01:42 · LAX 10:42 · JFK 13:42
    Developed with CodeLauncher
    ♥ Do have faith in what you're doing.