C++学习笔记(15)——类的继承

面向对象编程的主要目的之一是提供可重用的代码。可重用意味着我们不希望修改之前写好的类,类的继承使得在不修改原有的类内容的基础上可以开发新的内容。同时,还可以在不提供类的具体实现方式的前提下完成这一工作,也是对知识产权的一种保护。本篇笔记将总结类的继承的相关知识点。同样,按照惯例,将在总结知识的过程中设计一个具有某种实际意义的类。

一、从一个软件需求入手

公司的人力资源处要求写一个员工信息管理的软件。以C++面向对象的思想,我们应该基于数据抽象来设计类,这个过程也即类的封装。要求表示员工的信息包括姓名,年龄,入职时间,工龄(通过计算得到),工资(根据职称计算得到)等。员工大概分为助理工程师,工程师、高级工程师和研究员。

由于每类员工有不同的工资计算模板,如果没有继承的概念,我们可以为每类人员都设计一个类,每个类都有自己的计算工资的算法。但是这样每个类都会有相同的年龄、性别、入职时间属性,乃至计算工龄的方法可能也是一致的。几个类之间存在很多重复性内容,显然不是C++的特性。

这里首先介绍几个名词:派生、继承、基类(父类)、派生类(子类)。由A类派生出B类,也即称为B类继承A类,A被称为基类或父类;B被称作派生类或子类。

很自然地,我们可以设计一个带有员工共同属性的基类,在此基类上派生若干派生类来达到效果。

我们设计的基类如下:

// CEmployee.h
#ifndef CEMPLOYEE_H
#define CEMPLOYEE_H

#include <iostream>
#include <string>
using namespace std;

class CEmployee
{
public:
	CEmployee(const string &Nm = "NULL",
			  const int &Ag = 0,
			  const int &By = 1900);//默认构造函数
	~CEmployee();

	int GetWorkYear(){return workyear;};
	void ShowInfo();

private:
	string name;
	int age;
	int beginyear;
	int workyear;

	void CalWorkYear();
};

#endif

// CEmployee.cpp
#include "CEmployee.h"
#include <ctime>

CEmployee::CEmployee(const string &Nm,
					 const int &Ag,
					 const int &By)
{
	name = Nm;
	age = Ag;
	beginyear = By;
	workyear = 0;
	CalWorkYear();
}
CEmployee::~CEmployee()
{

}
void CEmployee::CalWorkYear()
{
	time_t tt = time(NULL);
	tm *t = localtime(&tt);
	int nowyear= t->tm_year;
	workyear = nowyear + 1900 - beginyear;
}
void CEmployee::ShowInfo()
{
	cout<<name<<":"<<age<<" years old,"
		<<"  Begin Work at "<<beginyear
		<<",  Work for "<<workyear<<" years."
		<<endl;
}

上述代码中默认构造函数之所以能成为默认的,是因为为他添加了默认值。编译器将不再生成不带参数的默认构造函数。计算工龄使用了ctime中的获取时间的库函数,具体原理可以自行百度,这里仅仅计算年限。构造函数的具体实现处可以修改为常见的列表式初始化风格:

CEmployee::CEmployee(const string &Nm,const int &Ag,
					 const int &By):name(Nm),age(Ag), 
					 beginyear(By), workyear(0)
{	
	CalWorkYear();
}

格式为直接在函数头后添加冒号,加上各个 成员变量(形参)。这种表达方式在类继承中应用更为广泛。

二、派生一个类

这里,我们首先派生出一个工程师类。派生类声明的格式是:

class 派生类:public 基类

{

}

public关键字也显示了派生类可以继承基类的共有接口,也即基类public下的成员变量和成员函数都可被继承。我们可以用CEngnieer类的对象调用上述GetWorkYear和ShowInfo函数。

派生类的继承特性要完成两件事:编写自己的构造函数和添加额外的数据成员和成员函数。派生类的构造函数首先要完成对基类中的数据成员的初始化,但由于这些数据成员是基类私有的,派生类无法直接访问。但可以通过基类的构造函数访问这些变量。因此,派生类的构造函数必须使用基类的构造函数。C++用初始化列表语法来完成这种工作。一种形式是形参覆盖基类和派生类所有的成员变量,但这显得有点繁琐;另一种形式是直接通过设计形参为基类的对象,通过基类的拷贝构造函数实现:

#ifndef CENGINEER_H
#define CENGINEER_H

#include "CEmployee.h"

class CEngineer:public CEmployee
{
public:
	CEngineer(double Sa = 0.0, 
		const string &Nm = "NULL",
		const int &Ag = 0,
		const int &By = 1900);
	CEngineer(double Sa, const CEmployee &Em);
	~CEngineer();
	double GetSalary();
private:
	double salary;
};
#endif

