C++ | 类继承

1. 概述

C++有3种继承方式:公有继承(public)、保护继承(protected)、私有继承(private)。

一个B类继承于A类,或称从类A派生类B。这样的话,类A称为基类(父类),类B称为派生类(子类)。派生类中的成员,包含两部分:一部分是从基类继承过来的,另一类是派生类自己增加的成员。

派生类继承基类,派生类拥有基类中全部成员变量和成员方法(除了构造函数和析构函数),但是在派生类中,继承的成员并不一定能直接访问,不同的继承方式会导致不同的访问权限。派生类的访问权限规则如下:

image

#include<iostream>
using namespace std;

class A{
public:
	int mA;
protected:
	int mB;
private:
	int mC;
};

1.1 公有继承

class B:public A{
public:
	void printB(){
		cout << "printB:\n";
		cout << mA << endl; // 可访问基类A的public属性
		cout << mB << endl; // 可访问基类A的protected属性
		// cout << mC << endl; // 不可访问基类A的private属性
	}
};
class SubB:public B{
public:
	void printSubB(){
		cout << "printSubB:\n";
		cout << mA << endl;
		cout << mB << endl;
		// cout << mC << endl; // 不可访问
	}
};

1.2 私有继承

使用私有继承,基类的公有成员和保护成员都将称为派生类的私有成员。这意味着基类方法将不会成为派生对象公有接口的一部分,但可以在派生类的成员函数中使用它们,而在继承层次结构之外是不可用的。

class D:private A{
public:
	void printD(){
		cout << "printD:\n";
		cout << mA << endl; // 可访问基类A的public属性
		cout << mB << endl; // 可访问基类A的protected属性
		// cout << mC << endl; // 不可访问基类A的private属性
	}
};
class SubD:public D{ // 在继承层次结构之外不可用
public:
	void printSubD(){
		cout << "printSubD:\n";
		// cout << mA << endl; // 不可访问
		// cout << mB << endl; // 不可访问
		// cout << mC << endl; // 不可访问
	}
};

1.3 保护继承

使用保护继承时,基类的公有成员和保护成员都将成为派生类的保护成员。当从派生类派生出第三代类时,私有继承和保护继承的区别便呈现出来了。

使用私有继承时,第三代类将不能使用基类(第一代类)的接口,这是因为基类的公有方法在其派生类(第二代类)中都将变成私有方法;

使用保护继承时,基类的公有方法在第二代类中将变成受保护的,因此第三代类可以使用它们。

class C:protected A{
public:
	void printC(){
		cout << "printC:\n";
		cout << mA << endl;  // 可访问基类A的public属性
		cout << mB << endl;  // 可访问基类A的protected属性
		// cout << mC << endl; // 不可访问基类A的private属性
	}
};
class SubC:public C{
public:
	void printSubC(){
		cout << "printSubC:\n";
		cout << mA << endl;
		cout << mB << endl;
		// cout << mC << endl; // 不可访问
	}
};

2. 继承中的构造和析构

不是所有的函数都能自动从基类继承到派生类中。构造函数和析构函数用来处理对象的创建和析构操作,构造和析构函数不能被继承,必须为每一个特定的派生类分别创建。

  • 子类对象在创建时会首先调用父类的构造函数,父类构造函数执行完毕之后,才会调用子类的构造函数;
  • 当父类构造函数带参时,需要在子类初始化列表中显示调用父类的构造函数;
  • 析构函数调用顺序和构造函数相反。
#include <iostream>
#include <string>
using namespace std;

class Animal{
private:
	string mName;
public:
	Animal(string name) { 
		cout << "Animal带参构造函数...\n";
		mName = name;
	}
	~Animal(){
		cout << "Animal析构函数...\n";
	}
};

class Bird:public Animal{
private:
	bool can_flight; // 能否飞行
public:
	Bird(bool cf, string name):Animal(name){
		cout << "Bird带参构造函数...\n";
		can_flight = cf;
	}
	~Bird(){
		cout << "Bird析构函数...\n";
	}

};

int main(){
	Bird(true, "海鸥");
	return 0;
}

输出:

Animal带参构造函数...
Bird带参构造函数...
Bird析构函数...
Animal析构函数...

注意:operator=也不能被继承,因为它完成类似构造函数的行为。

3 派生类和基类之间的特殊关系

派生类与基类之间有一些特殊关系。

3.1 派生类对象可以使用基类的方法

派生类对象可以使用基类的方法,条件是该方法不是私有的:

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

