到目前为止,我们编写的所有类都足够简单,可以直接在类定义内部实现成员函数。例如,下面是一个简单的 Date
类,其中所有成员函数都定义在 Date
类定义内部:
#include <iostream>
class Date
{
private:
int m_year{};
int m_month{};
int m_day{};
public:
Date(int year, int month, int day)
: m_year { year }
, m_month { month }
, m_day { day}
{
}
void print() const { std::cout << "Date(" << m_year << ", " << m_month << ", " << m_day << ")\n"; }
int getYear() const { return m_year; }
int getMonth() const { return m_month; }
int getDay() const { return m_day; }
};
int main()
{
Date d { 2015, 10, 14 };
d.print();
return 0;
}
然而,随着类变得越来越长、越来越复杂,将所有成员函数定义放在类内部会使类更难管理和使用。使用一个已编写的类只需要理解其公共接口(公共成员函数),而不需要理解类的内部工作原理。成员函数实现会将公共接口与实际使用类不相关的细节混淆。
为了解决这个问题,C++ 允许我们通过在类定义之外定义成员函数来将类的“声明”部分与“实现”部分分开。
下面是与上面相同的 Date
类,其构造函数和 print()
成员函数在类定义之外定义。请注意,这些成员函数的原型仍然存在于类定义内部(因为这些函数需要作为类类型定义的一部分进行声明),但实际实现已移到外部:
#include <iostream>
class Date
{
private:
int m_year{};
int m_month{};
int m_day{};
public:
Date(int year, int month, int day); // constructor declaration
void print() const; // print function declaration
int getYear() const { return m_year; }
int getMonth() const { return m_month; }
int getDay() const { return m_day; }
};
Date::Date(int year, int month, int day) // constructor definition
: m_year{ year }
, m_month{ month }
, m_day{ day }
{
}
void Date::print() const // print function definition
{
std::cout << "Date(" << m_year << ", " << m_month << ", " << m_day << ")\n";
};
int main()
{
const Date d{ 2015, 10, 14 };
d.print();
return 0;
}
成员函数可以在类定义之外定义,就像非成员函数一样。唯一的区别是,我们必须在成员函数名称前加上类类型名称(在本例中为 Date::
),以便编译器知道我们正在定义该类类型的成员,而不是非成员。
请注意,我们将访问函数保留在类定义内部。由于访问函数通常只有一行,因此在类定义内部定义这些函数只会增加极少的混乱,而将它们移到类定义之外会导致许多额外的代码行。因此,访问函数(以及其他琐碎的单行函数)的定义通常保留在类定义内部。
将类定义放入头文件中
如果在源 (.cpp) 文件中定义类,则该类只能在该特定源文件内部使用。在大型程序中,我们通常希望在多个源文件中使用我们编写的类。
在第2.11课——头文件中,您了解到可以将函数声明放在头文件中。然后您可以将这些函数声明 #include
到多个代码文件(甚至多个项目)中。类也是如此。类定义可以放在头文件中,然后 #include
到任何需要使用类类型的其他文件中。
与函数不同,函数只需要前向声明即可使用,编译器通常需要看到类的完整定义(或任何程序定义的类型)才能使用该类型。这是因为编译器需要了解成员是如何声明的,以确保它们得到正确使用,并且需要能够计算该类型对象的大小才能实例化它们。因此,我们的头文件通常包含类的完整定义,而不仅仅是类的前向声明。
命名类头文件和代码文件
通常,类定义在与类同名的头文件中,并且任何在类外部定义的成员函数都放在与类同名的 .cpp 文件中。
这是我们的 Date 类,再次拆分为 .cpp 和 .h 文件
Date.h
#ifndef DATE_H
#define DATE_H
class Date
{
private:
int m_year{};
int m_month{};
int m_day{};
public:
Date(int year, int month, int day);
void print() const;
int getYear() const { return m_year; }
int getMonth() const { return m_month; }
int getDay() const { return m_day; }
};
#endif
Date.cpp
#include "Date.h"
Date::Date(int year, int month, int day) // constructor definition
: m_year{ year }
, m_month{ month }
, m_day{ day }
{
}
void Date::print() const // print function definition
{
std::cout << "Date(" << m_year << ", " << m_month << ", " << m_day << ")\n";
};
现在,任何其他想要使用 Date
类的头文件或代码文件都可以简单地 #include "Date.h"
。请注意,Date.cpp 也需要编译到任何使用 Date.h 的项目中,以便链接器可以将对成员函数的调用连接到它们的定义。
最佳实践
最好将您的类定义放在与类同名的头文件中。琐碎的成员函数(例如访问函数、空函数体的构造函数等)可以在类定义内部定义。
最好在与类同名的源文件中定义非琐碎的成员函数。
如果在头文件中定义类,并且头文件被 #include
多次,这是否会违反单定义规则?
类型不受单定义规则 (ODR) 中“每个程序只能有一个定义”部分的影响。因此,将类定义 #include
到多个翻译单元中不存在问题。如果存在问题,类将不会有太大用处。
在单个翻译单元中多次包含类定义仍然是 ODR 违规。然而,头文件保护(或 #pragma once
)将防止这种情况发生。
内联成员函数
成员函数不受 ODR 的豁免,因此您可能想知道当成员函数定义在头文件中(然后可能包含到多个翻译单元中)时,我们如何避免 ODR 违规。
在类定义内部定义的成员函数是隐式内联的。内联函数不受单定义规则中“每个程序只能有一个定义”部分的豁免。
在类定义外部定义的成员函数不是隐式内联的(因此受单定义规则中“每个程序只能有一个定义”部分的影响)。这就是为什么此类函数通常在代码文件中定义(在那里它们在整个程序中只有一个定义)。
或者,如果在类定义之外定义的成员函数是内联的(使用 inline
关键字),它们可以保留在头文件中。这是我们的 Date.h 头文件,其中在类外部定义的成员函数标记为 inline
:
Date.h
#ifndef DATE_H
#define DATE_H
#include <iostream>
class Date
{
private:
int m_year{};
int m_month{};
int m_day{};
public:
Date(int year, int month, int day);
void print() const;
int getYear() const { return m_year; }
int getMonth() const { return m_month; }
int getDay() const { return m_day; }
};
inline Date::Date(int year, int month, int day) // now inline
: m_year{ year }
, m_month{ month }
, m_day{ day }
{
}
inline void Date::print() const // now inline
{
std::cout << "Date(" << m_year << ", " << m_month << ", " << m_day << ")\n";
};
#endif
此 Date.h 可以包含到多个翻译单元中而不会出现问题。
关键见解
在类定义内部定义的函数是隐式内联的,这允许它们 #include
到多个代码文件中而不会违反 ODR。
在类定义外部定义的函数不是隐式内联的。它们可以通过使用 inline
关键字使其内联。
成员函数的内联展开
编译器必须能够看到函数的完整定义才能执行内联展开。通常,此类函数(例如访问函数)在类定义内部定义。但是,如果您想在类定义之外定义成员函数,但仍希望它有资格进行内联展开,则可以在类定义下方(在同一个头文件中)将其定义为内联函数。这样,函数的定义就可以供任何 #include
该头文件的人访问。
那么为什么不把所有东西都放在一个头文件中呢?
您可能会忍不住将所有成员函数定义放入头文件中,无论是在类定义内部,还是作为内联函数在类定义下方。虽然这可以编译,但这样做有几个缺点。
首先,如上所述,在类定义内部定义成员会使您的类定义变得混乱。
其次,如果您更改头文件中的任何代码,那么您需要重新编译所有包含该头文件的文件。这可能会产生连锁反应,一个小的更改会导致整个程序需要重新编译。重新编译的成本可能差异很大:一个小型项目可能只需一分钟或更少时间即可构建,而一个大型商业项目可能需要数小时。
相反,如果您更改 .cpp 文件中的代码,则只需重新编译该 .cpp 文件。因此,在有选择的情况下,通常最好在可能的情况下将非琐碎的代码放在 .cpp 文件中。
在某些情况下,违反将类定义放在头文件中并将非琐碎成员函数放在代码文件中的最佳实践可能是有意义的。
首先,对于仅在一个代码文件中使用且不打算重复使用的小类,您可能更喜欢直接在使用它的单个 .cpp 文件中定义类(以及所有成员函数)。这有助于明确该类仅在该单个文件内使用,而不打算更广泛地使用。如果您后来发现您想在多个文件中使用它,或者发现类和成员函数定义正在使您的源文件变得混乱,您总是可以将该类稍后移到单独的头文件/代码文件中。
其次,如果一个类只有少量非琐碎的成员函数,并且这些函数不太可能更改,那么创建一个只包含一两个定义的 .cpp 文件可能不值得(因为它会使您的项目变得混乱)。在这种情况下,最好将成员函数设置为 inline
并将其放在头文件中类定义的下方。
第三,在现代 C++ 中,类或库越来越多地以“仅头文件”的形式分发,这意味着类或库的所有代码都放在一个头文件中。这样做主要是为了使这些文件的分发和使用更容易,因为头文件只需要 #include
,而代码文件需要明确添加到使用它的每个项目中,以便它可以被编译。如果有意为分发创建仅头文件的类或库,所有非琐碎的成员函数都可以设置为 inline
并放置在头文件中类定义的下方。
最后,对于模板类,在类外部定义的模板成员函数几乎总是定义在头文件中,位于类定义下方。就像非成员模板函数一样,编译器需要看到完整的模板定义才能实例化它。我们在第15.5课——带成员函数的类模板中介绍了模板成员函数。
作者注
在未来的课程中,我们的大多数类将定义在单个 .cpp 文件中,所有函数都直接在类定义中实现。这样做是为了使示例简洁明了,易于自行编译。在实际项目中,类更常见地放在自己的代码和头文件中,您应该习惯这样做。
成员函数的默认参数
在第11.5课——默认参数中,我们讨论了非成员函数默认参数的最佳实践:“如果函数有前向声明(尤其是在头文件中),请将默认参数放在那里。否则,将默认参数放在函数定义中。”
因为成员函数总是作为类定义的一部分声明(或定义),所以成员函数的最佳实践实际上更简单:始终将默认参数放在类定义内部。
最佳实践
将成员函数的任何默认参数放在类定义内部。
库
在您的程序中,您一直使用属于标准库的类,例如 std::string
。要使用这些类,您只需 #include
相关头文件(例如 #include <string>
)。请注意,您不需要将任何代码文件(例如 string.cpp
或 iostream.cpp
)添加到您的项目中。
头文件提供了编译器验证您编写的程序在语法上是否正确所需的声明。然而,属于 C++ 标准库的类的实现包含在预编译文件中,该文件在链接阶段自动链接。您永远看不到代码。
许多开源软件包都提供 .h 和 .cpp 文件供您编译到程序中。但是,大多数商业库只提供 .h 文件和预编译的库文件。这有几个原因:1) 链接预编译库比每次需要时重新编译它更快;2) 预编译库的一个副本可以被许多应用程序共享,而编译后的代码会编译到使用它的每个可执行文件中(增加文件大小);3) 知识产权原因(您不希望人们窃取您的代码)。
我们将在附录中讨论如何将第三方预编译库包含到您的项目中。
虽然您可能暂时不会创建和分发自己的库,但将类分成头文件和源文件不仅是一种好习惯,它还使创建自己的自定义库变得更容易。创建自己的库超出了这些教程的范围,但是如果您想分发预编译的二进制文件,分离声明和实现是这样做的先决条件。
小测验时间
问题 #1
鸣谢读者“learnccp lesson reviewer”提供这些测验问题。
在类定义之外定义成员函数的目的是什么?
a) 使类定义更短,更易于管理。
b) 将公共接口与实现细节分离。
c) 当在源文件中定义时,在实现细节更改时最大限度地减少重新编译时间。
d) 以上所有。
如何在类定义之外定义成员函数?
a) 像普通函数一样定义函数,不带任何类前缀。
b) 使用作用域解析运算符 (::) 将类名作为前缀定义函数。
c) 在类定义内部声明函数,并使用 friend 关键字在外部定义它。
d) 以上都不是。
何时应在类定义内部定义琐碎的成员函数?
a) 总是如此,以提高性能。
b) 当函数只有一行代码时。
c) 当函数经常被调用时。
d) 不建议在类定义内部定义任何成员函数。
为了方便在多个文件或项目中重用,类定义应该放在哪里?
a) 在与类同名的 .cpp 文件中。
b) 在与类同名的单独头文件中。
c) 在包含头文件的 .cpp 文件中。
d) 代码中的任何位置,只要函数在类之外定义即可。
以下关于类和成员函数单定义规则的说法哪项是正确的?
a) 它禁止在头文件中定义类。
b) 它允许在同一个文件中多次包含类定义。
c) 在类定义内部定义的成员函数不受单定义规则的限制。
d) 非琐碎的成员函数应始终在头文件中定义。