C++面向对象续
1.继承与派生
1.1 相关概念
**继承(Inheritance)**可以理解为一个类从另一个类获取成员变量和成员函数的过程。例如类 B 继承于类 A,那么 B 就拥有 A 的成员变量和成员函数。
在C++中,**派生(Derive)**和继承是一个概念,只是站的角度不同。继承是儿子接收父亲的产业,派生是父亲把产业传承给儿子。
被继承的类称为父类或基类,继承的类称为子类或派生类。“子类”和“父类”通常放在一起称呼,“基类”和“派生类”通常放在一起称呼。
以下是两种典型的使用继承的场景:
-
当你创建的新类与现有的类相似,只是多出若干成员变量或成员函数时,可以使用继承,这样不但会减少代码量,而且新类会拥有基类的所有功能。
-
当你需要创建多个类,它们拥有很多相似的成员变量或成员函数时,也可以使用继承。可以将这些类的共同成员提取出来,定义为基类,然后从基类继承,既可以节省代码,也方便后续修改成员。
我们来看一个案例:
#include<iostream>
using namespace std;
//基类 Pelple
class People{
public:
void setname(char *name);
void setage(int age);
char *getname();
int getage();
private:
char *m_name;
int m_age;
};
void People::setname(char *name){ m_name = name; }
void People::setage(int age){ m_age = age; }
char* People::getname(){ return m_name; }
int People::getage(){ return m_age;}
//派生类 Student
class Student: public People{
public:
void setscore(float score);
float getscore();
private:
float m_score;
};
void Student::setscore(float score){ m_score = score; }
float Student::getscore(){ return m_score; }
int main(){
Student stu;
stu.setname("小明");
stu.setage(16);
stu.setscore(95.5f);
cout<<stu.getname()<<"的年龄是 "<<stu.getage()<<",成绩是 "<<stu.getscore()<<endl;
return 0;
}
运行结果:
小明的年龄是 16,成绩是 95.5
本例中,People 是基类,Student 是派生类。Student 类继承了 People 类的成员,同时还新增了自己的成员变量 score 和成员函数 setscore()、getscore()。这些继承过来的成员,可以通过子类对象访问,就像自己的一样。
请认真观察代码第21行:
class Student: public People
这就是声明派生类的语法。class 后面的“Student”是新声明的派生类,冒号后面的“People”是已经存在的基类。在“People”之前有一关键宇 public,用来表示是公有继承。
继承方式包括 public(公有的)、private(私有的)和 protected(受保护的),此项是可选的,如果不写,那么默认为 private。我们将在下节详细讲解这些不同的继承方式。
1.2 三种继承方式
C++继承的一般语法为:
class 派生类名:[继承方式] 基类名{
派生类新增加的成员
};
继承方式限定了基类成员在派生类中的访问权限,包括 public(公有的)、private(私有的)和 protected(受保护的)。此项是可选项,如果不写,默认为 private(成员变量和成员函数默认也是 private)。
现在我们知道,public、protected、private 三个关键字除了可以修饰类的成员,还可以指定继承方式。
public、protected、private 修饰类的成员
类成员的访问权限由高到低依次为 public --> protected --> private,我们在《C++类成员的访问权限以及类的封装》一节中讲解了 public 和 private:public 成员可以通过对象来访问,private 成员不能通过对象访问。
现在再来补充一下 protected。protected 成员和 private 成员类似,也不能通过对象访问。但是当存在继承关系时,protected 和 private 就不一样了:基类中的 protected 成员可以在派生类中使用,而基类中的 private 成员不能在派生类中使用,下面是详细讲解。
public、protected、private 指定继承方式
不同的继承方式会影响基类成员在派生类中的访问权限。
1) public继承方式
- 基类中所有 public 成员在派生类中为 public 属性;
- 基类中所有 protected 成员在派生类中为 protected 属性;
- 基类中所有 private 成员在派生类中不能使用。
2) protected继承方式
- 基类中的所有 public 成员在派生类中为 protected 属性;
- 基类中的所有 protected 成员在派生类中为 protected 属性;
- 基类中的所有 private 成员在派生类中不能使用。
3) private继承方式
- 基类中的所有 public 成员在派生类中均为 private 属性;
- 基类中的所有 protected 成员在派生类中均为 private 属性;
- 基类中的所有 private 成员在派生类中不能使用。
总结:共有继承派生类里面不改变继承以后基类的属性,保护继承的话除了私有继承以外均变成保护属性,私有继承在派生类里面全部私有。私有属性不管在什么继承里面都是基类私有,派生类不能访问。
也就是说,继承方式中的 public、protected、private 是用来指明基类成员在派生类中的最高访问权限的。
不管继承方式如何,基类中的 private 成员在派生类中始终不能使用(不能在派生类的成员函数中访问或调用)。
下表汇总了不同继承方式对不同属性的成员的影响结果
继承方式/基类成员 | public成员 | protected成员 | private成员 |
---|---|---|---|
public继承 | public | protected | 不可见 |
protected继承 | protected | protected | 不可见 |
private继承 | private | private | 不可见 |
我们来看一个案例:
#include<iostream>
using namespace std;
//基类People
class People{
public:
void setname(char *name);
void setage(int age);
void sethobby(char *hobby);
char *gethobby();
protected:
char *m_name;
int m_age;
private:
char *m_hobby;
};
void People::setname(char *name){ m_name = name; }
void People::setage(int age){ m_age = age; }
void People::sethobby(char *hobby){ m_hobby = hobby; }
char *People::gethobby(){ return m_hobby; }
//派生类Student
class Student: public People{
public:
void setscore(float score);
protected:
float m_score;
};
void Student::setscore(float score){ m_score = score; }
//派生类Pupil
class Pupil: public Student{
public:
void setranking(int ranking);
void display();
private:
int m_ranking;
};
void Pupil::setranking(int ranking){ m_ranking = ranking; }
void Pupil::display(){
cout<<m_name<<"的年龄是"<<m_age<<",考试成绩为"<<m_score<<"分,班级排名第"<<m_ranking<<",TA喜欢"<<gethobby()<<"。"<<endl;
}
int main(){
Pupil pup;
pup.setname("小明");
pup.setage(15);
pup.setscore(92.5f);
pup.setranking(4);
pup.sethobby("乒乓球");
pup.display();
return 0;
}
运行结果:
小明的年龄是15,考试成绩为92.5分,班级排名第4,TA喜欢乒乓球。
我们还可以改变访问权限:
使用 using 关键字可以改变基类成员在派生类中的访问权限,例如将 public 改为 private、将 protected 改为 public。
注意:using 只能改变基类中 public 和 protected 成员的访问权限,不能改变 private 成员的访问权限,因为基类中 private 成员在派生类中是不可见的,根本不能使用,所以基类中的 private 成员在派生类中无论如何都不能访问。
class Student : public People {
public:
void learning();
public:
using People::m_name; //将protected改为public
using People::m_age; //将protected改为public
float m_score;
private:
using People::show; //将public改为private
};
void Student::learning() {
cout << "我是" << m_name << ",今年" << m_age << "岁,这次考了" << m_score << "分!" << endl;
}
1.3 继承当中的覆盖问题
如果派生类中的成员(包括成员变量和成员函数)和基类中的成员重名,那么就会遮蔽从基类继承过来的成员。所谓遮蔽,就是在派生类中使用该成员。就相当于只调用了派生类里面的成员。
这种方法叫做覆盖重写。
#include<iostream>
using namespace std;
//基类People
class People{
public:
void show();
protected:
char *m_name;
int m_age;
};
void People::show(){
cout<<"嗨,大家好,我叫"<<m_name<<",今年"<<m_age<<"岁"<<endl;
}
//派生类Student
class Student: public People{
public:
Student(char *name, int age, float score);
public:
void show(); //遮蔽基类的show()
private:
float m_score;
};
Student::Student(char *name, int age, float score){
m_name = name;
m_age = age;
m_score = score;
}
void Student::show(){
cout<<m_name<<"的年龄是"<<m_age<<",成绩是"<<m_score<<endl;
}
int main(){
Student stu("小明", 16, 90.5);
//使用的是派生类新增的成员函数,而不是从基类继承的
stu.show();
//使用的是从基类继承来的成员函数
stu.People::show();
return 0;
}
运行结果:
小明的年龄是16,成绩是90.5
嗨,大家好,我叫小明,今年16岁
本例中,基类 People 和派生类 Student 都定义了成员函数 show(),它们的名字一样,会造成遮蔽。第 37 行代码中,stu 是 Student 类的对象,默认使用 Student 类的 show() 函数。
但是,基类 People 中的 show() 函数仍然可以访问,不过要加上类名和域解析符。
基类成员和派生类成员的名字一样时会造成遮蔽,这句话对于成员变量很好理解,对于成员函数要引起注意,不管函数的参数如何,只要名字一样就会造成遮蔽。换句话说,基类成员函数和派生类成员函数不会构成重载,如果派生类有同名函数,那么就会遮蔽基类中的所有同名函数,不管它们的参数是否一样。
其实,继承的本质就是:作用域能够彼此包含,被包含(或者说被嵌套)的作用域称为内层作用域(inner scope),包含着别的作用域的作用域称为外层作用域(outer scope)。一旦在外层作用域中声明(或者定义)了某个名字,那么它所嵌套着的所有内层作用域中都能访问这个名字。同时,允许在内层作用域中重新定义外层作用域中已有的名字。
1.4 继承与派生的构造函数
前面我们说基类的成员函数可以被继承,可以通过派生类的对象访问,但这仅仅指的是普通的成员函数,类的构造函数不能被继承。
这种矛盾在C++继承中是普遍存在的,解决这个问题的思路是:在派生类的构造函数中调用基类的构造函数。
看一个例子,怎么在派生类里面调用基类的构造函数:
#include<iostream>
using namespace std;
//基类People
class People{
protected:
char *m_name;
int m_age;
public:
People(char*, int);
};
People::People(char *name, int age): m_name(name), m_age(age){}
//派生类Student
class Student: public People{
private:
float m_score;
public:
Student(char *name, int age, float score);
void display();
};
//People(name, age)就是调用基类的构造函数
Student::Student(char *name, int age, float score): People(name, age), m_score(score){ }
void Student::display(){
cout<<m_name<<"的年龄是"<<m_age<<",成绩是"<<m_score<<"。"<<endl;
}
int main(){
Student stu("小明", 16, 90.5);
stu.display();
return 0;
}
运行结果为:
小明的年龄是16,成绩是90.5。
请注意第 23 行代码:
Student::Student(char *name, int age, float score): People(name, age), m_score(score){ }
People(name, age)
就是调用基类的构造函数,并将 name 和 age 作为实参传递给它,m_score(score)
是派生类的参数初始化表,它们之间以逗号,
隔开。
构造函数的调用顺序
从上面的分析中可以看出,基类构造函数总是被优先调用,这说明创建派生类对象时,会先调用基类构造函数,再调用派生类构造函数,如果继承关系有好几层的话,例如:
A --> B --> C
那么创建 C 类对象时构造函数的执行顺序为:
A类构造函数 --> B类构造函数 --> C类构造函数
构造函数的调用顺序是按照继承的层次自顶向下、从基类再到派生类的。
就相当于构造函数的调用顺序是从上到下的
但是,还有一点要注意,派生类构造函数中只能调用直接基类的构造函数,不能调用间接基类的。以上面的 A、B、C 类为例,C 是最终的派生类,B 就是 C 的直接基类,A 就是 C 的间接基类。
事实上,通过派生类创建对象时必须要调用基类的构造函数,这是语法规定。换句话说,定义派生类构造函数时最好指明基类构造函数;如果不指明,就调用基类的默认构造函数(不带参数的构造函数);如果没有默认构造函数,那么编译失败。
1.5 继承与派生的析构函数
和构造函数类似,析构函数也不能被继承。与构造函数不同的是,在派生类的析构函数中不用显式地调用基类的析构函数,因为每个类只有一个析构函数,编译器知道如何选择,无需程序员干涉。
另外析构函数的执行顺序和构造函数的执行顺序也刚好相反:
- 创建派生类对象时,构造函数的执行顺序和继承顺序相同,即先执行基类构造函数,再执行派生类构造函数。
- 而销毁派生类对象时,析构函数的执行顺序和继承顺序相反,即先执行派生类析构函数,再执行基类析构函数。
就相当于构造函数反过来。
#include <iostream>
using namespace std;
class A{
public:
A(){cout<<"A constructor"<<endl;}
~A(){cout<<"A destructor"<<endl;}
};
class B: public A{
public:
B(){cout<<"B constructor"<<endl;}
~B(){cout<<"B destructor"<<endl;}
};
class C: public B{
public:
C(){cout<<"C constructor"<<endl;}
~C(){cout<<"C destructor"<<endl;}
};
int main(){
C test;
return 0;
}
运行结果:
A constructor
B constructor
C constructor
C destructor
B destructor
A destructor
1.6 多继承
在前面的例子中,派生类都只有一个基类,称为单继承(Single Inheritance)。除此之外,C++也支持多继承(Multiple Inheritance),即一个派生类可以有两个或多个基类。
多继承的语法也很简单,将多个基类用逗号隔开即可。例如已声明了类A、类B和类C,那么可以这样来声明派生类D:
class D: public A, private B, protected C{
//类D新增加的成员
}
D 是多继承形式的派生类,它以公有的方式继承 A 类,以私有的方式继承 B 类,以保护的方式继承 C 类。D 根据不同的继承方式获取 A、B、C 中的成员,确定它们在派生类中的访问权限。
多继承形式下的构造函数和单继承形式基本相同,只是要在派生类的构造函数中调用多个基类的构造函数。以上面的 A、B、C、D 类为例,D 类构造函数的写法为:
D(形参列表): A(实参列表), B(实参列表), C(实参列表){
//其他操作
}
基类构造函数的调用顺序和和它们在派生类构造函数中出现的顺序无关,而是和声明派生类时基类出现的顺序相同。仍然以上面的 A、B、C、D 类为例,即使将 D 类构造函数写作下面的形式:
D(形参列表): B(实参列表), C(实参列表), A(实参列表){
//其他操作
}
那么也是先调用 A 类的构造函数,再调用 B 类构造函数,最后调用 C 类构造函数。
看一个例子:
#include <iostream>
using namespace std;
//基类
class BaseA{
public:
BaseA(int a, int b);
~BaseA();
protected:
int m_a;
int m_b;
};
BaseA::BaseA(int a, int b): m_a(a), m_b(b){
cout<<"BaseA constructor"<<endl;
}
BaseA::~BaseA(){
cout<<"BaseA destructor"<<endl;
}
//基类
class BaseB{
public:
BaseB(int c, int d);
~BaseB();
protected:
int m_c;
int m_d;
};
BaseB::BaseB(int c, int d): m_c(c), m_d(d){
cout<<"BaseB constructor"<<endl;
}
BaseB::~BaseB(){
cout<<"BaseB destructor"<<endl;
}
//派生类
class Derived: public BaseA, public BaseB{
public:
Derived(int a, int b, int c, int d, int e);
~Derived();
public:
void show();
private:
int m_e;
};
Derived::Derived(int a, int b, int c, int d, int e): BaseA(a, b), BaseB(c, d), m_e(e){
cout<<"Derived constructor"<<endl;
}
Derived::~Derived(){
cout<<"Derived destructor"<<endl;
}
void Derived::show(){
cout<<m_a<<", "<<m_b<<", "<<m_c<<", "<<m_d<<", "<<m_e<<endl;
}
int main(){
Derived obj(1, 2, 3, 4, 5);
obj.show();
return 0;
}
运行结果:
BaseA constructor
BaseB constructor
Derived constructor
1, 2, 3, 4, 5
Derived destructor
BaseB destructor
BaseA destructor
从运行结果中还可以发现,多继承形式下析构函数的执行顺序和构造函数的执行顺序相反。
总结:多继承下执行顺序是先调用基类的构造函数,再调用派生类的构造函数,再调用相关方法,最后再回到析构函数。
命名冲突
当两个或多个基类中有同名的成员时,如果直接访问该成员,就会产生命名冲突,编译器不知道使用哪个基类的成员。这个时候需要在成员名字前面加上类名和域解析符::
,以显式地指明到底使用哪个类的成员,消除二义性。
1.7 虚继承与虚基类
多继承(Multiple Inheritance)是指从多个直接基类中产生派生类的能力,多继承的派生类继承了所有父类的成员。
多继承时很容易产生命名冲突,即使我们很小心地将所有类中的成员变量和成员函数都命名为不同的名字,命名冲突依然有可能发生,比如典型的是菱形继承。
在一个派生类中保留间接基类的多份同名成员,虽然可以在不同的成员变量中分别存放不同的数据,但大多数情况下这是多余的:因为保留多份成员变量不仅占用较多的存储空间,还容易产生命名冲突。假如类 A 有一个成员变量 a,那么在类 D 中直接访问 a 就会产生歧义,编译器不知道它究竟来自 A -->B–>D 这条路径,还是来自 A–>C–>D 这条路径。下面是菱形继承的具体实现:
//间接基类A
class A{
protected:
int m_a;
};
//直接基类B
class B: public A{
protected:
int m_b;
};
//直接基类C
class C: public A{
protected:
int m_c;
};
//派生类D
class D: public B, public C{
public:
void seta(int a){ m_a = a; } //命名冲突
void setb(int b){ m_b = b; } //正确
void setc(int c){ m_c = c; } //正确
void setd(int d){ m_d = d; } //正确
private:
int m_d;
};
int main(){
D d;
return 0;
}
这段代码实现了上图所示的菱形继承,第 25 行代码试图直接访问成员变量 m_a,结果发生了错误,因为类 B 和类 C 中都有成员变量 m_a(从 A 类继承而来),编译器不知道选用哪一个,所以产生了歧义。
为了消除歧义,我们可以在 m_a 的前面指明它具体来自哪个类:
void seta(int a){ B::m_a = a; }
这样表示使用 B 类的 m_a。当然也可以使用 C 类的:
void seta(int a){ C::m_a = a; }
为了解决命名冲突的问题,我们引入了新的东西:虚基类与虚继承。
虚继承(Virtual Inheritance)
为了解决多继承时的命名冲突和冗余数据问题,C++ 提出了虚继承,使得在派生类中只保留一份间接基类的成员。
//间接基类A
class A{
protected:
int m_a;
};
//直接基类B
class B: virtual public A{ //虚继承
protected:
int m_b;
};
//直接基类C
class C: virtual public A{ //虚继承
protected:
int m_c;
};
//派生类D
class D: public B, public C{
public:
void seta(int a){ m_a = a; } //正确
void setb(int b){ m_b = b; } //正确
void setc(int c){ m_c = c; } //正确
void setd(int d){ m_d = d; } //正确
private:
int m_d;
};
int main(){
D d;
return 0;
}
这段代码使用虚继承重新实现了上图所示的菱形继承,这样在派生类 D 中就只保留了一份成员变量 m_a,直接访问就不会再有歧义了。
虚继承的目的是让某个类做出声明,承诺愿意共享它的基类。其中,这个被共享的基类就称为虚基类(Virtual Base Class),本例中的 A 就是一个虚基类。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含一份虚基类的成员。
我们会发现虚继承的一个不太直观的特征:必须在虚派生的真实需求出现前就已经完成虚派生的操作。在上图中,当定义 D 类时才出现了对虚派生的需求,但是如果 B 类和 C 类不是从 A 类虚派生得到的,那么 D 类还是会保留 A 类的两份成员。
换个角度讲,虚派生只影响从指定了虚基类的派生类中进一步派生出来的类,它不会影响派生类本身。
1.8 虚继承的构造函数
虚基类是由最终的派生类初始化的,换句话说,最终派生类的构造函数必须要调用虚基类的构造函数。对最终的派生类来说,虚基类是间接基类,而不是直接基类。这跟普通继承不同,在普通继承中,派生类构造函数中只能调用直接基类的构造函数,不能调用间接基类的。
我们看看代码:
#include <iostream>
using namespace std;
//虚基类A
class A{
public:
A(int a);
protected:
int m_a;
};
A::A(int a): m_a(a){ }
//直接派生类B
class B: virtual public A{
public:
B(int a, int b);
public:
void display();
protected:
int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }
void B::display(){
cout<<"m_a="<<m_a<<", m_b="<<m_b<<endl;
}
//直接派生类C
class C: virtual public A{
public:
C(int a, int c);
public:
void display();
protected:
int m_c;
};
C::C(int a, int c): A(a), m_c(c){ }
void C::display(){
cout<<"m_a="<<m_a<<", m_c="<<m_c<<endl;
}
//间接派生类D
class D: public B, public C{
public:
D(int a, int b, int c, int d);
public:
void display();
private:
int m_d;
};
D::D(int a, int b, int c, int d): A(a), B(90, b), C(100, c), m_d(d){ }
void D::display(){
cout<<"m_a="<<m_a<<", m_b="<<m_b<<", m_c="<<m_c<<", m_d="<<m_d<<endl;
}
int main(){
B b(10, 20);
b.display();
C c(30, 40);
c.display();
D d(50, 60, 70, 80);
d.display();
return 0;
}
在最终派生类 D 的构造函数中,除了调用 B 和 C 的构造函数,还调用了 A 的构造函数,这说明 D 不但要负责初始化直接基类 B 和 C,还要负责初始化间接基类 A。而在以往的普通继承中,派生类的构造函数只负责初始化它的直接基类,再由直接基类的构造函数初始化间接基类,用户尝试调用间接基类的构造函数将导致错误。
1.9 向上转型
类其实也是一种数据类型,也可以发生数据类型转换,不过这种转换只有在基类和派生类之间才有意义,并且只能将派生类赋值给基类,包括将派生类对象赋值给基类对象、将派生类指针赋值给基类指针、将派生类引用赋值给基类引用,这在 C++ 中称为向上转型(Upcasting)。相应地,将基类赋值给派生类称为向下转型(Downcasting)。
将派生类对象赋值给基类对象
下面的例子演示了如何将派生类对象赋值给基类对象:
#include <iostream>
using namespace std;
//基类
class A{
public:
A(int a);
public:
void display();
public:
int m_a;
};
A::A(int a): m_a(a){ }
void A::display(){
cout<<"Class A: m_a="<<m_a<<endl;
}
//派生类
class B: public A{
public:
B(int a, int b);
public:
void display();
public:
int m_b;
};
B::B(int a, int b): A(a), m_b(b){ }
void B::display(){
cout<<"Class B: m_a="<<m_a<<", m_b="<<m_b<<endl;
}
int main(){
A a(10);
B b(66, 99);
//赋值前
a.display();
b.display();
cout<<"--------------"<<endl;
//赋值后
a = b;
a.display();
b.display();
return 0;
}
运行结果:
Class A: m_a=10
Class B: m_a=66, m_b=99
----------------------------
Class A: m_a=66
Class B: m_a=66, m_b=99
本例中 A 是基类, B 是派生类,a、b 分别是它们的对象,由于派生类 B 包含了从基类 A 继承来的成员,因此可以将派生类对象 b 赋值给基类对象 a。通过运行结果也可以发现,赋值后 a 所包含的成员变量的值已经发生了变化。
赋值的本质是将现有的数据写入已分配好的内存中,对象的内存只包含了成员变量,所以对象之间的赋值是成员变量的赋值,成员函数不存在赋值问题。运行结果也有力地证明了这一点,虽然有a=b;
这样的赋值过程,但是 a.display() 始终调用的都是 A 类的 display() 函数。换句话说,对象之间的赋值不会影响成员函数,也不会影响 this 指针。
这种转换关系是不可逆的,只能用派生类对象给基类对象赋值,而不能用基类对象给派生类对象赋值。理由很简单,基类不包含派生类的成员变量,无法对派生类的成员变量赋值。同理,同一基类的不同派生类对象之间也不能赋值。
2.多态与虚函数
“多态(polymorphism)”指的是同一名字的事物可以完成不同的功能。多态可以分为编译时的多态和运行时的多态。前者主要是指函数的重载(包括运算符的重载)、对重载函数的调用,在编译时就能根据实参确定应该调用哪个函数,因此叫编译时的多态;而后者则和继承、虚函数等概念有关,是本章要讲述的内容。本教程后面提及的多态都是指运行时的多态。
2.1 多态
基类的指针也可以指向派生类对象,请看下面的例子:
#include <iostream>
using namespace std;
//基类People
class People{
public:
People(char *name, int age);
void display();
protected:
char *m_name;
int m_age;
};
People::People(char *name, int age): m_name(name), m_age(age){}
void People::display(){
cout<<m_name<<"今年"<<m_age<<"岁了,是个无业游民。"<<endl;
}
//派生类Teacher
class Teacher: public People{
public:
Teacher(char *name, int age, int salary);
void display();
private:
int m_salary;
};
Teacher::Teacher(char *name, int age, int salary): People(name, age), m_salary(salary){}
void Teacher::display(){
cout<<m_name<<"今年"<<m_age<<"岁了,是一名教师,每月有"<<m_salary<<"元的收入。"<<endl;
}
int main(){
People *p = new People("王志刚", 23);
p -> display();
p = new Teacher("赵宏佳", 45, 8200);
p -> display();
return 0;
}
运行结果:
王志刚今年23岁了,是个无业游民。
赵宏佳今年45岁了,是个无业游民。
我们直观上认为,如果指针指向了派生类对象,那么就应该使用派生类的成员变量和成员函数,这符合人们的思维习惯。但是本例的运行结果却告诉我们,当基类指针 p 指向派生类 Teacher 的对象时,虽然使用了 Teacher 的成员变量,但是却没有使用它的成员函数,导致输出结果不伦不类(赵宏佳本来是一名老师,输出结果却显示人家是个无业游民),不符合我们的预期。
换句话说,通过基类指针只能访问派生类的成员变量,但是不能访问派生类的成员函数。
为了消除这种尴尬,让基类指针能够访问派生类的成员函数,C++ 增加了虚函数(Virtual Function)。使用虚函数非常简单,只需要在函数声明前面增加 virtual 关键字。
#include <iostream>
using namespace std;
//基类People
class People{
public:
People(char *name, int age);
virtual void display(); //声明为虚函数
protected:
char *m_name;
int m_age;
};
People::People(char *name, int age): m_name(name), m_age(age){}
void People::display(){
cout<<m_name<<"今年"<<m_age<<"岁了,是个无业游民。"<<endl;
}
//派生类Teacher
class Teacher: public People{
public:
Teacher(char *name, int age, int salary);
virtual void display(); //声明为虚函数
private:
int m_salary;
};
Teacher::Teacher(char *name, int age, int salary): People(name, age), m_salary(salary){}
void Teacher::display(){
cout<<m_name<<"今年"<<m_age<<"岁了,是一名教师,每月有"<<m_salary<<"元的收入。"<<endl;
}
int main(){
People *p = new People("王志刚", 23);
p -> display();
p = new Teacher("赵宏佳", 45, 8200);
p -> display();
return 0;
}
运行结果:
王志刚今年23岁了,是个无业游民。
赵宏佳今年45岁了,是一名教师,每月有8200元的收入。
和前面的例子相比,本例仅仅是在 display() 函数声明前加了一个virtual
关键字,将成员函数声明为了虚函数(Virtual Function),这样就可以通过 p 指针调用 Teacher 类的成员函数了,运行结果也证明了这一点(赵宏佳已经是一名老师了,不再是无业游民了)。
有了虚函数,基类指针指向基类对象时就使用基类的成员(包括成员函数和成员变量),指向派生类对象时就使用派生类的成员。换句话说,基类指针可以按照基类的方式来做事,也可以按照派生类的方式来做事,它有多种形态,或者说有多种表现方式,我们将这种现象称为多态(Polymorphism)。
总结:使用虚函数就可以解决了多态里面的采用哪一个派生类的方法进行执行。
多态是面向对象编程的主要特征之一,C++中虚函数的唯一用处就是构成多态。
C++提供多态的目的是:可以通过基类指针对所有派生类(包括直接派生和间接派生)的成员变量和成员函数进行“全方位”的访问,尤其是成员函数。如果没有多态,我们只能访问成员变量。
我们借助引用也可以。
int main(){
People p("王志刚", 23);
Teacher t("赵宏佳", 45, 8200);
People &rp = p;
People &rt = t;
rp.display();
rt.display();
return 0;
}
运行结果:
王志刚今年23岁了,是个无业游民。
赵宏佳今年45岁了,是一名教师,每月有8200元的收入。
2.2 虚函数构成条件
这节我们来重点说一下虚函数的注意事项。
-
只需要在虚函数的声明处加上 virtual 关键字,函数定义处可以加也可以不加。
-
为了方便,你可以只将基类中的函数声明为虚函数,这样所有派生类中具有遮蔽关系的同名函数都将自动成为虚函数。关于名字遮蔽已在《C++继承时的名字遮蔽》一节中进行了讲解。
-
当在基类中定义了虚函数时,如果派生类没有定义新的函数来遮蔽此函数,那么将使用基类的虚函数。
-
只有派生类的虚函数覆盖基类的虚函数(函数原型相同)才能构成多态(通过基类指针访问派生类函数)。例如基类虚函数的原型为
virtual void func();
,派生类虚函数的原型为virtual void func(int);
,那么当基类指针 p 指向派生类对象时,语句p -> func(100);
将会出错,而语句p -> func();
将调用基类的函数。 -
构造函数不能是虚函数。对于基类的构造函数,它仅仅是在派生类构造函数中被调用,这种机制不同于继承。也就是说,派生类不继承基类的构造函数,将构造函数声明为虚函数没有什么意义。
-
析构函数可以声明为虚函数,而且有时候必须要声明为虚函数,这点我们将在下节中讲解。
下面是构成多态的条件:
- 必须存在继承关系;
- 继承关系中必须有同名的虚函数,并且它们是覆盖关系(函数原型相同)。
- 存在基类的指针,通过该指针调用虚函数。
上节我们讲到,构造函数不能是虚函数,因为派生类不能继承基类的构造函数,将构造函数声明为虚函数没有意义。
这是原因之一,另外还有一个原因:C++ 中的构造函数用于在创建对象时进行初始化工作,在执行构造函数之前对象尚未创建完成,虚函数表尚不存在,也没有指向虚函数表的指针,所以此时无法查询虚函数表,也就不知道要调用哪一个构造函数。
在实际开发中,一旦我们自己定义了析构函数,就是希望在对象销毁时用它来进行清理工作,比如释放内存、关闭文件等,如果这个类又是一个基类,那么我们就必须将该析构函数声明为虚函数,否则就有内存泄露的风险。也就是说,大部分情况下都应该将基类的析构函数声明为虚函数。
2.3 纯虚函数与抽象
在C++中,可以将虚函数声明为纯虚函数,语法格式为:
virtual 返回值类型 函数名 (函数参数) = 0;
纯虚函数没有函数体,只有函数声明,在虚函数声明的结尾加上=0
,表明此函数为纯虚函数。
最后的
=0
并不表示函数返回值为0,它只起形式上的作用,告诉编译系统“这是纯虚函数”。
包含纯虚函数的类称为抽象类(Abstract Class)。
纯虚函数没有函数体,不是完整的函数,无法调用,也无法为其分配内存空间。
抽象类通常是作为基类,让派生类去实现纯虚函数。派生类必须实现纯虚函数才能被实例化。
也就是说抽象类必须要用一个函数进行实例化,并且覆盖重写虚函数。
#include <iostream>
using namespace std;
//线
class Line{
public:
Line(float len);
virtual float area() = 0;
virtual float volume() = 0;
protected:
float m_len;
};
Line::Line(float len): m_len(len){ }
//矩形
class Rec: public Line{
public:
Rec(float len, float width);
float area();
protected:
float m_width;
};
Rec::Rec(float len, float width): Line(len), m_width(width){ }
float Rec::area(){ return m_len * m_width; }
//长方体
class Cuboid: public Rec{
public:
Cuboid(float len, float width, float height);
float area();
float volume();
protected:
float m_height;
};
Cuboid::Cuboid(float len, float width, float height): Rec(len, width), m_height(height){ }
float Cuboid::area(){ return 2 * ( m_len*m_width + m_len*m_height + m_width*m_height); }
float Cuboid::volume(){ return m_len * m_width * m_height; }
//正方体
class Cube: public Cuboid{
public:
Cube(float len);
float area();
float volume();
};
Cube::Cube(float len): Cuboid(len, len, len){ }
float Cube::area(){ return 6 * m_len * m_len; }
float Cube::volume(){ return m_len * m_len * m_len; }
int main(){
Line *p = new Cuboid(10, 20, 30);
cout<<"The area of Cuboid is "<<p->area()<<endl;
cout<<"The volume of Cuboid is "<<p->volume()<<endl;
p = new Cube(15);
cout<<"The area of Cube is "<<p->area()<<endl;
cout<<"The volume of Cube is "<<p->volume()<<endl;
return 0;
}
运行结果:
The area of Cuboid is 2200
The volume of Cuboid is 6000
The area of Cube is 1350
The volume of Cube is 3375
本例中定义了四个类,它们的继承关系为:Line --> Rec --> Cuboid --> Cube。
Line 是一个抽象类,也是最顶层的基类,在 Line 类中定义了两个纯虚函数 area() 和 volume()。
在 Rec 类中,实现了 area() 函数;所谓实现,就是定义了纯虚函数的函数体。但这时 Rec 仍不能被实例化,因为它没有实现继承来的 volume() 函数,volume() 仍然是纯虚函数,所以 Rec 也仍然是抽象类。
直到 Cuboid 类,才实现了 volume() 函数,才是一个完整的类,才可以被实例化。
纯虚函数的几点说明
-
一个纯虚函数就可以使类成为抽象基类,但是抽象基类中除了包含纯虚函数外,还可以包含其它的成员函数(虚函数或普通函数)和成员变量。
-
只有类中的虚函数才能被声明为纯虚函数,普通成员函数和顶层函数均不能声明为纯虚函数。
3.运算符重载
3.1 基本内容
所谓重载,就是赋予新的含义。函数重载(Function Overloading)可以让一个函数名有多种功能,在不同情况下进行不同的操作。**运算符重载(Operator Overloading)**也是一个道理,同一个运算符可以有不同的功能。
实际上,我们已经在不知不觉中使用了运算符重载。例如,+
号可以对不同类型(int、float 等)的数据进行加法操作;<<
既是位移运算符,又可以配合 cout 向控制台输出数据。C++ 本身已经对这些运算符进行了重载。
我们先看一个例子:
#include <iostream>
using namespace std;
class complex{
public:
complex();
complex(double real, double imag);
public:
//声明运算符重载
complex operator+(const complex &A) const;
void display() const;
private:
double m_real; //实部
double m_imag; //虚部
};
complex::complex(): m_real(0.0), m_imag(0.0){ }
complex::complex(double real, double imag): m_real(real), m_imag(imag){ }
//实现运算符重载
complex complex::operator+(const complex &A) const{
complex B;
B.m_real = this->m_real + A.m_real;
B.m_imag = this->m_imag + A.m_imag;
return B;
}
void complex::display() const{
cout<<m_real<<" + "<<m_imag<<"i"<<endl;
}
int main(){
complex c1(4.3, 5.8);
complex c2(2.4, 3.7);
complex c3;
c3 = c1 + c2;
c3.display();
return 0;
}
运行结果:
6.7 + 9.5i
本例中义了一个复数类 complex,m_real 表示实部,m_imag 表示虚部,第 10 行声明了运算符重载,第 21 行进行了实现(定义)。认真观察这两行代码,可以发现运算符重载的形式与函数非常类似。
运算符重载其实就是定义一个函数,在函数体内实现想要的功能,当用到该运算符时,编译器会自动调用这个函数。也就是说,运算符重载是通过函数实现的,它本质上是函数重载。
运算符重载的格式为:
返回值类型 operator 运算符名称 (形参表列){
//TODO:
}
operator
是关键字,专门用于定义重载运算符的函数。我们可以将operator 运算符名称
这一部分看做函数名,对于上面的代码,函数名就是operator+
。
operator
是关键字,专门用于定义重载运算符的函数。我们可以将operator 运算符名称
这一部分看做函数名,对于上面的代码,函数名就是operator+
。
运算符重载函数除了函数名有特定的格式,其它地方和普通函数并没有区别。
上面的例子中,我们在 complex 类中重载了运算符+
,该重载只对 complex 对象有效。当执行c3 = c1 + c2;
语句时,编译器检测到+
号左边(+
号具有左结合性,所以先检测左边)是一个 complex 对象,就会调用成员函数operator+()
,也就是转换为下面的形式:
c3 = c1.operator+(c2);
c1 是要调用函数的对象,c2 是函数的实参。
上面的运算符重载还可以有更加简练的定义形式:
complex complex::operator+(const complex &A)const{ return complex(this->m_real + A.m_real, this->m_imag + A.m_imag);}
return 语句中的complex(this->m_real + A.m_real, this->m_imag + A.m_imag)
会创建一个临时对象,这个对象没有名称,是一个匿名对象。在创建临时对象过程中调用构造函数,return 语句将该临时对象作为函数返回值。
运算符重载函数不仅可以作为类的成员函数,还可以作为全局函数。更改上面的代码,在全局范围内重载+
,实现复数的加法运算:
#include <iostream>
using namespace std;
class complex{
public:
complex();
complex(double real, double imag);
public:
void display() const;
//声明为友元函数
friend complex operator+(const complex &A, const complex &B);
private:
double m_real;
double m_imag;
};
complex operator+(const complex &A, const complex &B);
complex::complex(): m_real(0.0), m_imag(0.0){ }
complex::complex(double real, double imag): m_real(real), m_imag(imag){ }
void complex::display() const{
cout<<m_real<<" + "<<m_imag<<"i"<<endl;
}
//在全局范围内重载+
complex operator+(const complex &A, const complex &B){
complex C;
C.m_real = A.m_real + B.m_real;
C.m_imag = A.m_imag + B.m_imag;
return C;
}
int main(){
complex c1(4.3, 5.8);
complex c2(2.4, 3.7);
complex c3;
c3 = c1 + c2;
c3.display();
return 0;
}
运算符重载函数不是 complex 类的成员函数,但是却用到了 complex 类的 private 成员变量,所以必须在 complex 类中将该函数声明为友元函数。
当执行c3 = c1 + c2;
语句时,编译器检测到+
号两边都是 complex 对象,就会转换为类似下面的函数调用:
c3 = operator+(c1, c2);
3.2 遵循规则
-
并不是所有的运算符都可以重载。能够重载的运算符包括:
+ - * / % ^ & | ~ ! = < > += -= = /= %= ^= &= |= << >> <<= >>= == != <= >= && || ++ – , -> -> () [] new new[] delete delete[] -
重载不能改变运算符的优先级和结合性。假设上一节的 complex 类中重载了
+
号和*
号,并且 c1、c2、c3、c4 都是 complex 类的对象,那么下面的语句:
c4 = c1 + c2 * c3;
等价于:
c4 = c1 + ( c2 * c3 );
3.3 流对象重载
在C++中,标准库本身已经对左移运算符<<
和右移运算符>>
分别进行了重载,使其能够用于不同数据的输入输出,但是输入输出的对象只能是 C++ 内置的数据类型(例如 bool、int、double 等)和标准库所包含的类类型(例如 string、complex、ofstream、ifstream 等)。
cout 是 ostream 类的对象,cin 是 istream 类的对象,要想达到这个目标,就必须以全局函数(友元函数)的形式重载<<
和>>
,否则就要修改标准库中的类,这显然不是我们所期望的。
重载输入运算符>> 与 <<
下面我们以全局函数的形式重载>>
,使它能够读入两个 double 类型的数据,并分别赋值给复数的实部和虚部:
-
1. istream & **operator**>>(istream &in, complex &A){ 2. in >> A.m_real >> A.m_imag; 3. **return** in; 4. }
同样地,我们也可以模仿上面的形式对输出运算符>>
进行重载,让它能够输出复数,请看下面的代码:
ostream & operator<<(ostream &out, complex &A){ out << A.m_real <<" + "<< A.m_imag <<" i "; return out;}
ostream 表示输出流,cout 是 ostream 类的对象。由于采用了引用的方式进行参数传递,并且也返回了对象的引用,所以重载后的运算符可以实现连续输出。
为了能够直接访问 complex 类的 private 成员变量,同样需要将该函数声明为 complex 类的友元函数:
friend ostream & operator<<(ostream &out, complex &A);
3.4 []重载
下标运算符[ ]
必须以成员函数的形式进行重载。该重载函数在类中的声明格式如下:
返回值类型 & operator[ ] (参数);
或者:
const 返回值类型 & operator[ ] (参数) const;
3.5 自增自减运算符重载
自增++
和自减--
都是一元运算符,它的前置形式和后置形式都可以被重载。请看下面的例子:
#include <iostream>
#include <iomanip>
using namespace std;
//秒表类
class stopwatch{
public:
stopwatch(): m_min(0), m_sec(0){ }
public:
void setzero(){ m_min = 0; m_sec = 0; }
stopwatch run(); // 运行
stopwatch operator++(); //++i,前置形式
stopwatch operator++(int); //i++,后置形式
friend ostream & operator<<( ostream &, const stopwatch &);
private:
int m_min; //分钟
int m_sec; //秒钟
};
stopwatch stopwatch::run(){
++m_sec;
if(m_sec == 60){
m_min++;
m_sec = 0;
}
return *this;
}
stopwatch stopwatch::operator++(){
return run();
}
stopwatch stopwatch::operator++(int n){
stopwatch s = *this;
run();
return s;
}
ostream &operator<<( ostream & out, const stopwatch & s){
out<<setfill('0')<<setw(2)<<s.m_min<<":"<<setw(2)<<s.m_sec;
return out;
}
int main(){
stopwatch s1, s2;
s1 = s2++;
cout << "s1: "<< s1 <<endl;
cout << "s2: "<< s2 <<endl;
s1.setzero();
s2.setzero();
s1 = ++s2;
cout << "s1: "<< s1 <<endl;
cout << "s2: "<< s2 <<endl;
return 0;
}
运行结果:
s1: 00:00
s2: 00:01
s1: 00:01
s2: 00:01
operator++() 函数实现自增的前置形式,直接返回 run() 函数运行结果即可。
operator++ (int n) 函数实现自增的后置形式。
自减运算符一模一样。
4.C++模板
我们进入之前,我们先看一个函数:
//交换 int 变量的值
void Swap(int *a, int *b){
int temp = *a;
*a = *b;
*b = temp;
}
//交换 float 变量的值
void Swap(float *a, float *b){
float temp = *a;
*a = *b;
*b = temp;
}
//交换 char 变量的值
void Swap(char *a, char *b){
char temp = *a;
*a = *b;
*b = temp;
}
//交换 bool 变量的值
void Swap(bool *a, bool *b){
char temp = *a;
*a = *b;
*b = temp;
}
是不是发现一个函数名有很多个,看着就烦。
我们就引入了这样的话题:模板。
4.1 函数模板
在C++中,数据的类型也可以通过参数来传递,在函数定义时可以不指明具体的数据类型,当发生函数调用时,编译器可以根据传入的实参自动推断数据类型。这就是类型的参数化。
值(Value)和类型(Type)是数据的两个主要特征,它们在C++中都可以被参数化。
所谓函数模板,实际上是建立一个通用函数,它所用到的数据的类型(包括返回值类型、形参类型、局部变量类型)可以不具体指定,而是用一个虚拟的类型来代替(实际上是用一个标识符来占位),等发生函数调用时再根据传入的实参来逆推出真正的类型。这个通用函数就称为函数模板(Function Template)。
在函数模板中,数据的值和类型都被参数化了,发生函数调用时编译器会根据传入的实参来推演形参的值和类型。换个角度说,函数模板除了支持值的参数化,还支持类型的参数化。
一但定义了函数模板,就可以将类型参数用于函数定义和函数声明了。说得直白一点,原来使用 int、float、char 等内置类型的地方,都可以用类型参数来代替。
#include <iostream>
using namespace std;
template<typename T> void Swap(T *a, T *b){
T temp = *a;
*a = *b;
*b = temp;
}
int main(){
//交换 int 变量的值
int n1 = 100, n2 = 200;
Swap(&n1, &n2);
cout<<n1<<", "<<n2<<endl;
//交换 float 变量的值
float f1 = 12.5, f2 = 56.93;
Swap(&f1, &f2);
cout<<f1<<", "<<f2<<endl;
//交换 char 变量的值
char c1 = 'A', c2 = 'B';
Swap(&c1, &c2);
cout<<c1<<", "<<c2<<endl;
//交换 bool 变量的值
bool b1 = false, b2 = true;
Swap(&b1, &b2);
cout<<b1<<", "<<b2<<endl;
return 0;
}
运行结果:
200, 100
56.93, 12.5
B, A
1, 0
请读者重点关注第 4 行代码。template
是定义函数模板的关键字,它后面紧跟尖括号<>
,尖括号包围的是类型参数(也可以说是虚拟的类型,或者说是类型占位符)。typename
是另外一个关键字,用来声明具体的类型参数,这里的类型参数就是T
。从整体上看,template<typename T>
被称为模板头。
在讲解C++函数重载时我们还没有学到引用(Reference),为了达到交换两个变量的值的目的只能使用指针,而现在我们已经对引用进行了深入讲解,不妨趁此机会来实践一把,使用引用重新实现 Swap() 这个函数模板:
#include <iostream>
using namespace std;
template<typename T> void Swap(T &a, T &b){
T temp = a;
a = b;
b = temp;
}
int main(){
//交换 int 变量的值
int n1 = 100, n2 = 200;
Swap(n1, n2);
cout<<n1<<", "<<n2<<endl;
//交换 float 变量的值
float f1 = 12.5, f2 = 56.93;
Swap(f1, f2);
cout<<f1<<", "<<f2<<endl;
//交换 char 变量的值
char c1 = 'A', c2 = 'B';
Swap(c1, c2);
cout<<c1<<", "<<c2<<endl;
//交换 bool 变量的值
bool b1 = false, b2 = true;
Swap(b1, b2);
cout<<b1<<", "<<b2<<endl;
return 0;
}
下面我们来总结一下定义模板函数的语法:
template <typename 类型参数1 , typename 类型参数2 , ...> 返回值类型 函数名(形参列表){
//在函数体中可以使用类型参数
}
类型参数可以有多个,它们之间以逗号,
分隔。类型参数列表以< >
包围,形式参数列表以( )
包围。
typename
关键字也可以使用class
关键字替代,它们没有任何区别。
4.2 类模板
C++ 除了支持函数模板,还支持类模板(Class Template)。函数模板中定义的类型参数可以用在函数声明和函数定义中,类模板中定义的类型参数可以用在类声明和类实现中。类模板的目的同样是将数据的类型参数化。
声明类模板的语法为:
template<typename 类型参数1 , typename 类型参数2 , …> class 类名{
//TODO:
};
类模板和函数模板都是以 template 开头(当然也可以使用 class,目前来讲它们没有任何区别),后跟类型参数;类型参数不能为空,多个类型参数用逗号隔开。
一但声明了类模板,就可以将类型参数用于类的成员函数和成员变量了。换句话说,原来使用 int、float、char 等内置类型的地方,都可以用类型参数来代替。
上面的代码仅仅是类的声明,我们还需要在类外定义成员函数。在类外定义成员函数时仍然需要带上模板头,格式为:
template<typename 类型参数1 , typename 类型参数2 , …>
返回值类型 类名<类型参数1 , 类型参数2, ...>::函数名(形参列表){
//TODO:
}
第一行是模板头,第二行是函数头,它们可以合并到一行,不过为了让代码格式更加清晰,一般是将它们分成两行。
-
1. **template**<**typename** T1, **typename** T2> //模板头 2. T1 Point<T1, T2>::getX() **const** /*函数头*/ { 3. **return** m_x; 4. } 5. 6. **template**<**typename** T1, **typename** T2> 7. void Point<T1, T2>::setX(T1 x){ 8. m_x = x; 9. } 10. 11. **template**<**typename** T1, **typename** T2> 12. T2 Point<T1, T2>::getY() **const**{ 13. **return** m_y; 14. } 15. 16. **template**<**typename** T1, **typename** T2> 17. void Point<T1, T2>::setY(T2 y){ 18. m_y = y; 19. }
使用类模板创建对象
上面的两段代码完成了类的定义,接下来就可以使用该类创建对象了。使用类模板创建对象时,需要指明具体的数据类型。请看下面的代码:
纯文本复制
Point<int, int> p1(10, 20);
Point<int, float> p2(10, 15.5);
Point<float, char*> p3(12.4, "东经180度");
与函数模板不同的是,类模板在实例化时必须显式地指明数据类型,编译器不能根据给定的数据推演出数据类型。
光看看是不行的,不知道怎么用,所以我们看一个综合案例:
#include <iostream>
using namespace std;
template<class T1, class T2> //这里不能有分号
class Point{
public:
Point(T1 x, T2 y): m_x(x), m_y(y){ }
public:
T1 getX() const; //获取x坐标
void setX(T1 x); //设置x坐标
T2 getY() const; //获取y坐标
void setY(T2 y); //设置y坐标
private:
T1 m_x; //x坐标
T2 m_y; //y坐标
};
template<class T1, class T2> //模板头
T1 Point<T1, T2>::getX() const /*函数头*/ {
return m_x;
}
template<class T1, class T2>
void Point<T1, T2>::setX(T1 x){
m_x = x;
}
template<class T1, class T2>
T2 Point<T1, T2>::getY() const{
return m_y;
}
template<class T1, class T2>
void Point<T1, T2>::setY(T2 y){
m_y = y;
}
int main(){
Point<int, int> p1(10, 20);
cout<<"x="<<p1.getX()<<", y="<<p1.getY()<<endl;
Point<int, char*> p2(10, "东经180度");
cout<<"x="<<p2.getX()<<", y="<<p2.getY()<<endl;
Point<char*, char*> *p3 = new Point<char*, char*>("东经180度", "北纬210度");
cout<<"x="<<p3->getX()<<", y="<<p3->getY()<<endl;
return 0;
}
运行结果:
x=10, y=20
x=10, y=东经180度
x=东经180度, y=北纬210度
5.异常
我们看似程序没有什么问题,但是结果通常是bug!bug!bug!
我们在C++里面引入了异常机制。
程序运行时常会碰到一些错误,例如除数为 0、年龄为负数、数组下标越界等,这些错误如果不能发现并加以处理,很可能会导致程序崩溃。
C++ 异常处理机制就可以让我们捕获并处理这些错误,然后我们可以让程序沿着一条不会出错的路径继续执行,或者不得不结束程序,但在结束前可以做一些必要的工作,例如将内存中的数据写入文件、关闭打开的文件、释放分配的内存等。
C++ 异常处理机制会涉及 try、catch、throw 三个关键字,本章将为你一一讲解。
5.1 异常捕获
我们可以借助 C++ 异常机制来捕获上面的异常,避免程序崩溃。捕获异常的语法为:
try{
// 可能抛出异常的语句
}catch(exceptionType variable){
// 处理异常的语句
}
try
和catch
都是 C++ 中的关键字,后跟语句块,不能省略{ }
。try 中包含可能会抛出异常的语句,一旦有异常抛出就会被后面的 catch 捕获。从 try 的意思可以看出,它只是“检测”语句块有没有异常,如果没有发生异常,它就“检测”不到。
从 try 的意思可以看出,它只是“检测”语句块有没有异常,如果没有发生异常,它就“检测”不到。catch 是“抓住”的意思,用来捕获并处理 try 检测到的异常;如果 try 语句块没有检测到异常(没有异常抛出),那么就不会执行 catch 中的语句。
这就好比,catch 告诉 try:你去检测一下程序有没有错误,有错误的话就告诉我,我来处理,没有的话就不要理我!
我们来看一个案例:
#include <iostream>
#include <string>
#include <exception>
using namespace std;
int main(){
string str = "http://c.biancheng.net";
try{
char ch1 = str[100];
cout<<ch1<<endl;
}catch(exception e){
cout<<"[1]out of bound!"<<endl;
}
try{
char ch2 = str.at(100);
cout<<ch2<<endl;
}catch(exception &e){ //exception类位于<exception>头文件中
cout<<"[2]out of bound!"<<endl;
}
return 0;
}
发生异常时必须将异常明确地抛出,try 才能检测到;如果不抛出来,即使有异常 try 也检测不到。所谓抛出异常,就是明确地告诉程序发生了什么错误。
说得直接一点,检测到异常后程序的执行流会发生跳转,从异常点跳转到 catch 所在的位置,位于异常点之后的、并且在当前 try 块内的语句就都不会再执行了;即使 catch 语句成功地处理了错误,程序的执行流也不会再回退到异常点,所以这些语句永远都没有执行的机会了。
执行完 catch 块所包含的代码后,程序会继续执行 catch 块后面的代码,就恢复了正常的执行流。
关于「如何抛出异常」,我们将在下节讲解,这里重点是让大家明白异常的处理流程:
抛出(Throw)–> 检测(Try) --> 捕获(Catch)
发生异常后,程序的执行流会沿着函数的调用链往前回退,直到遇见 try 才停止。在这个回退过程中,调用链中剩下的代码(所有函数中未被执行的代码)都会被跳过,没有执行的机会了。
当然,我们也可以使用多个catch块来处理异常。
try{
//可能抛出异常的语句
}catch (exception_type_1 e){
//处理异常的语句
}catch (exception_type_2 e){
//处理异常的语句
}
//其他的catch
catch (exception_type_n e){
//处理异常的语句
}
实例:
#include <iostream>
#include <string>
using namespace std;
class Base{ };
class Derived: public Base{ };
int main(){
try{
throw Derived(); //抛出自己的异常类型,实际上是创建一个Derived类型的匿名对象
cout<<"This statement will not be executed."<<endl;
}catch(int){
cout<<"Exception type: int"<<endl;
}catch(char *){
cout<<"Exception type: cahr *"<<endl;
}catch(Base){ //匹配成功(向上转型)
cout<<"Exception type: Base"<<endl;
}catch(Derived){
cout<<"Exception type: Derived"<<endl;
}
return 0;
}
实际上, catch 在匹配异常类型时发生了向上转型(Upcasting)。
上述代码结果是:Exception type: Base
5.2 异常抛出
在 C++ 中,我们使用 throw 关键字来显式地抛出异常,它的用法为:
throw exceptionData;
exceptionData 是“异常数据”的意思,它可以包含任意的信息,完全有程序员决定。exceptionData 可以是 int、float、bool 等基本类型,也可以是指针、数组、字符串、结构体、类等聚合类型
throw 关键字除了可以用在函数体中抛出异常,还可以用在函数头和函数体之间,指明当前函数能够抛出的异常类型,这称为异常规范
double func (char param) throw (int);
这条语句声明了一个名为 func 的函数,它的返回值类型为 double,有一个 char 类型的参数,并且只能抛出 int 类型的异常。如果抛出其他类型的异常,try 将无法捕获,只能终止程序。
如果函数会抛出多种类型的异常,那么可以用逗号隔开:
double func (char param) throw (int, char, exception);
如果函数不会抛出任何异常,那么( )
中什么也不写:
double func (char param) throw ();
如此,func() 函数就不能抛出任何类型的异常了,即使抛出了,try 也检测不到
但是,异常规范的初衷实现起来有点困难,所以大家达成的一致意见是,最好不要使用异常规范。
异常规范是 C++98 新增的一项功能,但是后来的 C++11 已经将它抛弃了,不再建议使用。