C++ 特性使用建议

2022-12-02 16:14:17 浏览数 (1)

文章目录

  • 1.引用参数
  • 2.右值引用
  • 3.函数重载
  • 4.缺省参数
  • 5.变长数组和 alloca()
  • 6.友元
  • 7.异常
  • 8.运行时类型识别
  • 9.类型转换
  • 10.流
  • 11.前置自增和自减
  • 12.const 用法
  • 13.constexpr 用法
  • 14.整型
  • 15.64位下的可移植性
  • 16.预处理宏
  • 17.认清0、''、nullptr 与 NULL
  • 18.sizeof
  • 19.auto
  • 20.列表初始化
  • 21.Lambda 表达式
  • 22.模板编程
  • 23.Boost 库
  • 24.C 11
  • 参考文献

1.引用参数

使用引用替代指针且所有不变的引用参数必须加上const。在C 语言中,如果函数需要修改变量的值,参数必须为指针,如int foo(int *pval),在 C 中,函数还可以声明引用参数int foo(int &val),定义引用参数防止出现 (*pval) 这样丑陋的代码。像拷贝构造函数这样的应用也是必需的,而且更明确,不接受 NULL 指针。

2.右值引用

建议:只在定义移动构造函数与移动赋值操作时使用右值引用,区分std::move与std::forward的作用。

右值引用是一种只能绑定到临时对象的引用的一种,其语法与传统的引用语法相似,例如void f(string&& s); 声明了一个其参数是一个字符串的右值引用的函数。用于定义移动构造函数使得移动一个值而非拷贝之成为可能。例如,如果v1 是一个vector<string>,则auto v2(std::move(v1))将很可能不再进行大量的数据复制而只是简单地进行指针操作,在某些情况下这将带来大幅度的性能提升。

std::move是无条件转换为右值,而std::forward是有条件转换为右值,只会将绑在右值上的参数转换为右值,起到转发一个参数给到另一个函数而保持原来的左值性质或者右值性质。 二者只进行了转换,没有移动对象。

3.函数重载

(1)仅在输入参数类型不同、功能相同时使用重载函数(含构造函数),当使用具有默认形参值的函数(方法)重载的形式时,需要注意防止二义性。

代码语言:javascript复制
void fun(int x,int y=2,int z=3);
void fun(int x);

(2)如果您打算重载一个函数,可以试试改在函数名里加上参数信息。例如,用 AppendString() 和 AppendInt() 等,而不是一口气重载多个Append()。

4.缺省参数

不建议使用缺省函数参数,尽可能改用函数重载。虽然通过缺省参数,不用再为个别情况而特意定义一大堆函数了,与函数重载相比,缺省参数语法更为清晰,代码少,也很好地区分了必选参数和可选参数。但是缺省参数函数调用的代码难以呈现所有参数,开发者只能通过查看函数申明或定义确定如何使用API,当缺省参数不适用于新代码时可能导致重大问题。

5.变长数组和 alloca()

不要使用变长数组和 alloca()。变长数组和 alloca() 不是标准 C 的组成部分,更重要的是,它们根据数据大小动态分配堆栈内存,会引起难以发现的内存越界 bugs。改用更安全的分配器(allocator),像 std::vector std::unique_ptr<T[]>,可有效避免内存越界错误。

6.友元

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

友元扩大了(但没有打破)类的封装边界。某些情况下,相对于将类成员声明为 public,使用友元是更好的选择,尤其是如果你只允许另一个类访问该类的私有成员时。当然,大多数类都只应该通过其提供的公有成员进行互操作。

7.异常

在 C 基础之上,C 引入了异常处理机制,给开发者处理程序错误提供了便利。使用异常主要有如下优点: (1)异常允许应用高层决定如何处理在底层嵌套函数中发生的失败,不用管那些含糊且容易出错的错误代码。 (2)很多现代语言都用异常。引入异常使得 C 与 Python,Java 以及其它类 C 的语言更一脉相承。 (3)有些第三方 C 库依赖异常,关闭异常将导致难以与之结合。 (4)异常是处理构造函数失败的唯一途径,虽然可以用工厂模式产生对象或 Init() 方法代替异常,但是前者要求在堆栈分配内存,后者会导致刚创建的实例处于 ”无效“ 状态。 (5)使用异常处理,便于代码测试。

