15-2 类与头文件

迄今为止我们编写的类都足够简单,能够直接在类定义内部实现成员函数。例如,下面这个简单的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;
}

image

然而,随着类变得越来越长且复杂,将所有成员函数定义都放在类内部会使类更难管理和使用。使用一个已编写的类只需理解其公共接口(即公共成员函数),而无需了解类的底层工作原理。成员函数的实现会用与实际使用类无关的细节充斥公共接口。

为解决此问题,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;
}

image

成员函数可以像非成员函数一样在类定义外部定义。唯一的区别在于,我们必须在成员函数名前添加类型的名称(本例中为 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 的项目中,以便链接器可以将对成员函数的调用连接到其定义。

最佳实践:
建议将类定义放在与类同名的头文件中。非实质性成员函数(如访问函数、空体构造函数等)可直接在类定义内部定义。

建议将非实质性成员函数定义在与类同名的源文件中。


在头文件中定义类是否会违反单定义规则?如果该头文件被多次包含的话。

类型不受单定义规则(ODR)中“每个程序只能有一个定义”部分的限制。因此,将类定义包含到多个翻译单元中并不存在问题。若存在此限制,类将失去实用价值。

在单个翻译单元中重复包含类定义仍会违反ODR规则。不过,头文件保护机制(或#pragma once指令)能有效防止这种情况发生。


内联成员函数

成员函数同样受单定义规则约束,因此您可能疑惑:当成员函数定义在头文件中(该头文件可能被多个翻译单元包含)时,如何避免违反单定义规则?

在类定义内部定义的成员函数默认具有内联属性。内联函数不受单定义规则中“每个程序仅允许一个定义”部分的约束。

在类定义外部定义的成员函数不具有默认内联属性(因此受单定义规则约束)。这正是此类函数通常定义在代码文件中的原因(在整个程序中仅存在一个定义)。

另一种方案是:若将类外部定义的成员函数标记为内联(使用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 关键字将它们内联。


成员函数的内联展开

编译器必须能够看到函数的完整定义才能执行内联展开。通常此类函数(如访问函数)定义在类定义内部。但若需在类定义外部定义成员函数,同时仍希望其具备内联展开资格,可将其作为内联函数定义在类定义正下方(位于同一头文件中)。如此便能使该函数的定义对所有包含该头文件的程序可见。


那么为什么不把所有内容都放在头文件里呢?

你可能会想把所有成员函数定义都放在头文件里,要么放在类定义内部,要么作为内联函数放在类定义下方。虽然这样能编译通过,但存在几个弊端。

首先,如前所述,在类定义内部定义成员会使类定义变得杂乱无章。

其次,若修改头文件中的任何代码,所有包含该头文件的文件都需重新编译。这可能引发连锁反应——一个微小改动导致整个程序需要重新编译。重新编译的成本差异巨大:小型项目可能只需一分钟甚至更短时间,而大型商业项目则可能耗时数小时。

相反地,若修改.cpp文件中的代码,仅需重新编译该文件。因此在可选情况下,通常应将非简单代码置于.cpp文件中。

在某些特殊场景下,打破“类定义置于头文件、非简单成员函数置于代码文件”的最佳实践可能合理:

首先,对于仅在单个代码文件中使用且不作通用复用的微型类,可直接在使用该类的.cpp文件中定义类本体及所有成员函数。此举能明确该类仅限于单文件内部使用,不作广泛复用。若后续发现需在多个文件中使用该类,或觉得类定义与成员函数定义使源文件过于杂乱,随时可将类移至独立的头文件/代码文件中。

其次,若类仅包含少量不太可能变更的非平凡成员函数,创建仅含一两个定义的.cpp文件可能得不偿失(因其会增加项目杂乱度)。此类情况下,更优方案是将成员函数声明为inline,并置于头文件的类定义下方。

第三,现代C++中,类或库正日益采用“仅头文件”模式分发,即所有代码均置于头文件中。此设计主要为简化文件分发与使用流程——头文件仅需通过#include包含,而代码文件则需显式添加至每个使用项目中才能编译。若刻意创建头文件类库用于分发,所有非平凡成员函数均可内联并置于头文件的类定义下方。

最后需注意:对于模板类,定义在类外部的模板成员函数几乎总是在头文件中、类定义下方进行定义。与非成员模板函数相同,编译器需要看到完整的模板定义才能进行实例化。模板成员函数将在第15.5课——带成员函数的类模板中详细讲解。

作者注
后续课程中,多数类将定义于单个.cpp文件中,所有函数直接在类定义内实现。此举旨在保持示例简洁且便于读者自行编译。实际项目中,类通常分别存放于代码文件和头文件中,建议读者逐步适应这种规范。


成员函数的默认参数

在第 11.5 课——默认参数中,我们讨论了非成员函数默认参数的最佳实践:“如果函数有前向声明(尤其是头文件中的声明),则将默认参数放在那里。否则,将默认参数放在函数定义中。”

由于成员函数始终被声明(或定义)为类定义的一部分,因此成员函数的最佳实践实际上更简单:始终将默认参数放在类定义中。

最佳实践:
将成员函数的任何默认参数放在类定义中。


库文件

在编程过程中,你使用了标准库中的类,例如std::string。要使用这些类,只需包含相应的头文件(如#include )。请注意,你无需在项目中添加任何代码文件(如string.cpp或iostream.cpp)。

头文件提供了编译器验证程序语法正确性所需的声明。然而,C++标准库中类实现的具体代码被封装在预编译文件中,该文件会在链接阶段自动关联。你永远不会看到这些代码。

许多开源软件包同时提供.h和.cpp文件供您编译使用。但大多数商业库仅提供.h文件和预编译库文件。这种做法有几个原因:1)链接预编译库比每次使用时重新编译更快;2)单份预编译库可供多个应用程序共享,而编译后的代码会被编译进每个使用它的可执行文件(导致文件体积膨胀);3)知识产权考量(防止他人窃取代码)。

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

虽然短期内您可能不会创建并分发自有库,但将类拆分为头文件和源文件不仅符合规范,更能为后续创建自定义库奠定基础。本教程虽不涉及库创建,但若需分发预编译二进制文件,声明与实现的分离实为必要前提。


问题 #1

感谢读者“learnccp 课程复习者”提供这些测验题。

在类定义外部定义成员函数的目的是什么?

a) 使类定义更简洁且易于管理。
b) 将公共接口与实现细节分离。
c) 当定义在源文件中时,可最大限度减少实现细节变更时的重新编译时间。
d) 以上皆是。

