【C++】继承

【C++】继承,第1张

文章目录 一、什么是继承1.概念2.定义(1)格式(2)继承关系和访问限定符(3)继承后基类成员的访问权限 二、基类和派生类赋值转换1.对象2.指针和引用 三、继承中的作用域1.同名成员变量2.同名成员函数 四、派生类的默认成员函数1.构造函数2.拷贝构造3.赋值运算符重载4.析构函数 五、友元和静态成员六、菱形继承1.继承类型2.菱形继承3.虚继承4.关于多继承 感谢阅读,如有错误请批评指正


一、什么是继承 1.概念

继承(inheritance)机制是面向对象程序设计中使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能。这样产生的新类,称派生类(或子类),被继承的类称基类(或父类)。

继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。之前接触的复用都是函数复用,继承是类设计层次的复用。


下面先看一段代码来理解继承的作用,这一部分主要是看看继承到底有什么用,具体细节都会在后面讲到。

代码如下:

//Person类
class Person
{
public:
	void Print()
	{
		cout << "name:" << _name << endl;
		cout << "age:" << _age << endl;
	}
protected:
	string _name = "peter"; // 姓名
	int _age = 18; // 年龄
};

//Student类,学生有名字和年龄,所以用继承复用Person的代码
class Student : public Person//共有继承Person类
{
protected:
	int _stuid; // 学号
};

//Teacher类,老师有名字和年龄,所以用继承复用Person的代码
class Teacher : public Person//共有继承Person类
{
protected:
	int _jobid; // 工号
};

int main()
{
	Student s;
	Teacher t;

	s.Print();
	t.Print();
	
	return 0;
}

通过调试可以看到,s和t对象中都含有Person的成员变量,这是从Person处继承来的。

调试结果如下:


继承继承的是成员,也就是说包括成员变量和成员函数,上图通过调试说明继承到了成员变量,下图通过运行程序说明继承到了成员函数。

运行结果如下:


2.定义 (1)格式

继承的定义方式如下,可参考前文代码。


(2)继承关系和访问限定符

继承关系和访问限定符均有三种,分别是public、protected、private,继承方式是在继承时指明的,访问限定符就是类内成员的访问方式。


(3)继承后基类成员的访问权限

各种继承方式和类成员访问权限组合后情况如下:

总结:

基类private成员无论以什么方式继承到派生类中都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。基类private成员在派生类中不能被访问,如果基类成员不想在派生类外直接被访问,但需要在派生类中访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。上面的表格看起来复杂,实际上归纳一下就会发现:基类的私有成员在子类都是不可见;基类的其他成员在子类的访问方式就是访问限定符和继承方式中权限更小的那个(权限排序:public>protected>private)。使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,但最好显式地写出继承方式。在实际使用时一般都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,扩展和维护性不强。
二、基类和派生类赋值转换 1.对象

代码如下:

//Person类
class Person
{
public://这里将成员都定义为public便于测试
	string _name = "peter"; // 姓名
	int _age = 18; // 年龄
};

//Student类,学生有名字和年龄,所以用继承复用Person的代码
class Student : public Person
{
public://这里将成员都定义为public便于测试
	int _stuid = 1; // 学号
};

int main()
{
	Student s;//子类
	Person p;//父类

	p = s;//子类对象可以赋值给父类对象
	s = p;//父类对象不能赋值给子类对象
	
	return 0;
}

子类赋值给父类可以正常编译,父类赋值给子类编译报错。

编译结果如下:


这里需要提到一个“切片”的规则,如下图所示:

由于子类的成员数量一定大于等于父类的成员数量,所以当一个子类赋值给父类时,子类就可以将属于与父类的成员赋值给父类,多余的成员由于父类中不含有,所以不用管。也就是将父类的成员切片出来赋值给父类。

如上图,父类Person对象中有_name和_age两个成员,子类Student对象赋值时将自己含有的这两个成员切片赋值给父类对象。


2.指针和引用

指针和引用实际仍遵循上述规则,实际上与对象和对象间的赋值差别不大,具体可见下图。


三、继承中的作用域

基类和派生类都是独立的作用域,在不同作用域内可以定义同名的变量、函数而不会发生冲突,所以在子类访问这些同名的内容时就需要注意。

1.同名成员变量

如果子类和父类中有同名成员,子类成员将屏蔽对父类同名成员的直接访问,这种情况叫隐藏(但在子类成员函数中,可以使用父类::父类成员来显式地进行访问)。

下面代码中,在父类和子类中定义同名的_name成员变量,观察在子类中访问时访问的是哪个。

代码如下:

//Student的_name和Person的_name构成隐藏关系
//从编译角度来看,代码没有问题,但是从逻辑角度来看,两个同名变量非常容易混淆
class Person
{
protected:
	string _name = "父类"; // 姓名
	int _num = 111; // 身份z号
};
class Student : public Person
{
public:
	void Print()
	{
		cout << "姓名:" << _name << endl;
		cout << "学号:" << _num << endl;
	}
protected:
	string _name = "子类"; // 姓名
};

int main()
{
	Student s;
	s.Print();
	return 0;
}

