C++面向对象程序设计

类和对象

类的定义

​ 默认情况下,类成员是私有访问的,并且是私有继承的。类的变量和函数成为类的成员。通常一个类由下面的成员构成:

  • 类的数据成员:定义类对象的状态和属性。

  • 一个或多个构造函数:用于初始化类对象。

  • 一个或多个析构函数:用于完成清除工作,如释放动态分配的内存、关闭文件等等。

  • 类的成员函数:定义对象的行为。

    类定义的语法规则为

    1
    2
    3
    类关键字(class or struct or union) __declspec(可选) 类名(可选) 基类名(可选){
    成员列表
    };

    类是可以嵌套的。比如说这里的Tree,Tree的left和right成员都可以定义为Tree类本身的类型。

    1
    2
    3
    4
    5
    class Tree{
    void*data;
    Tree*left;
    Tree*right;
    };

    还可以用typedef隐藏类名。

    1
    2
    3
    4
    typedef struct{
    int x;
    int y;
    }point;

    对象的定义

    对象是在运行时定义了类型的储存区域,除了保存状态信息外,还定义了行为。

    1
    2
    3
    class Account{
    Account();//默认构造器
    }:Account account;

    上面代码首先声明了名为Account的类,然后定义了对象名为account的Account类对象。

    还有一种特殊的类——空类

1
2
3
4
5
6
7
class noMenclass{

};//不包含任何成员的类
void main(){
noMenclass a;
cout<<"空类对象的大小为"<<sizeof(a)<<endl;//输出结果显然为1
}

但是它的对象长度并不为o0,长度为1.

嵌套类

类可以在类中声明,这样的类叫作嵌套类。嵌套类的声明与类的声明相同,只是声明的位置实在其他类的范围之内。

1
2
3
4
5
6
7
8
9
10
11
12
class Animal{
public:
int num=1;
class Dog{
public:
void bark();
};
class Cat{
public:
void jump();
};
};

上面的代码在Animal类中定义了Dog和Cat两个类,注意,这两个类只在Animal类的范围内才有效。而Animal类的对象不包含Dog和Cat类的对象,只是声明了两个类,并没有定义它们的对象。在嵌套类定义的类中定义的变量和类型,在嵌套类中可以使用。比如在Animal::Dog类中可以使用Animal类中定义的num.

嵌套类只在定义的类的范围内有效。要引用一个嵌套类,则必须指定完整的类名。比如我们要引用Animal::Dog里面的bark方法。

1
Animal::Dog::bark();

类的成员以及特性

构造函数

与类名称相同的成员函数成为构造函数,无返回值。如果为类指定了构造函数,则此类型的对象会在创建之前使用构造函数进行初始化。即便没有在构造函数中写代码,构造函数也会执行默认操作,完成必要的初始工作。如果定义类的时候没有写构造函数,系统会生成一个默认的无参构造函数,不做任何工作。

构造函数按参数种类分为:无参构造函数、有参构造函数、有默认参数构造函数。

无参构造函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Student {
public:
Student() {
age = 18;
name = "小明";
}
void study() {
cout << name << "正在学习" << endl;
}
void showage() {
cout << "年龄为 " << age << endl;
}
private:
int age;
string name;
};
int main() {
Student s;
s.showage();
s.study();
return 0;
}

有参构造函数

顾名思义,就是构造函数中含有参数。

1
2
3
4
5
6
7
Student(int a,string n){
age=a;
name=n;
}
int main(){
Student s(18,"小明");
}

针对上面的代码,我们做了一点小改动,显然当我们创建类的时候,需要传入相应的参数,与上面的无参构造函数有区别。

有默认参数构造函数

在对象的实例化时,若传入了参数,则传入的参数优先,若没有传入参数,则使用默认参数。

1
2
3
4
5
Student(int a,string n="小明");
Student::Student(int a,int n){
age=a;
name=n;
}

我们设置了name(姓名)的默认参数为”小明”,如果我们在创建类的时候,传入一个新的参数,最后我们得到的就是该参数,否则为默认参数。但是age(年龄)我们并没有设置默认参数,因此无论name给不给定参数,我们都需要给age传入相应的参数。

注意:

  • 在一个类中定义了一个带默认参数的构造函数后,不能再定义有冲突的重载构造函数。

    1
    2
    Student(int a,string n="小明");
    Student(int a=18,string n="小明");

