每一个不曾起舞的日子都是对生命的辜负
如果你正处在学校,那么一定会接触到学生、老师、食堂阿姨、保安大叔……如果将其都转化成计算机语言,那么每一类人都属于一个类:
虽然他们扮演的角色不同,但都属于person,即人类的属性都是一样的。对于我们一直遵循的代码尽量复用的原则,为的就是避免代码造成冗余,尤其是函数的使用;那对于上述的类来说,每一个类都重复了一次人类最基本的属性,这样也会造成代码的冗余,如果我们把这几个类的公共属性像在排序中的交换函数一样给提炼出来从而单独变成一个函数一样,那么这个公共属性是不是就避免了代码的重复使用了呢?那是当然!因此,为了使不同类的公有属性不复用,我们可以将所有的公共的属性单拿出来封装成一个类,供其他的类同时使用,这样就避免了代码冗余并且尽可能的复用,同时这也是继承的意义。
继承(inheritance)机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
拿上面引出的举例,既然知道继承是什么,那么看看代码:
class Person
{
public:void Print(){cout << "name:" << _name << endl;cout << "age:" << _age << endl;}
protected:string _name = "peter"; // 姓名int _age = 18;
};// 继承后父类的Person的成员(成员函数+成员变量)都会变成子类的一部分。这里体现出了
//Student和Teacher复用了Person的成员。下面我们使用监视窗口查看Student和Teacher对象,可
//以看到变量的复用。调用Print可以看到成员函数的复用。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;
}
需要注意的是,继承到s和t中的Print的结果是按照基类的缺省值打印的。
发现,s和t只继承了p的public中的内容,所以和访问权限也是有关系的,那有什么关系呢?
一、定义格式
下面我们看到Person是父类,也称作基类。Student是子类,也称作派生类。
而基类有三种继承方式,基类也有三种访问限定符,即一共有9种组合方式。
二、继承方式和访问限定符
三、继承基类成员访问方式的变化
类成员/继承方式 | public继承 | protected继承 | private继承 |
---|---|---|---|
基类的public成员 | 派生类的public成员 | 派生类的protected成员 | 派生类的private成员 |
基类的protected成员 | 派生类的protected成员 | 派生类的protected成员 | 派生类的private成员 |
基类的private成员 | 在派生类中不可见 | 在派生类中不可见 | 在派生类中不可 |
总结:
那么总归要看一下这几种继承方式的现象,实践是检验真理的唯一标准:
// 实例演示三种继承关系下基类成员的各类型成员访问关系的变化
class Person
{
public:void Print(){cout << _name << endl;cout << "访问" << endl;}
protected:string _name; // 姓名
private:int _age; // 年龄
};
//class Student : protected Person
//class Student : private Person
class Student : public Person
{
protected:int _stunum; // 学号
};int main()
{Person p;Student s;s.Print();return 0;
}
若将继承方式变成protected或者private,就会变成派生类的私有,就不能调用继承的方法。
1. 派生类对象 可以赋值给 基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片或者切割。寓意把派生类中父类那部分切来赋值过去。
先不谈这个,在这之前多次提到的隐式类型转换,我们知道对于两个不同类型的变量,可以通过隐式类型转换进行赋值操作,这种赋值并不是直接把一个类型的变量的值赋值给另一个类型的变量的值,而是通过产生一个临时变量,这个临时变量的类型就是被赋值变量的类型,此外,这个临时变量还具有常性,这就有涉及到了一个知识:权限的放大和缩小,众所周知,权限是不能放大的,只能同阶或者权限缩小,同阶就是相同类型的变量的赋值,而缩小就是对应const和非const所说(int与double的转换被称为整形截断和整形提升,和权限无关)
既然知道了隐式类型转换的知识,那在回到这里,派生类的对象可以赋值给基类的对象,这个行为并不是刚刚提到的隐式类型转换,也不会产生临时变量,就是直接进行赋值,而派生类对象与基类的相同部分赋值给基类,就像这个切片一样。
那么通过代码看看现象:
class Person
{
protected:string _name; // 姓名string _sex; //性别
public:int _age; // 年龄
};
class Student : public Person
{
public:int _No; // 学号
};int main()
{Person p;Student s;// 天然的,中间不存在类型转换:不产生临时变量p = s;Person& rp = s;//验证了不产生临时变量,因为不用constrp._age++;Person* ptrp = &s;ptrp->_age++;return 0;
}
通过调试,进一步验证了上述的论述。
2. 基类对象不能赋值给派生类对象。
因为可以切割,却不能补充,因此这样在引用/指针的时候是不对的。
当然,也不绝对,有几个例子是可以将基类对象赋值给派生类对象的,但这里知道即可,后续会补充上。
3. 基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类的指针是指向派生类对象时才是安全的。这里基类如果是多态类型,可以使用RTTI(Run-Time Type Information)的dynamic_cast 来进行识别后进行安全转换。(ps:这个我们后面再讲解,这里先了解一下)
这就是上面所说的特殊的例子。
下面就以代码形式说明上述概念:
一、同名成员变量的冲突
// Student的_num和Person的_num构成隐藏关系,可以看出这样代码虽然能跑,但是非常容易混淆
class Person
{
protected:string _name = "小李子"; // 姓名int _num = 111; //身份证号
};
class Student : public Person
{
public:void Print(){cout << " 姓名:" << _name << endl;cout << ":" << _num << endl;}
protected:int _num = 999; // 学号
};
void Test()
{Student s1;s1.Print();
};
int main()
{Test();return 0;
}
上述代码中,基类和派生类都有_num这个成员变量,而对于s1来说他默认会调用自己的成员变量,当然成员函数也是这样,因此,当执行s1.Print()时,会打印999。
那如果想使用基类的成员变量,就需要明确作用域,即通过Person::_num
来确定是Person的成员变量。
因此,当子类与父类成员(包括成员变量、成员函数)有同名成员时,子类会隐藏父类的成员。(对于父类的private,也只是隐藏了,实际上也继承到子类了,只不过因为私有的特性,即便使用作用域也会报错)。
二、同名成员函数的冲突
// B中的fun和A中的fun不是构成重载,因为不是在同一作用域
// B中的fun和A中的fun构成隐藏,成员函数满足函数名相同就构成隐藏。
class A
{
public:void fun(){cout << "func()" << endl;}
};
class B : public A
{
public:void fun(int i){A::fun();cout << "func(int i)->" << i << endl;}
};
void Test()
{B b;b.fun(10);
}
int main()
{Test();return 0;
}
两个fun()是重定义/隐藏的关系。即便fun()不传值,也不会调用父类的fun(),因为二者之间不是在同一个作用域因此不构成函数重载,反而会报错,因为和正常的函数一样,参数的数量不匹配导致报错。因此想调用父类的同名成员函数,同样像上面一样,通过A::fun()
调用。
在最初的类和对象章节,讲过默认生成的四个成员函数:构造函数、拷贝构造函数、析构函数、赋值运算符重载。而现在我们学习了继承,知道基类可以传给派生类成员变量以及成员函数,但这默认成员函数对于每一个类来说都必须是自己的,并且基类的成员变量有可能比派生类的成员变量的数量不匹配,种种情况都表明,派生类的默认成员函数不能使用基类的默认成员函数(即便基类的成员函数会继承下来),那如何自己写出这几个默认成员函数呢?不写自动生成又是否可以呢?我将基类放在开头,下面的代码都是此基类的派生类。
class Person
{
public:Person(const char* name = "peter")//构造函数: _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;}
protected:string _name; // 姓名
};
下面就开始探讨:
派生类不写构造函数,不会报错:
如果将基类构造函数去掉,派生类不写默认成员函数就会报错,因为派生类调用的是父类的构造函数。
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
派生类写构造函数,像下面这样是否可以呢?
class Student : public Person
{
public:Student(const char* name, int num=18)//构造函数:_name(name),_num(num){cout <<"Student()"<
可见,直接显式调用父类的成员变量是不对的,因此我们必须通过让父类的构造函数将派生下来的成员变量构造初始化,即谁的变量由谁构造。
class Student : public Person
{
public:Student(const char* name, int num=18)//构造函数:Person(name), _num(num){cout << "Student()" << endl;}
protected:int _num; //学号
};
这样就符合C++的规则了。
接着上段构造函数的代码,如果不写拷贝构造,编译器会默认生成:
对于拷贝构造,我们需要拷贝构造的有三部分:基类继承下来的类型、内置类型、自定义类型。如果不写拷贝构造,完全可以解决基类继承下来的和内置类型,因为属于基类的类型会自动调用基类的拷贝构造,上面的结果也可以证实这一点,而内置变量的拷贝构造会随着初始化列表进行值拷贝,因此不写拷贝构造的结果与类和对象中的情况是一样的。
但是自己写拷贝构造函数也是必要的,因为会有自定义类型的成员变量,下面就来写一下:
class Student : public Person
{
public:Student(const char* name, int num=18)//构造函数:Person(name), _num(num){cout << "Student()" << endl;}Student(const Student& s)//拷贝构造函数:Person(s),_num(s._num){}
protected:int _num; //学号
};
我们发现,这与构造函数几乎是一样的,此外,对于Person(s)
这里发生的就是上述所说的切片赋值。
不写这个函数调用默认的赋值运算符重载,情况和拷贝构造一样,成员变量中含自定义变量则不能处理。
class Student : public Person
{
public:Student(const char* name, int num=18)//构造函数:Person(name), _num(num){cout << "Student()" << endl;}Student(const Student& s)//拷贝构造:Person(s), _num(s._num){}Student& operator=(const Student& s){if (this != &s){Person::operator=(s);//注意是Person,避免隐藏_num = s._num;}return *this;}
protected:int _num; //学号
};
赋值运算符重载这里需要注意的就是防止同名造成基类的operator隐藏。
赋值前:
赋值后:
终止:
总结: 实现了上述三个默认成员函数,便发现,只要是基类继承下来的成员变量,那就用基类的构造函数代替显式的构造就可以了,此外就是需要注意同名隐藏的问题,除了这些,派生类的默认成员函数与之前在类和对象中的默认成员函数毫无区别。那就只剩下的派生类的析构函数了。
如果按照正常的逻辑写析构函数,会发生报错:
这是析构函数与前三者的不同,如果加上作用域,就不会发生错误:
class Student : public Person
{
public:Student(const char* name, int num)//构造函数:Person(name), _num(num){cout << "Student()" << endl;}Student(const Student& s)//拷贝构造:Person(s), _num(s._num){}Student& operator=(const Student& s){if (this != &s){Person::operator=(s);//注意是Person,避免隐藏_num = s._num;}return *this;}~Student()//析构{Person::~Person();}
protected:int _num; //学号
};
int main()
{Student a("张三");return 0;
}
1. 子类析构函数和父类析构函数构成隐藏关系。(由于多态需求关系,所有析构函数都会特殊处理成destructor
的函数名,这样由于函数名相同就会发生隐藏)
通过结果发现,析构调用了两次,因此一旦成员变量有new出来的指针,并且析构中写delete p[]
的话,就会导致new出来的一块空间析构两次,这样是错误的。因此,析构这里也需要与前三部分的不同:析构不需要像前三个一样显示调用对应的基类的析构函数,而是会自动生成基类的析构函数。因此,不必在派生类的析构函数中调用基类的析构,而是会自动生成。
class Student : public Person
{
public:Student(const char* name, int num = 18)//构造函数:Person(name), _num(num){cout << "Student()" << endl;}Student(const Student& s)//拷贝构造:Person(s), _num(s._num){}Student& operator=(const Student& s){if (this != &s){Person::operator=(s);//注意是Person,避免隐藏_num = s._num;}return *this;}~Student()//析构{//Person::~Person();//不用调用cout << "~Student()" << endl;}
protected:int _num; //学号
};
int main()
{Student a("张三");return 0;
}
2. 发现顺序:
构造: 基类先构造,派生类后构造。
析构: 派生类先析构,基类后析构。
class Student;//前置声明
class Person
{
public:friend void Display(const Person& p, const Student& s);
protected:string _name; // 姓名
};
class Student : public Person
{
protected:int _stuNum; // 学号
};
void Display(const Person& p, const Student& s)
{cout << p._name << endl;cout << s._stuNum << endl;
}
int main()
{Person p;Student s;Display(p, s);return 0;
}
发现,经过上面的解释,Display并不属于Student类的友元,因此这样是错的。因此,需要将Display同时作为两个类的友元,这样才不会出错:
class Student;
class Person
{
public:friend void Display(const Person& p, const Student& s);
protected:string _name; // 姓名
};
class Student : public Person
{
public:friend void Display(const Person& p, const Student& s);
protected:int _stuNum; // 学号
};
void Display(const Person& p, const Student& s)
{cout << p._name << endl;cout << s._stuNum << endl;
}
int main()
{Person p;Student s;Display(p, s);return 0;
}
基类定义了static静态成员,则整个继承体系里面只有一个这样的成员。 无论派生出多少个子类,都只有一个static成员实例 。
class Person
{
public:Person() { ++_count; }string _name; // 姓名
public:static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
protected:int _stuNum; // 学号
};
class Graduate : public Student
{
protected:string _seminarCourse; // 研究科目
};
//静态成员属于整个类,所有对象,同时也属于所有派生类及对象
int main()
{Person p;Student s;Graduate g;p._name = "张三";s._name = "李四";p._count++;s._count++;cout << p._count << endl;cout << s._count << endl;cout << &p._count << endl;cout << &s._count << endl;cout << Person::_count << endl;cout << Student::_count << endl;cout << Graduate::_count << endl;return 0;
}
class Person
{
public:Person() { ++_count; }void Print(){cout << "Print" << endl;}string _name; // 姓名
public:static int _count; // 统计人的个数。
};
int Person::_count = 0;
class Student : public Person
{
protected:int _stuNum; // 学号
};
//静态成员属于整个类,所有对象,同时也属于所有派生类及对象
int main()
{//->和*不一定就是解引用Person* ptr = nullptr;ptr->_count++;//cout << ptr->_name << endl;//1. noptr->Print();//2. yescout << ptr->_count << endl;//3. yes(*ptr).Print();//4.yescout << (*ptr)._count << endl;//5.yesreturn 0;
}
这在this指针中讲过,第一个不对的原因是_name
作为成员变量,那么对于成员变量,->
或者(*ptr)
一定是解引用,由于是空指针,一定会报错。而Print作为成员函数,其不在类中而是在代码段,因此算不上解引用,_count
作为静态变量在静态区不在类中当然也算不上解引用。(下面通过反汇编也可以看出)
即在对象中存储就属于解引用,否则不属于。
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
对于Java,只有单继承,没有多继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承:菱形继承是多继承的一种特殊情况。
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。在Assistant的对象中Person成员会有两份。
举例说明数据冗余/二义性:
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; // 主修课程
};int main()
{Assistant a;a._name;//出错:二义性return 0;
}
空间角度:数据冗余;从逻辑上:二义性。这两种问题实际上也都可以解决。
解决数据冗余与二义性:
int main()
{Assistant a;//指定作用域a.Student::_name = "张三";a.Teacher::_name = "张老师";return 0;
}
通过指定作用域的方式,就可以解决二义性的问题。即以学生的身份他叫张三,以老师的身份它叫做张老师。
但指定作用域没有彻底解决这个问题,因为这样就是将二义性的东西彻底分开,即如上的地址不同,对象就不同。那如何彻底解决呢?这就涉及到了虚继承。
提到虚拟继承,就多了一个关键字:virtual
虚拟继承可以解决菱形继承的二义性和数据冗余的问题。如上面的继承关系,在Student和Teacher的继承Person时使用虚拟继承,即可解决问题。需要注意的是,虚拟继承不要在其他地方去使用。
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 = "小张";a.Student::_name = "张三";a.Teacher::_name = "张老师";cout << &a._name << endl;cout << &a.Student::_name << endl;cout << &a.Teacher::_name << endl;return 0;
}
这样,他们代表的都是同一个变量,通过地址也可以看出:
如果是普通的菱形继承,像这样:
class A
{
public:int _a;
};
class B : public A
{
public:int _b;
};
class C : public A
{
public:int _c;
};
class D : public B, public C
{
public:int _d;
};
int main()
{D d;d._b = 1;d._c = 2;d._d = 3;d.B::_a = 4;d.C::_a = 5;return 0;
}
我们知道这样会造成二义性和代码冗余,并且通过监视窗口了解数据,然而监视窗口是优化后的,并不能直接看到原始的内存分配,因此下面打开内存窗口观察:
整体都作为D类的数据,D类继承了A、B、C,由于菱形继承的代码冗余,发现B和C继承下来的A都存在在D中,而虚拟继承为了解决两个A同时出现,采用了以下的策略:(下面改成虚拟继承的代码)
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 = 1;d._c = 2;d._d = 3;d.B::_a = 4;d.C::_a = 5;return 0;
}
再打开内存监视观察:
发现,菱形虚拟继承将普通菱形继承在B和C中的A都消除了,在一个新的位置单开了一个位置存储A,但同时也发现,两个黄色的框的地址代表的是什么我们不知道,所以再开两个窗口看看这个地址存储的是什么东西:
由于是小端16进制,当然需要成对的倒着读。即B中地址指向的大小为20,C中地址指向的大小为12,通过左侧内存已的两段地址相减,正好一一对应,而这两个数字就是举例虚基类对象的偏移量。为什么会有偏移量?如果是相同类的赋值,根本不需要这样的东西,而上面谈到过,将派生类赋值给基类会发生切片,切片就会导致等号两边的对象的成员变量的相对位置会发生变化,由于A地址只有一个,此时如果仍要找到该成员变量的位置,就需要一个数据记录下来之前的相对位置,这样才能通过各种作用域去访问同一个A。
那为什么普通的菱形继承没有这个偏移量呢?因为普通的菱形继承不会优化成一个A,这也就代表每一个A都有一个对应的值,他们并不相同,因此不需要通过各种作用域去访问同一个A,每一个作用域都有属于自己的A。
所以可以看出普通菱形继承和菱形虚拟继承的区别:
- 普通菱形继承无法将多个相同的继承类变成一个,这些类即便成员相同,但存储的值也不同。
- 菱形虚拟继承则是将普通菱形继承的相同的继承的类合并成一个,即便访问方式(即作用域)不同,每一个访问方式也能通过相对偏移量找到相同的A。
那这样看起来,似乎由于偏移量的产生使内存占用更多了?当然不是,如果A占用的内存非常大,此消彼长之下,内存空间的占用就会变小。
下面看看继承与组合的代码方式,这些都是见过的。
1. 继承
class X
{int _x;
};
class Y :public X
{int _y;
};
2.组合
class M
{int _m;
};
class N
{M _mm;//组合int _n;
};
继承和组合都是一种代码复用的方式,但是也有所区别。相对于继承,组合更加的保护复用的成员变量,因为继承可以继承下来public、protect、private(虽然看不到,但还是继承了);而组合只能在另一个类中使用该类的public,同时,也将继承和组合命名为白箱和黑箱,顾名思义,白箱就是一览无余,而黑箱与白箱恰好相反。因此,在继承和组合都能使用的话,推荐使用组合,因为组合可以更好的保护public以外的成员。
- 什么是菱形继承?菱形继承的问题是什么?
- 什么是菱形虚拟继承?如何解决数据冗余和二义性的
- 继承和组合的区别?什么时候用继承?什么时候用组合?
答:
多继承中的一种特殊继承,即一个类可能被另一个类以不同的作用域继承多次。菱形继承会导致代码的二义性及空间浪费。
通过virtual将 “菱形继承的腰部” 进行修改,防止腰部以下重复使用相同的类。解决冗余与二义性则通过偏移量的访问方式。
继承与组合就像白箱与黑箱,对于非public,一个一览无余,一个恰好相反。因此,在需要派生类和基类间的依赖关系强时,就用继承。相反就用组合。如果都可以,就优先使用组合。