class Animal{
private:
	string mName;
public:
	Animal() { mName = "no name";}
	Animal(string name) { mName = name;}
	void showAnimal(){
		cout << "Name: " << mName << endl;
	}
};
class Bird:public Animal{
private:
	bool can_flight; // 能否飞行
public:
	Bird(bool cf, string name):Animal(name){ can_flight = cf; }
	void showBird(){
		cout << "Can_flight(1-can;0-can't): " << can_flight << endl;
	}
};

int main(){
	Bird b(true, "海鸥");
	b.showAnimal(); // 派生类对象可以使用基类的方法
	b.showBird();
	return 0;
}

输出:

Name: 海鸥
Can_flight(1-can;0-can't): 1

3.2 基类指针可以在不进行显式类型转换的情况下指向派生类对象

int main(){
	Bird b(true, "海鸥");
	Animal *pa = &b;
	pa->showAnimal();
	return 0;
}

输出:

Name: 海鸥

3.3 基类引用可以在不进行显式类型转换的情况下引用派生类对象

int main(){
	Bird b(true, "海鸥");
	Animal &ra = b;
	ra.showAnimal();
	return 0;
}

输出:

Name: 海鸥

然而,基类指针或者引用只能调用基类方法,因此,不能使用pa或ra来调用派生类的showBird方法,只是单向的,不可以将基类对象和地址赋给派生类引用或指针:

int main(){
	Animal a("海鸥");
	Bird &rb = a; // 非法
    Bird *pb = &a; // 非法
	return 0;
}

3. 继承中同名成员的处理方法

3.1 同名变量

  • 当子类成员和父类成员同名时,子类依然从父类继承同名成员 。子类访问其成员默认访问子类的成员(本作用域,就近原则);
  • 在子类通过作用域::进行同名成员区分。
#include <iostream>
#include <string>
using namespace std;

class Father{
public:
	int mParam;
public:
	Father():mParam(0){}
	void display(){cout << mParam << endl;}
};

class Son:public Father{
public:
	int mParam;
public:
	Son():mParam(1){}
	void display(){
		cout << Father::mParam << endl; // 在派生类中使用于基类同名成员,显示使用类名限定符
		cout << mParam << endl;
	}
};

int main(){
	Son son;
	cout << son.mParam << endl; // 就近原则,默认访问子类成员
	son.display();
	return 0;
}

3.2 同名方法

Father.cpp

class Father{
public:
	// 重载方法
	void func1(){
		cout << "Father::void func1()...\n";
	}
	void func1(int param){
		cout << "Father::void func1(int param)...\n";
	}
	// 非重载方法
	void func2(){
		cout << "Father::void func2()...\n";
	}
};

Son.cpp

class Son:public Father{
public:
	void func2(){
		Father::func2(); // 基类func2()将隐藏,可通过类作用域运算符指定调用基类func2()方法
		cout << "Son::void func2()...\n";
	}
};

int main(){
	Son son;
	son.func2();
	return 0;
}

输出:

Father::void func2()...
Son::void func2()...

Daughter.cpp

class Daughter:public Father{
public:
	void func1(int param1, int param2){ // 改变参数列表->重新定义继承自基类的方法,同名基类方法将被隐藏
		Father::func1(10); // 可通过类作用域运算符指定调用基类方法
		cout << "Daughter::void func2(int param1, int param2)...\n";
	}
	int func1(int param){ // 改变返回值类型->重新定义继承自基类的方法,同名基类方法将被隐藏
		Father::func1(10); // 可通过类作用域运算符指定调用基类方法
		cout << "Daughter::int func1(int param)...\n";
		return param;
	}
};

int main(){
	Daughter daughter;
	cout << daughter.func1(1) << endl;;
	return 0;
}

输出:

Father::void func1(int param)...
Daughter::int func1(int param)...
1

总结:

重新定义继承的方法并不是重载。如果重新定义派生类中的继承函数,基类中所有的同名方法都将被隐藏,派生类对象将无法使用它们。(但是在派生类中可以通过类作用域运算符指定调用基类方法)

如果基类方法在基类的类定义中被重载了,则应该在派生类中重新定义所有的基类版本。如果只定义一个版本,则其他版本将被隐藏,派生类对象将无法使用它们。

若不需要修改继承自基类的方法,则只需通过类作用域运算符指定调用基类方法即可。

4. 多继承

4.1 多继承概念

同时继承多个类,即为多继承。

image

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

class Singer{
public:
	void show(){
		cout << "Singer::show()..." << endl;
	}
};
class Waiter{
public:
	void show(){
		cout << "Waiter::show()..." << endl;
	}
};

class SiningWaiter:public Singer, public Waiter{};

int main(){
	SiningWaiter sw;
	// sw.show(); // show是从Singer继承而来的,还是从Waiter继承来的呢?
	return 0;
}

多继承会带来一些二义性的问题,如果两个基类中有同名的函数或变量,那么通过派生类对象去访问时就不能明确到底是调用从基类1继承的版本还是从基类2继承的版本。

解决方法:显式指定调用哪个基类的版本。

int main(){
	SiningWaiter sw;
	sw.Singer::show();	
	sw.Waiter::show();
	return 0;
}

输出:

Singer::show()...
Waiter::show()...

4.2 菱形继承和虚继承:菱形继承

两个派生类继承同一基类,而又有某个第三代类同时继承了这两个派生类,这种继承称为菱形继承。

image

class Animal{
protected:
	int age;
public:
	Animal(){ age = 0; }
	void show(){
		cout << "Animal::show()..." << endl;
	}
};

class Sheep:public Animal{ // 羊类
public:
	void show(){
		cout << "Sheep::show()..." << endl;
	}
};
class Camel:public Animal{ // 驼类
public:
	void show(){
		cout << "Camel::show()..." << endl;
	}
};

class Alpaca:public Sheep, public Camel{}; // 羊驼类

这种继承主要带来两类问题:

  • 成员访问产生二义性;
  • 第三代类重复继承了第一代类的数据(羊驼类继承自动物类的函数与数据继承了双份)。
int main(){
	Alpaca alpaca;
	// 问题1:成员访问二义性
	// alpaca.show(); // 二义性
	alpaca.Sheep::show();	
	alpaca.Camel::show();
	// 问题2:重复继承
	cout << "Animal size: " << sizeof(Animal) << endl;
	cout << "Sheep size: " << sizeof(Sheep) << endl;
	cout << "Camel size: " << sizeof(Camel) << endl;
	cout << "Alpaca size: " << sizeof(Alpaca) << endl;
	return 0;
}

输出:

Sheep::show()...
Camel::show()...
Animal size: 4
Sheep size: 4
Camel size: 4
Alpaca size: 8

创建派生类对象时,程序首先调用基类构造函数,然后再调用派生类构造函数。因此,Sheep对象和Camel对象中各含有一个基类对象(即Animal对象),所以才会有Sheep size: 4Camel size: 4。而由于Alpaca同时继承了Sheep和Camel,因此Alpaca对象中将包含两个Animal对象,因此Alpaca size: 8

image

这种重复继承将带来一些问题。例如,通常可以将派生类都西昂的地址赋予基类指针,但现在将出现二义性:

Alpaca al;
Animal *pa = &al; // 出现二义性

通常,这种赋值将把基类指针设置为派生类对象中的基类对象的地址。但现在al中包含2个Animal对象,有2个地址可供选择,所以应使用类型转换来指定对象:

Animal *pa1 = (Sheep)&al; // the Animal in Sheep
Animal *pa2 = (Camel)&al; // the Animal in Camel

对于这种菱形继承所带来的问题,C++提供了一种方式——采用虚基类。

4.3 菱形继承和虚继承:虚基类

1)虚基类

