36

当遵循最佳实践时,我曾经认为 C++ 的对象模型非常健壮。
然而,就在几分钟前,我意识到了我以前从未有过的。

考虑这段代码:

class Foo
{
    std::set<size_t> set;
    std::vector<std::set<size_t>::iterator> vector;
    // ...
    // (assume every method ensures p always points to a valid element of s)
};

我写过这样的代码。直到今天,我还没有看到它有问题。

但是,再想一想,我意识到这个类非常糟糕:
它的复制构造函数和复制赋值复制了内部的迭代器vector,这意味着它们仍然会指向 set的!新的毕竟不是真正的副本!

换句话说,即使这个类没有管理任何资源(没有 RAII) ,我也必须手动实现复制构造函数!

这让我感到惊讶。我以前从未遇到过这个问题,我也不知道有什么优雅的方法可以解决它。再想一想,在我看来,默认情况下复制构造是不安全的——事实上,在我看来,默认情况下类不应该可复制的,因为它们的实例变量之间的任何类型的耦合都有可能呈现默认副本-构造函数无效

迭代器从根本上来说存储不安全吗?或者,默认情况下类真的应该是不可复制的吗?

下面我能想到的解决方案都是不可取的,因为它们不允许我利用自动生成的复制构造函数:

  1. 为我编写的每个重要类手动实现一个复制构造函数。这不仅容易出错,而且为一个复杂的类编写起来也很痛苦。
  2. 永远不要将迭代器存储为成员变量。这似乎受到严重限制。
  3. 在我编写的所有类上默认禁用复制,除非我能明确证明它们是正确的。这似乎完全违背了 C++ 的设计,该设计对于大多数类型来说都具有值语义,因此是可复制的。

这是一个众所周知的问题吗?如果是,它是否有一个优雅/惯用的解决方案?

4

7 回答 7

21

C++ 复制/移动 ctor/assign 对于常规值类型是安全的。常规值类型的行为类似于整数或其他“常规”值。

它们对于指针语义类型也是安全的,只要操作不会改变指针“应该”指向的内容。指向“你自己”或其他成员的东西是失败的一个例子。

它们对于引用语义类型有些安全,但在同一个类中混合指针/引用/值语义在实践中往往是不安全/错误/危险的。

零规则是您创建的类的行为类似于常规值类型或不需要在复制/移动时重新定位的指针语义类型。然后您不必编写复制/移动 ctors。

迭代器遵循指针语义。

围绕这一点的惯用/优雅是将迭代器容器与指向容器紧密耦合,并在那里阻塞或写入复制 ctor。一旦一个包含指向另一个的指针,它们就不是真正分开的东西。

于 2015-06-07T01:25:36.507 回答
18

是的,这是一个众所周知的“问题”——每当你在一个对象中存储指针时,你可能需要某种自定义的复制构造函数和赋值运算符来确保指针都是有效的并指向预期的东西.

由于迭代器只是集合元素指针的抽象,它们有同样的问题。

于 2015-06-07T00:30:05.037 回答
14

这是一个众所周知的问题吗?

嗯,这是众所周知的,但我不会说众所周知。兄弟指针并不经常出现,而且我在野外看到的大多数实现都以与您的完全相同的方式被破坏。

我相信这个问题很少发生,以至于没有引起大多数人的注意;有趣的是,由于我现在关注的 Rust 多于 C++,由于类型系统的严格性(即编译器拒绝这些程序,提示问题),它经常出现在那里。

它有优雅/惯用的解决方案吗?

有许多类型的兄弟指针情况,所以这真的取决于,但是我知道两个通用的解决方案:

  • 钥匙
  • 共享元素

让我们按顺序回顾它们。

指向一个类成员,或者指向一个可索引的容器,然后可以使用偏移量而不是迭代器。它的效率略低(可能需要查找),但它是一个相当简单的策略。我已经看到它在共享内存情况下效果很好(使用指针是禁忌,因为共享内存区域可能映射到不同的地址)。

