用C++写Java Style程序

来源:互联网 发布:淘宝标题营销词汇 编辑:程序博客网 时间:2024/04/29 04:10

前言

故事的起因源自于一项“翻译”工作,工作内容是将门户Java版自动切换客户端改写成C++版。然而起始阶段“翻译”过程并不顺畅,原因是虽然两种语言语法类似,但仍有一些本质上的区别很难“直译”。就如同我们在翻译英文文章的时候总会发现有些单词很难直译成中文对应物,于是要么生造一个词、要么就得绕个圈子才能解释清楚。除此之外,我,一个用了很长时间Java后来又转为C++开发的人来说,始终割舍不下Java那优雅的线程模型、所有变量(除了基本数值变量)都是引用的编程理念、只管new不需要delete的傻瓜式内存管理、实用的静态初始化区块……,我一直想不明白为何C++不内置线程支持、垃圾回收这些现代编程语言特征,类似google的GO语言那样。所以我在开发过程中一直在C++世界寻找可以让我在写代码时更贴近Java习惯的替代品。经过一段时间的摸索实践,我总结了一些经验和范例,使我可以在享受Java语法功能方面简洁优雅的同时不失C++的强大控制力和高效性。

1 线程模型与锁
1.1 线程模型Java的线程模型是在语言层面就支持的。通常我们可以通过两种方法来定义一个线程:直接继承Thread类并覆写run()方法或实现Runnable接口并实现run()方法。run()里面存放的是逻辑相关代码,调用start()即可使线程启动。而C++在语言层面并未支持线程模型,而需要使用额外的库来实现线程功能(比如pthread)。于是你就会很烦躁地看到pthread_create()、pthread_exit(),、pthread_join()等一堆函数和一些更令人烦躁的属性设置(pthread_attr_t)。Oh My God!还我简洁的线程模型!!

上帝和骂街解决不了问题,我们自己动手实现一套类java的线程模型。首先定义接口IRunnable,纯虚函数run()用于实现类填充逻辑。

 

第二步是定义线程基类,里面含有我们熟悉的run()和start()。我们自定义的线程类只需要继承自AbstractThread,实现run()方法就可以了。于是烦躁去无踪,简洁清爽的感觉又回来了。不过这样一来,我们自定义线程类还想继承其他类的话就得使用多重继承,这是C++中很容易误用和导致错误的部分,后续的代码需要特别注意。这里还有一个需要注意的点是代码最后的那个互斥锁,每个线程内部都应该有一把锁作为线程内部的同步控制之用,java的synchronized关键字实际上是使用了基类Object中的互斥锁实现的,我们需要额外写一个。

 

1.2 锁Java语言在jdk1.5以前用的是一种简单的同步模型,即整个Java世界的基类Object内定义了一把互斥锁,于是所有Java类就都可以用这把锁进行同步控制。具体操作上是用synchronized关键字修饰的类的成员函数、{}括起来的代码块,于是被修饰的函数和代码块就可以隐式利用Object的互斥锁进行线程同步。划时代的jdk1.5引入了单独的线程包java.util.concurrent,里面包含了多种新的线程模型、线程安全容器、锁等等工具,极大地提高了java线程工具的可用性。

同线程模型一样,C++语言本身并不包含锁的功能,我们只能借助于外部库(如pthread)来实现锁的功能。仍然是一堆烦人的函数,我们简单的包装一下,希望把它变得更好用一些。

1.2.1 读写锁


简单包装了一下,避开了pthread_XXX系列函数并适时一些异常,登时感觉好用了很多(或者是心理作用……)。

1.2.2 互斥锁


类似的封装思路,看起来很上流,不过总觉得还有点什么事让我们放不下心来,我们下一节讨论。

1.2.3 锁的安全释放
java里面有一个很好用的异常处理范式:try…catch…finally,其中java保证无论try块中是否抛出异常,都会执行finally块中代码,这就给了我们一个机会在异常出现之后进行一些常规的清理动作,如关闭数据库连接、释放锁等等。然而C++没有finally块,所以如果我们在函数中加了锁,一旦发生异常,我们必须花很大力气+把代码搞的面目全非才能保证把锁安全的释放掉。如何才能更优雅的完成这项艰巨的任务?

首先我们复习一下C++异常处理部分的一个特性:C++保证,在函数抛出异常的时候,异常抛出点之前声明的所有临时变量都将被析构。利用这一性质,我们只要把要同步的代码用{}包裹起来,在代码块的起始部分声明一个锁的Wrapper对象(我们定义为LockWrapper),在程序执行完这段代码或抛出异常的时候,LockWrapper的析构函数将被调用,这时我们有机会在析构函数中把锁释放掉。

 

于是,我们在执行某一互斥操作的时候就可以像下面这样写。简洁,优雅,安全……

 

