• C++注定要失败吗?


    原文链接:IS C++ DOOMED? – Tednesday Games

    译者 | 章雨铭   责编 | 张红月

    我在闲暇时用C++写了一个连续队列(https://github.com/Tednesday/cpp-contiguous-circular-queue )。下面就那次练习,与大家分享下我的想法。

    引言

    在我写过的众多数据结构中,我从来没有写过一个“idiomatic”。于是我开始思考,使用这些正确的方法真的可行吗?在涉及到一些特别琐碎和细微的操作时,比如移动语义,隐藏的复制语义、运算符重载、复杂和隐含的初始化逻辑、异常安全等等,会导致产生的结果组合过多,于是编写基本数据结构变得相当困难。而且最大的问题是,你不得不关心这些问题,因为所有的事情都是有关联的,最终会产生连锁反应。

    一个例子

    简单的问题往往会导致复杂的麻烦。

    比方说,我想知道一个构造函数是否失败。有两个选择,一个是传递一个in-out参数,另一个是抛出一个异常。选择前者会令人恼火。

    因为你多添加了一个参数,复杂了初始化,(不要触及不同类型的初始化)所以我们抛出一个异常来代替。

    1. MyConstructor() {
    2.   /* do stuff */
    3.   if (something_bad) {
    4.     throw exception;
    5.   }
    6. }

    那么恭喜你,你刚刚放弃了STL的很多功能。你不情愿地重新打开它们。然后想使用C++惯用语,对吗?但是异常会造成性能损失。所以理想情况下,我们想把它们关掉。所以我们关掉它们,并且接受我们无法真正确定一个构造函数是否失败。

    现在有一个问题,每一个操作都可能抛出一个异常。这意味着你需要写代码,用RAII逻辑来包装每一个可能的信息源。即使它会使程序更加复杂。

    1. int my_function() {
    2.    
    3.   FILE file = open_file();
    4.   do_stuff_a(file);
    5.   do_stuff_b(file);
    6.   c = d;
    7.   close_file(file);
    8. }

    上述例子非常简单。但这不是一个异常安全问题。do_stuff_a, do_stuff_b和c = d可能会抛出一个异常,因此我们需要封装FILE,以便在出现异常时正确地销毁它。

    1. class FileWrapper {
    2.   FILE file;
    3.   FileWrapper() {
    4.     file = open_file();
    5.   }
    6.   ~FileWrapper() {
    7.     close_file(file);
    8.   }
    9. }
    10. int my_function() {
    11.    
    12.   FileWrapper file_wrapper;
    13.   do_stuff_a(file_wrapper.file);
    14.   do_stuff_b(file_wrapper.file);
    15.   c = d;
    16. }

    看起来不错吧?但并非如此。所有的逻辑都包含在一个函数的范围内,而我们现在有一个额外的包装类和两个额外的函数。虽然我认为这不助于理解。(这是个有点微不足道的例子)

    这也引起了新的挑战。我应该跳到哪里?哪些对象会被销毁?我怎么知道哪些操作会被抛出?现在突然间,原本很简单事情就变得复杂了许多。

    总之,重点是,一些简单的事情也做不了,比如检查一个构造函数是否失败,而不使用异常。而使用异常意味着对任何自定义数据类型的RAII的完全承诺。现代的C++惯用语并不是随心所欲的,它像一份完整的大餐,无论你喜欢与否,你都要吃它。

    另一个问题是复制和移动语义。以返回值优化(RVO)为例。

    它能使函数调用更符合人体工程学,但它扰乱了拷贝语义。现在,调用一个函数所产生的副本与普通的副本含义不同,即使它们看起来是一样的。而这是假设RVO总是有效的(但它并没有)。

    当我们把复制过程中产生的许多可能性考虑进去,我们就需要花大量的时间来了解,在程序的一些最简单的操作中到底发生了什么。它移动了吗?它复制了吗?它被省略了吗?它需要被移动吗?它需要复制吗?这个任务还在做什么?它为什么抛出?

    而当涉及到实现移动和复制构造函数时,你会觉得自己肯定做错了什么。只要看看有多少个值类别就知道了https://en.cppreference.com/w/cpp/language/value_category

    这就是设计中的摩擦看起来像什么。当设计中的两个不同的部分不断地相互抵触时。当本应只做一件事的东西却做了N多事,而且好处并不明显。我们真的需要在基于值的复制语义之上的移动语义吗?拷贝消除真的是对的吗?拷贝的最初含义现在已经发生了实质性的变化,这是一件好事吗?

    反方观点

    该观点是,所有这些复杂性都是有必要的且有用的。它更有利于解决问题,减少复杂性。

    原则上,我并不反对这一点。但是有两个问题。

    主要问题是,这个程序语言保证 "你不会为你不使用的东西付费"。挑选最能解决你当前问题的功能,应该是C++的全部卖点。不幸的是,很多这些功能在设计上都是相互渗透的。

    第二个问题是,要想写出满足这些要求的代码,简直太复杂了。我反对这样的说法,即复杂性是必须的。不是的。只有在你完全依赖 C++惯用语的情况下才需要。它之所以如此复杂,是因为每一个新的功能都在试图修复上一个功能的错误,这就是为什么功能之间存在着很大的依赖性。在我看来,这并不是一件好事。语言的复杂性无助于降低领域的复杂性。

    解决方法?

    在现代编程语言的设计有一种趋势,试图转移复杂性,使其只存在于接口后面。

    在C++中,这表现为库实现者做所有繁重工作的形式。复杂性隐藏在接口后面,编写正确的、习惯语的数据结构很快就变成不适合普通人做的事。

    这令人蒙羞,因为编写自定义数据结构是非常有用的,我们应该鼓励每个人都这样做。但在C++中,它不再是第一类公民,因为多年来不断增加却从未减少东西,造成了不必要的和失控的复杂性。

    试着步入一个STL实现。标准类型应该能被合格的C++程序员所理解。如果代码不容易被理解,那是语言的失败。

    我对未来的发展没有什么特别深刻的印象。许多新的建议完全是离奇的,脱离了看得见的现实。当然,在你意识到所遵循的设计理念原本就是这样的后,他们就只是在实现当前流行的编程方式。

    这些提案似乎更依赖于研究经费、网络名声和自我,而不是朝着一些明确的设计目标努力。

    从专业角度讲,这些设计决策很花钱。我付出了很多金钱和时间在摩擦和复杂性上。这就是为什么对那些不能达到我最终目标的设计方法,我提不起兴趣的原因。

    我的工作不是编写满足任意标准的完美程序。我的工作是用工具制作东西。语言是达到目的的手段,而不是目的本身,除非C++意识到这一点,不然它是注定要失败的。

  • 相关阅读:
    SpringBoot整合RabbitMQ
    通用HttpClient封装
    Spring Cloud Alibaba Nacos 2.2.3 (1) - 下载与持久化 数据库配置
    我不建议你使用SELECT *
    /usr/bin/ld: cannot find -lmysqlcllient
    计算机网络复习-第六章应用层
    ubuntu在PowerShell的vi编辑器蓝色注释看不见
    PolarDB-X 源码解读:事务的一生
    格林公式挖洞法中内曲线顺时针的直观解释
    Linux共享内存
  • 原文地址:https://blog.csdn.net/m0_66023967/article/details/123116730