​ 像上面这种重定义的带默认参数的构造器就是错误的

  • 参数缺省值只能出现在函数的声明中,而不能出现在定义体中。

    1
    2
    3
    4
    Student(int a,string n="小明"){
    age=a;
    name=n;
    }

    上面的代码显然为定义体,但是缺省值却出现在传参列表中,明显是错误的。

  • 如果函数有多个参数,参数只能从前往后挨个儿缺省。

    1
    2
    Student(int a=18,string n);//×
    Student(int a,string n="小明");//√

    多个参数只能先写缺省值,然后再写默认值。

构造函数按类型分为:普通构造函数,拷贝(复制)构造函数。

拷贝构造函数

上面所说的均为默认构造函数,当对象与对象之间进行复制时,需要用到拷贝构造函数。如果没有定义拷贝构造函数,编译器会自动生成一个拷贝构造函数。

1
2
Student s(18,"小明");
Student s1(s);

还是用到上面的例子,这里的s1就是经过s拷贝而来的。

下面,将介绍几种拷贝构造函数的调用方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Person {
private:
int num;
public:
Person() {
cout << "无参构造器" << endl;
}
Person(int num) {
this->num = num;
cout << "有参构造器" << endl;
}
Person(const Person&p) {
num = p.num;
cout << "拷贝构造" << endl;
}
~Person() {
cout << "Person()析构函数调用" << endl;
}
};
  • 括号法

    1
    2
    Person a(10);
    Person b(a);
  • 显式法

    1
    2
    Person p1 = Person(10);
    Person p2 = Person(p1);

    这里的Person(10)叫做匿名对象,当前行执行结束后,系统会立即回收掉匿名对象。

    此外,不要利用拷贝构造函数初始化匿名对象

    1
    Person(p2);//x

    针对上面的代码,这种方案是不可行的。编译器会认为Person(p2);等价于Person p2;导致重定义。

  • 隐式法

    1
    2
    Person p3 = 10;//相当于Person p3 = Person(10);
    Person p4 = p3;

拷贝构造函数调用时机

  • 使用一个已经创建完毕的对象来初始化一个新对象

    1
    2
    Person p1(10);
    Person p2(p1);
  • 值传递的方式给函数参数传值

    1
    2
    void test2(Person p) {
    }

    当我们在主函数调用它时,会自动拷贝一份到test2函数当中

  • 值方式返回局部对象

    1
    2
    3
    4
    5
    6
    7
    8
    9
    Person work(){
    Person p1;
    cout << (int*)&p1 << endl;
    return p1;//返回值优化,避免了不必要的拷贝构造
    }
    void test3() {
    Person p = work();
    cout << (int*)&p << endl;
    }

    在以前的版本中,这种方法是能调用拷贝构造的,但是在后面的版本中,编译器进行了返回值优化,优化掉不必要的拷贝复制函数的调用。因此,当我们调用test3函数时,只会调用一个默认构造函数。且p1和p的地址也是一样的。

析构函数

析构函数是构造函数的逆操作,当对象销毁或者释放时,系统会自动调用析构函数。指定析构函数的方法是在类中增加一个函数,然后再类名前加一个“~”号。当不再需要对象时,析构函数会清除对象所占用的资源。如果程序语言没有提供析构函数,编译器将隐式地声明一个默认析构函数。

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
#include<iostream>
#include<string.h>
using namespace std;
class point {
public:
point(char* c);
~point();//析构函数
void print();
private:
double x;
double y;
char* c;
};
point::point(char* str) {//实现将传入的字符拼接操作
c = new char[strlen(str)];//开辟一段新的内存
if (c)strcpy(c, str);
}
point::~point() {
delete[] c;
}
void point::print() {
int i;
for (i = 0; i < strlen(c); i++) {
cout << c[i];
}
cout << endl;
}
int main() {
char s1[] = "aaaa";
point p(s1);
p.print();
p.~point();
return 0;
}

在这里析构函数的作用就体现出来了,它将构造函数创建的字符数组的内释放。

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
class Person {
public:
Person() {
cout << "Person构造函数" << endl;
}
~Person() {
cout << "Person析构函数" << endl;
}
};
class Animal {
public:
Animal() {
cout << "animal构造函数 " << endl;
}
~Animal() {
cout << "animal析构函数" << endl;
}
};
int main() {
Person p;
Animal a;
return 0;
//output Person构造函数
// animal构造函数
// animal析构函数
// Person析构函数
}

注意构造和析构的先后顺序,上面这段代码,我们依次创建了两个对象,但是输出结果并不与我们想象的那样先创建的先销毁,后创建的后销毁。而是先构造的后析构,后构造的先析构。

