Google C++编程风格指南(四):智能指针和其他C++特性


C++ #编程风格指南2012-11-09 11:01

      Google有很多自己实现的使C++代码更加健壮的技巧、功能,以及有异于别处的C++的使用方式。

1. 智能指针(Smart Pointers)

      如果确实需要使用智能指针的话,scoped_ptr完全可以胜任。在非常特殊的情况下,例如对STL容器中对象,你应该只使用std::tr1::shared_ptr,任何情况下都不要使用auto_ptr。

“智能”指针看上去是指针,其实是附加了语义的对象。以scoped_ptr为例,scoped_ptr被销毁时,删除了它所指向的对象。shared_ptr也是如此,而且,shared_ptr实现了引用计数(reference-counting),从而只有当它所指向的最后一个对象被销毁时,指针才会被删除。

      一般来说,我们倾向于设计对象隶属明确的代码,最明确的对象隶属是根本不使用指针,直接将对象作为一个域(field)或局部变量使用。另一种极端是引用计数指针不属于任何对象,这样设计的问题是容易导致循环引用或其他导致对象无法删除的诡异条件,而且在每一次拷贝或赋值时连原子操作都会很慢。

虽然不推荐这么做,但有些时候,引用计数指针是最简单有效的解决方案。

Google所谓的不同之处,在于尽量避免使用智能指针:D,使用时也尽量局部化,并且,安全第一。

1. 引用参数(Reference Arguments)

      所以按引用传递的参数必须加上const。

定义:在C语言中,如果函数需要修改变量的值,形参(parameter)必须为指针,如int foo(int *pval)。在C++中,函数还可以声明引用形参:int foo(int &val)。

优点:定义形参为引用避免了像(*pval)++这样丑陋的代码,像拷贝构造函数这样的应用也是必需的,而且不像指针那样不接受空指针NULL。

缺点:容易引起误解,因为引用在语法上是值却拥有指针的语义。

结论:

函数形参表中,所有引用必须是const:

 

事实上这是一个硬性约定:输入参数为值或常数引用,输出参数为指针;输入参数可以是常数指针,但不能使用非常数引用形参。

在强调参数不是拷贝而来,在对象生命期内必须一直存在时可以使用常数指针,最好将这些在注释中详细说明。bind2nd和mem_fun等STL适配器不接受引用形参,这种情况下也必须以指针形参声明函数。

2. 函数重载(Function Overloading)

      仅在输入参数类型不同、功能相同时使用重载函数(含构造函数),不要使用函数重载模仿缺省函数参数。

定义:可以定义一个函数参数类型为const string&,并定义其重载函数类型为const char*。

 

优点:通过重载不同参数的同名函数,令代码更加直观,模板化代码需要重载,同时为访问者带来便利。

缺点:限制使用重载的一个原因是在特定调用处很难确定到底调用的是哪个函数,另一个原因是当派生类只重载函数的部分变量会令很多人对继承语义产生困惑。此外在阅读库的客户端代码时,因缺省函数参数造成不必要的费解。

结论:如果你想重载一个函数,考虑让函数名包含参数信息,例如,使用AppendString()、AppendInt()而不是Append()。

3. 缺省参数(Default Arguments)

      禁止使用缺省函数参数。

优点:经常用到一个函数带有大量缺省值,偶尔会重写一下这些值,缺省参数为很少涉及的例外情况提供了少定义一些函数的方便。

缺点:大家经常会通过查看现有代码确定如何使用API,缺省参数使得复制粘贴以前的代码难以呈现所有参数,当缺省参数不适用于新代码时可能导致重大问题。

结论:所有参数必须明确指定,强制程序员考虑API和传入的各参数值,避免使用可能不为程序员所知的缺省参数。

4. 变长数组和alloca(Variable-Length Arrays and alloca())

      禁止使用变长数组和alloca()。

优点:变长数组具有浑然天成的语法,变长数组和alloca()也都很高效。

