More Effective C++ 条款(1-9)总结
More Effective C++ 条款(1-9)总结
基础议题
条款1:仔细区别pointers和references
- 如果有一个变量,其目的是用来指向(代表)另一个对象,但是也有可能它不指向(代表)这个变量,那么应该使用
pointer
,因为可将pointer
设为null
,反之设计不允许变量为null
,那么使用reference
- 以下这是有害的行为,其结果不可预期(C++对此没有定义),编译器可以产生任何可能的输出
char *pc = 0; // 将 pointer 设定为null
char& rc = *pc; // 让 refercence 代表 null pointer 的 解引值
- 没有
null reference
, 使用reference
可能比pointers
更有效率,在使用reference
之前不需要测试其有效性
void printDouble(const double& rd)
{
cout << rd; // 不需要测试rd,它
} // 肯定指向一个double值
//相反,指针则应该总是被测试,防止其为空:
void printDouble(const double *pd)
{
if (pd) // 检查是否为NULL
{
cout << *pd;
}
}
pointers
可以被重新赋值,指向另一个对象,reference
却总是指向(代表)它最初获得的哪个对象- 实现某些操作符。如operator[],操作符应返回某种“能够被当作assignment赋值对象”
总结
当你知道你需要指向某个东西,而且绝不会改变指向其他东西,或是当你实现一个操作符而其语法需求无法由pointers达成,你就应该选择reference。任何其他时候,请采用pointers
条款2:最好使用C++转型操作符
- 旧式的C转型方式,它几乎允许你将任何类型转换为任何其他类型,这是十分拙劣的
旧式转型存在的问题:
- 例如将
pointer-to-const-object
转型为一个pointer-to-non-const-object
(只改变对象的常量性),和将一个pointer-to-base-class-object
转型为一个pointer-to-derived-class-object
(完全改变一个对象的类型),其间有很大的差异。但是传统的C转型动作对此并无区分 - 难以辨识,旧式转型由一小对小括号加上一个对象名称(标识符)组成,而小括号和对象名称在C++的任何地方都有可能被使用
static_cast:
static_cast
基本上拥有与 C 旧式转型相同的威力与意义,以及相同的限制(如不能将struct转型为int)。- 不能移除表达式的常量性,由
const_cast
专司其职 - 其他新式 C++ 转型操作符适用于更集中(范围更狭窄)的目的
(type) expression // 原先 C 的转型写码形式
static_cast<type>(expression) // 使用 C++ 转型操作符
const_cast:
const_cast
用来改变表达式的常量性(constness)或变易性(volatileness),使用const_cast
,便是对人类(编译器)强调,通过这个转型操作符,你唯一打算改变的是某物的常量性或变易性。这项意愿将由编译器贯彻执行。如果将const_cast
应用于上述以外的用途,那么转型动作会被拒绝
#include <iostream>
using namespace std;
class Widget {};
class SpecialWidget : public Widget {};
void update(SpecialWidget* psw);
SpecialWidget sw; // sw是个 non-const 对象
const SpecialWidget& csw = sw; // csw 确实一个代表sw的 reference
// 并视之为一个const对象
update(&csw); // 错误!不能及那个const SpecialWidget*
// 传给一个需要SpecialWidget* 的函数
update(const_cast<SpecialWidget*>(&csw)); // 可!&csw的常量性被去除了
update((SpecialWidget*)&csw); // 可!但较难识别 C 旧式转型语法
const_cast
最常见的用途就是将某个对象的常量性去除掉
dynamic_cast:
- 用来转型继承体系重“安全的向下转型或跨系转型动作”。也就是说你可以利用
dynamic_cast
,将“指向base ckass objects
的pointers
或references
”转型为“指向derived(或sibling base)class objects
的pointers
或references
”,并得知转型是否成功。如果转型失败,会以一个null
指针或一个exception
(当转型对象是reference
)表现出来:
Widget *pw;
update(dynamic_cast<SpecialWidget*>(pw)); // 很好,传给update()一个指针,指向pw所指的
// pw所指的SpecialWidget--如果pw
// 真的指向这样的东西;否则传过去的
// 将是一个 null 指针
void updateViaRef(SpecialWidegt& rsw);
updateViaRef(dynamic_cast<SpecialWidegt&>(*pw)); // 很好,传给updateViaRef()的是
// pw所指的SpecialWidget--如果
// pw真的指向这样的东西;否则
// 抛出一个exception
dynamic_cast
只能用来协助你巡航于继承体系之中。它无法应用在缺乏虚函数(请看条款24)的类型身上,也不能改变类型的常量性(constness)- 如果不想为一个不涉及继承机制的类型执行转型动作,可使用
static_cast
;要改变常量性(constness),则必须使用const_cast
reinterpret_cast:
- 最后一个转型操作符是
reinterpret_cast
。这个操作符的转换结果几乎总是与编译平台息息相关。所以reinterpret_cast
不具移植性 reinterpret_cast
的最常用用途是转换"函数指针"类型。
typedef void (*FuncPtr)(); // FuncPtr是个指针,指向某个函数
// 后者无须任何自变量,返回值为voids
FuncPtr funcPtrArray[10]; // funcPtrArray 是个数组
// 内有10个FuncPtrs
假设由于某种原因,希望将以下函数的一个指针放进funcPtrArray中
int doSomething();
如果没有转型,不可能办到,因为doSomething
的类型与funcPtrArray
所能接受的不同。funcPtrArray
内各函数指针所指函数的返回值是void
,但doSomething
的返回值却是int
funcPtrArray[0] = &doSomething; //错误!类型不符
funcPtrArray[0] = reinterpret_cast<FuncPtr>(&doSomething); //这样便可通过编译
某些情况下这样的转型可能会导致不正确的结果(如条款31),所以你应该尽量避免将函数指针转型。
补充:
More Effective C++
没有过多的对reinterpret_cast
操作符进行解释,但我觉得应该对它进行更多说明,因为它实在是太强大了,也应该对使用规则做出足够多的说明- reinterpret_cast通过重新解释底层位模式在类型之间进行转换。它将
expression
的二进制序列解释成new_type
,函数指针可以转成void*再转回来。reinterpret_cast
很强大,强大到可以随便转型。因为他是编译器面向二进制的转型,但安全性需要考虑。当其他转型操作符能满足需求时,reinterpret_cast
最好别用。 - 更多了解可看cpp reference reinterpret_cast
总结:
在程序中使用新式转型法,比较容易被解析(不论是对人类还是对工具而言),编译器也因此得以诊断转型错误(那是旧式转型法侦测不到的)。这些都是促使我们舍弃C旧式转型语法的重要因素
条款3:绝对不要以多态(polymorphically)方式处理数组
假设你有一个class BST
及一个继承自BST的class BalancedBST
;
class BST {};
class BalancedBST : public BST {};
现在考虑有个函数,用来打印BSTs数组中的每一个BST的内容
void printBSTArray(ostream& s, const BST array[], int numElements)
{
for (int i = 0 ; i < numElements; ++i)
{
s << array[i]; // 假设BST objects 有一个
// operator<< 可用
}
}
当你将一个由BST对象组成的数组传给此函数,没问题:
BST BSTArray[10];
printBSTArray(cout, BSTArray, 10); // 运行良好
然而如果你将一个BalancedBST
对象所组成的数组交给printBSTArray
函数,会发生什么事?
BalancedBST bBSTArray[10];
printBSTArrat(cout, bBSTArray, 10); // 可以正常运行吗?
- 此时就会发生错误,因为array[i]代表的时
*(array+i)
,编译器会认为数组中的每个元素时BST对象,所以array和array+i之间的距离一定是i*sizeof(BST) - 然后当传入由
BalancedBST
对象组成的数组,编译器会被误导。它仍假设数组中每一元素的大小是BST的大小,但其实每一元素的大小是BalancedBST的大小。因此当BalancedBST
的大小不等于BST
的大小时,会产生未定义的行为 - 当尝试通过一个·base class·指针,删除一个由derived class objects组成的数组,上述的问题还会再次出现,下面是你可能做出的错误尝试
void deleteArray(ostream& os,BST array[])
{
os << "Delete array,at address" <<
static_cast<void*>(array) << 'n';
delete []array;
}
编译器看到这样的句子
delete[] array;
会产生类似这样的代码,问题也就跟之前一样出现了
for(int i = the number of elements in the array-1; i >= 0; --i)
{
array[i].BST::~BST(); // 调用array[i]的 destructor
}
总结:
- 多态和指针算术不能混用,数组对象几乎总是涉及指针的算术运算,数组和多态不要混用
条款4:非必要不提供default constructor
后续看过条款43,再回头来补充
总结:
- 添加无意义的default constructors,也会影响classes的效率。如果class constructors可以确保对象的所有字段都会被正确地初始化,为测试行为所付出的时间和空间代价都可以免除。如果default constructors无法提供这种保证,那么最好避免让default constructors出现。虽然这可能会对classes的使用方式带来某种限制,但同时也带啦一种保证:当你真的使用了这样的classes,你可以预期它们所产生的对象会被完全地初始化,实现上亦富有效率
操作符
条款5:对定制的“类型转换函数”保持警觉
在你从未打算也未预期的情况下,此类函数可能会被调用,而其结果可能是不正确、不直观的程序行为,很难调试。
- 假如有以下这段代码,假设你忘记为
Rational
写一个operator<<
,那么你或许会认为以下打印动作不会成功,因为没有适当的operator<<
可以调用。但是你错了,编译器面对下述动作,发现不存在任何operator<<
可以接受一个Rational,但它会想尽办法(包括找出一系列可接受的隐式类型转换)让函数调用成功。
class Rational {
public:
Rational(int a = 0, int b = 1);
operator double() const; // 定义了一个将类转化为double的转换函数
// 将Rational 转换为double
private:
float val;
Rational r(1, 2);
double d = 0.5 * r;
cout << d << "\n"; // 0.25
cout << r; // 0.5
};
- "可被接受的转换程序定义"十分复杂,但本例中你的编译器发现,只要调用Rational::operator double,将r隐式转换为double,调用动作便能成功。解决办法就是以功能对等的另一个函数取代类型转换操作符,不妨以一个名为
asDouble
的函数取代operator double:
- 避免隐式类型转换带来的问题,使用关键词
explicit
。这个特性之所以被导入,就是为了解决隐式类型转换带来的问题。只要将constructors声明为explicit
,编译器便不能因隐式类型转换的需要而调用它们。
template<class T>
class Array{
public:
explicit Array(int size); // 注意,使用"explicit"
}
条款6:区别increment/decrement操作符的前置(prefix)和后置(postfix)形式
- 前置式返回一个
reference
,后置式返回一个const
对象 increment
操作符的前置式意义"increment and fetch"(累加然后取出),后置式意义"fetch and increment"(取出然后累加)
前置式:
// 前置式:累加然后取出(increment and fetch)
UPInt& UPInt::operator++()
{
*this += 1;
return *this;
}
后置式:
// 后置式:取出然后累加(fetch and increment)
const UPInt UPInt::operator++(int)
{
UPInt oldValue = *this;
++(*this);
return oldValue;
}
请注意后置式操作符并未动用其参数。是的,其参数的唯一目的只是为了区别前置式和后置式而已。ints
并不允许连续两次使用后置式increment
操作符。因此下列代码无法运行。
int i = 3;
i++++; // 错误!后置式返回const对象,因operator++为非const函数,所以无法执行第二次后置式increment操作
++++i; // 合法!前置式返回reference,i值前置式increment两次,i = 5
++++++++i; // 合法!前置式返回reference,i值前置式increment四次,i = 7
++i; // 调用 i.operator++();
i++; // 调用 i.operator++(0);
--i; // 调用 i.operator--();
i--; // 调用 i.operator--(0);
总结:
- 后置式
increment
函数,该函数必须产生一个临时对象,作为返回值之用。效率不如前置式。游戏引擎架构中说:前置式效率更好,但会打乱流水线
条款7:千万不要重载&&, || 和,操作符
当你重载&&,||操作符时,你正从根本层面改变整个游戏规则,因为从此"函数调用"语义会取代"骤死式 语义"
如果你将operator&&重载,下面这个式子:
if (expression1 && expression2) { }
会被编译器视为以下两者之一
if (expression1.operator&&(expression2)) // 假设operator&&是个 member function
if (operator&&(expression1, expression2)) // 假设operator&&是个 global function
虽然看起来没什么太大改变,但是"函数调用"语义和所谓的"骤死式"语义有两个重大的区别。第一,当函数调用动作被执行,所有参数值都必须评估完成,当调用操作符operator&&
和operator||
时,两个参数都已评估完成。没有骤死式语义。第二,C++语言规范并未明确定义函数调用做东中各参数的评估顺序,所以没办法知道expression1
和expression2
哪个会先被评估。这与骤死式评估法形成鲜明的对比,后者总是由左向右评估其自变量。
for (int i = 0, j = strlen(s) - 1; i < j ; ++i, --j)
表达式如果包含逗号,那么逗号左侧会先被评估,然后逗号的右侧再被评估;最后,整个逗号表达式的结果以逗号右侧的值为代表。面对上述循环的最后一个成分,编译器首先评估++i,然后是--j,而整个逗号表达式的结果是--j的返回值
- 如果将操作符写成一个
non-member funcion
,你绝对无法保证左侧表达式一定比右侧表达式更早被评估,因为两个表达式都被当做函数调用时的自变量,传递给该操作符函数,而你无法控制一个函数的自变量评估顺序。所以non-member
做法不可行。 - 剩下可能的做法是写成一个
member function
。但即便如此也不能保证逗号操作符的左操作数会先被评估,因为编译器并不强迫做这样的事情。因此,你"不能将逗号操作符重载,并保证其行为像它应该有的那样"。所以不要轻易地将他重载。
总结:
- 如果你没有什么好理由将某个操作符重载,就不要去做。面对
&&
,||
和,
,实在难有什么好理由,因为不管你多么努力,就是无法令其行为像它们应有的行为一样。
条款8:了解各种不同意义的new和delete
先说明new operator
和operator new
之间的差异。(此处所说的new operator
,即某些C++教程如C++ Primer所谓的new
expression)
string* ps = new string("Memory Management");
它的动作分为两方面。第一,它分配足够的内存,用来放置某类型的对象。第二,它调用一个constructor
(对象的构造函数),为刚才分配的内存中的那个对象设定初值。new operator
总是做这两件事,无论如何你不能改变其行为。
你能够改变的是用来容纳对象的那块内存的分配行为。new operator
调用某个函数,执行必要的内存分配动作,你可以重写或重载那个函数,改变其行为。这个函数的名称叫做operator new
函数operator new
通常声明如下:
void* operator new(size_t size);
其返回值是void*
。此函数返回一个指针,指向一块原始的、未设初值的内存(如果你喜欢,可以写一个新版的operator new
,在其返回内存指针之前先将那块内存设定初值。只不过这种行为颇为罕见就是了)
void *rawMemory = operator new(sizeof(string));
这里的operator new
将返回指针,指向一块足够容纳一个string
对象的内存
和malloc
一样,operator new
的唯一任务就是分配内存。它不知道什么是constructors
,operator new
只负责内存分配。取得operator new
返回的内存并将之转换为一个对象,是new operator
的责任
Placement new
有时候你真的会想直接调用一个constructor
,偶尔你会有一些分配好的原始内存,你需要在上面构建对象。有一个特殊版本的operator new
,称为placement new
,允许你那么做
如果你希望将对象产生于heap
,请使用new operator
。它不但会分配内存而且为该对象调用一个constructor
。如果你只是打算分配内存,请调用operator new
,那就没有任何constructor
会被调用。如果你打算在heap objects
产生时自己决定内存分配方式,请写一个operator new
,并使用new operator
,它将会自动调用你缩写的operator new
。
如果你打算在已分配(并拥有指针)的内存中构造对象,请使用placement new
删除和内存释放
如果你只打算处理原始的、未定初值的内存,应该完全回避new operator
和delete operator
,改调用operator new
取得内存并以operator delete
归还给系统:
void *buffer = operator new (50 * sizeof(char)); // 分配足够的内存,放置50个chars;没有调用任何ctors
operator delete(buffer); // 释放内存,没有调用任何dtors
如果你使用placement new
,在某内存块中产生对象,你应该避免对那块内存使用delete operator
。因为delete operator
会调用operator delete
,但是该内存包含的对象最初并非是由operator new
分配得来的。毕竟placement new
只是返回它所接收的指针而已。
总结:
new operator
和delete operator
都是内建操作符,无法为你所控制,但是它们所调用的内存分配/释放函数则不然。你可以修改它们完成任务的方式,至于它们的任务,已经被语言规范固定死了。
本文作者:shadow_lr
本文链接:https://www.cnblogs.com/shadow-lr/p/MoreEffective_Cplusplus_1-9.html
版权声明:本作品采用shadow-lr许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步