#include "CEngineer.h"

CEngineer::CEngineer(double Sa/* = 0.0*/, const string &Nm,
					 const int &Ag /*= 0*/,const int &By /*= 1900*/)
	:CEmployee(Nm, Ag, By)
{
	salary = Sa;
}
CEngineer::CEngineer(double Sa, const CEmployee &Em)
	:CEmployee(Em)
{
	salary = Sa;
}
CEngineer::~CEngineer()
{

}
double CEngineer::GetSalary()
{
	return salary*1.2;
}

这里,第一个构造函数是通过第一种初始化列表形式,调用基类的默认构造函数实现这一过程;第二个构造函数是第二种通过基类的拷贝构造函数实现。基类中并没有提供显式拷贝构造函数,编译器会自动生成一个隐式拷贝构造函数,因为这里并没有在构造函数中使用new(见C++学习笔记——类的特殊接口函数),因此可以使用隐式拷贝构造函数的浅拷贝来完成。

另外,Salary变量和GetSalary函数是派生类自己所拥有的接口。总结基类和派生类之间的关系如下:

1 派生类对象可以访问基类的非私有成员和函数。非私有包括public和protected。特别地,protected下通常只有函数,使得这些函数能够被它地派生类对象访问,而不能被其他人访问。如CEngineer en, en可以调用CEmployee下地ShowInfo函数。

2.基类地指针或引用可以在没有强制转换地情况下指向或引用派生类的对象。如CEmployee *ep1 = &en; CEmployee &ep2 = en; 这方便了我们用一组基类指针去统一化管理各种派生类对象,如作为函数形参时:myfunc(CEmployee *ep),则实参可以时ep也可以是en。

3. 相反的,不能将基类对象赋值给派生类指针或引用。即不可以CEngineer *en1 =&ep; 但是可以强制转换:CEngineer *en1 =(CEngineer *) &ep;但这样会带来问题,用en1去访问Salary将出现错误。因此最好杜绝这种强制转换做法。

4.基类的指针或引用不可以调用仅属于派生类的接口。也即ep1->GetSalary();编译器将报错。

5.可以将派生类对象赋值给基类对象,这实际会调用基类的隐士赋值运算符。即:ep=en;

以上总结的这种继承关系被称为is-a关系,即派生类对象也是基类对象,可以对基类对象执行的任何操作也可以对派生类对象执行。这有点像数学中的充分不必要条件一样,是单向的。

三、多态继承

我们可能希望同一种方法在派生类和基类中的具体实现不一样。如,我们希望在CEnginer类中也写一个ShowInfo函数,但显示的内容增加薪水。这种基于类继承实现的函数重载被成为多态继承或多态重载。这种多态不是通过函数的特征标来实现的,而是通过调用它的类的对象。

我们在CEngineer.h中添加一个public接口:

void ShowInfo();

在CEngineer.cpp中添加它的定义:

void CEngineer::ShowInfo()

{

   CEmployee::ShowInfo();

   cout<<"Salary is "<<GetSalary()

      <<endl;

}

这里,为了能访问基类中的成员函数,需要在函数前用作用域运算符::来调用它。如果不这样,就会成为一个递归函数,而且是一个出不来的递归函数。添加了这个函数后,基类和派生类中都将有通用的ShowInfo函数。那么编译器如何知道调用哪个呢?

很简单的情况是,不同的类对象调用它对应的类下的成员函数。这很好理解,编译器会将基类的函数翻译为:CEmployee::ShowInfo();把派生类的函数翻译为:CEngineer::ShowInfo()。

ep.ShowInfo()则打印

wayne:28 years old,  Begin Work at 2016,  Work for 2 years.

en.ShowInfo()则打印:

wayne:28 years old,  Begin Work at 2016,  Work for 2 years.

Salary is 12000

四、虚方法(虚函数)

如果我们想通过数组来统计员工信息,其中既有CEmployee,也有CEngineer,而数组中每个元素的类型必须相同。但鉴于上述的is-a关系,我们可以用CEmployee指针来指向CEngineer。于是可以创建CEmployee  *eps[100]来实现这一工作。但是使用eps[0]->ShowInfo()时,按照上述多态继承的规则都将调用CEmployee::ShowInfo();即便是eps[0]指向了en对象,如eps[0] = new CEngineer(10000, wayne, 26, 2016),调用ShowInfo后不显示薪水。如何实现调用的多态接口函数是指针(引用)实际指向(引用)的对象呢?可以通过虚函数来实现。