Boost.MultiIndex 使用了另一种解决方案,它包含在另一种内存布局中。它源于侵入式容器的原理:侵入式容器不是将元素放入容器中(在内存中移动它),而是使用元素内部已经存在的钩子将其连接到正确的位置。从那里开始,很容易使用不同的钩子将单个元素连接到多个容器中,对吧?

嗯,Boost.MultiIndex 更进一步:

  1. 它使用传统的容器接口(即,将您的对象移入),但对象移入的节点是具有多个钩子的元素
  2. 它在单个实体中使用各种钩子/容器

您可以查看各种示例,尤其是示例 5:序列索引看起来很像您自己的代码。

于 2015-06-07T14:29:59.990 回答
9

这是一个众所周知的问题吗

是的。任何时候你有一个包含指针的类,或者像迭代器这样的指针式数据,你必须实现自己的复制构造函数和赋值操作符,以确保新对象具有有效的指针/迭代器。

如果是这样,它是否有一个优雅/惯用的解决方案?

也许没有你想的那么优雅,并且可能不是最好的性能(但是,副本有时不是,这就是 C++11 添加移动语义的原因),但也许这样的东西对你有用(假设包含指向同一父对象的std::vector迭代器):std::set

class Foo
{
private:
    std::set<size_t> s;
    std::vector<std::set<size_t>::iterator> v;

    struct findAndPushIterator
    {
        Foo &foo;
        findAndPushIterator(Foo &f) : foo(f) {}

        void operator()(const std::set<size_t>::iterator &iter)
        {
            std::set<size_t>::iterator found = foo.s.find(*iter);
            if (found != foo.s.end())
                foo.v.push_back(found);
        }
    };

public:
    Foo() {}

    Foo(const Foo &src)
    {
        *this = src;
    }

    Foo& operator=(const Foo &rhs)
    {
        v.clear();
        s = rhs.s;

        v.reserve(rhs.v.size());
        std::for_each(rhs.v.begin(), rhs.v.end(), findAndPushIterator(*this));

        return *this;
    }

    //...
};

或者,如果使用 C++11:

class Foo
{
private:
    std::set<size_t> s;
    std::vector<std::set<size_t>::iterator> v;

public:
    Foo() {}

    Foo(const Foo &src)
    {
        *this = src;
    }

    Foo& operator=(const Foo &rhs)
    {
        v.clear();
        s = rhs.s;

        v.reserve(rhs.v.size());
        std::for_each(rhs.v.begin(), rhs.v.end(),
            [this](const std::set<size_t>::iterator &iter)
            {
                std::set<size_t>::iterator found = s.find(*iter);
                if (found != s.end())
                   v.push_back(found);
            } 
        );

        return *this;
    }

    //...
};
于 2015-06-07T00:56:36.147 回答
7

是的,当然这是一个众所周知的问题。

如果您的类存储了指针,作为经验丰富的开发人员,您会直观地知道默认的复制行为可能不足以满足该类的要求。

您的类存储迭代器,并且由于它们也是存储在其他地方的数据的“句柄”,因此适用相同的逻辑。

这并不“令人惊讶”。

于 2015-06-07T00:54:40.937 回答
5

不管理任何资源的断言Foo是错误的。

除了复制构造函数之外,如果set删除了 的元素,则必须在Foo其中管理代码vector以便删除相应的迭代器。

我认为惯用的解决方案是只使用一个容器 a vector<size_t>,并在插入之前检查元素的计数是否为零。然后复制和移动默认值就可以了。

于 2015-06-07T01:22:50.813 回答
3

“天生不安全”

不,您提到的功能本质上并不是不安全的;您想到了三种可能的安全解决方案这一事实证明了这里没有“固有的”缺乏安全性,即使您认为这些解决方案是不可取的。

