目录
二、C++
1. C,java,C++区别
Q1. C++与Java的区别?
Q2. C++与C的区别?
Q3. C++调用C接口 ?
虽然C语言是C++的一部分,但是C++是无法直接调用C语言接口,这是因为C++支持函数重载,因此需要函数名称和函数参数来确定唯一的函数签名,而C语言不支持函数重载。所以C++与C语言对于同一个函数生成的函数签名不同,因此C++不能直接调用C接口。要调用C接口就需要使用
extern “C”
。
2. C++ 对象模型(C++ class底层原理)
相比于C语言的精炼和简洁,C++主要体现了其良好的扩展性。当一个C语言被封装成一个C++程序时,其内存空间布局并没有增加成本。其在布局和存取时间上的额外负担是由virtual
引起的,包括virtual function
和virtual base class
.
C++ 对象模型是C++面向对象的底层实现原理,是支持C++面向对象的基础。在C语言中,语言并没有支持“数据”与“函数”之间的关联性,而在C++中,通过ADT,在类中定义数据和函数,实现了数据与函数的绑定。
2.1 基本对象模型与C++对象模型
在C++对象模型之前,有两种基本的对象模型:简单对象模型,表格驱动对象模型
💗 2.1.1 基本对象模型
1. 简单对象模型
在简单对象模型中,一个object由一系列slots
组成,每个slot
是一个指针,指向一个成员。
2. 表格对象模型
在表格对象模型中,一个object仅包含两个slots
,一个指向object成员变量表
,另一个指向object成员函数表
。在成员函数表中,每个slot
包含一个成员函数的地址。因此,采用表格对象模型的class,其每个对象具有相同的大小,即两个slots
的大小。该模型的缺点是:因为其间接性而导致空间和存取时间上的额外负担。
💗 2.1.2 C++对象模型
C++对象模型结合了简单对象模型和表格对象模型的优点,并对内存存取和空间进行了优化。在此模型中,non-static
成员变量被放置到对象内部,static
成员变量、static
和non-static
函数成员都放到对象之外。但是,如果应用程序代码本身没有改变,只有class中的non-static
成员发生改变时,整个应用程序也需要进行重新编译。
2.2 C++ 构造函数模型
💗 2.2.1 默认构造函数
在这里有两个常见的错误认知:
● 错误① :若程序员没有自己定义无参数的构造函数,那么编译器会自动生成默认构造函数(此处是指non-trivial
),来进行对成员函数的初始化。
● 错误②:编译器合成出来的default constructor
会明确设定’“class内每一个data member
的默认值”
默认的构造函数分为有用(non-trivial
)的和无用(trivial
),所谓无用的默认构造函数就是一个空函数、什么操作也不做,而有用的默认构造函数是可以初始化成员的函数。
当用户没有显式的定义默认构造函数,或者基类和继承类都没有自定义的构造函数,则此时编译器生成的均是trival
默认构造函数。当用户有以下4种情况之一时,编译器生成的non-trivial
默认构造函数:
💗 2.2.2 默认拷贝构造函数
如果类中没有定义默认拷贝构造函数或类中只含有POD
成员数据,则编译器通常采用Bitwise copy
的方式进行浅拷贝,由于是POD数据,因此没有任何影响。
如果类中存在以下4种情况中的一种时,如果程序员未显式定义拷贝构造函数,编译器会自动完成拷贝构造函数的实现,编译器自动实现版本是按照bitwise
拷贝方式来完成的,因此如果是以下4种情况,程序员不显式定义自己的拷贝构造函数,就会出现错误(尤其是类成员中含有指针、引用、虚函数时):
2.3 C++继承的底层原理
由于在C++的继承关系中,基类的析构函数必须是虚函数(为了防止内存泄露)。因此,在C++对象模型中,继承关系是对已有的虚函数表的扩充。
💗 2.3.1 C++ 子类单继承
C++的继承类中继承关系就是对基类的虚函数表直接进行修改和扩充。
● 如果子类没有函数重写父类函数,则父类虚函数在虚函数表的前面,子类虚函数在虚函数表的后面。
● 如果子类有函数重写父类函数,则父类虚函数在虚函数表的前面,子类虚函数在虚函数表的后面,且子类重写父类的虚函数,替换父类对应的虚函数,并出现在虚函数表前面。
💗 2.3.2 C++ 子类多继承
C++子类多继承与单继承类似,但是多重继承会有多个虚函数表,继承n个类,其子类中会存在n个继承的父类虚函数表。如果子类改写了父类的虚函数,那么就会用子类自己的虚函数覆盖虚函数表的相应的位置,如果子类有新的虚函数,那么就添加到第一个虚函数表的末尾。
💗 2.3.3 C++ 子类虚继承
虚继承是为了解决在重复继承中多个间接父类的问题,因此虚继承的实现方式不能再通过每个虚基类提供一个虚函数指针的方式来实现。
虚继承的派生类内存结构,和普通继承完全不同。虚继承的子类,有单独的虚函数表,另外也单独保存一份父类的虚函数表,两部分之间用一个四个字节的0x00000000
来作为分界。派生类的内存中,首先是自己的虚函数表,然后是派生类的数据成员,然后是0x00000000
,之后就是基类的虚函数表,之后是基类的数据成员。
2.4 C++ RTTI 机制
RTTI
是C++继承和多态的基础,RTTI
(Run Time Type Identification)即通过运行时类型识别,使程序能够使用基类的指针或引用来检查着这些指针或引用所指的对象的实际派生类型。
C++中通过两个操作符来提供RTTI:
① typeid运算符,该运算符会返回一个类型为std::typeinfo
对象的const引用,type_info
是std中的一个类,用于记录与类型相关的信息。
② dynamic_cast运算符,该运算符用于检测基类的指针或引用能否安全地转换为派生类类型的指针或引用。
💗 2.4.1 typeid()关键字 与 type_info类
type_info
记录着与类型相关的信息,type_info
的构造函数和赋值操作符都为私有。用户并不能自己定义一个type_info
的对象,而只能通过typeid()运算符
返回一个对象的const
引用来使用type_info
的对象。
class type_info {
public:
virtual ~type_info();
int operator==(const type_info& rhs) const;
int operator!=(const type_info& rhs) const;
int before(const type_info& rhs) const;
const char* name() const;
const char* raw_name() const;
private:
void *_m_data;
char _m_d_name[1];
type_info(const type_info& rhs);
type_info& operator=(const type_info& rhs);
};
1. typeid 识别静态类型
静态类型是编译时期就已经确定,在程序运行过程中并不会改变。静态类型包括:
● 类型名
● 一个基本类型的变量
● 一个具体的对象
● 一个指向不含有virtual函数的类对象指针的解引用/引用
class A { ...... // 具有virtual函数 };
class B : public X { ...... // 具有virtual函数};
class C { ...... // 没有virtual函数};
int main(){
int n = 0;
B b;
C c;
C *pc = &c;
// int和XX都是类型名
cout << typeid(int).name() << endl;
cout << typeid(b).name() << endl;
// n为基本变量
cout << typeid(n).name() << endl;
// b所属的类虽然存在virtual,但是b为一个具体的对象,而不是指向类对象的指针或引用
cout << typeid(b).name() << endl;
// pc为一个指针,属于基本类型
cout << typeid(py).name() << endl;
// pc指向的Y的对象,但是类C不存在virtual函数
cout << typeid(*pc).name() << endl;
return 0;
}
2. typeid 识别动态类型
当typeid
中的操作数是如下情况之一时,typeid
运算符需要在程序运行时计算类型(多态),其操作数的类型在编译时期是不能被确定的。一个具备多态性质的类,内含直接声明或继承而来的virtual函数,在C++对象模型中可以知道,在虚函数表指针vptr
之前是指向type_info
对象的地址,每个类都对应着一个type_info
对象。
● 一个指向含有virtual
函数的类对象的指针的解引用
● 一个指向含有virtual
函数的类对象的引用
3. 类 Class
所谓的抽象就是在原有数据的基础上,实现对数据的隐藏,即封装。将数据放在类的私有部分是一种封装,将类成员函数的实现和声明放在不同的文件也是一种封装。通过封装隐藏了数据,留出接口方便用户的调用。
类是C++中将抽象转换为用户定义类型的工具,将数据表示和数据处理方法组合成一个整洁的包。
类规范由类声明和类方法定义组成。类声明(.h文件)以数据成员方式描述数据部分(数据封装),以成员函数的方式描述公有接口(调用接口)。类方法(.cpp文件)定义描述如何实现类成员函数。若在类声明(.h文件)中定义类成员函数,则该成员函数被看作是内联函数。
Q1. class与struct的区别?
① struct
的成员默认是public
,class
的成员默认是private
。
② 在C++中,如果没有多态和虚继承,struct和class的效率相同。
Q2. struct 在C和C++中的区别?
① 在C中:
● struct是是用户自定义数据类型(UDT);
● struct没有访问权限设置,且成员只能是变量的集合,不能是函数。
● struct的成员不能直接初始化。
② 在C++中:
● 在C++中,struct是抽象数据类型(ADT),支持成员函数的定义。
● 在C++中,struct增加了访问权限,默认权限是public。
● 在C++中,struct也不能在定义成员的时候进行初始化。
3.1 类关键字,成员(函数,变量)与类作用域
💗 3.1.1 类关键字
类通过关键字private
,public
,protected
对类成员进行访问控制;
private
私有的,变量和成员函数只能在本类中使用。
public
公有的,变量和成员函数可以在类和继承的类中使用。public
成员函数提供了对象与程序之间的接口,使数据隐藏。数据隐藏后只能通过成员函数来访问数据成员。
定义成员函数时,使用作用域解析运算符(
::
)来标识函数所属的类。同一个类的成员函数可以不用作用域解析符就相互调用,并可以访问类的
private
成员。
class Student{
int age;
char name[20]; //类声明默认为private,可不使用private
public:
int getAge() const;
void setAge(int age); //成员函数声明
};
int Student::getAge() const{ //作用域解析符标识函数所属的类
return age; //成员函数可以访问本类的private
}
通常,将类方法定义(声明)放在头文件(.h)中,将类方法实现放在源文件(.cpp)文件中。当成员函数定义在类声明中(.h中),则成员函数自动成为内联函数。
注意:对于const
对象和const
成员函数,常对象不能调用non-const
成员函数,需要将成员函数变为const
才能调用。
💗 3.1.2 类成员函数
对于类的成员函数,并不是一个对象对应一个单独的成员函数,而是同一类的所有对象共享这个成员函数体,因此,类成员函数在编译期,其地址就已经确定了。当调用此成员函数时,会将当前对象的this
指针传入成员函数。因此每个对象所占用的存储空间只是该对象的数据部分(虚函数指针和虚基类指针也属于数据部分)所占用的存储空间,而不包括函数代码所占用的存储空间。
💗 3.1.3 类作用域
当类的成员变量或成员函数是non static
的时,只能通过对象调用的方式获取,只有当static
成员函数,static
成员变量和嵌套类型才能使用类作用域操作符进行获取。
template<class T>
class Test{
public:
int a;
static int A;
static int B();
typedef T c;
};
int main(){
cout<<Test::A<<endl;
cout<<Test::B()<<endl;
typedef typename Test<int>::c newName; //此句就是将typename Test<int>::c嵌套类型 重命名为 newName
}
3.2 类的构造函数与析构函数
💗 3.2.1构造函数与析构函数
构造函数一般分为5种:
Q1. 构造函数有什么作用 ?
由于程序不能直接访问数据成员,就需要一个成员函数在对象创建时就对数据成员进行初始化,所以出现了构造函数。
Q2.一个空类(用户未定义构造函数的类)编译器会自动生成哪些成员函数?
对于这个问题,很多人会上来就说,空类会自动生成【默认构造函数】,【默认拷贝构造函数】,【赋值运算符】,【取址运算符】,【const取址运算符】,【析构函数】,其实并不是。C++编译器会根据不同的情况来生成构造函数。如下图所示:
对于空类,除了编译器对构造函数的合成,还有以下需要注意的:
① 当声明一个空类时,编译器不会生成任何成员函数,但是会生成1个字节的占位符,若编译器合成了构造函数,此时占用的空间仍为1个字节。
② 编译器生成的默认拷贝构造函数是浅拷贝,在对含有指针成员的对象进行拷贝时,必须要自己定义拷贝构造函数,使拷贝后的对象指针成员有自己的内存空间,否则可能会导致出现悬挂指针或重复释放。
class test{
int a;
};
int main(){
test *t=new test();
test *a=t; //此时a与t指向同一个地址,但删除其中一个,则另一个称为悬挂指针。若两个都delete则会出错。
}
Q3. 为什么拷贝构造函数中必须为引用传递,不能按值传递?
若拷贝构造函数是按值传递,则会导致循环递归下去。因为当按值传递过程中,对象的复制会再次调用拷贝构造函数,所以导致循环递归。
Q4. 析构函数何时被调用?
① 对象生命周期结束,被销毁时;
② 主动调用delete
;
③ 对象a是对象b的成员,b的析构函数调用时,a的析构函数也被调用。(不是继承关系)
以实现string类为目标,详细说明各个构造函数的用法。
#include<iostream>
#include<string.h>
using namespace std;
class String{
private:
char *m_data; //声明字符串指针,未分配空间
public:
String();
String(const char *str);
String(const String &other);
String &operator=(const String &other);
~String();
void getStr();
};
String::String(){ //默认构造函数
m_data=NULL; // 1.将数据成员指针进行初始化或置空,否则会出现野指针
}
String::String(const char *str){ //一般构造函数
if(str==NULL){ //1.首先判断传入参数是否为空,若为空则无法进行复制,将m_data分配一个空间,并0放入
m_data=new char[1]; //2.若传入参数不为空,则先对m_data分配空间,然后将数据复制
m_data[0]='\0';
}else{
m_data=new char[strlen(str)+1];
strcpy(m_data,str);
}
}
String::String(const String &other){ //拷贝构造函数
m_data=new char[strlen(other.m_data)+1]; //1.对m_data分配空间,然后将数据复制
strcpy(m_data,other.m_data);
}
String& String::operator=(const String &other){ //赋值运算符
/*if(this==&other) //1.判断是否自我赋值
return *this; //2.由于赋值前,对象已占有一定大小内存,所以先释放之前已占内存
//3.对m_data分配空间,将数据复制
delete [] m_data; //如果释放m_data后,内存再次分配时失败,会导致m_data中的数据被删除
m_data=new char[strlen(other.m_data)+1];
strcpy(m_data,other.m_data);
return *this;*/
if(this!=&other){ //保证了异常安全性
String strTemp(other); //首先复制原对象,在复制的对象上进行操作,防止原对象被修改。
char *m_dataTemp=strTemp.m_data; //复制当前数据,并交换
strTemp.m_data=m_data;
m_data=m_dataTemp;
}
return *this;
}
String::~String(){ //析构函数
delete [] m_data;
}
void String::getStr(){
cout<<m_data<<endl;
}
int main(){
String a(“Hello”); //调用一般构造函数
String b(a); //调用拷贝构造函数
String c=a; //调用拷贝构造函数
String d;
d=a; //调用赋值运算符
}
💗 3.2.2 POD 类型 与 trivial,non-trivial
POD(Plain Old Data)类型是一种概念,通俗来讲,一个类或结构体通过二进制拷贝后还能保持其数据不变,那么它就是一个POD类型。一个POD类型满足“trivial定义”和“标准布局定义”:
当一个类是POD类型,则该类的默认构造函数和析构函数是trivial
,即该类的构造函数和析构函数没有实际的作用(但也会被调用)。如果一个类中不满足POD条件,则该类是non-trivial
的,此时该类在构造函数显式的进行内存分配操作,在析构函数就必须显式的释放该类的内存空间,否则会产生内存泄露。
为了提高程序的效率,当一个类的构造函数和析构函数是trivial
时,我们对这个类进行构造、析构、拷贝和赋值时不会调用该类的构造函数和析构函数,而是采用最有效率的方法:直接采用内存操作如malloc()
,memcpy()
等提高性能。这也是SGI STL的底层基本原理。
💗 3.2.3 C++ explicit关键字
在C++的构造函数中,当构造函数只有一个参数,或者有n
个参数,但有n-1
个参数提供了默认值,这是该构造函数会被编译器自动进行隐式类型转换,从而看做conversion运算符
。为了避免这一情况,C++引入explicit
关键字,被explicit
关键字修饰的类构造函数,不能进行自动地隐式类型转换,只能显式地进行类型转换。
3.3 类的内存分配
根据类的声明和定义,如下图所示。在类声明当中,只是描述了类的数据结构怎么定义的,并没有对类进行内存空间的分配。只要当类创建了对象,才进行内存分配。
在C++中,通常通过new
关键字对实例进行创建并进行内存分配。在使用new
创建实例后,会有三个过程:
① 为新的对象分配内存空间
② 调用构造函数初始化对象中的值
③ 返回该对象的一个引用
Q1.new和delete的底层原理 ?
new
在底层调用operator new
全局函数,而operator new
调用malloc
来进行内存空间申请和分配。
delete
在底层通过operator delete
全局函数来释放空间,而operator delete
调用free
来进行内存空间释放。
Q2.类的空间如何计算 ?
● .定义一个空类型,没有任何成员变量和成员函数,对其求sizeof()的结果是多少 ?
当只定义一个空的类型,没有任何成员变量和成员函数,对该类型求sizeof()
,得到的结果是1,这是因为实例必须在内存中占有一定的空间,因此编译器会安插一个char
,使object
在内存中配置独一无二的地址。
● 当在一个空类型中,只添加构造函数和析构函数,对其求sizeof()的结果是多少 ?
当在该类型中添加一个构造函数和析构函数时,其sizeof()
结果还是1,因为调用构造函数和析构函数只需要函数的地址即可,与类型的实例无关。因此不会在实例中添加额外信息。
● 当析构函数为虚函数时,其sizeof结果是多少 ?
当类中有虚函数时,编译器会为该类生成虚函数表,并在该类型的每一个实例中添加一个指向虚函数表的指针_vptr
,在32位机器中,一个指针占4个字节,在64位机器中,一个指针占8个字节。
● 当对一个空基类进行虚继承,对其求sizeof()的结果是多少 ?
由于虚继承既包含子类自己的虚函数表指针,也包含父类的虚函数表指针,同时还有4个字节的分隔符(0x00000000)
,因此对一个空基类进行虚继承,其sizeof()=4+4+4=12
class B{};
class B1 :public virtual B{};
class B2 :public virtual B{};
class D : public B1, public B2{};
int main(){
B b;
B1 b1;
B2 b2;
D d;
cout << "sizeof(b)=" << sizeof(b)<<endl; //1
cout << "sizeof(b1)=" << sizeof(b1) << endl; //4
cout << "sizeof(b2)=" << sizeof(b2) << endl; //4
cout << "sizeof(d)=" << sizeof(d) << endl; //8
}
3.4 类指针和类对象的区别
▶ 类指针:是一个内存地址值,指向内存中存放的类对象(包括一些成员变量所赋的值).
▶ 类对象:利用类的构造函数在内存中分配一块内存(包括一些成员变量所赋的值)。
区别①: 类指针变量是间接访问,但可实现多态(通过父类指针可调用子类对象),并且没有调用构造函数
类对象是直接声明可直接访问,但不能实现多态,声明即调用了构造函数(已分配了内存)。
区别② : 类指针:用的是内存堆,程序结束后需释放它,通过new
关键字创建的是类指针,需要通过delete
来释放。
类对象:用的是内存栈,是个局部的临时变量.
区别③ :在应用时,类指针用 ->
操作符、 类对象用.
操作符;
区别④ :定义类对象实例时,分配了内存,类指针变量则未分配类对象所需内存,当new之后才会分配内存。
Student t; //类对象
student *t=new student(); //类指针,类指针只有在使用new时,才会调用构造函数
3.5 this指针
this指针的出现与C++编译器相关,在C++编译中,C++的类被翻译为C的struct,类中的成员变量称为struct的变量,类中的成员函数成为C的全局函数(C语言中所有函数均是全局函数),为了类与成员函数的关系,引入this指针。
this指针指向的是成员函数的作用对象,在成员函数中,通过this指针可以找到对象的地址。注意:由于static成员函数属于任何对象,因此static成员函数不能使用this指针。
class Complex {
public:
double real, imag;
Complex(double r, double i) : real(r), imag(i) {}
Complex AddOne(){
this->real++; //this表示的就是对象的地址
return *this; //返回当前对象
}
};
3.6 C++ 类切割行为
当把一个派生类对象赋给一个基类对象时,或基类对象强制转换派生类对象时会发生对象切割(splice
)。
4. 友元 friend
Q1.为什么要使用友元函数?
友元为不同类的成员函数之间,类的成员函数与一般函数之间建立了一种数据共享机制。通过友元,一个其他函数或者另一个类中的成员函数可以访问到本类中的private
成员和protected
成员(非成员函数访问私有数据)。
注:① 友元函数虽然在类声明中声明,但它不是成员函数,不能使用成员运算符来调用,在定义时也不需要使用作用域解析运算符(::)来标识函数所属的类。
② 友元函数虽然不是成员函数,但它与成员函数的访问权限相同。不需要通过指针和对象就可以直接调用友元函数。
③ 创建友元函数需要在函数声明前加关键字friend
,不能在函数定义中使用friend
关键字,除非函数定义也是原型。
class Student{
int Age;
public:
Student(int a);
friend Student operator+(int a,const Student &t); //友元函数在类声明中声明,但不是成员函数,在函数声明中加friend
Student operator*(const Student &t) const;
void show() const;
};
Student::Student(int a){ //默认构造函数
Age=a;
}
void Student::show() const{
cout<<this->Age<<endl;
}
Student Student::operator*(const Student &t)const { //运算符重载函数
Student ss(0);
ss=this->Age*t.Age;
return ss;
}
void dis(){ //× 该函数不是友元函数,所以在函数中不能调用对象的私有数据
Student stu(100);
cout<<stu.Age<<endl;
}
Student operator+(int a,const Student &t) { //友元函数定义,可以调用类私有数据,函数的定义不用加关键字friend
Student ss(0);
ss.Age=a+t.Age; //访问私有成员Age
return ss;
}
int main(){
Student s1(10);
Student s2(15);
Student su=2+s2; //调用友元函数
su.show();
Student su1=s1*s2; //调用运算符重载函数
su1.show();
}
5. 继承
继承存在三种方式:
继承允许程序可以在保持原有的特性基础上进行扩展,增加功能。继承展现了面向对象程序设计层析结构,是类设计层次的复用。
5.1 派生类构造与析构函数
Q2. 为什么派生类构造函数要调用基类构造函数 ?
在派生类中的数据成员包括从基类继承来的数据成员、派生类新增的数据成员。但是基类的构造函数和析构函数派生类不能继承,因此派生类需要自己的构造函数和析构函数。
① 对于派生类中从基类继承来的数据成员,就需要调用基类的构造函数进行初始化。如果没显式的调用基类构造函数,将自动调用默认构造函数。
② 对于派生类中新增的数据成员,在派生类构造函数中初始化即可。
Q3.为什么一个基类的析构函数必须是虚函数 ?
用基类指针或引用指向派生类对象,当析构的时候,若基类的析构函数不是虚函数,则派生类就不会重写基类的析构函数,因此只会析构基类,而不会析构派生类,导致派生类对象内存泄露。将基类的析构函数声明为虚函数之后,派生类的析构函数也自动成为虚析构函数,在主函数中基类指针p指向的是派生类对象,当delete
释放p指针所指向的存储空间时,会执行派生类的析构函数(多态),派生类的析构函数执行完之后会紧接着执行基类的析构函数,以释放从基类继承过来的成员变量所消耗的资源。
Q4.构造函数与析构函数的调用顺序 ?
① 对于单继承(B继承A)的时候,构造方法的调用是先调用基类构造函数,然后调用派生类构造函数。而对于析构方法是先调用派生类析构函数,然后调用基类析构函数。
② 对于多继承(C继承A,C继承B),构造方法的调用是先调用父类再调用子类,在调用父类的顺序是按照继承的先后顺序进行,而对于析构方法是先调用子类再调用父类,在调用父类析构函数的顺序是按照继承的先后的相反顺序进行。如 C->A,C->B,构造函数顺序为:A,B,C;析构函数顺序为:C,B,A。
③ 对于虚继承,其父类只会被调用一次,且为第一个继承子类调用。
5.2 派生类继承基类成员的状态变化
派生类不同的继承方式(public,private,protected)会导致从基类继承的成员的状态发生变化:
5.3 接口继承与实现继承
在public
继承条件下,public
继承通常分为两部分:接口继承和实现继承。通常分为三种情况:
1. 基类中声明了纯虚函数:继承类只继承函数接口 ,需要在继承类中定义对应的接口。
2. 基类中声明的成员函数中包含虚函数:为了让继承类继承该函数的接口和缺省实现。
3.基类中声明的成员函数不是虚函数: 为了令继承类继承函数的接口和固定的实现方式,任何继承类成员函数不能改变该成员函数的行为。
但是,如果某个继承类不需要基类声明 virtual
成员函数的缺省实现,这时需要断开virtual
函数接口与其缺省实现的"连接"。如下所示:
class Airplane{
public:
virtual void fly(const Airplane & destination)=0; //纯虚函数在detived类中必须实现
.....
};
void Airplane::fly(const Airplane & destination){ //pure virtual 实现
.... //缺省的实现函数
}
class ModelA:public Airplane{
virtual void fly(const Airplane & destination){ //ModelA 航班获取base类virtual 成员函数 fly() 缺省实现。
Airplane::fly(destination);
}
.....
}
class ModelB:public Airplane{
virtual void fly(const Airplane & destination); //ModelC 航班重新实现base类virtual 成员函数 fly()
.....
}
void fly(const Airplane & destination){ //重新实现base类virtual 成员函数 fly()
......
}
5.4 虚继承与菱形继承问题
Q1. 什么是菱形继承?
假设有两个类Father1
和Father2
,他们都是类GrandFather
的子类。现在又有一个新类Son
,这个新类通过多继承机制对类Father1
和Father2
都进行了继承,此时类GrandFather
、Father1
、Father2
和Son的继承关系是一个菱形,因此这种继承关系在C++中通常被称为菱形继承。
从下图中可以看出,Son
类间接的从GrandFather
类中继承了二份相同的基类数据成员,因此会出现二义性问题,需要通过域(::)
成员运算符进行区分
对应的钻石继承的程序为:
#include <iostream>
using namespace std;
class GrandFather{
public:
GrandFather(){}
GrandFather(int v):value(v){
}
virtual ~GrandFather(){}
protected:
int value;
};
class Father1 : public GrandFather{
public:
Father1(){}
Father1(int v):GrandFather(v){}
~Father1(){}
void setValue(int val){
this->value=val;
}
};
class Father2 : public GrandFather{
public:
Father2(){}
Father2(int v):GrandFather(v){}
~Father2(){}
int getValue(){
return value;
}
};
class Son:public Father1,public Father2{
public:
Son(){}
Son(int v):Father1(v),Father2(v){}
~Son(){}
};
int main(){
Son s(10);
s.setValue(20);
cout<<s.getValue()<<endl; //虽然s对象将数据设置成20,但是最后结果输出为 10。
return 0;
}
Q2.如何解决钻石继承问题 ?
针对上述问题,通过虚继承可以解决。
虚继承:在继承定义中包含了virtual
关键字的继承关系.
虚基类:在虚继承体系中通过关键字virtual
继承而来的基类。虚基类不是在声明基类时声明的,而是在声明派生类时通过指定其继承该基类的方式来声明的。
虚继承和虚基类的原理是由编译系统实现的,C++编译系统在实例化类Son
时,只会将虚基类的构造函数调用一次,忽略虚基类的其他派生类(class Father1
,class Father2
)对虚继承的构造函数的调用,从而保证了虚基类的数据成员不会被多次初始化。
注意:
1 .在派生类对象中,同名的虚基类只产生一个虚基类子对象,而同名的非虚基类则各产生一个非虚基类子对象。
2. 虚基类的子对象是由最后派生出来的类的构造函数通过虚基类构造函数来初始化的,因此,在派生类的构造函数的成员初始化列表中必须列出对虚基类构造函数的调用,如果没有列出,则表示使用虚基类的默认构造函数。
#include <iostream>
using namespace std;
class GrandFather{ //此时的基类Grandfather成为虚基类
public:
GrandFather(){} //虚基类默认构造函数
GrandFather(int v):value(v){} //虚基类一般参数构造函数
virtual ~GrandFather(){}
protected:
int value;
};
class Father1 : virtual public GrandFather{ //在继承是添加virtual,表明是虚继承
public:
Father1(){}
//在派生类的构造函数的成员初始化列表中必须列出对虚基类构造函数的调用,否则将调用虚基类默认构造函数
Father1(int v):GrandFather(v){}
~Father1(){}
void setValue(int val){
this->value=val;
}
};
class Father2 : virtual public GrandFather{ //在继承是添加virtual,表明是虚继承
public:
Father2(){}
//在派生类的构造函数的成员初始化列表中必须列出对虚基类构造函数的调用,否则将调用虚基类默认构造函数
Father2(int v):GrandFather(v){}
~Father2(){}
int getValue(){
return value;
}
};
class Son:public Father1,public Father2{
public:
Son(){}
//在最后的派生类构造函数添加虚基类的构造函数,将调用指定的虚基类构造函数,若没有指定,将调用虚基类默认构造函数
Son(int v):Father1(v),Father2(v),GrandFather(v){}
~Son(){}
};
int main(){
Son s(10);
s.setValue(20);
cout<<s.getValue()<<endl;
return 0;
}
//最后结果输出为 20
6. 多态
多态可以分为静态多态和动态多态。
6.1 动态多态
Q1. 什么是动态多态 ?
多态是指同一操作作用于不同的对象,可以产生不同的执行结果。在C++中,多态就是基类通过指针或引用指向继承类的对象,使其得到不同的计算结果。
Q2.如何实现动态多态 ?
动态多态的实现主要通过虚函数 virtual来实现。要实现多态,需要两个必不可少的条件:
① 基类中必须包含虚函数,并且派生类中一定要对基类中的虚函数进行重写。如果没有virtual
,程序根据引用类型或指针类型选择方法。 如果使用virtual
,程序将根据引用或指针指向的对象的类型选择方法。通过派生类中对基类的重写以实现多态性。
② 通过基类对象的指针或者引用调用派生类 。
Q3.为什么必须通过指针或引用才能实现多态 ?
一个指针或一个引用之所以支持多态,是因为它们并不引发内存任何与类型有关的内存委托操作; 通过指针或引用,会受到改变的只有它们所指向内存的大小和解释方式 。
如果把一个派生类对象直接赋值给基类对象,就涉及到对象类型的问题,编译器就会回避虚函数机制,从而无法实现多态。
Q4.在多态中构造函数能调用虚函数吗? 会出现什么结果 ?
首先,在多态中基类的构造函数是可以调用虚函数的,但不会出现多态的结果。这是因为,在派生类对象构造期间进入基类的构造函数时,对象类型有派生类类型变成了基类类型,但此时的派生类对象并没有完成初始化,因此,基类的构造函数调用虚函数时,仅仅会调用基类的虚函数,而不会产生多态的结果。
class TableTennisPLayer{ //基类
public:
virtual void a(){ //基类虚函数,在声明(原型)中使用关键字virtual
cout<<"基类a"<<endl;
}
void b(){ //基类成员函数
cout<<"基类b"<<endl;
}
};
class RatedPlayer : public TableTennisPLayer{ //派生类
public:
void a(){ //派生类虚函数,对基类成员函数进行重写,这里不能添加virtual关键字。重新定义方法不是重载。
cout<<"派生类a"<<endl;
}
void b(){
cout<<"派生类b"<<endl;
}
};
int main(){
TableTennisPLayer t1; //基类
RatedPlayer r1; //派生类
/*当成员函数调用方式是通过基类的引用或者指针,如果没有virtual,程序根据引用类型或指针类型
选择方法,如果使用virtual,程序将根据引用或指针指向的对象的类型选择方法。*/
TableTennisPLayer &t1_ref=t1;
TableTennisPLayer &t2_ref=r1; //派生类引用转为基类引用,向上强制转换
t1_ref.a(); //引用基类,结果为"基类a"
t2_ref.a(); //引用派生类,结果为"派生类a"
t1_ref.b(); //引用基类,没有虚函数,结果为"基类b"
t2_ref.b(); //引用派生类,没有虚函数,结果为"基类b"
t1.b(); //如果通过对象进行调用,将调用各自类的成员函数,调用基类对象,结果为"基类b"
r1.b(); //调用派生类对象,结果为"派生类b"
}
Q5. 虚函数的实现原理是什么 ?
虚函数是通过【虚函数指针vptr
+虚函数表vtbl
】来实现的。虚函数表中存放着虚函数的函数地址。虚函数指针与虚函数表vtbl
有如下特性:
① 虚函数表在编译期间创建,并存放在内存.rodata
段。编译期间编译器就为每个类确定好了对应的虚函数表里的内容。在程序运行时,编译器会把虚函数表的首地址赋值给虚函数表指针。
② 虚函数表是根据类来分配的,不是以实例来分配的,即不同的类有不同的虚函数表,同一个类的不同对象共用同一份虚函数表。
③ 虚函数指针vptr
跟随对象实例化而创建,即当实例化对象时,会调用对象的构造函数,在初始化列表之前会将虚函数表vtbl
的首地址作为虚函数指针vptr
的地址。
④ 在C++中存在一个指针vptr
,这个指针指向一个虚函数表。所以,在空间占用上,一个拥有虚函数的类(无论这个类中有多少虚函数)比没有虚函数的类要多占4个字节(64位)。所以,如果一个类不用作基类,就不要将成员函数设为虚函数。
⑤ 若派生类【没有】对基类虚函数进行重写,则派生类的虚函数表与基类的虚函数表相同。当派生类修改了基类的虚函数,就是将派生类虚函数表的虚函数地址进行了修改,使其指向了派生类的虚函数。
⑥ 若一个基类没有定义虚函数,则这个类就没有虚函数指针和虚函数表。
6.2 纯虚函数(抽象类与接口)
Q1.为什么使用纯虚函数 ?
在许多情况下,在基类中不能对虚函数给出有意义的实现,而把它声明为纯虚函数,它的实现留给该基类的派生类去做。
纯虚函数的实现方法:在基类中实现纯虚函数的方法是在函数原型后加 =0。
纯虚函数注意:
1. 纯虚函数声明的方法必须在派生类中进行定义。
2. 包含纯虚函数的类是抽象类,抽象类只能作为基类,抽象类不能定义实例,但可以声明指向实现该抽象类的具体类的指针或引用。
class TableTennisPLayer{
public:
virtual void a()=0; //纯虚函数,在基类中只定义方法,不具体实现
};
class RatedPlayer : public TableTennisPLayer{
public:
void a(){ //纯虚函数声明的方法必须在派生类中进行定义,否则会报错
cout<<"派生类a"<<endl;
}
};
int main(){
TableTennisPLayer t1; //× 纯虚函数的类是抽象类,抽象类不能定义实例,
//但可以声明指向实现该抽象类的具体类的指针或引用
RatedPlayer r1;
TableTennisPLayer &t1=r1; //抽象类声明指向实现该抽象类的具体类的指针或引用
r1.a(); //输出为“派生类a”
t1.a(); //输出为“派生类a”
}
C++中,抽象类与接口的区别:接口是一种特殊的抽象类,接口存在两个条件:
(1) 类中没有定义任何成员变量
(2) 类中所有成员函数都是公有且都是纯虚函数
6.3 运算符重载
运算符重载也是C++多态的一种,虽然运算符重载没有virtual关键字,但运算符重载隐藏了内部机理,强调了实质。运算符函数为operator op()。在运算符表示法中,运算符左侧的对象是调用对象,在成员函数中可以用this指针来表示,右侧的对象是作为参数被传递的对象。
除了使用成员函数operator op()对运算符进行重载,也可以通过友元函数+operator op()的方式进行运算符的重载。对第一种方法(成员函数),一个操作数是通过this指针进行隐式传递,另一个操作数作为函数参数显式的传递;对第二种方法(友元函数),两个操作数都作为参数来传递。
class Student{
int Age;
public:
Student(int a);
Student operator+(const Student &t) const; //运算符重载成员函数,运算符左侧是调用对象,右侧是作为参数被传递的对象
friend Student operator*(int a,const Student &t); //友元函数运算符重载
void show() const;
};
Student::Student(int a){ //重载函数
Age=a;
}
void Student::show() const{
cout<<this->Age<<endl;
}
Student operator*(int a,const Student &t) { //友元函数定义,可以调用类私有数据
Student ss(0);
ss.Age=a*t.Age;
return ss;
}
Student Student::operator+(const Student &t) const { //重新定义+号运算符,使两个对象能够相加
Student ss(0);
ss=this->Age+t.Age; //这里的this指针指向s1, t表示通过引用传递的参数s2
return ss;
}
int main(){
Student s1(10);
Student s2(15);
Student stu=s1+s2; //s1为调用对象,s2作为参数被传递
Student stu1=12+s1; //× 注意:由于运算符左侧的对象是调用对象,此时运算符左侧为12,不是对象
Student stu2=2*s2; //调用友元函数,2和s2都作为参数传递
su.show();
}
重载运算符的限制:
① 重载后的运算符必须至少有一个操作数是用户定义的类型,防止用户为标准类型重载运算符。
② 使用运算符时不能违反运算符原来的语法规则。
③ 不能修改运算符的优先级。
④ 不能创建新运算符。
⑤ =,(),[],-> 这四个运算符只能通过成员函数进行重载。
⑥ 可重载运算符如下:
6.4 函数的重载,重写和隐藏
当介绍完多态,就有一个问题,如何去区分函数的重载,重写和隐藏。
① 函数重载(在同一个类中):同一个访问区域内,被声明的几个具有不同参数的同名函数,
class Test{
public:
void test(int i);
void test(double d);
void test(int i,double d);
int test(double d); //函数重载不关心返回值的类型,所以不是函数重载!!!
};
② 函数重写/覆盖(多态,在不同类中):在派生类中,存在重新定义的函数,其函数名,函数参数和返回值类型和基类中的被重写函数相同,且基类中被重写函数必须有virtual
来修饰。
class Base{
public:
virtual void fun(int i){ //基类被重写函数
cout<<"Base : i"<<endl;
}
};
class Derived:public Base{
public:
virtual void fun(int i){ //派生类函数重写
cout<<"Derived: i"<<endl;
}
}
③ 函数隐藏: 派生类中函数屏蔽了与其同名的基类的函数,注意:只要同名函数就行,不管参数列表是否相同,基类中的函数都会被屏蔽。
class Base{
public:
void fun(int i,double d){
cout<<"Base : i"<<endl;
}
};
class Derived:public Base{
public:
int fun(int i){ //派生类函数隐藏了同名基类函数
cout<<"Derived: i"<<endl;
return i;
}
};
Q1. 函数重载与重写区别 ?
① 范围不同:函数重写本质就是多态,是在不同的类中;函数重载是同一个类中。
② 参数不同:函数重写的参数列表相同;函数重载的参数列表不一定相同。
③ virtual
修饰:函数重写的基类必须有virtual修饰,函数重载不必须有virtual修饰。
7. 异常安全
Q1: 什么是异常安全 ?
C++ 异常安全一般从两方面来考虑:一是不泄漏资源,二是不允许数据的破坏。
1. 不泄漏资源:通常利用RAII
技术,利用智能指针shared_ptr
或者利用类来管理资源。
2. 不允许数据的破坏:通常利用copy and swap
技术,在打算修改的对象复制一个副本,在副本上进行修改,等修改完成,且没有异常抛出时,将副本和原对象进行置换swap
7.1 不泄露资源
在C++中,涉及的内存主要为栈和堆。栈上的内存的分配和回收都是由编译器控制的,因此栈不会出现内存的泄露,只会出现栈溢出(stack overflow
),而堆内存由程序员进行管理,且C++没有垃圾回收机制,所以,C++的内存泄露都是在堆上泄露的。
💗 7.1.1 智能指针
C++中有四个智能指针:auto_ptr(C++11弃用),shared_ptr,weak_ptr,unique_ptr。
Q1: 为什么使用智能指针 ?
C++的内存管理是很复杂的。在通过new动态分配内存时,会出现两种情况导致内存泄露:
① 忘记通过delete来释放内存
② 在程序执行到delete语句之前,就由于某些原因退出了。
利用智能指针就能避免内存泄露问题。智能指针是一个存放指针的类
1. auto_ptr
auto_ptr
本质上是利用RAII技术,在构造时获取资源,在析构时释放资源。C++11中auto_ptr
已经被抛弃,auto_ptr
的特点如下,这些特点也是auto_ptr
被抛弃的原因:
① auto_ptr
没有使用引用计数,在拷贝构造函数和赋值运算符中将对象的所有权转移了。
② auto_ptr
不能指向数组,因为auto_ptr
析构时调用的是delete
而不是delete []
。
③ 如果两个auto_ptr
指针指向同一个对象时,当该对象的生存周期结束后,系统会调用析构函数,这样导致的结果是程序对同一个对象删除了2次,造成程序出错。
针对上述的特点有三种方法可以解决:
● 通过定义赋值运算符,实现深拷贝。
● 将对象的删除所有权设置成唯一,如unique_ptr
● 采用引用计数的方法,如shared_ptr
2. shared_ptr(共享拥有)
shared_ptr
是一个引用计数智能指针,每个shared_ptr
对象关联一个共享的引用计数用于共享对象的所有权,允许多个指针指向同一个对象。
每当shared_ptr
被赋值(或拷贝构造)给其它 shared_ptr
时,共享的引用计数器就加1,当一个 shared_ptr 析构或者被用于管理其它裸指针时,这个引用计数器就减1,如果此时发现引用计数器为0,释放指针指向的资源。为了保证线程安全性,引用计数器的加1,减1操作都是原子操作。
int main(){
//make_shared对new进行了封装创建了一个智能指针并初始化Hello,此时计数器p0:1
shared_ptr<String> p0=make_shared<String>("Hello");
shared_ptr<String> p1(p0); //拷贝构造智能指针p1指向p0,计数器p1=p0:2
shared_ptr<String> p2=p1; //f赋值智能指针p2指向p1,计数器p2=p1=p0:3
p1.reset(); //停止p1指针的共享计数器减1,但不会清除内存,计数器p1:0 p0=p2:2
cout<<(*p0).getStr(); //对智能指针解引用
p0.use_count(); //获取p0的引用计数
}
Q2: shaerd_ptr 使用注意事项 ?
① 每当shared_ptr通过make_sharead<T>
创建时,就会产生一个引用计数器。共享指针时不会创建引用计数器,会使计数器加1。
② 不要使用相同的原始指针拷贝构造创建多个shared_ptr
对象。因为在这种情况下,不同的shared_ptr
对象不会知道它们与其他shared_ptr
对象共享指针。当删除指针时会产生悬挂指针。
int *pi=new int(15); //拷贝构造 错误使用
shared_ptr<int> sp1(pi);
shared_ptr<int> sp2(pi); //×,会产生悬挂指针
cout<<*sp1<<" "<<*sp2<<endl; //输出为 15 15
sp1.reset(); //解引用sp1指针
cout<<*sp2<<endl; //输出为0,此时sp2成为悬挂指针
int i=12;
shared_ptr<int> sp3=make_shared<int>(i);
shared_ptr<int> sp4=make_shared<int>(i);
sp3.reset(); //解引用sp3指针
cout<<*sp4<<endl; //输出为12,此时sp4不是悬挂指针
shared_ptr<int> sp3(new int(15)); //拷贝构造 正确使用
shared_ptr<int> sp4(sp3);
shared_ptr<int> sp5(sp3);
③ 不要从栈而不是堆的内存中创建shared_ptr
对象。若在栈中创建shared_ptr
,在删除时会导致程序崩溃。
int x = 12;
std::shared_ptr<int> ptr(&x); //错误,x变量是在栈中
④ 防止shared_ptr交叉引用,交叉引用会导致泄漏。
交叉引用如下图所示,A对象
和B对象
在离开作用域的时候,调用各自析构函数,但由于交叉引用,使得引用计数不为0,导致泄漏。 可以使用weak_ptr
来解决交叉引用的问题,因为weak_ptr
是弱引用,不会增加计数器个数。
class A;
class B;
class A{
public:
std::shared_ptr<B> ptr;
};
class B{
public:
std::shared_ptr<A> ptr;
};
void TestSharedPtrCrossReference(){
boost::shared_ptr<A> ptrA( new A() );
boost::shared_ptr<B> ptrB( new B() );
ptrA->ptr = ptrB; //交叉引用
ptrB->ptr = ptrA; //交叉引用
cout <<" ptrA.use_count: " << ptrA.use_count() << endl; // ptrleader.use_count: 2
cout <<" ptrB.use_count: " << ptrB.use_count() << endl; //ptrmember.use_count: 2
}
Q3: shaerd_ptr 的线程安全性 ?
在shared_ptr
中主要包含两个成员:指向对象的指针和指向给对象指针的引用计数。
shared_ptr
的引用计数本身是安全且无锁的,但对象的读写不是线程安全的。因此,shared_ptr
的线程安全级别和内建类型,标准库容器一样:
① 一个stared_ptr
对象可以被多个线程同时读取
② 两个shared_ptr
对象可以被两个线程同时写操作
③ 如果从多个线程读写同一个shared_ptr
对象,则需要加锁。
Q4: 结合shaerd_ptr原理设计并实现shared_ptr ?
#include<iostream>
using namespace std;
template <typename T>
class Shared_ptr {
public:
Shared_ptr():m_ptr(nullptr),m_count(new size_t){}
Shared_ptr(T *ptr):m_ptr(ptr),m_count(new size_t){
*m_count = 1;
}
~Shared_ptr() {
if (--(*m_count) == 0) {
delete m_ptr;
delete m_count;
m_ptr = nullptr;
m_count = nullptr;
}
}
Shared_ptr(const Shared_ptr &src) {
m_count = src.m_count;
m_ptr = src.m_ptr;
++(*m_count);
}
Shared_ptr& operator=(const Shared_ptr &src) {
if (this == &src)
return *this;
else{
(*m_count)--; //这里减去的是赋值操作之前的引用计数,如a=b,则再次运行到a=c时,a从指向b变为指向c,则需要会对a的计数-1
if (m_count == 0) {
delete m_count;
delete m_ptr;
}
m_count = src.m_count;
m_ptr = src.m_ptr;
(*m_count)++;
return *this;
}
}
T* operator ->() {
return m_ptr;
}
T & operator*() {
return (*m_ptr);
}
void count() {
cout <<(*m_count) << endl;
}
private:
T *m_ptr;
size_t *m_count=0;
};
int main() {
Shared_ptr<int> sm(new int(10));
sm.count();
Shared_ptr<int> sm1(sm);
sm1.count();
Shared_ptr<int> sm2 = sm;
sm2.count();
while (1);
}
3. weak_ptr
weak_ptr
是一种弱引用,它不会增加对象的引用计数。利用weak_ptr
可以解决shared_ptr
的交叉引用问题,将CMember
类的shared_ptr
变为weak_ptr
,则ptrmember->leader = ptrleader;
不会增加ptrleader
的引用次数,在ptrleader
离开作用域时,引用计数减为0。
class CLeader;
class CMember;
class CLeader{
public:
CLeader() { }
~CLeader() { }
std::shared_ptr<CMember> member;
};
class CMember{
public:
CMember() { }
~CMember() { }
std::weak_ptr<CLeader> leader;
};
void TestSharedPtrCrossReference(){
boost::shared_ptr<CLeader> ptrleader( new CLeader );
boost::shared_ptr<CMember> ptrmember( new CMember );
ptrleader->member = ptrmember; //交叉引用
ptrmember->leader = ptrleader; //交叉引用
cout <<" ptrleader.use_count: " << ptrleader.use_count() << endl; // ptrleader.use_count: 1
cout <<" ptrmember.use_count: " << ptrmember.use_count() << endl; //ptrmember.use_count: 2
}
4. unique_ptr(独立拥有)
unique_ptr()
是一个独有指针,保证对对象的独有权,当unique_ptr
销毁时,其所指向的对象也被销毁,两个unique_ptr
不能指向一个对象,不能进行拷贝和赋值操作,只能进行移动操作。unique_ptr
只能通过new来分配内存。
int main(){
unique_ptr<String> u0=new String("unique"); //×,不能进行赋值操作
unique_ptr<String> u0(new String("Unique")); //只能通过new来创建
unique_ptr<String> u1=u0; //×,不能通过编译,unique_ptr不能指向一个对象
cout<<u0->getStr()<<endl;
u0.release();
String *temp=u0.release(); //放弃对所指对象的控制权,返回保存的指针,必须接收返回的指针,否则会导致内存的泄露
cout<<temp->getStr();
}
💗 7.1.2 RAII机制
Q1. 什么是RAII机制 ?
RAII
技术是利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。其思想是将C++资源通过类进行管理,在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。因此,采用RAII
技术,对象所需的资源在其生命期内始终保持有效,在生命周期结束时不需要显式地释放资源,避免用户忘记释放资源而导致资源泄漏。
7.2 不允许数据的破坏
异常安全函数提供以下三种层次的保证: 安全等级依次提升。
1. 基本保证:如果异常被抛出,"程序内任何事物仍然保持在有效状态下.没有任何对象或数据结构会因此而败坏."然而程序的现实状态很难预料:客户必须调用成员函数来确认对象的状态.
2. 强烈保证:如果异常被抛出,程序状态不改变.“如果函数成功,就是完全成功,如果函数失败,程序会回复到’调用函数之前’的状态”.
3. 不抛掷(nothrow)保证:程序绝不抛出异常且总是能够完成承诺的内容.
利用copy and swap
技术:在打算修改的对象复制一个副本,在副本上进行修改,等修改完成,且没有异常抛出时,将副本和原对象进行置换swap
//例如:有个class 来表示带背景图片的GUI菜单类:
struct PMImpl{ //创建一个struct来存储背景图片数据
std::trl::shared_ptr<Image> bgImage;
int imageChange;
}
class Lock{ //利用Lock类来管理互斥锁资源
public:
explicit Lock(Mutex *pm):mutexPtr(pm){
lock(mutexPtr);
}
~Lock(){
unlock(mutexPtr);
}
private:
Mutex mutexPtr;
};
class PrettyMenu{
public:
void PrettyMenu::changeBackground(std::istream &imgSrc)
.....
private:
Mutex mutex;
std::trl::shared_ptr<PMImpl> pimpl;
};
void PrettyMenu::changeBackground(std::istream &imgSrc){
using std::swap;
Lock m1(&mutex); //RAII技术
std::trl::shared_ptr<PMImpl> pNew(new PMImpl(* pimpl)) ; //将pimpl复制,创建一个pNew副本
pNew->bgImage.reset(new Image(imgSrc)) //修改副本,用新实例化的imgSrc修改复制后的pNew副本
++pNew->imageChange;
swap(pimpl,pNew); //交换pNew与pimpl
};
8. C++11新特性
C++的发展史如下图所示:
8.1 对原有语法标准的继承及扩充
① 增加新类型: long long 整型。
② 快速初始化成员列表:
在C++11 中,允许使用等号 =
或者花括号 {}
进行就地的非静态成员变量初始化。除此之外,可以通过initialize_list<T>模板类
来自定义列表初始化。
#include<iostream>
#include<map>
#include<vector>
#include <initializer_list> //initialize_list<T>模板类头文件
int a[]={1,2,3};
vector<int> v{1,2,3};
map<int,double> m={{1,1,0},{2,1,2}}; //初始化成员列表
void fun(initializer_list<int> l){ //自定义列表初始化作为输入参数
<>for(auto i=l.begin();i!=l.end();i++)
cout<<(*i)<<endl;
}
int main(){
fun({1,2,3,4,5}); //{1,2,3,4,5}为初始化列表
}
Q1. 初始化成员列表的优点 ?
初始化列表可以防止类型收窄-禁止将数值赋给无法存储它的数值变量。
类型收窄是指一些可以使得数据变化或精度丢失的隐式类型转换,以下几种情况会导致类型收窄:
(1). 浮点数隐式转换为整型数 int a=1.3
(2). 从高进度浮点数转为低精度的浮点数 long double –> double
(3). 从整型转化为较低长度整型 unsigned char=1024,1024不能被一般长度为8位的unsigned char容纳
(4). 整型转为浮点型,如果整型数大到浮点数无法精确显示。
③ auto 与 decltype声明
auto
用于实现自动类型推断,常用于迭代器中。auto
的自动类型推断发生在编译期,所以使用auto
并不会造成程序运行时效率的降低。decltype
将变量类型声明为表达式指定的类型,当用decltype(i)
类获取类型时,编译器将依序判断以下规则:
for(auto i=v.begin();i!=v.end();i++){}
decltype(x) y; //使y的类型与x相同
#include<iostream>
using namespace std;
enum class {K1,K2,K3} anon_e;
union {
decltype(anon_e) key; //key的类型由anon_e确定
char *name;
}anon_u;
struct {
int d;
decltype(anon_u)id; //id的类型由anon_u确定
}anon_s[100];
int main(){
int i;
decltype(i) a; //a:int
decltype((i)) b; //b:int & ,编译错误
decltype(anon_s) as; //as的类型由anon_s确定
as[0].id.key=decltype(anon_e)::K1; //引用匿名强类型枚举中的值
}
Q1.为什么要使用auto类型 ?
● auto
用于代替冗长复杂,变量使用范围专一的变量声明。
auto
和
decltype
对函数模板追踪返回类型
函数模板是函数重用的重要方式。一个函数模板的返回类型依赖于实际的入口参数类型,导致该返回类型在模板实例化之前无法确定。利用
auto
和
decltype
可以解决这个问题:
template<typename T1,typename T2>
//利用decltype来追踪返回值类型,并赋值给auto类型。返回类型后置
auto Sum(T1 &t1,T2 &t2)->decltype(t1+t2){
return t1+t2;
}
● 定义函数模板时,auto
用于声明依赖模板参数的变量类型。
template<typename Tx,typename Ty>
void add(Tx x,Ty y){
auto v=x+y;
}
Q2. auto的注意事项 ?
● auto
变量必须在定义时初始化。
● 函数或者模板参数不能被声明为auto
。
● auto
不是一个真正的类型,仅仅是一个占位符。
● 定义在一个auto
序列的变量必须始终推导成同一类型。
● 如果初始化表达式是引用,则去除引用语义。
● 如果初始化表达式为const
或volatile
,则除去const/volatile
语义。如果auto
关键字带上&号,则不去除const
语义。
● 初始化表达式为数组时,auto推导类型为指针,若auto带上&,则推导类型为数组类型。
Q3. auto的底层原理是什么 ?
auto
使用的是模板实参判断机制。auto
被一个虚构的模板类型参数T
替代,将变量作为函数参数,并将其传递给模板并推断为实参。
④ 非静态成员变量的sizeof()
在C++11中,可以对非静态成员变量使用sizeof
操作。而在C++98中,只有对象实例或静态成员才能对其成员进行sizeof
操作。
class Test{
int hand;
static Test * all;
};
int main(){
Test t;
cout<<sizeof(Test::all); //C++98,C++11通过
cout<<sizeof(Test::hand0); //C++11通过
}
⑤ final控制与override控制
final
关键字用于阻止派生类中对抽象类成员虚函数的重写。override
关键字用于派生类中继承抽象类的成员虚函数必须重载为基类中的同名函数。
class Object{
virtual void fun()=0;
virtual void funabcd(int a)=0;
};
class Derived:public Object{
void fun() final; //阻止抽象类的重写
void funabcd() override; //无法编译,参数不同
void funabcd(int a) override; //编译通过
void funabdc() override; //编译不通过,名称不同
};
⑥ nullptr 指针
C++11中,将nullptr代替了NULL,因为NULL表示的是0,防止与数字0重复。
⑦ 外部模板 extern
在C++98中,extern只能用于变量,在C++11中,extern也可以用于函数和外部模板,使模板达到共享。
⑧ 基于范围的for循环
对范围可以确定的迭代可以使用基于范围的for循环 -> for(用于迭代的变量:迭代的范围)
int arr[5]={1,2,3,4,5};
vector<int> v={1,2,3,4,5}
for(int a:arr)
cout<<a<<endl;
for(auto i:v)
cout<<i<<endl;
⑨ 强枚举类型
枚举类型是对常量数值的别名。
在C语言中,对常量数值的别名通常有三种方法:
(1).宏定义 – 会干扰到正常代码
(2).匿名的enum
(3).静态常量 – 在代码中产生实际的数据。增加存储空间
#define Male 0 #宏定义
#define Female 1
enum {Male,Female}; //匿名的enum
const static int Male=0; //静态常量
const static int Female=1;
Q1. 枚举类型与强枚举类型的区别 ?
⑴.在C语言中enum
中成员的名字都是全局可见的,强枚举类型拥有强作用域,不会输出到父作用域空间,不是全局可见的,因此使用时必须加上其所属的枚举类型的名称。
⑵.enum
类型成员都被隐式地转换为整型。强枚举类型成员值不能与整型隐式相互转换。
⑶.枚举类型数值在进行数值比较时,首先被隐式转为int类型数据,然后进行比较运算。强枚举类型默认底层类型为int,也可以显式地指定底层类型。
enum Type1 {a,b,c,d} //枚举类型
enum class Type{A,B,C,D}; //强枚举类型
enum class Type_c : char{A,B,C,D}; //强枚举类型指定底层类型为chare
Type t=Type::A; //强枚举类型使用时必须加上其所属的枚举类型的名称。
cout<<(int)t<<endl;
8.2 移动语义(构造)和右值引用
💗 8.2.1 右值引用 &&
在 C++11 的程序中, 所有的值必属于左值、 将亡值、 纯右值三者之一。左值引用是对变量的别名,右值引用是对数据的别名,通常是一个临时变量或将要销毁的对象。右值引用是不能够绑定到任何的左值的。
右值引用有两个作用:
① 引入右值引用可以直接从寄存器中读取(移动)数据,可以减少拷贝,提高效率。
int x=10;
int y=20;
int && r1=x+y; //r1为右值引用,是x+y计算结果的别名,即使修改了x或y也不会影响r1
💗 8.2.2 移动语义
这里首先要区分内存移动与内存拷贝,内存移动是将一段数据移动到另一段内存中,可以直接通过指针进行移动。而内存拷贝是将原始数据复制一份,这个过程就需要内存的分配。因此内存移动效率要高于内存拷贝。
Q1. 为什么需要移动语义与移动构造函数 ?
移动语义避免了移动原始数据,而只修改了记录,移动语义属于浅拷贝。如在自定义拷贝构造函数中,一般是通过深拷贝进行的。但当数据很大时,大量数据复制会耗时。因此提出移动构造函数。移动构造函数是浅拷贝。使数据成员指向要拷贝对象的数据内存地址。
💗 8.2.3 强制移动 std::move()
std::move()
将一个左值强制转换为右值引用,被转换的左值,其生命期没有发生改变。利用
std::move()
可以将左值转为右值,然后调用移动构造函数。
注意:由于右值引用通常用于临时变量,利用std::move()
将一个变量的左值转为右值后(将一个永久变量变为临时变量),若调用移动构造函数会导致其该变量的数据为空。
8.3 新的类功能
💗 8.3.1 继承构造函数
派生类在初始化时,需要在初始化列表中调用基类的构造函数,完成构造函数的传递。如果基类拥有多个构造函数,则派生类也需要实现与基类构造函数对应的构造函数。
继承构造函数提供了一种让派生类能够继承基类构造函数的机制,从而减少继承中,派生类对基类中过多的构造函数进行”透传”,简化代码。
(1).如果一个继承构造函数不被相关代码使用,编译器不会为其产生真正的函数代码。
(2). 如果基类的构造函数被声明为私有成员函数, 或者派生类是从基类中虚继承的, 那么就不能够在派生类中声明继承构造函数。
(3).一旦使用了继承构造函数, 编译器就不会再为派生类生成默认构造函数。
(4).继承构造函数无法初始化派生类的数据成员,如果需要对派生类数据成员进行初始化,可以将某一个构造函数单独进行初始化列表初始化。
class Base{
public:
Base(int i){}
Base(double a,int j)()
Base(float f){};
};
class Derived :public Base{
public:
using Base::Base; //继承构造函数,继承基类构造函数,若派生类中没有对应的构造函数就调用基类构造函数
Derived(int i):Base(i),dei(i){} //需要初始化派生类成员时,单独将某一构造函数进行初始化列表。
private:
int dei;
};
💗 8.3.2 委派构造函数
在类中多个构造函数,有些构造函数需要包含其他构造函数中已有的代码。通过委托构造函数可以简化程序。
class Test{
public:
Info(){ Init(); }
Info(int i):Info(){type=i;}
Info(char e):Info(){ name=e;}
private:
char name{'a'};
int type{1};
};
💗 8.3.3 移动构造函数
详见7.2。
8.4 Lambda 表达式
Lambda表达式是一种匿名函数(没有函数名),其本身是一个函数。一个lambda表达式语法定义如下所示:
① lambda函数的定义和使用是在同一个地方进行的,定义即使用,因此不需要翻阅源代码(一般函数的定义和使用是分开进行的)
② 在一般函数中无法再次定义函数,而lambda作为函数是可以用在函数中的。
③ lambda函数可访问作用域内的任何动态变量。
④ lambda函数是内联函数。在重复调用时,其效率高于通过函数指针方式。
⑤ 当一个函数是临时一用,且函数中的操作很简单时,就不一定要定义函数名,可以通过lambda表达式来实现。
Q2. lambda函数与普通函数的区别 ?
① Lambda函数跟普通函数相比不需要定义函数名。
② Lambda函数可以通过捕捉列表访问一些上下文中的数据。
③ lambda函数运算是基于初始状态进行的。而普通函数是基于参数的运算。
Q3. 如何理解lambda函数的捕获列表 ?
lambda
的实现方式是创建一个小类。此类重载了
operator()
,因此它的作用就像一个函数。
lambda
函数是此类的实例;构造该类时,周围环境中的所有变量都将传递到
lambda
函数类的构造函数中,并保存为成员变量。
注意:当使用引用捕获时,lambda函数能够修改
lambda
函数之外的局部变量,但如果从函数返回lambda
函数,则不能使用按引用捕获,因为在函数返回后该局部变量的引用无效。
Q4. lambda函数使用时一些问题?
① 按值传递捕捉列表与按引用传递捕捉列表效果不同。
(1).按值传递,其传递的值在lambda函数定义时已经决定。
(2).按引用传递,其传递的值在lambda函数调用时决定。
int main(){
int boys=4,girls=3;
//捕捉父作用域所有变量,计算变量的和,返回totalNum为名称的int型函数
auto totalNum= [=] {}->int{return girls+boys}; //lambda函数定义
cout<<totalNum()<<endl; //lambda函数使用
//计算传入的变量的和,返回sum为名称的int型函数
auto sum=[](int a,int b) -> int{return a+b;};
cout<<sum(10,23)<<endl;
auto print=[=]()->void{
printf("test\r\n");
printf("Hello\r\n");
};
print();
}
8.5 C++ 标准类型转换 cast
所谓的类型转换,其本质是一种编译器的指令,大部分情况下,转型cast并不改变一个指针所含的真正地址,其只影响被指出的内存的大小和其内容的解释方式。
C++与C语言相比,其强制转换方式更加丰富。在C++中,常见的显式转换cast
有4种: static_cast
, const_cast
, dynamic_cast
, reinterpret_cast
💗 8.5.1 static_cast
static_cast < type-id > (expression)
,该运算符会把expression转换为type-id类型,但没有运行时类型检查来保证转换的安全性。static_cast
不能转换掉expression的const
、volatile
、或者_unaligned
属性其用法如下:
① 用于类层次结构中父类和子类之间指针或引用的转换。
● 进行上行转换(把派生类的指针或引用转换成基类表示)是安全的;
● 进行下行转换(把基类指针或引用转换成派生类表示)时,由于基类中不包含派生类中的成员,且没有动态类型检查,所以是不安全的。
② 用于基本数据类型之间的转换,如把int
转换成char
,把int
转换成enum
。
③ 把空指针转换成目标类型的空指针
④ 把任何类型的表达式转换成void
类型。
int main() {
int a = 68;
double d = static_cast<double>(a); //基本数据之间转换
void *b = static_cast<void *>(&a); //int * -> void * -> char *
cout << (char *)b << endl;
char *data;
data = static_cast<char *>(malloc(sizeof(char) * 10)); // void* -> char*
const int ca=10;
char cb=static_cast<char>(ca); //注意,这时候是能转换成功的,这是因此,ca是从符号表读取出来的数值,并没有内存空间,其变量ca没有const属性
}
💗 8.5.2 dynamic_cast
由于static_cast
在类层次结构中向下转换是不安全的,因此提出了dynamic_cast
,用于向下转换时检测其是否能够安全的进行转换,当dynamic_cast
检测失败时,如果是指针则返回一个0值,如果是转换的是引用,则抛出一个bad_cast
异常。dynamic_cast
基于RTTI机制实现,只能用于含有虚函数的类;
class Base
{
public:
Base(){};
virtual void Show(){cout<<"This is Base calss";}
};
class Derived:public Base
{
public:
Derived(){};
void Show(){cout<<"This is Derived class";}
};
int main(){
//这是第一种情况,基类指向派生类(派生类指针转换为基类指针),可以转换成功
Base* base = new Derived;
//这是第二种情况,派生类指向基类(基类指针转换为派生类),转换失败
Base * base1 = new Base;
if(Derived *der1 = dynamic_cast<Derived*>(base1)){
cout<<"第二种情况转换成功"<<endl;
der1->Show();
}
else {
cout<<"第二种情况转换失败"<<endl;
}
}
💗 8.5.3 const_cast
当我们调用了一个参数不是const
的函数,而我们要传进去的实际参数确实const
的,但是我们知道这个函数是不会对参数做修改的。于是我们就需要使用const_cast
去除const
限定,以便函数能够接受这个实际参数。
💗 8.5.4 reinterpret_cast
reinterpret_cast
用在任意指针(或引用)类型之间的转换;以及指针与足够大的整数类型之间的转换;从整数类型(包括枚举类型)到指针类型,无视大小。将数据以二进制存在形式的重新解释。
int i;
char *p = "This is an example.";
i = reinterpret_cast<int>(p); //将字符转换为二进制,再转换为int
9. C++ I/O (标准输入输出)
在C++中,I/O发生在流中。流是字节序列,当字节流从设备(如键盘、磁盘驱动器、网络连接等)流向内存,这叫输入操作。如果字节流是从内存流向设备(如显示屏、打印机、磁盘驱动器、网络连接等),这叫做输出操作
C++的I/O分为三种:
① 标准I/O: 对系统指定的标准设备的输入输出,如 键盘->内存->显示器
② 文件I/O: 以外存磁盘文件为对象进行输入输出,如 磁盘文件 ->内存 ->磁盘文件
③ 串I/O(又称字符串输入输出): 对内存中指定的空间进行输入输出(通常指定一个字符数组作为存储空间)。
9.1 C++ 缓冲区
缓冲区又称为缓存,它是内存空间的一部分,这些存储空间用来缓冲输入或输出的数据,这部分预留的空间就叫做缓冲区。缓冲区根据输入设备还是输出设备分为输入缓冲区和输出缓冲区。
Q1. 为什么需要缓冲区?
缓冲区就是一块内存区,它用在输入输出设备和CPU之间,用来缓存数据。它使得低速的输入输出设备和高速的CPU能够协调工作,避免低速的输入输出设备长时间占用CPU,使其能够高效率工作。如利用打印机打印文档,由于打印机的打印速度不快,这时可以将计算机的数据传输到打印机的缓冲区中,然后打印机在慢慢从缓冲区中读取数据,避免了打印机长时间占用计算机的I/O。
💗 9.1.1 缓冲区类型及刷新
缓冲区类型分为全缓冲,行缓冲和不带缓冲三种类型:
当出现以下情况时,会发生缓冲区的刷新:
① 缓冲区满时;② 执行flush
语句;③ 执行endl
语句;④ 关闭文件
9.2 标准IO
在C++中有五种标准IO:
① 标准输出流 std::cout
② 标准输入流 std::cin
:std::cin
的输入过程如下所示:
string name;
string Class;
cout << "Please enter your name: ";
cin >> name; //输入John Doe
cout << "Enter the class : ";
cin >> Class; //输入No1
cout << "Hello, " << name<< endl; //输出 Hello,John
cout << "You live in " << city << endl;
//输出You live in Doe 在键盘缓冲区中找到的剩余字符
return 0;
③ 整行读取getline()
:getline()
可读取整行,包括前导和嵌入的空格,并将其存储在字符串对象中。同时getline()
可以对输入的字符串,根据某个特定字符进行分割。
string name;
string str;
getline(cin, name); //输入John Doe
cout << "Hello, " << name << endl; //输出John Doe
while(getline(cin, str,'#')){ //输入one#two
cour<<str; //分割后字符串 输出one,two没有输出,这时需要使用stringstream流
}
Notice:当getline()
和cin()
一起使用时,由于cin()
从缓冲区读取数据,且遇到空白字符(Tab,Enter,Space
)停止读取字符,而getline()
遇到Enter
时会停止读取字符,因此会存在一下问题:
//错误方式:
int sum;
cin>>sum; //当输入数据后,按Enter结束输入,此时数据在缓冲区中,cin从缓冲区中读取,直到Enter,但此时Enter还在缓冲区中
string str;
cin>>str; //当再次输入字符串后,由于原先的Enter还在缓冲区,因此此时str为空,要想使str能正确读取,需要将缓冲区的Enter删除掉
//正确方式:
int sum;
cin>>sum;
cin.ignore(); //忽略缓冲区中最后的Enter
string str;
cin>>str;
④ 标准错误流 cerr
:cerr
对象是非缓冲的,且每个流插入到 cerr
都会立即输出。
⑤ 标准日志流 clog
:clog
对象是缓冲的。这意味着每个流插入到 clog
都会先存储在缓冲在,直到缓冲填满或者缓冲区刷新时才会输出。
9.2 文件IO
9.3 串IO
串IO包括三个类,要使用他们创建对象就必须包含sstream.h头文件。
① istringstream类:用于执行串流的输入操作。
istringstream istr;
istr.str("1 56.3"); //输入字符串“1 56.3”
cout<<istr.str()<<endl; //显示字符串到屏幕
int a;double b;
istr>>a; //空格会成为字符串参数的内部分界
istr>>b;
cout<<a<<" "<<b<<endl; //a为1,b为56.3
② ostringstream类:用于执行串流的输出操作。
ostringstream ostr;
ostr.put('d'); //通过put()或者左移操作符可以不断向ostr插入单个字符或者是字符串
ostr.put('e');
ostr<<"fg";
string str=ostr.str(); //通过str()返回增长过后的完整字符串数据
cout<<str<<endl; //defg
③ strstream类:支持串流的输入输出操作。stringstream可以用于数据类型的转换,还可用于空格分割的字符串的切分。
stringstream sstr;
int a=100;
string str;
sstr<<a;
sstr>>str; //int 100 -> string 100
sstr.clear();
string n="Hello world";
string b="12,45";
double d;
char name[200];
ssrt<<n;
sstr>>name; //string -> char[];
sstr.clear();
sstr<<b;
sstr>>d //string -> double
string str_cin("one#two#three"); //字符串分割
stringstream ss;
ss << str_cin;
while (getline(ss, str, '#'))
cout << str<< endl;
10. C++ SGI STL
10.1 STL 简介
SGI STL分为六大组件,如下图所示:
① STL标准头文件(无文件后缀),如vector, deque, list, map, algorithm, functional …
② SGI STL内部文件(STL的真正实现),如stl_vector.h, stl_deque.h, stl_list.h, stl_map.h, stl_algo.h, stl_function.h …
10.2 SGI STL - 空间配置器 allocator
从上图可以看出allocator
是STL的底层核心,allocator
除了负责内存的分配和释放,还负责对象的构造和析构。在SGI STL中包含两个空间配置器,std::allocator
和std::alloc
。std::allocator
的效率不高,只是new
和delete
的简单封装,因此SGI STL多数采用std::alloc
作为空间配置器。下面介绍std:alloc
如何提高效率的:
传统的C++内存new
和delete
分为两个阶段,std::alloc
将这两个阶段划分成四个不同操作:
1. construct()
template <class _T1, class _T2> // T1为类名称,T2为构造函数的初始化值
inline void construct(_T1* __p, const _T2& __value) {
_Construct(__p, __value);
}
template <class _T1> //调用T1类的默认构造函数
inline void construct(_T1* __p) {
_Construct(__p);
}
template <class _T1, class _T2> // T1为类名称,T2为构造函数的初始化值
inline void _Construct(_T1* __p, const _T2& __value) {
new ((void*) __p) _T1(__value);
}
template <class _T1> //调用T1类的默认构造函数
inline void _Construct(_T1* __p) {
new ((void*) __p) _T1();
}
2. destory()
destory()
进行析构时分为两类:
① 对于单个对象,直接调用析构函数即可。
② 对于采用迭代器进行析构时并没有直接调用类的析构函数,而是首先利用_type_traits
进行对象类型的萃取,然后调用has_trivial_destructor()
,判断该对象类型是trivial
还是non-trivial
,若是trivial
则什么都不用做,若是non-trivial
则依次destory()
对象,这样做是因为trivial
的析构函数是没有实际作用的,因此直接对non-trivial
进行destory
能提高迭代析构的效率。
template <class _Tp>
inline void destroy(_Tp* __pointer) { //version1:直接调用该对象的析构函数
_Destroy(__pointer);
}
template <class _ForwardIterator> //version2:接收first和last两个迭代器
inline void destroy(_ForwardIterator __first, _ForwardIterator __last) {
_Destroy(__first, __last);
}
template <class _ForwardIterator>
inline void _Destroy(_ForwardIterator __first, _ForwardIterator __last) {
__destroy(__first, __last, __VALUE_TYPE(__first));
}
template <class _ForwardIterator, class _Tp> //__type_traits进行类型萃取
inline void __destroy(_ForwardIterator __first, _ForwardIterator __last, _Tp*){
typedef typename __type_traits<_Tp>::has_trivial_destructor _Trivial_destructor;
__destroy_aux(__first, __last, _Trivial_destructor());
}
template <class _ForwardIterator> //如果是non-trivial就执行destory()
void __destroy_aux(_ForwardIterator __first, _ForwardIterator __last, __false_type){
for ( ; __first != __last; ++__first)
destroy(&*__first);
}
template <class _ForwardIterator> //如果是trivial什么也不做
inline void __destroy_aux(_ForwardIterator, _ForwardIterator, __true_type) {}
💗 10.2.2 空间配置与释放
SGI STL的空间配置与释放是通过malloc
和free
完成的,而不是通过new
和delete
完成的。考虑小区块的分配导致内存碎片, STL设计了双层级空间配置器,当配置区>128bytes
时,调用第一级配置器,当配置区<128bytes
时,调用第二季配置器。第一级配置器直接利用malloc
和free
进行空间配置和释放,第二级配置器利用memory pool
进行空间配置和释放。
第一级配置器直接利用
malloc
和
free
进行空间配置和释放,内存分配过程如下图所示:
#ifndef __STL_STATIC_TEMPLATE_MEMBER_BUG
static void (* __malloc_alloc_oom_handler)();
#endif
public:
static void* allocate(size_t __n){ //内存分配
void* __result = malloc(__n);
if (0 == __result) __result = _S_oom_malloc(__n);
return __result;
}
static void deallocate(void* __p, size_t /* __n */){ //内存释放
free(__p);
}
static void* reallocate(void* __p, size_t /* old_sz */, size_t __new_sz){ //内存重新分配
void* __result = realloc(__p, __new_sz);
if (0 == __result) __result = _S_oom_realloc(__p, __new_sz);
return __result;
}
static void (* __set_malloc_handler(void (*__f)()))(){
void (* __old)() = __malloc_alloc_oom_handler;
__malloc_alloc_oom_handler = __f;
return(__old);
}
};
#ifndef __STL_STATIC_TEMPLATE_MEMBER_BUG
template <int __inst>
void (* __malloc_alloc_template<__inst>::__malloc_alloc_oom_handler)() = 0; //内存分配不足处理函数,STL默认不调用
#endif
template <int __inst>
void* __malloc_alloc_template<__inst>::_S_oom_malloc(size_t __n) //内存分配不足处理函数
{
void (* __my_malloc_handler)();
void* __result;
for (;;) {
__my_malloc_handler = __malloc_alloc_oom_handler;
if (0 == __my_malloc_handler) { __THROW_BAD_ALLOC; }
(*__my_malloc_handler)();
__result = malloc(__n);
if (__result) return(__result);
}
}
template <int __inst>
void* __malloc_alloc_template<__inst>::_S_oom_realloc(void* __p, size_t __n)
{
void (* __my_malloc_handler)();
void* __result;
for (;;) {
__my_malloc_handler = __malloc_alloc_oom_handler;
if (0 == __my_malloc_handler) { __THROW_BAD_ALLOC; }
(*__my_malloc_handler)();
__result = realloc(__p, __n);
if (__result) return(__result);
}
}
2. 第二层配置器 - memory pool
为了防止内存碎片的产生,第二层配置器采用memory bool
来管理内存。memory bool
会配置一大块内存(远远>128 bytes),并维护一个指向未使用内存的free_lists
指针。当分配内存时,从free_lists
中取出,释放内存时再放入free_lists
中。为了方便管理,memory bool
维护了16个free_lists
,各自分别管理8,16,24,32,40,48…120,128的小区块 (类似于Linux内存管理buddy system
) 。除此之外,第二层配置器会将任一小于128 bytes
的内存请求上调为8的倍数,以对应free_lists
。内存分配过程如下如所示:
10.3 SGI STL - 迭代器 iterators
STL将数据容器和算法分开,彼此独立,两者之间就需要一种方法进行连接,该方法便是迭代器。
迭代器的本质是一种智能指针,并对operator*
和operator->
进行重载。有时,迭代器相应类型为所指对象的类别,这就需要迭代器动态的通过参数来获取相应类型,这就需要C++ traits技术(类型萃取技术)。
SGI STL共包含5个相应的类别:value_type
,difference_type
,reference_type
,pointer_type
和oterator_category
由于迭代器的本质是指针,当向容器中添加元素
insert
,删除元素
erase
的操作可能会使指向元素的指针,引用或迭代器失效。一个失效的指针,引用或者迭代器将不再表示任何元素。使用失效的指针,会引起程序崩溃。迭代器的失效通常包括两种类型:
① 由于插入元素
insert
或push_back
,使得容器元素空间不足,需要扩容,导致存放原容器元素的空间不再有效,从而使得指向原空间的迭代器失效。② 由于删除
erase
或pop_back
元素,使得某些元素次序发生变化,使得原本指向某元素的迭代器不再指向希望指向的元素。
10.4 SGI STL - 序列容器
序列容器包含最常用数据结构,分为序列式和关联式。
序列式:array
,list
,stack
,queue
,vector
关联式:set
,map
,hash table
💗 10.4.1 vector
vector
是一种动态数组,其数据结构是线性连续空间。相比于array静态数组,vector
可以随着元素的加入,自行扩充空间,以容纳新的元素。
● vector
的数据结构
● vector
的基本性质:
① vector所谓动态增加空间,并不是在原空间之后接续新空间,而是以原大小的两倍另外配置一块较大空间,然后将原内容拷贝过来,在原内容之后构造新元素,并释放原空间。因此,当对vector的操作引起空间配置时,指向原vector的所有迭代器都失效了。
② vector的API:
//注意添加头文件#include<vector>
vec.back(); //返回vec的最后一个元素
vec.front(); //返回vec的第一个元素
vec.clear(); //清楚vec的元素
vec.empty(); //判断vec是否为空
vec.pop_back(); //删除vec的最后一个元素
vec.push_back(num); //在vec的最后插入一个元素num
vec.insert(vec.begin()+1,num); //在vec的第一个索引位置插入数组num
vec.size(); //返回vec元素的个数
vec.resize(cnt); //将vec的现有元素个数调整为cnt个,多则删,少则补,其值随机
vec.swap(vec1); //将vec中的元素与vec1的元素进行交换
Q1.在vector的扩容机制中,为什么是成倍增长而不是增长固定大小 ?
按成倍增长的效率(O(1))要比按固定大小增长的效率(O(n))高。
若以成倍方式增长,假定有 n 个元素,倍增因子为 m,则需要重新分配空间的次数为
l
o
g
m
n
log_m^{n}
logmn,且第
i
i
i次配空间有
m
i
m^i
mi个元素需要复制。
∑
1
l
o
g
m
n
m
i
=
n
m
m
−
1
\sum_1^{log_m^{n}}m^i = \frac{nm}{m-1}
∑1logmnmi=m−1nm,总时间复杂度为O(1),因此整个n元素加入vector
所需要的均摊时间为常量O(1)。
若以固定大小增长,假定有 n 个元素,每次增长为k,则需要重新分配空间的次数为
n
/
k
n/k
n/k,且第
i
i
i次配空间有
k
i
ki
ki个元素需要复制。
∑
1
n
/
k
k
i
=
n
2
\sum_1^{n/k}ki =n^2
∑1n/kki=n2,因此整个n元素加入vector
所需要的均摊时间为O(n)。
Q2.在vector的扩容机制中,为什么是2倍或1.5倍增长?
如果以大于2 倍的方式扩容,下一次申请的内存会大于之前分配内存的总和,导致之前分配的内存不能再被使用(由于分配新空间时需要数据复制,且新空间的大小比原空间总和大,导致原空间的内存只能释放,无法重复利用)。所以,最好的增长因子在(1,2)之间。
当使用2倍增长时,每次扩展的新尺寸必然刚好大于之前分配的总和,当使用1.5倍增长时,在几次扩展以后,可以重用之前的内存空间。
vector
插入元素时会根据元素与备用空间,剩余空间大小进行不同的插入方式,如下如所示:
💗 10.4.2 list
list
是双向循环链表,相比于vector
的连续线性空间,list
每次插入或删除一个元素,就配置或释放一个元素空间。
● list
的数据结构
由于list是一个双向循环链表,因此,在数据结构上,只需要一个尾端空白指针就能表现整个链表:
● list
的基本性质
① 由于list
节点不保证在存储空间中连续存在,因此list
提供Bidirectional Iterators
,使list
的迭代器有能力进行递增,递减,取值,成员存取等操作。
② list
的插入操作insert
和接合操作splice
都不会造成list
迭代器的失效。
③ 通过find()
查找元素的迭代器时,哨兵迭代器位于所查找元素的前方。
10.5 SGI STL - 关联容器
关联容器中的每个元素都有一个键值(key)和一个实值(value),包括set和map两类容器,关联式容器内部结构是一个BST。
1. set
set的特性如下:
① set底层以RB-tree为数据结构;
②
set
元素的键值就是实值,即
key=value
,所以
set
不允许两个元素有相同的键值,可以用来进行数据去重。
③
set
中所有都会根据元素的键值自动排序,且默认采用递增排序,不允许迭代器来改变set中元素的值,这样会破坏set的结构。
2. unordered_set
C++ 11中新增了
unordered_set
容器,其特性如下:
①
unordered_set
不以键值对的形式存储数据,而是直接存储数据的值 ;
②
unordered_set
不允许两个元素有相同的键值;
③
unordered_set
不会对数据进行排序。
④
unordered_set
底层以
hash table
为数据结构。
💗 10.5.2 map
1. map
map的特性如下:
①
map
底层以RB-tree为数据结构;
②
map
的所有元素都是
pair
,同时拥有实值
value
和键值
key
。
pair
的第一元素视为键值,第二元素视为实值,map不允许两个元素同时拥有相同的键值。
③
map
会根据元素的键值
key
自动排序,且默认采用递增排序,不允许迭代器来改变set中元素的值,这样会破坏map的结构。
2. unordered_map
C++ 11中新增了
unordered_map
容器,其特性如下:
①
unordered_map
底层以
hash table
为数据结构;
②
unordered_map
的所有元素都是pair,同时拥有实值value和键值key。pair的第一元素视为键值,第二元素视为实值,
unordered_map
不允许两个元素同时拥有相同的键值。
③
unordered_map
不会根据元素的键值自动排序。
10.6 SGI STL - string
string
是由模板类basic_string<class _CharT,class _traits,class _alloc>
实例化生成的一个类。其结构如下图所示:
typedef basic_string <char> string;
typedef basic_string <wchar> wstring;
💗 10.6.1 string 对象大小
根据C++对象模型,C++对象大小由非静态成员变量决定。在basic_string
中,非静态成员变量只有一个:mutable _Alloc_hider _M_dataplus
。_Alloc_hider
是继承与_Alloc
的内存空间分配器,在_Alloc_hider
中包含成员变量_M_p
,指向实际的数据。
_M_length
, _M_capacity
, _M_refcount
, 它们并不是直接作为string对象的成员,而是通过_Rep
来管理这些变量,这样string只需要保存一个_Rep
指针即可,最大限度减小了string对象的大小,减小了对象拷贝的消耗。因此,sizeof(string对象)
的大小为8。
string ss("Hello");
cout<<sizeof(ss)<<endl; //8
💗 10.6.2 string copy-on-write机制
在string中,大部分的string对象拷贝用于只读因此每次都拷贝内存是没有必要的,且消耗性能,因此引入了copy-on-write机制,将内存拷贝延迟到写操作时。
string s = "Hello World";
string s1 = s; // 读操作,不实际拷贝内存
cout << s1 << endl; // 读操作,不实际拷贝内存
s1 += "I want it."; // 写操作,拷贝内存
Q1.copy-on-write
机制的实现过程?
① 在string对象拷贝的时候浅拷贝,只复制地址指针(共享内存)。当发生写操作时,首先开辟内存空间并拷贝内存,然后对原string的引用计数减一,最后在新的内存空间中做修改。
② 当多个对象共享地址指针时,需要一个引用计数管理内存的释放时机,当引用计数减为0时释放内存。
③ 最后要满足多线程安全性。对于读操作,多线程读同一对象时是安全的。对于写操作,由于不同的string对象可能共享同一引用计数,写操作会修改该引用计数,因此引用计数必须为原子操作。
Q2.copy-on-write
机制的问题 ?
① 可能带来增加内存拷贝问题:当A和B共享一段内存时,在多线程环境下,对A,B同时进行写操作,则会出现如下执行序列:A写操作,A拷贝内存,B写操作,B拷贝内存,加上初始构造,共发生三次内存申请。如果使用string全拷贝,只会发生两次内存申请。
② 不正确的操作会导致内存拷贝:非const
的string调用operator[]
,at()
,begin()
,end()
等操作会导致内存的申请和拷贝。
string s1("test for copy");
string s2(s1);
cout << s2 << endl; // shared
cout << s2[1] << endl; // leaked,此处会重新申请并拷贝内存
③ 不正确的操作会导致数据不一致:
string s1("abc");
const string s2(s1);
char *p = const_cast<char*>(&s2[0]); // 不规范的操作
*p = 'x';
cout << "s1=" << s1 << endl; //xabc
cout << "s2=" << s2 << endl; //xabc
11. C++ 模板
11.1 template 模板
Q1.什么是模板 template ?
template
是C++支持参数化多态的工具,template
是静态动态(编译期动态)的实现方式之一。用template
可以使用户为类或者函数声明一种一般模式,使得类中的某些数据成员或者成员函数的参数、返回值取得任意类型。
💗 11.1.1 template 参数
1. 参数分类:与函数的实参和形参相同,template
参数分为形参和实参两种。
① 模板形参:
● 类型参数:表明这个模板参数是一个类型,template<typename T,...>
● 非类型参数:非类型参数的形参只能是整形,指针,引用,除此之外的类型都是不允许的,如double,float。调用非类型模板形参的实参必须是一个常量表达式,即必须能在编译时计算出结果。
template <typename T,typename K> //类型模板参数,T,K分别为一个类型
class T1{}
template <typename T,int a> //a为非类型参数,只能为整形int,指针,引用
class T2{}
...
int main(){
T1<int,int> t1; //T,K都为int类型
const int a=10;
T2<double,a> t2; //因为模板是在编译期得到结果,因此a必须是常量
T2<double,12> t3;
}
② 模板实参
编译器使用函数调用中的实参类型来推断出模板实参,然后用这些实参生成对应的函数。
2. 参数特化(具体化)与偏特化
① 参数特化:
虽然采用template
能够使参数更加通用化,但有些时候某些函数或类模板需要用于特定的结构或类,不能直接使用泛型模板展开实现,这时就需要针对某个特殊的类型或者是某一类特殊的类型实现一个模板。
struct job{
char name[40];
double salary;
};
template<class T> //函数模板实现
void swap(T &a, T &b) {
T temp;
temp = a;
a = b;
b = temp;
}
template <> void Swap<job>(job &j1, job &j2){ //特化=显式具体化-job类型
double t1;
int t2;
//交换salary
t1 = j1.salary;
j1.salary = j2.salary;
j2.salary = t1;
}
int main(void){
int a = 1, b = 2;
job zhangSan = {"张三", 80000, 6};
job liSi = {"李四", 60000, 4};
Swap(a, b); //隐式实例化
Swap<int>(a, b); //显式实例化
Swap(zhangSan, liSi); //编译器将实例化显式具体化模板job类型函数
return 0;
}
② 参数偏特化:
如果class template拥有一个以上的template
参数,可以针对其中某个或数个template
参数进行特化。
template <class T1, class T2>
struct is_template<T1*, T2>{ enum { value = 2}; }; //只特化了T1
💗 11.1.2 template 的分类
template
分为函数模板和类模板:
template <class T> void swap(T& a,T& b){} //函数模板
template <class T> class A { //类模板
public:
T a; T b;
T fun(T c, T &d);
};
程序运行时匹配模板时,遵循的优先级是:非模板函数>具体化模板>常规模板
1. 显式实例化与隐式实例化
● 显式实例化:就是清楚的表明你要实例化的类型。
● 隐式实例化:通过编译器自己推测判断要实例化的类型。
template<class T> //函数模板实现
void swap(T &a, T &b) {
T temp;
temp = a;
a = b;
b = temp;
}
int main(void){
int a= 1, b = 2;
Swap(a, b); //隐式实例化
Swap<int>(a, b); //显式实例化
return 0;
}
💗 11.1.3 template 的模板编译和链接
1. template 的编译模型
Q1. 模板编译模型与传统编译模型的区别 ?
● 传统编译模型:在传统编译模型中,C++将声明和实现分别放在两个文件中,这样可以实现分离编译,使程序以模块的方式进行组织。当调用函数时,编译器只需要看到函数的声明即可,类似地,定义类类型的对象时,类定义必须可用,但成员函数的定义不是必须存在的。因此,应该将类定义和函数声明放在头文件中,而普通函数和类成员函数的定义放在源文件中。
● 模板编译模型:在模板编译时,它不会立即产生代码,只有调用函数模板或类模板的对象时,编译器才会产生特定类型的模板实例。要进行实例化,编译器必须能够访问定义模板的源代码。当调用函数模板或类模板的成员函数的时候,编译器需要函数定义,需要那些通常放在源文件中的代码。 因此,需要将模板的定义和实现都放到头文件中。
2. template 的链接
在多文件编译过程中,可能存在重复的实例模板,则在链接过程中,链接器会检查是否存在重复定义,并将所有冗余的模板实例代码删除,保存链接后的可执行文件中所有的实例都是唯一的。
11.2 C++ 类型萃取技术
类型萃取使用模板技术(模板特化) 来“萃取”对象类型,主要在用于在编译器在编译期获取某一参数,某一变量,某一对象等任何C++相关对象的类型,判断它们是否是某个类型,两个变量是否是同一类型,是否是引用,是否是指针,是左值还是右值等属性,从而在泛型算法中来对该类型进行特殊的处理用来提高效率。
Q1.类型萃取技术主要应用场景 ?
● 用于判断一个类型是否为POD
类型
类型萃取最主要的作用是用于判断一个变量是否为POD变量还是自定义类型变量,则在变量进行拷贝时,POD变量进行浅拷贝,自定义变量进行深拷贝,从而提高效率。
● 用于参数类型的推导
// 例如:通过template进行参数推导参数类型,并获取参数的指针,但此方法只能推导参数类型,不能推导返回值类型。
template <class I, class T>
void func_impl(I iter, T t) {
T tmp; // 这里就是迭代器所指物的类型新建的对象
}
template <class I>
inline void func(I iter) {
func_impl(iter, *iter); // 传入iter和iter所指的值,class自动推导
}
int main() {
int i;
func(&i);
}
💗 11.2.1 C++ 中的类型萃取
在C++中,type_traits
提供了丰富的编译期间计算、查询、判断、转换和选择的帮助类。
#include <iostream>
#include <array>
#include <string>
#include <type_traits>
struct A {};
struct B : A {};
int main() {
// 1.类型判断 is_array(数组),is_function(函数),is_reference(引用),is_pod(POD),is_trivial(内置)
std::cout << "int: " << std::is_array<int>::value << std::endl;
std::cout << "int[3]: " << std::is_reference<int[3]>::value << std::endl;
std::cout << "string: " << std::iS_POD<std::string>::value << std::endl;
std::cout << "string[3]: " << std::is_trivial<std::string[3]>::value << std::endl;
// 2.两个模板类型之间的关系:is_same(类型是否相同),is_base_of(是否基类),is_convertible(前面模板参数类型能否转换为后面模板参数类型)
std::cout << "A, B: " << std::is_base_of<A,B>::value << std::endl;
std::cout << "A&, B&: " << std::is_base_of<A&,B&>::value << std::endl;
}