C++三大特性(1)——继承

本文详细介绍了C++中的继承概念、不同访问权限的继承方式、作用域规则、默认成员函数、菱形继承与虚拟继承的区别,以及继承与组合的适用场景。强调了继承的层次结构复用和组合的黑箱复用在设计中的权衡与优先级。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一.继承的概念及定义

概念

继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保存原有类特性的基础上进行拓展,增加功能,这样产生新的类,称为派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。

#include<iostream>
using namespace std;

//父类
class Person
{
public:
    void print()
    {
        cout << "name:" << _name << endl;
        cout << "age:" << _age << endl;
    }
protected:
    string _name = "bear";
    int _age = 20;
};

//子类
class Student : public Person
{
protected:
    int _stuid;
};


//子类
class Teacher : public Person
{
protected:
    int _jobid;
};



int main()
{
    Student s;
    Teacher t;
    s.print();
    t.print();
    return 0;
}

 在上述代码中, Student类与Teacher类都复用了Person的成员,继承后的父类的Person成员(成员函数+成员变量)都会变成子类的一部分。在监视窗口中可以看到s与都有一个Person类

 Student与Teacher都称为派生类或者子类,Person也称为基类或者父类。

 继承格式

继承关系和访问限定符

访问限定符有三种:

1.public访问

2.protected访问

3.private访问

继承方式也有三种:

1.public继承

2.protected继承

3.private继承

继承基类成员访问方式的变化

当基类的不同访问限定符的成员以不同的继承方式继承到派生类当中后,该成员在派生类的访问方式也会发生变化

类成员/继承方式public继承protected继承private继承
基类的public成员派生类的public成员派生类的protected成员派生类的private成员
基类的protected成员派生类的protected成员派生类的protected成员派生类的private成员
基类的private成员在派生类中不可见在派生类中不可见在派生类中不可见

例如Student类继承了Person类,但是无法在Student类中访问Person类中的private中的成员

 默认继承方式

假如我们不写基类的继承方式的话,那么:

class中,访问方式为private

struct中,访问方式为public

归纳总结五点

1.基类的private成员无论以什么方式继承,在派生类中都是不可见的,这里的不可见是指基类的私有成员虽然被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。

2.基类的private成员在派生类中是不能被访问的,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就需要定义为protected,由此可以看出,protected限定符是因继承才出现的。

3. 基类成员访问方式的变化规则也不是无迹可寻的,我们可以认为三种访问限定符的权限大小为:public > protected > private。

4.使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式

5.在实际运用中一般使用的都是public继承,几乎很少使用protected和private继承,也不提倡使用protected和private继承,因为使用protected和private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。

二.基类和派生类对象赋值转换

派生类对象可以赋值给基类的对象,基类的指针以及基类的引用,因为在这个过程中,会发生基类和派生类对象之间的赋值转换

也可以看成切片,将派生类中基类的部分切出来赋值过去,如下图:

 派生类对象赋值给基类指针:

派生类对象赋值给基类引用:

 

例如下列代码:

//基类
class Person
{
protected:
	string _name; 
	string _sex;  
	int _age;     
};
//派生类
class Student : public Person
{
protected:
	int _stuid;   
};

 在该代码中,可以出现下列的情况:

Student s;
Person p = s;     //派生类对象赋值给基类对象
Person* ptr = &s; //派生类对象赋值给基类指针
Person& ref = s;  //派生类对象赋值给基类引用

 但是,基类对象不能赋值给派生类对象,但是基类的指针可以通过强制类型转换赋值给派生类的指针,但是此时基类的指针必须是指向派生类的对象才是安全的。

三.继承中的作用域

1.在继承体系中,基类与派生类都有独立的作用域

2.子类与父类中有同名成员的话,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)

3.需要注意的是,成员函数的隐藏中,只要子类与派生类中的函数名相同就会构成隐藏

4.所以实际中在继承体系里面最好不要定义同名的成员

例如下述代码:

#include<iostream>
using namespace std;

//父类
class Person
{
public:
    
protected:
    int _age = 100;
};

//子类
class Student : public Person
{
public:
    void print()
    {
        cout << _age << endl;
    }
protected:
    int _age = 20;
};



int main()
{
    Student s;
    s.print();//20
    return 0;
}

子类中有自身的_age,也有父类中的_age,那么在访问时将自动隐藏掉父类的_age,访问子类本身的_age。

假如我们需要访问父类中的_age,那么就要使用作用域限定符进行指定访问

例如下述代码:

#include<iostream>
using namespace std;

//父类
class Person
{
public:
    
protected:
    int _age = 100;
};