下面补充一下delete和delete[]的区别:我们都知道,delete释放new分配的单个对象指针指向的内存,delete[]释放new分配的对象数组指针指向的内存。

对于简单类型,如 int *a=new int[10] ,这种无论采用delete还是delete[]都是可以的,因为分配简单类型内存时,内存大小已经确定,系统可以记忆并且进行管理,在析构时,系统不会调用析构函数。它直接通过指针获取一段分配的空间。

但是,针对class,这两种方式就体现出差异了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<iostream>
using namespace std;
class A {
public:
A() {
cout << "构造函数被调用~" << endl;
}
~A() {
cout << "析构函数被调用~" << endl;
}
};
int main() {
A* a = new A[3];
//delete a;
delete[]a;
return 0;
}

当我们使用delete[]的时候,输出结果为三个“构造函数”,三个“析构函数”,说明资源释放完全了,但是我们使用delete时,只出现了一句“析构函数”,且引发了错误,说明资源释放的不充分,只是释放了a[0]的内存,其他的内存并没有释放,从而导致内存泄漏。

this指针

this指针只能在成员函数中使用,它指向被调用的成员函数所属的对象。当一个对象的成员函数被调用时,编译器会隐式地传递该对象的地址作为 this 指针。

1
2
3
4
5
6
7
8
9
10
11
class A {
public:
A(int num) {
this->num = num;
}
void print() {
cout << this->num;
}
private:
int num;
};

通过使用this指针,我们可以在成员函数中访问当前对象的成员变量,即便他们的函数参数与局部变量同名,这样可以避免命名冲突。

来看下面一段代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Person {
public:
int age;
Person(int age) {
this->age = age;
}
Person& PersonAdd(Person &p) {//返回值为引用
this->age += p.age;
return *this;
}
};
int main() {
Person p(10);
Person p1(10);
p1.PersonAdd(p).PersonAdd(p).PersonAdd(p);//链式写法
cout << p1.age << endl;
return 0;
}

我们写了一个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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Person {
public:
string name;
int age;
void showname() {
cout << "name" << endl;
}
void showage() {
//if (this == NULL)return;
//特判
cout << age << endl;
}
};
void test() {
Person* p = NULL;
p->showage();
p->showname();
}
int main() {
test();
return 0;
}

运行时出错,原因在于showage,空指针指向的属性无法表示,因此直接报错。最佳办法是增加一个特判,这样也能提高代码的健壮性。

构造函数调用规则

默认情况下,c++编译器至少给一个类添加3个函数

  • 默认构造函数
  • 默认析构函数
  • 默认拷贝构造函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person {
public:
int age;
Person(int age) {
this->age = age;
}
~Person() {
cout << "析构函数调用" << endl;
}
};
void test() {
Person p1(10);
Person p2(p1);
}

这里我们并没有写拷贝构造函数,但是输出结果为两句“析构函数调用”,说明编译器自动帮我们创建了一个拷贝构造函数。另外两个就不举例了,比较简单。

此外,如果你写了拷贝构造函数,那么编译器就不提供默认构造函数以及有参构造函数。

深拷贝和浅拷贝

浅拷贝

利用编译器提供的拷贝构造函数,会做浅拷贝操作。它仅复制对象的基本类型成员和指针成员的值,而不复制指针所指向的内存。这可能导致两个对象共享相同的资源,从而引发潜在的问题,如内存泄漏、意外修改共享资源等。

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
class Person {
public:
int age;
int* num;
Person(const Person&p) {
age = p.age;
num = p.num;
cout << "拷贝构造" << endl;
}
Person(int age, int num) {
this->age = age;
this->num = new int(num);//创建在堆区
//堆区开辟的空间需要手动释放,所以在析构函数里需要释放
cout << "有参构造" << endl;
}
~Person() {
if (num != NULL) {
delete num;
num = NULL;
}
}
};
int main() {
Person p1(10, 10);
Person p2(p1);
return 0;
}

上面的代码我们写了两个属性,age和num,分别用int和int指针型来表示。指针型的变量需要创建在堆区,因此在释放时候需要我们手动进行释放,所以在析构函数中需要增加一条delete操作。但是运行的时候就会报错

深拷贝

重新在堆区申请一空间,防止释放内存操作的冲突,以解决浅拷贝带来的问题。

1
2
3
4
5
Person(const Person&p) {
age = p.age;
num = new int(*p.num);//深拷贝
cout << "拷贝构造" << endl;
}

