为何我但愿用C而不是C++来实现ZeroMQ(一)

开始前我要先作个澄清:这篇文章同Linus Torvalds这种死忠C程序员吐槽C++的观点是不一样的。在个人整个职业生涯里我都在使用C++,并且如今C++依然是我作大多数项目时的首选编程语言。天然的,当我从2007年开始作ZeroMQ(ZeroMQ项目主页)时,我选择用C++来实现。主要的缘由有如下几点:python

1.  包含数据结构和算法的库(STL)已经成为这个语言的一部分了。若是用C,我将要么依赖第三方库要么不得不本身手动写一些自1970年来就早已存在的基础算法。程序员

2.  C++语言自己在编码风格的一致性上起到了一些强制做用。好比,有了隐式的this指针参数,这就不容许经过各类不一样的方式将指向对象的指针作转换,而那种作法在C项目中经常见到(经过各类类型转换)。一样的还有能够显式的将成员变量定义为私有的,以及许多其余的语言特性。算法

3.  这个观点基本上是前一个的子集,但值得我在这里显式的指出:用C语言实现虚函数机制比较复杂,并且对于每一个类来讲会有些许的不一样,这使得对代码的理解和维护都会成为痛苦之源。编程

4.  最后一点是:人人都喜欢析构函数,它能在变量离开其做用域时自动获得调用。网络

现在,5年过去了,我想公开认可:用C++做为ZeroMQ的开发语言是一个糟糕的选择,后面我将一一解释为何我会这么认为。数据结构

首先,很重要的一点是ZeroMQ是须要长期连续不停运行的一个网络库。它应该永远不会出错,并且永远不能出现未定义的行为。所以,错误处理对于ZeroMQ来讲相当重要,错误处理必须是很是明确的并且对错误应该是零容忍的。数据结构和算法

C++的异常处理机制却没法知足这个要求。C++的异常机制对于确保程序不会失败是很是有效的——只要将主函数包装在try/catch块中,而后你就能够在一个单独的位置处理全部的错误。然而,当你的目标是确保没有未定义行为发生时,噩梦就产生了。C++中引起异常和处理异常是松耦合的,这使得在C++中避免错误是十分容易的,但却使得保证程序永远不会出现未定义行为变得基本不可能。编程语言

在C语言中,引起错误和处理错误的部分是紧耦合的,它们在源代码中处于同一个位置。这使得咱们在错误发生时能很容易理解到底发生了什么:函数

1
2
3
int rc = fx ();
if (rc != 0)
     handle_error();

在C++中,你只是抛出一个异常,到底发生了什么并不能立刻得知。工具

1
2
3
int rc = fx();
if (rc != 0)
     throw std::exception();

这里的问题就在于你对于谁处理这个异常,以及在哪里处理这个异常是不得而知的。若是你把异常处理代码也放在同一个函数中,这么作或多或少还有些明智,尽管这么作会牺牲一点可读性。

