C++默认定义是在源文件或头文件中?

5

在《Effective C++:Item 30:理解内联的里里外外》中,Scott Meyer指出,构造函数和析构函数通常不是内联的最佳选择。

在类定义中定义函数会隐式地(而不是显式地)请求将其作为内联。根据编译器的质量,编译器会决定是否实际将定义的函数作为内联函数(无论是显式还是隐式)。

综合考虑所有这些因素,将空/复制/移动构造函数、复制/移动赋值运算符和析构函数明确定义为默认函数(即使用default关键字)是否比在头文件中更好的做法?毕竟,default仅涉及实现,而非双重delete


1
定义一个非内联的函数毫无意义。 - n. m.
1
现代编译器甚至可以内联函数,即使您在.cpp文件中定义它们。您必须相信您的编译器!话虽如此,我认为单独定义= default函数是不寻常的。 - Bo Persson
@Pavel - 这只是一个定义问题。 LTCG选项实际上在链接之后(或期间)调用编译器,因此仍然是编译器生成内联代码。 当我说“现代编译器”时,我可能指的是“C ++语言的现代实现”。 :-) - Bo Persson
1
“明确定义空/复制/移动构造函数是否是更好的实践?” 如果不是空的,你的问题是什么? - n. m.
2
关于术语的一个要点:当您在类定义内部定义任何成员函数时,成员函数隐式地成为内联函数。成为内联函数有两个后果:首先,可以在多个翻译单元中定义相同的函数,前提是定义“相同”;其次,它请求编译器在原地扩展函数(即将其内联)。后者就是名称的由来,但现在它并不特别重要。尽管如此,这样的函数内联函数,即使它没有被内联。 - Pete Becker
显示剩余4条评论
2个回答

9

即使从未阅读过《Effective C++:条款30》,我也可以明确地告诉您,在 .cpp 内定义看起来为空的构造函数/析构函数是完全有道理的:

// MyClass.h:
class MyClass
{
public:
    MyClass();
    ~MyClass();

    ...
}

// MyClass.cpp:
MyClass::MyClass() = default;
MyClass::~MyClass() = default;

这可能看起来会浪费数字墨水,但这确实是对于拥有大量继承列表或非常规成员的重型类所必须采取的方式。
我为什么认为必须这样做呢?
因为如果不这样做,在每个创建或删除 MyClass 的翻译单元中,编译器都将不得不发出关于整个类层次结构的内联代码,以创建/删除所有成员和/或基类。在大型项目中,这通常是导致构建耗时的主要原因之一。
举个例子,比较生成的汇编与非内联 ctor/dtor没有内联。注意如果你有多级继承和虚拟类,那么生成代码的数量会非常快速地增长,一些人称其为 C++ 代码膨胀。
基本上,如果你的类中有内联函数,并且你在 N 个不同的 cpp 文件中使用了该函数(或更糟糕的是在某些被许多其他 cpp 文件使用的头文件中使用了该函数),那么编译器将不得不在 N 个不同的目标文件中发出该代码,并在链接时将所有这些 N 个副本合并成一个版本。这个规则基本上适用于任何其他函数,然而,通常不会在头文件中将大型函数内联(因为这很糟糕)。构造函数、析构函数和默认赋值运算符等的问题在于它们可能看起来像是空的或没有 C++ 代码,但实际上它们需要对所有成员和基类递归地执行相同的操作,而所有这些都会导致生成大量的代码。

那么这肯定也适用于复制/移动构造函数和赋值运算符吗? - Matthias
@Matthias 是的,如果你在许多其他cpp文件中复制某个类的某个对象,那么编译器将不得不多次发出该代码(这需要时间来编译和生成),然后需要时间来链接和合并重复的代码。 - Pavel P
你可能需要在定义头文件成员(这样可以在编译时进行自然优化,而无需使用LTO)和使用LTO/LTCG之间做出选择,尤其是在大型项目中,LTO/LTCG也可能需要很长时间。个人而言,我会在头文件中定义许多简单的函数(特别是像抽象模板/特性这样的琐碎事情,您自然希望进行内联、复制省略等),并将大型实现放在CPP文件中,仅公开高级接口(没有跨对象文件的低级互操作);不使用LTO。 - maxbc
1
@maxbc 正确。但是构造函数/析构函数、默认赋值等等远非微不足道,尽管它们看起来像是无操作,这就是重点。 - Pavel P

4

在源文件中定义一个= default析构函数的另一种用途是与std::unique_ptr结合使用的PImpl惯用法

头文件: example.hpp

#include <memory>

// Example::Impl is an incomplete type.

class Example
{
public:
    Example();
    ~Example();
private:
    struct Impl;
    std::unique_ptr< Impl > impl_ptr;
};

源文件: example.cpp

#include "example.hpp"

struct Example::Impl
{
    ...
};

// Example::Impl is a complete type.

Example::Example()
   : impl_ptr(std::make_unique< Impl >())
{}

Example::~Example() = default; // Raw pointer in std::unique_ptr< Impl > points to a complete type so static_assert in its default deleter will not fail.

在代码中销毁std::unique_ptr<Impl>的位置,Example::Impl必须是一个完整的类型。因此,在头文件中隐式或显式定义Example::~Example将无法编译。
类似的论点也适用于移动赋值运算符(因为编译器生成的版本需要销毁原始的Example::Impl)和移动构造函数(因为编译器生成的版本需要在出现异常时销毁原始的Example::Impl)。

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