本篇回顾 Namespaces,特别常用的一个 C++ 特性。

Name Conflicts

随着项目模块变多,类型自然也会变多,此时就会遇到一个问题:多个模块间的类型出现重名。这就是名称碰撞问题,现有的策略主要分为两种思路。

第一种策略是从结构入手,为模块划分边界,作用域、命名空间、封装就属此类。这种策略需要更改程序的结构,增加新的语法。

第二种策略是从语义入手,建立共识,命名约定、增加前后缀就属此类。这种策略无需更改程序的结构,基于现有的语法就可以实现。

从效果上来看,第一种策略更加方便,能够更有逻辑地组织代码,协作开发大型项目,封装内部细节,支持带修饰的名称决议。

C++ Namespaces 就属于第一种策略,除了解决名称冲突,还可以用来划分程序的逻辑,控制 API 的表现形式。要编写一个成熟的大型项目,这属于必须掌握的特性之一。

Review of Namespaces

C++ 存在三种形式的 Namesapces:Named Namespaces、Unnamed Namespaces 和 Nested Namespaces。示例:

// Named Namespaces
namespace A {}                        // C++11
inline namespace B {}                 // C++11
inline namespace [[deprecated]] C {}  // C++17

// Unnamed Namespaces
namespace {}                          // C++11
inline namespace {}                   // C++11
inline namespace [[deprecated]] {}    // C++17

// Nested Namespaces
namespace A::B {}                     // C++17
namespace A::inline B::C {}           // C++17
namespace A::B::inline C::inline D {} // C++20

发展历程从 C++11 到 C++20,最常用的是 Named Namespaces,这也是大家最为熟悉的。

还有一种特殊的形式称为 Global Namespaces,无须声明,也不属于 Unnamed Namespaces,以 :: 显式访问。通过不需要使用,某些情况下可能在不同的作用域间产生了命名冲突,通过 :: 可以明确指定访问的是最外层的实体,从而避免名称碰撞。

一个 Namespace 就是一个逻辑单元,将代码组织到正确的逻辑单元,可以提高项目的模块化程度和可读性,增强项目的可伸缩性,降低管理大型项目 APIs 的难度。

Unnamed Namespaces

Unnamed namespaces,也叫 Anonymous namespaces,是命名空间三种形式的一种。这种形式可以省略命名空间的名称,如:

namespace { /* .. . */ }

在语义上与等价于:

namespace unique_name { /* ... */ }
using namespace unique_name;

编译器会自动生成一个唯一的名称,并使用 using-directives 自动导入名称。

与其他形式的命名空间不同,Unnamed namespaces 的链接方式是 Internal Linkage,标准描述为:

An unnamed namespace or a namespace declared directly or indirectly within an unnamed namespace has internal linkage. All other namespaces have external linkage.

注意,Unnamed namespaces 里面的所有内容都是 Internal Linkage,即便是用 extern

Global static 的作用也是 Internal Linkage,Unnamed namespaces 可以作为它的一种替代方式。主要是 static 所赋予的意义太多,有十几种用法,而 Unnamed namespaces 是一种专门用于控制全局可见性的方式。

C++ Core Guidelines [SF.22] 推荐优先使用 Unnamed namespaces,内容如下:

Nothing external can depend on an entity in a nested unnamed namespace. Consider putting every definition in an implementation source file in an unnamed namespace unless that is defining an “external/exported” entity.

/*-----Bad------*/ 
static int f();
int g();
static bool h();
int k();
/*-----Good-----*/
namespace {
int f();
bool h();
}
int g();
int k();

LLVM Coding Standards 也推荐使用 Unnamed namespaces,它更加通用,不仅支持变量、函数,也支持类型(static 不能用在 struct/class 前面)。但是,它也有一个问题,就是当代码过长时,很难看出一个随机位置的实体(变量/函数/类型)是否处于 Unnamed namespaces 当中,必须扫描文件中的大部分内容。因此,LLVM 推荐尽量将 Unnamed namespaces 的范围缩小,仅在类声明时使用。

Unnamed namespaces 当然也有其他问题,比如无法在空间之外特化模板:

#include <iostream>

namespace {
    template<typename T>
    void func(T t) {
        std::cout << "Generic template\n";
    }
}

// This is not allowed, as it tries to specialize a template from an anonymous namespace
template<>
void func<double>(double t) {
    std::cout << "Specialized template for double outside anonymous namespace\n";
}

int main() {
    func(42);    // Uses generic template
    func(3.14);  // Uses generic template
}

这也是为何又引入了 inline namespace,否则的话在这种情境下又必须使用 static