总结:如果属性有在堆区开辟的,那么一定要自己提供拷贝构造函数,防止浅拷贝带来的问题。

初始化列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Person {
public:
int a;
int b;
int c;
Person(int a_num,int b_num,int c_num) :a(a_num), b(b_num), c(c_num) {
//初始化列表
}
};
void test() {
Person p(10,10,10);
cout << p.a << " " << p.b << " " << p.c << " " << endl;
}
int main() {
test();
return 0;
}

初始化列表提供了一种相对于传统构造函数更为简单的方法,只需写一行即能完成对属性的赋值。一定要注意冒号的位置!!!

类的作用域和对象的生存期

类的作用域是指定义的有效范围,类的数据成员

按生命期的不同,对象可分为如下四种:

  • 局部对象

    局部对象在运行函数时被创建,调用构造函数;当函数运行结束时被释放,调用析构函数。

  • 静态对象

  • 全局对象

    全局对象在程序开始运行时,main运行前创建对象,并调用构造函数;在程序运行结束时被释放,调用析构函数。

  • 自由储存对象

    用new分配的自由储存对象在new运算时创建对象,并调用构造函数;在delete运算时被释放,调用析构函数。自由储存对象一经new运算创建,就会始终保持知道delete运算时,即使程序运行结束它也不会自动释放。

访问权限

  • 公共权限(public):类内和类外都能访问
  • 保护权限(protected):类内能访问,类外不能访问
  • 私有权限(private):类内能访问,类外不能访问

值得注意的是,保护权限和私有权限的区别是,子类能访问父类的保护内容却不能访问它的私有内容。

structclass的区别:默认访问权限不同。struct默认访问权限为公共,class默认访问权限为私有。

类对象作为类成员

c++类中的成员可以是另一个类的对象,就叫做对象成员。

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
class Phone {
public:
string pname;
Phone(string name) {
pname = name;
cout << "Phone有参构造函数" << endl;
}
~Phone() {
cout << "Phone析构函数" << endl;
}
};
class Person {
public:
string name;
Phone phone;
Person(string name, string pname) :name(name), phone(pname) {
cout << "Person有参构造函数" << endl;
}//初始化列表
~Person() {
cout << "Person析构函数" << endl;
}
};
void test() {
Person p("jack", "iphone");
cout << p.name << endl;
cout << p.phone.pname << endl;

}
int main() {
test();
return 0;
}

我们创建了两个类:Person和Phone.在Person类中,我们增加了一个Phone属性,这就是对象成员。

注意:当其他类对象作为本类成员,构造时先构造类对象,再构造自身;析构时先析构自身,再析构类对象。因此上面的输出顺序为:

1
2
3
4
Phone有参构造函数
Person有参构造函数
Person析构函数
Phone析构函数

静态成员

静态成员变量

  • 类内声明,类外初始化操作

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class 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
    2
    Person p;
    cout << p.n << endl;//对象访问
    1
    cout << Person::n << endl;//类名访问
  • 编译阶段分配内存

  • 静态成员变量也是有访问权限的

静态成员函数

1
2
3
4
5
6
7
8
9
static void fun() {
cout << "fun" << endl;
}
void test2() {
Person p;
p.fun();

Person::fun();
}

原理跟静态成员变量一样。

注意,静态成员函数只能访问静态成员变量。

C++对象模型

成员变量和成员函数分来存储

空对象占用内存为1. C++编译器会给每个空对象也分配一个字节的空间,是为了区分空对象占内存的位置。

1
2
3
4
5
6
7
class Person {
int n;
};
void test() {
Person p;
cout << "对象占用内存为" << sizeof(p) << endl;
}

当对象中有一个成员变量时,它占用的内存为4,说明非静态成员变量属于类的对象上。

1
2
3
4
5
6
7
class Person {
static int n;
};
void test() {
Person p;
cout << "对象占用内存为" << sizeof(p) << endl;
}

当我们多加一个static,那么大小又变回1了,说明静态成员变量不属于类的对象。同理,当一个类同时又静态成员变量和非静态成员变量时,它们的效果不是叠加的,即大小还是4.还有,非静态成员函数和静态成员函数都是不属于类的对象上的。

const修饰成员函数

