类和头文件
本节阅读量:到目前为止,我们编写的类都非常简单,因此可以直接在类定义中实现成员函数。例如,下面是一个简单的Date类,其中所有成员函数都在Date类定义中实现:
|
|
然而,随着类变得越来越长、越来越复杂,把所有成员函数定义都放在类中,会让类更难管理和使用。使用已经编写好的类时,通常只需要理解其public接口(公共成员函数),而不需要了解类内部的实现细节。将成员函数实现放在类定义中,会让公共接口和实际使用类时无关的实现细节混在一起。
为了解决这个问题,C++允许在类定义之外定义成员函数,从而将类的“声明”部分与“实现”部分分开。
下面仍然是同一个Date类,只是构造函数和 print() 成员函数定义在类定义之外。请注意,这些成员函数的原型仍然保留在类定义中(因为这些函数需要作为类类型定义的一部分进行声明),但实际实现已经移到外部:
|
|
成员函数可以像非成员函数一样在类定义之外定义。唯一的区别是,必须用类类型的名称(在本例中为Date::)作为成员函数名的前缀,以便编译器知道我们定义的是该类类型的成员,而不是一个非成员函数。
请注意,这里仍然把访问函数的定义留在类定义中。由于访问函数通常只有一行,在类定义内定义它们造成的干扰很小,而将它们移到类定义外会增加许多额外的代码行。基于这个原因,访问函数(以及其他琐碎的单行函数)的定义通常会保留在类定义中。
将类定义放在头文件中
如果在源(.cpp)文件中定义类,则该类只能在这个特定源文件中使用。在较大的程序中,我们通常希望在多个源文件中使用同一个类。
我们知道可以将函数声明放在头文件中,然后把这些函数声明#include到多个代码文件(甚至多个项目)中。类也不例外。类定义可以放在头文件中,然后在任何需要使用该类类型的文件中#include这个头文件。
与只需要前向声明即可调用的函数不同,编译器通常需要看到类(或任何程序定义类型)的完整定义,才能使用该类型。这是因为编译器需要了解成员的声明,以确保它们被正确使用,并且需要计算该类型对象的大小,以便实例化对象。因此,头文件通常包含类的完整定义,而不仅仅是类的前向声明。
命名类的头文件和代码文件
通常,类会定义在与类同名的头文件中,而在类外定义的成员函数会放在与类同名的.cpp文件中。
下面是拆分为.cpp和.h文件后的Date类:
date.h:
|
|
date.cpp:
|
|
现在,任何想要使用Date类的头文件或代码文件都可以直接#include “Date.h”。请注意,date.cpp也需要被编译进任何使用date.h的项目中,这样链接器才能将成员函数调用连接到对应的定义。
最佳实践
优先将类定义放在与类同名的头文件中。琐碎的成员函数(例如访问函数、具有空函数体的构造函数等)可以在类定义中定义。
首选在与类同名的源文件中定义非平凡的成员函数。
如果头文件被#included多次,那么在头文件中定义类是否违反了单定义规则?
类型定义可以出现在多个翻译单元中,只要每个定义完全一致即可。因此,将类定义包含到多个翻译单元中没有问题。
但是,如果同一个类定义在单个翻译单元中被包含多次,仍然会造成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相关头文件(如#include <string>)。请注意,您不需要将任何代码文件(如string.cpp或iostream.cpp)添加到项目中。
头文件提供编译器所需的声明,用于验证您编写的程序在语法上是否正确。然而,C++标准库中类的实现包含在预编译文件中,并会在链接阶段自动链接。你通常看不到这些实现代码。
许多开源软件包会同时提供.h和.cpp文件,供您编译到程序中。然而,大多数商业库只提供.h文件和预编译库文件。这有几个原因:1)链接预编译库比每次使用时重新编译更快;2)预编译库的单个副本可以被许多应用程序共享,而编译后的代码会被编译进每个使用它的可执行文件中;3)出于知识产权考虑(您不希望别人窃取您的代码)。
虽然您可能暂时不会创建和分发自己的库,但将类拆分为头文件和源文件不仅是一种良好的代码组织方式,也会让以后创建自定义库更容易。创建自己的库超出了本教程的范围,但如果您希望分发预编译的二进制文件,分离声明和实现就是前提。