目录
1. 类对象的复制
2. 权限修饰符
3. 成员函数的定义与声明
4. 构造函数
(1)explicit关键字
(2)构造函数初始化列表
(3)默认构造函数
(4)=default;和=delete;
(5)拷贝构造函数
(6)移动构造函数
(7)继承构造函数
(8)类型转换构造函数
(8.1)类型转换函数
5. 析构函数
(1)在什么情况下有必要书写自己的析构函数呢?
(2)析构函数的成员销毁
6. 默认参数
7. 内联函数
8. 类成员函数末尾的const
9. mutable
10. static
(1)static成员变量
(2)static成员函数
11. const
12. 重载运算符
(1)拷贝赋值运算符
13. 子类
(1)函数遮蔽
(2)using关键字:子类调用父类重载函数
(3)virtual虚函数:父类指针既能够调用父类,也能够调用子类中的同名同参成员函数
(4)final关键字
(5)多态
(6)纯虚函数&抽象类
(7)继承的构造函数
(7.1)using关键字:生成子类构造函数
(8)虚基类与虚继承(虚派生)
(8.1)虚基类
(9)补充
14. 友元
(1)友元函数
(2)友元类
(3)友元成员函数
(4)友元概念的优缺点:
15. RTTI运行时类型识别
(1)dynamic_cast
(2)typeid运算符
16. 左值、右值、std::move函数
17. 临时变量
(1)相关概念
(2)产生临时对象的几种情况和解决方案
以传值的方式给函数传递参数会产生临时变量
类型转换会生成临时变量
函数返回对象的时候会产生临时变量
18. 对象移动、移动构造函数与移动赋值运算符
(1)移动对象
(2)移动构造函数
(2.1)noexcept关键字
(3)移动赋值运算符
(4)总结
1. 类对象的复制
类对象是可以复制的,复制后,每个对象都有不同的地址(每个对象的内容都保存在不同的内存中,彼此互不影响),而且成员变量的值都相等。
类对象的复制,就是定义一个新对象时,用另外一个老对象里面的内容进行初始化。在写法上,对象的复制可以使用“=” “()” “{}” “={}”等运算符进行。默认情况下,这种类对象的复制是每个成员变量逐个复制。
2. 权限修饰符
在类定义内部,private和public修饰符修饰其下面的所有成员,一直遇到其他的public或者private修饰符。因为定义class时,默认所有成员为private,所以不加public修饰的成员全部都是private的。此外,一个类的定义中可以出现多个public、多个private,这都被系统所允许。
3. 成员函数的定义与声明
(1)如果一个成员函数在class定义的内部将该成员函数完整地写出来,包括该成员函数的所有实现代码,对于这种写法的成员函数,称为“成员函数的定义”。
(2)如果一个成员函数在class定义的内部(一般位于一个.h文件中)只写出其声明,而具体的函数体代码写在了class定义的外部(一般位于一个.cpp文件中),那么,写在class内部的这部分称为“成员函数的声明”,写在class外部的这部分称为“成员函数的实现”。
(3)类的成员函数地址和类对象(类实例)没有关系,是归属于类的(有类在就有成员函数地址在),所以类的成员函数在内存中是有地址的(不要理解成必须要创建出个类对象/类实例才会有成员函数地址)。
4. 构造函数
构造函数的目的(存在的意义)就是初始化类对象的数据成员(成员变量)。
正常情况下,构造函数应该被声明为public,因为创建一个对象时系统要调用构造函数,这说明构造函数是一个public函数,能够被外界调用,因为class(类)默认的成员是private(私有)成员,所以必须说明构造函数是一个public函数,否则就无法直接创建该类的对象了(创建对象代码编译时报错)。
(1)explicit关键字
在C++中,如果一个类有一个可以接受单个参数的构造函数,那么该构造函数可以被用作隐式类型转换。这意味着你可以使用等号将一个与该参数类型相同的值赋给该类的对象。
class Teacher {
public:
int age;
Teacher(int _age) //单参构造函数
{
age = _age;
}
};
void main()
{
Teacher t1 = 28; //隐式初始化(其实是构造并初始化)
Teacher t2 = {28}; //隐式初始化(其实是构造并初始化)
Teacher t3{ 28 }; //显式初始化(也叫直接初始化)
Teacher t4 = Teacher(28); //显式初始化(也叫直接初始化)
Teacher t5(28); //显式初始化(也叫直接初始化)
}
可以使用`explicit`关键字来修饰构造函数,从而防止其被用作隐式类型转换,此时如果想要创建新对象时,需要明确地调用构造函数。(构造函数声明中带有explicit(显式),则这个构造函数只能用于初始化和显式类型转换)
总结起来说, `explicit`关键字能够防止C++编译器进行可能会导致错误或者混淆的隐式类型转换。一般来说,单参数的构造函数都声明为explicit,除非有特别的原因。
class Teacher {
public:
int age;
explicit Teacher(int _age) //带有explicit(显式),则这个构造函数只能用于初始化和显式类型转换
{
age = _age;
}
};
void main()
{
Teacher t1 = 28; //错误,不能隐式初始化
Teacher t2 = {28}; //错误,不能隐式初始化
Teacher t3{ 28 }; //显式初始化(也叫直接初始化)
Teacher t4 = Teacher(28); //显式初始化(也叫直接初始化)
Teacher t5(28); //显式初始化(也叫直接初始化)
}
(2)构造函数初始化列表
构造函数初始化列表在调用构造函数的同时,可以初始化成员变量的值,注意这种写法为冒号括号逗号式写法,位于构造函数定义(实现)中。注意,这种写法只能用在构造函数中。初始化列表的执行是在函数体执行之前就执行了的。
Teacher(int _age) :age(_age) //初始化列表 { //函数体 }
这个构造函数做的事其实可以看成两部分:函数体之前和函数体之中。
根据上面的代码,成员age的初始化是在函数体开始执行之前进行的(初始化列表先执行),如上面的age(_age)。然后再执行函数体(也就是{}包着的部分),如果在函数体中给成员变量值,那就成了赋值而不是成员变量初始化了。
提倡优先考虑使用构造函数初始化列表,原因如下:
(1)构造函数初始化列表写法显得更专业,有人会通过此来鉴别程序员的水平。
(2)一种写法叫作初始化,一种写法叫作赋值,叫法不同。对于内置类型如int类型的成员变量,使用构造函数初始化列表来初始化和使用赋值语句来初始化其实差别并不大。但是,对于类类型的成员变量,使用初始化列表的方式初始化比使用赋值语句初始化效率更高(因为少调用了一次甚至几次该成员变量相关类的各种特殊成员函数,如构造函数等)。
class Teacher {
public:
int age;
Teacher(int _age) //函数体内赋值
{
age = _age;
}
};
class Teacher {
public:
int age;
Teacher(int _age) :age(_age) {} //构造函数初始化列表
};
(3)默认构造函数
如果一个类没有自己的构造函数,编译器可能会生成一个“合成的默认构造函数”,也可能不生成一个“合成的默认构造函数”,生成与否取决于具体需要。但不管如何,生成该类的对象都会成功。
假设编译器因为需要,原本是能够生成一个“合成的默认构造函数”。但是,如果程序员自己写了一个构造函数,不管这个构造函数带几个参数,编译器就不会创建合成的默认的构造函数了。
(4)=default;和=delete;
在C++11中,引入了两种新的写法“=default;”和“=delete;”。
一般这种“=default”写法只适合一些比较特殊的函数,如默认构造函数(不带参数),普通成员函数、带参数的构造函数都不能这样写。
class Teacher {
public:
int age;
Teacher() = default;
};
“=delete;”这个写法是用来让程序员显式地禁用某个函数而引入的。比如如果想禁用编译器生成这个“合成的默认构造函数”,这就需要用到“=delete;”了。
class Teacher {
public:
int age;
Teacher() = delete;
}
void main()
{
Teacher* t1 = new Teacher();//会报错:无法引用 "Teacher" 的默认构造函数 -- 它是已删除的函数
}
(5)拷贝构造函数
如果一个类的构造函数的第一个参数是所属的类类型引用,若有额外的参数,那么这些额外的参数都有默认值。该构造函数的默认参数必须放在函数声明中,除非该构造函数没有函数声明,那么这个构造函数就叫拷贝构造函数。这个拷贝构造函数会在一定的时机被系统自动调用。
1. 一般来讲,拷贝构造函数的第一个参数都是带const修饰的;
2. 拷贝构造函数,一般都不声明为explicit。(单参数的构造函数,一般声明为explicit,以防止出现代码模糊不清的问题)
3. 如果一个类没有自己的拷贝构造函数,编译器可能会合成一个“拷贝构造函数”,也可能不会合成一个“拷贝构造函数”,是否合成取决于具体需要。
4. 调用拷贝构造函数的情形:
- 将一个对象作为实参传递给一个非引用类型的形参(因为要进行复制构造,因此这种写法效率低,不提倡使用)。
- 从一个函数中返回一个对象。
- 使用一个对象初始化另一个对象时,会调用拷贝构造函数。
(6)移动构造函数
见下文。
(7)继承构造函数
见下文。
(8)类型转换构造函数
有一种构造函数被称为“类型转换构造函数”,这种构造函数主要是可以将某个其他的数据类型数据(对象)转换成该类类型的对象。类型转换构造函数有如下特点:
- 该构造函数只有一个形参,该形参又不是本类的const引用(不然就成拷贝构造函数了)。其实,形参是待转换的数据类型(就是把哪种其他类型数据转换成该类类型对象),所以显然待转换的数据类型都不应该是本类类型。
- 在类型转换构造函数中,需要指定转换的办法。
- 可以增加explicit关键字,这表示禁止进行隐式类型转换。
- 可以把这种构造函数看作普通的构造函数,因为它就是带了一个形参的构造函数,长相不太出奇,但是要把其他类型数据转换成类类型对象,就会调用这个构造函数来实现。
class Complex {
public:
double real;
double imag;
// 类型转换构造函数
explicit Complex(double r) {
real = r;
imag = 0.0;
}
};
void main{
double num = 3.14;
Complex c1(num); //调用类型转换构造函数
Complex cc = 10; //隐式类型转换,调用类型转换构造函数(当前有explicit,所以这行错误)
}
(8.1)类型转换函数
类型转换运算符也有人叫它类型转换函数,因为它看起来是一个成员函数,所以这两种叫法都可以。
类型转换函数和类型转换构造函数的能力正好相反:类型转换运算符是类的一种特殊成员函数,它能将一个本类类型对象转成某个其他类型数据。这种成员函数的一般形式为:
operator type() const;//其中 type 是要转换的目标类型。
- 末尾的const是可选的项,表示不应该改变待转换对象的内容,但不是必须有const。
- “type”表示要转换成的某种类型,一般只要是能作为函数返回类型的类型都可以。所以一般不可以转成数组类型或者函数类型(把一个函数声明去掉函数名剩余的部分就是函数类型,如void(inta,intb)),但是转换成数组指针、函数指针、引用等都是可以的。
- 类型转换运算符,没有形参(形参列表必须为空),因为类型转换运算符是隐式执行的,所以无法给这些函数传递参数。同时,也不能指定返回类型,但是却会返回一个对应类型(“类型名”所指定的类型)的值。
- 必须定义为类的成员函数。
class MyClass {
public:
operator int() const {
return some_value;
}
private:
int some_value;
};
上述代码是将 MyClass 类型对象转换为 int 类型。
5. 析构函数
如果不写自己的析构函数,编译器可能会生成一个“默认析构函数”,也可能不会生成一个“默认析构函数”,是否生成取决于具体需要。
析构函数也是类中的一个成员函数,它的名字是由波浪线连接类名构成,必须被public修饰,没有返回值,不接受任何参数,不能被重载,所以一个给定的类,只有唯一一个析构函数。
(1)在什么情况下有必要书写自己的析构函数呢?
例如,在构造函数里如果new了一段内存,那么,一般来讲,就应该写自己的析构函数。在析构函数里,要把这段new出来的内存释放(delete)掉,只有手工delete的时候,类的析构函数才会被系统调用。请注意,即便编译器会生成“默认析构函数”,也绝不会在这个“默认析构函数”里释放程序员自己new出来的内存,所以,如果不自己写析构函数释放new出来的内存,那就会造成内存泄漏。
(2)析构函数的成员销毁
析构函数做的事情,其实也可以看成两部分:函数体之中和函数体之后。当释放一个对象的时候,首先执行该对象所属类的析构函数的函数体,执行完毕后,该对象就被销毁,此时对象中的各种成员变量也会被销毁。所以,对象中的成员变量不是在析构函数的函数体里面销毁的,而是函数体执行完成后由系统隐含销毁的。
成员变量初始化的时候是在类中先定义的成员变量先进行初始化,销毁的时候是先定义的成员变量后销毁。
6. 默认参数
任何函数都可以有默认参数,对于传统函数,默认参数一般放在函数声明中而不放在函数定义(实现)中,除非该函数没有声明只有定义。对于类中的成员函数,默认参数写在类的成员函数声明而非实现(注意称谓:成员函数的实现等价于传统函数的函数定义)中,也就是一般会写在.h头文件中。
7. 内联函数
直接在类的定义中实现的成员函数会被当作inline内联函数来处理。
系统将尝试用函数体内的代码直接取代函数调用代码,以提高程序运行效率。但还是老话:内联函数只是对编译器的建议,能不能inline成功,依旧取决于编译器,所以,成员函数的定义体尽量写得简单,以增加被inline成功的概率。
8. 类成员函数末尾的const
在成员函数的末尾增加一个const时,请注意,对于成员函数的声明和实现代码分开的情形下,不但要在成员函数的声明中增加const,也要在成员函数的实现中增加const。
那么这个成员函数末尾的const起什么作用呢?告诉系统,这个成员函数不会修改该对象里面的任何成员变量的值等,也就是说,这个成员函数不会修改类对象的任何状态。这种在末尾缀了一个const的成员函数也称为“常量成员函数”。
普通函数(非成员函数)末尾是不能加const的。编译都无法通过,因为const在函数末尾的意思是“成员函数不会修改该对象里面任何成员变量值”,普通函数没有对象这个概念,所以自然不能把const放在普通函数末尾。
9. mutable
在末尾有const修饰的成员函数中,是不允许修改成员变量值的。那在设计类成员变量的时候,假如确实遇到了需要在const结尾的成员函数中希望修改成员变量值的需求,怎么办呢?(也许有人会说,那就把函数末尾的const去掉,变成一个不以const结尾的成员函数。那这个时候可能面临上面曾提到过的另外一个问题——如果这个成员函数从const变成非const了,那么就不能被const对象调用了。)
所以,引入了mutable修饰符(关键字)来修饰一个成员变量。一个成员变量一旦被mutable所修饰,就表示这个成员变量永远处于可变状态,即使是在以const结尾的成员函数中。
10. static
(1)static成员变量
有没有这样一种成员变量,不属于某个对象的,而是属于整个类的(跟着类走)?有。这种成员变量就叫static成员变量(静态成员变量),其特点是:不属于某个对象,而是属于整个类,这种成员变量可以通过对象名来修改(也可以通过类名来修改),但一旦通过该对象名修改了这个成员变量的值,则在其他该类对象中也能够直接看到修改后的结果。
1. C语言:
在函数体、代码块内部:被static声明的变量只初始化一次,然后在整个程序生命周期内保持其值。
static在全局变量或函数前:限制了其访问范围,只能在当前文件(编译单元)中访问。
2. C++:
在类中:静态成员(包括静态成员变量和静态成员函数)属于类本身,而不是类的实例。所有类的实例共享同一个静态成员。
非类中使用与C语言相同。
静态成员变量和普通成员变量不同,普通成员变量在定义一个类对象时,就已经被分配内存了。
一般会在某一个.cpp源文件的开头来定义这个静态成员变量,这样能够保证在调用任何函数之前这个静态成员变量已经被成功初始化,从而保证这个静态成员变量能够被正常使用。
(2)static成员函数
静态成员函数实现时就不需要在前面加static关键字了。
11. const
(1)对于类的const成员,只能使用初始化列表来初始化,而不能在构造函数内部进行赋值操作。
(2)构造函数要进行很多看得见和看不见的写值操作,所以构造函数不能声明成const。
class A {
int num1; //默认为private
public:
int num2;
const int num3;
A(int a, int b, int c) :num1(a), num2(b), num3(c) { //对于类的const成员,只能使用初始化列表来初始化,而不能在构造函数内部进行赋值操作。
//num3 = c; //错误
}
A(int a, int b) :num1(a), num2(b), num3(18) {
}
};
12. 拷贝赋值运算符
可以自己进行赋值运算符的重载(即拷贝赋值运算符),如果不自己重载这个运算符,编译器会用默认的对象赋值规则为对象赋值,甚至在必要的情况下帮助我们重载赋值运算符(如类A没有重载赋值运算符,但类A嵌套的类B中有重载的赋值运算符,在进行对象赋值时,编译器就会在类A中重载“赋值运算符”并在其中插入代码来调用类B中重载的赋值运算符中的代码。)。
拷贝赋值运算符的目的是将右侧对象的值拷贝给左侧对象,而不是创建一个新的对象。因此,operator=运算符的返回值通常是一个指向其左侧运算符对象的引用。
class Teacher {
public:
int age;
Teacher() {
std::cout << "无参构造函数" << std::endl;
}
Teacher(int _age) :age(_age) {
std::cout << "含参构造函数" << std::endl;
}
Teacher(const Teacher& t) //拷贝构造函数
{
std::cout << "拷贝构造函数" << std::endl;
}
Teacher& operator= (const Teacher& t)
{
age = t.age;
std::cout << "重载赋值运算符" << std::endl;
return *this;
}
}
void main()
{
Teacher t1 = Teacher(10); //含参构造函数
Teacher t2; //无参构造函数
t2 = t1; //重载赋值运算符,注意这是个赋值运算符,并不调用拷贝构造函数
Teacher t3 = t1; //拷贝构造函数
}
13. 子类
定义子类的一般形式为:
class 子类名:继承方式 父类名
继承方式(访问等级/访问权限):public、protected、private之一
C++支持多继承;继承时不提供访问修饰符,则默认继承方式为private。
C#不支持多继承,但支持多实现。
(1)函数遮蔽
正常情况下,父类中的成员函数只要是用pubic或者protected修饰的,子类只要不采用private继承方式来继承父类,那么子类中都可以调用。
但是,在C++的类继承中,子类会遮蔽父类中的同名函数,不论此函数的返回值、参数。也就是说,父类和子类中的函数只要名字相同,子类中的函数就会遮蔽掉父类中的同名函数。只要子类中有一个和父类同名的成员函数,那么,通过子类对象,完全无法调用(访问)父类中的同名函数。
class Human {
public:
int age;
void Print() {
std::cout << "print Human" << std::endl;
}
void Print(int i) {
age = i;
std::cout << "print Human age:" << age << std::endl;
}
};
class Men :public Human {
public:
void Print() {
std::cout << "print Men" << std::endl;
}
void PrintAge() {
std::cout << age << std::endl;
}
};
void Test()
{
Human *human1 = new Human();
Human *human2 = new Men(); //父类指针可以new一个子类对象
Men *men = new Men();
human1->Print(); //print Human
human2->Print(); //print Human 注意:此时父类指针没有办法调用子类的成员函数
men->Print(); //print Men
men->Print(1); //错误
}
(2)using关键字:子类调用父类重载函数
在C++11中,可以通过using这个关键字让父类同名函数在子类中可见。换句话说就是“让父类的同名函数在子类中以重载方式使用”。
- 这种using声明只能指定函数名,不能带形参列表,并且父类中的这些函数必须都是public或者protected(只能在子类成员函数中调用),不能有private的,否则编译会出错。换句话说,是让所有父类的同名函数在子类中都可见,而无法只让一部分父类中的同名函数在子类中可见。
- using声明这种方法引入的主要目的是实现可以在子类实例中调用到父类的重载版本的函数。再回忆一下重载函数的概念:重载函数就是函数名字相同,但函数的参数类型或者参数个数并不相同。
- 如果子类中的成员函数和父类中的同名成员函数参数个数、参数类型完全相同,那么仍然是无法调用到父类中的该函数的。
class Human {
public:
int age;
void Print() {
std::cout << "print Human" << std::endl;
}
void Print(int i) {
age = i;
std::cout << "print Human age:" << age << std::endl;
}
};
class Men :public Human {
public:
using Human::Print;
void Print() {
std::cout << "print Men" << std::endl;
}
void PrintAge() {
std::cout << age << std::endl;
}
};
void Test()
{
Human *human1 = new Human();
Human *human2 = new Men(); //父类指针可以new一个子类对象
Men *men = new Men();
human1->Print(); //print Human
human2->Print(); //print Human 注意:此时父类指针没有办法调用子类的成员函数
men->Print(); //print Men 注意:符合上述最后一点
men->Print(1); //print Human age:1 可调用父类中的重载函数
}
(3)virtual虚函数:父类指针既能够调用父类,也能够调用子类中的同名同参成员函数
父类指针很强大,不仅可以指向父类对象,也可以指向子类对象(父类指针可以new一个子类对象),但是注意,此时父类指针没有办法调用子类的成员函数。(上述例子可以说明)
想通过一个父类指针,既能够调用父类,也能够调用子类中的同名同参成员函数,这是可以做到的。但是对这个同名同参的成员函数有要求:在父类中,这个成员函数的声明的开头必须要增加virtual关键字声明,将该成员函数声明为虚函数。
- 在子类中,该函数声明前是否增加virtual没有强制要求,但建议加上,不加也可以。
- 子类的虚函数的形参要和父类的完全一致。否则会被认为是和父类中的虚函数完全不同的两个函数了。
-
为了避免在子类中写错虚函数,在C++11中,可以在子类函数声明所在行的末尾增加一个override关键字。(注意,这个关键字是用在子类中,而且是虚函数专用的)。override这个关键字主要就是用来说明派生类中的虚函数,用了这个关键字之后,编译器就会认为这个子类的虚函数是覆盖了父类中的同名的虚成员函数(virtual)的,那么编译器就会在父类中找同名同参的虚成员函数,如果没找到,编译器就会报错。
-
如果不是用父类类型指针,而是用普通对象来调用虚函数,那虚函数的作用就体现不出来了。通过父类的指针,只有到了程序运行时期,根据具体执行到的代码行,才能找到动态绑定(所谓动态,表示的就是在程序运行的时候(运行到调用虚函数这行代码时)才能知道调用了哪个子类的虚函数)到父类指针上的对象(new的是哪个具体的对象)。
-
父类的析构函数一般写成虚函数。
如果父类的析构函数不是虚函数,用父类指针new一个子类对象,在delete的时候系统不会调用子类的析构函数。只有子类的析构函数也被调用,子类的这个对象才算完整地删除。
delete用new创建的对象(父类指针指向的子类对象),只要父类析构函数被声明为虚函数,就可以正常调用子类析构函数。
所以给出如下结论,请牢记:
(1)如果一个类想要做父类,务必要把这个类的析构函数写成virtual析构函数。只要父类的析构函数是virtual(虚)函数,就能够保证delete父类指针时能够调用正确的析构函数。
(2)普通的类可以不写析构函数,但如果是一个父类(有孩子的类),则必须要写一个析构函数,并且这个析构函数必须是一个虚析构函数(否则肯定会出现内存泄漏)。
(3)虚函数(虚析构函数也是虚函数的一种)会增加内存和执行效率上的开销,类里面定义虚函数,编译器就会给这个类增加虚函数表,在这个表里存放虚函数地址等信息。
(4)读者将来在寻找C++开发工作时,遇到面试官考核诸如“为什么父类(基类)的析构函数一定要写成虚函数”的问题时,一定要慎重回答,简而言之的答案就是:唯有这样,当delete一个指向子类对象的父类指针时,才能保证系统能够依次调用子类的析构函数和父类的析构函数,从而保证对象(父指针指向的子对象)内存被正确地释放。
class Human {
public:
int age;
virtual void Print() {
std::cout << "print Human" << std::endl;
}
void Print(int i) {
age = i;
std::cout << "print Human age:" << age << std::endl;
}
};
class Men :public Human {
public:
using Human::Print;
virtual void Print() override {
std::cout << "print Men" << std::endl;
}
void PrintAge() {
std::cout << age << std::endl;
}
};
void Test()
{
Human *human1 = new Human();
Human *human2 = new Men(); //父类指针可以new一个子类对象
Men *men = new Men();
human1->Print(); //print Human
human2->Print(); //print Men 注意:此时父类指针可以调用子类同名同参函数
men->Print(); //print Men
men->Print(1); //print Human age:1
}
(4)final关键字
与override关键字相对的还有一个final关键字,final关键字用于虚函数和父类中的。如果在函数声明的末尾增加final,那么任何在子类中尝试覆盖该成员函数的操作都将引发错误(一般在多层继承中)。
(5)多态
多态性只是针对虚函数说的。非虚函数,不存在多态的说法。
(6)纯虚函数&抽象类
纯虚函数是在父类中声明的虚函数,它在父类中没有函数体(或者说没有实现,只有一个声明),要求任何子类都要定义该虚函数自己的实现方法,父类中实现纯虚函数的方法是在函数原型后面加“=0”,或者可以说成是在该虚函数的函数声明末尾的分号之前增加“=0”。
一个类中一旦有了纯虚函数,那么就不能生成这个类的对象了。
class Human {
public:
virtual void Print() = 0;
};
void Test()
{
Human* human1 = new Human();//错误
}
这种带有纯虚函数的类(Human)就叫抽象类。抽象类不能用来生成对象,主要目的是统一管理子类(或者说建立一些供子类参照的标准或规范)。
- 含有纯虚函数的类叫抽象类。抽象类不能用来生成对象,主要当作父类用来生成子类。
- 子类中必须要实现父类(抽象类)中定义的纯虚函数(否则就没法用该子类创建对象——创建对象就会编译错误)
(7)继承的构造函数
一个类只继承其直接基类(父类)的构造函数(不能继承间接基类如爷爷类的构造函数)。
(7.1)using关键字:生成子类构造函数
在上文函数遮蔽时就是用这个关键字使父类中的重载函数在子类中可见。所以,using的功能就是让某个名字在当前作用域内可见。
当using作用于父类的构造函数时,编译器碰到这条using语句就会产生代码:编译器会把父类的每个构造函数都生成一个与之对应的子类构造函数,也就是说,父类中的每一个构造函数,编译器都在子类中生成一个形参列表相同的构造函数。
如果父类的构造函数有默认参数,那么编译器遇到这种using A::A;代码的时候,就会在子类B中构造出多个构造函数:
- 第一个构造函数是带所有参数的构造函数。
- 其余的构造函数,每个分别省略掉一个默认参数。
class Human {
public:
int age;
Human()
{
}
Human(int _age, int value = 10) :age(_age)
{
}
};
class Men :public Human {
public:
using Human::Human;
};
上述代码中,Men的构造函数有五个重载:无参、单参、双参、拷贝、移动构造函数。
(8)虚基类与虚继承(虚派生)
派生列表中,同一个基类只能出现一次。但如下两种情况是例外的:
- 派生类可以通过它的两个直接基类分别继承同一个间接基类。(菱形继承)
- 直接继承某个基类,然后通过另一个基类间接继承该类。
```cpp
class A { int a; };
class B : public A {};
class C : public A {};
class D : public B, public C {}; // D 从 B 和 C 继承了两份 A 的数据成员
```
在上面的代码中,D 类从 B 和 C 类那里分别继承了一份 A 的数据成员。这意味着在 D 对象中存在两个 `a` 数据成员,这显然是不我们希望看到的。
为了解决这个问题,C++引入了虚基类(virtual base class)机制。当一个类作为虚基类被继承时,在任何情况下都只有一份该类型数据成员存在。
(8.1)虚基类
虚基类是C++中的一个概念,主要用于解决多继承时的菱形继承问题。在多继承环境下,如果两个或更多的基类拥有相同的基类,则会出现重复继承的问题,这就是所谓的菱形继承。修改上面代码如下:
```cpp
class A { int a; };
class B : virtual public A {};
class C : virtual public A {};
class D : public B, public C {}; // OK: D 从 B 和 C 只接收一份包含A 的数据成员
```
通过将B和C对A进行虚拟(virtual)公共(public)派生,在D对象中就只存在一份A类型数据成员。
- 类B和类C从A类的虚继承,只影响到从B、C这些类中进一步派生出来的类,如类D,而对B、C本身没有什么影响。
- 类B和类C都得从A虚继承,一个都不能少。如果只有某一个从A虚继承(如类B或者类C),另外一个没有虚继承,则类D还是摆脱不了产生两份A类子内容的命运。扩展开来说,就是所有从A而来的派生类都要虚继承A类。
因为虚基类(类A)在派生类(类D)中只有一份子内容了,所以这份子内容由虚基类A的直接子类B和C谁来初始化呢?难以抉择,所以系统规定,干脆这个虚基类A的初始化工作也必须由这个派生类D来做。也就是说,虚基类A是由最底层的派生类来初始化。
一般而言,基类的构造顺序跟派生类定义时列表(派生列表)中基类的出现顺序保持一致而与派生类构造函数初始化列表中基类的初始化顺序无关。
但是含有虚基类时有点不一样:在多重继承中,如果一个类同时继承了多个基类,而这些基类又有共同的虚基类,那么这个虚基类就成为了虚基类子部分。虚基类子部分会被最先初始化(不管这个虚基类在继承体系中是什么位置、什么次序等),然后再按派生列表中基类的出现顺序来初始化其他类。(如上述代码中A先构造,然后B,然后C)
class A
{
public:
int a;
A(int a):a(a)
{
std::cout << "A" << std::endl;
}
};
class B:virtual public A
{
public:
B(int a):A(a)
{
std::cout << "B" << std::endl;
}
};
class C:virtual public A
{
public:
C(int a) :A(a)
{
std::cout << "C" << std::endl;
}
};
class D:public B,public C //派生列表
{
public:
D(int a):C(a),B(a),A(a) //构造初始化列表
{
std::cout << "D" << std::endl;
}
};
一旦A成为虚基类之后,A类的初始化工作就不会再由它的直接子类B、C来初始化了。也就是说,在类B和类C中看到的上述的构造函数初始化列表中的代码行不会再去调用A类的构造函数,所以A类的初始化不会被多次进行。A类的初始化工作只会由派生类D来进行。所以,最终A类构造函数还是只会被执行一次。
(9)补充
- 为什么基类指针可以new一个派生类对象呢(或者说基类引用可以指向/引用一个派生类对象)?因为派生类对象含有基类部分,所以可以把派生类对象当成基类对象来使用,换句话说就是可以用基类指针new一个派生类对象。
- 构造一个派生类对象时,基类的构造函数会被调用,派生类的构造函数也会被调用,这说明一个问题:虽然在派生类中含有从基类继承而来的成员变量、成员函数,但是,派生类并不能直接初始化这些成员,派生类实际上是使用基类的构造函数来初始化它的基类部分。
- 如果构造派生类对象时,基类的构造函数需要参数,怎样通过派生类把基类构造函数的参数传递给基类构造函数呢?通过派生类构造函数的初始化列表可以达到此目的。
- 用派生类对象为一个基类对象初始化或者赋值时,只有该派生类对象的基类部分会被复制或者赋值,派生类部分将被忽略掉。
Human* human0 = new Men();
Human& human1 = *human0;
- 静态类型:就是变量声明时的类型,静态类型编译的时候就是已知的,如上面代码中的human0、human1,它们的静态类型就是Human类型指针和Human类型引用。
- 动态类型:就是这个指针或者引用所代表的(所表达的)内存中的对象的类型,human0的动态类型是Men类型指针,而human1的动态类型是Men类型引用。显然,动态类型只有在运行的时候(执行到这行代码的时候)才能知道。所以,只有基类指针或者引用才存在这种静态类型和动态类型不一致的情况。如果不是基类的指针或者引用,那么静态类型和动态类型永远都应该是一致的。
14. 友元
(1)友元函数
友元函数本身是一个函数,通过将其声明为某个类的友元函数,它就能够访问这个类的所有成员,包括任何用private、public、protected修饰的成员。
友元函数声明代码不受public、protected、private的限制,只有类成员的声明或者定义才需要public、protected、private来修饰。
class _A {
friend void PrintAge(_A A);
private:
int age;
};
void PrintAge(_A A)
{
std::cout << A.age << std::endl;
}
(2)友元类
如果类B是类A的友元类,那么B就可以在B的成员函数中访问类A的所有成员(成员变量、成员函数),而不管这些成员是用什么修饰符(private、protected、public)来修饰的。
class _A {
private:
friend class _B; //B是A的友元类,B可访问A的所有成员
int age;
};
class _B {
private:
void PrintAge(_A& A)
{
std::cout << A.age << std::endl;
}
};
每个类都负责控制自己的友元类和友元函数,所以,有一些注意点要说明:
- 友元关系是不能被子类继承的。
- 友元关系是单向的,例如上面类B是类A的友元类,但这并不表示类A是类B的友元类。
- 友元关系也没有传递性,例如类B是类A的友元类,类C是类B的友元类,这并不代表类C是类A的友元类。友元类关系的判断,最终还是要看类定义中有没有对应的friend类声明。
(3)友元成员函数
上述友元类的方式,有点显得太霸道,范围太广泛(影响太广)。),因为这样做,类B(类A的友元类)的所有成员函数都可以访问类A的私有成员变量。
现在,不让整个类B成为类A的友元类,而是只让类B中的某些成员函数成为类A的友元函数:
//__A.h
#pragma once
#include "__B.h"
class __A
{
private:
friend void __B::PrintAge(__A& A); //B的PrintAge是A的友元成员函数,PrintAge可访问A的所有成员
int age = 10;
};
//__B.h
#pragma once
#include <iostream>
class __A;
class __B
{
public:
void PrintAge(__A& A);
};
//__B.cpp
#include "__B.h"
#include "__A.h"
void __B::PrintAge(__A & A)
{
std::cout << A.age << std::endl;
}
(4)友元概念的优缺点:
优点:允许在特定情况下某些非成员函数访问类的protected或者private成员,从而提出“友元”概念,使访问protected和private成员成为可能。
缺点:破坏了类的封装性(例如本来private修饰的成员用意就是不允许外界访问),降低了类的可靠性和可维护性。
15. RTTI运行时类型识别
RTTI(RunTime Type Identification),翻译成中文的意思是“运行时类型识别”。也就是通过运行时类型识别,程序能够使用父类(基类)的指针或引用来检查这些指针或引用所指的对象的实际子(派生)类型。
RTTI可以看作系统提供出来的一种功能,或者说是一种能力。这种功能或者能力通过两个运算符来实现。
- dynamic_cast运算符:能将父类的指针或者引用安全地转换为子类的指针或者引用。
- typeid运算符:返回指针或者引用所指对象的实际类型。
值得注意的是:上述两个运算符要能够正常的如所期望的那样工作,父类中至少要有一个虚函数,不然这两个运算符工作的结果很可能与预期的不一样。因为只有虚函数的存在,这两个运算符才会使用指针或者引用所指对象的类型(new时的类型)。
(1)dynamic_cast
对于指针转换:假如开发中使用的是别人写的库,传递过来一个指针,想区分这个指针是父类类型还是子类类型,使用dynamic_cast运算符就能够判断出来——用dynamic_cast能转换成功,就说明这个指针实际上是要转换到的那个类型。所以dynamic_cast运算符是帮助开发者做安全检查。判断是否转换成功,可判断指针是否为nullptr。
对于引用这种情况,如果转换失败,程序会抛出一个std::bad_cast异常,这个异常在标准库头文件里是有定义的。
class Human {
public:
int age;
virtual void Print() {
std::cout << "print Human" << std::endl;
}
};
class Men :public Human {
public:
virtual void Print() override {
std::cout << "print Men" << std::endl;
}
void PrintAge() {
std::cout << age << std::endl;
}
};
//如果有虚函数,可以将子类转为父类后,再使用dynamic_cast将父类转为子类
void TestDynamicCast()
{
//测试1
Human *human_ = new Men();
Men *men_ = dynamic_cast<Men*>(human_);
if (men_ != nullptr)
{
men_->PrintAge();
}
//测试2
Men men;
men.age = 22;
Human *human = &men;
// Men *men1 = human;// 不可以通过静态类型转
Men *men1 = dynamic_cast<Men*> (human); //如果Human没有虚函数,会报错:“dynamic_cast”:“Human”不是多态类型
if (men1 != nullptr)
{
men1->PrintAge();
}
}
(2)typeid运算符
typeid运算符有两种形式:
- typeid(类型)。
- typeid(表达式)。通过这个运算符,可以获取到对象的类型信息。这个运算符会返回一个常量对象的引用。这个常量对象的类型一般是标准库类型type_info,其实type_info就是一个类(类类型)。
【type_info类】
typeid运算符会返回一个常量对象的引用,这个对象的类型一般是标准库类型type_info,这其实是一个类。
- 成员函数name():用于获取类型名字信息
一般来讲,使用typeid运算符其实是为了比较两个指针是否指向同一种类型。
- 只要两个指针定义时的类型(静态类型)相同(都是Human *),不管它们指向的是父类还是子类实例(不管new的是什么对象),typeid就相等。
- 只要两个指针运行时指向的类型相同(new的对象类型相同),typeid就相等,不管它们定义时的类型是否相同。
void TestTypeId()
{
Human *human0 = new Men();
Human *human1 = new Men();
Human *human2 = new Women();
//静态对象测试
if (typeid(human1) == typeid(human2))
{
std::cout << "相等" << std::endl; //相等,都是Human *
}
std::cout << typeid(human1).name() << "," << typeid(human2).name() << std::endl; //注意括号里没有解引用。静态对象测试,都是Human *
//动态对象测试
if (typeid(*human0) == typeid(*human1))
{
std::cout << "相等" << std::endl; //相等,都是Men
}
std::cout << typeid(*human0).name() << "," << typeid(*human1).name() << std::endl; //动态对象测试,都是men,一个women
std::cout << typeid(*human1).name() << "," << typeid(*human2).name() << std::endl; //动态对象测试,一个men,一个women
delete human1;
delete human2;
}
--------结果:
相等
class Human *,class Human *
相等
class Men,class Men
class Men,class Women
16. 左值、右值、std::move函数
可参考前面的这个博文。
一般来讲,左值是一个持久的值,右值是一个短暂的值。为什么说右值短暂呢?因为右值要么就是字面值常量,要么就是一个表达式求值过程中创建的临时对象,这个临时对象的特性:(1)所引用的对象将要被销毁。(2)该对象没有其他用户。所以,右值引用能自由地接管所引用的对象资源。
有两个特殊的类成员函数叫作移动构造函数和移动赋值运算符,外观看起来与拷贝构造函数和复制赋值运算符非常像,只不过移动构造函数和移动赋值运算符需要的参数类型是“&&”这种右值引用类型,而拷贝构造函数和拷贝赋值运算符需要的参数类型是“&”这种左值引用类型。
std::move是一个C++11标准库里的新函数,因为move这个名字比较容易和其他函数名重名,所以使用的时候,往往都把前面的std::带上,而不是因为使用了using namespacestd就把前面的std::省略了。
这个move函数就是把一个左值强制转换成一个右值。
17. 临时变量
(1)相关概念
1. ++i是直接给i变量加1,然后返回i本身,因为i是变量,所以可以被赋值,因此是左值表达式。
int i = 5;
++i = 10; //i为10
2. i++先产生一个临时变量来保存i的值用于使用目的,再给i加1,接着返回临时变量,之后系统再释放这个临时变量,临时变量被释放掉了,不能再被赋值,因此是右值表达式。
3. 临时对象会额外消耗系统资源,所以编写代码的原则就是产生的临时对象越少越好。
4. C++语言只会为const引用(如成员函数中的形参const string& strsource)产生临时对象,而不会为非const引用(如string& strsource)产生临时对象。
5. 临时对象就是一种右值。
(2)产生临时对象的几种情况和解决方案
-
以传值的方式给函数传递参数会产生临时变量
解决方案:以引用形式传值。
-
类型转换会生成临时变量
解决方案:定义时初始化代替赋值操作。
class Teacher {
public:
int age;
Teacher() {
std::cout << "无参构造函数" << std::endl;
}
Teacher(int _age) :age(_age) {
std::cout << "含参构造函数" << std::endl;
}
Teacher(const Teacher& t) //拷贝构造函数
{
std::cout << "拷贝构造函数" << std::endl;
}
Teacher& operator= (const Teacher& t)
{
age = t.age;
std::cout << "重载赋值运算符" << std::endl;
return *this;
}
~Teacher() {
std::cout << "析构函数" << std::endl;
}
};
类型转换会生成临时变量:
void main()
{
Teacher t1;
t1 = 10; //赋值运算符
}
运行结果:
无参构造函数
含参构造函数
重载赋值运算符
析构函数
析构函数
“t1=10;”这行代码系统做了以下几件事:
- 用10这个数字创建了一个类型为Teacher的临时对象。
- 调用拷贝赋值运算符(重载赋值运算符)把这个临时对象里面的各个成员值赋给了t1对象。
- 销毁这个刚刚创建的Teacher临时对象。
解决方案:
void main()
{
Teacher t1 = 10; // 定义时初始化
}
运行结果:
含参构造函数
析构函数
定义了t1对象,系统就为t1对象创建了预留空间,然后用10调用构造函数来构造临时对象的时候,这种构造是在为t1对象创建的预留空间里进行的,所以并没有真的产生临时对象。
-
函数返回对象的时候会产生临时变量
解决方案:return时直接调用构造函数生成假的临时变量,当外部有变量用来接收函数返回的值时,则不会立即调用这个对象(假临时对象)的析构函数,否则会立即调用这个对象(假临时对象)的析构函数。
Teacher GetTeacher(int age)
{
Teacher teacher; //调用无参构造函数
teacher.age = age;
return teacher; //调用拷贝构造函数,系统把teacher对象信息复制给临时对象了,因为teacher对象的生命周期马上要结束,在销毁之前,系统要把teacher的信息复制出来(复制到临时对象中去)。
}
void main()
{
Teacher t = GetTeacher(20);//函数返回的临时对象实际是被直接构造到t里面去了,相当于临时对象被t接管了或者说t其实就是这个临时对象。
}
//运行结果:
无参构造函数
拷贝构造函数
析构函数
析构函数
//优化后:
Teacher GetTeacher(int age)
{
return Teacher(age);
}
void main()
{
Teacher t = GetTeacher(20);//假的临时对象被t接管
}
//运行结果:
含参构造函数
析构函数
18. 对象移动、移动构造函数与移动赋值运算符
右值引用引入的目的是提高程序运行效率问题,提高的手段是把复制对象变成移动对象从而提高程序运行效率。
(1)移动对象
假设对象A不再使用了,那么就可以把对象A里面有一些如用new分配的内存块的所有权转给对象B,对于B来讲,就不用new出一些内存块了,把对象A中new的内存块直接转给B,然后对象A再把指向这些内存块的指针清空一下(因为这个内存块属于B,A就应该切断和这些内存块的联系)。这就相当于把对象A中的一些内存块转给了对象B,而对象B就不用自己再重新分配一块内存块,而直接用在A中分配的内存块就好了,这就叫移动对象。
(2)移动构造函数
当定义一个对象并用另外一个同类型对象初始化时,系统会调用拷贝构造函数,当用另外一个对象给一个对象赋值时,系统会调用拷贝赋值运算符。
- 如果复制数据,如要把对象A复制给对象B,那对象A里面的数据还能使用,但如果把对象A(实际上是对象A中部分数据)移动给对象B(对象A的数据就会出现残缺),那显然对象A就不能再被使用,否则因为数据的残缺可能会导致出现问题。
- 这里移动的概念并不是把内存中的数据从一个地址倒腾到另外一个地址,因为倒腾数据这个动作工作量很大(跟复制没啥区别),影响效率。所以,这里所讲的移动,指的是把一块内存地址中的数据的所有者从原来的所有者标记为新所有者,如原来这块数据的所有者是对象A,经过所谓的“移动”后,这块数据的所有者就变成对象B了。此时对象A就变得残缺了,原则上就不要再去使用对象A了。
移动构造函数与拷贝构造函数很类似,拷贝构造函数的写法:
Teacher(const Teacher& t); //拷贝构造函数
注意这里拷贝构造函数里的形参是一个const引用,也是一个左值引用。在移动构造函数中,这个形参是一个右值引用而不是左值引用,也就是带两个“&”(&&)的引用。其实,右值引用这个概念,就是为了支持这里所说的对象移动的操作的。所以C++11这个标准才新创造出来一个带两个“&&”的类型。
移动构造函数的第一个参数就是一个右值引用参数(那实参就得传递进来一个右值,因为右值引用形参正是要绑右值的,所以,右值作为实参)。C++就是根据传递进来的是否是一个右值实参来确定是不是要调用移动构造函数或者是不是要调用移动赋值运算符。移动构造函数的写法:
Teacher(Teacher&& t) :stu(t.stu) //移动构造函数
{
t.stu = nullptr;
std::cout << "移动构造函数" << std::endl;
}
移动构造函数除了第一个参数是右值引用之外,如果有其他额外的参数,那么这些额外的参数都要有默认值,这一点和拷贝构造函数完全相同。
(2.1)noexcept关键字
noexcept关键字对移动构造函数很有用,它用来通知编译器该移动构造函数不抛出任何异常(提高编译器工作效率,否则编译器会为可能抛出异常的函数做一些额外的处理准备工作)
在C++中,如果你的移动构造函数被标记为noexcept,但实际上它抛出了异常,那么程序将会调用std::terminate()来终止程序。这是因为noexcept关键字是一种承诺,表示该函数不会抛出任何异常。如果违反了这个承诺,编译器就没有其他选择只能终止程序。所以,如果要将移动构造函数标记为noexcept,那么一定要保证其不会抛出异常。
如果移动构造函数的函数声明和函数实现分开的话,那么在声明和实现部分都加noexcept关键字。
(3)移动赋值运算符
Teacher& operator= (Teacher&& t) noexcept
{
stu = t.stu;
t.stu = nullptr;
std::cout << "移动赋值运算符" << std::endl;
return *this;
}
Teacher a1 = Teacher();
Teacher a2;
a2 = a1; //拷贝赋值运算符
a2 = std::move(a1); //移动赋值运算符
只有一个类没定义任何自己版本的拷贝构造函数、拷贝赋值运算符、析构函数,且类的每个非静态成员都可以移动时,编译器才会为该类合成移动构造函数或者移动赋值运算符。那什么叫成员可以移动呢?
- 内置类型(如整型、实型等)的成员变量可以移动。
- 如果成员变量是一个类类型,如果这个类有对应的移动操作相关的函数,则该成员变量可以移动。
(4)总结
在有必要的情况下,应该考虑尽量给类添加移动构造函数和移动赋值运算符,达到减少拷贝构造函数和拷贝赋值运算符调用的目的,尤其是需要频繁调用拷贝构造函数和拷贝赋值运算符的场合。当然,一般来讲,只有使用new分配了大量内存的这种类才比较需要移动构造函数和移动赋值运算符。
不抛出异常的移动构造函数、移动赋值运算符都应该加上noexcept,用于通知编译器该函数本身不抛出异常。否则有可能因为系统内部的一些运作机制原本程序员认为可能会调用移动构造函数的地方却调用了拷贝构造函数。此外,此举还可以提高编译器的工作效率。
一个对象移动完数据后当然不会自主销毁,但是,程序员有责任使这种数据被移走的对象处于一种可以被释放(析构)的状态。因此,诸如类中的移动构造函数中的“t.stu = nullptr;;”语句以及移动赋值运算符中的“ t.stu = nullptr;;”语句存在的意义都是使被移走的对象处于一种可以被释放的状态。
一个本该由系统调用移动构造函数和移动赋值运算符的地方,如果类中没有提供移动构造函数和移动赋值运算符,则系统会调用拷贝构造函数和拷贝赋值运算符代替。