Rust惑点启示系列(一):避免随意使用Clone

发布时间:2024-10-12 21:43
最后更新:2024-10-13 22:48
所属分类:
Rust

在编写Rust程序的时候,发生未被注意的所有权转移是一件非常常见的事情,而能做出的修改也往往是Clone一下。我们似乎从来没有想过这样解决所有权转移问题带来的后果。那么在这里就对如何选择使用Clone的这个问题简单的讨论一下。

本文是《Rust惑点启示笔记》系列文章中的第一篇。这个系列的文章主要计划对Rust语言使用过程中经常会出现的一些容易迷惑的惑点进行一个启发式的讨论和记录。

本系列专题还有以下文章:

  1. Rust惑点启示系列(一):避免随意使用Clone
  2. Rust惑点启示系列(二):从函数中返回一些东西
  3. Rust惑点启示系列(三):引用的生命期从来就不够长
  4. Rust惑点启示系列(四):到处都是的大括号
  5. Rust惑点启示系列(五):工具类型太多了
  6. Rust惑点启示系列(六):如何下手编写一个函数
  7. Rust惑点启示系列(七):使用全局变量和单例
  8. Rust惑点启示系列(八):奇形怪状的Rust闭包

Clone会带来什么

既然说到了Clone,那就必须要从基本数据类型和自定义数据类型说起了。

在Rust里,基本数据类型中的标量数据类型都实现了Copy,这就代表这些标量数据在从一个变量传递给另一个变量的时候,是通过按位复制而不是所有权转移的。换句话说,如果用一个指向了标量数据类型的变量给另一个变量赋值,那么系统会再开辟出一块内存区域,并将原来变量指向的标量逐位复制到新开辟的内存里,然后再把这块新内存区域的地址交给被赋值的变量。这样一套操作下来,内存里就有两个保存在不同内存地址里的内容一模一样的标量了。

基本数据类型里的复合数据类型就稍微复杂了一点儿,但是Rust也给了一个规律:如果组成复合数据类型的元素类型都实现了Copy,那么复合数据类型也是会自动实现Copy的。

这样说起来基本数据类型基本上还是都有可能实现Copy的。所以操作基本数据类型的时候,大部分情况下我们不会遇到需要手动使用Clone的问题。

但是程序不会总是那么简单的,我们更加常用的是structenum这样的自定义数据类型,相比基本数据类型,这些自定义数据类型就要复杂的多了,所以它们也不可能自动实现Copy。这也就是我们在编码的时候经常会遇到Rust编译器提示我们所使用的类型没有实现Copy特征的原因。

如果Rust编译器提示你一个类型需要实现Copy,那多半问题不出在这个类型没有实现Copy特征上,而是这个类型基本上不可能实现Copy。千万不要想着如何去手动给这个类型实现一个Copy

对于无法被编译器自动实现Copy特征的复合数据类型和自定义数据类型来说,我们常常会选择使用#[derive(Clone)]过程宏来给这个数据类型自动实现一个Clone特征,这样一来就可以在提示需要Copy的地方直接使用.clone()或者Clone::clone()来对整个数据类型实例进行复制了。至此,从Rust编译器看来,数据类型无法被Copy的问题完美解决。

回到这一节的标题,那实现的Clone特征到底带来了什么?

答案跟你想的一样,它的效果跟Copy特征一样,把我们的数据类型实例在内存里复制了一份。不过至于这个数据类型执行的深复制还是浅复制,还是由这个数据类型的组成元素的数据类型来决定。如果元素不是引用计数等浅复制类型的话,一般Rust会采用深复制的方法来完成Clone操作。

Clone会带来什么就不言而喻了,首当其冲的就是内存使用量的增长。

可能你会觉得我Clone的数据类型都是小型的数据类型,那么点儿内存无所谓的。但是试想一下,如果现在操作的是一个甚至数个大型结构体呢?这种时候付出的就不只是占用更多内存的问代价了,而更多的是大型数据结构在内存中复制带来的性能降低的问题。

