前言
C++ 的考试结束了,具体成绩还未知。在同学们都抱怨 C++ 考试的题量大、考题难度高的时候,和大家的反应不同,我并没有什么感觉。虽然这并不意味着我能考得很好,说不定一个星期后成绩一出我就会被归类成没有任何资格评价 C++ 的人,但是我还是想写下这篇博客。
我接触 C++ 相当久了,但是我真的不再喜欢 C++ 了,这门语言实在太让我失望,甚至愤怒。在 C++ 的考试过后,除非迫不得已,不然我可能再也不会碰 C++ 了吧。最近又和朋友一起讨论,讨论后更加愤慨,于是决定写下这篇文章。
极致的心智负担
C++ 这门语言给我带来了极高的心智负担。每次写 C++,我都担惊受怕。其实,很多东西都可以由编译器自动完成吧。为了实现 100% 零成本且透明的抽象,C++ 把太多的东西都丢给开发者做了。
构造函数和赋值运算符重载
学过 C++ 的人都知道,C++ 语言中有一般的构造函数、拷贝构造函数、移动构造函数、拷贝赋值运算符重载和移动赋值运算符重载这五大件。每当我用 C++ 写下一个类,我都要反复思考它是否可以拷贝,是否可以移动,并为它写下可能不会使用几次的两种构造函数和赋值运算符重载。通常,我都会因为懒而只写一个移动构造函数,但是这也已经够累了。我们需要在移动构造函数里将原对象的值全部拷贝过来,然后要对原变量进行清除,还必须在析构函数里保证一个已经被移动了的对象仍能被正确析构,这是一件十分累人的活。
不确定的特性
其实,我通常是不需要写移动构造函数的,但是,因为标准对一些特性的说明飘忽不定,为了保障代码能够在尽可能多的编译器下正常工作,我不得不写移动构造函数。
比如,直到 C++17 前,没有人保证 RVO 的实施。在最新版本中,如果一个返回一个对象的函数直接返回一个纯右值,那么这个对象会直接在接收这个对象的内存上构建。具体是什么意思呢,请看下面的代码:
struct A {};
A make_a() {return A();}
/*
...
*/
// 返回值不会被复制和移动,而是直接在 value 所在的内存上构造
A value = make_a();
这个特性可能从 C++11 起就有了,但是直到 C++17 才被保证实施。也就是说,如果我用较旧的 C++ 版本,我不知道这个时候我是否需要写移动构造函数!为了保证程序的可移植性,我不得不写移动构造函数。相比之下,Rust 中的结构体只要不是 Pin 的就都是可以移动的。
“软弱”的标准委员会
委员会始终不肯强硬地统一一些标准,无疑给开发者带来了很多隐形负担。我十分憎恶 C++ 的标准委员会——它太过保守,没让编译器的行为更加统一,导致开发者的心智负担被严重提高。
此外,虽然不完全是标准委员会的问题,但是声称自己的代码是跨平台的 C++,其实没有一个统一的ABI。MSVC 和其他编译器各用一个 ABI,导致一个库会因为操作系统和编译器的不同分出好几个构建。新标准中,模块功能也有很多种实现,并不通用。在这里我并不想批评微软,似乎也不能怪罪标准委员会,思来想去只能对着 C++ 本体发脾气。
现代而古老的语言
C++ 已经诞生了相当久了,这么多年来积累的语法,就算有些是不合理的,也不可能删掉了。
太过透明导致太过复杂
在 C++23 时,C++ 的 \lambda 表达式已经可以同时涵括四种括号了:
// 这是一个合法带模板的 lambda 表达式
[]<class T>(T) {};
为了让开发者可以自由地设定捕获方式,引入了方括号;为了让开发者在 \lambda 中可以使用模板,引入了尖括号;为了表示参数列表,引入了小括号;表达式中还有一个函数体,因此大括号也不能少。至此,我们得到了一个奇丑无比的包含四种括号的表达式。虽然这是透明的妥协,但不能否定它的丑,我也不好怪罪谁,只能在这发发牢骚了。
太过想要表现导致无法回头
大家可能听说过 C++ 的 std::vector<bool>
。
这句话很搞笑,但却是真的。当初标准委员会想要借用 std::vector<bool>
体现出 C++ 的高级特性——模板特例化,为 bool 类型的 vector 设定了特殊实现。在这个实现里,每个 bool 按位存储,通过 vector 的各种操作函数得到的并不是 bool,而只是一个代理。这和其他的 vector 完全不同——使用其他的 vector,你可以随意 vec.data()
,可以随意读写,但是在 std::vector<bool>
这里,你什么都做不到,你只能去找 std::deque<bool>
。
与之类似的还有 std::ofstream::operator<<()
这种流处理的运算符重载。它并不好用,并不整齐,但是标准委员会为了体现 C++ 的独特功能而做出了这种东西。
太过追求质朴导致晦涩难懂
这一段笔者已经很久没接触,记忆有些不清楚,但仍希望说明。
C++ 太追求质朴了。如果你希望实现 if (自定义对象非空)
的功能,你可以:
- 为对应类实现
operator bool
- 为对应类实现
operator nullptr_t
而 (似乎) 没有其他方法。
但是因为隐式类型转换,可能会导致意料之外的行为,比如:
// 或许我们想取地址,但是忘记加取地址符,本该报错的代码提供了一个 nullptr
void *p = obj;
char c;
char *pc = &c;
// pc = pc + 1,这也合理吗?
pc = pc + obj;
// 或许在 C++ 里不该有隐式类型转换,这破坏了强类型,但是我实在是懒得再写文字批评这点了
虽然都太过极端,都太过追求完美,但也都十分奇怪吧。但是其实 C++11 引入了 explicit 的转换,让我们免于受奇怪行为的折磨。但是我觉得最好的方案仍是——不使用这种功能。不过,标准库中仍在使用这种功能。
极端保守的委员会
从上文可以看到,曾经的标准委员会确实给我们带来了不少让人惊喜的功能,虽然这些功能不被所有人喜欢,但还是很有前瞻性。可是如今,委员会却一失以往的激进,转换为保守的象征。
我曾经也很期待 std::executor
,可是它让我等太久了。写着难用的 std::promise
,实在是体会不到什么美好。直到我使用 JavaScript 写了一些脚本后才发现,原来异步代码可以写得这么流畅。相比之下,C++ 标准委员会认为标准库只该提供最基础的功能,甚至连一些需要已然普及的硬件支持的功能都不肯加入。有的提案从 C++17 就提出,但是到 C++26 还未能加入到标准,实在是太令人失望了。我只能从期待,到偶尔关心,再到后来的不抱期望,转而使用其他库了。或许使用其他库才是我本来该做的吧。
混乱的包管理
C++ 有很多构建方式,比如 msbuild、Makefile。而 CMake 则是跨平台的标准,然而他极其难用,这点是被许多人所认同的。但是大家依旧在用,只有它对库的支持是最广的,这让我十分难受。虽然有 vcpkg、xmake 这些相当好的替代品,但是不知为何他们就是不能成为主流。和 npm、cargo 比起来,我真的十分羡慕他们能够有的生态。C++ 的包管理 (以及以前没有模块时头文件-源文件的项目结构) 可以说带来了相当的灵活性。可是,灵活性的代价就是丑陋吗?我不想再看到混乱的 C++ 代码和混乱的 C++ 项目结构了。
此外,都说写 C++ 的人有很大概率是性能偏执狂,我觉得我也会是了。选择库的时候,会在意它实现的性能,性能不高、实现不优雅、命名不规范的都不会用。可是当这个库满足这些条件时,他却不好用了——和标准库一样,简陋难用。
理想和现实的分别
总感觉 C++ 的版本更新实在是没有意义,新内容只存在于 C++ 狂热者的新玩具中。另外,C++ 还缺乏统一的编程范式——对,它太自由了,自由到每个人的代码都有自己的风格,没有统一的“这样写就不会错”的代码范式。这两者相结合,导致每个公司都在用老版本的 C++,不使用一点标准库,而是完完全全地使用自己的代码风格、自己的代码库——这一点也不优美统一,完全与我所学习的内容脱节。
我从书中学到,我应该尽可能地使用引用,避免使用指针。因此,我实现了移动构造函数,让对象尽可能不被指针化传递。同时,我使用智能指针,保证资源在恰当的时间被释放,不会发生内存泄漏的问题。但是,现实确是,实践中要用到的 C++ 版本不超过 C++11、完全不需要使用移动语义,因为只需要进行指针传递。同时,学习的标准库用法也完全没有意义,因为很多情况下项目根本不允许使用 STL 和标准库!甚至吧,模板也是不会用到的,我根本不需要担心 virtual 函数的性能消耗,也不需要担心模板带来的体积膨胀。
那么,我到底是为了什么学习 C++ 呢?我是否应该把 C++ 当成 C with class 来使用呢?理想破灭之后,又有什么可以让我接着学习呢。
结语
C++ 编程不仅让我过度担心代码的可工作性,给我极高的心智负担,还要我用着古老又现代的别扭语法消耗我的精力,用看似美好实则简陋的标准库消耗我的期待。世界上的 C++ 使用实践则是打破了 C++ 在我心中的最后的一点优雅幻想,指出了我在 C++ 中的大多学习和对优雅的追求都毫无意义。
种种的种种,在我脑中旋转,让我对 C++ 再也喜欢不起来,再也不想碰了,我开始逃避。这个学期的数据结构课上,我全部作业都是用 C 语言写的,一点 C++ 也没用上。
但是我不能再逃避了,我还是得说出来:
对不起,C++,和你接触那么久,我终于是不喜欢你了。
分手吧,C++。