在C++11中,何时将类型设置为不可移动?

133

我很惊讶这个问题没有出现在我的搜索结果中,考虑到在C++11中移动语义的实用性,我认为某个人以前应该已经问过了:

在何时需要(或者说是一个好主意)将类设为不可移动的?

(除了与现有代码兼容性等原因之外。)


3
"Boost始终走在前沿" - "昂贵的可移动类型" (http://www.boost.org/doc/libs/1_48_0/doc/html/container/move_emplace.html) - SChepurin
1
@SChepurin:“昂贵的移动”并不是非常清楚...我的意思是,如果某些东西对于你的使用情况来说太昂贵了,那么你应该避免它,无论是在现实生活中还是在编程或游戏中 :-) 它并没有告诉我任何我不知道的东西。 - user541686
2
据我所知,可移动的类仍然可能受到切片的影响,因此禁止所有多态基类(即所有具有虚函数的基类)的移动(和复制)是有意义的。 - Philipp
1
@Mehrdad:我只是想说,“T具有移动构造函数”和“T x = std::move(anotherT);是合法的”并不等同。后者是一个移动请求,如果T没有移动构造函数,则可能会退回到复制构造函数。那么,“可移动”到底意味着什么? - sellibitze
1
“因为您不想关心证明移动对于您的类型是良好定义的”,这个怎么样?你只需在需要时将其设置为不可移动。当它被使用时,您可以正确地实现移动语义。我只是猜测。但在C++03中,我习惯默认使所有类型都不可复制,只是为了编写更少的代码和更少的关注,因为YAGNI。 - v.oddou
显示剩余11条评论
4个回答

114

Herb 的回答(在被编辑之前)实际上给出了一个不应该被移动的类型的好例子:std::mutex

操作系统的本地互斥锁类型(例如,在 POSIX 平台上是 pthread_mutex_t)可能不是“位置不变”的,这意味着对象的地址是其值的一部分。例如,操作系统可能会保留指向所有已初始化互斥锁对象的指针列表。如果 std::mutex 包含作为数据成员的本地操作系统互斥锁类型,并且本地类型的地址必须保持固定(因为操作系统维护其互斥锁的指针列表),则要么 std::mutex 必须将本地互斥锁类型存储在堆上,以便在 std::mutex 对象之间移动时保持在同一位置,要么就不能移动 std::mutex。将其存储在堆上是不可能的,因为 std::mutex 具有 constexpr 构造函数,并且必须有资格进行常量初始化(即静态初始化),以便全局 std::mutex 在程序执行开始之前得到构造,因此其构造函数不能使用 new。因此,std::mutex 的唯一选择是不可移动的。

相同的推理适用于包含需要固定地址的内容的其他类型。如果资源的地址必须保持固定,请不要移动它!

不将 std::mutex 移动的另一个论点是,这样做很难安全地进行,因为你需要知道在移动互斥锁时是否有人试图锁定它。由于互斥锁是可以用来防止数据竞争的构建块之一,如果它们本身不安全,则会很不幸!通过不可移动的 std::mutex,你可以知道在它被构造和销毁之间的唯一操作是锁定和解锁。这些操作明确保证是线程安全的,不会引入数据竞争。这个论点同样适用于 std::atomic<T> 对象:除非它们可以原子移动,否则无法安全地移动它们,因为另一个线程可能正在尝试在对象上调用 compare_exchange_strong。因此,另一个不应该可移动的情况是,它们是安全并发代码的低级构建块,并且必须确保对它们的所有操作都是原子性的。如果对象值可能随时移动到新对象,则需要使用原子变量来保护每个原子变量,以便知道是否可以安全地使用它或者它已经被移动...以及用于保护该原子变量的原子变量,依此类推...

我认为可以概括地说,当一个对象仅是纯粹的内存块时,不作为值或值抽象的持有者的类型,移动它是没有意义的。基本类型如int无法移动:移动它们只是复制。你不能将int的内部数据拆分出来,只能复制其值并将其设置为0,但它仍然是一个具有值的int,只是内存中的字节而已。但在语言术语中,int仍然是“可移动”的,因为复制是一种有效的移动操作。 对于不可复制的类型,如果您不想或不能移动内存块,并且也无法复制其值,则它是不可移动的。互斥锁或原子变量是特定的内存位置(具有特殊属性),因此移动它是没有意义的,并且也不可复制,因此它是不可移动的。


18
不能移动的另一个不那么陌生的例子是有特殊地址的有向图结构中的节点。 - Potatoswatter
3
如果互斥锁是不可拷贝和不可移动的,那么如何复制或移动一个包含互斥锁的对象?(比如一个带有自己的互斥锁用于同步的线程安全类...) - tr3w
4
@tr3w,除非你在堆上创建互斥量并通过unique_ptr或类似方式持有它,否则你无法这样做。 - Jonathan Wakely
2
@tr3w:你只是移动整个类,除了互斥部分吗? - user541686
3
@BenVoigt,但新对象将拥有自己的互斥锁。我认为他的意思是具有用户定义的移动操作,可以移动除互斥锁成员之外的所有成员。那么如果旧对象过期了怎么办?它的互斥锁也会跟着过期。 - Jonathan Wakely
显示剩余17条评论