在基类的ShowInfo函数申明前添加virtual关键字,使得该函数成为虚函数,则在eps[0]->ShowInfo()时将显示薪水。

只有通过指针和引用才能展现虚函数的特性。定义虚函数是为了允许用基类的指针来调用子类的这个函数,实现动态多态。

某成员函数在基类中被定义为虚函数,在派生类中该函数无需添加virtual也默认为虚函数(但通常我们还是会添加这个virtual关键字以示它为虚函数)。

构造函数不能是虚函数,析构函数应当是虚函数。因为,如果基类指针指向的是子类对象,将调用子类的析构函数,然后自动调用基类的析构函数。如果析构函数不是虚函数,则不会调用派生类的虚函数。而且,当基类的析构函数声明为虚函数后,即便子类的析构函数与其名字不同,也一样自动成为虚函数。所以我们应该在CEmployee.h中将析构函数声明改为:

virtual ~CEmployee();

友元不是成员函数,只有成员函数才可以是虚的,因此友元不能是虚函数。但可以通过让友元函数调用虚成员函数来解决友元的虚问题。

如果派生类没有重新定义函数,将使用该函数的基类版本。即把派生类CEngineer中的ShowInfo函数删除,则上述调用都将自动调用CEmployee的不打印薪水的接口。

重新定义虚函数不是重载,而是覆盖(或是隐藏),即使派生类中重新定义了虚函数,无论函数列别是否相同,都将隐藏同名的基类方法。如果定义CEngineer::ShowInfo(int a),则在调用时en.ShowInfo()(不带参数)将报错。原因就是重新定义的带参数的函数把原来的函数覆盖了。所以,如果重新定义继承方法,应与基类中的原型完全相同。但返回类型可以随派生类的类型协变。

五、动态联编

我在函数探幽笔记中大概总结了函数多态的匹配问题,这里结合类的多态继承再谈这个问题。编译器匹配函数的过程被称作函数名联编。在编译阶段就完成联编叫做静态联编。然而虚函数使得该函数无法静态联编,以内只有到运行的时候才知道应该调用哪个函数。这种方法叫做动态联编。

动态联编即发生在基类指针(引用)指向(引用)派生类对象时,使用虚函数的情况。动态联编可以让程序在运行时选择用哪个函数,但是由于要跟踪指针或引用,效率不如静态联编,因为静态联编效率更高。另外,如果没必要在派生类中定义一个同名函数,也就没必要用虚函数这个概念了,也就用不到动态联编了。

六、抽象基类

在你做完员工类的工作后,人力资源处让你再添加一类学生信息。你发现学生类不能继承于员工类,但和员工类有很多公用的内容。我们可以从员工类CEmployee和CStudent类抽象出一个CWorker类,然后从这个CWorker类派生CEmployee和CStudent类。比如两个类中都有计算工龄方法,但是计算的方法不同,学生需要在年限的基础上打折扣(出去实习期,寒暑假等)。这个CalWorkYear()函数不能在CWorker中定义,但为了可以用CWorker的对象管理CStudent的对象,我们又希望CalWorkYear能被CWorker的对象调用。

可以通过声明纯虚函数的方式来实现。纯虚函数是一种特别的虚函数,在声明纯虚函数时,在函数声明的最左侧依然有关键字“virtual”,为了将其与普通的虚函数区分开,在函数声明的最右侧使用“=0”来标注该函数为虚函数。具体形式如下;

virtual void CalWorkYear()=0;

在类中使用了纯虚函数,则该类成为一个抽象基类。在抽象基类中可以不定义纯虚函数的具体定义(不是不能,是不必)。

不能创建抽象类的对象(也不能作为函数形参),但是可以创建指针和数组,因为不能创建对象,自然定义纯虚函数也是没有必要的,但是也不必要把所有抽象类的函数成员创建成纯虚函数;

抽象类只能作为基类,不可作为派生类;

往往要求在抽象类的子类中要重新定义抽象类中的所有纯虚函数,以覆盖纯虚函数,否则子类继承了这个纯虚函数,子类依然是抽象类,无法创建子类对象;

在抽象类的纯虚函数在的子类中被重新定义后,子类中的同名函数成为虚函数,从而实现多态。

按照上述知识将最初设计的员工类修改为了以CWorker为抽象基类,派生出CEmployee员工类和CStudent学生类,从CEmployee员工类又派生出CEngineer工程师类。源码上传至本人代码库,如有需要可自行下载

 

发布了76 篇原创文章 · 获赞 63 · 访问量 5万+

猜你喜欢

转载自blog.csdn.net/bjtuwayne/article/details/85015581
今日推荐