写这篇文章的目的
身为C++的零基础初学者,短期内把《C++Primer》啃下来是一个比较笨但是有效的方法,一方面可以掌握比较规范的C++语法(避免被项目中乱七八糟的风格带跑偏),另一方面又可以全面地了解C++语法以及C++11新标准(后续要做的事情就剩下查漏补缺,不断完善自己的知识体系)。
个人感觉从零学习一门新知识比较好的方法是快速了解知识的全貌,然后构建自己的知识地图,后续不断地补充相应的细节。
由于《C++Primer》和大多数的教科书一样废话连篇,因此想要精炼一下每篇文章的内容再打印成pdf,方便温故知新。
基础
1. 左值和右值
这两个名词原本是从
C
继承过来的,主要是为了帮助记忆,左值可以位于赋值表达式左侧,而右值不行。
C++
的表达式要不然就是右值r-value
,要不然就是左值l-value
。但是在C++
语言中,两者的区别没有那么简单:
- 左值表达式的求值结果是一个对象或者一个函数,但是以常量对象为代表的某些左值却不能作为赋值语句的左侧运算对象
- 虽然某些表达式的求值结果是对象,但是它们实际上是右值而不是左值
- 简单的归纳:当一个对象被用作右值时,用的是对象的值(内容);当对象被用作左值的时候,用的是对象的身份(在内存中的位置)
在需要右值的地方可以用左值来替代,但是不能把右值当成左值(也就是内存中的位置)来使用。当一个左值被当做右值来使用的时候,实际上使用的是它的内容(值)。
2. 求值顺序
优先级规定了运算对象的组合方式,但是没有说明运算对象按照什么顺序求值,比如:int i = f1() * f2();
,我们只能知道f1
和f2
会在执行乘法之前被调用,但是不清楚f1
和f2
两者的调用顺序。这种情况在f1
和f2
同时修改了同个对象的值时可能引发非预期的错误。
有四种运算符明确规定了运算对象的求值顺序:
- 逻辑与运算符
&&
:先求左侧 - 逻辑或运算符
||
:先求左侧 - 条件运算符
?:
:右结合律 - 逗号运算符
,
:先求左侧
算术运算符
需要注意如下几点:
- 当计算结果超出该类型所能表示的范围时可能产生溢出,比如最大的
short
型数值为32767
,这时候+1
可能输出-32768
(这是因为符号位从0
变为1
,从而变成负值)。当然在别的系统程序的行为可能不同甚至崩溃。 -
/
除法运算在运算对象都是整数时会将商的小数部分剔除,并且如果两个运算对象的符号相同则商为正,否则为负 - 参与
%
取余运算的两个运算对象必须是整数类型,如果m
和n
是整数且n
非零,则表达式(m/n)*n + m%n
的求值结果与m
相同。(这意味着如果m%n
不等于0
,则它的符号与m
相同)。具体示例如下:
21 % 6; // 3
21 % 7; // 0
-21 % -8; // -5
21 % -5; // 1
总计一下,对于除法
/
而言,(-m) / n
和m / (-n)
都等于-(m / n)
;对于取余%
而言,m % (-n)
等价于m % n
,(-m) % n
等价于-(m % n)
逻辑运算符
逻辑与&&
和逻辑或||
都是先求左侧对象的值再求右侧运算对象的值,当且仅当左侧运算对象无法确定表达式的结果才会计算右侧运算对象的值,这种策略被称为短路求值。基于短路求值的特点,我们可以通过左侧运算对象来确保右侧运算对象求值的正确性和安全性:
// 只能左侧运算对象为真则右侧运算对象才安全
index != s.size() && !isspace(s[index])
赋值运算符
- 赋值运算符的左侧运算对象必须是一个可修改的左值(复制一下,左值指的是对象,可修改的左值意味着能修改对象的值)。例如
const int ci = i;
是一个初始化语句而非赋值语句,因为该左值是常量不可修改。 -
C++11
新标准允许使用花括号括起来的初始值列表作为赋值语句的右侧运算对象 - 赋值运算满足右结合律,则
ival = jval = 0;
会将两个变量都赋值为0
- 赋值运算的优先级较低,所以一般都需要给赋值部分加上括号使得其符合我们的预期
递增和递减运算符
- 前置版本和后置版本
后置版本也会将运算对象加/减一,但是求值结果是运算对象改变之前的值的副本。这两种运算符必须作用于左侧运算对象,其中前置版本呢将对象本身作为左值返回,后置版本将对象原始值的副本的作为右值返回。
除非必须,否则不用递增递减运算符的后置版本:前置版本的递增运算将值加1之后直接返回该运算对象,但是后置版本需要将原始值存储下来以便于返回这个未修改的内容,如果我们不需要修改前的值的话就是一种性能上的浪费。对于整数和指针类型而言,编译器可能对这种额外的工作进行优化,但是如果是对于相对复杂的迭代器类型,这种额外的工作就消耗巨大了。建议养成使用前置版本习惯,这样不仅不需要担心性能问题,而且不会引入非预期的错误。
int i = 0, j;
j = ++i; // j = 1, i = 1: 前置版本得到递增之后的值
j = i++; // j = 1, i = 2:后置版本得到递增之前那的值
- 后置版本的可能使用场景
最常用的场景就是在一条语句中混用解引用和递增运算符的后置版本:
auto pbeg = v.begin();
// 输出元素直到遇到第一个负值
while (pbeg != v.end() && *pbeg >= 0)
cout << *pebg++ << endl; // 输出当前值并将pbeg向前移动一个元素
*pbeg++
这种写法非常普遍,会先把pbeg
的值加1,然后返回pbeg
的初始值的副本作为其求解结果,此时解引用的运算对象是pbeg
未增加之前的值。
成员访问运算符
点运算符和箭头运算符都可用于访问成员,ptr->mem
等价于(*ptr).mem
。需要注意的是解引用运算符优先级低于点运算符,所以必须加上括号。
条件运算符
条件运算符满足右结合律,意味着运算对象一般按照从右往左的顺序组合,因此我们使用嵌套条件运算符:
finalgrade = (grade > 90) ? "high pass"
: (grade < 60) ? "fail" : "pass"
注意条件运算符的优先级非常低,所以一条长表达式中嵌套了条件运算子表达式时,通常需要在两端加上括号:
cout << ((grade < 60) ? "fail" : "pass"); // 输出pass或者fail
位运算符
1. 移位运算符
左移运算符<<
在右侧插入值为0
的二进制位,右移运算符>>
的行为则依赖其左侧运算对象的类型,如果该运算对象是无符号类型,在左侧插入值为0
的二进制位;如果该运算符是带符号类型,则在左侧插入符号位的副本或值为0的二进制位。
2. 位求反运算符
对于char
类型的运算对象首先提升为int
类型,提升时运算对象原来的位保持不变,往高位添加0
即可。接下来将提升后的值逐位求反。
3. 位与、位或和位异或
- 位与:两个都是
1
则返回1
,否则为0
- 位或:两个至少有一个为
1
则返回1
,否则为0
- 位异或:两个有且只有一个为
1
则返回1
sizeof运算符
sizeof
返回一条表达式或者一个类型名字所占的字节数。
- 对
char
或者类型为char
的表达式执行sizeof
,返回1
- 对引用类型执行
sizeof
运算得到被引用对象所占空间的大小 - 对指针执行
sizeof
得到指针本身所占空间的大小 - 对解引用指针执行
sizeof
运算得到指针你指向的对象所占空间的大小,指针本身不需要有效 - 对数组执行
sizeof
运算得到整个数组所占空间的大小,等价于对数组中所有的元素各执行一次sizeof
运算并将所得结果求和 - 对
string
对象或vector
对象执行sizeof
运算只返回该类型固定部分的大小,不会计算对象中的元素占用了多少空间
因为
sizeof
的返回值是一个常量表达式,因此我们可以用sizeof
的结果声明数组的维度
类型转换
1. 隐式转换
- 比
int
类型小的整型值首先提升为较大的整型类型 - 在条件中,非布尔值转化为布尔值
- 初始化过程中,初始值转换为变量的类型;在赋值语句中,右侧运算对象转化为左侧运算对象的类型
- 如果算术运算或者关系运算的运算对象有多种类型,需要转换为同一种类型
- 函数调用也会发生隐式转换
2. 算术转换
- 整型提升:负责把小整数类型转换为大的整数类型
- 无符号类型的运算对象:如果一个运算对象是无符号类型,另一个运算对象是带符号类型,其中的无符号类型不小于带符号类型,那么带符号的运算对象就会转换为无符号的。例如
unsigned int
和int
运算时,int
类型转换为unsigned int
。但是需要注意如果int
类型为负,则可能带来一定的副作用(因为无符号类型无法显示负值)。 - 带符号类型大于无符号类型时,则转换的结果依赖于机器。如果无符号类型的所有值都能存在该带符号类型类型中,则无符号类型转换为带符号类型;如果不能,则带符号类型的运算对象转换为无符号类型。例如
unsigned int
和long
,并且int
和long
的大小相同,则long
类型转换为unsigned int
,如果long
类型占用空间大于int
,则unsigned int
类型转换为long
。
2. 其他隐式类型转换
- 数组转换为指针:在大多数用到数组的表达式中,数组自动转换为指向数组首元素的指针
- 指针的转换:
0
或nullptr
可以转换为任意指针类型;指向任意非常量的指针可以转换为void*
;指向人以对象的指针能转换为const void*
- 转换为布尔类型:存在从算术类型或指针类型向布尔类型自动转换的机制
- 转换为常量:允许将指向非常量类型的指针转换为指向对应的常量类型的指针,对于引用也是如此
3. 显式转换/强制类型转换
static_cast
任何具有明确定义的类型转换,只要不包含底层const
就可以使用static_cast
,一种常用的方法是把一个较大的算术类型赋值给较小的类型,这种用法告诉编译器和读者:我们知道并不在乎潜在的精度损失。
int i, j;
double slope = static_cast<double> (j) / i;
另一种用法对于编译器无法自动执行的类型转换也非常有用,例如我们使用static_cast
召回存在与void*
指针中的值:
void* p = &d;
double *dp = static_cast<double*>(p);
const_cast
const_cast
只能改变运算对象的底层const
,一旦我们去掉了某个对象的const
性质,编译器就不再阻止我们对该对象进行写操作了。如果对象本身不是一个常量,那么使用强制类型转换获得写权限是合法的行为,但是如果对象是一个常量,再使用const_cast
执行写操作就会产生未定义的后果:
const char *pc;
char *p = const_cast<char*>(pc); // 正确,但是通过p写值是未定义的行为
reinterpret_cast
使用
reinterpret_cast
是非常危险的,主要是因为类型改变了但是编译器没有给出任何警告或者错误的提示信息。
reinterpret_cast
通常为运算对象的位模式提供较低层次上的重新解释,例如:
int *ip;
int *pc = reinterpret_cast<char*>(ip);
// 必须牢记pc的真实对象时一个int而非字符,如果把pc当成普通的字符指针容易在运行时发生错误,例如使用string str(pc);
- 旧式的强制类型转换
如果替换后不合法,则旧式的强制类型转换执行与
reinterpret_cast
具有类似的功能。因此使用旧式的强制类型转换是不被推荐的行为。
type (expr); // 函数形式的强制类型转换
(type) expr; // C语言风格的强制类型转换
网友评论