Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
ZhangHanDong opened this issue Jan 23, 2019 · 21 comments
Labels
已回答 已回答请教的问题 第五章 第五章 精选

Comments

@ZhangHanDong
Copy link
Owner

ZhangHanDong commented Jan 23, 2019

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

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


编译器默认自动调用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以「内存安全」为设计原则对这门语言的精巧设计。

以上。

@kvinwang
Copy link

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


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

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

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

use std::ops::Deref;

#[derive(Copy, Clone)]
struct Cat {
    age: u8
}

#[derive(Copy, Clone)]
struct DancingCat<'a>(&'a Cat);

impl Cat {
    fn say(&self) {
        println!("Meow! I am {:p}", &self);
    }
}

impl<'a> Deref for DancingCat<'a> {
    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();
}

因此:

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

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

@kvinwang
Copy link

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

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.

@ZhangHanDong
Copy link
Owner Author

ZhangHanDong commented Jan 24, 2019

@kvingwang

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

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

@ZhangHanDong ZhangHanDong added this to the 第三次印刷 milestone Jan 24, 2019
@mzji
Copy link

mzji commented Jan 27, 2019

个人反对这样实现 Deref ……

@kvinwang
Copy link

个人反对这样实现 Deref ……

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

@mzji
Copy link

mzji commented Jan 27, 2019

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

@mzji
Copy link

mzji commented Jan 27, 2019

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

@kvinwang
Copy link

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

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

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

  • 那Rust首先要把自己开除了,最常用的&T&mut T首先就不满足该”规定“。
  • Servo作为Rust的头牌项目,里面大量地”违规“实现Deref,也得开除了。

@kvinwang
Copy link

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

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

@mzji
Copy link

mzji commented Jan 28, 2019

而是 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 用途

@mzji
Copy link

mzji commented Jan 28, 2019

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

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

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

@mzji
Copy link

mzji commented Jan 28, 2019

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

impl<T: Float> Deref for Finite

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

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

@kvinwang
Copy link

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

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

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

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


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

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


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

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

@mzji
Copy link

mzji commented Jan 28, 2019

而是 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 的程度来的更低……开除出社区都是轻的,应该挂城墙以儆效尤。当然,这是个人意见。以下不再进行这方面的讨论。

@kvinwang
Copy link

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

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

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

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

@mzji
Copy link

mzji commented Jan 28, 2019

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

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

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

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

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

@ZhangHanDong
Copy link
Owner Author

讨论真激烈

@ZhangHanDong
Copy link
Owner Author

最终的说明:

  1. 本书描述的是普遍行为,但万事总能找出特例。不纠结特例,讲清楚普遍行为即可。
  2. 下面这个例子,就算制造出了引用语义,它依旧属于复制语义的一员。 需要理解的重点在于:复制语义是可以安全进行按位复制的类型。并不会影响读者对复制语义的理解。
use std::ops::Deref;

#[derive(Copy, Clone)]
struct Cat {
    age: u8
}

#[derive(Copy, Clone)]
struct DancingCat<'a>(&'a Cat);

impl Cat {
    fn say(&self) {
        println!("Meow! I am {:p}", &self);
    }
}

impl<'a> Deref for DancingCat<'a> {
    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 ,更倾向于@kvingwang 的说法,这里的should不应该等同于must,它并没有限定给智能指针使用。在Rust里,Deref更应该是一种方便处理容器和容器内数据操作的一种行为。只不过是更多地被智能指针使用。

@ZhangHanDong
Copy link
Owner Author

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

@dwing4g
Copy link

dwing4g commented Aug 21, 2019

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

@ZhangHanDong
Copy link
Owner Author

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
已回答 已回答请教的问题 第五章 第五章 精选
Projects
None yet
Development

No branches or pull requests

4 participants