1.3 原子计数器很多时候(如生成协议的sequence)我们都需要一个线程安全的计数器,但如果为了线程安全在一个不断++的int变量前加个互斥锁就感觉有些太重了,但是不加有时候会引起很多烦恼。java.util.concurrent包里包含了各种类型线程安全的计数器(AtomicInteger)和数组(AtomicArray),着实令人眼馋哪。C++就没那么幸运了,曾经有人说++i在很多平台上是原子操作,但实际测试了一下发现并非如此,所以我们没法偷懒还得自己动手丰衣足食。

基本思路是仿照linux内核的同步方式,用嵌套汇编的方式来解决问题。代码很简单应该无须解释,++、--和清零操作都有了,作为一个线程安全的计数器足矣。这里需要注意的是,由于用了80x86的汇编,我们这段代码无法移植到其他平台上,但是鉴于公司的服务器从硬件到系统都是统一部署配置的,这个问题应该不用太过担心。

 

2 引用与内存管理
在Java的世界中,除了基本数值变量之外,所有的类变量都是引用。Java的引用概念和C++的有本质的不同,C++的引用是指变量的别名,而Java中的引用我们可以理解为指针的Wrapper,而且是线程安全的。在内存管理方面,众所周知Java的一个最大卖点就是垃圾回收机制,并且随着虚拟机垃圾回收算法的不断改进,垃圾回收对于系统性能的影响越来越小。没用过Java的人也可以想像得到,只管new不管delete还是相当爽的,同时我们不必担心忘了delete某些资源而造成的内存泄漏,也不会因为对Raw指针的错误操作而把内存写坏。在C++里我们也可以拥有自动化的内存管理吗?答案是boost::shared_ptr<T>。

2.1 shared_ptrshared_ptr实际上也是一个Raw指针的wrapper,它通过引用计数的方式来管理所指资源的生命周期。即每当shared_ptr被copy的时候,所指资源对象的引用计数就加1;当shared_ptr对象析构的时候,所指资源对象的引用计数就减1,当引用计数为0的时候,shared_ptr将调用删除器(缺省直接delete指针)将所指资源对象删除。于是我们就可以放心大胆地new出对象,塞入到shared_ptr里,然后让shared_ptr帮我们管理资源对象的生命周期。我们从此再不用担心因为只顾new忘了delete或者抛异常没来得及delete而造成的问题,大大降低程序出现内存泄漏的机会。这也正是《effective C++》的作者对boost库中只能指针推崇备至的原因。正因为它是如此有用,我们更有必要深入了解一下它的局限性、潜规则,避免因为误用而导致的问题。

2.1.1 循环引用
其实引用计数也是一种最简单的垃圾回收算法,但是它的一个很大的缺陷在于可能存在循环引用。而一旦形成循环引用,环上的所有资源对象的引用计数永远不会是0,shared_ptr也就没法帮我们正确删除那些已经没用了的资源对象,于是内存泄漏再次发生。不过幸好我们可以用weak_ptr来帮助我们解决部分问题,看下面的例子。

 

本例中shared_from_this()是获取this指针的shared_ptr,我们后面再说。考虑parent_,如果把weak_ptr改为shared_ptr的话会有什么问题?对,答案是循环引用。weak_ptr是shared_ptr的观察者,在把shared_ptr赋给weak_ptr的时候引用计数是不会加1的。所以shared_ptr配合weak_ptr可以帮助我们解决循环引用的困扰。但必须强调的是,我们首先还是要能看出程序中有类似上述例子中的问题,才能对症下药用shared_ptr配合weak_ptr加以解决,但如果我们没看出来呢?还是要自己小心些才行……

Java在应付循环引用的一堆对象的时候就会比较智能,垃圾回收器会根据某种算法找到一些“跟对象”,然后根据这些跟对象顺藤摸瓜找到所有正在被使用的对象。而剩下的那些“对象孤岛”自然就是可以被回收掉的。这就避免了循环引用带来的问题。当然这是题外话,与shared_ptr无关。

2.1.2 混用Raw指针和shared_ptr带来的问题
第一个例子如下图所示,先new了一个指针出来,然后赋给一个在括号作用域内的shared_ptr变量。在作用域结束之后shared_ptr析构,引用计数为0,p所指向的内存被清空,于是在最后一行再使用p的时候将会core掉。

 

第二个例子稍微复杂一些,主要是p4在析构时删掉了资源。导致后面p1,p2析构之后又再次删除已经析构过的指针导致异常。

 

这里总结一点就是既然用了shared_ptr,那就信任它,把资源对象的生命周期管理完全交给它。我们不应再对Raw指针进行额外的操作,既不要把它取出来用也不要把它再赋给其他shared_ptr。如果是因为此原因导致问题,那不是shared_ptr的错,而是我们确实误用了。

