【问题标题】:Should functions declared with `= default` only go in the header file使用 `= default` 声明的函数是否应该只放在头文件中
【发布时间】:2020-06-25 16:40:51
【问题描述】:

在定义类时,现在通常使用= default 进行析构函数/复制构造函数和复制赋值。查看我的代码库,这些几乎总是只在头文件中,但有些同事已将它们放在 .cpp 文件中。在这种情况下,最佳做法是什么?

当这些函数位于标头中并依赖链接器对它们进行重复数据删除时,编译器是否会多次生成这些函数。如果您有一个庞大的班级,是否只值得将它们放在.cpp 文件中?对于我们大部分是旧的 C++98 代码,什么都不做的函数通常也只定义在头文件中。什么都不做虚拟析构函数似乎经常被移到.cpp 文件中。对于需要其地址来填充虚拟方法表的虚拟方法来说,它是否(或曾经)在某种程度上很重要。

还建议将noexcept() 子句放在= default 函数上吗?编译器似乎是自己派生的,因此它仅在存在时用作 API 文档。

【问题讨论】:

  • 你的意思是把MyClass() = default;放在头文件的类定义中还是把MyClass::MyClass() = default作为cpp中的构造函数定义?
  • 前几天这个咬到我了:如果你有一个std::unique_ptr<forward_declared_class> 成员,并且想隐藏forward_declared_class,你需要将析构函数放在.cpp 中。否则,包含标题的任何人也将需要您要隐藏的类的定义。
  • @molbdnilo 具有唯一指针的 PIMPL 就是一个众所周知的例子。 eerorika 的答案应该得到更多关注,因为其他两个投票较多的答案都强烈推荐仅使用头文件的解决方案。
  • @DanielLangr imo 定义显式默认的特殊成员函数的微妙之处,特别是对于默认构造函数,除了它们的第一个声明之外,再加上对“统一初始化语法”S s{}; 故障安全的常见误解恕我直言,在众多代码库中为 UB 灾难提供了完美的配方,大多数开发人员不知道这些细节,并且“模式”可能会在不被理解的情况下迅速传播。对于 PIMPL 实现,即使显式默认构造函数非常适合 ...
  • ... 特定用例,可能值得考虑自己明确定义它,例如通过一个典型的语义上合理有序的成员初始化器列表。不过,有不同的答案很好,但对我来说,安全总是胜过聪明(不过,我确实在安全关键领域工作)。

标签: c++ c++11


【解决方案1】:

显式默认的函数不一定不是用户提供的

在这种情况下,最佳做法是什么?

根据经验,我建议您始终定义显式默认函数在他们的(第一次)声明;即,将 = default 放在(第一个)声明中,这意味着(在您的情况下)标题(特别是类定义),因为两者之间存在细微但本质上的差异。构造函数是否被认为是用户提供的

来自[dcl.fct.def.default]/5 [摘录,强调我的]:

[...] 如果函数是用户声明的并且没有在其第一次声明时显式默认或删除,则该函数是用户提供的。 [...]

因此:

struct A {
    A() = default; // NOT user-provided.
    int a;
};


struct B {
    B(); // user-provided.
    int b;
};

// A user-provided explicitly-defaulted constructor.
B::B() = default;

构造函数是否由用户提供反过来会影响初始化该类型对象的规则。特别是,类类型T,当值初始化时,如果T的默认构造函数不是用户-,将首先零初始化对象提供。因此,此保证适用于上述A,但不适用于B,并且具有(用户提供!)默认构造函数的对象的值初始化离开可能非常令人惊讶对象的数据成员处于未初始化状态。

引用from cppreference [摘录,强调我的]:

值初始化

在这些情况下执行值初始化:

  • [...]
  • (4) 当命名变量(自动、静态或线程本地)使用由一对大括号组成的初始化程序声明时。

值初始化的效果是:

  • (1) 如果T 是没有默认构造函数的类类型或具有用户提供的 或已删除的默认构造函数,则对象是默认的-初始化;

  • (2) 如果T 是一个类类型具有既不是用户提供也不是删除的默认构造函数(也就是说,它可能是一个具有隐式定义或默认默认值的类构造函数),对象初始化为零,然后默认初始化,如果它有一个非平凡的默认构造函数;

  • ...

