速来报名!AICon北京站鸿蒙专场~ 了解详情
写点什么

C++ 语法糟透了,Carbon 修复了它

  • 2022-08-23
    北京
  • 本文字数:4609 字

    阅读完需:约 15 分钟

C++ 语法糟透了,Carbon 修复了它

本文最初发布于 ITNEXT


我在 Twitter 上读到过许多关于 Carbon 的评论,有许多 C++开发人员对 Carbon 这门编程语言的语法很不满意。人们反复提到的一个问题是:


如果他们是要为 C++开发人员开发一门新语言,那为什么要让它看上去和 C++完全不同?C++的语法已经很好了,而且为人所熟知。


实际上不是这样的,C++的语法一点也不好。但是,一旦你在 C++领域呆了足够长的时间,就很容易将其内化而忽略这个事实。Chandler Carruth 在介绍Carbon的时候未能很好地说明为什么 C++语法多么问题重重。


虽然我不能保证能说明得很好,但我会试着挖下 C++语法的问题,以及为什么我们需要一个升级。让我们从一个简单的对比开始:



可以看到,Carbon 中的大部分语句都有一个引导关键字,如fnvarletclass。有了这些关键字,你一看就知道自己正在处理的语句是什么类型的。Carbon 是故意这样设计的。下面这句话来自Carbon的首次公开展示:


我不知道是否有人做过 C++解析器,那不是一个完整的编译器。其难度超乎想象。我们可以做得更好。—— Chandler Carruth


作为一名经常开发各种工具来辅助 C++开发的开发者,我完全同意这个说法。编写代码解析 C++难度非常大,这是我从 1998 年开始编写 C++代码以来的亲身感受。Java 和 C#社区有许多令人惊叹的 IDE 和工具,而我们 C++开发者多年来一直使用糟糕的工具进行开发,命令补全经常会出问题,尤其是在大型项目中。特别地,与 Java 和 C#开发人员可以使用的工具相比,C++的重构工具功能非常有限。


VS Code C++高亮显示器的维护者解释了为什么开发 C++工具如此之难:


C++语法高亮显示器有 19000 行代码,这比任何语言都要多,而且是第二大的将近 4 倍(Typescript 5000 行)。是不是很厉害?不是的,因为即便如此,它甚至还是无法高亮显示变量声明中的自定义类型。Rust 高亮显示器毫不费力就能做到这一点,因为它和 Carbon 非常像。

—— Jeffrey Hykin


不过,即便是 Java 和 C#,在解析方面也不是很理想,因为它们借用了许多 C/C++语法。我最先注意到使用引导关键字的好处还是在 Go 语言中。Go 在函数定义中引入了func 关键字,这使得代码搜索变得很容易,我可以更容易地分辨出是使用还是定义。下图分别展示了查找MassFlow 函数调用和查找MassFlow 函数定义:


C/C++语法的问题是,需要解析多个语言符号才能确定一个语句是什么。对于人类读者而言,这可能无关紧要,但对工具来说就并非如此了,编写一个正则表达来搜索什么东西,难度要大很多。

最棘手的分析


关于 C++代码的解析难度,有一个具体的例子是“最棘手的解析”。这个特别的术语最早是由 Scott Meyers 在其著作《Effective STL 中文版:50 条有效使用 STL 的经验》中提出的。下面这个例子可以说明这一问题:


// C++ most vexing parsevoid foo(double x) {  int bar(int(x));}
复制代码


第二行代码存在多义性。我们可以将其解释成如下所示的函数声明:


// A function named bar takes an integer and returns an integer.int bar(int x);
复制代码


C++允许在函数声明中用小括号把参数x括起来。这样,C++解析器就不好区分是声明bar 函数,还是声明一个变量bar ,并将x 值转为整型对其初始化。下面是一个更复杂点的例子:


// C++ Unnamed temporarystruct Timer {};
struct TimeKeeper { explicit TimeKeeper(Timer t); int getTime();};
int main() { TimeKeeper time_keeper(Timer()); return time_keeper.getTime();}
复制代码


main函数的第一行存在多义性:


TimeKeeper time_keeper(Timer());
复制代码


在 C++中,这一行看上去像定义了一个函数time_keeper ,返回TimeKeeper 对象,并接收一个函数对象作为参数。在 C++中声明函数时,可以不指定参数名。int foo(int); 是一个合法的函数签名。这种问题在 Carbon 中不会出现,原因如下:


  • Carbon 类没有构造函数

  • 对象通过赋值初始化


