老万故事会

【老万】谷歌新语言 Carbon 能干翻 C++ 吗?

7 月 19 号,在加拿大多伦多的 2022 Cpp North 大会上,谷歌宣布了正在开发的一种编程语言 Carbon,定位于取代 C++。

我一看新闻,发布人是钱德勒⋅卡拉斯(Chandler Carruth)。这不是钱子吗?一瞬间,那些寂寞的春天涌上心头......

Image

山景城来的钱德勒在发布 Carbon

~~ 往事并不如烟 ~~

大概是 2006 到 2007 年吧,我刚开发完 gtest 和 gmock(谷歌的 C++ 测试框架),正着手它们的开源工作。钱德勒自告奋勇加入了项目,作为 CMake 和 autoconfig 专家解决了很多开源环境下的构建系统(build system)问题,帮助我完成了系统的开源。

第一次合作,钱德勒飘逸的长发,出色的技术和火一般的工作热情给我深刻的印象,非常愉快。在开源完 gtest/gmock 后,我决定和他再干点啥。

C++ 是谷歌的第一编程语言。当时谷歌在 C++ 的开发效率上遇到了一些挑战,基于 GCC 编译器的 C++ 开发工具越来越适应不了谷歌的需求,而 GCC 本身几十年的技术债务和不够模块化的架构让它的改进和二次开发都非常困难。钱德勒建议我们致力于改进基于 LLVM 工具库的 Clang C++ 编译器。

Clang 是 Apple 公司主持的新编译器项目,架构比较现代化,改进起来容易上手,而且它模块化的设计特别适合用来开发程序分析和重构工具 -- 这些正是谷歌急需的。

当时 Clang 对 C++ 的支持还很不完善,C++ 标准里的很多特性都不支持。于是我们俩牵头成立了谷歌 C++ 编译器小组,完善 Clang,并在它上面开发帮助 C++ 程序员提高工作效率的工具。

这个小组叫什么名字?我跟钱德勒发生了争执。最后我的意见得到了更多人的支持:就叫 Cymbal(钹)吧。

这个铿锵有力的名字出自三点考虑:

  1. Clang 的含义是“C languages”,因为它可以编译 C 家族的一系列语言,不仅仅是 C++。但是 clang 本身也是一个象声词,表示金属震动的巨响。而钹可以发出这种声音(Cymbal makes a clang)。

  2. 编译器是处理符号(symbol)的。Cymbal 和 symbol 发音完全相同。

  3. Cymbal 是 C 打头的。

那时大约是 2008 年。这证明我有谐音哏的天赋,比王建国出道还早,要是有人跟我炒 CP,说不定早就红了。

Cymbal 小组规模逐渐扩大。我们把 Clang 的 C++ 支持实现完成后,又修正了谷歌 C++ 代码库中所有和 Clang 不兼容的部分,用 Clang 替换掉了 GCC。

像谷歌这样浩瀚的 C++ 代码库,不时常重构就会年久失修,被自己的重量压垮。但 C++ 语言相当复杂,开发 C++ 程序的重构工具通常需要耗费大量的精力,结果还不尽如人意。为了解决这个问题,钱德勒、我,还有一位德国慕尼黑的同事曼努尔⋅克莱默克(Manuel Klimek)开发了一个 C++ 源码匹配器(matcher)工具库,简化了这类工作。这个库开源成了 Clang 的一部分。后来谷歌内部基于这个库开发了不少 C++ 工具。

Image

慕尼黑来的曼努尔在遛娃

