C++ 的默认复制构造函数本质上是不安全的吗?迭代器从根本上来说也是不安全的吗?

Is C++'s default copy-constructor inherently unsafe? Are iterators fundamentally unsafe too?

我曾经认为在遵循最佳实践时,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)
};

我写过这样的代码。直到今天,我才发现它有问题。

但是,仔细想想,我意识到这个 class 非常 坏了:
它的复制构造函数和复制赋值 复制 vector 中的迭代器 ,这意味着它们仍将指向 oldset!新的毕竟不是正版!

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

这让我感到震惊。我以前从未遇到过这个问题,而且我不知道有什么优雅的方法可以解决它。再考虑一下,在我看来 copy 构造默认情况下是不安全的 —— 事实上,在我看来 classes 应该 默认情况下不 可复制,因为它们的实例变量之间的任何类型的耦合都有使默认复制构造函数无效的风险

迭代器从根本上来说是不安全的吗? 或者,classes 真的应该默认是不可复制的吗?

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

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

这是一个众所周知的问题吗?如果是,它有 elegant/idiomatic 解决方案吗?

是的,这是众所周知的 "problem" -- 每当您在对象中存储指针时,您可能需要某种自定义复制构造函数和赋值运算符来确保指针全部有效并指向预期的东西。

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

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

如果您的 class 存储指针,作为有经验的开发人员,您会凭直觉知道默认复制行为 可能 不足以满足 class。

您的 class 存储迭代器,并且由于它们也 "handles" 存储在别处的数据,所以同样的逻辑适用。

这很难"astonishing"。

Is this a well-known problem

是的。任何时候你有一个 class 包含指针,或类似指针的数据,如迭代器,你必须实现自己的复制构造函数和赋值运算符,以确保新对象具有有效的 pointers/iterators.

and if so, does it have an elegant/idiomatic solution?

可能没有你喜欢的那么优雅,并且可能在性能上不是最好的(但是,副本有时不是,这就是 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;
    }

    //...
};

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

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

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

C++ copy/move ctor/assign 对于常规值类型是安全的。常规值类型的行为类似于整数或其他 "regular" 值。

它们对于指针语义类型也是安全的,只要操作不改变指针 "should" 指向的内容。指向某物 "within yourself" 或其他成员是失败的示例。

它们对于引用语义类型来说有些安全,但是在同一个 class 中混合 pointer/reference/value 语义在实践中往往是 unsafe/buggy/dangerous。

零规则是您使 classes 的行为类似于常规值类型或不需要在 copy/move 上重新定位的指针语义类型。那你就不用写 copy/move ctors.

迭代器遵循指针语义。

围绕这个的idiomatic/elegant是为了将迭代器容器与指向的容器紧密耦合,并在那里阻塞或写入复制ctor。一旦一个包含指向另一个的指针,它们就不是真正独立的东西。

Is this a well-known problem?

好吧,它是众所周知的,但我不会说众所周知。 兄弟指针不常出现,我在野外看到的大多数实现都以与你的完全相同的方式被破坏。

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

does it have an elegant/idiomatic solution?

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

  • 共享元素

让我们按顺序回顾一下。

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

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

嗯,Boost.MultiIndex 更进一步:

  1. 它使用传统的容器接口(即把你的对象移进去),但是对象移进去的节点是一个多钩子的元素
  2. 它在单个实体中使用多种hooks/containers

您可以检查 various examples and notably Example 5: Sequenced Indices 看起来很像您自己的代码。

“本质上不安全”

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

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

“魔术”默认行为

问题是您显然希望您可以相信默认的复制构造函数在诸如此类的重要情况下“做正确的事”。我不确定您为什么期望正确的行为——也许您希望记住经验法则(例如“3 法则”)将是确保您不会搬起石头砸自己脚的可靠方法?当然,那会是 nice(而且,正如另一个答案中所指出的,Rust 比其他低级语言更进一步地使脚射更难),但 C++ 根本不是专为那种“轻率”class 设计而设计,也不应该

构造函数行为的概念化

我不打算尝试解决这是否是一个“众所周知的问题”的问题,因为我真的不知道“姊妹”数据和迭代器存储问题的特征有多明显是。但我希望我能让你相信,如果你花时间为你写的每个 class 考虑复制构造函数行为,那么这不应该是一个 令人惊讶的 问题。

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

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

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

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

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 时,return 值 可能会被移动 ,但它也可能是复制构造的。对 foo 的赋值也会调用一个复制构造函数,尽管这也可能被省略。除非 useIters 通过引用获取其参数,否则您 在那里调用了一个复制构造函数。

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

然而,假设 std::vector 可以 像这样工作:假设它有一个特殊的重载“vector-of-iterators”可以重新安置迭代器,并且可以以某种方式“告诉”编译器仅在实际需要重新安置迭代器时调用此特殊构造函数。 (请注意,“仅在为包含 包含迭代器基础数据类型的实例的 class 生成默认构造函数时调用特殊重载”的解决方案不会不起作用;如果您的案例中的 std::vector 迭代器指向 不同的 标准集,并且被简单地视为 reference 到由其他 class 管理的数据?哎呀,编译器应该如何知道迭代器是否都指向 same 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?
    }
}

好的,所以我们希望每个新的、复制的迭代器被重新定位以引用[=30的不同实例=].但是这些信息从哪里来?请注意,复制构造函数不能将 new _Ctnr<T> 作为参数:那么它将不再是复制构造函数。在任何情况下,编译器如何知道要提供哪个 _Ctnr<T>? (还要注意,对于许多容器来说,为新容器找到“相应的迭代器”可能并不简单。)

使用 std:: 容器进行资源管理

这不仅仅是编译器没有做到或应该做到的“智能”问题。在这种情况下,作为程序员,您的头脑中有一个需要特定解决方案的特定设计。特别是,如上所述,您有两个资源,都是 std:: 容器。而且你们之间有关系。在这里,我们得到了大多数其他答案已经陈述的内容,到此为止应该非常非常清楚:related class 成员需要特别注意,因为 C++ 默认不管理这种耦合。 但我希望 清楚这一点是你不应该认为问题是具体出现的因为数据成员耦合;问题很简单,默认构造并不神奇,程序员在决定让隐式生成的构造函数处理复制之前必须了解正确复制 class 的要求。

优雅的解决方案

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

但是用户定义的复制构造函数优雅的;允许您编写它们 C++ 对编写正确的非平凡 classes.

问题的优雅解决方案

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

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

显式定义与删除

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

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

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

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

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

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

现在,考虑 class Foo:

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

马上,鉴于简单地复制 vector 的成员是行不通的,您可以看出默认构造函数将行不通。所以现在你需要决定你的 class 是否需要可复制。