【C++】C++高级

一、类

1.浅拷贝与深拷贝

浅拷贝

C++在进行浅拷贝时,只拷贝栈区的内存空间,不拷贝堆区的内存空间,即浅拷贝只拷贝非指针的成员变量和指针本身,而不拷贝指针所指向的堆区的内容。

我们代码1.1为例

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
39
40
41
//代码1.1
class Obj
{
public:
Obj(char *tp)
{
len = strlen(tp);
p = (char*)malloc(len + 1);
strcpy_s(p, len+1, tp);
}
~Obj()
{
if (p != NULL)
{
free(p);
p = NULL;
len = 0;
}
}
void Show()
{
cout << *p << *(p + 1) << *(p + 2) << endl;
}
private:
char *p;
int len;
};

void text(Obj &obj)
{
Obj obj2 = obj;
}

int main()
{
Obj obj1("asd");
text(obj1);
obj1.Show();
system("pause");
return 0;
}

我们作一个图示:

​ 我们没有自定义Obj类的拷贝构造函数,所以当代码执行到Obj obj2 = obj1;时,编译器将调用默认的拷贝构造函数,然而, 编译器默认的拷贝构造函数是一个浅拷贝,所以新创建的对象obj2没有自己的堆区空间,obj2.p指向的是obj1.p所指向的内存地址。

​ 上面的代码编译是通不过的, 原因在于,对象析构时,同一个内存地址0x0001被对象obj1和obj2一起析构了两次。当代码执行完test(obj1)时,对象obj2被析构,指针obj2.p所指向的内存地址0x0001被释放,所以当代码执行到obj1.Show()时,使用了已经被释放掉的内存0x0001地址,从而导致运行错误。

这里我有一个疑问,既然浅拷贝在拷贝有指针的对象时,会出现两次析构而出错,所以浅拷贝只能拷贝没有指针成员的对象,那么浅拷贝和深拷贝似乎没有什么区别了,那么浅拷贝存在的意义是什么呢?

深拷贝

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
33
34
35
36
//代码1.2
class Obj
{
public:
Obj(char *tp)
{
len = strlen(tp);
p = (char*)malloc(len + 1);
strcpy_s(p, len+1, tp);
}
//------------------------------
//拷贝构造函数--深拷贝
Obj(const Obj &obj)
{
len = obj.len;
p = (char*)malloc(len + 1);
strcpy_s(p, len + 1, obj.p);
}
//------------------------------
~Obj()
{
if (p != NULL)
{
free(p);
p = NULL;
len = 0;
}
}
void Show()
{
cout << *p << *(p + 1) << *(p + 2) << endl;
}
private:
char *p;
int len;
};

当一个类中定义了拷贝构造函数,则在对象拷贝时,编译器就不会在调用默认的拷贝构造函数转而调用自定义的拷贝构造函数,当我们把代码1.1中类的定义改为代码1.2中类的定义后,程序就可以正常执行了。

使用深拷贝时,我们还需要注意下面的情况:

1
2
3
Obj obj1("asd");
Obj obj3("fgh");
obj3 = obj1;

此时,在obj3=obj1;处依旧调用默认的拷贝构造函数,这里我们要弄清楚obj3=obj1Obj obj3 = obj1之间的区别,obj3=obj1是将obj1赋值给obj3=赋值与拷贝构造函数没有什么关联,=在赋值时是C++编译器自己调用默认的拷贝构造函数—浅拷贝,和类中有无定义深拷贝无关;而Obj obj3=obj1则是使用obj1来构造obj3,此时如果类中定义了深拷贝构造函数,就会使用深拷贝。要解决这个问题,就需要显示重载=运算符了。

小知识

  • 在定义拷贝构造函数时,必须使用引用传递,否则会出现无限拷贝的情况,因为,如果我们使用传值传递的话,在传递对象到拷贝构造函数时,又会调用拷贝构造函数将实参拷贝给形参,而这个过程又会将对象传递给拷贝构造函数,从而在此调用拷贝构造函数将实参拷贝给形参,如此无限循环
  • 拷贝构造函数只能有一个参数,且必须是自身类的引用,否则编译器将识别被普通构造函数

2.初始化参数列表

作用

我们以下面的代码1.3来说明初始化参数列表

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 A
{
public:
A(int a)
{
this->a = a;
}
private:
int a;
};
class B
{
public :
B(int i)
{
this->i = i;
}
int i;
A a;

};
int main()
{
B b(1);
system("pause");
return 0;
}