常函数

  • 成员函数后加const称为常函数

    1
    2
    void 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
    24
    class 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
    31
    class 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
    26
    class 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
    38
    class 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
      14
      class 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
      5
      Person 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
    4
    Person operator+(Person &p, int num) {
    Person temp(p.a + num, p.b + num);
    return temp;
    }

    左移运算符(<<)重载

    我们在输出一个数据的时候,通常会用到cout<<……,但是想要输出一个类,那么这个方法就行不通了。于是乎可以用运算符重载。

    • 成员函数重载

      1
      2
      3
      void operator<<(ostream &cout) {
      cout << this->a << " " << this->b << endl;
      }

      依照上面的加法运算符重载,我们可以照葫芦画瓢。这里传入的参数为cout,而他的类型是ostream(输出流),然后我们就可以在函数体内自定义我们想要输出的格式了。

      但是当我们依照惯例使用cout<<P的时候,报错了。而p<<cout却是对的。因为在成员函数中,这种重载相当于p.operator<<(cout);,显然,这种编写格式不符合我们编写代码的惯例,因此一般不会利用成员函数重载<<

    • 全局函数重载

      1
      2
      3
      4
      ostream& operator<<(ostream &cout,Person &p) {
      cout << p.a << " " << p.b << endl;
      return cout;//链式编程思想
      }

      看到这里不要慌,出现了很多没见过的东西。

      首先,这个函数的类型为ostream&,为什么当函数运行结束时还要返回一个cout呢?我们之前在类对象中说过链式编程的思想,就是一个类反复调用自身的函数,最终得出结果的案例。那么在这里我们就能明显感受到链式编程思想带来的好处。如果这个函数仍然为初始的void,那么我们输出一次就不能继续输出了,cout<<p<<endl这种方式是错误的。而当我们用链式编程思想,就能迎刃而解了。

    递增运算符(++)重载

    递增运算符有两种,一个是前置递增,还有一个是后置递增。

    • 前置递增

      1
      2
      3
      4
      Person& operator++() {//返回引用是为了一直对一个数据进行操作
      num++;
      return *this;
      }

      这里唯一需要注意的就是重载函数的返回值,首先递增必然是返回同一个都对象,其次返回引用。

    • 后置递增

      1
      2
      3
      4
      5
      6
      7
      Person operator++(int) {
      //int代表占位参数,可以用于区分前置和后置递增
      Person temp = *this;
      num++;
      return temp;
      //不能返回引用,因为这个temp是局部对象,执行完就销毁了
      }

      后置递增用到了一个临时创建的类。

    赋值运算符(=)重载

    前文我们说到,编译器至少给一个类提供3个函数:默认无参构造函数,默认拷贝构造函数和默认析构函数。现在我们将再增加一个:赋值运算符重载。

    1
    2
    3
    Person 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
    8
    Person& operator=(Person &p) {//引用返回,以实现连续赋值操作,与左移运算符重载操作一样
    if (age != NULL) {//这里需要判断一下该属性在堆中的内存是不是干净的,如果有,也要即使清理掉,防止后患
    delete age;
    age = NULL;
    }
    age = new int(*p.age);
    return *this;
    }

    与之前的深拷贝类似,我们在进行赋值的时候,不单单要进行值的赋值,还要新建一块内存。

    关系运算符重载

    1
    2
    3
    4
    int operator==(Person p) {
    if (this->age == p.age)return 1;
    else return 0;
    }

    由于这块内容较为简单,这里就给出一个大致参考。包括不等,大于和小于号都可以用这种写法重载。

    函数调用运算符()重载

    这个括号就是函数后面跟着的小括号,其实它也能重载。

    由于重载后使用的方式非常像函数的调用,因此成为仿函数。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class 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
2
class 子类 : 继承方式 父类{
};

其中,子类也称为派生类,父类也成为基类

下面来看具体案例

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
class Father {
public:
void m1() {
cout << "m1" << endl;
}
void m2() {
cout << "m2" << endl;
}
};
class Son:public Father{
public:
void mm1() {
cout << "mm1" << endl;
}
void mm2() {
cout << "mm2" << endl;
}
};
int main() {
Son son;
son.m1();
son.m2();
son.mm1();
return 0;
}

这里父类的方法在子类中都能用到,减少重复的代码,这就是继承的好处。

继承方式

  • 公共继承

    父类的公共属性和保护属性到了子类仍然不变

  • 保护继承

    父类中的公共属性和保护属性到了子类均变为保护属性

  • 私有继承

    父类中的公共属性和保护属性到了子类均变为私有属性

父类中的私有属性在子类中均不可访问,无论用哪种继承方式。

对象模型

那么子类究竟占多大的空间呢?下面我们来讨论一下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Base {
public:
int aa;
int bb;
private:
int cc;
};
class Son :public Base {
public:
int dd;
};
int main() {
Son son;
cout << sizeof(son) << endl;
return 0;
}

