面向对象三大特性:封装、继承、多态
RE: 封装
- C++把数据和方法封装在类里面
- 迭代器和适配器
继承
1 基类 & 派生类
一个类可以派生自多个类,这意味着,它可以从多个基类继承数据和函数。定义一个派生类,我们使用一个类派生列表来指定基类。类派生列表以一个或多个基类命名,形式如下:
class 派生类(子类): 访问修饰符 基类(父类)
注:
访问修饰符是 public、protected 或 private 其中的一个,
未使用它则默认是 private
假设有一个基类 Shape,Rectangle 是它的派生类,如下所示:
#include <iostream>
using namespace std;
// 基类
class Shape
{
public:
void setWidth(int w)
{
width = w;
}
void setHeight(int h)
{
height = h;
}
protected:
int width;
int height;
};
// 派生类
class Rectangle: public Shape
{
public:
int getArea()
{
return (width * height);
}
};
int main(void)
{
Rectangle Rect;
Rect.setWidth(5);
Rect.setHeight(7);
// 输出对象的面积
cout << "Total area: " << Rect.getArea() << endl;
return 0;
}
2 访问控制和继承
访问 public protected private
同一个类 yes yes yes
派生类 yes yes no
外部的类 yes no no
3 赋值兼容转换(切片)
派生类对象 可以赋值给 基类的对象/基类的指针/基类的引用 。
(切片:把派生类中基类那部分切来赋值过去) 赋值语句:父类对象 = 子类对象
而 基类对象不能赋值给派生类对象 。
注:单独的语法规则,不是类型转换,没有产生临时变量,与下述引例机制不同
引例:截断和提升
// 类型转换会产生临时变量
int i = 1234;
printf("%x\n", i);// 4d2
// 截断
char ch = i;
printf("%x\n", ch);// ffffffd2
// 提升
i = ch;
printf("%x\n", i);// ffffffd2
const int& ref_i = i;// 临时变量具有常性
printf("%d\n", ref_i);// -46
const char& ref_ch = ch;// 临时变量具有常性
printf("%d\n", ref_ch);// -46
1234 的二进制表示为:10011010010(除 2 取余法)
补全至16位:0000 0100 1101 0010
然后,将每一组二进制转换为十六进制:
0000 = 0
0100 = 4
1101 = D(大小写皆可)
0010 = 2
最终组合成十六进制:4d2
第一个输出结果:4d2
截断:
因为 char 类型只占用 1 个字节(8 位),而 i 是一个 32 位的整数,在赋值给 char 时,只保留了最低 8 位的内容(1101 0010,即 0xd2),其余高位被截断。
留下的内容最高位是 1,表明这是一个负数,当 char 被提升为 int 以打印时,会进行符号扩展,高位会被填充为 1,使得 ch 变成 0xFFFFFFD2(在 32 位系统上)。
第二个输出结果:ffffffd2
提升:
ch 是有符号类型且其值为 1101 0010,
找到补码:当前的二进制数 11010010 就是补码。
求反码(将所有位取反):11010010 取反后得到:00101101
加 1:00101101 + 1 = 00101110
这个结果是 00101110,对应的十进制值是 46。
因为符号位是 1,表示这是一个负数,所以最终值为:-46。ch 是有符号类型且其值为 0xD2(-46 in decimal),类型提升时高位会填充符号位,因此 i 的值为 0xFFFFFFD2
第三个输出结果:ffffffd2
// 赋值兼容转换
Shape Sh1;
Rect1.name = "RECT";
Sh1 = Rect1;
Shape* ptr = &Sh1;
Shape& ref = Sh1;
ptr->name += "x";
ref.name += "x";
Rect1.PrintName();
Sh1.PrintName();
cout << endl;
两次打印结果如下:
RECT
RECTxx
对象的赋值是拷贝赋值;
Rect1 和 Sh1 是两个独立的对象: 由于 Sh1 是 Rect1 的副本,对 Sh1 的修改不会影响 Rect1,反之亦然。
4 继承的作用域
各作用域的影响:
作用域 | 语法编译查找规则 | 生命周期 |
---|---|---|
局部域 | ✅ | ✅ |
全局域 | ✅ | ✅ |
命名空间域 (默认不查找,除非展开或指定) | ✅ | 不存在 |
类域 | ✅ | 不存在 |
4.1 示例1
class Person
{
protected:
string _name = "小李子"; // 姓名
int _num = 111; // 身份证号
};
class Student : public Person
{
public:
void Print()
{
cout << "姓名:" << _name << endl;
cout << "学号:" << _num << endl;// Student的_num和Person的_num构成隐藏关系
cout << "身份证号:" << Person::_num << endl;
}
protected:
int _num = 999; // 学号
};
void TestStu()
{
Student s1;
s1.Print();
};
姓名:小李子
学号:999
身份证号:111
4.2 示例2
class A {
public:
void ft() {
cout << "void ft()" << endl;
}
};
class B :public A {
public:
void ft(int i) {
A::ft();
cout << "void ft(int i), i = " << i << endl;
}
};
void TestB() {
B b;
b.ft(1);
// Q:重载,隐藏,编译报错,运行报错?
// A:两者构成隐藏,函数重载的前提是在同一个作用域
}
void ft()
void ft(int i), i = 1
变式:
class A {
public:
void ft() {
cout << "void ft()" << endl;
}
};
class B :public A {
public:
void ft(int i) {
cout << "void ft(int i), i = " << i << endl;
}
};
void TestB() {
B bb;
bb.ft();
// Q:重载,隐藏,重写,编译报错,运行报错?(不定项选择)
// A:两者构成隐藏且编译报错
// 如何调用父类?
b2.A::ft();
}
5 继承过程中涉及的构造、拷贝构造和析构函数的工作机制
5.1 构造函数在继承中的作用和调用顺序
5.1.1 构造函数的调用顺序
当创建派生类对象时,基类的构造函数会先于派生类的构造函数被调用。这是因为派生类需要依赖基类的成员和功能,所以必须先初始化基类部分。
在构造派生类对象时,不能先初始化派生类再初始化基类,因为派生类的构造函数可能依赖于基类的成员。如果基类未先初始化,这些成员将包含随机值,从而导致不确定的行为。
示例:
#include <iostream>
using namespace std;
class Base {
public:
Base() {
cout << "Base Default Constructor" << endl;
}
};
class Derived : public Base {
public:
Derived() {
cout << "Derived Default Constructor" << endl;
}
};
int main() {
Derived d;
return 0;
}
输出:
Base Default Constructor
Derived Default Constructor
解释:
- 基类成员当成一个整体:在构造派生类对象时,基类部分被当作一个整体,调用其默认构造函数来初始化。
- 派生类自己的成员:
- 内置类型成员:根据编译器的实现,可能会自动初始化(如置零),也可能不处理,产生未定义的值。
- 自定义类型成员:会调用它们的默认构造函数进行初始化。
5.1.2 派生类构造函数如何初始化基类
如果基类没有默认构造函数,或者需要传递参数,可以在派生类的构造函数的初始化列表中显式调用基类的构造函数。
示例:
class Base {
public:
int baseValue;
Base(int x) : baseValue(x) {
cout << "Base Parameterized Constructor" << endl;
}
};
class Derived : public Base {
public:
int derivedValue;
Derived(int x, int y) : Base(x), derivedValue(y) {
cout << "Derived Parameterized Constructor" << endl;
}
};
解释:
- 派生类的构造函数在初始化列表中调用了基类的构造函数
Base(x)
,并初始化了自己的成员derivedValue(y)
。
5.2 拷贝构造函数在继承中的行为
5.2.1 默认拷贝构造函数的生成
当你没有显式定义拷贝构造函数时,编译器会为你生成一个默认拷贝构造函数。对于派生类,默认拷贝构造函数的行为如下:
- 基类成员当成一个整体:调用基类的拷贝构造函数来复制基类部分的数据。
- 派生类自己的成员:
- 内置类型成员:逐个成员进行值拷贝(浅拷贝)。
- 自定义类型成员:调用它们的拷贝构造函数进行复制。
示例:
class Base {
public:
int baseValue;
Base(int x) : baseValue(x) {}
Base(const Base& other) : baseValue(other.baseValue) {
cout << "Base Copy Constructor" << endl;
}
};
class Derived : public Base {
public:
int* derivedValue;
Derived(int x, int y) : Base(x) {
derivedValue = new int(y);
}
// 默认拷贝构造函数
// Derived(const Derived& other) : Base(other), derivedValue(other.derivedValue) {}
~Derived() {
delete derivedValue;
}
};
当我们执行以下代码:
Derived d1(10, 20);
Derived d2 = d1; // 调用默认拷贝构造函数
可能的问题:
derivedValue
是一个指针,默认拷贝构造函数会进行浅拷贝,即复制指针的值。- 这会导致
d1
和d2
的derivedValue
指向同一块内存,可能在析构时造成重复释放(double free)等错误。
5.2.2 需要自定义拷贝构造函数的情况
当派生类的成员涉及到动态内存分配或需要深拷贝时,必须自定义拷贝构造函数。
示例(自定义拷贝构造函数):
class Derived : public Base {
public:
int* derivedValue;
Derived(int x, int y) : Base(x) {
derivedValue = new int(y);
}
Derived(const Derived& other) : Base(other) { // 调用基类的拷贝构造函数
derivedValue = new int(*other.derivedValue); // 深拷贝
cout << "Derived Copy Constructor" << endl;
}
~Derived() {
delete derivedValue;
}
};
解释:
- 基类成员当成一个整体:在派生类的拷贝构造函数中,显式调用了基类的拷贝构造函数
Base(other)
。 - 派生类自己的成员:
- 内置类型成员:如果有内置类型成员,默认会进行值拷贝。
- 自定义类型成员:需要手动编写代码来实现深拷贝,防止多个对象共享同一块内存。
5.3 析构函数在继承中的作用和调用顺序
5.3.1 析构函数的调用顺序
当销毁派生类对象时,析构函数的调用顺序与构造函数相反:
- 首先调用派生类的析构函数,清理派生类特有的资源。
- 然后调用基类的析构函数,清理基类部分的资源。
示例:
class Base {
public:
~Base() {
cout << "Base Destructor" << endl;
}
};
class Derived : public Base {
public:
~Derived() {
cout << "Derived Destructor" << endl;
}
};
int main() {
Derived d;
return 0;
}
输出:
Derived Destructor
Base Destructor
解释:
- 先销毁派生类部分,释放派生类特有的资源。
- 然后销毁基类部分,确保对象的所有资源都被正确释放。
5.3.2 虚析构函数的重要性
在涉及多态的情况下,如果你通过基类指针删除派生类对象,基类的析构函数必须是虚函数(virtual
),否则可能导致派生类的析构函数不被调用,造成资源泄漏。
示例:
class Base {
public:
virtual ~Base() {
cout << "Base Destructor" << endl;
}
};
class Derived : public Base {
public:
~Derived() {
cout << "Derived Destructor" << endl;
}
};
int main() {
Base* ptr = new Derived();
delete ptr; // 正确调用派生类和基类的析构函数
return 0;
}
输出:
Derived Destructor
Base Destructor
解释:
- 基类的析构函数被声明为虚函数后,
delete
基类指针时,会先调用派生类的析构函数,再调用基类的析构函数。 - 如果基类析构函数不是虚函数,只会调用基类的析构函数,派生类的资源可能得不到释放。
5.4 总结与注意事项
5.4.1 默认构造函数的行为
- 基类成员:在派生类的构造过程中,基类部分被当作一个整体,调用基类的默认构造函数。
- 派生类的内置类型成员:编译器可能会自动初始化(如置零),也可能不处理,这取决于编译器实现。
- 派生类的自定义类型成员:会调用它们的默认构造函数进行初始化。
5.4.2 默认拷贝构造函数的行为
- 基类成员:调用基类的拷贝构造函数,复制基类部分的数据。
- 派生类的内置类型成员:逐个成员进行值拷贝(浅拷贝)。
- 派生类的自定义类型成员:调用它们的拷贝构造函数。
5.4.3 何时需要自定义拷贝构造函数
- 当派生类的成员涉及到动态内存分配、文件句柄、网络连接等需要深拷贝的资源时,必须自定义拷贝构造函数和赋值运算符,以正确管理资源,防止浅拷贝带来的问题。
5.4.4 赋值操作符的注意事项
- 类似于拷贝构造函数,赋值操作符在默认情况下也会进行浅拷贝。如果涉及到需要深拷贝的成员,应该自定义赋值操作符。
5.4.5 避免资源泄漏和悬垂指针
- 正确地管理对象的生命周期,确保析构函数能被正确调用,防止资源泄漏。
- 注意浅拷贝带来的悬垂指针问题(指针指向已被释放的内存)。