运行结果:

1
错误	C2512	“A”: 没有合适的默认构造函数可用

​ 这个问题就在于,在类B中组合了一个A类的成员,编译器在构造B类对象时,同时会构造一个A类对象作为B类的成员,然而,因为A类自定义了一个有参的构造函数,所以在构造A类时,编译器不会使用默认构造函数,而是使用自定义的有参构造函数,问题就出在这里,编译器在构造A类时,没有参数传递到A类的有参构造函数中。初始化参数列表就是用于解决这种问题的。

​ 需要说明的是,如果A类中没有自定义有参的构造函数,则在B类构造对象时编译器自动调用A类的默认构造函数构造A类对象成员,就不会报错。

​ 初始化参数列表可以让我们在构造B类对象时,根据参数列表来构造不同的A类成员。

使用

初始化参数列表的使用如下面的B(int i):a1(1),a2(2,"asd")

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
39
40
41
42
43
44
45
46
47
48
49
50
class A
{
public:
A(int a)
{
this->a = a;
cout << "构造小A" << endl;
}
A(int a, string str)
{
this->a = a;
this->str = str;
cout << "构造大A" << endl;
}
~A()
{
cout << "析构A" << endl;
}
int a;
string str;
};
class B
{
public :
B(int i):a1(1),a2(2,"asd")
{
this->i = i;
cout << "构造B" << endl;
}
~B()
{
cout << "析构B" << endl;
}

int i;
A a1;
A a2;
};
void test()
{
B b(1);
cout << b.a1.str << endl;
cout << b.a2.str << endl;
}
int main()
{
test();
system("pause");
return 0;
}

输出结果:

1
2
3
4
5
6
7
8
构造小A
构造大A
构造B

asd
析构B
析构A
析构A

​ 值得注意的是 A类对象的构造顺序不是由初始化参数列表的顺序决定的,而是由对象的申明的前后顺序决定的,如:B(int i):a1(1),a2(2,"asd")B(int i):a2(2,"asd"),a1(1)的构造顺序是一样的,但是当我们将类B中组合的A类对象的申明顺序改为如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class B
{
public :
B(int i):a1(1),a2(2,"asd")
{
this->i = i;
cout << "构造B" << endl;
}
~B()
{
cout << "析构B" << endl;
}

int i;
A a2;
A a1;
};

则构造顺序就变为“先构造a2再构造a1”了。

 析构的顺序和构造的顺序相反。

 小知识

  •  当一个类中组合了其他的类对象作为成员时,拷贝构造函数也必须使用初始化参数列表,来构造对象成员然后拷贝

3.匿名对象的生命周期

什么是匿名对象

1
2
3
4
5
6
7
8
9
10
11
12
13
calss A
{
public:
int a;
A(int a)
{
this->a = a;
}
}
void main()
{
A(1);
}

上面的语句A(1)创建的就是一个匿名临时的对象, 需要注意,如果一个类只有无参的构造函数,那么这个类将无法构建匿名对象,匿名对象的生命周期就只在创建匿名对象的这条语句内,如果我们不使用一个对象来接收这个匿名对象,那么匿名对象会在语句结束时被销毁,当我们使用A a = A(1);不会出现匿名对象拷贝到类B对象b的情况,这种语句已经被C++优化成了类B的构造语句。说这么多其实匿名对象没什么卵用。

4.new和delete

1.new和delete的用法

new可以为基础类型数组分配内存空间,new分配的内存空间都分配在上。随意new出来的内存空间必须使用一个指针来指向,不能使用同类型的变量来接收,也禁止不接受。

new 基础类型

1
2
int *p = new int;
delete p;

new 数组

1
2
int *p = new int[10];
delete[] p;

new 类

1
2
3
4
5
6
//C++
A *p = new A();
delete p;
//C
A *pc = (A*)malloc(A);
free(pc);

new deletemalloc free的区别

  • 在基础类型和基础类型数组方面new deletemalloc free几乎没有什么区别

  • new不仅会分配内存还会调用构造函数,而malloc只会分配内存

  • delete会调用析构函数来销毁对象,而free只是单纯的释放内存

小知识

new deletemalloc free是可以穿插使用的,即new可以和free搭配使用,malloc可以和delete搭配使用。

二、继承

继承这边主要分析一下虚继承

1.虚继承

虚继承的出现主要是为解决如下的继承关系中的二义性问题

图1

