unique_ptr,pimpl/前向声明和完整定义

16

我已经查看了这里这里的问题,但仍然无法找出错误所在。

这是调用代码:

#include "lib.h"

using namespace lib;

int
main(const int argc, const char *argv[]) 
{
    return 0;
}

这是库的代码:

#ifndef lib_h
#define lib_h

#include <string>
#include <vector>
#include <memory>

namespace lib
{

class Foo_impl;

class Foo
{
    public:
        Foo();
        ~Foo();

    private:
        Foo(const Foo&);
        Foo& operator=(const Foo&);

        std::unique_ptr<Foo_impl> m_impl = nullptr;

        friend class Foo_impl;
};

} // namespace

#endif

clang++ 给了我这个错误:

'sizeof' 在不完整的类型 'lib::Foo_impl' 上的无效应用
注意:请求成员函数'std :: default_delete ::operator()'的实例化

你可以看到我已经特别声明了 Foo 析构函数。我还错过了什么?


1
这似乎与NSDMI有关... - dyp
2
如果你把 std::unique_ptr<Foo_impl> m_impl = nullptr; 改为 std::unique_ptr<Foo_impl> m_impl; 会发生什么呢?我认为这样做是可行的。 - cageman
1
我认为这里发生了类似的问题:http://coliru.stacked-crooked.com/a/984df6900bd1ba8b 标准似乎对于即使被忽略,NSDMI是否必须有效存在存在一些模糊。 - dyp
1
如果没有人很快提供一个合理的答案,我将会发送一封邮件到std-discussion。编译器的作者似乎都同意这种行为,但我找不到它在哪里或是否有规定。 - dyp
@dyp 听起来很合理。我也发现标准在这方面非常模糊。我找不到一个好的参考,无论编译器是否正确拒绝了 Op 的示例。 - ComicSansMS
显示剩余5条评论
4个回答

12

在实例化std::unique_ptr<Foo_impl> m_impl = nullptr之前,必须完整实现Foo_impl

保留类型声明(但不初始化)可以修复错误(std::unique_ptr<Foo_impl> m_impl;),然后需要在代码中稍后初始化它。

您看到的错误来自于用于测试此问题的技术的实现;不完整类型。基本上,对于仅在前向声明时(即在代码/编译中使用时缺少定义)的类型,sizeof将导致错误。

可能的修复方法如下:

class Foo_impl;

class Foo
{
  // redacted
  public:
    Foo();
    ~Foo();

  private:
    Foo(const Foo&);
    Foo& operator=(const Foo&);

    std::unique_ptr<Foo_impl> m_impl;// = nullptr;
};

class Foo_impl {
  // ...
};

Foo::Foo() : m_impl(nullptr)
{
}

为什么需要完整类型?

通过= nullptr实例化使用复制初始化,需要声明构造函数和析构函数(对于unique_ptr<Foo_impl>)。析构函数需要unique_ptr的删除器函数,该函数默认调用delete来释放指向Foo_impl的指针,因此需要Foo_impl的析构函数,并且Foo_impl的析构函数在不完整类型中未声明(编译器不知道它的样子)。请参见Howard's answer

关键是在不完整类型上调用delete会导致未定义的行为(§ 5.3.5/5),因此在unique_ptr的实现中明确检查了这种情况。

另一种解决此问题的替代方法可能是使用直接初始化,如下所示:

std::unique_ptr<Foo_impl> m_impl { nullptr };

关于非静态数据成员初始化器(NSDMI)是否需要存在成员定义的情况,似乎存在一些争议。至少对于clang(可能也适用于gcc),这似乎是需要成员定义存在的情况。