缺点:变长数组和alloca()不是标准C++的组成部分,更重要的是,它们在堆栈(stack)上根据数据分配大小可能导致难以发现的内存泄漏:“在我的机器上运行的好好的,到了产品中却莫名其妙的挂掉了”。

结论:

      使用安全的分配器(allocator),如scoped_ptr/scoped_array。

5. 友元(Friends)

      允许合理使用友元类及友元函数。

      通常将友元定义在同一文件下,避免读者跑到其他文件中查找其对某个类私有成员的使用。经常用到友元的一个地方是将FooBuilder声明为Foo的友元,FooBuilder以便可以正确构造Foo的内部状态,而无需将该状态暴露出来。某些情况下,将一个单元测试用类声明为待测类的友元会很方便。

      友元延伸了(但没有打破)类的封装界线,当你希望只允许另一个类访问某个成员时,使用友元通常比将其声明为public要好得多。当然,大多数类应该只提供公共成员与其交互。

6. 异常(Exceptions)

      不要使用C++异常。

优点:

1) 异常允许上层应用决定如何处理在底层嵌套函数中发生的“不可能发生”的失败,不像出错代码的记录那么模糊费解;

2) 应用于其他很多现代语言中,引入异常使得C++与Python、Java及其他与C++相近的语言更加兼容;

3) 许多C++第三方库使用异常,关闭异常将导致难以与之结合;

4) 异常是解决构造函数失败的唯一方案,虽然可以通过工厂函数(factory function)或Init()方法模拟异常,但他们分别需要堆分配或新的“非法”状态;

5) 在测试框架(testing framework)中,异常确实很好用。

缺点:

1) 在现有函数中添加throw语句时,必须检查所有调用处,即使它们至少具有基本的异常安全保护,或者程序正常结束,永远不可能捕获该异常。例如:if f()calls g() calls h(),h抛出被f捕获的异常,g就要当心了,避免没有完全清理;

2) 通俗一点说,异常会导致程序控制流(control flow)通过查看代码无法确定:函数有可能在不确定的地方返回,从而导致代码管理和调试困难,当然,你可以通过规定何时何地如何使用异常来最小化的降低开销,却给开发人员带来掌握这些规定的负担;

3) 异常安全需要RAII和不同编码实践。轻松、正确编写异常安全代码需要大量支撑。允许使用异常;

4) 加入异常使二进制执行代码体积变大,增加了编译时长(或许影响不大),还可能增加地址空间压力;

5) 异常的实用性可能会刺激开发人员在不恰当的时候抛出异常,或者在不安全的地方从异常中恢复,例如,非法用户输入可能导致抛出异常。如果允许使用异常会使得这样一篇编程风格指南长出很多!

结论:

      从表面上看,使用异常利大于弊,尤其是在新项目中,然而,对于现有代码,引入异常会牵连到所有依赖代码。如果允许异常在新项目中使用,在跟以前没有使用异常的代码整合时也是一个麻烦。因为Google现有的大多数C++代码都没有异常处理,引入带有异常处理的新代码相当困难。

      鉴于Google现有代码不接受异常,在现有代码中使用异常比在新项目中使用的代价多少要大一点,迁移过程会比较慢,也容易出错。我们也不相信异常的有效替代方案,如错误代码、断言等,都是严重负担。

      我们并不是基于哲学或道德层面反对使用异常,而是在实践的基础上。因为我们希望使用Google上的开源项目,但项目中使用异常会为此带来不便,因为我们也建议不要在Google上的开源项目中使用异常,如果我们需要把这些项目推倒重来显然不太现实。

对于Windows代码来说,这一点有个例外。

      对于异常处理,显然不是短短几句话能够说清楚的,以构造函数为例,很多C++书籍上都提到当构造失败时只有异常可以处理,Google禁止使用异常这一点,仅仅是为了自身的方便,说大了,无非是基于软件管理成本上,实际使用中还是自己决定。

