C++ 类与头文件:声明与实现分离的最佳实践

迄今为止,我们编写的所有类都非常简单,因而都可以直接在类定义内部实现成员函数。例如,下面是一个简单的 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。虽然可行,但存在以下弊端:

  1. 类体内实现会淹没类定义;
  2. 头文件一旦改动,所有包含该头文件的源文件皆需重编译,可能引发连锁重编译;大型商业项目重编译时间可达数小时。
  3. 若仅修改源文件,则仅需重编译该文件。因此,在可选情况下,尽量将非短小代码置于源文件。

合理偏离最佳实践的几种情形

  1. 仅供单个源文件使用的小型类,可直接在该 .cpp 内定义类及全部实现,以表明其局部用途;若后续需复用,再拆分为独立头/源文件即可。
  2. 若类仅含极少量非短小成员函数且极少变动,为其单独创建 .cpp 可能得不偿失,此时可将其 inline 后置于头文件。
  3. 现代 C++ 中,一些库以“仅头文件”(header-only)形式发布,即将全部代码置于头文件,便于分发与使用。若有意创建 header-only 库,可将所有非短小成员函数 inline 后放在头文件。
  4. 模板类的模板成员函数,若定义在类外,几乎总是放在头文件内。与非成员模板函数类似,编译器需看到完整定义才能实例化。相关内容见第 15.5 课《带成员函数的类模板》。

作者注

后续课程中,多数示例将直接在单个 .cpp 文件内定义类,并将所有函数实现写在类体中,以保持示例简洁、易于自行编译。
在实际项目中,更常见的做法是将类拆分为头文件与源文件,读者应尽早养成此习惯。


成员函数的默认实参

第 11.5 课《默认实参》曾讨论非成员函数的最佳实践:“若函数有前置声明(尤其是位于头文件),则在声明处给出默认实参;否则在定义处给出。”

成员函数始终作为类定义的一部分声明(或定义),因此规则更简洁:始终将成员函数的默认实参写在类定义内


最佳实践

将成员函数的所有默认实参置于类定义内。


库的使用

在先前程序中,你已使用标准库中的类(如 std::string)。只需 #include 相应头文件(如 #include <string>)即可,而无需手动添加任何源文件(如 string.cpp 或 iostream.cpp)。

  • 头文件提供编译器所需之声明,以验证语法正确性;
  • 标准库类的实现位于预编译库,在链接阶段自动链接。你无需查看其源码。

众多开源软件会同时提供 .h 与 .cpp;而多数商业库仅提供 .h 及预编译库文件,原因有三:

  1. 链接预编译库远快于每次重编译;
  2. 一份预编译库可被多个程序共享,而源码编译后体积将膨胀至每个可执行文件;
  3. 知识产权考量——防止源码泄露。

附录将讨论如何把第三方预编译库纳入项目。

你虽暂时不会发布自己的库,但将类拆分为头文件与源文件不仅符合规范,也为日后创建自定义库奠定基础。自建库虽超出本教程范围,但“声明与实现分离”是发布预编译二进制库的先决条件。


小测验

问题 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) 非短小成员函数应始终在头文件中定义。

关注公众号,回复"cpp-tutorial"

可领取价值199元的C++学习资料

公众号二维码

扫描上方二维码或搜索"cpp-tutorial"