C++手爱德华

我一直在寻找一种可以类比使用C++编程时心情的东西,直到我想起1900年Tim Burton的电影,剪刀手爱德华。

在这部电影中,可怜的家伙(Johnny Depp)想温柔的拥抱Winona Ryder但是他笨拙的剪刀手对他们俩都会造成伤害。他的脸上留下了很多伤疤。

拥有一对剪刀手并不总是坏事。爱德华有许多才能,比如,它可以给狗剪除漂亮的发式。

当参加完一些C++会议后,我总会有一些想法,最近参加的是Going Native 2013。去年可喜的是大多会议围绕着闪亮的C++11最新标准。但今年更多的关于实际检查。不要误会,这里有太多的漂亮的小狗发式可以展现(我是说C++代码可以简洁优雅)但是会议的主题总是关于如何避免出错及出错后的补救。

恐怖小窝

这里有太多的关于C++禁忌的讲座使我意识到这可能不是初级程序员的问题,这明显是C++语言自身的错。所以你仅仅学习了语言的基本元素后就使用它,你会遍体鳞伤的。

C++对此有借口:向后兼容 — 特别是兼容C语言。你应该认为C作为C++的子集应该像人们不应该每天使用汇编一样,除非你是个汇编程序员。假如你对你的C++工具箱视而不见,你看到的总是裸指针、for循环等丑陋的东西。

一个人所共知的禁忌是不要使用malloc动态分配内存,不要使用free释放内存。malloc接受一个size并返回一个void*,你必须把它转换成其它有用的类型。很难实现一个比这更糟糕的内存分配API。这有一段真的很糟的代码(但一般是运行正确的,假如不可能出现空指针解引用的话):

1
2
3
4
5
6
7
8
9
10
11
12
struct Pod {
    int count;
    int * counters;
};

int n = 10;
Pod * pod = (Pod *) malloc (sizeof Pod);
pod->count = n
pod->counters = (int *) malloc (n * sizeof(int));
...
free (pod->counters);
free (pod);

我希望人们不会写出这样的代码,但我确信现实世界的好多程序的代码中有这样的结构。

C++通过替换malloc和free为new和delete”解决”了多余的强转和易出错的size计算。正确的C++代码应该这样:

1
2
3
4
5
6
7
8
9
10
11
12
struct Pod {
    int count;
    int * counters;
};

int n = 10;
Pod * pod = new Pod;
pod->count = n;
pod->counters = new int [n];
...
delete [] pod->counters;
delete pod;

顺便说一下,空指针引用的问题同样被解决了,因为如果系统内存耗尽,new总是会抛出异常。但这里在第二个new的地方还是有几率发生内存泄漏,所以这是现实中正确的代码:

1
2
3
4
5
6
7
8
9
10
11
12
class Snd { // Sophisticated New Data
public:
    Snd (int n) : _count(n), _counters(new int [n]) {}
    ~Snd () { delete [] _counters; }
private:
    int _count;
    int * _counters;
};

Snd * snd = new Snd (10);
...
delete snd;

我们做完了吗?才没呢!这段代码不是异常安全的。

C++的基本准则是避免裸指针、避免数组、避免delete。所以医治malloc的良药new也是不应使用的,它会返回危险的指针。

我们都知道(满脸的伤疤可以证明)尽一切可能使用STL容器和智能指针。也应该按值传递参数。不,等一下。按值传递参数因为多余的拷贝会增加性能损耗。使用shared_ptr或shared_ptr的容器怎么样?但这会增加引用计数的滥用。这里有新的解决方案:移动语义和右值引用。

我能举出无数类似的例子。看到规律了没?一个问题的解决方案可能会引入新的问题。不仅C子集需要避免使用。每个新的语言特性或库组建都会带来新的缺陷。当你听完Scott Meyers讲过后,你会发现一个新的特性怎么设计的这么烂(猜一下Scott Meyers论证的最新的缺陷是什么?是移动语义)。

C++的哲学

Bjarne Stroustrup一直强调向后兼容对C++来说多么重要。它是C++哲学的根基。它是决定那些代码是合法的依据。然而兼容性给语言演化代理了极大的拖累。如果自然界和C++一样是向后兼容的,人们还会有尾巴、腮、脚蹼、触角 — 在进化的过程中它们都有过作用。

C++变成了极度复杂的语言。同一件事情有无数种实现方式,但大部分都走向错误、危险、难维护。问题是代码可以编译甚至运行。错误或者缺陷会在后来被发现,甚至是产品发布以后。

你可能会说这是编程语言的自然特性。如果你真这样想,那要好好看看Haskell了。你的第一反应是:用这种极度严苛的语言我不知怎样实现第一件事(阶乘、斐波那契等)。这完全不同于C++。你不会意识到,运气好的话,需要十年你才能发现C++的“真谛”。能否想到,越好的C++程序员,他的代码越具有“函数性”。请教任何一个C++大神,它会回答你:避免可变的(mutation),避免边际效应,避免继承和派生。但是你将需要严厉的准则和可以控制你同事的能力,因为C++太宽容了。

Haskell一点也不宽容,它不会让你或者你的同事写出不安全的代码的。是的,一开始你可能因为想要用haskell实现C++几分钟可以完成的事情而抓狂。假如你走运,为Sean Parent1或类似严格的程序员共事,他将review你的代码,并指出你不要用C++编码了。或者你自己继续在黑暗的日子里,数着自己伤害自己的伤口个数。

资源管理