2.1.3 资源对象获取this指针的shared_ptr
this指针是一个比较特殊的指针,由于shared_ptr是一种非侵入式(不知google之)的管理方案,资源对象本身对于自己的引用计数毫不知情,所以如果资源对象的方法中要获取一个指向自己的shared_ptr,就需要做一些额外的处理。如下所示,CResouce就可以在成员函数中用enable_shared_from_this::shared_from_this()获取指向自己的shared_ptr了。

 

2.1.4 用临时的shared_ptr当参数带来的问题
考虑下面的代码有啥问题:

void test()

{

foo(boost::shared_ptr<MyObj>(new MyObj()),g());

}

由于C++并不保证函数中参数表达式的执行顺序,所以如果例子中执行顺序是new MyObj、g()、构造shared_ptr,则g()抛异常的时候MyObj就会泄漏。《effective C++》作者建议这样写:

void test()

{

boost::shared_ptr<implementation> sp (new MyObj());

foo(sp,g());

}

2.1.5 shared_ptr与多态
多态是面向对象的三个基本概念之一,我们可以采用多态技术实现接口与实现的分离。但是shared_ptr并不直接支持多态。比如有类Father和Child,Child继承自Father。我们不能直接这样写:

shared_ptr<Father> p1(new Father);

shared_ptr<Child> p2 = p1;// 编译错误。

为了实现多态的目的,我们必须进行一次显示的类型转换:

shared_ptr<Father> p1(new Father);

shared_ptr<Child> p2 = static_pointer_cast<Child>(p1);// 编译错误。

这是一次有代价的转换,但是换来了灵活性。

2.1.6 执行效率
我们可以想象得到,既然用了资源对象外部的引用计数,就不可避免地要进行同步操作以保证引用计数的准确性。虽然在新版本中采用了lock-free的原子整数操作一定程度上降低了线程同步开销,但是有人压测实际维护引用计数带来的开销大约占了5%的CPU,并非全无代价。如果真的在乎这5%还是要另想办法才行。

2.1.7 auto_ptr与shared_ptr
auto_ptr是stl里面自带的只能指针,它没有采用引用计数的方式,它管理对象的方式为:如果两个auto_ptr进行赋值操作,赋值一方将会把指针的控制权“转移”给被赋值一方。从源码上看是赋值一方把指针交给了被赋值一方,然后自己内部赋一个NULL。如此一来,auto_ptr就完全没办法放到stl容器中了。以vector<auto_ptr<T>>为例,如果把其中的一个值赋给外面的一个auto_ptr,则指针控制权随之转移,vector里面的值就变成了NULL,这是随时可能导致程序崩溃的事情。《effective C++》作者的说法是:遇到这种情况如果编译器能报错的话算你走运,如果没报错的话你才更应该小心。

2.2 NullPointer for shared_ptr在Java中,如果引用为空我们可以返回null,这和C++中的指针为空我们可以用NULL一样。但如果shared_ptr为空我们应该如何表达?这是很常见的需求,例如我们有个函数返回某一cache中资源的shared_ptr,有时需要给用户返回一个空智能指针表示cache中不存在改资源。直接用默认构造函数生成的只能指针里面是一个NULL,不过为了让看的人更明白,我选择这样做:

shared_ptr<MyObj> pNullInfo(static_cast<MyObj*>(0));

如果要判断pNullInfo是否为空,只需要像下面这样就行,和Raw指针用法是一样的。

if( !pNullInfo)

……

else ……

3 静态初始化代码块
Java类里面可以定义一个static代码块来进行一些必要的初始化动作。像这样:

class Foo{

static{

……//一些初始化动作

}

…….//其他操作

}

static块内的代码将在该类的对象第一次被用到的时候执行(lazy loading),很是方便。但是C++不支持这种这种写法,根据C++的初始化方法,于是我们很难对类里定义的static成员进行较为复杂一些的初始化动作。简单赋个初值还行,复杂了就没办法。通常的解决方案是写一个static的init()方法,并要求类的使用者在使用类之前一定要实现调用一下这个init来进行必要的初始化。这种方式既麻烦又不安全(多线程情况下还要加锁)。

我想到一个解决方法是在类里面生成写一个嵌套类,并声明一个该类的static成员,要进行的初始化动作可以放在这个嵌套类的构造函数里。于是,当该类被初始化的时候相应的动作也得到执行。因为C++保证:在类被使用之前,所有的静态成员都已经被初始化完毕。于是我们就可以获得java中static代码块一样的效果,写个例子如下:

 

4 参考资料:
1. shared_ptr四宗罪 http://blog.liancheng.info/?p=85

2. 关于boost::shared_ptr...高兴得太早 http://www.cppblog.com/Charlib/archive/2010/02/22/76313.html

 

本文来自CSDN博客,转载请标明出处:http://blog.csdn.net/kabini/archive/2010/12/05/6056613.aspx

原创粉丝点击