使交换更快、更易于使用和异常安全

14

昨晚我无法入睡,开始思考 std::swap。这是熟悉的 C++98 版本:

template <typename T>
void swap(T& a, T& b)
{
    T c(a);
    a = b;
    b = c;
}

如果用户自定义的类Foo使用外部资源,这是低效的。常见的习惯用法是提供一个方法void Foo::swap(Foo& other)和std::swap的特化。请注意,这不适用于类模板,因为您无法部分特化函数模板,并且在std命名空间中重载名称是非法的。解决方案是在自己的命名空间中编写模板函数,并依赖于参数相关查找来找到它。这在很大程度上取决于客户端遵循“使用std :: swap惯用语”而不是直接调用std :: swap。非常脆弱。
在C++0x中,如果Foo具有用户定义的移动构造函数和移动分配运算符,则提供自定义swap方法和std :: swap 特化几乎没有性能优势,因为C++0x版本的std :: swap使用高效移动而不是复制:
#include <utility>

template <typename T>
void swap(T& a, T& b)
{
    T c(std::move(a));
    a = std::move(b);
    b = std::move(c);
}

不再需要与swap搏斗已经使程序员减轻了很多负担。目前的编译器尚未自动生成移动构造函数和移动赋值操作符,但据我所知,这将会改变。那么唯一剩下的问题是异常安全,因为通常来说,移动操作是允许抛出异常的,这就引发了一系列问题。 “移后对象的状态究竟是什么?”这个问题更加复杂。
然后我在想,如果一切顺利,C++0x中std::swap的语义究竟是什么?交换前后对象的状态如何?通过移动操作进行交换通常不会涉及到外部资源,只会涉及到“平缓”的对象表示本身。
因此,为什么不编写一个模板swap来执行这个任务: 交换对象表示
#include <cstring>

template <typename T>
void swap(T& a, T& b)
{
    unsigned char c[sizeof(T)];

    memcpy( c, &a, sizeof(T));
    memcpy(&a, &b, sizeof(T));
    memcpy(&b,  c, sizeof(T));
}

这是最有效的方法:它只需快速地访问原始内存。无需用户干预:不需要定义任何特殊的交换方法或移动操作。这意味着它甚至可以在C++98中使用(请注意,它没有右值引用)。但更重要的是,我们现在可以忘记异常安全问题,因为memcpy永远不会抛出异常。
我看到这种方法存在两个潜在问题:
首先,并非所有对象都适合交换。如果类设计者隐藏了复制构造函数或复制赋值运算符,则尝试交换该类的对象应在编译时失败。我们可以简单地添加一些死代码来检查类型上是否允许复制和赋值操作:
template <typename T>
void swap(T& a, T& b)
{
    if (false)    // dead code, never executed
    {
        T c(a);   // copy-constructible?
        a = b;    // assignable?
    }

    unsigned char c[sizeof(T)];

    std::memcpy( c, &a, sizeof(T));
    std::memcpy(&a, &b, sizeof(T));
    std::memcpy(&b,  c, sizeof(T));
}

任何一个好的编译器都可以轻松地消除死代码。(可能有更好的方法来检查“交换一致性”,但这不是重点。重要的是它是可能的)。
其次,一些类型在复制构造函数和复制赋值运算符中可能执行“不寻常”的操作。例如,它们可能会通知观察者它们的更改。我认为这只是一个小问题,因为这种类型的对象可能本来就不应该提供复制操作。
请让我知道您对这种交换方法的看法。它在实践中能行吗?你会使用它吗?您能否确定此方法会破坏哪些库类型?您还看到其他问题吗?讨论!

大多数使用std::swap的现有用例都可以使用移动语义更好的解决方案。 - aschepler
是的,移动语义和移动构造函数。查看此链接:http://stackoverflow.com/questions/4820643/understanding-stdswap-what-is-the-purpose-of-tr1-remove-reference - Michael Smith
2
考虑到 swap(*polymorphicPtr1,*polymorphicPtr2)... 你的交换函数将会交换两个对象的 vtable... 如果有人在调用 swap 后调用虚函数,这将会造成混乱。 - smerlin
@smerlin:但是多态对象真的不应该有复制构造函数或赋值运算符,对吧? - fredoverflow
@FredOverflow:是的,但问题是:你能否编写一个静态断言来检查T是否为多态类型。否则,尽管使用多态类型的赋值运算符不是一个好习惯(在我看来,有时候复制构造函数还是可以的),但仍然会有人使用它。 - smerlin
1
@smerlin:实际上,是的 :) C++0x 提供了 std::is_polymorphic 类型特性。 - fredoverflow
5个回答

