weixin_39835178
weixin_39835178
2020-11-22 09:11

对书中值语义、引用语义、栈拷贝、按位复制等概念的澄清

最近,由读者朋友 对本书第五章所有权系统中出现的「按位复制」、「栈复制」、「值类型」、「值语义」和「引用语义」等概念提出了质疑,并且指出了这些概念混乱使用的问题。

经过连续多天的讨论,今天整理出结果来一致澄清一下这些概念。

编译器默认自动调用x的clone方法

编译器会默认自动调用x的clone方法 对于实现Copy的类型,其clone方法必须是按位复制的

修改为:

代码清单5-3中的变量x为整数类型,当它作为右值赋值给变量y时,编译器会默认自动按位复制。x是值语义类型,被复制以后,x和y就是两个不同的值,互不影响。

这是因为整数类型实现了Copy trait,第4章介绍过,对于实现Copy的类型,其clone方法只需要简单地实现按位复制即可。对于拥有值语义的整数类型,整个数据存储于栈中,按位复制以后,不会对原有数据造成破坏,不存在内存安全的问题。

说明: 其实这里说「自动调用x的clone方法」,是为了方便读者理解这种默认行为。对于Rust中Copy的语义,开发者是无法修改的。也就是说,对于赋值、或者传参等行为发生的时候,实现Copy的类型默认是按位复制。开发者自己实现Copy trait,必须也实现clone方法。至于clone方法是如何实现的不重要,重要的是,它们必须有按位复制的能力。但是标准库文档里建议你只需要实现按位复制即可。注意,这里指的是隐式调用clone的行为,而非显式调用clone方法。

按位复制和栈复制

其实书里问题的根源在于,我当时错误地将「按位复制」理解为「栈复制」。虽然按「栈复制」来理解Rust中的Copy行为,也没有什么影响。但确实不太严谨。

所以,首先需要明确「按位复制」,等同于C语言里的memcpy。 所以,我将书里出现的相关批注做了修改:

C语言中的memcpy会从源所指的内存地址的起始位置开始拷贝n个字节,直到目标所指的内存地址的结束位置。但如果要拷贝的数据中包含指针,该函数并不会连同指针指向的数据一起拷贝。

按位复制,只是复制「值」,而不会复制「值」中包含指针指向的数据。也可以说,它是浅复制的一种特定形式。它不会进行深复制。拿Rust中的String字符串来说,其本质是一个智能指针,在栈上存储着元信息,但是在堆里存储的具体的数据。如果对其进行按位复制,只会复制其栈上的元信息,而不会复制其堆里的数据。如果想深复制,只能显式地调用其clone函数。

所以,这是我书里没有说明清楚的一个地方。 因为Rust默认是在栈上存储的,所以,按位复制通常都是发生在栈上复制。但是按位复制,并不一定只能复制栈上的数据。

对于值类型和引用类型的修改如下:

值类型一般是指可以将数据都保存到同一位置的类型,一些原生类型,比如数值、布尔值、结构体等都是值类型。因此对值类型的操作效率一般比较高,使用完立即会被回收。值类型作为右值(在值表达式中)执行赋值或传入函数等操作时,会自动复制一个新的值副本,并且该副本和原始的值没有直接关系,互不影响。

引用类型则会存在一个指向实际存储区域的指针。比如通常一些引用类型会将数据存储在堆中,而栈中只存放指向堆中数据的地址(指针)。因此对引用类型的操作效率一般比较低,使用完交给GC回收,这样更安全一些。但是没有GC的语言则需要靠手工来回收,就多了很多风险。

对于值语义和引用语义的修改如下:

为了更加精准地对这种复合类型或对象进行描述,值语义(Value Semantic)和引用语义(Reference Semantic)被引入,定义如下。

  • 值语义:复制(赋值操作)以后,两个数据对象拥有的存储空间是独立的,相互之间互不影响。
  • 引用语义:复制(赋值操作)以后,两个数据对象,相互之间互为别名。操作其中任意一个数据对象,则会影响到另一个。