当我们的类的继承过程中出现这种继承关系时,我们需要使类B和类C分别虚继承类A来解决二义性,具体操作如下:

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 A
{
public:
int a;
};
class B : virtual public A
{
public:
int b;
};
class C : virtual public A
{
public:
int c;
};
class D : public B,C
{
public:
int d;
};
void main()
{
D d;
d.a = 1;
}

这里有一点要注意,虚继承的应用场景有限,虚继承只能解决这种情况:

而不能解决这种情况:

2.继承中的static关键字

 类中的静态成员变量被类的所有对象共享,同时也被类的派生类的所有对象共享。

三、多态

​ 在C++的几个特性中,封装、继承和抽象都相对好理解,而多态则不太好理解,这里就说说C++的多态。

1.多态分两种:

​ 静态多态:静态多态指的就是函数重载和运算符重载。

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
39
40
41
42
43
44
class Animal
{
public:
string name = "动物";
virtual void speak()
{
cout << name << "在叫" << endl;
}
};

class Dog : public Animal
{
public:
string name = "狗";
void speak()
{
cout << name << "在叫" <<endl;
}
};

string operator+(Animal animal,Dog dog)
{
return animal.name + "是" + dog.name + "的父类";
}

void Run(Animal animal)
{
cout << animal.name <<"在跑" << endl;
}

void Run(Dog dog)
{
cout << dog.name << "在跑" << endl;
}

int main()
{
Animal animal;
Dog dog;
Run(animal);
Run(dog);
cout<<animal+dog<<endl;
system("pause");
}

​ 动态多态:动态多态则是通过继承和虚函数实现标签相同的函数因为传入不同的对象来实现不同的功能。

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
class Animal
{
public:
string name = "动物";
virtual void speak()
{
cout << name << "在叫" << endl;
}
};

class Dog : public Animal
{
public:
string name = "狗";
void speak()
{
cout << name << "在叫" <<endl;
}
};

void Speak(Animal *animal)
{
animal->speak();
}

int main()
{
Animal animal;
Dog dog;
Speak(&animal);
Speak(&dog);
system("pause");
return 0;
}

​ 动态多态中只能用父类对象的指针或引用来指向子类或自身对象。

2.多态的实现原理

​ C++多态的实现依赖于类的虚函数表,当一个类中定义了虚函数,那么这个类就拥有的了一个记录这个虚函数入口地址的虚函数表,子类继承父类时也会继承父类的虚函数表,当子类重写父类的虚函数时,则子类的虚函数入口地址将覆盖父类的地址,如此当子类对象调用此函数时则从子类的虚函数表中寻找入口地址,当父类的对象调用此函数时则从父类的虚函数表中寻找入口地址。

1
2
3
4
5
当类创建虚函数时,编译器会在类中生成一个虚函数表
虚函数表是一个存储类成员函数指针的数据结构
虚函数表有编译器自动生成和维护
虚成员函数会被编译器放入虚函数表中
存在虚函数时,每一个对象中都会拥有一个指向虚函数表的虚函数表指针(vptr)

简单来说,多态实现的条件有三:

  • 要有继承

  • 要有虚函数重写

  • 要有父类指针(或引用)指向子类对象

3.纯虚函数和抽象类

​ 纯虚函数的定义:

1
virtual void speak() = 0

​ 定义了纯虚函数的类就被成为抽象类,C++引入纯虚函数和抽象类的概念就是为了更好的使用多态,抽象类不能实例化对象,这个特性就规范了继承这个抽象类的子类必须重写父类的虚函数,因为如果继承了抽象类的子类不重写父类的虚函数,那么子类也是一个抽象类,子类便也不能实例化对象,如此便规范了多态实现,防止当子类很多时,出现某个子类在编写时忘记重写父类的虚函数,而导致这个子类没有实现多态。

4.虚析构函数和纯虚析构函数

  • 虚析构函数的定义:
1
virtual ~Animal();
  • 纯虚析构函数的定义:
1
2
3
4
5
6
virtual ~Animal() = 0;
//纯虚析构函数必须要有申明也要有实现
Animal::~Animal()
{
//代码实现
}

如果子类在堆区中定义了数据,那么我们使用父类指针或引用来使用多态时,父类指针或引用是无法寻找到子类在堆区中的数据并释放的。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
33
34
35
36
37
38
39
40
41
class Animal
{
public:
Animal()
{
cout << "这是Animal的构造函数" << endl;
}
~Animal()
{
cout << "这是Animal的析构函数" << endl;
}
};