虚基类使得从多个第二代类(它们的基类(第一代类)相同)派生出的对象只继承一个基类(第一代类)对象。

通过在类声明中使用关键字virtual,可以使Animal被硬座Sheep和Camel的虚基类(virtual和public的次序无关紧要):

class Sheep:virtual public Animal{...};
class Camel:virtual public Animal{...};
class Alpaca:public Sheep, public Camel{...};

现在,Alpaca对象将只包含一个Animal对象。从本质上说,继承的Sheep和Camel对象共享一个Animal对象,而不是各自引入自己的Animal对象副本。

image

class Sheep:virtual public Animal{};
class Camel:virtual public Animal{};

class Alpaca:public Sheep, public Camel{}; // 羊驼类
int main(){
	Alpaca alpaca;
	alpaca.show();
	cout << "Alpaca size: " << sizeof(Alpaca) << endl;
	return 0;
}

输出:

Animal::show()...
Alpaca size: 12

通过虚继承的方式解决了菱形继承带来的二义性问题。但是为什么Alpaca size: 12呢?

2)虚基类实现原理(难点)

Sheep和Camel通过虚继承的方式派生自Animal,编译器将为Sheep类和Camel类各自增加一个指针vbptr(virtual base pointer),vbptr指向了一张表,这张表保存了当前虚指针(即Sheep和Camel中的vbptr)相对于虚基类首地址的偏移量。

