cpp核心编程-面向对象
本文是b站黑马程序员的课后笔记,讲的真不错,建议去听听b站地址
1.内存分区模型
cpp程序在执行时,将内存分为4个区域:
- 代码区:存放函数体的二进制代码,由操作系统进行管理
- 全局区:存放全局变量和静态变量以及常量
- 栈区:由编译器自动分配释放,存放函数的参数值,局部变量等等
- 堆区:由程序员分配和释放,若程序员不释放(俗称内存泄露),程序结束时由操作系统回收
存在的意义:
不同区域存放的数据,赋予不同的生命周期,如此编程更加有条理和灵活。
1.1 程序运行前
在程序编译后,会生成可执行程序,未执行程序前分为两个区域:
代码区:
- 存放CPU执行的机器指令
- 代码区是共享的,共享的目的是对于频繁被执行的程序,只需要在内存中有一份代码即可
- 代码区是只读的,防止程序意外地修改了它的指令
全局区:
- 全局变量(
global
或在函数体外面声明)和静态变量static
存放在此 - 全局区还包含了常量区,字符串常量
"abcdefg"
和其他常量也存放在此- 其他常量仅仅包括const修饰的全局变量
const global
- const修饰的局部变量在局部区域
const local
- 其他常量仅仅包括const修饰的全局变量
- 该区域的数据在程序结束后由操作系统释放
1.2 程序运行后
栈区:
- 由编译器自动分配释放,如函数的参数值,局部变量等
- 注意:不要返回局部变量的地址,因为当函数体结束后,对应栈空间上中的内存会被释放
堆区:
- 由程序员分配释放,若程序员忘了释放(俗称内存泄露),程序结束时由操作系统回收
- 在cpp中主要利用
new
在堆区中开辟内存
int * func()
{
int *p = new int(10);// new返回的是地址
return p;
}
int main(){
int *p = func();
cout << *p << endl;
// 释放内存
delete p;
}
//数组的堆空间开辟与释放
int *arr = new int[10];
delete[] arr; // delete需要加个[]
如上func()
返回的地址为堆区的地址,不会随着函数体生命结束而结束。
2.引用
引用的作用:给变量取个别名
原理:相当于声明一个新变量,该变量与原变量共享同一个地址
语法:数据类型 &别名 = 原名
int main(){
int a = 100;
int &b = a;
cout<<b<<endl; // 100
a = 10;
cout<<b<<endl; // 10
int c = 99;
b = c;
cout<<a<<endl; // 99
}
注意事项:
- 引用必须要初始化,必须告诉它是谁的别名
- 引用一旦初始化后,就不可更改
2.1 引用做函数参数
方式:
void swap(int &a, int &b){
int temp = a;
a = b;
b = temp;
}
作用:利用引用的技术,形参将修饰实参,能改变传入变量的值。称作:引用传递
优点:可以简化指针修改实参(地址传递)
三种传递形参的方式:
- 值传递
(int a)
- 引用传递
(int &a)
- 地址传递
(int *a)
2.2 引用做函数的返回值
方式:
int& test(){
static int a = 20;
return a;
}
// 如果函数作为左值,那么必须返回引用
int main(){
int &ref = test();
test() = 10000;
cout << ref << endl;
}
注意事项:不要返回局部变量的引用
2.3 引用的本质
本质:引用的本质在cpp内部实现是一个指针常量
int a = 10;
// cpp内部的实现,等价于int *const ref = &a;指针常量指向不可改
int &ref = a;
ref = 20; // 等价于*ref = 20
2.4 常量引用
作用:主要用来修饰形参,防止误操作。在函数形参列表中,加const
修饰形参,防止形参改变实参
int a = 10;
const int &ref = a; // const int *ref = &a;
其实就相当于在指针常量前加了个const
,使得ref变成了修饰常量,引用的指向和指向的值都不可以修改。
3.函数提高
3.1 默认参数
需要注意的有两点:
- 一旦某个变量使用了默认参数,则其右边的参数也应该全是默认参数形式
int add(int a, int b=10, int c=10){...}
- 如果函数声明有默认参数,函数实现就不能有默认参数
int func(int a=10, b=20);
int func(int a, int b){
return a+b;
}
3.2 函数的占位参数
使用方法:
返回值类型 函数名(数据类型){}
注意:调用函数时必须填补该位置
void func(int a, int){
cout << "funnnnc" << endl;
}
int main(){
func(10,10);
}
占位参数还可以有默认参数
3.3 函数重载
作用:函数名可以相同,提高复用性
函数重载满足条件:
- 同一个作用域下
- 函数名称相同
- 函数参数类型不同或者个数不同或者顺序不同
注意:函数返回值不能作为重载条件
注意事项:
- 引用作为函数重载的条件
void func(int &a){
cout <<func(int &a)<<endl;
}
void func(const int &a){
cout <<func(const int &a)<<endl;
}
int main(){
int a = 10;
func(a); // 滴啊用的是func(int &a)
func(10); // 调用的是func(const int &a),因为func(const int &a)可以传值,而func(int &a)不可以
}
- 函数重载碰到默认参数
当函数重载碰到默认参数,会发生二义性
void func(int a){
cout <<func(int a)<<endl;
}
void func(int a, int b=10){
cout <<func(int a, int b=10)<<endl;
}
以上函数重载会发生二义性,导致编译器不知道调用哪一个
4.类和对象
万事万物皆为对象,对象的三大特性:封装、继承、多态
4.1 封装
4.1.1 封装的意义
封装意义之一:
可以把属性和行为写在一起,表现事物
封装意义之二:
在类设计时,可以把属性和行为放在不同的权限下,加以控制
访问权限有三种:
- public:成员 类内可以访问,类外可以访问
- protected :成员 类内可以访问,类外不可以访问;儿子可以访问父亲的protected内容
- private:成员 类内可以访问,类外不可以访问;儿子不可以访问父亲的private内容
4.1.2 struct和class的区别
cpp中class
和struct
的核心区别在于
默认的访问权限不同:
struct
默认权限为公共class
默认权限为私有
其他区别有:
struct
不能用于声明类模板,而class
可以class
是引用类型,struct
是值类型。值类型在传递和赋值时将进行复制,而引用类型则只会使用引用对象的一个"指向"
所以,struct
适合作为一种数据结构的集合,而class
适合作为一种对象。
4.1.3 成员属性设置为私有
- 将所有成员属性设置为私有,可以自己控制读写权限
- 对于写权限,我们可以检测数据的有效性
#include<string>
class Person(
public:
// 优点1
void setAge(int age){
// 优点2
if (age<0 || age >120){
cout << "Invalid age setting!"<<endl;
return;
}
_age = age;
}
// 优点1
int getAge(){
return _age;
}
private:
int _age;
)
在对属性的读写中,相当于多了一个接口,在这个接口中,我们可以有很多行为
4.2对象的初始化和清理
4.2.1构造函数与析构函数
存在的意义:
一个对象或者变量没有初始状态,其后果是未知的;若使用完一个对象或变量,没有及时清理,也会造成一定的安全问题
实现:
cpp利用了构造函数和析构函数解决上述问题,这两个函数会被编译器自动调用,完成对象的初始化和清理工作。如果我们不提供构造和析构,编译器会提供,编译器提供的构造函数和析构函数是空实现
- 构造函数:主要作用在于创建对象时为对象的成员属性赋值,构造函数由编译器自动调用,无须手动调用
在创建对象的时候,编译器会首先给构造函数分配内存空间,然后再调用构造函数进行对象创建
- 析构函数:主要作用在于对象销毁前系统自动调用,执行一些清理工作
class Person{
public:
Person(){
cout << "Creating!" << endl;
}
~Person(){
cout<< "Done!" << endl;
}
};
int main(){
Person *pointer = new Person(); // 创建对象前,调用构造函数
delete pointer; // 销毁前,调用析构函数
cout << "Hello, world!" << endl;
}
4.2.2 构造函数的分类及调用
两种分类方式:
- 按参数分为:有参构造和无参构造
- 按类型分为:普通构造和拷贝构造(
Person(const Person &p)
)
三种调用方式:
- 括号法
Person p(10)
- 显示法
Person p = Person(10)
- 隐式转换法
Person p = 10
class Person{
public:
// 拷贝构造
Person(const Person &p){
_age = p._age;
cout << "Creating!" << endl;
}
~Person(){
cout<< "Done!" << endl;
}
private:
int _age;
};
有趣的是,p._age(private)
能够在同类,不同内存空间中进行调用
4.2.3 拷贝函数调用时机
cpp中拷贝构造函数调用时机通常有三种情况
- 使用一个已经创建完毕的对象来初始化一个新对象
- 值传递的方式给函数参数传值
- 以值方式返回局部对象
使用一个已经创建完毕的对象来初始化一个新对象:
Person p1;
Person p2(p1);
值传递的方式给函数参数传值:
int doSomething(Person p){
return p._age;
}
以值方式返回局部对象:
Person makePerson(){
Person p
return p;
}
int main(){
Person p = makePerson();
}
4.2.4构造函数调用规则
默认情况下,cpp编译器至少给一个类添加3个函数
- 默认构造函数(无参,函数体为空)
- 默认析构函数(无参,函数体为空)
- 默认拷贝构造函数,对属性进行值拷贝
构造函数调用规则如下:
- 如果用户定义有参构造函数,cpp不再提供无参构造,但是会提供默认拷贝构造
- 如果用户定义拷贝构造函数,cpp不会再提供其他构造函数
4.2.5深拷贝与浅拷贝
深拷贝和浅拷贝是面试经典问题,也是常见的一个坑
浅拷贝:简单的赋值拷贝操作
深拷贝:在堆区重新申请空间,进行拷贝操作
问题:
首先,我们在创建类的时候,可能会开辟一个堆空间进行变量存储;而析构函数的作用通常就是,释放这些在堆空间创建的变量内存,如下所示:
// 错误做法
class Person{
public:
Person(int age, double height){
_age = age;
_height = new double(height);
cout << "Person created!"<< endl;
};
~Person(){
delete _height;
};
int _age;
double *_height;
};
int main(){
Person p1(10, 160);
Person p2(p1);
}
由于默认的拷贝构造函数是浅拷贝,即进行值传递,所以在执行Person p2(p1);
后,p1
和p2
的*height
将会指向同一个内存空间。所以在函数main()
结束时,p2
的析构函数会率先释放内存,如此变回导致p1
的_height
指向消失,程序报错!!所以正确的做法应当如下:
class Person{
public:
Person(int age, double height){
_age = age;
_height = new double(height);
cout << "Person created!"<< endl;
};
Person(const Person& other){
_age = other._age;
_height = new double(*(other._height)); // 深拷贝
cout << "Person created!"<< endl;
};
~Person(){
delete _height;
};
int _age;
double *_height;
};
int main(){
Person p1(10, 160);
Person p2(p1);
}
在调用拷贝构造函数时,为_height
在堆空间创建s新的内存,如此两个对象的变量就不会指向同一个内存空间了。
4.2.6 初始化列表
作用:cpp提供了初始化列表语法,用来初始化属性
语法:构造函数():属性1(值1),属性2(值2)...{}
class Person{
public:
Person(int age, double height):_age(age),_height(height){
cout << "Person created!"<< endl;
};
int _age;
double height;
}
4.2.7 类对象作为类成员
就是指类中的成员可以是另一个类的对象,我们称该成员为对象成员。
所以在初始化的时候,一般先初始化成员变量,再初始化自身;
析构的时候,先析构自身,再析构成员变量
4.2.8 静态成员
静态成员就是在成员变量和成员函数前加上关键字static
,称为静态成员
静态成员分为:
-
静态成员变量
- 所有对象共享一份数据
- 在编译阶段分配内存
- 类内声明,类外初始化:因为定义一个static变量是为了保证只初始化一次,为大家所用,如果可以在类内初始化,会导致每个对象都包含该静态成员,就为对象空间上了,不符合初心
-
静态成员函数
- 所有对象共享同一个函数:因为内存开辟在全局区
- 静态成员函数只能访问静态成员变量
class Person{
public:
static void func(){
cout << "Hey static!" <<endl;
m_A = 10; // 只能访问静态成员变量
//m_B = 1; //禁止访问非静态成员变量
cout << m_A << endl;
};
static int m_A; // 类内声明
int m_B;
};
int Person::m_A = 0; // 需要在类外初始化
int main(){
Person::func(); // 直接通过类名调用
Person p;
p.func();
}
作用:静态成员函数主要为了调用方便,不需要生成对象就能调用,所以静态成员函数相当于一个带有命名空间的全局函数
就像我们调用数学Math
里的求最小值最大值,就可以直接调用Math::max();
,而不需要生成实例。
4.3 cpp对象模型和this指针
4.3.1 成员变量和成员函数分开存储
- cpp中,类内的成员变量和成员函数分开存储
- 只有非静态成员变量才属于类的对象上
class empty_Person{
};
int main(){
empty_Person pp;
// cpp编译器会给每个空对象分配一个字节空间,是为了区分空对象占内存的位置
// 每个空对象都应该有一个独一无二的内存地址
cout << sizeof(pp) << endl; // 1
}
class Person{
public:
int m_A; // 非静态成员变量,属于类的对象上
static int m_B; // 静态成员变量,不属于类的对象上
void func(){} // 成员函数,不属于类的对象上
static void func(){} // 静态成员函数,不属于类的对象上
};
int Person::m_B=0;
int main(){
Person pp;
cout << sizeof(pp) << endl; // 4,只有非静态成员变量在对象上
}
所以,只有非静态成员变量在对象上。
4.3.2 this指针概念
通过上一节知道,每一个非静态成员函数只会诞生一份函数实例,也就是说多个同类型的对象会共用一份代码。
那么,这一块代码是如何区分哪个对象调用自己呢?
cpp通过提供特殊的对象指针,this指针,解决上述问题。this指针指向被调用的成员函数所属的对象
- this指针是隐含每一个非静态成员函数内的一种指针
- this指针不需要定义,直接使用即可
this指针的用途:
- 当形参和成员变量同名时,可用this指针来区分
- 在类的非静态成员函数中返回对象本身,可用
return *this
经常用于链式编程思想:
class Person{
public:
Person(){
m_weight=10;
}
Person& addweight(const int &weight){
m_weight += weight;
// 返回对象本身
return *this;
};
int m_weight;
};
int main(){
Person p;
int a = 10;
p.addweight(a).addweight(a);
cout << p.m_weight << endl; // 输出30,addweight(a)是引用返回
}
class Person{
public:
Person(){
m_weight=10;
}
// 此处有变
Person addweight(const int &weight){
m_weight += weight;
return *this;
};
int m_weight;
};
int main(){
Person p;
int a = 10;
p.addweight(a).addweight(a);
cout << p.m_weight << endl; // 输出20,addweight(a)是值返回,所以会调用Person的拷贝构造函数进行值传递,所以链式传递不成功
}
所以要注意,若想要实现单个对象的链式编程,需要引用返回,而不是值返回
4.3.3 空指针访问成员函数
在cpp中,空指针是可以访问其未调用对象相关成员变量的成员函数。
class Person{
public:
void showsomething(){
cout << "I'm a big man" << endl;
}
};
int main(){
Person *p = nullptr;
p->showsomething();
}
但是需要注意的是,内部不能够有调用成员变量,或者可以用判断调用指针是否为空进行判断是否运行后续程序:
if (this==nullptr){
return;
}
4.3.4 const修饰成员函数
常函数:
- 成员函数后加const后,我们称这个函数为常函数
- 常函数内不可以修改成员属性
- 成员属性声明时加关键字mutable后,在常函数中依然可以修改
常对象:
- 声明对象前加const,称该对象为常对象
- 常对象只能调用常函数
this指针的本质就一个指针常量,它的指向不可以修改
class Person{
public:
void showsomething(){
this->m_weight = 20;
// this = nullptr //这是错误的,因为指针常量的指向不可以修改
}
int m_weight;
};
若在成员函数后加const,则指针常量进一步进化为修饰常量,它的指向不可以改变,而且它指向的值也不可以改变
class Person{
public:
void showsomething() const{
this->m_weight = 20; // 不可修改
this->m_height = 170; // 加了mutable关键则之后就可以修改
// this = nullptr //这是错误的,因为指针常量的指向不可以修改
}
int m_weight;
mutable int m_height;
};
若在对象前面加const,则该对象变为常函数,不能修改值,且只能够调用常函数
const Person p;
4.4 友元
友元就是让朋友可以访问我的私有成员
根据朋友的类型不同,可分为:
- 全局函数做友元
- 类做友元
- 成员函数做友元
class Person{
friend void goodFriend(Person *person); // 声明该全局函数为Person的朋友,可访问其私有成员
friend class others; // 声明该类为Person的朋友,可访问其私有成员
friend void others::getFriendMoney(); // 声明others的成员函数为Person的朋友,可访问其私有成员
public:
int hair;
private:
int money
};
//全局函数
void goodFriend(Person *person){
cout << person->money << endl; // 访问Person的私有成员
}
//类
class others{
public:
void getFriendMoney(){
Person p;
cout << p.money << endl;
}
}
4.5 运算符重载
运算符重载:对已有的运算符重新进行定义,赋予其另一种功能,以适应不同的数据类型
4.5.1 加号运算符重载
两种重载方式:
- 通过成员函数重载+号
- 通过全局函数重载+号
运算符重载,也可以发生运算符重载
通过成员函数重载+号:
// 成员函数重载本质调用
Person p3 = p1.operator+(p2);
class others{
public:
Person operator+(Person &p){
Person temp;
temp._age = this->_age + p._age;
return temp;
}
int _age;
}
int main(){
Person p1;
p1._age = 10;
Person p2;
p2._age = 20;
Person p3 = p1 + p2; //加号运算符重载
cout << p3._age << endl;
}
通过全局函数重载+号:
// 全局函数重载本质调用
Person p3 = operator+(p1, p2);
Person operator+(Person &p1, Person&p2){
Person tmp;
tmp._age = p1._age + p2._age;
return tmp;
}
运算符重载,也可以发生运算符重载:
Person operator+(Person &p1, int _age);
4.5.2 左移运算符重载
可用于:
cout << p << endl;
- 不能利用成员函数重载左移运算符,因为希望
cout
在对象的左边 - 只能利用全局函数重载左移运算符
void operator<<(ostream &cout, Person &p){
cout << p.b << endl;
}
cout << p;
// 还可以采取连式编程的思想
ostream & operator<<(ostream &cout, Person &p){
cout << p.b << endl;
return cout;
}
cout << p << endl;
如果想让重载运算符函数访问私有成员,可用利用友元,将函数重载运算符变为好朋友。
4.5.3 递增运算符
首先,递增分为前置递增和后置递增:
cout << i++ << endl; // 先输出i,再执行+
cout << ++i << endl; // 先执行+,s
具体的实现如下:
class myInteger{
friend ostream& operator<<(ostream& cout, myInteger myint);
public:
myInteger(){
m_num=0;
}
// 重载前置++运算符,返回引用是为了一直对同一数据进行递增操作,类似于内置数据类型
myInteger operator++(){
m_num++;
return *this; // 返回引用
}
// 后置++运算符,利用占位参数int进行区分
myInteger operator++(int){
myInteger tmp = *this;
m_num++;
return tmp; // 返回值,返回的是一个拷贝,返回的一瞬间会执行拷贝构造函数
}
private:
int m_num;
};
ostream& operator<<(ostream& cout, myInteger myint){
cout << myint.m_num;
return cout;
}
int main(){
myInteger myint;
cout << myint++ << endl;
cout << ++myint << endl;
cout << myint << endl;
return 0;
}
需要注意的点:
- 前置++运算符返回的是引用,后置++运算符返回的是值
- 后置运算符重载需要利用
int
进行占位来表示
4.5.4 赋值运算符重载
赋值运算符重载通常解决的是编译器默认的赋值操作是进行浅拷贝的拷贝构造函数调用,而如此会带来的内存重复释放问题
所以解决方式是:在赋值运算符重载中进行深拷贝,即在堆空间上创建一个新的内存空间。
class Cat{
public:
Cat(int age){
m_age = new int(age);
}
~Cat(){
if (m_age != nullptr){
delete m_age; // 堆区开辟的内存需要程序员手动释放
m_age = nullptr;
}
}
int *m_age;
Cat& operator=(Cat &c){
if (m_age != nullptr){
delete m_age;
m_age = nullptr;
}
m_age = new int(*(c.m_age));
return *this;
}
};
ostream& operator<<(ostream& cout, Cat &c){
cout << *(c.m_age);
return cout;
}
int main(){
Cat p1(10);
Cat p2(20);
p2 = p1;
cout << p2 << endl;
return 0;
}
4.5.5 关系运算符重载
作用:重载关系运算符,可以让两个自定义类型的对象进行对比操作。
实现起来比较简单,这里就不多加介绍了,记得返回的是bool
类型即可。
4.5.6 函数调用运算符重载
- 函数调用运算符()也可以重载
- 由于重载后使用方式非常像函数的调用,因此称为仿函数
- 仿函数没有固定写法,非常灵活
class Cat{
public:
Cat(int age, string name){
m_name = name;
m_age = age;
}
// 函数运算符重载
void operator()(){
cout << "Hi, I'm " << this->m_name << endl;
}
int m_age;
string m_name;
};
int main(){
Cat c(10, "Tom"); // 注意,string类型要用双引号
c();
// 匿名函数对象调用
Cat(10, "Jack")(); // 还可以这样调用,调用完后该对象生命便结束
return 0;
}
-
函数调用运算符重载非常有用,可能pytorch中的nn.module.forward就是被nn.module的函数运算符重载所调用。
-
还可以利用匿名对象进行函数调用,也侧面说明了运算符的调用必须要有实例化对象,不能说简单地通过类来调用。
4.6 继承
继承,为了减少重复的代码。
4.6.2 继承方式
继承的语法:
class 子类 : 继承方式 父类
继承方式可以分为三类:
- 公共继承
- 保护继承
- 私有继承
继承方式的意思就是,从父类继承得到的成员属性在本类将如何变化
![image-20210110125932819](index_files/image-20210110125932819.png)
4.6.3 继承中的对象模型
class Person{
public:
int m_A;
void saysomething(){
cout << "Hello, world!" << endl;
}
private:
int m_B;
protected:
int m_C;
};
class Son : public Person{
private:
int m_D;
};
int main(){
Person p;
Son s;
cout << sizeof(p) << endl; // 12
cout << sizeof(s) << endl; // 16
s.saysomething(); // Hello,world!
}
-
父类中所有非静态成员属性都会被子类继承
-
父类中私有属性成员是被编译器隐藏了,因此是访问不到的,但是的确被继承
4.6.4 继承中的构造和析构顺序
顺序:
- 父类构造
- 子类构造
- 子类析构
- 父类析构
4.6.5 同名成员处理
- 子类对象可以直接访问到子类中同名成员
- 子类对象加作用域可以访问到父类同名成员
- 当子类与父类拥有同名的成员函数,子类会隐藏父类中同名成员函数,加作用域可以访问到父类中同名函数
class Person{
public:
void saysomething(){
cout << "Hello, dad's world!" << endl;
}
};
class Son : public Person{
public:
void saysomething(){
cout << "Hello, son's world!" << endl;
}
};
int main(){
Son s;
s.Person::saysomething(); // Hello, dad's world!
s.saysomething(); // Hello, son's world!
}
对于静态成员也类似,特别之处在于,静态成员不仅仅可以通过对象调用,还可以通过类名作用域来调用。
4.6.7 多继承语法
cpp允许一个类继承多个类
语法:
class 子类 : 继承方式 父类1, 继承方式 父类2...
对于同名的属性成员,也需要加类名作用域加以区分。
cpp实际开发中不建议使用多继承
4.6.8 菱形继承与虚继承
菱形继承,如下图所示:
在使用菱形继承方式时,SwimBasket
继承了Basketball
和Swim
的m_score
同名成员变量,虽然同名,但两者的作用域来源不一样,所以Swimbasket
对象继承的时候会创建两个内存空间来存储它们。故SwimBasket
想要调用m_score
时,需要特别指定父类名。
class Sport{
public:
int m_score;
};
class Swim : virtual public Sport{};
class Basketball : virtual public Sport{};
class SwimBasket : public Swim, public Basketball{};
int main(){
Basketball b;
Swim s;
SwimBasket p;
p.Basketball::m_score = 100;
p.Swim::m_score = 200;
cout << p.Basketball::m_score << endl; // 100
cout << p.Swim::m_score << endl; // 200
// cout << p.m_score << endl; // 错误,无法调用
cout << sizeof(b) << endl; // 4
cout << sizeof(s) << endl; // 4
cout << sizeof(p) << endl; // 8
}
类Swim和类Basketball各自从类Sport派生(非虚继承且假设类Sport包含一些数据成员),且类SwimBasket同时多继承自类Swim和Basketball,那么SwimBasket的对象就会拥有两套Sport的实例数据(可分别独立访问,一般要用适当的消歧义限定符)。但是如果类Swim与Basketball各自虚继承了类Sport,那么SwimBasket的对象就只包含一套类Sport的实例数据。其原理是,间接派生类SwimBasket
穿透了其父类(上面例子中的Swim
与Basketball
),实质上直接继承了虚基类Sport
其中,虚继承是通过虚指针和虚表实现的。
class Sport{
public:
int m_score;
};
class Swim : virtual public Sport{};
class Basketball : virtual public Sport{};
class SwimBasket : public Swim, public Basketball{};
int main(){
Basketball b;
Swim s;
SwimBasket p;
p.Basketball::m_score = 100;
p.Swim::m_score = 200;
cout << p.Basketball::m_score << endl; // 200
cout << p.Swim::m_score << endl; // 200
cout << p.m_score << endl; // 200
cout << sizeof(b) << endl; // 16
cout << sizeof(s) << endl; // 16
cout << sizeof(p) << endl; // 24
}
其中的内存模型如下所示
class SwimBasket
object
0 - class Swim (primary base)
0 - vptr_Swim
8 - class Basketball
8 - vptr_Basketball
16 - int m_score
sizeof(C): 24 align: 8
linux64中cpp编译器的int型占据4个字节,指针占据8个字节。理论上应该是(Swim的虚指针+Basketball的虚指针+SwimBasket的m_score成员变量)=20,但由于需要进行8字节对齐,所以sizeof(SwimBasket)=24。
当我们修改p.Swim::m_score
时候,其他类的虚指针就会根据虚表中相对p.Swim
的偏移量动态同步进行修改,如此达到对同一内存空间操纵的目的。
所有当2*8+4*x<=8x
===>x>=4
的时候,就开始节省内存啦~~~!
4.7 多态
4.7.1 多态的基本概念
多态是cpp面向对象三大特征之一
多态分为两类
- 静态多态:函数重载和运算符重载属于静态多态,复用函数名
- 动态多态:派生类和虚函数实现运行时多态
静态多态和动态多态区别:
- 静态多态的函数地址早绑定 - 编译阶段确定函数地址
- 动态多态的函数地址晚绑定 - 运行阶段确定函数地址
以下是动态多态的一个例子:
class Person{
public:
virtual void saysomething(){
cout << "I'm a person!" << endl;
}
};
class Kid : public Person{
public:
void saysomething(){
cout << "I'm a kid!" << endl;
}
};
void test(Person &p){
p.saysomething();
};
int main(){
Person p;
Kid k;
test(p); // "I'm a person!"
test(k); // "I'm a kid!"
cout << sizeof(p) << endl; // 8
cout << sizeof(k) << endl; // 8
}
总结:
多态满足条件:
- 有继承关系
- 子类重写父类中的虚函数
多态使用条件
- 父类指针或引用指向子类对象
重写:函数的返回值类型,函数名,参数列表完全一致称为重写。
多态的原理:
关键在于,含有虚函数声明的类会创建一个表(vtable),在vtable中,编译器放置特定类的虚函数的地址,该表被一个**虚函数表指针(vptr)**秘密地指向。
当继承该类的子类重写了虚函数后,子类中的虚函数表指针将指向自身的表,而表中有所重写的函数。而当我们传入Person &p
的是Kid
对象时,在运行阶段,由于"多态机制",Kid
对象将会调用Kid
对象的虚函数表指针,而该指针指向的是Kid
对象的vtable,表中有自己重写的虚函数&Kid::saysomething()
。
简单一句话就是,多态就是根据传入不同的子类对象,来指向对应子类的虚函数表。
4.7.3 纯虚函数和抽象类
在多态中,通常父类中虚函数的实现是毫无意义的,主要都是调用子类重写的内容,因此可以将虚函数改为纯虚函数
纯虚函数语法:virtual 返回值类型 函数名 (参数列表) = 0;
当类中有了纯虚函数,这个类也称为抽象类。
通俗解释:其实这个功能就像是规定一套范式,一套必须遵守的范式。
抽象类特点:
- 无法实例化对象
- 子类必须重写抽象类中的纯虚函数,否则也属于抽象类
4.7.5 虚析构和纯虚析构
**问题背景:**多态使用时,如果子类有属相开辟到堆区,那么父类指针在释放时无法调用到子类的析构代码
问题代码:
class Animal{
public:
Animal(){
cout << "Animal construct"<<endl;
}
~Animal(){
cout << "Animal release"<<endl;
}
virtual void speak()=0;
};
class Cat : public Animal{
public:
Cat(string name){
m_name = new string(name);
cout << "Cat construct"<<endl;
}
~Cat(){
if (m_name != nullptr){
delete m_name;
m_name = nullptr;
cout << "Cat release"<<endl;
}
}
virtual void speak(){
cout << *m_name << " Cat is speaking!" << endl;
}
string *m_name;
};
int main(){
Animal *animal = new Cat("Tom");
animal->speak();
delete animal;
}
// output
// Animal construct
// Cat construct
// Tom Cat is speaking!
// Animal release
产生原因,我们是用父类的指针去调用对象,所以释放父类指针的时候,不会调用子类的析构函数。所以导致子类如果有堆区属性,则会出现内存泄漏。
解决办法,利用虚析构:
// 虚析构
virtual ~Animal(){
cout << "Animal release"<<endl;
}
或者,利用纯虚析构:
class Animal{
public:
Animal(){
cout << "Animal construct"<<endl;
}
virtual ~Animal()=0
virtual void speak()=0;
};
Animal::~Animal(){
cout << "Animal release" << endl;
}
- 注意,纯虚析构需要声明也需要实现
- 有了纯虚构之后,这个类也属于抽象类,无法实例化对象。