此外,Google C++ Style Guide 不建议在头文件使用 Unnamed namespaces,描述如下:

Use of internal linkage in .cc files is encouraged for all code that does not need to be referenced elsewhere. Do not use internal linkage in .h files.

那么在头文件中使用有何隐患呢?

首先,可能会违背 ODR。在同一 TU 中,多个 Unnamed namespaces 使用的是同一名称。这意味着以下示例中,a 被定义了两次,一个源文件中引用该头文件将导致 ODR 错误。

// file.h
namespace {
    int a;
}

namespace {
    int a;
}

其次,可能会产生奇怪的结果。例如:

// tu_one.h
namespace {
    int val = 42;
}

// cause unexpected results
inline int get_value() { return val; }

// tu_one.cpp
#include "tu_one.h"
#include <iostream>

void print_tu_one() {
    std::cout << "tu_one value: " <<  get_value() << "\n";
    val = 100;
}

// tu_two.cpp
#include "tu_one.h"
#include <iostream>

void print_tu_two() {
    std::cout << "tu_two value: " << get_value() << "\n";
    val = 200;
}

int main() {
    extern void print_tu_one();

    print_tu_one();
    print_tu_two();
    print_tu_one();
    print_tu_two();
}

一共有两个 TUs,val 处于 Unnamed namespace,属于 Internal Linkage,而 get_value() 属于 External Linkage。两个 TUs 共用同一份 get_value() 函数,而 val 是各有各的,返回结果时将产生不可预料的结果。

测试的结果如下:

tu_one value: 42
tu_two value: 100
tu_one value: 100
tu_two value: 100

最后,还可能会导致代码膨胀。包含 Internal Linkage 的每个 TUs 都会有一份拷贝,致使可执行文件膨胀。

可见,主要原因集中于 Unnamed namespaces 的反直觉行为,放到头文件中易于出错。但万事无绝对,只要能够避免这些潜在的问题,使用也无妨。比如 nlohmann json issue 552 就在头文件中用了如下代码:

/// namespace to hold default `to_json` / `from_json` functions
namespace
{
constexpr const auto& to_json = detail::static_const<detail::to_json_fn>::value;
constexpr const auto& from_json = detail::static_const<detail::from_json_fn>::value;
}

这里是 constexpr 引用对象,而非 constexpr 变量,后者在 Global Scope 下默认是 Internal Linkage,而前者是 External Linkage,并且不会隐式 const。因此,代码中显式添加了 const,并且将定义全部放在 Unnamed namespace 当中,以改变链接方式。

Inline Namespaces

Unnamed namespaces 有一个唯一的名称会通过 using-directives 自动导入,倘若可以手动指定这个名称,就是 inline namespaces 了。此时,自动导入的名称就是 inline namespace 的名称,比如 std::literalsstd::liternals::chrono_literals实现

#if __cplusplus >= 202002L
  inline namespace literals
  {
  inline namespace chrono_literals
  {
    /// @addtogroup chrono
    /// @{
#pragma GCC diagnostic push
#pragma GCC diagnostic ignored "-Wliteral-suffix"
    /// Literal suffix for creating chrono::day objects.
    /// @since C++20
    constexpr chrono::day
    operator""d(unsigned long long __d) noexcept
    { return chrono::day{static_cast<unsigned>(__d)}; }

    /// Literal suffix for creating chrono::year objects.
    /// @since C++20
    constexpr chrono::year
    operator""y(unsigned long long __y) noexcept
    { return chrono::year{static_cast<int>(__y)}; }
#pragma GCC diagnostic pop
    /// @}
  } // inline namespace chrono_literals
  } // inline namespace literals
#endif // C++20

正因如此,std/std::literals/std::literals::chrono_literals 任选其一,都可以直接使用时间相关的 Literals。例子:

#include <chrono>
#include <iostream>

int main()
{
    // using namespace std;
    // using namespace std::literals;
    using namespace std::literals::chrono_literals;
    auto day = 24h;
    auto halfhour = 0.5h;
    std::cout << "one day is " << day.count() << " hours (" << day << ")\n"
              << "half an hour is " << halfhour.count() << " hours ("
              << halfhour << ")\n";
}

因此,inline namespaces 最基本的作用就是影响名称查找规则,既可以使用命名空间显式地导入组件,也可以使用隐式的导入行为。但需记住,这并不会像 Unnamed namespaces 那样改变原有的链接方式。

基于这一特性,可控制库 API 版本的向后兼容性。比方说现在库中有一个 S::foo 函数:

namespace mylib {

    namespace v0 {

        struct S {
            void foo() {
                std::cout << "vo::foo()\n";
            }
        };

    } // namespace v0

} // namespace mylib

