首页 > 代码库 > GoogleCpp风格指南 5) 其他特性_part1
GoogleCpp风格指南 5) 其他特性_part1
5 其他C++特性 Other C++ Features
5.1 引用参数 Reference Arguments
Tip 所有按引用传递的参数必须加上 const;
定义:
在C语言中, 如果函数需要修改变量的值, 参数必须为指针, 如 int foo(int* pval); 在C++中, 函数还可以声明引用参数 int foo(int& val);
优点:
定义引用参数防止出现 (*pval)++ 这样丑陋的代码; 像拷贝构造函数这样的应用也是必需的, 而且更明确, Note 不接受NULL指针;
缺点:
容易引起误解, 因为引用在语法是值变量却拥有指针的语义;
结论:
函数参数列表中, 所有引用参数都必须是 const;
1 | void Foo( const string &in, string *out); |
事实上这在 Google Code是一个硬性约定: 输入参数是值参或const引用, 输出参数为指针; 输入参数可以是const指针, 但决不能是非const的引用参数;
[Add] 除非是惯例, 如swap() [新惯例~] <<<
把输入参数定义为const指针的情况: 强调参数不是拷贝而来的, 在对象生存周期内必须一直存在; 最好同时在注释中详细说明一下; Note bind2nd和 mem_fun等STL适配器不接受引用参数, 这种情况下你也必须把函数参数声明成指针类型;
[Add]
在以下情况输入参数定义为 const T*比 const T&要更合适:
- 传递一个空指针 null
- 函数保存了一个输入参数的指针或引用;
记住大多数时间里输入参数应该是const T&; 使用 const T*作为替代来和使用者reader通信的话, 输入参数应该以不同方式处理; 因此如果选择了const T*, 一定要有个确定的理由; 否则使用者会感到困惑, 也找不到解释;
<<<
[Add]
右值引用 Rvalue References
使用 rvalue reference仅仅是为了定义move构造和move赋值操作; 不要使用 std::forward [ c++11 http://en.cppreference.com/w/cpp/utility/forward ]
定义:
rvalue reference是引用的一种类型, 可以仅仅绑定临时对象; 语法与传统的引用语法相似; 例如, void f(string&& s); 声明一个函数的参数是一个string的rvalue reference.
优点:
- 定义一个move构造(), 可以将一个值move而不用copy它; 如果 v1是一个 vector<string>, 例如 auto v2(std::move(v1)) 可能只是会作为简单的指针来操作, 而不是copy大量的数据; 在一些情况下, 这样可以大大优化性能;
结论:
只有在定义move ctor和move assign operator的时候才使用 rvalue ref, 就像Copyable and Movable Types里描述的; 不要使用std::forward工具函数; 你可以使用 std::move来表达将一个值从一个对象move到另一个, 而不是copy它;
<<<
5.2 函数重载 Function Overloading
Tip 仅在输入参数类型不同, 功能相同时使用重载函数(含构造函数), 不要用函数重载模拟缺省函数参数;
[Add] 对于使用者来说, 需要在调用点很容易能明白发生了什么, 而不是不得不去查询哪个重载被调用了;
定义:
可以编写一个参数类型为 const string&的函数, 然后用另一个参数类型为 const char*的函数重载它:
1 2 3 4 5 | class MyClass { public : void Analyze( const string &text); void Analyze( const char *text, size_t textlen); }; |
优点:
通过重载参数不同的同名函数, 令代码更加直观, 对于模板化的代码可能重载是必须的, 同时它也为使用者带来便利;
缺点:
限制使用重载的一个原因是在某个特定调用点很难确定到底调用的是哪个函数; 另一个原因是当派生类只覆盖override了某个函数的部分变体, 会令很多人对继承的语义产生困惑; 此外在阅读库的用户代码时, 可能会因反对使用缺省函数参数造成不必要的费解;
[Add] 如果一个函数只是以参数类型这一个属性来进行重载, 使用者需要理解C++复杂的配对规则才能知道具体发生了什么; 有许多人会被继承的语义搞糊涂: <<<
结论:
如果想重载一个函数, 考虑让函数名包含参数信息, 例如, 使用 AppendString(), AppendInt(), 而不是 Append();
5.3 缺省参数 Default Arguments
Tip 我们不允许使用缺省函数参数 [Add] 除了下面解释的几种有限的情况; 可以的话, 使用函数重载来模拟; <<<
优点:
多数情况下, 你写的函数可能会用到很多的缺省值, 但偶尔你也会修改这些缺省值; 无须为了这些偶尔情况定义很多的函数, 用缺省参数就能轻松的做到这点; [Add] 和重载函数相比, 默认参数的语法更清晰, 减少重复代码, 而且可以清晰区分参数的‘required‘和‘optional‘的性质;<<<
缺点:
大家通常都是通过查看别人的代码来推断如何使用API; 用了缺省参数的代码更难维护; 从老代码复制粘贴来的新代码可能只包含部分参数; 当缺省参数不适用于新代码时可能会导致重大问题;
[缺省为空的一般没问题, 否则就不行, 或者使用Java的缺省参数方式, 定义两个函数, 一个转发另一个, 加入缺省参数值; 缺省函数不能用于虚函数, 默认参数是按类型静态绑定的]
[Add] 函数指针在默认参数中是容易产生混淆的, 函数签名常常和调用签名不匹配; 在现有函数中添加一个默认参数会改变它的类型, 如果代码是取的函数地址会造成问题; 用函数重载的话可以避免这类问题; 另外, 默认参数可能会带来大量代码, 它们在每个调用点都被复制--其实对于默认参数只存在于函数定义中的情况下, 应该使用重载函数;<<<
结论:
我们规定所有参数必须明确指定, 迫使程序员理解API和各参数值的意义, 避免默默地使用他们可能都还没意识到的缺省参数;
[Add] 即使对上述的缺点描述情况还不那么严重的时候, 它们依然超过默认参数带来的(小小的)益处; 所以除了下述情况, 我们要求所有的参数都要显式地指定:
- 一个指定特例是对于.cc文件中的static函数(或者在一个匿名空间中的函数); 这种情况下, 上述缺点不会发生, 因为函数使用是局部化的;
- 另外, 默认函数参数在ctor中被允许; 上述列出的缺点大多多cotr不适用, 因为无法取得ctor的地址;
- 还有一个特例是当默认参数用来模拟变长参数列表的时候:
1 2 3 4 5 | // Support up to 4 params by using a default empty AlphaNum. string StrCat( const AlphaNum &a, const AlphaNum &b = gEmptyAlphaNum, const AlphaNum &c = gEmptyAlphaNum, const AlphaNum &d = gEmptyAlphaNum); |
<<<
5.4 变长数组和 alloca() Variable-Length Arrays and alloca()
Tip 我们不允许使用变长数组和 alloca();
[http://stackoverflow.com/questions/1018853/why-is-alloca-not-considered-good-practice, http://c-faq-chn.sourceforge.net/ccfaq/node121.html ]
优点:
变长数组具有浑然天成的语法; 变长数组和 alloca()也都很高效;
缺点:
变长数组和 alloca()不是标准C++的组成部分; 更重要的是, 它们根据数据大小动态分配堆栈内存, 会引起难以发现的内存越界错误overwriting bugs: "在我的机器上运行得好好的, 发布后却莫名其妙的挂掉了";
结论:
使用安全的内存分配器, [Remove ]如 scoped_ptr, scoped_array; <<<
[Add] std::vector 或 std::unique_ptr<T[]><<
5.5 友元 Friends
Tip 我们允许合理的使用友元类及友元函数;
通常友元应该定义在同一文件内, 避免使用者跑到其他文件查找使用该类的私有成员; 经常用到友元的一个地方是将 FooBuilder声明为Foo的友元, 以便 FooBuilder正确构造 Foo的内部状态, 而无需将该状态暴露出来; 某些情况下, 将一个单元测试类声明为待测类的友元会很方便;
友元扩大了, 但没有打破类的封装边界; 某些情况下, 相对于将类成员声明为 public, 使用友元是更好的选择, 尤其是如果你只允许另一个类访问该类的私有成员时; 当然, 大多数类都只应该通过提供的公用成员进行操作;
5.6 异常 Exceptions
Tip 我们不使用C++异常;
优点:
- 异常允许上层应用决定如何处理在底层嵌套函数中"不可能出现的"失败, 不像错误码记录error-prone bookkeeping那么含糊又容易出错;
- 很多现代语言都使用异常, 引入异常使得 C++与 Python, Java以及其他 C++相近的语言更加兼容;
- 许多第三方 C++库使用异常, 禁用异常将导致很难集成integrate这些库;
- 异常是处理构造函数失败的唯一方法; 虽然可以通过工厂函数或 Init()方法替代异常, 但它们分别需要堆分配heap allocation或新的"无效invalid"状态;
- 在测试框架中使用异常确实很方便;
缺点:
- 在现有函数中添加 throw语句时, 你必须检查所有调用点; 所有调用点得至少有基本的异常安全保证exception safety guarantee, 否则永远捕获不到异常, 只好"开心的"接受程序终止的结果; 例如, 如果f()调用了g(), g()又调用了h(), h()抛出的异常被f()捕获, g()要当心, 很可能会因疏忽而未被妥善清理;
- 更普遍的情况是, 如果使用异常, 光凭查看代码是很难评估程序的控制流: 函数返回点可能在你意料之外; 这会导致代码管理和调试困难, 你可以通过规定何时何地如何使用异常来降低维护开销, 但是让开发人员必须掌握并理解这些规定带来的代价更大;
- 异常安全Exception safety要求同时采用 RAII和不同的编程实践; 要想轻松编写正确的异常安全代码, 需要大量的支撑机制配合; 另外, 要避免代码使用者去理解整个调用结构图, 异常安全代码必须把写持久化状态的逻辑部分隔离到"提交"阶段; 它在带来好处的同时, 还有成本(也许你不得不为了隔离"提交"而整出令人费解的代码); 允许使用异常会驱使我们不断为此付出代码, 即使我们觉得这很不划算;
- 启用异常使生成的二进制文件体积变大, 延长了编译时间(或许影响不大), 还可能增加地址空间压力; [32位内存不够, Virtual Address Space]
- 异常的实用性可能会怂恿开发人员在不恰当的时候抛出异常, 或者在不安全的地方从异常中恢复; 例如, 处理非法用户输入时就不应该抛出异常; 如果我们要完全列出这些约束, 这份风格指南会长出很多!
[还有异常在边界抛出的情况]
结论:
从表面上看, 使用异常利大于弊, 尤其是在新项目中; 但是对于现有代码, 引入异常会牵连到所有相关代码; 如果新项目允许异常向外扩散propagated, 在跟以前未使用异常的代码整合时也将是个麻烦; 因为Google现有的大多数C++代码都没有异常处理, 引入带有异常处理的新代码相当困难;
[可以将异常的使用限制在某个适用的模块中, 比如Http, RestAPI处理反馈]
鉴于Google现有代码不接受异常, 在现有代码中使用异常比在新项目中使用的代价多少要大一些; 迁移过程也比较慢, 容易出错; 我们不相信异常的使用有效替代方案, 如错误码error code, 断言等会造成严重负担;
我们并不是基于哲学或道德层面反对使用异常, 而是在实践的基础上; 我们希望在Google使用自己的开源项目, 但项目中使用异常会为此带来不便, 因此也建议不要在Google的开源项目中使用异常; 如果需要把这些项目推倒重来显然不太现实;
对于Windows代码来说, 有个特例;
(译注: 对于异常处理, 不是短短几句能说清的, 以构造函数为例, 很多C++书上都提到当构造失败时只有异常可以处理, Google禁止使用异常这一点, 仅仅是为了自身的方便; 无非是基于软件管理成本上, 实际使用中还是自己决定);
[个人项目随意, 公司项目还是要看成本, 以及和其他项目的兼容性]
5.7 运行时类型识别 Run-Time Type Information (RTTI)
Tip 我们禁止使用RTTI;
定义:
RTTI允许程序员在运行时识别C++类对象的类型; 这是由 typeid或 dynamic_cast完成的;
缺点:
在运行时判断类型通常意味着设计问题; 如果需要在运行期间确定一个对象的类型, 这通常说明类的层次结构有缺陷, 需要重新设计;
[Add] 任意地undisciplined使用RTTI使得代码难以维护; 将会导致基于类型的判别树或者switch语句在代码中各处分布, 所有这些都将在今后产生修改时一一检查 <<<
优点:
[Add] RTTI的标准替代方案(下面描述)需要修改或者重新设计类层次还在讨论中in question; 有时候这种修改是不可行infeasible或者不可取的undesirable, 特别是对于广泛使用的或者成熟的代码中; <<
RTTI在某些单元测试中非常有用; 比如对于工厂类的测试, 用来验证一个新建对象是否为期望的动态类型; 除测试外, 极少用到;
[Add] 在管理对象和它们的mock之间的关系方面也很有用;
RTTI在考虑多重抽象对象multiple abstract objects的时候很有用:
1 2 3 4 5 6 7 | bool Base::Equal(Base* other) = 0; bool Derived::Equal(Base* other) { Derived* that = dynamic_cast <Derived*>(other); if (that == NULL) return false ; ... } |
<<<
结论:
RTTI有合理的用处, 但它容易被滥用; 除单元测试外, 不要使用RTTI; 如果你发现自己不得不写一些行为逻辑取决于对象类型的代码, 考虑换一种方式判断对象类型;
- 如果要实现根据子类类型subclass来确定执行不同逻辑的代码, 虚函数无疑更合适; 在对象内部就可以处理类型识别问题;
- 如果要在对象外部的代码中判断类型, 考虑使用双重分派double-dispatch方案, 如访问者Vistor模式; [函数按类型重载: virtual void visit(const Class& obj) = 0;] 可以方便地在对象本身之外利用内置built-in的类型系统确定类的类型;
[Add]
当程序逻辑能保证一个给出的基类的实例实际上是一个特定派生类的实例, 那么dynamic_cast可以在这个对象上自由使用; 通常这种情况下也可以使用static_cast;
基于类型的判断树Decision tree 是个强烈的信号, 表示你的代码在向错误的方向发展wrong track;
1 2 3 4 5 6 | if ( typeid (*data) == typeid (D1)) { ... } else if ( typeid (*data) == typeid (D2)) { ... } else if ( typeid (*data) == typeid (D3)) { ... |
这样的代码通常在新的subclass添加到类的继承体系后就被破坏了; 而且, 当一个subclass的属性改变了, 很难找到以及修改受影响的代码块;
<<<
不要试图手工实现一个貌似RTTI的替代方案workaround, 反对使用RTTI的理由, 同样适用于那些在类型继承体系上使用类型标签的替代方案; 而且, workaround会掩盖你真实的意图;
5.8 类型转换 Casting
Tip 使用C++的类型转换, 如 static_cast<>(); 不要使用 int y = (int)x; 或 int y = int(x);等转换方式;
定义:
C++采用了有别于C的类型转换机制, 对转换操作进行归类区分;
优点:
C语言的类型转换问题在于模棱两可的操作; 有时是在做强制转换(如 (int)3.5), 有时是在做类型转换(如 (int)"hello"); C++的转换可以防止这些; 另外, C++的类型转换在查找时更醒目;
缺点:
恶心的语法;
结论:
不要使用C风格类型转换, 而应该使用C++风格;
- 用 static_cast替代C风格C-style的值转换, 或某个类指针需要明确的向上转换up-cast为父类指针时;
- 用 const_cast去掉 const限定符; [以及volatile..尽量不要这么做]
- 用 reinterpret_cast指针类型和整型或其他指针之间进行不安全的相互转换; 仅在你对所做的一切了然于心而且理解别名问题的时候使用; [不安全的类型转换, 按位转换]
[Remove]
- dynamic_cast测试代码之外不要使用; 除非是单元测试, 如果你需要在运行时确定类型信息, 说明有设计缺陷; [针对于RTTI, 多态使用, 即要有 vitrual关键字] <<<
[对于有些简单的 (int)var_float, 如果你知道自己在做什么, 这么写节省时间, 看起来省力些] [Qt中有个qobject_cast()对应 dynamic_cast, 但不需要RTTI支持]
5.9 流 Streams
Tip 只在记录日志logging时使用流;
定义:
流用来替代 printf()和 scanf();
优点:
有了流, 在打印时不需要关心对象的类型, 不用担心格式化字符串与参数列表不匹配; (虽然在gcc中使用 printf也不存在这个问题); 流的构造和析构函数会自动打开和关闭对应的文件;
缺点:
流使得 pread()等功能函数很难执行; 如果不使用 printf风格的格式化字符串printf-like hacks, 某些格式化操作(尤其是常用的格式字符串 %, *s)用流处理的性能是很低的; 流不支持字符串操作符重新排序(%1$s directive), 而这一点对于软件国际化很有用;
[pread, pwrite - read from or write to a file descriptor at a given offset -- LINUX]
结论:
不要使用流, 除非是日志接口需要; 使用 printf之类的printf-like routine代替;
使用流还有很多利弊, 但代码一致性胜过一切, 不要在代码中使用流;
拓展讨论 Extended Discussion
对这一条规则存在一些争论, 这儿给出点深层次原因; 回想一下唯一性原则(Only One Way): 我们希望在任何时候都只使用一种确定的 I/O类型, 使代码在所有 I/O处都保持一致; 因此, 我们不希望用户来决定是使用流还是 printf+read/write/etc; 相反, 我们应该决定到底使用哪一种方式; 把日志作为特例是因为日志是一个非常独特的应用, 还有一些是历史原因;
流的支持者们主张流是不二之选, 但观点不是那么清晰有力; 他们指出流的每个优势也都有其劣势; 流最大的优势是在输出时不需要关心打印对象的类型; 这是一个亮点; 同时也是一个不足: 你很容易用错类型, 而编译器不会报警; 使用流是容易造成这类错误:
1 2 | cout << this ; // Prints the address cout << * this ; // Prints the contents |
由于 <<被重载overloaded, 编译器不会报错; 就因为这一点我们反对使用操作符重载;
有人说 printf的格式化丑陋不堪, 易读性差, 但流也好不到哪里去; 看看下面的两段代码, 实现相同的功能, 哪个更清晰?
1 2 3 4 5 6 | 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还有长度安全性, 缓冲区溢出等问题 -- Exceptional C++ Style, 2]
还会有这样那样的问题出现; (你可能会说"把流封装一下就会比较好了", 这儿可以, 其他地方呢? 而且不要忘了, 我们的目标是使语言更紧凑, 而不是添加一些别人需要学习的新机制;)
每一种方式都是各有利弊, "没有最好, 只有更适合"; 简单性原则告诫我们必须从中选择其一, 最后大多数majority决定采用 printf + read/write;
---TBC---YCRGoogleCpp风格指南 5) 其他特性_part1