《C++Primary》阅读简要总结

三月份的主要任务之一就是阅读C++Primary这本书,终于在昨天25号下午完成了基础部分的阅读,算是对基础知识整体梳理了一遍,开始看这本书大概可以追溯到去年12月份,在那之前看了C++的入门书籍《Essential C++》,另外也完成了侯捷老师的《面向对象程序设计上和下》、以及《C++源码剖析》这三个网课的学习。到今年三月份,算是工作九个月了,才把C++Primary这本书的基础部分看完。整体进度是没有达到我去年设想的预期的,但今年的话,每个月都会安排学习任务,相信会有持续性的进步可以看到。

C++Primary这本书的话,总体上知识点非常细,会针对每种工程中遇到的情况做介绍和解释,基础部分主要学习如何正确高效地使用C++,可以概括为两大部分:一是C++语法基础,二是STL的使用。绝大多数情况下,使用C++容器总是没错的。再往后的类设计者的工具这部分内容,感觉还是要有了一定代码量之后再读才能有所收获。就放到后续再安排吧。

1. C++语法基础

熟悉C++语法是写好C++代码的基石,我也不知道该从哪里说起了,这些内容绝大多数情况下,编译器就能检查出来,不外乎变量定义、写循环判断、写函数定义,外加一些限定词令其拥有特性。等等这些,不再详述。
C++基础部分的内容还是非常重要的,围绕class可以展开说很多东西

2. STL

STL六大部件:容器、算法、迭代器、分配器、适配器、仿函数。这几个组件相互配合,可以完成非常复杂的任务,在日常编码中,也是优先使用STL已有的东西,比如Vector会大量使用,所以熟悉STL非常重要。以一个一组图像数据送入程序进行图像分割为例,来简要说明一下各个部件的作用。

2.1 容器

这里面最常用的是Vector,程序中一般性的变长数组变量都会用这个来保存,如图像所在文件夹路径或者图像路径列表文件传参到main函数后,先对其进行解析,得到所有图像路径存入vector, 如图像数据送入推理框架如onnxruntime前,数据流以void*指到对应的内存位置,同时还需要给对应输入的维度数据,因为推理过程肯定是在三维或四维张量上进行的,维度数据肯定不能少,这些数据也可以存储到vector,到用的时候通过Vector.data()获取到内存地址。等等这些,反正就是如果你不知道该用哪种容器,那就用vector。
和vector非常相似的容器时array,但array初始化后size固定,相较于vector不够灵活,但这是相对的,如果在非常确定size的情况下或者不允许size更改的情况下,使用array越是一个好选择。
然后其他序列式容器如list(forward_list),deque,用的比较少。list的特性是插入删除指定元素非常简单O(1),不需要移动其他数据的位置。deque在对头和队尾操作数据方便,此外在线程池设计方案中,我们一般会维护一个队列用于存储已提交的任务,在线程池中的worker空闲时将队头的任务抛给worker执行,这里面还涉及到多线程的相关东西。
另外就是关联式容器了,主要包含set和map,底层主要以红黑树实现,另外还有一种使用哈希表实现的(unordered_map/set)。set只有键但map是键值对作为元素。一般map要用的比较多一些,用来存储用于查找的字典或者累计记录程序运行中产生的一些结果,如图像分割结果id映射到真实name就需要一个map。set就是一个集合。此外mutil_map和mutil_set还允许元素键重复,map和set则反之。

2.2 算法

算法部分主要包含很多常用的算法,常用如sort、find、for_each、replace(replace_if,replace_copy)、count(count_if),accumulate,binary_search。而且都是函数模板,所以可以适用于很多输入类型。总之容器和算法是单独设计的,再通过迭代器联通,即可用相应算法操作容器,底层其实是通过重载来调用不同函数进行处理的。但一般容器如果提供了对应的函数接口,则优先使用容器的function,要比算法模块提供的复杂度更低。在工作中刻意去使用这些算法,要比写循环要更高效。带_if后缀的算法要提供一个返回bool的可调用对象,作用与传入容器的每个元素或者用于两个元素对比。这里又涉及到可调用对象,其实就是可以用()运算符进行执行操作的对象:包括函数、函数指针和函数对象。

2.3 迭代器