int main()
{
    using namespace mylib::v0;
    S s;
    s.foo();
}

在下一版本中,需要修改 S 类的结构,这里假设把 foo() 的名称变成 bar()。只要是公开发布、存在用户群体的库,直接修改自然是不成的,那样别人只要稍微升级一下库的版本,旧代码就一片红色。于是,可以新增加一个 v1 版本的实现:

namespace mylib {

    namespace v0 {

        struct S {
            void foo() {
                std::cout << "vo::foo()\n";
            }
        };

    } // namespace v0

    namespace v1 {

        struct S {
            void bar() {
                std::cout << "v1::bar()\n";
            }
        };

    } // namespace v1

} // namespace mylib

int main()
{
    using namespace mylib::v1;
    S s;
    s.bar();
}

若是想用旧的,依旧使用 using namespace mylib::v0 即可保证代码兼容。但是,哪个库每次使用还需要手动写明 v0/v1... 啊?用户并不一定清楚库的版本,通常来说,他们只想使用最新的版本。这便是 Inline Namespaces 的用武之地,改变代码为:

// ......

    inline namespace v1 {

        struct S {
            void bar() {
                std::cout << "v1::bar()\n";
            }
        };

    } // namespace v1

} // namespace mylib

int main()
{
    using namespace mylib;
    S s;
    s.bar();
}

如此一来,只需使用 using namespace mylib,便能静默切到最新的实现版本。若想兼容旧代码,则手动切换为指定的版本即可。

你可能会觉得以下这种方式也能达到同样效果,用不用 inline namespaces 不是必须。

// ......

    namespace v1 {

        template<typename T>
        struct S {
            void bar() {
                std::cout << "v1::bar()\n";
            }
        };

    } // namespace v1

    using namespace v1;

} // namespace mylib

int main()
{
    using namespace mylib;
    S<int> s;
    s.bar();
}

但是,当你尝试在命名空间之外特化 S 时:

// ......

    namespace v1 {

        template<typename>
        struct S {
            void bar() {
                std::cout << "v1::bar()\n";
            }
        };

    } // namespace v1

    using namespace v1;

} // namespace mylib

namespace mylib {

    // specialization outside its namespace
    template<>
    struct S<void> {
        void bar() {
            std::cout << "void v1::bar()\n";
        }
    }

} // namespace mylib

int main()
{
    using namespace mylib;
    S<int> s;
    s.bar();
}

就会遇到编译错误,而如果使用 Inline Namespaces 就不会存在这个错误。

Inline Namespaces 的另一个作用是控制 ABI 版本,当 ABI 改变时,调用代码和被调用代码的数据内存布局将会变得不一致,这往往会产生 UB。例如:

// lib.h
namespace mylib {
    struct S {
        int x = 42;

        void foo() const;
    };
} // namespace mylib

// lib.cpp
#include "lib.h"
#include <iostream>

void mylib::S::foo() const {
    std::cout << x << '\n';
}

// main.cpp
#include "lib.h"

int main() {
    mylib::S s;
    s.foo();
}

lib.cpp 编译成共享库,再用 main.cpp 链接该库。

$> g++ -fPIC -shared -o libs.so lib.cpp
$> g++ -o main main.cpp -L. -ls -Wl,-rpath,.
$> ./main
42

这里正常情况,接着改变一下类的结构,API 保持不变:

// lib.h
namespace mylib {
    struct S {
        char c = '+';
        int x = 42;

        void foo() const;
    };
} // namespace mylib

// lib.cpp
#include "lib.h"
#include <iostream>

void mylib::S::foo() const {
    std::cout << c << '\n';
}

重新编译生成链接库,不必重新编译 main.cpp,直接链接输出:

$> g++ -fPIC -shared -o libs.so lib.cpp
$> ./main
*

可见,ABI 已经发生了改变,结果变得不可预料,此时最简单的解决办法就是重新编译 main.cpp。但 Inline Namespaces 可以更好的定位此问题,将新旧代码同样使用 v0/v1 区分版本,ABI 改变时,函数的 Mangled name 将不一致,从而产生链接错误。

具体来讲,将代码变成这样:

// lib.h
namespace mylib {

    namespace v0 {

        struct S {
            int x = 42;

            void foo() const;
        };

    } // namespace v0

   inline namespace v1 {

       struct S {
           char c = '+';
           int x = 42;

           void foo() const;
       };

   } // namespace v1

} // namespace mylib

// lib.cpp
#include "lib.h"
#include <iostream>

void mylib::S::foo() const {
    std::cout << c << '\n';
}

再企图将可执行程序链接到新的共享库时,将产生编译期错误:

