作者 | Jimmy Hartzell
策划 | 云昭
本文的作者Jimmy Hartzell是一名在公司内部教授高级C++课程的专家,却在重返“现代化”C++之后,对这门语言的改进感到非常失望。在这篇文章中,作者将重点讨论各种C++的小“毒瘤”,这些“毒瘤”的设计决策让作者大有“恨铁不成钢”之感。
作者同时也有丰富的Rust经验,但并没有将C++与 Rust 或其他编程语言进行比较,而是重点从 C++ 的角度来探讨这些看似很有技术含量的“毒瘤”究竟有没有意义。
一、“技巧”并不高大上,也不值得炫耀
重返C++开发,我自信满满,满怀期待:我仍然怀揣C++各种“吊诡”技能,并且仍然可以高效地工作,而且如今我已经使用了一种更现代的编程语言,遗留问题应该会更少。但现实是C++ 带来的刺痛更多。
Rust 中有很多我怀念的功能,都能在C++中轻松添加,比如很明显的安全功能,再比如对sum type(在 Rust 中称为 enums)或元组(tuple)的first-class支持。(澄清:std::tuple和std::variant 不是first-class的支持,这两个实际上非常的笨重。)
不过,在开始讨论吊诡的“剪纸”技巧之前,我想先谈谈我所看到的 C++ 的主要防御“措辞”之一,我发现下面的描述特别令人困惑:
C++ 是一种很棒的编程语言。这些抱怨只是来自那些不胜任的人。如果他们是更好的程序员,他们会欣赏 C++ 的做事方式,并且他们不需要他们的帮助。像 Rust 这样的语言对于真正的专业人士来说没有帮助。
显然,这种措辞有点“抓马”,但我已经多次看到这种态度。我对它最宽容的看法是,C++ 的困难正是其功能强大的标志,也是使用强大的编程语言的自然成本。然而,在很多情况下,它对我来说是精英主义的一种形式:让“弱鸡”程序员变得轻松是一个毫无意义的大众想法,而优秀的程序员不会从让事情变得更容易中受益。
作为一个在我职业生涯的大部分时间里都从事 C++ 专业编程并且教授(公司内部)高级 C++ 课程的人,这对我来说简直是无稽之谈。我确实知道如何驾驭 C++ 的许多“剪纸和避雷”的技巧,并且很高兴在处理 C++ 代码库时这样做。但尽管我经验丰富,但它们仍然会拖慢我的速度并分散我的注意力,将注意力从我试图解决的实际问题上转移开,并导致代码的可维护性较差。
至于好处,我没看到C++的这些技巧有什么高大上的。除非是遗留代码库或者仅在恰好不支持 Rust 的特定编译器中可用的优化方面,C++ 比 Rust 更高效或更合适,否则这些技巧大多用来处理跟编程语言的实际设计无关的其他问题。
虽然我为自己的 C++ 技能感到自豪,但令我感到担忧的是,这些看起来“更好的技术”可以使它们部分过时。即便拥有了让它变得更容易的功能,我也不会欣赏。在大多数情况下,这种“技巧”并不能使C++为我解决更多工作的问题,反而是C++ 创造了不必要的额外工作的问题,因为使用这些所谓的技巧本身就让你忽略了你工作的目的——不要这样做。不要让我开始研究头文件!
二、鸡肋的“剪纸”技巧,意义不大
我也希望我的编程语言对初学者友好。我总是会与其他具有各种技能的程序员一起工作,而且我宁愿不必纠正我同事的错误——或者我自己早期、更愚蠢版本的错误。虽然我也不同意为了让一种编程语言对初学者更友好而牺牲掉功能,但许多甚至大多数 C++ 对初学者不友好(并且令专家厌烦)的功能,实际上并没有使该语言变得更强大。
言归正传,以下是我在从Rust回归 C++ 开发时注意到的最大的鸡肋“剪纸”技巧。
1.const不是默认值
const当可以标记参数时,很容易忘记标记参数。你可能只是忘记输入关键字。对于 来说尤其如此 this,它是一个隐式参数:你没有时间显式地输入this参数,因此如果没有适当的修饰符,它不会坐在那里看起来很有趣。
如果 C++ 有相反的默认值,其中除非显式声明每个值、引用和指针都是const可变的,那么我们更有可能根据函数是否需要改变它来正确声明每个参数。如果有人包含mutable关键字,那是因为他们知道自己需要它。如果他们需要它但忘记了,编译器错误会提醒他们。
现在,你可能认为这并不重要,因为你可以不使用const或不拥有具有不需要的功能的函数 - 但有时你必须const在 C++ 中接受这些事情。如果你通过非引用获取参数const,则调用者只能使用左值来调用你的函数。但如果通过引用获取参数const,调用者可以使用左值或右值。因此,为了以自然的方式使用某些函数,必须通过const引用获取其参数。
一旦有了const引用,你就只能(轻松)用它调用接受const引用的函数,因此,如果其中任何函数忘记声明参数const,则必须包含const_cast – 或稍后更改函数以正确接受const。
以免你认为这只是一个草率的新手错误,请注意,标准库中的许多函数必须更新,以代替 const_iterator或补充,iterator当正确发现它们对像const_iterator: 这样的函数有意义时erase。事实证明,对于像 之类的函数erase,集合必须是可变的,而不是迭代器——C++ 库的维护者一开始就犯了错误。
2.强制Copy
在 C++ 中,可复制对象是对象行为的默认特权方式。如果你不希望对象可复制,并且其所有字段都可复制,则通常必须将复制构造函数和复制赋值运算符标记为= delete。默认情况下,编译器会为你编写代码 - 代码可能不正确。
但是,如果你确实让你的class只能移动,请小心,因为这意味着在某些情况下你无法使用它。在 C++11 中,没有符合人体工程学的方法来通过 move 进行 lambda 捕获——这通常是我想要将变量捕获到闭包中的方式。
这在 C++14 中已被“修复”——因为当你想要从一开始就应该使用默认值时,你现在可以使用极其笨拙的移动捕获语法。
然而,即便如此,祝你使用 lambda 好运。如果你想把它放在 a 中std::function,那么直到今天你仍然不走运。std::function期望它管理的对象是可复制的,如果你的闭包对象是仅移动的,则将无法编译。
这个问题将在 C++23 中得到解决, std::move_only_function 但与此同时,我被迫使用抛出某种运行时逻辑异常的复制构造函数来编写类。即使在 C++23 中,可复制函数也将是默认的假设情况。
奇怪的是,因为大多数复杂的对象,尤其是闭包,永远不会也不应该被复制。一般来说,复制复杂的数据结构是一个错误——缺少&或缺少std::move。但这是一个错误,没有任何警告,并且代码中没有明显的迹象表明正在执行复杂的、需要大量分配的操作。这是给新 C++ 开发人员的早期教训——不要按值传递非原始类型——但即使是高级开发人员也可能时不时地搞砸,而且一旦它进入代码库,就很容易错过。
3.通过引用获取参数:反人性的设计
在 C++ 中按元组返回多个值是反人性的。std::tie这是可以做到的,但是对和 的调用std::make_tuple是冗长且分散注意力的,更不用说你将不习惯地编写,这对于正在阅读和调试你的代码的人来说总是不好的。
旁注:有人在评论中提出了结构化绑定,好像这解决了问题。结构化绑定是现代 C++ 支持者喜欢引用的半途而废的一个很好的例子。结构化绑定对某些人有帮助,但如果你认为它们使按元组返回符合人体工程学,那你就错了。你仍然需要在函数返回语句中或 在函数的返回类型中写入std::pair或 。这不是最糟糕的,但它仍然不如完整的一流元组支持那么轻量级,并且还不足以说服人们不使用参数,这是我真正的抱怨。std::make_tuplestd::tuple即便如此,并不是输出参数(或输入输出参数)不好,而是它们在 C++ 中不好,因为没有好的方法来表达它们。
那么我们该怎么办呢?元组的笨重导致人们转而使用参数。要使用输出参数,你最终会通过非引用获取参数const,这意味着该函数应该修改该参数。
问题是,这仅在函数签名中标记。如果你有一个通过引用获取参数的函数,则该参数在调用站点看起来与按值参数相同:
// Return false on failure. Modify size with actual message size,
// decreasing it if it contains more than one message.
bool got_message(const char *void mesg, size_t &size);
size_t size = buff.size();
got_message(buff.data(), size);
buff.resize(size);
如果你快速阅读调用代码,则该调用可能看起来 resize是多余的,但事实并非如此。size正在被 修改 got_message,并且知道它正在被修改的唯一方法是查看函数签名,该函数签名通常位于另一个文件中。
出于这个原因,有些人更喜欢通过指针传递 out 参数和 in-out 参数:
bool got_message(const char *void mesg, size_t *size);
size_t size = buff.size();
got_message(buff.data(), &size);
buff.resize(size);
如果指针不可为空的话,那就太好了。nullptr在这种情况下参数意味着什么?它会触发未定义的行为吗?如果将调用者的指针传递给它会怎样?开发者经常忘记记录函数如何使用空指针。
这可以通过不可为空的指针来解决,但很少有程序员在实践中真正这样做。当某些东西不是默认值时,它往往不会在适当的地方使用。对此的可持续答案是改变默认设置,而不是与人性作斗争的英勇尝试。
4.方法实现可能会矛盾
在 C++ 中,每次编写一个类(尤其是较低级别的类)时,你都有责任对编程语言中具有特殊语义重要性的某些方法做出决策:
- 构造函数(copy):X(const X&)
- 构造函数(move):X(X&&)
- 作业(copy):operator=(const X&)
- 作业(move):operator=(X&&)
- 析构函数:~X()
对于许多类,默认实现就足够了,如果可能的话你应该依赖它们。这是否可行取决于简单地复制所有字段是否是复制整个对象的明智方法,而这很容易忘记考虑。
但是,如果你需要其中之一的自定义实现,那么你就需要编写所有这些。这就是所谓的“5 法则”。你必须编写所有这些,即使两个赋值运算符的正确行为可以完全由适当的构造函数与析构函数相结合来确定。编译器可以默认实现引用这些其他函数的赋值运算符,因此始终是正确的,但事实并非如此。正确实现它们是很棘手的,需要诸如显式防止自分配或与按值参数交换等技术。无论如何,它们都是样板文件,并且是具有许多此类内容的编程语言中可能出错的另一件事。
旁注:确实,许多类可以使用= default所有这些方法。但是,如果你自定义复制构造函数或移动构造函数,则还必须自定义赋值运算符以匹配,即使默认实现可能是正确的(如果语言定义得更智能的话)。通过引用5规则就可以清楚地看出这一点,它基本上说明了这一点。完整的规则在CPP参考中进行了解释。如果自定义复制或移动构造函数,相应的= default 赋值运算符将会出错。当心!请注意示例代码如何不使用= default赋值运算符,即使赋值运算符不包含逻辑。
三、C++ 做了太多错误的决策
看到 Hacker News 上的评论后,我觉得有必要添加这一部分。每当有人抱怨 C++ 中的任何问题时,就会有人提到修复该问题的较新版本的 C++。这些“修复”通常不是那么好,只有当你习惯了一切都有点笨拙时,才感觉像是修复。
原因如下:
- 默认方式仍然是旧的、糟糕的方式。例如,通过 move 捕获 lambda 应该是默认值,而std::move_only_functionC++23 中即将推出的 lambda 应该是默认值std::function。
- 出于这个原因,并且因为旧的、糟糕的方式从来没有启用警告,所以即使是新程序员也会继续以糟糕的方式做事。
当然,我知道这对于向后兼容性很重要。但这就是整个问题:C++ 积累了太多错误的决策。为什么要复制参数传递集合的默认值,更不用说 lambda 捕获了?我知道历史原因,但这并不意味着现代编程语言应该这样工作。
即使 C++11 也无法消除这样一个事实:原始指针和 C 风格数组具有良好的语法,而智能指针看起来很std::array 糟糕。即使 C++11 也无法澄清它正在围绕一种无需移动而设计的语言工作。
4、写在最后:C++痼疾难消
不幸的是,我非常清楚为什么做出这些决定,而这正是原因之一:与遗留代码的兼容性。C++ 没有版本系统,无法弃用核心语言功能。如果创建了 C++ 的新版本,它将不再是 C++ ——尽管我支持人们将 C++ 转换为新语法并清理其中一些内容的努力。
这也是唯一的好处:与历史的连续性。虽然我可以看到其中的价值,但它的价值非常有限,范围也非常有限。但是,如果你忽略掉向后兼容性和现有的大型代码库,那么这些“剪纸”技巧都不会使编程语言变得更强大或更好,只会更难使用。我看到过支持“人工维护头文件”的观点,这让我很惊讶,告诉我 C++ 在这些问题上的设计选择有什么好处。
有人可能会说这些事情微不足道,但这些都会减慢程序员的速度,同时又让他们烦恼。如果你有足够的经验,你的潜意识可能擅长驾驭这些“招式”,但设想一下,这些潜意识原本是要来注意哪些方面的。
想象一下,你在初级同事的代码审查中能很快察觉到这些错误吗?如果你是严格审核人,还需要多少时间?如果这些问题得到解决,开发者会变得更加有效、更加高效、更加快乐。编程也会变得既有趣又快捷。
原文链接:https://www.thecodedmessage.com/posts/c++-papercuts/