转移 vs 复制

Created @ 2013-06-19, r1 rev 2013-06-19, markdown @ 2015-09-15.

  ISO C++11 引入右值引用以及转移构造等机制作为转移语义的内建支持。对应原本的复制构造等机制抽象的复制语义,可以说和转移至少在语言层次上是并列的。   那么转移和复制有什么共同点和区别?它们的关系如何?以下讨论几个相关的问题。

1.不论具体的语言支持,一般意义上的复制/转移语义抽象

  复制和转移是使程序从一个执行状态迁移到另一个执行状态的操作。不过依赖的概念可能有些模糊。方便起见,这里约定“值”表示某个抽象的单一(可分辨)状态,假设“变量”是可变的值的容器。

  对于最简单情况,值可以取空值(记作 e )和非空的非平凡值(记作 v )。

  (不过,一般这样的抽象是不够的,因为复制和转移本质上表示的不仅是决定性(deterministic)操作,前后两个状态可以有未指定的值。因此可以引入一个表示变量的未指定的取值 ? 。)

  设程序 P 的两个执行状态 E1E2 ,每个执行状态有两个取值状态(“变量”) V1V2 ,分别可以取值 ve ,也可能是 ?

  E1P 的初始状态,满足 E1 = {{V1, V2} | V1 = v} 。以表示映射,那么有:

复制操作 C: E1 → E2 满足 E2 = {{V1, V2} | V1 = v ∧ V2 = v}

转移操作 M: E1 → E2 满足 E2 = {{V1, V2} | V2 = v}

  直观理解,一个变量的值在复制后出现在原来的变量和另一个其它变量中;一个变量的值在转移后出现在另一个其它变量中。

2.结构化复制和转移

  为了在语言中表达复制和转移,需要借助类型系统及约定的原始操作。

  对于复制来说,除了少数“纯函数式”的语言,基本的操作是赋值(本身就是一种复制)。相反,可能由于复制可以用转移和赋“空值”复合,一般语言不直接提供转移操作。

  实际上,赋值作为简单的复制一般是不够用的。一般来说,语言提供的抽象手段允许复用基本操作,对于构建复制和转移操作也不例外。和经典的函数调用类似,这里的有效的复用可以是结构化的:递归的且有层次性的。

  C++ 提供了复制构造表达一种符合结构化程序思路来构建复制(初始化)语义:自定义类型的复制使用成员复制(以及构造函数体的其它副作用),直至基本类型的复制(同赋值)。自定义类型的复制构造反过来也成为了复制赋值的基础。

  C++11 的转移构造也使用类似的机制。而对于基本类型来说,复制和转移在这里是一致的。

  从上面的形式可以看出,复制实质蕴含转移:任何复制都是转移,复制是附加了更强约束的转移。

  逻辑上转移的实现似乎可以用复制和删除取代,或者扩展转移机制以实现复制。看起来似乎提供复制和转移两套近似的机制有点浪费。那么:

a. 复制能代替转移吗?

  答案是可行的——事实上 C++11 前就是这样做的。但是 C++ 在这里有一个特性——复制构造允许复制成员以外的副作用——导致了致命的弱点:复制开销无法被轻易优化掉。为了克服这点, ISO C++ 甚至有专门的语义规则允许忽略复制的副作用。区分复制和转移允许更好地减少不必要的开销。在逻辑上也有更重要和普遍的漏洞。原理见下文。

b. 转移能代替复制吗?

  让转移成为更“基本”的复制,扩展转移以实现复制至少对于 C++ 来说也不符合实际。复制无法被转移直接取代,除非允许重载基本类型的操作——如果是这样,基本类型的操作又用什么表示呢?在 C++ 的框架内部行不通,而暴露底层实现(如汇编)给用户看来也不是干净的做法。

