标准是否规定副本必须相等?

10
假设我有一种奇怪的字符串类型,它要么拥有其底层缓冲区,要么不拥有:
class WeirdString {
private:
    char* buffer;
    size_t length;
    size_t capacity;
    bool owns;

public:
    // Non-owning constructor
    WeirdString(char* buffer, size_t length, size_t capacity)
        : buffer(buffer), length(length), capacity(capacity), owns(false)
    { }

    // Make an owning copy
    WeirdString(WeirdString const& rhs)
        : buffer(new char[rhs.capacity])
        , length(rhs.length)
        , capacity(rhs.capacity)
        , owns(true)
    {
        memcpy(buffer, rhs.buffer, length);
    }

    ~WeirdString() {
        if (owns) delete [] buffer;
    }
};

那个复制构造函数在标准中是否有违规之处?考虑如下:

WeirdString get(); // this returns non-owning string
const auto s = WeirdString(get());

s是拥有或非拥有的,这取决于额外的复制构造函数是否被省略,而在C++14及之前,这是允许但是可选的(尽管在C++17中是保证的)。这种薛定谔所有权模型表明,这个复制构造函数本身是未定义行为。

它是吗?


一个更形象的例子可能是:

struct X {
    int i;

    X(int i)
      : i(i)
    { }

    X(X const& rhs)
      : i(rhs.i + 1)
    { }        ~~~~
};

X getX();
const auto x = X(getX());

根据省略的副本,x.i 可能比 getX() 返回的值多0、1或2。标准文件有关于此的规定吗?


4
从 C++ 的角度来看,薛定谔的猫只是处于未指定状态。仅仅因为你不知道从一组明确定义的可能状态中具体是哪一个状态,并不会导致未定义行为。 - MSalters
3
f() + g()表达式中,不确定是先调用f还是g,这本身并不足以声明该表达式具有未定义的行为。当然可能存在一种情况,即g某种程度上依赖于由f产生的副作用,在没有副作用的情况下会出现未定义的行为。你所面临的情况类似:复制构造函数可能会被省略,从而导致得到拥有或非拥有实例——这本身不会引发未定义的行为;但是可能存在某些进一步的操作依赖于实例处于特定状态,并且在状态不符时出现问题。 - Igor Tandetnik
@IgorTandetnik 是的,但我们明确说明了这种情况将是未定义行为。我觉得奇怪的是似乎没有关于复制构造函数应该做什么的措辞。 - Barry
@IgorTandetnik 首先,我希望它至少能指定这两个实例应该是等价的,即使它对于这意味着什么含糊其辞。 - Barry
2
那有什么好处呢?如果标准无法准确说明要求,那么程序员就无法验证他们的代码是否符合该要求。无论如何,为什么特别是复制构造函数的行为会让你如此困扰呢?标准描述的非确定性抽象机器是非确定性的 - 这可以很容易地被除了复制省略之外的其他事物触发。 - Igor Tandetnik
显示剩余9条评论
2个回答

5

关于新问题的代码

struct X {
    int i;

    X(int i)
      : i(i)
    { }

    X(X const& rhs)
      : i(rhs.i + 1)
    { }        ~~~~
};

X getX();
const auto x = X(getX());

在这里,拷贝构造函数没有进行拷贝操作,因此你打破了编译器假设它会进行拷贝操作的前提。

在C++17中,我相信您可以保证在上述示例中不会调用拷贝构造函数。但是我手头没有C++17的草案。

在C++14及更早版本中,对于调用getX时是否调用拷贝构造函数以及是否调用拷贝初始化的拷贝构造函数取决于编译器。

C++14 §12.8/31 class.copy/31

当满足某些条件时,即使所选用的构造函数和/或对象的析构函数具有副作用,实现也允许省略类对象的复制/移动构造。

这不是“未定义行为”的意义上的未定义行为,即可能会调用鼻子恶魔。对于正式术语,我会选择“未指定行为”,因为它取决于实现并且不需要记录在文档中。但是,在我看来,选择什么名称并不重要:重要的是标准只是说在指定条件下,编译器可以优化掉复制/移动构造,而不管被优化掉的构造函数的副作用 - 因此您不能且不应该依赖它们。


关于 C++17,请参阅P0135,该标准已被WP投票通过(最新版本为N4618,您可以在谷歌上搜索)。 - Columbo

4

关于问题中有关类X的部分是在此答案之后添加的。它本质上不同,因为X的复制构造函数不会复制。因此我已经单独回答了这个问题。(请参考)

关于原问题中的WeirdString:这是您的类,因此标准对其没有任何要求。

但是,标准允许编译器假定复制构造函数仅复制,不做其他事情

幸运的是,您的复制构造函数确实如此,但如果(我知道这不适用于您,但如果)它主要产生其他影响,并且您依赖于该影响,则复制省略规则可能会破坏您的期望。

当您想要拥有一个保证的实例时(例如为了将其传递给线程),您可以简单地提供一个unshare成员函数、带有标签参数的构造函数或工厂函数。

通常不能依赖于复制构造函数被调用。


为避免问题,最好处理所有可能的复制,这意味着还要处理复制赋值运算符operator=

否则,您可能会冒着两个或更多实例都认为自己拥有缓冲区并负责释放的风险。

通过定义移动构造函数并声明或定义移动赋值运算符,还支持移动语义是个好主意。

通过使用std::unique_ptr<char[]>来保存缓冲区指针,可以更加确保所有这些操作的正确性。

其他事项中,这也防止了通过复制赋值运算符无意中进行复制。


std::string 是不相关的,如果我需要一个拥有字符串的所有权,那么复制的省略将会造成严重破坏。问题具体在于是否对复制构造函数有任何要求才能使复制省略有效。 - Barry
抱歉出现了一点二进制反转。嗯,我能理解你想要一个保证拥有实例来传递给线程的需求。其中一种方法是提供一个unshare成员函数,或者带有标签参数的构造函数,或者一个工厂函数。 - Cheers and hth. - Alf
你介意只删除第一个hr之后的所有内容吗?它与问题无关且分散注意力。这个问题不是关于如何正确实现字符串的,而是关于拥有一个不完全复制构造函数的影响(你答案的前半部分提到了这一点)。 - Barry
@Barry:好的,我猜错了你做这个的原因。所以,删除中间部分。我认为最后一个关于总体复制控制的部分仍然相关,不是吗? - Cheers and hth. - Alf

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