原文链接:IS C++ DOOMED? – Tednesday Games
译者 | 章雨铭 责编 | 张红月
我在闲暇时用C++写了一个连续队列(https://github.com/Tednesday/cpp-contiguous-circular-queue )。下面就那次练习,与大家分享下我的想法。
在我写过的众多数据结构中,我从来没有写过一个“idiomatic”。于是我开始思考,使用这些正确的方法真的可行吗?在涉及到一些特别琐碎和细微的操作时,比如移动语义,隐藏的复制语义、运算符重载、复杂和隐含的初始化逻辑、异常安全等等,会导致产生的结果组合过多,于是编写基本数据结构变得相当困难。而且最大的问题是,你不得不关心这些问题,因为所有的事情都是有关联的,最终会产生连锁反应。
简单的问题往往会导致复杂的麻烦。
比方说,我想知道一个构造函数是否失败。有两个选择,一个是传递一个in-out参数,另一个是抛出一个异常。选择前者会令人恼火。
因为你多添加了一个参数,复杂了初始化,(不要触及不同类型的初始化)所以我们抛出一个异常来代替。
MyConstructor() {
/* do stuff */
if (something_bad) {
throw exception;
}
}
那么恭喜你,你刚刚放弃了STL的很多功能。你不情愿地重新打开它们。然后想使用C++惯用语,对吗?但是异常会造成性能损失。所以理想情况下,我们想把它们关掉。所以我们关掉它们,并且接受我们无法真正确定一个构造函数是否失败。
现在有一个问题,每一个操作都可能抛出一个异常。这意味着你需要写代码,用RAII逻辑来包装每一个可能的信息源。即使它会使程序更加复杂。
int my_function() {
FILE file = open_file();
do_stuff_a(file);
do_stuff_b(file);
c = d;
close_file(file);
}
上述例子非常简单。但这不是一个异常安全问题。do_stuff_a, do_stuff_b和c = d可能会抛出一个异常,因此我们需要封装FILE,以便在出现异常时正确地销毁它。
class FileWrapper {
FILE file;
FileWrapper() {
file = open_file();
}
~FileWrapper() {
close_file(file);
}
}
int my_function() {
FileWrapper file_wrapper;
do_stuff_a(file_wrapper.file);
do_stuff_b(file_wrapper.file);
c = d;
}
看起来不错吧?但并非如此。所有的逻辑都包含在一个函数的范围内,而我们现在有一个额外的包装类和两个额外的函数。虽然我认为这不助于理解。(这是个有点微不足道的例子)
这也引起了新的挑战。我应该跳到哪里?哪些对象会被销毁?我怎么知道哪些操作会被抛出?现在突然间,原本很简单事情就变得复杂了许多。
总之,重点是,一些简单的事情也做不了,比如检查一个构造函数是否失败,而不使用异常。而使用异常意味着对任何自定义数据类型的RAII的完全承诺。现代的C++惯用语并不是随心所欲的,它像一份完整的大餐,无论你喜欢与否,你都要吃它。
另一个问题是复制和移动语义。以返回值优化(RVO)为例。
它能使函数调用更符合人体工程学,但它扰乱了拷贝语义。现在,调用一个函数所产生的副本与普通的副本含义不同,即使它们看起来是一样的。而这是假设RVO总是有效的(但它并没有)。
当我们把复制过程中产生的许多可能性考虑进去,我们就需要花大量的时间来了解,在程序的一些最简单的操作中到底发生了什么。它移动了吗?它复制了吗?它被省略了吗?它需要被移动吗?它需要复制吗?这个任务还在做什么?它为什么抛出?
而当涉及到实现移动和复制构造函数时,你会觉得自己肯定做错了什么。只要看看有多少个值类别就知道了https://en.cppreference.com/w/cpp/language/value_category
这就是设计中的摩擦看起来像什么。当设计中的两个不同的部分不断地相互抵触时。当本应只做一件事的东西却做了N多事,而且好处并不明显。我们真的需要在基于值的复制语义之上的移动语义吗?拷贝消除真的是对的吗?拷贝的最初含义现在已经发生了实质性的变化,这是一件好事吗?
该观点是,所有这些复杂性都是有必要的且有用的。它更有利于解决问题,减少复杂性。
原则上,我并不反对这一点。但是有两个问题。
主要问题是,这个程序语言保证 "你不会为你不使用的东西付费"。挑选最能解决你当前问题的功能,应该是C++的全部卖点。不幸的是,很多这些功能在设计上都是相互渗透的。
第二个问题是,要想写出满足这些要求的代码,简直太复杂了。我反对这样的说法,即复杂性是必须的。不是的。只有在你完全依赖 C++惯用语的情况下才需要。它之所以如此复杂,是因为每一个新的功能都在试图修复上一个功能的错误,这就是为什么功能之间存在着很大的依赖性。在我看来,这并不是一件好事。语言的复杂性无助于降低领域的复杂性。
在现代编程语言的设计有一种趋势,试图转移复杂性,使其只存在于接口后面。
在C++中,这表现为库实现者做所有繁重工作的形式。复杂性隐藏在接口后面,编写正确的、习惯语的数据结构很快就变成不适合普通人做的事。
这令人蒙羞,因为编写自定义数据结构是非常有用的,我们应该鼓励每个人都这样做。但在C++中,它不再是第一类公民,因为多年来不断增加却从未减少东西,造成了不必要的和失控的复杂性。
试着步入一个STL实现。标准类型应该能被合格的C++程序员所理解。如果代码不容易被理解,那是语言的失败。
我对未来的发展没有什么特别深刻的印象。许多新的建议完全是离奇的,脱离了看得见的现实。当然,在你意识到所遵循的设计理念原本就是这样的后,他们就只是在实现当前流行的编程方式。
这些提案似乎更依赖于研究经费、网络名声和自我,而不是朝着一些明确的设计目标努力。
从专业角度讲,这些设计决策很花钱。我付出了很多金钱和时间在摩擦和复杂性上。这就是为什么对那些不能达到我最终目标的设计方法,我提不起兴趣的原因。
我的工作不是编写满足任意标准的完美程序。我的工作是用工具制作东西。语言是达到目的的手段,而不是目的本身,除非C++意识到这一点,不然它是注定要失败的。