1
2
3
4
5
6
7
8
9
try {
    
     int rc = fx();
     if (rc != 0)
     throw std::exception(“Error!”);
    
catch (std::exception &e) {
     handle_exception();
}

可是,考虑一下,若是同一个函数中抛出了两个异常时会发生什么?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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代码:

1
2
3
4
5
6
7
if (condition1)
     handle_exception1();
if (condition2)
     handle_exception2();

C代码的可读性明显高的多,并且还有一个附加的优点——编译器会为此产生更高效的代码。这还没完呢。再考虑一下这种状况:异常并非由所抛出异常的函数来处理。在这种状况下,异常处理可能发生在任何地方,这取决于这个函数是在哪调用的。虽然乍一看咱们能够在不一样的上下文中处理不一样的异常,这彷佛颇有用,但很快就会变成一场噩梦。

当你在解决bug的时候,你会发现几乎一样的错误处理代码在许多地方都出现过。在代码中增长一个新的函数调用可能会引入新的麻烦,不一样类型的异常都会涌到调用函数这里,而调用函数自己并无适当进行的处理,这意味着什么?新的bug。

若是你依然坚持要杜绝“未定义的行为”,你不得不引入新的异常类型来区分不一样的错误模式。然而,增长一个新的异常类型意味着它会涌如今各个不一样的地方,那么就须要在全部这些地方都增长一些处理代码,不然你又会出现“未定义的行为”。到这里你可能会尖叫:这特么算什么异常规范哪!

好吧,问题就在于异常规范只是以一种更加系统化的方式,以按照指数规模增加的异常处理代码来处理问题的工具,它并无解决问题自己。甚至能够说如今状况更加糟糕了,由于你不得不去写新的异常类型,新的异常处理代码,以及新的异常规范。

经过上面我描述的问题,我决定使用去掉异常处理机制的C++。这正是ZeroMQ以及Crossroads I/O今天的样子。可是,很不幸,问题到这并无结束…

考虑一下当一个对象初始化失败的状况。构造函数没有返回值,所以出错时只能经过抛出异常来通知出现了错误。但是我已经决定不使用异常了,那么我不得不这样作:

1
2
3
4
5
6
7
class foo
{
public :
     foo();
     int init();
    
};

当你建立这个类的实例时,构造函数被调用(不容许失败),而后你显式的去调用init来初始化(init可能会失败)对象。相比于C语言中的作法,这就显得过于复杂了。

1
2
3
4
5
struct foo
{
    
};
int foo_init( struct foo *self);

可是以上的例子中,C++版本真正邪恶的地方在于:若是有程序员往构造函数中加入了一些真正的代码,而不是将构造函数留空时会发生什么?若是有人真的这么作了,那么就会出现一个新的特殊的对象状态——“半初始化状态”。这种状态是指对象已经完成了构造(构造函数调用完成,且没有失败),但init函数尚未被调用。咱们的对象须要修改(特别是析构函数),这里应该以一种方式妥善的处理这种新的状态,这就意味着又要为每个方法增长新的条件。

看到这里你可能会说:这就是你人为的限制使用异常处理所带来的后果啊!若是在构造函数中抛出异常,C++运行时库会负责清理适当的对象,那这里根本就没有什么“半初始化状态”了!很好,你说的很对,但这根本可有可无。若是你使用异常,你就不得不处理全部那些与异常相关的复杂状况(我前面已经描述过了)。而这对于一个面对错误时须要很是健壮的基础组件来讲并非一个合理的选择。

此外,就算初始化不是问题,那析构的时候绝对会有问题。你不能在析构函数中抛出异常,这可不是什么人为的限制,而是若是析构函数在堆栈展转开解(stack unwinding)的过程当中恰好抛出一个异常的话,那整个进程都会所以而崩溃。所以,若是析构过程可能失败的话,你须要两个单独的函数来搞定它:

1
2
3
4
5
6
7
class foo
{
public :
    
     int term();
     ~foo();
};

如今,咱们又回到了前面初始化的问题上来了:这里出现了一个新的“半终止状态”须要咱们去处理,又须要为成员函数增长新的条件了…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class foo
{
public :
     foo () : state (semi_initialised)
     {
          ...
     }
 
     int init ()
     {
         if (state != semi_initialised)
             handle_state_error ();
         ...
         state = intitialised;
     }
 
     int term ()
     {
          if (state != initialised)
              handle_state_error ();
          ...
          state = semi_terminated;
     }
 
     ~foo ()
     {
          if (state != semi_terminated)
              handle_state_error ();
          ...
     }
 
     int bar ()
     {
          if (state != initialised)
              handle_state_error ();
          ...
     }
};

将上面的例子与一样的C语言实现作下对比。C语言版本中只有两个状态。未初始化状态:整个结构体能够包含随机的数据;以及初始化状态:此时对象彻底正常,能够投入使用。所以,根本不必在对象中加入一个状态机。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct foo
{
     ...
};
 
int foo_init ()
{
     ...
}
 
int foo_term ()
{
     ...
}
 
int foo_bar ()
{
     ...
}

如今,考虑一下当你把继承机制再加到这趟浑水中时会发生什么。C++容许把对基类的初始化做为派生类构造函数的一部分。抛出异常时将析构掉对象已经成功初始化的那部分。

1
2
3
4
5
6
class foo: public bar
{
public :
     foo ():bar () {}
    
};

可是,一旦你引入单独的init函数,那么对象的状态数量就会增长。除了“未初始化”、“半初始化”、“初始化”、“半终止”状态外,你还会遇到这些状态的各类组合!!打个比方,你能够想象一下一个彻底初始化的基类和一个半初始化状态的派生类。

这种对象根本不可能保证有肯定的行为,由于有太多状态的组合了。鉴于致使这类失败的缘由每每很是罕见,因而大部分相关的代码极可能未通过测试就进入了产品。

总结以上,我相信这种“定义彻底的行为”(fully-defined behaviour)打破了面向对象编程的模型。这不是专门针对C++的,而是适用于任何一种带有构造函数和析构函数机制的面向对象编程语言。

所以,彷佛面向对象编程语言更适合于当快速开发的需求比杜绝一切未定义行为要更为重要的场景中。这里并无银弹,系统级编程将不得不依赖于C语言。

最后顺带提一下,我已经开始将Crossroads I/O(ZeroMQ的fork,我目前正在作的)由C++改写为C版本。代码看起来棒极了!

 

译注:这篇新出炉的文章引起了大量的回复,有以为做者说的很对的,也有人认为这根本不是C++的问题,而是做者错误的使用了异常,以及设计上的失误,也有读者提到了Go语言多是种更好的选择。好在做者也都能积极的响应回复,因而产生了很多精彩的技术讨论。建议中国的程序员们也能够看看国外的开发者们对于这种“吐槽”类文章的态度以及他们讨论问题的方式。

 

英文原文:martin_sustrik      编译:伯乐在线— 陈舸

相关文章
相关标签/搜索