继承
继承的概念
继承机制是面向对象程序设计使代码可以复用的最重要的手段,它允许程序员在保 持原有类特性的基础上进行扩展,增加功能,这样产生新的类,称派生类。继承呈现了面向对象 程序设计的层次结构,体现了由简单到复杂的认知过程。以前我们接触的复用都是函数复用,继承是类设计层次的复用。
继承的定义
定义格式
继承关系和访问限定符
继承基类成员访问方式的变化
基类是 private ,这种情况下,无论以何种方式继承,对于基类私有的部分在子类中都是不可见的,这里的不可见指的是无法访问,但仍然继承下来了,只是无法使用而已。但是可以通过父类提供的方法进行间接的访问和使用。如图:(Print 方法在上图父类中有写)如图:
对于父类私有成员,子类无论哪种继承都无法访问,对于父类其他成员,继承后被哪种访问限定符修饰取决于父类成员访问限定符和继承方式中权限小的那一个。
从继承这里我们也可以看出 protect 的意义:正是因为有了继承,protect 才有意义,对于父类不想让外界访问也不想让子类访问的成员,父类可以用 private 修饰,对于父类不想让外界访问,但想让子类访问的成员可以用 protect 修饰。
使⽤关键字 class 时默认的继承⽅式是private,使⽤ struct 时默认的继承⽅式是public
基类和派生类对象赋值转换
派生类对象可以赋值给基类的对象 / 基类的指针 / 基类的引用。这里有个形象的说法叫切片 或者切割。寓意把派生类中父类那部分切来赋值过去。
基类对象不能赋值给派生类对象。
基类的指针或者引用可以通过强制类型转换赋值给派生类的指针或者引用。但是必须是基类 的指针是指向派生类对象时才是安全的。
子赋值给父:向上转换(可以)
父赋值给子:向下转换(不可以)
不会产生临时变量,将子类中父亲的切片出来拷贝给父类
不能向下转换原因:
根本问题:基类指针/引用可能并不实际指向派生类对象
派生类通常比基类有更多成员,错误向下转型会导致访问不存在的数据:
基本继承示例代码:
#include<iostream>
using namespace std;
// 基类 Person
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "peter"; // 姓名
int _age = 18; // 年龄
};
// 公有继承 Person
class Student : public Person
{
protected:
int _stuid; // 学号
};
// 公有继承 Person
class Teacher : public Person
{
protected:
int _jobid; // 工号
};
int main()
{
Person p;
Student s;
// 赋值兼容转换(切割/切片)
Person p1 = s; // 派生类对象赋值给基类对象
Person& rp = s; // 基类引用引用派生类对象
rp._name = "张三"; // 通过引用修改派生类中的基类部分
Person* ptrp = &s; // 基类指针指向派生类对象
ptrp->_name = "李四"; // 通过指针修改派生类中的基类部分
return 0;
}
学生和教师类继承了人这个类,人这个类中所有的东西在学生和教师类里已经都具有了,需要注意的是成员变量虽然继承过来了,但各自的对象都独立有这样一份成员变量,使用起来互不影响,而对于继承过来的成员方法都使用同一份(构造函数除外)。
继承中的作用域
1. 在继承体系中基类和派生类都有独立的作用域。
2. 子类和父类中有同名成员,子类成员将屏蔽父类对同名成员的直接访问,这种情况叫隐藏, 也叫重定义。(在子类成员函数中,可以使用 基类::基类成员 显示访问)
3. 需要注意的是如果是成员函数的隐藏,只需要函数名相同就构成隐藏。
4. 注意在实际中在继承体系里面最好不要定义同名的成员。
隐藏/重定义:子类和父类有同名成员,子类隐藏父类成员(就近原则)
重载:同一个作用域
隐藏:父子类域中函数名相同
派生类不能直接在初始化列表中初始化基类的成员变量,必须通过基类的构造函数来初始化基类成员
成员隐藏(重定义)示例
class Person
{
public:
void fun()
{
cout << "Person::func()" << endl;
}
protected:
string _name = "小李子"; // 姓名
int _num = 111; // 身份证号
};
// 派生类 Student
class Student : public Person
{
public:
// 隐藏了父类的 fun() 函数
void fun()
{
cout << "Student::func()" << endl;
}
void Print()
{
cout << "姓名:" << _name << endl;
cout << _num << endl; // 访问派生类的 _num
cout << Person::_num << endl; // 访问基类的 _num
}
protected:
int _num = 999; // 学号 (隐藏了基类的 _num)
};
int main()
{
Student s;
s.Print();
s.fun(); // 调用派生类的 fun
s.Person::fun(); // 显式调用基类的 fun
return 0;
}
派生类的默认成员函数
6个默认成员函数,“默认”的意思就是指我们不写,编译器会变我们自动生成一个,那么在派生类 中,这几个成员函数是如何生成的呢?
1. 派生类的构造函数必须调用基类的构造函数初始化基类的那一部分成员。如果基类没有默认 的构造函数,则必须在派生类构造函数的初始化列表阶段显示调用。
class person { public: person() :_name("张三") {} void Print() { cout << "person" << _name << endl; } protected: int _age; string _name; }; class student :public person { public: student() :person() ,_num(0) {} void Print() { cout << "student" << _name <<_num<< endl; } protected: int _num; };
class Base { protected: int _x; public: Base(int x) : _x(x) {} // 基类构造函数初始化 _x }; class Derived : public Base { public: // 通过 Base(x) 初始化基类成员 _x Derived(int x) : Base(x) {} // 错误:不能在派生类初始化列表直接初始化 _x // Derived(int x) : _x(x) {} };
2. 派生类的拷贝构造函数必须调用基类的拷贝构造完成基类的拷贝初始化。
3. 派生类的operator=必须要调用基类的operator=完成基类的复制。
4. 派生类的析构函数会在被调用完成后自动调用基类的析构函数清理基类成员。因为这样才能保证派生类对象先清理派生类成员再清理基类成员的顺序。
5. 派生类对象初始化先调用基类构造再调派生类构造。
6. 派生类对象析构清理先调用派生类析构再调基类的析构。
7. 因为后续一些场景析构函数需要构成重写,重写的条件之一是函数名相同(这个我们后面会讲 解)。那么编译器会对析构函数名进行特殊处理,处理成destrutor(),所以父类析构函数不加 virtual的情况下,子类析构函数和父类析构函数构成隐藏关系。
成员函数示例
class Person
{
public:
Person(const char* name)
: _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;
delete _pstr;
}
protected:
string _name; // 姓名
string* _pstr = new string("111111111");
};
class Student : public Person
{
public:
// 先调用基类构造函数,再初始化派生类成员
Student(const char* name = "张三", int id = 0)
:Person(name)
,_id(id)
{}
// 拷贝构造
Student(const Student& s)
:Person(s) // 调用基类拷贝构造
,_id(s._id)
{}
// 赋值运算符
Student& operator=(const Student& s)
{
if (this != &s)
{
Person::operator=(s); // 调用基类赋值运算符
_id = s._id;
}
return *this;
}
~Student()
{
// 子类析构完成后会自动调用父类析构
cout << *_pstr << endl;
delete _ptr;
}
protected:
int _id;
int* _ptr = new int;
};
int main()
{
Student s1;
Student s2(s1); // 调用拷贝构造
Student s3("李四", 1);
s1 = s3; // 调用赋值运算符
return 0;
}
Student s2(s1); // 调用拷贝构造,没写拷贝构造默认掉默认构造
派生类只用析构自己的就可以了
构造子类后编译器自动析构父类(子可以用父,父不可以用子)
由于后边多态的问题析构函数函数名被特殊处理了,统一处理成destructer
继承与友元
友元关系不能继承,也就是说基类友元不能访问子类私有和保护成员
友元函数示例
class Student;
class Person
{
public:
friend void Display(const Person& p, const Student& s);
protected:
string _name; // 姓名
};
class Student : public Person
{
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成员实例 。
静态成员属于父类和派生类,派生类中不会单独拷贝一份,继承的是使用权
静态成员属于类本身,不属于对象
无论是否涉及继承,静态成员(static 变量/函数)都是类的全局共享成员,不属于任何一个对象。所有对象(包括基类和派生类的对象)访问的是同一份静态成员。
派生类不会单独拷贝静态成员
静态成员不会被派生类复制,而是直接继承访问权。
基类和派生类共享同一个静态成员。
静态成员继承示例
class Person
{
public:
Person() { ++_count; }
protected:
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 s1;
Student s2;
cout << Person::_count << endl; // 输出3,因为创建了3个对象
return 0;
}
复杂的菱形继承与菱形虚拟继承
单继承:一个子类只有一个直接父类时称这个继承关系为单继承
多继承:一个子类有两个或以上直接父类时称这个继承关系为多继承
菱形继承:菱形继承是多继承的一种特殊情况。
菱形继承的问题:从下面的对象成员模型构造,可以看出菱形继承有数据冗余和二义性的问题。 在Assistant的对象中Person成员会有两份。
菱形继承代码:
主要问题
1. 数据冗余问题
Assistant
对象会包含 两个Person
子对象:一个来自
Student
继承路径一个来自
Teacher
继承路径这意味着
_name
和_age
会被存储两次,造成内存浪费
2. 二义性问题(编译错误)
当尝试直接访问
as._name
时,编译器无法确定应该使用哪个路径的_name
:as._name = "张三"; // 错误:对成员'_name'的访问不明确
3. 必须明确指定访问路径
要解决二义性问题,必须指定访问路径:
as.Student::_name = "张三"; // 通过Student路径访问 // 或 as.Teacher::_name = "张三"; // 通过Teacher路径访问
虚继承(解决菱形继承问题)
通过virtual关键字来实现虚继承解决菱形继承问题
Person
成为虚基类(Virtual Base Class)。
Assistant
对象只包含一份Person
子对象,Student
和Teacher
共享它。
_age
和_name
不再冗余,所有访问都指向同一个内存位置。virtual要加在第一个会引起数据冗余的类上
class Person
{
public:
string _name; // 姓名
int _age;
};
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 as;
as.Student::_age = 18;
as.Teacher::_age = 30;
as._age = 19;
return 0;
}
虚拟继承解决数据冗余和二义性的原理
菱形继承
菱形继承有数据冗余问题
菱形虚拟继承
虚继承构造函数调用顺序
#include<iostream>
using namespace std;
class A {
public:
A(const char* s) {
cout << s << endl; // 打印构造信息
}
~A() {}
};
class B : virtual public A // 虚继承A
{
public:
B(const char* sa, const char* sb)
: A(sa) { // 初始化虚基类A
cout << sb << endl;
}
};
class C : virtual public A // 虚继承A
{
public:
C(const char* sa, const char* sb)
: A(sa) { // 初始化虚基类A
cout << sb << endl;
}
};
class D : public B, public C
{
public:
// 注意:虚基类A的初始化由D直接负责
D(const char* sa, const char* sb, const char* sc, const char* sd)
: A(sa), // 显式初始化虚基类(实际最先执行)
B(sa, sb), // 初始化B(此时不会重复构造A)
C(sa, sc) { // 初始化C(此时不会重复构造A)
cout << sd << endl;
}
};
int main() {
// 场景1:构造D对象(菱形继承)
D* p = new D("class A", "class B", "class C", "class D");
/* 输出顺序:
class A (虚基类A的构造)
class B (B的构造)
class C (C的构造)
class D (D的构造)
*/
delete p;
// 场景2:单独构造B对象(单继承)
B b("class A", "class B");
/* 输出顺序:
class A (虚基类A的构造)
class B (B的构造)
*/
return 0;
}
在虚继承中,构造顺序遵循:
虚基类最先构造(无论它在初始化列表中的位置)
非虚基类按声明顺序构造(
class D : public B, public C
则先B
后C
)最后构造派生类自身
初始化列表顺序仅决定参数传递
如果交换
B
和C
的参数(如C(sa, sc)
写在B(sa, sb)
前面),参数会正常传递,但构造顺序不变
虚继承内存布局示例
class A
{
public:
int _a;
};
// 虚继承 A
class B : virtual public A
{
public:
int _b;
};
// 虚继承 A
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;*/
D d;
d._a = 1; // 虚继承后只有一个_a
B b;
b._a = 2;
b._b = 3;
B* ptr = &b;
ptr->_a++; // 通过基类指针访问
ptr = &d;
ptr->_a++; // 通过基类指针访问派生类对象
return 0;
}
每个虚继承类存储一个虚基表指针,指向其虚基表。
虚基表存的是偏移量不是A的地址(用来动态计算虚基类的位置)
偏移量在切割和切片中需要
虚基表中偏移量里第一个为其他值进行了预留
d1和d2都直接指向这个数据,不需要在内部开额外空间存重复数据
ptr->a不知道是B还是D的,B和D中间可能搁这好几部分,先通过虚基表指针找到虚基表,再凭借偏移量可以找到A。
继承和组合
public继承是一种is-a的关系。也就是说每个派生类对象都是一个基类对象。
组合是一种has-a的关系。假设B组合了A,每个B对象中都有一个A对象。
继承允许你根据基类的实现来定义派生类的实现。这种通过生成派生类的复用通常被称 为白箱复用(white-box reuse)。术语“白箱”是相对可视性而言:在继承方式中,基类的内部细节对子类可见 。继承一定程度破坏了基类的封装,基类的改变,对派生类有很 大的影响。派生类和基类间的依赖关系很强,耦合度高。
对象组合是类继承之外的另一种复用选择。新的更复杂的功能可以通过组装或组合对象来获得。对象组合要求被组合的对象具有良好定义的接口。这种复用风格被称为黑箱复 用(black-box reuse),因为对象的内部细节是不可见的。对象只以“黑箱”的形式出现。 组合类之间没有很强的依赖关系,耦合度低。优先使用对象组合有助于你保持每个类被封装。
实际尽量多去用组合。组合的耦合度低,代码维护性好。不过继承也有用武之地的,有些关系就适合继承那就用继承,另外要实现多态,也必须要继承。类之间的关系可以用继承,可以用组合,就用组合。
特性 | 继承 | 组合 |
---|---|---|
关系 | is-a | has-a |
耦合度 | 高 | 低 |
灵活性 | 低(编译时确定) | 高(运行时可替换) |
代码复用 | 白盒复用(了解实现) | 黑盒复用(只使用接口) |
多态支持 | 支持 | 间接支持(通过接口) |
基类/组件修改影响 | 影响所有派生类 | 影响范围有限 |
适合场景 | 需要多态/接口扩展 | 代码复用/功能组合 |
//继承
class A
{
public:
int _a;
};
class B : public A
{
public:
int _b;
};
//组合
class A
{
public:
int _a;
};
class B :
{
public:
A a();
int _b;
};
多态
注意:
只有成员函数才能+virtual
对象里不存成员函数,只有成员变量
不同对象传过去调用不同函数
多态调用看的指向的对象
普通对象看当前类型
派生类的重写虚函数可以不+virtual
协变:返回值可以不同,但返回值必须是父子关系指针和引用
虚函数三同(函数名,参数列表,返回值类型)
多态的概念
多态是面向对象编程的三大特性之一(封装、继承、多态),它允许不同类的对象对同一消息做出不同的响应
多态分为:
编译时多态(静态多态):通过函数重载和模板实现
运行时多态(动态多态):通过虚函数和继承实现
我们主要了解运⾏时多态。要实现运行时多态,必须满足以下条件:
继承关系:存在基类和派生类
虚函数:基类中使用
virtual
声明函数指针/引用:通过基类指针或引用调用虚函数
多态的定义和实现
多态的构成条件
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。比如Student继承了 Person。Person对象买票全价,Student对象买票半价。
1.调用函数是重写的虚函数
2.基类指针或引用调用(子类指针只能指向子类,虚表只有子类虚函数,父类没有子类成员变量或方法会引发内存错误)
基础多态代码:
#include <iostream>
using namespace std;
// 基类
class Person {
public:
virtual void BuyTicket() {
cout << "买票-全价" << endl;
}
};
// 派生类
class Student : public Person {
public:
virtual void BuyTicket() override {
cout << "买票-半价" << endl;
}
};
// 多态调用函数
void Func(Person& p) {
p.BuyTicket(); // 多态调用
}
int main() {
cout << "----- 基础多态演示 -----" << endl;
Person Mike;
Student Johnson;
Func(Mike); // 输出: 买票-全价
Func(Johnson); // 输出: 买票-半价
return 0;
}
“调用函数是重写的虚函数”
体现在
Student
类中virtual void BuyTicket() override
,它重写了基类Person
的虚函数BuyTicket()
。运行时通过
Person& p
调用p.BuyTicket()
时,会根据实际对象类型(Person
或Student
)决定调用哪个版本(多态)。“基类指针或引用调用”
体现在
Func(Person& p)
的参数是基类引用,且p.BuyTicket()
通过该引用调用虚函数。如果改为
Func(Person p)
(传值而非引用),则失去多态,始终调用Person::BuyTicket()
。
虚函数
虚函数:即被virtual修饰的类成员函数称为虚函数。
virtual void BuyTicket() {
cout << "买票-全价" << endl;
}
虚函数的重写(覆盖)
派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的 返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。(派生类中virtual可以不写,但是父类必须写,因为如果父类不写这个函数就不是虚函数了)
// 派生类
class Student : public Person {
public:
virtual void BuyTicket() override {
cout << "买票-半价" << endl;
}
};
虚函数重写的两个例外:
协变
基类与派生类虚函数返回值类型不同
派生类重写基类虚函数时,与基类虚函数返回值类型不同。即基类虚函数返回基类对象的指 针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
协变仅适用于指针或引用类型
Student::f()
返回int*不合法,
int*
与A*
无继承关系,不满足协变规则。
class A{};
class B : public A {};
class Person {
public:
virtual A* f() {return new A;}
};
class Student : public Person {
public:
virtual B* f() {return new B;}
};
析构函数的重写
基类与派生类析构函数的名字不同
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加virtual关键字, 都与基类的析构函数构成重写,虽然基类与派生类析构函数名字不同。虽然函数名不相同, 看起来违背了重写的规则,其实不然,这里可以理解为编译器对析构函数的名称做了特殊处 理,编译后析构函数的名称统一处理成destructor。
确保通过基类指针删除派生类对象时正确调用派生类析构
内存释放过程:
通过虚表找到
Student::~Student()
执行派生类析构:
输出
~Student()
释放
ptr
指向的数组
自动调用基类析构
Person::~Person()
若不使用虚析构函数:
delete p
只会调用Person::~Person()
导致
ptr
内存泄漏(约40字节)
防止基类new派生类,析构基类
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
virtual ~Person() { cout << "~Person()" << endl; }
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
~Student() {
cout << "~Student()" << endl;
delete[] ptr;
}
protected:
int* ptr = new int[10];
};
int main()
{
//Person p;
//Student s;
Person* p = new Person;
p->BuyTicket();
delete p;
p = new Student;
p->BuyTicket();
delete p; // p->destructor() + operator delete(p)
// 这里我们期望p->destructor()是一个多态调用,而不是普通调用
return 0;
}
禁止在派生类中释放基类资源
基类析构函数已经负责其自身资源的释放,派生类不应干涉。层级化资源管理
基类管理基类的资源
派生类管理派生类新增的资源
像堆叠的俄罗斯套娃,各层管好自己的部分
C++11 override 和 final
C++对函数重写的要求比较严格,但是有些情况下由于疏忽,可能会导致函数 名字母次序写反而无法构成重载,而这种错误在编译期间是不会报出的,只有在程序运行时没有 得到预期结果才来debug会得不偿失,因此:C++11提供了override和final两个关键字,可以帮 助用户检测是否重写。
final:修饰虚函数,表示该虚函数不能再被重写
final
的作用:
当用于虚函数时(如
virtual void Drive() final
),表示禁止派生类重写该函数。当用于类时(如
class Benz final
),表示禁止其他类继承Benz
。
class Car
{
public:
virtual void Drive() final {}
};
class Benz :public Car
{
public:
virtual void Drive() {cout << "Benz-舒适" << endl;}
};
override: 检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
class Car{
public:
virtual void Drive(){}
};
class Benz :public Car {
public:
virtual void Drive() override {cout << "Benz-舒适" << endl;}
};
设计不希望被继承类
1.基类构造函数私有 (C++98)
1)通过将构造函数设为私有,阻止派生类实例化(因为派生类构造时需要调用基类构造函数)。
2)派生类析构时需要调用基类析构函数,私有化析构函数可阻止继承。
2.基类加一个final (C++11)
重载、覆盖(重写)、隐藏(重定义)的对比
抽象类
概念
在虚函数的后面写上 =0 ,则这个函数为纯虚函数。包含纯虚函数的类叫做抽象类(也叫接口类),抽象类不能实例化出对象。派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
纯虚函数和抽象类
func 为纯虚函数,所以 A 为抽象类。抽象类不允许实例化对象。
class A
{
public:
virtual void fun() = 0;
};
虽然抽象类不可以实例化出对象,但是我们可以写一个类来继承它,并重写里面的纯虚函数,这时这个子类是可以实例化出对象并可以调用重写后的方法的。
class A
{
public:
virtual void fun() = 0;
};
class B:public A
{
public:
virtual void fun()
{
cout << " " << endl;
}
};
多态的原理
练习
class A
{
public:
virtual void func(int val = 1) { cout << "A->" << val << endl; }
virtual void test() { func(); }//隐含this指针
};
class B : public A
{
public:
virtual void func(int val = 0) { cout << "B->" << val << endl; }
};
int main()
{
B* p = new B;
p->test();
return 0;
}
p 指针调 test 方法的时候会去 A 类中调用,A 类的 test 方法中又调用了 func 方法,类的成员函数是有一个隐含的 this 指针的,这个 func 方法就是通过这个 this 指针调用的,这个 this 指针类型是 A*(基类指针)func 方法是重写虚函数,满足了多态的条件。
test()
是从A继承的,调用func()
时使用A的默认参数1但实际调用的是B类的
func()
实现,因为p指向B对象
多态的条件
父类的指针和引用
虚函数的重写
为什么不能是子类指针或引用,为什么不能是父类对象:
子类赋值给父类对象切片,不会拷贝虚表,如果拷贝虚表那么父类对象虚表中时父类虚函数还是子类就不确定了
派生类虚表先将父类拷贝一份再将修改的进行覆盖
多态的底层
虚函数表(vtable):
Student
对象会包含一个虚函数表,包含:
重写的
BuyTicket()
(半价版本)继承的
Func1()
、Func2()
新增的
Func3()
(应该有的,编译器优化了)访问控制:
Func3()
是private
,无法通过基类指针调用,但仍在虚表中存在。(vs优化了)
_a
在基类中是public
(建议改为protected
,避免直接暴露)。
class Base
{
public:
virtual void Func1()
{
cout << "Base::Func1()" << endl;
}
virtual void Func2()
{
cout << "Base::Func2()" << endl;
}
void Func3()
{
cout << "Base::Func3()" << endl;
}
private:
int _b = 1;
};
class Derive : public Base
{
public:
virtual void Func1()
{
cout << "Derive::Func1()" << endl;
}
private:
int _d = 2;
};
int main()
{
Base b;
Derive d;
return 0;
}
1.派生类对象d中也有一个虚表指针,d对象由两部分构成,一部分是父类继承下来的成员,虚表指针也就是存在部分的另一部分是自己的成员。
2. 基类b对象和派生类d对象虚表是不一样的,这里我们发现Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1,所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。重写是语法的叫法,覆盖是原理层的叫法。
3. 另外Func2继承下来后是虚函数,所以放进了虚表,Func3也继承下来了,但是不是虚函 数,所以不会放进虚表。
4. 虚函数表本质是一个存虚函数指针的指针数组,一般情况这个数组最后面放了一个nullptr。
5. 总结一下派生类的虚表生成:
a.先将基类中的虚表内容拷贝一份到派生类虚表中
b.如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数
c.派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
6. 虚函数存在哪的?虚表存在哪的? 答:虚函数存在虚表,虚表存在对象中。
注意上面的回答的错的。虚表存的是虚函数指针,不是虚函数,虚函数和普通函数一样的,都是存在代码段的,只是他的指针又存到了虚表中。另外对象中存的不是虚表,存的是虚表指针。vs下虚表存在代码段
内存分布
内存问题(虚函数表指针)
虚函数表也简称虚表
虚函数本质放在代码段
虚表里存的是虚函数的地址
虚函数表指针 (vptr)
当类包含虚函数时,编译器会自动添加一个 虚函数表指针 (vptr),指向该类的虚函数表 (vtable)。
在 64 位系统 下,指针的大小是 8 字节。
因此,
Base
类至少占用 8 字节(用于存储vptr
)。
成员变量 _b
_b
是一个char
类型,占用 1 字节。但由于 内存对齐(alignment),编译器会在
_b
后面填充 7 字节,使得vptr
和_b
整体对齐到 8 字节 边界(优化访问速度)。
组成部分 | 大小(64 位) | 大小(32 位) |
---|---|---|
虚函数表指针 | 8 字节 | 4 字节 |
char _b |
1 字节 | 1 字节 |
填充字节 | 7 字节 | 3 字节 |
总大小 | 16 字节 | 8 字节 |
为什么不是9字节
如果
sizeof(Base)
是 9 字节(vptr
8 +_b
1),那么当Base
对象存储在数组中时,第二个对象的vptr
会错位(起始地址不是 8 的倍数),导致 性能下降 或 崩溃(某些 CPU 架构要求指针地址对齐)。因此,编译器会自动填充字节,使类的大小是 最大对齐单位(8 字节)的整数倍。
多态实现指向父类调父类指向子类调子类 :
指向父类在父类虚函数表中找到父类地址,找父类虚表
指向子类在子类虚函数表中找到子类地址(切片后看到的还是父类对象,但是是子类里的父类,他的虚表已经被覆盖了,找到的是子类地址)
函数调用栈帧才会去开空间
同类型对象共用虚表,虚表不在栈上
没有独立函数不建立自己的虚表
C++ 的多态机制、对象内存布局和继承关系
class Person {
public:
virtual void BuyTicket() { cout << "买票-全价" << endl; }
virtual void Func1()
{
cout << "Person::Func1()" << endl;
}
virtual void Func2()
{
cout << "Person::Func2()" << endl;
}
//protected:
int _a = 0;
};
class Student : public Person {
public:
virtual void BuyTicket() { cout << "买票-半价" << endl; }
private:
virtual void Func3()
{
//_b++;
cout << "Student::Func3()" << endl;
}
protected:
int _b = 1;
};
void Func(Person& p)
{
p.BuyTicket();
}
void test()
{
Person ps1;
Student st1;
}
int main()
{
Person ps;
Student st;
st._a = 10;
ps = st;
Person* ptr = &st;
Person& ref = st;
test();
return 0;
}
由表可推断虚表是储存在常量区的。
x86环境运行
多继承
class Base1 {
public:
virtual void func1() { cout << "Base1::func1" << endl; }
virtual void func2() { cout << "Base1::func2" << endl; }
private:
int b1;
};
class Base2 {
public:
virtual void func1() { cout << "Base2::func1" << endl; }
virtual void func2() { cout << "Base2::func2" << endl; }
private:
int b2;
};
class Derive : public Base1, public Base2 {
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
private:
int d1;
};
int main()
{
Derive d;
Base1* ptr1 = &d;
ptr1->func1();
Base2* ptr2 = &d;
ptr2->func1();
Derive* ptr3 = &d;
ptr3->func1();
return 0;
}
对象内存分布和虚函数表布局
base1
vfptr: 指向
Derive
类为Base1
部分维护的虚表(地址0x00007ff7179bbd30
)[0]
: 重写的Derive::func1()
(地址0x00007ff7179b1348
)[1]
: 继承的Base1::func2()
(地址0x00007ff7179b1334
)
b1: 未初始化的
int
成员(值-858993460
是Debug模式的填充值0xCCCCCCCC
)
base2
vfptr: 指向
Derive
类为Base2
部分维护的虚表(地址0x00007ff7179bbd38
)[0]
: Thunk函数(地址0x00007ff7179b1230
),用于调整this
指针并跳转到Derive::func1()
[1]
: 继承的Base2::func2()
(地址0x00007ff7179b10eb
)
b2: 同样未初始化的
int
成员
总结
多重继承的虚表:
每个基类(
Base1
/Base2
)有独立的虚表指针
Derive
重写的func1()
在Base1
虚表中直接替换,在Base2
虚表中通过Thunk调用Thunk函数:
当通过
Base2*
调用func1()
时,需要调整this
指针(指向Base2
子对象的起始位置)Thunk会先修正
this
指针(减去Base2
在Derive
中的偏移量),再跳转到Derive::func1()
未初始化值:
-858993460
(即0xCCCCCCCC
)是Debug模式的填充值,用于标记未初始化的栈内存
两种多态
静态(编译时)的多态,函数重载
动态(运行时)的多态,继承,虚函数重写,实现的多态
int main()
{
//静态多态(编译时多态)
int i = 1;
double d = 1.1;
cout << i << endl;
cout << d << endl;
//动态多态(运行时多态)
Person ps;
Person* ptr = &ps;
ps.BuyTicket();
ptr->BuyTicket();
return 0;
}
静态
实现方式:函数重载
operator<<
针对不同的参数类型(int和double)有不同的实现在编译时就能确定调用哪个版本的函数
特点:
通过函数重载实现
编译时确定具体调用哪个函数
不需要虚函数或继承关系
效率高(无运行时开销)
动态
实现方式:
继承关系
虚函数重写
通过基类指针或引用调用
特点:
通过虚函数表(vtable)实现
运行时确定调用哪个函数
需要继承和虚函数
有一定的运行时开销(查虚函数表)
两种多态的关键区别
特性 | 静态多态 | 动态多态 |
---|---|---|
实现方式 | 函数重载、模板 | 虚函数、继承 |
确定时机 | 编译时 | 运行时 |
性能 | 高效(无额外开销) | 有一定开销(查虚函数表) |
灵活性 | 较低(编译时确定) | 高(运行时可改变行为) |
典型应用 | 运算符重载、函数重载 | 接口设计、多态对象处理 |
虚基表和虚函数表区别
特性 | 虚基表 (Virtual Base Table) | 虚函数表 (Virtual Function Table, vtable) |
---|---|---|
用途 | 解决虚继承中的共享基类偏移问题 | 实现运行时多态,管理虚函数调用 |
触发条件 | 当类使用virtual 继承时(如class D : virtual public B ) |
当类包含virtual 成员函数时 |
存储内容 | 存储虚基类相对于当前对象的偏移量 | 存储虚函数的地址(指向实际实现的函数指针) |
指针名称 | vbptr (虚基表指针) |
vfptr (虚函数表指针) |
内存位置 | 位于对象内存布局的起始或相关位置 | 通常位于对象内存布局的起始位置 |
编译器生成逻辑 | 确保多个派生类共享同一虚基类实例时能正确访问基类成员 | 确保通过基类指针/引用调用时能正确跳转到派生类实现 |
是否依赖运行时 | 是(运行时计算偏移量) | 是(运行时查表确定函数地址) |
典型场景 | 菱形继承(如B ← D1 ← D 和B ← D2 ← D ,B 为虚基类) |
基类定义虚函数,派生类重写(如Shape::draw() ) |
访问开销 | 额外间接寻址(通过vbptr 找到偏移量再访问基类) |
一次指针解引用(通过vfptr 跳转到函数地址) |
调试查看方式 | 在调试器中观察vbptr 和偏移量 |
在调试器中观察vfptr 和函数地址列表 |