20

这将会破坏那些有指向自己成员的指针的类实例。例如:

class SomeClassWithBuffer {
  private:
    enum {
      BUFSIZE = 4096,
    };
    char buffer[BUFSIZE];
    char *currentPos; // meant to point to the current position in the buffer
  public:
    SomeClassWithBuffer();
    SomeClassWithBuffer(const SomeClassWithBuffer &that);
};

SomeClassWithBuffer::SomeClassWithBuffer():
  currentPos(buffer)
{
}

SomeClassWithBuffer::SomeClassWithBuffer(const SomeClassWithBuffer &that)
{
  memcpy(buffer, that.buffer, BUFSIZE);
  currentPos = buffer + (that.currentPos - that.buffer);
}

现在,如果你只是使用memcpy(),那么currentPos会指向哪里?显然是旧的位置。这将导致非常有趣的错误,每个实例实际上都使用另一个实例的缓冲区。


1
老实说,让“Reader”对象具备复制构造和赋值功能对我来说似乎是一个设计错误。 - fredoverflow
1
@Fred,这只是一个抽象的例子。我可能应该将其命名为“SomeClassWithBuffer”,但这并不重要。 - Sergei Tachenov
同时,OP建议的缓冲区可能没有正确对齐T - Motti
@Motti,我认为这不是问题,因为它只是用于临时存储,并且T被视为字节数组,对于任何类型来说都是完全有效的。 - Sergei Tachenov
6
点赞:相关趣闻:在libstdc++和libc++(http://libcxx.llvm.org/)中,所有基于节点的std ::容器都具有Sergey所演示的设计(因此会与memcpy-swap断开连接)。 这是“嵌入式末端节点”优化之一,是基于节点的容器中更重要的优化之一。 这使得默认构造函数和移动构造函数都可以是noexcept的。 在我看来,没有比这更重要的了。 当然,这些容器可以创建自己的swap重载。 但是要点是:Sergey的设计并不罕见。 - Howard Hinnant
显示剩余2条评论

20
那为什么不编写一个“swap”模板,确切地实现这一点:交换对象的表示形式*呢?
一旦构造完毕,对象就会在复制其所在字节时发生许多破坏。事实上,人们可以想出似乎无穷无尽的情况,在这些情况下,这种方法可能行不通 - 即使在实践中,它可能适用于98%以上的情况。
这是因为所有这些背后的问题是,除了C语言外,在C++中,我们不应该将对象视为纯粹的原始字节。毕竟,正是因为此,我们才有了构造和析构函数:将原始存储转换为对象,并将对象重新转换为原始存储。一旦运行构造函数,对象所在的内存就不仅仅是原始存储。如果你将它视为不是这样的,你会破坏某些类型。
然而,本质上,移动对象不应该比您的想法表现得更差,因为一旦您开始递归地调用std::move(),通常最终会到达内置的移动位置。 (如果对于某些类型还有更多的移动内容,最好不要自己搞定这些内容!)毫无疑问,整块移动内存通常比单个移动快(而且编译器不太可能发现它可以将单个移动优化为一种全面的std::memcpy()),但这是我们为抽象化的不透明对象所付出的代价。特别是当您与我们以前进行的复制进行比较时,这种代价非常小。
但是,您可以使用std::memcpy()为聚合类型优化swap()。

1
@Fred,我的词典上说“praxis”是一个完全合法的英语单词。不是吗?虽然英语不是我的母语,但我很好奇。 - Sergei Tachenov
1
你确定相邻的移动没有合并成一个吗?这似乎是一种微不足道的优化。(我想它可能会因为存在“未移动”的数据,如虚拟表指针而被打乱) - Matthieu M.
Matthieu:不,我不确定。请注意,我写了“这很不可能”。 - sbi
@sbi:我明白你的意思,但我也很好奇——你能举出一个实际的例子来说明memcpy复制对象会导致其破坏吗?我所知道的唯一情况是针对那些持有指向自身的指针类型……但我认为我从未在实践中见过这种情况。 - user541686
@sbi:我们实际上正在讨论这个问题在这里;请随意在那里发帖。我意识到如果存在循环依赖关系,并且对象通常会使用正常的swap更新外部引用,那么就会有麻烦...但实际上我们能想到的唯一情况是迭代器,它们无论如何都会失效。请参阅该帖子以获取讨论。因此,在实践中,如果您知道您没有自动更新的循环依赖关系(这并不常见),那么您应该没问题? - user541686
显示剩余3条评论

7
一些类型可以交换,但不能复制。独特的智能指针可能是最好的例子。检查可复制性和可赋值性是错误的。
如果T不是POD类型,则使用memcpy进行复制/移动是未定义的行为。
常见的习惯用法是提供一个void Foo::swap(Foo& other)方法和std::swap的特化。请注意,这对于类模板不起作用...
更好的习惯用法是使用非成员交换,并要求用户无资格调用交换,以便ADL适用。这也适用于模板:
struct NonTemplate {};
void swap(NonTemplate&, NonTemplate&);

template<class T>
struct Template {
  friend void swap(Template &a, Template &b) {
    using std::swap;
#define S(N) swap(a.N, b.N);
    S(each)
    S(data)
    S(member)
#undef S
  }
};

关键是使用std::swap作为后备的using声明。Template的swap友好关系有助于简化定义;NonTemplate的swap也可能是友好的,但这是一个实现细节。

6
我认为这只是一个小问题,因为这些对象可能本来就不应该提供复制操作。

那就简单地说错了。通知观察者的类和不应该被复制的类是完全无关的。比如shared_ptr呢?它显然应该是可复制的,但它也显然会通知观察者- 引用计数。现在确实是这样,交换后引用计数是相同的,但是对于所有类型来说,特别是涉及多线程时,常规复制而不是交换时等情况,这绝对不是正确的。对于可以移动或交换但不能复制的类,这尤其错误。
因为一般来说,移动操作是允许抛出异常的。

他们绝对不是。在几乎任何涉及移动并可能抛出异常的情况下,几乎不可能保证强异常安全性。从记忆中看,C++0x标准库定义明确指出,在任何标准容器中可用的任何类型在移动时都不能抛出异常。
这是最高效的方式。

那也是错的。您假设任何对象的移动都仅仅是它的成员变量,但可能不是所有成员变量。我可能有一个基于实现的缓存,并且我可能决定在我的类中不应该移动此缓存。作为实现细节,我完全有权利不移动任何我认为不需要移动的成员变量。然而,您希望移动所有这些变量。
现在,对于许多类来说,您的示例代码应该是有效的。但是,对于许多完全合法的类来说,它绝对不是有效的,更重要的是,如果操作可以简化为那个操作,它将编译为该操作。这是为了毫无好处地破坏完全良好的类。

+1 表示指出:「……C++0x 标准库的定义,从记忆中来看,明确规定任何可用于任何标准容器的类型在移动时不得抛出异常。」 - Martin Ba
我会再加上一个+1,因为“... 更重要的是,它最终会编译成那个操作...” - Martin Ba

1

你的swap版本如果与多态类型一起使用,将会造成混乱。

请考虑:

Base *b_ptr = new Base();    // Base and Derived contain definitions
Base *d_ptr = new Derived(); // of a virtual function called vfunc()
yourmemcpyswap( *b_ptr, *d_ptr );
b_ptr->vfunc(); //now calls Derived::vfunc, while it should call Base::vfunc
d_ptr->vfunc(); //now calls Base::vfunc while it should call Derived::vfunc
//...

这是错误的,因为现在b包含Derived类型的vtable,所以在一个不属于Derived类型的对象上调用了Derived::vfunc

普通的std::swap只交换Base的数据成员,所以使用std::swap是可以的。


仅交换Base的数据成员可能会破坏Derived对象的不变量。这就是为什么为多态对象提供赋值运算符没有太多意义的原因之一。请注意,Bjarne Stroustrup认为默认情况下为每个用户定义的类提供赋值运算符是历史偶然。 - fredoverflow

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接