值语义可以保证变量值的独立性(Independence)。独立性的意思是,如果想修改某个变量,只能通过它本身来修改;而如果修改了它本身,并不影响其复制品。也就是说,如果只能通过变量本身来修改值,那么它就是具有值语义的变量。

对于引用语义的数据对象,赋值操作时按位复制,可能存在内存不安全风险。比如只复制了栈上的指针,堆上的数据就多了一个管理者,多了一层内存安全的隐患。

「Copy语义和Move语义」 vs 「值语义、引用语义」

在Rust中,可以通过能否实现Copy trait来区分数据类型的值语义和引用语义。但为了描述的更加精准,Rust也引入了新的语义:复制(Copy)语义和移动(Move)语义。复制语义对应值语义,也就是说,实现了Copy的类型,在进行按位复制的时候,是安全的。移动语义对应引用语义,也就是说,在传统语言(比如C++)中本来是引用语义的类型,在Rust中不允许按位复制,只允许移动所有权,只有这样才能保证安全。这样划分是因为引入了所有权机制,在所有权机制下同时保证内存安全和性能。 Rust的数据默认存储在栈上。

对于默认可以安全地在栈上进行按位复制的类型,就只需要按位复制,也方便管理内存。对于默认只可在堆上存储的数据,因为无法安全地进行按位复制,如果要保证内存安全,就必须进行深度复制。当然,你也可以把实现Copy的类型,通过Rust提供的特定API(比如Box语法)将其放到堆上,但它既然是实现了Copy,就是一个可以安全进行按位复制的类型。深度复制需要在堆内存中重新开辟空间,这会带来更多的性能开销。如果堆上的数据不变,只需要在栈上移动指向堆内存的指针地址,不仅保证了内存安全,还可以拥有与在栈上进行复制的等同性能。

也许有的人会说,即便只移动存储在栈上的指针,那其实在Rust编译器内部也很可能是一个按位复制行为,因为单论指针而言,它也可以看作是一个值。但我们这里说的是上层的语义。对于Move语义而言,代表的是按位复制不安全,所以Rust编译器不允许它实现Copy。

所以,对于Rust而言,可以实现Copy trait的类型,则表示它拥有复制语义,在赋值或传入函数等行为时,默认会进行按位复制。它和传统概念中的值语义类型相对应,因为两个独立不关联的值,操作其中一个,不影响另外一个,是安全的。对于不能实现Copy trait的类型,它实际上和传统的引用语义类型相对应,只不过在Rust中,如果只是简单的按位复制,则会出现图5-1那样的不安全问题。所以,为了安全,它必须是移动语义。移动语义实际上在告诉编译器,该类型不要简单的按位复制,那样不安全。所以,其他语言中的引用语义到了Rust中,就成了移动语义。但是被移动的值,相当于已经废弃了,无法使用。如果从这个角度来看,你如果认为Rust语言中并不存在引用语义类型,只有值语义类型,也是可以的。 另外,需要注意,Rust中默认的引用和指针也都实现了Copy。

说明: 这几段,主要是澄清Rust中的Copy语义。Copy的重点在于,是否可以安全地进行按位复制。实际上,要不要把它看成值语义或引用语义,都是看你自己。书里,只是给你提供一个视角,也方便你把Rust中的新概念「Copy语义」和「Move语义」与旧知识「值语义」和「引用语义」挂上钩。这样,即方便你理解所有权机制,又重点体现了,Rust以「内存安全」为设计原则对这门语言的精巧设计。

以上。

该提问来源于开源项目:ZhangHanDong/tao-of-rust-codes

  • 点赞
  • 写回答
  • 关注问题
  • 收藏
  • 复制链接分享
  • 邀请回答

