C++面向对象编程基础
C++面向对象编程基础知识
参考南软2024秋C++高级程序设计课件 CPP-2-1
为什么使用面向对象
不使用面向对象的隐患
- 安全隐患:结构体的访问控制权是public的,这意味着其中所有变量外部可以自由访问,而某些状态应当只通过相关的函数调用更新
- 不符合数据类型定义:public权限带来的衍生问题,对外暴露出来的数据结构(实现)可能并不满足数据类型的定义/要求,如栈用数组实现,但数组对外暴露了,可在任意位置更新、操作而非栈顶、压栈、出栈。
使用面向对象
- 对数据结构进行封装,各司其职,利于对象之间的协作和任务分解
- 信息隐藏,对外暴露必要的接口和功能,而非实现
面向对象的视角
结构化编程
程序 = 算法 + 数据结构
面向对象编程
程序 = 对象
对象 = 数据 + 行为(体现职责)
信息传递:函数调用
用类来管理
面向对象体系下的项目开发流程
需求,架构,构建模式,代码,测试用例,项目组织
评价标准:开发效率
封装
类
- 成员变量
- 成员函数
默认提供的函数
编译器会为类提供一些默认函数,只要你不显式地声明相关的函数
class Empty { |
共6个(除去常说的构造、析构、拷贝、赋值,还要注意会有两个版本的取地址操作符默认提供)
还要注意的是编译器只有在你用到相关函数的时候才会生成对应的默认版本(前提是没有显式声明),比如如果你不使用Empty去构造一个类就不会生成默认构造函数,其余函数同理
下面以gcc version 11.4.0 (Ubuntu 11.4.0-1ubuntu1~22.04),Target: x86_64-linux-gnu
的g++
编译器为例,编译指令为
$ g++ tmp.cc -S -o tmp.s -O0 |
比如不使用Empty构造类,则编译器没有生成对应的默认构造函数
class Empty{ |
.file "tmp.cc" |
反之生成了
class Empty{ |
.file "tmp.cc" |
模块
foo.cppm
export module M; // 导出为模块M |
foo2.cppm
export module K; // 导出为模块K |
bar.cpp
import M; // 依赖模块M |
clang
中的模块单元在 .cppm
编译后输出
- 可导入的
pcm
- 可链接的对象文件
根据上述依赖关系,编译顺序为K => M => …
构造函数
与java
一致,默认构造函数无参,支持重载
且
- 当类中不实现任何构造函数,编译器会提供默认构造函数(各种)
- 一旦定义任何一种构造函数,编译器不再提供默认(五三原则的背景),但可以手动
=default
构造函数既可以是public的也可以是private的;private的构造函数旨在类自己接管对象创建(如单例模式)
五三原则
事实上C++11为三原则(Rules of Three),C++17引入了移动的语义,因此扩展到了五原则(Rules of Five)
规定:(事实上是一种好的编程实践)若在类定义时重载了拷贝构造函数、拷贝赋值运算符、析构函数、移动构造函数、移动赋值运算符之一或之几,也要主动重载剩余的,即牵一发而动全身。
调用
创建对象时自动调用
示例
class Obj { |
注:形如Obj o1 = 1;
这种写法仅限于构造函数没有被 explicit
关键字修饰,因为这种写法实际上发生了隐式类型转换,自动触发了隐式构造函数调用,从整型1转成了对象。
成员初始化表
- 对构造函数的补充
- 会先于构造函数体被执行
- 建议按类成员的声明顺序进行初始化,可以减轻编译器的负担(在较新版的CLion里不按顺序会警告)
示例
class A { |
如果类中持有其他对象,则在构造函数执行时会逐级调用成员对象的构造函数(若在构造函数中初始化成员对象)
当成员变量不多时,建议使用成员初始化表代替赋值操作
使用成员初始化表的缘由
- const成员变量和引用成员变量必须在成员初始化表中初始化,而不能在构造函数体中进行赋值
- 派生类初始化基类也必须在成员初始化表中完成(
:
后面,{
前面) - 效率高
- 例外:成员变量过多,再使用成员初始化表会降低可读性和可维护性
析构函数
析构函数会在对象消亡时自动调用,一般需要程序员在其中主动释放对象持有的非栈内存资源(如指针指向的堆内存)
和构造函数一样可以是public也可以是private
私有析构函数
class A { |
当析构函数被private修饰,则不能通过形如A a;
的形式在栈上创建对象(私有构造函数应该也是同理),因为此时的析构函数为私有,外部无法访问到,编译器无法实现在对象生命周期结束时自动释放对象,因此会触发编译错误。因此只能通过A* a = new A;
形式创建。
同理,直接delete a
也是会触发编译错误的,同样是因为析构函数为私有,外部作用域访问非法。因此类内部必须对外实现一个public的接口,并在接口实现中主动调用delete
(类内部作用域可访问私有成员),防止内存泄漏。
一个更好的实践:
class A { |
GC vs RAII
众所周知Java使用一种叫做垃圾回收(Garbage Collection)的机制管理和释放资源,但这也导致了Java运行的效率瓶颈;而C++采用一种叫做RAII(Resource Acquisition Is Initialization)的机制让程序员自行实现资源的分配(构造函数中)与释放(析构函数中),再由编译器管理的构造与析构函数集中处理。
拷贝构造函数
创建对象时,传入同类对象对其进行初始化,往往用来实现对象深拷贝操作。
也是自动调用的(创建对象时自动匹配重载版本)
浅拷贝
不论变量的类型,只是对变量值进行一份拷贝,这对于值类型无可厚非,但对于指针变量,浅拷贝只会复制指针的值(即地址),而非其中的内容,这将导致多个指针指向同一块内存,一旦通过其中某个指针释放了内存(本意可能只是释放特定的指针),所有剩余的指针都将悬挂,造成悬挂指针隐患。
深拷贝
对于值类型变量仍是拷贝一份值存储,而对于指针类型变量,则会额外开辟一块新内存,并将旧指针指向地址中的数据拷贝到新内存中。
默认的拷贝构造函数
- 默认是浅拷贝的
- 逐个成员初始化
- 若存在成员对象,则递归地执行上述步骤,即对每个成员对象也调用其拷贝构造函数
什么时候需要实现拷贝构造函数
深拷贝防止浅拷贝引入的悬挂指针风险。
移动构造函数(C++17)
右值的概念
声明参数时使用&&
告知编译器这里要传一个右值参数,与左值不同,右值只有本身的值,不能被赋值。
示例
string::string(string &&s): p(s.p) { |
*注:*上例中的s
虽然在传参时是右值,但是传完参之后的s
是一个左值,存储的是一个右值,如果需要把它作为右值传给其他需要右值的函数,必须使用std::move()
获取右值。
void process(string &&s) { |
为什么需要移动构造函数?
- 性能提升:可以高效的转移资源,不同于深拷贝的逐字节复制,移动操作直接转移资源的所有权,以时间复杂度高效完成
- 内存管理:转移资源所有权避免了重复开辟新内存,避免了不必要的空间开销和内存浪费,简化资源的管理(不需要管理多份相同资源)
- 支持标准库容器的优化,比如
vector
的扩容 - 返回值优化——避免不必要的内存分配,当在函数中返回一个局部对象时,C++11引入的返回值优化(RVO)和移动语义(通过移动构造函数和移动赋值运算符)可以避免不必要的内存分配。
返回值优化示例:
MyString createString() { |
- 简化代码
动态内存
动态内存分配在堆上
C:
- malloc
- free
C++:
- new
- delete
动态对象
创建
在堆上通过new
创建,并通过delete
主动释放其内存。其中new
绑定constructor,delete
绑定destructor。
示例
class A { |
C++支持对基础类型也是用new(完全替代C的malloc
)来进行动态内存分配
int* ptr = (int*) malloc(sizeof(int)); |
int* ptr = new int; // 两种写法对于基本类型是等价的 |
*注:*无论存储的数据占多大的空间,指针本身的大小都是一样的(4B
)
C
风格的malloc不会调用构造函数(new
会),只是分配一块指定对象类型的空间,并不会进行初始化;同理free也不会调用析构函数(delete
会),只是释放指定对象被分配的空间,不会释放其进一步占用的资源。
删除
delete ptr;
需要注意的是,delete
只是释放ptr指向的内存被分配出去的空间,即这块空间不再是被分配的状态,不再属于ptr(不再是合法空间),而ptr指针本身的值没有发生改变,即仍指向这块不再属于自己的空间,成为悬挂指针。因此,一个安全的编程习惯是:delete
掉一个对象后把对应的指针置为nullptr
。
delete ptr1; |
动态对象数组
创建:A *p = new A[100];
删除:delete[] p; // []不可省
注意以下几点:
- p是一个指针(数组的首地址),p指向的数组空间的每个元素是具体的对象
A
, 不是指针 - 这种写法无法显式初始化,相应的类
A
必须提供默认(无参)构造函数,如果没有会触发编译错误 delete
中的[]
不能省
delete时[]不能省的原因
delete
期望释放分配出去的所有空间,但指针本身不记录数组的大小信息,编译器仅从delete
和ptr
无法知道应该释放多少内存。而数组空间在分配时会在指针指向的首地址前预留4B
的内容,其中存了数组的大小,而[]
会告诉编译器去找指针前4字节的内容,确认要释放多少内存。
二维数组分配时先分配行,再给每一行分配列;释放时先释放各行的每一列,再释放每一行(和分配反过来)
const成员
const成员属性的初始化在构造函数的成员初始化表中进行
注意const修饰的位置:
const A*
是指针指向空间的值不能变A* const
是指针本身的值不改变(常量指针)
const成员函数
函数在声明时被const修饰,代表该函数中不会改变任何变量的值,只读
class A { |
对于上述代码编译器如何检查被const修饰的成员函数的定义是否合法呢?编译器会对const修饰的函数中所有变量都带上const,这样若函数定义中尝试写一个变量,就会触发编译错误
继续上述代码,若定义了一个常量对象a
const A a(0, 0); // a是常量对象,其中的所有成员变量都会被编译器自动带上const |
若对象在声明时被const修饰,则这个对象中所有成员变量都会自动加上const(编译器来做,同时做后续对const的检查),因此a
将只能调用类中的const成员函数,因为编译器无法确定非const函数是否会”写“成员变量,但const成员函数一定不会,因此编译器只允许调用const函数确保const的变量不被写,而非const的调用会触发编译错误
// a.f(); 编译错误 |
静态成员
既可以通过对象使用,也可以直接通过类名作用域来使用
A a; |
C++支持观点:类也是对象
静态成员变量
所有类的示例共享同一个静态成员变量。
为什么要引入静态成员——同一个类的不同对象如何共享变量?
- 如果使用全局变量,缺乏数据保护
- 且会造成名污染
class A { |
- 类对象共享
- 唯一拷贝
- 遵循类访问控制(public,protected,private)
需要注意的是,静态成员的定义必须在外部不能在内部(const
除外),因为静态变量在编译器眼中类似全局常量,全局常量不能重定义(内部定义静态变量会有重定义问题,比如反复include多次),因此静态成员变量在类内声明,类外定义;但如果是static const
,还是要在内部定义(const
要求)。
静态成员函数
static
修饰函数
- 只能存取静态成员变量,调用静态成员函数
- 遵循类访问控制
友元
friend
关键字,使外在友元的定义中也能访问类私有成员。不使用友元就只能通过public访问成员或对private成员提供getter/setter,降低了对private成员的访问效率。
分类
- 友元函数
- 友元类
- 友元类成员函数
示例:
void func(); |
对上例的解释:
B
的前向声明可以省略,因为B
的作用域是全局,此处的friend
可帮助在全局声明friend
类似extern
(但并不是),因此func可以不必在之前先声明(类似全局函数的效果,friend
帮助其将作用域扩展到全局),因为friend void func();
相当于声明了,可以在之后定义- 但
C::f()
必须先在C中声明,不然编译器找不到C::f
的声明会编译错误(因为C::f
不是全局函数而是类的某个函数,friend
做不到像extern
那样扩展全局函数的声明范围) func
,B
,C::f
均能访问A的私有成员
友元不具有传递性,即A是B的友元,B是C的友元,但A不是C的友元
另一个示例:
class Vector; // 此处不加前向声明会触发编译错误,因为下面的代码存在循环依赖,谁在前面顶哟都会缺少对方的声明,因此前向声明打破循环依赖 |
(•‿•)