Uploaded by 欧成国

《现代C 白皮书》电子版

advertisement
现代C++白皮书(中文版)
在拥挤和变化的世界中茁壮成长 C++ 2006-2020
Thriving in a Crowded and Changing World: C++ 2006-2020
(美)Bjarne
Stroustrup
吴咏炜、杨文波、张云潮 等
著
译
全球C++及系统软件技术大会 出品
— 作者简介 —
Bjarne Stroustrup
C++之父,美国国家工程院、ACM、IEEE院士
Bjarne Stroustrup,是C++的设计者和最初的实现者,C++语言ISO标准的主导者。美国国家工
程院、ACM、IEEE院士。于1979年获得剑桥大学计算机博士学位,毕业后加入贝尔实验室,担任
贝尔实验室大规模程序设计研究部门的负责人,在那里领导并发明了C++。他的研究领域包括:分
布式系统、编程语言、软件开发工具。曾于2018年荣获美国国家工程院颁发 查尔斯·斯塔克·德拉普
尔奖,被誉为工程学界的诺贝尔奖。现任摩根士丹利技术部董事总经理,哥伦比亚大学客座教授。
代表作品:《 C++ 程序设计语言》《 C++ 语言的设计和演化》。
出品方简介
秉承“全球专家,卓越智慧”的宗旨,通过汇聚全球顶尖IT技术
专家,致力于为广大企业用户提供高端IT技术和产品领域的技术
高端IT咨询与教育平台
咨询、技术会议、企业内训、专家讲座、研讨会等服务。践行“
推动科技变革,赋能组织创新”的使命。
官方网站:www.boolan.com
演讲申请:speaker@boolan.com
咨询热线:400-821-5876
商务合作:partner@boolan.com
咨询邮箱:service@boolan.com
媒体联系:media@boolan.com
CPP-Summit
全球C++及系统软件技术大会
C + +
a n d
Sy s t e m
S o f twar e
3月11-12日
Summit
上海万豪虹桥大酒店
大会简介
系统级软件是数字世界的基础设施,C++自1985年由Bjarne Stroustrup博士在贝尔实验
室发明以来,一直被誉为系统级编程“皇冠上的明珠〞。 秉承“全球专家,卓越智慧“的
理念,全球C++及系统软件技术大会(Cpp-Summit)于2005年由李建忠老师发起创办,
并得到Bjarne
Stroustrup和众多C++大师专家的支持。每一年,我们特邀国内外C++和
系统软件领域的大师、专家、学者,汇聚一堂,在北京、上海、深圳等城市轮流举办,深
度探讨相关领域的最佳工程实践和前沿方法。在各方支持下,全球C++及系统软件技术大
会已经成长为系统软件领域公认的风向标会议。
• 现代C++语言
• 系统级软件
• 架构与设计演化
• 高性能与低时延
• 质量与效能
• 工程与工具链
• 嵌入式开发
• 分布式与网络应用
会议日期:2022年3月11-12日
会议地点:上海万豪虹桥大酒店
大会官网:www.cpp-summit.org
扫码免费领取权威C++技术资料,更享多重福利:
1. 领取C++及系统软件技术大会PPT全套资料
2. 更多专业珍贵典藏技术资料分享
3. 国内外大师与专家交流机会
4. 精品课程学习资源不定期放送
5. C++高质量技术人交流平台
培训课程
C++开 发 系 列 :
• 软件设计思想方法与模式(C++)
• 现代 C++ 11/14/17/20 实践培训
• C++高质量代码最佳实践
• C++性能优化高级培训
• C++整洁代码与架构实践
• C++嵌入式编程最佳实践
• C++ 并发与并行计算
• C++面向对象开发高端培训
• C++低延迟性能优化
• C++代码与架构重构
• STL标准库与泛型编程
• C++内存管理与优化
• C++开发者测试实践
• C++安全可信编码与设计
• C++网络编程开发实践
更多课程系列:
机器学习与人工智能系列
JAVA与云原生系列
架构与设计系列
产品经理系列
测试管理系列
网络安全系列
大数据与云计算系列
移动开发系列
Web开发系列
系列运维系列
微软平台系列
数据库系列
技术咨询
软件架构设计与重构
研发管理与效能提升
以业务需求为导向,关键技术为支撑,从领域驱动模型
帮助企业建立和改善IT研发管理体系,通过提供
到微服务架构,从架构风格到质量评审,从设计原则到
流程、工具、方法论等赋能型支撑,大幅度提升
性能优化,我们致力于全栈技术架构方案和咨询。
团队研发效能和软件过程成熟度。
人工智能与机器学习
大数据系统建设与优化
利用智能战略和深度学习技术,结合计算机视觉、
数据是现代企业的资产,通过助力大数据系统建
自然语言处理知识图谱、智能推理等关键技术推动
设,从数据收集存储、分析、可视化,帮助企业
企业人工智能基础设施的建设和战略打造。
优化数字资产,发掘数据价值。
数字化转型与体系建设
数据分析与用户增长
数字化转型是企业生存和发展的必然选择,通过
数据驱动的产品策略是实现增长的关键通过构建
全面梳理企业业务流程,帮助企业打造数字化体
体系化的数据分析框架,引入前沿的增长黑客实
系,立于数字经济不败之地。
践,帮助构建企业产品增长的“北极星”。
精益产品管理与创新
敏捷组织转型与变革
帮助企业全面梳理从产品战略、需求管理、用户
帮助企业引入先进的敏捷管理工具和管理体系,
体验、产品发布、产品迭代、运营维护等全流程
重塑IT研发团队组织架构和管理流程创新,提升
的产品管理策略,打造卓越的创新产品。
组织效能,打造团队创新能力。
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
摘要
到 2006 年时,C++ 已经在业界广泛使用了 20 年。它既包含了自 1970 年代初引入
C 语言以来一直没有改变的部分,又包含了在二十一世纪初仍很新颖的特性。从
2006 年到 2020 年,C++ 开发者人数从约 300 万增长到了约 450 万。在这段时期
里,有新的编程模型涌现出来,有硬件架构的演变,有新的应用领域变得至关重
要,也有好些语言在争夺主导地位,背后有雄厚的资金支持和专业的营销。
C++——一种没有真正商业支持的、老得多的语言——是如何在这些挑战面前继
续茁壮成长的?
本文重点关注 ISO C++ 标准在 2011 年、2014 年、2017 年和 2020 年的修订版中
的重大变化。标准库在篇幅上约占 C++20 标准的四分之三,但本文的主要重点仍
是语言特性和它们所支持的编程技术。
本文包含了长长的特性清单,其中记录了 C++ 的成长。我会对重要的技术要点进
行讨论,并用简短的代码片段加以说明。此外,本文还展示了一些失败的提案,
以及导致其失败的讨论。它提供了一个视角,如何看待这些年来令人眼花缭乱的
事实和特性。我的重点是塑造语言的想法、人和流程。
讨论主题包括各种方向上的努力,包括:通过演进式变化保留 C++ 的本质,简化
C++ 的使用,改善对泛型编程的支持,更好地支持编译期编程,扩展对并发和并
行编程的支持,以及保持对几十年前的代码的稳定支持。
ISO C++ 标准是通过一个共识流程演化而来的。无可避免,在方向、设计理念和原
则方面,不同的提案间存在竞争和(通常是礼貌性的)冲突。委员会现在比以往
任何时候都更大、更活跃,每年有多达 250 人参加三次为期一周的会议,还有更
多的人以电子方式参加。我们试图(并不总是成功)减轻各种不良影响,包括
“委员会设计”
、官僚主义,以及对各种语言时尚的过度热衷。
具体的语言技术话题包括内存模型、并发并行、编译期计算、移动语义、异常、
lambda 表达式和模块。要设计一种机制来指定模板对其参数的要求,既足够灵活
和精确,又不会增加运行期开销,实践证明这很困难。设计“概念”来做到这一
点的反复尝试可以追溯到 1980 年代,并触及到 C++ 和泛型编程的许多关键设计问
题。
文中的描述基于个人对关键事件和设计决策的参与,并以 ISO C++ 标准委员会档案
中的数千篇论文和数百份会议记录作为支持。
i
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
现代 C++ 的文艺复兴
C++ 作为一门博大精深的语言,其发展演化历程也堪称波澜壮阔。由于教育的原
因,很多人对 C++ 还停留在 C++98 之前的版本。殊不知 C++ 在经历从 2006 年之
后至今的 15 年“激流勇进式的发展”,在很多人眼里已经近乎变为一个全新的语
言。这 15 年间,国际 C++ 标准委员会发布的 4 个版本:C++11/14/17/20 也被统
称为“现代 C++”。因为这一时期对 C++ 发展的里程碑作用,我将其称为“现代
C++ 的文艺复兴”
。
本书来自于 C++ 之父 Bjarne Stroustrup 为 ACM 国际计算机协会的编程语言历史分
会(HOPL)2021 年会撰写的论文。我是在 2020 年全球 C++ 及系统软件技术大会
(Cpp-Summit)前和 Bjarne 沟通大会主题演讲时,听他几次提及本文,捧读后遂
一发不可收拾。我当时即判断这个长篇论文显然将是现代 C++ 发展历史上无可替
代的、最重要的文献。鉴于其重要性,我迫不及待地想将其引入中国 C++ 社区,
我将这一愿望表达给了 Bjarne。Bjarne 非常慷慨、也非常欣喜地授权我来组织本
书的中文翻译工作,并由全球 C++ 及系统软件技术大会以公益的方式在中国 C++
社区中发行。
本书对现代 C++ 从 2006 年到 2020 年间的发展做了百科全书式的回顾。它并不是
典型意义上的教科书,虽然它鞭辟入里地解释了现代 C++ 语言几乎所有的重要特
性和功能。更难能可贵的是 Bjarne 花了很多笔墨来交代很多重要特性和功能的来
龙去脉,它们的缘起、演化过程中的各种提案、最后的决策考量等——这些看后
都让人大呼过瘾,有“知其然、知其所以然”之痛快。
在所有编程语言里,C++ 最为独特、同时也争议极大。一方面,作为一门强大而
古老的编程语言,C++ 是当今人类信息基础设施的主要构建者,从航空航天到生
物信息,从电信设施到微电子,从互联网基础设施到人工智能,从汽车地铁到万
物互联,很难想象离开 C++编写的软件这些领域会怎样?在全球 IT 科技巨头中,
Google、微软、腾讯等,C++ 也都是当之无愧的首要编程语言。但另一方面,每
过一段时间都会有新语言出来号称挑战 C++,引发部分人担心 C++ 的地位会不会
被新语言所威胁?特别在对现代 C++ 发展不了解的人眼里,甚至由此产生对 C++
莫名的危机感。这个问题也经常在 C++ 技术大会上被问起。
Bjarne 对这个问题在书中有着很清晰的回答:C++ 在其近 40 年的发展中取得成功
的根本原因是,它填补了编程领域一个重要的“生态位”:需要有效使用硬件和管
理高复杂性的应用程序。C++ 的核心精神“直接映射硬件”和“零开销抽象”正
是对这一“生态位”恰如其分的支撑。换言之,如果不那么在乎性能开销,那么
C++ 并不是最好的语言选择(Java、Go、Python 等正是填补了这些领域);或者软
件规模不大、无需很多抽象手段来管理软件的复杂度,那么 C 语言就足够。但如
iii
现代 C++ 的文艺复兴
果性能是软件的关键指标,同时又有越来越大的复杂度,那么 C++ 几乎是独一无
二的选择。我们看到 C++ 这些年来的发展,都是紧扣 40 年前 Bjarne 为 C++ 设定
的“生态位”与“核心精神”而开展的。只有深刻理解这一点,才能从根本上抓
住 C++ 的发展脉络。
全书展示了 Bjarne 带领 C++ 标准委员会对现代 C++ 发展冷静、睿智、而又执着的
判断和决策。比如在面向对象大行其道的年代,Bjarne 就冷静地指出“一切皆对
象”是一种错误的偏执。面向对象不是 C++ 的所有,而仅仅是其支持的多种编程
范式(面向过程、面向对象、泛型编程、函数式编程)中的一种。在很多场合,
其他的编程范式要比面向对象更合适。再比如 Bjarne 早在 1994 年就提出需要为
泛型编程提供规范化接口这一重要设施:即后来被称为概念(concept)的语言机
制。但因为各种原因,概念自 2003 年提出后,遇到各种曲折的协商、争论、实现
障碍等诸多羁绊,最终在 Bjarne 呕心沥血的推动下,才正式纳入 C++20 的正式标
准。
本书另外一个难能可贵的地方是 Bjarne 对于 C++ 语言发展过程中一些缺失之处也
有非常深刻的反思。比如对于标准委员会过于关注语言和库的设计,而忽略“动
态链接、构建系统和静态分析等工具设施”
,Bjarne 也直言是一大错误。再比如,
对于很多专家的各种奇思妙想,Bjarne 甚至在 2018 年写了一篇文章《记住瓦萨
号!》来提醒标准委员会,追求大而全的新奇功能,而忽略稳定性对 C++ 是非常危
险的,后来为此领导标准委员会“方向组”提出《C++ 程序员的“权利法案”》。
Bjarne 还谈到 2006 年是 C++ 发展的最低谷,那时候本来打算推出的 C++0x 标准
由于委员会的决策机制和实现问题而变得遥遥无期。另一方面单核处理器的性能
停止提高(Herb Sutter 有著名的文章:“The Free Lunch Is Over”),这种环境对
C++ 语言的期待其实很高,但 C++ 那时候的发展缓慢,将很多本来是 C++ 的机会
拱手让位给了很多其他商业语言。
很多人对于主导 C++ 语言的幕后力量其实不甚了解,Bjarne 在本书中也花费笔墨
解释了国际 C++ 标准委员会的机制。这方面,我和 Bjarne 本人也有过几次交流。
如果盘点很多编程语言背后的主导力量,大致分以下几类:第一类是公司主导的
语言,例如 Go、Swift、C# 等。Bjarne 曾经谈过在 C++ 发展的历史上,有好几家
巨头希望说服他加入并将 C++ 纳入公司范畴,但 Bjarne 都清醒地婉拒了。我问为
什么?Bjarne 谈到这类语言由于有商业力量把持,有大笔资金投入,所以在一段
时间发展看起来非常快,配套支持也非常好。但由于被公司把持,只能为公司的
平台战略服务,一旦公司平台战略转向,那么这类语言也会快速衰落。回望编程
语言的发展历史,不得不佩服 Bjarne 在这个问题上的睿智和长远眼光。第二类是
个人英雄主义+社区主导的语言,比如 Python、PHP 等,这类语言在社区上有非
常旺盛的生命力,但 Bjarne 对这种野蛮生长的方式也有着天然的警觉,从长远来
看,他担心这种“一盘散沙”的方式会让一个语言错失方向和一些重大功能。
iv
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
Bjarne 早在 1989 年就为 C++ 语言选择了第三种方式,即“基于共识建立的国际标
准委员会”,这种机制使得要将一个特性纳入标准,首先要说服绝大多数的人同
意。
“共识流程”固然使得 C++ 标委会的决策速度缓慢,但它带来的是 C++ 语言惠
及所有人的、长期的繁荣和稳定。我个人认为,这种决策机制,也是使得 C++ 在
所有编程语言中显得非常独特的一个幕后原因。
最后我要特别谈谈本书的作者,广受尊敬的 C++ 之父 Bjarne Stroustrup。我和
Bjarne 相识于 2005 年,那是我们第一次邀请他来上海举办首届 C++ 技术大会并发
表主旨演讲。在相识相交的 16 年间,Bjarne 对待技术问题时的睿智和犀利、与人
相处时的平易近人,都给我留下极其深刻的印象。如果对比其他编程语言的发明
者,你会发现 Bjarne 也是一个独特的存在。1979 年,在贝尔实验室发明 C++ 时,
Bjarne 当时年仅 29 岁,正可谓风华正茂。后来当各路编程语言天才被各大公司重
金招至麾下,Bjarne 选择放弃各种公司诱惑,力排众议将 C++ 纳入 ISO 国际标
准,成立标准委员会,为 C++ 的百年发展大计殚精竭虑。在 C++ 语言发展的每一
个关键节点,从 C++98、C++11、C++14、C++17,一直到今天的 C++20,Bjarne
既亲力亲为、勇于开拓,也广开言路、从谏如流,在一些重大问题上发挥他无与
伦比的影响力和智慧。Bjarne 本人对 C++ 在中国的发展也非常热心,接受我的邀
请长期担任全球 C++ 及系统软件技术大会的联席主席,不辞辛劳地为中国 C++ 社
区播撒他的智慧,关心现代 C++ 在中国的教育,其情殷殷,其心切切。他对本书
中文版的发布也非常关心。
本书中文版的发布也要感谢由吴咏炜、杨文波、张云潮等组成的翻译团队(译序
中一一列出了他们的名字)
,他们都是来自中国 C++ 社区的热心贡献者。当然,最
要感谢的还是作者本人 Bjarne Stroustrup,他不仅是 C++ 的发明者,也是现代 C++
文艺复兴的缔造者。希望本书的智慧能够引领我们一起前进!
李建忠
全球 C++ 及系统软件技术大会联席主席
2021 年 10 月 于上海浦东
v
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
译序
这 是 C++ 之 父 Bjarne Stroustrup 的 HOPL4 论 文 (URL:
https://www.stroustrup.com/hopl20main-p5-p-bfc9cd4--final.pdf)的中文版。
HOPL 是 History of Programming Languages(编程语言历史)的缩写,是 ACM
(Association of Computing Machines,国际计算机协会)旗下的一个会议,约每
十五年举办一次。Bjarne 的这篇论文是他为 2021 年 HOPL IV 会议准备的论文,也
是他的第三篇 HOPL 论文。在这三篇前后间隔近三十年的论文里,Bjarne 记录了
C++ 的完整历史,从 1979 年到 2020 年。这篇 HOPL4 论文尤其重要,因为它涵盖
了 C++98 之后的所有 C++ 版本,从 C++11 直到 C++20。如果你对更早期的历史也
感 兴 趣 的 话 , 则 可 以 参 考 他 的 其 他 HOPL 论 文 (URL:
https://www.stroustrup.com/papers.html),及他在 1994 年出版的《C++ 语言的
设计和演化》
(The Design and Evolution of C++)
。
鉴于这篇论文对于 C++ 从业者的重要性,全球 C++ 及系统软件技术大会(URL:
http://cpp-summit.org/)的主办方 Boolan(URL: http://boolan.com/) 组织了一
群译者,把这篇重要论文翻译成了中文,让 C++ 开发人员对 C++ 的设计原则和历
史有一个系统的了解。参加论文翻译工作的译者有(按拼音序):
•
•
•
•
•
•
•
•
•
•
•
•
•
•
•
•
•
陈常筠
高辉
何荣华
何一娜
侯晨
侯金亭
彭亚
王奎
王绍新
吴咏炜
徐宁
杨文波
于波
余水清
翟华明
章爱国
张云潮
vii
译序
论文翻译的校对和体例统一工作由吴咏炜、杨文波、张云潮完成。最后的发布由
吴咏炜完成。
我们翻译的是论文的正文部分,英文原文超过 140 页。最后的参考文献部分,由
于翻译的意义不大,没有译出。如果想要阅读参考文献的话,只能请你到英文原
文结尾的 References 部分自行查找了。
翻译过程中我们发现了一些原文中的小问题,并在译文中进行了修正或标注(绝
大部分已经经过 Bjarne 老爷子确认)。当然,在翻译过程中引入翻译错误或其他
技术问题,恐怕也在所难免——不过,跟 ACM 上发表论文不同,线上版本(URL:
https://github.com/Cpp-Club/Cxx_HOPL4_zh)仍然是可以修正的。所以,如果
你,亲爱的读者,发现问题的话,请不吝提交 pull request,我们会尽快检查并进
行修正。
吴咏炜
viii
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
目录
摘要 ................................................................................................................................................................. i
现代 C++ 的文艺复兴 ........................................................................................................................... iii
译序 ............................................................................................................................................................. vii
目录 ............................................................................................................................................................... ix
1. 前言 ........................................................................................................................................................... 1
1.1 年表............................................................................................................................................... 2
1.2 概述............................................................................................................................................... 4
2. 背景:C++ 的 1979–2006 ............................................................................................................... 5
2.1 第一个十年 ................................................................................................................................ 5
2.2 第二个十年 ................................................................................................................................ 8
2.2.1 语言特性........................................................................................................................ 8
2.2.2 标准库组件................................................................................................................ 10
2.3 C++ 的 2006............................................................................................................................ 12
2.4 其他语言 .................................................................................................................................. 15
3. C++ 标准委员会 ................................................................................................................................ 19
3.1 标准............................................................................................................................................ 19
3.2 组织............................................................................................................................................ 20
3.3 对设计的影响 ........................................................................................................................ 24
3.4 提案检查清单 ........................................................................................................................ 32
4. C++11:感觉像是门新语言......................................................................................................... 35
4.1 C++11:并发支持................................................................................................................ 37
4.1.1 内存模型..................................................................................................................... 38
4.1.2 线程和锁..................................................................................................................... 40
4.1.3 期值(future) ........................................................................................................ 42
4.2 C++11:简化使用................................................................................................................ 45
4.2.1 auto 和 decltype ................................................................................................. 46
4.2.2 范围 for ..................................................................................................................... 50
4.2.3 移动语义..................................................................................................................... 51
4.2.4 资源管理指针 ........................................................................................................... 54
4.2.5 统一初始化................................................................................................................ 56
4.2.6 nullptr ..................................................................................................................... 59
4.2.7 constexpr 函数 ..................................................................................................... 60
4.2.8 用户定义字面量 ...................................................................................................... 62
ix
目录
4.2.9 原始字符串字面量 ................................................................................................. 63
4.2.10 属性............................................................................................................................ 63
4.2.11 垃圾收集 .................................................................................................................. 64
4.3 C++11:改进对泛型编程的支持 .................................................................................. 65
4.3.1 lambda 表达式 ......................................................................................................... 65
4.3.2 变参模板..................................................................................................................... 69
4.3.3 别名 .............................................................................................................................. 70
4.3.4 tuple........................................................................................................................... 72
4.4 C++11:提高静态类型安全 ............................................................................................ 74
4.5 C++11:支持对库的开发 ................................................................................................. 74
4.5.1 实现技巧..................................................................................................................... 75
4.5.2 元编程支持................................................................................................................ 76
4.5.3 noexcept 规约 ........................................................................................................ 76
4.6 C++11:标准库组件 ........................................................................................................... 78
5. C++14:完成 C++11 ....................................................................................................................... 81
5.1 数字分隔符 ............................................................................................................................. 82
5.2 变量模板 .................................................................................................................................. 82
5.3 函数返回类型推导............................................................................................................... 83
5.4 泛型 lambda 表达式 ........................................................................................................... 83
5.5 constexpr 函数中的局部变量 ..................................................................................... 84
6. 概念 ........................................................................................................................................................ 87
6.1 概念的早期历史 ................................................................................................................... 88
6.2 C++0x 概念 ............................................................................................................................. 90
6.2.1 概念定义..................................................................................................................... 92
6.2.2 概念使用..................................................................................................................... 93
6.2.3 概念映射..................................................................................................................... 93
6.2.4 定义检查..................................................................................................................... 94
6.2.5 教训 .............................................................................................................................. 95
6.2.6 哪里出错了? ........................................................................................................... 97
6.3 Concepts TS ............................................................................................................................ 99
6.3.1 定义检查.................................................................................................................. 101
6.3.2 概念使用.................................................................................................................. 103
6.3.3 概念的定义............................................................................................................. 104
6.3.4 概念名称引导器 ................................................................................................... 105
6.3.5 概念和类型............................................................................................................. 106
x
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
6.3.6 改进 ........................................................................................................................... 108
6.3.7 等效语法.................................................................................................................. 109
6.3.8 为什么在 C++17 中没有概念? ..................................................................... 110
6.4 C++20 概念 .......................................................................................................................... 112
6.5 概念的命名 .......................................................................................................................... 114
7. 错误处理 ........................................................................................................................................... 115
7.1 背景......................................................................................................................................... 115
7.2 现实中的问题 ..................................................................................................................... 116
7.3 noexcept 规约 .................................................................................................................. 119
7.4 类型系统的支持 ................................................................................................................ 120
7.5 回归基础 ............................................................................................................................... 120
8. C++17:大海迷航 ......................................................................................................................... 123
8.1 构造函数模板参数推导 .................................................................................................. 124
8.2 结构化绑定 .......................................................................................................................... 125
8.3 variant、optional 和 any ...................................................................................... 128
8.4 并发......................................................................................................................................... 130
8.5 并行 STL ................................................................................................................................ 131
8.6 文件系统 ............................................................................................................................... 132
8.7 条件的显式测试 ................................................................................................................ 133
8.8 C++17 中未包含的提议 .................................................................................................. 134
8.8.1 网络库 ...................................................................................................................... 134
8.8.2 点运算符.................................................................................................................. 135
8.8.3 统一调用语法 ........................................................................................................ 136
8.8.4 缺省比较.................................................................................................................. 138
9. C++20:方向之争 ......................................................................................................................... 141
9.1 设计原则 ............................................................................................................................... 141
9.2 我的 C++17 清单 ............................................................................................................... 143
9.3 C++20 特性 .......................................................................................................................... 144
9.3.1 模块 ........................................................................................................................... 145
9.3.2 协程 ........................................................................................................................... 148
9.3.3 编译期计算支持 ................................................................................................... 152
9.3.4 <=> ............................................................................................................................. 154
9.3.5 范围 ........................................................................................................................... 154
9.3.6 日期和时区............................................................................................................. 155
9.3.7 格式化 ...................................................................................................................... 156
xi
目录
9.3.8 跨度 ........................................................................................................................... 157
9.4 并发......................................................................................................................................... 159
9.5 次要特性 ............................................................................................................................... 160
9.6 进行中的工作 ..................................................................................................................... 161
9.6.1 契约 ........................................................................................................................... 161
9.6.2 静态反射.................................................................................................................. 165
10. 2020 年的 C++ ............................................................................................................................. 169
10.1 C++ 用来做什么? ......................................................................................................... 169
10.2 C++ 社区 ............................................................................................................................. 171
10.3 教育和研究 ....................................................................................................................... 172
10.4 工具 ...................................................................................................................................... 174
10.5 编程风格 ............................................................................................................................ 175
10.5.1 泛型编程 ............................................................................................................... 176
10.5.2 元编程 .................................................................................................................... 176
10.6 编码指南 ............................................................................................................................ 178
10.6.1 一般方法 ............................................................................................................... 179
10.6.2 静态分析 ............................................................................................................... 180
11. 回顾 .................................................................................................................................................. 183
11.1 C++ 模型 ............................................................................................................................. 183
11.2 技术上的成功................................................................................................................... 184
11.3 需要工作的领域.............................................................................................................. 185
11.4 教训 ...................................................................................................................................... 186
11.5 未来 ...................................................................................................................................... 188
致谢 .......................................................................................................................................................... 191
xii
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
1. 前言
最初,我设计 C++ 是为了回答这样的一个问题:如何直接操作硬件,同时又支持
高效、高级的抽象?C++ 在 1980 年代仅仅是一个基于 C 和 Simula 语言功能的组
合,在当时的计算机上作为系统编程的相对简单的解决方案,经过多年的发展,
已经成长为一个远比当年更复杂和有效的工具,应用极其广泛。它保持了如下两
方面的关注:
•
•
语言结构到硬件设备的直接映射
零开销抽象
这种组合是 C++ 区别于大多数语言的决定性特征。“零开销”是这样解释的
[Stroustrup 1994]:
•
•
你不用的东西,你就不需要付出代价(“没有散落在各处的赘肉”)。
你使用的东西,你手工写代码也不会更好。
抽象在代码中体现为函数、类、模板、概念和别名。
C++ 是一种活的语言,因此它会不断变化以应对新出现的挑战和演变中的使用风
格。2006 年至 2020 年期间的这些挑战和变化是本文的重点。当然,一门语言本
身不会改变;是人们改变了它。所以这也是参与 C++ 演化的人们的故事,他们识
别出面临的挑战,诠释解决方案的局限,组织他们的工作成果,并解决他们之间
必然出现的分歧。当我呈现一种语言或标准库特性时,其背景是 C++ 的一般发展
和当时参与者的关切。对于在早期被接受的许多特性,我们现在从大量的工业使
用中获得了后见之明。
C++ 主要是一种工业语言,一种构建系统的工具。对于用户来说,C++ 不仅仅是
一种由规范定义的语言;它是由许多部分组成的工具集的一部分:
•
•
•
•
•
•
•
语言
标准库
许多的其他库
庞大的——常常是旧的——代码库
工具(包括其他语言)
教学和培训
社区支持
只要有可能,只要合适,我就会考虑这些组成部分之间的相互作用。
1
1. 前言
有一种流传广泛的谬见,就是程序员希望他们的语言是简单的。当你不得不学习
一门新的语言、不得不设计一门编程课程、或是在学术论文中描述一门语言时,
追求简单显然是实情。对于这样的用途,让语言干净地体现一些明确的原则是一
个明显的优势,也是理想情况。当开发人员的焦点从学习转移到交付和维护重要
的应用程序时,他们的需求从简单转移到全面的支持、稳定性(兼容性)和熟悉
度。人们总是混淆熟悉度和简单,如果可以选择的话,他们更倾向于熟悉度而不
是简单。
看待 C++ 的一种方式是,把它看成几十年来三种相互矛盾的要求的结果:
•
•
•
让语言更简单!
立即添加这两个必要特性!
!
不要搞砸我的(任何)代码!
!
!
我添加了感叹号,因为这些观点的表达常常带着不小的情绪。
我想让简单的事情简单做,并确保复杂的事情并非不可能或没有必要地难。前者
对于不是语言律师的开发者来说是必不可少的;后者对于基础性代码的实现者是
必要的。稳定是所有意图持续运行几十年的系统的基本属性,然而一种活的语言
必须适应不断变化的世界。
C++ 有一些总体构想。我阐述了一些(如《C++ 语言的设计和演化》(The Design
and Evolution of C++)[Stroustrup 1994](§2)、设计原则(§9.1),以及 C++ 模型
(§11.1)
)并试图让语言在演化时遵循它们。然而,C++ 的开发由 ISO 标准委员会
控制,它主要关注的是长长的新特性列表,以及对实际细节的关心。这是社区里
最能表达和最有影响力的人所坚持的东西,仅仅基于哲学或理论观点就否认他们
的关切和意见的话,恐怕就失之鲁莽了。
1.1 年表
为了给出一个快速的概述,这里有一个粗略的年表。如果你不熟悉 C++,很多术
语、构造、库都会晦涩难懂;大多数在以前的 HOPL 论文 [Stroustrup 1993, 2007]
或本文中有详细解释。
•
•
1979 年:工作始于“带类的 C”,它变成了 C++;拥有了第一个非研究性
的用户;

语言: class 、构造函数/析构函数、 public/private 、简单继
承、函数参数类型检查

库:task(协程和仿真支持)、用宏参数化的 vector
1985 年:C++ 的首次商业发行;TC++PL1 [Stroustrup 1985b]

语言:virtual 函数、运算符重载、引用、常量
2
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
•
•
•
•
•
•

库:complex 算法,流输入输出
1989–91 年:ANSI 和 ISO 标准化开始;TC++PL2 [Stroustrup 1991]

语言:抽象类、多重继承、异常、模板

库:输入输出流(但没有 task)
1998 年 :C++98、 第 一 个 ISO C++ 标 准 [Koenig1998]、TC++PL3
[Stroustrup 1997]

语言:namespace、具名类型转换*、bool、dynamic_cast

库:STL(容器和算法)
、string、bitset
2011 年:C++11 [Becker 2011],TC++PL4 [Stroustrup 2013]

语言:内存模型、 auto 、范围 for 、 constexpr 、lambda 表达
式、用户定义字面量……

库:thread 和锁、future、unique_ptr、shared_ptr、array、
时间和时钟、随机数、无序容器(哈希表)……
2014 年:C++14 [du Toit 2014]

语言:泛型 lambda 表达式、constexpr 函数中的局部变量、数字
分隔符……

库:用户定义字面量……
2017 年:C++17 [Smith 2017]

语言:结构化绑定、变量模板、模板参数的构造函数推导……

库:文件系统、 scoped_lock 、 shared_mutex (读写锁)、 any 、
variant、optional、string_view、并行算法……
2020 年:C++20 [Smith 2020]

语言: concept 、 module 、协程、三路比较、改进对编译期计算
的支持……

库:概念、范围、日期和时区、span、格式、改进的并发和并行
支持……
请注意,早年 C++ 的库是很匮乏的。事实上,当时还是存在大量各种各样的库
(包括图形用户界面库),但很少被广泛使用,并且很多库是专有软件。这是在开
源开发普及之前的事。这造成了 C++ 社区没有一个重要的共享基础库。在我的
HOPL2 论文 [Stroustrup 1993] 的回顾中,我认为那是早期 C++ 最糟糕的错误。
任务库 [Stroustrup 1985a,c] 是一个基于协程的库,支持事件驱动的仿真(例如随
机数生成)
,与替代方案相比是非常高效的,甚至可以运行在很小的计算机上。例
如,我在 256KB 的内存中运行了 700 个任务的仿真。任务库在 C++ 早期非常重
要,是贝尔实验室和其他地方许多重要应用的基础。然而,它有点丑陋,并且不
*
译注:即新的、非 C 风格的类型转换。
3
1. 前言
容易移植到 Sun 的 SPARC 体系结构,因此大多数 1989 年以后的实现都不支持它。
2020 年,协程才刚刚回归(§9.3.2)
。
总的来说,C++ 的特性不断增多。ISO 委员会也废除了一些特性,对语言进行了稍
许清理,但是考虑到 C++ 的大量使用(数十亿行代码),重要的特性是永远不会被
移除的。稳定性也是 C++ 的关键特性。要解决跟语言不断增长的规模和复杂性相
关的问题,办法之一是通过编码指南(§10.6)
。
1.2 概述
这篇论文是按照 ISO 标准发布的大致时间顺序组织的。
•
•
•
•
•
•
•
•
•
•
•
§1:前言
§2:背景:C++ 的 1979–2006
§3:C++ 标准委员会
§4:C++11:感觉像是门新语言
§5:C++14:完成 C++11
§6:概念
§7:错误处理
§8:C++17:大海迷航
§9:C++20:方向之争
§10:2020 年的 C++
§11:回顾
如果一个主题跨越了一段较长的时间,比如“概念”和标准化流程,我会把它放
在一个地方,让内容优先于时间顺序。
这篇论文特别长,真是一篇专题论文了。但是从 2006 年到 2020 年,C++ 经历了
两次主要修订:C++11 和 C++20;而论文的早期读者们也都要求获得更多的信
息。结果就是论文的页数几乎翻倍。即使以目前的篇幅,读者也会发现某些重要
的主题没有得到充分的展现,如并发和标准库。
4
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
2. 背景:C++ 的 1979–2006
C++ 从 1979 年到 2006 年的历史记录在我的 HOPL 论文中 [Stroustrup 1993,
2007]。在那段时间里,C++ 从一个单人的研究项目成长为大约有 300 万程序员的
社区。
2.1 第一个十年
后来成为了 C++ 的东西始于 1979 年 4 月,名为带类的 C。我的目标是设计一个工
具,它既拥有直接而高效的处理硬件的能力(例如编写内存管理器、进程调度器
和设备驱动程序),又同时可以有类似 Simula 的功能来组织代码(例如“强”静
态可扩展类型检查、类、多级类和协程)。我想用这个工具编写一版 Unix 内核,
可以在通过局域网或共享内存互联的多个处理器上运行。
我选择 C 作为我工作的基础,因为它足够好,并且在办公室里就能得到很好的支
持:我的办公室就在 Dennis Ritchie 和 Brian Kernighan 走廊对面。然而,C 语言并
不是我考虑的唯一语言。Algol68 当时深深吸引了我,我还是 BCPL 和其他一些机
器 层 面 的 语 言 的 专 家。C 后 来 的 巨 大 成 功 在 当 时还 完 全 不 确 定 , 但是 Brian
Kernighan 和 Dennis Ritchie 杰出的介绍和手册 [Kernighan and Ritchie1978] 已经
出现,Unix 也正开始它的胜利路程。
最初我实现的是一个预处理器,它将“带类的 C” 差不多逐行翻译成 C。1982
年,在“带类的 C”的用户数量增长到了几十人的时候,这种方法已经显得无法
把控了。所以我写了一个传统的编译器,叫作 Cfront,1983 年 10 月第一次给别
人使用。Cfront 是一个传统的编译器,它有一个词法分析器、一个构建抽象语法
树的语法分析器、一个用类型装饰语法树的类型检查器,以及一个重新排列 AST
以提高生成代码的运行期效率的高层次优化器。关于 Cfront 的本质有很多困惑,
因为当时它最终输出的是 C(优化的,不是特别可读的 C)。我生成了 C,这样我
就不必直接处理当年正在使用的众多的(非标准化)链接器和优化器。不过,
Cfront 一点也不像传统的预处理器。你可以在计算机历史博物馆的源代码收藏
[McJones 2007–2020] 中找到一份带有文档的 Cfront 源代码。Cfront 从“带类的
C”自举为 C++,所以第一个 C++ 编译器是用(简单的)C++ 写的,适合非常小的
计算机(内存小于 1MB,处理器速度小于 1MHz)
。
“带类的 C” 添加到 C 上的第一个特性是类。我从早期在 Simula 中的使用中了解
到它们的力量,在 Simula 中,类是严格静态、但又可扩展的类型系统的关键。我
立即添加了构造函数和析构函数。它们当时非常新颖,但从我的计算机架构和操
作系统背景来看,我认为它们也不算很新奇,因为我需要一个机制来建立一个工
5
2. 背景:C++ 的 1979–2006
作环境(构造函数)和一个逆操作来释放运行期获得的资源(析构函数)。以下摘
自我 1979 年的实验记录本:
•
“new 函数”为成员函数创建运行的环境
•
“delete 函数”则执行相反的操作
“new 函数”和“delete 函数”这两个术语是“构造函数”和“析构函数”的原
始术语。直到今天,我仍然认为构造函数和析构函数是 C++ 的真正核心。另见
(§2.2.1)和(§10.6)
。
当时,除了 C 语言,基本上所有语言都有适当的函数参数类型检查。我认为没有
它我无法完成任何重要的事情。因此,在我的部门主管 Alexander Fraser 的鼓励
下,我立即添加了(可选的)函数参数声明和参数检查。这就是 C 语言中现在所
说的函数原型。1982 年,在看到让函数参数检查保持可选的效果后,我将其设为
强制的。这导致了十几二十年里关于与 C 不兼容的大声抱怨。人们想要保留他们
的类型错误,或者至少许多人大声说他们不想检查,并以此作为不使用 C++ 的借
口。这个小事实也许能让人们认识到,演化一门被大量使用的语言所涉及到的各
种问题。
鉴于过于狭隘的 C 和 C++ 爱好者之间偶尔会恶语相向,或许值得指出,我一直是
Dennis Ritchie 和 Brian Kernighan 的朋友,在 16 年里几乎天天同他们一起吃午
饭。我从他们那里学到了很多,现在还经常同 Brian 见面。我将一些对 C++ 语言
的贡献 [Stroustrup 1993] 归功于他们两位,而我自己也是 C 的主要贡献者(例如
函数定义语法、函数原型、const 和 // 注释)。
为了能够理性思考 C++ 的成长,我想出了一套设计规则。这些在 [Stroustrup
1993, 1994] 中有介绍,所以这里我只提一小部分:
•
不要陷入对完美的徒劳追求。
•
始终提供过渡路径。
•
说出你的意图(即,能够直接表达高层次的思路)
。
•
不要隐式地在静态类型系统方面违规。
•
为用户定义类型提供和内置类型同样好的支持。
•
应取消预处理器的使用。
•
不要给 C++ 以下的低级语言留有余地(汇编语言除外)。
这些并不是没有雄心壮志的目标。其中某些目标,现在 2020 年了我依然在为之努
力工作。在 1980 年代早期到中期,我给 C++ 添加了更多的语言功能:
6
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
•
•
•
•
•
•
1981 年:const——支持接口和符号常量的不变性。
1982 年:虚函数——提供运行期多态。
1984 年:引用——支持运算符重载和简化参数传递。
1984 年:运算符和函数重载——除了算法和逻辑运算符外,还包括:允
许用户定义 =(赋值)
、()(调用;支持函数对象(§4.3.1)
)
、[](下标访
问)和 ->(智能指针)
。
1987 年:类型安全链接——消除许多来自不同翻译单元中不一致声明的
错误。
1987 年:抽象类——提供纯接口。
在 1980 年代后期,随着计算机能力的急剧增强,我对大型软件更感兴趣,并做了
如下补充:
•
•
模板——在经历了多年使用宏进行泛型编程的痛苦之后,更好地支持泛型
编程。
异常——试图给混乱的错误处理带来某种秩序;RAII(§2.2.2)便是为此
目标而设计的。
后面这些功能并没有受到普遍欢迎(例如,见(§7))。部分原因是社区已经变得
庞大和难以管理。ANSI 标准化已经开始,所以我不再能够私下实现和实验。人们
坚持大规模的精心设计,坚持在认真实施之前进行广泛的辩论。我不能再在明知
道不可能让每个人都满意的情况下,从一个最小的提议开始,把它发展成一个更
完整的功能。例如,人们坚持到处使用笨重的带有 template<class T> 前缀的模
板语法。
在 1980 年代末,“面向对象”的宣传变得震耳欲聋,淹没了我对 C++ 传达的讯
息。我对 C++ 是什么和应当成为什么的看法被广泛忽视了——很多人甚至从未听
说过。对于“面向对象”的某些定义来说,所有新语言都应是“纯面向对象的”。
“不真正面向对象”被视为是糟糕的,不容争辩。
我从未使用过“C++ 是一种面向对象的编程语言”这种说法,这件事很多人并不
知道,或者因为感到有些尴尬而有意忽略了。那时候,我的标准描述是
C++ 是一门偏向系统编程的通用编程语言,它是
•
更好的 C
•
支持数据抽象
•
支持面向对象编程
•
支持泛型编程
7
2. 背景:C++ 的 1979–2006
这个说法过去和现在都是准确的,但不如“万物皆对象”这样的口号令人兴奋!
2.2 第二个十年
ANSI C++ 委员会是 1989 年 12 月在华盛顿特区的一次会议上成立的,距离第一次
使用“带类的 C”这个名称仅仅 10 年多的时间。大约有 25 名 C++ 程序员出席了
会议。我出席了会议,还有另外一些近些年来依然活跃的 ISO C++ 标准委员会成员
当时也在。
经过了惯例性的、大约十年的工作,该委员会终于发布了第一个标准:C++98。
我和许多其他人自然更愿意更快地输出一个标准,但是委员会规则、过度的雄心
和各种各样的延迟使我们在时间表方面与 Fortran、C 和其他正式标准化的语言站
在了同一起跑线上。
形成 C++98 的工作是 HOPL3 论文的核心 [Stroustrup 2007],所以这里我只简单总
结一下。
2.2.1 语言特性
C++98 的主要语言特性是
•
•
•
•
•
•
•
模板——无约束的、图灵完备的、对泛型编程的编译期支持,在我早期工
作(§2.1)的基础上进行了许多细化和改进;这项工作仍在继续(§6)
。
异常——一套在单独(不可见的)路径上返回错误值的机制,由调用方栈
顶上的“在别处” 的代码处理;见(§7)
。
dynamic_cast 和 typeid——一种非常简单的运行期反射形式(
“运行期
类型识别”
,又名 RTTI)
。
namespace——允许程序员在编写由几个独立部分组成的较大程序时避免
名称冲突。
条件语句内的声明——让写法更紧凑和限制变量作用域。
具名类型转换——(static_cast、reinterpret_cast 和 const_cast)
:
消除了 C 风格的类型转换中的二义性,并使显式类型转换更加显眼。
bool:一种被证明非常有用和流行的布尔类型;C 和 C++ 曾经使用整数作
为布尔变量和常量。
让我们看一个简单的 C++98 例子。dynamic_cast 是面向对象语言中常被称为类似
“是某种”的概念的 C++ 版本:
void do_something(Shape* p)
{
if (Circle* pc = dynamic_cast<Circle*>(p)) { // p 是某种 Circle?
// ... 使用 pc 指向的 Circle ...
8
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
}
else {
// ... 不是 Circle,做其他事情 ...
}
}
dynamic_cast 是一个运行期操作,依赖于存储在 Shape 的虚拟函数表中的数据。
它通用、易用,并且与其他语言类似的功能一样高效。然而,dynamic_cast 变得
非常不受欢迎,因为它的实现往往是复杂的,特殊情况下手动编码可能更高效
(可以说这导致 dynamic_cast 违反了零开销原则)。在条件语句里使用声明很新
颖,不过当时我认为我只是沿用了 Algol68 里的这个主意而已。
一种更简单的变种是使用引用而不是指针:
void do_something2(Shape& r)
{
Circle& rc = dynamic_cast<Circle&>(r);
// ... 使用 rc 引用的 Circle ...
}
// r 是某种 Circle!
这简单地断言 r 指代一个 Circle,如果不是则抛出一个异常。思路就是,错误能
够在本地被合理地处理时,使用指针和测试,如果不能则依赖引用和异常。
C++98 中最重要的技术之一是 RAII(Resource Acquisition Is Initialization, 资源获
取即初始化)。那是我给它取的一个笨拙的名字,想法就是每个资源都应该有一个
所有者,它由作用域对象表示:构造函数获取资源、析构函数隐式地释放它。这
个想法出现在早期的“带类的 C ”中,但直到十多年后才被命名。这里有一个我
经常使用的例子,用来说明并非所有资源都是内存:
void my_fct(const char* name) // C 风格的资源管理
{
FILE* p = fopen(name, "r"); // 打开文件 name 来读取
// ... 使用 p ...
fclose(p);
}
问题是,如果(在 fopen() 和 fclose() 的调用之间)我们从函数 return 了,或
者 throw 了一个异常,或者使用了 C 的 longjmp,那么 p 指向的文件句柄就泄漏
了。文件句柄泄漏会比内存泄漏更快地耗尽操作系统的资源。这个文件句柄是非
内存资源的一个例子。
解决方案是将文件句柄表示为带有构造函数和析构函数的类:
class File_handle {
FILE* p;
9
2. 背景:C++ 的 1979–2006
public:
File_handle(const char* name,const char* permissions);
~File_handle(); // 关闭文件
// ...
};
// 打开文件
我们现在可以简化我们的用法:
void my_fct2(const char* name)
{
File_handle p(name,"r");
// ... 使用 p ...
} // p 被隐式地关闭
// RAII 风格的资源管理
// 打开文件 name 来读取
随着异常的引入,这样的资源句柄变得无处不在。特别的,标准库文件流就是这样一个资源句
柄,所以使用 C++98 标准库,这个例子变成:
void my_fct3(const string& name)
{
ifstream p(name);
// 打开文件 name 来读取
// ... 使用 p ...
} // p 被隐式的关闭
请注意,RAII 代码不同于传统的函数使用,它允许在库中一劳永逸地定义“清理
内存”,而不是程序员每次使用资源时都必须记住并显式编写。至关重要的是,正
确和健壮的代码更简单、更短,并且至少与传统风格一样高效。在接下来的 20 年
里,RAII 已遍布 C++ 库。
拥有非内存资源意味着垃圾收集本身不足以进行资源管理。此外,RAII 加上智能
指针(§4.2.4)消除了对垃圾收集的需求。另见(§10.6)
。
2.2.2 标准库组件
C++98 标准库提供了:
•
•
•
•
•
•
STL——创造性的、通用的、优雅的、高效的容器、迭代器和算法框架,
由 Alexander Stepanov 设计。
特征(trait)——对使用模板编程有用的编译期属性集(§4.5.1)
。
string——一种用于保存和操作字符序列的类型。字符类型是一个模板参
数,其默认值是 char。
iostream——由 Jerry Schwartz 和标准委员会精心制作,基于我 1984 年
的简单的数据流,处理各种各样的字符类型、区域设置和缓冲策略。
bitset——一种用于保存和操作比特位集合的类型。
locale——用来处理不同文化传统的精致框架,主要与输入输出有关。
10
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
•
•
valarray——一个数值数组,带有可优化的向量运算,但遗憾的是,未见
大量使用。
auto_ptr — — 早 期 的 代 表 独 占 所 有 权 的 指 针 ; 在 C++11 中 , 它 被
shared_ptr (共享所有权)和 unique_ptr (独占所有权)(§4.2.4)替
代。
毫无疑问,STL 框架是最为重要的标准库组件。我认为可以说,STL 和它开创的泛
型编程技术挽救了 C++,使它成长为一种有活力的现代语言。像所有的 C++98 功
能一样,STL 在其他地方已经有了广泛的描述(例如 [Stroustrup 1997, 2007]),
所以在这里我只会给出一个简单的例子:
void test(vector<string>& v,
{
vector<string>::iterator
= find_if(v.begin(),
if (p != v.end()) { //
// ... 使用 *p ...
}
else {
//
// ...
}
list<int>& lst)
p
v.end(), Less_than<string>("falcon"));
p 指向 'falcon'
没找到 'falcon'
vector<int>::iterator q
= find_if(lst.begin(), lst.end(), Greater_than<int>(42));
// ...
}
标准库算法 find_if 遍历序列(由 begin/end 定界)寻找谓词为真的元素。该算
法在三个维度上都是通用的:
•
•
•
序列元素的存储方式(这里是 vector 和 list)
元素的类型(这里是 string 和 int)
用于确定何时找到元素的谓词(此处为 Less_than 和 Greater_than)
注意这里没有用到任何面向对象的方法。这是依赖模板的泛型编程,有时也被称
为编译期多态。
模板的写法仍然很原始,但是从 2017 年左右开始,我可以使用 auto(§4.2.1)、
范围(§9.3.5)和 lambda 表达式(§4.3.1)来简化代码:
void test2(vector<string>& v, list<int>& lst)
{
auto p = find_if(v,[](const string& s) { return s<"falcon"; })
if (p!=v.end()) {
// ...
11
2. 背景:C++ 的 1979–2006
}
// ...
auto q = find_if(lst,[](int x) { return x>42; })
if (q!=lst.end()) {
// ...
}
// ...
}
2.3 C++ 的 2006
2006 年,我和 ISO C++ 委员会的大多数其他成员都对功能丰富的 C++0x 标准寄予
厚望。计划在 2007 进行特性冻结,所以我们有一个合理的预期,C++0x 将是
C++08 或 C++09。事实上 C++0x 变成了 C++11,引出了关于十六进制 C++0xB 的笑
话。
在我 2006 年的 HOPL 论文 [Stroustrup 2007] 中,我列出了 39 个提案,并预测前
21 个会进入 C++0x。有趣的是,我列表上的前 25 个建议中,有 24 个进入了
C++11。我把提案 22–25 列为“正在制定中,目标是在 2007 年 7 月进行投票”。
令我惊喜的是,它们全都成功了。而提案 26–39 则连 C++17 都没有进入。这中间
就留下了第 10 号提案“概念”
,它有一个自己的长长的悲伤故事,不过最终还是
以进入 C++20 而快乐收尾。
我和其他许多人对 C++0x 的延迟感到沮丧,并担心在面对来自更现代、资金更充
足的替代品的竞争时,一个未经改进的 C++ 可能无法作为一种活的语言生存下
去。在 2006 年,Java 的使用仍在增加,微软的 C# 也有大量的支持和营销。我在
2006 年的估计是 C++ 的使用在过去 4 年中首次略有下降。获取真实的数字很难,
我的最佳估计(下降 7%)完全在误差范围内,但确实有理由去担心。类似 Java
和 C# 这样的语言会作出这样一种假设,并常常大声宣扬:C++ 没有生态位:
•
•
•
•
“低级编程”可以由少量的 C 或汇编代码处理。
“高级编程”则可以使用一种带有巨大的运行时支持系统的更安全、更小
并使用垃圾收集的语言来做,这样可以更好、更便宜、更高效地完成。
像 Java 和 C# 这样的托管语言使用垃圾收集和一致的运行期范围检查,使
得不太专业的程序员能更有生产力,这样可以减少对高技能的开发人员的
需求。
编程语言与平台的深度集成,并使用集成工具来进行支持,这对生产力和
大型系统的构建至关重要。
显然,我和许多其他人并不同意。但这些在过去和现在都是严肃的争辩,它们如
果正确的话应该导致 C++ 被放弃使用。C++ 基于传统的编程语言模型,与底层操
作系统分离,并由众多独立的工具供应者提供支持。托管语言往往是专有的;只
12
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
有一个庞大而富有的组织才能开发所需的庞大基础设施和库。我和 C++ 社区中的
其他许多人更喜欢不受公司控制的语言;这是我参加 ISO 标准工作的一个原因。
回想起来,2006 年可能是 C++ 的最低谷,但重要的技术事件也恰好在此时发生
了:大约在 2005 年,历史上第一次单个处理器(单核)的性能停止提高,能效
(
“每瓦特的性能”)成为一个关键指标(尤其是对于服务器集群和手持设备)。计
算经济学转而青睐更好的软件。硬件的进步再也不能完全掩盖语言或编程技术的
低效。这样,执掌“利器”的高手跟差点的程序员或受工具链开销束缚的程序员
相比,能赢得高一个数量级的经济优势,而这种优势十多年之后还依然存在。即
使在今天,这些事实还没有被所有的教育和管理体制充分领会,但是现在有许多
重要的任务,为它们花时间精心打造高性能的代码会获得巨大的回报。
另一个转折点来自供应商,他们试图通过定义标准接口(比如图形用户界面)将
自己喜欢的语言强加给所有用户,而这只能通过使用他们喜欢的、通常是专有的
语言来实现。比如谷歌对安卓系统使用 Java,苹果对 iOS 使用 Objective-C,微软
对 Windows 使用 C#。应用程序供应商可以尝试通过使用一些编程方言来避开锁
定 , 例 如 Objective C++ [Objective C++ Wikipedia 2020] 或 C++/CLI [ECMA
International 2005],但是这样写出的代码仍然不可移植。许多组织,比如如
Adobe、谷歌和微软,他们的响应方式是使用 C++ 编写他们要求苛刻的应用程序
的主要部分,然后为各种平台(如 Android、iOS 和 Windows)使用薄接口层。
2006 年时这一趋势几乎不引人注目。
在便携式设备(尤其是智能手机)上,对能效和平台独立性的需求是彼此融合
的。一个影响是,据我在 2018 年的最佳估计,自 2006 年以来 C++ 程序员的数量
增长了约 50%,达到约 450 万名开发人员 [Kazakova 2015]。也就是说开发者每年
增长 15 万人,十年来每年大约增长 4%。
2006 年,很少有人注意到硬件趋势对 C++ 固有优势的滋养。而社区和标准委员会
正在关注新的语言特性和库,以增加 C++ 的实用性并提高对它的热情。包括我在
内的一些委员感到迫切需要重大改进。其他人更关注于稳定语言和改进它的实
现。一个标准委员会需要这两个群体,但创新和整顿之间不断的拉锯战是紧张的
来源。就像在任何大型组织中一样,维护现状和服务当前用户的人有组织上优
势。在《C++ 程序设计语言(第三版)》[Stroustrup 1997] 中,我引用了尼科
洛·马基雅维利(Niccolo Machiavelli)的话:
没有什么比开创一种新秩序更难于推行、更让人怀疑能否成功、处理起来
更加危险。因为改革者会与所有从旧秩序中获利的人为敌,而所有从新秩
序中获利的人却只是冷淡的捍卫者。
我的观点是 C++ 需要显著的改进来更好地服务于它的用户群体。C++ 应用程序被
大规模部署,但是新项目通常选择更流行的语言,一些成功的 C++ 项目被改写成
13
2. 背景:C++ 的 1979–2006
这样的语言。举例来说,谷歌的许多大规模应用,如搜索,一直是基于他们的
map-reduce 框架 [Dean and Ghemawat 2004, 2008]。它就是 C++ 程序。然而,由
于 它 因 为 商 业 原 因 是 专 有 的 , 人 们 复 制 了 它 , 而 开 源 的 map-reduce 框 架
(Hadoop)出于各种原因是用 Java 实现的。这对于 C++ 社区来说是一件憾事。
开发转向其他语言的另一个重要原因是,模板提供的接口的灵活性使得使用所有
C++ 特性并提供稳定的 ABI 变得极其困难:可以灵活,也可以提供稳定的二进制
接口,但大多数组织都做不到两者兼顾。我认为人们之所以需要 C++ 编写的程序
提供 C、Java、C# 之类的接口,这是个促成因素。C++ 的 ABI 稳定性是一个真正的
技术难题,尤其是因为 C++ 标准必须独立于平台。
除了 C++ 社区的问题,到 2006 年,随着纸质出版的减少以及记者们关注流行技术
和广告收入,大多数涉及 C++ 的专业软件杂志已经死亡。Dr. Dobbs 期刊又持续了
几年(2009 年 2 月停刊)。C++ 会议被吸收到“面向对象”或一般软件开发会议
中,剥夺了 C++ 社区展示新发展的场所。书籍仍在编写中,但程序员阅读的书籍
越来越少(或至少购买的书越来越少,因为盗版变得越来越容易,因此统计数据
变得越来越不可靠)
,在线资源变得越来越受欢迎。
一个更严重的问题是 C++ 在教育中的作用正在急剧下降。C++ 不再是“新的、有
趣的”
,而 Java 正作为一种更简单、更强大的语言被直接推向大学。美国高中计算
机科学考试突然从 C++ 变成了 Java。在大学里,Java 作为入门语言的使用急剧增
加。C++ 的教学质量也在下降,大多数课程优先选择 C 语言,或者认为严重依赖
类层次结构的面向对象编程是唯一正确的方法。这两种方法都弱化了 C++ 的优
势,并且需要大量使用宏。标准库(依靠泛型编程;(§2.2))和 RAII(依赖构造
函数/析构函数对(§2.2.1)
)经常被完全排除在基础课程之外,或者被放在一个所
谓的“高级特性”部分,大多数学生要么从未接触过,要么认为它很可怕。教科
书经常陷入晦涩难懂的细节。当然也有例外,但平均来说,呈现给学生的 C++ 远
不是最佳的工程实践。在 2005 年,我接受了挑战,给大学一年级的学生教编程。
我调查了大约二十本最流行的 C++ 编程教材,最后大声抱怨:
如果那就是 C++,我也会不喜欢它!
在用一本著名的教科书教了一年书后,我开始只用自己的教案,并且在 2008 年出
版了《C++ 程序设计:原理与实践》(Programming: Principles and Practice Using
C++)[Stroustrup 2008a],但直到今天,许多 C++ 教学仍带有 1980 年代的特色。
尽管如此,C++ 的使用又开始增加了。我认为这是因为根本的技术趋势再次青睐
C++,并且在二十一世纪的第一个十年结束的时候,C++11 的出现也有所帮助。
Boost 库和 Boost 组织非常重要 [Boost 1998–2020]。1998 年,经验丰富的开发者
及 WG21 的有影响力的成员 Beman Dawes 建立了一个“C++ 代码库网站”[Dawes
14
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
1998],其明确目标是开发 C++ 库以确立现有实践,使得未来的标准化可以据此进
行。在此之前,C++ 甚至从来没有一个公共的代码库。Boost 慢慢成长为一个活跃
的组织,有新库的同行评审和一年一度的会议。Boost 库被广泛使用,最流行的被
吸收到标准中(例如, regex (§4.6)、 thread (§4.1.2)、 shared_ptr (§4.6)、
variant(§8.3)和文件系统(§8.6)
)。对于 C++ 社区来说重要的是,Boost 库比
它们的 ISO 标准版本早十多年,但仍被当作某种“预备标准”来信任。有许多委
员会成员都参与了 Boost,特别是 Dave Abrahams、Doug Gregor、Jaakko Jarvi、
Andrew Sutton,当然还有 Beman Dawes。
到 2006 年,C++ 在业界已经不再是新鲜刺激的东西,但它遍布很多行业。在 C++
诞生的电信行业,它一直被大量使用。从电信领域出发,它已经扩展到游戏(如
Unreal、PlayStation、Xbox 和 Douglas Adams 的《宇宙飞船泰坦》)、金融(如摩
根士丹利和 Renaissance Technologies)
、微电子(如英特尔和 Mentor Graphics)
、
电影(如皮克斯和 Maya)、航空航天(如洛克希德·马丁和美国国家航空航天
局)和许多其他行业。
就我个人而言,我特别喜欢 C++ 在科学和工程中的广泛使用,比如高能物理(例
如 CERN 欧洲核子研究中心、SLAC 国家加速器实验室、费米实验室)、生物学(例
如人类基因组项目)、空间探索(例如火星漫游车和深空通信网络)、医学和生物
学(例如断层扫描、常规成像、人类基因组项目和监控设备)等等。
2.4 其他语言
人们常常会寻找其他编程语言对 C++ 的直接技术影响。其实非常之少。典型情况
是,影响涌现自共同的前代语言和共同思想(而非特定的现有语言)。扩展 C++ 的
决定性理由往往与 C++ 社区中已经发现的问题有关。直接从流行语言中借鉴的情
况并不常见,而且比人们想象的要困难得多。大多数标准委员会成员都掌握多种
语言,并密切留意(其他语言中)有用的功能、库和技巧。
下面是其他语言在二十一世纪对 C++ 的某些真实或假想的影响:
•
•
•
auto——从初始化器推断类型的能力。它在现代语言中很流行,但也已由
来已久。我不知它的最早起源,但我在 1983 年实现这个功能的时候,也
并不认为它很新颖(§4.2.1)
。
tuple——许多语言,特别是源自函数式编程传统的语言,都有元组,它
通常是一个内置类型。C++ 标准库 tuple 及其许多用法都从中受到启发。
std::tuple 派生自 boost::tuple [Boost 1998–2020](§4.3.4)
。
regex——加入 C++11 的标准库 regex 是(经由 Boost;已致谢)从 Unix
和 JavaScript 的功能中拷贝来的(§4.6)
。
15
2. 背景:C++ 的 1979–2006
•
•
•
•
•
•
•
•
函数式编程——函数式编程特性和 C++ 构造之间有许多明显的相似之处。
大多数不是简单的语言特性,而是编程技巧。STL 受到函数式编程的启
发,并首先在 Scheme [Stepanov 1986] 和 Ada [Musser and Stepanov 1987]
中进行了尝试(未成功)
。
future 和 promise——源自 Multilisp,经由其他 Lisp 方言(§4.1.3)
。
范 围 for — — 许 多 语 言 中 都 有 对 应 物 , 但 直 接 启 发 来 自 STL 序 列
(§4.2.2)
。
variant、any 和 optional——显然受到多种语言的启发(§8.3)
。
lambda 表达式——显然,部分灵感来自于函数式语言中 lambda 表达式的
应用。但是,在 C++ 中,lambda 表达式的根源还可以上溯到 BCPL 语言中
用作表达式的代码块、局部函数(多次被 C 和 C++ 拒绝,因其容易出错且
增加了复杂性)和(最重要的)函数对象(§4.3.1)
。
final 和 override——用于更明确地管理类层次结构,并且在许多面向对
象的语言中都可以使用。在早期的 C++ 中已经考虑过它们了,但当时被认
为是不必要的。
三向比较运算符 <=>,受 C 的 strcmp 及 PERL、PHP、Python 和 Ruby 语
言的运算符的启发(§9.3.4)
。
await——C++ 里最早的协程(§1.1)受 Simula 启发,但是作为库提供,
而不是作为语言特性,这是为了给其他替代的并发技术留出空间。C++20
中的无栈协程的思想主要来自 F#(§9.3.2)
。
即使以非常直接的方式从另一种语言借用了某个特性,该特性也会发生变化。通
常,为了适合 C++ 语法会发生很大变化。当从支持垃圾收集的语言借鉴时,生命
周期问题必须得到解决。而 C++ 区分对象和对象的引用,这通常使得 C++ 需要以
和原始语言不同的方式来解决。在“翻译”成 C++ 的过程中,经常会发现全新的
用法。在把 lambda 引入 C++ 的过程中,出现了大量的此类现象的事例(§4.3.1)
。
在很多人的想象中,我(和其他参与 C++ 的人)整日无所事事,满脑子想的是在
流行语言中如何占据主导地位,为这个复杂的语言战争制定战略。实际上,我并
没有在那上面花时间。大多数日子里,我不会去思考其他的语言,除非我碰巧出
于一般的技术兴趣去学习一门其他语言或要使用它来完成一些工作。我要做的是
与软件开发人员交谈,考虑人们在使用 C++ 时遇到的问题,还要考虑潮水般涌入
标准委员会的改进建议。当然,我也编写代码来体验问题并测试改进思路。问题
在于要能抽出时间冷静地考虑,什么是根本的,什么只是一时流行,以及什么会
造成危害。
同样,C++ 对其他语言的贡献也难以估量。通常,类似的特性是平行演化的,或
有着共同的根源。例如:
16
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
•
•
•
•
•
Java 和 C# 中的泛型——他们采用了其他语言的泛型模式,但采用了 C++
语法,并且是在 C++ 大规模展示了泛型编程的用途之后,才添加泛型。
Java、Python 等的资源弃置惯用法(dispose idiom)——这大致是在垃圾
收集语言中最能接近析构函数的做法了。
D 编 程 语 言 进 行 编 译 期 求 值 — — 我 向 Walter Bright 解 释 了 早 期 的
constexpr 设计。
C++ 基于构造函数和析构函数的对象生存期模型是 Rust 灵感的一部分。好
笑的是,最近 C++ 经常被指责从 Rust 那里借用了这种想法。
C 采用了 C++11 的内存模型、函数声明和定义语法、以声明为语句、
const、// 注释、inline 以及 for 循环中的初始化表达式。
C++ 与其他语言之间的许多差异源于 C++ 对析构函数的使用。这使得垃圾收集的
语言很难直接从 C++ 借用。
17
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
3. C++ 标准委员会
国际 C++ 标准委员会正式名称为 ISO/IEC JTC1/SC2/WG21,它是 C++ 发展的核
心。自 1991 年成立以来,就一直如此。而从 1989 年开始到它成立之前,C++ 开
发的中心则是美国国家标准学会(ANSI)的 C++ 标准委员会 [Stroustrup 1993]。
C++ 没有腰缠万贯的所有者,也没有其他重要的资金提供来源,因此社区依赖于
企业开发和开源项目。对于很多处于相互竞争的组织中的人来说,WG21 和各个
国家的标准委员会是他们能够会面并联合解决问题的唯一场合。
委员会成员都是志愿者,也没有带薪的秘书处,虽然许多委员确实以其工作组织
的代表身份出现。在每次会议上,都会有人自豪地声称就代表“自己”。也就是
说,他们没有得到赞助,只代表自己。有些人换工作后,就会代表新组织,这种
情况并不少见。许多人以“参加 C++ 标准委员会”作为接受新工作的条件。有人
加入了委员会来学习 C++,有人则把“C++ 委员会成员”当作资格来引用(并非
一定是真的)
。
有些人仅参加过几次会议,不那么经常。另一方面,也有人一直参加了大多数会
议,数十年没有间断。一开始时,还有现在,我们一年开三次会。在 1998 年标准
之后的几年里,我们一年只开两次会。目前,除了面对面的会议,还有很多的电
话会议进行补充,以及天天都有的大量电子邮件。
在这里,我会描述
•
•
•
标准的作用(§3.1)
委员会的组织(§3.2)
委员会的结构对 C++ 设计的影响(§3.3)
3.1 标准
标准委员会的目的是编写标准。制定标准的一个官方理由是“促进贸易,特别是
减少国际贸易的技术壁垒和人为障碍”
,及“提供实现经济、效率和互操作性的框
架”。标准是规范,不是实现。它的目的是保持多个实现一致,并确定“一致性”
在一个必须能够有效利用各种不同底层硬件的世界里到底意味着什么。许多程序
员在理解这一点上存在问题。他们要么认为当前的编译器就是语言的定义,要么
难以理解为什么很难在许多不同的、通常是竞争性的组织之间达成 100% 的协
议。在 1990 年代,委员会考虑过制定形式规约(formal specification),但咨询过
世界一流的专家后得出结论,规约技术的进展和成员的水平都还达不到制定 C++
形式规约的程度。当然,也考虑过参考实现,但语言的复杂度,特别是与硬件使
用和优化相关的问题,已经挫败了这种想法。如果有参考实现,它会太复杂,也
会代价过大。要么就得把它简化到一种对最困难的问题没有帮助的程度,但这样
19
3. C++ 标准委员会
的困难问题正是最需要参考实现的场合。再有,当 N 个彼此竞争的实现团队记录
他们的决策、运行广泛的合规性测试并讨论它们的不同之处时,会有些意外收
获;如果已经有了个复杂的参考实现,就可能掩盖掉这样的意外收获。对于
C++,从前端实现来说(Clang、EDG、GCC 和微软)N 至少为 4,至于后端,N 少
说有十几个。
因此,标准委员会正在努力解决拥有多种实现带来的问题。另一条路是冒险搞单
一文化。如果 C++ 技术只来源于一个组织,那么无论好坏,每个人都会得到相同
的东西。一个控制“唯一真正实现”的组织将在社区中拥有主导话语权,他们出
现的问题,就会影响到所有人。特别是,一旦有资金问题、商业顾虑、政治见解
或者技术上的一意孤行,就会严重破坏语言及其用户群体。
无论好坏,C++ 社区选择了“半组织”的混乱,里面有一个很大的委员会加上多
个编译器、工具及库的供应者。我们没有用统一所有权或独裁者模式。
3.2 组织
对于 C++17 和 C++ 20 的工作,每次面对面的 WG21 会议有多达 250 人出席,而总
成员人数约为出席人数的两倍。此外,加拿大、芬兰、法国、德国、俄罗斯、西
班牙、英国、美国等十几个国家都有国家标准委员会以及 C++ 标准技术联盟的付
费支持成员。成员代表了一百多个组织。为了让大家有所了解,在此列举部分成
员所属组织:苹果、Bloomberg、欧洲核子研究中心、Codeplay、EDG(Edison
Design Group)、Facebook、谷歌、IBM、英特尔、微软、摩根士丹利、英伟达、
Qt、高通、红帽、Ripple、美国 Sandia 国家实验室、拉珀斯维尔应用科技大学
(HSR)和马德里卡洛斯三世大学。编译器供应者、硬件供应者、金融、游戏、
库供应者、平台供应者、国家实验室(物理)等都有坚实的代表。早期 C++ 中突
出的电信业者的身影已经减少,而过去极少的大学的身影似乎在增加。
显然,如此庞大的组织和个人组成的群体代表着千差万别的兴趣和技术背景,需
要一个组织结构来运作。会议是围绕工作组(WG)和研究组(SG)进行组织的。
2019 年的夏天,我们已经有了这样一些分组:
•
•
•
•
核心工作组(Core WG 或 CWG)——编写语言的最终标准文本——主席
Michael Miller(EDG)
。
库工作组(Library WG 或 LWG)——为标准库编写最终标准文本——主席
Marshall Clow(C++ 联盟,之前代表高通)。
演化工作组(Evolution WG 或 EWG)——处理语言建议——主席 Ville
Voutilainen(Qt,之前代表 Symbio)。
库演化工作组(Library Evolution WG 或 LEWG)——处理标准库提案——
主席 Titus Winters(谷歌)
。
20
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
研究组探索新领域并设计可能的标准化:
•
•
•
•
•
•
•
•
•
•
•
•
SG1 并发——并发和并行性主题——主席 Olivier Giroux(英伟达)
。
SG5 事 务 性 存 储 — — 探 索 事 务 性 存 储 的 构 造 — — 主 席 Michael Wong
(Codeplay,之前代表 IBM)
。
SG6 数值——包括但不限于定点数、浮点数和分数——主席 Lawrence
Crowl(
“自己”
,之前代表谷歌和 Sun)
。
SG7 编译期编程——最初专注于编译期反射,然后扩展到一般的编译期编
程——主席 Chandler Carruth(谷歌)。
SG12 未定义的行为和漏洞——系统地审查漏洞和未定义/未指定的行为—
—主席 Gabriel Dos Reis(微软,之前代表得州农工大学)
。
SG13 人机界面和 I/O——精选的底层输出(例如图形、音频)和输入
( 例 如键 盘、 指点 设备) 的 I/O 原 语— —主 席 Roger Orr( 英 国 标准
(BSI)
)
。
SG14 游戏开发和低延迟——游戏开发者和其他有低延迟要求的人感兴趣
的主题——主席 Michael Wong(Codeplay,之前代表 IBM)
。
SG15 工具——与针对标准 C++ 的开发者工具创建有关的主题,其中包括
但不仅限于模块和包管理——主席 Titus Winters(谷歌)
。
SG16 Unicode——与 C++ 中的 Unicode 文本处理相关的主题——主席 Tom
Honermann(Synopsis)
。
SG19 机器学习——主席 Michael Wong(CodePlay,之前代表 IBM)
SG20 教育——探索可以支持学习者和教师掌握今天的 C++ 的方法——主
席 Jan Christiaan van Winkel(谷歌)
SG21 契约——在 C++20 失败后尝试设计出契约系统(§9.6.1)——主席
John Spicer(EDG)
2017 年成立了一个小组来解决与语言和标准库设计缺乏方向有关的问题 [Dawes
et al. 2018]。该方向组(DG)的成员由召集人与工作组主席协商后任命,其成员
是委员会、语言和标准库的长期贡献者。最初的成员是 Beman Dawes、Howard
Hinnant、Bjarne Stroustrup、David Vandevoorde 和 Michael Wong。之后,Beman
退休,Roger Orr 加入。DG 的主席是轮流担任的,从我开始。DG 是咨询机构,其
政策是只有在其成员一致同意的情况下才能提出意见。它维护有一份描述其建议
的文档 [Dawes et al. 2018; Hinnant et al. 2019]。
工作组可持续十年以上,成员变化也很少。研究组则聚散自由,可能因兴趣使
然,或者因为工作已经完成并提交给工作组进行最后的处理。例如,四个最重要
的研究组已宣告胜利完成并解散:
•
•
SG2 模块——主席 Gabriel Dos Reis(微软,之前代表得州农工大学)
。
SG3 文件系统——主席 Beman Dawes(“自己”)
。
21
3. C++ 标准委员会
•
•
SG8 概念——主席 Andrew Sutton(俄亥俄州阿克伦大学,之前代表得州
农工大学)
。
SG9 范围——更新 STL,以使用概念,简化表示法,及提供无限序列和管
道——主席,Eric Niebler(Facebook)
。
SG4 网络,目前处于休眠状态,因为其结果正在等待被合并到标准(§8.8.1)中。
另一个研究组 SG11 数据库,因缺乏共识和缺乏足够数量的志愿者完成工作而解
散。
某些研究组会产出技术规范(TS)
,这些技术规范可能是具有重要意义的文件,也
以标准本身的风格写就。它们具有一定的官方(ISO)地位,但不能提供国际标准
(IS)所具有的长期稳定性。并发研究组(SG1)自 2006 年以来一直活跃,大部
分时间由 Hans-J. Boehm(谷歌,之前代表过惠普实验室和 SGI)领导,它的地位
已经接近 WG 了。
除了这些分组外,还有一个半官方的 C/C++ 联络组,由同时加入 C++ 委员会和 C
委员会(ISO/SC22/WG14)的成员组成。这个小组力图减少 C 和 C++ 之间的不兼
容性,而 C++ 标准也会把每种不兼容之处记录下来。如果没有联络小组的不断努
力,C 和 C++ 的兼容性远没有现在好。不过,即便如此,大多数从 C++ 导入 C 的
特性都被修改过,而这就引入了一些不兼容性。
ISO 只需要也只认可三名正式官员:
•
•
•
召集人——担任工作组主席,制定工作组会议时间表(召开会议),任命
研究组,并向更高级别的 ISO(SC22、JTC1 和 ITTF)负责——Herb Sutter
(微软),自 2002 年以来一直担任该职位的工作,除 2008–2009 年期间
是由 P.J. Plauger(Dinkumware)担任。
项目编辑——最终负责将委员会批准的更改应用于标准的工作草案——
Richard Smith( 谷 歌 );Pete Becker(Dinkumware) 负 责 C++11;
Stefanus Du Toit(Intel)负责 C++14。
书记——负责记录和分发 WG21 会议的会议纪要——Nina Ranns(Edison
Design Group,之前代表 Symantec)
。
各个国家的标准委员会有各自自己的官员和章程。
显然,这些年来这些职位由不同的人担任过,但尽管工作量通常很大,很少有人
在职少于 5 年。我曾担任 EWG 的主席 24 年,到 2014 年才把这一职位移交给 Ville
Voutilainen。
通常,较小的提案直接提交给 EWG 和/或 LEWG,较大的提案则从研究组开始。
提案需要以书面形式提出,并有人进行演示。一般来说,处理一项重要的提案需
22
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
要数次会议(通常为数年)
,并且需要数篇论文、修订论文和反复演示。最后,已
经获得大力支持的提案将提交给整个委员会进行最终表决。召集人查看表决结果
并裁定是否达成共识。共识不只是要多数。委员会更倾向于能在经过工作组处理
和投票后获得一致同意,如果达不到,通常至少也需要 9 比 1 或 8 比 2 的优势。
召集人很可能会认为 8 比 2 的多数票“未达成共识”
。如果国家标准机构的负责人
或几个主要委员表示强烈反对,就会发生这种情况。这样议题就会处于悬而未决
的状态或者导致提案只被部分采纳。
标准会议令人筋疲力尽。通常,委员从早餐到午夜一直在讨论工作问题。大多数
时候,正式会议在 8:30–12:30 和 14:00–17:30 举行,加上大多数时候都会进行的
晚间会议(19:00–22:00)
。正在准备提案的委员的工作时间比这些还要长。WG 和
SG 主席一般在大多数用餐时间都在开会。周一至周五是全天,而如果没有任何意
外发生,大多数委员会成员到了星期六的 15:00 左右会收工。不过,当会议在诸
如夏威夷科纳(Kona)之类的好地方举行时,委员会以外的人似乎都不愿意相信
开会并不是什么度假。
在 WG 和 SG 里,每个出席者都可以投一票。委员会全体会议的正式投票则是每个
到会的组织一票(这样,大型组织就不会有多票),再加上国家标准机构的票数。
“技术性投票”和国家机构投票必须一致才算达成共识。
委员会 2006 年以前的历史记录在 [Stroustrup 1993, 1994, 2007] 中。C++ 基金会
(§10.2)在其网站(isocpp.org/std)上维护了一份会及时更新的描述,涵盖组
织、关键人物和委员会流程。
从 1989 年起,委员会的所有论文几乎都可以从一份文集中获取到 [WG21 1989–
2020]。目前,该文集每年增加 500 多篇论文。另外,很多委员会的讨论是在已归
档的邮件列表中进行的。每天可能有超过一百条邮件消息。要跟上委员会中发生
的所有事情非常难,特别是由于很多事情需要专门的技术知识才能跟进。我将自
己的 WG21 论文集保存在主页 [Stroustrup 1990–2020] 上。
传统上,ISO 标准每十年左右修订一次。例如,我们有 C89、C99 和 C11。如此长
的修订周期是有问题的,如果新特性错过了特性冻结,我们就会要再等上 12 年左
右才能将它加入标准。人们自然就会主张将即将通过的标准拖延一两年:“这个特
性太重要了,不能等,因此得延迟一下标准的发布!”这就是为什么原本的 C++0x
结果成了 C++11,在 C++98 后过了 13 年。
在 C++11 之后,一些委员会成员希望缩短周期,召集人 Herb Sutter 建议我们采用
列车模型。也就是说,列车在预定时间出发,任何没上车的人将不得不等待下一
班。大家喜欢这个建议,也花了挺长时间讨论标准修订之间的合适间隔。我主张
短点,3 年,因为再长(例如 5 年)就容易被“这个特性非常重要,等不了”这样
的说法拖累,导致发布延迟。我们商定了三年的发布周期,Herb Sutter 补充建议
23
3. C++ 标准委员会
采用交替发行大版本和小版本的英特尔“滴答”模型。这也得到了同意,因此在
C++11(§4)三年后,我们发布了 C++14(§5),它纳入了之前被延迟的特性并纠
正了早期使用中发现的小问题。C++17 也按时交付,但可惜并不是一次大升级
(§8)
。C++20 在 2019 年 2 月通过投票,确定了完整的发布特性。最终技术性投
票于 2020 年 2 月在布拉格完成。
3.3 对设计的影响
这样的工作组织方式、复杂的决策流程以及大量的参与者会如何影响 C++ 的发
展?看看委员会的规模、组成及其流程,我认为,任何建设性成果居然能从中产
生,都足以令人惊喜。这已经不只是“委员会的设计”了,而是“多委员会的联
合设计”
。
此外,委员会的管理结构非常薄弱,甚至缺乏最基本的管理工具:
•
•
•
成员资格、发言或投票没有任何资质要求(例如,学历或实际经验)。支
付 ISO 会员费(2018 年美国会员为 1280 美元)并参加两次会议,就能拥
有正式投票权。在研究组和工作组中,任何人都可以发言与投票,即使这
是他们的第一次参加会议。
除了让提案得到采纳,以及看到改进后的标准而感到满足,并没有任何其
他回报。不过,满足感确实是一个主要动力。
没有真正的办法来阻止破坏性行为。非官方委员会管理人员所能做的只是
有礼貌地提醒人们不要做别人认为具有破坏性的事情。然而委员们对于什
么是有破坏性的,意见也不一致。
当考虑在一个大型委员会里演化一门语言的各种问题之前,请记住委员会里大部
分时间和工作都是为了解决“小问题”;就是那些不会上升到语言设计哲学、学术
出版物、或会议演示层面的问题。它们对于防止语言及其标准库被分割成方言,
并保证在编译器和平台之间的可移植性至关重要。这些问题包括:命名、名称查
找、重载决策、语法细节、构造的确切含义、临时变量的生存周期、链接,还有
其他很多很多。许多问题需要技巧才能得以解决,而拙劣的解决方案可能带来让
人吃惊而具有破坏性的后果。解决方案往往经过精心设计,以最大程度减少对现
有代码的破坏。委员会每年解决数百个问题。我估计委员至少要为此花费他们时
间和精力的三分之一,乃至于三分之二。这项工作往往被忽视和低估。如果你用
过计算机或计算机化的设备(例如电话或汽车),你得感谢 CWG 和 LWG 的工作。
当关注由一个庞大的委员会引起的问题时,也请记住,这些问题本质是一种有钱
人的烦恼:C++ 的标准化流程由数百位各种不同背景的热心人士所驱动,他们的
经验各不相同,但都满怀理想主义。
24
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
委员会应起到过滤作用,也就是说,把坏提案挡在标准之外,同时,还要提升最
后通过的提案的品质。委员会的存在,是要鼓励大家提出建议,并主动提供帮
助。然而,并没有正式的提案征求流程。
不存在全职的 C++ 设计者,尽管有许多全职人员从事 C++ 编译器、库和工具方面
的工作。直到最近,委员会中还很少有人从事应用程序开发,这是一个问题,因
为它使委员会偏向于语言“律师”、高阶特性和实现问题,而不是直接解决大量
C++ 开发者的需求——很多成员只是间接地了解这些需求。最近新成员急剧增
加,也许会部分缓解这个问题。
委员会中的教育工作者相对较少。这可能是个问题,因为委员会(理所当然地)
高度重视“易于学习”,但是委员们对其含义有着非常不同的理念(经常会意见强
烈)
。这往往使关于“简单性”和“易用性”的讨论变得混乱。
当思考组织问题对 C++ 发展的影响时,请记住,ISO 流程本不是为 200 人的会议
而设计的——典型的 ISO 编程语言委员会只有一二十人。平均而言,我们在某种
程度上是通过识别和解决问题来进行管理。考虑下面这些观察到的问题:
•
延迟:多阶段的流程为延迟、阻止提案和提案变化提供了很多机会。常常
出现几十名委员坚持要满足他们的要求的情况,往往是通过阐释、扩展和
寻找特例的方式。一个人眼中的过度延迟在另一个人看来却是尽职尽力。
例如:概念(当前方案为 6 年(§6))、契约(从开始到失败花了 6 年
(§9.6.1))、网 络(15 年 , 仍在 进行 中(§8.8.1)) 和 constexpr(5 年
(§4.2.7)
)
。甚至 nullptr 被接受也花费了三年时间(§4.2.6)
。
•
孤立特性:大多数委员会成员喜欢特性的添加。另一方面,他们(非常合
理地)深刻地担心破坏现有代码的可能性。这给了孤立特性系统性的优
势,孤立特性是不影响语言和标准库其余部分的小提案。这样的小提案很
少会对语言的使用产生重大影响,但却会增加学习和实现的复杂性。而
且,到头来,它们往往还是会和其他特性发生令人惊讶的交互。
例如:大多数在本语言演化总结中不值得提及的特性。结构化绑定
(§8.2)和运算符 <=>(§8.8.4)都需要多次会议去完善。
•
后来者居上:有时经过多年的工作之后,提案已接近投票表决,一些一向
未曾关注提案的委员此时进入讨论并提供了替代提案。这样的提案可能与
原始提案有戏剧性的差异,或者只是一系列小的请求。这往往导致延迟、
混乱、甚至有时是争执。这种时候,已经议定的问题又重被激活,而未经
尝试(通常也未实现)的新想法和多年工作的成果获得了接近相等的权
重。对老提案而言,瑕疵已经被发现过了,相应的技术折中也已经完成。
25
3. C++ 标准委员会
人们很容易想象新事物的好处而忘记意外后果定律:意外后果总是会出现
的。新的和相对未经审查的总是看起来比老的更好。这使得较早提案的拥
护者变得具有防御性,从而分散了进一步完善“老提案”的精力。在这里
“老”可能只是几年,或者就像概念(§6)那样十几年。有时,接受未经
尝试的后期变更(所谓改进)是为了安抚反对派;这经常导致意外的后
果。后期加入讨论的人们,通常不会认为有“冲刺的必要”,而是自然地
希望他们自己的想法得到认真考虑(而通常并没有认真考虑老提案的细节
和理由)
。这就可能会与已经在老提案上投入多年工作的人们产生摩擦。
例 子 : 结 构 化 绑 定 ( 语 法 更 改 , 对 位 域 的 新 增 支 持 , 笨 拙 的 get()
(§8.2))、概念(§6)。数字分隔符(§5.1)、点运算符(§8.8.2)、模块
(§9.3.1)
、协程(§9.3.2)
、契约(§9.6.1)
。
•
热情总青睐新事物:唤起对新事物的热情比反对它们容易。每个提案都是
为某人解决某事,支持者愿意花大量时间展现其价值。而要反对它们,有
人就不得不说像这样的话:




“不,这个问题不是那么重要。
”
“不,这种解决方案有缺陷。”
“不,你还没有充分记录解决方案。
”
“不,你还没有仔细检查替代方案。
”
不管措辞怎么客气,这都让反对者看起来更像“坏人”,是他们阻碍了进
步并否认支持者需求的合理性。更糟糕的是,拥护者总是比反对者花费更
多的时间来准备论文和演讲。大多数人喜欢对自己相信的事物进行建设性
的工作,而不是小心地拆除他人的工作。因此,支持者通常都很热情并且
准备充分,而反对者总是显得意见含糊而不懂细节。然而,每项新特性都
有其成本:如设计、规范、实现、修订、部署和教学(§9.5)。我害怕在
演化工作组度过周四下午。那时,EWG 成员经过几天的大提案工作而感
到疲倦,许多老成员(例如我)已经被拖入其他小组,参会者又急于看到
有成果。这种时候,小提案就会只经受相对较少的审查而滑入标准。
例如:条件中的显式测试(§8.7)
、inline 变量(§8)、结构化绑定的后期
更改(§8.2)
。
•
过度自信:相对于整个语言及标准库的复杂度,尤其是不同应用领域的
C++ 用户所面临的问题的复杂度,个人在日常工作中能获得的经验总是不
足的。并非所有委员会成员都能意识到这一局限,或是能通过质疑自身经
验的推广价值加以弥补。这就导致某些一般性有限的提案被过度推广。更
糟糕的是,一些委员强烈反对某些提案,是因为他们不认为有必要解决该
26
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
提 案 所 针 对 的 问 题 。 语 言 设 计 需 要 一 定 的 智 力 上 的 谦 逊 [Stroustrup
2019b]。先想出来的解决方案很少是最好的,而未经进一步认真思考就提
出轻率的反对意见和建议很少会带来改进。
例子:出于对犯错者的保护,就不举例了。
•
实现时机不当:在标准流程中,实现提案晚了有风险:特性出现严重缺
陷、有潜在无法实现的部分、以及缺乏使用反馈;实现早了也有风险:特
性以不完整的、次优的且难以使用的形式冻结。委员会中的许多人不会投
票赞成尚未实现或以他们不信任的方式实现的提案。另一方面,许多实现
者不愿意为委员会未批准的提案投入实现资源。这是一个困难而现实的两
难困境。委员会经常听到“它已经实现了吗?”的问题。通常,“它是经
过设计的吗?” 和“要如何使用?” 是更重要的问题。人们很容易在细
节中迷失。我提出的走出这一困境的方法是,就建议的方向、提案的总体
范围达成一致,然后从一个相对较小子集的详细设计和实现出发,以关键
用例为指导前进。这样,我们可以相对较早地获得用户体验,并了解该特
性如何与其他特性交互。这需要对这种语言应该是什么有一个长远的看法
[Stroustrup 1993, 1994, 2007](§1),
(§11.2),否则语言就会沦为机会主
义的零敲碎打。如果这个方法起作用,语言将从反馈和有机增长中受益。
例子:模块(§9.3.1)
、C++ 0x 概念(§6)和 <=>(§8.8.4)
。
•
特性交互:最难处理的问题之一是特性的组合使用。一定程度上这是规范
和实现的技术问题。因此,这会占用大量委员会时间。从设计的角度来
看,更难的问题是要预计新特性在整个语言的语境中如何使用,这些语境
包括其他正在考虑中的语言和库的新特性。每个特性都应设计成便于同其
他特性结合使用。我担心这一点没有得到重视。很少有提案书提供详细的
讨论,而委员会里关于特性交互的讨论往往简短或混乱。其结果之一是,
个别特性趋于膨胀而只好把它孤立于语言的其余部分才能用起来。
例子:tuple(§4.3.4)和 <=>(§9.3.4)。为 lambda 表达式(§4.3.1)中的
动作指定专用语法的(失败)提案。
•
篇幅和分心:千头万绪往往同时发生,没有人能全跟得上。那些尝试全部
关注的人,就容易失去对真正重要课题的关注,而把注意力分散在一些事
实证明并不那么重要的课题上。如今每年有超过 500 篇委员会论文,有些
长达数十甚至数百页。与 2010 年代初相比,文献总篇幅翻了一番。我注
意到,2018 年秋天的会前邮件(新论文汇总)的字数是莎士比亚全集的
三倍。
27
3. C++ 标准委员会
电子邮件的泛滥最让人分心,因为许多委员喜欢通过一波一波地爆发短邮
件来进行技术讨论。在这样的讨论中掉队意味着失去对问题的跟踪,其结
果可能是,共识只是从几个一直能跟得上讨论的人中间浮现。
这种讨论不利于冷静而系统地权衡各种选择。有时候,它会导致不幸的特
性滑入标准。有时候,它会导致不一致的设计理念体现于语言和标准库的
不同部分,进而损害了互操作性。
例子:any、optional 和 variant 的不同接口。概念(§6)
。
•
精确规范:标准是规范,而不是实现。但是,标准是用英语编写的,因此
我们做不到数学般的精度。委员会的许多成员擅长数学,但不擅长数学的
人更多,因此在规范中没办法使用数学记法。试图使英文文本精确而详
尽,则会让文本变得生硬又难以理解。我常常很难理解标准中对我自己提
案的描述。
大多数委员是程序员,而不是设计师,因此规范有时看起来会像程序——
用没有类型系统或编译器的低级语言写成的程序。有详尽的如果、那么、
否则的说明,却很少写出不变量。更糟糕的是,很多词汇是继承自 C,而
且是基于程序源代码文本中的标记,因此,更高级别的概念仅被间接提
出。
奇怪的是,标准库规范在结构上明显比语言规范更为正式。
•
经院主义:当然有必要大力强调标准文本的正确性和准确性。但是,人们
有时会忘记标准本身可能就是错误的,而仅根据标准文本的论证来讨论正
确性。这样一来,根据标准文本所应反映的模型和使用上的论证,反倒可
能被忽略。
•
方向:哪些问题是真实的?重要吗?对于谁?哪些紧急?十年后,哪些解
决方案仍然有意义?有些事情也许算个问题,但这并不意味着它必须在语
言里有直接的解决方案。尤其是,委员会很难记住这一点:一种语言不可
能对所有人来说都是万能的。更难以接受的是,它居然不能解决每个委员
最紧急的问题 [Stroustrup 2018d]。
例子:C++17(§8)和 C++ 20(§9)。
•
专一关注:一些委员仅关注一个或两个课题,例如语言技术、易用性、
“可教学性”、效率、使用单一编程风格、在单个行业中使用、在单个公
司中使用、单个语言特性等。对于专一关注的委员而言,这可能是一种非
常有效的技巧,但这样做会让广泛的、平衡的进展变得困难。过分相信理
28
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
论或个人经验则是这个问题的另一类例子。一个好的建议在许多领域都会
推动进步,但通常不能在所有这些方面都达到完美。
•
原则的不适当应用:将一般原则应用于具体事例通常很困难。有时,我们
会不与其他原则进行必要折中,就去严格应用某项原则。折中的必要性是
《设计和演化》一书 [Stroustrup 1994] 将设计原则称为“经验法则”的原
因之一。有时,似乎没有经验基础就凭空冒出来一个原则。有时,一个提
案严格遵循了某一个原则,而另一个提案则忽略它。有原则的设计很困
难;它需要品味、经验以及原则。实用的语言设计不只是从第一原理出发
进行演绎的练习。通常,多种原则之间必须进行权衡。
•
倾向专家的偏见:想象别人的问题总是困难的。委员会成员几乎都是某方
面的专家。在日常工作中,他们通常是处理最细微、最复杂问题的人。这
样的问题在“外面”的数十亿行常规 C++ 代码中一般不常见,而且也不是
大多数 C++ 程序员所苦恼的问题。但是,对委员会来说,专家级的问题通
常就是紧急问题,也是最容易通过流程的问题。
例子:支持 enable_if 和类型特征(§4.5.1)在标准库中的使用简直水到
渠成,但接受概念(§6)却大费周章。
•
聪明的问题:委员会成员一般是聪明人,他们中许多人无法抵御机灵的解
决方案。此外,他们也很难断定,并非每个问题都值得解决,而拥有解决
方案也并不意味着我们必须将其纳入标准。这会带来过于精巧的特性,带
来大多数程序员用不着的特性。公平起见,也需要指出,许多程序员也很
聪明,有时也会以使用过分机灵的语言和标准库特性为乐。
例子:在有些提案中,即使简单用法也需要用上严肃的模板元编程。
•
不愿妥协:大多数委员会成员都有强烈的意见,但要在一个大型团体中达
成共识需要妥协。分辨哪些妥协无关紧要,而哪些妥协事关基本原则,有
时会很困难。后一类妥协可能对语言造成破坏,应该避免。不幸的是,当
委员们坚信自己所担忧的才至关重要时,他们比起心态开放的委员就有了
有关键的战术优势。有些人能做到从整体上关注语言而不纠结于个别话
题,但他们往往得向不能如此的人们屈服。而反过来,那些从不认真质疑
自己的原则或需求的人,倒往往可以向别人视为必要的技术妥协发动猛
攻。取得进展需要关注整个社区,有自知之明,并懂得适当的谦逊
[Stroustrup 2019b]。
•
缺乏优先级:从技术的角度来看,所有问题都是平等的:不精确的规范就
是不精确规范,这一点与它未能正确规定的内容是什么不相干。任何可能
29
3. C++ 标准委员会
从类型系统的漏洞中混进代码的错误原则上都可能造成死亡和毁灭。但
是,现实世界中不同错误的影响可能大不相同。实际上,大多数晦涩的细
节基本上没有破环性。有些人在研究设计细节时很难记住这一点。
例子:在数字分隔符(§5.1)上花费的时间比在范围 for(§4.2.2)上花费
的时间更多。
•
完美主义:一个标准预期会被几百万人用到,并且可以稳定数十年。人们
自然希望它是完美的。这会导致特性膨胀(特性过多),尤其是导致单个
特性的膨胀。程序员善于想象出问题,特性在委员会走流程的时候,委员
们会坚持要它解决掉所有想象中的问题。这会导致严重的使命偏离,并导
致只有专家才会喜爱的特性。这也可能导致特性一直无法加入标准。
例子:. 运算符(§8.8.2)
、网络库(§8.8.1)和异常规约(§4.5.3)
。
•
少数人的阻挠:共识流程可以防止某些类型的错误,尤其是防止多数人的
暴政。但是,它很容易受到个人和小团体的阻挠。这可以是好事(避免错
误),但是当它在提案流程的各个阶段一再发生,或正好在最后一刻发生
时,就会具有破坏性了。
例子: constexpr (§4.2.7)、 . 运算符(§8.8.2)、模块(§9.3.1)和协程
(§9.3.2)
。
•
内聚的团体:许多工作组和研究组都拥有稳定的核心人员群体,这些年来
他们形成了内聚的技术观、共享的词汇表和特定的运作方式。这会使“外
部人员”难以交流和贡献。这也可能使设计跨越 WG 边界的特性(例如同
时具有库和语言部分的特性)变得困难。每个小组都往往会设计出适合其
自身组织结构领域的内容,再次印证了老格言,即系统的结构总是长得像
创造它的组织的结构。
例子:范围 for (§4.2.2)和可能需要更改语言的并发机制(§4.1.3)。
any、optional 和 variant(§8.3)的接口差异。
从积极的一面来看,基于个人敌意或针锋相对的行为非常罕见。从这个意义上
讲,委员会是非常专业的。
幸运的是,并非每个提案都受所有这些现象的影响,并且大多数其他大型项目也
会遇到这类问题。但是,以其 ISO 标准所代表的 C++ 语言,整体上反映出了这些
现象。它们不是新问题,但是自 C++11 起出现得越来越多。我怀疑它们是由以下
因素共同造成的
30
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
•
•
•
•
委员会人数增加
新人的涌入
成员的专业化(分散化)
成员对 C++ 历史的了解有所减少
尽管存在这些严重的问题,但标准制定流程仍屡屡成功,原因之一是很多人不断
努力将负面影响降到最低。方向组(Direction Group)的建立就是这方面的努力
的一部分(§3.2)[Dawes et al. 2018; Stroustrup 2018d]。另见(§11.4)。工作组
主席、笔记记录员、会议组织者和编辑组的不懈努力是无形的,但却至关重要。
例如,Jens Maurer 数十年来一直在 CWG 中做笔记,帮助提案者编写标准文本,
安排网络访问,为无法出席的成员安排电话接入,安排会议室,告知成员当地旅
行的可能性,等等。
有其他方案吗?在理想的世界里,我会建议限定由一小部分(大约 5 人)的全职
受信任专家委员做决定,而由大团队完成(例如超过 350 人的委员会)完成讨
论、提案、以及大部分流程。但我不认为 C++ 会发展成这样,因为:
•
•
•
没有人喜欢放弃权力(在这种情况下是投票权)。
要为固定的全职专家团队保持稳定的资金投入需要非同小可的技能(而这
种技能在 C++ 社区还没有出现)
。
成功的时刻不会发生激进的变化;只有 C++ 使用量的显著下降才能促进委
员会进行剧烈的组织创新(那时多半已经为时已晚)
。
我不认为公司控制是可行的替代方案,因为:
•
•
•
公司期望投资回报。
公司的支持往往几年后就会消失。
公司往往选择差异化的优势,而不是惠及所有人的进步。
我也不认为完全开放的审议流程(成千上万的投票者)是可行的:
•
•
超过千人的投票就会失去品味。
大群体的成员和意见没法在几十年里保持稳定。
对许多大型开源项目起作用的分级审批程序可能至少提供了部分方案,但是在 C
和 C++ 的标准化开始时,这方面的经验很少。当这样一个系统运行良好时,你在
审批层级中的地位越高,审批者的知识基础就越广阔,他们关注的领域也就越广
泛。在组织结构顶部,我们会找到一人或多人,他们对所有知识都有所了解、对
所有用户都有所关心。与此鲜明对比的是,随着提案接近最终批准,ISO 流程会稀
释专业知识和关注领域:全体会议上,许多委员对他们不感兴趣、领域经验有限
31
3. C++ 标准委员会
且没有密切关注的提案进行投票。人们努力想负起责任,但是这真的很难。还要
从大局角度来看待每个提案,把它们当作其中的一部分,那就几乎不可能了。
这样看来,WG21 的工作还不算糟糕。我确实担心这样的工作模式能否使 C++ 长
久保持连贯、并且与时俱进。从另一个角度来看,出席 C++ 标准会议的有 200 多
人,比其他标准的团体要大一个数量级,而 ISO 的流程本来就是为那种较小的团
体设计的。另外,委员的多样性远远超过了过去的老三样:头发斑白的专家、公
司代表、以及国家机构代表。混乱有可能爆发。
我从温斯顿·丘吉尔的格言中得到些许安慰,“民主是最糟糕的政府形式,除了所
有那些人类一再尝试过的其他形式”
。
特别要指出,我不认为经常被建议的“仁慈的终身独裁者”模式可以规模化,而
且,不管怎么说,该模型从来就没对 C++ 适用过。
在我心目中,启动语言设计项目的理想模式是单个人或一小群密切配合的朋友。
但我看不到这种方式可以规模化。一门成熟的语言需要数十甚至数百个人来解决
他们必须面对的各种问题。即使只是与相关的标准、行业组织进行协调,也会让
一个小规模、紧密配合的团体彻底应接不暇。
3.4 提案检查清单
C++98 有个“如何编写提案”的指南 [Stroustrup et al. 1992],但奇怪的是,演化
组并没有为提给 C++14、C++17 或 C++20 的提案准备一份检查清单。有一份针对
标准库提案的检查清单 [Meredith 2012]。对于 C++20,国家标准机构负责人的一
份说明 [van Winkel et al. 2017] 和 Direction Group 的一份文件 [Hinnant et al. 2019]
给出了一些指导。以下是一个简短而不完整的问题清单,这些问题几乎总会被提
给一项提案:
•
•
•
•
•
•
•
•
•
要解决的问题是什么?将为什么样的用户提供服务?新手?专家?
解决方案是什么?阐明它所基于的原则。给出简单的使用案例和专家级的
使用案例。
有哪些替代解决方案?库解决方案是否足够?为什么现有功能不够好?
为什么解决方案需要在标准中?
采用该技术存在哪些障碍?从现有的技术过渡可能需要多久?
已经实现了吗?在实现过程中遇到了或预期会遇到哪些问题?有用户体验
吗?
会不会有很大的编译期开销?
该特性是否能融入到现有工具和编译器的框架中?
与变通方案相比,会有运行期开销吗?在时间上?在空间上?
32
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
•
•
•
•
•
•
•
•
•
•
•
会有兼容性问题吗?会破坏现有的代码吗?ABI 会被破坏吗?
新功能将如何与现有功能和其他新功能交互?
解决方案是否容易教授?教给谁?谁来教?
标准库会受到怎样的影响?
该提案是否会导致对未来标准进一步扩展的要求?
该特性在标准里如何措辞表达?
用户在使用新功能时可能会犯哪些错误?
就整个 C++ 社区的利益而言,该提案是否属于前 20 名?前 10?
该提案是否属于特定子社区的前三名?哪个子社区?
该提案是解决某一类问题的通用机制还是某个特定问题的特定解决方法?
如果是针对一类问题,是哪一类问题?
该提案在语义、语法和命名方面是否与语言的其余部分一致?
理想的情况是,一项提案能够回答所有这些问题,甚至更多,但这种情况很少发
生。特别是,在最初的提案中,理由往往非常薄弱,因为提案者认为所处理的问
题的重要性和他们建议的解决方案非常明显。然而,后续的论文、修改、电子邮
件讨论和演化组的面对面讨论通常都会涉及这些问题,但很少对各个提案进行系
统的或一致的检查。成员们倾向于关注技术细节(例如,语法、歧义、优化机会
和命名),而不是重新探讨根本问题。有时,我所认为的糟糕的提案会混进去。原
因通常是提案者的极大热情加上反对者的分心、礼貌和疲惫 [Stroustrup 2019b]。
33
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
4. C++11:感觉像是门新语言
C++11 [Becker 2011] 发布后,其实现相对来说很快就出现了。这导致了极大的热
情,增加了使用,有大量新人涌入 C++ 世界,并进行了大量的实验。C++11 的三
个完整或几乎完整的实现在 2013 年面世。我当时的评论被广泛认为是准确的——
C++11 感觉像是一门新的语言 [Stroustrup 2014d]。为什么 C++11 在帮助程序员
方面做得如此出色?又是如何做到的?
C++11 引入了大量令人眼花缭乱的语言特性,包括:
•
•
•
•
•
•
•
•
•
•
•
•
•
•
•
•
•
•
•
内存模型——一个高效的为现代硬件设计的底层抽象,作为描述并发的基
础(§4.1.1)
auto 和 decltype——避免类型名称的不必要重复(§4.2.1)
范围 for——对范围的简单顺序遍历(§4.2.2)
移动语义和右值引用——减少数据拷贝(§4.2.3)
统一初始化—— 对所有类型都(几乎)完全一致的初始化语法和语义
(§4.2.5)
nullptr——给空指针一个名字(§4.2.6)
constexpr 函数——在编译期进行求值的函数(§4.2.7)
用户定义字面量——为用户自定义类型提供字面量支持(§4.2.8)
原始字符串字面量——不需要转义字符的字面量,主要用在正则表达式中
(§4.2.9)
属性——将任意信息同一个名字关联(§4.2.10)
lambda 表达式——匿名函数对象(§4.3.1)
变参模板——可以处理任意个任意类型的参数的模板(§4.3.2)
模板别名——能够重命名模板并为新名称绑定一些模板参数(§4.3.3)
noexcept——确保函数不会抛出异常的方法(§4.5.3)
override 和 final——用于管理大型类层次结构的明确语法
static_assert——编译期断言
long long——更长的整数类型
默认成员初始化器——给数据成员一个默认值,这个默认值可以被构造函
数中的初始化所取代
enum class——枚举值带有作用域的强类型枚举
以下是主要的标准库组件列表(§4.6)
:
•
unique_ptr 和 shared_ptr — — 依 赖 RAII(§2.2.1) 的 资 源 管 理 指 针
•
(§4.2.4)
内存模型和 atomic 变量(§4.1.1)
35
4. C++11:感觉像是门新语言
•
•
•
•
•
•
•
•
•
•
•
•
thread 、 mutex 、 condition_variable 等——为基本的系统层级的并发
提供了类型安全、可移植的支持(§4.1.2)
future 、 promise 和 packaged_task , 等 — — 稍 稍 更 高 级 的 并 发
(§4.1.3)
tuple——匿名的简单复合类型(§4.3.4)
类型特征(type trait)——类型的可测试属性,用于元编程(§4.5.1)
正则表达式匹配(§4.6)
随机数——带有许多生成器(引擎)和多种分布(§4.6)
时间——time_point 和 duration(§4.6)
unordered_map 等——哈希表
forward_list——单向链表
array——具有固定常量大小的数组,并且会记住自己的大小
emplace 运算——在容器内直接构建对象,避免拷贝
exception_ptr——允许在线程之间传递异常
还有更多,但这些是最重要的变化。所有这些都在 [Stroustrup 2013] 中进行了描
述,许多信息可以在网上获得(例如 [Cppreference 2011–2020])
。
这些表面上互不相干的扩展怎么能组成一个连贯的整体?这怎么可能真正地改变
我们写代码的方式,使之变得更好呢?C++11 做到了这一点。在相对较短的时间
里(算 5 年吧),大量的 C++ 代码被升级到 C++11(并进一步升级到 C++14 和
C++17)
,而且 C++ 在会议和博客上的呈现也完全改变了。
这种在语言的“感觉”和使用风格上的巨大变化,并不是由某位大师级工匠指导
的传统的精心设计过程的结果,而是海量建议经由一大批不断变化的个人层层决
策过滤后的结果。
在我的 HOPL3 论文 [Stroustrup 2007] 中,我正确地描述了 C++11 语言的许多特
性。值得注意的例外是“概念”
,我会在(§6)中进行讨论。我将不再赘述细节,
而是根据它们所解决的程序员需求来描述功能的“主题”分类。我认为这种看待
提案的方式是 C++11 成功的根源:
•
•
•
•
•
•
§4.1:支持并发
§4.2:简化使用
§4.3:改进对泛型编程的支持
§4.4:增加静态类型安全
§4.5:支持对库的开发
§4.6:标准库组件
36
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
这些“主题”并不是不相干的。事实上,我猜想 C++11 之所以成功,是因为它相
互关联的功能彼此加成,形成了一张精细的网络,可以处理真正的需求。每一个
主题里都有我喜欢的特性。我怀疑,我在写作(例如 [Stroustrup 1993, 1994,
2007])和演讲中明确表述了 C++ 的目标,也帮助设计保持了合理的重点。对我来
说,衡量每个新特性的一个关键指标是它是否使 C++ 更接近它的理想,例如,是
否通过引入该特性能让对内建类型和用户定义类型的支持更加相似(§2.1)
。
纵观 C++11,我们可以看到有些改进建议在 2002 年左右就被提出,有不少库也出
现得很早,经常是作为 Boost 的一部分 [Boost 1998–2020]。然而,直到 2013 年
才有完整的 C++11 实现。在 2020 年,一些组织仍在为升级到 C++11 而苦恼,因
为代码库巨大,程序员不思进取,教学方式陈旧,以及编译器严重过时(尤其是
在嵌入式系统领域)
。不过 C++17 的采用速度明显快于 C++98 和 C++11;并且,
早在 2018 年,C++20 的一些主要特性就已经投入生产使用。
直到 2018 年,我仍能看到 C++98 前的编译器被用于教学。我认为这是对学生的
虐待,剥夺了他们接触学习我们 20 多年的进展的机会。
对标准委员会、主要编译器厂商以及大多数 C++ 的积极支持者来说已是遥远的过
去的东西,对许多人来说,仍然是现在——甚至是未来。其结果是,人们对 C++
到底是什么仍然感到困惑。只要 C++ 继续演化,这种困惑就会持续下去。
4.1 C++11:并发支持
C++11 必须支持并发。这既是显而易见的,也是所有主要用户和平台供应商的共
同需求。C++ 一直在大多数软件工业的基础中被重度使用,而在二十一世纪的头
十年,并发性变得很普遍。利用好硬件并发至关重要。和 C 一样,C++ 当然一直
支持各种形式的并发,但这种支持那时没有标准化,并且一般都很底层。机器架
构正在使用越来越精巧的内存架构,编译器编写者也在应用越来越激进的优化技
术,这让底层软件编写者的工作极为困难。机器架构师和优化器编写者之间亟需
一个协定。只有有了明确的内存模型,基础库的编写者才能有一个稳定的基础和
一定程度的可移植性。
并发方面的工作从 EWG 中分离出来,成为由 Hans-J. Boehm(惠普,后加入谷
歌)领导的专家成员组成的并发组。它有三项职责:
•
•
•
§4.1.1:内存模型
§4.1.2:线程和锁
§4.1.3:期值
此外,并行算法(§8.5)、网络(§8.8.1)和协程(§9.3.2)是单独分组处理的,并
且(正如预期)还没法用于 C++11。
37
4. C++11:感觉像是门新语言
4.1.1 内存模型
最紧迫的问题之一,是在一个有着多核、缓存、推测执行、指令乱序等的世界里
精确地规定访问内存的规则。来自 IBM 的 Paul McKenney 在内存保证方面的课题
上非常活跃。来自剑桥大学的 Mark Batty 的研究 [Batty et al. 2013, 2012, 2010,
2011] 帮助我们将这一课题形式化,见 P. McKenney、M. Batty、C. Nelson、H.
Boehm、A. Williams、S. Owens、S. Sarkar、P. Sewell、T. Weber、M. Wong、L.
Crowl 和 B. Kosnik 合作的论文 [McKenney et al. 2010]。它是 C++11 的一个庞大而
至关重要的部分。
在 C11 中,C 采用了 C++ 的内存模型。然而,就在 C 标准付诸表决前的最后一刻,
C 委员会引入了不兼容的写法,而此时 C++11 标准修改的最后一次机会已经过
去。这成了 C 和 C++ 实现者和用户的痛苦。
内存模型很大程度上是由 Linux 和 Windows 内核的需求驱动的。目前它不只是用
于内核,而且得到了更加广泛的使用。内存模型被广泛低估了,因为大多数程序
员都看不到它。从一阶近似来看,它只是让代码按照任何人都会期望的方式正常
工作而已。
最开始,我想大多数委员都小瞧了这个问题。我们知道 Java 有一个很好的内存模
型 [Pugh 2004],并曾希望采用它。令我感到好笑的是,来自英特尔和 IBM 的代表
坚定地否决了这一想法,他们指出,如果在 C++ 中采用 Java 的内存模型,那么我
们将使所有 Java 虚拟机的速度减慢至少两倍。因此,为了保持 Java 的性能,我们
不得不为 C++ 采用一个复杂得多的模型。可以想见而且讽刺的是,C++ 此后因为
有一个比 Java 更复杂的内存模型而受到批评。
基本上,C++11 模型基于之前发生(happens-before)关系 [Lamport 1978],并
且既支持宽松的内存模型,也支持顺序一致 [Lamport 1979] 的模型。在这些之
上,C++11 还提供了对原子类型和无锁编程的支持,并且与之集成。这些细节远
远超出了本文的范围(例如,参见 [Williams 2018])
。
不出所料,并发组的内存模型讨论有时变得有点激烈。这关系到硬件制造商和编
译器供应商的重大利益。最困难的决定之一是同时接受英特尔的 x86 原语(某种
全存储顺序,Total Store Order(TSO)模型 [TSO Wikipedia 2020] 加上一些原子
操作)和 IBM 的 PowerPC 原语(弱一致性加上内存屏障)用于最底层的同步。从
逻辑上讲,只需要一套原语,但 Paul McKenney 让我相信,对于 IBM,有太多深
藏在复杂算法中的代码使用了屏障,他们不可能采用类似英特尔的模型。有一
天,我真的在一个大房间的两个角落之间做了穿梭外交。最后,我提出必须支持
这两种方式,这就是 C++11 采用的方式。当后来人们发现内存屏障和原子操作可
38
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
以一起使用,创造出比单单使用其中之一更好的解决方案时,我和其他人都感到
非常高兴。
稍后,我们增加了对基于数据依赖关系的一致性支持,通过属性(§4.2.10)在源
代码中表示,比如 [[carries_dependency]]。
C++11 引入了 atomic 类型,上面的简单操作都是原子的:
atomic<int> x;
void increment()
{
x++; // 不是 x = x + 1
}
显然,这些都是广泛有用的。例如,使用原子类型使出名棘手的双重检查锁定优
化变得极为简单:
mutex mutex_x;
atomic<bool> init_x;
int x;
// 初始为 false
if (!init_x) {
lock_guard<mutex> lck(mutex_x);
if (!init_x) x = 42;
init_x = true ;
} // 在此隐式释放 mutex_x(RAII)
// ... 使用 x ...
双重检查锁定的要点是使用相对开销低的 atomic 保护开销大得多的 mutex 的使
用。
lock_guard 是一种 RAII 类型(§2.2.1)
,它确保会解锁它所控制的 mutex。
Hans-J. Boehm 将原子类型描述为“令人惊讶地流行”,但我不能说我感到惊讶。
从没有 Hans 那么专业的角度,我对简化更为欣赏。C++11 还引入了用于无锁编程
的关键运算,例如比较和交换:
template<typename T>
class stack {
std::atomic<node<T>*> head;
public:
void push(const T& data)
{
node<T>* new_node = new node<T>(data);
new_node->next = head.load(std::memory_order_relaxed);
while(!head.compare_exchange_weak(new_node->next, new_node,
39
4. C++11:感觉像是门新语言
std::memory_order_release, std::memory_order_relaxed)) ;
}
// ...
};
即使有了 C++11 的支持,我仍然认为无锁编程是专家级的工作。
4.1.2 线程和锁
在内存模型之上,我们还提供了线程加锁的并发模型。我认为线程加锁级别的并
发是应用程序使用并发的最差模型,但是对于 C++ 这样的语言来说,它仍然必不
可少。不管它还是别的什么,C++ 一直是一种能够与操作系统直接交互的系统编
程语言,可用于内核代码和设备驱动程序。因此,它必须支持系统最底层支持的
东西。在此基础上,我们可以建立各种更适合特定应用的并发模型。就我个人而
言,我特别喜欢基于消息的系统,因为它们可以消除数据竞争,而数据竞争可能
产生极为隐晦的并发错误。
C++ 对线程和锁级别编程的支持是 POSIX 和 Windows 所提供的线程和锁的类型安
全变体。在 [Stroustrup 2013] 有所描述,在 Anthony Williams 的书 [Williams
2012, 2018] 中有更为深入的探讨:
•
•
thread——系统的执行线程,支持 join() 和 detach()
mutex——系统的互斥锁,支持 lock()、unlock() 和保证 unlock() 的
RAII 方式
•
•
condition_variable——系统中线程间进行事件通信的条件变量
thread_local——线程本地存储
与 C 版本相比,类型安全使代码更简洁,例如,不再有 void** 和宏。考虑一个简
单的例子,让一个函数在不同的线程上执行并返回结果:
class F { // 传统函数对象
public:
F(const vector<double>& vv, double* p) : v{vv}, res{p} { }
void operator()();
// 将结果放入 *res
private:
const vector<double>& v; // 输入源
double* res;
// 输出目标
};
double f(const vector<double>& v);
// 传统函数
void g(const vector<double>& v, double* res); // 将结果放入 *res
int comp(vector<double>& vec1, vector<double>& vec2, vector<double>& vec3)
40
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
{
double
double
double
// ...
thread
thread
thread
res1;
res2;
res3;
t1 {F{vec1,res1}};
t2 {[&](){res2=f(vec2);}};
t3 {g,ref(vec3),&res3};
// 函数对象
// lambda 表达式
// 普通函数
t1.join();
t2.join();
t3.join();
cout << res1 << ' ' << res2 << ' ' << res3 << '\n';
}
类型安全库支持的设计主要依赖变参模板(§4.3.2)。例如,std::thread 的构造
函数就是变参模板。它可以区分不同的可执行的第一个参数,并检查它们后面是
否跟有正确数量正确类型的参数。
类似地,lambda 表达式(§4.3.1)使 <thread> 库的许多使用变得更加简单。例
如,t2 的参数是访问周围局部作用域的一段代码(lambda 表达式)。
在发布标准的同时,让新特性在标准库中被接受和使用是很困难的。有人提出这
样做过于激进,可能会导致长期问题。引入新的语言特性并同时使用它们无疑是
有风险的,但它通过以下方式大大增加了标准的质量:
•
•
•
•
给用户一个更好的标准库
给用户一个很好的使用语言特性的例子
省去了用户实现底层功能的麻烦
迫使语言特性的设计者应对现实世界的困难应用
线程和锁模型需要使用某种形式的同步来避免竞争条件。C++11 为此提供了标准
的 mutex(互斥锁)
:
mutex m;
int sh;
// 控制用的互斥锁
// 共享的数据
void access ()
{
unique_lock<mutex> lck {m};
sh += 7;
} // 隐式释放互斥锁
// 得到互斥锁
// 操作共享数据
unique_lock 是一个 RAII 对象,确保用户不会忘记在这个 mutex 上调用 unlock()。
41
4. C++11:感觉像是门新语言
这些锁对象还提供了一种防止最常见形式的死锁的方法:
void f()
{
// ...
unique_lock<mutex> lck1 {m1,defer_lock}; // 还未得到 m1
unique_lock<mutex> lck2 {m2,defer_lock};
unique_lock<mutex> lck3 {m3,defer_lock};
// ...
lock(lck1,lck2,lck3); // 获取所有三个互斥锁
// ... 操作共享数据 ...
}
// 隐式释放所有互斥锁
这 里 , lock() 函 数 “ 同 时 ” 获 取 所 有 mutex 并 隐 式 释 放 所 有 互 斥 锁 (RAII
(§2.2.1)
)
。C++17 有一个更优雅的解决方案(§8.4)
。
线程库是由 Pete Becker(Dinkumware)在 2004 年首次为 C++0x 提出的 [Becker
2004],它基于 Dinkumware 对 Boost.Thread [Boost 1998–2020] 所提供的接口的
实现。在同一次会议上(华盛顿州 Redmond 市,2004 年 9 月)提出了第一个关
于内存模型的提案 [Alexandrescu et al. 2004],这可能不是巧合。
最大的争议是关于取消操作,即阻止线程运行完成的能力。基本上,委员会中的
每个 C++ 程序员都希望以某种形式实现这一点。然而,C 委员会在给 WG21 的正
式通知 [WG14 2007] 中反对线程取消,这是唯一由 WG14(ISO C 标准委员会)发
给 WG21 的正式通知。我指出,
“但是 C 语言没有用于系统资源管理和清理的析构
函数和 RAII”
。管理 POSIX 的 Austin Group 派出了代表,他们 100% 反对任何形式
的这种想法,坚称取消既没有必要,也不可能安全进行。事实上 Windows 和其他
操作系统提供了这种想法的变体,并且 C++ 不是 C,然而 POSIX 人员对这两点都
无动于衷。在我看来,恐怕他们是在捍卫自己的业务和 C 语言的世界观,而不是
试图为 C++ 提出最好的解决方案。缺乏标准的线程取消一直是一个问题。例如,
在并行搜索(§8.5)中,第一个找到答案的线程最好可以触发其他此类线程的取
消(不管是叫取消或别的名字)。C++20 提供了停止令牌机制来支持这个用例
(§9.4)
。
4.1.3 期值(future)
一个类型安全的、标准的、类似 POSIX/Windows 的线程库是对正在使用的不兼容
的 C 风格库的重大改进,但这仍然是 1980 年代风格的底层编程。一些成员,特别
是我,认为 C++ 迫切需要更现代、更高层次的东西。举例来说,Matt Austern(谷
歌,之前代表 SGI)和我主张消息队列(
“通道”
)和线程池。这些意见没有什么进
展,因为有反对意见说没有时间来做这些事情。我恳求并指出,如果委员会中的
专家不提供这样的功能,他们最终将不得不使用“由我的学生匆匆炮制的”功
42
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
能。委员会当然可以做得比这好得多。“如果你不愿意这样做,请给我一种方法,
就一种方法,在没有显式同步的情况下在线程之间传递信息!
”
委员会成员分为两派,一派基本上想要在类型系统上有改进的 POSIX(尤其是 P.J.
Plauger)
,另一派指出 POSIX 基本上是 1970 年代的设计,“每个人”都已经在使
用更高层次的功能。在 2007 年的 Kona 会议上,我们达成了一个妥协:C++0x
(当时仍期望会是 C++09)将提供 promise 和 future ,以及异步任务的启动器
async(),允许但不需要线程池。和大多数折中方案一样,
“Kona 妥协”没有让任
何人满意,还导致了一些技术问题。然而,许多用户认为它是成功的——大多数
人不知道这当时是一种妥协——这些年来,已经出现了一些改进。
最后,C++11 提供了:
•
•
•
•
future——一个句柄,通过它你可以从一个共享的单对象缓冲区中 get()
一个值,可能需要等待某个 promise 将该值放入缓冲区。
promise——一个句柄,通过它你可以将一个值 put() 到一个共享的单对
象缓冲区,可能会唤醒某个等待 future 的 thread。
packaged_task——一个类,它使得设置一个函数在线程上异步执行变得
容易,由 future 来接受 promise 返回的结果。
async()——一个函数,可以启动一个任务并在另一个 thread 上执行。
使用这一切的最简单方法是使用 async()。给定一个普通函数作为参数,async()
在一个 thread 上运行它,处理线程启动和通信的所有细节:
double comp4(vector<double>& v)
// 如果 v 足够大则会产生多个任务
{
if (v.size()<10000)
// 值得用并发机制吗?
return accum(v.begin(),v.end(),0.0);
auto v0 = &v[0];
auto sz = v.size();
auto
auto
auto
auto
f0
f1
f2
f3
=
=
=
=
async(accum,v0,v0+sz/4,0.0);
async(accum,v0+sz/4,v0+sz/2,0.0);
async(accum,v0+sz/2,v0+sz*3/4,0.0);
async(accum,v0+sz*3/4,v0+sz,0.0);
return f0.get()+f1.get()+f2.get()+f3.get();
//
//
//
//
第一部分
第二部分
第三部分
第四部分
// 收集结果
}
async 将代码包装在 packaged_task 中,并管理 future 及其传输结果的 promise
的设置。
43
4. C++11:感觉像是门新语言
值或异常都可以通过这样一对 future/promise 从一个 thread 传递到另一个
thread。例如:
X f(Y); // 普通函数
void ff(Y y, promise<X>& p)
// 异步执行 f(y)
{
try {
X res = f(y);
// ... 给 res 计算结果 ...
p.set_value(res);
}
catch (...) {
// 哎呀:没能计算出 res
p.set_exception(current_exception());
}
}
为简单起见,我没有使用参数的完美转发(§4.2.3)
。
对应 future 的 get() 现在要么得到一个值,要么抛出一个异常——与 f() 的某个
等效同步调用完全一样。
void user(Y arg)
{
auto pro = promise<X>{};
auto fut = pro.get_future();
thread t {ff,arg,ref(pro)}; // 在不同线程上运行 ff
// ... 做一会别的事情 ...
X x = fut.get();
cout << x.x << '\n';
t.join();
}
int main()
{
user(Y{99});
}
标准库的 packaged_task 自动化了这个过程,可以将普通函数包装成一个函数对
象,负责 promise/future 的自动配置并处理返回和异常。
我曾希望这会产生一个由线程池支持的工作窃取(work-stealing)的实现,但我
还是失望了。
另见(§8.4)
。
44
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
4.2 C++11:简化使用
C++ 是“专家友好”的。我想我是第一个将这句话用作委婉的批评,并且在 C++
中推行“简单的事情简单做!”的口号的人。当然,主要面向工业应用的语言就应
该对专家友好,但是一门语言不能只对专家友好。大多数使用编程语言的人并不
是专家——他们也不想精通该语言的方方面面,而只是想把工作做到足够好,不
会因为语言而分心。编程语言的存在,是为了能够表达应用程序的创意,而不是
把程序员变成语言律师。语言的设计应该尽力让简单的事情能够简单地做。语言
要给专家使用的话,则必须额外确保,没有啥基本事项是不可能做的,并且代价
也不会过于高昂。
当讨论潜在的 C++ 语言扩展和标准库组件时,另外一个准则是“教起来容易
吗?”这个问题现在已经很普遍了,它最早是由 Francis Glassborow 和我倡导的。
“教起来容易”的思想起源于 C++ 的早期,可以在《C++ 语言的设计和演化》
[Stroustrup 1994] 中找到。
当然,新事物的拥护者不可避免地认为他们的设计简单、易用、足够安全、高
效、易于传授,及对大多数程序员有用。反对者则倾向于怀疑他们的部分甚至全
部说法。但是,确保对 C++ 提议的每个特性都经历这样的讨论是很重要的:可以
通过面对面会议,可以通过论文 [WG21 1989–2020],也可以通过电子邮件。在这
些讨论中,我经常指出,我大部分时间也是个新手。也就是说,当我学习新的特
性、技巧或应用领域时,我是一个新手,我会用到从语言和标准库中可以获得的
所有帮助。一个结果是,C++11 提供了一些特别的功能,旨在简化初学者和非语
言专家对 C++ 的使用。
每一项新特性都会让一些人做某些事时更加简单。“简化使用”的主题聚焦于这样
一些语言特性,它们的主要设计动机是让已知的惯用法使用起来更加简单。下面
列举其中的一些:
•
•
•
•
•
•
•
•
§4.2.1:auto——避免类型名称的不必要重复
§4.2.2:范围 for——简化范围的顺序遍历
§4.2.3:移动语义和右值引用——减少数据拷贝
§4.2.4:资源管理指针——管理所指向对象生命周期的“智能”指针
(unique_ptr 和 shared_ptr)
§4.2.5:统一初始化——对所有类型都(几乎)完全一致的初始化语法和
语义
§4.2.6:nullptr——给空指针一个名字
§4.2.7:constexpr 函数——编译期被估值的函数
§4.2.8:用户定义字面量——为用户自定义类型提供字面量支持
45
4. C++11:感觉像是门新语言
•
•
•
•
§4.2.9:原始字符串字面量——转义字符(\)不被解释为转义符的字面
量,主要用在正则表达式中
§4.2.10:属性——将任意信息同一个名字关联
§4.2.11:与可选的垃圾收集器之间的接口
§4.3.1:lambda 表达式——匿名函数对象
在 C++11 开始得到认真使用后,我就开始在旅行时做一些不那么科学的小调查。
我会问各地的 C++ 使用者:你最喜欢哪些 C++11 的特性?排在前三位的一直都
是:
•
•
•
§4.2.1:auto
§4.2.2:范围 for
§4.3.1:lambda 表达式
这三个特性属于 C++11 中新增的最简单特性,它们并不能提供任何新的基础功
能。它们做的事情,在 C++98 中也能做到,只是不那么优雅。
我认为这意味着不同水平的程序员都非常喜欢让惯常用法变简洁的写法。他们会
高兴地放弃一个通用的写法,而选择一个在适用场合中更简单明确的写法。有一
个常见的口号是,“一件事只应有一种说法! *”这样的“设计原则”根本不能反
映现实世界中的用户偏好。我则倾向于依赖洋葱原则 [Stroustrup 1994]。你的设
计应该是这样的:如果要完成的任务是简单的,那就用简单的方法做;当要完成
的任务不是那么简单时,就需要更详细、更复杂的技巧或写法。这就好比你剥下
了一层洋葱。剥得越深,流泪就越多。
请注意,这里简单并不意味着底层。void*、宏、C 风格字符串和类型转换等底层
功能表面上学起来简单,但使用它们来产出高质量、易维护的软件就难了。
4.2.1 auto 和 decltype
C++11 中最古老的的新特性,是能够在初始化的时候就给对象指定一个确定的类
型。例如:
auto i = 7;
auto d = 7.2;
auto p = v.begin();
//
//
//
//
i 是个整数
d 是个双精度浮点数
p 是 v 的迭代器类型
(begin() 返回一个迭代器)
译注:参考 Python 在 PEP 20—The Zen of Python 中的不同态度:
“应该有且仅有一种明显的
完成任务的方式(There should be one—and preferably only one—obvious way to do it)
。
”
*
46
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
auto 是一个方便的静态特性,它允许从初始化表达式中推导出对象的静态类型。
如果要用动态类型的变量,应该使用 variant 或者 any(§8.3)
。
我早在 1982/83 年冬天就实现了 auto,但是后来为了保持 C 兼容性而不得不移除
了这一特性。
在 C++11 中,大家提出用 typeof 运算符代替已经流行的 typeof 宏和编译器扩
展。不幸的是,不同 typeof 宏在处理引用时并不兼容,因而采用任何一种都会严
重破坏现有代码。引入一个新的关键字总是困难的,因为如果它简短而且意义明
确,那它一定已经被使用了成千上万次。如果建议的关键字又丑又长,那大家就
会讨厌它。
Jaakko Jarvi,Boost 库最多产的贡献者之一,那时是我在得州农工大学的同事。他
当时领导了 typeof 的讨论。我们意识到语义的问题可以概括为:“一个引用的
typeof 到底是引用自身,还是所引用的类型?”同时,我们还感觉到,typeof 有
点冗长而且容易出错,比如:
typeof(x+y) z = y+x;
在这里,我以为我重复计算了 x+y,但其实并没有(潜在的不良影响),但不管怎
么样,我为什么要把任何东西重复写两遍呢?这时候我意识到,我其实在 1982 年
就解决过这个问题,我们可以“劫持”关键字 auto 来消除这种重复:
auto z = y+x;
// z 获得 y+x 的类型
在 C 和早期的 C++ 中,auto 曾表示“在自动存储(比如栈上)上分配”,但是从
来没有被用过。我们查看了数百万行的 C 和 C++ 代码,确认了 auto 只在一些测试
集和错误中用到过,于是我们就可以回收这个关键字,用作我 1982 年的意思,表
示“获取初始化表达式的类型”
。
剩下的问题是,我们要在某些场景中把引用的类型也推导为一个引用。这在基于
模板的基础库中并不少见。我们提出了用 decltype 运算符来处理这种保留引用的
语义:
template<typename T> void f(T& r)
{
auto v = r;
// v 是 T
decltype(r) r2 = r;
// r2 是 T&
// ...
}
为什么是 decltype?可惜,我已经不记得是谁建议了这个名字了,但是我还记得
原因:
47
4. C++11:感觉像是门新语言
•
•
•
•
typeof 已经不能用了,因为那样会破坏很多老代码
我们找不到其他优雅、简短、且没有被用过的名字了
decltype 足够好记(
“declared type”的简写)
;但也足够古怪,因而没有
在现有代码中用过
decltype 还算比较短
提议 decltype 的论文写于 2003 年 [Jarvi et al. 2003b],而通过投票接受到标准中
的论文写于 2006 年 [Jarvi et al. 2007]。Jaakko Jarvi 做了让 decltype 通过委员会
评审的大部分细节的工作,Doug Gregor、Gabriel Dos Reis、Jeremy Siek 和我也帮
过忙,并且在一些论文中作为合著作者出现。事实证明,澄清 decltype 的确切语
义比我在这里说的要难得多。花费数年在一个看上去很简单的特性细节上的情况
并不少见——部分原因是特性的固有复杂性,部分原因则是,需要最后批准的人
可真不少,他们需要同意每个细节的设计和具体说明都已经让人满意了。
我认为 auto 是个纯粹的简化特性,而 decltype 的主要目的,则是让基础库可以
使用复杂的元编程。然而,从语言使用的技术角度来看,它们是密切相关的。
我探索过推广 auto 到另外两个显而易见的场景 [Stroustrup and Dos Reis 2003b]:
作为返回类型和参数类型。这显而易见,因为在 C++ 中,参数传递和值返回被定
义为初始化。但在 2003 年,当我第一次向委员会提出这些想法时,演化工作组的
成员们毫不掩饰地表现出恐惧的神情。考虑下面的例子:
auto f(auto arg)
{
return arg;
}
auto x = f(1);
auto s = f(string("Hello"));
// x 是 int
// s 是 string
当我向委员会提出这个想法时,我收到了超过我的任何其他提案的负面反馈。我
形容当时的情景“就像贵妇见到了老鼠一样”,他们叫嚷着:“咦咿……!”。然
而,故事还没结束。C++17 后来对 lambda 表达式(§4.3.1)的参数和返回值都支
持了 auto,而对普通的函数,C++17 只支持返回值的 auto。作为概念的一部分
(§6.4)
,C++20 为函数参数添加了 auto 支持,至此才完全实现了我在 2003 年提
出的建议。
C++11 中添加了一种弱化的 auto 用法,把返回类型的说明放到参数后面。例如,
在 C++98 中,我们会这样写:
template<typename T>
vector<T>::iterator vector<T>::begin() { /* ... */ }
48
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
重复出现的 vector<T>:: 令人厌烦,当时也没法表达返回类型依赖于参数类型
(这在一些泛型编程中很有用)。C++11 弥补了这个问题,并提高了代码的可读
性:
template<typename T>
auto vector<T>::begin() -> iterator { /* ... */ }
这样,在多年努力后,我们终于有了 auto。它立即就变得非常流行,因为它让程
序员不用再拼写冗长的类型名称,也不需要在泛型代码中考虑类型的细节。例
如:
for (auto p = v.begin(); p != v.end(); ++p) ...
// 传统的 STL 循环
它允许人们对齐名字:
class X {
public:
auto f() -> int;
auto gpr(int) -> void;
// ...
};
void use(int x, char* p)
{
auto x2 = x*2;
// x2 是 int
auto ch = p[x]; // ch 是 char
auto p2 = p+2;
// p2 是 char*
// ...
}
还曾经有论文主张尽量多地使用 auto [Sutter 2013b]。有句话很经典:每个有用
的新特性,一开始都会被滥用和误用。一段时间后,部分开发者找到了平衡点。
把这种平衡的用法阐述为最佳实践,是我(和很多其他人)致力于编程指南
(§10.6)的原因之一。对于 auto,我收到了很多评论,说当人们将它和没有明显
类型的初始化表达式放一起使用时可读性不好。因此,C++ 核心指南 [Stroustrup
and Sutter 2014–2020](§10.6)有了这条规则:
ES.11:使用 auto 来避免类型名称的多余重复
我的书 [Stroustrup 2013, 2014d] 中也有类似的建议。考虑下面的例子:
auto n = 1; // 很好:n 是 int
auto x = make_unique<Gadget>(arg);
auto y = flopscomps(x,3);
// 很好:x 是 std::unique_ptr<Gadget>
// 不好:flopscomps() 返回的是啥东西?
49
4. C++11:感觉像是门新语言
这仍然无法百分百地确定如何在每种情况下应用该规则,但有规则总比没有规则
要好得多,并且代码会比使用绝对规则“不许使用 auto!”和“永远使用 auto!
”
更加可读。真实世界的编程往往需要更多的技巧,不会像展示语言特性的例子这
样简单。
如果 flopscomps() 不是泛型计算的一部分,那么最好显式地声明想要的类型。我
们需要等到 C++ 20 才能用概念来约束返回类型(§6.3.5)
:
Channel auto y = flopscomps(x,3);
// y 可以当做 Channel 使用
那么,针对 auto 的工作值得吗?它是一个很小的功能,对于简单的情况,一天就
可以实现,但却花了 4 年的时间才在委员会通过。它甚至都不算新颖:很多语言
40 年前就有这样的功能了,甚至带类的 C 在 35 年前就有这样的功能!
对 C++ 标准委员会通过哪怕是最小的功能所需的时间,以及常伴其间的痛苦讨
论,经常让我感到绝望。但是另一方面,把事情做好之后,成千上万的程序员会
从中受益。当某件事做得很好时,最常见的评论是:“这很明显啊!怎么你们要花
那么久?”
4.2.2 范围 for
范围 for 是用来顺序遍历一个序列中所有元素的语句。例如:
void use(vector<int>& v, list<string>& lst)
{
for (int x : v) cout << x << '\n';
int sum = 0;
for (auto i : {1,2,3,5,8}) sum+=i; // 初始化列表是一个序列
for (string& s : lst) s += ".cpp"; // 使用引用允许遍历时修改
}
它最初是由 Thorsten Ottosen(丹麦奥尔堡大学)提出的,理由是“基本上任何现
代编程语言都内置了某种形式的 for each” [Ottosen 2005]。我通常不认为“别人
都有了”是个好的论据,但在这一情况下,真正的要点是,简单的范围循环可以
简化一种最常见的操作,并提供了优化的机会。所以,范围 for 完美符合我对
C++ 的总体设计目标。它直接表达应该做什么,而不是详细描述如何做。它的语
法简洁,语义明晰。
由于更简单和更明确,范围 for 语句消除了一些“微不足道”然而常见的错误:
void use(vector<int>& v, list<string>& lst)
{
for (int i=0; i<imax; ++i)
for (int j=0; i<imax; ++j) ... // 错误的嵌套循环
50
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
// 多循环了一次的错误
for (int i=0; i<=max; ++i) ...
}
尽管范围 for 够简单了,它在这些年还是有些变化。Doug Gregor 曾建议使用
C++0x 中的概念来修改范围 for,方案优雅并且得到了批准 [Ottosen et al. 2007]。
我还记得他在我在得州的办公室里写这个提案的场景,但很遗憾,后来因为删除
了 C++0x 的概念(§6)
,我们不得不回退了那些修改。在 2016 年,它还做过一点
小修改,以配合 Ranges TS(§9.3.5)所支持的无限序列。
4.2.3 移动语义
在 C 和 C++ 中,要从函数获得大量的数据,传统做法是在自由存储区(堆、动态
内存)上分配空间,然后传递指向该空间的指针作为函数参数。比如,对于工厂
函数和返回容器(例如 vector 和 map)的函数就需要如此。这对开发者来说看起
来很自然,而且相当高效。不幸的是,它是显式使用指针的主要来源之一,导致
了写法上的不便、显式的内存管理,以及难以查找的错误。
多年来,很多专家使用“取巧”的办法来解决这个问题:把句柄类作为简单数值
(常称为值类型)来传递,例如:
Matrix operator+(const Matrix&, const Matrix&);
void use(const Matrix& m1, const Matrix& m2)
{
Matrix m3 = m1+m2;
// ...
}
这里 operator+ 让我们可以使用常规的数学记法,同时也是一个工厂函数返回大
对象的示例。
通过 const 引用把 Matrix 传递给函数,一直是传统而高效的做法。而问题在于,
如何以传值来返回 Matrix 而不用拷贝所有的元素。早在 1982 年,我曾通过一种
优化方案来部分解决这一问题,即干脆将返回值分配在调用函数的栈帧上。它工
作得很好,但它只是优化技术,不能处理更复杂的返回语句。而用户在按值返回
“大对象”时,需要确保绝不会进行大量的数据复制。
要做到这一点,需要观察到“大对象”通常是在自由存储区上的数据的一个句
柄。为了避免复制大量的数据,我们只需要确保在实现返回时,构造函数复制的
只是句柄,而不是所有元素。C++11 对这个问题的解决方案如下所示:
class Matrix {
double* elements;
// 指向所有元素的指针
51
4. C++11:感觉像是门新语言
// ...
public:
Matrix (Matrix&& a) // 移动构造
{
elements = a.elements; // 复制句柄
a.elements = nullptr;
// 现在 a 的析构函数不用做任何事情了
}
// ...
};
当用于初始化或赋值的源对象马上就会被销毁时,移动就比拷贝要更好:移动操
作只是简单地把对象的内部表示“窃取”过来。&& 表示构造函数是一个移动构造
函数,Matrix&& 被称为右值引用。当用于模板参数时,右值引用的符号 && 被叫
做转发引用,这是由 John Spicer 在 2002 年的一次会议上,同 Dave Abrahams 和
Howard Hinnant 一起提出的。
这个 Matrix 的例子有个有意思的地方:如果 Matrix 的加法返回指针的话,那传
统的数学记号(a+b)就不能用了。
移动语义蕴含着性能上的重大好处:它消除了代价高昂的临时变量。例如:
Matrix mx = m1+m2+m3;
string sx = s1+s2+s3;
// 不需要临时变量
// 不需要临时变量
这里我添加了 string 的例子,因为移动语义立刻就被添加到了所有的标准库容器
上,这可以让一些 C++98 的程序拿来不做任何代码修改就获得性能提升。
允许类的设计者定义移动操作后,我们就有了完整的对对象生命周期和资源管理
的控制,这套控制始于 1979 年对构造函数和析构函数的引入。移动语义是 C++ 资
源管理模型的重要基石 [Stroustrup et al. 2015],正是这套机制使得对象能够在不
同作用域之间可以简单而高效地进行移动。
早期对参数传递、完美转发和智能指针强调颇多,可能掩盖了这个重要的一般性
观点。Howard Hinnant、Dave Abrahams 和 Peter Dimov 在 2002 年提出了移动语
义的一般化版本 [Hinnant et al. 2004, 2002]:
右值引用可以用于给现有类方便地添加移动语义。意思是说,拷贝构造函
数和赋值运算符可以根据实参是左值还是右值来进行重载。当实参是右值
时,类的作者就知道他拥有对该实参的唯一引用。
一个突出的例子是生成“智能指针”的工厂函数:
template <class T, class A1>
std::shared_ptr<T> factory(A1&& a1)
52
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
{
return std::shared_ptr<T>(new T(std::forward<A1>(a1)));
}
现已进入标准库的函数 forward 告诉编译器将实参视为右值引用,因此 T 的移动
构造函数(而不是拷贝构造函数)会被调用,来窃取该参数。它本质上就是个右
值引用的类型转换。
在 C++98 中,没有右值引用,这样的“智能指针”很难实现。在 C++11 中,解决
方案就简单了 [Hinnant et al. 2006] *:
template <class T>
class clone_ptr
{
private:
T* ptr;
public:
// ...
clone_ptr(clone_ptr&& p)
// 移动构造函数
: ptr(p.ptr)
// 拷贝数据的表示
{
p.ptr = 0;
// 把源数据的表示置空
}
clone_ptr& operator=(clone_ptr&& p) // 移动赋值
{
std::swap(ptr, p.ptr);
return *this;
// 销毁目标的旧值
}
};
很快,移动语义技术就被应用到了标准库的所有容器类上,像 vector、string 和
map。shared_ptr 和 unique_ptr 的确智能,但它们仍然是指针。我更喜欢强调移
动构造和移动赋值,它们使得(以句柄表示的)大型对象在作用域间能够高效移
动。
右值引用的提案在委员会中涉险过关。有人认为右值引用和移动语义多半来不及
进入 C++11,因为这些概念很新,而我们那时连合适的术语都没有。部分由于术
语上的问题 [Miller 2010],右值引用这一术语在核心语言和标准库中的使用就有
译注:下面代码引自 2006 年的论文,但 operator= 的实现不符合现代惯用法:一般要么把参
数设为 clone_ptr p,这就成了一个可以同时适配拷贝或移动的通用赋值函数;要么在函数体
内 进 行 一 次 移 动 构 造 , 先 clone_ptr temp(std::move(p)); 再 std::swap(ptr,
temp.ptr);。否则,当传递的实参是 std::move 的结果(xvalue)而不是真正的临时对象
(prvalue)时,代码的行为会不符合预期。当然,就如下面 Bjarne 讨论到的,在 2006 年应该
还没有 xvalue 和 prvalue 的概念。
*
53
4. C++11:感觉像是门新语言
了分歧,从而使得标准草案中出现了不一致。在 2010 年 3 月的匹兹堡会议上,我
参与了核心工作组(CWG)的讨论,在午饭休息的时间,在我看来“我们陷入了
僵局,或者混乱之中,也许兼而有之”。我没有去吃午饭,而是对问题进行了分
析,并得出结论,这里只涉及到两个基本概念:有标识符(identity),及可被移
动。从这两个原语出发,我推导出了传统的左值和右值类别 [Barron et al. 1963],
以及解决我们的定义问题所需要的三个新类别。在核心工作组回来之后,我提出
了我的解决方案。它很快就得到了接受,这样我们就在 C++11 中保留了移动语义
[Stroustrup 2010a]。
4.2.4 资源管理指针
C++11 提供了“智能指针”
(§4.2.4)
:
•
•
shared_ptr——代表共享所有权
unique_ptr——代表独占所有权(取代 C++98 中的 auto_ptr)
添加这些表示所有权的资源管理“智能指针”对编程风格有很大的影响。对很多
人来说,这意味着不再有资源泄漏,悬空指针的问题也显著减少。在自动化资源
管理和减少裸指针使用的努力中,它们是最明显的部分了(§4.2.3)
。
shared_ptr 是传统的计数指针:指向同一对象的所有指针共享一个计数器。当最
后一个指向对象的共享指针被销毁时,被指向的对象也会被销毁。这是一种简
单、通用且有效的垃圾收集形式。它能正确地处理非内存资源(§2.2.1)。为了正
确处理环形数据结构,还需要有 weak_ptr;不过,这往往不是最好的做法。人们
常常简单地使用 shared_ptr 来安全地从工厂函数返回数据:
shared_ptr<Blob> make_Blob(Args a)
{
auto p = shared_ptr<Blob>(new Blob(a));
// ... 把很多好东西填到 *p ...
return p;
}
当把对象移出函数时,引用计数会从 1 变到 2 再变回 1。在多线程程序中,这通常
是涉及到同步的缓慢操作。另外,粗率地使用和/或实现引用计数,会增加分配和
回收的开销。
正如预期的那样, shared_ptr 很快就流行起来,并在有些地方被严重滥用。因
此,后来我们提供了不引入额外开销的 unique_ptr 。unique_ptr 对它所指的对
象拥有独占的所有权,并会在自身被销毁的时候把指向的对象也简单地 delete
掉。
54
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
unique_ptr<Blob> make_Blob(Args a)
{
auto p = unique_ptr<Blob>(new Blob(a));
// ... 把很多好东西填到 *p ...
return p;
}
shared_ptr 和 weak_ptr 是 Peter Dimov 的工作成果 [Dimov et al. 2003]。Howard
Hinnant 贡 献 的 unique_ptr 是 对 C++98 的 auto_ptr 的 改 进 [Hinnant et al.
2002]。考虑到 unique_ptr 是 auto_ptr 的即插即用式的替代品,这提供了从标
准中(最终)删除有缺陷的功能的难得机会。资源管理指针跟移动语义、完美转
发及右值引用的工作密切相关(§4.2.3)
。
资源管理指针被广泛地用于持有对象,以便异常(及类似的情况)不会导致资源
泄漏(§2.2)
。例如:
void old_use(Args a)
{
auto q = new Blob(a);
// ...
if (foo) throw Bad(); // 会泄漏
if (bar) return;
// 会泄漏
// ...
delete q;
// 容易忘
}
显式使用 new 和 delete 的旧方式容易出错,在现代 C++ 中已经不推荐使用(例
如,C++ 核心指南(§10.6)
)
。现在我们可以这样写:
void newer_use(Args a)
{
auto p = unique_ptr<Blob>(new Blob(a));
// ...
if (foo) throw Bad(); // 不会泄漏
if (bar) return;
// 不会泄漏
// ...
}
这种写法更简短、更安全,迅速就流行开去。不过,“智能指针”仍然被过度使
用:“它们的确智能,但它们仍然是指针。”除非我们确实需要指针,否则,简单
地使用局部变量会更好:
void simplest_use(Args a)
{
Blob b(a);
// ...
55
4. C++11:感觉像是门新语言
if (foo) throw Bad(); // 不会泄漏
if (bar) return;
// 不会泄漏
// ...
}
智能指针用于表示资源所有权的主要用途是面向对象编程,其中指针(或引用)
用于访问对象,而对象的确切类型在编译时并不知道。
4.2.5 统一初始化
出于历史原因,C++ 有多种初始化的写法,而它们的语义有惊人的不同。
从 C 语言中,C++ 继承了三种初始化形式,并添加了第四种形式:
int x;
int x = 7;
int a[] = {7,8};
string s;
vector<int> v(10);
//
//
//
//
//
默认初始化(仅适用于静态变量)
值初始化
聚合初始化
由默认构造函数初始化
由构造函数初始化
用于初始化的概念既取决于要初始化的对象的类型,也取决于初始化的上下文。
这是一团乱麻,而且人们也认识到这一点。比如,为什么可以用列表初始化内建
数组,但却不能初始化 vector?
int a[] = {7,8};
vector<int> v = {7,8};
// 可以
// 应该可以工作(显然,但是没有)
上一个例子令我非常不舒服,因为它违反了 C++ 的根本设计目标,即为内建类型
和用户定义的类型提供同等的支持。特别是,因为对数组初始化有比 vector 更好
的支持,这会鼓励人们使用容易出错的内建数组。
当 C++0x 的工作从 2002 年开始的时候,Daniel Gutson、Francis Glassborow、
Alisdair Meredith、Bjarne Stroustrup 和 Gabriel Dos Reis 曾进行了许多讨论和提
议,来解决其中一些问题。在 2005 年,Gabriel Dos Reis 和我提出了统一初始化的
写法,该写法可用于每种类型,并且在程序中的任何地方都具有相同的含义
[Stroustrup and Dos Reis 2005b]。这种写法有望大大简化用户代码并消除许多不
易察觉的错误。该表示法是基于使用花括号的列表写法。举例来说:
int a = {5};
int a[] {7,8};
vector<int> v = {7,8};
// 内建类型
// 数组
// 具有构造函数的用户定义的类型
56
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
花括号({})对于单个值是可选的,并且花括号初始化器列表之前的 = 也是可选
的。为了统一起见,在许多 C++98 不允许使用花括号或者 = 初始化的地方都接受
花括号样式的初始化:
int f(vector<int>);
int i = f({1,2,3});
// 函数参数
struct X {
vector<int> v;
int a[];
X() : v{1,2}, a{3,4} {}
X(int);
// ...
}
// 成员初始化器
vector<int>* p = new vector<int>{1,2,3,4};
X x {}; // 默认初始化
// new 表达式
template<typename T> int foo(T);
int z = foo(X{1}); // 显式构造
其中许多的情形,例如为使用 new 创建的对象提供初始化器列表,干脆就没法使
用以前的方式来完成。
可惜,对于这一理想,我们仅仅达到不完全的近似,我们有的方案只能算大致统
一。有些人发现,使用 {…} 很别扭,除非 … 是同质对象的列表,而其他人则坚持 C
语言中对聚合和非聚合的区分,并且许多人担心没有显式类型标记的列表会导致
歧义和错误。例如,以下写法被认为是危险的,不过最终还是被接受了:
struct S { string s; int i; };
S foo(S s)
{
// ...
return {string{"foo"},13};
}
S x = foo({string{"alpha"},12.3});
在一种情况下,对统一写法的追求被一种惯用法击败。考虑:
vector<int> v1(10);
vector<int> v2 {10};
vector<int> v3 {1,2,3,4,5};
// 10 个元素
// 10 个元素还是 1 个值为 10 的元素?
// 拥有 5 个元素的 vector
57
4. C++11:感觉像是门新语言
使用像 vector<int> v1(10) 的指定大小的初始化器的代码有数百万行,而从基
本原则上来说,vector<int> v2 {10} 确实是模棱两可的。假如是在一门新的语
言中,我不会使用普通的整数来表示大小,我会为此指定一种特定的类型(比如
Size 或 Extent)
;举例来说:
vector<int> v1 {Extent{10}};
vector<int> v2 {10};
// 10 个元素,默认值为 0
// 1 个元素,值为 10
但是,C++ 并不是一门新语言,因此我们决定,在构造函数中进行选择时优先选
择初始化器列表解释。这使 vector<int> v2 {10} 成为具有一个元素的 vector,
并且使 {…} 初始化器的解释保持一致。但是,当我们想要避免使用初始化器列表
构造函数时,这就迫使我们使用 (…) 写法。
初始化的问题之一正在于,它无处不在,因此基本上所有程序和语言规则的问题
都会在初始化上下文中体现出来。考虑:
int x = 7.2;
int y {7.2};
// 传统的初始化
// 花括号初始化
从大约 1974 年将浮点数引入 C 语言以来,x 的值就是 7;也就是说,7.2 被隐式
截断,从而导致信息丢失。这是错误的来源。花括号初始化不允许窄化转换(此
处为截断)
。很好,但是升级旧代码变得更加困难:
double d = 7.2;
int x = d;
// 可以:截断
int y {d};
// 错误
这是一个常见问题的例子。人们想要一条简单的升级路径,但是除非需要做出一
些努力和更改,否则一次非常简单的升级的结果是,旧的问题和错误得以保留。
改善一门广泛使用的语言比我们一般想像的要难。
经过许多激烈的辩论和许多修改(并非其中每一项我都认为是改进),统一初始化
在 2008 年被批准进入 C++0x [Stroustrup 2008b]。
与以往一样,写法是一个有争议的问题,但是最终我们同意有一个标准库类型的
initializer_list 用作初始化器列表构造函数的参数类型。举例来说:
template<typename T> class vector {
public:
vector(initializer_list<T>); // 初始化器列表构造函数
// ...
};
vector<int> v3 {1,2,3,4,5};
// 具有 5 个元素的 vector
58
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
令人遗憾的是,统一初始化({}——初始化)的使用并不像我期望的那样广泛。
人们似乎更喜欢熟悉的写法和熟悉的缺陷。我似乎陷入了 N+1 问题:你有 N 个不
兼容和不完整的解决方案,因此添加了一个新的更好的解决方案。不幸的是,原
始的 N 个解决方案并没有消失,所以你现在有了 N+1 个解决方案。公平地说,有
一些细微的问题超出了本文的范围,这些问题只是在 C++14、C++17 和 C++20 中
被逐步补救。我的印象是,泛型编程和对更简洁的表示法的推进正在慢慢增加统
一初始化的吸引力。所有标准库容器(如 vector)都有初始化器列表构造函数。
4.2.6 nullptr
在 C 和 C++ 中,如果将字面量 0 赋值给指针或与指针比较时它表示空指针。更令
人困惑的是,如果将任何求值为零的整数常量表达式赋值给指针或与指针比较时
它也表示空指针。例如:
int* p = 99-55-44; // 空指针
int* q = 2;
// 错误:2 是一个 int,而不是一个指针
这使很多人感到烦恼和困惑,因此有一个标准库宏 NULL(从 C 中采用),它在标
准 C++ 中定义为 0。某些编译器会对 int* p = 0 提出警告;但是我们仍然没法为
函数针对指针和整数重载而避免 0 的歧义。
这很容易通过给空指针命名来解决,但是不知何故没有人能提出一份人们能达成
一致的提议。在 2003 年的某个时候,我正通过电话参加一个会议,讨论如何给空
指针命名。如 NULL、null、nil、nullptr 和 0p 等建议名都是备选方案。照旧,
那些简短而“漂亮”的名字已经被使用了成千上万次,因此不能在不破坏数百万
行代码的情况下使用。我听了数十次这样的讨论,有点厌烦了,只是在似听非
听。人们说到 null pointer, null ptr, nullputter 的变体。我醒过来说:
“你们都在说
nullptr。我想我没有在代码中看到过它”
。
Herb Sutter 和我写下了该提案 [Sutter and Stroustrup 2003],该提案在 2007 年相
对容易地通过了(仅仅进行了四次小修订后)
,所以现在我们可以说:
int p0 = nullptr;
int* p1 = 99-55-44;
int* p2 = NULL;
// 可以,为了兼容性
// 可以,为了兼容性
int f(char*);
int f(int);
int x1 = f(nullptr); // f(char*)
int x2 = f(0);
// f(int)
我对 nullptr 的发音是“null pointer”
。
59
4. C++11:感觉像是门新语言
我仍然认为如能将宏 NULL 定义为 nullptr 可以消除一类重要的问题,但委员会认
为这一改变过于激进。
4.2.7 constexpr 函数
在 2003 年,Gabriel Dos Reis 和我提出了用于在 C++ 中进行常量表达式求值的一
种根本不同且明显更好的机制 [Dos Reis 2003]。人们当时使用(无类型的)宏和
贫乏的 C 语言定义的常量表达式。另一些人则开始使用模板元编程来计算值
(§10.5.2)
。
“这既乏味又容易出错” [Dos Reis and Stroustrup 2010]。我们的目标
是
•
•
•
•
•
让编译期计算达到类型安全
一般来说,通过将计算移至编译期来提高效率
支持嵌入式系统编程(尤其是 ROM)
直接支持元编程(而非模板元编程(§10.5.2)
)
让编译期编程与“普通编程”非常相似
这个想法是简单的:允许在常量表达式中使用以 constexpr 为前缀的函数,还允
许在常量表达式中使用简单用户定义类型,叫字面量类型。字面量类型基本上是
一种所有运算都是 constexpr 的类型。
考虑这样一个应用,为了提高效率、支持 ROM 或可靠性,我们想使用一套单位制
[Dos Reis and Stroustrup 2010]:
struct LengthInKM {
constexpr explicit LengthInKM(double d) : val(d) { }
constexpr double getValue() { return val; }
private:
double val;
};
struct LengthInMile {
constexpr explicit LengthInMile(double d) : val(d) { }
constexpr double getValue() { return val; }
constexpr operator LengthInKM() { return LengthInKM(1.609344 * val); }
private:
double val;
};
有了这些,我们可以制作一个常量表,而不必担心单位错误或转换错误:
LengthInKM marks[] = { LengthInMile(2.3), LengthInMile(0.76) };
60
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
传统的解决方案要么需要更多的运行时间,要么需要程序员在草稿纸上算好值。
我对单位制的兴趣是由 1999 年的火星气候探测者号的失事激发的,事故原因是单
位不匹配没有被发现 [Stephenson et al. 1999]。
constexpr 函数可以在编译期进行求值,因此它无法访问非本地对象(它们在编
译时还不存在)
,因此 C++ 获得了一种纯函数。
为什么我们要求程序员应该使用 constexpr 来标记可以在编译期执行的函数?原
则上,编译器可以弄清楚在编译期可以计算出什么,但是如果没有标注,用户将
受制于各种编译器的聪明程度,并且编译器需要将所有函数体“永远”保留下
来,以备常量表达式在求值时要用到它们。我们选择 constexpr 一词是因为它足
够好记,但又“足够奇怪”而不会破坏现有代码。
在某些地方,C++ 需要常量表达式(例如,数组边界和 case 标签)
。另外,我们可
以通过将变量声明为 constexpr 来要求它在编译期被初始化:
constexpr LengthInKM marks[] = { LengthInMile(2.3), LengthInMile(0.76) };
void f(int x)
{
int y1 = x;
constexpr int y2 = x;
constexpr int y3 = 77;
}
// 错误:x 不是一个常量
// 正确
早期的讨论集中在性能和嵌入式系统的简单示例上。直到后来(大约从 2015 年开
始 ), constexpr 函 数 才 成 为 元 编 程 的 主 要 支 柱 (§10.5.2)。C++14 允 许 在
constexpr 函数中使用局部变量,从而支持了循环;在此之前,它们必须是纯函
数式的。C++20(最终,在首次提出后约 10 年)允许将字面类型用作值模板参数
类型 [Maurer 2012]。因此,C++20 将非常接近最初的目标(1979 年),即在可以
使用内建类型的地方也都可以使用用户定义的类型(§2.1)
。
constexpr 函数很快变得非常流行。它们遍布于 C++14、C++17 和 C++20 标准
库,并且不断有相关建议,以求在 constexpr 函数中允许更多的语言构造、将
constexpr 应 用 于 标 准 库 中 的 更 多 函 数 , 以 及 为 编 译 期 求 值 提 供 更 多 支 持
(§9.3.3)
。
但是, constexpr 函数进入标准并不容易。它们一再被认为是无用和无法实现
的。实现 constexpr 函数显然需要改进较老的编译器,但是很快,所有主要编译
器的作者都证明了“无法实现”的说法是错误的。关于 constexpr 的讨论是有史
以来最激烈、最不愉快的。让初始版本通过标准化流程 [Dos Reis and Stroustrup
2007] 花费了四年的时间,而完整地完成又花了十二年的时间。
61
4. C++11:感觉像是门新语言
4.2.8 用户定义字面量
“用户定义字面量”是一个非常小的功能。但是,它合乎我们的总体目标,即让
用户定义类型得到和内建类型同等的支持。内建类型有字面量,例如, 10 是整
数,10.9 是浮点数。我试图说服人们,对于用户定义类型,显式地使用构造函数
是等价的方式;举例来说, complex<double>(1.2,3.4) 就是 complex 的字面量
等价形式。然而,许多人认为这还不够好:写法并不传统,而且不能保证构造函
数 在 编 译 期 被 求 值 ( 尽 管 这 还 是 早 年 间 的 事 )。 对 于 complex , 人 们 想 要
1.2+3.4i。
与其他问题相比,这似乎并不重要,所以几十年来什么都没有发生。2006 年的一
天,David Vandevoorde(EDG)、Mike Wong(IBM)和我在柏林的一家中餐馆吃
了一顿丰盛的晚餐。我们在餐桌边聊起了天,于是一个设计浮现在一张餐巾纸
上。这个讨论的起因是 IBM 的一项十进制浮点提案中对后缀的需求,该提案最终
成了一个独立的国际标准 [Klarer 2007]。在大改后,该设计在 2008 年成为用户定
义字面量(通常称为 UDL)[McIntosh et al. 2008]。当时让 UDL 变得有趣的重要发
展是 constexpr 提案的进展(§4.2.7)
。有了它,我们可以保证编译期求值。
照例,找到一种可接受的写法是一个问题。我们决定使用晦涩的 operator"" 作为
字面量运算符(literal operator)的写法是可以接受的,毕竟 "" 是一个字面量。
然后,""x 是后面跟着后缀 x 的字面量。这样一来,要定义一个用于 complex 数的
Imaginary 类型,我们可以定义:
constexpr Imaginary operator""i(long double x) { return Imaginary(x); }
现在,3.4i 是一个 Imaginary,而 1.2+3.4i 是 complex<double>(1.2,3.4)。任
务完成!
这一功能的语言技术细节相当古怪,但我认为对于一个相对很少使用的特性来
说,这是合理的。即使在大量使用 UDL 时,字面量运算符的定义也很少。最重要
的是后缀的优雅和易用性。对于许多类型,重要的是可以在编译时完成从内建类
型到用户定义类型的转换。
很自然,人们使用 UDL 来定义许多有用的类型的字面量,有些来自标准库(例
如, s 代表 秒, s 代表 std::string )。关于支持二进制字面量的讨论,Peter
Sommerlad(HSR)提出了我认为的“最佳滥用规则”奖的候选方案:适当地定
义 operator""_01(long int),于是 101010_01 就成了个二进制字面量!当惊讶
和笑声平息下来后,委员会决定在语言本身里定义二进制字面量并使用 0b 作为前
缀,表示“binary”
(例如 0b101010)
,类似于使用 0x 表示“hexadecimal”(例如
0xDEADBEEF)
。
62
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
4.2.9 原始字符串字面量
这是一个罕见的简单特性,它的唯一目的是为容易出错的写法提供一种替代方
法。和 C 一样,C++ 使用反斜杠作为转义字符。这意味着要在字符串字面量中表
示反斜杠,你需要使用双反斜杠(\\)
,当你想在字符串中使用双引号时,你需要
使用 \"。然而,通常的正则表达式模式广泛使用反斜杠和双引号,所以模式很快
变得混乱和容易出错。考虑一个简单的例子(美国邮政编码)
:
regex pattern1 {"\\w{2}\\s*\\d{5}(-\\d{4})?"}; // 普通字符串字面量
regex pattern2 {R"(\w{2}\s*\d{5}(-\d{4})?)"};
// 原始字符串字面量
这两种模式是相同的。原始字符串字面量 R"(…)" 的括号可以精调以容纳更复杂的
模式,但是当你使用正则表达式(§4.6)时,最简单的版本就足够了,而且非常
方便。当然,提供原始字符串字面量是一个小细节,但是(类似于数字分隔符
(§5.1)
)
,深受需要大量使用字面量的人们的喜爱。
原始字符串字面量是 Beman Dawes 在 2006 年 [Dawes 2006] 基于使用 Boost.Regex
[Maddock 2002] 的经验而提出来的。
4.2.10 属性
在程序中,属性提供了一种将本质上任意的信息与程序中的实体相关联的方法。
例如:
[[noreturn]] void forever()
{
for (;;) {
do_work();
wait(10s);
}
}
属性 [[noreturn]] 通知编译器或其他工具 forever() 永远不会返回,这样它就
可以抑制关于缺少返回的警告。属性用 [[…]] 括起来。
属性最早是在 2007 年由库工作组的负责人 Alisdair Meredith [Meredith 2007] 提出
来的,目的是消除专有属性写法(例如 __declspec 和 __attribute__)之间的不
兼容性,这种不兼容性会使库实现更加复杂。对此,Jens Maurer 和 Michael Wong
对问题进行了分析,并提出了 [[…]] 语法,方案是基于 Michael 为 IBM 的 XL 编译
器所做的实现 [Maurer and Wong 2007]。除了对大量不可移植的实践进行标准化
之外,这还将允许用更少的关键字来完成语言扩展,而新的关键字总是有争议
的。
63
4. C++11:感觉像是门新语言
该提案提到了可能的使用:覆盖虚函数的明确语法,动态库,用户控制的垃圾收
集,线程本地存储,控制对齐,标识“简旧数据”
(POD)类,default 和 delete 的
函数,强类型枚举,强类型 typedef,无副作用的纯函数,final 覆盖,密封类,对
并发性的细粒度控制,运行期反射支持,及轻量级契约编程主持。在早期的讨论
中还提到了更多。
“属性”当然是一个使某些事情变得更简单的特性,但我不确定它是否鼓励了良
好的设计,或者它简化的“事情”总是能产生最大的好处。我可以想象属性打开
了闸门,放进来一大堆不相关的、不太为人们了解的、次要的特性。任何人都可
以为编译器添加一个属性,并游说各处采用它,而不是向 WG21 提出一个特性。
许多程序员就是喜欢这些小特性。不需要引入关键字和修改语法能降低门槛。但
同样能造成不可避免的对特性交互关注度不够,造成重叠而不兼容的类似特性出
现在不同的编译器中。这种情况在私有扩展中已经发生过了,但我认为它们是不
可避免的、局部的,而且往往是暂时的。
为了限制潜在的损害,我们决定属性应该意味着不改变程序的语义。也就是说,
忽略属性,编译器不会有任何危害。多年来,这条“规则”几乎奏效。大多数标
准属性——尽管不是全部——没有语义效果,即使它们有助于优化和错误检测。
最后,大多数最初那些建议的对属性的使用都通过普通的语法和语言规则来解
决。
C++11 增加了标准属性 [[noreturn]] 和 [[carries_dependency]]。
C++17 增加了 [[fallthrough]]、[[nodiscard]] 和 [[maybe_unused]]。
C++20 增 加 了 [[likely]] 、 [[unlikely]] 、 [[deprecated(message)]] 、
[[no_unique_address]] 和 [[using: …]]。
我仍然看到属性扩散是一个潜在的风险,但到目前为止,水闸还没有打开。C++
标准库大量使用了属性;[[nodiscard]] 属性尤其受欢迎,特别用来防止由于没
有使用本身是资源句柄的返回值而造成的潜在资源泄漏。
属性语法被用于(失败的)C++20 契约设计(§9.6.1)
。
4.2.11 垃圾收集
从 C++ 的早期开始,人们就考虑可选的垃圾收集(对于“可选”有各种定义)
[Stroustrup 1993, 2007]。经过一番争论,C++11 为 Mike Spertus 和 Hans-J. Boehm
设计的保守垃圾收集器提供了一个接口 [Boehm and Spertus 2005; Boehm et al.
2008]。然而,很少有人留意到这一点,更少有人使用了垃圾收集(尽管有好的收
集器可用)
。设计的方法是 [Boehm et al. 2008]:
64
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
同时支持垃圾收集实现和基于可达性的泄漏检测器。这是通过把“隐藏指
针”的程序定为未定义行为来实现的;举例来说,将指针与另一个值进行
异或运算,然后将它转换回普通指针并对其进行解引用就是一种隐藏行
为。
这项工作造福了 C++ 语义的精确规范,并且 C++ 中也存在一些对垃圾收集的使用
(例如,在 Macaulay2 中 [Eisenbud et al. 2001; Macaulay2 2005–2020])。然而,
垃圾收集器不处理非内存资源,而 C++ 社区通常选择使用资源管理指针(§4.2.4)
和 RAII(§2.2.1)二者的组合。
4.3 C++11:改进对泛型编程的支持
泛型编程(及其产物模板元编程(§10.5.2))在 C++ 98 中迅速轻松地获得了成
功。它的使用对语言造成了严重的压力,而不充分的语言支持导致了巴洛克式矫
揉造作的编程技巧和可怕的错误消息。这证明了泛型编程和元编程的实用性,许
多明智的程序员为了获得其好处而甘愿承受其痛苦。这些好处是
•
•
•
•
超越以 C 风格或面向对象风格所可能获得的灵活性
更清晰的代码
更细的静态类型检查粒度
效率(主要来自内联、让编译器同时查看多处的源代码,以及更好的类型
检查)
C++11 中支持泛型编程的主要新特性有:
•
•
•
•
•
§4.3.1:lambda 表达式
§4.3.2:变参模板
§4.3.3:template 别名
§4.3.4:tuple
§4.2.5:统一初始化
在 C++11 中,概念本应是改进支持泛型编程的核心,但这并没有发生(§6.2.6)。
我们不得不等到 C++20(§6.4)
。
4.3.1 lambda 表达式
BCPL 允许将代码块作为表达式,但是为了节省编译器中的空间,Dennis Ritchie
没有在 C 中采用这个特性。我在这点上遵循了 C 的做法,但是添加了 inline 函
数,从而(重新)得到在没有函数调用的开销下执行代码的能力。不过,这仍然
不能提供以下能力
65
4. C++11:感觉像是门新语言
•
•
把代码写在需要它的那个准确位置上(通常作为函数参数)。
从代码内部访问代码的上下文。
在 C++98 的开发过程中,曾有人提议使用局部函数来解决第二点,但被投票否决
了,因为这可能成为缺陷的来源。
C++ 不允许在函数内部定义函数,而是依赖于在类内部定义的函数。这使得函数
的上下文可以表示为类成员,因而函数对象变得非常流行。函数对象只是一个带
有调用运算符( operator()() )的类。这曾是一种非常高效和有效的技术,我
(和其他人)认为有名字的对象比未命名的操作更清晰。然而,只有当我们可以
在某样东西使用的上下文之外给它一个合理的名称,特别是如果它会被使用多次
时,这种清晰度上的优势才会表现出来。
2002 年,Jaakko Jarvi 和 Gary Powell 编写了 Boost.Lambda 库 [Jarvi and Powell
2002] 这让我们可以写出这样的东西
find_if(v.begin(), v.end(), _1<i);
// 查找值小于 i 的元素
这里,_1 是代码片段 _1<i 的某个第一个实参的名称,而 i 是表达式所在作用域
(enclosing scope)中的一个变量。_1<i 展开为一个函数对象,其中 i 被绑定到
一个引用,_1 成为 operator()() 的实参:
struct Less_than {
int& i;
Less_than(int& ii) :i(ii) {} // 绑定到 i
bool operator()(int x) { return x<i; } // 跟参数比较
}
lambda 表达式库是早期模板元编程的典范(§10.5.2),非常方便和流行。不幸的
是,它的效率并不特别高。多年来,我追踪了它相对于手工编码的同等实现的性
能,发现它的开销是后者的 2.5 倍且这种差距相当一致。我不能推荐一种方便但却
很慢的东西。这样做会损害 C++ 作为产生高效代码的语言的声誉。显然,这种慢
在一定程度上是由于优化不当造成的,但出于这个和其他原因,我们有一群人在
Jaakko Jarvi 领导下决定将 lambda 表达式作为一种语言特性 [Willcock et al. 2006]
来提出。举例来说:
template<typename Oper>
void g(Oper op)
{
int xx = op(7);
// ...
}
void f()
66
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
{
int y = 3;
g(<>(int x) -> int {return x + y;});
// 以 lambda 表达式作为参数调用 g()
}
这里,xx 会变成 3+7。
<> 是 lambda 表达式引导器。我们不敢提出一个新的关键词。
这一提议引起了相当多的兴奋和许多热烈的讨论:
•
•
•
•
•
•
•
•
•
语法应该是富有表现力的还是简洁的?
lambda 表达式可以从哪个作用域引用什么名字?[Crowl 2009]。
从 lambda 表达式生成的函数对象应该是可变的吗?默认情况下不是。
lambda 表达式能是多态的吗?到 C++14 才可以(§5.4)
。
lambda 表达式的类型是什么?独有的类型,除非它基本上是一个局部函
数。
lambda 表达式可以有名字吗?不。如果你需要一个名字,就把它赋给一
个变量。
名称是由值绑定还是由引用绑定?你来选择。
变量可以移动到 lambda 表达式中(相对于复制)吗?到 C++14 才可以
(§5)
。
语法是否会与各种非标准扩展发生冲突?(不严重)
。
到 2009 年 lambda 表达式被批准时,语法已经发生了变化,变得更加合乎惯例
[Vandevoorde 2009]:
void abssort(float* x, unsigned N)
{
std::sort(x, x+N,
[](float a, float b) { return std::abs(a) < std::abs(b); }
);
}
从 <> 切换到 [] 是由 Herb Sutter 建议并由 Jonathan Caves 实现的。这种变化在一
定程度上是由于需要一种简单的方法来指定 lambda 表达式可以使用周围作用域中
的哪些名称。Herb Sutter 回忆道:
我的并行算法项目需要 lambda 表达式,这是我的动机……看到 EWG 所采
用的 lambda 表达式那实在丑到爆的用法,以及从语法一致性/干净性的角
度来看极为糟糕的设计(例如,捕获出现在两个分开的位置,语法元素使
用不一致,顺序错误——因为“构造函数”元素应该先出现然后才是调用
“运算符”元素,以及其他一些小问题)
。
67
4. C++11:感觉像是门新语言
默认情况下,lambda 表达式不能引用在本地环境的名字,所以它们只是普通的函
数。然而,我们可以指定 lambda 表达式应该从它的环境中“捕获”一些或所有的
变量。回调是 lambda 表达式的一个常见用例,因为操作通常只需要写一次,并且
操作会需要安装该回调的代码上下文中的一些信息。考虑:
void test()
{
string s;
// ... 为 s 计算一个合适的值 ...
w.foo_callback([&s](int i){ do_foo(i,s); });
w.bar_callback([=s](double d){ return do_bar(d,s); });
}
[&s] 表示 do_foo(i,s) 可以使用 s , s 通过引用来传递(“捕获”)。 [=s] 表示
do_bar(d,s) 可以使用 s,s 是通过值传递的。如果回调函数在与 test 相同的线
程上被调用,[&s] 捕获可能效率更高,因为 s 没有被复制。如果回调函数在不同
的线程上被调用,[&s] 捕获可能是一个灾难,因为 s 在被使用之前可能会超出作
用域;这种情况下,我们想要一份副本。一个 [=] 捕获列表意味着“将所有局部
变量复制到 lambda 表达式中”
。而一个 [&] 捕获列表意味着“lambda 表达式可以
通过引用指代所有局部变量”
,并意味着 lambda 表达式可以简单地实现为一个局
部函数。事实证明,捕获机制的灵活性非常有价值。捕获机制允许控制可以从
lambda 表达式引用哪些名称,以及如何引用。这是对 1990 年代人们担心局部函
数容易出错的一种回答。
lambda 表达式的实现基本上是编译器构建一个合适的函数对象并传递它。捕获的
局部变量成为由构造函数初始化的成员,lambda 表达式的代码成为函数对象的调
用运算符。例如,bar_callback 变成:
struct __XYZ {
string s;
__XYZ(const string& ss) : s{ss} {}
int operator()(double d) { return do_bar(d,s); }
};
lambda 表达式的返回类型可以从它的返回语句推导出来。如果没有 return 语句,
lambda 表达式就不会返回任何东西。
我把 lambda 表达式归类为对泛型编程的支持,因为最常见的用途之一——也是主
要的动机——是用作 STL 算法的参数:
// 按降序排序:
sort(v.begin(),v.end(),[](int x, int y) { return x>y; });
因此,lambda 表达式显著地增加了泛型编程的吸引力。
68
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
在 C++11 之后,C++14 添加了泛型 lambda 表达式(§5.4)和移动捕获(§5)
。
4.3.2 变参模板
2004 年,Douglas Gregor、Jaakko Jarvi 和 Gary Powell(当时都在印第安纳大学)
提出了变参模板 [Gregor et al. 2004] 的特性,用来:
直接解决两个问题:
•
不能实例化包含任意长度参数列表的类模板和函数模板。
•
不能以类型安全的方式传递任意个参数给某个函数
这些都是重要目标,但我起初发现其解决方案过于复杂,记法太过晦涩,按我的
品味其编程风格又太递归。不过在 Douglas Gregor 于 2004 年做的精彩演示之后,
我改变了主意并全力支持这项提案,帮助它在委员会顺利通过。我被说服的部分
原因是变参模板和当时的变通方案在编译时间上的对比测量。编译时间过长的问
题随模板元编程的大量使用(§10.5.2)变得越来越严重,对此变参模板是一项重
大(有时是 20 倍)改进。可惜,变参模板越变越流行,也成了 C++ 标准库中必需
的部分,以至编译时间的问题又出现了。不过,成功的惩罚(在当时)还是在遥
远的将来。
变参模板的基本思路是,递归构造一个参数包,然后在另一个递归过程来使用
它。递归技巧是必须的,因为参数包中的每个元素都有它自己的类型(和大小)。
考虑 printf 的一种实现,能够处理可由标准库 iostream 的输出运算符 << 输出的
每种类型 [Gregor 2006]:
为了创建类型安全的 printf(),我们采用以下策略:写出字符串直至碰
到第一个格式说明符,按格式打印相应的值,然后递归调用 printf() 来
打印字符串剩下部分和其余各值。
template<typename T, typename... Args>
void printf(const char* s, const T& value, const Args&... args)
{
while (*s) {
if (*s == '%' && *++s != '%') { // 忽略 % 后的字符:
// 我们已经知道要打印的类型了!
std::cout << value;
return printf(++s, args...);
}
std::cout << *s++;
}
throw std::runtime error("extra arguments provided to printf");
}
69
4. C++11:感觉像是门新语言
这里 <typename T, typename... Args> 指定了一个传统的列表,有头(T)和尾
(Args)
。每次调用会处理头,然后以尾为参数来调用自身。普通字符会被简单打
印,而格式符 % 则表示某个参数要被打印了。Doug(当时他住在印第安纳州)提
供了一个测试例子:
const char* msg = "The value of %s is about %g (unless you live in %s).\n";
printf(msg, std::string("pi"), 3.14159, "Indiana");
结果会打印
The value of pi is about 3.14159 (unless you live in Indiana).
这个实现的好处之一是,和标准的 printf 不同,用户定义的类型也和内建类型一
样会得到正确处理。通过使用 << 也避免了类型指示符和参数类型之间的不匹配,
比如 printf("%g %c","Hello",7.2)。
这个 printf 所展示的技巧是 C++20 format(§9.3.7)的基础之一。
变参模板的缺点是容易导致代码膨胀,因为 N 个参数意味着模板的 N 次实例化。
4.3.3 别名
C 定义类型别名的机制是靠 typedef。例如:
typedef double (*pf)(int);
// pf 是一个函数指针,该函数接受一个 int
// 返回一个 double
这是有点诘屈聱牙,但是类型别名在 C 和 C++ 代码中非常有用,使用非常普遍。
从最初有 C++ 模板的时候,人们就一直考虑是否可以有 typedef 模板;如果可
以,它们应该是什么样子。2002 年时,Herb Sutter 提出一个方案 [Sutter 2002]:
template<typename A, typename B> class X { /* ... */ };
template<typename T> typedef X<T,int> Xi; // 定义别名
Xi<double> Ddi;
// 相当于 X<double, int>
在此基础之上,又经历了冗长的邮件列表讨论,Gabriel Dos Reis(当时在法国国
立计算机及自动化研究院)和 Matt Marcus(Adobe)解决了特化相关的若干棘手
问 题 ,并 引 入 David Vandevoorde 称 之 为别 名 模 板 的简 化语 法 [Dos Reis and
Marcus 2003]。例如:
template<typename T, typename A> class MyVector { /* ... */};
template<typename T> using Vec = MyVector<T, MyAlloc<T> >;
其中的 using 语法,即要引入的名字总是出现在前面,则是我的建议。
70
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
我和 Gabriel Dos Reis 一道把这个特性推广成一个(几乎)完整的别名机制,并最
终得到接受 [Stroustrup and Dos Reis 2003c]。即便不涉及模板,它也给了人们一
种记法上的选择:
typedef double (*analysis_fp)(const vector<Student_info>&);
using analysis_fb = double (*)(const vector<Student_info>&);
类型和模板别名是某些最有效的零开销抽象及模块化技巧的关键。别名让用户能
够使用一套标准的名字而同时让各种实现使用各自(不同)的实现技巧和名字。
这样就可以在拥有零开销抽象的同时保持方便的用户接口。考虑某通讯库(使用
了 Concepts TS [Sutton 2017] 和 C++20 的简化记法)中的一个实例:
template<InputTransport Transport, MessageDecoder MessageAdapter>
class InputChannel {
public:
using InputMessage = MessageAdapter::InputMessage<Transport::InputBuffer>;
using MessageCallback = function<void(InputMessage&&)>;
using ErrorCallback = function<void(const error_code&)>;
// ...
};
概念和别名对于规模化地管理这样的组合极有价值。
InputChannel 的 用 户 接 口 主 要 由 三 个 别 名 组 成 , InputMessage 、 MessageCallback 和 ErrorCallback,它们由模板的参数初始化而来。
InputChannel 需要初始化它的传输层,该传输层由一个 Transport 对象表示。然
而, InputChannel 不应该知道传输层的实现细节,所以它不应直接初始化它的
Transport 成员。变参模板(§4.3.2)就派上了用场:
template<InputTransport Transport, MesssageDecoder MessageAdapter>
class InputChannel {
public:
template<typename... TransportArgs>
InputChannel(TransportArgs&&... transportArgs)
: _transport {forward<TransportArgs>(transportArgs)... }
{}
// ...
Transport _transport;
}
如果没有变参模板,就得定义出一个通用接口来初始化传输层,或者得把传输层
暴露给用户。
71
4. C++11:感觉像是门新语言
这个漂亮的例子展示了如何把 C++11 的特性(加上概念)组合起来以优雅的零开
销方案解决一个困难问题。
4.3.4 tuple
C++98 有个 pair<T,U> 模板;它主要用来返回成对的值,比如两个迭代器或者一
个指针加上一个成功标志。2002 年时,Jaakko Jarvi 在参考 Haskell、ML、Python
和 Eiffel 后,提议把这个思路进一步推广,变成 tuple(元组)[Jarvi 2002]:
元组是大小固定而成员类型可以不同的容器。作为一种通用的辅助工具,
它们增加了语言的表现力。举几个元组类型一般用法的例子:
•
作为返回类型,用于需要超过一个返回类型的函数
•
编组相关的类型或对象(如参数列表中的各条目)成为单个条目
•
同时赋多个值
对于特定的设计意图,定义一个类,并在里面对成员进行合理命名、清晰表述成
员间的语义关系,通常会是最好的做法。Alisdair Meredith 在委员会内力陈以上观
点,劝阻在接口中过度使用未命名的类型。然而,当撰写泛型代码时,把多个值
打包到一个元组中作为一个实体进行处理往往能简化实现。元组对于不值得命
名、不值得设计类的一些中间情况特别有用。
比如,考虑一个只需返回三个值的矩阵分解:
auto SVD(const Matrix& A) -> tuple<Matrix, Vector, Matrix>
{
Matrix U, V;
Vector S;
// ...
return make_tuple(U,S,V);
};
void use()
{
Matrix A, U, V;
Vector S;
// ...
tie(U,S,V) = SVD(A); // 使用元组形式
}
在 这 里 , make_tuple() 是 标 准 库 函 数 , 可 以 从 参 数 中 推 导 元 素 类 型 来 构 造
tuple,tie() 是标准库函数,可以把 tuple 的成员赋给有名字的变量。
使用 C++17 的结构化绑定(§8.2)
,上面例子可简化为:
72
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
auto SVD(const Matrix& A) -> tuple<Matrix, Vector, Matrix>
{
Matrix U, V;
Vector S;
// ...
return {U,S,V};
};
void use()
{
Matrix A;
// ...
auto [U,S,V] = SVD(A); // 使用元组形式和结构化绑定
}
进一步的记法简化被提议加入 C++20 [Spertus 2018],但没来得及成功通过:
tuple SVD(const Matrix& A) // 从返回语句中推导出元组模板参数
{
Matrix U, V;
Vector S;
// ...
return {U,S,V};
};
为什么 tuple 不是语言特性?我不记得当时有人这么问过,尽管一定有人想到过
这一点。长期以来(自 1979 年)
,我们的策略就是,如果能合理地将新特性以库
的形式加入 C++,就不要以语言特性加入;如果不能,就要改进抽象机制使其成
为可能。这一策略有显而易见的优势:
•
•
•
通常对一个库做试验比对一个语言特性做试验更容易,这样我们就更快地
得到更好的反馈。
库可以早在所有编译器升级到支持新特性之前就得到严肃使用。
抽象机制(类,模板等)上的改进,能在眼前问题之外提供帮助。
tuple 以 Boost.Tuple 为基础构建,其实现之巧妙也足以让众人引以为傲。在这一
特性上,并没有出现运行期效率方面的理由,使我们去偏向一个语言实现而不是
库实现。这让人颇为敬佩。
参数包就是一个拥有编译器支持接口的元组的例子(§4.3.2)
。
元组大量用于 C++ 和其他语言(例如 Python)交互的程序库里。
73
4. C++11:感觉像是门新语言
4.4 C++11:提高静态类型安全
依赖静态类型安全有两大好处:
•
•
明确意图

帮助程序员直接表达想法

帮助编译器捕获更多错误
帮助编译器生成更好的代码。
第二点是第一点的结果。受 Simula 的启发,我对 C++ 的目标是要提供一个灵活可
扩展的静态类型系统。目的不仅是类型安全,还要能够直接表达细粒度的区别,
例如物理单位检查(§4.2.7)。一段只用了内建类型如整型和浮点型写成的程序,
也算是类型安全但却没有由此带来特别的安全优势。那样的代码没有直接表达应
用中的概念。特别需要指出,int 或者 string 几乎可以表达任何东西,所以传递
这样的值就完全没有给出被传递值的任何语义信息。
C++11 中与类型安全直接相关的改进有:
•
•
•
•
•
•
•
•
•
对于线程和锁的类型安全接口——避免 POSIX 和 Windows 在并发代码中
对 void** 及宏的依赖(§4.1.2)
范围 for——避免错误地指定范围(§4.2.2)
移动语义——解决指针的过度使用问题(§4.2.3)
资源管理指针(unique_ptr 和 shared_ptr(§4.2.4)
)
统一初始化——让初始化更通用,更一致,更安全(§4.2.5)
constexpr——消除多处(无类型和无作用域的)宏的使用(§4.2.7)
用户定义的字面量——让用户定义类型更像内建类型(§4.2.8)
enum class——消除一些涉及整型常量的弱类型做法
std::array——避免内建数组不安全地“退化”成指针
委员会一直收到建议,应当通过禁止不安全特性(例如,废弃像内建数组和类型
转换这样的 C 风格特性)来改善类型安全。然而,移除特性(
“取缔”它们)的尝
试一再失败,因为用户无视移除的警告并坚持要求实现的提供者继续支持这些特
性。一个更可行的方式似乎是给用户提供使用指南和实施指南的手段,同时保持
标准本身继续和先前的版本兼容(§10.6)
。
4.5 C++11:支持对库的开发
设计 C++ 基础库,往往要在性能和易用性方面同 C++ 及其他语言的内置功能。这
时,查找规则、重载决策、访问控制、模板实例化规则等特性之中的微妙之处会
组合起来,产生强大的表达能力,但同时也暴露出可怕的复杂性。
74
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
4.5.1 实现技巧
有些实现技巧实属“黑魔法”,不应当暴露给非专家。大部分程序员可以愉快地编
写多年好的 C++ 代码,而不用了解这些复杂手段和神秘技巧。遗憾的是,初学者
们一拥而上去研究这些最可怕的特殊代码,并从给别人(经常是错误地)解释它
们的过程中得到巨大的自豪感。博主和演讲者们通过显摆令人提心吊胆的例子抬
高他们的名望。这是 C++ 语言复杂性名声的一个主要来源。在其他语言中,要么
不提供这样的优化机会,要么手段被藏在了优化器内部。
我不能在此深入细节,就只提一个技巧,它在 C++11 的发展中作为关键技巧出
现,并在基于模板的库(包括 C++ 标准库)中广为使用。它以奇怪的缩写为人所
知:SFINAE(Substitution Failure Is Not An Error,替换失败不是错误)
。
你如何表达一个当且仅当某个谓词为真时才有的操作?概念为 C++20 提供了这样
的支持(GCC 自 2015 年开始支持)
,但在 21 世纪早期,人们不得不依赖于晦涩的
语言规则。例如:
template<typename T, typename U>
struct pair {
T first;
U second;
// ...
enable_if<is_copy_assignable<T>::value
&& is_copy_assignable<U>::value,pair&>::type
operator=(const pair&);
//...
};
这样,当且仅当 pair 的两个成员都有拷贝赋值操作时 pair 才有拷贝赋值操作。
这超乎寻常的丑陋,但它对于定义和实现基础库也超乎寻常的有用——在概念还
没有出现时。
要点在于,如果成员都有拷贝赋值, enable_if<…,pair&>::type 会成为一个普
通的 pair&,否则它的实例化就会失败(因为 enable_if 没有为赋值提供一个返
回类型)。这里 SFINAE 就起作用了:替换失败不是错误;失败的结果就如同整条
声明不曾出现一样。
这里的 is_copy_assignable 是一个 type trait(类型特征),C++11 提供了数十
个这样的特征以便程序员在编译期询问类型的属性。
enable_if 元函数由 Boost 开创并成为 C++11 的一部分。一个大致合理的实现:
template<bool B, typename T = void>
struct enable_if {}; // false 的情况:里面没有 type
75
4. C++11:感觉像是门新语言
template<typename T>
struct enable_if<true, T> { typedef T type; }; // type 是 T
SFINAE 的精确规则非常微妙而难以驾驭,但是在用户的不断压力下,它们在
C++11 的发展过程中变得越来越简单和通用。SFINAE 的一个附带收获是,它从内
部显著改善了编译器,因为编译器必须能够从失败的模板实例化中进行无副作用
的回退。这就大大阻止了编译器对非本地状态的使用。
4.5.2 元编程支持
二十一世纪的头十年对于 C++ 元编程来说有点像是无法无天的美国西部拓荒时
代,新的技巧和应用在仅有基本模板机制支持的情况下被不断尝试。那些基本机
制被反复使用到令人痛苦。错误信息可谓糟糕透顶,编译时间经常奇慢无比,编
译器资源(如内存、递归深度和标识符长度)会轻易耗尽。同时,人们纷纷重新
发现同样的问题,并重新发明一些基本技巧。显然,我们需要更好的支持。改进
尝试采用了两条(至少理论上)互补的路径:
•
•
语言:概念(§6)
,编译期函数(§4.2.7)
,lambda 表达式(§4.3.1),模板
别名(§4.3.3)
,以及更精确的模板实例化规范(§4.5.1)
。
标准库:tuple(§4.3.4)
,类型特征(§4.5.1),以及 enable_if
(§4.5.1)
。
遗憾的是,概念在 C++11(§6.2)中失败了,这给(通常复杂得可怕而且容易出
错 的 ) 权 宜 之 计 留 下 了 生 存 空 间 , 典 型 情 况 会 涉 及 类 型 特 征 和 enable_if
(§4.5.1)
。
4.5.3 noexcept 规约
起初的异常设计没有办法表明某个异常可能会从某函数中抛出。我仍然认为那才
是正确的设计。为了让异常为 C++98 接纳,我们不得不加入异常规约,来列举一
个函数会抛出那些异常 [Stroustrup 1993]。使用异常规约可选,并会在运行期进
行检查。正如我担心的那样,这带来了维护的问题,在展开路径上对异常反复检
查增加的运行期开销,还有源代码膨胀。在 C++11 中,异常规约被废弃 [Gregor
2010],而到了 C++17,我们终于(一致同意)移除了异常规约这个特性。
一直有人希望能够在编译时检查函数会抛出什么异常。从类型理论的角度,在小
规模程序中,在有高速编译器和对代码完全控制的情况下,那当然行得通。委员
会一再拒绝这种想法,原因是它不能扩展到由数十(或更多)组织维护的百万行
代码规模的程序上 [Stroustrup 1994]。参见(§7.4)
。
76
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
没有异常规约,库实现者们就要面对一个性能问题:在许多重要场合,一个库实
现者需要知道一个拷贝操作是否会抛异常。如果会,就必须拿到一份拷贝以避免
留下一个无效对象(这样会违犯异常保证 [Stroustrup 1993])。如果不会,我们可
以直接写入到目标中。在这种场合,性能的差别可以非常显著,而最简单的异常
规约 throw(),什么也不抛出,在此可以帮助判断。于是,在异常规约被弃之不
用并最终从标准中移除的时候,我们基于 David Abrahams 和 Doug Gregor 的提案
[Abrahams et al. 2010; Gregor 2010; Gregor and Abrahams 2009] 引入了 noexcept
概念。
一个 noexcept 函数仍会被动态检查。例如:
void do_something(int n) noexcept
{
vector<int> v(n);
// ...
}
如果 do_something() 抛异常,程序会被终止。这样操作恰好非常接近零开销,
因为它简单地短路了通常的异常传播机制。参见(§7.3)
。
还有一个条件版本的 noexcept,用它可以写出这样的模板,其实现依赖于某参数
是否会抛异常。这是最初促成 noexcept 的用例。例如,下面代码中,当且仅当
pair 的两个元素都有不抛异常的移动构造函数时,pair 的移动构造函数才会声明
不抛异常:
template<typename First, typename Second>
class pair {
// ...
template <typename First2, typename Second2>
pair(pair<First2, Second2>&& rhs)
noexcept(is_nothrow_constructible<First, First2&&>::value
&& is_nothrow_constructible<Second, Second2&&>::value)
: first(move(rhs.first)),
second(move(rhs,second))
{}
// ...
};
其中的 is_nothrow_constructible<> 是 C++11 标准库的类型特征(type traits)
之一(§4.5.1)
。
在这相对底层和非常通用的层级写出最优代码可不简单。在基础层面上,懂得到
底该按位拷贝,该移动,还是该按成员拷贝,会带来非常大的区别。
77
4. C++11:感觉像是门新语言
4.6 C++11:标准库组件
C++ 跟其他现代语言比一直有个小巧的标准库。此外,大多标准库组件都很基
础,而不是试图处理应用层面的任务。不过,C++11 增加了几个关键的库组件来
支持特定任务:
•
•
•
•
thread——基于线程和锁的并发
regex——正则表达式
chrono——时间
random——随机数产生器和分布
和大量的商业支持程序库相比,这显然小得可怜,但这些组件质量很高,并且跟
之前的标准 C++ 相比数量也多多了。
设计这些组件,是要服务于一些特定任务。在这些任务中,它们为程序员提供了
重大帮助。遗憾的是,这些库来自不同背景,体现在接口风格上,就出现了差
异;除了要灵活和高性能之外它们没有一致的整体设计哲学。C++11 在合入一个
组件方面没有明晰的标准(C++98 有一些 [Stroustrup 1994])。更准确地说,我们
只是从现有的、已被社区证明成功的组件中接收组件进来。很多组件来自 Boost
(§2.3)
。
如果你需要使用正则表达式,标准库中新加入的 regex 就是个巨大改进了。类
似,加入无序容器(哈希表)
,如 unordered_map,为很多程序员省去了大量繁琐
的工作,使之可以产出更好的程序。然而,这些库组件并没有对人们组织代码的
方式产生重大影响,所以我在此不对这些库组件的细节展开讨论。
regex 库主要是 John Maddock 的工作 [Maddock 2002]。
哈希表不巧错过了 C++98 的截止时间,因而出现在了 C++0x 的第一批提案之中
[Austern 2002]。它们被称做无序的(例如 unordered_map ),是为了区别于老
的、有序的标准容器(例如 map)
,也是因为较明显的名字(例如 hash_map)已经
在 C++11 之前被其他库大量使用了。另外,unordered_map 也可以说是个更好的
名字,因为它指出了类型提供什么,而不是它是如何实现的。
random 库提供了分布函数和随机数产生器,其复杂性被誉为“每个随机数库都想
长成的样子”。但它对初学者或者一般用户(常需要随机数)并不易用。它在
2002 年由 Jens Maurer [Maurer 2002] 提出,并在 2006 年经由费米国家实验室的
一群人修订 [Brown et al. 2006],随即被接受。
相比之下,Howard Hinnant 的 chrono 库 [Hinnant et al. 2008] 处理时间点和时间
间隔,在提供复杂功能的同时仍保持了易用性。例如:
78
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
using namespace std::chrono; // 在子命名空间 std::chrono
auto t0 = system_clock::now();
do_work();
auto t1 = system_clock::now();
cout << duration_cast<milliseconds>(t1-t0).count() << "msec\n";
其中的 duration_cast 把依赖于时钟的“嘀嗒”节拍数转换为程序员选用的时间
单位。
使用如此简单的代码,你可以让大一学生都能感受到不同算法和数据结构的代价
差异。chrono 为 thread 库提供了时间支持(§4.1.2)
。
到了 C++20,chrono 得到进一步增强,加入了处理日期和时区的功能(§9.3.6)
。
C++20 也允许把上面的例子简化为:
cout << t1-t0 << '\n';
这就会把 t0 和 t1 之间的时间差自动以合适的单位进行输出。
79
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
5. C++14:完成 C++11
依据大版本和小版本交替发布的计划,C++14 [du Toit 2014] 的目标是“完成
C++11”(§3.2);也就是说,接受 2009 年特性冻结后的好的想法,纠正最初大规
模使用 C++11 标准时发现的问题。对这个有限目标而言,C++14 是成功的。
重要的是,它表明 WG21 可以按时交付标准。反过来,这也使得实现者能够按时
交付。在 2014 年年底之前,三个主要的 C++ 实现者(Clang、GCC 和微软)提供
了大多数人认为完整的特性。尽管并没有完美地符合标准,但人们基本上可以对
所有的特性和特性组合进行实验。要能编译“用到所有高级特性”的库,还需要
延后一些时间(对微软而言要到 2018 年),但对于大多数用户而言,对标准的符
合程度足以满足实际使用。标准工作和实现工作已经紧密联系在一起。这给社区
带来了很大的不同。
C++14 特性集可以概括为:
•
•
•
•
•
•
•
•
•
二进制字面量,例如 0b1001000011110011
§5.1:数字分隔符——为了可读性,例如 0b1001'0000'1111'0011
§5.2:变量模板——参数化的常量和变量
§5.3:函数返回类型推导
§5.4:泛型 lambda 表达式
§5.5:constexpr 函数中的局部变量
移动捕获——例如 [p = move(ptr)] {/* ... */}; 将值移入 lambda 表
达式
按类型访问元组,例如 x = get<int>(t);
标准库中的用户定义字面量,例如:10i,"Hello"s,10s,3ms,55us,
17ns
这些特性中的大多数都面临着两个问题:“很好,什么使你花了这么长的时间?”
以及“谁需要这个?”我的印象是,每个新特性都有着重要的需求作为动机——
即使该需求不是通用的。在 constexpr 函数中添加局部变量和泛型 lambda 表达式
大大改善了人们的代码。
重要的是,从 C++11 升级到 C++14 是相对无痛的,没有 ABI 破坏。经历过从
C++98 到 C++11 这一大而困难的升级的人感到了惊喜:他们升级可以比预想还
快,花费的精力也更少。
81
5. C++14:完成 C++11
5.1 数字分隔符
奇怪的是,数字分隔符引起了最激烈的争论。Lawrence Crowl 反复提出了各种选
项的分析 [Crowl 2013]。包括我在内的许多人都主张使用下划线作为分隔符(和
好几种其他语言一样)
。例如:
auto a = 1_234_567;
// 1234567
不幸的是,人们正在使用下划线作为用户定义字面量后缀的一部分:
auto a = 1_234_567_s;
// 1234567 秒
这可能会引起歧义。例如,最后一个下划线是多余的分隔符还是后缀的开始?令
我惊讶的是,这种潜在的歧义使下划线对很多人来说变得难以接受。其中一个原
因是,为了免得程序员遇到意想不到的结果,库小组为标准库保留了不以下划线
开头的后缀。经过长时间的讨论,包括全体委员会(约 100 人)的辩论,我们一
致同意使用单引号:
auto a = 1'234'567;
auto b = 1'234'567s;
// 1234567(整数)
// 1234567 秒
尽管有严厉的警告指出使用单引号会破坏无数的工具,但实际效果似乎不错。单
引号由 David Vandevoorde 提出 [Crowl et al. 2013]。他指出,在一些国家,特别是
在瑞士的金融符号中,单引号被当作分隔符来使用。
我的另一个建议,使用空白字符,则一直没有得到认同:
int a = 1 234 567;
int b = 1 234 567 s;
// 1234567
// 1234567 秒
许多人认为这个建议是一个与在愚人节发表的老文章 [Stroustrup 1998] 有关的笑
话。而实际上,它反映了一个旧规则,即相邻字符串会被连 接在一起,因而
"abc" "def" 表示 "abcdef"。
5.2 变量模板
2012 年,Gabriel Dos Reis 提议扩展模板机制,在模板类、函数和别名 [Dos Reis
2012] 之外加入模板变量。例如:
template<typename T>
constexpr T pi = T(3.1415926535897932385);
template<typename T>
T circular_area(T r)
82
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
{
return pi<T> * r * r;
}
起初,我觉得这是一种平淡无奇的语言技术上的泛化,没有特别重要的意义。然
而,为指定各种精度的常数而采取的变通办法由来已久,而且充斥着令人不安的
变通和妥协。经过这种简单的语言泛化,代码可以大幅简化。特别是,变量模板
作为定义概念的主要方式应运而生(§6.3.6)
。例如:
// 表达式:
template<typename T>
concept SignedIntegral = Signed<T> && Integral<T>;
C++20 标准库提供了一组定义为变量模板的数学常数,最常见的情况是定义为
constexpr [Minkovsky and McFarlane 2019]。例如:
template<typename T> constexpr T pi_v = unspecified;
constexpr double pi = pi_v<double>;
5.3 函数返回类型推导
C++11 引入了从 lambda 表达式的 return 语句来推导其返回类型的特性。C++14 将
该特性扩展到了函数:
template<typename T>
auto size(const T& a) { return a.size(); }
这种写法上的便利对于泛型代码中的小函数来说非常重要。但用户必须很小心,
此类函数不能提供稳定的接口,因为它的类型现在取决于它的实现,而且在编译
到使用这个函数的代码时,函数实现必须是可见的。
5.4 泛型 lambda 表达式
lambda 表达式是函数对象(§4.3.1),因此它们显然可以是模板。有关泛型(多
态)lambda 表达式的问题在 C++11 的工作中已经进行了广泛讨论,但当时被认为
还没有完全准备好(§4.3.1)
。
2012 年,Faisal Vali、Herb Sutter 和 Dave Abrahams 提议了泛型 lambda 表达式
[Vali et al. 2012]。提议的写法只是从语法中省略了类型:
auto get_size = [](& m){ return m.size(); };
83
5. C++14:完成 C++11
委员会中的许多人(包括我)都强烈反对,指出该语法太过特别,且不能推广到
受约束的泛型 lambda 表达式中。因此,写法更改为使用 auto 作为标记,指明有
类型需要推导:
auto get_size = [](auto& m){ return m.size(); };
这 使 泛 型 lambda 表 达 式 与 早 在 2002 年 就 提 出 的 概 念 提 案 和 泛 型 函 数 建 议
[Stroustrup 2003; Stroustrup and Dos Reis 2003a,b] 保持一致。
这种将 lambda 表达式语法与语言其他部分所用的语法相结合的方向与一些人的努
力背道而驰,这些人希望为泛型 lambda 表达式提供一种独特(超简洁)的语法,
类似于其他语言 [Vali et al. 2012]:
C# 3.0 (2007):
Java 1.8 (~2013):
D 2.0 (~2009):
x => x * x;
x -> x * x;
(x) { return x * x; };
我认为,使用 auto 而且没有为 lambda 表达式引入特殊的不与函数共享的记法是
正确的。此外,我认为在 C++14 中引入泛型 lambda 表达式,而没有引入概念,则
是个错误;这样一来,对受约束和不受约束的 lambda 表达式参数和函数参数的规
则和记法就没有一起考虑。由此产生的语言技术上的不规则(最终)在 C++20 中
得到了补救(§6.4)。但是,我们现在有一代程序员习惯于使用不受约束的泛型
lambda 表达式并为此感到自豪,而克服这一点将花费大量时间。
从这里简短的讨论来看,似乎委员会流程对记法/语法给予了特大号的重视。可能
是这样,但是语法并非无足轻重。语法是程序员的用户界面,与语法有关的争论
通常反映了语义上的分歧,或者反映了对某一特性的预期用途。记法应反映基础
的语义,而语法通常偏向于对某种用法(而非其他用法)有利。例如,一个完全
通用和啰嗦的记法有利于希望表达细微差别的专家,而一个为表达简单情况而优
化的记法,则有利于新手和普通用户。我通常站在后者这边,并且常常赞成两者
同时都提供(§4.2)
。
5.5 constexpr 函数中的局部变量
到 2012 年,人们不再害怕 constexpr 函数,并开始要求放松对其实现的限制。实
际上有些人希望能够在 constexpr 函数中执行任何操作。但是,无论是使用者还
是编译器实现者都还没有为此做好准备。
经 过 讨 论 ,Richard Smith( 谷 歌 ) 提 出 了 一 套 相 对 适 度 的 放 松 措 施 [Smith
2013]。特别是,允许使用局部变量和 for 循环。例如:
84
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
constexpr int min(std::initializer_list<int> xs)
{
int low = std::numeric_limits<int>::max();
for (int x : xs)
if (x < low)
low = x;
return low;
}
constexpr int m = min({1,3,2,4});
给定一个常量表达式作为参数,这个 min() 函数可以在编译时进行求值。本地的
变量(此处为 low 和 x)仅在编译器中存在。计算不能对调用者的环境产生副作
用。Gabriel Dos Reis 和 Bjarne Stroustrup 在原始的(学术)constexpr 论文中指
出了这种可能性 [Dos Reis and Stroustrup 2010]。
这种放松简化了许多 constexpr 函数并使许多 C++ 程序员感到高兴。他们不满地
发现,以前在编译时只能对算法的纯函数表达式进行求值。特别是,他们希望使
用循环来避免递归。就更长期来看,这释放出了要在 C++17 和 C++20(§9.3.3)中
进一步放松限制的需求。为了说明潜在的编译期求值的能力,我已经指出
constexpr thread 也是可能的,尽管我并不急于对此进行提案。
85
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
6. 概念
对 C++ 来说,泛型编程和使用模板的元编程已经取得了巨大的成功。但是,对泛
型组件的接口却迟迟未能以一种令人满意的方式进行合适的规范。例如,在
C++98 中,标准库算法大致是如下规定的:
template<typename Forward_iterator, typename Value>
ForwardIterator find(Forward_iterator first, Forward_iterator last,
const Value & val)
{
while (first != last && *first != val)
++first;
return first;
}
C++ 标准规定:
•
•
•
第一个模板参数必须是前向迭代器。
第二个模板参数类型必须能够使用 == 与该迭代器的值类型进行比较。
前两个函数参数必须标示出一个序列。
这些要求是隐含在代码中的:编译器所要做的就是在函数体中使用模板参数。结
果是:极大的灵活性,对正确调用生成出色的代码,以及对不正确的调用有糟糕
得一塌糊涂的错误信息。解决方案显而易见,将前两项条件作为模板接口的一部
分来指定:
template<forward_iterator Iter, typename Value>
requires equality_comparable<Value, Iter::value_type>
forward_iterator find(Iter first, Iter last, const Value& val);
这大致就是 C++20 所提供的了。注意 equity_comparable 概念,它捕获了两个模
板参数之间必需有的关系。这样的多参数概念非常常见。
表达第三个要求([first:last) 是一个序列)需要一个库扩展。C++20 在 Ranges 标
准库组件(§9.3.5)中提供了该特性:
template<range R, typename Value>
requires equality_comparable<Value, Range::value_type>
forward_iterator find(R r, const Value& val)
{
auto first = begin(r);
auto last = end(r);
while (first!=last && *first!=val)
++first;
87
6. 概念
return first;
}
为了规范模板对其参数的要求,对其提供良好支持,有过数次尝试。本节会进行
描述:
•
•
•
•
§6.1:概念的早期历史
§6.2:C++0x 中的概念
§6.3:Concepts TS
§6.4:C++20 中的概念
6.1 概念的早期历史
1980 年,我猜想泛型编程可以通过 C 风格的宏来有效支持 [Stroustrup 1982]。然
而我完全错了。一些有用的简单泛型抽象能通过这种方法表达,1980 年代的标准
化之前的 C++ 通过 <generic.h> 中的一组宏为泛型编程提供支持,但宏在大型项
目或广泛使用的情况下无法有效管理。尽管泛型编程在当时流行的“面向对象的
思想”中并没有一席之地,我确实发现了一个问题,需要解决它才能达到我对
“带类的 C”的目标。
大约在 1987 年,我尝试设计具有合适接口的模板 [Stroustrup 1994],但失败了。
我需要三个基本属性来支持泛型编程:
•
•
•
全面的通用性/表现力——我明确不希望这些功能只能表达我想到的东
西。
与手工编码相比,零额外开销——例如,我想构建一个能够与 C 语言的数
组在时间和空间性能方面相当的 vector。
规范化的接口——我希望类型检查和重载的功能与已有的非泛型的代码相
类似。
那时候没人知道如何做到全部三个方面,因此 C++ 所做到的是:
•
•
•
图灵完备性 [Veldhuizen 2003]
优于手动编码的性能
糟糕的接口(基本上是编译期鸭子类型)
,但仍然做到了静态类型安全
前两个属性使模板大获成功。
由于缺乏规范化的接口,我们在这些年里看到了极其糟糕的错误信息,到了
C++17 还仍然是这样。缺乏规范化的接口这一问题,让我和很多其他人困扰很多
年。它让我非常困扰的原因是,模板无法满足 C++ 的根本的设计标准 [Stroustrup
88
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
1994]。我们(显然)需要一种简单的、没有运行期开销的方法来指定模板对其模
板参数的要求。
多年以来,一些人(包括我)相信模板参数的要求可以在 C++ 本身中充分指定。
1994 年,我在 [Stroustrup 1994] 中记录了基本的想法,并在我的网站上发布了示
例 [Stroustrup 2004–2020]。自 2006 年以来,基于 Jeremy Siek 的作品,Boost 提
供了该想法的一个变体,Boost 概念检查库 [Siek and Lumsdaine 2000–2007]。不
知何故,它并未像我所希望的那样广泛流行。我怀疑原因是它不够通用、不够优
雅(Boost 感到有义务使用宏隐藏细节),并且在标准中不受支持。许多人将其视
为一种奇技淫巧。
为 C++ 定义的概念可以追溯到 Alex Stepanov 在泛型编程上的工作,这是 1970 年
代末开始的,一开始用的名称是“代数结构” [Kapur et al. 1981]。注意,那差不
多比 Haskell 的类型类设计 [Wadler and Blott 1989] 要早十年,比我尝试解决 C++
的类似问题要早 5 年。对于这种需求,Alex Stepanov 早在 1990 年代末期的讲座中
就使用了“概念”这一名称,并记录在 [Dehnert and Stepanov 2000]。我之所以提
到这些,是因为许多人猜测概念是从 Haskell 类型类派生而来但被错误命名了。
Alex 使用“概念”这一名称是因为概念此处用来代表应用领域(如代数)中的基
本概念。
目前使用概念作为类型谓词,依靠使用模式来描述操作,起源于二十一世纪初期
Bjarne Stroustrup 和 Gabriel Dos Reis 的工作并记录在 [Dos Reis and Stroustrup
2005b, 2006; Stroustrup and Dos Reis 2003b, 2005a]。这种方法在 1994 年的《设
计和演化》[Stroustrup 1994] 一书也被提及,但是我不记得我第一次进行尝试的
时间了。将概念建立于使用模式的主要原因是为了以一种简单而通用的方式处理
隐式转换和重载。我们了解 Haskell 类型类,但它们对当前的 C++ 设计影响不大,
因为我们认为它们太不灵活了。
精确指定并检查一个模板对于参数的要求曾经是 C++0x 的最出彩之处,会对泛型
编程提供关键支持。可是,它最终甚至没能进入 C++17。
Bjarne Stroustrup 和 Gabriel Dos Reis 在 2003 年发表的论文 [Stroustrup 2003;
Stroustrup and Dos Reis 2003a,b] 明确指出,概念是简化泛型编程的宏伟计划的一
部分。例如,一个 concept 可以被定义为一组使用模式的约束,就是说,作为对
某种类型有效的语言构造 [Stroustrup and Dos Reis 2003b]:
concept Value_type {
constraints(Value_type a)
{
Value_type b = a;
a = b;
Value_type v[] = {a};
// 拷贝初始化
// 拷贝赋值
// 不是引用
89
6. 概念
}
};
template<Value_type V>
void swap(V& a, V& b);
// swap() 的参数必须是值类型
但是,当时的语法和语义还很不成熟。我们主要是试图建立设计标准 [Stroustrup
and Dos Reis 2003a]。 从 现 代 (2018 年 ) 的 角 度 来 看 ,[Stroustrup 2003;
Stroustrup and Dos Reis 2003a,b] 有很多缺陷。但是,它们为概念提供了设计约
束,并在以下方面提出了建议:
•
•
•
•
•
•
•
•
概念——用于指定对模板参数要求的编译期谓词。
根据使用模式来指定原始约束——以处理重载和隐式类型转换。
多参数概念——例如 Mergeable<In1,In2,Out>。
类型和值概念——也就是说,概念既可以将值也可以将类型当作参数,例
如 Buffer<unsigned char,128> 。
模板的“类型的类型”简写记法——例如 template<Iterator Iter> …。
“模板定义的简化记法”——例如 void f(Comparable&); 使泛型编程更
接近于“普通编程”
。
auto 作为函数参数和返回值中约束最少的类型。
统一函数调用(§8.8.3)——减少泛型编程与面向对象编程之间的风格差
异问题(例如 x.f(y)、f(x,y) 和 x+y)
。
奇怪的是,我们没有建议通用的 requires 子句(§6.2.2)。这些都是后面所有概念
变体的一部分。
6.2 C++0x 概念
2006 年,基本上每个人都期望 [Gregor et al. 2006; Stroustrup 2007] 中所描述的概
念版本会成为 C++09 的一部分,毕竟它已经投票进入了 C++ 标准草案(工作文
件)。但是,C++0x 变成了 C++11,并且在 2009 年,概念因复杂性和可用性问题
陷入困境 [Stroustrup 2009a,b],委员会以绝对多数票一致同意放弃概念设计
[Becker 2009]。失败的原因多种多样,而且可能使我们获得在 C++ 标准化努力之
外的教训。
在 2004 年,有两项独立的工作试图将概念引入 C++。因为主要支持者分别来自印
第安纳大学和得克萨斯农工大学,这两派通常就被称为“印第安纳”和“得克萨
斯”
:
•
印第安纳:一种与 Haskell 类型类相关的方法,主要依赖于操作表来定义
概念。这派认为,程序员应当显式声明一个类型“模拟”了一个概念;也
90
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
•
就是说,该类型提供了一组由概念指定的操作 [Gregor et al. 2006]。关键
人物是 Andrew Lumsdaine(教授)和 Douglas Gregor(博士后和编译器作
者)
。
得克萨斯:一种基于编译期类型谓词和谓词逻辑的方法。这派认为,可用
性很重要,因而程序员不必显式指定哪些类型与哪些概念相匹配(这些匹
配可以由编译器计算)。对于 C++,优雅而有效地处理隐式转换、重载以
及 混 合 类 型 的 表 达 式 被 认 为 是 必 需 的 [Dos Reis and Stroustrup 2006;
Stroustrup and Dos Reis 2003b]。关键人物是 Bjarne Stroustrup(教授)和
Gabriel Dos Reis(博士后,后来成为教授)。
根据这些描述,这些方法似乎是不可调和的,但是对于当时的参与人员而言,这
并不明显。实际上,我认为这些方法在理论上是等效的 [Stroustrup and Dos Reis
2003b]。该论点的确可能是正确的,但对于 C++ 上下文中的详细语言设计和使用
的实际影响并不等同。另外,按照委员会成员的解释,WG21 的共识流程强烈鼓
励合作和联合提案,而不是在竞争性的提案上工作数年,最后在它们之间进行大
决战(§3.2)。我认为后一种方法是创造方言的秘诀,因为失败的一方不太可能放
弃他们的实现和用户,并就此消失。请注意,上面提到的所有的人在一起与
Jeremy Siek(印第安纳的研究生和 AT&T 实验室的暑期实习生)和 Jaakko Jarvi
(印第安那的博士后,得州农工大学教授)是 OOPSLA 论文的合著者,论文展示
了折中设计的第一个版本。印第安纳和得克萨斯的团体从未完全脱节,我们为达
成真正的共识而努力。另外,从事这项工作之前,我已经认识 Andrew Lumsdaine
很多年。我们确实希望折中方案能够正常工作。
在实现方面,印第安纳的设计的进度远远领先于得克萨斯的设计的进度,并且具
有更多人员参与,所以我们主要基于此进行。印第安纳的设计也更加符合常规,
基于函数签名,并且与 Haskell 类型类有明显相似之处。考虑到涉及的学术界人士
的数量,重要的是印第安纳的设计被视为更符合常规并且学术上更为得体。看来
我们“只是”需要
•
•
•
使编译器足够快
生成有效的代码
处理重载和隐式转换。
这个决定使我们付出了三年的辛勤工作和许多争论。
C++0x 概念设计在 [Gregor et al. 2006; Stroustrup 2007] 中得到阐述。前一篇论文
包含一个标准的学术“相关工作”部分,将这个设计与 Java、C#、Scala、Cecil、
ML、Haskell 和 G 中的工具进行比较。在这里,我使用 [Gregor et al. 2006] 中的例
子进行总结。
91
6. 概念
6.2.1 概念定义
概念被定义为一组操作和相关类型:
concept EqualityComparable<typename T> {
bool operator==(const T& x, const T& y);
bool operator!=(const T& x, const T& y) { return !(x==y); }
}
concept InputIterator<typename Iter> {
// Iter 必须有 value_type 成员:
typename value_type = Iter::value_type;
// ...
}
某些人(印第安纳)认为概念和类之间的相似性是一种优势。
但是,概念中指定的函数并不完全类似于类中定义的函数。例如,在一个 class
中定义的运算符具有隐式参数(
“this”
)
,而 concept 中声明的运算符则没有。
将概念定义为一组操作的方法中存在一个严重的问题。考虑在 C++ 中传递参数的
方式:
void
void
void
void
f(X);
f(X&);
f(const X&);
f(X&&);
暂时不考虑 volatile,因为它在泛型代码参数中很少见到,但是我们仍然有四种
选择。在一个 concept 中,我们是否
•
•
•
•
将 f 表示为一个函数,用户是否为调用选择了正确的参数?
是否重载了 f 的所有可能?
将 f 表示为一个函数,并要求用户定义一个 concept_map(§6.2.3)映射
到 f 的所需的参数类型?
语言是否将用户的参数类型隐式映射到模板的参数类型?
对于两个参数,我们将有 16 种选择。尽管很少有三个参数泛型函数,但是这种情
况 我 们 会 有 4*4*4 种 选 择 。 变 参 模 板 会 如 何 呢 ? 我 们 会 有 4N 种 选 择 , 如
(§4.3.2)
。
传递参数的不同方式的语义并不相同,因此我们自然而然地转向接受指定的参数
类型,将匹配的负担推到了类型设计者和 concept_maps 的作者(§6.2.3)
。
92
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
类似地,我们到底是在为 x.f(y) (面向对象样式)指定 concept 还是为 f(x,y)
(函数样式),还是两者兼而有之。这个问题在我们尝试描述二元运算符时,例如
+,会立刻出现。
回想起来,我们对于在以特定类型的操作或特定的伪签名定义的概念框架内解决
这些问题太过乐观了。“伪签名”某种程度上代表了对此处概述的问题的解决方
案。
概念之间的关系通过显式细化定义:
concept BidirectionalIterator<typename Iter>
: ForwardIterator<Iter> {
// ...
}
// BidirectionalIterator 是
// 一种 ForwardIterator
细化有点像,但又不那么像类派生。这个想法是为了让程序员明确地建立概念的
层次结构。不幸的是,这给系统引入了严重的不灵活性。概念(按常规的英语含
义)通常不是严格的层次结构。
6.2.2 概念使用
一个概念既可以用作 where 子句中的推断,也可以用作简写:
template<typename T>
where LessThanComparable<T>
// 显式谓词
const T& min(const T& x, const T& y)
{
return x<y ? x : y;
}
template<GreaterThanComparable T> // 简写记法
const T& max(const T& x, const T& y)
{
return x>y ? x : y;
}
对于简单的“类型的类型”的概念,简写形式(最早在 [Stroustrup 2003] 中提
出)很快变得非常流行。但是,我们很快发现,现有代码中的标识符中 where 太
过于流行,于是将其重命名为 requires。
6.2.3 概念映射
概念和类型之间的关系是由 concept_map 的特化来定义的:
93
6. 概念
concept_map EqualityComparable<int> {};
// int 满足 EqualityComparable
// student_record 满足 EqualityComparable:
concept_map EqualityComparable<student_record> {
bool operator==(const student_record& a, const student_record& b)
{
return a.id_equal(b);
}
};
对于 int,我们可以简单地说 int 类型具有 EqualityComparable 所要求的属性
(也就是说,它具有 == 和 !=)
,然而,student_record 没有 ==,但是我们可以
在 concept_map 中添加一个。因此,concept_map 是一种非常强大的机制,可以
在特定的环境中非侵入性地往类型中添加属性。
既然编译器已经知道 int 是可比较的,为什么我们还要再告诉编译器?
这一直是一个争论的焦点。
“印第安纳小组”一般认为明确表达意图(永远)是好
的,而“得克萨斯小组”倾向于认为除非一条概念映射能增加新的功能,写它就
不只是没用,更可能有害。显式的声明是否能使用户避免因为语义上无意义的
“意外”语法匹配而导致的严重错误?还是说这种错误会很少见,显式的建模语
句多半只是增加了编写麻烦和犯错误的机会?折中的解决方案是允许在 concept
的定义处通过加上 auto 来声明使用某条 concept_map 是可选的:
auto concept EqualityComparable<typename T> {
bool operator==(const T& x, const T& y);
bool operator!=(const T& x, const T& y) { return !(x==y); }
}
这样,当一个类型被要求是 EqualityComparable 时,即使用户没有提供该类型
的特化,编译器也会自动使用指向 EqualityComparable 的 concept_map。
6.2.4 定义检查
编译器根据模板参数的概念检查模板定义中的代码:
template<InputIterator Iter, typename Val>
requires EqualityComparable<Iter::value_type,Val>
Iter find(Iter first, Iter last, Val v)
{
while (first<last && !(*first==v)) // 错误:EqualityComparable 中没有 <
++first;
return first;
}
94
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
这里我们用到了 < 比较迭代器,但 EqualityComparable 只保证了 ==,因此这个
定义不能通过编译。捕获这种无保障操作的使用那时被视为一个重要的好处,但
是事实证明这会带来严重的负面影响:
(§6.2.5)和(§6.3.1)
。
6.2.5 教训
初始提案得到了相对迅速的批准,之后的若干年,我们忙于为初始的设计堵漏,
还要应付在通用性、可实现性、规范质量和可用性方面的意见。
作为主要实现者,Doug Gregor 为生成高质量的代码做出了英勇的表现,但最终,
支持概念的编译器在速度上仍然比只实现了无约束模板的编译器慢了 10 倍以上。
我怀疑实现问题的根源是在编译器中采用类的结构来表示概念。这样可以快速获
得早期结果,但却让概念用上了本来为类精心打造的表示方式,但概念并不是
类。将概念表示为一组函数(类似于虚成员函数),导致在处理隐式转换和混合类
型操作时出问题。将来自不同上下文的代码灵活的加以组合,原本是支撑泛型编
程和元编程的强大代码生成技术的“秘诀”
,但这种组合却无法使用 C++0x 的概念
来指定。要赶上(无约束的)模板性能,用于指定概念的函数就不能作为可被调
用的函数出现在生成的代码中(更糟糕的是,间接的函数调用也不行)
。
我不愉快地联想到了许多早期 C++ 编译器作者由于采用了 C 编译器的结构和代码
库而遇到的问题,当时用来处理 C++ 作用域和重载的代码没法合适地放到 C 语言
的编译器框架中。本着设计概念应该直接以代码表示的观点,Cfront(§2.1)使用
了特定的作用域类来避免这种问题,然而,大多数 C 语言背景的编译器作者认为
他们可以使用熟悉的 C 技巧走捷径,最终还是不得不从头开始重写 C++ 前端代
码。语言设计和实现技巧可以非常强烈地彼此影响。
很快,事情就变得很明显:为了完成从无约束的模板到使用概念的模板的转换,
我们需要语言支持。在 C++0x 的设计中,这两类模板非常不同:
•
•
受约束模板不能调用无约束模板,因为不知道无约束模板使用什么操作,
因此无法对受约束模板进行定义检查。
无约束模板可以调用受约束模板,但是检查必须推迟到实例化的时候,因
为在那之前我们不知道无约束模板在调用中使用什么类型。
第一个问题的解决方案是允许程序员使用 late_check 块,告诉编译器“别检查这
些来自受约束模板的调用” [Gregor et al. 2008]:
template<Semigroup T>
T add(T x, T y) {
T r = x + y;
// 用 Semigroup<T>::operator+
late_check {
r = x + y; // 使用在实例化的时候找到的 operator+
95
6. 概念
// (不考虑 Semigroup<T>::operator+)
}
return r;
}
这一“解决方案”充其量只能算是个补丁,而且有一个特殊的问题,即调用到的
无约束模板中不会知道 Semigroup 的 concept_map 。这样就导致一个“有趣效
果”,即一个对象可以在一段程序的两个地方以一模一样的方式被使用,但却表达
不同的语义。这样一来,类型系统就以一种实在难以追踪的方式被破坏了。
随着概念的使用越来越多,语义在概念(实际上是类型和库)设计中的作用变得
越来越清晰,委员会中的许多人开始推动一种表达语义规则的机制。这并不奇
怪,Alex Stepanov 喜欢说“概念全都是语义问题”
。然而,大部分人那时都像对待
其他语言功能一样对待概念,他们更关心语法和命名查找规则。
2009 年,Gabriel Dos Reis(在我大力支持下)提出了一种称为 axiom(公理)的
记法并获得批准 [Dos Reis et al. 2009]:
concept TotalOrdering<typename Op, typename T> {
bool operator()(Op, T, T);
axiom Antisymmetry(Op op, T x, T y) {
if (op(x, y) && op(y, x))
x <=> y;
}
axiom Transitivity(Op op, T x, T y, T z) {
if (op(x, y) && op(y, z))
op(x, z);
}
axiom Totality(Op op, T x, T y) {
op(x, y) || op(y, x);
}
}
奇怪的是,要让公理的概念被接受很困难。主要的反对意见似乎是,提议者们明
确拒绝了让编译器针对它们所使用的类型来对公理进行测试“以捕获错误”的想
法。显然,axiom 就是数学意义上的公理(也就是说,是因为你通常无法检查而
允许作的一些假设),这一观念对于某些委员是陌生的。另外一些人则不相信指定
公理还可以帮助编译器以外的工具。不过, axiom 还是被纳入了 concept 规范
中。
我们在概念的定义和实现上都存在明显的问题,但我们有了一套相当完整的工
具,努力地试图通过使用标准库 [Gregor and Lumsdaine 2008] 和其他库中定义的
概念来解决这些问题并获得经验。
96
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
6.2.6 哪里出错了?
2009 年,我不情愿地得出结论,概念工作陷入了困境。我期望能被我们解决掉的
问题仍在加剧,而新的问题又层出不穷:
•
•
•
•
•
•
•
•
我们仍然没有达成一致意见,在大多数情况下,到底应使用隐式还是显式
建模(隐式或显式使用 concept_map),哪种才是正确的方法。
我们仍然没有达成一致意见,是要依赖概念之间隐式还是显式的关系陈述
(我们是否应该以某种非常类似面向对象的继承的方式,显式地构建“精
化”关系的层次结构?)
。
我们仍不断看到一些实例,由受概念约束的代码生成出来的代码不及无约
束模板生成出来的代码。来自模板的后期组合机会仍然显示出惊人的优
势。
编写概念来捕获我们在泛型和非泛型 C++ 中惯于使用的每种转换和重载情
况仍然很困难。
我们看到了越来越多的例子,这些例子中,足够复杂的 concept_map 和
late_check 的组合导致了对类型的不一致的看法(也就是对类型系统的
惊人和几乎无法追踪的破坏)
。
标准草案中规范的复杂性吹气球般迅速膨胀,超出了所有人的预期(有
91 页,这还不包括库中对概念的使用),我们中的一些人认为它基本上不
可读。
用于描述标准库的概念集越来越大(大约有 125 个概念,仅 STL 就有 103
个)
。
编译器在代码生成方面越来越好(因为 Doug Gregor 的英勇努力),但速度
仍未提高。一些主要的编译器供应商私下里向我透露,如果一个支持概念
的编译器比旧的编译器慢 20% 以上,他们就不得不反对这些概念,不管
它们有多好。当时,支持概念的编译器要慢 10 倍以上。
在 2009 年春季,在标准的邮件群组上进行过一场广泛的讨论。起头的是 Howard
Hinnant,他提出一个关于概念使用的非常实际的问题:他正在设计的工具可以通
过两种方式来完成:一种将需要大量用户——不一定是专家用户——编写概念映
射。另一种——远不够优雅的——设计将避免使用概念映射(和概念),以免要求
用户了解有关概念的任何重要知识。
“普通用户”需要理解概念吗?理解到足以使
用它们就行?还是要能理解到足以定义它们?
这个讨论主题后来被称作“码农小明是否需要概念?”。谁是“码农小明”?
Peter Gottschling 问道。这是个好问题,我回答道:
我认为大多数 C++ 程序员都是“码农小明”(我再次表示反对该术语),我
大部分时间和使用大多数库的时候都是“码农小明”,我预料我一直都会
97
6. 概念
是,因为我会一直保持学习新技术和库。但是,我想使用概念(并且在必
要时使用概念映射),我希望“使用原则”比现在这样仅供专家使用的精
细功能要简单得多。
换句话说,我们是应该将概念设计成为供少数语言专家进行细微控制的精密设
备,还是供大多数程序员使用的健壮工具?在语言特性和标准库组件的设计中,
这个问题反复出现。关于类,我多年以来都听到这样的声音;某些人认为,显然
不应该鼓励大多数程序员定义类。在某些人眼里,普通的程序员(有时被戏称为
“码农小明”)显然不够聪明或没有足够的知识来使用复杂的特性和技巧。我一向
强烈认为大多数程序员可以学会并用好类和概念等特性。一旦他们做到了,他们
的编程工作就变得更容易,并且他们的代码也会变得更好。整个 C++ 社区可能需
要花费数年的时间来吸取教训;但是如果做不到的话,我们——作为语言和库的
设计者——就失败了。
为了回应这场讨论,并反映我对 C++0x 概念的工作方向的日益关注,我写了一篇
论文 Simplifying the use of concepts [Stroustrup 2009c] 概述了在我看来要让概念在
C++0x 中变得可接受所必须做的最小改进:
•
•
•
•
尽量少使用 concept_map。
使所有 concept_map 隐式/自动化。
概念如需要 begin(x),那它也得接受 x.begin(),反之亦然(统一函数
调用)
;
(§6.1)
,
(§8.8.3)
使所有标准库概念隐式/自动化。
这篇论文非常详细地包含了多年来出现的许多例子和建议。
我坚持让所有概念都成为隐式/自动的原因之一是观察到,如果给一个选择,最不
灵活和最不轻信的程序员可能会强迫每个人都接受他们所选择的显式概念。库作
者们表现出一种强烈的倾向,即通过使用显式的(非自动的)概念把决策推到用
户那去做,即便是对于那些最明显的选择也一样。
我当时注意到,C++ 泛型编程之父 Alex Stepanov 不久之前所写的《编程原本》
(Elements of Programming)[Stepanov and McJones 2009] 并没有使用哪怕是一条
concept_map 来描述 STL 工具的超集和当时常见的泛型编程技术的超集。
委员展开了一次讨论回应我的论文,焦点是,为了及时加入标准,我们是否来得
及达成共识。结论也很显然,没多大希望。我们没法同意“修补”概念让它对大
多数程序员可用,同时还能(多少)及时地推出标准。这样,“概念”,这个许多
有能力的人多年工作的成果,被移出了标准草案。我对“删除概念”决定的总结
[Stroustrup 2009a,b] 比技术论文和讨论更具可读性。
98
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
当委员会以压倒多数投票赞成删除概念时(我也投票赞成删除),每个发言的人都
再次确认他们想要概念。投票只是反映出概念设计还没有准备好进行标准化。我
认为问题要严重得多:委员会想要概念,但委员们对他们想要什么样的概念没有
达成一致。委员会没有一套共同的设计目标。这仍然是一个问题,也不仅仅出现
在概念上。委员之间存在着深刻的“哲学上”的分歧,特别是:
•
•
显式还是隐式:为了安全和避免意外,程序员是否应该显式地说明如何从
潜在可选方案中做决策?该讨论最终涉及有关重载决策、作用域决策、类
型与概念的匹配、概念之间的关系,等等。
专家与普通人:关键语言和标准库工具是否应该设计为供专家使用?如果
是这样,是否应该鼓励“普通程序员”只使用有限的语言子集,是否应该
为“普通程序员”设计单独的库?这个讨论出现在类、类层次结构、异
常、模板等的设计和使用的场景中。
这两种情况下,回答“是”都会使功能的设计偏向于复杂的特性,这样就需要大
量的专业知识和频繁使用特殊写法才能保证正确。从系统的角度,我倾向于站在
这类论点的另一端,更多地信任普通程序员,并依靠常规语言规则,通过编译器
和其他工具进行检查以避免令人讨厌的意外。对于棘手的问题,采用显式决策的
方式比起依靠(隐式)的语言规则,程序员犯错的机会只多不少。
不同的人从 C++0x 概念的失败中得出了不同的结论,我得出三点主要的:
•
•
•
我们过分重视早期实现。我们原本应该花更多的精力来确定需求、约束、
期望的使用模式,以及相对简单的实现模型。此后,我们可以依靠使用反
馈来让我们的实现逐步增强。
有些分歧是根本的(哲学上的),无法通过折中解决,我们必须尽早发现
并阐明此类问题。
没有一套功能集合能做到既满足一个大型专家委员会的所有不同愿望,又
不会变得过分庞大,这种膨胀会成为实现者的难题和用户的障碍。我们必
须确定核心需求,并用简单的写法来满足;对于更复杂的用法和罕见的用
例,则可以用对使用者的专业知识要求更高的功能和写法。
这些结论与概念没有什么特别的关系。它们是对大团体内的设计目标和决策过程
的一般观察。
6.3 Concepts TS
2009 年,几乎是在概念刚从 C++0x 移除之后,Gabriel Dos Reis、Andrew Sutton
和我开始重新设计概念。这次设计是根据我们最初的想法、从 C++0x 语言设计中
得到的经验、使用 C++0x 概念的经验,以及标准委员会的反馈。我们的结论是
99
6. 概念
•
•
•
概念必须有语义上的意义
概念数量应该相对较少
概念应该基本,而非最小
我们认为 C++ 标准库中包含的大部分单独使用的概念是没有意义的 [Sutton and
Stroustrup 2011]。“对于任何合理的‘概念’定义,STL 都用不了 103 个‘概
念’
!
”我在和 Andrew Sutton 的讨论中大声嚷道,“基础代数都没有超过十几个概
念!
”语言设计的讨论可以变得相当热烈。
2011 年,在 Andrew Lumsdaine 的敦促下,Alex Stepanov 在 Palo Alto 召集了为期
一周的会议。一个相当大的团队,包含了大多数与 C++0x 概念工作密切相关的
人,加上 Sean Parent 和 Alex Stepanov,一起讨论从用户的角度来解决这个问题:
理想情况下,一个被适度约束的 STL 算法集应当是什么样子?然后,我们回家记
录我们以用户为导向的设计,并发明语言机制以接近这个理想设计 [Stroustrup
and Sutton 2012]。这一努力重新启动了标准工作,而且使用的是一种全新的、与
C++0x 工作完全不同且更好的方法。2016 年 ISO 出版的概念的 TS(技术规范)
[Sutton 2017] 和 C++20 概念(§6.4)就是该会议的直接结果。Andrew Sutton 的实
现从 2012 年开始就被用于实验,并作为 GCC 6.0 或更高版本的一部分发布。
在 Concepts TS 中 [Sutton 2017]
•
•
•
•
•
•
•
概念基于编译期谓词(包括多参数谓词和值参数)
。
以使用模式来描述原始要求 [Dos Reis 和 Stroustrup 2006](requires 表达
式)
。
概念可以用在一般的 requires 子句中,当作模板形参定义中 typename 的
替代,也可以当作函数形参定义中类型名的替代。
从类型到概念的匹配是隐式的(没有 concept_map)
。
重载中概念间是隐式的关系(通过计算得出,而不需要为概念进行显式细
化)
。
没有定义检查(至少目前还没有,所以也没有 late_check)
。
没有 axiom,但这只是因为我们不想因为一个潜在有争议的特性而让设计
更加复杂、产生拖延。C++0x 的 axiom 也可以是一个好起点。
与 C++0x 的概念相比,这里非常强调简化概念的使用,其中的一个主要部分是不
要求程序员做显式表达,而让编译器根据明确规定的、简单的算法来解决问题。
支持由用户显式决策的人认为以上的方案重语义而轻语法,并警告会有“意外匹
配”和“惊吓”
。最常见的例子是 Forward_iterator 与 Input_iterator 的区别
仅在于语义:Forward_iterator 允许在其序列中做多遍扫描。没有人否认这种例
100
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
子的存在,但围绕这些例子的重要性以及如何解决它们的争论却没断过(仍然很
起劲)
。我认为让几个罕见的复杂例子主导设计是大错特错。
Concepts TS 设计是基于这样的看法(有大量经验支持),即上面这样的例子非常
罕见(特别是在精心设计的概念中 [Stroustrup 2017]),通常被概念编写者很好地
理解,而且常常可以通过在最受约束的概念上添加操作以反映语义上的差异来解
决。例如,Forward_iterator/Input_iterator 问题的一个简单解决方案是要求
Forward_iterator 提供一个 can_multipass() 操作。此操作甚至不需要做任何
事情;它存在只是为了让概念决策机制能够检查它的存在。因此,不需要专门添
加新的语言特性来解决可能出现的意外歧义。
因为这一点经常被忽视,我必须强调,概念是谓词,它们不是类或类层次结构。
根本上,我们只是问某个类型一些简单的问题,如“你是迭代器吗?”并问类型
的集合关于它们的互操作的问题,如“你们之间能用 == 来相互比较吗?”
(§6.3.2)。使用概念时,我们只问那些可以在编译期回答的问题,不涉及运行期
求值。潜在的歧义是通过比较类型(或类型集合)所涉及的谓词来检测的,而不
是让程序员写决策规则(§6.3.2)
。
出于对 C++0x 概念(§6.2.6)中所发生问题的敏感,我们小心翼翼地设计概念,以
求使用它们不会隐含显著的编译期开销。即使是 Andrew Sutton 的编译器的早期
版本,编译使用了概念的模板的速度也比编译使用变通方案(例如 enable_if
(§4.5.1)
)的程序要快。
6.3.1 定义检查
在 Palo Alto 会议后几个月之内的某个时间点,Andrew Sutton、Gabriel Dos Reis 和
我做出决定,分阶段着手设计和实现概念的语言特性。这样,我们可以从实现的
经验中学习,并在“设计冻结”之前获得早期的反馈。特别是,我们决定推迟实
现定义检查(§6.2.4);也就是说,检查并确保模板没有使用并未为其参数指定的
功能。考虑 std::advance() 的一个简化版本,它将迭代器在序列中向前移动 n 个
位置:
template<Forward_iterator Iter>
void advance(Iter p, int n)
{
p+=n; // p 前进 n 个位置
}
Forward_iterator 不提供 += ,只提供 ++ ,所以定义检查会把它当作错误抓出
来。如果不单独(在使用前)检查 advance() 的函数体,我们将只会从 += 的(错
误)使用中得到糟糕的实例化时的错误信息,请注意,模板实例化生成的代码总
会经过类型检查,所以不做定义检查不会导致运行期错误。
101
6. 概念
我们认为,概念带来的约 90% 的好处会从使用点检查中收获,而对于那些相对专
家级的受约束模板作者来说,没有定义检查也能将就一段时间。这里 90% 显然是
基于有限信息的临时估计,但得益于十年间在概念上的工作,我认为这是一个不
错的猜测。作为语言特性和库的设计者,对我们来说,更重要的是从使用中获得
经验,这一经验获得的过程始于 Palo Alto 技术备忘录 [Stroustrup and Sutton
2012] 中的 STL 算法示例。我们重视反馈胜于重视理论完整性。这种看法曾是激
进的。回顾一下关于概念的文档(在 C++ 和其他语言中)
,之所以将概念作为语言
特性提供,定义检查总是被强调成一个主要原因 [Gregor et al. 2006; Stroustrup
and Dos Reis 2003b]。
这种新设计一度被称为轻量概念(Concepts Lite),许多人认为它不完整,甚至没
用 。 但 是 , 我 们 很 快 发 现 , 不 进 行 定 义 检 查 会 带 来 真 正 的 好 处 [Sutton and
Stroustrup 2011]。
•
•
•
有了定义检查,我们在开发过程中就没办法使用部分概念检查。在构建一
个大程序的初始阶段中,不知道全部的需求是非常常见的。部分检查可以
让很多错误在早期被发现,并有助于根据早期使用的反馈逐步改进设计。
定义检查使得设计难以拥有稳定的接口。特别是,要往类或者函数中增加
调试语句、统计收集、追踪或者“遥测”之类的支持,就不能不改变类或
函数的接口来包含相应功能。这些功能对于类或函数来说很少是根本的,
而且往往会随着时间的推移而改变。
当我们不使用定义检查时,现有的模板可以逐渐转换为使用概念。但是,
如果我们有定义检查,一个受约束的模板就不能使用一个无约束的模板,
因为我们一般没法知道无约束的模板使用了哪些功能。另外,不管做不做
定义检查,一个无约束的模板使用一个有约束的模板都意味着后期(实例
化时)检查。
从 2014 年起担任 EWG 主席的 Ville Voutilainen 更为坚定地表示:
我不能支持任何包含定义检查的概念提案。
我们最终可能会得到一种定义检查的形式,但前提是我们能够设计一种机制来避
开它,以满足过渡和数据收集的需要。这需要仔细考虑,需要进行实验。C++0x
的 late_check 是不够的。
定义检查的问题是使用的问题,而不是实现的问题。Gabriel Dos Reis 设计并实现
了一种名为 Liz 的实验语言,用来测试 Concepts TS 设计中的功能 [Dos Reis
2012],包括定义检查。如果我们找到一种可接受的定义检查形式,我们就可以实
现它。
102
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
6.3.2 概念使用
简单的示例看起来很像 C++0x 及更早的版本中的样子:
template<Sequence Seq, Number Num>
Num sum(Seq s, Num v)
{
for (const auto& x : s)
v += x;
return v;
}
这里 Sequence 和 Number 是概念。使用概念而不是 typename 来引入类型的名称,
意味着使用的类型必须满足概念的要求。需要注意的是,由于 Concepts TS 不提供
定义检查,所以使用 += 不会被概念所检查,而只会在后期、在实例化时检查。以
上是最初的开发阶段中可能的做法,稍后我们很可能会更为明确:
template<typename T>
using Value_type = typename T::value_type;
// 简化的别名
template<Sequence Seq, typename Num>
requires Arithmetic<Value_type<Seq>,Num>
Num sum(Seq s, Num v)
{
for (const auto& x : s)
v += x;
return v;
}
也就是说,我们必须有算数运算符,包括 +=,以供 Sequence 的值类型和我们用
作累加器的类型的组合使用。我们不再需要说明 Num 为 Number,Arithmetic 会检
查 Num 具有所需的一切属性。在这里, Arithmetic 被显式地用作(C++0x 风格
的)requires 子句中的谓词。
重载是通过挑选具有最严格要求的函数来处理。考虑标准库中的经典函数
advance 的一个简单版本:
template<Forward_iterator Iter>
void advance(Iter p, int n) // 将 p 向前移动 n 个元素
{
while (--n)
++p; // 前向迭代器有 ++,但没有 + 或者 +=
}
template<Random_access_iterator Iter>
void advance(Iter p, int n) // 将 p 向前移动 n 个元素
103
6. 概念
{
p += n;
// 随机迭代器有 +=
}
也就是说,我们应该对提供随机访问的序列使用第二个版本,对只提供前向迭代
的序列使用第一个版本。
void user(vector<int>::iterator vip, list<string>::iterator lsp)
{
advance(vip, 10); // 使用较快的 advance()
advance(lsp, 10); // 使用较慢的 advance()
}
编译器将这两个函数的概念分解为原始(“原子”)要求,由于前向迭代的要求是
随机访问迭代要求的严格子集,所以这个例子可以被解决。
当一个参数类型同时匹配到互相之间不是严格子集的重叠要求时,会产生歧义
(编译期错误)
。例如:
template<typename T>
requires Copyable<T> && Integral<T>
T fct(T x);
template<typename T>
requires Copyable<T> && Swappable<T>
T fct(T x );
int x = fct(2); // 有歧义:int 满足 Copyable、Integral 和 Swappable
auto y = fct(complex<double>{1,2}); // OK:complex 不满足 integral
程序员唯一能利用的控制机制是在定义概念时为其增加操作。不过对于现实世界
的例子来说,这似乎已经足够了。当然,你可以定义一些只在语义上有差异的概
念,这样就没有办法根据我们的纯语法概念来区分它们。然而,要避免这样做并
不困难。
6.3.3 概念的定义
通过 requires 表达式的使用模式可指定概念的原始要求:
template<typename T, typename U =T>
concept Equality_comparable =
requires (T a, U b) {
{ a == b } -> bool ; // 使用 == 比较 T 和 U 得到一个 bool 值
{ a != b } -> bool ; // 使用 != 比较 T 和 U 得到一个 bool 值
};
104
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
requires 表达式是 Andrew Sutton 发明的,作为他实现 Concepts TS 的一部分。
事实证明它们非常有用,以至于用户坚持认为它们应该成为标准的一部分。
=T 为第二个类型参数提供默认值,因此概念 Equality_comparable 可以用于单个
类型。
使用模式的写法是 Bjarne Stroustrup 基于 2003 年的想法在 Palo Alto 的现场会议
上发明的 [Stroustrup and Dos Reis 2003b]。这种写法及其思想并不涉及函数签名
或函数表的实现。
不存在特定的机制来表达类型与概念相匹配,但如果有人要这么做,可以使用
C++11 中普通的 static_assert:
static_assert(Equality_comparable<int>);
// 成功
static_assert(Equality_comparable<int,long>); // 成功
struct S { int a; };
static_assert(Equality_comparable<S>);
// 失败了,因为结构体不会
// 自动生成 == 和 != 操作
来自 C++0x(及更早的 [Stroustrup 2003])中的关联类型表示法也得到了支持:
template<typename S>
concept Sequence = requires(S a) {
typename Value_type<S>;
typename Iterator_type<S>;
{
{
{
{
begin(a) } -> Iterator_type<S>;
end(a) } -> Iterator_type<S>;
a.begin() } -> Iterator_type<S>;
a.end() } -> Iterator_type<S>;
// S 必须具有值类型。
// S 必须具有迭代器类型。
//
//
//
//
begin(a) 必须返回一个迭代器。
end(a) 必须返回一个迭代器。
a.begin() 必须返回一个迭代器。
a.end() 必须返回一个迭代器。
requires Same_type<Value_type<S>,Value_type<Iterator_type<S>>>;
requires Input_iterator<Iterator_type<S>>;
};
注意上面的代码有重复,这是为了可以同时接受 a.begin() 和 begin(a)。缺少统
一函数调用让人头疼(§6.1)
、
(§8.8.3)
。
6.3.4 概念名称引导器
从使用中我们学到的一件事情是,基础概念的使用有很多重复。我们在 requires
语句中直接使用了太多的 requires 表达式,并且使用了太多“小”概念。我们的
概念要求看起来像新手程序员编写的代码:很少的函数,很少的抽象,很少的符
号名。
105
6. 概念
考虑标准的 merge 家族函数。这些函数都接受三个序列的输入并需要指明这些序
列之间的关系。因此就有了对序列类型的三个要求和描述序列元素之间关系的三
个要求。第一次尝试:
template<Input_iterator In1, Input_iterator In2, Output_iterator Out>
requires Comparable<Value_type<In1>,Value_type<In2>>
&& Assignable<Value_type<In1>, Value_type<Out>
&& Assignable<Value_type<In2>, Value_type<Out>
Out merge(In1, In1, In2, In2, Out);
这种形式太乏味了;而且,这种引入类型名称的模式非常常见。例如,STL 中至
少有四个 merge 函数。乏味且重复的代码非常容易出错,也难以维护。我们很快
学会了更多使用多参数概念来定义类型间要求的共同模式:
template<Input_iterator In1, Input_iterator In2, Output_iterator Out>
requires Mergeable<In1,In2,Out>
Out merge(In1, In1, In2, In2, Out);
对于 Andrew Sutton 来说,这还是太混乱了。他在 2012 年使用概念编写的代码量
可能超过任何其他人。他提出了一种机制来表达“为满足一个概念的多个类型引
入一个类型名集合”
。这样将 merge 的示例减少到了逻辑上的最少限度:
Mergeable{In1,In2,Out} // 概念名称引导器
Out merge(In1, In1, In2, In2, Out);
仅仅通过尝试,你就能学到很多东西,这真是令人惊叹!同样令人惊叹的是,对
于那些尚未经历过这些问题的人,新颖的表示法和解决方案在他们那里也会遭遇
巨大的阻力。
6.3.5 概念和类型
许多人仍然将概念视为(无论过去和现在)类型的类型这个想法的变体。是的,
只有一个类型参数的概念可以看作是一个类型的类型,但只有最简单的用法才适
合该模式。
大多数泛型函数(算法)都需要不止一个模板参数,要让这样的函数有意义,这
些参数类型必须以某种方式关联起来。因此,我们必须使用多参数概念。例如:
template<Forward_iterator Iter, typename Val>
requires Equality_comparable<Value_type<Iter>,Val>
Forward_iterator find(Iter first, Iter last, Val v)
{
while (first!=last && *first!=v)
++first;
106
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
return first;
}
至关重要的是,多参数概念直接解决了处理隐式转换和混合类型操作的需求。早
在 2003 年,我就和 Gabriel Dos Reis 一起考虑过将每个参数的所有约束条件与其
他参数隔离开来说明的可能性 [Stroustrup 2003; Stroustrup and Dos Reis 2003b]。
这将涉及
•
•
•
•
参数化(例如,用值类型来参数化的 Iterator)
某种形式的继承(例如,Random_access_iterator 是一个
Forward_iterator)
能对一个模板参数应用多个概念的能力(例如,一个 Container 的元素必
须满足 Value_type 和 Comparable)
这三种技术的组合。
结果是非常复杂的模板参数类型约束。我们认为这种复杂性是不必要的,也无法
进行管理。譬如 x+y 和 y+x,其中 x 和 y 具有不同的模板参数类型,X 和 Y。在处
理各自的模板参数时,我们必须将 X 和 Y 以及 Y 和 X 进行参数化。在纯面向对象语
言中,这看起来很自然。毕竟,有两种方法可以进行 + 运算,一种在 X 的层次结
构中,一种在 Y 的层次结构中。然而,我早在 1982 年就拒绝了 C++ 的这个解决方
案。要完成这一图景,我们必须添加隐式类型转换(例如,处理 x+2 和 2+x)
。而
多参数概念与 C++ 解决此类场景的方式完全吻合,并避免了大部分的复杂性。
这个决定经过多年的反复审查并得到确认。在设计 C++0x 概念的努力中,人们尝
试应用了标准的学术系统,正如在 Haskell 类型类(typeclass)和 Java 约束中可见
的。但是,这些做法最终不能提供在大规模使用中所需要的实现和使用上的简单
性。
当一个泛型用法符合类型的类型这一模式时,概念能非常优雅地支持它。
•
•
类型指定了一组可以(隐式和显式)应用于对象的操作,依赖于函数声明
和语言规则,并会指定对象在内存中如何布局。
概念指定了一组可以(隐式和显式)应用于对象的操作,依赖于可以反映
函数声明和语言规则的使用模式,并且不涉及对象的布局。因此,概念是
一种接口。
我的理想是,能用类型的地方就能用概念,并且使用方式相同。除了定义布局
外,它们非常相似。概念甚至可以用来约束由其初始化器来确定其类型的变量类
型(受约束的 auto 变量(§4.2.1)
)
。例如:
template<typename T>
concept Integer = Same<T,short> || Same<T,int> || Same<T,long>;
107
6. 概念
Integer x1 = 7;
int x2 = 9;
Integer y1 = x1+x2;
int y2 = x2+x1;
void f(int&);
void f(Integer&);
// 一个函数
// 一个函数模板
void ff()
{
f(x1);
f(x2);
}
C++20 离实现这一理想接近了。为了使该例子能在 C++20 中工作,我们必须在每
个 Integer (§6.4)概念后添加一个逻辑上冗余的 auto 。另一方面,在 C++20
中,我们可以使用标准库里的 integral 概念来替换明显不完整的 Integer。
6.3.6 改进
在 Concepts TS 工作的初期,一个 concept 是一个返回 bool 值的 constexpr 函数
(§4.2.7)。这很合理,因为我们把概念看作是编译期的谓词。然后 Gabriel Dos
Reis 将变量模板引入到 C++14(§5.2)中。现在,我们有了选择:
// 函数风格:
template<typename T>
concept bool Sequence() { return Has_begin<T>() && Has_end<T>(); }
// 表达式风格:
template<typename T>
concept bool Sequence = Has_begin<T> && Has_end<T>;
我们可以愉快地使用任何一种风格,但是如果两种风格都允许的话,使用概念的
用户就必须知道概念定义中使用了哪种风格,否则无法正确使用括号。很快这就
成了一个大麻烦。
函数式风格允许概念重载,但是我们只有很少的概念重载例子;于是我们决定没
有概念重载也可以。因此,我们进行了简化,只使用变量模板来表达概念。
Andrew Sutton 率先全面使用了概念的表达式形式。
我们(Andrew Sutton、Gabriel Dos Reis 和我)始终知道,显式写出 concept 返回
bool 是多余的。毕竟,概念从定义上来看就是一个谓词。然而,我们决定不去搞
108
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
乱语法而专注于语义上的重要话题。后来,人们总是将冗余的 bool 作为一个反对
概念设计的论点,因此我们对其进行了修正,不再提到 bool。
删除 bool 是 Richard Smith 提出的一系列改进建议的一部分,其中还包括更精确
地描述什么是原子谓词,以及对匹配规则的简化 [Smith and Sutton 2017]。现在,
我们使用表达式风格:
// 表达式风格:
template<typename T>
concept Sequence = Has_begin<T> && Has_end<T>;
6.3.7 等效语法
Concepts TS 支持在函数声明中使用概念的三种表示法:
•
•
•
为通用起见,显式使用 requires 语句
简写表示法,用于表示类型的类型
自然表示法(也称为简短表示法、常规表示法等)
基本思想是,让程序员使用与特定声明的需求紧密匹配的表示法,而不会因使用
更复杂声明所需的表示法而淹没该定义。为了使程序员可以自由选择表示法,尤
其是允许在项目开发初期或维护阶段随着功能的变化而调整,这些风格的表示法
被定义为等效的:
void sort(Sortable &); // 自然表示法
等同于
template<Sortable S> void sort(S&); // 简写表示法
等同于
template<typename S> requires Sortable<S> void sort(S&);
用户对此感到非常满意,并且倾向于在大多数声明中使用自然和简写表示法。但
是,有些委员会成员对自然表示法感到恐惧(“我看不出它是一个模板!”),而喜
欢使用最显式的 requires 表示法,因为它甚至可以表达最复杂的示例(“为什么
你还要比那更复杂的东西?”
)
。我的解释是,我们对什么是简单有两种看法:
•
•
我可以用最简单、最快捷的方式编写代码
我只需要学习一种表示法
我赞成前一种观点,认为这是洋葱原则(§4.2)的一个很好的例子。
109
6. 概念
自然表示法成为强烈反对概念的焦点。我——还有其他人——坚持这种优雅的表
达
void sort(Sortable&); // 自然表示法
我们看到(过去和现在)这是有用而优雅的一步,可以使泛型编程逐渐变成一种
普通的编程方式,而不是一种具有不同语法、不同源代码组织偏好(“仅头文
件”)和不同编码风格(例如模板元编程(§10.5.2))的暗黑艺术。模块解决了源
代码组织问题(§9.3.1)。另外,更“自然”的语法解决了人们总是抱怨的关于模
板语法过于冗长和笨拙的问题,我同意这些抱怨。在设计模板时, template<…>
前缀语法不是我的首选。由于人们总是担心能力不强的程序员滥用模板而引起混
淆和错误,我被迫接受了这种写法。繁重的异常处理语法(try { … } catch
( … ) { … })也是类似的故事 [Stroustrup 2007]。似乎对于每个新特性,许多人
都要求有醒目的语法来防止实际和想象中的潜在问题。然后过一段时间后,他们
又抱怨太啰嗦了。
无论如何,有为数不少的委员会成员坚持认为自然表示法会导致混乱和误用,因
为人们(尤其是经验不足的程序员)不会意识到以这种方式定义的函数是模板,
和其他函数并不相同。我在使用和教授概念的多年里并没有观察到这些问题,因
此我并不特别担心这样的假设性问题,但反对意见仍然非常强烈。人们就是知道
这样的代码很危险。主要的例子是
void f(C&&); // 危险:C 是一个概念还是类型?
C&& 的含义因 f 是函数模板还是“普通的”函数而有所不同。在我看来,C&& 语义
上的这种差异是 C++11 中最不幸的设计错误,我们应该尝试纠正这一错误,而不
是让它影响概念的定义。毫无疑问,误解的可能性是真实存在的,并且一旦该机
制被很多人使用时,肯定会发生。但是,我在现实中没有看到过这种问题,而且
我怀疑经验相对丰富的程序员如果遇到这种差异真正会产生影响时,真的会遇到
麻烦。换句话说,我认为这是“尾巴摇狗”的一个示例;也就是说,一个不起眼
的例子阻止了一个可以使大量用户受益的特性。
我也很确定,我的目标是使泛型编程尽可能地像“普通”编程,但这不是普遍共
识。仍然有人认为,泛型编程超出了绝大部分程序员的能力。但我没有看到任何
证据。
6.3.8 为什么在 C++17 中没有概念?
我曾希望并期望在 C++17 看到概念。在我认为在 2017 年时间窗口可行的扩展
(§9.2)中,我把概念看作是对 C++ 程序员的基本词汇的最重大改进。它可以消
除很多对丑陋且易出错的模板元编程(§10.5.2)的需求,可以简化库的精确规范
110
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
定义,并显著改善库的设计。恐怕这就是问题的一部分:概念会直接影响所有投
票成员。有些人对旧的方式比较满意,有些人没有概念方面的经验,而有些人则
认为它们是未被尝试过的(
“学院派”/“理论派”
)想法。
C++0x 概 念(§6.2) 的 惨败 加 剧了 这 种担 忧, 这导 致 我们 首 先有 了技 术规 范
(TS)[Sutton 2017]。我们没有语言特性方面的技术规范经验,但是这似乎值得
尝试 :Andrew Sutton 在 GCC 中的概念实现仍然比较新,需 要谨慎评估。 在
(2013 年的)Bristol 标准会议上,Herb Sutter 强烈主张采用 TS 路线,而我和 JDaniel Garcia 警告说可能会有延期。我还指出了将概念与通用 lambda 表达式
(§4.3.1)分开考虑的危险性,但是“谨慎”和“我们需要更多经验”在标准委员
会里是很有力的理由。最终,我投票赞成了 Concepts TS。现在我把这看作是一个
错误。
2013 年,我们有了一个概念的实现和一个相当不错的规范(主要感谢 Andrew
Sutton)
,但是完成 Concepts TS 还是花了三年的时间。我无法识别出完善 TS 和纳
入 ISO 标准在严格程度有什么区别。但是,在 2016 年 Jacksonville 会议上,当对
TS 中描述的概念进行投票以将其纳入标准时,所有先前的反对意见又出现了。反
对者似乎只是忽略了概念三年。我甚至听到了只对 C++0x 中的概念设计有效、而
与 TS 概念设计无关的反对意见。人们再次主张“谨慎”和“我们需要更多的经
验”。据我所知,由于委员会人数增长的部分原因,在 Jacksonville 会议上还没有
尝试过概念的人比在 Bristol 时更多。除了我在过去十年中听到的所有反对意见之
外,有人提出了全新的反对意见,有人在全体委员会上提出了未经尝试的设计建
议,还被认真考虑了。
在 2016 年 2 月的 Jacksonville 会议上,Ville Voutilainen(EWG 主席)提议按照
Concepts TS [Voutilainen 2016c] 把概念放到标准中:
……程序员们非常渴望能使用新的语言特性,现在正是将其交付给他们的
时候了。概念化标准库需要花费时间,相信在这个过程中不会发现概念设
计有什么大的问题。我们不应该让程序员一直等待语言特性,只是因为一
些假想中的设计问题,这些问题没有证据,甚至有一些反证,很可能根本
不存在。为了使世界各地的 C++ 用户受益,让我们在 C++17 里交付概念
这一语言特性吧。
他得到了许多人的大力支持,尤其是 Gabriel Dos Reis、Alisdair Meredith(之前是
LWG 主席)和我,但是(尽管 EWG 在本周早些时候投了赞成票)投票结果依然对
我们不利:25 票赞成,31 票反对,8 票弃权。我的解释是,用户投了赞成票,语
言技术人员投了反对票,但这可能会被认为是酸葡萄吧。
在这次会议上,统一调用语法(§8.8.3)被否决,协程(§9.3.2)被转为 TS,基本
上确保了 C++17 只是标准的一个小版本(§8)
。
111
6. 概念
6.4 C++20 概念
在 2017 年,作为 C++20 的最早特性之一,WG21 将 Concepts TS [Sutton 2017] 中
基础部分和无争议的部分通过投票进入了工作文件(§6.3.2)
:
•
•
为通用起见,显式使用 requires 语句;例如 requires Sortable<S>
简写表示法,用于表示类型的类型;例如 template<Sortable S>
自然表示法(例如 void sort(Sortable&);(§6.3.7))因有争议而被排除在外。
被排除在外的原因有以下几点:
•
•
•
void sort(Sortable&); 是一个模板,但这不很明显。
void f(C&&); 的含义取决于 C 是概念还是类型。
在 Iterator foo(Iterator,Iterator); 中,三个 Iterator 必须是相同类
•
•
型,还是可以分开约束的类型?
自然语法令人困惑且难以教授。
我们如何约束 template<auto N> void f(); 中的参数?
这些异议并不新鲜,但这次它们伴随着许多使用全新语法的提案 [Honermann
2017; Keane et al. 2017; Koppe 2017a; Riedle 2017; Sutter 2018a]。这些提案各不
相同,和 Concepts TS 也不兼容。人们带着热情在会议上介绍这些提案,而其中没
有一个有实际经验的支持。相比之下,我的立场是基于约四年的教学经验、很多
的实验使用、一些业界应用,以及在几个标准库提案组件中的使用(如,迭代器
封 装 [Dawes et al. 2016]、 元组 实现 [Voutilainen 2016b]、 范 围 [Niebler et al.
2014])
。
在 Jacksonville 会议(2018)上,Tom Honerman 建议删除自然语法,并提出了另
一 种 选 择 [Honermann 2017]。 我 捍 卫 了 自 己 的 立 场 和 Concept TS 的 设 计
[Stroustrup 2017a,b]。我的辩护主要是
•
•
•
•
•
五年多来,自然语法在实际教学和使用中未引起任何问题。
用户喜欢它。
没有技术上的歧义。
它简化了常见用法。
这是使泛型编程更像普通编程的动力之一。
但这未能说服任何反对者,因此自然语法没有移到 C++20 的工作文件中。
最后一个反对意见来自 C++17 的一个新的小特性, auto 值参数 [Touton and
Spertus 2015],并成为反对的焦点:
template<auto N> void f();
112
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
人们想在语法上区分值模板参数和类型模板参数。通常,这意味着自 2002 年以来
一直在提案里被使用的简写语法将不再有效。
template<Concept T> void f(T&); // 建议被废止
在 2018 年中,我提出了一个最小折中方案 [Stroustrup 2018b]:
•
•
保留 template<Concept T> void f(T&); 的含义;
使用前缀 template 来识别使用自然表示法的模板(例如 template void
f(Concept&))
提议成功了,但是 Herb Sutter [Sutter 2018a] 提出的一个截然不同的建议也成功了
[Sutter 2018a]。我们当时处于一种非常特殊的境地,同时有两个截然不同且互不
兼容的提案,每个都得到了 EWG 的大多数人的支持。这种僵局为 Ville Voutilainen
(EWG 主席)提出一种变通方案打开了大门,这一方案在 2018 年 11 月得到了广
泛的支持并被接受 [Voutilainen et al. 2018]:
•
•
保留 template<Concept T> void f(T&); 的含义
使用 auto 来识别使用自然表达式的模板参数,例如 void f(Concept
auto&);
举例来说:
// 几乎自然的表达式:
void sort(Sortable auto& x); // x 必须 Sortable
Integral auto ch = f(val);
// f(val) 的结果必须为 Integral
Integral auto add(Integral auto x, Integral auto x); // 能用一个宽类型
// 来防止溢出
“自然表达式”已重命名为“缩写语法”
,虽然它不仅仅是一个缩写。
尽管我认为在这种 auto 的使用有些多余,分散和损害了我想使泛型编程变成“普
通编程”的目标,但我还是支持这种折中方案。也许在将来的某个时候,人们会
(正如当时 Herb Sutter 所暗示的那样)达成一致,让在概念名后的 auto 不再必
要。不过,我并没有抱太大的希望;很多人认为为技术实现而定义的语法标记很
重要。或许 IDE 的自动完成功能可以使用户免于手写这多余的 auto。
遗憾的是,对于重新引入概念名称引导器并没有达成共识(§6.3.4)。缺乏足够传
统的语法是一个主要的绊脚石。同样,仍然有很多人似乎不相信其有用。
延迟很多年才引入概念造成了长期的伤害。基于特征(traits)和 enable_if 的临
时设计数量激增。一代程序员在低级的、无类型的元编程中成长起来。
113
6. 概念
6.5 概念的命名
在发布 C++20 之前有关概念的最后讨论中,有一个是关于概念的命名约定。命名
始终是一个棘手的话题。在我早期涉及概念的工作中,我通常以非标准的命名类
型的方式来命名概念:像命名专有名词一样,将第一个字母大写,并用下划线来
分隔单词,以保证可读性(例如 Sortable 和 Forward_iterator)
。其他人(尤其
是印第安纳团队)则使用了驼峰式命名(例如 Sortable 和 ForwardIterator)。
不幸的是,这种命名约定悄悄进入了标准文本 [Carter 2018],并由于与标准库中
的所有其他名称不同而引起一些混乱。在那里,使用了下划线,不使用大写字母
(除了一些宏和三个晦涩难懂的示例)。然后有人认为,不同的命名约定旨在将
“新颖且困难”的概念与“常规构造”
(例如函数和类型)区分开来。
当我注意到这种辩解时,我非常不喜欢。在 C++ 中,我们通常不会把类型编码到
实体名称中,但我认为更改命名风格为时已晚。在 2019 年,Herb Sutter 对我的抱
怨做出了回应,提议重命名所有标准库中的概念,以遵循常见的标准库命名约定
[Sutter et al. 2019]。大部分概念设计者和范围库(§9.3.5)的设计者作为共同作者
都签了字。进行此更改的另一个原因是,我们开始看到标准库里概念的驼峰式名
称与其他库中的驼峰式名称之间存在冲突。使用驼峰式命名(或使用我的大写类
型约定)的原因之一就是为了避免与标准库冲突。因此,我们现在有了
sortable、forward_iterator 等。
C++20 标 准 库 包 含 大 约 70 个 概 念 , 包 括 constructible_from 、
convertible_to 、 derived_from 、 equal_comparable 、 invocable 、
mergeable 、 range 、 regular 、 same_as 、 signed_integral 、 semiregular 、
sortable、swappable 和 totally_ordered,涵盖了运算符调用、基本类型的使
用、范围和标准算法的需求。它们将指导许多 C++ 库的设计。请注意,这 70 个概
念中很多并不是基本概念,而只是为了方便表示或用作基本构建单元。
114
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
7. 错误处理
错误处理作为一种备受争议的话题,我认为将长期存在下去。许多人在这个问题
上有强烈的固有认知,其中一些是基于各种应用领域中扎实的经验——过去 50 多
年已经有了很多相关的技术积累。在错误处理领域,性能、通用性、可靠性的需
求往往发生冲突。
与 C++ 一样,问题不是我们没有解决方案,而是有太多解决方案。从根本上讲,
很难通过单一的机制来满足 C++ 社区的多样化需求,但是人们往往只看到问题的
一部分,就以为他们掌握了解决问题的终极方案 [Stroustrup 2019a]。
7.1 背景
C++ 从 C 语言中继承了各种基于错误返回码的机制,错误可以用特殊值、全局状
态、局部状态和回调等多种方式表达。例如:
double sqrt(double d);
int getchar();
char* malloc(int);
// 当 d 为负数时,设置 errno 为 33
// 遇到文件结尾返回 -1
// 如果分配出错,返回 0
C++ 的早期用户(1980 年代)发现这些技术令人困惑,也不足以解决所有问题。
返回 (值,错误码) 对变得流行,但这更增加了混乱和变化。例如:
Result r = make_window(arguments); // Result 是 (值,错误码) 对
if (r.error) {
// ... 错误处理 ...
}
Shape* p = r.value;
繁琐的重复错误检查使代码变得混乱。使用错误码时,很难将程序的主要逻辑与
错误处理区分开。程序的主线(业务逻辑)与大量奇怪和模糊的错误处理代码紧
密耦合在一起。对于那些错误处理本身就是主要的复杂逻辑而言,这种基于错误
返回码的处理方式可能会带来严重的问题。
使用包含 (值,错误码) 对的类会带来巨大的成本。除了检测错误码的成本外,许多
ABI(应用程序二进制接口)甚至不使用寄存器来传递小的结构体,所以 (值,错误
码) 对不仅传递了更多的信息(是通常数量的两倍),而且也使传递的性能有数量
级的降低。可悲的是,在许多 ABI 中,尤其那些针对嵌入式系统的 ABI(专为 C 代
码设计)
,这个问题直到今天(2020 年)依然存在。
115
7. 错误处理
此外,在出错的构造函数中没有真正好的方法来使用错误码来处理故障(构造函
数没有返回值),还有那些过去流行的具有复杂类层次结构的系统,子对象创建中
各种潜在错误也很难通过错误码的方式处理。
还有,对于所有传统的错误处理技术,最令人头疼的是人们会忘记检查错误。这
一直是错误的主要根源,并且在 2020 年的今天依旧如此。C++ 异常机制的主要目
标是使不完整或复杂的错误处理中的错误最小化。
C++ 异常是在 1988–89 年设计的,旨在解决当时普遍存在的复杂且容易出错的错
误处理技术。它们记录在 ARM(The Annotated C++ Reference Manual)[Ellis and
Stroustrup 1990] 中,并作为标准基础文档 [Stroustrup 1993] 的一部分被 ANSI
C++ 所采用。
与其他语言的异常设计相比,用于 C++ 的异常设计由于需要结合使用 C++ 代码和
其他语言(尤其是 C)的代码而变得复杂。考虑一个 C++ 函数 f() 调用一个 C 函
数 g(),该函数又调用一个 C++ 函数 h()。现在 h() 抛出异常由 f() 捕获。通常,
C++ 函数不知道被调用函数的实现语言。这样的场景使我们不能通过修改函数签
名以添加“异常传播参数”
,或隐式地向返回类型添加返回码的方法做错误处理。
与使用其他技术相比,异常与 RAII(§2.2)一起解决了许多棘手的错误处理问题
(例如,如何处理构造函数中的错误以及那些远离错误处理代码的错误),而且所
需的时间成本要小得多(与 1990 年代中期所用的技术相比通常不到 3%,甚至更
便宜)
。虽然异常从来没有引起争议,但我还是低估了它们引起争议的可能性。
7.2 现实中的问题
当然总有一些应用不适合使用异常,例如:
•
•
•
内存严重受限系统,异常处理所需的运行期支持内存会占用应用程序功能
所需要的内存。
工具链不能保证异常抛出后能够迅速做出响应的硬实时系统(例如
[Lockheed Martin Corporation 2005])
。
系统依赖于多台不可靠的计算机,因此立即崩溃并重新启动是对付那些无
法在本地处理的错误的合理(且几乎是必要的)方式。
因此,大多数 C++ 实现仍然保留了非异常机制的错误处理方式。另一方面,也存
在一些通过错误码无法提供良好解决方案的场景:
•
构造函数失败——由于构造函数没有返回值(不算被构造对象本身),单
纯依赖 RAII 的方式必须替换为通过对对象状态的显式检查来处理错误。
116
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
•
•
•
•
•
运算符——没有办法从 ++、*、-> 中返回错误码。你将不得不使用非本地
错误指示或使用繁琐的运算符名称,例如 multiply(add(a,b),c) 而不是
(a+b)*c。*
回调——使用回调的函数应该能够调用具有多种可能错误的函数(通常,
回调是 lambda 表达式(§4.3.1)
)
。
非 C++ 代码——我们无法通过那些没有专门做错误码处理的非 C++ 函数传
递错误。
调用链深处新的错误类型——必须准备调用链上的每个函数来处理或传播
一种新的错误(例如,程序中的网络错误,而它并不是专门为通过网络访
问数据而预先设计的)
。
忘记处理返回码——有一些精巧的方案来试图确保统一检查错误码,但是
它们要么不完整,要么依赖于在遗漏检查时使用异常或程序终止(例如
[Botet and Bastien 2018])
。
此外,还有一些与使用异常有关的现实问题:
•
•
•
•
•
有些人不愿意引入异常机制,是因为他们的代码由于无原则使用指针而形
成了一团乱麻。通常,这些人将他们的批评指向异常,而不是他们的陈旧
代码。
有些人(很多)根本不理解甚至不知道 RAII(§2.2),而只是把异常当作
返回错误码的一种替代机制来用。通常,把 try-catch 当作 if-then 的一种
形式来用的话,代码比正确使用错误码或 RAII 要更丑陋、更繁琐、更缓
慢。
异常的许多实现速度很慢,是因为实现者把 C++ 的异常与其他类型的异常
(例如微软的结构异常)统一处理,优先考虑调试(例如 GCC 在 throw 后
两次遍历堆栈来保存回溯),使用单一机制为各种语言服务(每一种都很
糟糕)
,或者只是没有在异常处理优化上花费很多开发精力。
这些年来,异常处理的性能相对较慢,是因为我们在优化非异常方面花费
了大量精力。我怀疑还有很大的优化机会。例如,Gor Nishanov 报告说,
通过一些与 Windows 和 Linux 上的协程实现相关的简单优化,使速度提高
了 1000 倍 [Nishanov 2019a]。不过,大幅改善空间占用可能会更难实
现。一些最近的实验看起来还比较不错 [Renwick et al. 2019]。
为了使异常被接受,我们不得不添加了异常规约 [Stroustrup 2007]。但异
常规约从来没有提供支持者们所声称的更好的可维护性,而确实提供了反
对者(包括我)所诟病的冗长和开销。一旦异常规约出现在语言中,许多
人就觉得使用它们是受到鼓励的,并将由此产生的问题归咎于异常机制本
译注:原文如此。实际上 Bjarne 的这个写法仍然是返回对象而不是错误码,因此仍需使用异
常。不用异常的写法还要啰嗦得多。
*
117
7. 错误处理
•
•
•
身。具有讽刺意味的是,那些坚定支持异常规约的人转而去帮助设计 Java
了。异常规约在 2010 年被宣布废弃,并最终在 2017 年被移除(§4.5.3)
。
作为部分替代方案,C++11 引入了 noexcept 作为一种更简单、更有效的
控制异常的机制(§4.5.3)
。
通过指定要捕获的异常类型来捕获异常往往使 throw 和 catch 的实现与运
行期类型识别(RTTI [Stroustrup 2007])纠缠在一起,这导致了效率低下
和复杂性。特别是,它会导致内存被消耗(被 RTTI 所需的数据消耗),即
使应用程序从不依赖 RTTI 来区分异常,对于简单的场景也很难做优化。
而且,依赖 RTTI 使得使用动态链接匹配很难优化。基本上,异常处理实
现是针对罕见的最复杂的情况进行优化的。当一个具有嵌套异常的类被添
加到标准库中,人们甚至被鼓励在最简单的情况下使用它时,情况就更糟
了。对于可以静态分析的类层次结构(在许多嵌入式系统中),以常量时
间进行快速类型匹配是可能的 [Gibbs and Stroustrup 2006]。由于异常是平
台 ABI 的一部分,这就使得要改变早期的过度设计非常之困难。
有人坚持只使用一种错误处理方法,并且通常得出这样的结论:由于异常
不适用于每种情况,因此该方法必须是错误码。那些由错误码所带来的问
题也就仅仅是“不方便而已”
。
一些人相信那些关于异常机制的基于最坏情况和/或不切实际的比较的低
效传闻,例如在添加异常后保留错误码处理方式,将不完整的错误处理与
基于异常的处理进行比较,或者使用异常来做简单的错误处理,而不是把
异常用于无法在本地处理的错误。很少有关于异常及其替代方案成本的认
真调查。我怀疑关于异常的虚假传说比任何事实都具有更大的影响力。
最终结果是 C++ 社区分裂为异常和非异常阵营。事实上,“不要异常”是一种方
言,而方言是标准要避免的事情之一(§3.1)。对于个人组织或社区而言,方言可
能有一些优势,但它使代码和技能的共享变得复杂,因此损害了整个 C++ 社区。
有人声称,异常机制的问题在于它违反了零开销原则(例如 [Sutter 2018b])。对
比通过终止应用来响应错误的处理方案,任何错误处理机制显然都是开销,也都
违反了零开销原则(除非考虑到处理终止的成本,例如在另一个处理器中)。在我
们设计异常时,我们考虑了这些,并认为开销是可接受的。理由是:异常情况很
少见;除非抛出异常,否则没有运行期开销;并且用于实现异常的表可以保存在
虚拟内存中 [Koenig and Stroustrup 1989]。在虚拟内存不可用或内存不足的情况
下,使用表来实现异常可能成为一个严重问题。我们当时设计异常时主要关注的
是,需要某种形式的错误传播和错误处理的系统。在这种情况下,零开销可以解
释为“异常与以在同样严格程度的错误处理下的错误码使用相比没有额外开销”。
如今,错误处理的混乱比以往任何时候都严重,处理错误的替代技术比以往任何
时候都多,从而造成很大的混乱和危害。假设有 N 种错误处理方式,又有人提出
118
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
了一个新的解决方案,只要旧的解决方案不被抛弃,现在我们就必须应对 N+1 种
方式(“N+1 问题”)
。如果一个组织有 M 个程序,使用了 N 个库,我们甚至可能
有 N*M 个需要处理的问题。异常的引入可以看作是将处理错误的常用方法从 7 种
增加到了 8 种。2015 年,Lawrence Crowl 撰写了一份问题分析报告 [Crowl2015a]
对这个问题进行了分析。
基础库的作者对多种错误处理方案的问题感受最为深刻。他们不知道他们的用户
喜欢什么,他们的用户可能有很多不同的偏好。C++17 文件系统库(§8.6)的作
者们选择了把接口重复一遍:对于每个操作,他们提供两个函数,一个在错误的
情况下抛出异常,另一个函数则通过设置标准库的 error_code 参数将错误码通过
参数传递出来:
bool create_directory(const filesystem::path& p); // 出现错误时抛异常
bool create_directory(const filesystem::path& p, error_code& ec) noexcept;
当然,这有点冗长,只会取悦那些仅喜欢异常或 error_code 的人。也要注意作者
提供了 bool 返回值,这样人们就不必一直使用 try 或直接测试 error_code 了。
事实上,文件系统(在我看来相当正确)使用异常来处理罕见的错误并不能让那
些认为异常有根本缺陷的人满意,特别是,它仍要求存在异常支持。
7.3 noexcept 规约
使用 noexcept(§4.5.3)
,人们可以抑制所有从函数抛出的异常,并允许调用者忽
略抛出异常的可能性。
使用 noexcept 可以使担心性能问题(或真或假)的人们放心。它也可以通过减少
控制路径的数量来改善优化效果,但前提是程序员不要通过测试返回码将这些路
径添加回去。许多低级函数,例如大多数 C 函数,都不存在异常。
使用 noexcept 可以简化错误处理(如果一个函数不抛出异常,我们就不需要捕获
任何异常)
,也可以使其复杂化(如果一个函数不能抛出异常,但又可能会失败,
我们必须使用其他错误处理机制)。特别是,在异常抛出与其处理程序之间的路径
上的 noexcept,会把一个异常变成程序终止运行。因此,对于一个处于维护期的
程序,在函数中使用使用 noexcept,可能会导致先前正确的程序失败。
请注意,异常被添加到 C++ 中的一个重要原因是为了支持那些在发生错误时也决
不可以无条件中止的应用。异常仅表示发生了故障,并且从 main() 到抛出点的路
径上的任何代码都可以对其进行处理。特别是,这样可以支持一个重要场景:在
终止之前进行一些本地清理(例如,刷新输出缓冲区,或向日志文件添加错误报
告)
。
119
7. 错误处理
7.4 类型系统的支持
解决 C++ 中的逻辑和性能问题的传统方法是将计算从运行期挪到编译期。显然,
将异常与静态类型系统集成的可能性在 1980 年代被认真考虑过,后来又反复被重
新考虑。如果异常是函数类型的一部分,那么程序就会有更好的类型检查,函数
就更能自我描述,异常处理也更容易优化。
不将异常作为类型系统的一部分的一个主要原因是,如果异常是函数类型的一部
分,那么对该函数可能抛出的异常集的更改将要求所有调用该函数的函数重新编
译。在一个大多数主要程序都由许多单独开发的库组成的世界里,这将导致灾难
性的脆弱,及无法管理的相互依赖 [Stroustrup 1994]。
函数指针方面也有相关的明显问题。在大多数主要的 C++ 程序中都有很多 C 风格
的代码,现在仍然如此。C 风格的泛型代码(例如,qsort 的比较函数参数)和回
调(例如,在 GUI 中)的主要参数化机制均会用到函数指针。
如果我需要一个指向函数的指针,并且异常是类型系统的一部分,那么,我要么
决定始终从所指向的函数中获取异常,要么不接受异常,要么以某种方式处理这
两种选择。除非将对类型查询的支持或基于异常的重载添加到语言中,否则都很
难两者兼顾。确定了要接受哪种类型函数指针参数后,我现在必须调整调用函数
中的错误检查方式以匹配所接受的函数指针类型。即使这些可以在 C++ 语言中处
理,也将影响与 C 的交互:这时如何将指向 C++ 函数的指针传递给 C?例如,如
何处理从 C 中回调依赖异常的 C++ 的函数?显然,C++ 函数中的异常不会消失,
因此我们将有四种选择:错误码、编译期检查的异常(例如 [Sutter 2018b])、当
前异常和 noexcept。只有当前的异常和非本地错误码不会影响类型系统或调用约
定(ABI 接口)
。幸运的是,很少有函数需要两个指针指向函数,否则我们将面临
选择 16 种方案的风险。因此,如果接受异常类型系统(就当前的异常而言),混
乱将是全方面的。
在现代 C++ 中,此类问题将以其他回调机制的不同形式继续存在,例如具有要被
调用的成员函数的对象、函数对象和 lambda 表达式。
我的结论(得到 WG21 的认可)过去和现在都是,在 C++ 的静态类型系统中添加
异常会导致系统脆弱、代码复杂性显著增加、严重的不兼容性以及与 C 代码交互
的问题。这一点在 1989 年就得到了重视。
7.5 回归基础
从根本上讲,我认为 C++ 需要两种错误处理机制:
•
异常——罕见的错误或直接调用者无法处理的错误。
120
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
•
错误码——错误码表示可以由直接调用者处理的错误(通常隐藏在易于使
用的检测操作中或作为 (值,错误码) 对从函数返回)
。
考虑代码:
void user()
{
vector<string> v{"hello!"};
for (string s; cin>>s; )
v.push_back(s);
auto ps = make_unique<Shape>(read_shape(cin));
Smiley_face face{Point{0,0},20};
// ...
}
这个例子人造的,但其编程风格并非不典型。我们可以从中看出,user() 函数里
有很多发生不太可能的错误的可能性:内存耗尽、读取错误、构造失败(例如,
在 Smile_face 的多层次结构中出现错误等)
。另外,使用 unique_ptr<Shape> 可
以防止内存泄漏。如果我们使用显式错误码而不是异常,那么这个函数中至少需
要进行五次错误检查,源代码数量将翻倍,并需要在各个构造函数中进行更多检
查。没有 RAII(及其与异常的集成)
,代码将进一步膨胀。一般来说,更多的代码
意味着更多的错误。当添加的代码使控制流程复杂时,尤其如此。这一点经常被
那些通过小例子论证的人所忽视。对于小例子来说,“就一项测试”关系不大,相
对也很难漏掉。
另一方面,有些错误是预料得到的,我们更愿意使用某种形式的错误码来对其进
行检查:
ifstream f {"Myfile"};
if (!f) {
// ... 处理错误 ...
}
// ... 使用 f ...
在这里,为方便起见,错误码隐藏在输入流的状态里。
因此,在理想情况下,应该只有两种错误处理的方法,但是我真的不知道如何达
到这样一种理想状态。仅仅 (值,错误码) 对就有十几种变体被广泛使用(例如
std::map::insert() ),并且还有一些新的变体也在 2011 年的 WG21 中被讨论
(如 [Botet and Bastien 2018; Sutter 2018b])。即使委员会能就其中一个方案达成
一致,也仍然会有至少十几个广泛使用的错误处理方案,每个方案都有一大群忠
实的追随者支持,许多方案都有数百万行难以更动的代码。
121
7. 错误处理
很少有关于异常的性能和 C++ 中返回码可靠性的认真研究([Renwick et al. 2019]
是一个例外)。但是,有许多不科学的小研究和许多大声表达的意见——常常声称
异常天生就比各种形式的错误码检查慢。这与我的经验不符。就我所知,还没有
任何严谨的研究发现在现实的例子中错误码能胜出“很多”,或者异常能胜出“很
多”
。在这一讨论场景下,
“很多”表示整数倍的差异,而不是几个百分点。
运行一个简单的性能测试:进行一个 N 层深度的调用序列,然后报告错误。如果
错误很少见,例如 1:1000 或 1:10000 的错误率,并且调用嵌套很深,例如 100 或
1000,则异常处理要比明确的错误码判断方式快得多。如果调用深度为 1,并且
错误发生的概率为 50%,则显式判断错误码测试将大获全胜。调用深度和错误概
率决定了这些测试之间的差异。我要问一个简单而潜在有用的问题:“一个错误要
多罕见才被看作是异常情况”?不幸的是,答案是“这要看情况”。这取决于代
码、硬件、优化器、异常处理的实现,等等等等。C++ 异常的设计假设答案至少
在 1:100 的范围。换句话说,错误指示的传播要远比显式的处理更为常见。
空间占用问题可能比运行期问题更难解决。对于那些遇到不能在本地处理的错误
就可以立即终止的系统,我可以想象这样一个实现,在遇到 throw 时立即终止程
序。但是如果要传播和处理错误,那么就不可避免,需要面对选择各种困难的折
中。
对 于 错 误 处 理 这 团 乱 码 , 任 何 解 决 方 案 都 很 可 能 遇 到 N+1 问 题 (§4.2.5)
[Stroustrup 2018a]。
奇怪的是,当初 C++ 引入异常时,人们担心的问题之一就是异常不够通用。许多
人认为恢复(resumption)语义必不可少 [Stroustrup 1993]。当时我的猜测是,
允许恢复将使异常处理的速度至少再降低两倍。
122
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
8. C++17:大海迷航
在经过 C++14 这个小版本标准之后,C++17 [Smith 2017] 原本被看作是一个大版
本。C++17 有很多新的特性,但没有一个我认为称得上重大。尽管我们已经有给
C++11 和 C++14 带来成功的工作流程,标准社区也更丰富、更强大、更热情,但
对于 C++17 的关键问题是:为什么所有的辛劳却没有带来更显著的改进?
C++17 有大约 21 个新的语言特性(取决于你的计数方式)
,包括:
•
•
•
•
•
•
•
•
•
•
•
•
构造函数模板参数推导——简化对象定义(§8.1)
推导指引——解决构造函数模板参数推导歧义的显式标注(§8.1)
结构化绑定——简化标注,并消除一种未初始化变量的来源(§8.2)
inline 变量——简化了那些仅有头文件的库实现中的静态分配变量的使
用 [Finkel and Smith 2016]
折叠表达式——简化变参模板的一些用法 [Sutton and Smith 2014]
条件中的显式测试——有点像 for 语句中的条件(§8.7)
保证的复制消除——去除了很多不必要的拷贝操作 [Smith 2015]
更严格的表达式求值顺序——防止了一些细微的求值顺序错误 [Dos Reis
et al. 2016b]
auto 当作模板参数类型——值模板参数的类型推导 [Touton and Spertus
2016]
捕捉常见错误的标准属性——[[maybe_unused]]、[[nodiscard]] 和
[[fallthrough]] [Tomazos 2015]
十六进制浮点字面量 [Koppe 2016a]
常量表达式 if——简化编译期求值的代码 [Voutilainen and Vandevoorde
2016]
不幸的是,这并不是完整的功能扩展列表。相当一部分是如此之小,我们很难简
单地描述它们。
C++17 标准库中增加了大约 13 个新特性,并加上了许多小的修改:
•
•
•
•
•
•
optional、any 和 variant——用于表达“可选”的标准库类型(§8.3)
shared_mutex 和 shared_lock(读写锁)和 scoped_lock(§8.4)
并行 STL——标准库算法的多线程及矢量化版本(§8.5)
文件系统——可移植地操作文件系统路径和目录的能力(§8.6)
string_view——对不可变字符序列的非所有权引用 [Yasskin 2014]
数学特殊函数——包括拉盖尔和勒让德多项式、贝塔函数、黎曼泽塔函数
[Reverdy 2012]
123
8. C++17:大海迷航
尽管我也喜欢 C++17 中的某些功能,但令人困扰的是这些功能没有统一的主题,
没有整体的规划,似乎只是由于可以达到投票多数而被扔进语言和标准库中的一
组“聪明的想法”。这种状况可能给未来语言的发展带来更大的弊端,因此必须采
取一些措施做出改变 [Stroustrup 2018d]。方向小组的成立是 WG21 针对这个问题
的回应(§3.2)
(§9.1)的一部分。
不可否认,C++17 提供了一些可以在小方面帮助大多数程序员的东西,但没有什
么可以让我认为是重大的。在这里,我将“重大”定义为“对我们思考编程和组
织代码的方式产生影响”
。在此,我描述了我猜想会产生最大积极影响的功能。
我也检查了一些尽管经过严肃考虑、仍没有进入 C++17 标准的例子:
•
•
•
•
•
•
§6.3.8:概念(C++20)
§8.8.1:网络库
§8.8.2:点运算符(operator.())
§8.8.3:统一函数调用
§8.8.4:简单类型的默认比较运算符 ==、!=、<、<=、> 和 >=
§9.3.2:协程(C++20)
我怀疑如果它们被采纳的话,其中的任何一项都会成为 C++17 最重要的特性之
一。它们符合 C++ 应该成为什么的一致观点(§9.2);即使只有少数几项,也会极
大地改变 C++17 的使用方式。
在 C++11 中我看到了相互支持的特性网,它们带来了更好的代码编写方式。对于
C++17,我没有看到。但是,C++20 完善了这样一张网,使 C++ 又向前迈进了一
大步(§9)。可以说 C++17 只是通向 C++20 路上的垫脚石,但是委员会的讨论对
此毫无暗示,重点始终放在单独的特性上。我甚至听到有人说“列车模型”
(§3.2)不适合长期规划;事实并非如此。
8.1 构造函数模板参数推导
几十年来,人们好奇为什么模板参数可以从其他函数参数中推导出来,却不能从
构造函数参数中推导。例如,在 C++98、C++11 和 C++14 中:
pair<string,int> p0 (string("Hi!"),129); // 不需要推导
auto p1 = make_pair("Hi!"s,129);
// p1 是 pair<string,int>
pair p2 ("Hi!"s,129);
// 错误:pair 缺少模板参数
很自然,在我第一次设计模板的时候,我也考虑过从构造函数参数中推导出模板
参数的可能性,但因为担心出现歧义而止步。解决方案也有技术障碍,但 Michael
Spertus 和 Richard Smith 克服了这些障碍。所以在 C++17 中,我们可以写上面最
124
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
后一个例子中那样的代码( p2 )而不会报错,这样一来就不需要 make_pair()
了。
这简化了类型的使用,例如 pair 和 tuple,还有当编写并行的代码时用到的锁和
互斥锁(§8.4)
。
shared_lock lck {m};
// 不需要显式写出锁类型
这是一个在 C++17 中少见的例子,相互支持的特性促成了明显的代码简化。不幸
的是,这些简化被接受与否都是个案,而非总体的简化努力的结果。所以,在类
型推导规则中“填坑”的努力仍在继续 [Spertus et al. 2018]。
除了这里的描述之外,这套机制提供了解决歧义的一种写法(§8.3)
。
8.2 结构化绑定
结构化绑定始于 Herb Sutter、Bjarne Stroustrup 和 Gabriel Dos Reis 的一个简单的
提案 [Sutter et al. 2015],旨在简化写法和消除剩余的几个变量未初始化的来源。
例如:
template<typename T, typename U>
void print(vector<pair<T,U>>& v)
{
for (auto [x,y] : v)
cout << '{' << x << ' ' << y << "}\n";
}
名称 x 和 y 被分别绑定于 pair 的第一个和第二个元素。这可算作是写法上的重大
便利。
C++14 给我们提供了返回多个值的方便方式。例如:
tuple<T1,T2,T3> f(/*...*/) // 优美的声明语法
{
// ...
return {a,b,c}; // 优美的返回语法
}
我认为在当前的 C++ 中,tuple 有点被过度使用了,当多个值并不互相独立的时
候,我倾向于使用明确定义的类型,但从写法上讲,这没有什么区别。然而,
C++14 并没有提供像创建多返回值那样方便的方式去解包它们。这导致了繁琐的
变通解决方案、变量未初始化或运行期开销。例如:
tuple<T1,T2,T3> res = f();
T1& alpha = get<0>(res);
// 通过 alpha 来间接访问
125
8. C++17:大海迷航
T2& val = get<1>(res);
T3 err_code = get<2>(res);
// 拷贝
很多专家更喜欢用标准库函数 tie() 去解包 tuple:
T1 x;
T2 y;
T3 z;
// ...
tie(x,y,z) = f(); // 使用现有变量的优美调用方式
向 tie() 函数赋值的时候,会向 tie() 函数的参数赋值。然而,使用 tie,你必
须分别定义变量,并且写出它们的类型以匹配 f() 返回的对象的成员(在这个例
子中就是 T1 、 T2 、和 T3 )。不幸的是,这会导致局部变量“设置前使用”的错
误,及“初始化后赋值”的开销。并且,大多数程序员并不知道 tie() 的存在,
或者认为在真实代码中使用它太奇怪了。
Herb Sutter 建议了一种跟正常返回语法类似的方案:
auto {x,y,z} = f(); // 优美的调用语法,会引入别名
这对任何有三个成员的 struct 都有效,而不仅仅只对 tuple 。消除核心指南
(§10.6)中未初始化变量的倒数第二个来源是我的主要动机。是的,我喜欢这种
写法,更重要的是它使 C++ 更接近自己的理想表达。
不是每个人都喜欢这个想法,而且我们几乎没能在 C++17 中及时讨论它。提出结
构化绑定的论文 [Sutter et al. 2015] 比较晚,而正当 2015 年 11 月底在科纳 Ville
Voutilainen 刚要结束 EWG 会议时,我注意到我们离午饭还有 45 分钟,我觉得小
组应该会想要看到这个提案。2015 年科纳的会议是我们冻结 C++17 的功能集的时
间点,所以这 45 分钟很关键。我们甚至没时间去另一个小组找到 Herb,我就直
接讲了这个提案。EWG 喜欢这个提案,会议纪要说鼓掌以资鼓励;EWG 想要这样
的东西。
现在,真正的工作开始了。
在这个及以后的会议中,几个人——尤其是 Chandler Carruth——指出要达到 C++
的理想,我们需要扩展将一个对象分解为多个值的能力,以应对不是 tuple 或普
通 struct 的类型。例如:
complex<double> z = 2+3i;
auto {re,im} = sqrt(z);
// sqrt() 返回复数值
标准库类型 complex 并没有暴露其内部表示。
126
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
在 C++17 中我们通过允许用户定义一系列 get 函数解决了这个问题,例如,
get<0> 和 get<1> 实际上是假装将结果看作是 tuple。这能工作,但需要用户提供
一些不优雅的重复样板式代码。关于潜在改进的讨论仍在继续,但没有明显的简
化被纳入 C++20。
有人要求让这种方式也能适用于返回数组的函数和返回带位域的 struct 的函数。
我们加入了对那些情况的支持,所以最终设计至少比原始提案复杂了一倍。
有一个冗长的争论(跨多次会议),是关于是否可能(或必须)显式地指定被引入
的局部变量类型。例如:
auto {int x, const double* y, string& z} = f();
// 非 C++
关于这种做法的理由——其中最雄辩的当属 Ville Voutilainen——如果没有显式类
型,写法的可读性将会降低,从而损害可维护性,还可能导致错误。这跟常见的
反对 auto 的理由很相似,而显式类型也会有它们自己的问题。如果类型跟返回值
不匹配怎么办?有人说这应该属于错误。有些人说,转换到指定的类型将是非常
有用的(例如,char[20] 返回到 string 中)。我指出结构化绑定应该引入零开销
别名,而任何意味着表示变化的类型转换将导致显著的开销。并且,结构化绑定
的一个目的是优化写法,而要求显式类型会导致代码比现有的方式更加冗长。
最初的提案使用花括号({})来聚合引入的名字:
auto {x,y,z} = f(); // 优美的调用语法,引入别名
然而一些成员,如 Chandler Carruth 和 David Vandevoorde,怕语法上会有歧义,
而坚持认为这样会令人困惑,
“因为 {} 代表作用域”
。所以我们有了 [] 语法:
auto [x,y,z] = f(); // 调用语法,引入别名
这是个小改动,但我认为是个错误。这个最后一刻的改动,导致了属性表达语法
的小小复杂化(比如 [[fallthrough]])(§4.2.10)。我对关于美学或作用域的论
据并不买账,并且在 2014 年我就展示了关于为 C++ 添加函数式编程风格的模式匹
配的想法,以 { … } 表示用模式将值分解出来(§8.3)。结构化绑定的设计就是为
了适应这一总体方案。
这些并不是唯一的后期修改提案。每个提案都增加了或将增加复杂性。
对语言每次升级仅孤立地增加一项功能是危险的。除非符合更大的规划,最后一
刻的改变也是危险的,容易导致在要求“完整性”的过程中“膨胀”。在这个结构
化绑定的例子中,我不相信允许结构化绑定指定位域能提供充分的效用,值得为
之提高复杂性。
127
8. C++17:大海迷航
8.3 variant、optional 和 any
可选类型可以用 union 表示,没有运行期开销。例如:
union U {
int i;
char* p;
};
U u;
// ...
int x = u.i;
char* p = u.p;
// 正确:当且仅当 u 持有整数
// 正确:当且仅当 u 持有指针
从 C 语言最早期开始,这就被当作一个不同的类型之间“分时共享”内存的基本
方法来使用和误用。没有编译期和运行期的检查来确保这个地址仅被用作其真实
指代的类型。确保 union 成员在使用上一致,是程序员的职责,然而令人头痛的
是程序员常在这个地方出错。
有经验的程序员通过将联合体封装在类中去避免问题,用类来确保正确使用。
Boost 特别提供了三种这样的类型:
•
•
•
optional<T>——持有 T 或什么都不持有
variant<T,U>——持有 T 或 U
any——持有任意类型
这些类型的巨大效用已经在 C++ 和许多其他语言中得到了证明。
委员会决定对这三种类型进行标准化。不幸的是,这三种类型的设计被分开讨
论,好像它们的使用情况毫不相干一样。相对于标准库而言,直接语言支持的可
能性似乎从未被认真考虑。结果是三种标准库类型(就像它们的 Boost 祖先一
样)彼此之间有很大的不同。因此,尽管这些类型的效用毋庸置疑,但它们是委
员会设计的一个典型案例。试考虑:
optional<int> var1 = 7;
variant<int,string> var2 = 7;
any var3 = 7;
auto x1 = *var1 ;
auto x2 = get<int>(var2);
auto x3 = any_cast<int>(var3);
// 对 optional 解引用
// 像访问 tuple 一样访问 variant
// 转换 any
128
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
为了提取存储的值,需要使用三种不兼容的写法之一。这对程序员来讲是一种负
担。没错,有经验的程序员会习惯的,但这种非要人们去习惯的不规则性本就不
该存在。
为了简化 variant 的使用,有一种访问者机制。首先我们需要一个辅助模板去定
义一个重载集合:
// 简单访问的样板:
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
模板 overloaded 真应该是标准的。只有那些熟悉变参模板(§4.3.2)和模板参数
推导(§8.1)的人才会觉得它比较简单。不过,有了 overloaded,我就能根据变
体的类型来构造出分支:
using var_t = std::variant<int, long, double, std::string>; // variant 类型
// 简单访问的样板:
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
void use()
{
std::vector<var_t> vec = {10, 20L, 30.40, "hello"};
for (auto& var : vec) {
std::visit (overloaded {
[](auto arg) { cout << arg << '\n '; },
// 处理整数类型
[](double arg) { cout << " double : " << arg << '\n '; },
[](const std::string& arg) { cout << "\"" << arg << "\"\n"; },
}, var);
}
}
毋庸置疑,variant 和它的伙伴们解决了一个重要问题,但其方式并不优雅。或
许将来的工作能减轻接口不一致上的困惑,从而让人能专注于真正需要区分的地
方。同时,应该让更多的 C++ 同仁去使用这些新的类型,从而消除 union 经年累
月带来的老问题。
我认为这三种可辨识 union 的变体只是权宜之计。要解决 union 的问题,函数式
编程风格的模式匹配更优雅、通用,潜在也更为高效。在 2014 年 11 月在伊利诺
伊大学厄巴纳——香槟分校举行的会议上,我发表了关于模式匹配相关设计问题
的演讲 [Solodkyy et al. 2014],部分内容基于我同得州农工大学的 Yuriy Solodkyy
和 Gabriel Dos Reis 合作的研究 [Solodkyy et al. 2013]。我们有一个库的实现,它的
性能和函数式编程语言相若,尽管没有和编译器进行集成。这个库不仅能应付包
129
8. C++17:大海迷航
含替换类型的封闭集合(代数类型),也能应付开放集合(类层次结构)。我们的
目的是消除对访问者模式的使用 [Gamma et al. 1994]。然而,我们没有一种能让人
普遍接受的语法。我的演讲目的是提高人们的兴趣,并设定长期的目标。人们对
此很感兴趣。在 C++17 完成后,工作已经开始 [Murzin et al. 2019, 2020]。或许模
式匹配能加入到 C++23 中(§11.5)
。
8.4 并发
在 C++17 中,以下类型的加入极大地简化了锁的使用:
•
•
scoped_lock——获取任意数量的锁,而不会造成死锁
shared_mutex 和 shared_lock——实现读写锁
例如,我们能获取多个锁,而不用担心会产生死锁:
void f()
{
scoped_lock lck {mutex1, mutex2, mutex3}; // 获得所有三把锁
// ... 操作共享数据 ...
} // 隐式地释放所有锁
C++11 和 C++14 没能带给我们读写锁。这显然是个严重的疏忽,原因是各种提议
的压力,以及处理提议所需的时间。C++17 通过加入 shared_mutex 解决了这一问
题:
shared_mutex mx;
// 一个可以被共享的锁
void reader()
{
shared_lock lck {mx}; // 跟其他 reader 共享访问
// ... 读 ...
}
void writer()
{
unique_lock lck {mx}; // writer 需要独占访问
// ... 写 ...
}
多个读线程可以“共享”该锁(即同时进入临界区)
,而写线程则需要独占访问。
我认为这些例子很好体现了“简单的事情简单做”的哲学。有时,我同很多 C++
程序员一样在想,
“是什么让他们花了这么长时间?”
请注意使用从构造函数参数推导出来的模板参数是如何简化表达写法的(§8.1)
。
130
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
8.5 并行 STL
从长远来看,并行算法的使用将是非常重要的,因为从用户角度看,没有什么比
只说“请执行这个算法”更简单的了。从实现者的角度来看,算法中有一套特定
接口而没有对算法的串行约束将是一个机会。C++17 只迈出了一小步,但这远比
没有开始好得多,因为它指明了方向。不出意外,委员会中有一些反对的声音,
大多数来自于希望为专家级用户提供复杂接口的人。有些人对这样简单的一个方
案是否可行表示严重怀疑,并主张推迟这一方案。
基本的想法是,为每个标准库算法提供一个额外参数,允许用户请求向量化和/或
多线程。例如:
sort(par_unseq, begin(v), end(v));
// 考虑并行和向量化
但这还只适用于 STL 算法,所以重要的 find_any 和 find_all 算法被忽略了。将
来我们会看到专门为并行使用而设计的算法。这正在 C++20 中变为现实。
另一个弱点是,仍然没有取消一个线程的标准方法。例如,在搜索中找到一个对
象后,一个线程不能停止其他正在并行执行的搜索。这是 POSIX 干预的结果,它
反对所有形式的取消操作(§4.1.2)
。C++ 20 提供了协作式取消(§9.4)
。
C++17 的并行算法也支持向量化。这很重要,因为对 SIMD 的优化支持是硬件在单
线程性能方面仍然(2017 年后)有巨大增长的少数领域之一。
在 C++20 中,我们(总算)能用范围库(§6.3)来避免显式使用容器的元素序
列,只要这么写:
sort(v);
不幸的是,并行版本的范围在 C++20 中没有及时完成,因此我们只能等到 C++23
才能这么写:
sort(par_unseq, v);
// 使用并行和向量化来对 v 进行排序
不想等 23 的话,我们可以自己实现适配器:
template<typename T>
concept execution_policy = std::is_execution_policy<T>::value;
void sort(execution_policy auto&& ex, std::random_access_range auto& r)
{
sort(ex, begin(r), end(r)); // 使用执行策略 ex 来排序
}
131
8. C++17:大海迷航
毕竟标准库是可扩展的。
8.6 文件系统
2002 年,Beman Dawes 编写了 Boost 文件系统库,成为最受欢迎的 Boost 库之一
[Boost 1998–2020]。2014 年,Boost 文件系统库 [Dawes 2002–2014](经修改
后)被加入了 TS [Dawes 2014, 2015],又经过进一步修改被加入了 C++17 标准。
跟文件名和文件系统打交道是很棘手的,因为它涉及到并行、多种自然语言和操
作系统间的差异。最终能通过标准方式操作目录(文件夹)是件好事(正如 Boost
从 15 年前开始做的那样)
。提供的关键类型是 path,对字符集和文件系统的不同
写法进行了抽象。例如:
void do_something(const string& name)
{
path p {name}; // name 可能是俄语或阿拉伯语
// name 可能使用 Windows 或 Linux 文件写法
try {
if (exists(p)) {
if (is_regular_file(p))
cout << p << " regular file, size: " << file_size(p) << '\n';
else if (is_directory(p)) {
cout << p << " directory, containing:\n";
for (auto& x : directory_iterator(p))
cout << "
" << x.path() << '\n';
}
else
cout << p << " exists\n";
}
else
cout << p << " does not exist\n";
}
catch (const filesystem_error& ex) {
cerr << ex.what() << '\n';
throw;
}
// ... 使用 p ...
}
捕捉异常可以防止罕见的错误,比如有人在 exists(p) 检查后、执行详细检索前
删除了文件。文件系统接口同时为罕见(异常)和常见(预期)错误提供了支持
(§7.2)
。
132
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
8.7 条件的显式测试
我认为“很多小的提案”是危险的,即使每个都能帮助一些人。考虑为条件增加
显式测试的能力 [Koppe 2016b]:
if (auto p = f(y); p->m>0) {
// ...
}
p->m>0 是一个显式测试,它的意思是:
{
auto p = f(y);
if (p->m>0) {
// ...
}
}
这是对 C++98 里在条件里同时声明和测试的推广(§2.2.1)
:
if (auto pd = dynamic_cast<Derived*>(pb)) { // 如果 pd 指向 Derived 类型则为真
// ...
}
问题是这种推广是否足够明显和有用,值得作为提案引入。我的回答是否定的。
然而,这是我被否决的一个例子(不是很罕见)。
我的观点是,显式测试最好体现在 if 语句中。那里更不容易被忽视,而且遵循常
规有其好处,特别是对那些不仅仅使用 C++ 语言编程的人。另一方面,显式测试
似乎在有的人那里很受欢迎,他们的代码设计成需要对每个函数的结果都做错误
检查。我个人非常反感那种设计风格(§7.5)。
有人为了用上新特性而积极地重写代码。我听说过好几个例子,有人看到下面这
样的代码:
if (auto p = f(y)) {
if (p->m>2) {
// ...
}
// ...
}
就立即重写为这样:
if (auto p = f(y); p->m>2) {
// ...
}
133
8. C++17:大海迷航
并声称这样更优雅和简洁。自然,当 p==nullptr 时它会崩溃,而最初的代码不
会。无论我们从中能得到什么好处,这样的重写可能带来新的错误和混乱。
为了通用,显式测试也可以用在 switch 和 while 条件中。在 C++20 中,这一机制
被进一步扩展到可以在范围 for 语句中包含初始化 [Koppe 2017c]。
8.8 C++17 中未包含的提议
除了概念(§6.3.8)以外,一些我认为很重要的提案没有加入 C++17。如果不提及
它们,C++ 的历史就不完整:
•
•
•
•
•
•
§6.3.8:概念(C++20)
§8.8.1:网络
§8.8.2:点运算符
§8.8.3:统一调用语法
§8.8.4:默认比较
§9.3.2:协程(C++20)
静态反射是在一个研究小组(§3)中处理的,并不在 C++17 的既定规划之中。但
作为一项重要工作,它是在这一时期启动的。
8.8.1 网络库
在 2003 年,Christopher M. Kohlhoff 开始开发一个名叫 asio 的库,以提供网络支
持 [Kohlhoff 2018]:
“Asio 是用于网络和底层 I/O 编程的一个跨平台 C++ 库,它采用现代化
C++ 的方式,为开发者提供了一致的异步模型”
在 2005 年,它成为了 Boost [Kohlhoff 2005] 的一部分,并在 2006 年被提案进入
标准 [Kohlhoff 2006]。在 2018 年,它成为了 TS [Wakely 2018]。尽管经过了 13 年
的重度生产环境使用,它还是未能进入 C++17 标准。更糟糕的是,让网络库进入
C++20 标准的工作也停滞不前。这意味着,在 asio 得以在生产环境中使用 15 年之
后,我们还是不得不至少等到 2023 年,才能看到它成为标准的一部分。延误原因
在于,我们仍在进行严肃的讨论,如何最好地将 asio 中和其他场合中处理并发的
方式一般化。为此提出的“执行器(executors)”提案得到了广泛的支持,并且有
人还期望它能成功进入 C++20 [Hoberock et al. 2019, 2018]。我认为 C++20 中执行
器和网络库的缺失,正是“最好是好的敌人”的一个例子。
134
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
8.8.2 点运算符
在标准化进程启动之初,首个对 C++ 扩展的提案,就是由 Jim Adcock 在 1990 年
提出的允许重载点(.)运算符的提案 [Adcock 1990]。从 1984 年开始,我们就可
以重载箭头运算符( -> ),并且该机制被重度使用,以实现“智能指针”(比如
shared_ptr)
。人们当时希望(并且现在仍然希望)能重载点运算符以实现智能
引 用 ( 代 理 )。 基 本 上 , 人 们 想 要 有 一 种 方 式 , 使 得 x.f() 意 味 着
x.operator.().f() ,从而 operator.() 可以控制对成员的访问。然而,关于该
议题的讨论总是陷入僵局,因为大家对于重载版的点运算符是否应该应用到其隐
式 使 用 上 无 法 达 成 一 致 。 举 个 例 子 : ++x 对 于 用 户 定 义 类 型 , 被 解 释 为
x.operator++()。现在,如果用户定义类型定义了 operator.(),++x 是否应该
表 示 x.operator.().operator++() ?Andrew Koenig 和 Bjarne Stroustrup 在
1991 年 [Koenig and Stroustrup 1991a] 尝试过解决这个问题,但被最初的提案者
Jim Adcock 所强烈反对。Gary Powell、Doug Gregor 和 Jaakko Jarvi 在 2004 年再度
进行了尝试,试图提案到 C++0x [Powell et al. 2004],但在委员会那里又一次陷入
僵局。最后,在 2014 年,Bjarne Stroustrup 和 Gabriel Dos Reis 又进行了一次尝
试,试图提案到 C++17,我认为该提案 [Stroustrup and Dos Reis 2014] 是更为全面
的,也是更为合理的。举例如下:
template<class X>
class Ref { // 智能引用(带有所有权)
public:
explicit Ref(int a) : p{new X{a}} {}
X& operator.() { /* 这里可以有代码 */ return *p; }
~Ref() { delete p; }
void rebind(X* pp) { delete p; p=pp; }
// ...
private:
X* p;
};
Ref<X> x {99};
x.f();
x = X{9};
x.rebind(new X{77});
// 意思是 (x.operator.()).f() 即 (*x.p).f()
// 意思是 x.operator.() = X{9} 即 (*x.p)=X{9}
// 意思是 x 持有并拥有那个新的 X
其基本想法是,在“句柄”(这里是 Ref 类)中定义的运算(比如构造、析构、
operator.() 和 rebind())会作用于句柄之上,而没有在“句柄”中定义的运算
则作用于该句柄所对应的 “值”
,也就是 operator.() 的结果之上。
在付出很多努力之后 [Stroustrup and Dos Reis 2016],这个提案也失败了。2014
年的这份提案失败的原因颇为有趣。当然,设计中还存在一些常见的措辞问题和
模糊的“阴暗角落”,但我认为,这份提案本来是可以获得成功的,如果不是因为
135
8. C++17:大海迷航
委 员会对 智能 引用的 想法 太过激 动以 至于逐 渐偏 离了目 标, 再加上 Mathias
Gaunard 和 Dietmar Kuhl [Gaunard and Kuhl 2015] 以及 Hubert Tong 和 Faisal Vali
[Tong and Vali 2016] 也分别提交了替代方案的话。这两份提案中,前者需要所有
试图定义 operator.() 的使用者去重度使用模板元编程,而后者基本上是面向对
象的,引入了一种新的继承形态和隐式转换。
operator.() 的动作应该取决于将被访问的成员呢?还是说 operator.() 应该是
个一元运算符,仅仅依赖于它应用的对象呢(就像 operator->() 一样)?前者是
Gaunard 和 Kuhl 的提案的核心。Bjarne Stroustrup 和 Gabriel Dos Reis 也考虑过让
operator.() 成为二元运算符,但结论是这种方案过于复杂,而且在这件事上跟
箭头运算符(->)保持匹配是重要的。
最后,虽然初始的提案并没有被真正拒绝(它被 EWG 所批准,但从未进入全体委
员会投票的阶段),但由于缺乏新的输入从而无法在相互竞争的提案中间赢得共
识,进一步的进展也就停滞不前了。另外,最初的提议者(Bjarne Stroustrup 和
Gabriel Dos Reis)也被更为重要的提案以及他们的“日常工作”分散了精力,比
如概念(§6)和模块(§9.3.1)。我认为点运算符的历程是一个典型案例,体现了
委员会成员对于 C++ 是什么和它应该发展成什么样(§9.1)缺乏共同的看法。三
十年的时间,六个提案,很多次的讨论,大量的设计和实现工作,最终我们却一
无所获。
8.8.3 统一调用语法
对概念的首次讨论是在 2003 年,在这个过程中提及了函数调用需要一个统一的语
法 [Stroustrup and Dos Reis 2003b]。也就是说,理想情况下 x.f(y) 和 f(x,y) 应
该含义相同。重点是,当编写泛型库时,你必须决定调用参数做运算时是采用面
向对象的写法还是函数式的写法(x.f(y) 或 f(x,y))。而作为用户,你不得不适
应库的设计者所做出的选择。不同的库和不同的组织会有不同的选择。对于运算
符,如 + 和 *,统一的重载决策是一直以来的规则;也就是说,一个使用(比如
x+y)既会找到成员函数,也会找到独立函数。在标准库中,我们使用泛滥成灾的
成对的函数来应对这种困境(例如,让 begin(x) 和 x.begin() 都能使用)
。
我应该在 1985 年左右,在委员会纠结于细节和潜在问题之前,就把这个问题解决
掉。但我当时没能把运算符的情形推广。
在 2014 年,Herb Sutter 和我各自提案了 “统一函数调用语法 ” [Stroustrup
2014a; Sutter 2014]。当然,这两份提案并不兼容,但我们立刻解决了兼容问题,
并将它们合并成了一份联合提案 [Stroustrup and Sutter 2015]。
136
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
Herb 的部分动力来自于希望在 IDE 里面支持自动完成,并且倾向于“面向对象”
的写法(例如 x.f(y)),而我则主要出于泛型编程的考虑,并且倾向于传统的数
学式写法(例如 f(x,y))
。
一如既往地,第一个严重的反对意见是兼容性问题;也就是,我们可能会破坏现
有的代码。最初的提案确实可能会破坏一些代码,因为它倾向于更好的匹配或使
得调用变得含糊,而我们的辩论主张是它是值得的,并且往往是有益的。但我们
在这场辩论中失败了,之后我们重新准备了一份修改过的版本,其工作方式基于
一个原则, x.f(y) 会首先查找 x 的类,仅当无法找到 f 成员函数时,才考虑
f(x,y)。类似的,f(x,y) 只会在没有相应的独立函数的情况下才会查找 x 对应的
类。这个方案并不会让 f(x,y) 和 x.f(y) 完全等价,但显然它不会破坏现有代
码。
这看起来很有希望,但却遭到了一片愤怒的嚎叫:它将意味着稳定接口的终结!
这个观点主要由来自谷歌的人提出,他们认为依赖于重载决策的接口无法再保持
稳定了,因为添加一个函数就有可能改变现有代码的含义。这当然是真的。考
虑:
void print(int);
void print(double);
print('a');
// 打印 'a' 的整数值
void print(char); // 添加一个 print () 以改变重载集合
print('a');
// 打印字符 'a'
我对于这个观点的回应就是,几乎任何程序都可被相当多的各种新增声明改变其
含义。而且,重载的一个常见用法,就是通过添加函数,来提供语义上更佳的方
案(往往是为了修复缺陷)
。我们总是强烈建议,不要在程序的半途添加会导致重
载集合的调用语义发生变化的重载(比如上例中的 print(char))。换句话说,这
个“稳定”的定义是不切实际的。我(和其他人)指出,这个问题对于类成员也
早就存在了。反方的基本回应是说,类成员的集合是封闭的,所以这个问题在类
成员上是可控的。我观察到,通过使用命名空间,和某个类相关的独立函数集合
几乎可以像成员一样来识别 [Stroustrup 2015b]。
在这个时候,大量的争议和混乱爆发了,新的提案也开始出现,并和正处于讨论
中的提案竞争。英国的代表建议采用 C# 风格的拓展方法 [Coe and Orr 2015],而
其他一些人,尤其是 John Spicer 坚持认为,如果我们需要一种统一的函数调用写
法,那它应该是一种全新的写法,以和现有的两种相区分。我还是不能看出添加
137
8. C++17:大海迷航
第 三 种 标 记 ( 例 如 所 建 议 的 .f(x,y) ) 能 统 一 什 么 。 这 只 会 变 成 N+1 问 题
(§4.2.5)的又一个案例。
在提案被否决后,我被要求在有了模块后(§9.3.1)重新审视该问题。到那时,对
独立函数名字的查找范围就可以被限定在它第一个参数的类所在的模块。这可能
可以使统一函数调用的提案起死回生,但我仍然无法看出这可以怎样解决(在我
看来过于夸大的)关于接口稳定性的顾虑。
又一次的,对 C++ 的角色和未来缺乏共同的看法阻碍了事情的进展(§9.1)
。
回头看,我认为面向对象的写法(如 x.f(y))压根就不该被引入。传统的数学式
写法 f(x,y) 就足够了。而且作为一个附带的好处,数学式写法可以很自然的给我
们 带 来 多 方 法 (multi-methods), 从 而 将 我 们 从 访 问 者 模 式 这 个 变 通 方 案
[Solodkyy et al. 2012] 中拯救出来。
8.8.4 缺省比较
和 C 一样,C++ 并没有给数据结构提供缺省的比较。比如:
struct S {
char a;
int b;
};
S s1 = {'a',1};
S s2 = {'a',1};
void text ()
{
S s3 = s1 ;
// 可以,初始化
s2 = s1 ;
// 可以,赋值
if (s1 == s2) { /* ... */ } // 错误:== 对 S 未定义
}
其原因在于,考虑到 S 的通常内存布局,在持有 S 的内存中的部分会有“未使用
的比特位”
,因此 s1==s2 的朴素实现,也就是比较持有 s1 和 s2 的字的比特位的
方式,可能会给出 false 值。如果不是由于这些“未使用的比特位”,C 语言至少
会有缺省的等值比较。我在 1980 年代早期曾经和 Dennis Ritchie 进行过讨论,但
我们当时都太忙了,因而没时间为解决这个问题做些什么。这个问题对于复制
(如 s1=s2)不是个问题,朴素而传统的方案就是简单的复制所有比特位。
由于简单实现的效率,允许赋值而不允许比较在 1970 年代是合适的,而到了
2010 年代就不合适了。现在我们的优化器可以很容易地处理这个问题,而且我
——跟其他很多人一样——已经厌倦了解释为什么没有提供这样的缺省比较。尤
138
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
其是很多 STL 算法需要 == 或 < ,如果用户没有显式地为这些数据结构定义
operator==() 和/或 operator<(),它们就无法支持简单的数据结构。
在 2014 年,Oleg Smolsky [Smolsky 2014] 提议了一种定义比较运算符的简单方
法:
struct Thing {
int a, b, c;
std::string d;
bool operator==(const Thing &) const = default;
bool operator<(const Thing &) const = default;
bool operator!=(const Thing &) const = default;
bool operator>=(const Thing &) const = default;
bool operator>(const Thing &) const = default;
bool operator<=(const Thing &) const = default;
};
这处理了正确的问题,但它是繁琐的(长长的六行代码就为了说明“我想要缺省
的运算符”),并且,和缺省就有比较运算符相比,这绝对是退而求其次了。它还
有些其他的技术问题(例如“但这个方案是侵入式的:如果我不能修改一个类,
我就没法给它添加比较能力”)
,但现在竞赛已经是在如何更好地在 C++17 支持运
算符上了。
我写了一篇论文讨论这个问题 [Stroustrup 2014c],并且提议为简单类提供缺省比
较 [Stroustrup 2014b]。事实证明,在这个上下文中,很难定义“一个类是简单
的”意味着什么,而且 Jens Maurer 发现了一些令人不愉快的作用域问题,关于在
有了缺省运算符的同时又自定义比较运算符的组合情况(例如,“在使用了缺省的
== 之 后 , 如 果 我 们 在 不 同 的 作 用 域 又 定 义 了 operator==() , 这 意 味 着 什
么?”
)
。
Oleg、我还有其他人写了更多的其他论文,但提案都停滞了。人们开始在提案上
堆积更多的要求。比如,要求缺省比较的性能在简单使用情况下要和三路比较相
等。Lawrence Crowl 写了对通用的比较的分析 [Crowl 2015b],论及如全序、弱序
和偏序这样的问题。EWG 的普遍观点是 Lawrence 的分析非常棒,但他需要时间
机器才能把这些机制加入到 C++ 中。
最后,在 2017 年,Herb Sutter 给出了一份提案(部分基于 Lawrence Crowl 的工
作),该提案基于三路比较运算符 <=>(如在各种语言中可见到的),基于该运算
符可以生成其他常用的运算符 [Sutter 2017a]。它没有为我们提供缺省的运算符,
但至少它让我们可以用一行公式去定义它们:
139
8. C++17:大海迷航
struct S {
char a;
int b;
friend std::strong_order operator<=>(S,S) = default;
};
S s1 = {'a',1};
S s2 = {'a',1};
bool b0 = s1==s2;
int b1 = s1<=>s2;
bool b2 = s1<s2;
// true
// 0
// false
上述方案是 Herb Sutter 所推荐的,因为它带来的问题最少(例如跟重载和作用域
相关的),但它是侵入式的。我无法在不能修改的类中使用这个方案。在这种情况
下,可以定义一个非成员函数的 <=>:
struct S {
char a;
int b;
};
std::strong_order operator<=>(S,S) = default;
关于 <=> 的提案包含了一个可选项,为简单类隐式定义 <=>,但不出所料,认为一
切都是显式的才更安全的人们投票否决了这个选项。
于是,我们得到的并不是一个让简单的例子在新手手中按预期工作的功能,而是
一个允许专家仔细打造精妙比较运算的复杂功能。
尽管这个 <=> 的提案并没有可用的实现,并且对标准库有强烈潜在影响。它还是
比其他任何我能想到的近期的提案都更容易地通过了委员会。不出所料,这个提
案带来了很多惊讶(§9.3.4),包括导致之前 == 提案未能成功的查找问题。我猜
测,关于比较运算符的讨论让很多人相信了我们总得做些什么,而 <=> 提案解决
了很多各种问题,并与其他语言中熟悉的内容相吻合。
将来的某个时间,我很可能会再次提议为简单类缺省定义 == 和 <=>。C++ 的新人
和普通用户理当享有这种简单性。
<=> 被提议于 2017 年,错过了 C++17,但经过后来很多进一步的工作,它进入了
C++20(§9.3.4)。
140
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
9. C++20:方向之争
由超过 350 名成员组成的委员会进行设计,不太可能产生一个连贯一致的结果。
大家都有截然不同的背景(包括不同的教育背景),也在各自的“日常工作”中承
受不同的压力,自然会在方向上、优先级上和委员会程序上有不同的见解。粗略
估算一下,对于每个提案,大概都有十多位成员会强烈反对其中部分内容。考虑
到 WG21 希望同意人数达到 80% 或 90% 才宣告达成共识,C++ 到目前为止的成功
令人惊讶。
9.1 设计原则
C++ 想发展成为什么样?或者说,WG21 对于它在努力做什么有一个清晰的观点
么?我认为答案是否定的。每位具体成员对于这个问题都有其个人想法,但没有
一个想法是被广泛接受的并且足够具体到可以指导个人的讨论和决策。
ISO C++ 标准委员会既没有一组得到广泛认可的设计标准,也没有一组得到广泛认
可的采纳某个特性的标准。有这个问题的存在,并不是因为没有做过这方面的尝
试。我曾经反复不断地明确强调以下设计标准:
•
•
•
•
•
•
在《C++ 语言的设计和演化》[Stroustrup 1994](§2.1)中提出的“经验
法则”包括有 RAII(§2.2.1)
、面向对象编程、泛型编程、静态类型安全。
简单的事情简单做!
(§4.2)则引出洋葱原则(§4.2)
。
直接映射到硬件和零开销抽象(§1)(§11.2)
。
基于意见反馈来发展 C++,以解决现实世界的实际问题(§11.2)
。
保持稳定性和兼容性 [Koenig and Stroustrup 1991b; Stroustrup 1994]。
直接和硬件打交道的能力,强有力的可组合的抽象机制,以及最小化的运
行时系统(参见我在 HOPL3 的论文 [Stroustrup 2007] 中的回顾)
。
问题在于,人们发现要在解释上达成一致太难,而要忽视他们所不喜欢的又太容
易。这种倾向,使得“什么才是重要的”这个问题上的根本分歧得以发酵。大家
基于他们所受的教育和他们的日常工作中所获得的理解,来做出设计决策。问题
之一是这种背景上的多样性,再加上标准委员(§3.3)内部对于 C++ 广泛应用领
域的不均衡覆盖。许多人只是对于自己的观点 [Stroustrup 2019b] 过于确定无疑。
而要分辨清楚到底什么只是一时的流行,什么才长远来看对 C++ 社区有帮助,确
实很困难。通常来说,第一个提出的解决方案往往不是最好的那个。
人们很容易在细节中迷失而忽略了大局。人们很容易关注当前的问题而忘记长期
目标(以十年计)。相反,委员会成员是如此专注于通用的原则和遥远的未来,以
至于对迫在眉睫的实际问题视而不见。
141
9. C++20:方向之争
在 2017 年,一群国家标准机构代表团 [van Winkel et al. 2017] 的领导人要求对 C++
的方向性问题予以正式严肃的考量,在他们的敦促之下,WG21 建立了方向组
(Direction Group,通常称之为 DG)以试图解决设计目标和方向的问题(§3.2)
。
DG 在 2018 年 发布了它的第一个广泛而详尽的声明 [Dawes et al. 2018],强调了要
遵守明确清晰的原则、一致性,并鼓励用流程来确保这些。比如说:
我们从根本上需要:
•
稳定性:有用的代码“存活”达数十年。
•
不断演进:世界在不断变化,而 C++ 也需要不断改变以面对新的挑
战。
这里有一种内在的张力。
DG 强调一致性有必要贯穿整个标准:
现如今,某些最为强大的设计技术融合了传统的面向对象编程方面、泛型
编程方面、函数式编程方面以及一些传统的命令式编程技术。这种组合,
而不是理论上的纯粹,才是理想的。
•
提供在风格(语法和语义)和使用风格上一致的特性。
该要求适用于库、语言特性,以及这两者的组合
当然了,还有静态类型:
C++ 极其依赖于静态类型安全,以达成其表达能力、性能和安全性。理想
的情况下应有
•
完全的类型安全和资源安全(没有内存损坏和内存泄漏)
该要求可以在不增加额外开销的情况下达成,尤其是,不需要添加垃圾收
集器,也不需要限制表达能力。
国家机构领导的要求 [van Winkel et al. 2017] 和 DG 的文档 [Dawes et al. 2018] 都强
调了委员会成员需要了解 C++ 的历史,以确保一定程度的连续性。一个缺乏历史
的组织无法对他们的设计内容保持一致性的观点。因此,HOPL 论文 [Stroustrup
1993, 2007] 和《C++ 语言的设计和演化》[Stroustrup 1994] 扮演了基石角色。
传统上,为符合 WG21 在 ISO 的章程,C++ 演化方面的工作主要都聚焦于语言和库
的课题。然而,开发者不仅仅需要考虑语言:程序是工具链(§1)的产物。令人
震惊的是,C++ 并没有关于动态链接库的标准,也没有标准化的构建系统。工具
研究小组 SG15 在 2018 年成立,以尝试应对工具方面的形形色色的问题(§3.2)
。
142
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
9.2 我的 C++17 清单
我一直努力鼓励委员会关注重要的改进——而不只去做那些容易完成和容易达成
一致的事情——作为这个努力的一部分,我制定了一个清单,包含了我认为重要
且适合引入 C++17 的内容及其理由:
•
•
•
•
•
•
•
•
•
•
概念——它让我们可以精确描述泛型程序,并解决对于错误信息质量的广
泛抱怨。
模块——只要它可以显著地提高与宏的隔离并大大优化编译时间。
范围库和其他关键 STL 组件对概念的使用——为主流用户改进错误信息质
量和提高库规范(
“STL2”
)的精确性。
统一调用语法——简化模板库的规范和使用。
协程——应该非常快速而简单。
网络库支持——基于 asio 库,如相应 TS 所描述。
契约——不一定需要在 C++17 的库规范中使用。
SIMD 向量和并行算法。
标准库词汇类型,比如 optional、variant、string_view 和
array_view。
一种在栈上提供数组(stack_array)的“魔法类型”
,合理支持安全、
便捷的使用。
在 2015 年 4 月份,在 Kansas 州 Lenexa 的 WG21 会议中,我在晚间会议上向一些
有共鸣的观众展示了这个清单。然而,几乎没有人感受到足够的动力去根据这个
清单调整工作焦点。这个清单后来“泄露”了出去,并且在网上引起了混乱的讨
论,因此我不得不把它正式写出来 [Stroustrup 2015a]。
如果是在一个团结的委员会中,该清单上的每一项都应该已经准备好进入 C++17
了。实际上我认为,如果我们专注于这个列表,完成其中的大约一半提案还是可
行的。然而我还是过于乐观 了。我们唯一达成共识的也就只有关于标准库词汇类
型的那一项。其中 array_view 被重命名为 span ,成了 C++20(§9.3.8)的一部
分。
幸运的是,列表上的大部分条目进入了 C++20。除了
•
•
•
•
•
网络库(§8.8.1)——现在是个 TS [Wakely 2018]
契约(§9.6.1)——差一点进入 C++20
统一函数调用(§8.8.3)
SIMD 向量——目前在一个 TS 中 [Hoberock 2019]
stack_array
143
9. C++20:方向之争
这份列表带来了日程安排上的争论。鉴于概念的提案(§6.3.8)在 2016 年的失败
看起来是不可避免了,我被询问——由整个委员会——是否我打算提议推迟标准
的发布一到两年,来把概念加入到标准中,让标准变成 C++18 或者 C++19。我拒
绝了,因为我认为可预见的发布周期对于整个社区而言更为重要,其重要性要超
过某个单项的改进。而且,当时也无法确保一定会就该提案形成共识,再说一次
日程延误很可能会造成多次延误。如果一份提案被认为值得推迟标准发布,那么
就会有人主张也有其他的提案同样值得标准发布的推迟。这样的逻辑使得 C++0x
变成了 C++11,哪怕当时曾有人希望是 C++06。
9.3 C++20 特性
WG21 将针对 C++20 的新提案的截止日期定为 2018 年 11 月,并在 2019 年 2 月会
议之后宣布“特性冻结”。2020 年 2 月,在捷克共和国布拉格举行的一次会议
上,技术投票结果为 79 比 0,一票弃权 [Smith 2020]。所有 15 个国家成员体的代
表团团长均投了赞成票。官方标准将由 ISO 在 2020 年末发布。C++20 特性包括:
•
•
•
•
•
•
•
•
•
•
•
§6.4:概念——对泛型代码的要求进行明确规定
§9.3.1:模块——支持代码的模块化,使代码更卫生并改善编译时间
§9.3.2:协程——无栈协程
§9.3.3:编译期计算支持
§9.3.4:<=>——三向比较运算符
§9.3.5:范围——提供灵活的范围抽象的库
§9.3.6:日期——提供日期类型、日历和时区的库
§9.3.8:跨度——提供对数组进行高效和安全访问的库
§9.3.7:格式化——提供类型安全的类似于 printf 的输出的库
§9.4:并发改进——例如作用域线程和停止令牌
§9.5:很多次要特性——例如 C99 风格的指派初始化器和使用字符串字面
量作为模板参数
以下内容在 C++20 时尚未准备就绪,但可能会成为 C++23 的主要特性:
•
•
•
§8.8.1:网络——网络库(sockets 等)
§9.6.2:静态反射——根据周围程序生成代码的功能
模式匹配——根据类型和对象值选择要执行的代码 [Murzin et al. 2019]
C++20 提供了一组反映 C++ 长期目标的特性,并解决了一些根本问题。例如,从
1994 年《C++ 语言的设计和演化》[Stroustrup 1994] 书中就提到了模块和概念,
而协程在整个 1980 年代都是“带类的 C”和 C++ 的一部分。C++20 对 C++ 的影响
将与 C++11 一样大。
144
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
不幸的是,C++20 没有对模块和协程提供标准库支持。这可能会成为一个严重的
问题,但当时实在没有时间来准备并赶上 C++20 的时间要求。C++23 应该会提供
所需的支持(§4.1.3)
。
9.3.1 模块
在 C++ 程序中改进模块化是一个显然的需求。从 C 语言中,C++ 继承了 #include
机制,依赖从头文件使用文本形式包含 C++ 源代码,这些头文件中包含了接口的
文本定义。一个流行的头文件可以在大型程序的各个单独编译的部分中被
#include 数百次。基本问题是:
•
•
•
不够卫生:一个头文件中的代码可能会影响同一翻译单元中包含的另一个
#include 中的代码的含义,因此 #include 并非顺序无关。宏是这里的一
个主要问题,尽管不是唯一的问题。
分离编译的不一致性:两个翻译单元中同一实体的声明可能不一致,但并
非所有此类错误都被编译器或链接器捕获。
编译次数过多:从源代码文本编译接口比较慢。从源代码文本反复地编译
同一份接口非常慢。
自“开辟鸿蒙”而始,这已经众所周知(例如,参见《C++ 语言的设计和演化》
[Stroustrup 1994] 第 18 章),但随着越来越多的信息被放入头文件( inline 函
数、constexpr 函数,还有尤其是模板),这些问题在这些年里变得越来越严重。
在 C++ 的早期,通常 10% 的文本来自头文件,但现在它更可能是 90% 甚至
99%。考虑下面的代码:
#include<iostream>
int main()
{
std::cout << "Hello, World\n";
}
这段标准代码有 70 个字符,但是在 #include 之后,它会产生 419909 个字符需
要编译器来消化。尽管现代 C++ 编译器已有骄人的处理速度,但模块化问题已经
迫在眉睫。
在委员会的鼓励下(并得到了我的支持),David Vandevoorde 在二十一世纪产出
了一系列模块设计 [Vandevoorde 2007,2012],但进展非常缓慢。委员会的首要
任务是完成 C++0x,而不是在模块上取得进展。David 主要靠自己奋斗,此外基本
就只得到一些精神支持了。在 2012 年,Doug Gregor 从苹果提交了一个完全不同
的模块系统设计 [Gregor 2012]。在 Clang 编译器基础设施中,这一设计已经针对
145
9. C++20:方向之争
C 和 Objective C 实现 [Clang 2014]。它依赖于语言之外的文件映射指令,而不是
C++ 语言里的构造。该设计还强调了不需要对头文件进行修改。
在 2014 年,由 Gabriel Dos Reis 领导的微软团队成员根据他们的工作提出了一项
提案 [Dos Reis et al. 2014]。从精神层面上讲,它更接近于 David Vandevoorde 的
设计,而不是 Clang/苹果的提议,并且很大程度上是基于 Gabriel Dos Reis 和
Bjarne Stroustrup 在得州农工大学所做的关于 C++ 源代码的最优图表示的研究
(于 2007 年发布并开源 [Dos Reis 2009; Dos Reis and Stroustrup 2009, 2011])
。
这为在模块方面取得重大进展奠定了基础,但同时也为苹果/谷歌/Clang 方式(和
实现)及微软方式(和实现)之间的一系列冲突埋下了伏笔。
为此一个模块研究小组被创建。3 年后,该小组主要基于 Gabriel Dos Reis 的设计
[Dos Reis 2018] 制订了 TS。
在 2017 年,然后在 2018 年又发生了一次,将 Modules TS 纳入 C++20 标准的建议
受阻,就因为谷歌提出了不同的设计 [Smith 2018a,b]。争论的主要焦点是在
Gabriel Dos Reis 的设计中宏无法导出。谷歌的人认为这是一个致命缺陷,而
Gabriel Dos Reis(和我)认为这对于模块化至关重要 [Stroustrup 2018c]:
模块化是什么意思?顺序独立性:import X; import Y; 应该与 import
Y; import X; 相同。换句话说,任何东西都不能隐式地从一个模块“泄
漏”到另一个模块。这是 #include 文件的一个关键问题。#include 中的
任何内容都会影响所有后续的 #include。
我认为顺序独立性是“代码卫生”和性能的关键。通过坚持这种做法,Gabriel
Dos Reis 的模块实现也比使用头文件在编译时间上得到了 10 倍量级的性能提升—
—即使在旧式编译中使用了预编译头文件也是如此。迎合传统头文件和宏的常规
使用的方式很难做到这一点,因为需要将模块单元保持为允许宏替换(“标记
汤”
)的形式,而不是 C++ 逻辑实体的图。
经过精心设计的一系列折中,我们最终达成了一个被广泛接受的解决方案。这一
多年努力的关键人物有 Richard Smith(谷歌)和 Gabriel Dos Reis(微软),以及
GCC 的模块实现者 Nathan Sidwell(Facebook),还有其他贡献者 [Dos Reis and
Smith 2018a,b; Smith and Dos Reis 2018]。从 2018 年年中开始,大多数讨论都集
中在需要精确规范的技术细节上,以确保实现之间的可移植性 [Sidwell 2018;
Sidwell and Herring 2019]。
考虑如下代码所示的 C++20 模块的简单示例:
export module map_printer;
// 定义一个模块
146
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
import iostream;
import containers;
using namespace std;
// 使用 iostream
// 使用我自己的 containers
export
// 让 print_map() 对 map_printer 的用户可用
template<Sequence S>
requires Printable<Key_type<S>> && Printable<Value_type<S>>
void print_map(const S& m) {
for (const auto& [key,val] : m) // 分离“键”和“值”
cout << key << " -> " << val << '\n';
}
这段代码定义了一个模块 map_printer,该模块提供函数 print_map 作为其用户
接口,并使用了从模块 iostream 和 containers 导入的功能来实现该函数。为了
强调与旧的 C++ 风格的区别,我使用了概念(§6)和结构化绑定(§8.2)
。
关键思想:
•
•
•
•
export 指令使实体可以被 import 到另一个模块中。
import 指令使从另一个模块 export 出来的的实体能够被使用。
import 的实体不会被隐式地再 export 出去。
import 不会将实体添加到上下文中;它只会使实体能被使用(因此,未
使用的 import 基本上是无开销的)
。
最后两点不同于 #include,并且它们对于模块化和编译期性能至关重要。
这个简单的例子纯粹是基于模块的;这是理想情况。但是,已经部署的 C++ 代码
也许有五千亿行,而头文件和 #include 并不会在一夜之间被淘汰,可能再过几十
年都不会。好几个人和组织指出,我们需要一些过渡机制,使得头文件和模块可
以在程序中共存,并让库为不同代码成熟度的用户同时提供头文件和模块的接
口。请记住,在任何给定的时刻,都有用户依赖 10 年前的编译器。
考虑在无法修改 iostream 和 container 头文件的约束下实现 map_printer:
export module map_printer;
import <iostream>
import "containers"
using namespace std;
// 定义一个模块
// 使用 iostream 头文件
// 使用我自己的 containers 头文件
export
// 让 print_map() 对 map_printer 的用户可用
template<Sequence S>
requires Printable<Key_type<S>> && Printable<Value_type<S>>
void print_map(const S& m) {
for (const auto& [key,val] : m) // 分离“键”和“值”
147
9. C++20:方向之争
cout << key << " -> " << val << '\n';
}
指名某个头文件的 import 指令工作起来几乎与 #include 完全一样——宏、实现
细节以及递归地 #include 到的头文件。但是,编译器确保 import 导入的“旧头
文件”不具有相互依赖关系。也就是说,头文件的 import 是顺序无关的,因此提
供了部分、但并非全部的模块化的好处。例如,像 import <iostream> 这样导入
单个头文件,程序员就需要去决定该导入哪些头文件,也因为与文件系统进行不
必要的多次交互而降低编译速度,还限制了来自不同头文件的标准库组件的预编
译。我个人希望看到颗粒度更粗的模块,例如,标准的 import std 表示让整个标
准库都可用。然而,更有雄心的标准库重构 [Clow et al. 2018] 必须要推迟到 C++23
(§11.5)了。
像 import 头文件这样的功能是谷歌/Clang 提案的重要组成部分。这样做的一个原
因是有些库的主要接口就是一堆宏。
在设计/实现/标准化工作的后期,反对意见集中在模块对构建系统的可能影响
上。当前 C 和 C++ 的构建系统对处理头文件已经做了大量优化。数十年的工作已
经花费在优化这一点上,一些与传统构建系统相关的人表示怀疑,是否可以不经
(负担不起的)重大重新设计就顺利引入模块,而使用模块的构建会不允许并行
编译(因为当前要导入的模块依赖于某个先前已导入模块的编译结果)[Bindels et
al. 2018; Lopes et al. 2019; Rivera 2019a]。幸运的是,早期印象过于悲观了 [Rivera
2019b],build2 系统已经为处理模块进行了修改,微软和谷歌报告说他们的构建
系统在处理模块方面显示出良好的效果,最后 Nathan Sidwell 报告说他在仅两周
的业余时间里修改了 GNU 的构建系统来处理模块 [Sidwell 2019]。这些经验的最终
演 示及关 键模块 实现者(Gabriel Dos Reis、Nathan Sidwell、Richard Smith 和
David Vandevoorde)的联署论文打动了几乎所有反对者 [Dos Reis et al. 2019]。
在 2019 年 2 月,模块得到了 46 比 6 的多数票,进入了 C++20;投票者中包含了
所有的实现者 [Smith 2019]。在那时,主要的 C++ 实现已经接近 C++20 标准。模
块有望成为 C++20 提供的最重要的单项改进。
9.3.2 协程
协程提供了一种协作式多任务模型,比使用线程或进程要高效得多。协程曾是早
期 C++ 的重要组成部分。如果没有提供协程的任务库,C++ 将胎死腹中,但是由
于多种原因,协程并没有进入 C++98 标准(§1.1)
。
C++20 协程的历史始于 Niklas Gustafsson(微软)关于“可恢复函数”的提案
[Gustafsson 2012]。其主要目的是支持异步 I/O;“能够处理成千上万或以百万计
客户的服务器应用程序”[Kohlhoff 2013]。它相当于当时引入到 C#(2015 年的
148
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
6.0 版)的 async/await 功能。类似的功能已经存在于 Python、JavaScript 和其他
语言里。Niklas 的提案引发了来自 Oliver Kowalke 和 Nat Goodspeed [Kowalke and
Goodspeed 2013] 的基于 Boost.Coroutine 的竞争提案,并引起了人们的浓厚兴
趣。await 设计无栈、不对称且需要语言支持,而源自 Boost 的设计则使用栈、具
有对称控制原语且基于库。无栈协程只能在其自身函数体中挂起,而不能从其调
用的函数中挂起。这样,挂起仅涉及保存单个栈帧(“协程状态”),而不是保存整
个栈。对于性能而言,这是一个巨大的优势。
协程的设计空间很大,因此很难达成共识。委员会中的许多人(包括我在内)都
希望能够综合考虑这两种方式的优点,因此一群感兴趣的成员对可选方案进行了
分析 [Goodspeed 2014]。结论是,有可能同时利用这两种方式的优点,但这需要
认真研究。这项研究花了数年时间,但没有得出明确的结果。与此同时,出现了
更多的提案。
至于密切相关的并发主题(§8.4),对所编写、演示和讨论的提案的完整解释超出
了本文的范围。在这里,我只描述一个概况。因为复杂的细节简直太多,在此也
只能简而言之;仅论文就有数百页,许多讨论都取决于高级用例的(有时是假设
的)高度优化实现的性能。讨论发生在 SG1(并发)
、EWG(演化)、LEWG(库演
化)
、CWG(核心语言)
、LWG(库)
,甚至在晚间会议和全体会议上。
在这些讨论和提案中,三种想法反复出现:
•
•
•
将协程的状态及其操作表示为 lambda 表达式,从而使协程优雅地适配
C++ 类型系统,而不需要 await 式协程 [Kohlhoff 2013] 所使用的某些“编
译器魔法”
。
为无栈和有栈协程提供通用接口——也可能为其他类型的并发机制,例如
线程和纤程,提供通用接口。[Kowalke 2015; Riegel 2015]。
为了在最简单和最关键的用途(生成器和管道)上获得最佳性能(运行时
间和空间)
,无栈协程需要编译器支持,并且一定不能为了支持更高级的
用例而在接口上作妥协 [Nishanov 2018,2019b]。
你不可能同时满足这三者。我非常喜欢通用接口的想法,因为这样可以最大限度
地减少学习需要的努力,并使得实验大为便捷。类似地,使用完全普通的对象来
表示协程将开放整个语言来支持协程。然而,最终性能论胜出。
在 2017 年,Gor Nishanov 基于 await 无栈方式的提案被接受为 TS [Nishanov
2017]。这一提案(不可避免地被戏称为“Gor-routines”)获得批准的原因是,它
的实现在其关键用例(管道和生成器)中表现出了卓越的性能 [Jonathan et al.
2018; Psaropoulos et al. 2017]。之所以把它写成 TS,而不是放到标准中,是因为
许多人喜欢更通用(但速度较慢)的有栈协程,有些人仍然希望这两种方式的零
149
9. C++20:方向之争
开销统一。我当时(今天仍没有变)的观点是,在合理的时间段里,统一并不可
能。我已经等了近 30 年的时间让协程重新回到 C++ 中,我可不想等待一个可能永
远不会到来的突破:
“最好是好的敌人。
”
和往常一样,命名是一个有争议的问题。特别是,TS 草案使用了关键字 yield,
这很快被判定为一个流行的标识符(例如,在金融和农业领域)。而且,协程产生
的结果需要被包到一个调用者可以等待的结构中(例如, future (§4.1.3)),因
此,协程 return 语句的语义与普通 return 语句的语义不是完全一样。所以,有
些 人 就 反 对 return 的 “ 复 用 ”。 作 为 回 应 , 演 化 工 作 组 引 入 了 关 键 字
co_return、co_yield 和 co_await,用于协程中的三个关键操作。使用下划线是
为了防止母语为英语的人将 coreturn、coyield 和 coawait 误读为 core-turn、
coy-ield 和 coa-wait。人们也探索了使 yield 和 await 成为上下文敏感的关键
词的可能性,但没有达成共识。这些新的关键词并不漂亮,它们很快就成为了那
些出于任何原因不喜欢 TS 协程的人们的靶子。
在 2018 年,TS 协程被提议纳入 C++20 标准,但在最后那一刻,来自谷歌的 Geoff
Romer、James Dennett 和 Chandler Carruth 提出了一个对新手颇不友好的提案
[Romer et al. 2018]。谷歌的提案名为“核心协程”(Core Coroutines),它和 Gor
的提案一样,需要库支持来使基本机制对非专家用户变得友好。所需要的库当时
还没有设计好。核心协程被宣称比 TS 协程更高效,并且解决了谷歌的一个用例,
用于不基于异常的错误传播。其思想基于将协程的状态表示为 lambda 表达式。为
了避免人们普遍鄙视的关键词 co_return、co_yield 和 co_await,核心协程提供
了据称更友好的运算符 [->] 和 [<-]。令人惊讶的是,作为运算符,[->] 有四个
字符长,并且有四个操作数,
“[”和“]”是标记的一部分。不幸的是,核心协程
没有实现,因此可用性和效率的主张无法得到验证。这推迟了关于协程的进一步
决定。
TS 协程的一个重要且可能致命的问题是,它依赖于自由存储区(动态内存、堆)
上的分配。在某些应用程序中,这是很大的开销。更糟糕的是,对于许多关键的
实时和嵌入式应用程序,自由存储区的使用是不允许的,因为它可能导致不可预
测 的 响 应 时 间 和 内 存 碎 片 的 可 能 性 。 核 心 协 程 没 有 这 个 问 题 。 然 而 ,Gor
Nishanov 和 Richard Smith 论证了,TS 协程可以通过多种方式之一保证几乎所有
用途都没有(并检测和防止其他用途)自由存储区的使用 [Smith and Nishanov
2018]。特别是,对于几乎所有的关键用例,都可以将自由存储区使用优化为栈分
配(所谓的“Halo 优化”*)
。
*
译注:Heap Allocation eLision Optimization。
150
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
随着时间的推移,核心协程不断发展和完善 [Romer et al. 2019a],但完整的实现
一直没有出现。在 2018 年,保加利亚国家标准机构反对 TS 协程设计 [Mihaylov
and Vassilev 2018],并提出了另一种设计 [Mihaylov and Vassilev 2019]。又一次,
提案宣称具有优雅、通用性和高性能,但同样地,没有任何实现存在。
这时候,演化小组的负责人 Ville Voutilainen 要求这三个仍然活跃的提案的作者撰
写两份评估和比较论文:
•
•
Coroutines: Use-cases and Trade-offs(
《协程:用例与取舍》
)[Romer et al.
2019b]
Coroutines: Language and Implementation Impact(《协程:语言与实现影
响》
)[Smith et al. 2019]
这三个提案(Gor、谷歌和保加利亚)都是无栈的,需要栈的用例被留给未来的提
案。所有这些提案都有数量惊人的定制点 [Nishanov 2018],它们的实现者和专家
用户都认为这些是必不可少的。结果表明,在不同的提案中,关键用例的表达并
没有显著不同。因此,这些差异可以认为很大程度上只是表面文章,不用多理
会。例如,co_await 比 [<-] 更丑吗?
这就只留下性能问题有待讨论。Gor 的提案,因为有着四年的生产环境使用,并
在微软和 Clang 编译器中都有实现,而具有明显的优势。在 C++20 的关键投票之
前的最后几次会议上,委员会听取了来自 Sandia [Hollman 2019]、微软 [Jonathan
et al. 2018] 和 Facebook [Howes et al. 2018] 的人的体验报告,并考虑了一些关于
基于使用体验的改进和简化的建议 [Baker 2019]。然而,(据我判断)打动委员
会、使其以 48 比 4 的绝对优势投票支持 Gor-routine 的要点是,在使用“普通的
lambda 表达式”来代表协程状态的策略中发现了一个根本性的缺陷。为了使表示
协程状态的 lambda 表达式与其他 lambda 表达式一样,必须在编译的第一阶段就
知道其大小。只有这样,我们才能在栈上分配协程状态、复制它们、移动它们,
并以语言允许的各种方式使用它们。但是,在优化器运行之前,栈帧(根本上,
这就是无栈协程的状态)的大小是未知的。没有从优化器返回到编译器早期阶段
的信息路径。优化器可能会通过消除变量来减小帧的大小,也可能会通过添加有
用的临时变量来增加帧的大小。因此,用来代表某个协程状态的 lambda 表达式不
能是“普通的”
。
最后,考虑一个 C++20 协程的简单例子:
generator<int> fibonacci()
{
int a = 0;
// 初值
int b = 1;
// 生成 0,1,1,2,3,5,8,13 ...
while (true) {
151
9. C++20:方向之争
int next = a+b;
co_yield a;
// 返回下一个斐波那契数
a = b;
// 更新值
b = next;
}
}
int main()
{
for (auto v : fibonacci())
cout << v << '\n';
}
使用 co_yield 使 fibonacci() 成为一个协程。generator<int> 返回值将保存生
成的下一个 int 和 fibonacci() 等待下一个调用所需的最小状态。对于异步使
用,我们将用 future<int> 而不是 generator<int>。对协程返回类型的标准库支
持仍然不完整,不过库就应该在生产环境的使用中成熟。
委员会本来可以更好地处理协程提案吗?也许可以吧;C++20 协程与 Niklas
Gustafsson 2012 年的提案非常相似。我们探索了替代方案固然很好,但我们真的
需要 7 年时间吗?许多有能力的人所做的大量努力是否可以更多协作、更少竞
争?我觉得更好的学术知识在早期阶段会有所帮助。毕竟,协程有约 60 年的历
史,例如 [Conway 1963]。人们是知道 C++ 和相关语言中的现代方法的,但我们的
理解既未共享,也不系统。如果我们当初花上几个月或一年的时间对基本设计选
择、实现技术、关键用例和文献进行彻底审核,我怀疑我们早在 2014 年就可以得
出 2019 年 2 月得出的结论。之后的几年本可以花在对我们所选择的基本方法进行
增量改进和功能添加上。
我们取得的进展和最后的成功很大程度上归功于 Gor Nishanov。要不是有他的坚
韧不拔和扎实实现(他完成了微软和 Clang 两种编译器里的实现)
,我们在 C++20
也不会有协程。锲而不舍是在委员会成功的关键要素。
9.3.3 编译期计算支持
多年以来,在 C++ 中编译期求值的重要性一直在稳步提高。STL 严重依赖于编译
期分发 [Stroustrup 2007],而模板元编程主要旨在将计算从运行期转移到编译期
(§10.5.2)
。甚至在早期的 C++ 中,对重载的依赖以及虚函数表的使用都可以看作
是通过将计算从运行期转移到编译期来获得性能。因此,编译期计算一直是 C++
的关键部分。
C++ 从 C 继承了只限于整型且不能调用函数的常量表达式。曾有一段时间,宏对
于任何稍微复杂点的事情都必不可少。但这些都不好规模化。一旦引入模板并发
现了模板元编程,模板元编程就被广泛用于在编译期计算值和类型上(§10.5.2)
。
152
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
在 2010 年,Gabriel Dos Reis 和 Bjarne Stroustrup 发表了一篇论文,指出编译期的
值计算可以(也应该)像其他计算一样表达,一样地依赖于表达式和函数的常规
规则,包括使用用户定义的类型 [Dos Reis and Stroustrup 2010]。这成为 C++11
(§4.2.7)里的 constexpr 函数,它是现代编译期编程的基础。C++14 推广了
constexpr 函数(§5.5)
,而 C++20 增加了好几个相关的特性:
•
consteval——保证在编译期进行求值的 constexpr 函数 [Smith et al.
2018a]
•
•
•
•
•
•
•
constinit——保证在编译期初始化的声明修饰符 [Fiselier 2019]
允许在 constexpr 函数中使用成对的 new 和 delete [Dimov et al. 2019]
constexpr string 和 constexpr vector [Dionne 2018]
使用 virtual 函数 [Dimov and Vassilev 2018]
使用 unions、异常、dynamic_cast 和 typeid [Dionne and Vandevoorde
2018]
使用用户定义类型作为值模板参数——最终允许在任何可以用内置类型的
地方使用用户定义类型 [Maurer 2012]
is_constant_evaluated() 谓词——使库实现者能够在优化代码时大大
减少平台相关的内部函数的使用 [Smith et al. 2018b]
随着这一努力,标准库正在变得对编译期求值更加友好。
这一努力的最终目的是为了让 C++23 或更高版本支持静态反射(§9.6.2)。在我最
初设计模板时,曾期望使用用户自定义类型作为模板参数类型,使用字符串作为
模板参数,但以我当时的能力无法恰当地设计和实现出这一功能。
有 些 人 希 望 每 一 个 C++ 构 造 在 编 译 期 都 能 可 用 。 特 别 是 , 他 们 希 望 能 够 在
constexpr 函数中使用完整的标准库。那可能就好过头了。比如,你真的需要在
编译期使用线程吗?是的,这可行。没有使所有函数在编译期都可用,这就给我
们留下了一个问题:哪些应该可用,哪些不应该可用。到目前为止,答案有点临
场发挥而并不连贯。这需要进一步完善。
要让一个语言的构造或库组件成为 constexpr,我们必须非常精确地进行描述,
并消除未定义行为的可能性。因此,推动编译期求值已经成为更精确的规范说
明、平台依赖性分析和未定义行为根源分析的主要驱动力。
显然,这种对编译期计算的推动为编译器带来了更多的工作。接口里需要增加更
多的信息,来允许编译器完成所有的工作,这个问题正在通过模块来解决
(§9.3.1)。编译器还通过缓存结果进行补偿,依赖并行构建的系统也很常见。然
而,C++ 程序员必须学会限制编译期计算和元编程的使用,只有在值得为了代码
紧凑性和运行期性能而引入它们的地方才使用。
153
9. C++20:方向之争
9.3.4 <=>
参见(§8.8.4)
。紧接在“飞船运算符”
(<=>)投票进入 C++20 之后,很明显,在
语言规则及其与标准库的集成方面都需要进一步的认真工作。出于对解决跟比较
有关的棘手问题的过度热情和渴望,委员会成了意外后果定律的受害者。一些委
员(包括我在内)担心引入 <=> 过于仓促。然而,在我们的担忧坐实的时候,早
已经有很多工作在假设 <=> 可用的前提下完成了。此外,三向比较可能带来的性
能优势让许多委员会成员和其他更广泛的 C++ 社区成员感到兴奋。因此,当发现
<=> 在重要用例中导致了显著的低效时,那就是一个相当令人不快的意外了。类
型有了 <=> 之后,== 是从 <=> 生成的。对于字符串,== 通常通过首先比较大小来
优化:如果字符数不同,则字符串不相等。从 <=> 生成的 == 则必须读取足够的字
符串以确定它们的词典顺序,那开销就会大得多了。经过长时间的讨论,我们决
定不从 <=> 生成 == 。这一点和其他一些修正 [Crowl 2018; Revzin 2018, 2019;
Smith 2018c] 解决了手头的问题,但损害了 <=> 的根本承诺:所有的比较运算符
都可以从一行简单的代码中生成。此外,由于 <=> 的引入,== 和 < 现在有了许多
不同于其他运算符的规则(例如, == 被假定为对称的)。无论好坏,大多数与运
算符重载相关的规则都将 <=> 作为特例来对待。
9.3.5 范围
范围库始于 Eric Niebler 对 STL 序列观念的推广和现代化的工作 [Niebler et al.
2014]。它提供了更易于使用、更通用及性能更好的标准库算法。例如,C++20 标
准库为整个容器的操作提供了期待已久的更简单的表示方法:
void test(vector<string>& vs)
{
sort(vs);
// 而不是 sort(vs.begin(),vs.end())
}
C++98 [Stroustrup 1993] 所采用的原始 STL 将序列定义为一对迭代器。这遗漏了
指定序列的两种重要方式。范围库提供了三种主要的替代方法(现在称为
ranges)
:
•
•
•
(首项,尾项过一) 用于当我们知道序列的开始和结束位置时(例如“对
vector 的开始到结束位置进行排序”
)
。
(首项,元素个数) 用于当我们实际上不需要计算序列的结尾时(例如“查
看列表的前 10 个元素”
)
。
(首项,结束判据) 用于当我们使用谓词(例如,一个哨位)来定义序列的
结尾时(例如“读取到输入结束”
)
。
154
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
range 本身是一种 concept(§6)
。所有 C++20 标准库算法现在都使用概念进行了
精确规定。这本身就是一个重大的改进,并使得我们在算法里可以推广到使用范
围,而不仅仅是迭代器。这种推广允许我们把算法如管道般连接起来:
vector<int> vec = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
auto even = [](int i){ return i%2 == 0; }
for (int i : vec | view::filter(even)
| view::transform( [](int i) { return i*i; } )
| view::take(5))
cout << i << '\n';
// 打印前 5 个偶整数的平方
像在 Unix 中一样,管道运算符 | 将其左操作数的输出作为输入传递到其右操作数
(例如 A|B 表示 B(A))
。一旦人们开始使用协程(§9.3.2)来编写管道过滤器,这
就会变得有趣得多。
在 2017 年,范围库成为了 TS [Niebler and Carter 2017];在 2019 年 2 月,它被投
进了 C++20 [Niebler et al. 2018]。
9.3.6 日期和时区
日期库是 Howard Hinnant(现在任职于 Ripple,之前任职于苹果)的作品,为
C++ 提供标准的日历和时区支持 [Hinnant and Kamiń ski 2018]。它基于 chrono 标
准库的时间支持。Howard 也是 chrono 标准库(§4.6)背后的主要人物。日期库
是多年工作和实际使用的结果。在 2018 年,它通过投票进入了 C++20,并和旧的
时间工具一起放在 <chrono> 中。
考虑如何表达时间点(time_point)
:
constexpr auto tp = 2016y/May/29d + 7h + 30min + 6s + 153ms;
cout << tp << '\n';
// 2016-05-29 07:30:06.153
该表示法很传统(使用用户定义的字面量§4.2.8)),日期表示为 年,月,日 结构。
但是,当需要时,日期会在编译期映射到标准时间线( system_time )上的某个
点(使用 constexpr 函数(§4.2.7)
)
,因此它极其快速,也可以在常量表达式中使
用。例如:
static_assert(2016y/May/29==Thursday);
// 编译期检查
默认情况下,时区是 UTC(又称 Unix 时间)
,但转换为不同的时区很容易:
zoned_time zt = {"Asia/Tokyo", tp};
cout << zt << '\n';
// 2016-05-29 16:30:06.153 JST
155
9. C++20:方向之争
日期库还可以处理星期几(例如,Monday 和 Friday)、多个日历(例如,格里历
和儒略历)
,以及更深奥(但必要)的概念,比如闰秒。
除了有用和快速之外,日期库还有趣在它提供了非常细粒度的静态类型检查。常
见错误会在编译期捕获。例如:
auto
auto
auto
auto
d1
d2
d2
d3
=
=
=
=
2019y/5/4;
2019y/May/4;
May/4/2019;
d2+10;
//
//
//
//
错误:是 5 月 4 日还是 4 月 5 日?
正确
正确(日跟在月后面)
错误:是加 10 天、10 个月还是 10 年?
日期库是标准库组件中的一个少见的例子,它直接服务于某应用领域,而非“仅
仅”提供支持性的“计算机科学”抽象。我希望在将来的标准中能看到更多这样
的例子。
9.3.7 格式化
iostream 库提供了类型安全的 I/O 的扩展,但是它的格式化工具比较弱。另外,
还有的人不喜欢使用 << 分隔输出值的方式。格式化库提供了一种类 printf 的方
式去组装字符串和格式化输出值,同时这种方法类型 安全、快捷,并能和
iostream 协同工作。这项工作主要是由 Victor Zverovich [Zverovich 2019] 完成的。
类型中带有 << 运算符的可以在一个格式化的字符串中输出:
string s = "foo";
cout << format("The string '{}' has {} characters",s,s.size());
输出结果是 The string 'foo' has 3 characters。
这是“类型安全的 printf”变参模板思想的一个变体(§4.3.2)。大括号 {} 简单
地表示了插入参数值的默认表示形式。
参数值可以按照任意顺序被使用:
// s 在 s.size() 前:
cout << format("The string '{0}' has {1} characters",s,s.size());
// s.size() 在 s 前:
cout << format("The string '{1}' has {0} characters",s.size(),s);
像 printf() 一样, format() 为展现格式化细节提供了一门小而完整的编程语
言,比如字段宽度、浮点数精度、整数基和字段内对齐。不同于 printf() ,
format() 是可扩展的,可以处理用户定义类型。下面是 <chrono> 库中(§9.3.6)
一个打印日期的例子 [Zverovich et al. 2019]:
156
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
string s1 = format("{}", birthday);
string s2 = format("{0:>15%Y-%m-%d}", birthday);
“年-月-日”是默认格式。>15 意味着使用 15 个字符和右对齐文本。日期库中还
包含了另一门小的格式化语言可以同 format() 一起用。它甚至可以用来处理时区
和区域:
std::format(std::locale{"fi_FI"}, "{}", zt);
这段代码将会给出芬兰的当地时间。默认情况下,格式化不依赖于区域,但是你
可以选择是否根据区域来格式化。相比于传统的 iostream,默认区域无关的格式
化大大提升了性能,尤其是当你不需要区域信息的时候。
输入(istream)没有等价的 format 支持。
9.3.8 跨度
越界访问,有时也称为缓冲区溢出,从 C 的时代以来就一直是一个严重的问题。
考虑下面的例子:
void f(int* p, int n) // n 是什么?
{
for (int i=0; i<n; ++i)
p[i] = 7; // 可以吗?
}
试问一个工具,比如编译器要如何知道 n 代表着所指向的数组中元素的个数?一
个程序开发人员如何要能够在一个大型程序中对此始终保持正确?
int x = 100;
int a[100];
f(a,x);
// 可以
f(a,x/2); // 可以:a 的前半部分
f(a,x+1); // 灾难!
几十年来,像“灾难”这样的评论一向是准确的,范围错误也一直是大多数重大
安全问题的根因。编译器不能够捕获范围错误,而运行期检查所有的下标则普遍
被认为对于生产代码来说代价过于高昂。
显而易见的解决方案就是提供一种抽象机制,带有一个指针再加上一个大小。举
例来说,1990 年,Dennis Ritchie 向 C 标准委员会提议:“‘胖指针’
,它的表示中
包括了内存空间以存放运行期可调整的边界。”[Ritchie 1990]。由于各种原因,C
标准委员会没有通过这个提案。在当时,我听到一条极可笑的评论:“Dennis 不是
C 的专家;他从不来参加会议。
”我没记住这到底是谁说的,也许这是件好事。
157
9. C++20:方向之争
2015 年,Neil MacIntosh(那个时候他还在微软)在 C++ 核心指南(§10.6)里恢
复了这一想法,那里我们需要一种机制来鼓励和选择性地强制使用高效编程风
格。span<T> 类模板就这样被放到 C++ 核心指南的支持库中,并立即被移植到微
软、Clang 和 GCC 的 C++ 编译器里。2018 年,它投票进入了 C++20。
使用 span 的一个例子如下:
void f(span<int> a) // span 包含一根指针和一条大小信息
{
for (int& x : a)
x = 7; // 可以
}
范围 for 从跨度中提取范围,并准确地遍历正确数量的元素(无需代价高昂的范
围检查)。这个例子说明了一个适当的抽象可以同时简化写法并提升性能。对于算
法来说,相较于挨个检查每一个访问的元素,明确地使用一个范围(比如 span)
要容易得多,开销也更低。
如果有必要的话,你可以显式地指定一个大小(比如操作一个子范围)。但这样的
话,你需要承担风险,并且这种写法比较扎眼,也易于让人警觉:
int x = 100;
int a[100];
f(a);
// 模板参数推导:f(span<int>{a, 100})
f({a,x/2}); // 可以:a 的前半部分
f({a,x+1}); // 灾难
自然、简单的元素访问也办得到,比如 a[7]=9, 同时运行期也能进行检查。span
的范围检查是 C++ 核心指南支持库(GSL)的默认行为。
事实证明,将 span 纳入 C++20 的最具争议的部分在于下标和大小的类型。C++ 核
心指南中 span::size() 被定义返回一个有符号整数,而不是标准库容器所使用的
无符号整数。下标的情况也类似。像在数组中,下标一向是有符号的整数,而在
标准库容器中下标却是无符号整数。这导致了一个古老争议的重演:
•
•
•
一组人认为显然下标作为非负数应该使用无符号整数。
一组人认为与标准库容器保持一致性更重要,这点使得使用无符号整数是
不是一个过去的失误变得无关紧要。
一组人认为使用无符号整数去表示一个非负数是一种误导(给人一种虚假
的安全感)
,并且是错误的主要来源之一。
不顾 span 最初的设计者(包括我在内)和实现者的强烈反对,第二组赢得了投
票,并受到第一组热情地支持。就这样, std::span 拥有无符号的范围大小和下
158
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
标。我个人认为那是一个令人悲伤的失败,即未能利用一个难得的机会来弥补一
个令人讨厌的老错误 [Stroustrup 2018e]。C++ 委员会选择了与问题兼容而不是消
除一个重大的错误来源,这在某种程度上是可以预见的,也算不无道理吧。
但是用无符号整数作为下标会出什么问题呢?这似乎是一个相当情绪化的话题。
我曾收到很多封与之相关的仇恨邮件。存在两个基本问题:
•
•
无符号数并不以自然数为模型:无符号数使用模算数,包括减法。比如,
如果 ch 是个 unsigned char,ch+100 将永远不会溢出。
整数和无符号数彼此相互转换,稍不留意负数值就会变成巨大的无符号数
值,反之亦然。比如,-2<2u 为假;2u 是 unsigned,因此 -2 在比较前会
被转换为一个巨大的正整数。
这是一个在真实环境下偶尔可见的无限循环的例子:
for (size_t i = n-1; i >= 0; --i) { /* ... */ }
// “反向循环”
不幸的是,标准库中的类型 size_t 是无符号类型,然后很明显结果永远 >=0。
总的来说,作为 C++ 继承自 C 的特性,有符号和无符号类型之间的转换规则几十
年来都是那种难以发现的错误的一个主要来源。但说服委员会去解决那些老问题
总是很难的。
9.4 并发
尽管做出了英勇的努力,并正在形成广泛的共识,但是人们所期望的通用并发模
型(
“执行器”)在 C++20 中还没有准备好(§8.8.1)。这并非是因为缺乏努力,我
们的努力中包括了 2018 年 9 月在华盛顿州贝尔维尔举行的为期两天的特别会议,
约有 25 人出席,其中有来自英伟达、Facebook 和美国国家实验室的代表。不
过,有几个不那么剧烈的有用改进还是及时完成了,其中包括:
•
•
•
•
•
jthread 和停止令牌 [Josuttis et al. 2019a]
atomic<shared_ptr<T>> [Sutter 2017b]
经典的信号量 [Lelbach et al. 2019]
屏障和锁存器 [Lelbach et al. 2019]
小的内存模型的修复和改进 [Meredith and Sutter 2017]
jthread (
“joining thread”的缩写)是一个遵守 RAII 的线程;也就是说,如果
jthread 超出作用域了,它的析构函数将合并线程而不是终止程序:
void some_fct()
{
159
9. C++20:方向之争
thread t1;
jthread t2;
// ...
}
在作用域的最后, t1 的析构函数会终止程序,除非 t1 的任务已经完成,已经
join 或 detach,而 t2 的析构函数将会等待其任务完成。
一开始的时候(C++11 之前)
,很多人(包括我在内)都希望 thread 可以拥有如
今 jthread 的行为,但是根植于传统操作系统线程的人坚持认为终止一个程序要
远比造成死锁好得多。2012 年和 2013 年,Herb Sutter 曾经提出过合并线程
[Sutter 2012, 2013a]。这引发了一系列讨论,但最终却没有作出任何决定。2016
年,Ville Voutilainen 总结了这些问题,并为将合并线程纳入 C++17 发起了投票
[Voutilainen 2016a]。投票支持者众多以至于我(只是半开玩笑地)建议我们甚至
可以把合并线程作为一个错误修复提交给 C++14。但是不知何故,进展又再次停
滞。到了 2017 年,Nico Josuttis 又一次提出了这个问题。最终,在八次修订和加
入了停止令牌之后,这个提案才成功进入了 C++20 [Josuttis et al. 2019a]。
“停止令牌”解决了一个老问题,即如何在我们对线程的结果不再感兴趣后停止
它。基本思想是使用协作式的线程取消方式(§4.1.2)。假如我想要一个 jthread
停止,我就设置它的停止令牌。线程有义务不时地去检查停止令牌是否被设置
了,并在设置时进行清理和退出。这个技巧由来已久,对于几乎每一个有主循环
的线程都能完好高效地工作,在这个主循环里就可以对停止令牌进行检查。
像 往 常 一 样 , 命 名 成 了 问 题 : safe_thread 、 ithread ( i 代 表 可 中 断 )、
raii_thread、joining_thread,最终成了 jthread。C++ 核心指南支持库 (GSL)
中称其为 gsl::thread。说真的,最合适的名字就是 thread,但是很不幸,那个
名字已经被一类不太有用的线程占用了。
9.5 次要特性
C++20 提供了许多次要的新特性,包括:
•
•
•
•
•
•
•
•
C99 风格的指派初始化器 [Shen et al. 2016]
对 lambda 捕获的改进 [Koppe 2017b]
泛型 lambda 表达式的模板参数列表 [Dionne 2017]
范围 for 中初始化一个额外的变量(§8.7)
不求值语境中的 lambda 表达式 [Dionne 2016]
lambda 捕获中的包展开 [Revzin 2017]
在一些情况下移除对 typename 的需要 [Vandevoorde 2017]
更多属性:[[likely]] 和 [[unlikely]] [Trychta 2016]
160
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
•
•
•
•
•
•
在不使用宏的情况下,source_location 给出一段代码中的源码位置
[Douglas and Jabot 2019]
功能测试宏 [Voutilainen and Wakely 2018]
条件 explicit [Revzin and Lavavej 2018]
有符号整数保证是 2 的补码 [Bastien 2018]
数学上的常数,比如 pi 和 sqrt2 [Minkovsky and McFarlane 2019]
位的操作,比如轮转和统计 1 的个数 [Maurer 2019]
其中有些属于改进,但是我担心的是晦涩难懂的新特性的数量之大会造成危害
[Stroustrup 2018d]。对于非专家来说,它们使得语言变得更加难以学习,代码更
加难以理解。我反对一些利弊参半的特性(比如,使用指派初始化器的地方原本
可以使用构造函数,那会产生更易于维护的代码)。很多特性具有特殊用途,有些
是“专家专用”。不过,有的人总是领会不到,一个对某些人有某种好处的特性,
对于 C++ 整体可能是个净负债。当然,那些增加写法和语义上的通用性和一致性
的小特性,则总是受欢迎的。
从标准化的角度来看,即使最小的特性也需要花时间去处理、记录和实现。这些
时间是省不掉的。
9.6 进行中的工作
当然,很多目标放在 C++20 之后版本的工作还在进行中,而另一些原本目标在
C++20 发布的工作则没能及时完成,尤其是:
•
•
•
§8.8.1:网络和执行器——再度延迟。
§9.6.1:契约——断言、前置条件和后置条件;原本目标是 C++20,但延
迟了。
§9.6.2:反射——基于当前编译的代码将代码注入程序;目标是 C++23。
另外,工作组和研究组也仍有工作正在进行中(§3.2)[Stroustrup 2018d]。
9.6.1 契约
契约的特殊之处在于,不但很多人希望它可以进入 C++20,而且契约是被投票写
入 C++20 的工作文件中的,只是在最后一刻被从中移除。一个由 John Spicer 主持
的新的研究组 SG21 已经成立,试图为 C++23 或者 C++26 提供某种形式的契约。
契约于 C++20 的遭遇是令人惋惜的,但可能也能给人以启发。
各种形式的契约在 C++ 和其他语言中都有着悠久的历史。我记得在 1970 年代初,
当我第一次遇到 Peter Naur 的不变量 [Naur 1966] 的时候,我一度被它深深吸引。
在 1990 年代早期,一个被称为 A++ 的断言系统被考虑用于 C++,但却被认为涉及
161
9. C++20:方向之争
面太广而不现实。在 1980 年代晚期,Bertrand Meyer 曾推广过 Eiffel 里“契约”
的概念 [Meyer 1994]。作为 C++0x 努力的一部分,一些提案 [Crowl and Ottosen
2006] 在 C++ 委员会受到了高度重视,但最终却失败了,主要原因在于被认为过
于复杂,写法也不优雅。
多年来,Bloomberg(那家纽约市的金融信息公司)一直使用一个名为“契约”
的实时断言系统去捕获代码中的问题。2013 年,来自 Bloomberg 的 John Lakos 提
议标准化该系统 [Lakos and Zakharov 2013]。这个提案受到了好评,但它遇到两
个问题:
•
•
它基于宏
它严格来说是代码实现中的断言,而不是可以增强接口的东西
修订接踵而至,但是共识却没有出现。为了打破僵局,一群来自微软、
Facebook、谷歌和马德里的卡洛斯三世大学的人提出一个“简单契约”的系统,
该系统不使用宏,并且对前置条件和后置条件提供支持(正如 C++0x 所尝试的)
[Garcia et al. 2015]。和 Bloomberg 的提案一样,这一提案得到了多年大规模工业
应用的背书,但它的重点是在静态分析中使用契约。J. Daniel Garcia(卡洛斯三世
大学)努力工作以求做出满足各方面需求的设计,但该提案也遭到了反对。
经过了无数次的会议、多篇论文和(偶尔激烈的)讨论之后,妥协显然是难以达
成了。两个小组请求我来进行协调。我之前宣称,讨论太专注在细枝末节上了,
而我们需要一个最小提案,包含两个小组的核心诉求,而不是有争议的细节。他
们要我来证明我的推断,拿一个这样的最小提案出来。在我和两个小组的代表轮
番讨论、工作了相当一段时间之后,我们最终联合各方共同起草了联合提案 [Dos
Reis et al. 2016a]。我认为这个设计技术上是相当充分的,并非一个政治上的妥
协。它旨在满足三方面的需求(按重要性排序):
•
•
•
系统和可控的运行期测试
为静态分析器提供信息
为优化器提供信息
在 J. Daniel Garcia 领导的进一步工作之后,该提案最终在 2018 年 6 月正式被
C++20 采纳 [Dos Reis et al. 2018]。
为避免引入新的关键字,我们使用属性语法。例如,[[assert: x+y>0]]。一个
契约对一个有效的程序不起任何作用,因此这种方式满足属性的原来概念
(§4.2.10)
。
有三种契约:
162
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
•
•
•
assert——可执行代码中的断言
expects——函数声明中的前置条件
ensure——函数声明中的后置条件
有三种不同等级的契约检查:
•
•
•
audit——“代价高昂的”谓词,仅在某些“调试模式”检查
default——“代价低廉的”谓词,即使在生产代码中检查也是可行的
axiom——主要给静态分析器看的谓词,在运行期从不检查
在违反契约时,将执行(可能是用户安装的)契约违反处理程序。默认行为是程
序立即终止。
我发现一个有意思的事:有一种构建模式允许程序在契约失败后继续执行。我的
第一反应是“疯了吧!契约旨在防止违反契约的程序运行”。那算是最最常见的反
应了。不论如何,John Lakos 坚信,基于 Bloomberg 代码的相关经验,当你把契
约加入一个大型的古老代码仓库,契约总是会被违反:
•
•
•
某些代码会违反契约,而实际上并没有做任何该契约所要防止的事情。
某些新契约本身就包含错误。
某些新契约具有意料之外的效果。
有了继续的选项,你可以使用契约违反处理程序去记录日志并继续运行。这样的
话,你既可以在单次运行中检测到多次违规,也可以让契约在假定正确的老代码
中启用。人们相信这是逐步采用契约的关键。
我们并没有找到充足的理由去添加类不变量,或允许在覆盖函数中削弱前置条
件,或允许在覆盖函数中增强后置条件。要点是简单。理想情况是先为 C++20 提
供一个最小的初始设计,然后如有需要再在之上添砖加瓦。
这个设计由 J. Daniel Garcia 实现,并于 2018 年 6 月投票通过进入 C++ 委员会的
C++20 的工作文件中。像往常一样,虽然规范还有一些问题,但我们相信能够赶
在最终标准发布前的两年内修复所有的问题。例如,人们发现工作文件文本中允
许编译器基于所有契约(无论检查与否)进行优化。那并非有意而为之。从所有
的契约在正确的程序中都有效的角度看,这是合理的,但是这么做,对于那些带
有特别为捕获“不可能的错误”而写的契约的程序来说却是灾难性的。考虑下面
的例子:
[[assert: p!=nullptr]]
p->m = 7;
163
9. C++20:方向之争
假如 p==nullptr,那么 p->m 将是未定义行为。编译器被允许假设未定义行为不
会发生;由此编译器优化掉那些导致未定义行为的代码。这样做的结果可能让人
大吃一惊。在这样的情况下,如果违反契约之后程序能够继续执行,编译器将被
允 许 假 定 p->m 是 有 效 的 , 因 此 p!=nullptr ; 然 后 编 译 器 会 消 除 契 约 关 于
p==nullptr 的检查。这种被称为“时间旅行优化”的做法当然是与契约的初衷大
相 径 庭 , 还 好 若 干 补 救 方 案 被 及 时 提 出 [Garcia 2018; Stroustrup 2019c;
Voutilainen 2019a]。
2018 年 8 月,在 C++20 新提案的最后期限过后,由 John Lakos 领导的 Bloomberg
的一个小组,包括 Hyman Rosen 和 Joshua Berne 在内,提出了一系列重新设计的
提案 [Berne et al. 2018; Berne and Lakos 2018a,b; Lakos 2018]。特性冻结的日期
(审议新提案的最后一天)是由委员会全体投票表决确定的。这些提案则是基于
在契约自身中规定契约行为的方案。例如, [[assert check_maybe_continue:
x>0]] 和 [[assert assume: p!=nullptr]]。
与其使用构建模式去控制所有契约(比如,激活所有默认契约或关闭所有基于契
约的运行期检查)的行为,你不如直接修改单个契约的代码。在这方面,这些新
方案与工作文件中决议通过的设计大相径庭。考虑下面的例子:
[[assert assume: p!=nullptr]]
这将使得 2014 年被否决的基于宏的方案卷土重来,因为管理代码变化的显然方式
是用宏,例如:
[[assert MODE1: p!=nullptr]]
这里的 MODE1 可以被 #define 成所支持的若干选项之一,如 assume 和 default。
或者,大致等效地,通过命令行上的参数(类似于命令行宏)来定义诸如 assume
之类的限定符的含义。
本质上,契约违约后继续执行的可能性与程序员对契约含义的控制的两者的结
合,将把契约机制从断言系统转变为一种新的控制流机制。
一些提案甚至建议放弃对静态分析的支持。类似这样的提案有几十个变种,全都
来得太晚,没一个能增进共识。
大量涌入的新奇提案(来自 Bloomberg 团队和其他团队,比如,[Berne 2019;
Berne and Lakos 2019; Khlebnikov and Lakos 2019; Lakos 2019; Rosen et al. 2019])
和成百上千讨论这些提案的电子邮件阻碍了真正必需的讨论,即对工作文件中的
设计现状进行问题修复。正如我曾不断警告的那样(比如 [Stroustrup 2019c]),
这些企图重新设计契约的提案的结果是,在 Nico Josuttis 的提议下,契约被从
C++20 中移除 [Josuttis et al. 2019b]。我认为去年关于契约的讨论是一个典型的例
164
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
子,谁都得不到任何东西,因为有人只想要按他们的方式来。时间会给出答案,
是否新的研究组 SG21 能够为 C++23 或 C++26 交付某种能够被更广泛接受的东
西。
9.6.2 静态反射
2013 年,一个研究“反射”的研究组(SG7)成立了,并发出了征集意见的呼吁
[Snyder and Carruth 2013]。有一个广泛的共识,那就是 C++ 需要静态反射机制。
更确切地说,我们需要一种方法来写出能检查它自己是属于哪个程序的一部分的
代码,并基于此往该程序中注入代码。那样,我们就可以使用简洁的代码替换冗
长而棘手的样板代码、宏和语言外的生成器。比如,我们可以为下面的场景自动
生 成 函 数 , 如 :I/O 流 、 日 志 记 录 、 比 较 、 用 于 存 储 和 网 络 的 封 送 处 理
(marshaling)、构造和使用对象映射、枚举的“字符串化”、测试支持,及其他
的更多可能 [Chochlí́k et al. 2017; Stroustrup 2018g]。反射研究组的目标是为
C++20 或 C++23 做好准备;我们认为 C++17 并不是一个现实的目标。
大家普遍认同,依赖在运行期遍历一个始终存在的数据结构的反射/内省方式不适
合 C++,因为这种数据的大小、语言构造的完整表示的复杂性和运行期遍历的成
本都会是问题。
很快出现了一些提案 [Chochlí́k 2014; Silva and Auresco 2014; Tomazos and Spertus
2014],并且,在接下来的数年里,由 Chandler Carruth 主持的研究组召开了多次
会议试图决定其范围和方向。选定的方式基于类型,这些类型以经典的面向对象
的 类 层 次 结 构 来 组 织 , 需 要 泛 型 的 地 方 由 概 念 (§6) 支 持 [Chochlí́k 2015;
Chochlí́k and Naumann 2016; Chochlí́k et al. 2017]。 该 方 式 主 要 由 Mató s
Chochlí́k、Axel Naumann 和 David Sankel 发展和实现。结果作为一项技术规范在
2019 得以批准 [Sankel 2018]。
在静态反射(预期的)长时间的酝酿期内,基于 constexpr 函数(§9.3.3)的编译
期计算稳步发展,最终出现了基于函数而不是类层次结构的静态反射的提案。主
要的拥护者是 Andrew Sutton、Daveed Vandevoorde、Herb Sutter 和 Faisal Vali
[Sutton and Sutter 2018; Sutton et al. 2018]。设计焦点转移的主要论据,一部分是
由于分析和生成代码这些事天生就是函数式的,而且基于 constexpr 函数的编译期
计算已经发展到元编程和反射相结合的地步。这种方法的另一个优点(最先由
Daveed Vandevoorde 提出)是从编译器的内部的数据结构看,为函数服务的天生
就比为类型层次结构服务的更小、生命周期更短暂,因此它们使用的内存明显更
少,编译速度也明显更快。
165
9. C++20:方向之争
2019 年 2 月在科隆召开的标准会议上,David Sankel 和 Michael Park 展示了一个
结合了这两个方法优点的设计 [Sankel and Vandevoorde 2019]。在最根本的层面上
仅有一个单一的类型存在。这达到了最大的灵活性,并且编译器开销也最小。
最重要的是,静态类型的接口可以通过一种类型安全的转换来实现(从底层的单
一类型 meta::info 到更具体的类型,如 meta::type_ 和 meta::class_)。这里有
一个基于 [Sankel and Vandevoorde 2019] 的例子。通过概念重载(§6.3.2),它实
现了从 meta::info 到更具体类型的转换。考虑下面的例子:
namespace meta {
consteval std::span<type_> get_member_types(class_ c) const;
}
struct baz {
enum E { /*...*/ };
class Buz{ /*...*/ };
using Biz = int;
};
void print(meta::enum_);
void print(meta::class_);
void print(meta::type_);
// 打印一个枚举类型
// 打印一个类类型
// 打印任何类型
void f()
{
constexpr meta::class_ metaBaz = reflexpr(baz);
template for (constexpr meta::type_ member : get_member_types(metaBaz))
print(meta::most_derived(member));
}
这里关键的新语言特性是 reflexpr 运算符,它返回一个(元)对象,该对象描述
了它的参数,还有 template for [Sutton et al. 2019],根据一个异质结构中的元素
的类型扩展每个元素,从而遍历该结构的各元素。
此外,我们也有机制可以将代码注入正在编译的程序中。
类似这样的东西很可能会在 C++23 或 C++26 中成为标准。
作为一个副作用,在反射方案上的雄心勃勃的工作也刺激了编译期求值功能的改
进:
•
•
•
标准中的类型特征集(§4.5.1)
源代码位置的宏(如 __FILE__ 和 __LINE__)被内在机制所替代 [Douglas
and Jabot 2019]
编译期计算的功能(例如,用于确保编译期求值的 consteval)
166
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
•
展开语句(template for——到 C++23 就可以用来遍历元组中的元素
[Sutton et al. 2019])
。
167
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
10. 2020 年的 C++
本节我们来查看一下在二十一世纪的第二个十年里,C++ 如何被使用,以及用来
做什么:
•
•
•
•
•
•
§10.1:C++ 用来做什么?
§10.2:C++ 社区
§10.3:教育和科研
§10.4:工具
§10.5:编程风格
§10.6:核心指南
C++ 的使用领域绝大部分与 2006 年相同(§2.3)。虽然有一些新的领域,但在大
多数情况下,我们看到的 C++ 还是在相同或类似的领域中被更加广泛和深入地使
用。C++ 没有突然成为一种面向 Web 应用开发的语言,虽然即使在那种场景下仍
有人用 C++ [Obiltschnig et al. 2005]。对于大多数程序员来说,C++ 依然是某种隐
没在后台的东西,稳定、可靠、可移植、高性能。最终用户是看不见 C++ 的。
编程风格则有更加巨大的变化。比起 C++98,C++11 是门好得多的语言。它更易
于使用,表达能力更强,性能还更高。2020 年发布的 C++20 则在 C++11 的基础上
做出了类似程度的改进。
10.1 C++ 用来做什么?
大致而言,C++ 可谓无处不在、无所不用。但是,大象无形,大多数 C++ 的使用
并不可见,被深深隐藏在重要系统的基础设施内部。
C++ 被用在哪里,是如何被使用的,没人能够完整了解。2015 年,捷克公司
JetBrains 委托进行了一项研究 [Kazakova 2015],结果显示在北美、欧洲、中东以
及亚太地区 C++ 被大量使用,在南美也有一些使用。“在南美的一些使用”就有
40 万开发者,而 C++ 开发者的总人数则达到了 440 万。使用 C++ 的行业有(按顺
序)金融、银行、游戏、前台、电信、电子、投资银行、营销、制造和零售。所
有迹象表明,自 2015 年以来,C++ 的用户数量和使用领域一直在稳步增长。
在这里,我将对 2006 到 2020 年期间内 C++ 的应用领域给出一个可能有些个人化
的、印象派的、非常不完整的概览:
•
工业界:电信(例如 AT&T、爱立信、华为和西门子)、移动设备(基本上
是所有,信号处理、屏幕渲染、对性能或可移植性有重大要求的应用)、
微电子(例如 AMD、英特尔、Mentor Graphics 和英伟达)
、金融(例如摩
169
10. 2020 年的 C++
•
•
•
•
•
•
•
•
•
•
根士丹利和文艺复兴)、游戏(几乎所有)、图形和动画(例如 Maya、迪
士 尼 和 SideFx)、 区 块 链 实 现 ( 例 如 Ripple)、 数 据 库 ( 例 如 SAP、
Mongo、MySQL 和 Oracle)
、云(例如谷歌、微软、IBM 和 Amazon)、人
工智能和机器学习(例如 TensorFlow 库)、运营支持(例如 Maersk 和
AT&T)
。
科学:航空航天(例如 Space X、火星漫游者、猎户座载人飞行器、詹姆
斯·韦伯太空望远镜)、高能物理(例如 CERN 欧洲核子研究中心、SLAC
国家加速器实验室、费米实验室)
、生物学(遗传学、基因组测序)、超大
规模计算。
教学:全球大多数工程院校。
软件开发:TensorFlow、工具、库、编译器、Emscripten(从 C++ 生成
asm.js 和 WebAssembly)
、运行期代码生成、LLVM(许多新语言的后台支
柱,也大量用于工具构建中)、XML 和 JSON 解析器、异构计算(例如
SYCL [Khronos Group 2014–2020] 和 HPX [Stellar Group 2014–2020])
。
Web 基础设施:浏览器(Chrome、Edge、FireFox 和 Safari)、JavaScript
引擎(V8 和 SpiderMonkey)、Java 虚拟机(HotSpot 和 J9)、谷歌和类似
组织(搜索、map-reduce 和文件系统)。
主 要 Web 应 用 : 阿 里 巴 巴 、Amadeus( 机 票 )、Amazon、 苹 果 、
Facebook、PayPal、腾讯(微信)
、Yandex。
工程应用:达索(CAD/CAM)
、洛克希德·马丁(飞机)。
汽车:辅助驾驶 [ADAS Wikipedia 2020; Mobileye 2020; NVIDIA 2020]、软
件架构 [Autosar 2020; Autosar Wikipedia 2020]、机器视觉 [OpenCV 2020;
OpenCV Wikipedia 2020]、宝马、通用、梅赛德斯、特斯拉、丰田、沃尔
沃、大众、Waymo(谷歌自动驾驶汽车)
。
嵌入式系统:智能手表和健康监控器(例如佳明)、相机和视频设备(例
如奥林巴斯和佳能)、导航辅助设备(例如 TomTom)、咖啡机(例如
Nespresso)、农场动物监控器(例如 Big Dutchman)、生产线温度控制
(例如嘉士伯)
。
安全:卡巴斯基、美国国家安全局、赛门铁克。
医疗和生物学:医学监测和成像(例如西门子、通用电气、东芝和飞利
浦)、断层扫描(例如 CT)、基因组分析、生物信息学、放射肿瘤学(例
如 Elekta 和 Varian)
。
虽然这只是冰山一角,但它展示了 C++ 使用的广度和深度。大多数 C++ 的使用对
其(间接)用户不可见。某些对 C++ 的使用早于 2006 年,但也有很多是之后才开
始的。没有一个主要现代系统只用单一语言写就,但是 C++ 在所有这里提到的应
用场合中发挥了重要作用。
170
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
我们常常忘记那些平凡的却在我们的生活中起着重要作用的应用。没错,C++ 可
以帮助运行美国国家航空航天局的深空网络,但也可以在人们日常熟悉的小设备
中运行,例如咖啡机、立体声扬声器和洗碗机。让我惊讶的是,C++ 竟然也被应
用于运转现代养猪场的先进系统中。
10.2 C++ 社区
与 2006 年相比,2020 年的 C++ 社区更加壮大,不断蓬勃发展、积极向上、富有
成效,并且急切地想看到未来的进一步改进。
与大多数编程语言社区相比,C++ 社区一向是出奇地无组织和分散。这个问题早
已有之,因为我就没有建立组织的才能。当时我的雇主 AT&T 贝尔实验室并不想
建立一个 C++ 社区,但是似乎其他所有人都非常感兴趣,并且愿意花钱来建立他
们的用户群。最终的结果是,许多公司,例如苹果、Borland、GNU、IBM、微软
和 Zortech 都建立了以其客户为中心的 C++ 社区,但是却没有总体的 C++ 社区,
社区没有中心。有杂志,读的人不多(相对于 C++ 社区的规模)。虽然有会议,但
它们倾向于被一般的“面向对象”的会议或“软件开发”的会议所吸收或者就演
变成了那些一般性会议。没有总体的 C++ 用户组。
如今,世界上有数十个本地、国家和国际 C++ 用户组,这些用户组之间也经常进
行一些合作。除此之外,还有数十个 C++ 会议,每个会议都有数百人参加:
•
•
•
•
•
•
•
C++ 基金会——成立于 2014 年,是一家非盈利性组织,旨在推广 ISO C++
(而不是任何特定供应商的 C++)
,它主办 CppCon 年度会议。
Boost——成立于 1999 年,它是一组经过同行评审的库、以及建造使用它
们的社区。Boost 举行年度会议。
Meeting C++——成立于 2012 年,是一个非常活跃的用户团体网络,定期
举行会议(最初在德国活跃)
。在不同地方有数十个 Meeting C++ 的会议和
聚会。
ACCU——成立于 1984 年,最初作为 C 用户组而建立,是所有现存 C++ 组
织中的爷爷辈了;它出版两本杂志,并举行年度会议(主要在英国活
跃)
。
isocpp.org——C++ 基金会的网站,其中包含与 C++ 有关的新闻,标准化
进程相关的信息,以及有用的链接。
cppreference.com——一个出色的在线参考资料;它甚至有一个历史部
分!
会议——CppCon、ACCU、Meeting++、C++ Now(以前称为 BoostCon)、
Qt、NDC、std::cpp 的会议,以及在波兰、俄罗斯、中国、以色列和其他
地方的一些会议。此外,很多通用软件会议上也在越来越多的安排 C++ 专
题。
171
10. 2020 年的 C++
•
•
•
博客——有许多,播客也是。
视频——视频已成为有关 C++ 的最新进展的主要信息来源。主要的 C++ 会
议通常会录制演讲视频并将其发布以供免费访问(例如 CppCon、C++Now
和 Meeting++)。视频采访已变得很流行。最多最受欢迎的托管网站是
YouTube,但不幸的是,YouTube 在有些拥有大型 C++ 开发者群体的国家
(例如中国)被封了。
GitHub——使共享代码和组织联合项目开发变得更加容易。
跟某些语言和供应商的集中组织相比,这还差得很远。但是,这些 C++ 社区和组
织富有活力,彼此保持联系,并且比在 2006 年的时候活跃得多。此外,一些企业
的用户组和会议也仍然活跃。
10.3 教育和研究
从 2006 年不太理想的状态(§2.3)到现在,C++ 的教育是否得到了改善?也许
吧,但是对于 C++ 来说,教育仍然不是强项,大多数教育还都集中在为业内人士
提供信息和培训上。在大多数国家/地区,很多大学毕业生对 C++ 语言及使用它的
关键技术只能算一知半解。对于 C++ 社区来说,这是一个严重的问题。因为,对
于一门语言来说,如果没有热情洋溢的程序员们源源不断、前赴后继地精通其关
键设计和实现技术,那它是无法在工业规模上取得成功的。假如更多使用 C++ 的
开发者知道如何更好地使用它,那他们就能做太多太多的事来改进软件!如果毕
业生带着更准确的 C++ 视角进入工作岗位,那么太多太多的事情会变得容易得
多!
C++ 教学所面临的一个问题是教育机构经常将编程视为低级技能,而不是基础课
目。好的软件对我们的文明至关重要。为了把控软件,我们需要像对待数学和物
理学一样,严肃认真地对待关键系统的软件开发。那种削足适履的方式对于教育
和软件开发是行不通的。一个学期的教学也远远不够。我们永远都不会期望在教
了短短几个月英语之后,学生就会懂得欣赏莎士比亚。同样,了解语言的基本机
制与精通内行所使用的惯用法和技巧之间是有差距的。就像任何主要的现代编程
语言一样,教授 C++ 也需要根据学生的背景和需求相应地调整教学方法。即使教
育机构意识到了这些问题并愿意做出一些弥补,奈何学生已经课满为患,教师也
很难保持不跟工业实践脱节。SG20(教育)正试图总结教授和使用现代 C++ 的方
法来提供一些帮助。SG15(工具)则可能提供更多支持教学的工具,从而越来越
多地发挥重要作用。
从 C++11 开始,我们对此有了越来越多的认识。例如,Kate Gregory 制作了一些
很棒的视频,介绍了如何教授 C++ [Gregory 2015, 2017, 2018]。最近的一些书籍
认识到在支持教育方面,不同的受众存在不同的需求,并试图迎头解决这些问
题:
172
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
•
•
•
《C++ 程序设计原理与实践》
(Programming: Principles and Practice Using
C++)[Stroustrup 2008a]——这是一本针对刚入门的大学生和自学人士的
教科书。
《C++ 语言导学》
(A Tour of C++)[Stroustrup 2014d,2018f]——针对经
验丰富的程序员的简短概述(200 页)
。
《发现现代 C++》(Discovering Modern C++)[Gottschling 2015]——这是
一本专门为数学背景较强的学生准备的书。
我 也 写 了 一 些 半 学 术 性 质 的 论 文 (Software Development for Infrastructure
[Stroustrup 2012] 和 What should we teach software developers? Why? [Stroustrup
2010b]),并在 CppCon 2017 开幕式上作了关于 C++ 教育的主题演讲(Learning
and Teaching Modern C++ [Stroustrup 2017c])。
自 2014 年左右以来,视频和在线课程的使用急剧增加。这对 C++ 的教学来说很有
帮助,因为这样就不需要一个中心组织或大量资金的支持。
以下列出了从 2006 到 2020 年间,与 C++ 语言相关的学术研究成果:
•
•
•
•
•
•
•
•
•
概念:泛型编程 [Dehnert and Stepanov 2000]、C++0x 概念 [Gregor et al.
2006]、使用模式 [Dos Reis and Stroustrup 2006]、库设计 [Sutton and
Stroustrup 2011]。
理论与形式化体系:继承模型 [Wasserrab et al. 2006]、模板和重载 [Dos
Reis and Stroustrup 2005a]、模板语义 [Siek and Taha 2006]、对象布局
[Ramananandro et al. 2011]、构造和析构 [Ramananandro et al. 2012]、用
于代码处理的表示形式 [Dos Reis and Stroustrup 2009,2011]、资源模型
[Stroustrup et al. 2015]。
动态查找:快速动态类型转换 [Gibbs and Stroustrup 2006]、模式匹配
[Solodkyyet et al. 2012]、多重方法 [Pirkelbauer et al. 2010]。
静态分析:可靠的表示法 [Yang et al. 2012]、实践经验 [Bessey 2010]。
性能:代码膨胀 [Bourdev and Jarvi 2006,2011]、异常实现 [Renwicket et
al. 2019]。
语言比较:泛型编程 [Garcia et al. 2007]。
并发和并行编程:内存模型 [Batty et al. 2013,2012,2011]、HPX(一个
适用于任何规模的并行和分布式应用程序的通用 C++ 运行时系统 [Kaiser
et al. 2009Sept])、STAPL( 自 适 应 泛 型 并 行 C++ 库 [Zandifar et al.
2014])
、TBB(英特尔的任务并行库 [Reinders 2007])
。
协程:数据库优化 [Jonathan et al. 2018; Psaropoulos et al. 2017]。
软件工程:代码的组织和优化 [Garcia and Stroustrup 2015]、常量表达式
求值 [Dos Reis and Stroustrup 2010]
173
10. 2020 年的 C++
看起来还有更多的关于 C++ 的学术研究机会,关于语言的特性和技巧(例如,异
常处理、编译期编程和资源管理),以及其使用的有效性(例如,静态分析或基于
真实世界代码和经验的研究)
。
C++ 社区中最活跃的成员中很少有人会考虑撰写学术论文,写书似乎更受欢迎
(例如,[Čukić 2018; Gottschling 2015; Meyers 2014; Stepanov and McJones 2009;
Vandevoorde et al. 2018; Williams 2018])
。
10.4 工具
与其他语言相比,在 1990 年代初期到中期,C++ 在用于工业用途的工具和编程环
境方面做得相当不错。例如,图形用户界面和集成软件开发环境都率先应用于
C++。后来,开发和投资的重点转移到专属语言,例如 Java(Sun)、C#(微软)
和 Objective-C(苹果)以及更简单的语言,例如 C(GNU)
。
在我看来,有两个主要原因:
•
•
资金:组织倾向于使用他们可以控制的语言和工具,从而提供比竞争对手
更大的差异化优势。从这个角度来看,C++ 由正式的标准委员会控制、强
调所有人的利益,这反倒成了一个缺点——某种公地悲剧的变体。
宏和文本定义:C++ 没有一个简单,可广泛使用的内部表示形式来简化基
于源代码的工具构建,并且大量使用宏必然导致程序员看到的跟编译器所
分析的有所不同。和 C 一样,C++ 是根据字符序列来定义的,而非根据直
接表示抽象且更易于操作的构造来定义。我与 Gabriel Dos Reis 一起定义了
这样一个表示形式 [Dos Reis and Stroustrup 2009, 2011],但事实证明 C++
社区中面向字符的传统难以克服。当初建造时没有意识到的规范化结构,
想通过翻新加上去就难了。
因此,在 2006–2020 年期间,与其他语言相比,C++ 被支持工具方面的问题严重
困扰。但是,随着以下这些工具的涌现,这种情况得到了稍许改善:
•
•
工业级的集成软件开发环境:例如微软的 Visual Studio [Microsoft 2020;
VStudio Wikipedia 2020] 和 JetBrains 的 CLion [CLion Wikipedia 2020;
JetBrains 2020]。这些环境不仅支持编辑和调试,还支持各种形式的分析
和简单的代码转换。
在 线 编 译 器 : 例 如 Compiler Explorer [Godbolt 2016] 和 Wandbox
[Wandbox 2016–2020]。这些系统允许从任何浏览器中编译 C++ 程序,有
时甚至可以执行。它们可用于实验,检查代码质量,还有比较不同的编译
器及编译器和库的不同版本。
174
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
•
•
•
•
•
•
•
GUI 库和工具:例如 Qt [Qt 1991–2020]、GTKmm [GTKmm 2005–2020] 和
wxWidgets [wxWidgets 1992–2020]。不幸的是,Qt 依赖于元对象协议
(meta-object protocol,缩写为 MOP),因此 Qt 程序还不是标准的 ISO
C++ 应用。静态反射(§9.6.2)使我们最终能够解决这个问题。C++ 社区
的问题不是没有好的 GUI 库,而是太多了,因此会有选择困难。
分析器:例如 Coverity [Coverity 2002–2020],Visual Studio 的 C++ Core
Guidelines 分析器(§10.6)和 Clang Tidy [Clang Tidy 2007–2020]。
编译器工具支持:例如 LLVM 编译器后端基础设施,可简化代码生成和代
码分析 [LLVM 2003–2020]。除了 C++ 本身,这为许多新语言提供了福
利。
构 建 系 统 : 例 如 build2 [Build2 2014–2020] 和 CMake [CMake 2000–
2020],以及 GNUmake[GNUmake 2006–2020]。同样,在没有标准的情况
下,选择会有困难。
包管理器:例如 Conan [Conan 2016–2020] 和 vcpkg [vcpkg 2016–2020]。
运行时环境:例如 WebAssembly:将 ISO C++ 编译为字节码以在浏览器中
部署的系统 [WebAssembly 2017–2020]。
运行时编译、JIT 和链接:例如 Cling [Cling 2014–2020; Naumann 2012;
Naumann et al. 2010] 和 RC++ [RC++ 2010–2020]。
上面列出的只是一些示例。像往常一样,C++ 用户面临的问题是可选方案的数量
众多,例如:[RC++ 2010–2020] 列出了 26 个用于在编译时生成代码的系统,并
且有数十个程序包管理器。因此,我们需要的是某种形式的标准化。
截至 2020 年,工具仍不是 C++ 的强项,但我们正在大范围内取得进展。
10.5 编程风格
针对大多数现实问题的最佳解决方案需要组合使用多种技术,这也是 C++ 演进的
主要动力。自然地,这让那些声称拥有单个简单最佳解决方案(“编程范式”)的
人感到不爽,但是支持多种风格一直是 C++ 的根本优势。考虑一下“绘制所有形
状”的例子,这个例子自 Simula 发展早期(绘图设备为湿墨绘图仪)以来就一直
用于说明面向对象编程。用 C++20,我们可以这样写:
void draw_all(range auto& seq)
{
for (Shape& s : seq)
s.draw();
}
该段代码是什么编程范式?
175
10. 2020 年的 C++
•
•
•
显然,它是面向对象编程:使用了虚函数和类层次结构。
显然是泛型编程:使用了模板(通过使用 range 概念进行参数化,我们得
到了一个模板)
。
显然,这是普通的命令式编程:使用了 for 循环,并按照常规 f(x) 语法
定义了一个将要被调用的函数。
对这个例子我可以进一步展开:Shape 通常具有可变状态;我可以使用 lambda 表
达式,也可以调用 C 函数;我可以用 Drawable 的概念对参数进行更多约束。对于
各种“更好”的定义,适当的技术组合比我能想到的任何一种单一范式所能提供
的解决方案更好。
C++ 支持多种编程风格(如您坚持,也可以称为“范式”),其背后的想法并不是
要让我们选择一种最喜欢的样式进行编程,而是可以将多种风格组合使用,以表
达比单一风格更好的解决方案。
10.5.1 泛型编程
在 2006 年,许多 C++ 代码仍然是面向对象的风格和 C 风格编程的混合体。自然而
然的,到 2020 年仍然有很多类似这样的代码。但是,随着 C++98 的到来,STL 风
格的泛型编程(通常称为 GP)变得广为人知,并且用户代码也逐渐开始使用
GP,而不只是简单地使用标准库。C++11 中对 GP 的更好支持为在生产代码中更广
泛的使用 GP 提供了极大的便利。但是,C++17 中缺少概念(§6),这仍然阻碍了
C++ 中泛型编程的使用。
基 本 上 , 所 有 专 家 都 阅 读 过 Alex Stepanov 的 《 编 程 原 本 》(Elements of
Programming,通常称为 EoP)[Stepanov and McJones 2009],并受到其影响。
基 于 模 板 的 泛 型 编 程 是 C++ 标 准 库 的 支 柱 : 容 器 、 范 围 (§9.3.5)、 算 法 、
iostream、 文 件 系 统 (§8.6)、 随 机 数 (§4.6)、 线 程 (§4.1.2)(§9.4)、 锁
(§4.1.2)
(§8.4)
、时间(§4.6)
(§9.3.6)、字符串、正则表达式(§4.6)和格式化
(§9.3.7)
。
10.5.2 元编程
C++ 中的元编程出自泛型编程,因为两者都依赖于模板。它的起源可以追溯到
C++ 模板的早期,当时人们发现模板是图灵完备的 [Vandevoorde and Josuttis
2002; Veldhuizen 2003],并以某种有用的形式提供编译期纯函数式编程。
模板元编程(通常称为 TMP)往往非常丑。有时,这种丑陋通过使用宏来掩盖,
从而造成了其他问题。TMP 几乎无处不在,这也证明了它确实有用。例如,如果
没有元编程,就无法实现 C++14 标准库。许多技巧和实验在 2006 年前就有了,
176
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
但是 C++11 具有更好的编译器、变参模板(§4.3.2)和 lambda 表达式(§4.3.1)
,
这些推动了 TMP 成为主流用法。C++ 标准库还增加了更多元编程的支持,比如:
编 译 期 选 择 模 板 conditional , 允 许 代 码 依 赖 于类 型 属 性 的 类 型 特 征 (type
trait)如“能否安全地按位复制类型 X?”(§4.5.1),还有 enable_if(§4.5.1)。
举例来说:
conditional<(sizeof(int)<4),double,int>::type x; // 如果 int 小,就用 double
计算类型以精确地反映需求,这可以说是 TMP 的本质。我们还可以计算值:
template <unsigned n>
struct fac {
enum { val = n * fac<n-1>::val };
};
template <>
struct fac<0> {
// 0 的特化:fac<0> 为 1
enum { val = 1 };
};
constexpr int fac7 = fac<7>::val;
// 5040
注意,模板特化在其中起着关键作用,这一点在大多数 TMP 中是必不可少的。它
已用于计算复杂得多的数值,也可以表示控制流(例如,在编译期计算决策表,
进行循环展开,等等)
。在 C++98 [Stroustrup 2007] 中,模板特化是一个很大程度
上没有得到足够重视的特性。
在设计精巧的库中以及在现实世界的代码中,诸如 enable_if 之类的原语已成为
数百甚至数千行的程序的基础。TMP 的早期示例包含一个完整的编译期 Lisp 解释
器 [Czarnecki and Eisenecker 2000]。此类代码极难调试,而维护它们更是可怕的
差事。我见识过这样的情形,几百行基于 TMP 的代码(不得不承认非常聪明),
在一台 30G 内存的计算机上编译需要好几分钟的时间,由于内存不足而导致最终
编译失败。即使是简单的错误,编译器的错误信息也可以达到几千行。然而,
TMP 仍被广泛使用。理智的程序员发现,尽管 TMP 有着各种问题,仍比起其他方
案要好。我见过 TMP 生成的代码比我认为一个合格的人类程序员会手写的汇编代
码要更好。
因此,问题变成了如何更好地满足这种需求。当人们开始把像 fac<> 这样的代码
视为正常时,我为此而感到担心。这不是表达普通数值算法的好方法。 概念
(§6)和编译期求值函数( constexpr (§4.2.7))可以大大简化元编程。举例来
说:
constexpr int fac(int n)
{
177
10. 2020 年的 C++
int r = 1;
while (n>1) r*=n--;
return r;
};
constexpr int fac7 = fac(7);
// 5040
这个例子说明,当我们需要一个值时,函数是最佳的计算方式,即使——尤其
——在编译期。传统模板元编程最好只保留用于计算新的类型和控制结构。
Jaakko Jarvi 的 Boost.Lambda [Jarvi and Powell 2002; Jarvi et al. 2003a] 是 TMP 的
早期使用案例,它帮助说服了人们 lambda 表达式是有用的,并且他们需要直接的
语言支持。
Boost 元编程库 Boost.MPL [Gurtovoy and Abrahams 2002–2020] 展示了传统 TMP
的最好和最坏的方面。更现代的库 Boost.Hana [Boost Hana 2015–2020] 使用
constexpr 函数。WG21 的 SG7(§3.2)试图开发一种更好的标准元编程系统,其
中还包括编译期反射(§9.6.2)
。
10.6 编码指南
我对 C++ 语言的最终目标是:
•
•
•
•
•
•
•
使用和学习上都要比 C 或当前的 C++ 容易得多
完全类型安全——没有隐式类型违规,没有悬空指针
完全资源安全——没有泄漏,不需要垃圾收集器
为其构建工具要相对简单——不要有宏
跟当前 C++ 一样快或更快——零开销原则
性能可预测——适用于嵌入式系统
表达力不亚于当前的 C++——很好地处理硬件
这和《C++ 语言的设计和演化》[Stroustrup 1994] 及更早版本中阐述的设计目标
并没有太多不同。显然,这是一项艰巨的任务,并且与较旧的 C 和 C++ 的多数用
法不兼容。
最早,在 C++ 还是“带类的 C”的时候,人们就建议创建语言的安全子集,并使
用编译器开关来强制执行这种安全性。但是,由于许多原因中的某一个原因,这
些建议失败了:
•
•
没有足够的人在“安全”的定义上达成一致。
不安全特性(对每种“不安全”的定义来说)是构建基本安全抽象的基
础。
178
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
•
•
安全子集的表达能力不足。
安全子集效率低下。
第二个原因意味着,你不能仅仅通过禁止不安全的功能来定义一个安全的 C++。
“通过限制以达到完美”这个方法,对于编程语言的设计来说,在极其有限的场
合下才能发挥作用。你需要考虑那些一般来说不安全但有安全用途的特性的使用
场景和特征。此外,该标准不能放弃向后兼容(§1.1),所以我们需要一种不同的
方法。
从一开始,C++ 就采用了不同的哲学 [Stroustrup 1994]:
让良好的编程成为可能比防止错误更重要。
这意味着我们需要“良好使用”的指南,而不是语言规则。但是,为了在工业规
模上有用,指南必须可以通过工具强制执行。例如,从 C 和 C++ 的早期开始,我
们就知道悬空指针存在的问题。例如:
int* p = new int[]{7,9,11,13};
// ...
delete[] p;
// 删除 p 指向的数组
// 现在 p 没有指向有效对象,处于“悬空”状态
// ...
*p = 7;
// 多半会发生灾难
虽然许多程序员已经开发出防止指针悬空的技术。但是,在大多数大型代码库
中,悬空指针仍然是一个主要问题,安全性问题比过去更加关键。一些悬空的指
针可以作为安全漏洞被利用。
10.6.1 一般方法
在 2004 年,我帮助制定了一套用于飞行控制软件 [Lockheed Martin Corporation
2005] 的编码指南,这套指南接近于我对安全性、灵活性和性能的构想。2014
年,我开始编写一套编码指南,以解决这一问题,并在更广泛的范围内应用。这
一方面是为了回应对用好 C++11 的实用指南的强烈需求,另外一方面是有人认为
的好的 C++11 让我看着害怕。与人们交谈后,我很快发现了一个明显的事实:我
并不是唯一沿着这样的路线思考和工作的人。因此,一些经验丰富的 C++ 程序
员、工具制作者和库构建者齐心协力,与来自 C++ 社区的众多参与者一起启动了
C++ 核心指南项目 [Stroustrup and Sutter 2014–2020]。该项目是开源的(MIT 许
可证)
,贡献者列表可以在 GitHub 上找到。早期,来自摩根士丹利(主要是我)
、
微软(主要是 Herb Sutter、Gabriel Dos Reis 和 Neil Macintosh)
、Red Hat(主要是
Jonathan Wakely)
、CERN、Facebook 和谷歌的贡献者都做出了突出贡献。
179
10. 2020 年的 C++
核心指南绝不是唯一的 C++ 编码指南项目,但却是最突出、最雄心勃勃的。它们
的 目 标 明 确 而 清 晰 , 那 就 是 显 著 提 升 C++ 代 码 的 质 量 。 例 如 , 早 在 Bjarne
Stroustrup、Herb Sutter 和 Gabriel Dos Reis 的论文中 [Stroustrup et al. 2015] 就阐
明了关于完全类型和资源安全的理想和基础模型。
为了实现这些雄心勃勃的目标,我们采用了一种“鸡尾酒式”的混合方法:
•
•
•
规则:一套庞大的规则集,意图在 C++ 里实现使用上的类型安全和资源安
全,推荐那些已知的有效实践,并禁止已知的错误和低效的来源。
基础库:一组库组件,使程序员可以有效的编写低层次程序,而无需使用
已知的容易出错的功能,并且从总体上为编程提供更高层次的基础。大多
数组件来自标准库,其中一些来自以 ISO 标准 C++ 编写的小型指南支持库
(Guidelines Support Library,GSL)
。
静态分析:检测违规行为、并强制执行指南关键部分的工具。
这些方法中的每一种都有很长的历史,但是每一项都无法单独在工业规模上解决
这些问题。例如,我是静态分析的忠实拥护者,但是如果程序员使用动态链接的
方式在一个单独编译的程序中编写任意复杂的代码,那么我最感兴趣的分析算法
(例如,消除悬空指针)是不能求解成功的。这里的“不能”是指“一般说来,
理论上是不可能的”
,以及“对于工业规模的程序而言在计算上过于昂贵”。
基 本 方 式 不 是 简 单 的 限 制 , 而 是 我 称 之 为 “ 超 集 的 子 集 ” 或 SELL 的 方 法
[Stroustrup 2005]:
•
•
首先,通过库功能对语言进行扩展,从而为正确的使用语言奠定坚实的基
础。
然后,通过删除不安全、易出错及开销过高的功能来设置子集。
对于库,我们主要依赖标准库的各个部分,例如 variant(§8.3)和 vector。小
型指南支持库(GSL)提供了类型安全的访问支持,例如 span 可以提供在给定类
型的连续元素序列上的带范围检查的访问(§9.3.8)。我们的想法是通过将 GSL 吸
收到 ISO 标准库中,从而最终也就不需要它了。例如,span 已被添加到 C++20 标
准库中。当时机成熟时,GSL 中对于契约的微弱支持也应当被合适的契约实现所
替代(§9.6.1)
。
10.6.2 静态分析
为了能规模化,静态分析完全是局部的(一次仅一个函数或一个类)。最难的问题
与对象的生命周期有关。RAII 是必不可少的:我们已经不止一次的看到,手动资
源管理的方法在很多语言中都很容易出错。此外,也有很多现存的程序,以一种
有原则的方式使用指针和迭代器。我们必须接受此类使用方式。要使一个程序安
180
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
全很容易,我们只需禁止一切不安全的功能。然而,保持 C++ 的表现力和性能是
核心指南的目标之一,所以我们不能仅仅通过限制来获得安全。我们的目的是一
个更好的 C++,而不是一个缓慢或被阉割的子集。
通过阐明原则、让那些优秀的做法更加显而易见、以及对已知问题进行机械化检
查,这些指南可以帮助我们把教学的重点放在那些让 C++ 更有效的方面。这些指
南还有助于减轻对语言本身的压力,以适应最新的发展趋势。
对于对象的生命周期,主要有两个要求:
•
•
切勿指向超出范围的对象。
切勿访问无效的对象。
考虑以下“基础模型”论文中的一个例子 [Stroustrup et al. 2015])
:
int glob = 666;
int* f(int* p)
{
int x = 4;
// ...
return &x;
// ...
return &glob ;
// ...
return new int{7};
// ...
return p;
// 局部变量
// 不行,会指向一个被销毁的栈帧
// 可以,指向某个“永远存在”的对象
// 可以(算是可以吧:不悬空,
//
但是把所有者作为 int* 返回了)
// 可以,来自调用者
}
指针指向已知会超过函数生命周期的对象(例如,作为参数被传递到函数中),我
们可以返回它,但对于指向局部资源的指针就不行。在遵循该指南的程序中,我
们可以确保作为参数的指针指向某资源或为 nullptr。
为避免泄漏,上面示例中的“裸 new”操作应当通过使用资源句柄(RAII)或所有
权标注来消除。
如果指针所指向的对象已重新分配,则该指针会变为无效。例如:
vector<int> v = { 1,2,3 };
int* p = &v[2];
v.push_back(4); // v 的元素可能会被重新分配
*p = 5;
// 错误:p 可能已失效
int* q = &v[2];
181
10. 2020 年的 C++
v.clear();
*q = 7;
// v 所有的元素都被删除
// 错误:q 无效
无效检查甚至比检查简单的悬空指针还要困难,因为很难确定哪个函数会移动对
象以及是否将其视为失效(指针 p 仍然指向某个东西,但从概念上讲已经指向了
完全不同的元素)。尚不清楚在没有标注或非本地状态的情况下,静态分析器是否
可以完全处理无效检查。在最初的实现中,每个将对象作为非 const 操作的函数
都被假定为会使指针无效,但这太保守了,导致了太多的误报。最初,关于对象
生命周期检查的详细规范是由 Herb Sutter [Sutter 2019] 编写的,并由他在微软的
同事实现。
范围检查和 nullptr 检查是通过库支持(GSL)完成的。然后使用静态分析来确保
库的使用是一致的。
静态分析设想最早是由 Neil Macintosh 实现的,目前已作为微软 Visual Studio 的一
部分进行发布。有一些检查规则已经成为了 Clang 和 HSR 的 Cevelop(Eclipse 插
件)[Cevelop 2014–2020] 的一部分。一些课程和书籍中都加入了关于这些规则的
介绍(例如 [Stroustrup 2018f])
。
核心指南是为逐步和有选择地采用而设计的。因此,我们看到其中一部分在工业
和教育领域被广泛采用,但很少被完全采用。要想完全采用,良好的工具支持必
不可少。
182
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
11. 回顾
编程语言设计的最终目的,是在程序员交付有用的程序的同时,改进他们的思考
方式和工作方式。尽管有些程序语言被视为“只是实验性的”,但是一旦程序语言
被用于和语言本身无关的实际工作,这门语言的设计者们就应对他们的用户承担
相应的责任。正确、合适、稳定性和足够的性能就成为重要的课题。对 C++ 来
说,这些事情在 1979 年仅用了 6 个月就发生了。C++ 已经茁壮成长了 40 年之
久。为什么能成功?又是如何成功的?
我之前的 HOPL 论文 [Stroustrup 1993, 2007] 以 1991 到 2006 年的观点回答了这
些问题。从那时起发生的变化,除了语言的特性和组件库之外,主要是标准委员
会的作用和影响(§3)
。
这里,我主要考虑:
•
•
•
•
•
§11.1:C++ 模型
§11.2:技术上的成功
§11.3:需要努力的领域
§11.4:教训
§11.5:未来
11.1 C++ 模型
C++ 为高要求的应用而生,并成长为一种重要的编程语言——在某些领域,它甚
至是主导语言。这是在没有认真的商业支持和没有营销的情况下达到的。许多现
代语言拷贝了它的特性和理念。关键的语言技术贡献有:
•
•
•
•
•
•
•
•
静态类型系统,对内置类型和用户定义类型具有同等支持(§2.1)
既有值语义,又有引用语义(§4.2.3)
系统和通用资源管理(RAII)
(§2.2)
支持高效的面向对象编程(§2.1)
支持灵活的和高效的泛型编程(§10.5.1)
支持编译期编程(§4.2.7)
直接使用机器和操作系统资源(§1)
通过库提供并发支持(往往使用内建函数实现)(§4.1)
(§9.4)
相较于目前占主导地位的依靠垃圾收集器和广泛运行期支持的“托管”模式——
典型的如 Java、C#、Python 和 JavaScript(§2.3)等语言——C++ 提供了一种不同
的、对许多应用领域来说更好的软件模式。我所说的“更好”是指更容易编写、
更有可能正确、更可维护、使用更少的内存、耗能更低和更快。
183
11. 回顾
这些贡献的领域是互帮互助的,举例来说:
•
•
•
•
引用语义(例如,指针和智能指针)支持使用值语义(例如,jthread 和
vector)高效地实现高级类型。
对内置类型和用户定义类型的统一规则,简化了泛型编程(内置类型不是
特殊情况)
。
编译期编程使得一系列的抽象技术因为能够有效使用硬件而变得负担得
起。
RAII 允许使用用户定义的类型,而无需采取特定的操作来支持其实现对资
源(包括非内存资源)的使用。
11.2 技术上的成功
C++ 成功的根本原因很简单——它填补了编程领域的的一个重要的“生态位”
:
需要有效使用硬件和管理高复杂性的应用程序
如果你能负担得起“浪费”25% 甚至 99% 的硬件机能,那可供选择的编程语言和
环境就多了。如果你的底层模块需要仅仅千行的底层代码,C 语言或者汇编语言
可以效劳。40 年以来,C++ 的独特“生态位”足以使其社区不断成长。
这里有一个现代(2014 年)的 C++ 总结:
•
•
直接映射到硬件

指令和基本数据类型

最初来自于 C 语言
零开销抽象

带构造和析构函数的类、继承、泛型编程、函数对象

最初来自于 Simula 语言(当时还不是零开销的)
Simula 开创了许多抽象机制和一个灵活的类型系统,但在运行时间和空间成本
上,它们带来了沉重的代价。与 1995 年的 C++(§2.1)描述相比,关注点从编程
技术转向了问题领域。这更多的是解释风格和人们兴趣的不同,而不是语言设计
的不同。这两个总结现在和当时都是准确的。
在过去几十年的基础上,21 世纪的关键技术进步包括:
•
•
内存模型(§4.1.1)
类型安全的并发支持:线程和锁(§4.1.2)、并行算法(§8.5)、合并线程
(§9.4)
184
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
•
•
•
•
•
•
类型推导:auto(§4.2.1)
、概念(§6)、模板参数推导(§8.1)、变参模板
(§4.3.2)
简化使用: auto (§4.2.1)、范围 for (§4.2.2)、并行算法(§8.5)、范围
(§9.3.5)
、lambda 表达式(§4.3.1)
移动语义(§4.2.3)
编译期编程:constexpr(§4.2.7)、编译期循环(§5.5)、可确保的编译期
求值和容器(§9.3.3)
、元编程(§10.5.2)
泛 型 编 程 :STL(§10.5.1)、 概 念 (§6)、 用 户 定 义 类 型 作 为 模 板 参 数
(§9.3.3)
、lambda 表达式(§4.3.1)
元编程(§10.5.2)
它们都与零开销原则相关,但最后两个有点令人惊讶,因为在 2006 至 2020 年期
间内,C++ 对它们的支持并不完全。
假如 C++ 分裂成互不兼容的方言,或者成为你无法长期依赖的东西,以上这些就
都失去意义了:
•
稳定性和兼容性至关重要(§1.1)
(§11.4)
新特性(C++11 以来)带来了标准库的改进(例如: unique_ptr 、 chrono 、
format 和 scoped_lock)
,也带来了很多其他库的改进。
C++ 的目的是成为构建应用程序的工具,许多用 C++ 开发的伟大应用程序,例如
在(§2.3)和(§10.1)章节提到的那些,是 C++ 真正的成功。
11.3 需要工作的领域
没有一种语言对所有人和所有事都是完美的。对于这点,没有人比既懂多种语
言、又严肃使用其中一种并努力支持它的人了解更多了。阻碍进步的很少是单纯
的无知。相反,重大改进的主要障碍是缺乏方向、缺乏开发资源以及害怕破坏现
有代码。
C++ 苦于诞生过早,在现代化的集成开发环境(IDE)、构建系统、图形界面
(GUI)系统和 Unicode 问世之前就已经诞生了。我期待 C++ 能慢慢赶上来。举例
来说:
•
工 具 使 用 :从 C 语 言 开始 , 用 字 符 和 词 法 标 记来 说 明 语 义 , 以 及 用
#include 和宏来组织源代码,这一直是有效工具建设的主要障碍。模块
应该会有所帮助(§9.3.1)
,而且是有可能为 C++ 设计出一个合理的内部表
示的 [Dos Reis and Stroustrup 2009, 2011]。
185
11. 回顾
•
•
•
•
教育:今天的 C++ 教学大多仍然过时和落后(§2.3)。核心指南(§10.6)
是对实践进行现代化的一种方法。WG21 的教育研究小组(§3.2)和许多
面向教育的会议报告表明,这些问题得到了重视和并正在解决中。
打包和发布:C++ 诞生时,由独立开发、维护的模块组成的软件并不常
见。今天,已经有了用于 C++ 的构建系统和打包管理程序。然而,还没有
一个是标准的,有些难以用于简单的任务,有些则不够通用,不能应对使
用 C++ 构建的大规模系统。我在 2017 年的 CppCon 主题演讲中提出了这
个问题,并向社区发起挑战 [Stroustrup 2017c] 来解决它。我认为我们正
在看到进展。此外,C++ 社区还缺少一个标准的地方来寻找有用的库。
Boost [Boost 1998–2020] 是解决这个问题的一个努力,GitHub 正逐渐成为
一个通用的资源库。但要达到让相对的新手能找到、下载、安装和运行几
个主流的库这样的方便程度,我们的路还很长。
字符集和图形:C++ 语言和标准库依赖于 ASCII,但大多数应用程序使用
某种形式的 Unicode。WG21 工作组现在有一个研究小组试图找到一个方
式去标准化 Unicode 支持(§3.2)
。缺乏标准的图形和图形界面则是更难的
问题。
清理陈年烂账:这非常困难,而且令人不快。例如,我们知道内置类型之
间的隐式窄化转换会导致无穷无尽的问题(§9.3.8),但是有数以万亿计的
C++ 代码行,这些代码以难以预测的方式依赖于那些转换。试图通过添加
“ 更 现 代 ” 的 特 性 来 替 换 旧 特 性 来 进 行 改 进 很 容 易 成 为 N+1 问 题
(§4.2.5)的牺牲品。改进的工具(例如静态程序分析和程序转换)提供
了希望。
大型语言社区所面临的挑战是多种多样的,不可能有单一而简单的解决方案。这
不仅仅是一个语法、类型理论或基本语言设计的问题。有些问题是商业性的。在
工业规模上取得成功所需的各种技能范围令人望而生畏。时间会证明,C++ 社区
是否能处理好所有这些问题,以及更多的其他问题。这点上我适度乐观,因为现
在所有领域都已经有一些积极的举措(§3.2)。
11.4 教训
C++ 是由一个大型委员会控制的,成员多种多样,并且会不断变化(§3.2)。因
此,除了技术问题外,我们必须考虑在语言的演化过程中什么是有效的:
•
•
•
•
问题驱动:C++ 开发应该被那些真实世界中的具体问题的需求所驱动。
简单:C++ 应该从简单、高效、易用的解决方案中进行推广而成长。
高效:C++ 语言和标准库应该遵循零开销原则。
稳定性:不要搞砸我的代码!
186
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
大部分(全部?)C++ 最成功部分的开发都遵从了那些“经验法则”。它们自然会
限制语言的发展范围,但这是好事。C++ 并不意味着对所有的人都是无所不能
的。此外,这些原则迫使 C++ 在现实世界的挑战中相对缓慢地成长,并从反馈中
受益。也请参见《C++ 语言的设计和演化》中的其他“经验法则” [Stroustrup
1994] 和我的 HOPL2 论文 [Stroustrup 1993]。这里面一直有连续性。
相比之下,一个功能如果设计时没有明确专注在解决大部分开发者实际面临的问
题上,那它通常会失败:
•
•
•
•
只为专家:某个功能从开始的时候就要满足所有专家的需要。
模仿:我们需要这个功能,因为它在另外某个语言里很流行。
理论性:语言理论里说语言里一定要有这个特性。
革命性:此功能非常重要,以至于我们必须打破兼容性,或者摒弃那些不
好的老方法。
我的结论是,尽早确定方向和期望至关重要。稍晚一些,就会有太多的人有太多
的不同意见,因而无法达成一套连贯而一致的想法。
给定一个方向和一套原则,一种语言可以基于反馈、用户经验、实验和作为工具
的理论成长。这是好的工程方法;反之,则是无原则的实用主义或教条的理想主
义。
C++ 标准委员会的章程几乎只关注语言和库的设计。这是有局限性的。一直以
来,像动态链接、构建系统和静态分析之类的重要主题大多被忽略了。这是个错
误。工具是软件开发人员世界的一个重要组成部分,要是能不把它们置于语言设
计的外围就好了。
热衷于各种不同的想法具有危险性。在 2018 年的一篇论文 [Stroustrup 2018d]
中,我列出了 51 条最近的提案:
我列出了我认为有可能显著改变我们编写代码方式的论文,每一篇对教
学、维护和编码指导都有重要的影响,其中许多对实现也有影响。
单独来说,许多(大多数)提案都是有道理的,但是放在一起却是疯狂
的,甚至足以危及 C++ 的未来。
那篇论文的题目是《记住瓦萨号!
》
(Remember the Vasa!)。瓦萨号是 17 世纪瑞典
的一艘宏伟战舰,由于设计上不断后期添加以及测试不充分,在首航时就沉没在
斯德哥尔摩港。在 1990 年代,委员会经常提醒自己记得瓦萨号,但在 2010 年
代,这一教训似乎已经被遗忘。
187
11. 回顾
为了对委员会的流程进行组织约束,方向组提出 C++ 程序员的“权利法案”
[Dawes et al. 2018]:
1.
2.
3.
4.
5.
6.
7.
编译期稳定性:新版本标准中的每一个重要行为变化都可以被支持以前版
本的编译器检测到。
链接期稳定性:除极少数情况外,应避免 ABI 兼容性破坏,而且这些情况
应被很好地记录下来并有书面理由支持。
编译期性能稳定性:更改不会导致现有代码的编译时间开销有明显增加。
运行期性能稳定性:更改不会导致现有代码的运行时间开销有明显增加。
进步:标准的每一次修订都会为某些重要的编程活动提供更好的支持,或
为某些重要编程群体提供更好的支持。
简单性:每一次对标准的修订都会简化某些重要的编程活动。
准时性:每一次标准的修订都会按照公布的时间表按时交付。
接下来的几十年,我们将会看到结果到底怎么样。
11.5 未来
从近期来说,C++20 会像 C++11 那样,让 C++ 社区受益良多。在 2020 年 2 月的
布拉格会议上,委员会对 C++20 进行了定稿,也投票同意了 Ville Voutilainen 的
“C++23 大胆计划” [Voutilainen 2019b]:
“在 C++23 努力做到以下几点:
”
•
•
•
•
对协程的库支持(§9.3.2)
模块化的标准库(§9.3.1)
通用异步计算模型(执行器)
(§8.8.1)
网络(§8.8.1)
注意关注点是在库上。
“同时也在以下方面取得进展:
”
•
•
•
静态反射功能(§9.6.2)
函数式编程风格的模式匹配(§8.2)
契约(§9.6.1)
鉴于这些议题的工作已经相当深入,委员会有可能会完成大部分工作。这一大群
充满热情的人还能拿出什么东西并达成共识,就不那么容易预测了。对于未来几
年,方向小组(我是其中的一员)提到了一些有希望进一步开展工作的领域
[Hinnant et al. 2020]:
•
改进 Unicode 的支持
188
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
•
•
•
支持简单图形和简单用户交互
支持新类型的硬件
探索错误处理的更好表达方式和实现方法
在委员会之外,我期望在构建系统、包管理和静态分析方面取得重大进展
(§10.4)
。
再往后的五年、十年或更远的未来,我在预测水晶球里就有点看不清了。在这个
时间范围内,我们需要着眼于根本,而不是具体的语言特性。我希望标准委员会
能注意到学到的教训(§11.4)
,并把重点放在根本上(§11.1)
:
•
•
•
把完全资源安全和类型安全的 C++ 作为追求目标
很好地支持各种各样的硬件
保持 C++ 的稳定性记录(兼容性)
保持稳定性需要在关注兼容性的同时,抵制试图通过添加大量“完美”特性来取
代不完美或不时髦的旧方式来大幅改善 C++ 的冲动。新的特性总是会带来意外
(有些令人愉快,有些则不那么令人愉快),旧的特性不会简单地消失。记住瓦萨
号![Stroustrup 2018d](§11.4)。很多情况下,库、指南和工具是比修改语言更
好的方法。
对于单线程计算来说,硬件已无法变得更快,所以对效率的重视将持续存在,而
有效支持各种形式的并发和并行的压力将不断增加(§2.3)。专用硬件将大量涌现
(例如,各种内存架构和特殊用途的处理器)
;这将使 C++ 这样的、可以利用这些
硬件的语言受益。唯一比硬件性能增长更快的是人们的期望。
随着系统越来越复杂,开销可负担的抽象机制的重要性也在增加。对于依赖实时
交互的系统,可预测的性能是至关重要的(例如,许多实时系统禁止使用自由存
储(动态内存)
)
。
随着我们对计算机化系统的依赖程度的增加、高手黑客数量的增多,安全问题只
会越来越重要。为了防御,我看好硬件保护,看好更结构化、能支持更好的静态
分析的系统,而非无休止的临时运行期检查和低级代码。
语言和系统之间的互操作性仍会至关重要;很少有大系统会只用一种语言来编
写。
随着系统变得越来越复杂,对可靠性的要求也越来越高,对设计和编码质量的需
求也急剧增加。我认为 C++ 已经为此做好了充分的准备,C++23 的计划是要进一
步加强它。然而,仅靠语言特性是不足以满足未来需求的。我们需要有工具支持
的使用指南,以确保语言的有效使用(§10.6)。特别是,我们需要确保完全的类
189
11. 回顾
型安全和资源安全,这必须反映在教育中。为了蓬勃发展,C++ 需要为新手提供
更好的教育材料,也需要帮助有经验的程序员掌握现代 C++。仅仅介绍奇技淫巧
和高级用法是不够的,而且反而会因为增强了 C++ 的复杂性名声而对语言造成伤
害。
由于种种原因,我们需要简化大多数的 C++ 使用的场景。C++ 的演进已经使之成
为可能,而我预计这一趋势将继续下去(§4.2)。改进的优化器——有能力利用代
码中使用的类型系统和抽象——让优化这件事变得不同了。在过去的几年里,这
极大地改变了我优化代码的方式。我从放弃精巧而复杂的东西开始,那是错误的
藏身之处;并且,如果我难以理解发生了什么,编译器和优化器也会如此。我发
现,这种方法通常会给我带来从适度到惊人的性能提高,同时也简化了未来的维
护。只有当这种方法不能给我带来我想要的性能时,我才会求助于高级(又称复
杂)的数据结构和算法。这是 C++ 抽象机制设计上的一大胜利。
我期待着看到用 C++ 构建更多令人兴奋的应用程序,并看到新的编程惯用法和设
计技巧的发展。
我也希望其他语言能从 C++ 的成功中学习。假如从 C++ 的演化中吸取的经验教训
仅局限于 C++ 社区,那将是可悲的。我希望并期待在其他语言和系统中看到 C++
模型的关键方面,这将是一个真正的成功衡量标准。在一定程度上,这已经发生
了(§2.4)
。
190
在拥挤和变化的世界中茁壮成长:C++ 2006–2020
致谢
我痛苦地意识到
•
•
这篇论文太长了。
对大多数技术主题的描述都省略了很多可以看作是根本的内容。很多情况
下,许多人经年累月的工作会被简化为一页甚至一句话。特别是,我忽略
了并发性这个极其重要的话题;它应该有一篇专门的长篇论文来进行详
述。
感谢让 C++ 成功的数以百万计的程序员,他们创建的应用是我们这个世界的关键
部件。
感谢本文草稿的审稿人,包括 Al Aho、A. Bratterud、Shigeru Chiba、J. Daniel
Garcia、Brent Hailpern、Howard Hinnant、Roger Orr、Aaron Satlow、Yannis
Smaragdakis、David Vandevoorde、J.C. Van Winkel 和 Michael Wong。本文的完整
性和准确性在很大程度上依靠这些审稿人。当然,错误是我自己的。
感谢 Guy Steele 帮我顺利解决了 LaTex 和 BibTex 中的谜团,把文章引用做到满足
ACM 要求的形式。
感谢所有在标准上努力工作的人。还有很多我没有提到的名字,可以在 WG21 论
文的作者和这些论文的致谢部分中找到。我参考和引用的许多“P”和“N”编号
的论文保存在 open-std.org/jtc1/sc22/wg21/docs/papers/。没有这些论文,本文
的一些内容就会过度依赖我的记忆了。
191
Download