使用异常也会带来很多问题,注意以下几点: (1)在现有函数中添加 throw 语句时,必须检查所有调用点,要么让所有调用点统统具备最低限度的异常安全保证,要么眼睁睁地看异常一路欢快地往上跑,最终中断掉整个程序。 (2)函数内抛出异常,注意栈展开时造成的内存泄漏。 (3)异常会彻底扰乱程序的执行流程并难以判断,函数也许会在您意料不到的地方返回。您或许会加一大堆何时何处处理异常的规定来降低风险,然而开发者的记忆负担更重了。 (4)异常安全需要RAII和不同的编码实践。要轻松编写出正确的异常安全代码需要大量的支持机制。 (5)启用异常会增加二进制文件大小,延长编译时间(或许影响不大),还可能加大地址空间的压力。 (6)滥用异常会变相鼓励开发者去捕捉不合时宜,或本来就已经没法恢复的伪异常。比如,用户的输入不符合格式要求时,也用不着抛异常。

总体来说,使用异常有利有弊。在新项目中,可以使用异常,但是对于现有代码,引入异常会牵连到所有相关代码。是否使用异常,需要结合实际情况来定。

8.运行时类型识别

禁止使用 RTTI。RTTI 允许程序员在运行时识别 C 类对象的类型。它通过使用 typeid 或者 dynamic_cast 完成。

优点:RTTI 在某些单元测试中非常有用。比如进行工厂类测试时,用来验证一个新建对象是否为期望的动态类型。RTTI 对于管理对象和派生对象的关系也很有用。

缺点: (1)在运行时判断类型通常意味着设计问题。如果你需要在运行期间确定一个对象的类型,这通常说明你需要考虑重新设计你的类。 (2)随意地使用 RTTI 会使你的代码难以维护。它使得基于类型的判断树或者 switch 语句散布在代码各处。如果以后要进行修改,你就必须检查它们。基于类型的判断树是一个很强的暗示,它说明你的代码已经偏离正轨了。不要像下面这样:

代码语言:javascript复制
if (typeid(*data) == typeid(D1)) {
	...
} else if (typeid(*data) == typeid(D2)) {
	...
} else if (typeid(*data) == typeid(D3)) {
	...
}

一旦在类层级中加入新的子类,像这样的代码往往会崩溃。而且,一旦某个子类的属性改变了,你很难找到并修改所有受影响的代码块。

结论: RTTI 有合理的用途但是容易被滥用,因此在使用时请务必注意。在单元测试中可以使用 RTTI,但是在其他代码中请尽量避免。尤其是在新代码中,使用 RTTI 前务必三思。如果你的代码需要根据不同的对象类型执行不同的行为的话,请考虑用以下的两种替代方案之一查询对象类型: (1)虚函数可以根据子类类型的不同而执行不同代码。这是把工作交给了对象本身去处理。 (2)如果这一工作需要在对象之外完成,可以考虑使用双重分发的方案,例如使用访问者设计模式。这就能够在对象之外进行类型判断。 (3)如果程序能够保证给定的基类实例实际上都是某个派生类的实例,那么就可以自由使用dynamic_cast。在这种情况下,使用dynamic_cast也是一种替代方案。

不要去手工实现一个类似RTTI的方案,反对RTTI的理由同样适用于这些方案,比如带类型标签的类继承体系。而且,这些方案会掩盖你的真实意图。

9.类型转换

不要使用 C 风格类型转换,而应该使用 C 风格的类型转换。 (1)用 static_cast 替代 C 风格的值转换,或某个类指针需要明确的向上转换为父类指针时。 (2)用 const_cast 去掉 const 限定符。 (3)用 reinterpret_cast 指针类型和整型或其它指针之间进行不安全的相互转换。仅在你对所做一切了然于心时使用。 (4)在有继承关系且存在虚函数的类类型之间使用dynamic_cast,达到运行时类型识别效果。

10.流

只在记录日志时使用流,使用C 风格的流对象用来替代printf()和scanf()。

优点: 有了流,在打印时不需要关心对象的类型,不用担心格式化字符串与参数列表不匹配,并且流的构造和析构函数会自动打开和关闭对应的文件。

缺点: 流使得 pread() 等功能函数很难执行。如果不使用 printf 风格的格式化字符串,某些格式化操作(尤其是常用的格式字符串 %.*s)用流处理性能是很低的。流不支持字符串操作符重新排序 (%1s),而这一点对于软件国际化很有用。 结论:

使用流还有很多利弊,但代码一致性胜过一切。每一种方式都是各有利弊,“没有最好,只有更适合”。简单性原则告诫我们必须从中选择其一,最后大多数决定采用 printf read/write。