是的,这里有RAII容器 (setvector) 正在管理资源。我认为您的观点是std容器“已经照顾好”RAII。但是您需要将容器实例本身视为“资源”,实际上您的班级正在管理它们。您没有直接管理堆内存是正确的,因为管理问题这一方面由标准库为您处理。但是管理问题还有更多,我将在下面详细讨论。

“魔术”默认行为

问题是您显然希望您可以信任默认的复制构造函数在诸如此类的非平凡情况下“做正确的事情”。我不确定您为什么期望正确的行为-也许您希望记住诸如“ 3规则”之类的经验法则将是确保您不会在脚上开枪的可靠方法? 当然那会很好(而且,正如另一个答案中指出的那样,Rust 比其他低级语言更进一步使脚射更加困难),但 C++ 根本不是为那种“轻率”的类设计而设计的,也不应该

将构造函数行为概念化

我不会尝试解决这是否是“众所周知的问题”的问题,因为我真的不知道“姐妹”数据和迭代器存储问题的特征有多大。但我希望我能说服你,如果你花时间为你编写的每个可以复制的类考虑复制构造函数行为,这应该不是一个令人惊讶的问题。

特别是,在决定使用默认复制构造函数时,您必须考虑默认复制构造函数实际上会做什么:即,它将调用每个非原始、非联合成员的复制构造函数(即具有复制构造函数)并按位复制其余部分。

复制你vector的迭代器时,std::vector复制构造函数是做什么的?它执行“深度复制”,即复制向量内的数据。现在,如果向量包含迭代器,这对情况有何影响?嗯,很简单:迭代器向量存储的数据,所以迭代器本身会被复制。迭代器的复制构造函数是做什么的?我不会实际查找这个,因为我不需要知道细节:我只需要知道迭代器在这个(和其他方面)就像指针,复制指针只是复制指针本身,不是指向的数据。即,迭代器和指针默认没有深度复制。

请注意,这并不奇怪:当然迭代器默认不进行深度复制。如果他们这样做了,那么每个被复制的迭代器都会得到一个不同的新集合。这比最初看起来更没有意义:例如,如果单向迭代器对其数据进行深度复制,这实际上意味着什么?大概你会得到一个部分副本,即仍然“在”迭代器当前位置“前面”的所有剩余数据,加上一个指向新数据结构“前面”的新迭代器。

现在考虑复制构造函数无法知道调用它的上下文。例如,考虑以下代码:

using iter = std::set<size_t>::iterator;  // use typedef pre-C++11
std::vector<iter> foo = getIters();  // get a vector of iterators
useIters(foo);    // pass vector by value

调用时getIters,返回值可能会被移动,但也可能是复制构造的。的赋值foo也调用了一个复制构造函数,尽管这也可以被省略。除非useIters通过引用获取其参数,否则您还会在此处调用复制构造函数。

任何这些情况下,您是否希望复制构造函数更改由 ? 包含的迭代 std::set所指向的std::vector<iter>?当然不是!因此,自然std::vector不能将复制构造函数设计为以这种特定方式修改迭代器,事实上,在实际使用它的大多数情况下,std::vector复制构造函数正是您所需要的。

然而,假设std::vector 可以像这样工作:假设它有一个特殊的“迭代器向量”重载,可以重新安装迭代器,并且编译器可以以某种方式被“告知”只在迭代器实际调用这个特殊构造函数时需要重新就座。(请注意,“仅在为包含迭代器的基础数据类型的实例的包含类生成默认构造函数时才调用特殊重载”的解决方案将不起作用;如果std::vector您的情况下的迭代器指向一个不同的标准集,并且被简单地视为对其他类管理的数据的引用?哎呀,一样 std::set?)忽略编译器如何知道何时调用这个特殊构造函数的问题,构造函数代码会是什么样子?让我们尝试一下,使用_Ctnr<T>::iterator我们的迭代器类型(我将使用 C++11/14isms 并且有点草率,但总体要点应该很清楚):