$> g++ -fPIC -shared -o libs.so lib.cpp
$> ./main
./main: symbol lookup error: ./main: undefined symbol: _ZNK5mylib2v01S3fooEv

这样就能够清晰地定位 ABI 问题,要么重新编译程序,要么链接匹配的 ABI 版本。

Namespace Alias

Namespaces 本质上要解决的还是广义上的命名问题,唯一、明确、简洁是追求的目标。名字太长时,叫来、用来和看来都多有不便,因此,就需要进一步简化,Namespace Alias 就提供了一种简化的方式——为长名称起别名。

主要形式为:

namespace A = long_namespace_name;
namespace B = ::long_namespace_name;
namespace C = nested::long_namespace_name;

真实例子,如:

namespace fs = std::filesystem;
fs::path p;

代码是给人读的,机器并不在意名称是什么、有多长,这属于可读性上的提升。

Namespace Alias 可能会导致名称遮蔽或重定义问题,因此最好不要在头文件中使用。

Using Namespace Directive

Namespace Alias 只是简化名称,并不能省略名称,而 using-directive 可以达到这一目的。

每个 C++ 新手的第一课都学过 using namespace std;,这就是 using-directive。

但这种做法几乎不会出现在真实项目中,Namespaces 本身就是为了避免名称冲突,而 using-directive 这种简化又可能会再次引起这个问题。

标准当中有许多组件都包含在 std 命名空间之下,像是许多数学函数,有些你可能都不清楚存在这么个函数。

一旦你使用了其他数学函数库,由于大量函数同名,将会产生严重的错误。因为存在隐式转换,也许调用的根本就不是你想调用的那个函数,编译器也不会报任何错误。

同样,使用其他人的库,你也不会清楚其中包含的全部组件,项目越大,产生这种错误的几率也越大,不正确的使用命名空间,将会埋下不少隐患。

故尽量不要在 Global Scope 下使用 using-directive,这会引起名称污染,且可能会降低可读性。对于有些名称前缀特别长的函数,优先采用 Namespace Alias 来简化代码。如果一定要使用,那一定是在明确影响范围的情况下。

这一条例也有例外,对于 Literals,可以放心大胆地在 Global Scope 下使用 using-directive:

using namespace std::chrono_literals;
auto duration = 5s;  // Ok
auto duration2 = std::chrono::seconds(5);

// This doesn’t work:
// auto duration = std::chrono_literals::5s; // Error

因为这些 Literals 无法被用户定制,是小心设计过的,不会产生名称冲突,你也只能够这样使用。

与 using-directive 对应的还有 using-declaration,它不会引入 Namespaces 下的所有符号,而是一次指定引入一个。例如

using std::cout;
cout << "Bring only one symbol into scope.\n";

但是它依旧会污染 Namespaces,使用的时候也要明确影响范围。

如果同时使用 using-directive 和 using-declaration 引入了一个符号,后者的优先级高于前者。如:

namespace A {
    int x = 10;
}

namespace B {
    int x = 20;
}

int main() {
    using namespace A;
    using B::x;
    std::cout << x; // 20
}

总之,除了 Literals,using-directive 尽量不用,using-declaration 在可知范围内使用,Namespace Alias 优先使用,它不会直接污染 Namespaces,能够提高代码的可读性。

Namespace Best Practices in C++ Core Guidelines

本节梳理 C++ Core Guidelines 中的 Namespaces 最佳实践。

第一条

C.5: Place helper functions in the same namespace as the class they support

例子:

namespace Chrono { // here we keep time-related services

    class Time { /* ... */ };
    class Date { /* ... */ };

    // helper functions:
    bool operator==(Date, Date);
    Date next_weekday(Date);
    // ...
}

辅助函数可以看作是类的接口的一部分,将这些函数放在与类相同的 Namespaces 下,可以清楚地表明它们之间的关系,并且还能通过 ADL 找到它们。

第二条

C.168: Define overloaded operators in the namespace of their operands

例子:

namespace N {
    struct S { };
    S operator+(S, S);   // OK: in the same namespace as S, and even next to S
}

N::S s;

S r = s + s;  // finds N::operator+() by ADL

这和第一条说的其实是同一个东西,无须解释。

第三条

SF.6: Use using namespace directives for transition, for foundation libraries (such as std), or within a local scope (only)

这条就是说只在代码迁移或过渡阶段、常用的基础库和 Local Scope 下可以使用 using-directive。因为不可能在所有情况下,都为 Namespaces 中的每个名称加上限定符,这样会分散注意力。但依旧谨记,除非迫不得已,绝不要在头文件或全局范围中使用。

