在多个文件中共享全局常量(使用内联变量)

某些应用场景下,若干符号常量需要在整个代码中使用(不限于单一位置)。这些常量可以是永不改变的物理或数学常数(如 π 或阿伏伽德罗常数),也可以是应用专属的“调谐”值(如摩擦系数或重力系数)。与其在每个需要它们的文件中重复定义(违反“不要重复自己”原则),不如在单一中心位置声明,然后在任何需要的地方使用。如此,一旦需要修改,只需改动一处,即可传播到所有位置。

本课介绍实现这一目标的常见方法。

方法一:将全局常量设为内部变量(C++17 之前)

在 C++17 之前,最简单且最常用的方案如下:

  1. 创建一个头文件存放这些常量;
  2. 在该头文件中定义一个命名空间(参见《用户自定义命名空间与作用域解析运算符》);
  3. 将常量全部放在该命名空间内,并确保为 constexpr
  4. 任何需要使用常量的文件只需 #include 该头文件。

示例:

constants.h

#ifndef CONSTANTS_H
#define CONSTANTS_H

// 自定义命名空间用于存放常量
namespace constants
{
    // 全局常量默认具有内部链接
    constexpr double pi { 3.14159 };
    constexpr double avogadro { 6.0221413e23 };
    constexpr double myGravity { 9.2 }; // m/s² —— 该行星重力较小
    // ……其他相关常量
}
#endif

main.cpp

#include "constants.h" // 把每个常量复制到本文件
#include <iostream>

int main()
{
    std::cout << "Enter a radius: ";
    double radius{};
    std::cin >> radius;

    std::cout << "The circumference is: " << 2 * radius * constants::pi << '\n';

    return 0;
}

当此头文件被 #include.cpp 文件时,其中定义的变量将在包含点被复制到该源文件。由于这些变量位于函数外,它们被视为该文件内的全局变量,因此可在文件任意位置使用。

由于 const/constexpr 全局变量默认具有内部链接,每个 .cpp 文件都会获得一份独立的变量副本,链接器无法看到它们。在绝大多数情况下,编译器会直接将 constexpr 变量优化掉。

此方案简单(且对小项目足够),但存在以下问题:

  • 每当 constants.h 被不同源文件包含一次,这些变量就被复制一次。若被 20 个文件包含,每个变量便重复 20 次。
    头文件保护仅防止同一文件多次包含,无法阻止跨文件的一次包含。
  • 修改任一常量值需重新编译所有包含该头文件的源文件,大型项目重编译耗时。
  • 若常量体积较大且无法被优化消除,会占用大量内存。

优点:

  • C++17 之前即可使用;
  • 可在任何包含该头文件的翻译单元中用于常量表达式。

缺点:

  • 头文件改动需重编译所有包含者;
  • 每个翻译单元各持一份变量副本。

方法二:将全局常量设为外部变量

若需频繁修改或新增常量,上述方案可能不便。
另一种做法是把常量改为外部变量:在单一 .cpp 文件中定义(确保仅一份定义),在头文件中放置前向声明供其他文件使用。

constants.cpp

#include "constants.h"

namespace constants
{
    // 使用 extern 确保具有外部链接
    extern constexpr double pi { 3.14159 };
    extern constexpr double avogadro { 6.0221413e23 };
    extern constexpr double myGravity { 9.2 }; // m/s²
}

constants.h

#ifndef CONSTANTS_H
#define CONSTANTS_H

namespace constants
{
    // 由于实际变量位于命名空间内,前向声明亦需在命名空间内
    // 变量无法前向声明为 constexpr,可前向声明为(运行时)const
    extern const double pi;
    extern const double avogadro;
    extern const double myGravity;
}

#endif

使用方式不变:

main.cpp

#include "constants.h"
#include <iostream>

int main()
{
    std::cout << "Enter a radius: ";
    double radius{};
    std::cin >> radius;

    std::cout << "The circumference is: " << 2 * radius * constants::pi << '\n';

    return 0;
}

此时符号常量仅在 constants.cpp 实例化一次,所有使用均链接到该实例;修改 constants.cpp 只需重编译该文件。

然而,该方法存在以下缺点:

  • 只有定义所在文件(constants.cpp)内,这些常量才是 constexpr;其他文件仅看到前向声明,无法用于常量表达式。
  • 编译器无法进行与常量表达式同等程度的优化。

关键洞察
若需在编译期上下文中使用变量(如数组大小),编译器必须看到变量定义(而非仅前向声明)。
由于编译器以源文件为单位编译,.cpp 中的定义对编译 main.cpp 时不可见,因此 constexpr 变量无法拆分到头文件和源文件,必须定义于头文件。

鉴于上述缺点,推荐仍把常量定义在头文件中(见前一节或下一节)。若常量值频繁变动导致重编译耗时,可临时将相关常量移至 .cpp 文件(采用本方法)。

优点:

  • C++17 之前可用;
  • 每变量仅需一份实例;
  • 修改常量值仅重编译一个文件。

缺点:

  • 前向声明与定义需分处两文件并保持同步;
  • 变量在定义文件外不可用于常量表达式。

方法三:C++17 内联变量(推荐)

7.9 课《内联函数与变量》介绍过:内联变量允许存在多份定义,只要这些定义完全一致。
constexpr 变量标记为 inline,即可在头文件中定义,再 #include 到任意 .cpp,既避免 ODR 违规,又消除变量重复。

提醒
constexpr 函数隐式内联,但 constexpr 变量隐式内联;若要内联 constexpr 变量,须显式添加 inline

关键洞察
内联变量默认具有外部链接,以便链接器去重;
非内联 constexpr 变量具有内部链接,若被多文件包含,每个翻译单元各持副本,不违反 ODR(因对链接器不可见)。

constants.h

#ifndef CONSTANTS_H
#define CONSTANTS_H

// 自定义命名空间存放常量
namespace constants
{
    inline constexpr double pi { 3.14159 };      // 注意:inline constexpr
    inline constexpr double avogadro { 6.0221413e23 };
    inline constexpr double myGravity { 9.2 };   // m/s²
    // ……其他相关常量
}
#endif

main.cpp

#include "constants.h"
#include <iostream>

int main()
{
    std::cout << "Enter a radius: ";
    double radius{};
    std::cin >> radius;

    std::cout << "The circumference is: " << 2 * radius * constants::pi << '\n';

    return 0;
}

可随意将 constants.h 包含进任意数量的源文件,这些变量仅实例化一次并在所有文件间共享。

本方法仍保留“头文件改动需重编译所有包含者”的缺点。

优点:

  • 可在任何包含该头文件的翻译单元中用于常量表达式;
  • 每变量仅需一份实例。

缺点:

  • 仅 C++17 及以上支持;
  • 头文件改动需重编译所有包含者。

最佳实践

若需全局常量且编译器支持 C++17,首选在头文件中定义 inline constexpr 全局变量。

提醒
对于 constexpr 字符串,请使用 std::string_view(参见 5.8 课《std::string_view 简介》)。

相关阅读

各类变量的作用域、存储期与链接属性的完整总结,参见《作用域、存储期与链接小结》。

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

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

公众号二维码

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