Alpaca派生于Sheep和Camel,将继承二者的vbptr指针,并调整了vbptr与虚基类首地址的偏移量(从‘ 第二代类相对基类的偏移量 ’调整为“ ‘第三代类中的第二代类副本’ 相对基类的偏移量”)。

因此,最后的Alpaca创建对象后,包含2个vbptr指针(Sheep子对象和Camel子对象各一个)和Animal子对象中的一个整形变量age,总共占12个字节。

就这样,使得菱形继承时,Alpaca对象将只包含一个Animal子对象。从本质上说,继承的Sheep子对象和Camel子对象共享一个Animal子对象,而不是各自引入自己的Animal子对象副本。

即使共享虚基类,但是必须要有一个类来完成基类的初始化(因为所有的对象都必须被初始化,哪怕是默认的),同时还不能够重复进行初始化,那到底谁应该负责完成初始化呢?C++标准中选择在每一次继承子类中都必须书写初始化语句(因为每一次继承子类可能都会用来定义对象),但是虚基类的初始化是由最后的子类完成,其他的初始化语句都不会调用。

class Animal{
protected:
	int age;
public:
	Animal(int age){ this->age = age; }
};

class Sheep:virtual public Animal{
public: // 每一次继承子类中都必须书写初始化语句
    Sheep(int age):Animal(age){} // 不调用Animal构造
};
class Camel:public Animal{
public: // 每一次继承子类中都必须书写初始化语句
    Camel(int age):Animal(age); // 不调用Animal构造
};

class Alpaca:public Sheep, public Camel{
public: // 每一次继承子类中都必须书写初始化语句
    Alpaca(int age):Animal(age); // 调用Animal构造 
};

5. 静态联编和动态联编

5.1 虚函数

1)虚函数概念

在函数声明时加上关键字virtual。这些函数被称为虚函数(virtual method)或虚方法。

在基类和派生类存在同名函数的情况下。如果函数是通过引用或指针而不是对象本身调用的,虚函数将确定使用基类的方法还是派生类的方法。如果使用了virtual关键字,程序将根据引用或指针指向的对象的类型来选择方法,否则将根据引用或指针的类型来选择。

如果show()不是虚的,则程序的行为如下:

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

class Animal{
protected:
	int age;
public:
	Animal(){ age = 0; }
	void show(){ // 没有添加关键字virtual
		cout << "Animal::show()..." << endl;
	}
};

class Sheep:public Animal{
public:
	void show(){ // 没有添加关键字virtual
		cout << "Sheep::show()..." << endl;
	}
};
int main(){
	Animal al;
	Sheep sp;
	Animal &ra = al;
	Animal *pa = &sp;
	ra.show();
	pa->show();
	return 0;
}

输出:

Animal::show()...
Animal::show()...

如果show()是虚的,则行为如下:

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

class Animal{
protected:
	int age;
public:
	Animal(){ age = 0; }
	void virtual show(){ // 添加关键字virtual
		cout << "Animal::show()..." << endl;
	}
    virtual ~Animal(){} // 虚析构函数 
};

class Sheep:public Animal{
public:
	void virtual show(){ // 添加关键字virtual
		cout << "Sheep::show()..." << endl;
	}
};
int main(){
	Animal al;
	Sheep sp;
	Animal &ra = al;
	Animal *pa = &sp;
	ra.show();
	pa->show();
	return 0;
}

输出:

Animal::show()...
Sheep::show()...

如果要在派生类中重新定义基类的方法,通常应将基类方法声明为虚的。这样,程序将根据对象类型而不是引用或指针的类型来选择方法版本。

2)虚析构函数

virtual ~Animal(){} // 虚析构函数 

虚析构函数是为了解决基类指针指向派生类对象,并用基类指针释放派生类对象。确保释放派生类对象时,按正确的顺序调用析构函数

如果基类的析构函数不是虚的,则将只调用对应于指针或引用类型的析构函数:

class Animal{
public:
	Animal(){ cout << "Animal构造函数\n"; }
	~Animal(){ cout << "Animal析构函数\n";} // 基类的析构函数不是虚的
};

class Sheep:public Animal{
private:
	char *mName;
public:
	Sheep(){
		cout << "Sheep构造函数\n";
		mName = new char[64];
		memset(mName, 0, 64);
		strcpy(mName, "no name");
	};
	~Sheep(){
		cout << "Sheep析构函数\n";
		if(mName!=NULL){
			delete[] mName;
			mName = NULL;
		}
	}
};
int main(){
	Animal *al = new Sheep;
	delete al;
	return 0;
}

输出:

Animal构造函数
Sheep构造函数
Animal析构函数

这意味着只有Animal的析构函数被调用,即使指针Animal *al指向的是一个Sheep对象。

