张汉东老师: 值语义、引用语义、栈拷贝、按位复制等概念
原文地址¶
https://github.com/ZhangHanDong/tao-of-rust-codes/issues/104
经过连续多天的讨论,今天整理出结果来一致澄清一下这些概。¶
编译器默认自动调用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以「内存安全」为设计原则对这门语言的精巧设计。
以上。