为了全面检查谷歌 C++ 代码的质量,曼努尔还和我结对编程(pair programming),写了一个基于 Clang 的工具来分析全部谷歌 C++ 源文件,把它们的分析结果存在一个 BigTable(谷歌开发的一种 NoSQL 存储系统)里方便查询。因为要处理的 C++ 文件太多,我们用 MapReduce 并行处理。这个系统叫 ClangMR,我们写了一篇文章介绍这种大规模自动重构代码的技术,发表在 2013 年的 ICSM 大会(https://research.google/pubs/pub41342/)。

因为 BigTable 和 MapReduce 本身是用 C++ 实现的,所以我们的操作造成了一种奇特的递归:用 MapReduce 分析 MapReduce,把 BigTable 存进 BigTable。

C++ 程序员还有一大痛点,就是头文件的管理。

C++ 对模块化编程的支持是非常原始的:如果你要用到一个库,先得 #include 这个库的头文件。但如果你 #include 了一些用不到的头文件,编译器也不会提醒你。日久天长,C++ 源文件里常常会积攒了一堆无用的 #include,白白浪费编译时间。还有,有时候你明明用到了一个库但是忘了 #include 它的头文件,程序却能神奇的工作,因为你 #include 的某个头文件间接地 #include 了这个头文件。但这种歪打正着的地下关系是靠不住的,经常会造成删除一个无用 #include 却莫名其妙 break 了隔壁的项目。这就进一步打击了大家做清洁的热情。长此以往,代码变得非常臃肿。

理论上说,#include 的维护是可以自动化的:只要写个程序分析一个 C++ 源文件到底用到了哪些库,再 #include 相应的头文件,一个不多,一个不少,就 OK 了。可现实是骨感的:因为 C++ 的复杂性(比如各种宏定义、模版、ADL、meta 编程),这个程序不是一般的难写,很多年来都没有人解决。

Clang 的完善让解决这个问题出现了曙光。一天 Cymbal 小组正开会,一位不速之客不请自来。他叫克雷格⋅西尔维斯汀(Craig Silverstein),是谷歌的三号员工(前两号是创始人),最早的 google.com 就是他写的(他的故事请看《我在谷歌弄啥咧 - 克雷格 #1》)。

以克雷格在公司的地位,想干啥就干啥。他把大部分时间花在工程师文化上,包括维护大量公共库函数。他对 #include 的维护有切肤之痛,听说我们的编译器开发得差不多了,想看看能不能搭船解决这个痛点。

Image

纽约来的克雷格在看着你

会后克雷格决定亲自出手开发一个叫 IncludeWhatYouUse (IWYU)的工具,自动清理程序中的 #include。我作为 Clang 组的代表也参与了项目,除了和克雷格讨论方案并审查他的设计和代码,我贡献了这个工具大约四分之一的代码。要不是克雷格写代码实在太快,让我在审查代码上花了太多时间,我应该有机会写更多代码的。

那时克雷格在纽约,我在西雅图,有时需要结对编程,我们就约好一个时间,我把屏幕共享出去,让他在纽约也可以操作。为安全起见,我会通过电话告诉他访问我屏幕的密码。经常,我们一天的工作是这样开始的:

克雷格:“嘿,ZY,你那边准备好了吗?”
我不出声。
克雷格:“听不见。你能说点什么吗?(Can you say something?)”
我:“点什么(something)。”
克雷格:“天啦,你搞什么搞...... 密码是什么?”
我:“OK,今天的密码非常安全,你准备好了吗?”
克雷格:“好了,说吧。”
我:“注意听啊:1…2…3……4。“
克雷格:“哦我的上帝!这太安全了!”

我不放过跟克雷格开玩笑的机会,毕竟我认识的大佬也不多。

后来克雷格离开了谷歌,去可汗学院搞在线教培。再后来我也离开了谷歌,去改进 Pinterest 的 C++ 开发环境,再一次面对 #include 维护的问题。我查了一下,现在解决这个问题的最佳方案居然还是我们开源的 IWYU。

于是我给克雷格写了封信,说没想到 IWYU 十年后还有人用。他回复道:“我记得钱德勒说过‘我们会把 IWYU 的功能迁移到 Clang,这样就不需要单独的 IWYU 工具了。几个月就能搞定。’我猜要是‘几个’的意思是‘几百’,他可能也没说错。”

虽然钱德勒没有如约把 IWYU 整合进 Clang,他也没有闲着。我离开 Cymbal 小组后,他一直在 C++ 领域深耕,十几年如一日。几年前见到曼努尔,问起他和钱德勒最近的工作。他说他们在改进 C++ 上花了很多力气,但是很多问题没法解决,因为 C++ 里面把缺省行为搞反了,要是改正,现在的 C++ 代码都会被干死,所以很多好的想法实现不了。

不过,他说,等钱德勒升到 8 级(principle engineer)就好了,那时候想干啥就干啥,就可以按自己的想法甩开膀子干了。

~~ Carbon 的诞生 ~~

前两年钱德勒升到了 8,没有食言,按自己的想法抛开 C++ 搞起了 Carbon 语言。

大家知道,Carbon 是“碳”的英文,碳的化学元素符号是 C。我觉得这可能是在宣示跟 C 语言(C++ 的前身)的渊源。也可能是个哏,Carruth-born,毕竟这是钱德勒⋅卡拉斯同学折腾出来的。不过,Carbon 团队说这就是一个 C 打头的词,不要想多了。

我看了钱德勒在 Cpp North 大会上官宣 Carbon 的讲演。小伙子剪去长发笑容可掬,曾经的苦痛都随风而去,看来这几年他真的是想干啥就干啥,开发新语言让他很开心。

他谈到为什么要开发 Carbon:C++ 有很多宏大目标没有完全实现,因为三大原因。

  1. C++ 从 C 继承了很多技术债务,又自己积累了几十年的技术债务。这种向下兼容策略的好处是继承了过去在 C/C++ 生态积累的大量资源,对 C++ 的成功是非常正确的决定,但负面作用是语言变得过于复杂。

  2. 为了保护用户已有投资,C++ 在演化时致力于向后兼容,很多错误不敢修正。

  3. C++ 语言标准按 ISO 流程演进。这套流程不适合快速解决复杂的技术问题。

Image

听说你有个改进 C++ 的好点子?

这些问题积重难返,改良已经于事无补。为有牺牲多壮志,敢教日月换新天,跳出 C++ 的框框才能新生。

Carbon 的目标,是在一切 C++ 适用的场景替代 C++,就像 C++ 取代 C,Swift 取代 Objective-C,TypeScript 取代 JavaScript,Kotlin 取代 Java。

为什么要造一个新的轮子?这世界那么多语言,就没有一个能干翻 C++ 吗?

还真的没有一种语言可以同时做到:

  • 像 C++ 一样高性能,

  • 没有 C++ 的各种缺点,

  • 可以和 C++ 代码无痛衔接,让用户渐进式把 C++ 代码库迁移到新的语言。

比如,各种基于垃圾回收技术的语言,性能离 C++ 都有差距。

Rust 可能是最接近于 C++ 继位者的语言(事实上,曼努尔正在带领一个谷歌团队探索这条道路),但是从 C++ 迁移到 Rust 并不容易。

每种语言都有自己的设计目标。很多目标是冲突的,不存在既当又立的好事。有品位、有智慧的设计师,会在鱼与熊掌之间,做一个最有利于语言应用场景的选择。

Carbon 的设计目标:

  1. 在 C++ 的各种应用场景,至少不输于 C++。

  2. 利用 C++ 现有的生态系统,不从头造一切。

  3. 和 C++ 可以互相调用,而且调用时没有额外开销。

  4. 用工具帮助用户迁移。

  5. 易学易用。

前三点把“C++”换成“C”,就是当初 C++ 的设计目标,也是 C++ 成功的原因。

钱德勒强调,Carbon 要成功,一定要做好三件事:(和C++的)互用性,迁移,还有演进。

  1. 实现无痛互用,才有可能保护 C++ 用户已有的投资,让他们愿意使用 Carbon。

  2. 提供 C++ 到 Carbon 迁移的自动化工具,可以降低用 Carbon 的门槛,让用户很快看到成效。

  3. 要能快速迭代,敢于修正前期的错误,不怕跟旧版本不兼容,红旗才能打得长久。为了减轻这种不兼容给用户造成的痛苦,每次语言出现不兼容的改变时都要提供工具帮用户自动升级。

~~ Carbon 长什么样?~~

Carbon 都要改正哪些在 C++ 的框架下无法改正的错误呢?举几个例子:

C++ 的语法出名的复杂。

看见 A<B>i,你怎么知道这是一个算术表达式 “A 小于 B 大于 i”呢还是一个类型为 A<B> 的变量 i 呢?

看见 A<B<i>>j,你又怎么知道这里的 >> 是一个移位操作符呢还是一个模版类型 A<B<i> >的一部分呢?

还有著名的最让人抓狂的解析(most vexing parse)问题:

int num_bytes(GetNumBytes());

是什么意思?

可能大多数 C++ 程序员都会说这一行定义了一个叫 num_bytes 的整型变量,并把它初始化为 GetNumBytes()。

错。在 C++ 编译器看来,这是在宣告一个叫 int_bytes 的函数。这个函数返回一个整数,它有一个参数,这个参数是一个函数指针,指向一个返回类型是 GetNumBytes 而且没有参数的函数。看到这里,你是不是已经出现了偏头痛的症状?

之所以出现这种匪夷所思的情况,是因为 C++ 的语法是有歧义的,而 C++ 引入了很多复杂的规则来消除歧义。这些规则,对用户和编译器的作者来说都是噩梦。

像 int num_bytes(GetNumBytes()); 这样的代码,既符合变量定义的语法,也符合函数宣告的语法。C++ 硬性规定这种情况下按函数宣告处理。如果你想要一个变量定义,可以再加一层括号,写成:

int num_bytes((GetNumBytes()));

C++ 里面充斥了大量这样“回”字四种写法式的冷知识,不知耗尽了多少有志青年的芳华。

Image

十年 C++ 程序员

Carbon 的设计理念是语法要明确无歧义,不但读者看得明白,编译器也好写,编译速度也可以飞快。

为了做到这一点,Carbon 采用了 introducer keyword 的语法:

  • 所有类的定义用 class 开头。

  • 所有函数的声明或定义用 fn 开头。

  • 所有右值的定义用 let 开头。

  • 所有左值的定义用 var 开头。

  • ……

比如:

// class 开启一个类定义。class Circle {  // fn 开启一个函数定义。me 相当于 C++ 的 this。  fn GetArea[me: Self]() -> f64 {    // let 开启一个右值常量定义,: 开启其类型。    let pi: f64 = 3.14159;    return pi * me.radius * me.radius;  }
// var 开启一个左值变量定义。  var radius: f64;}

总之,每种定义都有自己特定的关键字开头,parser 不要太好写。

所有 Carbon 函数参数都是只读的。如果函数体要修改一个参数,必须用指针传入:

// 把 source 赋给 *target。fn Assign(target: i32*, source: i32) {  // source = 0;  // 编译错误:Cannot assign to rvalue 'source'.  *target = source;}

C++ 里面有各种不同类型的引用(const 引用,mutable 引用,右值引用),掌握起来老费劲了。Carbon 没有引用,只有指针,降低了复杂度。

Carbon 的指针类型 T* 不支持空指针。如果一个对象可能不存在,类型必须是 Optional(T*)。这种做法相当于 C# 的 Nullable,可以防止很多空指针访问错误。

Carbon 的指针不支持算术运算,比如 p++,p + 5 这样的操作都不存在,可以防止地址越界的错误。

C++ 里类型表达式和值表达式有不同的语法,比如 vector<int> 是整型向量类型,Fib(5) 是函数调用。Carbon 把这两种语法合一了:类型是一种特殊的值,一个类型表达式的值是一个 compile-time 常量。比如 Vector(i32) 是整型向量类型,和 Fib(5) 这种函数调用的表达式语法看起来是一样的。这样便降低了学习成本和写 parser 的难度。

Carbon 没有全局名字空间(global namespace),所有定义都必须属于某个包(package),包的名字就是名字空间。这样就防止了不同模块之间的冲突。

Carbon 的类成员缺省都是公开的(public),如果需要 private 成员需要明确说明。钱德勒的解释是 public API 是一个类最重要的部分,所以语法要清爽。对这一点我持不同意见,因为这样容易让菜鸟程序员把很多不该公开的成员都公开,造成维护困难。

C++ 支持多重继承(一个 class 有多个父类)还有虚拟继承的概念,非常复杂。Carbon 只有单继承。

Carbon 的 class 缺省都是 final,不能被其它 class 继承。如果要允许有子类,要明确定义为 base class。

Carbon 的泛型(generics)是强检查的。定义泛型时,必须声明每个参数要实现哪些 interface。在调用泛型时,不需要知道泛型的定义就可以完成类型检查。

// A generic interface.interface Summary {  fn Summarize[me: Self]() -> String;}
// A generic function. T 必须实现 Summary。fn PrintSummary[T:! Summary](x: T) { Console.print(x.Summarize());}

这种设计比 C++20 的 concepts 更安全。

和 Go 的 duck typing 不同,Carbon 里面,一个类型要实现一个 interface 必须明确说明,更加严谨:

class NewsArticle {  // 明确表示要实现 Summary interface.  impl as Summary {    // 这不是一个 virtual 函数.    fn Summarize[me: Self]() -> String { … }  }}
fn SummarizeNews(n: NewsArticle) -> String {  // 不需要看 PrintSummary() 的实现就可以类型检查. PrintSummary(n); return n.Summarize();}

和基于继承的多态不同,Carbon 的 interface 实现不是 virtual 的,没有调用时查 vtable 的开销。

Carbon 甚至允许你扩展一个不属于你的类型,让它支持新的 interface:

import OtherPackage;  // OtherPackage 是别人家的。
// 但是我们可以让 OtherPackage.Tweet 支持 Summary interface。external impl OtherPackage.Tweet as Summary { fn Summarize[me: Self]() -> String { … }}

Carbon 可以直接调用 C++,没有任何开销:

// 导入一个 C++ 库到 Cpp 名字空间.import Cpp library "circle.h";
// 然后通过 Cpp.* 访问里面的定义。… c: Cpp.Circle …

C++ 也可以直接调用 Carbon(需要 Clang 编译器),没有任何开销:

// 导入 Carbon 库到 Geometry 名字空间.#include "geometry.carbon.h"
// 然后通过 Geometry::* 访问里面的定义。… Geometry::PrintArea(circles);

注意这里的 geometry.carbon.h 头文件并不真实存在 - Clang 会去找相应的 geometry.carbon 文件,自动生成对应的 C++。

~~ Carbon 的明天 ~~

钱德勒谈到他对 Carbon 语言如何演进的思考。开发一个语言不是一锤子买卖,像 C++ 都演化了几十年了。如果没有正确的道路,难免虎头蛇尾,始乱终弃。他觉得 Carbon 社区的文化建设是第一要紧的。为了证明他有文化佐证他的观点,他不惜引用了一段我没听说过的名言:

Culture eats strategy for breakfast, technology for lunch, and products for dinner, and soon thereafter everything else too.  - Peter Drucker, American educator
文化会把策略当早餐,技术当午餐,产品当晚餐,然后把其它一切都吃干净。- 彼特⋅德拉克,美国教育家

总之,文化不对,一切都免谈。

其实现在的 Carbon 更像是个预告片,设计没有完成,实现只是原型,文档还在撰写,工具尚未出现。这次官宣,与其说是发布,不如说是造势。按钱德勒的想法,Carbon 虽然在谷歌诞生,但它并不属于谷歌。这一项目已经在 github 开源了(https://github.com/carbon-language/carbon-lang),后续开发都会完全在开源社区公开进行,任何人都可以参与。

钱德勒立志打造一个超级友好的 Carbon 社区,大庇天下 C++ 用户俱欢颜。以他在 gtest/gmock/clang 等开源项目的经验,我对他的这一目标有信心。比如,Carbon 社区规定不能发(笑话其它语言的)段子,因为容易撕裂。

Carbon 目前有 3 位技术领导:谷歌的钱德勒⋅卡拉斯和理查德⋅史密斯(Richard Smith),还有微软的凯特⋅格里高利(Kate Gregory)。一切重大决定由他们三人定夺。除了 github 的论坛,还可以在 discord 的 Carbon Language 社区讨论。如果你想修改什么东西,在 github 上送一个 PR 就好了。

如果你想尝试一下 Carbon,除了克隆它的 github repo,也可以访问 https://compiler-explorer.com/ - 我试了一下,确实非常早期,有些文档里描述过的功能都不支持。

那么 Carbon 究竟水平如何,能不能取代 C++?我的观感:

Carbon 的目标是为 C++ 生态找一条切实可行的续命途径,不是探索程序设计语言的可能性,不追求在编程语言的理论上有所突破,主创团队也不是科班出身的研究者,但他们是长期的 C++ 用户和资深 C++ 编译器开发者,做决策时有务实的态度。在 Carbon 里我还没有看到颠覆性的设计,用到的都是已经被证明行之有效的技术,但这不是问题,因为 Carbon 参加的不是创新比赛,最后的成败还是要看它能不能实实在在地解决好 C++ 社区的问题。根据我对钱德勒和 Carbon 团队中其他前同事的技术能力和人品的了解,我持谨慎的乐观态度,期待看到 Carbon 社区的蓬勃发展。

至于 Carbon 能否取代 C++,钱德勒自己也认为目前言之过早。历史上有很多号称“C++ 终结者”的语言,最终只是昙花一现。风物长宜放眼量。现在我们要做的,不是急着给 Carbon 贴标签,炒热点,而是埋头苦干,过上五年十年再来看成败。

钱子,祝你成功!

~~~~~~~~~~

猜你会喜欢:

~~~~~~~~~~

关注老万故事会公众号:

本公众号不开赞赏不放广告。如果喜欢这篇文章,点个在看,转发给朋友就是对老万的最大支持。谢谢大家🙏