如果基类的析构函数是虚的,则将调用指针指向的相应对象的析构函数:

class Animal{
public:
	Animal(){ cout << "Animal构造函数\n"; }
	virtual ~Animal(){ cout << "Animal析构函数\n";} // 基类的析构函数是虚的
};

class Sheep:public Animal{
private:
	char *mName;
public:
	Sheep(){
		cout << "Sheep构造函数\n";
		mName = new char[64];
		memset(mName, 0, 64);
		strcpy(mName, "no name");
	};
	~Sheep(){
		cout << "Sheep析构函数\n";
		if(mName!=NULL){
			delete[] mName;
			mName = NULL;
		}
	}
};
int main(){
	Animal *al = new Sheep;
	delete al;
	return 0;
}

输出:

Animal构造函数
Sheep构造函数
Sheep析构函数
Animal析构函数

如果指针指向的是Sheep对象,将调用Sheep的析构函数,然后再自动调用基类的析构函数。

因此,使用虚析构函数能够确保正确的析构函数序列被调用。对于1)虚函数概念小节中将Animal析构函数声明为virtual并不是很重要,因为Sheep析构函数没有执行任何操作。然而,如果Sheep包含一个执行某些操作的析构函数,则基类Animal必须有一个虚析构函数,即使该虚析构函数不执行任何操作,见本小节Sheep的析构函数

因此,为基类声明一个虚析构函数成为一种惯例。

3)虚函数实现原理

》from 传智播客

image

注(订正):关于虚函数表指针。
编译器是为每个类的对象添加一个隐藏成员(而不是为这个类),该隐藏成员保存了指向该虚函数表的指针。该隐藏成员占据该对象的内存布局的最前端。
所以虚函数表只有一份,而有多少个对象,就对应多少个虚函数表指针。

》 from C++ Primer Plus

通常,编译器处理虚函数的方法是:给每个对象添加一个隐藏成员。隐藏成员中保存了一个指向函数地址数组的指针。这种数组称为虚函数表(virtual function table,vtbl)。(每个类编译器都为其创建了一个虚函数地址表(数组))虚函数表中存储了为类对象进行声明的虚函数的地址。

例如,基类对象包含一个指针vptr,该指针指向基类中所有虚函数的地址表。派生类对象也包含一个指针vptr,该指针指向派生类中所有虚函数的地址表。

如果派生类提供了虚函数的新定义,该虚函数表将保存新函数的地址;如果派生类没有重新定义虚函数,该虚函数表将保存继承自基类的函数原始版本的地址。如果派生类定义了新的虚函数,该函数的地址也将被添加到虚函数表中。

注意,无论类中包含的虚函数是1个还是10个,都只需要在对象中添加1个地址成员,只是表的大小不同而已。

image

调用虚函数时,程序将根据调用对象隐藏的指针vptr转向相应的虚函数地址表(函数地址数组)。如果使用类声明中定义的第1个虚函数,则程序将使用数组中的第1个函数地址,并执行具有该地址的函数代码块。如果使用类声明中的第3个虚函数,程序将使用数组中的第3个函数。

5.2 静态联编

程序调用函数时,将使用哪个可执行代码块呢?编译器将负责回答这个问题。将源代码中的函数调用解释为执行特定的函数代码块被称为函数名联编。

在C语言中,函数名联编非常简单,因为每个函数名都对应一个不同的函数。在C++中,由于函数重载的原因,这项任务更加复杂。编译器必须查看函数参数以及函数名才能确定使用哪个函数。然而,C/C++编译器可以在编译阶段就完成这种联编。在编译阶段中进行的联编被称为静态联编,又称为早期联编。

编译器根据函数调用者的对象类型,在编译阶段就确定函数的调用地址,这就是静态联编。

5.3 动态联编

然而,虚函数导致具体使用哪一个函数时不能在编译时确定的,因为编译器不知道用户将选择哪种类型的对象。所以,编译器必须生成能够在程序运行时选择正确的虚函数的代码,这被称为动态联编,又被称为晚期联编。

在运行阶段才能确定调用哪个函数。

编译器对非虚方法使用静态联编,对虚方法使用动态联编。例如:

Scientist *st = new Physicist;
st->show_all();

根据st指向的对象类型将show_all()调用Physicist:show_all()而不是Scientist:show_all()。但只有在运行程序时才能确定st指向的对象类型。所以编译器生成的代码将在程序执行时,根据对象类型将show_all()关联到Physicist:show_all()。

posted @ 2023-05-08 11:46  就良同学  阅读(339)  评论(0编辑  收藏  举报