运行上面的结果可以看到,结果是16,显而易见,子类中包含父类以及自身的属性。因此我们能得出结论:

父类中所有非静态成员属性都会被子类继承下去,父类中私有成员属性是被编译器给隐藏了,因此访问不到,但是却被继承下去了。

继承中的构造和析构顺序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Base {
public:
Base() {
cout << "Base构造函数" << endl;
}
~Base() {
cout << "Base析构函数" << endl;
}
};
class Son :public Base {
public:
Son() {
cout << "Son构造函数" << endl;
}
~Son() {
cout << "Son析构函数" << endl;
}
};

先构造父类再构造子类,而析构的顺序是相反的。

继承中同名的成员处理方式

当子类和父类拥有同名的属性或者函数时,我们是不能直接访问父类的成员的,直接访问也只能是子类的。那如果我们想访问父类的属性或函数该怎么办呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Base {
public:
int a=20;
void m() {
cout << "Base.m" << endl;
}
};
class Son :public Base {
public:
int a=100;
void m() {
cout << "Son.m" << endl;
}
};
int main() {
Son s;
cout << s.Base::a << endl;
s.Base::m();
return 0;
}

这时候只需加一个作用域即可,一定要注意是两个冒号!

此外,这种形式的本质是:如果子类中出现和父类同名的成员函数,子类的同名成员会隐藏掉父类中所有同名成员函数。

多继承语法

c++允许一个类继承多个类

1
class 子类 : 继承方式 父类1 , 继承方式 父类2 ....

多继承可能会引发父类中有同名成员出现,需要加作用域区分。c++实际开发中不建议使用多继承。

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
class Base1 {
public:
int b=10;
void m() {
cout << "base1.m" << endl;
}
};
class Base2 {
public:
int a=20;
void m() {
cout << "base2.m" << endl;
}
};
class Son :public Base1,public Base2{
public:

};
int main() {
cout << Son().a <<" "<< Son().b << endl;
Son().Base1::m();
Son().Base2::m();//记得加作用域

return 0;
}

菱形继承

两个派生类继承同一个基类,又有某个类同时继承两个派生类。

菱形继承的时候,两个父类有相同数据,需要加以作用域区分。但是这相同的数据只要有一份即可,菱形继承导致数据有两份,资源浪费。

利用虚继承可以解决菱形继承的问题,在继承之前加上关键字 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
    19
    class 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
    19
    class 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
2
3
4
5
class Animal{
public:
void m(){
}
};

这样一个类,我们首先来测一下它的大小,明显是1.但是当我们加上virtual后,大小突变为4.这又是为什么呢?仔细思考可以发现,增加的大小应该是一个指针。它的名字叫做vfptr,全称 virtual function pointer(虚函数指针)这个指针会指向vftable,存放虚函数表,记录虚函数地址。当子类重写父类的函数,子类中的虚函数表内部会替换成子类的虚函数地址。

纯虚函数和抽象类

在多态中,通常父类中虚函数的实现是毫无意义的,主要都是调用子类重写的内容,因此可以把虚函数改为纯虚函数

1
virtual 返回值类型 函数名 (参数列表) =0;

纯虚函数与虚函数主要区别就是大括号变成了=0

当类中有了纯虚函数,这个类也成为抽象类。抽象类无法实例化对象,其子类必须重写抽象类中的纯虚函数,否则也属于抽象类

虚析构和纯虚析构

多态使用时,如果子类中有属性开辟到堆区,那么父类指针在释放时无法调用到子类的析构代码。因此我们需要将父类中的析构函数改为虚析构或者纯虚析构。

下面看一个案例:

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
#include<iostream>
#include<string>
using namespace std;
class Animal {
public:
virtual void speak() = 0;
};
class Cat :public Animal {
public:
string* name;//开辟在堆区的属性
Cat(string name) {
this->name = new string(name);
}
virtual void speak() {
cout << "小猫"<<*name<<"说话" << endl;
}
~Cat() {
if (name != NULL) {
cout << "~Cat" << endl;
delete name;
name = NULL;
}
}
};
void test() {
Animal* a = new Cat("jack");
a->speak();
delete a;
}
int main() {
test();
return 0;
}

我们在cat类中增加了一个堆区的属性,我们在析构的时候需要同时将堆区开辟的内存销毁。但是当我们运行函数时,并没有出现“~Cat”的字样,也就说明没有析构成功,这是怎么回事呢?其实,父类指针在析构的时候,不会调用子类中的析构函数,子类如果有堆区的属性,就会造成内存泄漏。