7. 运行时类型识别(Run-Time Type Information, RTTI)

      我们禁止使用RTTI。

定义:RTTI允许程序员在运行时识别C++类对象的类型。

优点:RTTI在某些单元测试中非常有用,如在进行工厂类测试时用于检验一个新建对象是否为期望的动态类型。除测试外,极少用到。

缺点:运行时识别类型意味著设计本身有问题,如果你需要在运行期间确定一个对象的类型,这通常说明你需要重新考虑你的类的设计。

结论:

      除单元测试外,不要使用RTTI,如果你发现需要所写代码因对象类型不同而动作各异的话,考虑换一种方式识别对象类型。

      虚函数可以实现随子类类型不同而执行不同代码,工作都是交给对象本身去完成。

      如果工作在对象之外的代码中完成,考虑双重分发方案,如Visitor模式,可以方便的在对象本身之外确定类的类型。

      如果你认为上面的方法你掌握不了,可以使用RTTI,但务必请三思,不要去手工实现一个貌似RTTI的方案(RTTI-like workaround),我们反对使用RTTI,同样反对贴上类型标签的貌似类继承的替代方案(使用就使用吧,不使用也不要造轮子)。

8. 类型转换(Casting)

      使用static_cast<>()等C++的类型转换,不要使用int y = (int)x或int y = int(x);。

定义:C++引入了有别于C的不同类型的类型转换操作。

优点:C语言的类型转换问题在于操作比较含糊:有时是在做强制转换(如(int)3.5),有时是在做类型转换(如(int)"hello")。另外,C++的类型转换查找更容易、更醒目。

缺点:语法比较恶心(nasty)。

结论:使用C++风格而不要使用C风格类型转换。

1) static_cast:和C风格转换相似可做值的强制转换,或指针的父类到子类的明确的向上转换;

2) const_cast:移除const属性;

3) reinterpret_cast:指针类型和整型或其他指针间不安全的相互转换,仅在你对所做一切了然于心时使用;

4) dynamic_cast:除测试外不要使用,除单元测试外,如果你需要在运行时确定类型信息,说明设计有缺陷(参考RTTI)。

9. 流(Streams)

      只在记录日志时使用流。

定义:流是printf()和scanf()的替代。

优点:有了流,在输出时不需要关心对象的类型,不用担心格式化字符串与参数列表不匹配(虽然在gcc中使用printf也不存在这个问题),打开、关闭对应文件时,流可以自动构造、析构。

缺点:流使得pread()等功能函数很难执行,如果不使用printf之类的函数而是使用流很难对格式进行操作(尤其是常用的格式字符串%.*s),流不支持字符串操作符重新定序(%1s),而这一点对国际化很有用。

结论:

不要使用流,除非是日志接口需要,使用printf之类的代替。

使用流还有很多利弊,代码一致性胜过一切,不要在代码中使用流。

拓展讨论:

      对这一条规则存在一些争论,这儿给出深层次原因。回忆唯一性原则(Only One Way):我们希望在任何时候都只使用一种确定的I/O类型,使代码在所有I/O处保持一致。因此,我们不希望用户来决定是使用流还是printf + read/write,我们应该决定到底用哪一种方式。把日志作为例外是因为流非常适合这么做,也有一定的历史原因。

      流的支持者们主张流是不二之选,但观点并不是那么清晰有力,他们所指出流的所有优势也正是其劣势所在。流最大的优势是在输出时不需要关心输出对象的类型,这是一个亮点,也是一个不足:很容易用错类型,而编译器不会报警。使用流时容易造成的一类错误是

      cout << this;  // Prints the address
      cout << *this;  // Prints the contents 

编译器不会报错,因为<<被重载,就因为这一点我们反对使用操作符重载。

有人说printf的格式化丑陋不堪、易读性差,但流也好不到哪儿去。看看下面两段代码吧,哪个更加易读?