2
这是一个正确的答案。unique_ptr在实例化时(更准确地说,在调用删除器的时候)必须知道类型,但不需要在声明时就知道。通过在头文件中执行= nullptr,声明和实例化的点都移到了封闭类型未知的位置。 - ComicSansMS
1
@leemes,=nullptr需要构造函数和析构函数,而析构函数需要清理函数(默认情况下需要 Foo_impl 的析构函数,但不在不完整类型中(编译器不知道它的样子)。 - Niall
1
@Niall 我没有看到标准说 = nullptr 立即执行或需要做什么。在 [class.base.init]/8 中,NSDMI 与初始化相关联。但是我认为措辞表明这种初始化发生在构造函数内部。构造函数的定义在类定义之外,应该在 Foo_impl 完成之后。 - dyp
2
@Niall 我们的讨论有些误解。我特别想知道在头文件中使用 ... impl = nullptr 和在构造函数定义中使用 impl(nullptr) 之间的区别是什么?为什么它会将要求“移动”到头文件中?直觉上,除了构造函数之外,没有人应该关心头文件中的 ...=...。我原以为它只是构造函数初始化列表中 ...(...) 的语法糖。但显然它做得更多。 - leemes
1
@leemes,区别似乎在于Bar的默认构造函数是否平凡。在你的示例1中,如果在Bar的定义中添加一个非平凡的构造函数,则会生成错误 - Casey
显示剩余21条评论

9

这个声明:

std::unique_ptr<Foo_impl> m_impl = nullptr;

调用复制初始化。它的语义与以下内容相同:

std::unique_ptr<Foo_impl> m_impl = std::unique_ptr<Foo_impl>(nullptr);

即,它构造了一个临时的prvalue。这个临时的prvalue必须被销毁。那个析构函数需要看到Foo_impl的完整类型。即使prvalue和移动构造被省略了,编译器也必须表现得“好像”执行了。

相反,您可以使用直接初始化,此时unique_ptr的析构函数将不再需要:

std::unique_ptr<Foo_impl> m_impl{nullptr};

更新

Casey 指出,目前gcc-4.9即使使用直接初始化的形式也会实例化~unique_ptr()。然而在我的测试中,clang并没有这样做。我不知道其他编译器会怎么做。我相信在这方面,至少考虑了最新的核心缺陷报告,clang是符合规范的。


析构函数应该不是必需的,但至少g++会实例化它 - Casey
@Casey:你是在说第二种(直接初始化)形式吗?啊,我看了你的链接,原来是这样。谢谢。 - Howard Hinnant
很好。终于有了深入的解释。所以,直接初始化是(或应该是)“旧方法”(即成员初始化列表)的等价物?那么,为什么 C++11 的特性——能够直接在类定义中初始化成员——通常会使用复制初始化的代码片段进行广告宣传呢?(这是不相关的问题,我只是在想...) - leemes
@leemes:我最好的猜测是我们(社区)仍在学习如何正确使用这个功能。 - Howard Hinnant
2
如果通过异常退出Foo的构造,难道不需要dtor吗?(在堆栈展开期间销毁已构造的子对象) - dyp

4

替换

std::unique_ptr<Foo_impl> m_impl = nullptr;

使用

std::unique_ptr<Foo_impl> m_impl;

修复错误。


@dyp g++(GCC)4.9.0 20140604(预发布版) - 5gon12eder
是的,我指的是修复。我没有意识到它有歧义。 - 5gon12eder
@5gon12eder 哦,好的。我想知道 NSDMI 的问题是什么。将特殊成员函数(如默认构造函数)外部化的想法是在 Foo_impl 完成后定义它们。 - dyp
6
我在这个答案中缺少一个解释:为什么 = nullptr 要求类型是完整的?如果您添加它,我会给您加1分。 - leemes
@leemes:这超出了我的能力范围 :-( - Jarod42
显示剩余3条评论

2

N3936 [temp.inst]/2 状态:

除非类模板或成员模板已经被显式实例化或显式专门化,否则当特化在需要成员定义存在的上下文中引用特化时,会隐式实例化成员的特化; 特别地,静态数据成员的初始化(及任何相关的副作用)不会发生,除非静态数据成员本身以需要静态数据成员的定义存在的方式使用。

因此,这个问题实际上归结为具有非静态数据成员初始化器(NSDMI)的声明是否构成“需要该成员定义存在”的上下文,关于该成员类型的析构函数。虽然明确的是,类型的构造函数的声明立即需要确定NSDMI是否是适当类型来初始化该成员,但我认为只有外围类型的构造函数/析构函数需要构造函数/析构函数的定义,并且实现是不符合规范的。

话虽如此,目前正在由核心语言组审查有关NSDMI语义的几个问题:

因此,这里的混淆并不令人惊讶。


我甚至不确定是否需要适当声明构造函数。这就是我的模板示例的目的所在:即使未使用NSDMI,当前仍会出现错误,并且我不确定它是否应该是一个错误。 - dyp
@dyp 即使是非常特殊的情况,NSDMI 仍然是一个初始化器。因此,所有 8.5 要求仍然适用,并且必须适当地诊断,无论 NSDMI 是否实际上被 ODR-used。 - Casey
只要我所知,NSDMI 的语义在[class.base.init]/8中描述,该节然后引用了8.5。但这似乎发生在构造函数内部,即 NSDMI 是 mem-initializer 的语法糖(等同于)。 - dyp
@dyp 8.5/1说:“在8.5的其余部分描述的初始化过程也适用于由其他语法上下文指定的初始化,例如使用参数表达式初始化函数参数(5.2.2)或返回值的初始化(6.6.3)。”,因此它隐含地适用。从8.5中排除NSDMI需要明确的例外。 - Casey
我并不是在说NSDMIs不遵循8.5规则,我想说的是它们根据[class.base.init]/8进行操作。显然,初始化是在调用构造函数时执行的;现在的问题是,检查是何时执行的?8.5规则何时适用?是直接应用于定义点还是在构造函数内部? - dyp
@dyp 这不是关于何时何地的问题,标准并不关心时间和地点,它只描述符合规范的程序的语法和语义形式。包含声明 int i = "bob"; 的程序是不良形式的,并且需要进行诊断,无论它是全局变量、局部变量还是非静态数据成员的声明。 - Casey

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