template <typename T, typename _Ctnr>
std::vector< _Ctnr<T>::iterator> (const std::vector< _Ctnr<T>::iterator>& rhs)
  : _data{ /* ... */ } // initialize underlying data...
{
    for (auto i& : rhs)
    {
        _data.emplace_back( /* ... */ );  // What do we put here?
    }
}

好的,所以我们希望每个新的、复制迭代器都被重新定位以引用_Ctnr<T>. 但是这些信息是从哪里来的呢?请注意,复制构造函数不能将 new_Ctnr<T>作为参数:那么它将不再是复制构造函数。无论如何,编译器如何知道_Ctnr<T>要提供哪个?(也请注意,对于许多容器,为新容器找到“对应的迭代器”可能并非易事。)

std::容器资源管理

这不仅仅是编译器没有达到应有的“智能”程度的问题。在这种情况下,您(程序员)心中有一个需要特定解决方案的特定设计。特别是,如上所述,您有两个资源,都是std::容器。和你有他们之间的关系。在这里,我们得到了大多数其他答案都已说明的内容,此时应该非常非常清楚:相关的类成员需要特别注意,因为默认情况下 C++ 不管理这种耦合。但我希望也是这一点很清楚,您不应该认为问题是由于数据成员耦合而引起的;问题只是默认构造并不神奇,程序员在决定让隐式生成的构造函数处理复制之前必须了解正确复制类的要求。

优雅的解决方案

...现在我们开始讨论美学和观点。当您的类中没有任何必须手动管理的原始指针或数组时,您似乎发现被迫编写复制构造函数是不优雅的。

但是用户定义的复制构造函数优雅;允许您编写它们C++ 对编写正确的非平凡类问题的优雅解决方案。

诚然,这似乎是“3 规则”并不完全适用的情况,因为显然需要=delete复制构造函数或自己编写它,但还没有明确需要用户定义的析构函数. 但同样,您不能简单地根据经验法则进行编程并期望一切都能正常工作,尤其是在 C++ 等低级语言中;您必须了解(1)您真正想要什么以及(2)如何实现的细节。

std::set因此,考虑到您和您之间的耦合std::vector实际上会产生一个不平凡的问题,通过将它们包装在一个正确实现(或简单地删除)复制构造函数的类中来解决问题实际上是一个非常优雅(和惯用)的解决方案.

显式定义与删除

您在编码实践中提到了一个潜在的新“经验法则”:“在我编写的所有类上默认禁用复制,除非我能明确证明它们是正确的。” 虽然这可能是比“3 规则”更安全的经验法则(至少在这种情况下)(特别是当您的“我是否需要实施 3”的标准是检查是否需要删除器时),我的上面不要依赖经验法则的警告仍然适用。

但我认为这里的解决方案实际上比建议的经验法则更简单。你不需要正式证明默认方法的正确性;你只需要对它会做什么以及你需要它做什么有一个基本的了解。

上面,在我对您的特定案例的分析中,我详细介绍了很多细节——例如,我提出了“深度复制迭代器”的可能性。您不需要深入了解这些细节来确定默认的复制构造函数是否可以正常工作。相反,只需想象您手动创建的复制构造函数会是什么样子;您应该能够很快说出您想象中的显式定义的构造函数与编译器生成的构造函数有多相似。

例如,Foo包含单个向量的类data将具有如下所示的复制构造函数:

Foo::Foo(const Foo& rhs)
  : data{rhs.data}
{}

甚至不用写出来,你就知道你可以依赖隐式生成的,因为它和你上面写的完全一样。

现在,考虑您的类的构造函数Foo

Foo::Foo(const Foo& rhs)
  : set{rhs.set}
  , vector{ /* somehow use both rhs.set AND rhs.vector */ }  // ...????
{}

马上,鉴于简单地复制vector's 的成员是行不通的,您可以说默认构造函数将无法正常工作。所以现在你需要决定你的类是否需要可复制。

于 2015-06-07T21:42:28.110 回答