我以资源管理的例子开始这篇文章(严格说是内存管理),因为这是我的个人爱好。从90年代起我就开始写关于资源管理的东西和宣传它。不过失败的是20年后资源管理技术还是鲜为人知。Bjarne Stroustrup有责任花费一半的演讲时间给高级C++程序员讲解资源管理的知识。你也可能会责备初级程序员们没能领悟资源管理是C++编程的根基。但问题是语言并没有指出我一开始写的那段代码有什么纰漏。事实上学习正确的技术就好像学习新的语言。

为何如此艰难?因为C++中最重要的资源管理是内存管理。事实上需要反复强调的是垃圾收集解决不了资源管理的问题:这里有文件句柄,内核对象,打开的数据库等等。这都是重要的资源,但重要性被繁复的内存管理比下去了。为什么不支持垃圾收集,不是因为找不到一种高效的实现方式,因为C++是排斥垃圾收集的。编译器和运行时总要做最坏的打算:不仅一个指针可能是另一个的别名而且内存地址可以保存成整型甚至它的低字节用作位域(所以C++只考虑保守的垃圾收集)。

一个错误的常识是引用计数(shared pointer中使用)比垃圾收集更好。这有个研究显示它们是异曲同工的。你应该知道delete一个shared pointer可能会引发任意时间的程序暂停,这和垃圾收集的性能损耗是一样的。这不仅仅是因为一个靠谱的引用计数算法必须处理好环(cycles),并且每次引用计数到0,对象可以达到的指针都需要遍历。用shared pointer创建的数据结构可能花费很长的时间去释放,除了极简单的情况,你无法知道什么时候shared指针要清零了。

在单线程环境下小心的资源管理和使用shared_ptr还是很好的防御方式。但多线程下,麻烦来了。每一次增加减少计数都需要加锁!锁一般由原子变量实现,而不是互斥量。别傻了:使用原子变量消耗很大,这带给我C++的最大问题。

并发和并行(Concurrency and Parallelism)

早在8年以前,herb Sutter就发表了著名的声明:免费的午餐结束了!并发并不是发明在2005年。Posix线程1995年就定义了。微软在Windows95引入线程,在NT中支持了多处理器。然而并发是C++11中才有的知识。

C++11算是“白手起家”并发。它必须定义内存模型:多线程写内存时,什么时候和以什么顺序使其对其它线程可见。基于实用的考虑,C++的内存模型是从java中拷贝来的(去掉了一些有争议的数据竞争时的保证)。一句话就是,如果没有数据竞争C++程序是按次序的。

C++11定义了关于线程创建和管理的一些元素,这些同步元素都是被dijkstra和hoare在1960年定义的,比如互斥量(mutexes)和条件变量。有人可能会争辩这些是否是同步的好组件,但这没关系因为都知道他们不是可组装的(composable)。STM(Software Transactional Memory)是可组装的抽象,但这很难在命令式语言中高效及正确的实现。标准委员会有个STM学习小组,所以STM还是有机会成为标准的一部分的。但是因为C++对副作用不做任何控制,所以它会很难正确应用的。

还有一些误导和混淆是尝试去提供基于任务的并行,使用的是async tasks和非组装的futures(慎重考虑后都会在C++14中不推荐使用)。线程局部变量的标准化也使基于任务的并发很难实现。锁和条件变量也是线程相关的,而不是任务相关的。未来几年标准委员会的当务之急就是这些了:基于任务的并行, communication通道代替futures , 任务取消, 可能还有longer term, data-driven 并行, 包括支持GPU。一个微软PPL和英特尔TBB的衍生可能会被加进标准库(希望不是微软的AMP)。

预测推断所有这些可以在2015年标准化和实现。假如预测成真,我还是不相信人们会用C++实现并行编程。C++是为单线程编程设计的,支持并行编程需要革命而不是改革。四个字:数据竞争,指令型语言没有对此提供保护,可能除了D语言。

在C++中,数据默认是线程共享的、默认是可变的;函数默认是有副作用的。所有这些指针和引用为数据竞争提供了富饶的土壤。数据结构和函数在竞争上的先天不足使类型系统无法反射。在C++中,假如你有个对象的const引用,但不能保证另一个线程中不会修改它。更糟的是,一个const对象里面的引用默认是可变的。

D语言至少有深度const和不可变的概念(没有线程可以修改一个不可变的数据结构)。D语言朝着并发的另一个改良是可以定义纯函数(pure functions)。在D语言中,可变对象默认不是进程共享的。这是正确的方向,虽然增加了共享对象的运行开销。更重要的想法是,线程不是一个并发编程的好的抽象,所以这种改进在轻量级的任务序列中就不那么有效了。

但是C++对这些都不支持,并且看起来永远都支持不了。

当然,你可能认出了这些并发和并行的特征都是函数式语言具有的,特别是不可变和纯函数。别嫌我烦:Haskell是并发编程上执牛耳者,包括GPU编程。这是为什么我在布道C++技巧数载后轻易转向了haskell。每一个认真对待并发和并行的程序员都应该好好学习haskell看一下它是如何处理这一切的。这里有一本Simon Marlow写的不错的书:Parallel and Concurrent Programming in Haskell,读完它你或者开始在C++中使用函数式编程技术,或者认识到并发编程和指令式编程的分歧而转投Haskell。

结论

我认为C++语言和其哲学是和并发编程的需求冲突的。这种冲突导致并发编程在主流软件开发中缓慢前行。因为这过时的编程范式,微处理器、vector units和GPU的巨大能力被业界浪费了。

翻译自:Edward C++Hands


  1. Adobe公司的首席科学家和移动数字图像组的架构师