我们只需要在Animal类中添加一个虚析构函数即可

1
2
3
virtual ~Animal() {

}

同时我们也能增加一个纯虚析构函数

1
virtual ~Animal() = 0;

值得注意的是,纯虚函数在子类必须得有一定的具体实现,我们可以直接在类的外面进行一个空的具体实现,如下。

1
Animal::~Animal(){}

这样,纯虚析构才算完成。而有了纯虚析构之后,这个类也属于抽象类,无法实例化对象。

模板

概念

建立通用的模具,大大提高复用性。模板不能直接使用,它只是一个框架。

函数模板

泛型编程主要的技术就是模板。

函数模板就是建里一个通用函数,其函数返回值类型和形参类型可以不具体,用一个虚拟的类型来代表

语法

1
template<typename T>

当我们写一种复用需求很高的函数,比如两个数交换。但是c++中有很多数据类型,通常我们写的一种交换函数是不适用于另一种数据类型的,这时候我们可以使用模板来防止编写过多重复的函数。

1
2
3
4
5
6
template<typename T>
void Swap(T& a, T& b) {
T temp=a;
a = b;
b = temp;
}

这里的T就相当于一种数据类型,我们直接使用它创建变量。

而当我们需要调用的时候也有两种方式

1
2
3
4
5
6
int a = 10;
int b = 20;
//自动推导
Swap(a, b);
//指定类型
Swap<int>(a, b);

注意事项

  • 自动类型推导必须要推导出一致的数据类型

  • 模板必须要确定出T的数据类型,才可以使用

    一个函数如果不含模板数据类型,那么无法调用这个函数

    1
    2
    3
    4
    template<typename T>
    void fun() {
    //这种方式是不接受的
    }

普通函数与函数模板的区别

  • 普通函数调用时可以发生自动类型转换(隐式类型转换)、

    1
    2
    3
    int add(int a, int b) {
    return a + b;
    }

    这段代码,当我们传入一个字符型变量和一个整型变量,也能得到结果,因为函数吟诗地将字符型转换为了整型(ASCLL码)

  • 函数模板调用时,如果利用自动类型推导,不会发生隐式类型转换

    1
    2
    3
    4
    template<typename T>
    int add(T a, T b) {
    return a + b;
    }

    如果换成这种,就直接报错

  • 如果利用显示指定类型的方式,可以发生隐式类型转换

    1
    2
    3
    int a = 10;
    char c = 'c';
    cout<<add<int>(a, c);

    这样就指定系统为int类型,就可以转换啦

普通函数与函数模板调用规则

  • 如果普通函数和函数模板搜可以实现,优先调用普通用函数

  • 可以通过空模板参数列表来强制调用模板函数

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    template<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
2
3
4
5
6
7
8
9
10
11
class Person {
public:
int age;
};
template<typename T>
void add(T a, T b) {
cout << a + b << endl;
}
template<>void add(Person a, Person b) {//注意!重载的语法
cout << a.age + b.age << endl;
}

这段代码很好的提供了类和其他数据类型共用的模板函数。

类模板

类模板语法与函数模板类似。建立一个通用类,类中的成员和数据类型可以不具体指定,用一个虚拟的类型来代表。

语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
template<class nameType,class ageType>//两个属性,指定两个模板类型
class Person {
public:
nameType name;
ageType age;
Person(nameType name, ageType age) {
this->name = name;
this->age = age;
}
};
void test() {
Person<string, int> p("jack", 18);
}

区别

类模板与函数模板的区别主要有两点:

  • 类模板没有自动类型推导的方式

  • 类模板在模板参数列表中可以有默认参数

    还是上面的案例

    1
    template<class nameType,class ageType = int>

    我们在定义模板的时候可以直接指定某个属性的数据类型,这样在我们实例化对象的时候可以直接省略该处的声明

    1
    Person<string> p("jack", 18);

成员函数创建时机

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Person {
public:
void show() {
cout << "Person show" << endl;
}
};
class Person1 {
public:
void show2() {
cout << "Person1 show2" << endl;
}
};
template<class T>
class A {
public:
T t;
void fun1() {
t.show();
}
void fun2() {
t.show2();
}
};

来看代码,两个Person类我们先不看,我们创建一个A类,其中的属性t用的是模板类,但是系统目前不知道这个T表示的是哪个类,只要不创建对象就不会进入到函数体内,因此可以直接写t.show()

