类继承的简介
类继承的使用原因:
C++是面向对象的语言,而面向对象编程的主要目的之一就是提供可以重用的代码,当我们开发新的项目时,尤其是大型项目,我们重新使用已经测试过的代码比我们重新编写代码要好的多,虽然厂商给我们提供了很多的库函数,但是这也有局限性,除非厂商给我们提供库函数的源代码,否则我们无法根据自己的需求对函数进行扩展或修改,计算厂商给我们提供了源代码,我们也不建议这样去做,因为这样修改会有一定的风险,例如不经意修改函数的工作方式或者改变了库函数之间的关系,因此C++提供了更高层次的重用性
目前,很多的厂商都提供了类库,类库由类声明和实现构成,因为类声明组合了数据表示和类方法,因此提供了比函数库更完整的程序包,通常,类库是以源代码的方式提供的,因此我们可以对其进行修改,但是,C++提供了更好的方式来进行扩展和修改类,即类继承
类继承能给从已有的类派生出新的类,而派生类继承了原有类(基类)的特征,例如下面一些可以通过类继承完成的工作
1.在已有类的基础上增加新的功能
2.给类添加数据
3.可以修改类方法的行为
派生类
我们从一个类中派生出另外一个类,原始的类称为基类,而继承类被称为派生类,为了说明继承,我们首先需要一个基类,再在基类的基础上添加一个继承类,结构如下:
class 派生类名: public 基类名称
{
....
};
冒号指出派生类和基类的关系,上述声明表示该基类是一个公有基类,这被称为公有派生,派生类包含基类对象,使用公有派生,基类的公有成员将成为派生类的公有成员,基类的私有部分也会成为派生类的一部分,但是只能通过基类的公有和保护方法访问
上述的派生类有以下的特征:
派生类对象存储了基类的数据成员,即派生类继承了基类的实现
派生类对象可以使用基类的方法,即派生类继承了基类的接口
我们需要在继承特性中添加派生类自己的构造函数,派生类还可以添加额外的数据成员和成员函数,构造函数必须给新的成员和继承的成员提供数据
派生类不能直接访问基类的私有成员,必须通过基类的公有方法去访问,即派生类构造函数必须使用基类的构造函数
我们在创建派生类对象时,程序会首先创建基类的对象,即基类对象应该在程序进入派生类构造函数之前被创建,C++使用成员初始化语法列表来完成这个工作,即如下例所示
class base
{
private:
int a;
int b;
public:
base(int a1 = 0 , int b1 = 0 )
{
a = a1;
b = b1;
}
};
class Abase : public base
{
private:
int sum;
public:
Abase(int sum1 , int a1 , int b1) : base(a1,b1)
{
sum = sum1;
}
};
我们创建了一个基类base,并且创建了一个它的派生类Abase,在我们的代码中,base(1,2)就是我们的成员初始化列表,如果我们调用派生类创建了一个对象,并进行初始化的话,即
Abase AAA(3,1,2);
使用Abase类创建一个对象AAA,那么Abase的构造函数会把实参1和2赋值给形参a1和a2,然后将这些参数作为实参传递给base的构造函数,后者会创建一个基类base对象,并把数据1和2存储到对象中,然后程序进行Abase的构造函数体,完成对Abase对象的创建,并将3的值赋值给sum成员
因为我们需要首先创造基类的对象,如果不调用基类构造函数,程序会使用默认的基类构造函数,因此,除非我们要使用默认构造函数,否则我们都应该显示的去调用正确的基类的构造函数,当我们创建了派生类的对象之后,我们就可以通过派生类对象去调用基类中的成员函数
派生类构造函数要点总结
1.首先要创建基类对象
2.派生类的构造函数应该通过成员初始化列表的方式将基类信息传递给基类构造函数
3.派生类构造函数应该初始化派生类新增的数据成员
4.当派生类存在析构函数时,释放对象的顺序与创建对象的顺序相反,即先执行派生类的析构函数,再执行基类的析构函数
注意:
当派生类创造对象时,程序首先调用基类构造函数,然后再调用派生类构造函数,基类构造函数负责初始化继承的数据成员,而派生类的构造函数主要负责初始化新增的数据成员,派生类的构造函数总是调用一个基类构造函数,可以使用初始化列表的方式指明要使用的基类构造函数,否则使用默认构造函数
派生类与基类的特殊关系
派生类和基类的特殊关系如下所示:
1.派生类可以使用基类的方法,只要该方法不是基类的私有成员
2.基类指针可以在不进行显示类型转换的情况下指向派生类对象
Abase AAA(3,1,2); base * aaa = &AAA;
3.基类引用可以在不进行显示类型转换的情况下引用派生类的对象
Abase AAA(3,1,2); base & aaa = AAA;
注意:基类的指针和引用只能调用基类的方法,而不能调用派生类的方法,C++要求引用和指针的类型与赋给的类型匹配,但是这个规则对继承来说是例外,但是这个例如是单向的,不能将基类的对象赋值给派生类的指针或者引用,可以将派生类对象赋给基类对象
多态公有继承
我们在开发的过程中,可能会希望同一个方法在派生态和基态中的行为是不同的,即方法应该取决于调用该方法的对象,这些行为称为多态——具有多种形态,即同一个方法的行为取决于上限文,有两种重要的机制可以用于实现多态的公有继承
1.在派生类中重新定义基类
2.使用虚方法,在派生类中重新定义基类的方法
我们通过一个例子来说明这些方法
我们定义了两个类,一个用来保存银行客户的信息,即姓名账号和余额,可以创建账号,存取款,显示账号信息,另一个类是派生类,我们在第一个类的基础上新增了贷款的上限额度,贷款利率和当前贷款总额,在派生类中没有新的操作,但是对于取款和显示的操作实现不同,代码实现如下:
#include<iostream>
#include<string>
using namespace std;
class Brass
{
private:
string name;//名字
long acctnum;//账号
double balance;//余额
public:
Brass(const string & s = "Null", long acct = 0 , double bal = 0){name = s; acctnum = acct; balance = bal; }
void deposit(double amt){balance += amt;}//存款
virtual void withdraw(double amt){balance -= amt;}//取款
double Balance()const {return balance;}//看余额
virtual void Viewacct()const{cout<<"Name :"<<name<<" Acctnum: "<<acctnum<<" balance:$"<<balance<<endl;}//看全部信息
};
class BrassPlus : public Brass
{
private:
double maxloan;//借款上限
double rate;//利息
double owes;//总欠款
public:
BrassPlus(const string & s = "Null", long acct = 0, double bal = 0, double amx = 2000, double ra = 0.125):Brass(s,acct,bal){maxloan = amx; rate = rate; owes = 0;}
BrassPlus(const Brass & body, double amx = 2000, double ra = 0.125):Brass(body){maxloan = amx; rate = ra; owes = 0;}
virtual void withdraw(double amt);
virtual void Viewacct()const {Brass::Viewacct(); cout<<"Maxloan: $ "<<maxloan<<" Rate: "<<rate<<" owes:$ "<<owes<<endl;}
void restmaxloan(double amt){maxloan = amt;}
void restrate(double ra){rate = ra;}
};
void BrassPlus::withdraw(double amt)
{
double bal = Balance();
if(amt < bal)
Brass::withdraw(amt);
else if(amt <= bal + maxloan - owes )
{
double advance = amt - bal;
owes = advance * (1.0 * rate);
cout<<"Bamk advance :$ "<<advance<<endl;
deposit(advance);
Brass::withdraw(amt);
}
else
cout<<"NO\n";
}
int main(void)
{
Brass aaa("AAA",2112,10000);
BrassPlus bbb("BBB",2113,10021);
//通过对象访问
aaa.Viewacct();
bbb.Viewacct();
cout<<"----------\n";
//使用虚函数
Brass *ccc = new Brass("ccc");
Brass *ddd = new BrassPlus("ddd");
ccc->Viewacct();
ddd->Viewacct();
delete ddd;
delete ccc;
return 0;
}
我们在基类和派生类中都定义了ViewAcct和withdraw,我们基类中的ViewAcct只希望显示账号的信息,而我们派生类中的ViewAcct希望在基类的基础上增加显示我们的贷款,利率以及最大的贷款值,这时候就出现了一个ViewAcct的一个多态,我们对两个类同一个方法进行不同的操作是使用了虚方法——virtual,virtual说明如下
如果方法是通过引用或者指针而不是对象调用的,它将会确定使用哪一种方法,即如果我们没有使用关键字virtual时,程序将根据引用或者指针的类型选择方法,如果使用了virtual,程序将根据引用或者指针指向的类型来选择方法
通过对象调用:
Brass aaa("AAA",2112,10000); BrassPlus bbb("BBB",2113,10021); aaa.Viewacct(); bbb.Viewacct();
当前我们是根据类的对象来选择调用不同的方法,aaa调用Brass类,bbb调用BrassPlus类,这是通过类的对象来确定使用哪个成员函数,运行结果如下
通过引用或者指针调用,如果ViewAcct不是虚方法
Brass dom("AAA",1212, 4441); BrassPlus dot("DDD", 3513, 4452); Brass &b1 = dom; Brass &b2 = dot; b1.Viewacct(); b1.Viewacct();
在这里我们将基类的对象传递给基类的引用,将派生类的对象也传递给基类的引用,并且通过基类的引用调用ViewAcct,此时两个引用调用的都是基类中的ViewAcct,因为是根据引用或者指针的类型来确定
通过引用或指针调用,ViewAcct是虚方法
Brass *ccc = new Brass("ccc"); Brass *ddd = new BrassPlus("ddd"); ccc->Viewacct(); ddd->Viewacct();
此时程序会根据引用或者指针指向的对象来选择方法,虽然在这里都是Brass类的引用,但是ccc指向的是基类的对象,因此ccc调用的是基类中的方法,而ddd指向的是派生类的引用,因此ddd调用的是派生类的方法,运行结果如下
提示:
当我们在基类中声明了virtual之后,那么它在派生类中就会自动称为虚方法,但是最好在基类和派生类中同时声明,这样便于我们理解,并且,virtual只需要在类声明中加上,而不需要在类的定义里进行添加
如果我们要在派生类中重新定义基类的方法,则将其设置为虚方法,否则,就将其设计为非虚
注意:我们应该使用虚析构函数
我们在上面的例子中使用了delete来释放由new分配的对象的内存空间,这说明我们的基类中应该包含有一个虚析构函数,因为如果析构函数不是虚的,则我们的代码将只会调用对应于指针类型的析构函数,在我们程序中,它就只会去调用Brass类的析构函数,如果析构函数是虚的,那么应该会去调用相应对象类型的析构函数,对于我们现在上面所写的程序来说,析构函数并不重要,因为它并没有执行什么操作,当出现执行特定操作的析构函数时,需要将其定义为虚函数
静态联编和动态联编
程序在调用函数时,使用哪个可执行代码块呢?这由编译器来回答
将源代码中的函数调用解释为执行特定的函数代码块称为函数名联编,在C语言中,这非常简单,因为每个函数名都对应一个不同的函数,而在C++中,由于函数重载,这个任务更加复杂,编译器必须查看函数的参数以及函数名才能确定使用哪个函数,然而,C/C++编译器可以在编译过程完成这种联编,在编译过程中进行联编称为静态联编,虚函数又让这个工作变得更复杂,即使用哪一个函数不能在编译时确定,因为编译器不知道用户将选择哪种类型的对象,所以,编译器必须生成能够在程序运行时正确使用的虚方法代码,这就被称为动态联编
为什么要有两种联编以及为什么默认为静态联编
为了让程序能够在运行阶段进行决策,必须采取一些方法来跟踪基类指针或者指向的对象类型,这会增加额外的开销,如果我们的一个类不会用作基类,就不需要动态联编,并且如果派生类不重新定义基类的任何方法,也不需要动态联编,在这种情况下,使用静态联编效率更高更合理,C++的设计原则就是不要为不使用的特性付出代价,因此,仅当程序使用虚函数时,才会使用动态联编
虚函数的工作原理:
C++规定了虚函数的行为,但是将实现的方法留给了编译器的作者,通常,编译器处理虚函数的方法都是给每一个对象添加一个隐藏成员,隐藏成员中保存了一个指向函数地址数组的指针,这种数组称为虚函数表,虚函数表中存储了为类对象进行声明的虚函数的地址,所有虚函数的地址都存放都虚函数表中,如果派生类提供了虚函数的新定义,则该虚函数表会保存新函数的地址,如果派生类没有重新定义虚函数,则该表会保存函数原始版本的地址,如果派生类定义了新的虚函数,则该函数的地址也会被添加到虚函数表中,因此,使用虚函数在内存和执行速度有一定的成本
每个对象都将增大,增大的量为存储地址的空间
对于每个类,编译器都会创建一个虚函数地址表
对于每个函数调用,都需要执行一项额外的操作,即到表中查找地址
有关虚函数的注意事项:
1.在基类方法中使用关键字virtual可以使方法在基类以及所有的派生类中是虚的
2.如果使用指向对象符引用或者指针来调用虚方法,程序将使用为对象类型定义的方法,而不是使用指针或者引用类型定义的方法
3.如果定义的类将被用作虚类,则应该将那些要在派生类中重新定义的类方法声明为虚的
4.构造函数不能是虚函数,创建派生类对象时,将调用派生类的构造函数,而不是基类的构造函数,然后,派生类的构造函数将使用基类的一个构造函数
5.析构函数应该是虚函数,除非类不是基类,尽量写为虚函数
6.友元不能是虚函数,因为友元不是成员函数
7.如果重新定义类继承的方法,应该确保原型与原来的相同,否则基类中的会被覆盖
抽象基类
为什么要定义抽象基类?
当我们定义一个类即它的派生类时,如果基类中的很多方法派生类是不需要使用的,这样就会造成浪费,例如我们将一个椭圆定义为一个基类,将圆定义为一个派生类,椭圆这个基类里,需要有长轴短轴焦点等,而在圆这个派生类中只需要有半径面积等就足够了,长轴短轴焦点等都是不需要的,这样就有点浪费资源,我们可以将圆单独定义为一种类,但是这样效率不高,但是,我们还可以将圆和椭圆中抽象出它们的共性并作为一个基类,再从这个抽象基类里衍生出圆和椭圆的派生类,我们就可以使用抽象基类来同时管理两个派生类的对象
C++通过使用纯虚函数来提供未实现的函数,只不过它是在基类里声明的虚函数,它在基类中没有定义具体的操作内容,要求各个派生类根据自己的实际情况来自己定义版本
纯虚函数只需要在虚函数结尾处声明 = 0即可
当类声明中包含纯虚函数时,就代表这个类是一个抽象基类,则不能创建该类的对象,抽象类的意义就是以抽象类为基础,进而创建派生类,并且抽象类中必须有一个纯虚函数
我们可以根据一个例子来看看纯虚函数的使用
#include <iostream>
#include <cmath>
class Shape {
public:
virtual double area() const = 0; // 纯虚函数,需要在派生类中实现
virtual void display() const = 0; // 纯虚函数,需要在派生类中实现
virtual ~Shape() {} // 虚析构函数,确保派生类析构时调用正确的析构函数
};
class Ellipse : public Shape {
protected:
double semiMajorAxis; // 长轴
double semiMinorAxis; // 短轴
public:
Ellipse(double major, double minor) : semiMajorAxis(major), semiMinorAxis(minor) {}
double area() const override {
return M_PI * semiMajorAxis * semiMinorAxis;
}
void display() const override {
std::cout << "Ellipse with semi-major axis " << semiMajorAxis << " and semi-minor axis " << semiMinorAxis << std::endl;
}
};
class Circle : public Shape {
protected:
double radius; // 半径
public:
Circle(double r) : radius(r) {}
double area() const override {
return M_PI * radius * radius;
}
void display() const override {
std::cout << "Circle with radius " << radius << std::endl;
}
};
int main() {
Shape* shape1 = new Ellipse(5.0, 3.0);
Shape* shape2 = new Circle(4.0);
shape1->display();
std::cout << "Area: " << shape1->area() << std::endl;
shape2->display();
std::cout << "Area: " << shape2->area() << std::endl;
delete shape1;
delete shape2;
return 0;
}
继承和动态内存分配
继承是怎样与动态内存分配进行互动的呢?例如,如果我们使用了动态内存分配,并且重新定义赋值和复制构造函数,这将会怎么影响派生类的实现呢?这个问题的答案取决于派生类的属性,如果派生类也使用了动态内存分配,那么就需要几个新的小技巧,下面通过两个例子来看看这种情况
第一种情况:派生类不使用new
如果在基类中使用了动态内存分配,而派生类中没有使用new去分配内存空间,不需要去写析构函数,复制构造函数,重载赋值运算符,因为派生类会继承基类的这些特殊成员函数,并且在派生类对象被销毁时,会自动调用基类的析构函数来释放基类动态分配的内存
#include<iostream> #include<cstring> using namespace std; class baseDMA { private: char *label; int rating; public: baseDMA(const char *l = "NULL", int r = 0); baseDMA(const baseDMA &rs); virtual ~baseDMA(); baseDMA & operator=(const baseDMA &s); friend ostream &operator<<(ostream & os ,const baseDMA &a); }; class lacksDMA:public baseDMA { private: static const int COR_LEN = 10; char color[COR_LEN]; public: lacksDMA(const char *l = "NULL", int r = 0, const char *c = "Blank"):baseDMA(l,r) { strncpy(color, c, COR_LEN-1); color[COR_LEN - 1] = '\0'; } lacksDMA(const baseDMA & rs, const char * c = "blank"):baseDMA(rs) { strncpy(color, c, COR_LEN-1); color[COR_LEN - 1] = '\0'; } friend ostream &operator<<(ostream & os , const lacksDMA &ls); }; baseDMA::baseDMA(const char *l, int r) { label = new char[strlen(l) + 1]; strcpy(label,l); rating = r; } baseDMA::baseDMA(const baseDMA & rs) { label = new char[strlen(rs.label) + 1]; strcpy(label, rs.label); rating = rs.rating; } baseDMA::~baseDMA() { delete [] label; } baseDMA & baseDMA::operator=(const baseDMA & rs) { if(this == &rs) return *this; delete [] label; label = new char[strlen(rs.label) + 1]; strcpy(label, rs.label); rating = rs.rating; return *this; } ostream & operator<<( ostream & os, const baseDMA &rs) { os<<"Label : "<<rs.label<<" rating: "<<rs.rating<<endl; return os; } ostream & operator<<(ostream & os , const lacksDMA &ls) { //使用的是基类的<< os<<(baseDMA &)ls; os<<"Color :"<<ls.color<<endl; return os; } int main() { baseDMA base("ABC",6); cout<<base<<endl; lacksDMA lacks("EFG",4,"red"); cout<<lacks<<endl; lacksDMA lacks1(lacks); cout<<lacks1; }
运行结果如下:
lacksDMA lacks1(lacks);这就代表着派生类能够先调用基类的复制构造函数,再去调用默认的复制构造函数
第二种情况:派生类使用了new
如果在基类中使用了动态内存分配,而派生类中也使用new去分配内存空间,那么必须显式地去定义复制构造函数,析构函数和赋值运算符
我们可以在第一类的代码中加上派生类使用new的例子
class hasDMA : public baseDMA { private: char *style; public: hasDMA(const char *l = "NULL", int r = 0, const char *s = "null"); hasDMA(const char *l, int r, const char *s); hasDMA(const hasDMA &hs); ~hasDMA(); hasDMA &operator=(const hasDMA &hs); friend std::ostream &operator<<(std::ostream &os, const hasDMA &rs); };
当我们在派生类和基类中都使用了new来分配内存时,我们必须去自己编写复制构造函数,因为我们在这里style是一个指针,我们在传递值时必须要为指针开辟内存空间,都在就不是深层的传递,而仅仅只是地址的拷贝,还需要显式的去定义析构函数,如果我们没有定义,编译器会去生成默认的析构函数,不能将我们派生类中动态分配的内存空间进行释放,同理也需要重载赋值运算符,因为不进行重载我们新定义的指针传递的仅仅是地址
总结:
当基类和派生类都使用动态内存分配时,派生类的析构函数,复制构造函数,赋值运算符都必须使用相应的基类方法来处理基类元素
对于析构函数,这是必须自动完成的
对于构造函数,这是在通过初始化成员列表中调用基类的复制构造函数来完成的,如果不使用初始化列表,编译器会自动调用基类的默认构造函数
对于赋值运算符,这是通过作用域解析运算符显式地调用基类的赋值运算符来完成的
类设计回顾
编译器生成的成员函数
默认构造函数
要么没有参数,要么所有的参数都有默认值,如果没有定义,编译器会定义默认构造函数,这能够让我们创造对象,默认构造函数的一个功能是调用基类的默认构造函数以及调用本身是对象的成员所属类的构造函数,如果派生类构造函数的成员初始化列表中没有显式地调用基类构造函数,编译器会使用基类的默认构造函数来构造派生类对象的基类部分,在这种情况下,如果基类没有构造函数,编译会出错,如果我们定义了某种构造函数,编译器不会定义默认构造函数,在这种情况下,需要默认构造函数,需要自己定义
复制构造函数
复制构造函数接收所属类的对象作为参数,在以下的情况会调用复制构造函数
1.将新对象初始化为一个同类的对象
2.按值传递给函数
3.函数按值返回对象
4.编译器生成临时对象
如果程序没有使用显示复制构造函数,编译器会提供函数原型,但是不提供定义
注意:在某些情况下,成员的初始化是不合适的,例如使用new初始化的成员指针通过需要深度复制,或者类可能包含需要修改的静态变量,在这种情况下,我们需要自己定义
赋值运算符
默认的赋值运算符用于处理同类对象之间的赋值,如果语句创建新的对象,则使用初始化,如果修改已有对象的值,则使用赋值,默认赋值为成员赋值,当我们的代码中需要显式的定义复制构造函数,则必须显示的定义赋值运算符,原因相同
其他类方法
构造函数
构造函数不同于其他类方法,构造函数是创造对象,而其他的类方法是被现有的对象调用,这也是构造函数不被继承的原因
析构函数
一定要显式定义析构函数来释放类构造函数使用new分配的内存,并完成类对象所需的任何特殊的清理工作,对于基类,及时不需要析构函数,也应该定义一个虚析构函数
转换
使用一个参数就可以调用的构造函数定义了从参数类型到类类型的转换,也可以将可转换的类型传递给以类为参数的函数,将调用构造函数,在带一个参数的构造函数原型中加上explict将会禁止隐式转换,可以显示转换
按值传递对象与引用
编写使用对象作为参数时,应该按照引用传递而不是按值传递,因为效率高,按值传递会生成临时对象的拷贝,会调用复制构造函数和析构函数
在继承使用虚函数时,被定义为接收基类引用参数的函数可以接收派生类(基类指针或引用可以指向基类和派生引用)
返回对象和返回引用
返回对象会生成一个副本,效率低,注意不能返回局部变量的引用
方法不修改参数
const使用
使用const确保不修改参数,不修改调用对象