每周技巧 #130:命名空间命名

本节阅读量:

本文翻译自 Abseil 官网的 Tip of the Week #130: Namespace Naming

原文最初作为 TotW #130 发布于 2017 年 2 月 17 日。

作者:Titus Winters

快捷链接:abseil.io/tips/130

命名的精确性会夺走观看的独特性。 —— Pierre Bonnard

Google C++ 风格指南最早的提交中,就包含了许多人至今仍在使用的命名空间命名指导。大致来说,可以概括为“命名空间由包路径派生”。紧随 Java 包命名要求之后,这看起来很合理:我们希望能够唯一标识 C++ 中的符号,也希望命名空间选择具有唯一性和一致性。

但事实上,我们并不想这样。只是我们花了将近十年才意识到。

名称查找

先从 C++ 中名称查找如何工作,以及它和 Java 有什么不同说起。

1
2
3
4
5
6
7
namespace foo {
namespace bar {
void f() {
  Baz b;
}
}
}

在 C++ 中,对非限定名(Baz)的查找会在逐步扩大的作用域中搜索同名符号:先在 f()(函数)中找,然后在 bar 中找,然后在 foo 中找,最后在全局命名空间中找。

在 Java 中,不存在这样的非限定符号:符号要么是限定名:

1
2
3
public void f() {
  com.google.foo.bar.Baz b = new com.google.foo.bar.Baz();
}

要么通过导入引入,可以导入单个包成员,也可以使用通配符:

1
2
import com.google.foo.bar.Baz;
import com.google.foo.bar.*;

无论哪种情况,Baz 都不会在显式提供的包之外查找:通配符不会向下进入子包,搜索也不会扩展到父包。事实证明,Java 和 C++ 在处理父包/父命名空间方式上的这种差异,正是结构化命名空间命名(让命名空间结构匹配包层级)在 C++ 中是个错误的根本原因。

问题

用包构造命名空间的根本问题在于,我们在 C++ 中很少依赖完全限定查找,通常会写 std::unique_ptr,而不是 ::std::unique_ptr。再加上外围命名空间中的查找,这意味着对于深层嵌套包中的代码(例如 ::division::section::team::subteam::project),任何未完全限定的符号(std::unique_ptr)实际上都可能引用下面任意一个:

  • ::std::unique_ptr
  • ::division::std::unique_ptr
  • ::division::section::std::unique_ptr
  • ::division::section::team::std::unique_ptr
  • ::division::section::team::subteam::std::unique_ptr
  • ::division::section::team::subteam::project::std::unique_ptr

更糟的是:非限定搜索会从这个列表底部开始,一旦找到命名空间匹配就停止。这意味着,如果你的某个传递包含新增了一个以前没用过的命名空间,而它恰好匹配你通过非限定命名空间使用的某个符号的前导命名空间,你的构建就可能被破坏。严格来说,这甚至不一定表现为构建失败:如果有人添加了同名且 API 在语法上兼容的东西,而该 API 的实现完全不兼容,就可能在运行时造成大范围混乱。显然,对 std 来说这还不算太糟,因为没人应该添加嵌套命名空间 std;但更常见的命名空间呢?比如 testing 之类的东西呢?

名称并不是为了唯一性而选择的。由于团队常常创建本地工具包来处理与其依赖基础设施相关的常见任务,我们最终会得到本地的 utilpipeline 包,以及子命名空间。这就是不必要且非预期冲突的配方。

作为对比,Java 中的问题要小得多:如果你在 Java 中从两个包使用通配符导入,而其中一个包新增了一个和另一个包同名的符号,你的构建可能会坏掉。这个问题可以通过禁止通配符导入轻松且彻底地解决,许多 Java 风格就是这么做的。

两个一致选项,三种做法