1
A<Person1>a;

然后我们指定模板的类型为Person1,这时候就能识别成Person1的实例对象了,然后我们就能调用Person1中的show2函数了。值得注意的是,如果想要调用Person中的show函数,就会报错,因为类型为Person1与之不兼容。

类模板对象做函数参数

1
2
3
4
5
6
7
8
9
10
11
12
13
template<class t1,class t2>
class Person {
public:
t1 name;
t2 age;
Person(t1 name, t2 age) {
this->name = name;
this->age = age;
}
void show() {
cout << this->name << " " << this->age << endl;
}
};
  • 指定传入类型

    1
    2
    3
    4
    5
    6
    7
    void printPerson(Person<string,int>&p) {//注意参数列表。直接将类型告诉了编译器
    p.show();
    }
    void test() {
    Person<string, int>p("jack", 18);
    printPerson(p);
    }
  • 参数模板化

    1
    2
    3
    4
    template<class t1, class t2>//这个不能少
    void printPerson2(Person<t1,t2>&p) {
    p.show();
    }
  • 类模板化

    1
    2
    3
    4
    template<class t>
    void printPerson3(t &p) {
    p.show();
    }

    在开发中,最常用的还是第一种方式

类模板与继承

当类模板遇到继承时,需要注意以下几点:

  • 当子类继承的父类是一个类模板,子类在声明的时候,要指定出父类中的类型,如果不指定,编译器无法给子类分配内存

    1
    2
    3
    4
    5
    6
    template<class t>
    class Base {
    t num;
    };
    class Son :public Base<int> {//指定数据类型
    };
  • 如果想灵活指定出父类中的类型,子类也需要变成类模板

    1
    2
    3
    4
    5
    6
    7
    template<class t,class t1>//这里的t是父类中的模板,t1是子类中的模板
    class Son1 :public Base<t1> {
    t1 num1;
    };
    void test() {
    Son1<int,char>s;//指定父类和子类中属性的类型
    }

类模板成员函数类外实现

1
2
3
4
5
6
7
8
9
10
11
12
13
template <class t1,class t2>
class Person {
public:
t1 name;
t2 age;
Person(t1 name, t2 age);
void show();
};
template<class t1, class t2>//first
Person<t1, t2>::Person(t1 name, t2 age) {//second
this->name = name;
this->age = age;
}

与普通类函数类外实现不同的是,这里首先要写上模板,然后还要加上Person的作用域,一定不要忘记尖括号里面的内容。

1
2
3
4
template<class t1, class t2>
void Person<t1, t2>::show() {
cout << this->name << " " << this->age;
}

其他非构造函数也是如此。

类模板分文件编写

当一个文件中有非常多的类时,全部都写在这个文件显然是不合理的,会造成阅读源码的不适。我们可以考虑分文件编写,然后引用。

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
#include<iostream>
using namespace std;
template<class t1,class t2>
class person {
public:
t1 name;
t2 age;
person(t1 name, t2 age);
void show();
};
template<class t1, class t2>
person<t1, t2>::person(t1 name, t2 age) {
this->name = name;
this->age = age;
}
template<class t1, class t2>
void person<t1,t2>::show() {
cout << name << " " << age << endl;
}
void test() {
Person<string, int>p("jack", 18);
p.show();
}
int main() {
test();
return 0;
}

这是一个普通的类模板实现,现在我们将它分文件编写。

首先我们在头文件中添加一个名为Person.h的文件,用来存放类模板

1
2
3
4
5
6
7
8
9
10
11
12
//Person.h
#pragma once
#include<iostream>
using namespace std;
template<class T1, class T2>
class Person {
public:
T1 name;
T2 age;
Person(T1 name, T2 age);
void show();
};

以及在源文件中添加一个Person.cpp文件

1
2
3
4
5
6
7
8
9
10
#include"Person.h"//引用
template<class T1, class T2>
Person<T1, T2>::Person(T1 name, T2 age) {
this->name = name;
this->age = age;
}
template<class T1, class T2>
void Person<T1, T2>::show() {
cout << name << " " << age << endl;
}

同时,我们将主函数中的类模板删去,再引用Person.cpp.为什么不能引用.h文件呢,直接引用h相当于少了一个函数的解析,而剩下的内容系统就不知道怎么继续进行下去了。

当然还有另外一种方式,就是将.cpp和.h的内容写到一起,改为hpp文件(约定俗称的文件后缀)

类模板与友元