在下面的代码中,我们试着用 Carbon 重现了前面的 C++代码。我需要创建类函数Create 和Make 来代替 C++示例中使用的构造函数。


// Carbon class Timer {    fn Create() -> Self;};
class TimeKeeper { fn Make(t: Timer) -> Self; fn getTime[me: Self]() -> int;};
fn Main() -> i32 { let time_keeper: auto = TimeKeeper.Make(Timer.Create()); return time_keeper.get_time();}
复制代码


可以看到,Carbon 语法中有一些可能不太明显的东西。Self 是指封闭类的类型。方括号内的东西,如[me: Self] ,指任何没有显式传递的东西。那是诱发性的东西。在这种情况下,就相当于 C++开发者所熟悉的this 指针。Go 也有非常类似的方法。在 Go 中,getTime 方法将是下面这个样子:


// Go method examplefunc (me TimeKeeper) getTime() int
复制代码


方括号也可以用于其他隐式数据,比如类型参数。下面是 Carbon 代码的一个例子:


// Carbon code showing function parametersfn QuickSort[T:! Comparable & Movable](s: Slice(T)) {  if (s.Size() <= 1) {    return;  }  let p: i64 = Partition(s);  QuickSort(s[:p - 1]);  QuickSort(s[p + 1:]);}
复制代码


这里,我们没有指定this 对象,而是指定了一个类型参数T 。该参数必须满足Comparable 和Movable 接口。Carbon 使用单冒号: 来指定对象类型,而:! 则用于表示我们正在指定类型参数必须满足的接口。

解析 C++中的函数指针


当定义以函数为参数的高阶函数时,将返回类型放在前面而不是后面的问题就显现出来了。快速看下这段代码,然后告诉我,函数指针参数的名字是什么?


// C++int FindFirst(int xs[], int n, bool (*condition)(int x)) {    for(int i = 0; i < n; ++i) {        if (condition(xs[i])) {            return i;        }    }    return -1;}
复制代码


函数指针参数的名字是conditon 。我相信大多数人都会同意,这不是很快就可以读出来的东西。这体现了 C++语法的其中一个问题。我本打算大张旗鼓地展示下 Carbon 做得有多好,但很遗憾,Carbon 语言规范中没有任何地方提到函数指针。取而代之,我将展示下在 Go 语言中会是什么样子,并推测在 Carbon 中可以如何做。


// Gofunc FindFirst(xs []int, condition func(int) bool) int {  for i, x := range xs {    if condition(x) {      return i    }  }  return -1}
复制代码


Go 代码更容易阅读,这是因为参数名在前,而类型在后。它具有一致性。使用 Carbon,我认为代码会更清晰,因为它使用冒号来分隔类型和参数名:


// Carbon - Assumption (similar to Rust)fn FindFirst(xs: Slice(int), condition: fn(int) -> bool) -> i32
复制代码


实际上,这几乎就是使用 Rust 编写FindFirst 函数签名的方式。

摆脱常量引用混乱

在编写 C++代码时,其中最恼人的一件事就是处理常量正确性和引用。考虑下面的几何学示例代码。


// C++ classclass Circle {     public:    const Point& center() const;    float radius() const;        void  setCenter(const Point& pos);    void  setRadius(float radius);                bool inside(const Point& p) const;    bool intersect(const Circle& c) const;    private:    Point center;    float radius;};
复制代码


有时候你需要传递一个参数,比如圆的中心,一个常量引用const Point& pos ,但其他时候你不想这样做。我将半径作为一个float 副本来传递。从机器码层面考虑,那会更高效,因为单精度浮点数值可以通过一个微处理机寄存器来传递。而常量引用被实现为指针,也就是说,要获得实际需要的值就得额外进行一次间接取值。Carbon 完全解决了这个问题,它让编译器来确定怎么做最好。在 Carbon 中,上面的类可以实现为:


// Carbon classclass Circle {         fn center[me: Self]() -> Point;    fn radius[me: Self]() -> f32;        fn setCenter[addr me: Self*](pos: Point);    fn setRadius[addr me: Self*](radius: f32);                fn inside[me: Self](p: Point);    fn intersect[me: Self](c: Circle);        var center: Point;    var radius: float;}
复制代码


