都说C++难学,其中继承这一块的语法就是其中一个体现,像别的语言java就简化了继承这一块的语法(删去了多继承这个语法,就不存在菱形继承的问题),C++中尤其菱形继承(由多继承衍生出来的菱形继承场景)和虚函数、多态结合才是能让人从入门到放弃~
不过这有什么可怕的?只要我们迎难而上,只有难学的知识我们把它搞懂了,才能体现我们和其他人的差距。今天博主来给大家详细剖析C++继承这个语法~
目录
继承的概念
继承的定义
定义格式
继承关系和访问限定符
继承基类成员访问方式的变化
总结
基类和派生类对象赋值转换
子类对象可以赋值给父类对象
子类指针可以赋值给父类指针
子类引用可以赋值给父类引用
基类对象不能赋值给派生类对象
基类的指针可以通过强制类型转换赋值给派生类的指针
继承中的作用域
在继承体系中成员变量相同时
在继承体系中函数名相同时
补充
派生类的默认成员函数
基类代码
派生类代码
Student(const char* name = "张三", int num = 1)
Student(const Student& s)
Student& operator=(const Student& s)
~Student()
思考
我们不写默认生成的派生类的构造函数和析构函数会做些什么?
我们不写默认生成的拷贝构造函数和operator=会做些什么?
什么情况下需要我们自己写这几个子类的默认成员函数?
如果我们要自己写怎么办?如何自己写?
继承和友元
继承与静态成员
菱形继承和菱形虚拟继承
菱形继承的问题
二义性解决方法
数据冗余和二义性解决方法
虚拟继承解决数据冗余和二义性的原理
继承的反思和总结
笔试面试题
什么是菱形继承?菱形继承的问题是什么?
什么是菱形虚拟继承?如何解决数据冗余和二义性的
继承和组合的区别?什么时候用继承?什么时候用组合?
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类(基类或父类)特性的基础上进行扩展,增加功能,这样产生新的类,称派生类(或子类)。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
我们先来看一下继承这样的场景:
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "zhangsan";
int _age = 18;
};
class Student : public Person
{
protected:
int _stuid = 11111; //学号
};
class Teacher : public Person
{
protected:
int _jobid = 22222; //工号
};
int main()
{
Student s;
Teacher t;
s.Print();
t.Print();
return 0;
}
可能老铁们还不清楚继承的语法关系。先别着急,我们在这里先明白Student类和Teacher类继承了父类(Person)内部的成员变量和成员函数。我们在这里只是验证一下继承关系。
如果还是感到很迷茫的话,可以先往下看看继承的定义,再回过头来看这里也许就会好一些~
我们通过调试窗口来看看子类(派生类)继承父类(基类)成员的情况:
我们发现子类Student和Teacher定义对象的内部都有一份父类(Person)里面的所有成员。其次下面是自己类里面的成员。
总结:看上面红框框里面的内容就是继承下来的父类成员。通过继承操作我们发现可以丰富子类。
我们在派生类后面加一个 :(冒号) + 继承方式(public/protected/private) + 基类名字
注意:我们在冒号后面可以不用加继承方式,因为class默认访问方式是私有(private),struct默认访问方式是公有(public)。我们最好不要这么去做,还是尽量加上访问限定符提高代码可读性。
举个栗子:
ps1:
class Student : Person
{
protected:
int _stuid = 11111; //学号
};
默认Student继承方式是私有。
ps2:
struct Teacher : Person
{
protected:
int _jobid = 22222; //工号
};
默认继承方式是公有。
上面的表格在学校大家可能都见过,乍一看是不是感觉还挺复杂的,我们没必要去硬记它,今天我们来剖析一下它:
访问限定符的权限关系:public > protected > private
在继承方式和访问限定符两个中选取权限更小的那一个就作为继承下来的成员在派生类的访问权限。
我们在来看一下这个表格:
其他组合也是类似,就是在两个访问权限中找最小的那一个来作为继承下来的成员的访问权限。我们在学习类和对象的时候protected和private这两个访问权限我们讲的是作用是一样的,但是在继承这里protected和private就有一定的区别的。
private:继承下来的成员不能被访问或修改。
protected:继承下来的成员可以被访问或修改,但是不能在基类和派生类之外再去访问这些成员。
1、基类private成员在派生类中无论以什么方式继承都是不可见的。这里的不可见是指基类的私有成员还是被继承到了派生类对象中,但是语法上限制派生类对象不管在类里面还是类外面都不能去访问它。
2、 基类private成员在派生类中是不能被访问,如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为protected。可以看出保护成员限定符是因继承才出现的。
3、 实际上面的表格我们进行一下总结会发现,基类的私有成员在子类都是不可见。基类的其他成员在子类的访问方式 == Min(成员在基类的访问限定符,继承方式),public > protected > private。
4、 使用关键字class时默认的继承方式是private,使用struct时默认的继承方式是public,不过最好显示的写出继承方式。
5、 在实际运用中一般使用都是public继承,几乎很少使用protetced/private继承,也不提倡使用protetced/private继承,因为protetced/private继承下来的成员都只能在派生类的类里面使用,实际中扩展维护性不强。同时也经常在基类中将成员变量设置成protected。
举个栗子:
访问限定符是protected,继承方式是public -->protected
我们发现继承下来的protected权限是可以在子类内部访问甚至是修改被继承的成员变量。
如果继承下来的是private权限,那么继承下来的成员在子类中不可见!(就不举例子了~)
1、派生类对象可以赋值给基类的对象 / 派生类指针可以赋值给基类的指针 / 派生类引用可以赋值给基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
2、基类对象不能赋值给派生类对象
3、基类的指针可以通过强制类型转换赋值给派生类的指针(但是这种做法是有风险的,容易越界)。只有必须是基类的指针是指向派生类对象时才是安全的。
我们用代码来验证一下这个关系:
class Person
{
protected:
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public:
int _No; // 学号
};
int main()
{
Student s;
Person p = s;
}
我们编译一下看看是否正确:
class Person
{
protected:
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public:
int _No; // 学号
};
int main()
{
Student* s = new Student;
Person* p = s;
}
我们编译一下看是否可以:
class Person
{
protected:
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public:
int _No; // 学号
};
int main()
{
Student s1;
Student& s2 = s1;
Person& p = s2;
}
编译一下看是否成立:
class Person
{
protected:
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public:
int _No; // 学号
};
int main()
{
Person* p = new Person;
Student* s = new Student;
s = (Student*)p;
}
我们编译一下看看是否能通过:
这种做法容易越界访问/修改。
1、 在继承体系中基类和派生类都有独立的作用域。
2、 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏,也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
3、 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏(也叫作重定义)。
4、 注意在实际中在继承体系里面最好不要定义同名的成员。
当基类和派生类中都有一个相同的成员变量,如果在派生类中打印这个变量值时,会打印哪个作用域中的值呢?
举个栗子:
class Person
{
protected:
int _age = 18; // 年龄
};
class Student : public Person
{
public:
void Print()
{
cout << _age << endl;
}
int _age = 20;
};
int main()
{
Student s;
s.Print();
return 0;
}
运行结果:
我们发现打印的是Student作用域中的_age值。这是因为就近原则,当前作用域中的变量优先。那么我们如何打印继承下来的那个_age呢? 答案是:指明类域就可以了。
举个栗子:
class Person
{
protected:
int _age = 18; // 年龄
};
class Student : public Person
{
public:
void Print()
{
cout << Person::_age << endl;
cout << _age << endl;
}
int _age = 20;
};
int main()
{
Student s;
s.Print();
return 0;
}
运行结果:
在继承体系中,如果有两个函数名相同,那么就构成隐藏,也叫作重定义。
派生类的该函数会隐藏继承下来的函数。
举个栗子:
class Person
{
public:
void Print()
{
cout << "class Person" << endl;
}
protected:
int _age = 18; // 年龄
};
class Student : public Person
{
public:
void Print()
{
cout << "class Student : public Person" << endl;
}
int _age = 20;
};
int main()
{
Student s;
s.Print();
return 0;
}
运行以下,我们来看一下调用的是哪一个Print:
我们发现调用的是子类中的Print,因为子类隐藏了父类的Print,所以不能直接调到。
那我们该如何做才能调用父类的Print呢? 答案是:指定类域
举个栗子:
class Person
{
public:
void Print()
{
cout << "class Person" << endl;
}
protected:
int _age = 18; // 年龄
};
class Student : public Person
{
public:
void Print()
{
Person::Print(); //这个()不能省略!
}
int _age = 20;
};
int main()
{
Student s;
s.Print();
return 0;
}
运行结果:
我们在类外也可以指定类域调用:
int main()
{
Student s;
s.Person::Print();
return 0;
}
还是上面的代码场景,我们来运行看一下:
对于成员变量也是可以这么做。
举个栗子:
class Person
{
public:
public:
int _age = 18; // 年龄
};
class Student : public Person
{
public:
int _age = 20;
};
int main()
{
Student s;
cout << s.Person::_age << endl;
cout << s._age << endl;
return 0;
}
运行结果:
这种指向父类作用域的调用方法也能指向父类的父类的成员。 (套娃~)
6个默认成员函数,在派生类中,这几个成员函数是如何生成的呢?
1、 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
2、 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3、 派生类的operator=必须要调用基类的operator=完成基类的复制。
4、 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
5、 派生类对象初始化先调用基类构造再调派生类构造。
6、 派生类对象析构清理先调用派生类析构再调基类的析构
我们来用代码实现以下。
class Person
{
public:
//构造
Person(const char* name = "")
:_name(name)
{
cout << "Person(const char* name)" << endl;
}
//拷贝构造
Person(const Person& p)
:_name(p._name) //复用构造函数
{
cout << "Person(const Person& p)" << endl;
}
//赋值运算符重载
Person& operator=(const Person& p)
{
if (this != &p)
{
_name = p._name;
}
cout << "Person& operator=(const Person& p)" << endl;
return*this;
}
//析构函数
~Person()
{
cout << "~Person()" << endl;
}
protected:
string _name;//姓名
};
class Student : public Person
{
public:
//构造函数
Student(const char* name = "张三", int num = 1)
:Person(name)
,_num(num)
{
cout << "Student(const char* name , int num = 1)" << endl;
}
//拷贝构造
Student(const Student& s)
:Person(s) //调用父类的拷贝构造函数 --- 切片传参
, _num(s._num)
{
cout << "Student(const Student& s)" << endl;
}
//赋值运算符重载
Student& operator=(const Student& s)
{
if (this != &s)
{
//*this = s; //会造成栈溢出 --- 就近原则
//operator=(s);//会造成栈溢出 --- 就近原则
Person::operator=(s); //这里要指定父类的operator=
_num = s._num;
}
cout << "Student& operator=(const Student& s)" << endl;
return *this;
}
//析构函数
~Student()
{
cout << "~Student()" << endl;
}
protected:
int _num;//学号
};
我们针对上面的代码来分析下:
//构造函数
Student(const char* name = "张三", int num = 1)
:Person(name)
,_num(num)
{
cout << "Student(const char* name , int num = 1)" << endl;
}
//构造函数
Student(const char* name = "张三", int num = 1)
:Person(name) //调用父类的构造函数来完成继承下来父类类成员的初始化
,_num(num) //子类内部成员按照普通类的初始化方式进行初始化
{
cout << "Student(const char* name , int num = 1)" << endl;
}
//拷贝构造
Student(const Student& s)
:Person(s) //调用父类的拷贝构造函数 --- 切片传参
, _num(s._num)
{
cout << "Student(const Student& s)" << endl;
}
//拷贝构造
Student(const Student& s)
:Person(s) //调用父类的拷贝构造函数 --- 切片传参
, _num(s._num)//子类内部成员按照普通类的初始化方式进行初始化
{
cout << "Student(const Student& s)" << endl;
}
//赋值运算符重载
Student& operator=(const Student& s)
{
if (this != &s)
{
//*this = s; //会造成栈溢出 --- 就近原则
//operator=(s);//会造成栈溢出 --- 就近原则
Person::operator=(s); //这里要指定父类的operator=
_num = s._num;
}
cout << "Student& operator=(const Student& s)" << endl;
return *this;
}
//赋值运算符重载
Student& operator=(const Student& s)
{
if (this != &s)
{
//*this = s; //会造成栈溢出 --- 就近原则 *this是子类对象,s也是子类对象,子类对象给子类对象赋值又调用了该子类的operator=,这样就不断循环下去,不断递归,最后导致栈溢出。
//operator=(s);//会造成栈溢出 --- 就近原则 该调用方式也是不断调用该子类的operator=,这样就不断循环下去,不断递归,最后导致栈溢出。Person::operator=(s); //这里要指定父类作用域的operator=
_num = s._num;
}
cout << "Student& operator=(const Student& s)" << endl;
return *this;
}
//析构函数
~Student()
{
cout << "~Student()" << endl;
}
//析构函数
//析构函数名字会被统一处理成destructor() --->后面多态的博客会讲解~
//那么父类和子类的析构函数就会构成隐藏
~Student()
{
cout << "~Student()" << endl; //在这里具体实现博主就不写了,因为不同场景不同的写法,在这里打印一下这句话就先代表析构实现了哈~
}//我们首先要明白构造顺序:先构造父类,再构造子类。
根据栈帧的特性,析构顺序:先析构子类,再析构父类。
所以子类析构函数结束时会自动调用父类的析构函数,不需要我们显示的调用父类的析构函数!
这样就保证了先析构子类成员,再析构父类成员的顺序。
我们来测试一下代码是否正确:
int main()
{
Student s1("李四", 222);
Student s2("cyq", 333);
s1 = s2;
return 0;
}
运行结果:
我们发现打印结果和预期一样。
a、父类继承来的,调用父类的默认构造函数和析构函数处理。
b、自己的(内置类型和自定义类型成员)和普通类一样的实现方法。
a、父类继承来的,调用父类的拷贝构造函数和operator=
b、自己的(内置类型和自定义类型成员)和普通类一样的实现方法。
总结:原则,继承下来的调用父类的进行处理,子类自己的按照普通类基本规则进行处理。
1、父类没有默认构造函数,需要我们显示写构造。
2、如果子类有需要清理的资源,需要我们显示写析构函数。
3、如果子类存在深浅拷贝问题,就需要实现拷贝构造和运算符赋值重载解决深浅拷贝问题。
1、父类成员调用父类的对应的构造、拷贝构造、operator=、析构进行处理。
2、子类自己的成员按照普通类规则处理。
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员
举个栗子:
class Student; //-->提前声明,防止Person类中声明友元函数时找不到Student这个类。
class Person
{
public:
friend void Display(const Person& p, const Student& s);
protected:
string _name = "wmm"; // 姓名
};
class Student : public Person
{
protected:
int _stuNum; // 学号
};
void Display(const Person& p, const Student& s)
{
//Display是基类的友元函数,可以访问基类的私有或保护成员
cout << p._name << endl;
}
int main()
{
Person p;
Student s;
Display(p, s);
return 0;
}
运行结果:
但是如果基类的友元函数访问派生类的私有或保护成员呢?是否可以呢? 答案是不可以!
验证如下:
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子类,都只有一个static成员实例 。
我们来设计一个场景感受一下:
class Person
{
public:
Person()
{
++_count;
}
public:
static int _count; // 统计人的个数。
};
int Person::_count = 0; //静态成员初始化方法
class Student : public Person
{
protected:
int _stuNum; // 学号
};
class Graduate : public Student
{
protected:
string _seminarCourse; // 研究科目
};
int main()
{
Student s1;
Student s2;
Student s3;
Graduate s4;
cout << " 人数 :" << Person::_count << endl;
Student::_count = 0;
cout << " 人数 :" << Person::_count << endl;
return 0;
}
这段代码意思是,基类中有一个静态成员变量(_count)用来计数。两个子类去继承该父类。这样的话,每次构造出一个子类对象,都会先调用父类的构造函数,这时候父类构造函数里面的_count就会++,这样就相当于多了一个人(就好比在统计人数了)。
原理:基类的静态成员变量在整个继承体系中是共享的。
我们运行一下看看结果:
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
C++支持多继承,那么就会衍生出来一个很复杂的关系:菱形继承 。
菱形继承:菱形继承是多继承的一种特殊情况。
看下面这段代码:
class Person
{
public:
string _name; // 姓名
};
class Student : public Person
{
protected:
int _num; //学号
};
class Teacher : public Person
{
protected:
int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:
string _majorCourse; // 主修课程
};
从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在Assistant对象中Person成员会有两份。
Assistant对象有两个父类,并且两个父类都又指向一个父类Person。相当于Assistant的两个父类中各自存了一份Person。Person里面的数据就有两份!因此会导致了数据冗余和二义性得问题!
我们先看看如果不采取解决二义性的措施时编译器怎么报错:
我们如何解决二义性的问题呢? 答案是:指定类域
int main()
{
Assistant a;
// 需要显示指定访问哪个父类的成员可以解决二义性问题,但是数据冗余问题无法解决
a.Student::_name = "xxx";
a.Teacher::_name = "yyy";
}
我们通过调试窗口来看一下:
这种指定类域的方法就不会有冲突的情况了。调用的_name,是Assistant两个父类中的各自的_name。
虽然指定类域的方法能解决二义性的问题,但是数据冗余的问题还没有解决。因为在菱形继承体系中,Person是只有一个,那么里面的数据就应该只有一份。也就是说Student和Teacher中的_name应该是同一份。
那么如何解决呢?
在菱形继承的腰部加个virtual进行修饰解决数据冗余和二义性问题。
举个栗子:
class Person
{
public:
string _name; // 姓名
};
class Student : virtual public Person
{
protected:
int _num; //学号
};
class Teacher : virtual public Person
{
protected:
int _id; // 职工编号
};
class Assistant : public Student, public Teacher
{
protected:
string _majorCourse; // 主修课程
};
int main()
{
Assistant a;
a._name = "cyq";
a.Student::_name = "love";
a.Teacher::_name = "wmm";
a.Assistant::_name = "mmm";
a.Person::_name = "mmm";
return 0;
}
我们通过调试窗口来观察:
我们发现每次_name的赋值修改,无论是调用哪个类域的,实际上的是对Person类域的_name的修改。
为了研究虚拟继承原理,我们给出了一个简化的菱形继承继承体系,再借助内存窗口(因为监视窗口被处理过了,不容易观察)观察对象成员的模型。
B和C是如何找到共同的A呢?
这里可以分析出D对象中将A放到的了对象组成的最下面,这个A同时属于B和C,那么B和C如何去找到公共的A呢?这里是通过了B和C的两个指针,指向的一张表。这两个指针叫虚基表指针,这两个表叫虚基表。虚基表中存的偏移量。通过偏移量可以找到下面的A 。
我们用图来表示这个关系:
1. 很多人说C++语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,一定不要设计出菱形继承。否则在复杂度及性能上都有问题。
2. 多继承可以认为是C++的缺陷之一,很多后来的OO语言都没有多继承,如Java3. 继承和组合
public继承是一种is-a(是)的关系。也就是说每个派生类对象都是一个基类对象。
组合是一种has-a(有)的关系。假设B组合了A,每个B对象中都有一个A对象。
优先使用对象组合,而不是类继承 。继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响。派生类和基类间的依赖关系很强,耦合度高。对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合
补充:
is-a:"是"的关系,比如基类是Person,派生类是Student,我们知道Student是人,那么符合继承。
has-a:"有"的关系,比如基类是轮胎,派生类是汽车,我们知道汽车有轮胎,那么符合组合。
菱形继承是多继承的一种特殊情况。符合这样的模型属于菱形继承(上面有图)。
菱形继承带来了数据冗余和二义性的问题。
菱形虚拟继承使用方法:在菱形继承的腰部加上virtual修饰,比如:class B : virtual public A。虚拟继承使得派生类如果继承基类多次,但是只有一份基类的拷贝在派生类对象中。
数据冗余解决方法:指定类域来访问成员。
二义性的解决方法:使用虚拟继承来解决菱形继承的数据冗余和二义性。
is-a:"是"的关系,比如基类是Person,派生类是Student,我们知道Student是人,那么符合这样的关系就使用继承。
has-a:"有"的关系,比如基类是轮胎,派生类是汽车,我们知道汽车有轮胎,那么符合这样的关系就使用组合。
如果基类和派生类既符合is-a又符合has-a关系,优先考虑使用组合。
说实话,继承这一块的知识还是比较复杂的,后面的多态难度也是不比这个简单,但是博主会尽量把能想到的场景和知识点都详细的给大家罗列出来,让老铁们能更清楚学习。
看到这里,别忘了支持一下博主~