文章目录
五、运算符重载
1.友元
(1)定义
友元是在本类中用friend关键字声明,定义在类外部的函数或类。
友元可以在类外访问本类的私有成员,用friend关键字声明。
(2)友元的三种形式
①普通函数
②成员函数
③友元类
友元的第二种形式,需要将本类前向声明,某类中只能声明不能实现,这种方式割裂了代码,较为繁琐,使用较少。
1、3用的比较多。
代码链接:https://github.com/WangEdward1027/Object-Oriented/tree/main/friend
①将普通函数声明为类的友元函数
将普通函数声明为友元
②将其他类的成员函数声明为类的友元函数
另一个类的成员函数,想要访问本类的私有成员。
友元的第二种方式:其他类的成员函数 声明为友元函数 (较为繁琐,不推荐)
1.需要将本类前向声明
2.然后其他类的成员函数声明
3.本类的具体实现,并将其他类的成员函数声明为友元函数 (记得加 类名 作用域限定符)
4.其他类的成员函数的具体实现 (记得加 类名 作用域限定符)
代码链接:https://github.com/WangEdward1027/Object-Oriented/blob/main/friend/friendMembFunc.cpp
③友元类
如果某个类中有很多的成员函数都想要访问本类的私有成员,若还按照上面(2)的方式一个一个设置友元函数会非常繁琐。可以直接设置为友元类。这也是工作中常见的方法。
class Point {
//...
friend class Line; //将Line设置为Point的友元类
//则Line中所有的成员函数都能访问Point的私有成员。
//...
};
(3)友元的特点
1.友元不受类中访问权限的限制——可访问私有成员
2.友元破坏了类的封装性
3.不能滥用友元 ,友元的使用受到限制
4.友元是单向的——A类是B类的友元类,则A类成员函数中可以访问B类私有成员;但并不代表B类是A类的友元类,如果A类中没有声明B类为友元类,此时B类的成员函数中并不能访问A类私有成员
5.友元不具备传递性——A是B的友元类,B是C的友元类,无法推断出A是C的友元类
6.友元不能被继承——因为友元破坏了类的封装性,为了降低影响,设计层面上友元不能被继承
2.运算符重载
(1)运算符重载的概念
(1)C++ 预定义中的运算符的操作对象只局限于基本的内置数据类型,但是对于自定义的类型是没有办法操作的。当然我们可以定义一些函数来实现这些操作,但考虑到用运算符表达含义的方式很简洁易懂,当定义了自定义类型时,也希望这些运算符能被自定义类类型使用,以此提高开发效率,增加代码的可复用性。为了实现这个需求,C++提供了运算符重载。
其指导思想是(运算符重载的原则):希望自定义类类型在操作时与内置类型保持一致。
(2)42个运算符可以重载,有5个运算符不能重载。
sizeof既是函数,也是运算符。
可以直接 sizeof num;
(2)运算符重载的规则
(1)运算符重载时,其操作数类型中必须要有自定义类型或枚举类型,全都是内置类型无法进行运算符重载。
(2)其优先级和结合性还是固定不变的 a == b + c
(3)操作符的操作数个数是保持不变的
(4)运算符重载时 ,不能设置默认参数 ——如果设置了默认值,其实也就是改变了操作数的个数
(5)逻辑与 && 逻辑或 || 就不再具备短路求值特性 ,进入函数体之前必须完成所有函数参数的计算, 不推荐重载
(6)不能臆造一个并不存在的运算符:@、$
(3)运算符重载的三种形式 (重要)
(1)采用友元函数的重载形式
(2)采用普通函数的重载形式
(3)采用成员函数的重载形式
以加法运算符为例,认识这三种形式。
(4)运算符重载形式的选择 (重要)
返回本对象,返回值类型是 类名 &
①不会修改操作数的值的运算符,倾向于采用友元函数的方式重载,如 +、-
②会修改操作数的值的运算符,倾向于采用成员函数的方式重载,如+=、-=、=
③赋值=、下标[ ]、调用()、成员访问->、成员指针访问*->
运算符必须是成员函数形式重载
④与给定类型密切相关的运算符,如递增、递减和解引用运算符,通常应该是成员函数形式重载
⑤具有对称性的运算符可能转换任意一端的运算对象,例如相等性、位运算符等,通常应该是友元形式重载。例如输入、输出运算符,用友元函数形式重载。
⑥带.
的运算符不能重载。5个不能重载的运算符,见上文。
(5)运算符重载的步骤
(0)确定运算符重载的形式 (友元函数还是成员函数?)
(1)确定函数的返回值类型
(2)再写上函数名:operator运算符
(3)再补充参数列表:
①友元的普通函数——运算需要多少操作数就准备多少个参数。Point operator+(const Point & lhs,const Point & rhs)
②成员函数——考虑第一个操作数是this指针所指向的对象。Point operator+(const Point & rhs)
(4)最后完成函数体的内容(结合实际)。
(6)+运算符重载
①友元函数实现
普通函数 运算符重载,但类内声明为友元函数,可以直接访问私有成员。
②普通函数实现
公有的get接口,调用私有成员。
实际工作中不推荐使用,因为这种公有接口严重破坏了类的封装性(对私有成员的隐藏性),几乎完全失去了对私有成员的保护。一般很少使用,不推荐。
③成员函数实现
运算符重载的第三种方式 —— 成员函数形式
定义一个成员函数性质的运算符重载函数
要注意,成员函数的第一个操作数,实际上默认由this指针提供
(7)+=运算符重载
像+=这一类会修改操作数的值的运算符,倾向于采用成员函数的方式重载。
(8)++运算符重载
类比Complex,写出++运算符重载函数。按照我们目前的认知,前置++和后置++都应该选择成员函数的形式进行重载。
但是前置形式和后置形式都是只有一个操作数(本对象),参数完全相同的情况下,只有返回类型不同不能构成重载。前置形式和后置形式的区分只能通过设计层面人为地加上区分。
①前置++
Complex cx1(1,2);
++cx1;
cx1.operator++(); //本质
//前置++的形式
Complex & operator++(){
cout << "Complex & operator++()" << endl;
++_real;
++_image;
return *this;
}
②后置++
cx1++;
cx1.operator++(10); //本质。参数随便传一个int型数据,用不上
cx1.print();
//后置++的形式:参数列表中要多加一个int,与前置形式进行区分
Complex operator++(int){
cout << "Complex operator++(int)" << endl;
Complex temp(*this); //自增前保存副本,拷贝构造
++_real;
++_image;
return temp; //返回临时对象,又拷贝构造
}
//所以自定义类型最好前置++。因为后置++会有两次拷贝
(9)[ ]运算符重载
下标访问运算符重载,必须是成员函数形式重载。
需求:定义一个CharArray类,模拟char数组,需要通过下标访问运算符能够对对应下标位置字符进行访问。
-
分析[ ]运算符重载函数的返回类型,因为通过下标取出字符后可能进行写操作,需要改变CharArray对象的内容,所以应该用char引用;
-
[ ]运算符的操作数有两个,一个是CharArray对象,一个是下标数据,ch[0]的本质是ch.operator[] (0);
函数体实现需要考虑下标访问越界情况,若未越界则返回对应下标位置的字符,若越界返回终止符。
具体实现:
char & operator[](size_t idx){
if(idx < _capacity - 1){ // idx < size()
return _data[idx];
}else{
cout << "out of range!" << endl;
//return '\0'; //返回值类型char &,不能返回右值
static char nullchar = '\0'; //左值,用static延长生命周期
return nullchar;
}
}
CharArray ca("hello");
cout << ca[0] << endl;
ca[0] = 'X';
cout << ca[0] << endl;
ca[0] 本质是 ca.operator[](0)
class CharArray{
public:
CharArray(const char * pstr)
: _capacity(strlen(pstr) + 1)
, _data(new char[_capacity]())
{
strcpy(_data,pstr);
}
~CharArray(){
if(_data){
delete [] _data;
_data = nullptr;
}
}
//"hello"来创建
//capacity = 6
//下标只能取到 4
char & operator[](size_t idx){
if(idx < _capacity - 1){
return _data[idx];
}else{
cout << "out of range" << endl;
static char nullchar = '\0';
return nullchar;
}
}
void print() const{
cout << _data << endl;
}
private:
size_t _capacity;
char * _data;
};
CharArray ca("hello");
ca[0];
①非const版本的operator[]
②const版本的operator[]
两个参数不一样,多一个const(双重const),使得this指针属性不同,也就造成了参数不同,可以重载。
(10)输出输出运算符重载 (重要)
①输出流运算符 <<
输出流运算符 << 如果用成员函数重载,就变成了 cm1 >> cout,因为成员函数第一个参数一定是this指针。所以为了让cout作第一个参数,自定义类型对象作第二个参数,需要用友元函数形式重载 输出流运算符<<。
分析:
①输出流运算符有两个操作数,左操作数是输出流对象,右操作数是Complex对象。如果将输出流运算符函数写成Complex的成员函数,会带来一个问题,成员函数的第一个参数必然是this指针,也就是说Complex对象必须要作为左操作数。这种方式完成重载函数后,只能cx << cout这样来使用,与内置类型的使用方法不同,所以 输出流运算符的重载采用友元形式
②cout << cx这个语句的返回值是cout对象,因为cout是全局对象,不允许复制,所以返回类型为ostream &;
③参数列表中第一个是左操作数(cout对象),写出类型并给出形参名;第二个是右操作数(Complex对象),因为不会在输出流函数中修改它的值,采用const引用;
④将Complex的信息通过连续输出语句全部输出给os,最终返回os(注意,使用cout输出流时通常会带上endl,那么在函数定义中就不加endl,以免多余换行)
class Point {
public:
//...
friend ostream & operator<<(ostream & os, const Point & rhs);
private:
int _x;
int _y;
};
ostream & operator<<(ostream & os, const Point & rhs)
{
os << "(" << rhs._x << "," << rhs._y << ")";
return os;
}
void test0(){
Point pt(1,2);
cout << pt << endl; //本质形式: operator<<(cout,pt) << endl;
}
String类:重载<<
②输入流运算符 >>
为了避免要反过来写,不能采用 成员函数形式重载,还是 采用友元函数形式重载输入流运算符 >>
记得声明为友元friend
1.第一步
class Complex {
public:
//...
friend istream & operator>>(istream & is, Complex & rhs);
private:
int _real;
int _image;
};
istream & operator>>(istream & is, Complex & rhs)
{
is >> rhs._real;
is >> rhs._image;
return is;
}
2.——如果不想分开输出实部和虚部,也可以直接连续输入,空格符、换行符都能作为分隔符
istream & operator>>(istream & is, Point & rhs)
{
is >> rhs._x >> rhs._y;
return is;
}
3.但是还有个问题需要考虑,使用输入流时需要判断是否是合法输入
——可以封装一个函数判断接收到的是合法的int数据,在>>运算符重载函数中调用,请结合前面输入流的知识试着实现
(11)成员访问运算符
成员访问运算符包括.
和->
,其中.
这个运算符是不能重载的,->
运算符是可以重载的。
->运算符必须以成员函数形式重载。
①两层结构下的使用
思想:用局部对象的生命周期,来回收堆空间上的资源。
->运算符,可认为就是只有一个参数,即this指针。后面的函数名不当作参数、
现在也希望 ->的等价形式 (*对象).
也能用。就要重载 * 解引用运算符。
智能指针的雏形:栈上的对象,利用局部对象的生命周期,管理堆上的资源。
②三层结构下的使用 (难点)
若是创建ThirdLayer对象是用ThirdLayer tl(&ml);
则tl生命周期结束时,会调用析构函数,释放栈上的ml,会发生段错误。
应该用new的方式
Third调用->
③内存分析
创建ThirdLayer对象:先调用ThirdLayer构造函数 ->初始化参数时调用MiddleLayer的构造函数 -> 初始化时调用Data的构造函数。所以Data的构造函数先执行完毕,再是MiddeleLayer构造函数执行完毕,最后ThirdLayer构造函数执行完毕。
销毁ThirdLayer对象:先调用ThirdLayer析构函数,delete时调用MidderLayer的析构函数,delete时调用Data的析构函数。
(12)作业:string类的模拟实现:String类
代码链接:
3.可调用实体
可调用实体:普通函数、函数指针、成员函数
以下还要新学几个可调用实体:函数对象、成员函数指针
(1)函数对象: 函数调用运算符()的重载
1.定义:
重载了函数调用运算符() 的类的对象称为函数对象
2.作用:
让对象像函数一样被调用,即重载函数调用运算符()
3.实现:
如果想让一个对象想一个函数一样被调用,则在该类中对operator()进行运算符重载,()必须以成员函数的形式。
4.好处:
记录函数调用的次数,count变量可以放在对象内部,作为私有数据成员_cnt,重构的函数作为成员函数,进行++_cnt。记录这一系列变量被调用的次数。
若需要获得_cnt的值,定义一个getCount公有接口。
①若用全局变量记录函数变量调用的次数,但全局变量不够安全,可以被篡改次数。
②若用局部静态变量,只能记录该函数自己被调用的次数
函数对象相比普通函数的优点:
可以携带状态(函数对象可以封装自己的数据成员、成员函数,具有更好的面向对象的特性)
如上,可以记录函数对象被调用的次数,而普通函数只能通过全局变量做到(全局变量不够安全)。
除此之外,函数对象作为STL的六大组件之一而存在,可以做很多定制化的行为。后面的章节中会学到。
class FunctionObject{
void operator()(){
cout << "void operator()()" << endl;
}
};
void test0(){
FunctionObject fo;
fo(); //ok
}
5.举例:
返回值类型、参数,取决于要调用的函数
class FunctionObject{
void operator()(){
cout << "void operator()()" << endl;
}
};
void test0(){
FunctionObject fo;
fo(); //ok
}
(2)函数指针
1.意义:
①函数指针可以实现回调函数。
回调函数就是一个被作为参数传递的函数。【后续在在运行时多态时可能经常用到】
②也可以调用函数
2.形式:
(1)省略形式:
返回值类型 (*函数指针名) (形参类型) = 指向的函数名;
函数指针名(实参);
void (*p1)(int) = print; //省略形式定义
p1(4); //省略形式调用
(2)完整形式:
返回值类型 (*函数指针名) (形式参数类型) = &指向的函数名;
(*函数指针名)(实参);
void (*p2)(int) = &print; //完整形式定义
(*p2)(7); //完整形式调用
利用 typedef 将 void(*)(int) 这种逻辑类型,赋予了新的类型名称Function
这种类型的变量就是函数指针。
都是特定类型的函数指针,只能指向一种函数(这种函数的类型在定义函数指针类时就决定了)
(3)成员函数指针
1.普通函数指针,不能指向一个类的非静态成员函数。
若想要指向类的非静态成员函数,需要定义成员函数指针。
2.成员函数指针的要求:
①函数指针名前,要加 类名、作用域限定符
②定义和调用,必须使用完整形式。调用时需要通过对象进行调用,.*
是成员指针运算符:
包括.*
和->*
。
定义成员函数指针时,就已经确定了能够指向的成员函数的返回值类型、参数信息、类的信息。
3.意义:
4.举例:
typedef 成员函数指针
->*
(4)空指针的使用 (了解)
对象的空指针,若没有访问类的数据成员(涉及对象),则不会报错。但自己不要这样写。
4.类型转换函数
1.类型转换函数的作用:由自定义类型向其他类型转换
2.类型转换函数的形式:operator 目标类型(){ 函数体 }
3.特征:
①必须是成员函数
②没有返回值类型,没有参数
③在函数执行体中必须要返回目标类型的变量
4.类型转换:内置(类型)转内置、内置转自定义(隐式转换、重载=运算符)、自定义转内置、自定义转自定义
(1)自定义类型向内置类型转换
在Point类中定义这样的类型转换函数
class Point{
public:
//...
operator int(){
cout << "operator int()" << endl;
return _ix + _iy;
}
//...
};
使用时就可以写出这样的语句(与隐式转换的方向相反)
Point pt(1,2);
int a = 10;
//将Point类型对象转换成int型数据
a = pt;
cout << a << endl;
本质
(2)自定义类型向自定义类型转换
自定义类型可以向内置类型转换,还可以向自定义类型转换,但要注意将类型转换函数设为谁的成员函数
Point pt(1,2);
Complex cx(3,4);
pt = cx;
cx.print();
如上,想要让Complex对象转换成Point对象,并对pt赋值,应该在Complex类中添加目标类型的类型转换函数
class Complex
{
//...
operator Point(){
cout << "operator Complex()" << endl;
return Point(_real,_image);
}
};
举例:
要实现两个自定义类型之间相互转换 pt = cx;
,有3种方法:
1.方法一:类型转换函数
在Complex类中定义类型转换函数 operator Point()
2.方法二:也可以在Point类中重载赋值运算符函数 (=必须以成员函数形式重载)
需要在类外实现
3.方法三:隐式转换:
①用Complex对象,构造一个Point对象,调用特殊的构造函数
②再调用赋值运算符函数,临时Point对象赋值给pt
4.三者可以同时存在,优先级不同,不会冲突。
优先级(效率):赋值运算符函数、类型转换函数、隐式转换
使用起来最方便的是,类型转换函数。兼顾了书写便捷性和效率。
5.C++运算符优先级排序与结合性
6.嵌套类 (内部类)
(1)嵌套类的定义
Point类是Line类的内部类,并不代表Point类的数据成员会占据Line类对象的内存空间,在存储关系上并不是嵌套的结构。除非Line类中有Point类的成员子对象。否则不占据内存。
(2)嵌套类结构的访问权限
1.外部类对内部类的成员进行访问:需要内部类将外部类声明为友元类。
2.内部类对外部类的成员进行访问:可以直接访问。内部类默认为外部类的友元类。
(3)设计模式:pimpl模式 (了解)
1.概念
Pimpl (Pointer to Implementation) 模式是一种设计模式,它通过将可见类的实现细节隐藏在一个单独的实现类(这里我们用内部类)中,在可见类中仅暴露公有的接口,和指向该实现类的指针。
pimpl虽不是23种常见设计模式,但很常用,可以认为是第24种。
2.实现
(1)头文件只给出接口:
//Line.hpp
class Line{
public:
Line(int x1, int y1, int x2, int y2);
~Line();
void printLine() const;//打印Line对象的信息
private:
class LineImpl;//类的前向声明
LineImpl * _pimpl; //只有指针,去调用内部类
};
(2)在实现文件中进行具体实现,使用嵌套类的结构(LineImpl是Line的内部类,Point是LineImpl的内部类),Line类对外公布的接口都是使用LineImpl进行具体实现的
在测试文件中创建Line对象(最外层),使用Line对外提供的接口,但是不知道具体的实现
具体的实现都隐藏到LineImpl类中了。
(3)打包库文件,将库文件和头文件交给第三方
sudo apt install build-essential
g++ -c LineImpl.cc
ar rcs libLine.a LineImpl.o
生成libLine.a库文件
编译:g++ Line.cc(测试文件) -L(加上库文件地址) -lLine(就是库文件名中的lib缩写为l,不带后缀)
此时的编译指令为 g++ Line.cc -L. -lLine
不把实现文件.cpp交给客户,只把打包好的库文件.a和头文件.hh交给客户。
3.好处:
①实现信息隐藏;
②只要头文件中的接口不变,实现文件可以随意修改,修改完毕只需要将新生成的库文件交给第三方即可;
③可以实现库的平滑升级。
7.单例对象自动释放 (重点*)
(1)方式一:利用另一个对象的生命周期管理资源
AutoRelease.cc
问题:有两种情况会造成double free
(1)如果还手动调用了Singleton类的destroy函数,会导致double free问题,所以直接删掉destroy函数,将回收堆上的单例对象的工作完全交给AutoRelease对象
(2)不能用多个AutoRelease对象托管同一个堆上的单例对象。
尽管第一种方式不够完善,但用栈对象的生命周期自动管理的资源,是智能指针的雏形。
class AutoRelease{
public:
AutoRelease(Singleton * p)
: _p(p)
{ cout << "AutoRelease(Singleton*)" << endl; }
~AutoRelease(){
cout << "~AutoRelease()" << endl;
if(_p){
delete _p;
_p = nullptr;
}
}
private:
Singleton * _p;
};
void test0(){
AutoRelease ar(Singleton::getInstance());
Singleton::getInstance()->print();
}
(2)方式二:嵌套类 + 静态对象 (重点)
将AutoRelease类设为Singleton类的内部类,将AutoRelease类对象_ar作为Singleton类的静态对象成员。
AutoRelease类对象_ar是Singleton类的对象成员,创建Singleton对象,就会自动创建一个AutoRelease对象(静态区),它的成员函数可以直接访问 _pInstance
class Singleton
{
class AutoRelease{
public:
AutoRelease()
{}
~AutoRelease(){
if(_pInstance){
delete _pInstance;
_pInstance = nullptr;
}
}
};
//...
private:
//...
int _ix;
int _iy;
static Singleton * _pInstance;
static AutoRelease _ar;
};
Singleton* Singleton::_pInstance = nullptr;
//使用AutoReleas类的无参构造对_ar进行初始化
Singleton::AutoRelease Singleton::_ar;
void test1(){
Singleton::getInstance()->print();
Singleton::getInstance()->init(10,80);
Singleton::getInstance()->print();
}
即使手动不小心destroy(),也不会造成double free
已经把指针置空了,下次destroy()就不会进入函数体。
点评:比较完善,但是第三种方式写法会更简洁,同样的思想。
(3)方式三:atexit + destroy
很多时候我们需要在程序退出的时候做一些诸如释放资源的操作,但程序退出的方式有很多种,比如main()函数运行结束、在程序的某个地方用exit()结束程序、用户通过Ctrl+C操作来终止程序等等,因此需要有一种与程序退出方式无关的方法来进行程序退出时的必要处理。
方法就是用atexit函数来注册程序正常终止时要被调用的destroy函数(C/C++通用)。
atexit函数的特点:
①在程序退出时,调用被注册的函数
②同一个函数注册多次,注册几次就调用几次。
③如果注册了多个函数,后注册的先调用,先注册的后调用。
class Singleton
{
public:
static Singleton * getInstance(){
if(_pInstance == nullptr){
atexit(destroy); //注册destroy函数
_pInstance = new Singleton(1,2);
}
return _pInstance;
}
//...
};
private:
static void destroy(){
if(_pInstance){
delete _pInstance;
_pInstance = nullptr;
cout << ">> delete heap" << endl;
}
}
atexit注册了destroy函数,相当于有了一次必然会进行的destroy(程序结束时),即使手动调用了destroy,因为安全回收的机制,也不会有问题。
懒汉式、饿汉式
但是还遗留了一个问题,就是以上几种方式都无法解决多线程安全问题。以方式三为例,当多个线程同时进入if语句时,会造成单例对象被创建出多个,但是最终只有一个地址值会由_pInstance指针保存,因此造成内存泄漏。
可以使用饿汉式解决,但同时也可能带来内存压力(即使不用单例对象,也会被创建)
前三种,都无法保证多线程安全。
饱汉式,是在调用时才加载,但无法保证线程安全。
饿汉式,可以保证线程安全。是一开始就初始化,但会造成内存压力。
//对于_pInstance的初始化有两种方式:
//1.饱汉式(懒汉式)—— 懒加载,要用到对象时才进行创建,不使用到该对象,就不会创建。
Singleton* Singleton::_pInstance = nullptr;
//2.饿汉式 —— 一开始就创建单例对象(即使程序不使用这个单例对象)
Singleton* Singleton::_pInstance = getInstance();
饿汉式可以确保getInstance函数的第一次调用一定是在_pInstance的初始化时,之后再调用getInstance函数的时候,都不会进入if分支创建出对象。
同时,还有一个要考虑的问题——如果多线程环境下手动调用了destroy函数,那么又会让_pInstance变为空指针,之后再调用getInstance函数又创建了单例对象,还是有可能造成内存泄露,故而应该将destroy函数私有。
(4)方式四:atexit + pthread_once
pthread_once的原理,用一个变量来记录该变量是否被初始化过。
具体可以 man pthread_once来查看。
Linux平台可以使用的方法(能够保证创建单例对象时的多线程安全)
pthread_once函数可以确保初始化代码只会执行一次。
传给pthread_once函数的第一个参数比较特殊,形式固定;第二个参数需要是一个静态函数指针,pthread_once可以确保这个函数只会执行一次。
class Singleton
{
public:
static Singleton * getInstance(){
pthread_once(&_once,init_r);
return _pInstance;
}
void init(int x,int y)
{
_ix = x;
_iy = y;
}
void print(){
cout << "(" << this->_ix
<< "," << this->_iy
<< ")" << endl;
}
private:
static void init_r(){
_pInstance = new Singleton(1,2);
atexit(destroy);
}
static void destroy(){
if(_pInstance){
delete _pInstance;
_pInstance = nullptr;
}
}
Singleton() = default;//C++11
Singleton(int x,int y)
: _ix(x)
, _iy(y)
{
cout << "Singleton(int,int)" << endl;
}
~Singleton(){
cout << "~Singleton()" << endl;
}
Singleton(const Singleton & rhs) = delete;
Singleton & operator=(const Singleton & rhs) = delete;
private:
int _ix;
int _iy;
static Singleton * _pInstance;
static pthread_once_t _once; //静态数据成员
};
Singleton * Singleton::_pInstance = nullptr;
pthread_once_t Singleton::_once = PTHREAD_ONCE_INIT; //初始化
注意:
(1)如果手动调用init_r创建对象,没有通过getInstance创建对象,实际上绕开了pthread_once的控制,必然造成内存泄露 —— 需要将init_r私有
(2)如果手动调用了destroy函数,之后再使用getInstance来尝试创建对象,因为pthread_once的控制效果,不会再执行init_r函数,所以无法再创建出单例对象。所以不能允许手动调用destroy函数。
同时因为会使用atexit注册destroy函数实现资源回收,所以也不能将destroy删掉,应该将destroy私有,避免在类外手动调用。
8.std::string的底层实现 (*)
(0)string的历史版本
①深拷贝 (Eager Copy)
②写时复制 (COW,Copy-On-Write)
③短字符串优化 (SSO,Short String Optimization)
std::string的底层实现是一个高频考点,虽然目前std::string是根据SSO的思想实现的,但是我们最好能够掌握其发展过程中的不同设计思想,在回答时会是一个非常精彩的加分项。
首先,最简单的就是深拷贝。无论什么情况,都是采用拷贝字符串内容的方式解决,这也是我们之前已经实现过的方式。这种实现方式,在不需要改变字符串内容时,对字符串进行频繁复制,效率比较低下。所以需要对其实现进行优化,之后便出现了下面的COW的实现方式。
//如果string的实现直接用深拷贝
string str1("hello,world");
string str2 = str1;
如上,str2保存的字符串内容与str1完全相同,但是根据深拷贝的思想,一定要重新申请空间、复制内容,这样效率较低、开销较大。
(1)写时复制原理探究
写时复制的意思就是:只有进行写操作时才深拷贝。读操作就是浅拷贝 + 引用计数。
完全复制,考虑可以公用一片空间,避免了深拷贝,效率高。只有修改内容的时候才进行深拷贝。可以采用引用计数,避免double free。
只有在复制的时候,可以通过共享空间来优化效率。(但如果是两个string开辟的都是相同的字符串"hello",则没有优化空间。)
存放引用计数的设计方案:堆空间,在字符串最前面开辟4个字节,存放int型引用计数。
(2)CowString代码初步实现
1.无参构造
void initRefCount(){
*(int*)(_pstr - kRefCountLength) = 1;
}
CowString::CowString()
: _pstr(malloc())
{
initRefCount();
}
2.拷贝构造函数
CowString::CowString(const char * pstr)
: _pstr(malloc(pstr))
{
strcpy(_pstr,pstr);
initRefCount();
}
3.析构函数
void release(){
decreaseRefCount();
if(use_count() == 0){
delete [] (_pstr - kRefCountLength);
_pstr = nullptr;
cout << ">>>>delete heap" << endl;
}
}
CowString::~CowString(){
release();
}
4.赋值运算
CowString & CowString::operator=(const CowString & rhs){
if(this != &rhs){// 1.判断自赋值情况
release(); //2.尝试回收堆空间
_pstr = rhs._pstr; //3.浅拷贝
increaseRefCount(); //4.新的空间引用计数+1
}
return *this;
}
①代理模式
在我们建立了基本的写时复制字符串类的框架后,发现了一个遗留的问题。
如果str1和str3共享一片空间存放字符串内容。如果进行读操作,那么直接进行就可以了,不用进行复制,也不用改变引用计数;如果进行写操作,那么应该让str1重新申请一片空间去进行修改,不应该改变str3的内容。
cout << str1[0] << endl; //读操作
str1[0] = ‘H’; //写操作
cout << str3[0] << endl;//发现str3的内容也被改变了
我们首先会想到运算符重载的方式去解决。但是str1[0]返回值是一个char类型变量。
读操作:cout << char字符 << endl;
写操作:char字符 = char字符;
无论是输出流运算符还是赋值运算符,操作数中没有自定义类型对象,无法重载。而CowString的下标访问运算符的操作数是CowString对象和size_t类型的下标,也没办法判断取出来的内容接下来要进行读操作还是写操作。
—— 思路:创建一个CowString类的内部类,让CowString的operator[]函数返回是这个新类型的对象,然后在这个新类型中对<<和=进行重载,让这两个运算符能够处理新类型对象,从而分开了处理逻辑。
因为CharProxy定义在CowString的私有区域,为了让输出流运算符能够处理CharProxy对象,需要对此operator<<函数进行两次友元声明(内外都需要)。
附加代码:CharProxy赋值给CharProxy、CharProxy赋值给char
代码链接:https://github.com/WangEdward1027/STL/tree/main/string
(3)短字符串优化 (SSO)
①短字符串优化 (SSO)
短字符串优化 (SSO,Small String Optimization)
当字符串的字符数小于等于15时, buffer直接存放整个字符串;当字符串的字符数大于15时, buffer 存放的就是一个指针,指向堆空间的区域。这样做的好处是,当字符串较小时,直接拷贝字符串,放在 string内部,不用获取堆空间,开销小。
短字符串直接在栈上浅拷贝。
代码链接:https://github.com/WangEdward1027/STL/blob/main/string/SSO.cpp
②union
1.union的概念
union表示联合体(共用体),允许在同一内存空间中存储不同类型的数据。
2.union的特点
(1)联合体的大小等于其最大成员的大小,因此 Buffer 的大小是16 字节。string类的大小为32字节。
(2)联合体的所有成员共享一块内存(所有成员存在同一内存空间上),但是每次只能使用一个成员。
(3)对union的某个成员进行写操作,可能会导致整个union的内存被重新初始化。(因为共享内存,写这块共享内存就会导致大家都发生改变。因此每次只有一种解读方式,使用一个成员。例如)
class string {
union Buffer{
char * _pointer = nullptr;
char _local[16];
};
size_t _size;
size_t _capacity;
Buffer _buffer;
};
举例:
//union测试:
//union中所有成员共享一块内存空间,修改一个成员,可能导致其他成员也发生改变
#include <iostream>
using std::cout;
using std::cin;
using std::endl;
union Buffer{
char * _pointer = nullptr;// 初始化 _pointer 为 nullptr
char _local[16];
};
int main()
{
Buffer buffer;
if(buffer._pointer == nullptr) {
printf("_pointer is nullptr\n");
}else{
printf("_pointer is not nullptr\n");
}
cout << endl;
buffer._local[0] = 'a'; //修改 _local 的内容,
if(buffer._pointer == nullptr){ //却导致pointer的内容也发生修改
printf("_pointer is nullptr\n"); //因为union是共享内存空间
}else{
printf("_pointer is not nullptr\n");
}
return 0;
}
(4)最佳策略
Facebook提出的最佳策略,将三者进行结合:
因为以上三种方式,都不能解决所有可能遇到的字符串的情况,各有所长,又各有缺陷。综合考虑所有情况之后,facebook开源的folly库中,实现了一个fbstring, 它根据字符串的不同长度使用不同的拷贝策略, 最终每个fbstring对象占据的空间大小都是24字节。
①很短的(0~22)字符串用SSO,23字节表示字符串(包括’\0’),1字节表示长度
②中等长度的(23~255)字符串用eager copy,8字节字符串指针,8字节size,8字节capacity.
③很长的(大于255)字符串用COW, 8字节指针(字符串和引用计数),8字节size,8字节capacity.