2 c++编程-核心
重新系统学习c++语言,并将学习过程中的知识在这里抄录、总结、沉淀。同时希望对刷到的朋友有所帮助,一起加油哦!
本章是继上篇 c++编程-基础 之后的 c++ 编程-核心。
生命就像一朵花,要拼尽全力绽放!死磕自个儿,身心愉悦!
1 程序内存分区模型
c++程序内存分区:
区域 | 存放内容 | 管理方式 |
代码区 | 存放代码的二进制代码 | 由操作系统进行管理 |
全局区 | 存放全局变量、静态变量、常量 | 由操作系统进行管理,该区域的数据在程序结束后由操作系统释放。 |
栈区 |
函数的参数、局部变量等 | 由编译器自动对内存分配和释放 |
堆区 | 利用new操作符开辟内存 | 由程序员分配和释放。若程序员不主动释放,程序结束由操作系统释放 |
四区存在意义:
不同区域存放的数据,赋予不同生命周期,使编程更灵活。
1.1 程序运行前
在程序编译后会生成可执行程序,在未执行该程序前,分为两个区域:
代码区:
存放CPU执行的机器指令
代码区有两个特点:
是共享的。目的是对于可能被多次执行的程序,只需要在内存中有一份代码即可。
是只读的。原因是防止程序被意外修改。
全局区:
存放全局变量、静态变量
还包含一些常量:字符串常量、const修饰的全局常量。(const 修饰的局部常量不在该区)
1.2 程序运行后
栈区 |
函数的参数、局部变量等 | 由编译器自动对内存分配和释放 |
堆区 | 利用new操作符开辟内存 | 由程序员分配和释放。若程序员不主动释放,程序结束由操作系统释放 |
注意事项:
不要返回局部变量的地址,因为栈区的数据由系统自动释放,使用了可能会出错。
栈区示例:
堆区示例:
1.3 new操作符
作用:在堆区开辟数据
特点:
- 只能在堆区开辟数据;
- 由程序员手动开辟,手动释放;
- 释放利用操作符delete;
语法:
new 数据类型
利用new创建的数据,返回的是数据类型的指针。
示例1:基本语法
示例2:new 开辟数组
2 c++引用
2.1 引用的基本使用
作用:给变量起别名。可以跟变量一样操作数据。
语法:数据类型& 别名 = 原名
示例:
2.2 引用注意事项
1、引用在定义时必须初始化。
例如 int& b;//是错误
2、引用一旦初始化后,就不可以更改,即不能修改为其他变量的别名。
示例:
2.3 引用做函数参数
作用:函数传参数时,可以利用引用的技术让形参修饰实参
优点:可以简化指针修改实参
示例:
总结:通过引用传参和按地址传参可以得到一样的效果,但是引用传参更简单方便。
2.4 引用做函数返回值
作用:引用可以作为函数的返回值
注意:
- 不要返回局部变量的引用
- 若函数返回的是引用,则函数调用可以作为左值
示例:
结果:
ref = 10
ref = 10
ref = 2000
ref = 2000
2.5 引用的本质
本质:引用的本质在c++内部实现是一个指针常量
总结:c++推荐用引用,因为引用的本质是指针常量,但比使用指针方便,而且所有的指针操作编译器都自动在内部转换做了。
2.6 常量引用
作用:若不想在函数内部改变传入实参的值,用常量引用来做函数参数,防止函数内部误操作。
语法:const 数据类型 形参名
在函数形参列表中,可以加const修饰形参,防止形参改变实参。
示例:
3 函数高级
3.1 函数默认参数
作用:函数的形参列表中形参可以有默认值,在函数调用时可传入实参或不传入。
语法:返回值类型 函数名(形参 = 默认值){}
注意事项:
- 如果参数列表某个位置有默认值,则从这个位置后的参数都必须有默认值
- 函数声明和函数实现只能有一个地方有默认值,不能同时出现默认值。
示例:
3.2 函数占位参数
函数列表中可以有占位参数,用来做占位,调用函数时比如传入该位置的参数。
语法:返回值类型 函数名(数据类型){ }
示例:
3.3 函数重载
3.3.1 函数重载概述
作用:函数名可以相同,提高复用性。
函数重载的条件:
- 在同一个作用域下;
- 函数名相同;
- 函数参数类型不同,或参数个数不同,或参数顺序不同。
注意:函数返回值不同不能函数重载。
示例:
3.3.2 函数重载注意事项
注意两点:
- 引用参数作为重载条件。有const和无const引用参数仍然可以重载,调用方式不同。
- 函数重载碰到默认参数,容易出错,尽量避免重载时使用默认参数。
示例:
4 类和对象
面向对象三大特性:
- 封装
- 继承
- 多态
c++认为万事万物皆为对象,每个对象都有属性和行为。
例如:
人可以作为对象,属性有姓名、年龄、身高、体重……,行为有走、跑、吃饭、睡觉……
车可以作为对象,属性有方向盘、轮胎、座椅……,行为有载人、放音乐、开空调……
具有相同性质的对象,可以抽象称为类,人属于人类,车属于车类。
4.1 封装
4.1.1 封装的意义
封装的意义:
- 将属性和行为作为一个整体,表现事物
- 将属性和行为加以权限控制
意义1:将属性和行为作为一个整体,表现事物
语法:
class 类名{
访问权限:
属性
行为
}
扩展:
- 类中的属性和行为,统一称为 成员
- 属性,又可称为成员属性,或成员变量
- 行为,又可称为成员函数,或成员方法
示例:
意义2:将属性和行为加以权限控制
访问权限有三种:
权限名 | 作用域 |
public 公共权限 | 类内可以访问,类外可以访问 |
protected 保护权限 | 类内可以访问,类外不可以访问。子类可以访问父类成员 |
private 私有权限 | 类内可以访问 类外不可以访问。子类不可以访问父类成员 |
示例:
4.1.2 struct和class区别
struct和class唯一的区别在于默认访问权限不同。
区别:
- struct 默认权限:公共
- class 默认权限:私有
示例:
4.1.3 成员属性设置为私有
优点:
- 将所有成员属性私有化,自己控制读写权限;
- 对于写权限,可以检测数据写入的有效性。
示例:
4.2 对象的初始化和清理
4.2.1 构造函数和析构函数
对象的初始化和清理是两个非常重要的安全问题:
- 一个对象或变量没有初始化,对其使用的后果是未知的;
- 使用完一个对象或变量,没有及时清理,也可能会造成安全问题。
c++利用构造函数和析构函数解决上述问题。
这两个函数在类使用过程中会被编译器自动调用,完成对象初始化和清理工作。
如果代码不提供构造和析构,编译器会提供默认的空的构造函数和析构函数并调用。
构造函数:主要用于创建对象时为成员属性赋值,构造函数由编译器自动调用,无须手动调用。
析构函数:主要用于对象销毁前系统自动调用,执行一些清理工作。
构造函数语法:类名(){ }
特点:
- 没有返回值,也不用写void;
- 函数名与类名相同;
- 构造函数也可以有参数,可以有重载;
- 程序在调用对象时,一定会自动调用调用一次构造;
析构函数语法:~类名(){ }
特点:
- 没有返回值,也不用写void;
- 函数名与类名相同,在函数名前有一个~
- 析构函数不可以有参数,不能重载;
- 程序在对象销毁前会,一定会自动调用一次析构函数
示例:
结果:
这是构造函数
这是析构函数
4.2.2 构造函数的分类和调用
示例:
4.2.3 拷贝构造函数调用时机
- 使用一个已创建的对象来初始化另一个新对象;
- 值传递的方式给函数参数传值;
- 以值方式返回局部对象;
示例:
4.2.4 构造函数调用规则
默认情况下,编译器至少给一个类默认添加三个函数:
- 无参构造函数。函数体为空
- 拷贝构造函数。对属性进行值拷贝
- 析构函数。函数体为空
构造函数调用规则如下:
- 如果用户定义了有参构造函数,编译器就不再提供默认无参构造函数,但会提供默认拷贝构造函数。
- 如果用户定义了拷贝构造函数,编译器就不再提供默认无参构造函数。
示例1:
示例2:
4.2.5 深拷贝和浅拷贝
浅拷贝:简单的赋值拷贝操作
深拷贝:在堆区重新申请空间,再拷贝操作
示例:
4.2.6 初始化列表
作用:用来初始化类的成员属性
语法:
构造函数名(): 属性1(值1), 属性2(值2)……{
}
示例:
4.2.7 类对象作为类成员
类的成员可以是另一个类的对象,我们称该成员为对象成员
例如:
class A{ }
class B{
A a;
}
注意:
当其他类作为本类成员时,构造时先构造对象成员,再构造本类。析构函数顺序相反。
示例:
结果:
Phone 构造函数
Person 构造函数
张三 的手机: 苹果
4.2.8 静态成员
静态成员:在成员变量或成员属性前加上static。
静态成员变量特点:
- 所有对象共享同一份内存
- 在编译阶段就已分配好内存
- 类内声明,类外需初始化
静态成员函数特点:
- 所有对象共享同一个函数
- 静态成员函数只能访问静态成员变量
静态成员变量和静态成员函数都有两种访问方式:
- 通过对象访问
- 通过类名访问
另外,静态成员变量和静态成员函数都有访问权限,private权限的在类外都无法访问。
示例1:
示例2:
4.3 对象模型和this指针
4.3.1 成员变量和成员函数分开存储
在c++中,类内的成员变量和成员函数是分开存储。
只有非静态成员变量才属于类的对象上。
示例:
4.3.2 this指针概念
问题:
4.3.1中我们知道成员函数不占对象内存,也就是说多个同类型的对象会公用一块代码。
那么问题来了,这一块代码怎么区分是哪个对象来调用自己的呢?
解决办法:
每个对象都有this指针,this指向被调用的成员函数所属对象。
this指针特点:
- 是隐含在每一个非静态成员函数内的一种指针。
- this指针不需要定义,是编译器提供。
this指针用途:
- 当形参和成员变量同名时,用this区分。如this->n=n; 不能写成n=n;
- 在类的非静态成员函数中返回对象本身,可使用 return *this;
思考问题:this指针为什么不能用在静态成员函数中呢?
原因:
静态成员函数,在没有实例化对象时就可以调用,不属于某个具体的对象,也就是说用this无法指向到具体的对象,所以不能用。非静态成员函数属于具体的对象,执行到函数时对象已经创建,所以可以用this,表示对象自己。
示例:
4.3.3 空指针访问成员函数
空指针也是可以调用成员函数的,但是也要注意有没有用到this指针。
如果用到this指针,需要加以判断保证代码的健壮性。
示例:
4.3.4 const修饰成员函数
常函数:
- 成员函数后加const,称为常函数;
- 常函数内不可以修改成员属性;
- 成员属性声明时加关键字mutable,在常函数内依然可以修改。
常对象:
- 声明对象时前加const,称为常对象;
- 常对象只能调用常函数;
- 常对象不可以修改成员属性;
- 成员属性声明时加关键字mutable,常对象可以修改。
示例:
4.4 友元
案例:
生活中你家里有客厅和卧室,客厅是所有客人都可以进去,但是你的卧室是私密的,只有你可以进去,然后一些特殊的人,比如你的好朋友好闺蜜好基友也可以进去。
在c++中,有些私有属性,也想让类外特殊的一些函数或者类可以访问,就需要用到友元。
友元的作用:让一个函数或类,访问另一个类中的私有成员。
友元关键字:friend
友元的三种实现:
- 全局函数做友元
- 类做友元
- 成员函数做友元
4.4.1 全局函数做友元
4.4.2 类做友元
4.4.3 成员函数做友元
4.5 运算符重载
运算符重载:对已有的运算符重新定义,赋予其另外一种能力,以适应不同数据类型。
4.5.1 加号运算符重载
作用:实现两个自定义数据类型的相加。
实现方式:
- 通过类的成员函数来实现;
- 通过全局函数来实现
编译器给加号运算符重载起了一个名称:operator+
示例:
4.5.2 左移运算符重载
作用:输出自定义数据类型
注意:
- 通常不会利用成员函数来重载<<元算符,因为无法实现cout在左侧;
- 只能通过全局函数重载<<
示例:
4.5.3 递增运算符重载
作用:通过重载运算符,实现自己的整型数据递增
需要实现前置递增(++i)、后置递增(i++)。
重载后置递增,通过int占位符来区分。
示例1:类的成员函数实现
示例2:全局函数实现前置递增(无法实现后置递增)
4.5.4 赋值运算符重载
c++编译器给一个类至少提供4个函数:
- 默认构造函数(无参,函数体为空);
- 默认析构函数(无参,函数体为空);
- 默认拷贝构造函数,对属性拷贝进行赋值;(浅拷贝,只拷贝值);
- 赋值运算符 operator= ,对属性进行值拷贝。(浅拷贝,只拷贝值);
如果类中有属性指向堆区,做赋值操作符时也会出现深浅拷贝的问题。
示例:
4.5.5 关系运算符重载
作用:比较两个自定义类型对象
示例:
4.5.6 函数调用运算符重载
- 函数调用运算符 () 也可以重载
- 由于重载后使用的方式非常像函数的调用,因此称为仿函数。
- 仿函数没有固定的写法,非常灵活。
示例:
4.6 继承
有些类与类之间存在特殊的关系,例如下图:
从上图可以看出,下级别的成员除了拥有上级的共性,还有自己的特性。这种情况下,可以使用继承,减少代码的重复。
4.6.1 继承的基本语法
作用:减少代码重复
语法:class 子类 : 继承方式 父类
例:class A : public B
子类 也称为 派生类
父类 也称为 基类
派生类中的成员,包含两大部分:
- 从基类继承过来的
- 自己增加的成员
从基类继承过来的表现为共性,自己增加的成员是自己的个性。
案例:
在很多网站中,都有公共的头部、底部、左侧的列表栏,只有中间内容部分不同。
接下来分别利用普通写法和继承写法来实现网页中的内容,看下继承的意义和好处。
普通实现:
继承实现:
两种实现方式达到的效果一样,测试代码完全相同。继承方式实现的代码没有重复。
4.6.2 继承方式
继承语法:class 子类 : 继承方式 父类
继承方式有三种:
- 公共继承
- 保护继承
- 私有继承
示例:
4.6.3 继承中的对象模型
问题:子类继承父类,子类从父类继承过来的成员哪些属于子类对象中?
结论:
- 父类中的所有非静态成员属性都会被子类继承;
- 父类中的私有成员属性,只是被编译器隐藏了,因此访问不到,但是还会被继承下去。
示例:
还可以通过开发人员命令提示工具来验证结论
查看命令:cl /d1 reportSingleClassLayout类名 文件名
该工具是vs自带的一个命令行工具,我用的是2022版本vs,工具位置如下:
打开后切换到需要查看类所在文件路劲:
然后输入命令:cl /d1 reportSingleClassLayoutSon "4.6.3 继承中的对象模型.cpp"
reportSingleClassLayoutSon 意思是:报告单个类的布局Son
(注意:cl中l是字母l,d1中的1是数字1。后面引号中的文件名在窗口中打几个字用tab补齐)
就可以看到父类、子类的属性,及子类的大小:
4.6.4 继承中构造和析构顺序
子类继承父类后,当创建子类对象,也对调用父类的构造函数。
继承中构造和析构的顺序:
- 调用时,先构造父类,再构造子类;
- 析构顺序与构造顺序相反。
示例:
4.6.5 继承同名成员处理方式
1、子类和父类同名成员属性调用方式:
- 调子类成员属性:直接调用即可。—— 子类名.属性
- 调父类成员属性:需要在成员前加父类名作为作用域。——子类名.父类名::属性
2、同名成员函数调用方式与成员属性一样。
注意:如果子类出现父类的同名函数,则子类的同名成员函数会将父类中同名成员函数全部隐藏掉(包括有参无参的,全部同名重载函数)。如果要访问父类中被隐藏调的同名成员函数只能加父类名称的作用域。
示例:
4.6.6 继承同名静态成员处理方式
继承中,同名静态成员属性和静态成员函数在子类对象上访问,与4.6.5章节非静态的一样。
- 访问子类同名成员,直接访问;
- 访问父类同名成员,需要带父类名称作为作用域访问。
4.6.7 多继承语法
允许一个类继承多个父类。
语法:class 子类名: 继承方式 父类名1, 继承方式 父类名2……
由于多个父类中可能出现同名成员,在使用时需要加作用域区分。
正因为这个问题,不建议使用多继承开发模式。
示例:
4.6.8 菱形继承
定义:
两个派生类继承一个基类
又有一个类继承了两个派生类
这种继承称为菱形继承
示意图:
典型的菱形继承案例:
菱形继承的问题:
1、羊继承了动物,骆驼继承了动物,当羊驼使用数据时会产生二义性。
解决方法:使用作用域来解决。
例如:动物有最大年龄属性,羊有自己的最大年龄,骆驼也有自己的最大年龄,那羊驼
使用最大年龄时,需要加上羊的作用域能访问到羊的最大年龄,加上骆驼的作用域
能访问到骆驼的最大年龄
2、羊驼继承自动物的数据继承了两份,但是羊驼只需要一份。
例如:羊驼的最大年龄只可能有一种,不可能有两种
解决方法:
利用虚继承来解决。
在继承的父类之前加上关键字 virtual ,变为虚继承。
继承的基类,称为 虚基类。
示例一:继承基类
示例二:继承虚基类
总结:
另外,通过开发者命令工具查看到以下结果:
在不使用虚基类时,所占内存小,但是继承了基类的两份数据。
使用虚基类时,所在内存大,通过继承虚基类指针的形式,只继承一份基类数据。
查看继承基类的结果:
查看继承虚基类的结果:
4.7 多态
4.7.1 多态基本概念
多态分为两类:
- 静态多态:函数重载、运算符重载
- 动态多态:派生类和虚函数实现运行时多态。
静态多态和动态多态区别:
- 静态多态的函数地址早绑定——编译阶段确定函数地址。
- 动态多态的函数地址晚绑定——运行阶段确定函数地址。
在理解动态多态之前,需要先了解如下几个概念:
虚函数概念:
在函数前面加上关键字 virtual,叫虚函数。
语法:virtual void func(){}
函数重写:
函数返回值类型、函数名、参数列表,都要完全相同,不是函数重载。
函数重写一般发生在子类继承父类,子类重写父类的函数。
在子类继承父类时,如果想让子类执行子类内部重写父类的函数,那么这个函数就不能早绑定地址,需要在运行阶段再绑定地址,地址晚绑定——即实现动态多态。
动态多态满足条件:
- 有继承关系
- 父类定义的是虚函数
- 子类要重写父类虚函数(重写的这个函数展现的是动态多态特性)
动态多态使用方法:
父类的指针或引用执行子类对象
注意:
- 父类定义的虚函数,子类可以不重写,只有子类重写的虚函数才展现动态多态特性。
- 对于未重写的虚函数,子类通过父类指针或引用仍然可以调用。
示例一:静态多态
输出:
动物在叫
动物在叫
分析:
实际上从test()函数体内代码可以看出,想要的结果是:传入cat就想要cat在叫,传入dog就想要dog在叫。因此该代码并不能实现我们的目的。这个时候就需要函数地址晚绑定。
示例二:动态多态
输出:
cat在叫
dog在叫
示例三:子类通过父类指针或引用调用未重写父类虚函数
输出:
动物在吃
动物在吃
4.7.2 多态原理
主要使用开发者命令工具来剖析。
1、静态多态分析:
用命令查看:
类中成员函数是不占内存的,所以以上两类相当于是空类,都只占1字节空间。
2、动态多态
用命令查看:
Animal类占4字节内存空间,原因是保存了一个vfptr来指向vftable。
vfptr-虚函数(表)指针
v-virtual
f-function
ptr-pointer
vftable-虚函数表
v-virtual
f-function
table-table
vftable表内记录基类虚函数的地址:&Animal::speak
派生类Cai类占4字节内存空间,原因是保存了一个vfptr来指向vftable。
但是,vftable表内记录的是自身类虚函数的地址:&Cat::speak
正是因为基类与派生类vftable表中记录的地址不同,才实现了动态多态。
再扩展下:
若基类有多个虚函数,仍然只占4字节内存,只保一个vfptr来指向vftable,vftable里记录了多个虚函数地址。
4.7.3 多态优点
- 代码组织结果清晰
- 代码可读性强
- 利于前期和后期代码扩展和维护
案例:分别利用普通写法和多态写法,实现两个数运算的计算器。
示例一:普通写法
示例二:多态写法
4.7.4 纯虚函数和抽象类
在多态中,通常父类中定义的虚函数是用不到的,主要调用的是派生类中重写的函数。
所以,可以将父类中的虚函数定义为纯虚函数。
纯虚函数语法: virtual 返回值类型 函数名(参数列表)= 0;
当类中有一个纯虚函数,该类也称为抽象类。
抽象类特点:
- 无法实例化
- 子类必须重写抽象类中的纯虚函数,否则也为抽象类
示例:
4.7.5 多态案例-制作饮品
制作饮品的流程分四步:煮水、冲泡、倒入杯中、加入辅料。
利用多态的技术,提供抽象的饮品制作基类,提供子类制作茶和咖啡。
4.7.6 虚析构和纯虚析构
问题:
在多态使用时,如果子类有开辟到堆区的数据,需要在子类析构函数中释放。当使用父类指针指向子类对象时,无法调用到子类的析构函数。
解决方法:将父类的析构函数定义为虚析构或纯虚析构。
父类虚析构和纯虚析构,共性:
- 都可以解决父类指针指向子类对象释放子类对象的问题
- 都需要写具体实现代码
父类虚析构和纯虚析构,区别:
如果是纯虚析构,该类也属于抽象类,无法实例化对象。
虚析构语法:
virtual ~类名(){ }
纯虚析构语法:
- 在父类定义纯虚析构: virtual ~类名() = 0;
- 在父类外定义纯虚构函数对应函数实现: 类名::~类名(){ }
示例:
4.7.6 多态案例-组装电脑
5 c++文件操作
通过文件将数据持久化。
头文件需要包括 fstream
文件类型包括两种:
- 文本文件:文件以文本的ASCII码形式存储
- 二进制文件:文件以二进制形式存储,用户一般无法读懂。01的形式。
操作文件的三大类:
- ofstream 写操作
- ifstream 读操作
- fstream 读写操作
5.1 文件文本
5.1.1 写文件
写文件步骤:
- 包含头文件: #include <fstream>
- 创建流对象: ofstream ofs;
- 打开文件: ofs.open("文件路劲", 打开方式);
- 写数据: ofs << "写入的数据";
- 关闭文件: ofs.close();
文件打开方式:
打开方式 | 解释 |
ios::in | 为读文件而打开文件 |
ios::out | 为写文件而打开文件 |
ios::ate | 从初始位置开始写文件。覆盖原文件内容 |
ios::app | 追加方式写文件 |
ios::trunc | 如果文件存在,先删除,再创建 |
ios::binary | 二进制方式 |
注意: 文件打开方式可以配合使用,利用 | 操作符
例如:用二进制方式写文件 ios::binary | ios::out
示例:
5.1.2 读文件
读文件和写文件步骤类似,但是读取方式较多。
读文件步骤:
- 包含头文件: #include <fstream>
- 创建流对象: ifstream ifs;
- 打开文件并判断文件是否打开成功: ifs.open("文件路径", 打开方式);
- 读数据: 四种方式读取
- 关闭文件: ifs.close();
示例:
5.2 二进制文件
以二进制方式对文件进行读写操作,与文本方式步骤一致,打开方式要指定为 ios::binary
5.2.1 写文件
主要利用流对象调用成员函数write来写二进制文件。
函数原型: ostream& write(const char * buffer, int len);
参数解释:字符指针buffer 指向内存中一段存储空间,len是读写的字节数。
示例:
5.2.2 读文件
利用流对象调用成员函数read读取二进制数据。
函数原型:istream& read(char * buffer, int len);
参数解释:字符指针指向内存中一段存储空间。len是读取的字节数。
示例: