3.虚函数和纯虚函数
3.1动态编译
VTABLE实际是一个函数指针的数组,每个虚函数占用这个数组的一个slot。一个类只有一个VTABLE,不管它有多少个实例。派生类有自己的VTABLE,但派生类的VTABLE与基类的有相同的函数排列顺序,同名的虚函数被放在两个数组的相同位置。在创建类实例的时候,编译器还会在每个实例的内存布局中增加一个vptr字段,该字段指向本类的VTABLE。通过这些手段,编译器在看到一个虚函数调用的时候,就会将这个调用改写。
类的实例对象不包含虚函数表,只有虚指针;
一个类的虚函数在它自己的构造函数和析构函数中被调用的时候,它们就变成普通函数了,不“虚”了。也就是说不能在构造函数和析构函数中让自己“多态
设计一个基类的时候,如果发现一个函数需要在派生类里有不同的表现,那么它就应该是虚的。从设计的角度讲,出现在基类中的虚函数是接口,出现在派生类中的虚函数是接口的具体实现。通过这样的方法,就可以将对象的行为抽象化。
3.2 纯虚函数
在普通的虚函数后面加上" =0"这样就声明了一个pure virtual function. 纯虚函数的意思是:一个抽象类,不要实例化,纯虚函数用来规范派生类的行为,实际上就是所谓的“接口”。它告诉使用者,派生类都会有这个函数。
-
当在基类中抽象出一个方法,且该基类只做能被继承,而不能被实例化;
-
避免一个类被实例化,且在编译时就被发现,那使用pure virtual funcion
-
这个方法必须在派生类(derived class)中被实现;
3.3 基类虚析构函数
析构函数也可以是虚的,甚至是纯虚的.
class A {
public:
virtual ~A()=0; // 纯虚析构函数
};
当一个类打算被用作其它类的基类时,它的析构函数必须是虚的,否则派生类的析构函数用不上,会造成资源的泄漏。
原因是:如析构函数不被声明成虚函数,编译器实施静态绑定,在删除指向派生类的基类指针时,只会调用基类的析构函数而不调用派生类析构函数,这样会造成派生类对象析构不完全。
3.4禁止使用缺省参数
避免虚函数重载时,因参数声明不一致带来的困惑和问题,所有虚函数均不允许声明缺省参数值。
示例:虚函数display缺省参数值text是由编译时刻决定的,而非运行时刻,没有达到多态的目的:
virtual void Display(const std::string& text = "Sub!") {
std::cout << text << std::endl;
}
注:
构造函数不能是虚的
禁止在构造函数和析构函数中调用虚函数(只有基类构造完成后,才会完成派生类的构造,从而导致未实现多态的行为), 同样的道理也适用于析构函数。
4、深拷贝和浅拷贝
简单来说,如果一个类拥有资源,当这个类的对象发生复制过程时,如果资源重新分配就是深拷贝;反之没有重新分配资源,就是浅拷贝。
拷贝构造函数
生成一个实例化的对象会调用一次普通构造函数,而用一个对象去实例化一个新的对象所调用的则是拷贝构造函数.
调用情形:
1)用类的一个对象去初始化另一个对象的时候
2)当函数参数是类的对象时,就是值传递的时候;如果是引用传递则不会调用
3)当函数的返回值是类的对象或者引用的时候;
代码实例
#include <iostream>
#include <string>
using namespace std;
class A{
public:
A(int i){ data = i;} //自定义构造
A(A && a); //拷贝构造
int getdata(){return data;}
private:
int data;
};
A::A(A && a){ //拷贝构造函数
data = a.data;
cout <<"拷贝构造函数执行完毕"<<endl;
}
int getdata1(A a){ //参数是对象,值传递,调用拷贝构造
return a.getdata();
}
int getdata2(A &a){ //参数是引用传递,不调用拷贝构造
return a.getdata();
}
A getA1(){ //返回值是对象,会调用拷贝构造
A a(0);
return a;
}
A& getA2(){ //返回引用,调用拷贝构造,函数内是临时对象,离开就消失
A a(0);
return a;
}
int main(){
A a1(1);
A b1(a1); //用a1初始化b1,调用拷贝构造
A c1=a1; //用a1初始化c1,调用
int i=getdata1(a1); //函数形参是类的对象,调用拷贝构造
int j=getdata2(a1); //函数形参类型是引用,不调用
A d1=getA1(); //调用拷贝构造
A e1=getA2(); //调用
return 0;
}
拷贝构造函数和赋值运算符重载有以下不同:
1) 拷贝构造函数生成新的类对象,而赋值运算符不能
2) 由于拷贝构造函数是直接构造一个新的类对象,所以在初始化这个对象之前不用检验源对象是否和新建对象相同。而赋值运算符则需要这个操作,另外赋值运算中如果原来的对象中有内存分配要先把内存释放掉
注:当类中有指针类型的成员变量时,一定要重写拷贝构造函数和赋值运算符,不要使用默认的。
5 单参构造, explicit
1)单参数构造函数
一个参数的构造函数(或除了第一个参数外其余参数都有缺省值的多参构造函数),承担了两个角色。
- 用于构建单参数的类对象
- 隐含的类型转换操作符.
#include <iostream>
class Base
{
public :
Base(const char data) {
std::cout <<"constructor..." <<std::endl;
this->m_data = data;
}
protected :
char m_data;
};
int main(void)
{
Base base1('a'); // 用于构建单参数的类对象
Base base2 = 'b'; // 隐含的类型转换操作符
return 0;
}
这种方法看起来很方便,但有时这并不是我们想要,由此可能引入一些其他问题。触发隐式转换,生成一个临时的对象。往往这种隐式转换是让人迷惑的,并且容易隐藏Bug,得到了一个不期望的类型转换。
2)避免隐士转换
用来修饰类的构造函数,表明该构造函数是显式的,在某些情况下,要求类的使用者必须显示调用类的构造函数时就需要使用explicit,反之默认类型转换可能会造成无法预期的问题。声明为explicit的构造函数不能在隐式转换中使用,只能显示调用,去构造一个类对象。
class Test2
{
public:
explicit Test2(int n) {
num=n;
}//explicit(显式)构造函数
private:
int num;
};
Test2 t2=3;//编译错误,不能隐式调用其构造函数
Test2 t2(3);//显式调用成功
explicit只针对单个参数的类构造函数, 如果类构造函数参数大于或等于两个时, 是不会产生隐式转换的, 所以explicit关键字也就无效了。
拷贝构造函数X(const X&)不要声明为explicit,如被声明为explicit,则这个类对象不能用于传参和函数返回值,但仍可以直接调用。
6、析构函数,显示析构
C++的最基本惯用法,程序运行到对象作用域之外时,会隐式的调用析构函数,析构函数执行完成后,对象的资源就被释放。
析构函数定义方式为: ~类名(){...}
1) 析构事项
A、只有真实存在的对象离开其作用域时才会调用析构函数,对象的引用,指向对象的指针离开其作用域时,不会调用析构函数。建议当对象离开其作用域后,让对象的引用,指向对象的指针失效,或者干脆就不再使用它。
B、使用new运算符创建的对象的资源,只有使用delete运算符删除指向它的指针时,才会调用它的析构函数,释放它的资源。这点要特别注意,当在类中显式定义析构函数时,函数体中通常就包含delete语句。
C、类中的静态成员属于类,不属于类的对象,它们的资源不会被析构函数释放。
通常情况下,我们不需要显式定义析构函数,除非我们需要它完成一些工作。如果一个类需要手动定义一个析构函数,那么通常情况下,这个类也需要手动定义复制构造函数和赋值运算符重载函数。
复制构造函数用于对象的复制,赋值运算符重载函数的功能和复制构造函数几乎一样。通常,将复制构造函数和赋值运算符重载函数绑定,定义了一个,另一个也必须出现。
析构函数、复制构造函数和赋值运算符重载函数,这三个函数是C++类的复制控制(copy-control)成员。复制控制,就是控制类的对象的复制。其中复制构造函数和赋值运算符重载函数是用来复制对象,析构函数是用来删除对象。
通常,使用复制构造函数或者赋值运算符重载函数创建一个对象时,会获得资源,有时必须显式定义析构函数才能释放这样的对象的资源。
2) 显式析构
析构函数的调用与构造函数的调用有明显不同:析构函数可以被显式调用,而构造函数不能。显式调用析构函数和调用类的其它成员函数没什么不同。当析构函数被显式调用时,只执行它的函数体,而不删除对象的资源。也就是说,当析构函数被显式调用时,它就是一个普通的成员函数,没有析构功能。
并没有destroy对象, 只有对象声明周期结束时即对象销毁了再次调用destructor会造成Undefined Behavior;
1.析构函数并不是销毁对象,只是释放构造函数在构造时初始化的资源(包括堆上分配等)
2.只有类对象被销毁后再次调用用析构函数才会引起Undefined Behavior。
显式定义析构函数多用于以下两种情况:
1、用于查看对象在销毁的前一刻保存的内容。有时候为了测试程序,会用到。
2、在类中用new运算符动态分配了内存,可以在析构函数中使用delete运算符释放内存。这种情况是最常用的。因为编译器生成的析构函数是不会销毁new出来的动态对象,这一点是因为new出来的对象保存在内存中的堆(heap)区,而编译器生成的析构函数只会释放内存中的栈(stack)区。
显式定义的析构函数的作用不像显式定义的构造函数那么有用,显示定义的析构函数完全可以用别的函数代替,但是,为了使用方便,为了其它编程人员的使用,在需要显示定义析构函数的情况下,还是定义它比较好,这样符合通用编程风格。
7. 友元函数和友元类
友元提供了不同类的成员函数之间、类的成员函数和一般函数之间进行数据共享的机制。通过友元,一个不同函数或者另一个类中的成员函数可以访问类中的私有成员和保护成员。
友元的正确使用能提高程序的运行效率,但同时也破坏了类的封装性和数据的隐藏性,导致程序可维护性变差。
1) 友元函数
可以访问类的私有成员的非成员函数。它是定义在类外的普通函数,不属于任何类,但是需要在类的定义中加以声明。
friend 类型 函数名(形式参数);
一个函数可以是多个类的友元函数,只需要在各个类中分别声明
2) 友元类
友元类的所有成员函数都是另一个类的友元函数,都可以访问另一个类中的隐藏信息(包括私有成员和保护成员)。
friend class 类名;
注:
- 友元关系不能被继承。
- 友元关系是单向的,不具有交换性。若类B是类A的友元,类A不一定是类B的友元,要看在类中是否有相应的声明。
- 友元关系不具有传递性。若类B是类A的友元,类C是B的友元,类C不一定是类A的友元,同样要看类中是否有相应的申明。
8. Return Value Optimization
返回值优化(RVO)是一种编译器优化机制, 当函数需要返回一个对象的时候,如果自己创建一个临时对象返回,那么这个临时对象会消耗一个构造函数的调用、一个复制构造函数的调用以及一个析构函数(Destructor)的调用代价。而如果稍微做一点优化,就可以将成本降低到一个构造函数的代价,这样就省去了一次拷贝构造函数的调用和一次析构函数的调用。
在使用GNU/g++编译器时可以使用"-fno-elide-constructors
"选项来强制g++总是调用copy构造函数,即使在用临时对象初始化另一个同类型对象的时候。
三种拷贝构造函数调用的时机
最明显的就是用一个类对象初始化另外一个对象的时候
函数的参数是类对象,这个是函数按值传参数的时候,包括指针在内都是对原有的值的拷贝.
函数返回一个类对象,这是一个对象以值传递的方式从函数返回.
这些都只是语义上的分析, 现在编译器的编译策略, 很多情况下会把这些临时对象的创建都隐藏掉.
1) 命名返回值优化(NRVO)
函数中所有的返回语句全部是这一个对象的话,那么,命名返回值优化的作用就是,在这个对象建立的时候,直接在返回区建立。这样就使得函数返回时不需要调用拷贝构造函数了,减少了一个对象的创建与销毁过程。
2) 未命名返回值优化
在返回语句中直接创建一个临时对象并返回,指在返回语句中直接构造临时对象,这样就可以直接将临时对象构造在返回区中,节省了两个对象的创建与销毁的过程。
3) 隐式构造函数优化
当用赋值语句对一个对象进行赋值时,最一般的情况是先执行赋值号右侧的表达式,再将表达式的结果(一般是编译时产生的临时变量)赋值给对象。
然而,当用赋值语句对一个对象进行初始化时,则该表达式的结果就是该对象。即,不需要产生临时变量,而是直接将表达式的结果建立在该对象的位置上。
说明: 在栈空间中,调用函数时,会在压入实参之前,留下一个函数返回值的类型的大小那么大的空间,作为函数的返回区。而新建立的变量a,其地址恰恰就在返回区的这个地方,这两者是完全重合的。所以,在函数返回后,无需将函数返回值作为拷贝构造函数的参数去初始化a,而是什么都不用做。因为a所在的区域,就是函数的返回区域。
4) 无法进行RVO的情况
优化实现依赖于编译器,也就是说编译器只能对指定的符合其规则的代码进行RVO优化,对于不符合要求的却无能为力.
① 函数使用exeception, 为了保证异常的正确捕获, 编译器不会进行RVO
② 不对命名的函数对象(Different Named Objects), 这个情况是说,函数有可能有多个分支,最终导致有多个不同位置的return语句。对于此项优化,要求所有return返回的对象的名字都是一致的。如写成下面的情况,就不行:
Base Test( ) {
if(...) {
return b1;
} else {
return b2;
}
}
因此 优化的关键,就是最好只在一个地方返回。即函数只有一个出口
③ 嵌入的汇编代码中引用了返回对象。
示例:
return String(s1 + s2); 和 String temp(s1 + s2); return temp;
这是临时对象的语法,表示“创建一个临时对象并返回它” 。将发生三件事,首先,temp 对象被创建,同时完成初始化;然后拷贝构造函数把temp 拷贝到保存返回值的外部存储单元中;最后,temp在函数结束时被销毁(析构);
然而“创建一个临时对象并返回它”的过程则不同,编译器直接把临时对象创建并初始化在外部存储单元中,省去了拷贝和析构的耗费,提高了效率。
9. override,overload,final
1) override
虚函数总是在派生类中被改写,这种改写被称为“override”。派生类覆盖基类的虚函数,实现接口的重用,重写的函数必须有一致的参数表和返回值(C++标准允许返回值不同的情况,但很少编译器支持这个feature)。如名字打错,则编译器不会编译通过。为减少运行时错误,重写虚函数加上override。
不同范围(基类和派生类)、函数名字相同、参数相同、基类中必须有virtual关键字
2) redefinition
派生类屏蔽了其同名的基类函数overwrite
不同范围(基类和派生类)、函数名字相同、参数不同或者参数相同且无virtual关键字
3) overload
指编写一个与已有函数同名但是参数不同的函数。如,一个函数即可以接受整型数作为参数,也可以接受浮点数作为参数。 将语义相近的几个函数用同一个名字表示,但是参数和返回值不同,这就是函数重载
相同范围(同一个类中)、函数名字相同、参数不同、virtual关键字可有可无
靠参数而不能靠返回值类型的不同来区分重载函数。
重载操作符要有充分理由,且不要改变操作符原语义,如不要使用 ‘+’ 操作符来做减运算。
虽然**重载(overload)和覆盖(override)**都是实现多态的基础,但是两者实现的技术完全不相同,达到的目的也是完全不同的,覆盖是动态绑定的多态(虚函数),而重载是静态绑定的多态
不能被重载的运算符
有一些运算符是不允许被重载的。限制是出于安全方面的考虑,可防止错误和混乱。
1)不能改变 C++内部数据类型(如 int,float 等)的运算符。
2)不能重载‘.’,因为‘.’在类中对任何成员都有意义,已经成为标准用法。
3)不能重载目前C++运算符集合中没有的符号,如#,@,$等。原因有两点,一是难以理解,二是难以确定优先级。4)对已经存在的运算符进行重载时,不能改变优先级规则,否则将引起混乱
4) final
当不希望某个类被继承,或不希望某个虚函数被重写,可以在类名和虚函数后添加final关键字,添加final后被继承或重写,编译器会报错。
class Base{
virtual void foo();
};
class A : Base{
void foo() final; // foo 被override且是最后一个override,在其子类中不可重写
void bar() final; // Error:父类没有bar虚函数可以被重写或final
};
class B final : A { // 指明B是不可以被继承的
void foo() override; // Error: 在A中已经被final了
};
class C : B // Error: B is final
{
};
nullptr和NULL的区别
在C程序中
#define NULL (void*)(0)
- *void * 可以隐式的转换为 int * 和char * ;
在C++中:
由于cpp是强类型语言,void * 是不能隐式转换成其他类型的指针的
在C++中,NULL实际上是0. 因为C++中不能把void*类型的指针隐式转换成其他类型的指针,所以为了解决空指针的表示问题,C++引入了0来表示空指针,这样就有了上述代码中的NULL宏定义。
#include <iostream>
using namespace std;
void func(void* t)
{
cout << "func( void* )" << endl;
}
void func(int i)
{
cout << "func( int ) " << endl;
}
int main()
{
func(NULL);
func(nullptr);
system("pause");
return 0;
}
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GpvDv1TB-1628759670201)(file:///D:/写文章-CSDN博客_files/19ef957a552442268fb6fb6eb3076fcd.png)]
NULL在C++中就是0,这是因为在C++中void* 类型是不允许隐式转换成其他类型的,所以之前C++中用0来代表空指针,但是在重载整形的情况下,会出现上述的问题。所以,C++11加入了nullptr,可以保证在任何情况下都代表空指针,而不会出现上述的情况,因此,建议以后还是都用nullptr替代NULL吧,而NULL就当做0使用。
10 接口定义事项
1) 用T* 或T& 作为参数
不涉及所有权的场景,使用T* 或T& 作为参数,而不是智能指针
说明:
(1)只在需要明确所有权机制时,才通过智能指针转移或共享所有权.
(2)通过智能指针传递,限制了函数调用者必须使用智能指针(如调用者希望传递this )。
(3)传递共享所有权的智能指针存在运行时的开销。
2) 接口层面明确指针不为nullptr
nullptr是为解决原来C++中NULL的二义性问题而引进的一种新的类型,因为NULL实际上代表的是0
说明:
(1)避免解引用空指针的错误。
(2)避免重复检查空指针,提高代码效率。
14 引用、指针
引用就是某个目标变量的“别名”(alias),对应用的操作与对变量直接操作效果相同。声明一个引用的时,要对其进行初始化。引用声明完毕后,相当于目标变量名有两个名称,即该目标原名称和引用名,不能再把该引用名作为其他变量名的别名。声明一个引用,不是新定义了一个变量,它只表示该引用名是目标变量名的一个别名,它本身不是一种数据类型,因此引用本身不占存储单元,系统也不给引用分配存储单元。不能建立数组的引用。
引用比指针更安全,因为一定非空且不再指向其他目标;不需检查非法的NULL指针。
1) 作为函数参数
1)传递引用给函数与传递指针的效果是一样的。这时,被调函数的形参就成为原来主调函数中的实参变量或对象的一个别名来使用,所以在被调函数中对形参变量的操作就是对其相应的目标对象(在主调函数中)的操作。
2)使用引用传递函数的参数,在内存中并没有产生实参的副本,它是直接对实参操作;而使用一般变量传递函数的参数,当发生函数调用时,需要给形参分配存储单元,形参变量是实参变量的副本;如果传递的是对象,还将调用拷贝构造函数。因此,当参数传递的数据较大时,用引用比用一般变量传递参数的效率和所占空间都好。
3)使用指针作为函数的参数虽然也能达到与使用引用的效果,但是,在被调函数中同样要给形参分配存储单元,且需要重复使用"*指针变量名"的形式进行运算,这很容易产生错误且程序的阅读性较差;另一方面,在主调函数的调用点处,必须用变量的地址作为实参。而引用更容易使用,更清晰。
2) “常引用”
如果既要利用引用提高程序的效率,又要保护传递给函数的数据不在函数中被改变,就应使用常引用。
常引用声明方式:const 类型标识符 &引用名=目标变量名;
示例:
string foo( );
void bar(string & s);
那么下面的表达式将是非法的:
bar(foo( ));
bar(“hello world”);
foo()和"hello world"串都会产生一个临时对象,在C++中,临时对象都是const类型。上面表达式试图将一个const类型的对象转换为非const类型,这是非法的。引用型参数应该在能被定义为const的情况下,尽量定义为const。
3) 作为函数返回值类型
格式:类型标识符 &函数名(形参列表及类型说明){ //函数体 }
在内存中不产生被返回值的副本;因为这点,返回一个局部变量的引用是不可取的。因随着该局部变量生存期的结束,相应引用也会失效,产生runtime error!
注意:
1)不能返回局部变量的引用。这条可以参照Effective C++[1]的Item31。原因是局部变量会在函数返回后被销毁,被返回的引用就成了"无所指"的引用,程序会进入未知状态。
2)不能返回函数内部new分配的内存的引用。这条可以参照Effective C++[1]Item31。虽不存在局部变量的被动销毁问题,可对于这种情况(返回函数内部new分配内存的引用),又面临其它尴尬局面。如,被函数返回的引用只是作为一个临时变量出现,而没有被赋予一个实际的变量,那么这个引用所指向的空间(由new分配)就无法释放,造成memoryleak。
3)可以返回类成员的引用,但最好是const。这条原则可以参照Effective C++[1]的Item30。原因是当对象的属性是与某种业务规则相关联时,其赋值常与某些其它属性或者对象的状态有关,因此有必要将赋值操作封装在一个业务规则当中。如果其它对象可以获得该属性的非常量引用(或指针),那么对该属性的单纯赋值就会破坏业务规则的完整性。
4)流操作符重载返回值申明为“引用”的作用:
流操作符<<和>>,这两个操作符常常希望被连续使用,如:cout << “hello” << endl;因此这两个操作符的返回值应该是一个仍然支持这两个操作符的流引用。可选的其它方案包括:返回一个流对象和返回一个流对象指针。但对于返回一个流对象,程序必须重新(拷贝)构造一个新的流对象,也就是说,连续的两个<<操作符实际上是针对不同对象的!这无法让人接受。对于返回一个流指针则不能连续使用<<操作符。因此,返回一个流对象引用是惟一选择。这个唯一选择很关键,它说明了引用的重要性以及无可替代性。
赋值操作符=。这个操作符象流操作符一样,是可以连续使用的,如:x=j=10;或者(x=10)=100;赋值操作符的返回值必须是一个左值,以便可以被继续赋值。引用成了这个操作符的惟一返回值选择。
这四个操作符的对象都是右值,因此,它必须构造一个对象作为返回值。
5)在另外的一些操作符中,千万不能返回引用:±*/ 四则运算符。它们不能返回引用,Effective C++[1]的Item23详细的讨论了这个问题。这四个操作符没有side effect,因此,它们必须构造一个对象作为返回值,可选的方案包括:返回一个对象、返回一个局部变量的引用,返回一个new分配的对象的引用、返回一个静态对象引用。根据前面提到的引用作为返回值的三个规则,第2、3两个方案都被否决了。静态对象的引用又因为((a+b) ==(c+d))会永远为true而导致错误。所以可选的只剩下返回一个对象了。
4) 与指针区别
1)指针是一个新的变量,存储了另一个变量的地址,可以通过访问这个地址来修改另一个变量;引用只是一个别名,还是变量本身,对引用的任何操作就是对变量本身进行操作,以达到修改变量的目的。
2)引用只有一级,而指针可以有多级
3)指针传参的时候,还是值传递,指针本身的值不可以修改,需要通过解引用才能对指向的对象进行操作。引用传参的时候,传进来的就是变量本身,因此变量可以被修改
15. 指针,nullptr
1) 常量const修饰
const char* p // p所指向的内容为const类型不可修改
char const p; // 同上char const p // p不能修改, p所指向的内容可以修改
const char* const p //一个常指针指向一个常量
示例1:
char* s=”AAA”;
printf(“%s”,s);
s[0]=’B’;
printf(“%s”,s);
“AAA”是字符串常量。s是指针,指向这个字符串常量,所以声明s的时候就有问题。
const char* s=”AAA”; 然后又因为是常量,所以对是s[0]的赋值操作是不合法的。
-
数组指针操作
int a[5]={1,2,3,4,5};
int *ptr=(int *)(&a+1);
printf(“%d,%d”,(a+1),(ptr-1));
说明:
a,&a地址一样,但意思不一样,a是数组首地址,也就是a[0]的地址,*(a+1)就是a[1]。&a是对象(数组)首地址,,&a+1是下一个对象的地址,即a[5],则ptr实际是&(a[5]),也就是a+5。
&a是数组指针,类型为 int ()[5]; 而指针加1要根据指针类型加上一定的值,不同类型的指针+1之后增加的大小不同,a是长度为5的int数组指针,所以要加 5sizeof(int)
但ptr与(&a+1)类型是不一样的,所以ptr-1只会减去sizeof(int*)
3) 复杂声明
void * ( * (*fp1)(int))[10];
fp1是一个指针,指向一个函数,这个函数的参数为int型,函数的返回值是一个指针,这个指针指向一个数组,这个数组有10个元素,每个元素是一个void*型指针。
float (( fp2)(int,int,int))(int);
fp2是一个指针,指向一个函数,这个函数的参数为3个int型,函数的返回值是一个指针,这个指针指向一个函数,这个函数的参数为int型,函数的返回值是float型。
int (* ( * fp3)())10;
fp3是一个指针,指向一个函数,这个函数的参数为空,函数的返回值是一个指针,这个指针指向一个数组,这个数组有10个元素,每个元素是一个指针,指向一个函数,这个函数的参数为空,函数的返回值是int型。
4) 使用nullptr, 非NULL或0
nullptr的优势不仅在字面上代表了空指针,使代码清晰,而且不再是一个整数类型。nullptr是std::nullptr_t类型,而std::nullptr_t可隐式的转换为所有的原始指针类型,使得 nullptr 可表现成指向任意类型的空指针。
void F(int);
void F(int*);
F(nullptr); // 调用 F(int*)
auto result = Find(id);
if (result == nullptr) { // Find() 返回的是指针
// do something
}
注:0字面上是int类型(0L是long),所以NULL和0都不是指针类型。当重载指针和整数类型的函数时,传递NULL或0都调用到整数类型重载的函数。
直接使用0或NULL,代码不清晰且无法做到类型安全;使用NULL无法做到类型安全。这些都是潜在的风险。
5) 野指针说明
不是NULL指针,是未初始化或者未清零的指针,它指向的内存地址不是程序员所期望的,可能指向了受限的内存。
成因:
1)指针变量没有被初始化
2)指针指向的内存被释放了,但是指针没有置NULL
3)指针超过了变量的作用范围,比如b[10],指针b+11
… …
16. unique_ptr,shared_ptr
1) unique_ptr
基础指针的一个所有者,可以移到新所有者,但不会复制或共享。替换已弃用的auto_ptr
使用std::make_unique 而非new创建unique_ptr,说明:
- make_unique 提供了更简洁的创建方式
- 保证了复杂表达式的异常安全
//两次出现 MyClass,重复导致不一致风险
std::unique_ptr ptr(new MyClass(0, 1));
//只出现一次 MyClass,不存在不一致的可能
auto ptr = std::make_unique(0, 1);
重复出现类型可能导致非常严重的问题,且很难发现:
// 编译正确,但new和delete不配套
std::unique_ptr ptr(new uint8_t[10]);
std::unique_ptr ptr(new uint8_t);
// 非异常安全: 编译器可能按如下顺序计算参数:
// 1. 分配 Foo 的内存,
// 2. 构造 Foo,
// 3. 调用 Bar,
// 4. 构造 unique_ptr.
// 如果 Bar 抛出异常, Foo 不会被销毁,产生内存泄露。
F(unique_ptr(new Foo()), Bar());
// 异常安全: 调用函数不会被打断.
F(make_unique(), Bar());
例外: std::make_unique 不支持自定义 delete 。 在需要自定义 deleter 的场景,建议在自己的命名空间实现定 制版本的 make_unique 。 使用 new 创建自定义 deleter 的 unique_ptr 是最后的选择
2) shared_ptr
采用引用计数的智能指针。 如想要将一个原始指针分配给多个所有者(如,从容器返回了指针副本又想保留原始指针时),请使用该指针。 直至所有shared_ptr 所有者超出了范围或放弃所有权,才会删除原始指针。
使用std::make_shared 而不是new 创建shared_ptr
使用 std::make_shared 除了类似 std::make_unique 一致性等原因外,还有性能的因素。 std::shared_ptr 管理两个实体:
l 控制块(存储引用计数, deleter等)
l 管理对象
std::make_shared 创建 std::shared_ptr ,会一次性在堆上分配足够容纳控制块和管理对象的内存。而使用 std::shared_ptr(new MyClass) 创建 std::shared_ptr ,除了 new MyClass 会触发一次堆分配外, std::shard_ptr 的构造函数还会触发第二次堆分配,产生额外的开销。
另外,类似 std::make_unique , std::make_shared 不支持定制 deleter
3) weak_ptr
结合 sharedptr 使用的特例智能指针。 weakptr 提供对一个或多个 sharedptr 实例拥有的对象的访问,但不参与引用计数。 如果你想要观察某个对象但不需要其保持活动状态,请使用该实例。
-
unique_ptr 优先
而不是shared_ptr, 说明:
- shared_ptr 引用计数的原子操作存在可测量的开销,大量使用 shared_ptr 影响性能。
- 共享所有权在某些情况(如循环依赖)可能导致对象永远得不到释放。
- 相比于谨慎设计所有权,共享所有权是一种诱人的替代方案,但它可能使系统变得混乱
例外 : 在性能敏感、兼容性等场景可以使用原始指针
5) 禁止使用auto_ptr
在stl库中的std::auto_ptr具有一个隐式的所有权转移行为。转移所有权的行为通常不是期望的结果。对于必须转移所有权的场景,应使用显示转移的方式。开发人员对使用auto_ptr需保持谨慎,否则出现对空指针的访问。
auto_ptr A1(new T);
auto_ptr A2 = A1;// p1变为NULL
使用auto_ptr常见的有两种场景
一是作为智能指针传递到产生auto_ptr的函数外部(使用std::shared_ptr代替)。
二是使用auto_ptr作为RAII管理类,在超出auto_ptr 的生命周期时自动释放资源,可使用std::unique_ptr来显式的所有权转移。
注:
C++11标准之前,在一定需要对所有权进行转移的场景下,可使用std::auto_ptr,但建议对std::auto_ptr进行封装,并禁用封装类的拷贝构造函数和赋值运算符,以使该封装类无法用于标准容器。
17. string, char*, string::c_str
通常使用std::string代替char*,可使用string::c_str()获得字符指针。
l 不用考虑结尾的’\0’
l 可以直接使用+, =, ==等运算符以及其它字符串操作函数
l 不需要考虑内存分配操作,避免显式new/delete,以及由此导致的错误
注:
l 调用系统或其它第三方库的API时,针对已经定义好的接口,只能使用 char* 。但在调用接口之前都可使用string,在调用接口时使用string::c_str()获得字符指针。当在栈上分配字符数组当作缓冲区使用时,可以直接定义字符数组,不要使用string,也没必要使用类似 vector 等容器。
l 为了保证程序的可移植性,不要保存string::c_str()的指针,而是在每次需要时直接调用。
l 少数对性能要求非常高的代码中,为适配已有的只接受const char*类型入参的函数,可以临时保存 string::c_str()返回的指针。但必须严格保证string对象的生命周期长于所保存指针的生命周期,并且保证在所保存指针的生命周期内,string对象不会被修改。
示例:
std::string name = "demo";
const char* text = name.c_str(); // 表达式结束以后,name的生命周期还在,指针有效
std::string name = "demo";
std::string test = "test";
const char* text = (name + test).c_str(); // 表达式结束后+号产生的临时对象被销毁,指针无效
18. 左值,右值
在C++11中所有的值必属于左值、右值两者之一。可以取地址的、有名字的就是左值,反之,不能取地址的、没有名字的就是右值(将亡值或纯右值),函数的形参是lvalue(可取地址),其也可以是rvalue引用类型。
举个例子: int a = b+c, a 就是左值,其有变量名为a,通过&a可以获取该变量的地址;
表达式b+c、函数int func()的返回值是右值,在其被赋值给某一变量前,不能通过变量名找到它,&(b+c)这样的操作则不会通过编译。
右值引用和左值引用都是属于引用类型。无论是声明一个左值引用还是右值引用,都必须立即进行初始化。引用类型本身自己并不拥有所绑定对象的内存,只是该对象的一个别名。左值引用是具名变量值的别名,而右值引用则是不具名(匿名)变量的别名。
左值引用通常不能绑定到右值,但常量左值引用是个“万能”的引用类型。它可以接受非常量左值、常量左值、右值对其进行初始化。不过常量左值所引用的右值在它的“余生”中只能是只读的。相对地,非常量左值只能接受非常量左值对其进行初始化。
int &a = 2; # 左值引用绑定到右值,编译失败
int b = 2; # 非常量左值
const int &c = b; # 常量左值引用绑定到非常量左值,编译通过
const int d = 2; # 常量左值
const int &e = c; # 常量左值引用绑定到常量左值,编译通过
const int &b =2; # 常量左值引用绑定到右值,编译通过
右值值引用通常不能绑定到任何的左值,要想绑定一个左值到右值引用,通常需要std::move()将左值强制转换为右值,例如:
int a;
int &&r1 = c; # 编译失败
int &&r2 = std::move(a); # 编译通过
右值引用的规则
- C++11 增加了特殊的右值引用类型,用 T&& 表示
- 函数的重载决策中会区分左值引用和右值引用。右值优先匹配右值引用,一个临时对象是一个右值,可以匹配右值引用;
- 可以使用 std::move(exp) 或 static_cast<T&&>(exp) 强制生成右值引用
l std::move 只做了到右值引用的强制类型转换,不实际进行移动
l 实际的移动操作由匹配的函数完成(如果存在的话),如移动构造或赋值
Obj&& wrong_move() {
Obj obj;
return std:move(obj); // 未定义的行为
}
Obj bad_move() {
Obj obj;
return std:move(obj); // 禁止 NRVO
}
19. std::move语义
并不移动任何东西,唯一功能是将一个左值强制转化为右值引用,继而可通过右值引用使用该值,以用于移动语义。本质是一个无条件static_cast,形参为左值类型,返回右值引用。
1) 函数原型定义
template <typename T>
typename remove_reference<T>::type&& move(T&& t) {
return static_cast<typename remove_reference<T>::type&&>(t);
}
将对象的状态或所有权从一个对象转移到另一个对象,只是转移,没有内存搬迁或内存拷贝,可提高利用效率,改善性能。编译期计算,无运行时开销。
原理:首先,通过右值引用传递模板实现,利用引用折叠原理将右值经过T&&传递类型保持不变还是右值,而左值经过T&&变为普通的左值引用,以保证模板可以传递任意实参,且保持类型不变(左值还是右值,需要进行类型推导)。然后通过static_cast<>进行强制类型转换返回T&&右值引用,static_cast之所以能使用类型转换,是通过remove_refrence::type模板移除T&&,T&的引用,获取具体类型T。
示例:
template<typename T>
void f(T&& param);
f(10); // 10是右值
int x = 10;
f(x); // x是左值
2) 操作说明
l 可move的操作均为形参可接受右值引用的函数
l unique_ptr,调用move才可以赋值,赋值后原指针被置为空
l
stl(std::array、std::vector等)、std::string等具有move构造和赋值的可使用move。使用比如vector::push_back等这类函数时,会对参数对象连数据也会复制.这就造成对象内存的额外创建,本意把参数push_back进去就行。而通过std::move,可避免不必要的拷贝操作l 对象没有move构造和赋值则退化为copy.
l move会阻碍 RVO返回值优化;
std::string s0 = "hello";
std::string str = s0 + " world.";
s0 + " world." 的结果是个临时对象, 这个表达式是个右值,那么 str 对象的构造函数在 C++11 中,会优先调用 Move Ctor .
3) 移动构造和移动赋值
如果需要某个类支持移动操作,需要实现移动构造和移动赋值操作符,两者都具有移动语义,应该同时出现或者禁止。
// 同时出现
class AA {
public: …
AA(AA&&);
AA& operator=(AA&&);
…
};
// 同时禁止
class AA {
public:
AA(AA&&) = delete;
AA& operator=(AA&&) = delete;
};
-
禁止操作const对象
const对象不能修改,自然无法移动。std::move 会把对象转换成右值引用类型,极少有类型会定义以const右值引用为参数的移动构造函数和赋值操作符,因此实际往往退化成对象拷贝而不是对象移动,带来了性能上的损失。
std::string g_string; std::vector<std::string> g_stringList;
void func() {
const std::string myString = "String content";
g_string = std::move(myString); //复制
const std::string anotherString = "Another string content";
g_stringList.push_back(std::move(anotherString)); // 复制
}
注:如果不需要拷贝/移动函数,请明确禁止。