让我们在上面的类类型AB 上应用它:

A a{};
// Empty brace direct-list-init:
// -> A has no user-provided constructor
// -> aggregate initialization
// -> data member 'a' is value-initialized
// -> data member 'a' is zero-initialized

B b{};
// Empty brace direct-list-init:
// -> B has a user-provided constructor
// -> value-initialization
// -> default-initialization
// -> the explicitly-defaulted constructor will
//    not initialize the data member 'b'
// -> data member 'b' is left in an unititialized state

a.a = b.b; // reading uninitialized b.b: UB!

因此,即使对于您最终不会自责的用例,也只是代码库中存在模式,其中未定义显式默认(特殊成员)函数在他们(第一次)声明时可能会导致其他开发人员在不知道这种模式的微妙之处的情况下,盲目地遵循它,随后反而自责。

【讨论】:

  • 点个赞。我不知道你会写B::B() = default;。我今天学到了一些东西!
  • 谢谢,我一直在修复我的代码。一个不起作用的情况是一个类包含一个用于前向声明的类的 unique_ptr。显然它需要大小才能创建析构函数。我不愿意将前向声明更改为#include,还是应该?我认为需要一些最小的析构函数来避免missingkeyfunction error
  • @okapi 这个主要用于初始化;与往常一样,还有其他特殊情况,例如间接习惯用法,例如 eerorika 的回答 (PIMPL) 中描述的,它要求将特殊成员函数的声明与其定义分开。不过,即使在这种情况下,我也不建议使用 = default 来定义 user-provided 默认构造函数的标题外定义(对于 PIMPL,我们通常有一个自定义的默认构造函数) .
【解决方案2】:

= default; 声明的函数应该放在头文件中,编译器会自动知道何时标记它们noexcept。我们实际上可以观察到这种行为,并证明它确实发生了。

假设我们有两个类,FooBar。第一个类Foo 包含一个int,第二个类Bar 包含一个字符串。这些是定义:

struct Foo {
    int x;
    Foo() = default;
    Foo(Foo const&) = default;
    Foo(Foo&&) = default;
};

struct Bar {
    std::string s;
    Bar() = default;
    Bar(Bar const&) = default;
    Bar(Bar&&) = default;
};

对于Foo,一切都是noexcept,因为创建、复制和移动整数是noexcept。另一方面,对于Bar,创建和移动字符串是noexcept,但复制构造不是因为它可能需要分配内存,如果没有更多内存可能会导致异常。

我们可以通过使用 noexcept 来检查一个函数是否为 noexcept:

std::cout << noexcept(Foo()) << '\n'; // Prints true, because `Foo()` is noexcept

让我们为FooBar 中的所有构造函数执行此操作:

// In C++, # will get a string representation of a macro argument
// So #x gets a string representation of x
#define IS_NOEXCEPT(x) \
  std::cout << "noexcept(" #x ") = \t" << noexcept(x) << '\n';
  
int main() {
    Foo f;
    IS_NOEXCEPT(Foo()); // Prints true
    IS_NOEXCEPT(Foo(f)) // Prints true
    IS_NOEXCEPT(Foo(std::move(f))); // Prints true
    
    Bar b;
    IS_NOEXCEPT(Bar()); // Prints true
    IS_NOEXCEPT(Bar(b)) // Copy constructor prints false
    IS_NOEXCEPT(Bar(std::move(b))); // Prints true
}

这告诉我们编译器会自动推断默认函数是否为noexcept。 You can run the code for yourself here

【讨论】:

    【解决方案3】:

    使用= default 声明的函数应该只放在头文件中

    通常,类定义是放置默认定义的理想位置。

    但是,有时这不是一种选择。特别是,如果类定义不能依赖于间接成员的定义。这种情况的一个例子是使用指向不透明类型的唯一指针来实现 PIMPL 模式。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2020-09-28
      • 1970-01-01
      • 2013-10-07
      • 2016-01-27
      • 2011-06-07
      • 1970-01-01
      相关资源
      最近更新 更多