C++ 继承和多态
- 一、继承
-
- [1.1 继承概念](#1.1 继承概念)
- [1.2 继承定义格式](#1.2 继承定义格式)
- [1.3 继承方式与访问限定符](#1.3 继承方式与访问限定符)
- [1.4 基类和派生类对象赋值转换(切片)](#1.4 基类和派生类对象赋值转换(切片))
- [1.5 继承中的作用域与隐藏](#1.5 继承中的作用域与隐藏)
- [1.6 派生类的默认成员函数](#1.6 派生类的默认成员函数)
- [1.7 继承与友元、静态成员](#1.7 继承与友元、静态成员)
- [1.8 复杂菱形继承及菱形虚拟继承](#1.8 复杂菱形继承及菱形虚拟继承)
-
- [1.8.1 单继承](#1.8.1 单继承)
- [1.8.2 多继承](#1.8.2 多继承)
- [1.8.3 菱形继承图:Person → Student/Teacher → Assistant](#1.8.3 菱形继承图:Person → Student/Teacher → Assistant)
- [1.8.4 非虚继承内存布局:两份 Person](#1.8.4 非虚继承内存布局:两份 Person)
- [1.8.5 虚继承内存布局:虚基表指针 + 偏移量 + 公共 Person](#1.8.5 虚继承内存布局:虚基表指针 + 偏移量 + 公共 Person)
- [1.9 继承与组合](#1.9 继承与组合)
- 二、多态
- 三、多态原理与虚函数表
-
- [3.1 虚函数表](#3.1 虚函数表)
- [3.2 多态原理与多态调用图:基类指针 → 不同对象 → 不同虚表](#3.2 多态原理与多态调用图:基类指针 → 不同对象 → 不同虚表)
- [3.3 虚函数表图:对象中 vfptr,虚表中函数指针](#3.3 虚函数表图:对象中 vfptr,虚表中函数指针)
- [3.4 单继承虚表图:Base 虚表、Derive 虚表](#3.4 单继承虚表图:Base 虚表、Derive 虚表)
- [3.5 多继承虚表图:Base1 虚表、Base2 虚表、主虚表](#3.5 多继承虚表图:Base1 虚表、Base2 虚表、主虚表)
- [3.6 菱形继承、菱形虚拟继承的虚表](#3.6 菱形继承、菱形虚拟继承的虚表)
- [四、Date 日期类代码专题(运算符重载与注释详解)](#四、Date 日期类代码专题(运算符重载与注释详解))
-
- [4.1 拷贝构造](#4.1 拷贝构造)
- [4.2 getday 与闰年判断](#4.2 getday 与闰年判断)
- [4.3 构造函数](#4.3 构造函数)
- [4.4 比较运算符](#4.4 比较运算符)
- [4.5 加法、减法、自增自减](#4.5 加法、减法、自增自减)
- [4.6 日期减日期](#4.6 日期减日期)
- [4.7 赋值运算符重载](#4.7 赋值运算符重载)
- [4.8 const 成员函数与权限](#4.8 const 成员函数与权限)
- [4.9 & 重载、static 成员、初始化顺序](#4.9 & 重载、static 成员、初始化顺序)
- [4.10 隐式类型转换](#4.10 隐式类型转换)
- [4.11 友元与流运算符重载](#4.11 友元与流运算符重载)
- 五、继承与多态综合代码注释
-
- [5.1 `_Person`/`_Student` 访问限定符](#5.1
_Person/_Student访问限定符) - [5.2 继承中的作用域隐藏](#5.2 继承中的作用域隐藏)
- [5.3 默认成员函数完整注释](#5.3 默认成员函数完整注释)
- [5.4 多态缺省参数静态绑定](#5.4 多态缺省参数静态绑定)
- [5.1 `_Person`/`_Student` 访问限定符](#5.1
- 六、单例模式专题
-
- [6.1 懒汉模式](#6.1 懒汉模式)
- [6.2 饿汉模式](#6.2 饿汉模式)
-
- [懒汉 vs 饿汉](#懒汉 vs 饿汉)
- 七、面试题与自测
一、继承
1.1 继承概念
继承是面向对象程序设计使代码可以复用的重要手段。
允许在保持原有类特性的基础上进行扩展,增加功能,产生新类,称为派生类/子类 ,原有类称为基类/父类。
cpp
class Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
protected:
string _name = "peter"; // 姓名
private:
int _age = 18; // 年龄
};
class Student : public Person
{
protected:
int _studid; // 学号
};
Student继承Person后,Person的成员都会变成子类的一部分。Student和Teacher复用了Person的成员。- 继承是类设计层次的复用,不是函数层次的复用。
1.2 继承定义格式
cpp
class Student : public Person
{
public:
int _stuid; // 学号
int _major; // 专业
};
格式:
cpp
class 派生类 : 继承方式 基类
{
// ...
};
Student:派生类public:继承方式Person:基类
1.3 继承方式与访问限定符
| 类成员/继承方式 | public继承 | protected继承 | private继承 |
|---|---|---|---|
| 基类 public 成员 | 派生类 public | 派生类 protected | 派生类 private |
| 基类 protected 成员 | 派生类 protected | 派生类 protected | 派生类 private |
| 基类 private 成员 | 不可见 | 不可见 | 不可见 |
总结:
- 基类
private成员在派生类中无论怎么继承都不可见,但对象内存中仍然存在。 - 如果基类成员不想在类外直接被访问,但需要在派生类中能访问,就定义为
protected。 - 基类其他成员在子类的访问方式 =
Min(成员在基类的访问限定符, 继承方式),public > protected > private。 class默认继承方式是private,struct默认是public,最好显式写出。- 实际中一般用
public继承。
1.4 基类和派生类对象赋值转换(切片)
派生类对象可以赋值给基类的对象/指针/引用。
这叫切片:把派生类中父类那部分切出来赋值过去。
cpp
class Person
{
protected:
string _name; // 姓名
string _sex; // 性别
int _age; // 年龄
};
class Student : public Person
{
public:
int _No; // 学号
};
void Test()
{
Student sobj;
// 1. 子类对象可以赋值给父类对象/指针/引用
Person pobj = sobj;
Person* pp = &sobj;
Person& rp = sobj;
// 2. 基类对象不能赋值给派生类对象
// sobj = pobj; // 错误
// 3. 基类的指针可以通过强制类型转换赋值给派生类的指针
pp = &sobj;
Student* ps1 = (Student*)pp; // 这种情况转换时可以
ps1->_No = 10;
pp = &pobj;
Student* ps2 = (Student*)pp; // 虽然可以,但会存在越界访问问题
ps2->_No = 10;
}
cpp
// 1. 子类对象可以赋值给父类对象/指针/引用(切片)
// 补充:"切片"仅赋值父类部分成员,子类独有成员(如_stuid)会被舍弃,不会赋值给父类对象
p = s; // 正确:p仅接收s的父类部分(_name、_age),_stuid被舍弃
// 补充:父类指针指向子类对象时,仅能访问父类成员(按指针类型解析接口),无法访问子类独有成员
_Person* ptr = &s;
ptr->Print(); // 正确:调用父类Print(),无法调用子类Print()或访问_stuid
// 引用与指针规则一致:仅能访问父类成员
_Person& ref = s;
ref.Print(); // 正确:调用父类Print()
// 2. 基类对象不能赋值给派生类对象
// s = p; // 编译报错:父类对象无法给子类对象赋值(子类成员无来源)
// 3. 基类指针强制转换为派生类指针(风险提示)
// 补充:C风格强制转换不安全!若ptr实际指向父类对象(非子类对象),后续访问子类独有成员会触发未定义行为;
// C++推荐:有虚函数时用dynamic_cast(安全检查),无虚函数且确认指向子类时用static_cast
1.5 继承中的作用域与隐藏
子类和父类有独立作用域。
子类和父类同名成员,子类成员会屏蔽父类对同名成员的直接访问,这叫隐藏/重定义 。
成员函数隐藏只看函数名,参数不同也构成隐藏。
cpp
class Person
{
protected:
string _name = "小李子"; // 姓名
int _num = 111; // 身份证号
};
class Student : public Person
{
public:
void Print()
{
cout << "姓名:" << _name << endl;
cout << "身份证号:" << Person::_num << endl;
cout << "学号:" << _num << endl;
}
protected:
int _num = 999; // 学号
};
补充:若父类有同名函数void func(),子类有void func(int),调用父类func需显式加作用域:
// s1.Person_Scope::func(); // 正确调用父类无参func()
// s1.func(10); // 正确调用子类有参func()
// s1.func(); // 编译报错:子类func(int)隐藏父类func(),无匹配参数
1.6 派生类的默认成员函数
规则:
- 派生类构造函数必须调用基类构造函数初始化基类部分。
如果基类没有默认构造,必须在初始化列表显式调用。 - 派生类拷贝构造必须调用基类拷贝构造。
- 派生类
operator=必须调用基类operator=。 - 派生类析构函数调用完后自动调用基类析构。
- 构造顺序:基类 → 派生类。
- 析构顺序:派生类 → 基类。
- 编译器会把析构函数名统一处理成
destructor,所以父类析构不加virtual时,子类析构和父类析构构成隐藏。
cpp
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)
: Person(name) // 显式调用父类有参构造
, _num(num)
{
cout << "Student()" << endl;
}
Student(const Student& s)
: Person(s) // 调用父类拷贝构造
, _num(s._num)
{
cout << "Student(const Student& s)" << endl;
}
Student& operator=(const Student& s)
{
cout << "Student& operator= (const Student& s)" << endl;
if (this != &s)
{
Person::operator=(s); // 显式调用父类赋值运算符
_num = s._num;
}
return *this;
}
~Student()
{
cout << "~Student()" << endl;
}
protected:
int _num;
};
子类中父类的对象会调用父类的构造和析构函数,子类对象调用子类的构造和析构。
没法直接在子类的初始化列表里面初始化父类的对象,只能初始化子类的对象。
要手动初始化基类对象得在初始化列表里面使用
类名::(要赋值的参数)这种方法初始化(调用父类构造函数)。但是就算你不去手动调用父类的构造函数,编译器也会自动去调用默认构造函数。
拷贝构造也是一样的,子类调子类的,父类调父类的。
如果要在子类构造函数中手动调用父类拷贝构造,需要传递参数,但父类拷贝构造需要父类引用对象;子类中没有父类对象,这时可以传递子类对象,因为子类对象可以被父类指针或引用接受,然后发生切片。
operator=重载也是一样的,想要手动调用父类::operator=(子类对象);。注意这里一定调用的是父类的,要加限定符,不然会递归调用自己的
operator=,因为子类会隐藏父类的operator=,因为函数名相同。子类的构造、拷贝构造、
operator=,只要手动实现了对应函数,就必须在子类的该函数中显式手动调用父类的对应函数;如果子类没自己实现,用编译器自动生成的默认版本,编译器就会自动调用父类的对应函数。注意在子类中的析构函数一定要手动重新写析构函数,因为子类析构函数会"隐藏"父类析构函数(编译器统一处理为
~destructor),但是无需显式调用父类析构,在子类的析构函数结束后自动调用,这样就会导致父类被析构两次导致出错。为什么会自动调用?因为函数调用是要压栈的,所以就要满足先进后出。我们在构造的时候先构造的是父类,所以析构的时候后析构父类。也可以这样去理解:和构造函数一样,编译器会在子类的析构函数末尾添加父类的析构,所以不用自己手动调用。
静态成员无论是在父类还是子类中都只有一份。
继承和组合下的构造和析构:
子类继承父类,子类组合一个类。
子类先调用父类的构造函数,再调用组合的构造函数,最后调用自己的构造函数。析构相反。
因为构造是从内向外,析构是从外向内,是函数压栈的先进后出。
1.7 继承与友元、静态成员
友元关系不能继承,基类友元不能访问子类私有和保护成员。
cpp
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; // 错误:友元不能继承
}
静态成员:整个继承体系只有一个 static 成员。
cpp
class Person
{
public:
Person() { ++_count; }
protected:
string _name;
public:
static int _count;
};
int Person::_count = 0;
class Student : public Person
{
protected:
int _stuNum;
};
Student::_count 和 Person::_count 是同一个变量。
1.8 复杂菱形继承及菱形虚拟继承
1.8.1 单继承
一个子类只有一个直接父类。
cpp
class Person {};
class Student : public Person {};
class PostGraduate : public Student {};
1.8.2 多继承
一个子类有两个或以上直接父类。
cpp
class Student {};
class Teacher {};
class Assistant : public Student, public Teacher {};
1.8.3 菱形继承图:Person → Student/Teacher → Assistant
text
Person
/ \
Student Teacher
\ /
Assistant
代码:
cpp
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成员。 - 二义性 :访问
_name时,编译器不知道来自Student还是Teacher。
cpp
void Test()
{
Assistant a;
// a._name = "peter"; // 二义性
a.Student::_name = "xxx"; // 显式指定,解决二义性
a.Teacher::_name = "yyy"; // 但数据冗余仍在
}
菱形继承是指 "一个顶层父类被两个子类分别继承,这两个子类再共同被一个孙子类继承" 的特殊多继承结构,
其核心问题在于:一方面,由于两个子类均继承了顶层父类的成员,孙子类对象中会重复存储两份顶层父类的成员,
造成内存上的数据冗余;另一方面,当孙子类访问顶层父类的成员时,
编译器无法区分该成员是来自哪一个子类的继承路径,会产生访问的二义性,导致编译错误
1.8.4 非虚继承内存布局:两份 Person
结合你提供的非虚继承内存截图:
代码:
cpp
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 test_virtual()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
}
非虚继承时,D 对象大小是 20 字节:
B部分:A::_a+B::_bC部分:A::_a+C::_cD部分:D::_d
一共 5 个 int:a + a + b + c + d = 20 字节。
text
地址 0x008FF684: 01 00 00 00 // d.B::_a = 1
地址 0x008FF688: 03 00 00 00 // d._b = 3
地址 0x008FF68C: 02 00 00 00 // d.C::_a = 2
地址 0x008FF690: 04 00 00 00 // d._c = 4
地址 0x008FF694: 05 00 00 00 // d._d = 5
"这里就是菱形继承的问题二义性:在 d 里面有两个 a"。
解释:
d.B::_a和d.C::_a是两个独立变量。- 改其中一个,不影响另一个。
- 访问
d._a会二义性,因为编译器不知道你要哪个A::_a。
1.8.5 虚继承内存布局:虚基表指针 + 偏移量 + 公共 Person
加上 virtual:
cpp
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 test_virtual()
{
D d;
d.B::_a = 1;
d.C::_a = 2;
d._b = 3;
d._c = 4;
d._d = 5;
}
虚继承后,D 对象大小变成 24 字节。
text
地址 0x010FFDC0: bc ed d7 00 // B 的虚基表指针
地址 0x010FFDC4: 03 00 00 00 // d._b = 3
地址 0x010FFDC8: 24 f2 d7 00 // C 的虚基表指针
地址 0x010FFDCC: 04 00 00 00 // d._c = 4
地址 0x010FFDD0: 05 00 00 00 // d._d = 5
地址 0x010FFDD4: 02 00 00 00 // 公共 A::_a = 2
两个虚基表:
text
地址 0x00D7EDBC: 00 00 00 00
地址 0x00D7EDC0: 14 00 00 00 // 0x14 = 20
地址 0x00D7F224: 00 00 00 00
地址 0x00D7F228: 0c 00 00 00 // 0x0c = 12
根据观察我们发现这两个指针指向的空间里面存放的数据为偏移量
从当前位置到 a 这个地址的偏移量
注意这里我们是 16 进制所以十进制上的数为 20 和 12
解释:
0x010FFDC0是 B 部分的虚基表指针,它指向0x00D7EDBC。- 虚基表中偏移量
0x14 = 20:从0x010FFDC0到公共A::_a地址0x010FFDD4,相差0x14 = 20字节。 0x010FFDC8是 C 部分的虚基表指针,它指向0x00D7F224。- 虚基表中偏移量
0x0c = 12:从0x010FFDC8到公共A::_a地址0x010FFDD4,相差0x0C = 12字节。 - 公共
A::_a放在对象最下面,B 和 C 通过虚基表指针 + 偏移量找到它。 - 所以
d.B::_a和d.C::_a最终访问的是同一个A::_a,二义性和数据冗余都解决了。
加上 virtual 后大小结果为 24。根据查看内存占用情况我们发现多了两个像指针一样的东西,少了一个 a。
再次查看内存发现这两个指针指向的内容分别是
00000000 00000014和00000000 0000000c,翻译为十进制后为 20 和 12。根据观察我们发现这个数字为当前指针存储的位置到 a 这个存储位置的偏移量。
实际上这个指针指向的是虚基表(占八个字节),用于找基类里面公共的成员。A 这个类我们也叫虚基类,这两个指针叫虚基表指针。
在我们当前的情况下,我们没有节省空间,反而增大了 4 个字节。
但是如果我们创建 10000 个 d,在没有使用虚继承的情况下会占用 80012 字节,但加上 40020 字节(你来写出计算过程)。
但是这个过程会有效率的损失,所以在实际中我们尽量不要把继承模型设计成菱形继承。
计算补充:
- 非虚继承:每个
D20 字节,10000 个 = 200000 字节。但你说"80012"和"40020"可能来自其他示例?这里以实际代码为准:非虚继承 20 字节/对象,虚继承 24 字节/对象。10000 个对象,非虚 200000 字节,虚 240000 字节。虚继承反而更大,因为每个对象多了两个虚基表指针。 - 如果公共基类很大,虚继承节省的是公共基类的重复部分,但小对象时可能不划算。

1.9 继承与组合
public继承是一种is-a的关系。每个派生类对象都是一个基类对象。- 组合是一种
has-a的关系。假设 B 组合了 A,每个 B 对象中都有一个 A 对象。 - 优先使用对象组合,而不是类继承。
继承:
- 白箱复用:基类内部细节对子类可见。
- 继承一定程度破坏基类封装。
- 基类改变对派生类影响大。
- 派生类和基类依赖强,耦合度高。
组合:
- 黑箱复用:对象内部细节不可见。
- 组合类之间没有很强依赖关系,耦合度低。
- 优先使用对象组合有助于保持每个类被封装。
代码:
cpp
// Car和BMW Car和Benz构成is-a关系
class Car
{
protected:
string _colour = "白色"; // 颜色
string _num = "陕ABIT00"; // 车牌号
};
class BMW : public Car
{
public:
void Drive() { cout << "好开-操控" << endl; }
};
class Benz : public Car
{
public:
void Drive() { cout << "好坐-舒适" << endl; }
};
// Tire和Car构成has-a关系
class Tire
{
protected:
string _brand = "Michelin"; // 品牌
size_t _size = 17; // 尺寸
};
class Car
{
protected:
string _colour = "白色"; // 颜色
string _num = "陕ABIT00"; // 车牌号
Tire _t; // 轮胎
};
结论:
- 能用组合就用组合。
- 需要多态时必须继承。
- 有些关系适合继承才用继承。
二、多态
2.1 多态概念
多态:通俗来说,就是多种形态。具体点就是去完成某个行为,当不同的对象去完成时会产生出不同的状态。
例子:
- 普通人买票全价,学生买票半价,军人买票优先。
- 支付宝扫码红包,不同用户扫到不同金额。
多态是在不同继承关系的类对象,去调用同一函数,产生了不同的行为。
构成多态的两个条件:
- 必须通过基类的指针或者引用调用虚函数。
- 被调用的函数必须是虚函数,且派生类必须对基类的虚函数进行重写。
2.2 虚函数与重写
虚函数:被 virtual 修饰的类成员函数称为虚函数。
cpp
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
虚函数重写(覆盖):
派生类中有一个跟基类完全相同的虚函数(即派生类虚函数与基类虚函数的返回值类型、函数名字、参数列表完全相同),称子类的虚函数重写了基类的虚函数。
cpp
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
};
注意:
- 在重写基类虚函数时,派生类的虚函数在不加
virtual关键字时,虽然也可以构成重写(因为继承后基类的虚函数被继承下来了,在派生类依旧保持虚函数属性),但是该种写法不是很规范,不建议这样使用。
cpp
// void BuyTicket() { cout << "买票-半价" << endl; } // 可以但不规范
多态调用:
cpp
void Func(Person& p)
{
p.BuyTicket();
}
void Test()
{
Person Mike;
Func(Mike); // Person::BuyTicket
Student Johnson;
Func(Johnson); // Student::BuyTicket
}
解释:
p是指向Mike时,p->BuyTicket在Mike的虚表中找到Person::BuyTicket。p是指向Johnson时,p->BuyTicket在Johnson的虚表中找到Student::BuyTicket。- 不同对象完成同一行为,展现不同形态。
2.3 协变
协变:基类与派生类虚函数返回值类型不同。
派生类重写基类虚函数时,与基类虚函数返回值类型不同。
即基类虚函数返回基类对象的指针或者引用,派生类虚函数返回派生类对象的指针或者引用时,称为协变。
cpp
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;
}
};
解释:
- 返回值类型不同,但满足协变,仍构成重写。
- 了解即可。
2.4 析构函数的重写
如果基类的析构函数为虚函数,此时派生类析构函数只要定义,无论是否加 virtual 关键字,都与基类的析构函数构成重写。
虽然基类与派生类析构函数名字不同,看起来违背了重写规则,其实不然。
这里可以理解为编译器对析构函数的名称做了特殊处理,编译后析构函数的名称统一处理成 destructor。
cpp
class Person
{
public:
virtual ~Person()
{
cout << "~Person()" << endl;
}
};
class Student : public Person
{
public:
virtual ~Student()
{
cout << "~Student()" << endl;
}
};
int main()
{
Person* p1 = new Person;
Person* p2 = new Student;
delete p1;
delete p2;
}
解释:
- 只有派生类
Student的析构函数重写了Person的析构函数,delete对象调用析构函数时才能构成多态。 - 才能保证
p1和p2指向的对象正确调用析构函数。 - 否则
delete p2可能只调用~Person(),不调用~Student(),造成资源泄漏。
2.5 C++11 override 和 final
C++ 对函数重写的要求比较严格,有些情况下由于疏忽,可能导致函数名字母次序写反而无法构成重写,而这种错误在编译期间不会报出,只有在程序运行时没有得到预期结果才来 debug 会得不偿失。
因此 C++11 提供了 override 和 final 两个关键字,帮助用户检测是否重写。
final
修饰虚函数,表示该虚函数不能再被重写。
修饰类,表示该类不能被继承。
cpp
class Car
{
public:
virtual void Drive() final {}
};
class Benz : public Car
{
public:
virtual void Drive() { cout << "Benz-舒适" << endl; } // 错误
};
override
检查派生类虚函数是否重写了基类某个虚函数,如果没有重写编译报错。
cpp
class Car
{
public:
virtual void Drive() {}
};
class Benz : public Car
{
public:
virtual void Drive() override { cout << "Benz-舒适" << endl; }
};
2.6 重载、覆盖(重写)、隐藏(重定义)对比
| 概念 | 作用域 | 条件 |
|---|---|---|
| 重载 | 同一作用域 | 函数名相同,参数不同 |
| 重写/覆盖 | 基类和派生类作用域 | 函数名、参数、返回值相同(协变例外),且必须是虚函数 |
| 重定义/隐藏 | 基类和派生类作用域 | 函数名相同,不构成重写就是隐藏 |
2.7 抽象类
在虚函数后面写上 = 0,则这个函数为纯虚函数。
包含纯虚函数的类叫做抽象类(也叫接口类)。
抽象类不能实例化出对象。
派生类继承后也不能实例化出对象,只有重写纯虚函数,派生类才能实例化出对象。
纯虚函数规范了派生类必须重写,另外纯虚函数更体现出了接口继承。
cpp
class Car
{
public:
virtual void Drive() = 0;
};
class Benz : public Car
{
public:
virtual void Drive()
{
cout << "Benz-舒适" << endl;
}
};
class BMW : public Car
{
public:
virtual void Drive()
{
cout << "BMW-操控" << endl;
}
};
void Test()
{
Car* pBenz = new Benz;
pBenz->Drive();
Car* pBMW = new BMW;
pBMW->Drive();
}
接口继承和实现继承:
- 普通函数的继承是一种实现继承,派生类继承了基类函数,可以使用函数,继承的是函数的实现。
- 虚函数的继承是一种接口继承,派生类继承的是基类虚函数的接口,目的是为了重写,达成多态,继承的是接口。
- 所以如果不实现多态,不要把函数定义成虚函数。
三、多态原理与虚函数表
3.1 虚函数表
常考笔试题:sizeof(Base) 是多少?
cpp
class Base
{
public:
virtual void Func1()
{
cout << "Func1()" << endl;
}
private:
int _b = 1;
};
在 x86 下,b 对象是 8 字节。
除了 _b 成员,还多一个 _vfptr 放在对象的前面(注意有些平台可能会放到对象的最后面,这个跟平台有关)。
对象中的这个指针我们叫做虚函数表指针 (v 代表 virtual,f 代表 function)。
一个含有虚函数的类中都至少都有一个虚函数表指针,因为虚函数的地址要被放到虚函数表中,虚函数表也简称虚表。
改造代码:
cpp
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;
}
观察发现:
- 派生类对象
d中也有一个虚表指针,d对象由两部分构成:一部分是父类继承下来的成员,虚表指针也就是存在部分的;另一部分是自己的成员。 - 基类
b对象和派生类d对象虚表是不一样的。
Func1完成了重写,所以d的虚表中存的是重写的Derive::Func1。
所以虚函数的重写也叫作覆盖,覆盖就是指虚表中虚函数的覆盖。
重写是语法的叫法,覆盖是原理层的叫法。 Func2继承下来后是虚函数,所以放进了虚表;Func3也继承下来了,但是不是虚函数,所以不会放进虚表。- 虚函数表本质是一个存虚函数指针的指针数组,一般情况下这个数组最后面放了一个
nullptr。 - 派生类虚表生成:
- 先将基类中的虚表内容拷贝一份到派生类虚表中。
- 如果派生类重写了基类中某个虚函数,用派生类自己的虚函数覆盖虚表中基类的虚函数。
- 派生类自己新增加的虚函数按其在派生类中的声明次序增加到派生类虚表的最后。
- 易混淆问题:虚函数存在哪的?虚表存在哪的?
- 虚函数和普通函数一样,都是存在代码段的,只是它的指针又存到了虚表中。
- 对象中存的不是虚表,存的是虚表指针。
- 虚表存的是虚函数指针,不是虚函数。
- VS 下虚表存在代码段(常量区)。
3.2 多态原理与多态调用图:基类指针 → 不同对象 → 不同虚表
cpp
class Person
{
public:
virtual void BuyTicket()
{
cout << "买票-全价" << endl;
}
};
class Student : public Person
{
public:
virtual void BuyTicket()
{
cout << "买票-半价" << endl;
}
};
void Func(Person& p)
{
p.BuyTicket();
}
void Test()
{
Person Mike;
Func(Mike);
Student Johnson;
Func(Johnson);
}
图中:
- 红色箭头:
p指向Mike时,p->BuyTicket在Mike的虚表中找到Person::BuyTicket。 - 蓝色箭头:
p指向Johnson时,p->BuyTicket在Johnson的虚表中找到Student::BuyTicket。
这就是多态:
- 同样是
p.BuyTicket(),不同对象调用不同函数。 - 条件:虚函数重写 + 基类指针/引用调用。
- 运行时通过对象中的
vfptr找到对应虚表,再取函数地址。
cpp
//1.
//每个含虚函数的类(包括子类)会生成独立的虚函数表(vtable),表中存储类内虚函数的地址;
//若子类重写父类虚函数,会将自身虚函数表中对应位置的地址,
//替换为子类重写后函数的地址(仅修改子类自己的表,不影响父类表)。
//2.
//每个含虚函数的类的对象,会包含一个隐藏的虚指针(vptr),
//该指针在对象创建时被初始化,指向所属类的虚函数表。
//3.
//当通过父类指针 / 引用调用虚函数时,程序会在运行时通过对象的 vptr 找到对应类的 vtable,
//再从表中获取目标虚函数的地址并调用 ------ 因不同子类对象的 vptr 指向不同子类的 vtable,
//最终会执行对应子类重写后的函数,实现 "调用哪个类的对象,就执行哪个类的函数" 的多态效果。
3.3 虚函数表图:对象中 vfptr,虚表中函数指针
cpp
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;
};
监视窗口:
text
Base b:
_vfptr 0x00e4dc80
[0] 0x00d14d8 Base::Func1
[1] 0x00d14ec Base::Func2
_b = 1
Derive d:
_vfptr 0x00dd3c
[0] 0x00d14e7 Derive::Func1
[1] 0x00d14ec Base::Func2
_b = 1
_d = 2
解释:
Base对象b中有_vfptr,指向Base虚表。Base虚表存Base::Func1、Base::Func2。Derive对象d中有_vfptr,指向Derive虚表。Derive虚表中,Func1被重写,所以存Derive::Func1;Func2没重写,仍存Base::Func2。Func3不是虚函数,不进虚表。- 对象中存的是虚表指针
vfptr,不是虚表本身。 - 虚表存的是虚函数指针,不是虚函数本身。
- 虚函数和普通函数一样在代码段,虚表一般在代码段/常量区。
3.4 单继承虚表图:Base 虚表、Derive 虚表
你提供的单继承虚表打印图:
cpp
class Base
{
public:
virtual void func1() { cout << "Base::func1" << endl; }
virtual void func2() { cout << "Base::func2" << endl; }
private:
int a;
};
class Derive : public Base
{
public:
virtual void func1() { cout << "Derive::func1" << endl; }
virtual void func3() { cout << "Derive::func3" << endl; }
virtual void func4() { cout << "Derive::func4" << endl; }
private:
int b;
};
打印虚表代码:
cpp
typedef void(*VF_PTR)(); //函数指针
// 打印虚表,本质打印指针(虚函数指针)数组
void PrintVFT(VF_PTR* vft)
{
for (size_t i = 0; vft[i] != nullptr; i++) //虚表的最后一个指针放的是0
{
printf("[%d]:%p->", i, vft[i]);
VF_PTR f = vft[i]; //这是取出了函数的地址
f();//调用取出的地址的函数
}
cout << endl << endl;
}
void Test_print_vft()
{
Base a;
Derive b;
//想要调用PrintVFT虚要这样写先取出自定义对象的虚表
//1.对实例化对象取地址在强转为(int*)在解引用就取出了虚表的第一个函数的地址了
//2,在转为VF_PTR*类型的
PrintVFT((VF_PTR*)(*(int*)&a));
PrintVFT((VF_PTR*)(*(int*)&b));
}
输出:
text
虚表地址>003F7864
第0个虚函数地址:0X3F100a,->Base::func1
第1个虚函数地址:0X3F1168,->Base::func2
虚表地址>003F7894
第0个虚函数地址:0X3F1104,->Derive::func1
第1个虚函数地址:0X3F1168,->Base::func2
第2个虚函数地址:0X3F1122,->Derive::func3
第3个虚函数地址:0X3F10a0,->Derive::func4
解释:
Base虚表:Base::func1、Base::func2。Derive虚表:func1被重写,覆盖为Derive::func1。func2没重写,继承Base::func2。func3、func4是子类新增虚函数,追加到虚表后面。
- 虚表最后一般放
nullptr。
3.5 多继承虚表图:Base1 虚表、Base2 虚表、主虚表
cpp
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;
};
打印:
cpp
void test_()
{
Derive d;
VF_PTR* vTableb1 = (VF_PTR*)(*(int*)&d);
PrintVFT(vTableb1);
//注意这里要加sizeof(_Base1)跳转到第二个虚表里面的时候要先转为char*这样才能正确跳转
VF_PTR* vTableb2 = (VF_PTR*)(*(int*)((char*)&d + sizeof(_Base1)));
PrintVFT(vTableb2);
}
输出:
text
虚表地址:01147874
第0个虚函数地址:0X1141104,->Derive::func1
第1个虚函数地址:0X1141217,->Base1::func2
第2个虚函数地址:0X1141122,->Derive::func3
虚表地址:01147864
第0个虚函数地址:0X1141154,->Derive::func1
第1个虚函数地址:0X11411ae,->Base2::func2
对象布局:
Base1部分:虚表指针Base1->vTable,b1Base2部分:虚表指针Base2->vTable,b2Derive部分:d1
主虚表:
- 继承列表中第一个基类
Base1对应的虚表是主虚表。 - 主虚表包含:
Derive::func1、Base1::func2、Derive::func3。 Base2虚表包含:Derive::func1、Base2::func2。- 子类新增虚函数
func3只放在主虚表中。
cpp
//1.一个_Derive类型的变量有多大 20个字节
//_Base1是8个字节(一个int 一个虚表指针) _Base2是8个字节(一个int 一个虚表指针)
//_Derive是子类所以会继承两个虚表指针 加上自己的一个变量int 20个字节
//注意这里要加sizeof(_Base1)跳转到第二个虚表里面的时候要先转为char*这样才能正确跳转
VF_PTR* vTableb2 = (VF_PTR*)(*(int*)((char*)&d + sizeof(_Base1)));
PrintVFT(vTableb2);
//在 C++ 多继承中,子类的主虚表由继承顺序直接决定 ------ 继承列表中第一个被声明的基类对应的虚表即为子类主虚表
//这是编译器的固定规则。主虚表会绑定子类对象的默认虚指针,不仅包含继承自第一个基类的重写和未重写虚函数指针,
//还会承载子类独有的虚函数;而其他基类对应的非主虚表,仅负责维护对应基类的接口体系,
//包含该基类的重写和未重写虚函数指针,不含子类独有的虚函数。
//判断主虚表可通过继承顺序(首个基类对应表)或查看子类新增虚函数的存储位置(仅在主虚表中)。
3.6 菱形继承、菱形虚拟继承的虚表
实际中不建议设计出菱形继承及菱形虚拟继承:
- 太复杂容易出问题。
- 访问某类成员有一定性能损耗。
- 菱形继承、菱形虚拟继承的虚表一般不需要研究清楚,实际中很少用。
注意:不要把虚函数表 和虚基表搞混。
- 虚函数表:存虚函数指针,用于多态。
- 虚基表:存偏移量,用于虚继承找公共基类。
四、Date 日期类代码专题(运算符重载与注释详解)
4.1 拷贝构造
cpp
//拷贝构造(是构造函数的一个重载)
//这里必须加&因为不加引用会导致无限递归调用拷贝构造因为实参传给形参也是一次拷贝构造
//加上const是为了防止d的值被修改(例如这种写反了会导致d(别名)的值也被修改了d._day=_day)
Date(const Date& d)
{
_year = d._year;
_month = d._month;
_day = d._day;
}
解释:
- 拷贝构造是构造函数的重载。
- 必须加
&,否则实参传给形参又是一次拷贝构造,无限递归。 - 加
const防止修改被拷贝对象。 - 如果写反
d._day = _day,会把原对象改掉。
4.2 getday 与闰年判断
cpp
int getday(int year, int month) const
{
//使用static是为了让这个arr不是每次构造都要去创造
static int arr[13] = { 0,31,28,31,30,31,30,31,31,30,31,30,31 };
//if (month == 2 && (year % 4 == 0 && year % 100 != 0) || year % 400 == 0)
//这个条件的问题在于逻辑运算符的优先级。C++ 中,&& 的优先级高于 || ,因此该表达式实际上被解释为:
//(month == 2 && (year % 4 == 0 && year % 100 != 0)) || (year % 400 == 0)
//这会导致错误的结果。例如,当 month 不等于 2 但 year 是 400 的倍数时,表达式会返回 true,使得非 2 月的天数也被错误地设为 29。
//所以需要添加括号来明确优先级,确保只有当月份为 2 且满足闰年条件时才返回 29 天
if (month == 2 && ((year % 4 == 0 && year % 100 != 0) || year % 400 == 0))
{
return 29;
}
return arr[month];
}
解释:
static int arr[13]:静态数组,只初始化一次,避免每次调用都创建。- 闰年条件:
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0。 - 优先级:
&&高于||,必须加括号。 - 否则非 2 月且年份是 400 倍数时,会错误返回 29。
4.3 构造函数
cpp
//构造函数
Date(int year = 0, int month = 1, int day = 1)//并不是所有日期都是合法的所以要加以判断
{
if (year >= 0 && month >= 1 && month <= 12 &&
day >= 1 && day <= getday(year, month))
{
_year = year;
_month = month;
_day = day;
}
else
{
cout << "非法日期" << endl;
}
}
解释:
- 默认参数:
year=0, month=1, day=1。 - 判断日期合法性。
- 天数上限用
getday动态获取。 - 非法日期输出提示。
4.4 比较运算符
cpp
//d==d1 等价于 d.operator==(d1);
bool operator==(const Date& d1) const
{
return _year == d1._year &&
_month == d1._month &&
_day == d1._day;
}
//比较大小
bool operator>(const Date& d1) const
{
if (_year > d1._year)
return true;
else if (_year == d1._year && _month > d1._month)
return true;
else if (_year == d1._year && _month == d1._month && _day > d1._day)
return true;
else
return false;
}
//d<=d1
//d.operator(d1);
bool operator>=(const Date& d1) const //bool operator>=(Date* this,const Date& d1) this指针就是指向d的*this就是d
{
return *this > d1 || *this == d1; //复用上面的代码以免代码重复的高d<=d1 等于d<d1||d==d1
} //复用的好处:如果后续我们还要加其他的判断条件就不用每个都修改了只用修改前面的哪两个
//d<d1
bool operator<(const Date& d1) const
{
//复用
//this = &d(d的地址(指针)) *this = d;
return !(*this >= d1);
}
bool operator<=(const Date& d1) const
{
return !(*this > d1);
}
bool operator!=(const Date& d1) const
{
return !(*this == d1);
}
解释:
operator==:逐成员比较。operator>:先年,再月,再日。operator>=:复用>和==。this是指向当前对象的指针,*this是当前对象。- 复用可以减少重复代码,后续修改方便。
4.5 加法、减法、自增自减
cpp
Date operator+(int day) const
{
//因为自己是不变的所以要拷贝一个临时变量
Date ret = (*this); //拷贝构造
//等于 Date ret(*this)
return ret += day;
}
Date& operator+=(int day)//引用做返回值
{
if (day < 0)
{
return (*this) -= -day; //例如 date += -5 等价于 date -= 5。
}
this->_day += day;
while (_day > getday(_year, _month))
{
_day -= getday(_year, _month);
_month++;
if (_month > 12)
{
_month = 1;
_year += 1;
}
}
return *this;
}
//-=
Date& operator-=(int day)
{
if (day < 0)
{
return (*this) += -day;
}
_day -= day;
while (_day <= 0)
{
int daysInPrevMonth = getday(_year, _month - 1);
if (_month == 1)
{
_month = 12;
_year -= 1;
daysInPrevMonth = getday(_year, 12); // 上一年的 12 月天数
}
else
{
_month -= 1;
}
_day += daysInPrevMonth;
}
return *this;
}
//-
Date operator-(int day) const
{
Date ret = (*this);
return ret -= day;
}
//--d
Date& operator--()
{
(*this) -= 1;
return *this;
}
//d--
Date operator--(int)//为了构成函数重载这里的int不会用
{
Date tmp = (*this);
(*this) -= 1;
return tmp; //返回减之前的数
}
//++d =》编译器会转换调用这个函数 d.operator++(&d)所以前置++不会有参数
//而后置++会调用 d.operator++(&d,0)所以要加参数
Date& operator++()
{
(*this) += 1;
return *this;
}
//d++ ->会调用这个 d.operator++(&d,0)
Date operator++(int)//为了构成函数重载这里的int不会用
{
Date tmp = (*this);
(*this) += 1;
return tmp; //返回加之前的数
}
解释:
operator+不改变自身,所以先拷贝ret,再ret += day。operator+=改变自身,返回引用Date&。- 负天数转成相反操作。
operator-=借位:如果_day <= 0,向前一个月借天数。- 前置
++/--:返回引用,少一次拷贝。 - 后置
++/--:带int占位参数,返回值,返回修改前的值。 - 能调前置就调前置,因为前置返回引用,少一份拷贝构造。
4.6 日期减日期
cpp
//日期减日期
int operator-(const Date& d1) const
{
int flag = 1;
int num = 0;
if ((*this) < d1)
{
flag = -1;
}
Date min = ((*this) < d1) ? (*this) : d1;
Date max = ((*this) > d1) ? (*this) : d1;
while (min != max)
{
++min; //能调前置就调前置 因为前置少一份拷贝构造在返回的时候
++num;
}
return num * flag;
}
解释:
- 计算两个日期相差天数。
- 先确定较小和较大日期。
- 循环
++min直到相等,统计天数。 flag处理正负。- 使用前置
++提高效率。
4.7 赋值运算符重载
cpp
//=的重载
//d3=d1;//这个等式是有返回值的是因为例如i=j=k; j=k的返回值就是j再i=j
Date& operator=(const Date& d) //不加&会多一步就是把d先构造出来在经行其他操作(d用的d1构造出来)
{
if (this != &d) //防止自己给自己赋值
{
_year = d._year;
_month = d._month;
_day = d._day;
}
return *this;
}
//d1=d2 两个对象都已经存在且初始化好了,这时候是运算符的重载
//Date d1=d2 或者 Date d1(d2) 这种用一个去初始另一个的是拷贝构造
解释:
operator=返回引用,支持连续赋值。- 加
&避免多一次拷贝构造。 - 自赋值检查。
- 区分:
Date d1 = d2;或Date d1(d2);是拷贝构造。d1 = d2;是赋值运算符重载。
4.8 const 成员函数与权限
cpp
//const成员函数调用成员函数,成员函数调用const修饰的成员函数
void f1() //void f1(Date* this)
{
f2(); //->this->f2(this);
}
void f2() const
{} //这里是非const传给const权限缩小是对的
//void f3()//void f3(Date* this)
//{}
//void f4() const //->void f4(const Date* this)
//{
// f3();// -> this->f3(this); this是const修饰的而f3的this没有const修饰
//} // 所以这里发生了权限的放大所以不行
解释:
const成员函数的this是const Date*。- 非
const可以调用const,权限缩小,安全。 const不能调用非const,权限放大,错误。
cpp
//const 变量调用类成员函数成员函数需要成员函数用const修饰
void func(const Date& d) //->d.func(&d) d是const修饰的而传给的this指针不是const修饰的
//发生了权限的放大所以d调用Print,Print需要用const修饰
{
d.Print();
}
解释:
const Date& d只能调用const成员函数。- 所以
Print()要加const。
4.9 & 重载、static 成员、初始化顺序
cpp
//&函数重载
Date* operator&()
{
return this;
}
const Date* operator&() const //const变量调用的 const Date d1
{
return this;
}
void Print() const //->void Print(const Date& this)
{
cout << _year << "-" << _month << "-" << _day << endl;
}
//static修饰成员函数函数是没有this指针的所以无法访问受保护的成员变量
//只能访问static修饰的成员变量
//static int Getday()
//{
// return n;n是static修饰的成员变量
//}
private:
//注意这里声明的顺序就是初始化列表初始化的顺序
int _year;
int _month;
int _day;
//如果要定义一个static的成员变量 注意static成员变量不在类里面而在静态区
//static int n; 注意这里是声明 定义在类外面定义
};
// int Date::n=0;//static成员变量定义
解释:
operator&可以重载,控制取地址行为。const版本用于const对象。static成员函数没有this,不能访问非静态成员。- 成员变量声明顺序就是初始化列表初始化顺序。
static成员变量在类外定义。
4.10 隐式类型转换
cpp
//c++11 才支持这么做 这里是隐式类型转换 会先构造一个Date tmp(2025,5,24)
//再用这个tmp拷贝构造dd
//怎么验证这个说法呢:Date& dd= { 2025,5,24 };
//这里会报错,因为tmp是临时变量具有常性而 dd是可变的权限放大了所以要加const
//但是这个过程会被优化成构造函数 如果不想这个过程发生在构造函数前面加explicit
Date dd = { 2025,5,24 };
解释:
Date dd = { 2025,5,24 };先构造临时对象,再拷贝构造。- 编译器可能优化成直接构造。
Date& dd = { ... };会报错,临时变量有常性,需要const Date&。explicit可以禁止隐式转换。
4.11 友元与流运算符重载
cpp
////友元函数
//
//class Date
//{
// friend ostream& operator<<(ostream& out, const Date& d);
// friend istream& operator >> (istream& in, Date& d);
//public:
// void Print()
// {
// cout << _year << "-" << _month << "-" << _day << endl;
// }
//
// /*void operator<<(ostream& out)
// {
// out << _year << "-" << _month << "-" << _day << endl;
// }*/
//
// Date(int year=0, int month=1, int day=1)
// :_year(year)
// ,_month(month)
// ,_day(day)
// {}
//
//private:
// //这里不是定义初始化而是给缺省值
// //c++11才支持
// int _year=0;
// int _month=1;
// int _day=1;
//};
//ostream& operator<<(ostream& out,const Date& d)
//{
// out << d._year << "-" << d._month << "-" << d._day << endl;
// return out;
//}
//
//istream& operator >> (istream& in,Date& d)
//{
// in >> d._year >> d._month >> d._day;
// return in;
//}
//
//
//int main()
//{
// Date d(2025, 5, 25);
//
// d.Print();
// Date d1(2025, 5, 24);
// //cout<<d1 ->cout.operator<<(d1) 这个符合我们的日常使用逻辑但结果不对()
// //d<<cout //d1.operator<<(cout) 这个不符合我们日常使用逻辑但结果是对的
// //所以在重载<<的时候不能使用成员函数 因为重载运算赋的时候总会把左边的参数给this指针
// //右边的参数给形参 而我们重载<<却是反的,而我们又要能访问成员变量就需要this指针
// //所以最后我们就要采用友元函数来实现写到类的外面
//
// cout << d;//->operator<<(cout,d);
//
// cout << d << d1;// cout<<d;cout<<d1;
// //所以cout<<d需要返回一个cout
//
// cin >> d >> d1;
//}
解释:
operator<<不能写成成员函数,因为左操作数是cout,不是Date对象。- 成员函数
operator<<会导致d << cout,不符合习惯。 - 所以写成友元函数:
ostream& operator<<(ostream& out, const Date& d)。 - 返回
ostream&支持连续输出。 operator>>同理,返回istream&。
五、继承与多态综合代码注释
5.1 _Person/_Student 访问限定符
cpp
// 基类:_Person(带继承权限演示)
class _Person
{
public:
void Print()
{
cout << "name:" << _name << endl;
cout << "age:" << _age << endl;
}
// 修正补充:类内部(成员函数、友元)中,private和protected成员访问权限完全相同(均可直接访问);
// 类外部:两者均无法访问;核心区别在继承:子类能访问父类的protected成员,不能访问父类的private成员
protected:
string _name = "peter"; // 姓名(子类可直接访问)
private:
int _age = 18; // 年龄(子类不可直接访问,但可通过父类public成员函数间接访问,如Print())
};
// 子类:_Student(public继承_Person)
class _Student : public _Person
{
public:
void Print() {
// 补充:父类private成员(_age)在子类对象内存中"存在"(切片可见),但子类代码无法直接访问;
// 若需访问_age,可调用父类的public成员函数(如父类的Print())间接获取
cout << "子类访问父类protected成员_name:" << _name << endl;
// cout << _age << endl; // 编译报错:父类private成员子类不可直接访问
}
protected:
int _stuid; // 学号(子类独有成员)
};
解释:
- 类内:
private和protected都能访问。 - 类外:都不能访问。
- 继承:子类能访问父类
protected,不能访问父类private。 - 父类
private成员在子类对象内存中存在,但语法不可见。
5.2 继承中的作用域隐藏
cpp
class Person_Scope
{
protected:
string _name = "小李子"; // 姓名(父类成员)
int _num = 111; // 身份证号(父类成员)
};
class Student_Scope : public Person_Scope
{
public:
void Print()
{
cout << " 姓名:" << _name << endl; // 访问父类_name(无同名,直接访问)
// 补充:同名成员需加父类作用域访问,否则默认访问子类成员(作用域遮蔽规则)
cout << " 身份证号:" << Person_Scope::_num << endl; // 访问父类_num
cout << " 学号:" << _num << endl; // 访问子类_num(默认优先解析子类成员)
}
protected:
// 补充:子类与父类同名成员(变量/函数)构成"隐藏"(重定义),与参数列表无关;
// 若为函数,即使参数不同,也会隐藏父类函数(非重载,重载要求同一作用域)
int _num = 999; // 学号(子类成员,隐藏父类_num)
};
解释:
- 同名成员隐藏。
- 函数隐藏只看函数名,不看参数。
- 要访问父类成员必须加
Person_Scope::。
5.3 默认成员函数完整注释
cpp
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)
: Person(name) // 显式调用父类有参构造(若父类无默认构造,必须显式调用)
, _num(num)
{
cout << "Student()" << endl;
}
// 补充:若父类无默认构造(如无参构造/全缺省构造),子类构造函数必须显式调用父类有参构造;
// 若父类有默认构造,子类不写则自动调用父类默认构造(但推荐显式调用,逻辑更清晰)
// 子类拷贝构造函数
Student(const Student& s)
: Person(s) // 调用父类拷贝构造(利用赋值兼容规则:子类对象s可传给父类拷贝构造的Person&参数)
, _num(s._num)
{
cout << "Student(const Student& s)" << endl;
}
// 补充:子类拷贝构造需手动初始化父类部分(调用父类拷贝构造),否则父类部分会调用默认构造(可能不符合预期)
// 子类赋值运算符重载
Student& operator=(const Student& s)
{
cout << "Student& operator= (const Student& s)" << endl;
if (this != &s)
{
// 补充:子类operator=会隐藏父类operator=(同名成员隐藏),必须加父类作用域显式调用;
// 若不调用,父类部分成员不会被赋值,仅子类部分赋值(逻辑不完整)
Person::operator=(s); // 显式调用父类赋值运算符,初始化父类部分
_num = s._num; // 初始化子类部分
}
return *this;
}
// 子类析构函数
~Student()
{
// 修正:子类析构函数会"隐藏"父类析构函数(编译器统一处理为~destructor)
// 无需显式调用父类析构在子类的析构函数结束后自动调用
cout << "~Student()" << endl;
}
protected:
int _num; // 学号
};
解释:
- 子类构造:先父类,再子类。
- 子类拷贝构造:
Person(s)切片。 - 子类
operator=:必须Person::operator=(s),否则父类部分不赋值。 - 子类析构:自动调用父类析构,不要手动调用。
- 析构顺序:子类 → 父类。
5.4 多态缺省参数静态绑定
cpp
class A
{
public:
virtual void func(int val = 1) { std::cout << "A->" << val << std::endl; }
virtual void test() { func(); }
};
class B : public A
{
public:
void func(int val = 0) { std::cout << "B->" << val << std::endl; }
};
void test()
{
B* p = new B;
p->test();
// 输出 B->1
}
解释:
B未重写test(),调用继承自A::test()。A::test()中调用func(),func是虚函数,B重写了,动态绑定到B::func()。- 但缺省参数是静态绑定,
A::test()中this静态类型是A*,所以使用A::func的缺省参数val = 1。 - 最终输出
B->1。
你代码注释:
cpp
//普通函数继承与同名处理:
//子类会完整继承父类普通函数的接口(返回值、参数、函数名) + 实现(函数体);
//若子类定义同名普通函数(参数列表可不同),属于 "隐藏" 而非 "重写"------ 父类函数会被遮蔽,
//需通过 "父类::函数名" 显式调用,且不支持多态。
//虚函数继承与重写:子类未重写时,会继承父类虚函数的接口 + 实现;
//若子类 "重写" 虚函数,需严格保持接口与父类一致(返回值、参数、函数名完全相同),
//仅替换函数体,且支持多态(通过父类指针 / 引用调用时,动态绑定到子类实现)。
六、单例模式专题
6.1 懒汉模式
懒汉模式:一开始不创建对象,到要用的时候再创建。
cpp
namespace lazyman
{
class sigleton
{
public:
// 只给一个接口创建对象而且要管控这个接口
// 注意这个接口必须是静态的因为外部没法创建对象也就没法通过对象调用类的函数了
// 所以只能是静态的函数
static sigleton* Get()
{
if (_pinst == nullptr) // 防止第二次创建的时候又加一次锁导致的性能损耗
{
std::unique_lock<std::mutex> lock(_mtx);
if (_pinst == nullptr)
{
_pinst = new sigleton; // 用手动开锁解锁 如果这里抛异常会导致死锁
// 所以采用unique_lock到了作用域结束时或者手动结束(用{}来括出一个局部域)就会解锁
}
}
return _pinst; // 无论调用多少次这个函数返回都是一指针
}
sigleton(const sigleton& s) = delete;
static void DelInstance()
{
std::unique_lock<std::mutex> lock(_mtx); // 防止别的线程再用这边释放
delete _pinst;
_pinst = nullptr;
}
private:
// 想让外面没法直接创建对象把构造函数写成私有的
sigleton() {}
static sigleton* _pinst; // 控制只能有一个对象的指针 这个指针也必须是静态的因为只有静态的对象
// 静态的函数才可以调用
static std::mutex _mtx;
};
sigleton* sigleton::_pinst = nullptr; // 静态成员变量再类的外面初始化
std::mutex sigleton::_mtx;
// 1.一般情况下如果想要手动释放单例对象,可以调用DelInstance
// 2. 如果需要程序结束时,正常释放单例对象可以加入下面的设计
class GC
{
public:
~GC() // 在出了作用域会自动调用析构函数
// 外面在创建一个全局的静态变量(作用域是程序结束所以当这个变量结束时就会调用析构函数释放单例对象)
{
sigleton::DelInstance();
}
};
static GC gc;
}
解释:
- 双检查锁:第一次判空避免每次加锁,第二次判空防止多线程同时创建。
unique_lock自动解锁,异常安全。- 禁用拷贝构造。
- 私有构造。
- 静态成员类外初始化。
GC类利用全局静态对象析构,程序结束时释放单例。
6.2 饿汉模式
饿汉模式:一开始(main 函数之前)就创建对象。
cpp
namespace hungryman
{
class sigleton
{
public:
static sigleton* Get()
{
return &_inst;
}
sigleton(const sigleton& s) = delete;
private:
sigleton() {}
static sigleton _inst;
};
sigleton sigleton::_inst;
// 这个时候不可能存在多线程 只会存在主线程就不会出现线程安全的问题
// 说明:饿汉模式利用全局静态对象的编译期初始化特性,天然线程安全(初始化发生在main执行前,无多线程竞争)
}
解释:
- 静态成员
_inst在main前初始化。 - 天然线程安全。
- 实现简单。
- 但可能影响启动速度。
懒汉 vs 饿汉
| 对比 | 懒汉 | 饿汉 |
|---|---|---|
| 创建时机 | 使用时创建 | 程序启动时创建 |
| 线程安全 | 需要加锁 | 天然线程安全 |
| 实现复杂度 | 较复杂 | 简单 |
| 启动影响 | 不影响启动 | 可能启动慢 |
| 多单例依赖顺序 | 可手动控制 | 无法保证 |
| 实际使用 | 多一点 | 较少 |
七、面试题与自测
继承面试题
- 什么是菱形继承?菱形继承的问题是什么?
- 什么是菱形虚拟继承?如何解决数据冗余和二义性?
- 继承和组合的区别?什么时候用继承?什么时候用组合?
多态面试题
- 什么是多态?
- 什么是重载、重写(覆盖)、重定义(隐藏)?
- 多态的实现原理?
inline函数可以是虚函数吗?- 可以,不过编译器就忽略
inline属性,这个函数就不再是inline,因为虚函数要放到虚表中去。
- 可以,不过编译器就忽略
- 静态成员可以是虚函数吗?
- 不能,因为静态成员函数没有
this指针,使用类型::成员函数的调用方式无法访问虚函数表,所以静态成员函数无法放进虚函数表。
- 不能,因为静态成员函数没有
- 构造函数可以是虚函数吗?
- 不能,因为对象中的虚函数表指针是在构造函数初始化列表阶段才初始化的。
- 析构函数可以是虚函数吗?什么场景下析构函数是虚函数?
- 可以,并且最好把基类的析构函数定义成虚函数。
- 对象访问普通函数快还是虚函数更快?
- 普通对象一样快。指针/引用对象,普通函数快,因为多态运行时需要到虚函数表中查找。
- 虚函数表是在什么阶段生成的,存在哪的?
- 编译阶段生成,一般存在代码段(常量区)。
- C++ 菱形继承的问题?虚继承的原理?
- 数据冗余、二义性;虚基表指针 + 偏移量。
- 注意不要把虚函数表和虚基表搞混。
- 什么是抽象类?抽象类的作用?
- 包含纯虚函数的类,不能实例化;强制派生类重写,体现接口继承。