C++面向对象程序设计
类和对象
类的定义
默认情况下,类成员是私有访问的,并且是私有继承的。类的变量和函数成为类的成员。通常一个类由下面的成员构成:
类的数据成员:定义类对象的状态和属性。
一个或多个构造函数:用于初始化类对象。
一个或多个析构函数:用于完成清除工作,如释放动态分配的内存、关闭文件等等。
类的成员函数:定义对象的行为。
类定义的语法规则为
1
2
3类关键字(class or struct or union) __declspec(可选) 类名(可选) 基类名(可选){
成员列表
};类是可以嵌套的。比如说这里的Tree,Tree的left和right成员都可以定义为Tree类本身的类型。
1
2
3
4
5class Tree{
void*data;
Tree*left;
Tree*right;
};还可以用typedef隐藏类名。
1
2
3
4typedef struct{
int x;
int y;
}point;对象的定义
对象是在运行时定义了类型的储存区域,除了保存状态信息外,还定义了行为。
1
2
3class Account{
Account();//默认构造器
}:Account account;上面代码首先声明了名为Account的类,然后定义了对象名为account的Account类对象。
还有一种特殊的类——空类
1 | class noMenclass{ |
但是它的对象长度并不为o0,长度为1.
嵌套类
类可以在类中声明,这样的类叫作嵌套类。嵌套类的声明与类的声明相同,只是声明的位置实在其他类的范围之内。
1 | class Animal{ |
上面的代码在Animal类中定义了Dog和Cat两个类,注意,这两个类只在Animal类的范围内才有效。而Animal类的对象不包含Dog和Cat类的对象,只是声明了两个类,并没有定义它们的对象。在嵌套类定义的类中定义的变量和类型,在嵌套类中可以使用。比如在Animal::Dog类中可以使用Animal类中定义的num.
嵌套类只在定义的类的范围内有效。要引用一个嵌套类,则必须指定完整的类名。比如我们要引用Animal::Dog里面的bark方法。
1 | Animal::Dog::bark(); |
类的成员以及特性
构造函数
与类名称相同的成员函数成为构造函数,无返回值。如果为类指定了构造函数,则此类型的对象会在创建之前使用构造函数进行初始化。即便没有在构造函数中写代码,构造函数也会执行默认操作,完成必要的初始工作。如果定义类的时候没有写构造函数,系统会生成一个默认的无参构造函数,不做任何工作。
构造函数按参数种类分为:无参构造函数、有参构造函数、有默认参数构造函数。
无参构造函数
1 | class Student { |
有参构造函数
顾名思义,就是构造函数中含有参数。
1 | Student(int a,string n){ |
针对上面的代码,我们做了一点小改动,显然当我们创建类的时候,需要传入相应的参数,与上面的无参构造函数有区别。
有默认参数构造函数
在对象的实例化时,若传入了参数,则传入的参数优先,若没有传入参数,则使用默认参数。
1 | Student(int a,string n="小明"); |
我们设置了name(姓名)的默认参数为”小明”,如果我们在创建类的时候,传入一个新的参数,最后我们得到的就是该参数,否则为默认参数。但是age(年龄)我们并没有设置默认参数,因此无论name给不给定参数,我们都需要给age传入相应的参数。
注意:
在一个类中定义了一个带默认参数的构造函数后,不能再定义有冲突的重载构造函数。
1
2Student(int a,string n="小明");
Student(int a=18,string n="小明");
像上面这种重定义的带默认参数的构造器就是错误的
参数缺省值只能出现在函数的声明中,而不能出现在定义体中。
1
2
3
4Student(int a,string n="小明"){
age=a;
name=n;
}上面的代码显然为定义体,但是缺省值却出现在传参列表中,明显是错误的。
如果函数有多个参数,参数只能从前往后挨个儿缺省。
1
2Student(int a=18,string n);//×
Student(int a,string n="小明");//√多个参数只能先写缺省值,然后再写默认值。
构造函数按类型分为:普通构造函数,拷贝(复制)构造函数。
拷贝构造函数
上面所说的均为默认构造函数,当对象与对象之间进行复制时,需要用到拷贝构造函数。如果没有定义拷贝构造函数,编译器会自动生成一个拷贝构造函数。
1 | Student s(18,"小明"); |
还是用到上面的例子,这里的s1就是经过s拷贝而来的。
下面,将介绍几种拷贝构造函数的调用方法:
1 | class Person { |
括号法
1
2Person a(10);
Person b(a);显式法
1
2Person p1 = Person(10);
Person p2 = Person(p1);这里的Person(10)叫做匿名对象,当前行执行结束后,系统会立即回收掉匿名对象。
此外,不要利用拷贝构造函数初始化匿名对象
1
Person(p2);//x
针对上面的代码,这种方案是不可行的。编译器会认为
Person(p2);
等价于Person p2;
导致重定义。隐式法
1
2Person p3 = 10;//相当于Person p3 = Person(10);
Person p4 = p3;
拷贝构造函数调用时机
使用一个已经创建完毕的对象来初始化一个新对象
1
2Person p1(10);
Person p2(p1);值传递的方式给函数参数传值
1
2void test2(Person p) {
}当我们在主函数调用它时,会自动拷贝一份到test2函数当中
值方式返回局部对象
1
2
3
4
5
6
7
8
9Person work(){
Person p1;
cout << (int*)&p1 << endl;
return p1;//返回值优化,避免了不必要的拷贝构造
}
void test3() {
Person p = work();
cout << (int*)&p << endl;
}在以前的版本中,这种方法是能调用拷贝构造的,但是在后面的版本中,编译器进行了返回值优化,优化掉不必要的拷贝复制函数的调用。因此,当我们调用test3函数时,只会调用一个默认构造函数。且p1和p的地址也是一样的。
析构函数
析构函数是构造函数的逆操作,当对象销毁或者释放时,系统会自动调用析构函数。指定析构函数的方法是在类中增加一个函数,然后再类名前加一个“~”号。当不再需要对象时,析构函数会清除对象所占用的资源。如果程序语言没有提供析构函数,编译器将隐式地声明一个默认析构函数。
1 |
|
在这里析构函数的作用就体现出来了,它将构造函数创建的字符数组的内释放。
1 | class Person { |
注意构造和析构的先后顺序,上面这段代码,我们依次创建了两个对象,但是输出结果并不与我们想象的那样先创建的先销毁,后创建的后销毁。而是先构造的后析构,后构造的先析构。
下面补充一下delete和delete[]的区别:我们都知道,delete释放new分配的单个对象指针指向的内存,delete[]释放new分配的对象数组指针指向的内存。
对于简单类型,如 int *a=new int[10] ,这种无论采用delete还是delete[]都是可以的,因为分配简单类型内存时,内存大小已经确定,系统可以记忆并且进行管理,在析构时,系统不会调用析构函数。它直接通过指针获取一段分配的空间。
但是,针对class,这两种方式就体现出差异了。
1 |
|
当我们使用delete[]的时候,输出结果为三个“构造函数”,三个“析构函数”,说明资源释放完全了,但是我们使用delete时,只出现了一句“析构函数”,且引发了错误,说明资源释放的不充分,只是释放了a[0]的内存,其他的内存并没有释放,从而导致内存泄漏。
this指针
this指针只能在成员函数中使用,它指向被调用的成员函数所属的对象。当一个对象的成员函数被调用时,编译器会隐式地传递该对象的地址作为 this 指针。
1 | class A { |
通过使用this指针,我们可以在成员函数中访问当前对象的成员变量,即便他们的函数参数与局部变量同名,这样可以避免命名冲突。
来看下面一段代码
1 | class Person { |
我们写了一个PersonAdd
函数,但注意到它的返回值为Person&
,那么通过引用返回究竟有什么用处呢?这个函数的主要目的就是,传入一个Person类,将它的age属性加到我们自己的Person类的age属性上,然后再返回我们的Person类。
return *this
返回的是当前对象的克隆或者本身(如果返回类型为A,则是克隆,会调用拷贝构造函数,返回的对象是拷贝出来的副本;如果返回类型为A&,则是本身)在这里,显然是返回的本身。还有一个与之相对的return this
,他会返回当前对象的地址,因此为什么要加上一个星号,作用就在于解引用。
在主函数中,我们反复调用了三次这个函数,因为返回值为类,我们能用链式写法,即p1.PersonAdd(p).PersonAdd(p).PersonAdd(p);
,输出结果为40.
现在我们将PersonAdd
函数的返回值改为Person,输出结果将发生变化为20,因为返回值是值类型,每一次调用函数将会创建一个新对象,并返回新对象。所以,无论调用几次PersonAdd
函数,实际上只有一次效果。
空指针访问成员函数
1 | class Person { |
运行时出错,原因在于showage
,空指针指向的属性无法表示,因此直接报错。最佳办法是增加一个特判,这样也能提高代码的健壮性。
构造函数调用规则
默认情况下,c++编译器至少给一个类添加3个函数
- 默认构造函数
- 默认析构函数
- 默认拷贝构造函数
1 | class Person { |
这里我们并没有写拷贝构造函数,但是输出结果为两句“析构函数调用”,说明编译器自动帮我们创建了一个拷贝构造函数。另外两个就不举例了,比较简单。
此外,如果你写了拷贝构造函数,那么编译器就不提供默认构造函数以及有参构造函数。
深拷贝和浅拷贝
浅拷贝
利用编译器提供的拷贝构造函数,会做浅拷贝操作。它仅复制对象的基本类型成员和指针成员的值,而不复制指针所指向的内存。这可能导致两个对象共享相同的资源,从而引发潜在的问题,如内存泄漏、意外修改共享资源等。
1 | class Person { |
上面的代码我们写了两个属性,age和num,分别用int和int指针型来表示。指针型的变量需要创建在堆区,因此在释放时候需要我们手动进行释放,所以在析构函数中需要增加一条delete操作。但是运行的时候就会报错
深拷贝
重新在堆区申请一空间,防止释放内存操作的冲突,以解决浅拷贝带来的问题。
1 | Person(const Person&p) { |
总结:如果属性有在堆区开辟的,那么一定要自己提供拷贝构造函数,防止浅拷贝带来的问题。
初始化列表
1 | class Person { |
初始化列表提供了一种相对于传统构造函数更为简单的方法,只需写一行即能完成对属性的赋值。一定要注意冒号的位置!!!
类的作用域和对象的生存期
类的作用域是指定义的有效范围,类的数据成员
按生命期的不同,对象可分为如下四种:
局部对象
局部对象在运行函数时被创建,调用构造函数;当函数运行结束时被释放,调用析构函数。
静态对象
全局对象
全局对象在程序开始运行时,main运行前创建对象,并调用构造函数;在程序运行结束时被释放,调用析构函数。
自由储存对象
用new分配的自由储存对象在new运算时创建对象,并调用构造函数;在delete运算时被释放,调用析构函数。自由储存对象一经new运算创建,就会始终保持知道delete运算时,即使程序运行结束它也不会自动释放。
访问权限
- 公共权限(public):类内和类外都能访问
- 保护权限(protected):类内能访问,类外不能访问
- 私有权限(private):类内能访问,类外不能访问
值得注意的是,保护权限和私有权限的区别是,子类能访问父类的保护内容却不能访问它的私有内容。
struct
和class
的区别:默认访问权限不同。struct
默认访问权限为公共,class
默认访问权限为私有。
类对象作为类成员
c++类中的成员可以是另一个类的对象,就叫做对象成员。
1 | class Phone { |
我们创建了两个类:Person和Phone.在Person类中,我们增加了一个Phone属性,这就是对象成员。
注意:当其他类对象作为本类成员,构造时先构造类对象,再构造自身;析构时先析构自身,再析构类对象。因此上面的输出顺序为:
1 | Phone有参构造函数 |
静态成员
静态成员变量
类内声明,类外初始化操作
1
2
3
4
5
6
7
8
9
10
11class Person {
public:
static int n;//类内声明
//static int n=100; (X)
};
int Person::n = 100;//类外初始化
//static int n=100; (X)
void test() {
Person p;
cout << p.n << endl;
}上面这段代码很好地阐述了类内声明,类外初始化的操作。注释中所展示的都是常见的错误写法。
所有对象都共享一份数据
有两种访问方式:通过对象进行访问和通过类名进行访问。
1
2Person p;
cout << p.n << endl;//对象访问1
cout << Person::n << endl;//类名访问
编译阶段分配内存
静态成员变量也是有访问权限的
静态成员函数
1 | static void fun() { |
原理跟静态成员变量一样。
注意,静态成员函数只能访问静态成员变量。
C++对象模型
成员变量和成员函数分来存储
空对象占用内存为1. C++编译器会给每个空对象也分配一个字节的空间,是为了区分空对象占内存的位置。
1 | class Person { |
当对象中有一个成员变量时,它占用的内存为4,说明非静态成员变量属于类的对象上。
1 | class Person { |
当我们多加一个static,那么大小又变回1了,说明静态成员变量不属于类的对象。同理,当一个类同时又静态成员变量和非静态成员变量时,它们的效果不是叠加的,即大小还是4.还有,非静态成员函数和静态成员函数都是不属于类的对象上的。
const
修饰成员函数
常函数
成员函数后加
const
称为常函数1
2void show() const {
}常函数内不可修改成员属性
this指针的本质是指针常量,指针的指向是不能修改的,但是指针的值能修改。但当我们在函数后加上一个
const
,使其变为常函数,本质上修饰的是this指针,让指针指向的值不可修改。成员属性声明时加关键字mutable后,在常函数中依然可以修改
1
mutable int age;
常对象
声明对象前加
const
称为常对象1
const Person p;
常对象只能用常函数
常对象不允许修改其属性的值
类和对象
友元
让一个函数或者类访问另一个类中私有成员
实现
全局函数做友元
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24class Building {
friend void show(Building build);//友元声明
public:
Building() {
a = "aa";
b = "bb";
}
public:
string a;
private:
string b;
};
void show(Building build) {
cout << build.a << endl;
cout << build.b << endl;
}
void test() {
Building build;
show(build);
}
int main() {
test();
return 0;
}在类的最开始加上友元(friend)的声明,然后我们就能在函数中放心大胆地访问私有属性了。
类做友元
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31class Building {
friend class Person;//友元类声明
public:
Building() {
a = "aa";
b = "bb";
}
public:
string a;
private:
string b;
};
class Person {
public:
Building *build;
Person() {
build = new Building;//构造函数在堆区申请内存
}
void show() {
cout << build->a << endl;
cout << build->b << endl;//访问私有属性
}
};
void test2() {
Person p;
p.show();
}
int main() {
test2();
return 0;
}跟上面如出一辙,都是在类的最开始加上友元声明,进而访问私有属性。
成员函数做友元
这一块内容有点小坑,咱们来设置一个情景
定义两个类:客人类(guest)和建筑类(building),实现对客人类不同的访问权限。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26class building;
class guest {
public:
string name;
building* build;
guest(string name) {
this->name = name;
build = new building;
}
void visit(building *build) {
cout << build->living_room;
cout << build->bedroom;
}
};
class building {
friend void guest::visit(building build);//友元函数基本语法,注意void的位置
private:
string bedroom;
public:
string living_room;
building() {
bedroom = "卧室";
living_room = "客厅";
}
};这段代码看着没什么问题,但是访问私有属性的时候却出现了错误。因为guest里用到了building类的初始化,即便是在开头声明了building,但是编译器仍然找不到building的构造器,因此我们需要将guest里面涉及到building类的函数全部写在最后。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38class building;
class guest {
public:
building *build;
string name;
guest(string name);//不要忘了声明
void visit();//不要忘了声明
};
class building {
friend void guest::visit();
private:
string bedroom;
public:
string living_room;
building() {
bedroom = "卧室";
living_room = "客厅";
}
};
//==============================
//涉及到building的写在文末
guest::guest(string name) {
this->name = name;
build = new building;
}
void guest::visit() {
cout << name << "访问了" << build->living_room << endl;
cout << name << "访问了" << build->bedroom << endl;
}
//==============================
void test() {
guest g("jack");
g.visit();
}
int main() {
test();
return 0;
}这时就能正常访问私有属性啦。
运算符重载
对已有运算符重新进行定义,赋予其另一种功能,以适应不同的数据类型。
加号运算符(+)重载
成员函数重载
1
2
3
4
5
6
7
8
9
10
11
12
13
14class Person {
public:
int a;
int b;
Person(int a, int b) {
this->a = a;
this->b = b;
}
Person operator+(Person &p) {
//成员函数重载
Person temp(this->a + p.a,this->b + p.b);
return temp;
}
};这里用到的operator+并不是我们随便起的,而是编译器严格定义的,想要重载运算符必须得用这种命名方式。然后,将两个类各个属性的加和赋值给一个新的类,并返回这个类。这样,就实现了两个类相加。
全局函数重载
1
2
3
4
5Person operator+(Person &p1,Person &p2) {
//全局函数重载
Person temp(p1.a + p2.a, p1.b + p2.b);
return temp;
}同理上文,唯一与上面不一样的是,传入的参数不一样。成员函数重载是因为本身就是一个类,可以调用自身的属性。而这个是定义在类外的,因此需要传入两个类参数。
值得注意的是,无论是成员函数重载还是全局函数重载,其本质上均是
Person p3 = p1.operator+(p2)
或Person p3 = operator+(p1, p2)
这两种形式,只不过编译器直接将他们变成Person p3 = p1 + p2
以简化操作。当然,两个数据类型,可以不仅仅局限为两个类,一个类一个整形等等都可以。
下面就是类与整形相加的例子:
1
2
3
4Person operator+(Person &p, int num) {
Person temp(p.a + num, p.b + num);
return temp;
}左移运算符(<<)重载
我们在输出一个数据的时候,通常会用到
cout<<
……,但是想要输出一个类,那么这个方法就行不通了。于是乎可以用运算符重载。成员函数重载
1
2
3void operator<<(ostream &cout) {
cout << this->a << " " << this->b << endl;
}依照上面的加法运算符重载,我们可以照葫芦画瓢。这里传入的参数为
cout
,而他的类型是ostream
(输出流),然后我们就可以在函数体内自定义我们想要输出的格式了。但是当我们依照惯例使用
cout<<P
的时候,报错了。而p<<cout
却是对的。因为在成员函数中,这种重载相当于p.operator<<(cout);
,显然,这种编写格式不符合我们编写代码的惯例,因此一般不会利用成员函数重载<<全局函数重载
1
2
3
4ostream& operator<<(ostream &cout,Person &p) {
cout << p.a << " " << p.b << endl;
return cout;//链式编程思想
}看到这里不要慌,出现了很多没见过的东西。
首先,这个函数的类型为
ostream&
,为什么当函数运行结束时还要返回一个cout
呢?我们之前在类对象中说过链式编程的思想,就是一个类反复调用自身的函数,最终得出结果的案例。那么在这里我们就能明显感受到链式编程思想带来的好处。如果这个函数仍然为初始的void
,那么我们输出一次就不能继续输出了,cout<<p<<endl
这种方式是错误的。而当我们用链式编程思想,就能迎刃而解了。
递增运算符(++)重载
递增运算符有两种,一个是前置递增,还有一个是后置递增。
前置递增
1
2
3
4Person& operator++() {//返回引用是为了一直对一个数据进行操作
num++;
return *this;
}这里唯一需要注意的就是重载函数的返回值,首先递增必然是返回同一个都对象,其次返回引用。
后置递增
1
2
3
4
5
6
7Person operator++(int) {
//int代表占位参数,可以用于区分前置和后置递增
Person temp = *this;
num++;
return temp;
//不能返回引用,因为这个temp是局部对象,执行完就销毁了
}后置递增用到了一个临时创建的类。
赋值运算符(=)重载
前文我们说到,编译器至少给一个类提供3个函数:默认无参构造函数,默认拷贝构造函数和默认析构函数。现在我们将再增加一个:赋值运算符重载。
1
2
3Person p(10);
Person p1(20);
p1=p;这种语法是没有问题的。
但是,一旦我们在类中存在建立在堆中的属性,那么这个方法显然就不适用了。还是回到上文所讲的:我们在析构函数中增加一个主动销毁的操作,即:
1
2
3
4
5
6~Person() {
if (age != NULL) {
delete age;
age = NULL;
}
}如果为浅拷贝,那么这段代码将会反复执行,同一块内存也会被反复析构,从而造成内存泄漏。但是编译器给我们提供的赋值运算符重载是浅拷贝,因此我们要进行改进。
1
2
3
4
5
6
7
8Person& operator=(Person &p) {//引用返回,以实现连续赋值操作,与左移运算符重载操作一样
if (age != NULL) {//这里需要判断一下该属性在堆中的内存是不是干净的,如果有,也要即使清理掉,防止后患
delete age;
age = NULL;
}
age = new int(*p.age);
return *this;
}与之前的深拷贝类似,我们在进行赋值的时候,不单单要进行值的赋值,还要新建一块内存。
关系运算符重载
1
2
3
4int operator==(Person p) {
if (this->age == p.age)return 1;
else return 0;
}由于这块内容较为简单,这里就给出一个大致参考。包括不等,大于和小于号都可以用这种写法重载。
函数调用运算符()重载
这个括号就是函数后面跟着的小括号,其实它也能重载。
由于重载后使用的方式非常像函数的调用,因此成为仿函数。
1
2
3
4
5
6
7
8
9
10class Add {
public:
int operator()(int n1,int n2) {
return n1 + n2;
}
};
void test() {
Add add;
cout<<add(10, 10);
}这里引入一个匿名函数对象,上面的函数体内可以这么改:
1
cout<<Add()(10, 10);
这样无需创建一个新对象就能调用函数了。
继承
实现
基本语法:
1 | class 子类 : 继承方式 父类{ |
其中,子类也称为派生类,父类也成为基类
下面来看具体案例
1 | class Father { |
这里父类的方法在子类中都能用到,减少重复的代码,这就是继承的好处。
继承方式
公共继承
父类的公共属性和保护属性到了子类仍然不变
保护继承
父类中的公共属性和保护属性到了子类均变为保护属性
私有继承
父类中的公共属性和保护属性到了子类均变为私有属性
父类中的私有属性在子类中均不可访问,无论用哪种继承方式。
对象模型
那么子类究竟占多大的空间呢?下面我们来讨论一下
1 | class Base { |
运行上面的结果可以看到,结果是16,显而易见,子类中包含父类以及自身的属性。因此我们能得出结论:
父类中所有非静态成员属性都会被子类继承下去,父类中私有成员属性是被编译器给隐藏了,因此访问不到,但是却被继承下去了。
继承中的构造和析构顺序
1 | class Base { |
先构造父类再构造子类,而析构的顺序是相反的。
继承中同名的成员处理方式
当子类和父类拥有同名的属性或者函数时,我们是不能直接访问父类的成员的,直接访问也只能是子类的。那如果我们想访问父类的属性或函数该怎么办呢?
1 | class Base { |
这时候只需加一个作用域即可,一定要注意是两个冒号!
此外,这种形式的本质是:如果子类中出现和父类同名的成员函数,子类的同名成员会隐藏掉父类中所有同名成员函数。
多继承语法
c++允许一个类继承多个类
1 | class 子类 : 继承方式 父类1 , 继承方式 父类2 .... |
多继承可能会引发父类中有同名成员出现,需要加作用域区分。c++实际开发中不建议使用多继承。
1 | class Base1 { |
菱形继承
两个派生类继承同一个基类,又有某个类同时继承两个派生类。
菱形继承的时候,两个父类有相同数据,需要加以作用域区分。但是这相同的数据只要有一份即可,菱形继承导致数据有两份,资源浪费。
利用虚继承可以解决菱形继承的问题,在继承之前加上关键字 virtual
变为虚继承。
1 | class A : virtual public B{ }; |
这里的B叫做虚基类。
多态
基本语法
静态多态
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class Animal {
public:
void m() {
cout << "Animal m" << endl;
}
};
class Cat :public Animal {
public:
void m() {
cout << "Cat m" << endl;
}
};
void mm(Animal &a) {
a.m();//地址早绑定,编译阶段确定函数地址
}
void test() {
Cat cat;
mm(cat);//输出Animal,此处不管传入的是Animal的任何子类还是Animal,都会当成Animal
}动态多态
子类重写父类的虚函数。
注意,重写和重载不一样,重写的返回值,形参列表和函数名都要一样。而重载只有函数名一样,参数列表可能不一样。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19class Animal {
public:
virtual void m() {//虚函数
cout << "Animal m" << endl;
}
};
class Cat :public Animal {
public:
void m() {
cout << "Cat m" << endl;
}
};
void mm(Animal &a) {
a.m();//地址晚绑定,运行阶段确定函数地址
}
void test() {
Cat cat;
mm(cat);//输出Cat
}可以看到动态多态和静态多态之间就差了一个
virtual
关键字。
原理剖析
1 | class Animal{ |
这样一个类,我们首先来测一下它的大小,明显是1.但是当我们加上virtual
后,大小突变为4.这又是为什么呢?仔细思考可以发现,增加的大小应该是一个指针。它的名字叫做vfptr
,全称 virtual function pointer
(虚函数指针)这个指针会指向vftable
,存放虚函数表,记录虚函数地址。当子类重写父类的函数,子类中的虚函数表内部会替换成子类的虚函数地址。
纯虚函数和抽象类
在多态中,通常父类中虚函数的实现是毫无意义的,主要都是调用子类重写的内容,因此可以把虚函数改为纯虚函数
1 | virtual 返回值类型 函数名 (参数列表) =0; |
纯虚函数与虚函数主要区别就是大括号变成了=0
当类中有了纯虚函数,这个类也成为抽象类。抽象类无法实例化对象,其子类必须重写抽象类中的纯虚函数,否则也属于抽象类
虚析构和纯虚析构
多态使用时,如果子类中有属性开辟到堆区,那么父类指针在释放时无法调用到子类的析构代码。因此我们需要将父类中的析构函数改为虚析构或者纯虚析构。
下面看一个案例:
1 |
|
我们在cat类中增加了一个堆区的属性,我们在析构的时候需要同时将堆区开辟的内存销毁。但是当我们运行函数时,并没有出现“~Cat”的字样,也就说明没有析构成功,这是怎么回事呢?其实,父类指针在析构的时候,不会调用子类中的析构函数,子类如果有堆区的属性,就会造成内存泄漏。
我们只需要在Animal类中添加一个虚析构函数即可
1 | virtual ~Animal() { |
同时我们也能增加一个纯虚析构函数
1 | virtual ~Animal() = 0; |
值得注意的是,纯虚函数在子类必须得有一定的具体实现,我们可以直接在类的外面进行一个空的具体实现,如下。
1 | Animal::~Animal(){} |
这样,纯虚析构才算完成。而有了纯虚析构之后,这个类也属于抽象类,无法实例化对象。
模板
概念
建立通用的模具,大大提高复用性。模板不能直接使用,它只是一个框架。
函数模板
泛型编程主要的技术就是模板。
函数模板就是建里一个通用函数,其函数返回值类型和形参类型可以不具体,用一个虚拟的类型来代表
语法
1 | template<typename T> |
当我们写一种复用需求很高的函数,比如两个数交换。但是c++中有很多数据类型,通常我们写的一种交换函数是不适用于另一种数据类型的,这时候我们可以使用模板来防止编写过多重复的函数。
1 | template<typename T> |
这里的T就相当于一种数据类型,我们直接使用它创建变量。
而当我们需要调用的时候也有两种方式
1 | int a = 10; |
注意事项
自动类型推导必须要推导出一致的数据类型
模板必须要确定出T的数据类型,才可以使用
一个函数如果不含模板数据类型,那么无法调用这个函数
1
2
3
4template<typename T>
void fun() {
//这种方式是不接受的
}
普通函数与函数模板的区别
普通函数调用时可以发生自动类型转换(隐式类型转换)、
1
2
3int add(int a, int b) {
return a + b;
}这段代码,当我们传入一个字符型变量和一个整型变量,也能得到结果,因为函数吟诗地将字符型转换为了整型(ASCLL码)
函数模板调用时,如果利用自动类型推导,不会发生隐式类型转换
1
2
3
4template<typename T>
int add(T a, T b) {
return a + b;
}如果换成这种,就直接报错
如果利用显示指定类型的方式,可以发生隐式类型转换
1
2
3int a = 10;
char c = 'c';
cout<<add<int>(a, c);这样就指定系统为int类型,就可以转换啦
普通函数与函数模板调用规则
如果普通函数和函数模板搜可以实现,优先调用普通用函数
可以通过空模板参数列表来强制调用模板函数
1
2
3
4
5
6
7
8
9
10
11template<typename T>
void fun(T a, T b) {
cout << a + b;
}
void fun(int a, int b) {
cout << a * b;
}
int main() {
fun<>(1, 1);
return 0;
}加一个空的尖括号即可
函数模板也可以发生重载
如果函数模板可以产生更好的匹配,优先调用函数模板
1
fun('a', 'b');
还是上面的案例,如果我们这么写参数,那么会调用哪个函数呢?结果是函数模板,两个char类型可以更好地匹配函数模板,即便也能通过强制转换调用普通函数。
模板的局限性
模板的重载可以为特定的类型提供具体的模板
1 | class Person { |
这段代码很好的提供了类和其他数据类型共用的模板函数。
类模板
类模板语法与函数模板类似。建立一个通用类,类中的成员和数据类型可以不具体指定,用一个虚拟的类型来代表。
语法
1 | template<class nameType,class ageType>//两个属性,指定两个模板类型 |
区别
类模板与函数模板的区别主要有两点:
类模板没有自动类型推导的方式
类模板在模板参数列表中可以有默认参数
还是上面的案例
1
template<class nameType,class ageType = int>
我们在定义模板的时候可以直接指定某个属性的数据类型,这样在我们实例化对象的时候可以直接省略该处的声明
1
Person<string> p("jack", 18);
成员函数创建时机
1 | class Person { |
来看代码,两个Person类我们先不看,我们创建一个A类,其中的属性t用的是模板类,但是系统目前不知道这个T表示的是哪个类,只要不创建对象就不会进入到函数体内,因此可以直接写t.show()
1 | A<Person1>a; |
然后我们指定模板的类型为Person1
,这时候就能识别成Person1
的实例对象了,然后我们就能调用Person1
中的show2
函数了。值得注意的是,如果想要调用Person
中的show
函数,就会报错,因为类型为Person1
与之不兼容。
类模板对象做函数参数
1 | template<class t1,class t2> |
指定传入类型
1
2
3
4
5
6
7void printPerson(Person<string,int>&p) {//注意参数列表。直接将类型告诉了编译器
p.show();
}
void test() {
Person<string, int>p("jack", 18);
printPerson(p);
}参数模板化
1
2
3
4template<class t1, class t2>//这个不能少
void printPerson2(Person<t1,t2>&p) {
p.show();
}类模板化
1
2
3
4template<class t>
void printPerson3(t &p) {
p.show();
}在开发中,最常用的还是第一种方式
类模板与继承
当类模板遇到继承时,需要注意以下几点:
当子类继承的父类是一个类模板,子类在声明的时候,要指定出父类中的类型,如果不指定,编译器无法给子类分配内存
1
2
3
4
5
6template<class t>
class Base {
t num;
};
class Son :public Base<int> {//指定数据类型
};如果想灵活指定出父类中的类型,子类也需要变成类模板
1
2
3
4
5
6
7template<class t,class t1>//这里的t是父类中的模板,t1是子类中的模板
class Son1 :public Base<t1> {
t1 num1;
};
void test() {
Son1<int,char>s;//指定父类和子类中属性的类型
}
类模板成员函数类外实现
1 | template <class t1,class t2> |
与普通类函数类外实现不同的是,这里首先要写上模板,然后还要加上Person的作用域,一定不要忘记尖括号里面的内容。
1 | template<class t1, class t2> |
其他非构造函数也是如此。
类模板分文件编写
当一个文件中有非常多的类时,全部都写在这个文件显然是不合理的,会造成阅读源码的不适。我们可以考虑分文件编写,然后引用。
1 |
|
这是一个普通的类模板实现,现在我们将它分文件编写。
首先我们在头文件中添加一个名为Person.h的文件,用来存放类模板
1 | //Person.h |
以及在源文件中添加一个Person.cpp文件
1 |
|
同时,我们将主函数中的类模板删去,再引用Person.cpp.为什么不能引用.h文件呢,直接引用h相当于少了一个函数的解析,而剩下的内容系统就不知道怎么继续进行下去了。
当然还有另外一种方式,就是将.cpp和.h的内容写到一起,改为hpp文件(约定俗称的文件后缀)