显示答案

d) 以上全部。

如何在类定义外部定义成员函数?

a) 直接将函数定义为普通函数,不添加类前缀。
b) 使用作用域解析运算符(::)在函数前添加类名。
c) 在类定义内部声明函数,外部使用friend关键字进行定义。
d) 以上皆非。

显示答案

b) 使用作用域解析运算符(::)在函数前添加类名作为前缀来定义该函数。

何时应在类定义内部定义简单成员函数?

a) 始终如此,以提升性能。
b) 当函数仅含一行代码时。
c) 当函数被频繁调用时。
d) 不建议在类定义内部定义任何成员函数。

显示答案

b) 当函数仅包含一行代码时。

为便于在多个文件或项目中复用,类定义应放置于何处?

a) 与类同名的.cpp文件中
b) 与类同名的独立头文件中
c) 包含该头文件的.cpp文件中
d) 代码任意位置(只要函数在类外部定义即可)

显示答案

b) 在与类同名的单独头文件中。

关于类与成员函数的单定义规则,下列哪项陈述正确?

a) 该规则禁止在头文件中定义类。
b) 允许在同一文件中多次包含类定义。
c) 类定义内部定义的成员函数不受单定义规则约束。
d) 非平凡成员函数应始终在头文件中定义。

显示答案

c). 在类定义内部定义的成员函数默认具有内联特性,因此不受单定义规则的限制。
posted @ 2026-01-01 18:12  游翔  阅读(35)  评论(0)    收藏  举报