class Dog : public Animal
{
public:
int *p;
Dog()
{
p = new int;
cout << "这是Dog的构造函数" << endl;
}
~Dog()
{
cout << "这是Dog的析构函数" << endl;
if (p != NULL)
{
cout << "释放堆区的p" << endl;
delete p;
p = NULL;
}
}
};

int main()
{
Animal *animal = new Dog();
delete animal;
system("pause");
return 0;
}

输出结果:

1
2
3
这是Animal的构造函数
这是Dog的构造函数
这是Animal的析构函数

可以看到,delete animal后并没有调用Dog的析构函数,释放子类Dog在堆区申请的空间。这样便出现了内存泄漏。

此时虚析构函数和纯虚析构函数便可以起作用了,我们再看一个例子:

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
39
40
41
42
class Animal
{
public:
Animal()
{
cout << "这是Animal的构造函数" << endl;
}
virtual ~Animal()//把父类的析构函数改为虚析构函数
{
cout << "这是Animal的析构函数" << endl;
}
};

class Dog : public Animal
{
public:
int *p;
Dog()
{
p = new int;
cout << "这是Dog的构造函数" << endl;
}
~Dog()
{
cout << "这是Dog的析构函数" << endl;
if (p != NULL)
{
cout << "释放堆区的p" << endl;
delete p;
p = NULL;
}
}
};

int main()
{
Animal *animal = new Dog();
delete animal;
system("pause");
return 0;
}

​ 输出结果:

1
2
3
4
5
这是Animal的构造函数
这是Dog的构造函数
这是Dog的析构函数
释放堆区的p
这是Animal的析构函数

​ 如此便可以释放子类Dog在堆区申请的空间了,纯虚析构函数和虚析构函数的作用是一样,只是纯虚析构函数有一个和纯虚函数一样的特性,即定义了纯虚析构函数的类也属于抽象类,纯虚析构函数必须实现,如果不实现所有继承了拥有纯虚析构函数的抽象类的派生类都属于抽象类。需要注意的是,因为纯虚析构函数的特性,纯虚析构函数的实现就必须在类外实现了。

5.重载、重写、重定义

重载

重载发生在一个类的内部,拥有相同函数名,相同返回值而参数列表不同的函数之间互为重载关系。如:

1
2
3
4
5
6
class A
{
public:
void fun(){}
void fun(int a){}
};

 只有相同函数名而参数列表不同的函数才是重载,函数名相同参数列表也相同而返回值不同的函数在C++中是不允许的。

重写

重写发生在基类和派生类之间,基类中定义虚函数(纯虚函数),派生类中定义和虚函数拥有相同函数名,相同参数列表和相同返回值的函数,这种情况下发生函数重写。如:

1
2
3
4
5
6
7
8
9
10
11
12
class A
{
public:
virtual void fun(){}
};
class B : public A
{
public:
void fun(){}//重写A类的fun函数
void fun(int a){}//重定义一个新函数
int fun(){return 0;}//这种情况C++不允许
};

重定义

重定义也是发生在基类和派生类之间,派生类拥有与基类函数名相同,返回值相同,而参数列表不同的函数,此时发生重定义。如:

1
2
3
4
5
6
7
8
9
10
class A
{
public:
void fun(){}
};
class B : public A
{
public:
void fun(int a){}//发生重定义
};

 派生类中可以重定义基类的任何函数,包括虚函数和纯虚函数。

6.父类指针和子类指针步长不一致问题

问题出现的场景是这样的:

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
class A
{
public:
int a;
virtual void print() {}
};
class B : virtual public A
{
public:
int b;
B(int b)
{
this->b = b;
}
void print()
{
cout << b << endl;
}
};
int main()
{
A *a = NULL;
B *b = NULL;
B array[2]{ B(1),B(2) };
a = array;
b = array;
a->print();
b->print();
a++; b++;
a->print();//这一步会出现异常
b->print();
system("pause");
return 0;
}

即父类指针a和子类指针b都指向一个子类对象数组,于是我们可以通过指针++的自增运算来逐步访问数组元素,问题就出在这,使用sizeof()计算两个类的大小分别得出,sizeof(A)=8;sizeof(B)=20;这就导致A类指针a每一次移步时只移动了8个字节,这个距离还远远没有达到下一个元素的首地址,所以访问会出错,这是因为指针每次移步移动的距离是指针类型的空间大小,如:A类大小为8,所以A类指针每移步一次走8个字节。