11.前置自增和自减

对简单数值(非对象),前置与后置均可,对于迭代器和其他构造类型对象使用前前置形式 ( i)。

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

12.const 用法

强烈建议在任何可能的情况下都要使用 const,此外有时改用 C 11 推出的 constexpr 更好。

使用const,大家更容易理解如何使用变量。编译器可以更好地进行类型检测,相应地,也能生成更好的代码。人们对编写正确的代码更加自信,因为他们知道所调用的函数被限定了能或不能修改变量值。即使是在无锁的多线程编程中,人们也知道什么样的函数是安全的。因此, 我们强烈建议在任何可能的情况下使用 const: (1)如果函数不会修改传你入的引用或指针类型参数,该参数应声明为 const。 (2)尽可能将函数声明为 const。访问函数应该总是 const。其他不会修改任何数据成员,未调用非 const 函数,不会返回数据成员非 const 指针或引用的函数也应该声明成 const。 (3)如果数据成员在对象构造之后不再发生变化,可将其定义为 const。

13.constexpr 用法

在C 11 里,用 constexpr 来定义真正的常量,或实现常量初始化。变量可以被声明成 constexpr 以表示它是真正意义上的常量,即在编译时和运行时都不变。constexpr 可以定义用户自定义类型的常量,也修饰函数返回值。

14.整型

C 内建整型中,仅使用 int。如果程序中需要不同大小的变量,可以使用 <stdint.h> 中长度精确的整型,如 int16_t。如果您的变量可能不小于 2^31,就用 64 位变量比如 int64_t。此外要留意,哪怕您的值并不会超出 int 所能够表示的范围,在计算过程中也可能会溢出。所以拿不准时,干脆用更大的类型。

15.64位下的可移植性

代码应该对 64 位和 32 位系统友好。处理打印,比较,结构体对齐时应切记:

对于某些类型,printf() 的指示符在 32 位和 64 位系统上可移植性不是很好。C99 标准定义了一些可移植的格式化指示符定义在头文件 inttypes.h,整型指示符应该按照如下方式使用:

类型

不要使用

使用

备注

void * (或其他指针类型)

%lx

%p

int32_t

%d

%“PRId32”

uint32_t

%u,%x

%“PRIu32”,%“PRIx32”

int64_t

%lld

%“PRId64”

uint64_t

%llu,%llx

%“PRIu64”,%“PRIx64”

size_t

%llu

%“PRIuS”,%“PRIxS”

C99 规定 %zu

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

16.预处理宏

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

宏意味着你和编译器看到的代码是不同的。这可能会导致异常行为,尤其因为宏具有全局作用域。值得庆幸的是,C 中,宏不像在 C 中那么必不可少。以往用宏展开性能关键的代码,现在可以用内联函数替代。用宏表示常量可被 const 变量代替。用宏 “缩写” 长变量名可被引用代替。千万别用宏进行条件编译,会令测试更加痛苦 ,当然使用条件宏防止头文件重复包含是个特例。

如果不可避免的需要使用宏,为尽可能避免使用宏带来的问题,请遵守下面的约定: (1)不要在 .h 文件中定义宏。 (2)在马上要使用时才进行 #define,使用后要立即 #undef,不要只是对已经存在的宏使用#undef。 (3)选择一个不会冲突的名称。 (4)不要试图使用展开后会导致 C 构造不稳定的宏,不然也至少要附上文档说明其行为。 (5)不要用 ## 处理函数,类和变量的名字。

17.认清0、‘’、nullptr 与 NULL

整数零用 0,实数零用 0.0,空字符用 ‘’,空指针用 nullptr 或 NULL。

整数零用 0,实数零用 0.0,空字符用 ‘’,这一点是毫无争议的。对于空指针,到底是用 0,NULL 还是 nullptr,C 11 项目用 nullptr,C 03 项目则用 NULL,不要使用0来表示空指针,毕竟 NULL 和 nullptr 看起来更像指针。实际上,一些 C 编译器对 NULL 的定义比较特殊,可以输出有用的警告,特别是 sizeof(NULL) 就和 sizeof(0) 不一样,一般情况下,sizeof(NULL) 表示 sizeof((void*)0),sizeof(0) 表示 sizeof(int),生成64位的可执行程序, sizeof((void*)0) = 8,sizeof(int) = 4。

18.sizeof

尽可能用 sizeof(varname) 代替 sizeof(type)。使用 sizeof(varname) 是因为当代码中变量类型改变时会自动更新。您或许会用 sizeof(type) 处理不涉及任何变量的代码,比如处理来自外部或内部的数据格式,这时用变量就不合适了。