58
简短回答:如果一个类型可复制,那么它也应该是可移动的。然而,反之不一定成立:一些类型(如std::unique_ptr)是可移动的,但复制它们没有意义;这些是自然的“只移动”类型。
稍长的回答如下:
有两种主要类型(除了其他更特殊用途的类型,例如 traits):
1. 像 intvector<widget> 这样的值类型。它们代表值,应当可以复制。在 C++11 中,通常应将移动视为复制的优化,因此所有可复制的类型自然应当也是可移动的,移动仅仅是一种在通常情况下你不再需要原始对象并且打算销毁它时,进行拷贝的高效方式。
2. 存在于继承层次结构中的引用类型,例如基类和具有虚函数或受保护成员函数的类。这些通常通过指针或引用保存,通常是 base*base&,因此不提供复制构造以避免切片;如果您确实想获得与现有对象完全相同的另一个对象,则通常调用像clone这样的虚函数。这些不需要移动构造或赋值有两个原因:它们不可复制,并且它们已经有了更有效的自然“移动”操作——您只需复制/移动指向对象的指针,对象本身根本不必移动到新的内存位置。
大多数类型都属于这两个类别之一,但也有其他种类的类型也很有用,只是比较罕见。特别是,在此处表达资源的唯一所有权的类型(例如std::unique_ptr)是自然的“仅限移动”类型,因为它们不像值类型(复制它们没有意义),但你会直接使用它们(并非始终通过指针或引用),因此希望将这些类型的对象从一个地方移动到另一个地方。

62
请问真正的Herb Sutter能否站起来? :) - fredoverflow
7
是的,我从一个OAuth谷歌账户切换到另一个账户,懒得找合并两个登录账户的方法了(这是OAuth存在的缺陷之一)。可能不会再使用另一个账户,所以现在我会用这个账户偶尔发一些Stack Overflow的帖子。 - Herb Sutter
7
我认为 std::mutex 是不可移动的,因为 POSIX 互斥锁是使用地址来操作的。 - Puppy
9
实际上,那被称为HerbOverflow。 - sbi
29
这篇文章获得了很多点赞,但没有人注意到它说的是当一个类型应该是移动语义时,而不是问题本身? :) - Jonathan Wakely
显示剩余7条评论

22

实际上,当我进行搜索时,我发现C++11中有相当多的类型是不可移动的:

  • 所有mutex类型(recursive_mutextimed_mutexrecursive_timed_mutex
  • condition_variable
  • type_info
  • error_category
  • locale::facet
  • random_device
  • seed_seq
  • ios_base
  • basic_istream<charT,traits>::sentry
  • basic_ostream<charT,traits>::sentry
  • 所有atomic类型
  • once_flag

显然,在Clang上正在进行讨论:https://groups.google.com/forum/?fromgroups=#!topic/comp.std.c++/pCO1Qqb3Xa4


1
迭代器不应该是可移动的?!为什么? - user541686
是的,我认为“迭代器/迭代器适配器”应该被删除,因为C++11已经有了move_iterator。 - billz
1
我认为你误解了没有显式移动操作(或者不能比复制更有效地移动)的对象,将它们视为不可移动类型。但最终所有可复制的类型也都是可移动的。特别是所有迭代器都是可复制的,因此也是可移动的,而不仅仅是std::move_iterator(无论如何,它有完全不同的目的)。同样,std::time_pointstd::duration(以及可能还有其他我没有仔细检查/思考的类型)也是如此。 - Christian Rau
1
这里的 std::reference_wrapper 也是如此。其他的确实似乎是不可移动的。 - Christian Rau
1
这些似乎可以分为三类:1. 低级并发相关类型(原子,互斥锁),2. 多态基类(ios_basetype_infofacet),3. 各种奇怪的东西(sentry)。 可能普通程序员唯一编写的无法移动的类属于第二类。 - Philipp
显示剩余4条评论

1
另一个我发现的原因是性能。假设你有一个名为'a'的类,它保存了一个值。你想输出一个界面,允许用户在有限的时间内(作用域)更改该值。
一种实现方法是从'a'返回一个“作用域保护”对象,在其析构函数中将值设置回去,如下所示:
class a 
{ 
    int value = 0;

  public:

    struct change_value_guard 
    { 
        friend a;
      private:
        change_value_guard(a& owner, int value) 
            : owner{ owner } 
        { 
            owner.value = value;
        }
        change_value_guard(change_value_guard&&) = delete;
        change_value_guard(const change_value_guard&) = delete;
      public:
        ~change_value_guard()
        {
            owner.value = 0;
        }
      private:
        a& owner;
    };

    change_value_guard changeValue(int newValue)
    { 
        return{ *this, newValue };
    }
};

int main()
{
    a a;
    {
        auto guard = a.changeValue(2);
    }
}

如果我将change_value_guard变为可移动类型,那么我必须在其析构函数中添加一个if语句来检查该保护器是否已被移动 - 这是一个额外的if,并且会影响性能。确实,它可能会被任何合理的优化器优化掉,但仍然很好的一点是,语言(需要C++17)不要求我们支付那个if,如果我们除了从创建函数返回它之外不打算移动这个保护器的话,就可以使用“不付费用原则”。

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