目录
1. 继承和友元
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员。
测试 demo 如下:
namespace Xq
{
class worker;
// 基类
class person
{
public:
friend void display(const person& pobj, const worker& wobj);
protected:
std::string _name = "haha";
};
// 派生类
class worker : public person
{
protected:
std::string _job_number = "111";
};
void display(const person& pobj, const worker& wobj)
{
cout << pobj._name << endl;
cout << wobj._job_number << endl;
}
}
void Test1(void)
{
Xq::person pobj;
Xq::worker wobj;
Xq::display(pobj, wobj);
}
现象如下:
总而言之, 友元关系不可以从基类继承到派生类中,并且友元要慎用且不宜多用。
2. 继承与静态成员
基类定义了static 静态成员,则整个继承体系里面只有一个这样的成员。无论派生出多少个子
类,都只有一个 static 成员实例 。
测试 demo 如下:
namespace Xq
{
class person
{
public:
person(const char* name = "lisi")
:_name(name)
{}
public:
std::string _name;
// 静态成员属性
static int count;
};
class worker : public person
{
public:
protected:
std::string _job_number = "111";
};
// 静态成员属性需要在类外定义
int person::count = 0;
}
void Test2(void)
{
Xq::person pobj;
Xq::worker wobj;
pobj._name = "wangwu";
cout << pobj._name << endl;
cout << wobj._name << endl;
cout << "wobj.count: " << wobj.count << endl;
// 基类更改这个静态成员
pobj.count = 5;
cout << "wobj.count: " << wobj.count << endl;
cout << "&pobj.count = " << &pobj.count << endl;
cout << "&wobj.count = " << &wobj.count << endl;
}
现象如下:
上面的_name,基类对象和派生类对象各自私有一份,而对于静态成员变量 count,派生类继承的 count 和基类里面的 count 是同一份。
即基类里面的静态成员,无论有多少个派生类,它们都共享同一个静态成员。
如果我们想确定基类和派生类一共实例化了多少个对象,我们可以这样做:
person(const char* name = "lisi")
:_name(name)
{
++count;
}
当实例化对象时 (无论是基类对象还是派生类对象),都会调用基类的构造函数,就会++count,且由于这个静态成员 count 是所有 ( 基类/派生类 ) 对象共享的,因此可以得到实例化 ( 基类/派生类 ) 对象的总个数;
3. 菱形继承以及菱形虚拟继承
3.1. 单继承
单继承:一个派生类只有一个直接基类,我们称这个继承关系为单继承。
比如有三个类,teacher 类继承 worker类,worker 类继承 person 类,如下所示:
class Person{};
// Worker 继承 Person 类
class Worker : public Person{};
// Teacher 公有继承 Worker 类
class Teacher : public Worker{};
如图所示:
3.2. 多继承
多继承:一个派生类有两个或以上直接基类时称这个继承关系为多继承。
比如 Assistant 类 继承 Student 类和 Teacher 类,如下:
class Student{};
class Teacher{};
// Assistant 类 公有继承 Student 和 Teacher
class Assistant : public Student, public Teacher {};
如图所示:
3.3. 菱形继承
菱形继承:菱形继承是多继承的一种特殊情况。
比如 Assistant 类继承 Student 类和 Teacher 类,同时 Student 类继承 Person 类,Teacher 继承 Person类,就形成了一个菱形继承,如下:
具体如下:
namespace Xq
{
class Person
{
public:
std::string _name; //姓名
};
class Teacher : public Person
{
protected:
std::string _id_card; //职工编号
};
class Student : public Person
{
protected:
std::string _num; // 学号
};
class Assistant : public Teacher, public Student
{
protected:
std::string _subject; //学科
};
}
void Test(void)
{
// 实例化一个 Assistant 对象
Xq::Assistant obj;
}
菱形继承的问题:从下面的 Assistant类的对象成员模型,可以看出菱形继承有数据冗余和二义性的问题。
可以看到,Assistant 的对象中 person 成员 (_name) 会有两份 (一份是 Teache r类中的,另一份是 Student 类中的),并且访问时会存在二义性问题,如下 demo:
void Test(void)
{
Xq::Assistant obj;
obj._name = "haha";
}
现象如下:
二义性问题即调用不明确,编译器在这里不知道这个_name 是哪一个类中的_name, 也许是 Student 这个类中的,也许是 Teacher 这个类中的,这就是菱形继承的二义性问题。
当然我们也可以解决这种问题,方案是:指明类域,如下 demo:
void Test(void)
{
Xq::Assistant obj;
obj.Teacher::_name = "haha";
obj.Student::_name = "hehe";
}
此时就可以成功编译,现象如下:
虽然可以通过指明类域解决菱形继承的二义性问题,但是菱形继承的数据冗余的问题没有解决。
因此,人们在此基础上引入了菱形虚拟继承,虚拟继承可以解决菱形继承的二义性问题和数据冗余的问题。
如上面的继承关系,在 Student 和 Teacher 继承 Person 时使用虚拟继承,即可解决二义性问题和数据冗余问题,需要注意的是,虚拟继承不要在其他地方去使用。
3.4. 菱形虚拟继承
菱形虚拟继承:
比如 Assistant 类继承 Student 类和 Teacher 类,同时 Student 类虚拟继承 Person 类,Teacher 虚拟继承 Person类,就形成了一个菱形虚拟继承,如下图所示:
上图对应的 code 如下:
namespace Xq
{
class Person
{
public:
std::string _name; //姓名
};
class Teacher : virtual public Person //虚拟继承
{
protected:
std::string _id_card; //职工编号
};
class Student : virtual public Person //虚拟继承
{
protected:
std::string _num; // 学号
};
class Assistant : public Teacher, public Student
{
protected:
std::string _subject; //学科
};
}
void Test()
{
Xq::Assistant obj;
obj._name = "haha";
}
此时还会有数据冗余问题和二义性问题吗? 结果如下所示:
通过现象,我们发现:
- 首先,此时不指明类域,也可以成功编译,即菱形虚拟继承可以解决二义性问题;
- 其次,我们发现,在 obj 这个对象中,它们的 _name 是同一个成员,因此,这也就解决了数据冗余的问题。
3.5. 菱形继承的底层细节
为了探究菱形继承的底层细节,我们所用的 demo 具体如下:
根据上面的继承关系,写出如下 code,并通过 Test1 来进行测试:
namespace Xq
{
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;
};
}
void Test1(void)
{
Xq::D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
}
运行进程,通过监视窗口,得到下面的结果:
我们也可以观察此时的内存窗口,如下:
因此,结合上面的两个窗口,我们得到此时的菱形继承的d对象模型,具体如下:
可以看到,此时基类A中的成员 _a 在菱形继承的D类模型中出现了两次,即数据冗余问题,也正因为这个原因,当D类实例化的对象访问这个_a成员时,会存在着二义性的问题。
3.6. 菱形虚拟继承的底层细节
为了探究菱形虚拟继承的底层细节,我们所用的 demo 具体如下:
根据上面的继承关系,写出如下 code,并通过 Test2 来进行测试:
namespace Xq
{
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;
};
}
void Test2(void)
{
Xq::D d;
d._a = 0;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
}
运行进程,通过监视窗口,得到下面的结果:
我们也可以观察此时的内存窗口,如下:
因此,结合上面的两个窗口,我们得到此时的菱形虚拟继承的d对象模型,具体如下:
首先,上面的代码可以成功编译,因此,菱形继承解决了二义性问题;
其次, 在菱形虚拟继承的D类对象模型中,只有一份 _a (A类中的),因此也解决了数据冗余的问题 (在这里将_a放在了一个公共的区域)。
但是,我们发现一个事实,与菱形继承相比,多了两个地址,那有人说,这不是空间浪费了吗?
实际上,并没有,如果此时的A很大 (有很多成员),如果没有虚拟继承,那么就会造成大量的重复数据 (数据冗余),进而导致浪费空间,因此,事实上,虚拟继承会将冗余数据变成一份数据 (共享),因此会节省空间。
那现在的问题就是,这两个地址是什么呢?
通过内存窗口查看这个两个地址指向的内容,具体如下:
B类对象中的地址 0x0058dd1c 的内容如下:
C类对象中的地址 0x0058dd38 的内容如下:
我们发现这个地址里面存的是一个数字,B类对象中的地址存储的 0x00000014,即20;C类对象中的地址存储的 0x0000000c,即 12。
这两个数字我们称之为相对距离,有的也叫偏移量。
刚刚我们说了虚拟继承会把这里的 _a 放在一个公共的区域里面,那么对于B和C这两个类它是如何知道这个_a 所在的位置呢?
因此,B和C类就需要借助这个偏移量。
同时,我们发现,
为什么要设计这个偏移量呢? 原因是因为在某些情况下,我们必须借助偏移量找到这个公共的位置。
例如当发生切片的时候,具体如下:
void Test3(void)
{
Xq::D d;
// 发生切片的时候:
// 比如 D 类对象经过切片得到B类(对象/引用/指针)
Xq::B b1 = d;
Xq::B& b2 = d;
Xq::B* b3 = &d;
// 也比如 D 类对象经过切片得到C类(对象/引用/指针)
Xq::C c1 = d;
Xq::C& c2 = d;
Xq::C* c3 = &d;
}
因为此时这里的 _a 成员 (A类中的_a) 在菱形虚拟继承中的D类对象只有一份,这一份 _a 被放在了D对象模型中的公共位置。
在非虚拟继承中,_a 会在B类对象中有一份,在C类对象中也有一份,当发生切片时,B和C类很容易找到这个 _a;
但如果是虚拟继承,这个_a就在公共位置,而并没有在B和C类对象中,因此,当发生切片时,B和C类就需要借助偏移量找到这个_a。
我们用下图总结下菱形虚拟继承:
3.7. 虚拟继承
接下来,我们来探讨一下虚拟继承的细节问题,我们所用的 demo 具体如下:
根据上面的继承关系,写出如下 code,并通过 Test4 来进行测试:
class A
{
public:
int _a;
};
class B : virtual public A
{
public:
int _b;
};
void Test4(void)
{
Xq::B b;
std::cout << "Xq::B b size: " << sizeof(b) << std::endl;
}
首先,有一个问题:我们单看B类,它实例化的对象是多大呢?
单看B类,它是一个单继承,因此里面一定会有_a (继承A类中的),那实例化的大小是8吗?这个虚拟继承是否会有影响?我们看看结果:
结果并不是8,那么肯定就是虚拟继承带来的变化了,我们通过内存窗口,查看一下,为什么是12呢?
不过在这之前,为了查看的更明显一点,我们更改一下测试代码,如下:
void Test5(void)
{
Xq::B b;
b._a = 1;
b._b = 2;
std::cout << "Xq::B b size: " << sizeof(b) << std::endl;
}
运行进程,调出内存窗口,现象如下:
我们发现菱形虚拟继承B类对象模型中有一个地址,而运行起来的进程是32位的,故地址是4字节,因此,B类的对象是12个字节。
既然它是一个地址,那么它的内容是什么呢?如下:
结合上面的分析,我们可以得出B对象的模型如下:
事实上,虚拟继承B类中的对象模型中会存储一个指针,这个指针只想一张表,这张表会存储偏移量的值,在这里因为是单虚拟继承,因此只有一个偏移量。
这里的指针 (偏移量地址) 我们称之为虚基表指针,这张表我们称之为虚基表。
4. 继承的总结
- 很多人说 C++ 语法复杂,其实多继承就是一个体现。有了多继承,就存在菱形继承,有了菱形继承就有菱形虚拟继承,底层实现就很复杂。所以一般不建议设计出多继承,也不建议设计出菱形继承,否则在代码维护以及性能上都有问题;
- 多继承可以认为是 C++ 的缺陷之一,很多后来的oo(object--oriented) 语言都没有多继承,如 Jave;
- 继承和组合:
- public 继承是一种 is-a 的关系。也就是说每个派生类对象都是一个基类对象;
- 组合是一种 has-a 的关系。假设 B 组合了 A ,每个 B 对象中都有一个 A 对象;
- 优先使用对象组合,而不是类继承。
- 继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称为白箱复用 (white-box reuse) ;
- 术语 “ 白箱 ” 是相对可视性而言:在继承方式中,基类的内部细节对子类可见,继承一定程度破坏了基类的封装,基类的改变,对派生类有很大的影响,派生类和基类间的依赖关系很强,耦合度高;
- 对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复用 (black-box reuse) ,因为对象的内部细节是不可见的。对象只以 “ 黑箱 ” 的形式出现。组合类之间没有很强的依赖关系,耦合度低;
- 优先使用对象组合有助于保持每个类的封装特性;
- 实际中尽量多去使用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。
下面用实例说明什么是 is-a (继承),什么是has-a (组合)?demo 具体如下:
is-a关系:
class A
{
public:
void func() { cout << _a << endl;}
protected:
int _a;
};
// (公有)继承 --- is-a关系
// 由于A中成员的改动可能会影响B类,体现了耦合度高的特点
class B : public A
{
public:
//...
};
has-a关系:
class C
{
public:
void func() {cout << _c << endl;}
protected:
int _c;
};
// 组合 --- has-a关系
// 而对于组合而言,C的保护改动基本不影响D类,体现了耦合度低的特点
class D
{
public:
// ...
protected:
class C _c; // (组合)
};
在未来代码设计中,遵循的设计原则是:低耦合,高内聚。
5. 相关继承练习题
5.1. 如何定义一个无法被继承的类?
第一种方式,将基类的构造私有化,派生类继承这个基类,在实例化对象时,需要调用基类的构造,但由于基类的构造已经私有化,故会编译报错。
namespace Xq
{
class A
{
public:
//将基类的构造函数私有化
private:
A(int a = int())
:_a(a)
{
cout << "A()" << endl;
};
protected:
int _a;
};
class B : public A
{
protected:
int _b;
};
}
void Test3(void)
{
Xq::B b;
}
现象如下:
上面的做法是 C++98 的做法,而对于 C++11 的做法是:通过关键字 final,被 final 修饰的类,无法被继承,编译器会强制检查。
namespace Xq
{
// 用 final 修饰 A类, 此时A类无法被继承
class A final
{
public:
A(int a = int())
:_a(a)
{
std::cout << "A()" << std::endl;
};
protected:
int _a;
};
class B : public A
{
protected:
int _b;
};
}
void Test4(void)
{
Xq::B b;
}
现象如下:
5.2. 关于切片的问题
class Base1 { public: int _b1;};
class Base2 { public: int _b2;};
class Derive : public Base1, public Base2 {public: int _d; };
int main()
{
Derive d;
Base1* p1 = &d;
Base2* p2 = &d;
Derive* p3 = &d;
return 0;
}
// A:p1 = p2 = p3
// B: p1 < p2 < p3
// C: p1 == p3 != p2
// D: p1 != p2 != p3
做这种问题,我建议先把 Derive 的对象模型画出来,如下:
此时,我们再代入问题,如下:
现在,我们就得出答案了, p1 == p3 != p2。
不过我需要补充几点:
在多继承场景下,派生类继承多个基类时,是先继承的谁呢?
当派生类继承多个基类时,写在前面的就先继承谁。
诸如上面的例子:
class Derive : public Base1, public Base2 {public: int _d; };
此时就是先继承 Base1,在继承 Base2,Derive 对象模型如下:
如果是下面这样呢?
class Derive : public Base2, public Base1 {public: int _d; };
此时就是先继承 Base2,在继承 Base1,Derive 对象模型如下:
我们发现这个问题的结果是:p1 == p3 != p2,但请记住,虽然 p1 和 p3 指向了同一个位置,但意义不同,因为,指针的类型决定了它能访问内容的大小,如下所示:
同时,我们再看一看对象里面的成员在内存中如何存储的,我们通过下面的 demo 测试:
void Test4()
{
Derive d;
Base1* p1 = &d;
Base2* p2 = &d;
Derive* p3 = &d;
p1->_b1 = 1;
p2->_b2 = 2;
p3->_d = 3;
}
通过内存窗口得到如下现象:
换言之,这三者的地址关系应该是: p1 == p3 < p2。
5.3. 选择正确选项
如下所示:
class A{
public:
A(char *s) { std::cout << s << std::endl; }
~A(){}
};
class B :virtual public A
{
public:
B(char *s1, char*s2) :A(s1) { std::cout << s2 << std::endl; }
};
class C :virtual public A
{
public:
C(char *s1, char*s2) :A(s1) { std::cout << s2 << std::endl; }
};
class D :public B, public C
{
public:
D(char *s1, char *s2, char *s3, char *s4) :C(s1, s3), B(s1, s2), A(s1)
{
std::cout << s4 << std::endl;
}
};
int main() {
D *p = new D("class A", "class B", "class C", "class D");
delete p;
return 0;
}
// A:class A class B class C class D
// B:class D class B class C class A
// C:class D class C class B class A
// D:class A class C class B class D
首先我们要知道,初始化列表的初始化顺序是由成员声明顺序决定的,而在继承中,谁先被继承,谁就先初始化。
在这里,A类最先被D类继承,然后D类继承B,在继承C,因此A类最先被初始化,下来是B,下来是C,因此最后的答案是A。
结果如下:
继承的宏观内容到此结束。