cerr << "Error connecting to '" << foo->bar()->hostname.first
     << ":" << foo->bar()->hostname.second << ": " << strerror(errno);

fprintf(stderr, "Error connecting to '%s:%u: %s",
        foo->bar()->hostname.first, foo->bar()->hostname.second,
        strerror(errno));

      你可能会说,“把流封装一下就会比较好了”,这儿可以,其他地方呢?而且不要忘了,我们的目标是使语言尽可能小,而不是添加一些别人需要学习的新的内容。

      每一种方式都是各有利弊,“没有最好,只有更好”,简单化的教条告诫我们必须从中选择其一,最后的多数决定是printf + read/write。

10. 前置自增和自减(Preincrement and Predecrement)

      对于迭代器和其他模板对象使用前缀形式(++i)的自增、自减运算符。

定义:对于变量在自增(++i或i++)或自减(--i或i--)后表达式的值又没有没用到的情况下,需要确定到底是使用前置还是后置的自增自减。

优点:不考虑返回值的话,前置自增(++i)通常要比后置自增(i++)效率更高,因为后置的自增自减需要对表达式的值i进行一次拷贝,如果i是迭代器或其他非数值类型,拷贝的代价是比较大的。既然两种自增方式动作一样(不考虑表达式的值),为什么不直接使用前置自增呢?

缺点:C语言中,当表达式的值没有使用时,传统的做法是使用后置自增,特别是在for循环中,有些人觉得后置自增更加易懂,因为这很像自然语言,主语(i)在谓语动词(++)前。

结论:对简单数值(非对象)来说,两种都无所谓,对迭代器和模板类型来说,要使用前置自增(自减)。

11. const的使用(Use of const)

      我们强烈建议你在任何可以使用的情况下都要使用const。

定义:在声明的变量或参数前加上关键字const用于指明变量值不可修改(如const int foo),为类中的函数加上const限定表明该函数不会修改类成员变量的状态(如class Foo { int Bar(char c) const; };)。

优点:人们更容易理解变量是如何使用的,编辑器可以更好地进行类型检测、更好地生成代码。人们对编写正确的代码更加自信,因为他们知道所调用的函数被限定了能或不能修改变量值。即使是在无锁的多线程编程中,人们也知道什么样的函数是安全的。

缺点:如果你向一个函数传入const变量,函数原型中也必须是const的(否则变量需要const_cast类型转换),在调用库函数时这尤其是个麻烦。

结论:const变量、数据成员、函数和参数为编译时类型检测增加了一层保障,更好的尽早发现错误。因此,我们强烈建议在任何可以使用的情况下使用const:

1) 如果函数不会修改传入的引用或指针类型的参数,这样的参数应该为const;

2) 尽可能将函数声明为const,访问函数应该总是const,其他函数如果不会修改任何数据成员也应该是const,不要调用非const函数,不要返回对数据成员的非const指针或引用;

3) 如果数据成员在对象构造之后不再改变,可将其定义为const。

然而,也不要对const过度使用,像const int * const * const x;就有些过了,即便这样写精确描述了x,其实写成const int** x就可以了。

关键字mutable可以使用,但是在多线程中是不安全的,使用时首先要考虑线程安全。

const位置

      有人喜欢int const *foo形式不喜欢const int* foo,他们认为前者更加一致因此可读性更好:遵循了const总位于其描述的对象(int)之后的原则。但是,一致性原则不适用于此,“不要过度使用”的权威抵消了一致性使用。将const放在前面才更易读,因为在自然语言中形容词(const)是在名词(int)之前的。

      这是说,我们提倡const在前,并不是要求,但要兼顾代码的一致性!

12. 整型(Integer Types)

      C++内建整型中,唯一用到的是int,如果程序中需要不同大小的变量,可以使用<stdint.h>中的精确宽度(precise-width)的整型,如int16_t。

定义:C++没有指定整型的大小,通常人们认为short是16位,int是32位,long是32位,long long是64位。