代码语言:javascript复制
Struct data;
memset(&data,0,sizeof(data));
memset(&data,0,sizeof(Struct));	//Warning

//可以用 sizeof(type) 处理不涉及任何变量的代码
if (raw_size < sizeof(int)) {
    LOG(ERROR) << "compressed record not big enough for count: " << raw_size;
    return false;
}

19.auto

用 auto 绕过烦琐的类型名,只要可读性好就继续用,但别用在局部变量之外的地方,比如声明头文件里的一个常量,那么只要仅仅因为程序员一时修改其值而导致类型变化的话,API 要翻天覆地了。

C 11 中,若变量被声明成 auto,那它的类型就会被自动匹配成初始化表达式的类型。您可以用 auto 来复制初始化或绑定引用。有时C 类型名有时又长又臭,特别是涉及模板或命名空间的时候,使用auto可以简化代码。

代码语言:javascript复制
vector<string> v;
...
auto s1 = v[0];  		// 创建一份 v[0] 的拷贝
const auto& s2 = v[0];  // s2 是 v[0] 的一个引用

sparse_hash_map<string,int>::iterator iter = m。find(val);
auto iter = m。find(val);	//简洁化重构

auto 还可以和 C 11 特性尾置返回类型(trailing return type)一起用,不过后者只能用在 Lambda 表达式里。

20.列表初始化

建议用列表初始化。早在 C 03 里,聚合类型(aggregate types)就已经可以被列表初始化了,比如数组和不自带构造函数的结构体:

代码语言:javascript复制
struct Point { int x; int y; };
Point p = {1,2};

从 C 11 开始,该特性得到进一步的推广,任何对象类型都可以被列表初始化。示范如下:

代码语言:javascript复制
// Vector 接收了一个初始化列表。
vector<string> v{"foo","bar"};
vector<string> v = {"foo","bar"};

// 可以配合 new 一起用。
auto p = new vector<string>{"foo","bar"};

// map 接收了一些 pair,列表初始化大显神威
map<int,string> m = {{1,"one"},{2,"2"}};

// 初始化列表也可以用在返回类型上的隐式转换。
vector<int> test_function() { return {1,2,3}; }

// 初始化列表可迭代。
for (int i : {-1,-2,-3}) {}

// 在函数调用里用列表初始化。
void TestFunction2(vector<int> v) {}
TestFunction2({1,2,3});

用户自定义类型也可以定义接收 std::initializer_list 的构造函数和赋值运算符,以自动列表初始化:

代码语言:javascript复制
class MyType {
public:
  	// std::initializer_list 专门接收 init 列表
  	// 得以值传递
	MyType(std::initializer_list<int> init_list) {
    	for (int i : init_list) append(i);
	}

	MyType& operator=(std::initializer_list<int> init_list) {
    	clear();
    	for (int i : init_list) append(i);
  	}
};
MyType m{2,3,5,7};

最后,列表初始化也适用于常规数据类型的构造,哪怕没有接收 std::initializer_list 的构造函数。

代码语言:javascript复制
double d{1,23};
// MyOtherType 没有 std::initializer_list 构造函数
class MyOtherType {
public:
	explicit MyOtherType(string);
	MyOtherType(int,string);
};
MyOtherType m = {1,"b"};
//如果构造函数是显式的(explict),不能用 = {}。
MyOtherType m{"b"};

千万别直接列表初始化 auto 变量,看下一句,估计没人看得懂:

代码语言:javascript复制
// Warning
auto d = {1.23};        	// d 即是 std::initializer_list<double>
auto d = double{1.23};  	//d 即为 double,并非 std::initializer_list。

21.Lambda 表达式

适当使用 Lambda 表达式。别用默认 Lambda 捕获,所有捕获都要显式写出来。

Lambda 表达式是创建匿名函数对象的一种简易途径,常用于把函数当参数传,例如:

代码语言:javascript复制
std::sort(v.begin(),v.end(),[](int x,int y){
    return Weight(x) < Weight(y);
});

C 11 首次提出 Lambdas,还提供了一系列处理函数对象的工具,比如多态包装器(polymorphic wrapper) std::function。传函数对象给 STL 算法,Lambdas 最简易,可读性也好。Lambdas、std::functions 和 std::bind 可以搭配成通用回调机制(general purpose callback mechanism),写接收有界函数为参数的函数也很容易了。