有两个特性可以防止这种远距离构建破坏:

  • 如果没有叶子命名空间(search::foo::bar)匹配任何顶层命名空间(::bar),也不匹配该叶子任一父命名空间的子命名空间(search::bar),就不会发生名称冲突。
  • 如果没有非限定查找,就不会有问题。

至少有三种方式可以做到这一点:

  • 总是对当前命名空间之外的所有东西使用完全限定名。这非常啰嗦,也有点奇怪:C++ 中没有什么东西(包括标准库)会给每个符号都写上前导 ::
  • 构建一些工具,用来识别新命名空间的引入,并确保它不会和同一层级中的任何其他命名空间重叠。也就是说,如果已经有 ::barsearch::foo::bar,就不要添加 search::bar
  • 不要深度嵌套:每个项目使用一个顶层命名空间,可以在不引入冗长/复杂名称的情况下得到同样结果,减少意外暴露,不让新工程师感到惊讶,也不需要构建任何工具。

当前风格指南建议最后一个选项,但必要时允许旧风格(命名空间匹配包名)。这主要是因为 Google 不想造成太多焦虑,也不想触发大家给已有东西重新命名空间。话虽如此,如果能在一个全新代码库中重来一次,我们会毫不含糊地说:每个项目的公开接口使用一个顶层命名空间。通过一个公共数据库确保命名空间唯一。这样我们只会得到 absl 这样的顶层命名空间,并且名称查找不会有歧义(局部符号和全局命名空间中的符号冲突除外,但现代规则本来也不鼓励使用全局命名空间)。

由于在这个变化之前已经存在大量代码,而且变化之后仍有大量代码遵循旧模式,我们发现自己处在某种半途状态:有些命名空间经常需要完全限定(::util),而有些命名空间显然唯一,永远不需要完全限定(std)。

但它能让东西保持有组织!

我经常听人说,小的/嵌套的命名空间能“让东西保持有组织”。把东西放在该放的位置感觉很对:像 StrCat()make_unique() 这样的东西,除了都在 Abseil 里之外彼此毫不相关,为什么要把它们放在一起?absl::strings::utilities 命名空间不是能帮助它和 absl::smart_ptrs 区分开来吗?

在其他语言里,这可能确实不错:组织得更好,而且没有坏处。然而,由于 C++ 的查找方式会逐层扩展到连续的外围命名空间作用域,你的细粒度命名空间会受到每个父命名空间中新增的每个符号(和子命名空间)影响。也就是说,虽然你并不精确地“包含”父命名空间中的名称,但名称/命名空间冲突的重要性几乎和你确实包含了它们一样。小的/深层嵌套的命名空间并不会把你和这个问题隔离开来,反而会加剧它。

最佳实践

从实践上说,考虑到大多数代码库的现实情况,我们能做的最好事情如下:

  • 为代码库维护某种形式的数据库,用来识别唯一命名空间。
  • 引入新命名空间时,使用该数据库,并把它作为顶层命名空间引入。
  • 如果因为某种原因无法做到上面两点,绝对永远不要引入一个匹配知名顶层命名空间的子命名空间。不要为 absltestingutil 等创建子命名空间。尽量给子命名空间取不太可能与未来顶层命名空间冲突的唯一名称。
  • 声明命名空间别名和 using 声明时,除非引用的是当前命名空间内的名称,否则使用完全限定名,见 TotW 119
  • 对于 util 或其他常被滥用命名空间中的代码,尽量避免完全限定,但必要时要限定。

TotW 119 中的建议对 .cc 文件也有帮助:我们对完全限定的担心并不是它不好,而是它和世界上其他 C++ 代码相比显得很奇怪。在 using 声明中有限使用能取得可接受的平衡。不过,即使完全遵循这个建议,也不能完全缓解非限定名称查找带来的危险,因为我们仍然有头文件,也不想在每个头文件里完全限定每个符号。

每周技巧 #126:`make_unique` 是新的 `new`

上一节

每周技巧 #131:特殊成员函数和 `= default`

下一节