优点:保持声明统一。

缺点:C++中整型大小因编译器和体系结构的不同而不同。

结论

<stdint.h>定义了int16_t、uint32_t、int64_t等整型,在需要确定大小的整型时可以使用它们代替short、unsigned long long等,在C整型中,只使用int。适当情况下,推荐使用标准类型如size_t和ptrdiff_t。

      最常使用的是,对整数来说,通常不会用到太大,如循环计数等,可以使用普通的int。你可以认为int至少为32位,但不要认为它会多于32位,需要64位整型的话,可以使用int64_t或uint64_t。

对于大整数,使用int64_t。

不要使用uint32_t等无符号整型,除非你是在表示一个位组(bit pattern)而不是一个数值。即使数值不会为负值也不要使用无符号类型,使用断言(assertion,译者注,这一点很有道理,计算机只会根据变量、返回值等有无符号确定数值正负,仍然无法确定对错)来保护数据。

无符号整型

      有些人,包括一些教科书作者,推荐使用无符号类型表示非负数,类型表明了数值取值形式。但是,在C语言中,这一优点被由其导致的bugs所淹没。看看:

      for (unsigned int i = foo.Length()-1; i >= 0; --i) ...

      上述代码永远不会终止!有时gcc会发现该bug并报警,但通常不会。类似的bug还会出现在比较有符合变量和无符号变量时,主要是C的类型提升机制(type-promotion scheme,C语言中各种内建类型之间的提升转换关系)会致使无符号类型的行为出乎你的意料。

因此,使用断言声明变量为非负数,不要使用无符号型。

13. 64位下的可移植性(64-bit Portability)

      代码在64位和32位的系统中,原则上应该都比较友好,尤其对于输出、比较、结构对齐(structure alignment)来说:

1) printf()指定的一些类型在32位和64位系统上可移植性不是很好,C99标准定义了一些可移植的格式。不幸的是,MSVC 7.1并非全部支持,而且标准中也有所遗漏。所以有时我们就不得不自己定义丑陋的版本(使用标准风格要包含文件inttypes.h):

 

// printf macros for size_t, in the style of inttypes.h http://yige.org/cpp/
#ifdef _LP64
#define __PRIS_PREFIX "z"
#else 
#define __PRIS_PREFIX 
#endif


// Use these macros after a % in a printf format string
// to get correct 32/64 bit behavior, like this: 
// size_t size = records.size(); 
// printf( "%"PRIuS"/n", size);


#define PRIdS __PRIS_PREFIX "d"
#define PRIxS __PRIS_PREFIX "x"
#define PRIuS __PRIS_PREFIX "u"
#define PRIXS __PRIS_PREFIX "X"
#define PRIoS __PRIS_PREFIX "o"

类型 不要使用 使用 备注
void *(或其他指针类型) %lx %p  
int64_t %qd, %lld %"PRId64"  
uint64_t %qu, %llu, %llx %"PRIu64", %"PRIx64"  
size_t %u %"PRIuS", %"PRIxS" C99指定%zu
ptrdiff_t %d %"PRIdS" C99指定%zd


      注意宏PRI*会被编译器扩展为独立字符串,因此如果使用非常量的格式化字符串,需要将宏的值而不是宏名插入格式中,在使用宏PRI*时同样可以在%后指定长度等信息。例如,printf("x = %30"PRIuS"/n", x)在32位Linux上将被扩展为printf("x = %30" "u" "/n", x),编译器会处理为printf("x = %30u/n", x)。

2) 记住sizeof(void *) != sizeof(int),如果需要一个指针大小的整数要使用intptr_t。

3) 需要对结构对齐加以留心,尤其是对于存储在磁盘上的结构体。在64位系统中,任何拥有int64_t/uint64_t成员的类/结构体将默认被处理为8字节对齐。如果32位和64位代码共用磁盘上的结构体,需要确保两种体系结构下的结构体的对齐一致。大多数编译器提供了调整结构体对齐的方案。gcc中可使用__attribute__((packed)),MSVC提供了#pragma pack()和__declspec(align())(译者注,解决方案的项目属性里也可以直接设置)。

