【编者按】一直以来,C 和C++都是非常优秀的编程语言。不过,两种语言虽名称有些相似,但应用场景存在巨大的不同。对于 C 语言而言,其主要被用于操作系统、容器、物联网、数据库等领域的开发,而 C++ 则是开发桌面软件、图形处理、游戏、网站的最佳工具。在本文中,作者原以为 C++ 在开发基础设施时会更胜一筹,然而经过与 C 语言的尝试对比,发现事实并非如此。
以下为翻译正文:
首先声明,在整个职业生涯中,我一直在使用C++,而且在做大多数项目时,C++仍然是我的首选语言。
因此,在开始构建个人项目ZeroMQ(可伸缩的分布式或并发应用程序设计的高性能异步消息库)时,我也选用了C++,主要原因如下:
C++会强制我在编程风格上保持一些基本的统一性。例如,this参数不允许使用几种不同的机制将指针传递给正在处理的对象,而这个问题在C项目中很常见。同样,不可以明确将成员变量标记为私有,此外还有C++的一些其他特征。
使用C语言实现虚函数非常复杂,会导致理解和管理代码的难度加剧。不过,严格来说,这个问题其实是上一个问题的一个子集,但我觉得有必要单独指出。
最后,每个人都喜欢在代码的末尾自动调用析构函数。
然而,事到如今,我不得不承认C++是一个糟糕的选择。下面,我来解释一下原因。
首先,我的个人项目ZeroMQ是一个持续运行的基础设施,永远不应该出故障,永远不应该表现出未定义的行为。因此,错误处理至关重要,必须做到明确且严格。
然而,C++的异常处理并不能满足我的需求。如果程序不会出错,那么选择C++没有任何问题,只需将main函数包装在try/catch中,集中在一个地方处理所有错误。
如果你的目标是保证不会出现未定义的行为,那么C++的异常处理就会变成一场噩梦。由于C++解耦了异常的发生与处理,因此错误处理非常容易,但也造成了你几乎不可能保证程序永远不会运行未定义的行为。
在C语言中,错误的产生和处理是紧密结合的,在同一块源代码中。因此,在出错时很容易理解发生了什么:
int rc = fx ();if(rc != 0) handle_error ();
而在C++中,你只能抛出错误,却不清楚究竟发生了什么:
int rc = fx ();if(rc != 0) throw std::exception ();
问题在于,你并不清楚在哪里处理异常。处理错误的代码在同一个函数中会更加方便理解,尽管不太方便阅读:
try { ... int rc = fx ();if(rc != 0) throw std::exception ("Error!"); ... catch (std::exception &e) { handle_exception (); }
然而,我们来考虑同一个函数抛出两个不同的错误,结果会怎么样:
class exception1 {}; class exception2 {}; try { ...if(condition1) throw my_exception1 (); ...if(condition2) throw my_exception2 (); ... } catch (my_exception1 &e) { handle_exception1 (); } catch (my_exception2 &e) { handle_exception2 (); }
以下是等效的C代码:
...if(condition1) handle_exception1 (); ...if(condition2) handle_exception2 (); ...
相较之下,C语言更加方便阅读,而且编译器也会生成更高效的代码。
然而,C++的问题还不仅限于此。考虑某个函数会引发异常,但不会处理异常的情况。在这种情况下,错误的处理可以放到任何地方,具体取决于从哪里调用该函数。
针对不同的情况,采用不同的方式处理异常?这种方法听起来似乎很有道理,但很快就会变成一场噩梦。
在修复某个Bug时,你会发现许多其他地方也有相同的Bug,因为它们都复制了同一段错误处理代码。每当添加一个函数调用,就有可能增加一个新异常,如果调用函数的代码没有妥善处理该异常,就意味着增加了一个新Bug。
如果你还想坚持“没有未定义的行为”原则,就不得不引入新异常,以便区分不同的故障模式。但是,添加新异常就意味着,它会上升到不同的地方。你必须在所有地方添加相应的异常处理,否则就会出现未定义的行为。
看到这里,你可能想说:这就是异常的正确用法啊?
然而问题在于,异常只是一个工具,目的是用更系统的方式管理呈现指数增长的错误处理代码,但它并不能解决根本的问题。甚至可以说,异常有可能导致情况恶化,因为你不仅需要编写新的异常类型,还需要针对新类型编写异常处理代码。
考虑到上述问题,我决定使用C++,但不使用异常。如今我的这个项目就是这样实现的。
不幸的是,问题并没有就此止步……
考虑一下,如果对象的初始化失败,会发生什么?构造函数没有返回值,因此只能通过抛出异常来报告失败。但是,我决定不使用异常。所以,我们必须像下面这样处理:
class foo { public: foo (); int init (); ... };
在创建实例时,会调用构造函数(这个函数不会失败),然后调用init函数(这个函数可能会失败)。
与C语言相比,C++代码更复杂:
struct foo { ... }; int foo_init (struct foo *self);
然而,C++代码真正的问题在于,如果开发人员在构造函数中编写一些代码,会发生什么?
在这种情况下,会出现一个特殊的新对象状态。由于对象已构造,但尚未调用init函数,因此是“半初始化”状态。我们应该修改对象(特别是析构函数)来处理这个新状态。这意味着,给每个方法添加新条件。
有人可能想说,这还不是因为你人为地添加了不使用异常的限制?!如果构造函数中抛出异常,C++运行时会正确地清理对象,不会出现“半初始化”状态。
话虽如此,然而问题在于,如果使用异常,如上所述,就必须处理所有与异常相关的复杂性。对于一个需要在遇到故障时表现出优秀的健壮性的基础设施组件来说,这不是一个合理的选择。
此外,即使初始化没有问题,对象的销毁也绝对会遇到问题。你不能在析构函数中抛出异常。这可不是我强加的人为限制,而是因为如果在进程中调用析构函数,或者恢复栈时恰好抛出异常,就会导致整个进程崩溃。
因此,如果销毁可能失败,你就需要两个单独的函数来处理它:
class foo { public: ... intterm (); ~foo (); };
这就遇到了与初始化相同的问题:一个“半终止”状态,我们必须以某种方式处理,向各个成员函数添加新条件。
class foo { public: foo () : state (semi_initialised) { ... } intinit() {if(state != semi_initialised) handle_state_error (); ... state = intitialised; } intterm() {if(state != initialised) handle_state_error (); ... state = semi_terminated; } ~foo() {if(state != semi_terminated) handle_state_error (); ... } intbar() {if(state != initialised) handle_state_error (); ... } };
与之相比,C语言的代码如下。其中只有两种状态。未初始化对象/内存,我们无需担心上述问题,而且结构可以包含任意数据。而且只要对象进入已初始化的状态,就可以正常工作。因此,对象中不需要状态机:
struct foo { ... }; intfoo_init() { ... } intfoo_term() { ... } intfoo_bar() { ... }
考虑一下,如果在上述代码中添加继承,会发生什么。C++允许将基类初始化为派生类构造函数的一部分。如果抛出异常,就会破坏已成功初始化的对象:
class foo : public bar { public: foo () :bar() {} ... };
然而,一旦引入单独的init函数,状态的数量就会开始增长。除了未初始化、半初始化、初始化和半终止状态之外,你还会遇到这些状态的组合。你可以想象一个基类已完全初始化、但派生类半初始化的对象。
对于这样的对象,几乎不可能确保其行为不出问题。对象的半初始化和半终止部分有很多不同的组合,并且鉴于它们只在非常罕见的情况下才会引发故障,因此大多数相关代码可能未经测试就进入了生产。
综上所述,我认为,如果你的需求是不允许出现未定义的行为,则不适合面向对象的编程。这个问题不仅限于C++,任何具有构造函数和析构函数的面向对象语言都不适合。
因此,更适合面向对象语言的项目是:对开发速度有要求、但对“不存在未定义的行为”没有太高要求。
这个问题没有灵丹妙药。系统编程选择C语言更为合适。
审核编辑:汤梓红
评论
查看更多