类和头文件
本节阅读量:到目前为止,编写的所有类都非常简单,以至于能够直接在类定义本身内实现成员函数。例如,下面是一个简单的Date类,其中所有成员函数都在Date类定义中进行实现:
|
|
然而,随着类变得越来越长、越来越复杂,在类中包含所有成员函数定义可能会使类更难管理和使用。使用已经编写的类只需要理解其public接口(公共成员函数),而不需要理解类在引擎盖下的工作方式。成员函数放在类定义中实现,将公共接口与实际使用类无关的细节弄得乱七八糟。
为了帮助解决这个问题,C++允许在类定义之外定义成员函数来将类的“声明”部分与“实现”部分分开。
这里是与上面相同的Date类,构造函数和 print() 成员函数定义在类定义之外。请注意,这些成员函数的原型仍然存在于类定义中(因为这些函数需要声明为类类型定义的一部分),但实际实现已移到外部:
|
|
成员函数可以像非成员函数一样在类定义之外定义。唯一的区别是,必须用类类型的名称(在本例中为Date::)作为成员函数名的前缀,以便编译器知道我们定义的是该类类型的成员,而不是非成员。
请注意,这里将定义的访问函数留在类定义中。由于访问函数通常只有一行,因此在类定义内定义这些函数混淆很小,而将它们移到类定义外将导致许多额外的代码行。由于这个原因,访问函数(和其他琐碎的单行函数)的定义通常留在类定义中。
将类定义放在头文件中
如果在源(.cpp)文件中定义类,则该类仅在该特定源文件中可用。在较大的程序中,通常希望使用在多个源文件中编写的类。
我们知道可以将函数声明放在头文件中。然后可以将这些函数声明#include到多个代码文件(甚至多个项目)中。类也不例外。类定义可以放在头文件中,然后#include在要使用类类型的任何其他文件中。
与只需要使用前向声明的函数不同,编译器通常需要查看类(或任何程序定义的类型)的完整定义,才能使用类型。这是因为编译器需要理解如何声明成员,以确保正确使用它们,并且它需要能够计算该类型的对象的大小,以便实例化它们。因此,头文件通常包含类的完整定义,而不仅仅是类的前向声明。
命名类的头文件和代码文件
通常,类在与类同名的头文件中定义,在类之外定义的任何成员函数都放在与类相同名称的.cpp文件中。
这里是我们的Date类,分为.cpp和.h文件:
date.h:
|
|
date.cpp:
|
|
现在,任何其他想要使用Date类的头文件或代码文件都可以简单地#include “Date.h” 。请注意,date.cpp还需要编译到任何使用date.h的项目中,以便链接器可以将对成员函数的调用连接到其定义。
最佳实践
优先将类定义放在与类同名的头文件中。琐碎的成员函数(例如访问函数、具有空函数体的构造函数等)可以在类定义中定义。
首选在与类同名的源文件中定义非平凡的成员函数。
如果头文件被#included多次,那么在头文件中定义类是否违反了单定义规则?
类型不受单定义规则(ODR)的限制。因此,不存在将类定义包含到多个翻译单元中的问题。
将类定义多次包含到单个翻译单元中仍然是ODR冲突。需要使用头文件保护(或#pragma once)防止这种情况发生。
内联成员函数
成员函数不能免除ODR,因此您可能想知道,当成员函数在头文件中定义时(然后将其包含在多个翻译单元中),如何避免ODR冲突。
在类定义内定义的成员函数是隐式内联的。内联函数不受单定义规则的约束。
在类定义之外定义的成员函数不是隐式内联的(因此受制于单定义规则)。这就是为什么这些函数通常在代码文件中定义(在整个程序中它们只有一个定义)。
或者,如果在类定义之外定义的成员函数是内联的(使用inline关键字),则可以将它们保留在头文件中。这是样例的date.h头文件,其中在类外部定义的成员函数标记为内联:
data.h:
|
|
此date.h可以包含在多个翻译单元中,而不会出现问题。
关键点
在类定义中定义的函数是隐式内联的,这允许将它们包含在多个代码文件中,而不会违反ODR。
在类定义外部定义的函数不是隐式内联的。可以通过使用inline关键字进行内联。
成员函数的内联扩展
编译器必须能够看到函数的完整定义,才能执行内联扩展。通常,这样的函数(例如访问函数)在类定义中定义。然而,如果您希望在类定义之外定义成员函数,但仍然希望它符合内联扩展的条件,则可以将其定义为类定义下面的内联函数(在相同的头文件中)。这样,任何包含这个头文件的人都可以访问函数的定义。
那么,为什么不将所有内容都放在头文件中呢?
您可能会试图将所有成员函数定义放入头文件中,要么在类定义内,要么作为类定义下的内联函数。虽然这能通过编译,但这样做有几个缺点。
首先,如上所述,在类定义中定义成员会使类定义混乱。
其次,如果您更改了头文件中的任何代码,则需要重新编译包含该头文件的每个文件。这可能会产生连锁反应,其中一个微小的更改会导致整个程序需要重新编译。重新编译的成本可能相差很大:一个小项目可能只需要一分钟或更少的时间来构建,而一个大型商业项目可能需要几个小时。
相反,如果更改.cpp文件中的代码,则只需要重新编译该.cpp文件。因此,如果可以选择,通常最好将非平凡的代码放在.cpp文件中。
在一些情况下,将所有内容放在单个文件中可能是有意义的。
首先,对于仅在一个代码文件中使用且不打算进行一般重用的小类,您可能更喜欢直接在使用它的单个.cpp文件中定义类(和所有成员函数)。这有助于明确类仅在该单个文件中使用,而不是用于更广泛的用途。如果以后发现要在多个文件中使用该类,或者发现类和成员函数定义使源文件混乱,则始终可以将该类移动到单独的头/代码文件中。
其次,如果类只有少量不太可能更改的成员函数,则创建仅包含一个或两个定义的.cpp文件可能不值得(因为它会使项目混乱)。在这种情况下,最好使成员函数内联,并将它们放在头文件中的类定义下。
第三,在现代C++中,类或库越来越多地作为“头文件”分发,这意味着类或库的所有代码都放在头文件中。这样做主要是为了使分发和使用这样的文件更容易,因为头文件只需要被#include,而代码文件需要显式添加到使用它的每个项目中才能编译它。如果有意为分发创建仅含头文件的类或库,则所有非平凡的成员函数都可以内联并放在类定义的头文件中。
最后,对于模板类,在类外部定义的模板成员函数几乎总是在头文件中的类定义下定义。就像非成员模板函数一样,编译器需要查看完整的模板定义才能实例化它。我们将在后续介绍这样的情况。
注
在以后的课程中,我们的大多数类将在单个.cpp文件中定义,所有函数都直接在类定义中实现。这样做是为了保持示例简洁,并且易于自己编译。在实际项目中,将类放在它们自己的代码和头文件中要常见得多,您应该习惯这样做。
成员函数的默认参数
在学习默认参数时,我们讨论了非成员函数的默认参数的最佳实践:“如果函数具有前向声明(特别是头文件中的声明),请将默认参数放在那里。否则,将默认参数放到函数定义中。”
由于成员函数总是作为类定义的一部分声明(或定义),因此成员函数的最佳实践实际上更简单:始终将默认参数放在类定义中。
最佳实践
将成员函数的任何默认参数放在类定义内。
库
在编写程序时,通常都使用了标准库的一部分类,如std::string。要使用这些类,只需#include相关的头文件(如#incluse <string>)。请注意,您不需要将任何代码文件(如string.cpp或iostream.cpp)添加到项目中。
头文件提供编译器所需的声明,以验证您正在编写的程序的语法正确性。然而,属于C++标准库的类的实现包含在预编译文件中,该文件在链接阶段自动链接。你永远看不到代码。
许多开源软件包同时提供.h和.cpp文件,供您编译到程序中。然而,大多数商业库仅提供.h文件和预编译的库文件。这有几个原因:1)链接预编译库比每次需要时重新编译它更快,2)预编译库的单个副本可以由许多应用程序共享,而编译的代码被编译到使用它的每个可执行文件中,以及3)知识产权原因(您不希望人们窃取您的代码)。
虽然您可能暂时不会创建和分发自己的库,但将类分离为头文件和源文件不仅是一种良好的形式,它还使创建自己的自定义库变得更容易。创建自己的库超出了本教程的范围,但如果您希望分发预编译的二进制文件,则分离声明和实现是这样做的前提。