4) 创建64位常量时使用LL或ULL作为后缀,如:

      int64_t my_value = 0x123456789LL;
      uint64_t my_mask = 3ULL << 48;

5) 如果你确实需要32位和64位系统具有不同代码,可以在代码变量前使用。(尽量不要这么做,使用时尽量使修改局部化)。

14. 预处理宏(Preprocessor Macros)

      使用宏时要谨慎,尽量以内联函数、枚举和常量代替之。

      宏意味着你和编译器看到的代码是不同的,因此可能导致异常行为,尤其是当宏存在于全局作用域中。

      值得庆幸的是,C++中,宏不像C中那么必要。宏内联效率关键代码(performance-critical code)可以内联函数替代;宏存储常量可以const变量替代;宏“缩写”长变量名可以引用替代;使用宏进行条件编译,这个……,最好不要这么做,会令测试更加痛苦(#define防止头文件重包含当然是个例外)。

宏可以做一些其他技术无法实现的事情,在一些代码库(尤其是底层库中)可以看到宏的某些特性(如字符串化(stringifying,使用#)、连接(concatenation,使用##)等等)。但在使用前,仔细考虑一下能不能不使用宏实现同样效果。

      下面给出的用法模式可以避免一些使用宏的问题,供使用宏时参考:

1) 不要在.h文件中定义宏;

2) 使用前正确#define,使用后正确#undef;

3) 不要只是对已经存在的宏使用#undef,选择一个不会冲突的名称;

4) 不使用会导致不稳定的C++构造(unbalanced C++ constructs)的宏,至少文档说明其行为。

15. 0和NULL(0 and NULL)

整数用0,实数用0.0,指针用NULL,字符(串)用'/0'。

整数用0,实数用0.0,这一点是毫无争议的。

      对于指针(地址值),到底是用0还是NULL,Bjarne Stroustrup建议使用最原始的0,我们建议使用看上去像是指针的NULL,事实上一些C++编译器(如gcc 4.1.0)专门提供了NULL的定义,可以给出有用的警告,尤其是sizeof(NULL)和sizeof(0)不相等的情况。

字符(串)用'/0',不仅类型正确而且可读性好。

16. sizeof(sizeof)

      尽可能用sizeof(varname)代替sizeof(type)。

      使用sizeof(varname)是因为当变量类型改变时代码自动同步,有些情况下sizeof(type)或许有意义,还是要尽量避免,如果变量类型改变的话不能同步。

      Struct data;
      memset(&data, 0, sizeof(data));

      memset(&data, 0, sizeof(Struct));

17. Boost库(Boost)

      只使用Boost中被认可的库。

定义:Boost库集是一个非常受欢迎的、同级评议的(peer-reviewed)、免费的、开源的C++库。

优点:Boost代码质量普遍较高、可移植性好,填补了C++标准库很多空白,如型别特性(type traits)、更完善的绑定(binders)、更好的智能指针,同时还提供了TR1(标准库的扩展)的实现。

缺点:某些Boost库提倡的编程实践可读性差,像元程序(metaprogramming)和其他高级模板技术,以及过度“函数化”("functional")的编程风格。

结论:为了向阅读和维护代码的人员提供更好的可读性,我们只允许使用Boost特性的一个成熟子集,当前,这些库包括:

1) Compressed Pair:boost/compressed_pair.hpp;

2) Pointer Container:boost/ptr_container不包括ptr_array.hpp和序列化(serialization)。

我们会积极考虑添加可以的Boost特性,所以不必拘泥于该规则。

class MyClass {
public:
  void Analyze(const string &text);
  void Analyze(const char *text, size_t textlen);
};

void Foo(const string &in, string *out);


相关文章

粤ICP备11097351号-1