21条回答

  • weixin_39835178 weixin_39835178 5月前

    感谢建议和示例。有些言辞是可以继续修改的。

    大致上没有太多错误即可,方便读者去理解就好了。万事总能找出特例,但书里侧重点是在于阐述共性。

    点赞 评论 复制链接分享
  • weixin_39924779 weixin_39924779 5月前

    个人反对这样实现 Deref ……

    点赞 评论 复制链接分享
  • weixin_39619481 weixin_39619481 5月前

    个人反对这样实现 Deref ……

    我只是随手写个例子来说明Copy不能对应值语义。好与不好不在考虑范围内。 不过我也没觉得这样的实现有什么不好,你说说你觉得不好的理由呢?

    点赞 评论 复制链接分享
  • weixin_39924779 weixin_39924779 5月前

    不是不好,而是 Deref / DerefMut 已经限定给 ~~智能~~ 指针使用,随意实现的话,语义不匹配,会引发大家的误解 一个比较的常见例子就是用 Deref / DerefMut 实现类似 C艹 中的继承的效果,是典型的 rust 反模式 修正一下,Deref / DerefMut 是指针语义。

    点赞 评论 复制链接分享
  • weixin_39924779 weixin_39924779 5月前

    Rust 社区很强调语义行为,不遵循语义搞东西容易被“开除”出社区,超麻烦的

    点赞 评论 复制链接分享
  • weixin_39619481 weixin_39619481 5月前

    而是 Deref / DerefMut 已经限定给智能指针使用,随意实现的话

    • should只是一种推荐做法,不能说就限定了。(我们第三次争辩这个了)
    • 并不是说要随意实现,而是要看业务场景,有需要就可以这么实现。

    Rust 社区很强调语义行为,不遵循语义搞东西容易被“开除”出社区,超麻烦的

    • 那Rust首先要把自己开除了,最常用的&T&mut T首先就不满足该”规定“。
    • Servo作为Rust的头牌项目,里面大量地”违规“实现Deref,也得开除了。
    点赞 评论 复制链接分享
  • weixin_39619481 weixin_39619481 5月前

    一个比较的常见例子就是用 Deref / DerefMut 实现类似 C艹 中的继承的效果,是典型的 rust 反模式

    不知道你是不是误解了我上面的例子是为了实现继承?并不是的,我根本没考虑继承,DancingCat没有包含Cat。

    点赞 评论 复制链接分享
  • weixin_39924779 weixin_39924779 5月前

    而是 Deref / DerefMut 已经限定给智能指针使用,随意实现的话

    * should只是一种推荐做法,不能说就限定了。(我们第三次争辩这个了)
    
    * 并不是说要随意实现,而是要看业务场景,有需要就可以这么实现。
    

    可以说这种需要出现概率颇低,而且一旦这么使用就得在文档里注明。个人接触过的 crate 里,不按标准库语义实现 Deref / DerefMut 的例子不多。(与其说“开除”,不如说“自绝于”?)

    Rust 社区很强调语义行为,不遵循语义搞东西容易被“开除”出社区,超麻烦的

    * 那Rust首先要把自己开除了,最常用的`&T`和`&mut T`首先就不满足该”规定“。
    

    &T 和 &mut T 是指针语义,这里没有问题

    * Servo作为Rust的头牌项目,里面大量地”违规“实现Deref,也得开除了。
    

    大略看了一下 servo 里面对于 Deref / DerefMut 的实现,除了个别用途不明(因为包裹的类型看不懂)的情况以外,其余 1) 要么是真·智能指针 2) 要么是纯 type wrapper ,这两个情况都算符合 Deref 用途

    点赞 评论 复制链接分享
  • weixin_39924779 weixin_39924779 5月前

    一个比较的常见例子就是用 Deref / DerefMut 实现类似 C艹 中的继承的效果,是典型的 rust 反模式

    不知道你是不是误解了我上面的例子是为了实现继承?并不是的,我根本没考虑继承,DancingCat没有包含Cat。

    只是单纯举例( Deref 语义实现不匹配的例子),不是说你这段代码是这样的。

    点赞 评论 复制链接分享
  • weixin_39924779 weixin_39924779 5月前

    说起来 servo 里确实有一处 deref 的用法值得商榷:

    impl Deref for Finite

    https://github.com/servo/servo/blob/master/components/script/dom/bindings/num.rs#L37

    std 里的 NonZero* 等类型都没有实现 deref ,这里却实现了……感觉是把编译时进行的行为检测移到了运行时 应该按照标准库的做法,给出一个 get 函数,来获取下面的东西

    点赞 评论 复制链接分享
  • weixin_39619481 weixin_39619481 5月前

    而是 Deref / DerefMut 已经限定给 ~~智能~~ 指针使用

    不用删掉”智能“两个字,你之前也引用过,官方reference里原话就是智能指针。

    好吧,既然你自行给Deref扩充了should应用范围,从智能指针到普通指针,再增加了wrapper的情况。

    那么,我上面的例子就是wrapper + 指针的合体,是否可以认为我们观点已经达成了一致:我上面的示例的Deref方式并没有问题?

    可以说这种需要出现概率颇低

    且不论你这个概率颇低说法是否能站得住脚。注意,我这里不是在推荐一种写代码的pattern,而是举例说明有那么一些情况他是不符合Copy与值语义对应的,这自然要举反例,与现实需要这种场景出现频率的高低、是否是常规用法并无太大关系,你反对的方向搞错了。

    与其说“开除”,不如说“自绝于”?

    没那么严重,这些都是你自己想象的。不过,这与主题无关,不希望从这方面展开讨论。

    点赞 评论 复制链接分享
  • weixin_39924779 weixin_39924779 5月前

    而是 Deref / DerefMut 已经限定给 智能 指针使用

    不用删掉”智能“两个字,你之前也引用过,官方reference里原话就是智能指针。

    好吧,既然你自行给Deref扩充了should应用范围,从智能指针到普通指针,再增加了wrapper的情况。

    那么,我上面的例子就是wrapper + 指针的合体,是否可以认为我们观点已经达成了一致:我上面的示例的Deref方式并没有问题?

    很多 Deref / DerefMut 的实现都打了智能指针的嘴巴子,特别是 &T / &mut T ,鉴于“随便加特例以满足限定要求”这种行为本身就是乱搞,我觉得把智能指针语义降低到指针语义是合理的。以后我再写 Deref / DerefMut 的实现,也会限定到指针语义(话又说回来,智能指针本身也是指针语义的一种附加限定/扩充)。 你给出的这个例子实际上是用 Deref / DerefMut 做附加语义,我个人持保留意见(相比纯 type wrapper 语义稍扩展了一些。这样实现或有合理之处,但也可以变化一下采取其他思路),参见我对 Finite 的批判(上面)

    可以说这种需要出现概率颇低

    且不论你这个概率颇低说法是否能站得住脚。注意,我这里不是在推荐一种写代码的pattern,而是举例说明有那么一些情况他是不符合Copy与值语义对应的,这自然要举反例,与现实需要这种场景出现频率的高低、是否是常规用法并无太大关系,你反对的方向搞错了。

    对于你所说的“这里并不是严格对应关系”我十分赞同。更进一步说,我反对在讲解 Rust 的(初级)材料中引入值语义/引用语义的说明,因为 1) Rust 本身没有提到这两个概念,也就是说要理解 Rust 并不需要引入这两个概念; 2) 这两个概念在其他语言中具有比在 Rust 中更强的作用。我只是反对用 Deref / DerefMut 举例而已。(总觉得我给自己挖了个坑)我其实想说的就是“虽然可以这么搞,但是这么搞的人 1) 要么清楚的知道TA在干什么(并愿意为此负责) 2) 要么已经完了”大概这样。

    与其说“开除”,不如说“自绝于”?

    没那么严重,这些都是你自己想象的。不过,这与主题无关,不希望从这方面展开讨论。

    我个人一直认为不按语义实现 trait 的性质并不比滥用 unsafe 的程度来的更低……开除出社区都是轻的,应该挂城墙以儆效尤。当然,这是个人意见。以下不再进行这方面的讨论。

    点赞 评论 复制链接分享
  • weixin_39619481 weixin_39619481 5月前

    很多 Deref / DerefMut 的实现都打了智能指针的嘴巴子,特别是 &T / &mut T ,鉴于“随便加特例以满足限定要求”这种行为本身就是乱搞,我觉得把智能指针语义降低到指针语义是合理的。以后我再写 Deref / DerefMut 的实现,也会限定到指针语义(话又说回来,智能指针本身也是指针语义的一种附加限定/扩充)。 你给出的这个例子实际上是用 Deref / DerefMut 做附加语义,我个人持保留意见(相比纯 type wrapper 语义稍扩展了一些。这样实现或有合理之处,但也可以变化一下采取其他思路),参见我对 Finite 的批判(上面)

    好吧,既然你觉得Rust在Deref、智能指针这方面规定得比较混乱,那你应该去给官方反应一下,我也赞同Rust需要把这些东西屡清楚一点。

    鉴于Servo里面有大量的我示例中类似的Deref用法,我觉得我们在原主题的观点上是一致的。

    至于社区对”未遵循Deref文档所描述的should的行为“的态度,我和你的看法不一样,我们可以另外找个地方讨论。

    点赞 评论 复制链接分享
  • weixin_39924779 weixin_39924779 5月前

    很多 Deref / DerefMut 的实现都打了智能指针的嘴巴子,特别是 &T / &mut T ,鉴于“随便加特例以满足限定要求”这种行为本身就是乱搞,我觉得把智能指针语义降低到指针语义是合理的。以后我再写 Deref / DerefMut 的实现,也会限定到指针语义(话又说回来,智能指针本身也是指针语义的一种附加限定/扩充)。 你给出的这个例子实际上是用 Deref / DerefMut 做附加语义,我个人持保留意见(相比纯 type wrapper 语义稍扩展了一些。这样实现或有合理之处,但也可以变化一下采取其他思路),参见我对 Finite 的批判(上面)

    好吧,既然你觉得Rust在Deref、智能指针这方面规定得比较混乱,那你应该去给官方反应一下,我也赞同Rust需要把这些东西屡清楚一点。

    鉴于Servo里面有大量的我示例中类似的Deref用法,我觉得我们在原主题的观点上是一致的。

    至于社区对”未遵循Deref文档所描述的should的行为“的态度,我和你的看法不一样,我们可以另外找个地方讨论。

    好的!通过和你的交流我受益良多,非常感谢

    点赞 评论 复制链接分享
  • weixin_39835178 weixin_39835178 5月前

    讨论真激烈

    点赞 评论 复制链接分享
  • weixin_39835178 weixin_39835178 5月前

    最终的说明:

    1. 本书描述的是普遍行为,但万事总能找出特例。不纠结特例,讲清楚普遍行为即可。
    2. 下面这个例子,就算制造出了引用语义,它依旧属于复制语义的一员。 需要理解的重点在于:复制语义是可以安全进行按位复制的类型。并不会影响读者对复制语义的理解。
    rust
    use std::ops::Deref;
    
    #[derive(Copy, Clone)]
    struct Cat {
        age: u8
    }
    
    #[derive(Copy, Clone)]
    struct DancingCat(&'a Cat);
    
    impl Cat {
        fn say(&self) {
            println!("Meow! I am {:p}", &self);
        }
    }
    
    impl Deref for DancingCat {
        type Target = Cat;
    
        fn deref(&self) -> &Cat {
            self.0
        }
    }
    
    fn main() {
        let cat = Cat{ age: 0 };
        let cat1 = DancingCat(&cat);
        let cat2 = cat1; // 实现Copy,但是是引用语义的DancingCat
        cat1.say();
        cat2.say();
    }
    
    1. Deref/DerefMut ,更倾向于 的说法,这里的should不应该等同于must,它并没有限定给智能指针使用。在Rust里,Deref更应该是一种方便处理容器和容器内数据操作的一种行为。只不过是更多地被智能指针使用。
    点赞 评论 复制链接分享
  • weixin_39835178 weixin_39835178 5月前

    如果还要讨论,就继续讨论吧,但这个issues已经关闭。

    点赞 评论 复制链接分享
  • weixin_39599081 weixin_39599081 5月前

    值语义和引用语义按我的理解应该起名为"直接语义"和"间接语义". 直接语义表示变量名直接表示其值, 变量的大小就是值本身所占的空间. 间接语义是让变量名间接表示某值, 本质上直接表示的是一个指针, 访问值是间接的. 而"引用语义"的概念在某些语言中并不简单地等同于指针, 而是严格的"别名"含义, 指针是可以改变其指向的, 而严格的"别名"并不能改变, 对它做赋值实际上是对其指向的值做赋值, 这跟指针的赋值完全不同. 所以我觉得"引用"这个词现在在不同的语言中的含义有些混乱了, 比如Java中习惯用"引用"来表示本质上是指针的东西, 但Java却没有"引用语义", 所以Java开发者在学其它语言时会对"引用"的概念产生歧义, 如遇到C#中的ref和out和C++的&才知道这才是真正的"引用语义", 现在学会Java,C++,C#的开发者再看Rust的"引用"概念,恐怕更混乱了. 另外, "值,引用,指针"这几个概念真的不能跟"栈,堆"混合在一起来讲, 太容易误解了, 实际上真的没什么关联.

    点赞 评论 复制链接分享
  • weixin_39835178 weixin_39835178 5月前

    各人有各人的理解吧,书中使用值语义、引用语义,只是帮助读者从过去的概念中方便迁移到Rust的Copy和Move中,便于理解。

    点赞 评论 复制链接分享
  • weixin_39619481 weixin_39619481 5月前

    一些内容仍然和我的理解有所偏差,我仍然认为无需关联映射起来。 其它就不继续纠结了,就「Copy语义和Move语义」 vs 「值语义、引用语义」我继续说明一下。 Rust的Copy/Move更侧重于规定底层的技术细节,其它语言的值语义/引用语义是稍微往上一点的抽象。

    操作其中一个,不影响另外一个,是安全的

    操作其中一个,要不要影响另一个,是业务策略问题,不存在安全与否。除了Copy外,还有lifetime用来保证内存操作安全。

    既然上文都指出了&T已经是特例了,那我们自然可以在Copy上实现更多引用语义, 比如:

    rust
    use std::ops::Deref;
    
    #[derive(Copy, Clone)]
    struct Cat {
        age: u8
    }
    
    #[derive(Copy, Clone)]
    struct DancingCat(&'a Cat);
    
    impl Cat {
        fn say(&self) {
            println!("Meow! I am {:p}", &self);
        }
    }
    
    impl Deref for DancingCat {
        type Target = Cat;
    
        fn deref(&self) -> &Cat {
            self.0
        }
    }
    
    fn main() {
        let cat = Cat{ age: 0 };
        let cat1 = DancingCat(&cat);
        let cat2 = cat1; // 实现Copy,但是是引用语义的DancingCat
        cat1.say();
        cat2.say();
    }
    

    因此:

    复制语义对应值语义 移动语义对应引用语义

    用”对应“来描述,都太强关联了一点,可以用"类似"这样的词语来描述就没有那么绝对。

    点赞 评论 复制链接分享
  • weixin_39619481 weixin_39619481 5月前

    另外,这句话仍然读不通顺:

    C语言中的memcpy会从源所指的内存地址的起始位置开始拷贝n个字节,直到目标所指的内存地址的结束位置

    直译其文档就好:

     void *
    memcpy(void *restrict dst, const void *restrict src, size_t n);
    

    DESCRIPTION The memcpy() function copies n bytes from memory area src to memory area dst.

    点赞 评论 复制链接分享