迭代器总是和容器一起出现,而且也经常作为stl算法的参数,主要作用是用于定位容器元素位置,相当于是一种泛型指针,拥有指针的一切特性,可以++,--,也可以*解指针(我一般称为解指针,很多地方也叫做解引用、提领,底层其实引用也是用指针实现的,只是引用表现得更加优雅,不易出错,但指针更加灵活)。很少有机会去创建一个迭代器,会使用容器指针就可以了,如.begin(),.end(),.rbegin(),.rend(),还有const_begin()等等。如果创建的话,也是去维护一个指针,然后重载很多指针拥有的操作符以及上述几个函数方法。

2.4 适配器

对某一部件进行整合修改,即可获得新的部件,可称之为适配过程。STL提供的适配器分为三大类,第一类容器适配器,如stack和queue是deque的适配器,stack和queue禁用了deque的一些接口,使之表现得像另一种容器,总之就是扩充或修改原来的部件,使之特性满足特定的应用场景,同样的设计思路在设计模式之适配器adaptor中也是一样的。第二类迭代器适配器,顾名思义,对迭代器进行封装改造。第三类仿函数适配器或称为仿函数,这是STL另一个单独的部件了。

2.5 仿函数

仿函数本质是一种行为类似于函数的对象,即函数对象,对()运算符进行了重载,例如greater和less,eg.: not1(bind2nd(less<int>(), 40)), less(a,b)本身是一个函数(或仿函数),接受两个参数用于比大小。现在用bind2nd函数适配器将第二个参数绑定为40,这样在后续传值的时候只需要给一个参数。最外层再将内部函数修饰————取反,则最终获得的函数传入值判断该值是否大于等于40.
bind2nd是之前的版本了,目前C++11应该统一使用bind,这是一个非常重的函数,eg.: fun_new = std::bind(fun_old,_1,_2),其中_1和_2是fun_new函数传参顺序,在这个例子中,调用fun_new(a,b)等价于调用fun_old(a,b),也可以调换传参顺序,或者提前绑定确定参数。eg.:fun_new = std::bind(fun_old,_2,_1,666),假设原fun_old传参数量为3,现在第三个参数确定为int值666,然后调用fun_new(a,b),即_1为a,_2为b,实际上等价于调用fun_old(b,a,666);举个例子,这部分内容在创建线程池task时会用到。

2.6 分配器

准确说应该是内存分配器,C++中动态内存的管理是一个非常容易出错的地方,C时代我们使用malloc分配内存,用free释放内存。在C++中,我们有了新的选择,用new和delete分配和释放内存,相比于malloc和free,它可以自动调用class的构造函数和析构函数,使用起来更加便捷。但底层其实还是使用malloc,malloc再调用操作系统API申请内存,申请的内存除了存储数据的区域外,还有一些头尾附加空间来存储其他必要的信息(如cookie来记录内存大小,在释放该块内存的时候会从这里提取信息来正确释放对应的空间区域)。 以上说的是用new来申请内存,其实这里说的分配器指的是另一个称为allocator的东西,不同于new,allocator允许我们将分配和初始化分离,使用allocator通常会提供更好的性能和更灵活的内存管理能力。容器如vector管理内存就是用allocator来实现的,也可以通过int*p = allocator<int>().allocate(512, int*()0)来主动申请,但需要再通过alocator<int>().deallocate(p, 512)来释放。实际生产中,主动这样创建非常少用,也有可能是我还未寄出到这样的需求,直接使用容器又安全又高效。如果不希望内存泄漏,直接用容器是十分明知的选择。此外,不同C++编译器在底层的实现也是不尽相同的,但暴露出来的接口一致。
当然,很多情况下我们创建类会使用new,直接完成初始化工作比较方便,并在完成相应处理后delete对应对象空间,如果在delete之前的处理过程中程序抛出异常,导致提前退出当前作用域,则在此作用域内分配的动态内存(即堆内存)失去了指向此地址的指针,导致无法完成释放,这种异常时有发生则会导致进程占用的内存越来越多,而且不好排查。一个基本原则是谁分类谁释放,还有一种解决方案是智能指针。内存泄漏排查的话,vagrind使用过几次,后续计划写篇博客详细了解一下。

3. 总结

这本书算是告一段落,学到了很多比较细的知识,还需要实践过程中不断理解,方能编码得心应手。

posted @ 2022-03-26 12:11  Lee-zq  阅读(283)  评论(0编辑  收藏  举报