可能会有疑问,为什么类B的大小是20?

我们可以算一算,类B继承至类A所以类A中拥有的成员变量,类B也拥有,占8字节,这8字节分别是int变量4字节和虚函数表指针4字节;类B自身定义了一个int变量占4字节,由于类B重写了类A的虚函数,所以类B也拥有一个自己的虚函数表指针,占4字节;类B虚继承了类A,在这个过程中,C++编译器会给类B增加一个属性,占4字节,于是,8+4+4+4+4=20


四、泛型编程

1.函数模板

函数模板的基本语法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//---------------函数模板
template <typename T>//告诉编译器我要开始泛型编程了,遇到T不要报错
void Fun(T &a,T &b)
{
T t = a;
a = b;
b = t;
}
//----------------
int main()
{
char a = 97,b = 102;
Fun(a, b);//自动推导类型调用
cout << a << "," << b << endl;
string x = "xxx", y = "yyy";
Fun<string>(x, y);//显示类型调用
cout << x << "," << y << endl;
system("pause");
return 0;
}

当函数模板遇到函数重载

当函数模板遇到函数重载准许下面4条原则

  • 函数模板可以像普通函数一样被重载
  • 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
template <typename T>
void Fun(T a,T b)
{
a = a + b;
cout << "我是函数模板" << endl;
}
template <typename T1,typename T2>
void Fun(T1 a,T2 b)
{
T1 x = a;
T2 y = b;
cout << "我是函数模板重载" << endl;
}
void Fun(int a, int b)
{
cout << "我是普通函数" << endl;
}
void Fun2(int a, int b)
{
cout << a << "," << b << endl;
}
int main()
{
Fun(1, 2);
Fun(0.1, 0.2);
Fun('c', 1);
Fun<>(1, 2);
Fun2(0.1, 0.2);
Fun2('a', 3);
system("pause");
return 0;
}

输出结果:

1
2
3
4
5
6
我是普通函数
我是函数模板
我是函数模板重载
我是函数模板
0,0
97,3

分析:

  • Fun(1,2):有完全匹配的普通函数,所以调用void Fun(int a, int b)
  • Fun(0.1,0.2):虽然普通函数void Fun(int a, int b)可以像void Fun2(int a, int b)一样进行隐式类型转换调用,倒是Fun()函数有更好的重载函数void Fun(T1 a,T2 b)模板匹配所以编译器优先调用void Fun(T1 a,T2 b)
  • Fun('c',1):编译器能找到匹配的函数模板重载void Fun(T1 a,T2 b)所以优先调用函数模板
  • Fun<>(1,2):使用了空参数列表,告诉编译器只匹配函数模板,即使代码段中有能完美匹配的普通函数,也只调用函数模板

2.类模板

定义

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
template <typename T>//定义
class TemplateA
{
public:
TemplateA(T a)
{
this->a = a;
}
void PrintA()
{
cout << "a:" << a << endl;
}
private:
T a;
};

void Test(TemplateA<int> &a)//类模板作形参
{
a.PrintA();
}

int main()
{
TemplateA<int> a(1);//使用
TemplateA<string> b("str");
TemplateA<bool> c(true);
Test(a);
b.PrintA();
c.PrintA();
system("pause");
return 0;
}

输出结果:

1
2
3
a:1
a:str
a:1

类模板的定义和函数模板的定义类似

使用

类模板的使用必须显示的确定模板的类型参数,如:TemplateA<string> b("str")

类模板作参数

类模板作参数也必须显示的确定模板那的类型参数,如:

void Test(TemplateA<int> &a)以便编译器为形参确定内存空间。

类模板派生普通类

类模板也可以被继承,但是在继承时需要显示确定模板的类型参数

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
template <typename T>
class TemplateA
{
public:
TemplateA(T a)
{
this->a = a;
}
void PrintA()
{
cout << "a:" << a << endl;
}
private:
T a;
};
class B : public TemplateA<int>//类模板派生普通类
{
public:
B(int a, int b) :TemplateA(a)
{
this->b = b;
}
void PrintB()
{
cout << "b:" << b << endl;
}
private:
int b;
};