//子类
class Student : public Person
{
public:
    void print()
    {
        cout << Person::_age << endl;
    }
protected:
    int _age = 20;
};



int main()
{
    Student s;
    s.print();//100
    return 0;
}

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

正常情况下,默认成员函数,即使我们不写,编译器也会自动生成,在类中,有六个默认成员函数。

但是,派生类的默认成员函数与类的默认成员函数不同

例如,我们创建了一个基类Person

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

//基类
class Person
{
public:

	//构造函数
	Person(const string& name = "bear")
		:_name(name)
	{
		cout << "Person()" << endl;
	}

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

	//赋值运算符重载函数
	Person& operator=(const Person& p)
	{
		cout << "Person& operator=(const Person& p)" << endl;
		if (this != &p)
		{
			_name = p._name;
		}
		return *this;
	}
	//析构函数
	~Person()
	{
		cout << "~Person()" << endl;
	}
private:
	string _name; //姓名
};

 当我们使用该基类派生出Student类时,其默认成员函数的逻辑如下:

//子类
class Student : public Person
{
public:
	//构造函数
	Student(const string& name, int id)
		:Person(name)//调用基类的构造函数初始化基类的那一部分成员
		, _id(id)//初始化派生类的成员
	{
		cout << "Student()" << endl;
	}

	//拷贝构造函数
	Student(const Student& s)
		:Person(s)//调用基类的拷贝构造函数完成基类成员的拷贝构造
		, _id(s._id)//拷贝构造派生类的成员
	{
		cout << "Student(const Student& s)" << endl;
	}

	//赋值重载运算符
	Student& operator=(const Student& s)
	{
		if (this != &s)
		{
			Person::operator=(s);//调用基类的operator=完成基类的成员赋值
			_id = s._id;//完成派生类的成员赋值
		}
		return *this;
	}

	//析构函数
	~Student()
	{
		cout << "~Student()" << endl;//调用完成后自动调用基类的析构函数
	}
private:
	int _id;
};

 可以看到,在派生类的默认成员函数中,都会先调用基类的默认成员函数

但是析构函数会先对派生类析构完再自动调用基类的析构函数,原因是:假如先调用基类的析构函数,那么此时派生类里的基类成员可能会出现问题。

总结如下:

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

2.派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝构造初始化。

3.派生类的operator=必须要调用基类的operator=完成基类的赋值。

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

5.派生类对象初始化先调用基类构造再调派生类构造。

6.派生类对象析构清理先调用派生类的析构再调用基类的析构。

7.因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(后续会讲到)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),即父类析构函数不加virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。

五.继承与友元

在类的讲解中,出现了一个友元的概念,在继承体系中也有这个概念,。但是,友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员,但是可以访问基类自身的私有和保护成员。

例如下列代码:
 

#include <iostream>
#include <string>
using namespace std;
//基类
class Person
{
public:
	//声明Display是Person的友元
	friend void Display(const Person& p, const Student& s);
protected:
	string _name; //姓名
};

//子类
class Student : public Person
{
protected:
	int _id; //学号
};

//显示
void Display(const Person& p, const Student& s)
{
	cout << p._name << endl; //可以访问
	cout << s._id << endl; //无法访问
}
int main()
{
	Person p;
	Student s;
	Display(p, s);
	return 0;
}

 假如想让Display函数也能够访问派生类Student的私有和保护成员,只能在派生类Student中进行友元声明。

class Student : public Person
{
public:
	friend void Display(const Person& p, const Student& s);//友元声明
protected:
	int _id; //学号
};

六.继承与静态成员

假如我们在基类定义了static静态成员,那么整个继承体系里只有一个这样的成员。无论有多少个派生类都只有一个static成员实例化。

//基类
class Person
{
public:
	Person()
	{
		++_count;
	}
	Person(const Person& p)
	{
		++_count;
	}

public:
	static int _count;//定义静态成员变量
};
int Person:: _count = 0;//静态成员变量在类外初始化

//派生类1
class Student : public Person
{
protected:
	string _stuid;
};

//派生类2
class Graduate : public Person
{
protected:
	string _cpp;
};

int main()
{
	Student s1;
	Student s2(s1);
	Student s3;
	Graduate s4;
	cout << Person::_count << endl;//4
	cout << Student::_count << endl;//4
}

 可以看到打印的结果都为4,还可以通过内存窗口查看_count的地址来确定

cout << &Person::_count << endl; //0x00000004
cout << &Student::_count << endl; //0x00000004

 查看地址也可以证明这两个看似不同的_count实际都指向同一个地址。

七.复杂的菱形继承及菱形虚拟继承

继承方式