什么情况下才需要Clone

Clone的使用,归根结底是为了解决所有权转移带来的问题。一个变量在将其所有内存的所有权移交出去以后,它就变成未初始化状态了,也就是说它变得不再可用。在其他的语言中这可能对程序的运行没有什么影响,但是在Rust里是无法通过编译的,你会收到一条所有权已被转移的错误。

Clone来解决所有权转移的问题其实并没有错,相反,这也是Rust推荐的操作。但是就像这篇文章标题所说的,为什么要“避免随意使用Clone”呢?其实这里要说的并不是不要使用Clone,而是要注意对什么实例使用Clone

为什么在其他语言中就很少出现Clone

对于Java和Go这些语言,我们经常会关注到其中被称为GC(Garbage Collect,垃圾回收)的内容。而且在我们学习其中函数定义的时候,也常常会提到“传值”和“传址”的区别。这是因为在这些语言中,很多复杂数据类型被直接使用引用优化了。语言接管了我们创建的数据类型的实例,我们所使用的实例,实际上都是语言运行时提供的托管实例的引用。

这些被托管的实例在运行时里通过引用计数等技术手段被监控着,当运行时能够确定这个被托管实例已经不再被使用了,那么才会释放掉它所占据的内存,也就是GC。

也就是这种托管的存在,导致我们在编写这些语言程序的时候,完全不用考虑这些实例在运行时里是如何被操作的,它们实际的内存存储形态是什么,我们只需要创建、使用即可,用完了也不需要过多的关心运行时什么时候会释放它。

GC的出现,其实让我们被“惯坏”了。诚然,语言的运行时接管很多低级功能以后,我们编码的心智负担会小很多,但是一些绝对不可控的风险也相应的变大了。这其实也是Rust总被诟病心智负担过大的一个原因之一。

RcArc来优化Clone

其实Rust里也不是没有提供这些引用技术的。能够最直接的避免大型数据结构在内存中反复复制的方法,就是使用引用计数数据类型:RcArc。这两个类型的功能基本上是一样的,只是Rc设计是非线程安全的,一般只适用于单线程或者线程内的条件下,而Arc则是线程安全的,可以放心的让它跨越线程边界。

对于RcArc的底层原理可以去参考其他的Rust教程,基本上所有的教程都会对这两个非常基础的数据类型做非常详细的讲解。这里只是提一句,它们的底层原理是通过引用计数来跟踪被包装实例的使用,当计数变为零的时候,那就说明现在实例已经没有在被使用了,可以安全释放了。

把一个自定义类型用Rc包装,例如:Rc<MyStruct>,然后再进行Clone。此时在内存中发生复制的实际上是Rc,也就是说对于MyStruct实例的引用计数增加了一个,真正复制出来的只是一个引用地址而已。这样就避免了复杂实例或者大型实例在内存中的深复制,而且也解决了所有权转移的问题,因为变量持有的实际上是Rc实例的所有权。

为什么不直接使用引用

习惯了Go和C++的人可能会问,Rust也是支持引用&的,那为什么不能直接使用引用呢?

首先要肯定的一点,使用引用也是可以的,因为RcArc本身就是一种智能引用数据类型。但是直接使用&来引用,实际上是需要考虑更多的问题的。

  1. 引用是需要考虑生命期的,如果引用了一个比当前语句块生命期短的内容,Rust编译器会报错的,毕竟引用在Rust里的正式称呼是“借用”,是没有所有权的。
  2. 在多线程下,直接使用引用可能会带来更大的心智负担。就更不要说可变引用的加入了。

所以引用&一般还是在能够明确控制生命期,所需操作比较明确、简单的条件下使用。

放心Clone

总之,Clone实际上是程序在运行的时候,内存里每时每刻都在发生的事情,我们需要Clone,但是我们还需要控制Clone的内容。所以在编写Rust代码写下.clone()之前,不妨先考虑一下现在自己正在复制的是什么,这个操作会不会带来什么潜在的问题。


索引标签
Rust
Clone
引用