C++编程规范(二)设计风格

这一章最有价值的条款:6.

 

5.一个实体应该有其内聚职责(Give one entity one cohesive responsibility)
这里的实体包括变量,类,函数,命名空间,模块,库等。每个实体的职责应该很清晰,不能过于分散。一个具有若干毫不相干职责的实体通常难于使用和重用,难于设计和实现,应该选择目的单一的简单函数,小且目的单一的类,边界清晰的内聚模块。
例1,标准C中的realloc就是一个臭名昭著的不良设计,这个函数承担了太多的任务:如果传入的指针参数 为NULL就分配内存空间,如果传入的大小参数为0就释放内存空间,如果可行则就地重新分配,如果不行则移到其他地方分配。这个函数不易于扩展,普遍认为它是一个目光短浅的失败设计。

例2,标准C++语言中,std:: basic_string是另一个臭名昭著的不良设计——巨大的类设计。在一个臃肿的类中添加了太多“多多益善”的功能,而这只是为了试图成为容器但却没有做到,在用迭代还是索引上犹豫不决,还毫无道理地重复了许多标准算法,而为扩展所留的空间又很小。

 

6.正确,简单和清晰第一
KISS(Keep it Simple Software):正确由于速度(fast),简单优于复杂,清晰优于机巧(cute),安全优于不安全。
简单设计和清晰代码的价值怎么强调都不过分。看看以下的经典格言:
Programs must be written for people to read, and only incidentally for machines to execute. --Harold Abelson and Gerald Jay Sussman
程序必须为阅读它的人而写,顺便用于计算机的执行。
Write programs for people first, computers second.--Steve McConnell
程序必须首先是为人而写,其后才是计算机。
The cheapest, fastest and most reliable components of a computer system are those that aren't there.--Gordon Bell
计算机系统中最便宜,最快速,最可靠的组件还不存在。
Those missing components are also the most accurate (they never make mistake), the most secure(they can't be broken into), and the easiest to design, document, test and maintain. The importance of a simple design can't be overemphasized. --Jon Bentley
所缺的恰是最精确(永不出错),最安全(坚不可摧),以及设计、文档编写、测试和维护起来最容易的部分。简单设计的重要性怎么强调也不过分。

最常见的紧张关系在代码清晰和代码优化之间。当你想要为了性能而进行不成熟的优化而影响清晰性时,记住条款8:It is far, far easier to make a correct program fast than it is to make a fast program correct.使一个正确的程序变快远比使一个快速是程序正确要容易得多

例1,避免不必要的/小聪明式 的操作符重载。
例2,使用命名变量而不是临时变量作为构造函数的参数,这样可以避免可能的声明二义性,使得你的代码更清晰和易于维护,且更安全。

 

7.编程中知道何时和如何考虑伸缩性
要关注数据量的增长,对数据处理的时间最好不要差于线性时间,当确实需要进行优化时,应考虑优化O(N),而不是小打小闹。
编写代码要充分考虑到未来需要处理的数据量的变化(看看内存和硬盘空间的增长),如果一个算法具有差于线性的渐进行为,再强大的系统也会俯首称臣:只要有足够多的数据。
C++标准库这点做得很好,他已经保证了容器和算法的性能复杂度。

  • 使用灵活的,动态分配的数据,而不是固定大小的数组。
  • 了解你的算法实际的复杂度,有些看似线性的算法,实际上会调用其他线性操作,结果算法实际上是二次的。
  • 优先使用线性算法或尽可能快的算法:常数时间复杂性的算法,比如push_back和散列表查询,是最完美的(见第76条和第80条)。O(logN)对数复杂性的算法,比如set/map操作和带有随机迭代器的lower_bound和upper_bound,也不错(见第76条、第85条和第86条)。O(N)线性复杂性的算法,比如vector::insert和for_each,也可以接受(见第76条、第81条和第84条)
  • 尽量避免劣于线性复杂度的算法:如向vector插入n个元素,使用vector::insert(position,first, last)优于重复调用vector::insert(postion,x),每次调用insert(postion,x)会调整空间,而实际上使得插入n个元素的复杂度是二次方的。
  • 除非已经山穷水尽,没有别的选择,否则不要使用指数复杂度的算法。

8.不要进行不成熟的优化
优化的第一原则就是:不要优化。优化的第二原则(仅适用于专家)是:还是不要优化。再三测试,而后优化。
TPCL引用的优美名言所说的:
Premature optimization is the root of all evil. --Donald Knuth(引用自Hoare)
不成熟的优化是万恶之源。
On the other hand, we cannot ignore efficiency.--Jon Bentley.
另一方面,我们不能忽视效率。
Hoare和Knuth当然而且永远是完全正确的(见第6条和本条)。Bentley也是如此(见第9条)。(呵呵,高大爷为了计算机程序设计艺术也开出过三张支票,我们只能说绝大部分时候,这些祖师爷们是正确的。)

不成熟的优化并不能是程序更快,两方面原因:

  • 我们程序员在估计哪些代码更快或者更小,以及哪些代码会成为瓶颈上名声很臭,包括该书的作者,也包括读者。
  • 现代的程序,越来越多的操作不是受限于CPU,而是内存,网络,磁盘,等待Web服务,等待数据库。最优情况下,对此类操作的代码优化也只是更快的进入等待操作。这也意味着程序员浪费了宝贵的时间改善了不需要改善的地方。

当需要优化时,首先是算法优化,尽量将优化进行封装和模块化,并在注释中清楚的说明优化的原因和所使用算法作为参考。


9.不要进行不成熟的劣化(pessimize)
避免不成熟的劣化并不意味着损害性能。不成熟的劣化,指的是以下情况:

  • 可以使用传引用的情况下定义传值的参数
  • 在使用前缀合适的情况下使用了后缀++
  • 在构造函数中使用赋值而不是初始化列表。

 

构造清晰而有效的程序有两种重要的方式:使用抽象和类库。例如,使用标准库的vector、list、map、find、sort和其他设施,这些都是由世界级的专家标准化并实现的,不仅能使你的代码更加清晰,更容易理解,而且启动也经常更快。
避免不成熟的劣化在开发一个库时特别重要,需要达成一种平衡,在倾向于更高效率和可复用性时,不能为了一小部分潜在调用者的利益而过分提高效率。

 

10.尽量减少全局和共享数据
共享意味着竞争。

避免使用名字空间作用域中具有外部连接的数据或者作为静态类成员的数据。这些数据会使程序逻辑变得更加复杂,使程序不同的(而且可能更糟,距离较远的)部分耦合得更加紧密。共享数据对单元测试会产生不良影响,因为使用共享数据的代码片断的正确性不仅取决于数据变化的过程,更取决于以后会使用该数据的未知代码区域的机能。

全局名字空间中的对象名称还会污染全局名字空间。

如果必须使用全局的、名字空间作用域的或者静态的类对象,一定要仔细地对其进行初始化。这种对象在不同编译单位中这种对象的初始化顺序是未定义的,正确处理它们需要特殊的技术(参阅本条的参考文献)。初始化顺序规则是非常难于掌握的,应该尽量避免使用;如果不得不用的话,则应该充分了解,小心使用。

名字空间作用域中的对象、静态成员对象或者跨线程或跨进程共享的对象会减少多线程和多处理器环境中的并行性,往往是产生性能和可伸缩性瓶颈的源头(见第7条)。为“无共享”而奋斗吧,用通信方式(比如消息队列)代替数据共享。

应该尽量降低类之间的耦合,尽量减少交互

程序范围的设施cin、cout和cerr比较特别,其实现方式很特别。

跨线程共享对象的代码应该总是将对这些共享对象的所有访问序列化

 

11.隐藏信息
为了尽量减少操作抽象的调用代码和抽象实现之间的依赖性,必须隐藏实现内部的数据。
绝对不要将类的数据成员设为public(见第41条),或者公开指向它们的指针或句柄(见第42条)而使其公开,这是一个很常见的信息隐藏的例子。
值的聚合(“C语言式的struct”)只是简单地将数据绑在了一起,并没有提供任何抽象,所以它不需要隐藏数据,数据本身就是接口。

 

12.知道何时和如何并发式编码
如果应用程序使用了多个线程或者进程,应该知道如何尽量减少共享对象(见第10条),以及如何安全地共享必须共享的对象。
多线程程序应按以下安全行事:
参考目标平台的文档,了解同步化原语:典型的原语包括从轻量级的原子整数操作到memory barrier,再到进程内和跨进程的互斥体
尽量用自己设计的抽象封装平台原语:这对于跨平台程序非常有益,或者用第三方的库也可以。
确保在多线程程序中使用的类型是安全的,至少要做到以下两点:1.保证非共享的对象是独立的。2.记录调用者在不同的线程中使用此类型的同一对象需要做什么。

在自己编写可用于多线程程序的类型时,也必须完成两项任务:首先,必须保证不同线程能够不加锁地使用该类型的不同对象(注意:具有可修改的静态数据的类型通常不能保证这一点)。其次,必须在文档中说明使用者在不同线程中使用该类型的同一个对象需要做什么,基本的设计问题是如何在类及其客户之间分配正确执行(即无竞争和无死锁地执行)的职责。主要的选择有下列几个方面。

  • 1.外部加锁:调用者负责加锁。
  • 2.内部加锁:每个对象将所有对自己的访问串行化,通常采用为每个公用成员函数加锁的方法来实现,这样调用者就可以不用串行化对象的使用了。
    例如,生产者/消费者队列通常使用内部加锁,因为它们存在的目的就是被跨线程共享,而且它们的接口就是为了在单独的成员函数调用(Push, Pop)期间能够进行适当的层次加锁而设计的。更一般的情况下,需要注意,只有在知道了以下两件事情之后这个选项才适用。
    第一,必须事先知道该类型的对象几乎总是要被跨线程共享的,否则到头来只不过进行了无效加锁。请注意大多数类型都不会遇到这种情况,即使是在多线程处理分量很重的程序中,大多数对象也不会被跨线程共享(这是好现象,见第10条)。
    第二,必须事先知道成员函数级加锁的粒度是合适的,而且能满足大多数调用者的需要。具体而言,类型接口的设计应该有利于粗粒度的、自给自足的操作。如果调用者总是需要对多个而不是一个操作加锁,那么就不能满足需要了,只能通过增加更多的(外部)锁,将单独加锁的函数组装成一个更大规模的已加锁工作单位。例如一个容器类型,如果它返回一个迭代器,则迭代器可能在用到之前就失效了;如果它提供find之类的能返回正确答案的成员算法,那么答案可能在用到之前就出错了;如果它的用户想要编写这样的代码:if( c.empty() ) c.push_back(x);,同样会出现问题。在这些情况下,调用者需要进行外部加锁,以获得生存期能够跨越多个单独成员函数调用的锁,这样一来每个成员函数的内部加锁就毫无用武之地了。
    因此,内部加锁是绑定于类型的公用接口的:在类型的各个单独操作本身都完整时,内部加锁才适用;换句话说,类型的抽象级别不仅提升了,而且表达和封装得更加精确了(比如,以生产者-消费者队列的形式,而不是普通的vector)。将多个原语操作结合起来,形成粒度更粗的公开操作,不仅可以确保函数调用有意义,而且可以确保调用简单。如果原语的结合是不能确定的,而且也无法将合理的使用场景集合集中到一个命名操作中,那么有两种选择:一是使用基于回调的模型(即让调用者调用一个单独的成员函数,但是以一个命令或者函数对象的形式传入它们想要执行的任务,见第87条到第89条);二是在接口中以某种方式暴露加锁。
  • 3.不加锁的设计,包括不变性(只读对象):无需加锁。

请注意,调用代码应该不需要知道你的类型的实现细节(见第11条)。如果类型使用了底层数据共享技术[如写时复制(copy-on-write)],那么你就不需要为所有可能的线程安全性问题负责了,但是必须负责恢复“恰到好处的”线程安全,以确保调用代码在履行其通常职责时仍是正确的:类型必须能够尽可能地安全使用,如果它没有使用隐蔽的实现共享的话(见[Sutter04c])。前面已经提到,所有正确编写的类型都必须允许在不同线程中无需同步便可操作不同的可见对象。

如果编写的是一个将要广泛使用的程序库,那么尤其要考虑保证对象能够在前面叙述的多线程程序中安全使用,而且又不会增加单线程程序的开销。例如,如果你正在编写的程序库包含一个使用了写时复制的类型,并且因而必须至少进行某种内部加锁,那么最好安排加锁在程序库的单线程编译版本中消失[#ifdef和空操作(no-op)实现是常见的策略]。

在获取多个锁时,通过安排所有获取同样的锁的代码以相同的顺序获取锁,可以避免死锁情况的发生。(释放锁则可以按照任意顺序进行。)解决方案之一,是按内存地址的升序获取锁,地址恰好提供了一个方便、惟一而且是应用程序范围的排序。

 

注:这段让我自己翻译太困难了,直接借鉴中文版的文字了。

 

13.确保资源为对象所拥有。使用显式RAII和智能指针。
C++的“资源获取即初始化”(resource acquisition is initialization,RAII)惯用法是正确处理资源的利器。RAII使编译器能够提供强大且自动的保证,这在其他语言中可是需要脆弱的手工编写的惯用法才能实现的。分配原始资源的时候,应该立即将其传递给属主对象。永远不要在一条语句中分配一个以上的资源。
每当处理需要配对的获取/释放函数调用的资源时,都应该将资源封装在一个对象中,让对象为我们强制配对,并在其析构函数中执行资源释放。
在实现RAII时,要小心复制构造和赋值(见第49条),编译器生成的版本可能并不正确。如果复制没有意义,请通过将复制构造和赋值设为私有并且不做定义来明确禁用二者(见第53条)。否则,让复制构造函数复制资源或者引用计数所使用的次数,并让赋值操作符如法炮制,如果必要,同时还要确保它释放了最开始持有的资源。一个经典的疏漏是在新资源成功复制之前释放了老资源(见第71条)。
最好用智能指针而不是原始指针来保存动态分配的资源。同样,应该在自己的语句中执行显式的资源分配(比如new),而且每次都应该马上将分配的资源赋予管理对象(比如shared_ptr),否则,就可能泄漏资源,因为函数参数的计算顺序是未定义的(见第31条)。例如:

void Fun( shared_ptr<Widget> sp1, shared_ptr<Widget> sp2 );

// ……

Fun( shared_ptr<Widget>(new Widget), shared_ptr<Widget>(new Widget) );

这种代码是不安全的。C++标准给了编译器巨大的回旋余地,可以将构成函数两个参数的两个表达式重新排序。说得更具体一些,就是编译器可以交叉执行两个表达式:可能先执行两个对象的内存分配(通过调用operator new),然后再试图调用两个Widget构造函数。这恰恰为资源泄漏准备了温床,因为如果其中一个构造函数调用抛出异常的话,另一个对象的内存就永远也没有机会释放了!(详细情况请参阅 [Sutter02]。)

这种微妙的问题有一个简单的解决办法:遵循建议,绝对不要在一条语句中分配一个以上的资源,应该在自己的代码语句中执行显式的资源分配(比如new),而且每次都应该马上将分配的资源赋予管理对象(比如shared_ptr)。例如:

shared_ptr<Widget> sp1(new Widget), sp2(new Widget);

Fun( sp1, sp2 );

智能指针有可能会被过度使用。如果被指向的对象只对有限的代码(比如纯粹在类的内部,诸如一个Tree类的内部节点导航指针)可见,那么原始指针就够用了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值