一个 .cpp 也相当于一种局部作用域,所以从名称冲突的角度来看,不论是在 .cpp 的顶部使用 using namespace X;,还是在其中的一个函数内部使用,抑或是在每个函数内部都使用,它们的名称冲突风险没有什么区别,都在可控范围内。

第四条

SF.7: Don’t write using namespace at global scope in a header file

这条就是明确指出不要在头文件中使用 using-directive 导入所有符号。

第五条

SF.20: Use namespaces to express logical structure

这条实践没有任何内容,但本文开始就强调 Namespaces 能够更有逻辑地组织代码,协作开发大型项目。这其实是工程思维,不涉及具体的代码,代码只是逻辑的表现形式。

第六条

SF.21: Don’t use an unnamed (anonymous) namespace in a header

示例:

// file foo.h:
namespace
{
    const double x = 1.234;  // bad

    double foo(double y)     // bad
    {
        return y + x;
    }
}

namespace Foo
{
    const double x = 1.234; // good

    inline double foo(double y) // good
    {
        return y + x;
    }
}

原因及例外我在前面的 Unnamed Namespaces 这一节已经深入解释过了。

第七条

SF.22: Use an unnamed (anonymous) namespace for all internal/non-exported entities

这条还是 Unnamed Namespaces,优先使用它来管理不需要导出的内部实体。原因也解释过了。

第八条

SL.3: Do not add non-standard entities to namespace std

这条是说不要往 std 命名空间中插入自己的实体,那可能会和标准未来加入的名称产生冲突。

但标准中会包含许多模板类,用户需要去特化定制自己的类型,此种情况下是可以使用的。例如:

template <>
struct std::formatter<mylib::response> {
    // ...
};

这八条实践,或或说八个潜在的坑,写过一些稍大一点的项目,自然就清楚了。

Namespaces Best Practices in Google C++ Style Guide

本节梳理 Google C++ Style Guide 中的 Namespaces 最佳实践。

大致汇总如下:

  • Do not use inline namespaces.(不要使用内联命名空间。)
  • Do not use Namespace aliases at namespace scope in header files except in explicitly marked internal-only namespaces.(不要在头文件的命名空间作用域中使用命名空间别名,除非是在明确标记为仅供内部使用的命名空间中。)
  • You may not use a using-directive to make all names from a namespace available.(禁止使用 using namespace 指令将整个命名空间的所有名称引入当前作用域。)
  • Namespaces wrap the entire source file after includes, gflags definitions/declarations and forward declarations of classes from other namespaces.(在 #includegflags 定义/声明以及其他命名空间中的类的前向声明之后,使用命名空间包裹整个源文件内容。)
  • Terminate multi-line namespaces with comments.(多行命名空间应在结束大括号处添加注释。)
  • Single-line nested namespace declarations are preferred in new code, but are not required.(在新代码中,建议使用单行嵌套命名空间声明,但这不是强制要求。)
  • Format unnamed namespaces like named namespaces. In the terminating comment, leave the namespace name empty.(未命名命名空间的格式应与命名命名空间相同;在结束注释中保留空的命名空间名位置。)
  • Don’t put namespace aliases in your public API.(不要在公共 API 中放置命名空间别名。)
  • Namespace names are snake_case (all lowercase, with underscores between words).(命名空间名称应使用 snake_case 风格——全部小写,单词间以下划线分隔。)
  • Namespaces do not add an extra level of indentation.(命名空间内部的代码不应额外增加缩进层级。)
  • Nested namespaces should avoid the names of well-known top-level namespaces, especially std and absl.(嵌套命名空间应避免使用知名顶级命名空间的名称,尤其是 stdabsl。)

说来说去,核心依旧是避免潜在的名称冲突,只是 Google 增加了一些格式上的要求。

最佳实践只是建议,并不是标准。建议是用来给新手避坑的,对于高手来说,并不一定需要遵守。因为高手知道自己在做什么,而新手却对相关特性一知半解,所以新手要靠规则来约束行为,等有一定的经验后,便可以突破规则。

Summary

本文系统而深入地探索了 C++ 中的 Namespaces,它能够划分程序的逻辑,封装内部细节,协作开发大型项目。

Namespaces 主要是为了避免名称冲突,有 Named Namespaces、Unnamed Namespaces 和 Nested Namespaces 三种定义形式,大型项目都会涉及。

Inline Namespaces 和 Unnamed Namespaces 这些高级用法需要掌握,需要明确 Namespaces 的影响范围。

掌握了本文的内容,对 Namespaces 就可以从「知道」进阶为「知识」。

Leave a Reply

Your email address will not be published. Required fields are marked *

You can use the Markdown in the comment form.