迄今为止,我们编写的所有类都非常简单,因而都可以直接在类定义内部实现成员函数。例如,下面是一个简单的 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;
}
然而,随着类规模扩大、复杂度提升,若所有成员函数仍写在类体内,将令类定义臃肿,难以维护。使用者只需了解类的公共接口(public 成员函数),而无需关注其实现细节;函数实现反而会淹没真正重要的接口信息。
为解决此问题,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); // 构造函数声明
void print() const; // print 函数声明
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) // 构造函数定义
: m_year{ year }
, m_month{ month }
, m_day{ day }
{
}
void Date::print() const // print 函数定义
{
std::cout << "Date(" << m_year << ", " << m_month << ", " << m_day << ")\n";
}
int main()
{
const Date d{ 2015, 10, 14 };
d.print();
return 0;
}
成员函数在类外定义的方式与非成员函数相同,唯需在函数名前加类名加作用域解析运算符 ::
(如此例中的 Date::
),以告知编译器该函数属于哪个类。
示例中,访问函数仍保留在类体内。由于这类函数通常仅有一行,放在类体内几乎不增加阅读负担;若移至类外,则会引入大量冗余行。因此,访问函数及其他极短小的函数常被保留在类体内部。
将类定义放入头文件
若仅在某个源文件(.cpp)中定义类,则该类只能在该文件内使用。大型项目往往需要在多个源文件间复用类。
在《头文件》中已介绍:可将函数声明放入头文件,再通过 #include
引入到多个代码文件(甚至多个项目)。类亦遵循同样原则:完整类定义可置于头文件,供任何需要该类类型的文件 #include
。
与函数不同,使用类时编译器通常需要看到完整定义(而非仅前向声明),因为编译器必须了解成员声明及对象大小,以便正确实例化。因此,头文件中通常存放类的完整定义,而非仅前向声明。
类头文件与源文件的命名惯例
通常,类定义存放于与其同名的头文件(.h 或 .hpp),而类外定义的成员函数则置于同名源文件(.cpp)。
以下示例将 Date 类拆分为 .h 与 .cpp:
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"
#include <iostream>
Date::Date(int year, int month, int day)
: m_year{ year }
, m_month{ month }
, m_day{ day }
{
}
void Date::print() const
{
std::cout << "Date(" << m_year << ", " << m_month << ", " << m_day << ")\n";
}
任何需要使用 Date 类的头文件或源文件只需 #include "Date.h"
。注意,还需将 Date.cpp 编译进项目,以便链接器将成员函数调用与其实现关联。
最佳实践
- 将类定义置于同名头文件;
- 把极短小的成员函数(如访问函数、空体构造函数等)保留在类体内;
- 将非短小成员函数定义于同名源文件。
在头文件定义类是否违反一次定义规则(ODR)?
类型定义豁免于 ODR “每个程序仅一次定义” 的限制。因此,多次 #include
类定义不会造成问题,否则类将难以复用。
然而,在同一个翻译单元内重复包含同一类定义仍属 ODR 违规。头文件防护(header guards)或 #pragma once
可防止此情况。
内联成员函数
成员函数并不豁免 ODR。若成员函数定义在头文件中,并被多个翻译单元 #include
,需确保不违反 ODR。
- 类体内定义的成员函数隐式内联(inline),因而可在多个翻译单元中安全出现;
- 类体外定义的成员函数不隐式内联,故通常置于源文件(确保整个程序只有一份定义);
- 也可将类体外定义的成员函数显式标记为
inline
,使其仍保留在头文件。
示例如下,将 Date.h 中的构造函数与 print() 显式设为内联:
Date.h:
#ifndef DATE_H
#define DATE_H
#include <iostream>
class Date
{
// ...(类体同前,略)...
};
inline Date::Date(int year, int month, int day)
: m_year{ year }
, m_month{ month }
, m_day{ day }
{
}
inline void Date::print() const
{
std::cout << "Date(" << m_year << ", " << m_month << ", " << m_day << ")\n";
}
#endif
如此即可在多个翻译单元中安全 #include
该头文件。
关键洞见
- 类体内定义的成员函数隐式内联,可在多处包含而不违 ODR;
- 类体外定义的成员函数不隐式内联,可显式加
inline
关键字。
内联展开与成员函数
编译器必须看到函数完整定义才能进行内联展开。
- 若希望成员函数可供内联,却又不想放在类体内,可在类定义之后(同一头文件内)以
inline
定义之。 - 如此任何包含该头文件者均可访问函数定义。
为何不把所有代码放入头文件?
有人倾向于将所有成员函数定义放在头文件——无论置于类体内,还是类外 inline
。虽然可行,但存在以下弊端:
- 类体内实现会淹没类定义;
- 头文件一旦改动,所有包含该头文件的源文件皆需重编译,可能引发连锁重编译;大型商业项目重编译时间可达数小时。
- 若仅修改源文件,则仅需重编译该文件。因此,在可选情况下,尽量将非短小代码置于源文件。
合理偏离最佳实践的几种情形
- 仅供单个源文件使用的小型类,可直接在该 .cpp 内定义类及全部实现,以表明其局部用途;若后续需复用,再拆分为独立头/源文件即可。
- 若类仅含极少量非短小成员函数且极少变动,为其单独创建 .cpp 可能得不偿失,此时可将其
inline
后置于头文件。 - 现代 C++ 中,一些库以“仅头文件”(header-only)形式发布,即将全部代码置于头文件,便于分发与使用。若有意创建 header-only 库,可将所有非短小成员函数
inline
后放在头文件。 - 模板类的模板成员函数,若定义在类外,几乎总是放在头文件内。与非成员模板函数类似,编译器需看到完整定义才能实例化。相关内容见第 15.5 课《带成员函数的类模板》。
作者注
后续课程中,多数示例将直接在单个 .cpp 文件内定义类,并将所有函数实现写在类体中,以保持示例简洁、易于自行编译。
在实际项目中,更常见的做法是将类拆分为头文件与源文件,读者应尽早养成此习惯。
成员函数的默认实参
第 11.5 课《默认实参》曾讨论非成员函数的最佳实践:“若函数有前置声明(尤其是位于头文件),则在声明处给出默认实参;否则在定义处给出。”
成员函数始终作为类定义的一部分声明(或定义),因此规则更简洁:始终将成员函数的默认实参写在类定义内。
最佳实践
将成员函数的所有默认实参置于类定义内。
库的使用
在先前程序中,你已使用标准库中的类(如 std::string
)。只需 #include
相应头文件(如 #include <string>
)即可,而无需手动添加任何源文件(如 string.cpp 或 iostream.cpp)。
- 头文件提供编译器所需之声明,以验证语法正确性;
- 标准库类的实现位于预编译库,在链接阶段自动链接。你无需查看其源码。
众多开源软件会同时提供 .h 与 .cpp;而多数商业库仅提供 .h 及预编译库文件,原因有三:
- 链接预编译库远快于每次重编译;
- 一份预编译库可被多个程序共享,而源码编译后体积将膨胀至每个可执行文件;
- 知识产权考量——防止源码泄露。
附录将讨论如何把第三方预编译库纳入项目。
你虽暂时不会发布自己的库,但将类拆分为头文件与源文件不仅符合规范,也为日后创建自定义库奠定基础。自建库虽超出本教程范围,但“声明与实现分离”是发布预编译二进制库的先决条件。
小测验
问题 1
(感谢读者 “learnccp lesson reviewer” 提供题目)
将成员函数定义在类外的目的是什么?
a) 使类定义更短,更易管理。
b) 将公共接口与实现细节分离。
c) 当定义于源文件时,可在实现细节变更时最小化重编译范围。
d) 以上皆是。
问题 2
如何在类外定义成员函数?
a) 像普通函数一样直接定义,无需类前缀。
b) 用作用域解析运算符 ::
加类名前缀定义函数。
c) 在类内声明,在类外用 friend
关键字定义。
d) 以上皆否。
问题 3
何时应将短小的成员函数定义在类内?
a) 总是,以提高性能。
b) 当函数只有一行代码时。
c) 当函数被频繁调用时。
d) 不建议在类内定义任何成员函数。
问题 4
为了便于在多个文件或项目中复用,类定义应置于何处?
a) 与类同名的 .cpp 文件。
b) 与类同名的独立头文件。
c) 包含头文件的 .cpp 文件。
d) 代码任意位置,只要函数在类外定义即可。
问题 5
关于类与成员函数的一次定义规则(ODR),下列哪项正确?
a) 禁止在头文件中定义类。
b) 允许在同一文件内多次包含类定义。
c) 类体内定义的成员函数豁免于 ODR。
d) 非短小成员函数应始终在头文件中定义。