本节我们来查看一下在二十一世纪的第二个十年里,C++ 如何被使用,以及用来做什么:
C++ 的使用领域绝大部分与 2006 年相同(§2.3)。虽然有一些新的领域,但在大多数情况下,我们看到的 C++ 还是在相同或类似的领域中被更加广泛和深入地使用。C++ 没有突然成为一种面向 Web 应用开发的语言,虽然即使在那种场景下仍有人用 C++ [Obiltschnig et al. 2005]。对于大多数程序员来说,C++ 依然是某种隐没在后台的东西,稳定、可靠、可移植、高性能。最终用户是看不见 C++ 的。
编程风格则有更加巨大的变化。比起 C++98,C++11 是门好得多的语言。它更易于使用,表达能力更强,性能还更高。2020 年发布的 C++20 则在 C++11 的基础上做出了类似程度的改进。
大致而言,C++ 可谓无处不在、无所不用。但是,大象无形,大多数 C++ 的使用并不可见,被深深隐藏在重要系统的基础设施内部。
C++ 被用在哪里,是如何被使用的,没人能够完整了解。2015 年,捷克公司 JetBrains 委托进行了一项研究 [Kazakova 2015],结果显示在北美、欧洲、中东以及亚太地区 C++ 被大量使用,在南美也有一些使用。“在南美的一些使用”就有 40 万开发者,而 C++ 开发者的总人数则达到了 440 万。使用 C++ 的行业有(按顺序)金融、银行、游戏、前台、电信、电子、投资银行、营销、制造和零售。所有迹象表明,自 2015 年以来,C++ 的用户数量和使用领域一直在稳步增长。
在这里,我将对 2006 到 2020 年期间内 C++ 的应用领域给出一个可能有些个人化的、印象派的、非常不完整的概览:
虽然这只是冰山一角,但它展示了 C++ 使用的广度和深度。大多数 C++ 的使用对其(间接)用户不可见。某些对 C++ 的使用早于 2006 年,但也有很多是之后才开始的。没有一个主要现代系统只用单一语言写就,但是 C++ 在所有这里提到的应用场合中发挥了重要作用。
我们常常忘记那些平凡的却在我们的生活中起着重要作用的应用。没错,C++ 可以帮助运行美国国家航空航天局的深空网络,但也可以在人们日常熟悉的小设备中运行,例如咖啡机、立体声扬声器和洗碗机。让我惊讶的是,C++ 竟然也被应用于运转现代养猪场的先进系统中。
与 2006 年相比,2020 年的 C++ 社区更加壮大,不断蓬勃发展、积极向上、富有成效,并且急切地想看到未来的进一步改进。
与大多数编程语言社区相比,C++ 社区一向是出奇地无组织和分散。这个问题早已有之,因为我就没有建立组织的才能。当时我的雇主 AT&T 贝尔实验室并不想建立一个 C++ 社区,但是似乎其他所有人都非常感兴趣,并且愿意花钱来建立他们的用户群。最终的结果是,许多公司,例如苹果、Borland、GNU、IBM、微软和 Zortech 都建立了以其客户为中心的 C++ 社区,但是却没有总体的 C++ 社区,社区没有中心。有杂志,读的人不多(相对于 C++ 社区的规模)。虽然有会议,但它们倾向于被一般的“面向对象”的会议或“软件开发”的会议所吸收或者就演变成了那些一般性会议。没有总体的 C++ 用户组。
如今,世界上有数十个本地、国家和国际 C++ 用户组,这些用户组之间也经常进行一些合作。除此之外,还有数十个 C++ 会议,每个会议都有数百人参加:
跟某些语言和供应商的集中组织相比,这还差得很远。但是,这些 C++ 社区和组织富有活力,彼此保持联系,并且比在 2006 年的时候活跃得多。此外,一些企业的用户组和会议也仍然活跃。
从 2006 年不太理想的状态(§2.3)到现在,C++ 的教育是否得到了改善?也许吧,但是对于 C++ 来说,教育仍然不是强项,大多数教育还都集中在为业内人士提供信息和培训上。在大多数国家/地区,很多大学毕业生对 C++ 语言及使用它的关键技术只能算一知半解。对于 C++ 社区来说,这是一个严重的问题。因为,对于一门语言来说,如果没有热情洋溢的程序员们源源不断、前赴后继地精通其关键设计和实现技术,那它是无法在工业规模上取得成功的。假如更多使用 C++ 的开发者知道如何更好地使用它,那他们就能做太多太多的事来改进软件!如果毕业生带着更准确的 C++ 视角进入工作岗位,那么太多太多的事情会变得容易得多!
C++ 教学所面临的一个问题是教育机构经常将编程视为低级技能,而不是基础课目。好的软件对我们的文明至关重要。为了把控软件,我们需要像对待数学和物理学一样,严肃认真地对待关键系统的软件开发。那种削足适履的方式对于教育和软件开发是行不通的。一个学期的教学也远远不够。我们永远都不会期望在教了短短几个月英语之后,学生就会懂得欣赏莎士比亚。同样,了解语言的基本机制与精通内行所使用的惯用法和技巧之间是有差距的。就像任何主要的现代编程语言一样,教授 C++ 也需要根据学生的背景和需求相应地调整教学方法。即使教育机构意识到了这些问题并愿意做出一些弥补,奈何学生已经课满为患,教师也很难保持不跟工业实践脱节。SG20(教育)正试图总结教授和使用现代 C++ 的方法来提供一些帮助。SG15(工具)则可能提供更多支持教学的工具,从而越来越多地发挥重要作用。
从 C++11 开始,我们对此有了越来越多的认识。例如,Kate Gregory 制作了一些很棒的视频,介绍了如何教授 C++ [Gregory 2015, 2017, 2018]。最近的一些书籍认识到在支持教育方面,不同的受众存在不同的需求,并试图迎头解决这些问题:
我也写了一些半学术性质的论文(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++ 语言相关的学术研究成果:
看起来还有更多的关于 C++ 的学术研究机会,关于语言的特性和技巧(例如,异常处理、编译期编程和资源管理),以及其使用的有效性(例如,静态分析或基于真实世界代码和经验的研究)。
C++ 社区中最活跃的成员中很少有人会考虑撰写学术论文,写书似乎更受欢迎(例如,[Čukić 2018; Gottschling 2015; Meyers 2014; Stepanov and McJones 2009; Vandevoorde et al. 2018; Williams 2018])。
与其他语言相比,在 1990 年代初期到中期,C++ 在用于工业用途的工具和编程环境方面做得相当不错。例如,图形用户界面和集成软件开发环境都率先应用于 C++。后来,开发和投资的重点转移到专属语言,例如 Java(Sun)、C#(微软)和 Objective-C(苹果)以及更简单的语言,例如 C(GNU)。
在我看来,有两个主要原因:
因此,在 2006–2020 年期间,与其他语言相比,C++ 被支持工具方面的问题严重困扰。但是,随着以下这些工具的涌现,这种情况得到了稍许改善:
上面列出的只是一些示例。像往常一样,C++ 用户面临的问题是可选方案的数量众多,例如:[RC++ 2010–2020] 列出了 26 个用于在编译时生成代码的系统,并且有数十个程序包管理器。因此,我们需要的是某种形式的标准化。
截至 2020 年,工具仍不是 C++ 的强项,但我们正在大范围内取得进展。
针对大多数现实问题的最佳解决方案需要组合使用多种技术,这也是 C++ 演进的主要动力。自然地,这让那些声称拥有单个简单最佳解决方案(“编程范式”)的人感到不爽,但是支持多种风格一直是 C++ 的根本优势。考虑一下“绘制所有形状”的例子,这个例子自 Simula 发展早期(绘图设备为湿墨绘图仪)以来就一直用于说明面向对象编程。用 C++20,我们可以这样写:
void draw_all(range auto& seq)
{
for (Shape& s : seq)
s.draw();
}
该段代码是什么编程范式?
range
概念进行参数化,我们得到一个模板)。for
循环,并按照常规 f(x)
语法定义了一个将要被调用的函数。对这个例子我可以进一步展开:Shape
通常具有可变状态;我可以使用 lambda 表达式,也可以调用 C 函数;我可以用 Drawable
的概念对参数进行更多约束。对于各种“更好”的定义,适当的技术组合比我能想到的任何一种单一范式所能提供的解决方案更好。
C++ 支持多种编程风格(如您坚持,也可以称为“范式”),其背后的想法并不是要让我们选择一种最喜欢的样式进行编程,而是可以将多种风格组合使用,以表达比单一风格更好的解决方案。
在 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)。
C++ 中的元编程出自泛型编程,因为两者都依赖于模板。它的起源可以追溯到 C++ 模板的早期,当时人们发现模板是图灵完备的 [Vandevoorde and Josuttis 2002; Veldhuizen 2003],并以某种有用的形式提供编译期纯函数式编程。
模板元编程(通常称为 TMP)往往非常丑。有时,这种丑陋通过使用宏来掩盖,从而造成了其他问题。TMP 几乎无处不在,这也证明了它确实有用。例如,如果没有元编程,就无法实现 C++14 标准库。许多技巧和实验在 2006 年前就有了,但是 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)
{
int r = 1;
while (n>1) r*=n--;
return r;
};
constexpr int fac7 = fac(7); // 5040
这个例子说明,当我们需要一个值时,函数是最佳的计算方式,即使——尤其——在编译期。传统模板元编程最好只保留用于计算新的类型和控制结构。
Jaakko Järvi 的 Boost.Lambda [Järvi and Powell 2002; Järvi 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)。
我对 C++ 语言的最终目标是:
这和《C++ 语言的设计和演化》[Stroustrup 1994] 及更早版本中阐述的设计目标并没有太多不同。显然,这是一项艰巨的任务,并且与较旧的 C 和 C++ 的多数用法不兼容。
最早,在 C++ 还是“带类的 C”的时候,人们就建议创建语言的安全子集,并使用编译器开关来强制执行这种安全性。但是,由于许多原因中的某一个原因,这些建议失败了:
第二个原因意味着,你不能仅仅通过禁止不安全的功能来定义一个安全的 C++。“通过限制以达到完美”这个方法,对于编程语言的设计来说,在极其有限的场合下才能发挥作用。你需要考虑那些一般来说不安全但有安全用途的特性的使用场景和特征。此外,该标准不能放弃向后兼容(§1.1),所以我们需要一种不同的方法。
从一开始,C++ 就采用了不同的哲学 [Stroustrup 1994]:
让良好的编程成为可能比防止错误更重要。
这意味着我们需要“良好使用”的指南,而不是语言规则。但是,为了在工业规模上有用,指南必须可以通过工具强制执行。例如,从 C 和 C++ 的早期开始,我们就知道悬空指针存在的问题。例如:
int* p = new int[]{7,9,11,13};
// ...
delete[] p; // 删除 p 指向的数组
// 现在 p 没有指向有效对象,处于“悬空”状态
// ...
*p = 7; // 多半会发生灾难
虽然许多程序员已经开发出防止指针悬空的技术。但是,在大多数大型代码库中,悬空指针仍然是一个主要问题,安全性问题比过去更加关键。一些悬空的指针可以作为安全漏洞被利用。
在 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 和谷歌的贡献者都做出了突出贡献。
核心指南绝不是唯一的 C++ 编码指南项目,但却是最突出、最雄心勃勃的。它们的目标明确而清晰,那就是显著提升 C++ 代码的质量。例如,早在 Bjarne Stroustrup、Herb Sutter 和 Gabriel Dos Reis 的论文中 [Stroustrup et al. 2015] 就阐明了关于完全类型和资源安全的理想和基础模型。
为了实现这些雄心勃勃的目标,我们采用了一种“鸡尾酒式”的混合方法:
这些方法中的每一种都有很长的历史,但是每一项都无法单独在工业规模上解决这些问题。例如,我是静态分析的忠实拥护者,但是如果程序员使用动态链接的方式在一个单独编译的程序中编写任意复杂的代码,那么我最感兴趣的分析算法(例如,消除悬空指针)是不能求解成功的。这里的“不能”是指“一般说来,理论上是不可能的”,以及“对于工业规模的程序而言在计算上过于昂贵”。
基本方式不是简单的限制,而是我称之为“超集的子集”或 SELL 的方法 [Stroustrup 2005]:
对于库,我们主要依赖标准库的各个部分,例如 variant
(§8.3)和 vector
。小型指南支持库(GSL)提供了类型安全的访问支持,例如 span
可以提供在给定类型的连续元素序列上的带范围检查的访问(§9.3.8)。我们的想法是通过将 GSL 吸收到 ISO 标准库中,从而最终也就不需要它了。例如,span
已被添加到 C++20 标准库中。当时机成熟时,GSL 中对于契约的微弱支持也应当被合适的契约实现所替代(§9.6.1)。
为了能规模化,静态分析完全是局部的(一次仅一个函数或一个类)。最难的问题与对象的生命周期有关。RAII 是必不可少的:我们已经不止一次的看到,手动资源管理的方法在很多语言中都很容易出错。此外,也有很多现存的程序,以一种有原则的方式使用指针和迭代器。我们必须接受此类使用方式。要使一个程序安全很容易,我们只需禁止一切不安全的功能。然而,保持 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}; // 可以(算是可以吧:不悬空,
// 但是把所有者作为 int* 返回了)
// ...
return p; // 可以,来自调用者
}
指针指向已知会超过函数生命周期的对象(例如,作为参数被传递到函数中),我们可以返回它,但对于指向局部资源的指针就不行。在遵循该指南的程序中,我们可以确保作为参数的指针指向某资源或为 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];
v.clear(); // v 所有的元素都被删除
*q = 7; // 错误:q 无效
无效检查甚至比检查简单的悬空指针还要困难,因为很难确定哪个函数会移动对象以及是否将其视为失效(指针 p
仍然指向某个东西,但从概念上讲已经指向了完全不同的元素)。尚不清楚在没有标注或非本地状态的情况下,静态分析器是否可以完全处理无效检查。在最初的实现中,每个将对象作为非 const
操作的函数都被假定为会使指针无效,但这太保守了,导致了太多的误报。最初,关于对象生命周期检查的详细规范是由 Herb Sutter [Sutter 2019] 编写的,并由他在微软的同事实现。
范围检查和 nullptr
检查是通过库支持(GSL)完成的。然后使用静态分析来确保库的使用是一致的。
静态分析设想最早是由 Neil Macintosh 实现的,目前已作为微软 Visual Studio 的一部分进行发布。有一些检查规则已经成为了 Clang 和 HSR 的 Cevelop(Eclipse 插件)[Cevelop 2014–2020] 的一部分。一些课程和书籍中都加入了关于这些规则的介绍(例如 [Stroustrup 2018f])。
核心指南是为逐步和有选择地采用而设计的。因此,我们看到其中一部分在工业和教育领域被广泛采用,但很少被完全采用。要想完全采用,良好的工具支持必不可少。