迈向C++0x标准库之二:名字空间和库的版本协调 《Dr.Dobb's 软件研发》第3期 Herb Sutter 关于C++标准库下一个版本的工作目前正在进行中。关于C++0x设施有一些基本的逻辑问题需要回答:
这些问题比它们看上去的要棘手得多。对于C++标准库所做出的选择应该有借鉴作用,它向库编写者展示了在C++中库的版本协调通常应该如何处理。不管怎么说,这也正是名字空间被设想为擅长解决的大问题之一,不是吗?如此看来我们不是有比缺乏名字空间的语言更好的工具吗?事情并非那么简单,你很快就会明白这一点。 源代码和二进制兼容性 首先,考虑摆在任何计划发行新版库的供应商面前一个最基本的问题:是支持源代码兼容还是支持二进制兼容,是全部支持还是全不支持。对于库的不同部分来说答案可能并不相同。举个例子,供应商可能决定大体上维持源代码兼容,但作为折衷权衡,也可能会放弃对特定设施的兼容努力。 源代码兼容大致意味着用户可能需要重新编译现有代码,但代码依然可以工作且其含义不变。如果你希望维持与一个库的较老版本源代码兼容,那就不要改变任何现有名字或函数的含义。不改变现有代码含义的纯粹的句法扩展是可以的。 对于源代码兼容来说以下做法是可以的:
对于源代码兼容来说以下做法是不可以的:
// Example 1-1 类似地,向一个模板加入新的默认模板参数尽管通常是可以的,但也可能会破坏使用模板模板参数(template template parameters)并因而依赖于最初精确数量的参数的现有代码: // Example 1-2 接下来,二进制兼容大致意味着用户无需重新编译任何现有代码,只要同新库重新连接即可。连接将仍然可以进行且结果得到的可执行文件的含义不会发生变化。假如你希望同一个库的较老版本保持二进制兼容,那就小心不要改变任何现有名字或函数签名。这些东西参与名字重整(name mangling),倘若不付出一些真正艰巨的努力将无法工作。 对于二进制兼容来说以下做法是可以的:
对于二进制兼容来说以下做法是不可以的:
以上阐述列表被总结为图1所示的维恩图(Venn diagram) — 它并未打算详尽无遗。关于源代码和二进制兼容,有更多的“什么是可以的”、“什么是不可以的”的议题,其中部分属于平台相关的问题。例如修改入口点 — 移动DLL中的函数的入口点顺序编号,或者改变成员函数的vtable槽位(slot),都是二进制不兼容的,即使函数自身的其他方面并没有被修改。
对于这篇文章中主要的源代码和二进制兼容问题来说这个总结足够了。你应该获得一个感性认识:通常来说,二进制兼容比源代码兼容更难以维持,但对于重叠问题来说情况有所不同,而且在某些情况下二进制兼容比源代码兼容更容易维持。 对这些问题做到心中有数后,现在让我们回到更大的问题 — C++库特别是标准库的版本控制。 寻找一个家:基本的选择 当谈及为下一代C++0x标准库寻找一个家时,问题可以归结为两个基本选择: 选项1:和父母亲一块住。这种方式代价低廉,如果你和你的家人能够和睦相处的话,这种方式美妙无比。对C++0x标准库走这条路线,意味着将所有东西倾倒入C++98库已经居住的同样地方也就是名字空间std中。这是一种显而易见的可能性,因为这正好就是缺乏名字空间的环境中的库所采取的办法,例如C99标准库就是如此。但对于C++来说这种做法似乎有点不够优雅,不是吗?难道这些新奇的名字空间不能被设想为一种有用的库的版本协调工具吗?因而有第二个选项: 选项2:搬出来,但呆在附近。这更有弹性,但需要更多的工作,并有赖于你的自律性和成熟度。倘若你粗心大意,它也会使你陷入困境。采用这种方式将会把C++98和C++0x库放于不同的名字空间。加入魔法使得向后兼容于现有代码并转而使用新设施的日子好过一些(这儿有很多创造性的调料可供选择),好好搅和搅和并祈祷好运出现。 选项1:和父母亲一块住在::std中 这个选项是最直截了当的。问题就如同上面所描述得那么多,另外还有一些新的缺点。 选项2:搬到另外一个名字空间但与::std保持联络 场景如下:全部新的C++0x标准库,包括部分与原始C++98标准库共享的东西,被搬出其父母亲的住所(::std)而住在它自己的新名字空间中(为了便于讨论,让我们称之为::std2)。原始的C++98标准库继续住在名字空间::std中,忘情于小镇上夜复一夜的狂野派对,同时std2又加入了一些新来的单身汉(例如那些新的基于散列的容器)以及其他区别(或许std2厌烦了绊倒躺在地板上的vector<bool>特化版并将它同上周的垃圾一起扔掉了)。 在简单的情况下,这工作得相当好: 对现有代码使用旧标准库:现有代码继续工作而不被破坏,含义没有任何变化,因为std仍然是一如既往的那个好std。 将大批东西转移到新标准库:在现有程序中将新标准库用作一个替代品就和从整体上将std::替换为std2::并将“using namespace std;”改为 “using namespace std2;”一样简单。(对于为什么using指示符并非有害的讨论可参见[1]中的条款40)。含义会改变的东西是那些同时存在于两个版本的库中的设施以及委员会明确决定修改的东西。这些东西数量很少并将会纪录以良好的文档。 有三个领域这种方式工作得不够好。第一个上面已经提到过了:修改名字会破坏连接兼容性。我们马上要讨论的一些技术依赖于修改当前居住在std中的设施的名字空间。 另外两个领域的细节值得深入钻研。一个领域对于使用者来说是可见的,另外一个领域对于库编写者可见,就让我们以这个顺序来讨论它们。 选项2,第一个问题:对现有代码使用新标准库 希望使用部分新标准库的用户代码可以通过引用std2::而做到这一点,但这仍然可能带来烦恼。考虑如下现有代码: // Example 2-1: 这是一个真正的问题。“啊,但这很容易得到修复。”有些人可能会这么说,“只要不在函数声明中写std::,在头文件中靠近f()的上方写一个using声明或using指示符即可。这样你就只需对每一个头文件中的一处进行修改。”但这种做法并不好:你确实应该在函数声明中明确地写上std::,因为可以证明有很好的理由永远都不要在头文件中写名字空间using声明或指示符,应该总是像f()那样在函数声明中使用显式名字空间限定符(再一次请参考[1]中的条款40)。 现在请注意,假如f()业已是一个像下面所示的模板的话就不会发生这样的问题: // Example 2-2: (BAD) 尽管上面的代码完全合法,但它并不是这个问题的解决方案。我认为这是说不过去的:告诉用户,假如他自己编写的函数的参数类型恰好引用了标准设施,就应该将该函数编写成模板(他自己的类也要如此这般处理)。这种做法愚不可及!在上面的情形下,可能没有什么理由要将f()写成模板。实施忠告使得所有这样的构造全部变成模板将意味着怂恿大量的用户模板出现,并且“不介意”对C++复杂性的新一轮猛烈谴责 — 我们不要走到那个份上。我们无疑会喜欢像“int f( std::vector<int>& )”这样的函数能够和新标准库vector协作,不管vector在C++0x中改没改变。 选项2,第二个问题:如何实现和维持分离的C++98和C++0x库名字空间 我通常从使用这些东西的战壕的C++程序员的角度来写关于C++的东西,毕竟这是我们绝大多数人的视点。然而,请跟我来,就一会儿,让我们假装我们是库实现者,看看我们会怎么处理这样的需求:实现完整的C++98标准库于名字空间std中,实现完整的C++0x标准库(无论最终结果如何,但很可能是主要的添加物再加上完全没有改变的最常用的东西)于另外的名字空间std2中。 主要问题在于如何处置从C++98到C++0x不发生变化的所有常用的东西。我们当然不希望编写它们两次:一次在std中,另一次是在std2中。接下来描述一些主要选项,并说明为什么这些选项是有问题的。 选项2(a):经由#include <impl>进行“代码粘贴” 这个“代码粘贴”选项中的思想是将一些不变的设施的实现(让我们还是以vector为例子)放入一个公共实现文件中,然后在两个名字空间中将其径自#include进来: // Example 3 这避免了代码复制,很好。 然而这种方式一个大的缺点在于并没有提供真正共享的实现。如果一个程序同时使用std::vector<int>和std2::vector<int>,那就可能遭遇到某些针对非内联函数的连接期膨胀。因为有两个实现品,它们其实是一样的,只是因为它们位于不同的名字空间从而其重整名字(mangled names)不同且大多数连接器无法消除掉一个。或许一个假想的无所不知的连接器可以注意到二者生成的代码是一样的且参数列表基本一致从而对两份拷贝进行“折叠”处理。但注意到参数列表真正一致是很困难的,而且两个版本的默认构造器可能还带有不同类型的参数(例如std::allocator<int>对std2::allocator<int>)。也许设想存在无所不知的连接器并不实际。此外,甚至对于一些基本的东西来说很多商业连接器仍然只是C-aware而不是C++-aware,这并不是什么秘密。 现在,尽管以上天真的粘贴-拷贝方式无法很好地共享实现品,但我们可以再向前进一步: 选项2(b):对__impl的包装器 这儿的思想是将std::vector和std2::vector一起包装于一个公共的实现里,比如__myob::__vector_impl。看起来像这样: // Example 4 因为可见的std::vector和std2::vector都只是“转发器”而已,所以它们很可能不会招致类似的连接期代码复制问题 — 即使std::vector<int>和std2::vector<int>被用于同一个程序中。因为它们所有函数通常都是一行内联的转发函数。单一共享的__vector_impl做了所有真正的工作,并且这一次它真的只有一个。我们不需要设想一个无所不能的连接器来剥除复制品,库的实现者防止了复制情况的发生。 选项2(c):使用“using” 有人可能会认为将一个设施提升到两个名字空间中的更C++化的方式是使用using声明。这个观点几乎是对的。思想看起来如下: // Example 5-1: Alternative 1, 或像这样: // Example 5-2: Alternative 2, 或者甚至像这样: // Example 5-3: Alternative 3, 哎呀,这也有问题!(吃惊了不是?)在这种情况下,问题滋生于这样的一个事实:经由一个using声明而被拖进来的实体并非具有全部同样的状态并呈现出和真正的声明同样的面貌。 这儿就是问题之一:用户被允许以他们自己的用户自定义类型来特化标准库模板。今天这是完全合法的C++98技术。但是,假如以上的可选方案被允许,并且你希望特化vector,你究竟到哪儿去特化它?特化版必须驻留于和原始模板相同的名字空间中。毕竟,用户可能说不出原始模板到底在哪儿,而且如果答案变得跟C++98的情况不一样了,情况尤其糟糕。 考虑如下目前合法的代码: // This is legal today in C++98: 如果我们采用上面所述的方案3,那么代码可以仍然引用std::vector<int>,但以上局部特化(partial specialization)的企图将变成非法的: // Alternative 3 again: Why it’s unworkable 实际上,我们将会看到第四个类似的选项具有同样的问题: 选项2(d):使用别名 名字空间别名是一个鲜为人知的C++特性 — 假如新闻评论的数量可资一种暗示的话 — 大多数作者都不多谈它们。它们的机理其实很简单,这儿是一个例子: // Example 6: Namespace aliases 也就是说,名字空间别名不过是名字空间的另一个名字而已。你不可以使用这个别名重新打开一个名字空间: namespace X = Y; 为什么我们关心这个?因为看起来这样挺好:将名字空间std命名为std1,并将C++98标准库放在那儿,然后创建一个新的std2名字空间,并将C++0x标准库放在那儿,然后只要使用std作为真正的名字空间名字的别名即可: namespace std = std2; // wouldn’t this be nice? 但这么一来你又倒退回“我可以引用使用那个名字的东西但我无法特化使用那个名字的模板”的境地中去了: // Used to be legal in C++98, but would break 用户将不得不知道关于实现品的技术,并且改为重新打开名字空间std2。当虑及针对库版本协调问题的名字空间别名时,光说“=”并不足够。 总结 在这个讨论中有些次要的问题我没有提及,还有一些讨论过的问题为了便于描述我略微虚饰了一些细节,但基础论点和问题都是有根有据的。明白了这些应该能带给你一个“试图使用C++98名字空间来管理新库发行版的版本变化所涉及的问题”的认知。标准库自身是一个例证,但这个讨论同样适用于任何第三方厂商提供的库,因为所有库都会面临版本协调问题的挑战。 还有一些额外的选项是如此糟糕以至于我提都没提。例如对例子2-1的一个“反解决方案”是使std2::vector继承自std::vector — 这种思想绝对恐怖!其一,将增加std::vector的负担。因为这么一来仅仅为了做兼容性的苦活就要使std::vector变成多态的。其二,同样的多态附加物将会以某种不必要地破坏二进制兼容性的方式而改变std::vector的设计。其三,它违背了泛型编程的精神。其四 — 假如需要列出第四条的话,那就是低劣的判断错误,“可能会发胖并且众所周知会导致实验室动物罹患癌症”。 1994年当Tom Cargill发表他的开创性的文章“Exception Handling: A False Sense of Security”[3]时,他展示了我们作为整个社群还并不真的知道如何编写异常安全的代码。这篇文章有趣之处在于结束分析的部分。不是说“这儿该怎么做”,而是说“不要以为我已经开列出的议题就是议题的全部,因为还有一些我知道的有关议题没有在此处讨论,我甚至不知道我是不是真的了解了全部议题,我也不认为任何别的什么人能够做到这一点,因此,我鼓励C++社群中的一些人撰写文章展示该怎么解决。”三年后才出现这样的文章(那些素材 — 被大大扩充后 — 现在成了[2]中的异常安全一节)。 我愿意以类似于Cargill先生的口气来结束这篇文章。我声明,由于以上的议题以及其他一些议题,我们作为整个社群仍然并不真的知道如何使用名字空间来有效地处理库版本协调问题。在这一点,除了把所有东西永远都放到std中我们没有更好的办法。还有些问题在这儿我没有讨论,我甚至不知道我是不是真的了解全部议题,我也不认为任何别的什么人能够做到这一点,因此,我鼓励C++社群中的一些人撰写文章展示该怎么解决。(在委员会的库工作组中有一群感兴趣的人正在探讨这个问题,这个群体也是我志愿去协调的。假如出现了一个答案,那很可能就出自于这个群体中的某个(些)人。这么说并不意味着别的专家就不应该做什么努力了) 即使可能存在一个圆满的解决方案,我也怀疑几乎肯定需要对核心语言名字空间特性至少做些小修小改。同时,假如C++0x标准库、扩充物以及其他所有东西最终仍然呆在名字空间std的房间里,你也不必感到讶异。有时外面的房租太贵了,还是住在家里划算。 附注 [1] Herb Sutter. More Exceptional C++ (Addison-Wesley, 2002). 荣耀 |