如下图所示,在子类中直接访问时访问的是子类中的_name,如果想要访问父类的_name就需要再前面加上域作用限定符,指定访问Person类内的_name。

运行结果如下:

下面仅在Print函数添加一行通过域作用限定符访问父类_name的代码,可以看到确实访问到了父类的成员变量。

运行结果如下:


2.同名成员函数

要注意的是,在父类和子类内同名的成员函数并不构成函数重载,因为函数重载的前提是两个函数在同一作用域。

成员函数的隐藏,只需要函数名相同就构成隐藏,对参数列表没有要求。

代码如下:

class A
{
public:
	void func()
	{
		cout << "父类" << endl;
	}
};

class B : public A
{
public:
	void func()
	{
		cout << "子类" << endl;
	}
};

int main()
{
	B b;
	b.func();
	b.A::func();
	return 0;
}

运行结果如下:


四、派生类的默认成员函数

如果对默认成员函数有问题,可前往【C++】类和对象2(this指针、默认成员函数、构造函数)和【C++】类和对象3(析构、拷贝构造、赋值运算符重载、const成员函数)

为逻辑清晰,下面每一部分只给出对应部分的成员函数代码,省略其它成员函数。

1.构造函数

派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函
数,则必须在派生类构造函数的初始化列表阶段显式调用。

下面的代码中Person有默认构造函数,则在子类的构造函数中,会先调用父类的默认构造函数完成父类成员的初始化,然后在调用子类的构造函数初始化子类的成员。

代码如下:

class Person
{
public:
	//父类构造函数
	Person(string name = "父类")//父类有默认构造函数
		:_name(name)
	{
		cout << "Person()" << endl;
	}
protected:
	string _name;
};
class Student : public Person
{
public:
	//子类构造函数
	Student(string name, int id)
		:_id(id)
	{
		cout << "Student()" << endl;
	}
protected:
	int _id;
};

int main()
{
	Student s("tom", 1);

	return 0;
}

下面的代码中,父类没有默认构造函数(简单说就是不传参无法初始化),则需要在子类的构造函数中显式调用父类构造函数完成父类成员的初始化。

代码如下:

class Person
{
public:
	//父类构造函数
	Person(string name)//如果不传参无法初始化
		:_name(name)
	{
		cout << "Person()" << endl;
	}
protected:
	string _name;
};
class Student : public Person
{
public:
	//子类构造函数
	Student(string name, int id)
		: Person(name)//调用父类构造函数初始化
		, _id(id)
	{
		cout << "Student()" << endl;
	}
protected:
	int _id;
};

int main()
{
	Student s("tom", 1);

	return 0;
}

2.拷贝构造

拷贝构造的逻辑和构造函数基本相同,需要注意的就是在子类中调用父类的拷贝构造时,直接传入子类对象即可,父类的拷贝构造会通过“切片”拿到父类的那一部分。

代码如下:

class Person
{
public:
	//父类拷贝构造函数
	Person(const Person& p)
		:_name(p._name)
	{
		cout << "Person(const Person& p)" << endl;
	}

protected:
	string _name;
};
class Student : public Person
{
public:
	//子类拷贝构造函数
	Student(const Student& s)
		: Person(s)//直接传s,通过切片拿到父类的部分
		, _id(s._id)
	{
		cout << "Student(const Student& s)" << endl;
	}

protected:
	int _id;
};

int main()
{
	Student s1("tom", 1);
	Student s2(s1);//拷贝构造

	return 0;
}

3.赋值运算符重载

子类的operator=必须要显式调用父类的operator=完成父类的赋值。

代码如下:

class Person
{
public:
	//父类的赋值运算符重载
	Person& operator=(const Person& p)
	{
		if (this != &p)
		{
			cout << "Person& operator=(const Person& p)" << endl;
			_name = p._name;
		}
		return *this;
	}

protected:
	string _name;
};
class Student : public Person
{
public:
	//子类赋值运算符重载
	Student& operator=(const Student& s)
	{
		if (this != &s)
		{
			cout << "Student& operator=(const Student& s)" << endl;
			//注意不能这样写,因为父类的赋值运算符重载被隐藏了,这样写会调用子类的赋值运算符重载导致无穷递归、栈溢出
			//operator=(s);
			Person::operator=(s);//指定作用域调用父类的赋值运算符重载,直接传s即可(会切片)
			_id = s._id;
		}

		return *this;
	}

protected:
	int _id;
};

int main()
{
	Student s1("tom", 1);
	
	Student s2(s1);//拷贝构造
	
	Student s3("jerry", 3);
	s1 = s3;//赋值运算符重载

	return 0;
}
4.析构函数

析构函数在这里很奇怪,希望读者能耐心地跟着代码和讲解看下去。

首先,根据前面的逻辑,子类的析构函数只需要先调用父类的析构函数,然后在做子类析构函数该做的事即可,这样不难写出如下代码。

代码如下:

class Person
{
public:
	//父类的析构函数
	~Person()
	{
		//父类没什么资源需要处理,所以父类的析构需要做的事以打印代替
		cout << "~Person()" << endl;
	}

protected:
	string _name;
};
class Student : public Person
{
public:
	//子类的析构函数
	~Student()
	{
		//调用父类的析构函数,由于函数名不同,不需要指定作用域
		~Person();
		//子类没什么资源需要处理,所以子类的析构需要做的事以打印代替
		cout << "~Student()" << endl;
	}

protected:
	int _id;
};

int main()
{
	Student s1("tom", 1);

	return 0;
}

然后会发现上面的代码编译不通过,如下图:

编译如下:

这里报的错误其实不太准确,真正的原因是由于多态的一些需要(之后的博客会讲到),所有类的析构函数的函数名都一样,取为destructor(),也就是说父类和子类的函数名在编译器看来是一样的,所以需要指定作用域来调用。


于是修改代码如下:

代码如下:

class Person
{
public:
	//父类的析构函数
	~Person()
	{
		//父类没什么资源需要处理,所以父类的析构需要做的事以打印代替
		cout << "~Person()" << endl;
	}

protected:
	string _name;
};
class Student : public Person
{
public:
	//子类的析构函数
	~Student()
	{
		//显式调用父类的析构函数
		Person::~Person();
		//子类没什么资源需要处理,所以子类的析构需要做的事以打印代替
		cout << "~Student()" << endl;
	}

protected:
	int _id;
};

int main()
{
	Student s1("tom", 1);

	return 0;
}

从打印结果看到,父类的析构函数被调用了两次,这又是为什么呢?

运行如下:


由于栈的特性,构造函数调用时先调父类再调子类,所以析构时先析构子类,再析构父类,编译器为了能保证这个特性,默认在子类析构完成后调用父类的析构函数,所以不需要在子类的析构函数中显式地调用父类的析构函数。

代码如下:

class Person
{
public:
	//父类的析构函数
	~Person()
	{
		//父类没什么资源需要处理,所以父类的析构需要做的事以打印代替
		cout << "~Person()" << endl;
	}

protected:
	string _name;
};
class Student : public Person
{
public:
	//子类的析构函数
	~Student()
	{
		//不需要显式调用父类的析构函数,编译器会自动调用
		//Person::~Person();
		//子类没什么资源需要处理,所以子类的析构需要做的事以打印代替
		cout << "~Student()" << endl;
	}

protected:
	int _id;
};

int main()
{
	Student s1("tom", 1);

	return 0;
}

运行如下:

总结:派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。


五、友元和静态成员

友元关系无法继承,如果需要友元关系需要在父类和子类中都声明。

父类的静态成员在整个类体系中都只有一个,无论下面有多少层继承关系。

这两点了解一下即可。


六、菱形继承 1.继承类型

单继承:一个子类只有一个直接父类的继承关系。

多继承:一个子类有两个或以上直接父类的继承关系。

菱形继承:单继承和多继承组合后的一种特殊情况。


2.菱形继承

菱形继承会有数据二义性的问题,以下面的代码为例进行说明,该代码的继承关系同上。

代码如下:

class Person
{
public:
	string name;
};

class Student : public Person
{
public:
	int studentNum;
};
class Teacher : public Person
{
public:
	int teacherNum;
};
class Assistant : public Student, public Teacher
{
public:
	int age;
};

int main()
{
	Assistant ast;
	ast.name = "a";
	ast.age = 18;
	ast.studentNum = 111;
	ast.teacherNum= 222;

	return 0;
}

这里代码编写好后,如下图编译器会提示name不明确,因为它可能是Student类继承的name,也可能是Teacher类继承的name,而实际上只需要一个name即足够记录,所以有代码冗余和二义性的问题。

通过调试也可以看到数据的冗余。

调试如下:

要想没有歧义,只能如下编写,但下面的代码显然很冗余,这也是菱形继承的问题所在。

代码如下:

int main()
{
	Assistant ast;
	//指定作用域
	ast.Student::name = "a";
	ast.Teacher::name = "a";
	ast.age = 18;
	ast.studentNum = 111;
	ast.teacherNum = 222;

	return 0;
}

3.虚继承

这一问题需要通过虚继承来解决,在继承方式前加上virtual。

代码如下:

class Person
{
public:
	string name;
};

class Student : virtual public Person//虚继承
{
public:
	int studentNum;
};
class Teacher : virtual public Person//虚继承
{
public:
	int teacherNum;
};
class Assistant : public Student, public Teacher
{
public:
	int age;
};

int main()
{
	Assistant ast;
	ast.Student::name = "a";
	cout << ast.name << endl;
	ast.Teacher::name = "b";
	cout << ast.name << endl;
	ast.name = "c";
	cout << ast.name << endl;

	return 0;
}

这样一来,main函数通过三种方式修改name,最后的结果都是同一个name被修改,这样就不会出现数据冗余和二义性的问题。

运行如下:


4.关于多继承

多继承是C++复杂的一个体现。有了多继承,就存在菱形继承,为了解决菱形继承,又出现了菱形虚拟继承,其底层实现又很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在
复杂度及性能上都有问题。


感谢阅读,如有错误请批评指正

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/langs/755805.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-04-30
下一篇 2022-04-30

发表评论

登录后才能评论

评论列表(0条)

保存