cc++一些常见小错误
记录一下,使用c/c++编码时常见的几个小误区。
一、指针的“1”
c/c++的指针非常灵活,在对不同类型的指针操作时,对指针的“1“的尺寸也会随着类型的变化而变化。例如对于4B尺寸的int类型,int*的”1“就是4B,在移动指针时,是以4B为”1“移动的,而不是1B。对于自定义类,这个”1“等于成员变量尺寸之和再经对齐后的大小。
x1// c++ 20
2
3int main(){
4 int a;
5 int *pa = &a;
6 cout << pa << endl; // 0x5905ff960
7 pa += sizeof(int); // 可能被误认为是移动4B
8 cout << pa << endl; // 预计是0x5905ff964,结果是0x5905ff970
9 pa +=1 ; // 正确做法
10 pa ++;++pa; // 同上
11 return 0;
12}
二、几个运算符重载的常见误区
对自定义类的运算符函数重载,被重载的运算符一般都跟随再自定义类的后面。但++、--、+、-<<、>>运算符需要特别注意。++、--、+、-四种运算符重载时都可以不带参数,此时重载都是前置形式(虽然+、-没有加数和减数为空的后置形式)。++和--的前置和后置两种形式,无论那种都作用于唯一一个对象,即该对象本身(与+-=这些不同)。因此为了区分两种形式,c++有约定,当++运算符函数重载时的参数为int时,会将运算符函数解析为后置形式,此时这个int是个哑元,可以不包含形参(带了也无所谓),仅起到解析重载函数的作用。
<<、>>与=、+=这些类似,重载时至少需要一个参数,且都是默认的后置形式。不过,<<和>>常被用于数据流的输入和输出,如果只有后置形式,使用时难免会发生误解和歧义,例如重载后”obj << cout” 表示向标准输出流添加对象数据,这与平时的用法不同。如果想要运用常见的表示形式,那么重载函数需要使用到前面和后面两个参数来区分符号前后两个位置的数据。如果是在全局环境(c++中是对象外)下,这十分容易,但在自定义类中,每个成员函数都存在一个已填入的隐藏的参数本对象指针this,这不像python是显式给出的。因此我们需要使用friend(友元)函数来去除重载函数中的this指针,并保留对该类私有属性的访问权。
xxxxxxxxxx
511// c++ 20
2
3using std::cout;
4using std::endl;
5class Number{
6private:
7 int _data;
8public:
9 Number(int data)
10 :_data(data)
11 {}
12 Number & operator++(){
13 _data += 2; // 区分下一般的前置++符号
14 return *this;
15 }
16 // 后置++
17 Number operator++(int){
18 Number pre = *this; // 这里默认拷贝就行
19 _data += 2; // 区分下一般的后置++符号
20 return pre;
21 }
22 // 后置<<
23 std::ostream & operator<<(std::ostream & os){
24 os << _data ;
25 return os;
26 }
27 // 前置<< 友元函数,此时相当于全局函数
28 friend std::ostream & operator<<(std::ostream & os,Number & number);
29 friend std::ostream & operator<<(std::ostream & os,Number && number);
30};
31// 友元函数必须定义在全局
32std::ostream & operator<<(std::ostream & os,Number & number){
33 os << number._data;
34 return os;
35}
36std::ostream & operator<<(std::ostream & os,Number && number){
37 os << number._data;
38 return os;
39}
40int main(){
41 Number number(10);
42 cout << number++ << "\n"
43 << ++number
44 <<endl;
45 number << cout << endl; // 有前置重载,这样也是ok的
46 return 0;
47}
48// 结果
49// 10
50// 14
51// 14
三、隐藏的三次拷贝
在编译器早期版本,c++中在做参数传递时、返回值时(传递给临时变量/右值)、接收返回值时,会发生三次拷贝,现代编译器最多只有两次(参数传递时、接收返回值时)。对于现代编译器,返回值时一般不会自动调用拷贝构造,不过移动语义(c++11)和返回值优化(直接在函数被调用的位置构造临时/匿名对象存储返回值,而不是在函数内部构造,后续对返回值的接收也是发生在函数被调用的位置)出现后已经被简化了,但仍要注意左右值变化。
x
1class A{
2private:
3 int data; //为对象占一个4B空间
4public:
5 A(){ cout << "A()" << endl;}
6 A(const A & a){
7 cout << "A(const A & a)" << endl;
8 }
9 A(const A && a){
10 cout << "const A(A && a)" << endl;
11 }
12 A & operator=(const A & other){
13 cout << "A & operator=(const A & other)" << endl;
14 return *this;
15 }
16};
17A test(){
18 A && ia = A{}; // 被绑定的非new的临时对象会被存放在当前栈空间中
19 // 对返回对局部变量的左值绑定也是同样不会发生返回值优化的(这样不好)
20 int ib;
21 cout << &ia << " " << &ib << endl;
22 return ia; // 返回作为左值(即使是右值引用)的局部变量不会触发返回值优化
23} // 原因是:左值可以反复使用,编译器不能假定接收者不会
24int main(){
25 A a = test();
26 int b;
27 cout << &a << " " << &b << endl;
28 return 0;
29}
30// 结果,未触发返回值优化
31A()
320x6987dff8b4 0x6987dff8b0 // ia与ib在test函数栈空间中
33A(const A && a) // 触发构造函数,未进行返回值优化
340x6987dff8fc 0x6987dff8f8 // 也能看到c++中是向低地址堆栈的
35
36A test2(){
37 A ia{}; // 针对命名局部变量返回值优化(NRVO)
38 int ib;
39 cout << &ia << " " << &ib << endl;
40 return ia;
41}
42int main(){
43 A a = test2();
44 int b;
45 cout << &a << " " << &b << endl;
46 return 0;
47}
48//结果
49A() // 整个过程中对象构造只有这一次,虽然表达式在test2函数中,但实际发生在main里的a上
500x28f1fff84c 0x28f1fff80c // ia不在test2栈空间中,而直接被存储在main栈空间的a中
510x28f1fff84c 0x28f1fff848
52
53A test3(){
54 int ib;
55 cout << &ib<< endl;
56 return A{}; // 针对匿名变量/右值的返回值优化(RVO)
57}
58int main(){
59 A && a = test3(); // 绑定临时变量
60 int b;
61 cout << &a << " " << &b << endl;
62 return 0;
63}
64//结果
650x2336dffbec
66A()
670x2336dffc24 0x2336dffc20 // test3里的A{}构造的临时对象位于main栈空间中
68
69int main(){
70 A a = A{};
71 A a2{A{}}; // 相当于编译器编译时转为 A a{} 和 A a2{}
72 return 0;
73}
74//结果,只有两次构造,而不是四次
75A()
76A()
从上面例子可以看到,如果函数返回一个对作为左值的局部变量,是不会触发返回值优化的。原因在于更大一个方面:
先跳出对这个返回值优化的解释,左值可以被反复使用,如果接收者接收到一个左值,那么他就能够“窃取/转移“其资源(移动语义),造成后续其他对该左值的操作变为非法操作,移动是有风险的。为了防止这种情况,编译器变得很保守,规定:对左值,默认进行拷贝操作。相反的,对于临时性的右值,其资源不转移就会消失,因此默认使用移动语义(如果有的话),转移其资源。这样我们就能知道,为什么不对左值返回值优化(实际也是一种逻辑上的资源移动,不过被编译器简化)。
x
1std::string str1 = "abcde"; // 构造左值
2std::string str2 = str1; // str1是一个左值,因此默认使用拷贝
3 // 这也是为什么=号常会被解析为拷贝和赋值构造
4cout << str1 << endl; // 程序未结束,str1变量名在符号表中的地址映射没有被销毁,
5 //如果刚才是移动,那么这里已经是非法操作了
6std::string str3 = std::string("abcedfgh") // 移动构造,这种就很安全不怕资源被窃取
四、类初始化陷阱
在构造对象不需要参数时,使用括号对对象初始化容易被误解为函数声明。此时,应该使用
classname obj 或者 classname obj{}初始化函数,其中classname obj{}是更正式的做法。
xxxxxxxxxx
81class A{
2public:
3 A(){} // 构造函数没有参数
4};
5int main(){
6 A a(); // 会被误认为是函数声明,应换为A a 或者A a{}
7 return 0;
8}