使用注意事项: (1)禁用默认捕获,捕获都要显式写出来。打比方,比起 [=](int x) {return x n;},您该写成 [n](int x) {return x n;} 才对,这样读者也好一眼看出 n 是被捕获的值。 (2)匿名函数始终要简短,如果函数体超过了五行,把 Lambda 表达式赋值给对象,即给Lambda 表达式起个名字或改用函数。 (3)如果可读性更好,就显式写出 Lambda 的尾置返回类型,就像auto。 (4)小用 Lambda 表达式怡情,大用伤身。Lambda 可能会失控,层层嵌套的匿名函数难以阅读。

22.模板编程

不要使用复杂的模板编程。模板编程是图灵完备的,利用C 模板实例化机制可以被用来实现编译期的类型判断、数值计算等。

优点: 模板编程能够实现非常灵活的类型安全的接口和极好的性能,一些常见的工具比如Google Test,std::tuple,std::function 和 Boost.Spirit。这些工具如果没有模板是实现不了的

缺点: (1)模板编程所使用的技巧对于使用C 不是很熟练的人是比较晦涩,难懂的。在复杂的地方使用模板的代码让人更不容易读懂,并且debug 和 维护起来都很麻烦。 (2)模板编程经常会导致编译出错的信息非常不友好:在代码出错的时候,即使这个接口非常的简单,模板内部复杂的实现细节也会在出错信息显示。导致这个编译出错信息看起来非常难以理解。 (3)大量的使用模板编程接口会让重构工具(Visual Assist X,Refactor for C 等等)更难发挥用途。首先模板的代码会在很多上下文里面扩展开来,所以很难确认重构对所有的这些展开的代码有用,其次有些重构工具只对已经做过模板类型替换的代码的AST 有用。因此重构工具对这些模板实现的原始代码并不有效,很难找出哪些需要重构。

结论: (1)模板编程有时候能够实现更简洁更易用的接口,但是更多的时候却适得其反。因此模板编程最好只用在少量的基础组件,基础数据结构上,因为模板带来的额外的维护成本会被大量的使用给分担掉。 (2)在使用模板编程或者其他复杂的模板技巧的时候,你一定要再三考虑一下。考虑一下你们团队成员的平均水平是否能够读懂并且能够维护你写的模板代码。或者一个非C 程序员和一些只是在出错的时候偶尔看一下代码的人能够读懂这些错误信息或者能够跟踪函数的调用流程。如果你使用递归的模板实例化,或者类型列表,或者元函数,又或者表达式模板,或者依赖SFINAE,或者sizeof 的trick 手段来检查函数是否重载,那么这说明你模板用的太多了,这些模板太复杂了,我们不推荐使用。 (3)如果你使用模板编程,你必须考虑尽可能的把复杂度最小化,并且尽量不要让模板对外暴漏。你最好只在实现里面使用模板,然后给用户暴露的接口里面并不使用模板,这样能提高你的接口的可读性。并且你应该在这些使用模板的代码上写尽可能详细的注释。你的注释里面应该详细的包含这些代码是怎么用的,这些模板生成出来的代码大概是什么样子的。还需要额外注意在用户错误使用你的模板代码的时候需要输出更人性化的出错信息。因为这些出错信息也是你的接口的一部分,所以你的代码必须调整到这些错误信息在用户看起来应该是非常容易理解,并且用户很容易知道如何修改这些错误

23.Boost 库

只使用 Boost 中被认可的库。Boost库集是一个广受欢迎,经过同行鉴定,免费开源的C 优秀库集。

优点:Boost代码质量普遍较高,可移植性好,填补了 C 标准库很多空白,如型别的特性,更完善的绑定器,更好的智能指针。

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

结论:为了向阅读和维护代码的人员提供更好的可读性,建议使用 Boost成熟的特性子集,如boost/heap、 boost/math/distributions、boost/container/flat_map、and boost/container/flat_set等。

24.C 11

适当使用 C 11的库和语言扩展,在用 C 11 特性前三思可移植性。

优点:在二〇一四年八月之前,C 11 一度是官方标准,被大多 C 编译器支持。它标准化了很多我们早先就在用的扩展的C 特性,简化了不少操作,大大改善了性能和安全。

缺点:C 11相对于C 98,复杂极了,标准文档1300页VS800 页!很多开发者也不怎么熟悉它。从长远来看,前者特性对代码可读性以及维护代价难以预估。

参考文献

Effective Modern C 条款23 理解std::move和std::forward Google C 编程风格指南

0 人点赞