这篇是这段时间看的侯捷关于C++基础的课程《C++面向对象高级编程》的笔记, 课程内容大家自己找吧. 这个课程主要是我用来C++回顾和拾遗的,其中很多内容都来自他其它的课程,并且有很多是《EffectiveC++》的内容,在看了在看了。
这里直接就是我当时记录的全部笔记了,有点乱,自己也不太满意。全文4.0k字, 难度不高内容也不长. 本文同步存于我的Github仓库,(https://github.com/ZFhuang/Study-Notes/tree/main/Content/C%2B%2B%E7%9B%B8%E5%85%B3/C%2B%2B%E9%9D%A2%E5%90%91%E5%AF%B9%E8%B1%A1%E9%AB%98%E7%BA%A7%E7%BC%96%E7%A8%8B)
C++面向对象高级编程(上)
C和C++的核心区别
- C++以面向对象设计为主, 面向对象是将数据与函数封装在一起, C是面向过程的, 将数据和函数分离实现, 数据通常是全局的.
- 而且还有面向对象和基于对象的区别, 面向对象面对多重classes的交互设计, 基于对象之面对单一class的设计
- C++才有重载
类相关
- 函数如果在类体中定义, 那么就会自动成为inline候选
- 构造函数后面的变量构造部分称为初始化列, 这部分处于函数体内的赋值阶段之前, 称为初始化阶段, 对元素的构造效率更高
- const函数, 在函数声明后面加上const后, 无法修改当前对象的成员, 且只能调用const成员函数. mutable类型可以例外.
- 传参尽量传引用, 且传引用的时候尽量写为const引用, 速度接近指针. 内置类型无须传引用, 但也可以. 对于会被改动的参数要小心
- 返回也尽量返回引用, 千万记得不能返回局部变量. 返回引用是为了方便接收者按照任意喜欢的形式处理返回值.
- friend是private的所有者声明的, 声明允许访问private的另一个类/函数. 也正是如此, 需要在文件前部分进行前置声明
- 构造函数没有返回值, 但其隐含了返回值, 就是当前的类型, 实际上返回的是this指针
- 相同class的各个对象互为friend
- 所有成员函数都隐含了第一个参数: this指针
- typename()是临时对象, 切记不可返引用
- 反引用还为了重载运算符的时候方便进行嵌套写法
- C++的操作符都作用于左侧, 因此重载必须针对左侧的类型.
关键的构造和析构
- 普通构造函数, 拷贝构造函数, 拷贝赋值函数
- 普通构造和拷贝构造没什么好说的, 主要是拷贝赋值. 首先要判断是否会出现自我赋值, 然后先delete自己的指针, 再深拷贝对方的指针:
- 如果不检测自我赋值的话, delete自己指针的时候有可能把对方指针内容也删掉, 很危险
系统堆栈
- 直接初始化的都是栈对象, 作用域结束的时候就会被清理, 也称为自动对象
- 函数中定义的静态对象在函数作用域结束后依然存在, 直到程序结束
- 全局域定义的称为全局对象, 也属于一种静态对象, 同样到程序结束才结束
- 静态成员必须类外定义, 不赋初值时静态成员为0
- 借助new(malloc)初始化的是堆对象, 生命直到delete才会结束, 如果指针作用域时还没有被delete, 那么就会发生内存泄漏. 堆上的分配称为动态分配.
- new的原理是先计算目标大小, 然后申请空间返回void*, 再强制转型为目标类型, 最后调用构造函数
- delete的原理是先调用析构函数, 然后再回收内存. 只写delete的话只会调用一次析构函数, 如果指针是数组的话一定要写delete[]才会进行多次析构. 同样的new[]才会进行多次构造.
- 动态分配普通内存, 灰色部分是debug模式才有的调试信息, 青色部分是padding, 因为VC每块分配的内存都是16字节对齐的, 红色是标记了这段内存块整体大小的cookie, 其中cookie最后一位是1代表这块内存被分配了, 0代表可用.
- 动态分配数组内存, 布局和上面差不多, 但是数组部分前面多了一个白色的整数标识了数组元素的数量
继承, 复合, 委托
- 继承代表is-a的关系, 功能来自父类
- 复合代表has-a的关系, 功能来自类内包装的另一个对象
- 委托, 利用指针实现, 功能来自类内一个指向另一个类的指针
- 构造的时候都是从小到大(从父类到子类), 析构则相反, 内存分配也是大套小的
设计模式
- 普通单例: 静态成员在类内, 只要一写出这个类就会构造
- Meyers单例: 核心的静态成员放在函数体内, 这样在主动调用这个类之前都不会进行构造
- 模板方法: 是继承和虚函数的一种典型应用. 父类写好一套算法流程, 但是其中调用的都是虚函数, 子类实现父类的函数后, 调用父类的算法流程, 父类由于本质上使用的是子类this指针, 因此会自动在流程中调用子类的实现.
- 观察者: 委托和继承的结合. 主体委托了多个观察者, 观察者是派生关系因此可以用基类来委托. 主体发生改变的时候, 调用某个函数将自己传给所需的观察者, 观察者被唤醒于时开始处理.
- 组件: 整个结构以多个不同派生但是基类相同的对象组成, 由于大家基类都相同所以可以互相嵌套
- 原型: 构造函数私有, 对外接口是clone, 通过clone某个委托了的原型对象来复制创建其它继承后的类. 主要用于从头构造代价大于拷贝构造的情况下.
C++面向对象高级编程(下)
转型函数
- 转型函数一般写作
operator TYPE() const {...}. 没有返回类型因为函数名就是返回类型, 而且一般会加const因为一般转型不会改变对象内容. - 有一种特殊的转型就是隐式构造, 指构造函数参数只有一个且恰好就是当前需要被转型的类型. 此时只要处理后的返回值能符合需求就会发生隐式构造.
- 可以通过在构造函数前面加上
explict使得这个构造函数只能进行显式构造. 这个关键字比较少见, 几乎只会出现在构造函数的前面
指针与引用
- 类中重载用于指针的运算符->时, 注意应该返回一个指针, 因为这个运算符比较特别, 编译器会将其重复展开直到获得实际内容为止
- 引用是一种别名, 本质是指针但是被完全包装为了原本对象的样子
- 引用必须在声明的时候初始化
- 引用不允许后期改变值, 一旦定义就无法修改指向
- 引用和range-for很搭, 可用来轻松修改容器的元素
- 引用最常用于函数参数上, 作为一种漂亮的指针.
模板
- 全特化的模板记得要去掉所有模板参数, 改写为template<>
- 模板模板参数: 指模板参数里面是一个模板, 在这种情况下可以让另一个模板类作为参数导入, 只要保证可控的其它模板参数都能正确填满即可.
- 模板参数的标注类型可以用class也可以用typename, 建议使用typename防止歧义
C++对象模型
- 不管是复合类还是继承类, 都是从内到外构造, 从外到内析构的.
- 编译器默认在构造函数初始化阶段调用父类的默认构造函数, 然后在析构函数的最后一行调用父类的析构函数. 我们也可以自定义需要调用的构造和析构
- 两者兼得的时候, 一般先构造继承, 然后构造复合, 最后构造子类本身. 析构反之. 但是要注意这个特性是编译器自己决定的, 不一定是这个安排
- 只要某个对象有虚函数, 那么其内存中就有一个虚指针在结构的顶端, 但是指向的虚函数表是一个类一份的.
- 因此我们说多态继承的时候是继承了函数的调用权而不是函数本身的空间
- 虚指针指向虚函数表, 虚函数表按照目标类中函数的声明顺序对函数地址指针进行排列, 函数指针指向代码段中的函数代码位置
- 对象的函数调用分为静态绑定和动态绑定两种, 静态绑定是指非虚函数和不满足多态条件的虚函数调用, 在静态绑定中的情况下, 编译后的代码实际上直接调用call跳转到真正函数的地址进行执行
- 动态函数绑定需要满足三个条件: 通过指针进行调用, 指针是从子类上转型到父类的, 调用目标是虚函数. 当满足动态绑定的条件时, 编译后的代码会自动用对象顶部的虚函数指针转到虚函数表查询, 计算出符合条件的虚函数的地址后再
call. 这个过程需要多出好多步的寄存器计算, 因此动态绑定运行起来比静态绑定慢 - 之所以要用指针来进行多态本质是因为我们无法管理大小不一的容器, 但是管理指针很方便
- 每当调用一个对象的函数时, 编译器会隐式传入一个
this指针. this指针本质上是指向当前调用函数的这个对象地址的指针 - 因为隐式传入的是指针, 因此可以通过让指针调用虚函数来实现模板设计模式
const
- const默认是作用在左边目标上的, 但是当左边没有元素时const也会作用到右边目标. 这本质上是受到C编译器从左开始扫描处理的实现原理影响.
- const具体分为const函数和const对象. const对象不能被修改, 而const函数保证不修改函数内的值. 我们应该将其理解为一种程序内的协议来看上面的图, 保证了元素不被改变的对象不能调用non-const函数
- 因此为了最大化使用范围, 我们应该尽可能编写const型函数, 防止看似无伤大雅的const对象无法调用所需函数
- 函数是否const是可以区分语义的, 也就是属于一种override. 程序区分的方法是当两个版本同时存在的时候, 对象只会调用与自己对应的版本.
- 由于const这个分类调用的特性, 我们可以对一些共享数据型的对象(例如string底层对字符串本身是共享储存的)进行优化, 当对象是const时, 无须考虑底层是否是共享的问题, 直接返回一个底层的拷贝值即可. 但是当对象不是const时, 访问数据的时候就需要拷贝一份然后返回引用以供外部安全修改了.
new和delete
- new和delete本身是表达式, 其本身的行为是无法修改的:
- new: 先调用operator new函数, 其内部通常包装了malloc函数申请内存, 返回void*. 然后调用构造函数在内存上填写所需的信息, 最后返回强制转型的目标类型指针
- delete: 先在目标内存上调用析构函数, 然后调用operator delete函数, 内部一般封装了free函数将指针内存进行释放
- 我们可以重载的是operator new和operator delete函数, 且不但可以重载类成员的, 还可以重载全局的. 对这两个函数进行重载一般都是为了自己维护内存, 进行例如内存池的特殊设计
- new的签名是
void* operator new(size_t), 参数是需要申请的内存的大小, 这个参数的值是由编译器自动填入的. delete的签名是void operator delete(void* ptr), 自然就是对应内存的指针 - 还有一类是数组型分配new[]和delete[], 其行为有些许变化:
- new[]: 先调用operator new[]函数, 此时参数是符合数组的
sizeof(TYPE)*N+4, 这里的4是为了标识数组内对象的数量, 否则以后就无法正确析构了. 申请完空间后会自动调用多次构造函数最后返回所需的指针 - delete[]: 自动多次析构最后, 最后调用operator delete[]函数
- 虽然没什么必要但是我们可以使用::new或::delete来强制调用全局版本的函数
- operator new和operator delete也可以重载, 其重载称为placement arguments版本, 也就是给他们加上额外的参数列, 参数在使用new的时候传入, 可以进行不太一样的自定义操作
- 但是这里要注意placement new可以自由使用, 但是placement delete无法主动调用, 它只在new产生异常的时候, 编译器自动进行对应版本的调用(没有匹配版本则使用默认版本), 且从placement new中提取输入的参数交给placement delete, 其目的仅仅是让程序员可以在异常发生的时候也正确归还那些构造到一半的内存.