单继承:一个子类只有一个直接父类时称这个继承关系为单继承

 多继承:一个子类有两个或以上的直接父类时称这个继承关系为多继承

 菱形继承:菱形继承是多继承的一种特殊情况

 从菱形的继承模型可以看到,Assistant对象里包含了Student与Teacher,它们两个各自又包含了一个Person,那么假如Person里有成员变量,那么Student里也有,Teacher里也有,最后到Assistant里就会有两份Person成员,那么这时候就会出现数据冗余和二义性问题。

通过显示指定访问父类的成员可以解决二义性问题,但是数据冗余的问题还是无法解决。

那么这时候为了同时解决这两个问题,就引出了一个虚拟继承的概念,如果上述的继承关系,在Student与Teacher继承Person的时候使用虚拟继承,那么就可以解决了,但是需要注意的是,虚拟继承最好不要在别的场景下使用,否则会出现很多问题。

菱形虚拟继承

例如下述的虚拟继承代码:

#include <iostream>
using namespace std;
class A
{
public:
	int _a;
};
class B : virtual public A
{
public:
	int _b;
};
class C : virtual public A
{
public:
	int _c;
};
class D : public B, public C
{
public:
	int _d;
};
int main()
{
	D d;
	d.B::_a = 1;
	d.C::_a = 2;
	d._b = 3;
	d._c = 4;
	d._d = 5;
	return 0;
}

 在继承后,可以在内存窗口看到二义性与数据冗余的问题都可以解决

菱形虚拟继承的原理

 通过内存窗口,我们可以看到D类对象当中各个成员在内存当中的分布情况:

 

 上图是菱形虚拟继承的内存对象成员模型:这里可以分析出D对象中A的成员_a放到了最后面,原本重复放置的_a成员的位置变成两个指针,这两个指针叫虚基表指针,而他们指向一个虚基表,虚基表中第二个数据存着偏移量,第一个数据是预留的位置(不需要关心这个),通过偏移量就可以找到下面A的成员_a。

所以,这两个指针通过一系列计算,最终都会找到成员_a的位置。

 

 我们若是将D类对象赋值给B类对象,在这个切片过程中,就需要通过虚基表中的第二个数据找到公共虚基类A的成员,得到切片后该B类对象在内存中仍然保持这种分布情况。

D d;
B b = d; //切片行为

 得到切片后该B类对象当中各个成员在内存当中的分布情况如下:

 可以看到,_a对象还是存储在B对象的最后。

八.继承的总结与反思

在C++的第一个特效继承里,我们就看到了相当复杂的语法,其中多继承就是一个体现。有了多继承,就可能存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出菱形继承,否则代码在复杂度及性能上都容易出现问题,当菱形继承出问题时难以分析,并且会有一定的效率影响。

多继承可以认为是C++的缺陷之一,很多后来的OO(Object Oriented)语言都没有多继承,如Java。

所以后来,就有了继承与组合。

继承是一种is-a的关系,也就是说每个派生类对象都是一个基类对象;

而组合是一种has-a的关系,若是B组合了A,那么每个B对象中都有一个A对象。

例如手机和小米手机就是一个 is—a的关系,他们之间适合使用继承:

class Phone
{
protected:
	string _colour; //颜色
	string _num; //系列
};
class MiPhone : public Phone
{
public:
	void Use()
	{
		cout << "this is MiPhone" << endl;
	}
};

 而手机和摄像头就是has—a的关系,它们之间适合使用组合

class Camera
{
protected:
	string _brand; //品牌
	size_t _size; //尺寸
};
class Phone
{
protected:
	string _colour; //颜色
	string _num; //系列
	Tire _c; //摄像头
};

假如两个类之间可以看成is—a的关系又可以看作has—a的关系,那么优先使用组合。

原因如下:

1.继承允许你根据基类的实现来定义派生类的实现,这种通过生成派生类的复用通常被称为白箱复用(White-boxreuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对于派生类可见,继承一定程度破坏了基类的封装,基类的改变对派生类有很大的影响,派生类和基类间的依赖性关系很强,耦合度高。


2.组合是类继承之外的另一种复用选择,新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口,这种复用风格被称之为黑箱复用(Black-box reuse),因为对象的内部细节是不可见的,对象只以“黑箱”的形式出现,组合类之间没有很强的依赖关系,耦合度低,优先使用对象组合有助于你保持每个类被封装。


3.实际中尽量多使用组合,组合的耦合度低,代码维护性好。不过继承也是有用武之地的,有些关系就适合用继承,另外要实现多态也必须要继承。若是类之间的关系既可以用继承,又可以用组合,则优先使用组合。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值