可以看到,setCenter 和setRadius 类似。Carbon 将找出把参数pos 和radius 传递给相应函数的最优方法,需要担心引用和常量。在 Carbon 中,参数默认是let类型的,因此它们本质上和常量类似。


有时,你确实会需要在调用者中修改一个值。Carbon 没有引用,因此你需要使用指针来代替。Self* 是指me 参数是一个指针,我们可以修改me 。与使用引用相比,使用指针的一个问题是需要获取对象地址。


// Carbon - Calling setRadius if it was defined as // fn setRadius[me: Self*](radius: f32)
var circle: Circle = MakeRandomCircle();var ptr: Circle* = &circle;ptr.setRadius(5);
复制代码


每次都使用这个地址来修改一个 Circle 对象很麻烦。出于这个原因,我们在me 前面加了addr关键字,让 Carbon 为我们获取地址。这就是为什么我们不获取地址就可以调用setRadius


// Carbon var circle: Circle = MakeRandomCircle();circle.setRadius(5);
复制代码

为代码可读性而设计


大多数开发人员从不研究或关心可用性和用户界面设计。实际上他们应该关心的,因为应用于用户界面布局的原则与编写清晰易读的代码有很多相通之处。


有一个重要的细节需要注意,那就是人类阅读文本的方式。只有当你还是个学习读写的孩子时,才会阅读单词中的单个字母。成年人通过形状来阅读单词,我们通过观察单词的形状来识别它们。


这意味着要快速识别不同的选择,单词和句子的形状应该不同。让我们通过一个例子来说明这个问题。考虑一个包含一系列选项的网页:

I want to customize tools...I want to have custom shows...I want to do custom animations...
复制代码


这是一个糟糕的设计,因为句子的形状相似度太高了,增加了视觉扫描的难度。我们可以改进下这个设计,把重复的部分提取出来:


I want to:  Customize tools...  Custom shows...  Custom animations...
复制代码


虽然有改进,但还是有问题,每个选项的第一个单词看上去还是非常相似。修改每个选项的措辞,会更容易阅读:


I want to do:  Tool Customizing...  Reset Shows...  Custom Animations...
复制代码


这些原则是如何应用到 Carbon 编程语言中的呢?Carbon 使用了简短的引导关键字,如fn ,让你可以把更多的注意力放在单个的方法或函数上:


// Carbon function names line upfn center[me: Self]() -> Point;fn radius[me: Self]() -> f32;    fn setCenter[addr me: Self*](pos: Point);fn setRadius[addr me: Self*](radius: f32);          fn inside[me: Self](p: Point);fn intersect[me: Self](c: Circle);
复制代码


这使得在 Carbon 中浏览一个函数或方法列表更快捷。像 Java 这样的语言在这方面很糟糕。我们首先读到的是public static void ,到最后才看到最重要的部分——函数名。C++好一点,但把返回值信息放在函数名之前还是会分散注意力:


// C++ function names don't line upconst Point& center() const;float radius() const;
复制代码


返回类型的名称不一样,方法名就无法对齐,浏览方法名列表就难一些。这也适用于变量列表。借助像 var 这样的引导关键词,就可以保证每一行的变量名都从相同的位置开始,读者就可以更快地浏览代码。


// Carbon variable names line upvar center: Point;var radius: float;
复制代码

小结


不管是就解析器方面,还是从开发人员阅读代码和使用工具角度来说,Carbon 的语法都更简单。Carbon 代码更便于开发人员搜索和浏览,因为有效位(如标识符)是从同样的字符偏移开始的。


原文链接:


C++ Syntax Sucks and Carbon Fixes It

2022-08-23 15:3914727

评论 3 条评论

发布
用户头像
“Rust 高亮显示器毫不费力就能做到这一点,因为它和 Carbon 非常像。” Rust比Carbon早多了好吧!
Carbon的目标不是现代C++的继任者吗?总要有点现代C++的影子,而不是为了语法解析器设计语法,没有了构造函数与C++混合编码并不会容易很多吧,大概又会引入golang的约定大于语法。
2022-08-29 10:03 · 上海
回复
用户头像
Carbon是C++的语法糖?
2022-08-27 13:31 · 北京
回复
用户头像
这个一看就。。。。不太能接受。。。
2022-08-23 19:35 · 浙江
回复
没有更多了
发现更多内容
C++ 语法糟透了,Carbon 修复了它_语言 & 开发_Erik Engheim_InfoQ精选文章