int main()
{
B b(1, 2);
b.PrintA();
b.PrintB();
system("pause");
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
28
29
30
31
32
33
34
35
36
37
38
39
template <typename T>
class TemplateA
{
public:
TemplateA(T a)
{
this->a = a;
}
void PrintA()
{
cout << "a:" << a << endl;
}
private:
T a;
};
template <typename T1,typename T2>
class TemplateB : public TemplateA<T2>//类模板派生类模板
{
public:
TemplateB(T2 a, T1 b) :TemplateA(a)
{
this->b = b;
}
void PrintB()
{
cout << "b:" << b << endl;
}
private:
T1 b;
};

int main()
{
TemplateB<string,char> b('A', "TemplateB");
b.PrintA();
b.PrintB();
system("pause");
return 0;
}

输出结果:

1
2
a:A
b:TemplateB

类模板的主要作用

类模板的主要作用就是将数据结构的表示和算法不受包含的元素类型的影响,即类模板将元素类型和数据结构算法分离开来了,使数据结构和算法成为真正意义上的数据结构和算法,如:链表不再因为int类型而定义一个int类型的链表,因string类型而定义一个string类型的链表,而是定义一个链表可以通用于所有类型。

五、异常处理

1.异常的基本语法

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
39
40
41
42
43
44
45
46
47
48
49
50
void Try(int x, int y)
{
if (y == 0)
{
cout << "除数不能等于0,抛出异常"<<endl;
throw y;//这里必须指明抛出异常的数据类型,否则程序无法处理异常,只能使用这个类型的变量,x和y的效果是一样的,不能直接抛出int,即这样是不行的throw int
}
cout << "x/y=" << x / y << endl;
}

void Test1()
{
Try(1, 2);
Try(3, 0);
}

void Test2()
{
try
{
Test1();
}
catch (char e)
{
cout << "处理char异常" << endl;
}
catch(...)
{
cout << "无法处理的异常继续往上抛" << endl;
throw;
}
}

int main()
{
try
{
Test2();
}
catch(int e)
{
cout << "处理int异常" << endl;
}
catch (...)
{
cout << "处理其他异常" << endl;
}
system("pause");
return 0;
}

输出结果:

1
2
3
4
x/y=0
除数不能等于0,抛出异常
无法处理的异常继续往上抛
处理int异常
  • 异常的抛出是可以跨函数的,如上面的代码,在Try函数里抛出的异常可以在mian函数中处理,中间跨过了Test1Test2两个函数
  • 如果在一个函数内捕捉到异常但是却无法处理可以通过throw继续向上抛,直至main函数,如上面代码,Test2捕捉到异常但是没有处理继续向上抛给了main函数,如果main函数还是没有处理,则会直接中断程序
  • C++使用cacth(...)来捕捉其他没有捕捉到的异常,如上面代码,main函数中只捕捉了int类型的异常,如果出现其他类型的异常则有cacth(...)来捕捉
  • 异常处理是按照类型匹配来处理的,即throw的int类型的异常只有cacth(int e)能够接收得到,否则就只能使用cacth(...)来接收未知异常

2.C++异常处理的特性

​ C++的异常处理具有跨函数性,这使得 异常引发 异常处理分离开来,这样下层函数可以不用过多的在一异常处理,而把重点放在问题的逻辑处理上,异常处理可以由上层调用者专门来处理。

3.异常接口申明

不抛出任何异常:

1
2
3
4
5
6
7
8
9
void Try(int x, int y) throw()//异常接口申明
{
if (y == 0)
{
cout << "除数不能等于0,抛出异常"<<endl;
throw x;
}
cout << "x/y=" << x / y << endl;
}

只能抛出列表中类型的异常:

1
2
3
4
5
6
7
8
9
void Try(int x, int y) throw(char,int*)
{
if (y == 0)
{
cout << "除数不能等于0,抛出异常"<<endl;
throw x;
}
cout << "x/y=" << x / y << endl;
}

可以抛出任何异常:

1
2
3
4
5
6
7
8
9
void Try(int x, int y)
{
if (y == 0)
{
cout << "除数不能等于0,抛出异常"<<endl;
throw x;
}
cout << "x/y=" << x / y << endl;
}

不过经过测试,三份代码无论是否写throw都是可以抛出并处理异常的,似乎这个语法没什么卵用,可能C++11摒弃了这种用法,但是考虑到兼容保留这个语法。

4.异常接收的3种方式

普通形参

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 A
{
public:
A() { cout << "构造A" << endl; }
A(const A &a) { cout << "拷贝A" << endl; }
~A() { cout << "析构A" << endl; }
};

void Try()
{
A a;
throw a;
}

int main()
{
try
{
Try();
}
catch(A e)//使用普通形参
{
cout << "处理int异常" << endl;
}
system("pause");
return 0;
}

输出结果:

1
2
3
4
5
6
7
构造A
拷贝A
拷贝A
析构A
处理int异常
析构A
析构A

可以看到a被拷贝两次,第一次从A a拷贝到throw a,第二次从throw a拷贝到catch(A e),可以看出如果使用普通形参来接收异常,异常变量会由异常抛出处拷贝到异常接收处。

引用

将上面代码的catch(A e)改为catch(A &e)

输出结果:

1
2
3
4
5
构造A
拷贝A
析构A
处理int异常
析构A

可以看到只拷贝了一次,即从A a拷贝到throw a

指针

上面代码应该修改为如下

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
class A
{
public:
A() { cout << "构造A" << endl; }
A(const A &a) { cout << "拷贝A" << endl; }
~A() { cout << "析构A" << endl; }
};

void Try()
{
A *a = new A();//对象必须创建在堆区,函数结束对象就会被销毁,而无法通过指针传递到catch中
throw a;
}

int main()
{
try
{
Try();
}
catch(A *e)
{
cout << "处理int异常" << endl;
delete e;//需要手动释放堆区的内存
}
system("pause");
return 0;
}

输出结果:

1
2
3
构造A
处理int异常
析构A

可以看到使用指针完全不需要拷贝,但是却需要消耗堆区的内存且容易造成内存泄露。

总结

总的来说,最优的方式还是使用引用。

5.继承在异常处理中的应用

在实际的项目中我们处理的异常并不是一些基础的数据类型,大多都是开发者的自定义类,这种情况在捕捉异常的时候就相当麻烦,尽管有些异常处理起来程序基本一致,但是却要将每一种异常一一捕捉并一一处理,下面的代码我们来模拟一下这种情况。

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 A//A类实现输入一个范围在0-10的奇数
{
public:
A(int a)
{
if (a % 2 != 0) {
if (a < 0)throw LtZero();
if (a > 10)throw GtTen();
else num = a;
}
else throw Even();
}
private:
int num;
};
class LtZero//专门处理异常的异常类
{};
class GtTen
{};
class Even
{};

int main()
{
try { A a(4); }
catch (LtZero &lz) { cout << "输入的数小于0" << endl; }
catch (GtTen &gt) { cout << "输入的数大于10" << endl; }
catch (Even &ev) { cout << "输入的数是偶数" << endl; }
catch (...) { cout << "其他异常" << endl; }
system("pause");
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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class A//A类实现输入一个范围在0-10的奇数
{
public:
A(int a)
{
if (a % 2 != 0) {
if (a < 0)throw LtZero();
if (a > 10)throw GtTen();
else num = a;
}
else throw Even();
}

class Even
{
public:
virtual void PrintErro()
{
cout<< "输入的数是偶数" << endl;
}
};
class LtZero:public Even
{
public:
void PrintErro()
{
cout << "输入的数小于0" << endl;
}
};
class GtTen:public Even
{
public:
void PrintErro()
{
cout << "输入的数大于10" << endl;
}
};
private:
int num;
};

int main()
{
try { A a(4); }
catch (A::Even e) { e.PrintErro(); }
catch (...) { cout << "其他异常" << endl; }
system("pause");
return 0;
}

可以看到我们的异常处理模块使用多态,繁杂程度被大幅缩水了,而我们的异常处理被集中在了异常处理类中,有时,如果我们的异常处理只对某一个类有效也是可以直接将异常处理类定义在抛出类里面的。

6.标准异常库

C++提供一些标准的异常库,头文件为:#include

六、标准IO流

1.标准IO流流程

2.标准输入流

标准函数 作用
cin cin>>操作支持任何基本类型的输入,但是遇到空格则结束读取
cin.get(char chr) 从缓冲区中读取一个字符到chr中,因为C++定义的cin.get(char)中会返回一个函数自身的引用,所以此函数支持链式编程,即cin.get(a).get(b).get(c);b表示依次从缓冲区中读取三个字符到a,b,c中
cin.get(char* buf,int cout) 从缓冲区中读取cout个字符到buf数组中,因为函数似乎会在数组末尾添加点什么,所以实际读取的字符数量是cout-1个,此函数也支持链式编程
cin.get(char* buf,int cout,char chr) 从缓冲区中读取cout个字符到buf数组中,如果碰到字符chr则结束读取
cin.getline(char buf,int cout) 从缓冲区中读取cout个字符到buf数组中
cin.ignore(int num) 忽略缓冲区当前读取指针开始的num个字符再读取
cin.peek() 判断缓冲区中是否有数据,如果有则返回第一个字符,如果没有则阻塞程序
cin.putback(char chr) 将读取出来的字符再返存回缓冲区,只能读取一个字符

3.标准输出流

标准函数 作用
cout 输出缓冲区内容,支持任何基本类型数据的输出
cout.flush(void) 刷新缓冲区,无视系统繁忙,强制输出缓冲区的字符,语法和cout一样“cout.flush()<<buf<<endl;”
cout.put(char chr) 在标准输出设备输出指针的当前位置插入字符chr,语法和cout一致
cout.write(char *chr,int cout) 输出*chr所指向空间中cout个数量的字符,即使越界也会继续输出,语法和cout一致
cout.width(int num) 输出num个字节宽度的字符,一般配合cout.fill(char chr)和其他cout函数一起使用
cout.fill(char chr) 配合cout.width(int num)和其他cout一起使用,在输出的num宽度的字符中将cout函数没有填充完的字符用chr填充
cout.setf(标记) 格式化cout输出,标记种类很多,具体的可以查阅资料,配合cout函数一起使用

4.文件IO流

​ 文件操作相对来说比较简单,总的来说就是5个步骤,即

操作步骤

  • ​ 包含头文件#include

  • ​ 创建流对象

  • ​ 打开文件

  • ​ 读写文件

    需要注意的是C++中文件写的方式是使用符号“<<”,如:fout << “文件内容”<<endl;

    同理文件读也可以使用“>>”来读。

  • ​ 关闭文件

    文件写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <iostream>
#include <string>
#include <fstream>

using namespace std;

void test()
{
ofstream fout;
fout.open("文件测试.txt", ios::out);
fout << "姓名:张三" << endl;
fout << "性别:男" << endl;
fout.close();
}

int main()
{
test();
system("pause");
return 0;
}

输出结果:

4种文件读的方式

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
39
40
41
42
43
44
45
46
47
48
49
#include <iostream>
#include <string>
#include <fstream>

using namespace std;

void test()
{
ifstream fin;
fin.open("文件测试.txt", ios::in);
if (!fin.is_open())//文件读需要多添加一步判断文件是否打开成功的步骤
{
cout << "文件打开失败" << endl;
}
char str[1024];
string strs;
char chr;
//第一中方式
//while (fin >> str)//操作符">>"每次只能读取一行数据,读到文件尾“EOF”时结束
//{
// cout << str << endl;
//}

//第二种方式
//while (fin.getline(str, 50))//ifstream::getline(char *str,int num);这个函数只支持字符数组,参数num指的是需要读取的字节数
//{
// cout << str << endl;
//}

//第三种方式
//while (getline(fin, strs))//与第二种方式不同的是,这个getline函数是全局的,且只支持输出到string类型的对象中
//{
// cout << strs << endl;
//}

//第四中方式
while ((chr = fin.get()) != EOF)//get()函数每次只能读取一个字符
{
cout << chr ;
}
fin.close();
}

int main()
{
test();
system("pause");
return 0;
}

文件的打开模式

​ C++提供6中文件的打开方式

打开方式 解释
ios::in 以读的形式打开
ios::out 以写的形式打开,会覆盖源文件
ios::ate 以写的形式打开并初始文件位置:文件尾,会覆盖源文件
ios::app 以追加的方式打开文件
ios::trunc 如果文件存在先删除再创建
ios::binary 以二进制的形式打开

读写二进制文件

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
39
40
41
#include <iostream>
#include <string>
#include <fstream>

using namespace std;

class Person
{
public:
string name = "张三";
string sex = "男";
};

void test()
{
ofstream fout;
fout.open("二进制文件测试.txt", ios::out | ios::binary);
Person p;
fout.write((char *)&p, sizeof(Person));//注意这里使用ofstream::write()来写
fout.close;

ifstream fin;
fin.open("二进制文件测试.txt", ios::in | ios::binary);
if (!fin.is_open())
{
cout << "文件打开错误" << endl;
return;
}
Person pin;
fin.read((char *)&pin, sizeof(Person));//注意这里使用ofstream::read()来读
cout << "姓名:" << pin.name << endl;
cout << "性别:" << pin.sex << endl;
fin.close();
}

int main()
{
test();
system("pause");
return 0;
}

本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!