3.控制流中断:异常安全

  比较 {{V1, V2} | V1 = v}{{V1, V2} | V1 = v ∧ V2 = v}{{V1, V2} | V2 = v} ,可见复制操作和转移操作在这里有一个根本的不同:复制操作需要多维持一个状态(“变量”)。

  从体系结构上(注意,并不限于具体语言的实现)来说,若不限定状态在语言实现上的某一个 phase of execution (普遍地,如编译时),复制操作必然需要申请获取资源(典型地,如内存);除非预留(本质上来说和限定 phase 类似,所谓“静态”的优化),这种资源的分配总是可能失败。

  一旦失败,依赖于被复制的状态的操作就不能进行,即错误必须被处理;否则逻辑上就不符合期望了——也就是错的。由于基本操作的普遍性,让控制流中断而不是让用户在调用端手动检查是比较简便的做法:在 C++ 中即抛出异常。(因为允许失败以及析构函数保证的决定性生存期终止语义,“变量”—— C++ 的对象可以很直接地作为资源的抽象,即 RAII 惯用法。)

  上面提到过的复制和转移是结构化的,这意味着除了基本类型(复制和转移一样,没有异常)外在任何层次都得考虑这个问题。只要有一层复制初始化操作(复制或转移)可能出现异常,那么依赖于这个不保证无异常的操作的复制初始化都不能幸免。也就是说复制可能抛出异常这点在一般意义上是普遍的(注意C++03的实践已经证明了限制异常规范的做法的失败)。

  而转移操作没有这个限制。理论上来说,任何转移操作总是可以被设计为保证成功的。所以转移操作往往用 noexcept 限定没有异常;标准库的泛型组件一般也要求被操作的对象类型可无异常抛出转移。

  考虑到异常安全和无异常抛出保证实现的困难性,这个差异是如此重要和普遍,以至于即便无视复制初始化的性能问题(毕竟还可能让 RVO 等挽救一下),单独区分出转移也是相当有意义的。

4.转移和交换

  注意 M: E1 → E2 中, E1E2 是对称的。这意味着两点:

a. 转移本质上是一种(对称的)状态迁移;

b. E1和E2作为执行状态可以置换。

  对于 C++ 来说, a. 的意义在于如果转移可以用简单的操作实现。注意对象转移后仍然会被销毁。对于基本类型对象,因为没有有副作用的析构(non-trvial destructor) ,所以可以直接复制/赋值(这再次说明了基本类型复制和转移的一致性)。

  不过对于整体状态来说还有个陷阱,导致程序不一定总是能得到期望行为。 {{V1, V2} | V2 = v} 隐式地蕴含 “V1 = ?”,即被转移的“变量”的状态是无法保证可预期的。如果需要可预期(仍然能足够安全地使用被转移了的资源),那么简单复制就不一定行得通了。比如说一个预期保持所有权、通过 delete 释放的内建指针,如果复制代替转移而不清空,最后会导致多次 delete 。   很遗憾这里C++无法有效地提供安全检查。一种安全的惯用法是使用交换例程(如 std::swap )来实现,不过需要指定的类型可交换。这早就是一种异常安全代码的常见技巧——C++11也约定 swappable 要求无异常抛出。

  b. 是 C++ 语言层次上难以利用的特性(和复制构造类似,转移操作也允许存在用户自定义的副作用,省了转移的优化也是受限的),在此不讨论(嘛, Prolog 什么的就算了……)。

5.转移语义:显式实现 vs 语言特性

  一个语言可以不直接支持转移需要的特性,而只是支持间接操作的“引用”类型,这样仍然可以实现转移。这时候典型的转移实现被称为“浅复制”(shallow copy) ,区分于完全值语义的(递归)“深复制”(deep copy) 。

  对于语言设计来说,这可以使设计简化,而不必像 C++11 那样引入右值引用类型来折腾类型系统。不过,这样往往意味着用户需要完成更多的重复代码。至少对于 C++ 这样对象模型中显式声称“对象即存储”精神(继承于 C )来说的语言来说是这样。

  从实际需求出发,理想情况下,语言应该能够分辨出转移和复制这两种不同的需求。像 C++11 在类型系统上做的手脚已经被证明可行的,那么除此之外,有没有比让用户显式实现更简单的方法呢?上面说过,至少在 C++ 中复制和转移无法互相取代,不过如果不限 C++ 嘛——

a. 转移取代复制。要是提供允许用户指定行为的基本类型操作的公开接口,那么转移就可能直接作为复制的基础了。不过,因为C++仅有的静态分派和多态(重载)依赖于类型系统,像C++要这么搞恐怕类型上的折腾还是省不掉……而实际上,分派也好模式匹配也好需要的签名要是类型以外的其它新特性,本质上来说也属于传统类型理论的范畴内。

b. 复制取代转移。不使用显式类型,取消复制初始化的副作用,让语言实现预测复制到底需要怎么实现。这是常见函数式语言的做法。这种复制相对很容易优化掉,代价是实体(“变量”)无法隐式地作为具有状态的资源抽象。

  至于 C 、 Java 什么的……算了,老实人肉实现吧。

  所以 C++ 其实也算个典型,虽然看上去糟烂混乱不堪大用,不过小心点用也还算差强人意了。

6.结论

  坑掉算了。

results matching ""

    No results matching ""