C语言之巅,带你登顶并发、面向对象,C语言顶级功能
原书:Extreme C Taking You To The Limit In Concurrency, OOP, And The Most Advanced Capabilities Of C(2019出版) 作者:Kamran Amini 译者:冰颖机器人

内容提要:本质特征,从源码到二进制,对象文件,处理内存结构,堆栈,面向对象和封装,组合与聚合,继承与多态,C++中抽象与面向对象,Unix历史与架构,系统调用与内核,最新C语言,并发,同步,执行线程,线程同步,执行过程,同步执行,单机IPC与Sockets,Socket编程,集成其他语言,单元测试与调试,构建系统
前言:当今,隔不了一段时间就有令人鼓舞的技术出现,豪华体验和快乐远超几十年前所能想象的。顶尖自动驾驶遍布大街小巷;高级物理学及其他科学分支改变我们感知现实,我们看到有关量子计算研究刚起步新闻,区块链技术和加密货币传闻,殖民星球计划。令人难以置信的是这些众多突破根源与少数核心技术。本书介绍的就是其中技术之一:c语言 我在读高一的时候开始C++编程,那是我加入一个2D青少年足球模拟队。自打那以后,我接触了Linux和c。我必须承认,那时后我并不太了解c与Unix的重要性,但之后通过各种项目中使用他们学到很多经验,随着学习,我渐渐看到他们至关重要。对于c知道越多,就越崇敬。最终,由于太感兴趣了,我决定要成为一个c语言编程专家。同时,我也要为传播这些知识做点贡献,使得更多人了解c语言重要性。这就是本书的初衷。 尽管有人错误相信c语言已过时,甚至从技术人员对其一无所知,然而从TIOBE 排名发现https:// www.tiobe.com/tiobe-index,并非如此。实际上,近15年来,c语言依然是最受欢迎的编程语言之一,和java一样流行。本书来自于我多年C、C++、Golang、Java、Python开发经验,涵盖BSD Unix版、Linux、微软Windows等各种平台。本书主要目的就是使得读者获取更多技巧技术水平更上一台阶。为了更进一步使用C语言,实践中使用那些来之不易的经验。这对我我们来说也非易事,故而将本书命名为C语言之巅。本书核心的就是这个过程,因而不会讨论比较其他编程语言。本书重在实用性,但仍然介绍实际应用中相关的重要核心理论。本书涵为读者准备了解决真实系统中所面临问题众多实例。 十分荣幸,能阐述如此重量级话题。有机会将内心深处的想法编书成册,欣喜之情无以言表。我十分感谢Andrew Waldron,是他鼓励我将此书作为我写作生涯处女作。。。 本书适合对c和c++了解不多的开发者,尤其是对于初级和中级c/c++工程师将提升知识经验中获益良多。希望读完本书之后,他们能变成高级工程师和升职。
本书内容提要 chapter1,重要特性:深刻影响使用c语言的那些特性,贯穿本书始终。主要包括预处理,宏命令定义,变量,函数指针,函数调用机制,结构体。 chapter2,编译和链接:如何构建c项目,编译详细过程。 chapter3,对象文件:查看c项目构建过程生成对象文件和各种类型,对象文件内部结构和可提取的信息。 chapter4,进程内存结构:进程内存布局,内存布局中段,静态动态内存布局。 chapter5,栈和堆:栈段,堆段,C语言生命周期中如何管理。 chapter6,面向对象编程和封装:c语言中面向对象,面向对象背后的原理。 chapter7,组合与聚合: chapter8,继承和多态:面向对象最重要的就是继承、多态,两类之间继承关系如何在c语言中实现。 chapter9,c++中抽象和面向对象编程:抽象数据类型如何用c语言实现,c++中面向对象概念内部实现。 chapet10,Unix历史和架构:C语言与Unix密切关系,学习Unix架构了解如何使用操作系统中功能函数。 chapter11,系统调用与内核:Unix架构内核环,详细系统调用,给Linux系统增加新调用。 chapter12,最新c语言:C18 C语言标准,与C11和C99差异。 chapter13,并发:并发环境各种属性,如交叉存取,再如条件竞争。 chapter14,同步:并行环境,条件竞争,数据竞争,死锁,信号量、互斥量、条件变量。 chapter15,线程执行:多项陈执行管理。 chapter16,线程同步:同步多线程,信号量,互斥量,条件变量。 chapter17,进程执行:创建或产生新进程,拉和推方式的多进程共享状态,并发。 chapter18,进程同步:多进程同步原理,共享进程信号量、互斥量、条件变量。 chapter19,单机进程间通信和套接字:基于推的进程间通信。 chapter20,套接字编程:各种类型套接字,Unix中套接字、TCP、UDP。 chapter21,与其他语言整合:C库、共享对象文件、加载C++、Java、Python、Golang程序。 chapter22,Unix测试和调试:CMocka和Google Test。 chapter23,构建系统:构建系统和构建脚本,Make,Ninjia,Bazel,Cmake。
代码下载:https://github.com/ PacktPublishing/Extreme-C
第一章:重要特性 预处理命令:#开头命令,编译器前将被替换成生成的C代码 宏:主要用于定义常量,函数,循环展开,代码生成,条件编译; 定义宏:#define,取消定义#undef #define ABC 5 #define ADD(a, b) a + b #define CODE printf("%d\n", i);(报错i未定义,因为现代c编译器可识别预处理前源码) $ clang example.c $ clang -E example.c(显示预处理转存代码) 利用宏定义DSL特定域语言 #define PRINT(a) printf("%d\n", a); #define LOOP(v, s, e) for (int v = s; v <= e; v++) { #define ENDLOOP } 使用时 LOOP(counter, 1, 10) PRINT(counter) ENDLOOP 宏参数:#(参数转换成字符串) 和##(将参数拼接到其他成员,通常用在变量名中) #define CMD(NAME) char NAME ## _cmd[256] = "";strcpy(NAME ##_cmd,#NAME); CMD(copy)变成char copy_cmd[256] = ""; strcpy(copy_cmd, "copy"); 可变参数宏:__VA_ARGS__ #define LOG_ERROR(format, ...) fprintf(stderr, format, __VA_ARGS__) 宏创建循环 #define LOOP_3(X, ...) printf("%s\n", #X); #define LOOP_2(X, ...) printf("%s\n", #X); LOOP_3(__VA_ARGS__) #define LOOP_1(X, ...) printf("%s\n", #X); LOOP_2(__VA_ARGS__) #define LOOP(...) LOOP_1(__VA_ARGS__) LOOP(copy paste cut) LOOP(copy, paste, cut) LOOP(copy, paste, cut, select) 宏优缺点:线性扁平难调试,如果可以写成c函数,就不要用宏 条件编译: • #ifdef • #ifndef • #else • #elif • #endif $ gcc -DCONDITION -E main.c(使用D选项传编译参数) #pragma once(避免重复包含) 变量指针:类似references(Java),asterisk ×,&引用 声明: int* ptr = 0; int * ptr = 0; int *ptr = 0; 空指针,指向无效内存地址 char* ptr = NULL;(一般避免使用0) char* ptr = nullptr;(C++11) 变量指针运算:指针本身大小都一样,然而指针算术步长各不相同 int* 4字节步长,char* 1字节步长 数组为指向数组第一个元素 通用指针:void*,算术步长未知,不能去引用,不能进行算术操作,一般作为传递形参 size_t 指针大小:硬件相关,依赖系统架构,sizeof获取,32位架构4字节,64位架构8字节 悬空指针:常常引起crash、segmentation fault 函数:阻塞函数,非阻塞函数(异步),函数设计重要性(内聚和复用) 栈管理:默认类型变量、数组、结构体,函数调用栈页包括返回值、参数、局部变量 传值与传引用:c语言只有传值,传指针也只是指针副本 函数指针:int (*func_ptr)(int, int); typedef int bool_t; typedef bool_t (*less_than_func_t)(int, int); 结构体:结构体在单个统一类型中封装相关值,_t 后缀表示 基本数据类型Primitive Data Types(PDTs) 用户自定义数据类型User-Defined Types (UDTs) 内存分布:结构体与数组相似,内存对齐 结构体嵌套:复合数据类型 typedef struct { int x; int y; } point_t; typedef struct { point_t center; int radius; } circle_t; typedef struct { point_t start; point_t end; } line_t; 结构体指针:
第二章:从源代码到二进制 标准C编译流程、预处理、编译器、汇编、链接 编译流程:预处理器、编译器、汇编器、链接器,前面三个生成可重定位对象文件,最后一步生成可执行对象文件,整个过程创建了对象文件,重定位、可执行和共享对象文件 平台:操作系统+特定硬件(架构)+CPU指令集,跨平台Cross-platform 本书运行平台gcc 7.3.0+Ubuntu 18.04+AMD 64-bit CPU 构建C工程:头文件(.h)对源文件(.c) 头文件:声明,枚举、宏、类型定义、函数声明、全局变量、结构体 源文件:定义, 构建:仅仅编译源文件,分别编译每个源文件(可以用命令传递多个源文件一起编译,但一般不推荐),头文件中需要编译的源代码会在包含其的源文件中编译 预处理:将头文件内容拷贝到源文件中,生成的代码称为翻译单元(编译单元,无预处理命令,#开头),可以用-E选项参数停止进一步编译(同样适用于clang) $ gcc -E ExtremeC_examples_chapter2_1.c 编译:生成汇编代码,可读目标机器依赖(可与构建架构不同),-S选项参数使得停留在编译阶段。.s文件 $ gcc -S ExtremeC_examples_chapter2_1.c $ cat ExtremeC_examples_chapter2_1.s 汇编:生成机器码,重定位对象文件(可作为中间对象文件,.o或.obj),二进制文件和对象文件都是在机器指令层,对象文件包含函数和全局变量预分配入口的机器层指令,as指令将.s文件生成.o文件,-o选项参数设定对象文件名字。另外,并非所有对象文件都是重定位对象文件,如共享对象文件 $ as ExtremeC_examples_chapter2_1.s -o ExtremeC_examples_chapter2_1.o -c选项参数直接完成以上三个步骤 $ gcc -c ExtremeC_examples_chapter2_1.c 编译有时候指前三个步骤,有时候指构建所有 链接:组合重定向文件创建可执行对象文件 支持新架构:汇编确定,已有厂商汇编工具,将机器码存储为ELF or Mach-O格式文件,C编译器,链接器 Unix类系统:ld默认链接 $ ld impl.o main.o(每个依赖都要指定,否者报错) $ gcc impl.o main.o 预处理器:解析命令,检查语法,拷贝内容,替换宏展开,语法树 gcc -E sample.c $ cpp ExtremeC_examples_chapter2_1.c(生成.i文件) $ clang -E ExtremeC_examples_chapter2_1.c > ex2_1.i 编译器:生成汇编指令,gcc负责生成特定目标架构汇编代码,第一步生成抽象语法s树(可重定位,独立于c的数据结构),第二步根据目标指令集生成汇编指令,编译前端到编译后端(as和ld命令用于创建平台相关的对象文件) 抽象语法树AST:根据c语法解析,将结果保存为树形数据结构,gcc和llvm都可实现 一旦生成抽象语法树,编译器后端开始优化AST,针对目标架构生成汇编代码。clang前端C编译器由llvm开发,llvm编译后端,LLVM IR抽象数据中间表示(llvm可以停留在IR阶段供学习) - FunctionDecl .表示main函数,后面由树入口或者节点 抽象语法树可以重排智力顺序以作优化 汇编器:生成包括正确机器层指令的对象文件,相同架构两个不同操作系统生成的对象文件也不相同,对象文件因操作系统而各部相同(虽然都包含相同的机器指令) 可执行链接格式ELF:可执行文件、对象文件、共享库都用此格式 链接器: c/c++工程产生项目文件:可执行文件.out(Unix类系统),.exe(windows),静态库文件.a(Unix类操作系统),lib(windows),动态库或共享对象文件.so(unix类系统).dylib(OS系统).dll(windows) 重定位对象文件作为临时性产物,只在链接时使用 可执行文件:可作为进程运行,main函数为c项目入口点,可执行对象文件入口点依赖与平台(并未main函数) 静态库文件:包括重定位对象的文件库,被链接到可执行文件中。 共享对象文件:更为复杂,运行时加载,可同时被不同进程调用 链接器执行过程:链接所有重定位对象文件,增加指定的静态库,创建最终的可执行对象文件 对象文件中机器指按区组织,或者说符号(说明如何链接和与其他对象文件联系一起) $ gcc -c ExtremeC_examples_chapter2_3.c -o target.o(生成对象文件) $ nm target.o(查看目标对象文件) $ readelf -s target.o(符号表,包含对象文件中定义的所有符号) $ objdump -d target.o(机器人代码反汇编) 链接器收集所有重定位文件符号,将其放到一个更大的对象文件中构成完整的可执行文件 编译使用函数的源代码,函数声明就够了,链接时提供定义即可 $ gcc -c ExtremeC_examples_chapter2_4_add.c -o add.o $ gcc -c ExtremeC_examples_chapter2_4_multiply.c -o multiply.o $ gcc -c ExtremeC_examples_chapter2_4_main.c -o main.o $ nm add.o $ nm multiply.o $ nm main.o $ gcc add.o multiply.o main.o $ gcc add.o main.o(出错) $ gcc main.o multiply.o(出错) $ gcc add.o multiply.o(出错) 骗过链接器:调用相同函数名不同参数数目的函数,编译通过,执行结果不正确,链接时只用了名字而没有参考含义的函数声明 %rbp寄存器 C++中采用名字重整避免以上情况发生 C++名字重整:对重载函数有不同的名字 $ g++ -c ExtremeC_examples_chapter2_6_add_1.o $ g++ -c ExtremeC_examples_chapter2_6_add_2.o $ readelf -s ExtremeC_examples_chapter2_6_add_1
第三章:对象文件 可重定位对象文件,执行对象文件,静态库,共享对象文件。可重定位对象文件为中间产物,作为部分组成其他最终对象文件。时至今日,对于c语讨论各种类型对象文件及其内部结果依然至关重要。主流c语言书籍仅介绍c语法和语言本书,然而作为成功的c程序员有必要了解更深。当你创建程序,不仅是开发和编程语言,而是整个过程:写代码、编译、优化,生成正确产品,更进一步在目标平台运行维护产品。了;了解中间步骤可拓展解决所遇到的问题,尤其是在嵌入式开发中,硬件架构和指令集颇具挑战。本章分为以下几部分:应用程序二进制接口,ABI;对象文件格式,ELF;重定位对象文件;可执行对象文件;静态库;动态库(共享对象文件);主要围绕Unix类系统。 应用二进制接口ABI: 任何库或者框架,不管采用什么技术和编程语言,api都展现确定功能集。也就是说,如果一个库想被其他代码使用,应该通过其提供的api,api提供路的公共接口,除此之外其他都是黑盒子。 一段时间后,库api修改了,如果要继续使用新版库,就需要修改代码适应新api,否者无法继续使用。当然,可以以继续使用老版本库而忽略新版本。这里,我们还是假设期望升级到最新版本库。ABI与api比较相似,只是所属不同层级。api保证功能操作兼容性,abi保证机器层指令兼容,也就是对象文件。 打个比方,一个程序不能使用不同abi的动态或者静态库。乃至,一个可执行文件(对象文件)不能在支持与所构建不同的abi系统中运行。一些重要显而易见的系统功能,如动态链接,加载可执行、函数调用惯例,需要完全一致的abi才能实现。一个ab包含以下:目标架构指令集(处理器指令、内存布局、端模式、寄存器等);存储数据类型、大小、对齐方式;函数调用惯例(描述函数如何被调用,如栈页结构,推参数顺序);定义系统调用;对象文件格式(重定位,可执行,共享对象文件);c++编译器生成模板文件,名字修改,虚拟表布局都是abi一部分。系统V abi是unix类操作系统(linux和bsd系统)中最为广泛abi标准,ELF可执行链接格式是系统V ABI中标准对象文件格式。 对象文件格式:对象文件有特定的格式(对象文件结构)存储机器层指令,需要注意的是,这不同于不同架构有不同指令集。对象文件格式和架构指令集是abi不同的两方面。ELF用于linux和其他unix类操作系统;Mach-O用于OS X系统(macOS,iOS),PE用于Windows系统。从历史演变角度来讲,不管是当下还是过去对象文件格式,可以说都是继承与老a.out对象文件格式(为早起unix版本设计)。a.out表示汇编输出,虽然这中文件格式已然过时,但这名字仍被大多数链接器作为默认可执行文件名。然而,a.out格式很快被COFF(通用对象文件格式)取代。COFF作为ELF基础,MachO或者PE文件格式俊基于COFF。ELF为操作系统标准二进制文件格式,包括但不限于Linux、FreeBSD、NetBSD、Solaris。也就是说,只要架构属于一类,ELF不过被其中哪个操作系统创建也可以在其他操作系统运行使用。 可重定位对象文件:c编译流程中,对象文件是在汇编过程生成的,这些文件是中间产物,作为生成最终c工程最终产品的主要部分。编译器翻译单元,为功能生成机器层指令,初始化声明的全局变量值,所定义的符号表和引用符号。根据对象文件格式被组织在一起,使用合适工具,可以从可重定位对象文件中提取出来。为很么叫可重定位对象文件,这是由于在链接过程中需要将多个可重定位文件放在一起构建更大的对象文件(可执行对象文件或者共享对象文件)。一个可重定位对象文件中机器层指令被放到其他重定位对象文件机器层指令,也就是说,指令应当容易移动或者可重定位。重定位对象文件中指令没有地址,只有到链接后才有地址,这也是为什么对象文件可重定位。 源代码1: int max(int a, int b) { return a > b ? a : b; } int max_3(int a, int b, int c) { int temp = max(a, b); return c > temp ? c : temp; } 源代码2: int max(int, int); int max_3(int, int, int); int a = 5; int b = 10; int main(int argc, char** argv) { int m1 = max(a, b); int m2 = max_3(5, 8, -1); return 0; } 生成ELF对象文件funcs.o和main.o:readelf查看,objdump命令,11个部分,.text包含多有机器层指令,.data和.bss区包含初始化全局变量,未初始化全局变量需要的字节数,.symtab部分包含符号表。只有相对地址,起始地址为0,没有绝对地址。 $ gcc -c ExtremeC_examples_chapter3_1_funcs.c -o funcs.o $ gcc -c ExtremeC_examples_chapter3_1.c -o main.o $ readelf -hSl funcs.o(查看) $ readelf -s funcs.o $ readelf -s main.o 可执行对象文件: 可执行对象文件为c工程最终产品之一,有着类似可重定位对象文件相同内容项:机器层指令、全局变量初始化值、符号表,但排列不同。为了展示这点我们可以研究ELF可执行对象文件(容易生成)内部结构。 ELF共享对象文件有很多额外段,每个段由一些扇区组成(0个或者更多),扇区根据内容植入段中。例如所有扇区包含相同机器层代码放到相同段中。 $ gcc funcs.o main.o -o ex3_1.out(生成) $ readelf -hSl ex3_1.out(查看扇区和段) Type:DYN(Shared object file),共享对象文件由特殊段INTERP(.interp区),加载程序运行可执行对象文件;TEXT段,包含所有机器层指令;DATA段包含初始化全局变量值和机器原始数据结构体;dynamic链接相关信息;ELF比可重定位共享对象有更多的加载和执行对象文件需要的数据。 可重定位对象文件没有任何绝对地址,这是由于扇区包含机器层指令还没被链接。从更深层角度来讲,链接多个可重定位对象文件实际上就是收集相似扇区并放到一起组成更多扇区。因此,在这步之后,符号完成并获得不变的地址。在可执行对象文件中,这地址是绝对的,而在共享对象文件中相对地址也是绝对的。 $ readelf -s ex3_1.out(查看符号表) 可执行对象文件中有两个不同的符号表:.dynsym(加载执行需要的符号),.symtab(已解析符号和动态符号表中未解析的符号)。在链接后,符号表中已解析的符号有对应的绝对地址。 静态库:可作为c项目产品。静态库由重定位对象文件组成的档案,通常链接到其他对象文件构成可执行对象文件。需要注意的是静态库并不被认为是一个对象文件,而是对象文件容器。换言之,不管在Linux系统中静态库并非ELF文件,或者并非macOS系统Mach-O文件。静态库仅仅是Unix ar工具创建的存档文件。当链接器链接静态库时首先抽取可重定位对象文件,然后从中查找解析未定义符号(其他对象文件声明而未定义的)。 在unix系统中,静态库根据广为接受约定命名,lib前缀,.a后缀名。对于其他操作系统是不同的,例如,windows系统,静态库以.lib扩展名。c工程源文件aa.c,bb.c,...一直到zz.c,为了生成可重定位对象文件,你需要使用命令行编译源文件。 $ gcc -c aa.c -o aa.o $ gcc -c bb.c -o bb.o ... $ gcc -c zz.c -o zz.o 如果工程很大包含成千个文件,编译可能就需要漫长时间。如果由算力强的构建机,一起并行运行编译,将显著减少构建时间。 $ ar crs libexample.a aa.o bb.o ... zz.o(创建静态库,crs选项参数,ar为通用工具,并非只为了新环境压缩档案文件,你可以用它将任何类型文件放到一起创建属于你自己的档案) 如果没有main函数,我们不能链接对象文件创建可执行文件。因此可以保存为可重定位对象文件,打包静态库,或者创建共享对象文件。 $ gcc -c ExtremeC_examples_chapter3_2_trigon.c -o trigon.o $ gcc -c ExtremeC_examples_chapter3_2_2d.c -o 2d.o $ gcc -c ExtremeC_examples_chapter3_2_3d.c -o 3d.o $ ar crs libgeometry.a trigon.o 2d.o 3d.o(打包成静态库) $ mkdir -p /opt/geometry(创建目录) $ mv libgeometry.a /opt/geometry(移动到目录,方便任何项目查找) $ ar t /opt/geometry/libgeometry.a(查看内容) 使用静态库,需要知道声明的公共接口,编译阶段需要知道存在的类型,函数声明等(在头文件中声明)。链接和加载阶段需要知道类型大小,函数地址。 $ gcc -c ExtremeC_examples_chapter3_3.c -o main.o(编译可重定位对象文) $ gcc main.o -L/opt/geometry -lgeometry -lm -o ex3_3.out(链接并创建可执行文件,-L/opt/geometry告诉gcc静态库动态库目录,/usr/lib和/usr/local/lib链接器默认查找的目录,-lgeometry告诉gcc查找libgeometry.a或者libgeometry.so,如果未找到则链接器终止并生成错误,-lm告诉gcc查找另一个名为libm.a或libm.so库,数学函数glibc,-o ex3_3.out告诉gcc输出可执行文件命名为ex3_3.out) 需要注意的是,在链接后不存在任何静态库依赖,所有东西都嵌入到可执行文件中,也就是运行可执行文件不需要静态库。静态库越大,里面的可重定位对象文件就越大,最终可执行文件也会很大。为了平衡二进制文件大小和依赖,用共享库可以获得更小的二进制文件。 动态库:共享库,.so(Unix类系统)或者.dylib扩展名(macOS),另一种复用库。顾名思义,动态库并不是最终可行性文件的一部分,相反,当加载执行进程时动态库被加载。动态库允许由未解析的符号在链接阶段存在(这些符号将在执行加载是搜索,动态链接器或者加载器完成)。 当加载一个进程并即将启动时,共享对象文件将被加载并映射到内存区域中进程访问。这个过程由动态链接器或者加载器实现(加载执行可执行文件)。ELF可执行和ELF共享对象文件都有段(可执行对象文件区)在ELF结构中。每个段有0个或者多个区,两者主要不同在于符号相对绝对地址。固定相对偏移,在不同进程每个指令地址不同,但两个指令地址相对距离是保存固定。这是由于可重定位文件位置无关。另一个不同之处就是有关加载一个elf可执行对象文件段在共享对象文件中不存在,也就是共享对象文件不能被单独运行。 $ gcc -c ExtremeC_examples_chapter3_2_2d.c -fPIC -o 2d.o(-fPIC共享对象文件强制选项参数,位位子独立代码) $ gcc -c ExtremeC_examples_chapter3_2_3d.c -fPIC -o 3d.o $ gcc -c ExtremeC_examples_chapter3_2_trigon.c -fPIC -o trigon.o 不同进程中动态库加载地址并不相同,加载器创建共享对象文件内存映射。如果指令地址是绝对的,那么共享对象文件就不能同时被不同进程加载在不同内存区域。 ld链接生成可重定位对象文件,然而前面章节解释不推荐使用(需要指明所有的可重定位对象文件,否者会出现未定义错误)。 $ gcc -shared 2d.o 3d.o trigon.o -o libgeometry.so(-shared选项参数要求gcc创建共享对象文件) $ mkdir -p /opt/geometry(创建目录) $ mv libgeometry.so /opt/geometry(移动库至目录便于查找) $ rm -fv /opt/geometry/libgeometry.a(移除静态库) $ gcc -c ExtremeC_examples_chapter3_3.c -o main.o $ gcc main.o -L/opt/geometry-lgeometry -lm -o ex3_3.out(如果存同时存在静态库动态库,gcc优先链接动态库) $ ./ex3_3.out(动态依赖,库不存在报错,需要将目录添加至环境变量) $ export LD_LIBRARY_PATH=/opt/geometry(导出设置环境变量) $ ./ex3_3.out(运行ok) $ LD_LIBRARY_PATH=/opt/geometry ./ex3_3.out(设置环境变量和同时执行) 手动加载动态库:没有被动态链接器自动加载。 $ gcc -shared 2d.o 3d.o trigon.o -lm -o libgeometry.so(偷懒的方式加载共享对象文件,无需链接步骤,-lm告诉链接器链接共享对象文件而标准数学库libm.so) dlopen和dlsym加载共享对象文件并找到符号。 polar_pos_2d_t (*func_ptr)(cartesian_pos_2d_t*);(声明) void* handle = dlopen ("/opt/geometry/libgeometry.so", RTLD_LAZY);(打开) func_ptr = dlsym(handle, "convert_to_2d_polar_pos");(查找) $ gcc ExtremeC_examples_chapter3_4.c -ldl -o ex3_4.out(无需链接自定义库了,前面手动加载) $ ./ex3_4.out
第四章:进程内存结构 对于c程序员来说,内存管理至关重要,需要对内存结构基本了解才能更好实践。实际上,这不限于c,如对于c++或者java来说,也是需要基本了解的内存和其工作方式,否者将面临无法跟踪和修复的严重问题。或许你已经知道c语言中内存管理均是手动完成,程序员唯一职责就是分配内存区域和不需要时释放已分配内存。 高级编程语言内存管理不尽相同,如java、C#部分由程序员管理,另外部分由底层语言平台管理。如java虚拟机,程序员申请内存分配,但不需要负责析构。垃圾回收器组件完成析构,自动释放分配的内存。 由于C、C++中没有垃圾回收器,专门给出章节探讨内存管理十分重要。本章是你对C/C++中内存是如何工作的有个基本理解。 本章中:从典型进程内存结构介绍开始(探索进程与内存交互方式),讨论静态动态内存布局,介绍前面提到的内存布局中的段(窥探可执行对象文件内部,进程加载是创建),介绍帮助我们检测段和其内容的探测工具和命令(对象文件内部和运行进程深处)。堆和栈段,进程动态内存布局,所有内存分配和释放都在这些段中,这也是程序员打交道最多的段。 进程内存布局:每当运行可执行文件,操作系统创建新的进程。一个进程激活状态,运行程序被加载到内存中,并有一个唯一的进程ID。操作系统主要的职责就是产生和加载新进程。一个进程保持运行状态知道正常退出或者进程收到退出信号(可忽略的信号SIGTERM结束b并清理、SIGINT中断信号Ctrl+C,强制立即杀死进程并不等待清理SIGKILL)。 当创建进程,操作系统干的第一件事就是根据预定义内存布局为进程分配专属内存区域。预定义内存布局不同操作系统之间或多或少有些相似,尤其是Unix系列操作系统。普通进程内存布局分为多个部分,每个部分称之为段,每个段是定义任务和存储特定类型数据的内存区域:未初始化数据段或以符号开始的块段BSS,数据段,文本段或者代码段,栈段,堆段。 探索内存结构:用于检查对象文件静态内存布局。| int main(int argc, char** argv) { return 0; } $ gcc ExtremeC_examples_chapter4_1.c -o ex4_1-linux.out(编译,生成包含静态内存布局的可执行对象文件) size工具用于打印可执行对象文件静态内存布局。 $ size ex4_1-linux.out(命令) 输出:text data bss dec hex filename 1099 544 8 1651 673 ex4_1-linux.out 静态布局中由Text、Data和BSS段。 在macOS系统中用clang编译,macOS属于POSIX兼容的操作系统,size命令为POSIX实用工具。 $ clang ExtremeC_examples_chapter4_1.c -o ex4_1-macos.out $ size ex4_1-macos.out 输出:__TEXT __DATA __OBJC others dec hex 4096 0 0 4294971392 4294975488 100002000 $ size -m ex4_1-macos.out Segment __PAGEZERO: 4294967296 Segment __TEXT: 4096 Section __text: 22 Section __unwind_info: 72 total 94 Segment __LINKEDIT: 4096 total 4294975488 macOS系统中,BSS段未显示,但实际上存在,因为它包含未初始化全局变量,由于需要多少个字节是知道的因此不需要将分配字节作为对象文件一部分。Text段、Data段大小不同,由于底层内存细节不同导致的。 BSS段:始于符号的块,从历史角度,这个名字用于表示未初始化保留区。基本上,也就是BSS段的用途所在,未初始化全局变量或全局变量置为0。 增加全局变量代码 int global_var1; int global_var2; int global_var3 = 0;(省略main,编译需补上) $ gcc ExtremeC_examples_chapter4_2.c -o ex4_2-linux.out $ size ex4_2-linux.out text data bss dec hex filename 1099 544 16 1659 67b ex4_2-linux.out(BSS大小已经变大) 这些特定的全局变量是静态内存布局的一部分,当进程加载时预分配,只要进程还在永不会被释放,即静态的生命周期。设计中我们通常优先使用局部变量,如果有太多的全局变量将增大二进制文件大小,另外将敏感数据放在全局区会产生安全问题,如并发,尤其是数据竞争,命名空间污染,未知所有权,太多变量在全局区,维护也是个问题。 macOS系统编译上述代码,仍然不会有BSS段,除了data段由0变成4KB(为data段分配新的内存页,页默认大小4KB)。_DATA区中由一个__common区,12字节,他就是引用未显示出来的BSS(3*4字节)。 DATA段:声明全局变量并初始非0值。Data段用于存储非0初始化全局变量 double global_var4 = 4.5; char global_var5 = 'A'; $ gcc ExtremeC_examples_chapter4_3.c -o ex4_3-linux.out $ size ex4_3-linux.out text data bss dec hex filename 1099 553 20 1672 688 ex4_3-linux.out(Data段变大9字节,double为8字节,char为1字节) macOS系统中编译,__DATA段中__data区,9字节。 size命令只显示段大小,并为显示内容。readelf或者objdump可显示ELF文件内容,可用于探测对象文件中静态内存布局。 在函数内部声明静态变量,每次调用时保留值。这些变量皆可以存储在Data段也可以存在BSS段,不管是否初始化,具体因平台而异。 linux系统objdump查看BSS段内容,macOS系统对应的是gobjdump。 $ gcc ExtremeC_examples_chapter4_4.c -o ex4_4.out $ objdump -s -j .data ex4_4.out(输出内容,地址+内容+内容ASCII码) a.out: file format elf64-x86-64 Contents of section .data: 内容中.字符表示这个字符不能用字母表中字符显示,-s选项告诉objdump显示指定区全部内容,-j .data告诉objdump显示.data区。端模式导致字节逆序,大段模式高字节在前,小段模式低字节在前。 macOS下编译,小段模式。linux中readelf检查对象文件内容,macOS对应的为dwarfdump。hexdump二进制内容显示。 $ gcc ExtremeC_examples_chapter4_4.c -o ex4_4.out $ gobjdump -s -j .data ex4_4.out Text段:文本段或代码段,包含程序中所有机器层指令,在可执行对象文件中可定位,作为静态内存布局一部分。当进程运行时这些指令被进程读取和执行。 linux中objdump命令转存生成可执行对象文件。 $ gcc ExtremeC_examples_chapter4_5.c -o ex4_5.out $ objdump -S ex4_5.out 机器层代码:.text,.init,.plt区 探测动态内存布局:动态内存布局是进程运行时内存,只要进程运行就一直存在。当你执行一个可执行对象文件时,程序调用加载器执行。产生新进程并动态创建初始化内存布局。为构建布局,静态布局段将从可执行对象文件中拷贝,除此之外,增加两个新的段,进程才可继续运行。总言之,我们希望运行进程内存布局中有5个段,3个段直接从可执行对象文件中的静态布局,另2个增加的段就是栈和堆段。这5个段都是动态的,仅当进程运行才存在,也就是说他们并不能在可执行对象文件任何一分部分中找到。进程的动态内存由这5个段共同组成。 栈段是分配变量的默认内存区域,大小有限,并不能分配很大的对象。相反,堆段是一个大且可调整的内存区域,用于容纳大的对象和大数组。和堆段打交道需要自己的API。 动态内存布局不同于动态内存分配,不应该混淆两个概念,他们本就是两个不同的东西。 进程动态内存的这5个段被主内存引用,分配专属与运行进程。除了静态常量的Text段,从字面上看他们的内容总是在运行时改变,这是由于这些段不断被进程中算法修改。 内存映射: #include <unistd.h>(只在Unix类系统或者POSIX系统中有,对于windows系统用windows.h替代) int main(int argc, char** argv) { while (1) {// Infinite loop sleep(1); // Sleep 1 second }; return 0; } $ gcc ExtremeC_examples_chapter4_6.c -o ex4_6.out(编译) $ ./ ex4_6.out &(运行) [1] 402(进程ID,PID,可以用kill -9 402 关闭进程) linux系统中,/proc目录可以找到进程信息。特殊文件系统,名为procfs(unix类系统都有,所有非unix类系统使用它,FreeBSD有,macOS没有),并非普通文件系统存储实体文件,但由跟多层次接口查询特定进程或者整个系统各种属性。进程内存由很多内存映射组成,每个内存映射表示特定内存区,映射到特定文件或者段,作为进程的一部分。简单点,在每个进程中栈和堆段都有自己的内存映射。 $ ls -l /proc/402(查看进程内容) $ cat /proc/402/maps(查看映射文件内容) 7f4ee16cb000-7f4ee188a000 r-xp 00000000 08:01 787362 x86_64-linux-gnu/libc-2.23.so(其中一条内容) 每行表示被分配的内存地址范围和映射到进程动态内存布局的特定文件或段。每个映射被1个或者多个空格分隔。 地址范围:可以找到文件路径在最前边,这是一种智能方式子啊不同进程中映射相同共享对象文件。 权限:表示内容是否可执行(x),读(r),写(w),共享(s),私有(p)。 偏移量:如果区域映射到文件,表示通文件开始的偏移量,非映射到文件该值为0. 设备:区域映射到文件,表示设备号(m:n),设备包含被映射的文件,如硬盘。 索引节点:区域映射文件,文件应当在文件系统上,文件系统节点号,这是抽象概念,如unix类系统的ext4,没个节点即可以表示文件也可以表示目录。每个索引号用于访问它的内容。 路径名或描述:区域映射文件,为文件路径名,否则为空或描述区目的,如[stack]表示栈段区。 栈段:栈在进程动态内存中起到关键作用,几乎存在与所有架构[stack]。栈和堆都时常改变的,不好查看他们内容,一般通过调试如gdb通过内存字节读取。前面提到,栈段通常用于有限大小对象,并不是个存储大对象的好地发。如果栈满了,进程就不能调用任何其他函数,因为函数调用严重依赖与栈段功能。如果栈满了,进程将被操作系统终结。栈满了导致著名的栈溢出错误。 栈段是分配临时变量默认的内存区域。每次分别都从栈顶开始,这是抽象概念,先进后出FILO或者先进先出LIFO。函数调用时,一个新的栈帧置于栈段顶部,这样才可以返回函数调用或结果给调用者。好的栈机制对程序至关重要,栈大小有限,只声明晓得变量,且栈不应当有太多栈帧,否者导致无限递归调用或者提案多函数调用。作为程序员,把数据和局部变量声明也考虑到算法中,而作为程序运行者,保存程序执行内部需要的数据。因此,你应当小心使用段,误用或者滥用可能导致陈翔运行中断甚至崩溃。 void func() { int a; } 调试器黏附在进程中可以查看栈段。 堆段: void* ptr = malloc(1024);(malloc函数分配内存) void指针不能之间去引用或者直接使用,一般转换成特定指针类型。 $ g++ ExtremeC_examples_chapter4_7.c -o ex4_7.out $ ./ex4_7.out & [1] 3451 Address: 0x1979001 $ cat /proc/3451/maps 01979000-0199a000 rw-p 00000000 00:00 0 [heap](0x21000字节或者132KB,分配的是1KB) 堆分配大小不实际的大,这是因为为了预防下一次再使用malloc加大内存分配,这种策略主要是因为从堆段分配成本较高(内存时间消耗大)。堆段可以大到超过132KB甚至GB,通常用于永久的全局的非常打的对象,如数组或者大的数据流。堆段分配或者释放需要调用c标准提供的特定函数。堆内存只能通过指针来访问,这也就是为什么指针对于c程序员来说必要。内存泄露对于程序来说是致命的,因为不断增加内存泄露最终导致占用整个内存空间,结束进程。可用free函数释放分配的内存。
第5章:堆和栈 程序员打交道最多的莫过于堆和栈段。像Data或者BSS段由编译器生成,且占整个进程内存很小比例。这不是说它们不重要,实际上跟堆栈问题息息相关。如果你花很多时间在堆栈上,绝大多数内存问题容易找到根源于这些栈。 栈:没有堆进程可以继续工作,但没有栈就不行。原因在于隐藏在函数调用机制中。就像前面解释那样,函数调用只有通过使用栈段,没有栈段,函数调用无从叹气,也就意味着无法执行。栈段及内容都是为进程正确执行精细设计的。因此,乱用栈可能破坏和终止进程。从栈段中分配是很快的,不需要任何特别的函数调用。不仅如此。释放和内存管理任务都是自动完成。这似乎在引诱鼓励过度使用栈。对此应该谨慎,栈使用有它自身的副作用。栈不是很大,不能存储大的对象。另外,不正确使用栈可能导致终止执行或崩溃。 char str[10]; strcpy(str,"akjsdhkhqiueryo34928739r27yeiwuyfiusdciuti7twe79ye");(strcpy拷贝内容大于栈分配的,导致崩溃) 如你看到的,高效写入前面变量内容和栈帧,在返回时程序跳转到错误指令地址。最终程序无法继续执行。 探测栈:栈段为私有内存,所属进程才有读改权限。如果想要读取栈或修改,那智能先成为栈所属进程的一部分。新工具集debuggers,调试器黏附在另一个进程并调该线程。调试进程任务通常观察和操作各种内存段,仅当调试时才可以读写私有内存块,另外一个就是控制程序执行执行顺序。gdb调试(GNU调试器)运行工程和读取栈内存。栈顶为分配变量和数组默认的地方,不能使用malloc或calloc,否者将分配到堆中。 #include <stdio.h> int main(int argc, char** argv) { char arr[4]; arr[0] = 'A'; arr[1] = 'B'; arr[2] = 'C'; arr[3] = 'D'; return 0; } 为了能调试工程,二进制必须构建为调试类型,即需要告诉编译器构建二进制需要包含调试符号。这些符号用于找到执行或引起崩溃的代码行。 $ gcc -g ExtremeC_examples_chapter5_1.c -o ex5_1_dbg.out(-g选项告诉编译器最终可执行对象文件必要包含调试信息,得到的二进制大小与没有调试信息时不同) $ gcc ExtremeC_examples_chapter2_10.c -o ex5_1.out $ ls -al ex5_1.out $ gcc -g ExtremeC_examples_chapter2_10.c -o ex5_1_dbg.ou $ ls -al ex5_1.out $ gdb ex5_1_dbg.out(gdb调试,gdb通过作为linux系统内建包安装,也可用brew install gdb来安装) gdb由一个命令行接口让你发送调试命令,r或者run命令执行对象文件,输入到调试器中。 (gdb) run(gdb启动进程,黏附该进程,项目指令执行和退出) gdb run并没有中断,因为我们没有设置断点。一个断点告诉gdb暂停程序执行等待命令。你可以设置很多断点,用b或break命令增加断点。 (gdb) break main (gdb) n(n或next继续执行到下一行) (gdb) print arr(打印变量) (gdb) x/4b arr(查看变量内存,arr指向4字节内容ASCII) 0x7fffffffcae0: 0x41 0x42 0x43 0x44 (gdb) x/8b arr(查看变量内存,arr指向8字节内容ASCII,后4字节包含调用main函数时最近顶部栈帧数据内容) 0x7fffffffcae0: 0x41 0x42 0x43 0x44 0xff 0x7f 0x00 0x00 栈段与其他段相比,填充内容是相反的。其他段从小地址开始,向高地址前向移动,而栈段相反从高地址开始向低地址后向移动。这样设计的背后原因在于现代计算机发展史,栈段功能如栈数据结构一样。从高地址读取栈段内容,可以高效读取已经入栈段的内容,如果改变这些字节,将改变栈,这是十分危险的。为什么可以看到比变量大小更多的内容,因为gdb查看指定数量字节内存,x命令并不关心变量的边界,只需要给出起始地址和打印字节数范围。 (gdb) set arr[1] = 'F'(set命令修改内存单元) (gdb) print arr 如果修改的内容地址非变量边界,它仍然可能越界。如果我们修改内容的地址远大于变量地址,将可能改变出栈的内容,注意,栈内存顺序与其他段行为相反。 (gdb) x/20x arr (gdb) set *(0x7fffffffcaed) = 0xff (gdb) x/20x arr (gdb) c(continue继续执行,如果修改到了致命的栈中字节,继续运行就可能程序崩溃,终止程序) 如前面所说,程序执行大多数致命程序都是与栈内存有关。因此,当写栈变量的时候应该十分小心,你不应该向超过变量边界写任何值,因为地址后向增长,可能覆盖掉已经写入的字节。 用q或quit命令退出gdb,将退出调试回到终端。另外需注意的是,给分配在栈顶部缓存(另一个字符数组或字节名)写入非检验的值也是危险的。一个攻击可以给程序写入精细设计的字节数组,并最终控制程序,这通常称为利用缓存一次攻击。 char str[10]; strcpy(str, argv[1]); printf("Hello %s!\n", str); 栈内存指针:每个栈都有自己的作用域(使用范围),它决定了栈变量的生命周期。栈变量由栈段特性,自动分配与释放内存,即自动内存管理。不管任何时候声明栈变量,都将自动在栈段顶部分配内存,这也是生命周期的开始,之后每个栈变量和其他栈帧置于其之上,并且只要这个变量存在,其他后面分配内存的变量将在其之上。当程序执行结束,变量被从栈中弹出来,自动释放内存,变量生命周期也就结束了。 int main(int argc, char** argv) { int a; return 0; }(main函数结束,a所占栈被释放,不同于全局变量,main退出时仍存在,全局变量分配在Data或BSS段) 函数返回局部变量地址,如果对接受这个变量地址进行访问的话将导致段错误,悬空指针。 int* get_integer() { int var = 10; return &var;(警告返回局部变量地址) } gcc中-Werror选项参数将所有警告当作错误,上面可用-Werror=return-local-addr防止编译通过。为了能在gdb中查看细节,编译时需要-g选项参数。 $ gcc -g ExtremeC_examples_chapter5_2.c -o ex5_2_dbg.out $ gdb ex5_2_dbg.out 需要注意的是,并行任务访问当前作用域变量,访问前其可能已经不存在了。 栈段:栈内存大小受限,不适合存储大对象;栈段地址后向增长,前向读取已近压入的字节;栈自动内存管理分配与释放;栈变量有自己的作用域和生命周期,设计时注意生命周期;指针指向栈变量需要在作用域内;当超出范围后栈变量内存将自动释放;指向当前作用域内变量,仅可以作为参数传给那些使用指针仍在当前作用域的函数。 堆:不管使用哪种语言编写代码,堆内存总以某种形式存在,这是由于堆相对于栈尤其独特的优势。当然它也有缺点,如分配堆内存区域速度相对慢些。使用时需要了解双面性,堆不能自动分配内存块,malloc或类似函数分配;堆可分配较大的内存区,堆越大,需要从系统中申请更多的堆页,分配地址从小到大;堆内存分配与释放都有编程者负责管理,有些编程语言由垃圾回收来管理,但c和c++中并没有;堆中分配变量没有作用域;内存管理困难;只能使用指针操作堆内存块,没有堆变量概念;堆段为所属线程私有,需要用调试器检测,由于c指针使用堆内存块与栈内存块一样,这是由于c做了很好的抽象,使用相同指针操作这两类内存,因此我们可以使用类似与检测栈的方法检查堆内存。 堆内存分配与释放:使用函数集或者API(C语言标准库内存分配函数)分配或者释放堆块。stdlib.h头文件中定义,malloc、calloc(clear and allocate,比malloc慢)、realloc(更慢)调整分配大小,free释放;有些教科书中,动态内存指得就是堆内存,动态内存分配也是堆内存分配代名词。c++中new和delete关键字分别与malloc和free对应。 char* ptr1 = (char*)malloc(10 * sizeof(char));(分配堆,不初始化,但在linux系统总是为0) (void*)&ptr1(变量地址) (void*)ptr1(变量所指的地址) free(ptr1); memset(ptr, 0, 16 * sizeof(char));// 用0填充 ptr = (char*)realloc(32 * sizeof(char));(不改变之前已分配块中数据,只是增加新的分配,为了避免碎片化,如果当前块大不够,将找一个足够容纳需要分配大小拷贝数据,并将前面已分配的块释放掉) 内存分析器对于探测运行进程内存问题十分有用,如内存泄露,最著名就是valgrind工具(运行程序将慢10到50倍),编译是需使用-g选项参数。LLVM Address Sanitizer(ASAN)内存错误检测工具,MemProf内存分析工具。valgrind沙箱运行无需重新编译,valgrind与ASAN内存分析器提供的内存相关系统调用封装(生成的二进制将包含分析器任务逻辑),MemProf程序可以与加载不同的库函数介入(不一定c标准库)指定LD_PRELOAD环境变量替代默认的libc库,无需在编译程序。 $ gcc -g ExtremeC_examples_chapter5_4.c -o ex5_4.out $ valgrind --leak-check=full ./ex5_4.out(选项参数帮助进一步跟踪) 堆内存原理:堆内存块并没有作用域,因此声明周期是模糊的,不用重新定义。堆声明周期并不一定由程序或者使用的c库决定,程序员是唯一定义堆内存块声明周期的人。程序员想要做出很好的决策是比较困难的,因为这没有通用一劳永逸的解决方案。简单的策略就是内存块所有者负责管理堆生命周期。 typedef struct { int front; int rear; double* arr; } queue_t; 如果一个实体(对象,函数,等)拥有堆内存块,应当直接注释,没拥有权内存块就不用去释放它。多次释放将导致两次free,导致内存崩溃。所有者策略还可使用垃圾回收器,自动回收没有指针指向没内存块,如古老的c垃圾回收Boehm-Demers-Weiser Conservative Garbage Collector,提供可调用而非malloc内存分配函数或其他标准c内存分配函数。另一种堆生命周期管理就是使用RAII资源获取即初始化,将资源生命周期(堆分配内存块)绑定到对象上,换句话话说,使用对象构造初始化资源,析构释放资源。但这种方法并不能在c中使用,因为没有对象构造,而c++中使用析构(自动)可以高效应用。在RAII对象中,资源初始化发生在构造中,去初始化资源放入析构中。 堆内存使用:堆内存分配非免费的,需要付出代价,并非所有内存分配函数代价都一样,malloc是消耗最少之一;堆空间分配内存块当不需要或者程序结束需要将其释放掉;堆内存块没有作用域,程序必须管理内存避免可能的内存泄露;选择堆内存管理策略十分必要;选择的策略或假设应该在代码中以文档形式写出来;在确定的编程语言像c++可以使用RAII对象来管理资源。 约束环境下内存管理:当内存资源十分珍贵受限,或性能是瓶颈而需要多少内存并不重要,需要特定的技术克服内存短板和性能下降。 约束环境:有限使用内存,自身对内存使用严格限制、硬件内存容量限制、操作系统不支持大的内存(MS-DOS);即便没有内存限制,但作为编程者也要尽力使用最少数量的内存。 内存约束环境:有限内存总是一个约束,算法设计应该处理好内存短板,嵌入式系统就属于这类。低内存消耗算法通常需要更多的计算时间。每个算法需要说明时间和内存复杂度,大多数时候我们需要在内存与之间折中,如排序算法。保守的做法就是当编程时就假定代码将运行在有限内存系统中,但避免陷入时间复杂度太高。 堆结构体:忽略内存对齐,有一个更紧凑的内存布局,然而读写时间将变多; 压缩:对于很多文本数据十分高效,文本数据由更高的压缩比相对于二进制数据,然而保存内存并非免费,压缩算法需要cpu计算,将降低性能,对于那些不经常使用的数据是可以的 外部数据存储:网络服务,云架构,硬盘,这些任务内存非主存储,而是缓存内存,我们不需要在同一时刻将所有数据加载到内存中,而是部分数据或者数据页加载到内存中,如数据库服务PostgreSAL和Oracle。 在大多数项目中,从0开始设计算法并不明智,可以借助现有的方法,如SQLite。 性能环境:以空间换时间,如使用缓存,缓存意味着消耗更多内存但换取更好的性能。虽然增加额外内存并非提高性能最好方式。 缓存:缓存为计算机系统通用技术,cpu有很多内部寄存器,进行快速读写操作,cpu从主内存中去数据比寄存器慢。数据库文件也类似,定义缓存机制。从慢存储中加载数据,受限从最近缓存中搜索是否加载过,如果找到了表示击中不需要再从慢存储中检索,否者称为丢失。 缓存友好代码:当cpu执行指令时,必须首先取出所需要的数据,这些数据分布在由指令决定的特定地址中,数据首先迁移到cpu寄存器,下次使用时直接用-缓存击中。击中率越高,运行越快。cpu通常取出一个地址和其相邻的地址,以提高击中率。 int friendly_sum(int* matrix, int rows, int columns) { int sum = 0; for (int i = 0; i <rows; i++) {//(行优先,更友好) for (int j = 0;j< columns; j++) { sum += *(matrix+ i * columns + j); } } return sum; } int not_friendly_sum(int* matrix, int rows, int columns) { int sum = 0; for (int j = 0; j < columns; j++) { for (int i = 0; i < rows; i++) { sum += *(matrix + i * columns + j); } } return sum; } $ time ./ex5_6.out friendly-sum 20000 20000(time计算运行时间,unix系统) 分配与释放代价:堆内存分配与释放操作费时费内存,尤其是需要经常操作时。堆分配需要找到足够大小未使用的内存块。c库中malloc与free函数,ptmalloc、tcmalloc、haoerd、dlmalloc等其他库。 分配与释放越少越好。 内存池:使用内存池预先分批固定大小堆内存块有效减少分配次数从而提高性一些性能。
第6章:对象与封装 关于面向对象编程或OOP,市面上由很多相关的书籍或文章。但是,我认为其中大多数都没有用非OOP语言如C阐述这一主题。或许你会说,这怎么可能?我们能用不支持OOP编程语言编写面向对象程序?更确切的说,是否由可能用C语言编写面向对象程序? 简单回答上述问题的化,就是yes,但在说明如何使用之前,我们需要先解释为什么。首先,我们要把这问题分解,什么是OOP的真实意思。为什么可能用不声称支持面向对象语言编写面向对象程序?这似乎自相矛盾,然而并非如此,本章我们尽力解释为什么可能和如何实现。 另一个可能困恼你的问题就是要用C作为主要编程语言讨论和了解OOP有何意义?现有成熟的C代码库几乎都以面向对象方式来编写,如开源核、服务实现如HTTPD、Postfix、nfsd、ftpd和许多其他C库OpenSSL、OpenCV。这不试试说c就是就是面向对象语言,相反,这些项目所采用的内部组织结构来源于面向对象思想。 我强烈推荐阅读本章及随后三章,首先了解更多OOP,这可以使你向前面提到库设计工程师那样思考与设计,此外对阅读那些源码来说也是十分有帮助的。 c语言语法并不直接支持面向对象概念如类、继承、虚拟函数。但是,它以间接方式支持面向对象概念。实际上,在Smalltalk、C++、与Java之前,历史上几乎所有计算机编程语言本质上都支持OOP方式。这是因为每种通用编程语言必须有一种方式来扩展数据类型,这是实现OOP第一步。 在语法方面,c并不能或者不支持面向对象特征,不是因为过时,而是由于本章下面要降到的合理原因。简单说,你依然可以用c语言编写面向对象编程,就是要多费点功夫来处理复杂性。 市面上很少有书籍和文章用c语言介绍oop,他们只是试着用c给为创建类型系统,实现继承多态等。这些书研究通过函数集、宏、预处理一起来编写面向对象程序,从而增加OOP支持。本章并不想有采用这中方法,在C外边创建新的C++,而是有何潜力用于OOP。 经常听到,OOP与过程模式和函数模式一样,是另一种编程模式。然而,OOP远不止此。OOP更像一种思考和分析问题的模式,一种看待世界和对象层次观念,也是我们理解和分析具体与抽象实体古老、内在,继承方法的一部分。这多我们理解自然至关重要。 我们一直从面向对象角度思考每个问题。OOP即采用人类一直使的用相同视角,只不过这次是使用编程语言来解决计算问题。这就解释问什么OOP是编写软件用得最普遍编程模式。 本章以及后面三章,将介绍所有OOP概念可以用C来实现,尽管可能有点复杂。我们知道可以用C语言实现OOP是因为有人已经那样做了,尤其是当才c之上创建C++程序,他们已经以面向对象的方式用c创建很多复杂成功的项目。 这些章节不建议采用特定库或者宏集合来声明类或建立继承关系又或是使用其他OOP概念。另外,我们不会强加任何方法或者原则,如特定命名约定。我们仅仅使用最原始的C实现OOP概念。 我们之所以用四章节来讲述C实现OOP,是因为面向对象背后的理论多,需要各种必要的例子来说明这些内容。OOP中大多数基本理论将在本章中介绍,而相关实践部分将在后面几章讨论。综上所述,我们需要讨论这些理论,是因为OOP概念对于大多数专业C程序员来说都是全新的,即便已有多年开发经验。 接下来四章将涵盖OOP方方面面,本章中,我们将讨论下面几点: 首先,OOP文献中使用的大部分基础术语定义,类、对象、熟悉、行为、方法、域等,这四章中大量使用这些术语,同时对理解OOP相关资源至关重要,因为他们是OOP语言公认主要部分; 本章第一部分并没有涵盖所有术语,着重讨论面向对象根本和背后的哲学,探索面向对象思考的本质; 本章第二部分专门介绍C语言和为什么不能和不是面向对象。这是一个应该正确回答的重要问题。这一主题将在第10章Unix历史与架构中进一步讨论,届时将探索Unix及与C之前紧密关系; 本章第三部分讨论封装,这是OOP中基础的概念。简言之,它使你可以创建并使用对象。将变量和方法放在对象内部,直接来自于封装概念,在第三部分中进行详细讨论并给出一些例子: 本章最后将讲述信息隐藏,虽然有些副作用(尽管很重要)。没有信息隐藏,我们将不能分离和解耦软件模块,也不能高效实现独立APIs供客户端使用,这正是本章最后讨论的部分。 本章主将组合,后面几章分别讲述聚合、继承、多态、抽象。 面向对象思维: 正如前面介绍的那样,面向对象思维就是分解和分析周围一切。当看到桌上的花瓶,你可能不加思考就知道花瓶与桌子是两个分离的物体。潜意识里,你就知道他们之间存在的边界将其分开,你知道改变花瓶颜色而桌子颜色可以保持不变。这些观察显示我们看待周围环境都是以面向对象视角的,换句话说,我们仅仅在脑海里对周围面向对象现实创建反应。我们也可以看到很多计算机游戏、3D建模软件、工程软件,所有的这些可能需要许多对象间交互。 OOP就是给软件设计和开发带来面向对象思考方式。面向对象思维是以我们默认的方式处理周围环境,这也就是为什么OOP变成编写软件最为常见的方法。当然,可能有些问题用面向对象方法可能很难解决,如果用其他方法分析可能反倒更好解决,但这种例子相对来说是很少的。 思想观念:你很难找到一个完全没有面向对象思想的项目,即使是用C写的或者其他非OOP语言写的。任何一个人写的程序,自然就带面向对象。这也可从变量名中得到佐证。 char* student_first_names[10];(用变量名组织相同概念下变量) char* student_surnames[10]; int student_ages[10]; double student_marks[10]; 变量名一直很重要,因为名字提醒我们脑海里的概念和与数据之间的关系。通过这种命名。如果采用临时变量名,代码将失去这些概念与关系。虽然这可能不会给计算机带来负担,但是将使分析程序及故障排查变得更为复杂,同时增加出错几率。 让我们进一步阐明在当前上下文中概念的含义。 概念是作为思想或观念存在于大脑中的精神或抽象。 一个概念可以通过对现实世界实体的感知来形成,也可以完全是虚构和抽象的。 当您看着一棵树或想起一辆汽车时,它们对应的图像会变成两个不同的图像概念。 需要注意的是有时我们在不同的上下文中使用概念一词,例如在“面向对象的概念”中,显然,概念一词的使用方式与我们刚才给出的定义不同。 与技术相关话题中,概念一词仅指理解相关话题的原理。 现在,我们将使用技术相关的定义。 概念对于面向对象的思维很重要,因为如果您无法在脑海中形成和保持对对象的理解,就无法提取有关它们表示和关联对象的详细信息,也无法理解它们之间的相互关系。 因此,面向对象思维是关于概念及其关系的思考。 随之而来的是,如果您想编写一个合适的面向对象程序,则需要对所有相关的对象,它们的相应概念以及它们之间的关系有一个正确的了解。 在你的思想中形成的一个面向对象的映射,其中包含许多概念及其相互关系,例如,当以团队的方式完成任务时,不容易与其他人交流。 不仅如此,这样的思想观念是易变和难以捉摸的,并且很容易忘记。 这也特别强调了以下事实: 需要模型和其他表示工具才能将思维导图转换为可交流的想法。 思维导图与对象模型: 在本节中,我们将看一个示例,以进一步了解到目前为止我们一直在讨论的内容。 假设我们有一个场景的书面描述,描述的目的是为了向观众传达相关的特定概念。 想象一下:正在描述的人脑海中有一张地图,其中列出了各种概念以及它们如何联系到一起,他们的目的是将这种思维导图传达给听众。 你可能会说,这或多或少是所有艺术表现形式的目标。 当你看画,听音乐或看小说时,它实际上正在发生。 现在,我们将看一下书面描述。 它描述了在一个教室里, 放松心情,尝试想象一下你正在阅读的内容。 您在脑海中看到的一切都是通过以下描述传达的概念: 我们的教室是一间有两个大窗户的旧房间。当你进入房间时,可以看到对面墙上的窗户。房间中间有许多棕色的木椅,五个学生坐在椅子上,其中两个是男孩。你右边的墙上有一个绿色的木制黑板,老师在和学生聊天,他是一个穿着一件蓝色衬衫的老人。 现在,让我们看看在我们的脑海中形成了什么概念。在想象之前,请注意你的想象力可能会在没有注意到的情况下消失。因此,让我们尽力将自己限制在描述的范围之内。例如,我可以想象更多,说这些女孩头发是金色的。但由于说明中未提及,因此我们不会考虑到这一点。在下一段中,我将解释在我脑海中是怎么样的,在继续之前,你也应该自己尝试一下。 在我脑海里,有五个概念(或心理图像或物体),每个班级的一个学生。对于椅子,也有另外五个概念,一个是木材,一个是玻璃。我知道每把椅子都是木头做的。这是木材的概念和椅子的概念之间的关系。另外,我知道每个学生都坐在椅子上。因此,在椅子和学生之间存在五种关系。我们可以继续找出更多的概念并将它们联系起来。我们很快就会有一个庞大而复杂的图形来描述数百个概念之间的关系。 现在,暂停片刻,看看你提取概念及其关系有何不同。这个过程,每个人都可以以不同的方式做到这一点。当你要解决特定问题时,也会发生类似过程。您需要在解决问题之前创建思维导图,这就是我们称为的理解阶段。 你可以使用基于问题概念以及它们之间的关系来解决问题。你用这些概念来解释你的解决方案,如果有人想了解您的解决方案,那么他们首先应该了解这些概念及其关系。 如果我告诉您这是你尝试使用计算机解决问题时确实发生的事情,您会可能会感到惊讶,但事实确实如此。你将问题分解为对象(与脑海里上下文中的概念相同)及其之间的关系,然后尝试根据这些对象编写程序,最终解决问题。 你要写的程序会在你脑海中模拟概念及其之间的关系。计算机将运行该解决方案,你可以验证它是否可以工作。你仍然是解决问题的人,但是现在计算机是你的同事,因为它可以执行您的解决方案,现在将其描述为从您的思维导图翻译过来的一系列机器层指令,速度更快,更准确。 面向对象的程序根据模拟对象概念,当在我们为脑海中创建问题思维导图时,该程序在其内存中创建对象模型。换句话说,如果我们要比较人类与面向对象的程序,则概念、思维和思维图分别相当于对象、内存和对象模型。这是我们在本节中提供的最重要的关系,它将我们的思维方式与面向对象的程序联系起来。 但是,为什么我们要使用计算机来模拟思维导图?因为计算机在速度和精度方面都表现很好。这是对此类问题非常经典的答案,但仍与我们的问题相关。创建和维护大型思维导图和相应的对象模型是一项复杂的任务,并且计算机可以做的很好。另一个优点就是,程序创建的对象模型可以存储在磁盘上,并供以后使用。 思维导图可能会因心情而被忘记或改变,但计算机却毫无情绪,并且对象模型比人类的思想更健壮。这就是为什么我们应该编写面向对象的程序:能够将我们的思想转变为有效的程序和软件。目前为止,思维导图是不能从脑海里下载保存的,或许将来某天可以。 代码中没有对象: 如果检查一下面向对象程序运行内存,你将发现由很多相互关联的对象, 人也是一样的。 如果你将人视为机器,则可以说他们一直至死。 现在,这是一个重要的类比。 对象只能存在于正在运行的程序中,就像概念只能存在于活人思想中一样。 这意味着只有在运行程序时才有对象。 这看起似乎自相矛盾,因为当你写程序时(面向对象那种),程序并不存在所以也不能运行。因此。程序不能运行也没有对象,我们要怎么编写免息对象代码呢? 实际上OOP并不是关于如何创建对象。它是关于创建一组指令的,程序运行时生成完全动态的对象模型。因此,面向对象的代码一旦编译并运行,就应该能够创建,修改,关联甚至删除对象。 因此,编写面向对象的代码是一项艰巨的任务。在对象存在之前,你需要先想象它们及其之间的关系。这正是OOP可能很复杂,之所以需要一种支持面向对象的编程语言的原因。想象尚未创建的对象并描述或工程化其各种细节的艺术通常称为设计。这就是为什么在面向对象的编程中,这个过程通常称为面向对象设计(OOD)的原因。 在面向对象的代码中,我们仅打算创建对象。 OOP生成一组有关何时以及如何创建对象的指令。当然,这不仅关于创建。所有有关对象的操作都可以使用编程语言进行详细描述。 OOP语言是,可以编写和执行与对象相关的不同操作,的一组指令(和语法规则)的语言。 到目前为止,我们已经看到,人思维中的概念与程序中的对象存在明显的对应关系。因此,对概念和对象执行的操作之间应该有对应关系。 每个对象都有自己的生命周期。对于思维中的概念也是如此。一个想法浮现在脑海中时,创建概念的心理图像,而在其他时候它将消失。对象也是如此。一个对象在一个时间被构造,而在另一时间被析构。 最后一点,一些心理概念是非常稳定不变的(相对于易变和短暂的概念)。这些概念似乎独立于任何思想,甚至在没有理解的之前已经存在。它们主要是数学概念,如数字2。在整个宇宙中,我们只有一个数字2,这意味着你的和我的数字2中概念是完全相同。如果我们尝试更改它,它将不再是数字2了。这正是我们跳出对象领域,进入另一个充满不可变对象的领域,它们被称为函数式编程。 对象属性:任何人脑中的每个概念都有一些关联属性。如果你还记得,在我们的课堂中说过我们有一个名为chair1的棕色椅子。换言之,每把椅子对象都有一个颜色属性,chair1对象的颜色为棕色。我们知道教室里还有另外四把椅子,它们的由不同的颜色属性。在我们的描述中,它们都是棕色的,但是在另一种描述中,可能是其中一两把是黄色的。 一个对象可以具有多个属性或一组属性,我们将设定的属性值统称为对象的状态。可以简单地将状态当作附加到对象的值列表,每个值都属于某个属性。一个对象可以在其生命周期中被修改,这样的对象就是可变的。这仅表示状态可以在其生命周期内改变。对象也可以是无状态的,这意味着它们不携带任何状态(或任何属性)。 一个对象也可以是不可变的,就像与数字2对应的概念(或对象)一样,它不能更改-不可变的意思是指状态在构造时确定的,此后不能更改。无状态对象可以认为是不可变对象,因为其状态在生命周期内不可改变。总之,不可变对象尤为重要,不改变状态有一个优势,尤其是在当多线程环境中共享对象。 域:每个程序都是为了解决特定问题,即使再小都有明确的定义域。域是软件工程文献中广泛使用的另一个重要术语。域定义了软件展示其功能的边界,还定义了软件应满足的需求。 域使用特定、预定的术语(词汇表)来说明它的任务,并使工程人员在其范围内开发。参与软件项目的每个人都应该知道项目的定义域。 例如,银行软件通常是为定义明确的域而构建的。它的术语表是一系列众所周知的术语,包括帐户,信贷,余额,转账,贷款,利息等。 术语表中的术语清楚地说明了定义域。例如,在银行域中无法找到患者,药物和剂量等术语。 如果某种编程语言没有提供用于处理给定域特定概念的工具(例如医疗领域中的患者和药物概念),那么使用该编程语言为这域编写软件将会很困难–不是不可能,但肯定是相当复杂。而且,软件越大,开发和维护就越困难。 对象之间关系:对象可以相互关联。他们可以互相引来表示之间的关系。例如,作为课堂描述的一部分,对象学生4(第四名学生)可能与对象椅子3(第三名主席)有坐与被坐的关系。 换句话说,学生4坐在椅子3上。 照这样,系统中所有对象都相互引用,并构建对象模型网络。 如前面提到的,对象模型与构成我们脑海的思维导图对应。 当两个对象相关时,一个状态的更改可能会影响另一个对象状态。 让我们通过一个例子来解释一下。 假设我们有两个不相关的对象p1和p2,它们表示像素,对象p1属性:{x: 53, y: 345, red: 120,green: 45, blue: 178},对象p2属性:{x: 53, y: 346, red:79, green: 162, blue: 23}。这种属性表示法与json并不完全相同。 现在,为了使他们关联,需要有个额外的属性表示它们之间的关系。对象p1状态变为{x: 53, y: 345, red: 120, green: 45, blue: 178, 下一个像素: p2},p2状态变为{x: 53, y: 346, red: 79,green: 162, blue: 23, 上一个像素: p1} 因此,如果两对象建立联系,这些对象状态(属性值)可以改变。对象间关系由新增的属性建立,这种关系变为状态的一部分。这就是对于可变与不可变的后果。 面向对象操作:OOP语言允许我们即将运行的程序中对象创建、对象析构、改变对象状态。因此,让我们看一下创建(类似与非专业术语的创造或建造):第一种创建空对象或最小数量属性对象,运行时确定添加更多属性,这种方法适用与解释性语言,JavaScript、Ruby、Python、Perl、PHP,属性作为内部数据结构映射便于运行时改变(原型OOP);第二种构建确定属性且执行时不改变的对象,仅运行属性值改变,不予许增减属性,程序员需要预先设计对象模板或模板类(类OOP)。类仅决定对象属性列表,并不决定运行时赋值。 对象与实例是同一个东西,可互换。然而在有些书中,可能由轻微差异。另外一个术语引用,需要着重解释下。对象或实例是引用分配的内存,存储对象值的地方,而引用更像指针,指向对象,同个对象可有多个引用。一般来讲,对象通常没有名字,而引用必须由名字。c语言中指针作为引用语法。堆对象没有名字,用指针指向它,栈对象为有名字的变量。 尽管两种方法都可以用,c尤其是c++,官方设计方式支持基于类方法,因此要创建c或c++对象需要先有一个类。人类从生活中成长为例,出生时是空对象,生活中各种好坏经历,开始成长和进化成熟的特质,存在先于本质。对象在运行时构造。对象不能引用不存在的属性,否者导致运行时错误,如内存崩溃或段错误、又或逻辑错误。对象修改(改变对象状态)有多种方式,改变属性值,增加或删除属性(基于原型oop)。在面向对象语言中,改变不可变对象状态是非法禁止的。 对象行为:对象有属性与功能列表,功能与需求域一致,每个功能可通过修改属性值来改变对象状态。 C语言非面向对象,这是为啥? c非面向对象并非因为是古老的语言。如果是这个原因,我们也可以找到一种方法使得其面向对象。最新C18标准并没有使c变成对象对象语言。另外,我们有C++,正是对基于c的OOP语言努力结果。如果C的命运是被面向对象语言取代,那么C就没有任何需求了,主要因为C++,但对C语言工程师的需求就知道不是这样。 人们用面向对象方式思考,但是CPU机器层指令是过程执行。CPU按时间一个一个执行指令集,可以跳转、取回、执行其他不同内存地址中指令,这与面向过程语言编写的程序函数调用十分相似,如C语言。 C语言不能面向对象,因为它处于面向对象与过程编程之间。面向对象是人理解问题方式,而过程执行是CPU运行方式。因此,在这之间,我们需要一个桥梁,否者,用面向对象编写的高层程序不能直接翻译成CPU过程指令。如果看一下高级编程语言如Java、JavaScript、Python、Ruby等,他们在架构中都有一部分或一层专门桥接与正在由操作系统建立的C库(Unix类系统标准C库,windows中Win32),例如Java虚拟机。尽管不是多有环境都需要面向对象(如JavaScript,Python既可以面向对象也可以面向过程),他们仍然需要这层把高层逻辑翻译成底层过程指令。 封装:将属性和函数放入对象实体中,这就是封装过程。封装简单意思就是将相关的东西放到一个表示对象容器中,首先在脑海中形成,随后翻译成代码。当你感觉到对象需要一些属性和功能函数时,那就在你脑海里做封装。封装是需要翻译成代码。编程语言具有封装能力至关重要,否者将相关变量放在一起会是一个站不住脚的斗争。 封装属性:就像我们前面看到的,用变量名字做封装,将不同变量组合相同对象中。隐式对象(程序员意识到存在的对象,编程语言并不知道): int pixel_p1_x = 56; int pixel_p1_y = 34; int pixel_p1_red = 123; int pixel_p1_green = 37; int pixel_p1_blue = 127; 通过变量名进行概念封装,并不是官方认为的封装,但存在与所有的编程语言,乃至汇编语言。我们需要方法提供显式封装,程序员与编程语言都能意识到被封装对象的存在,不提供显式封装的语言很难用。幸好,c语言提供了显式的封装,这也是为什么可以稍微容易写很多本质上是面向对象程序。后面会讲到,c并不显式提供封装行为,我们需要采用隐式方法实现。 注意,诸如在编程语言中总是需要显式封装特征。在这里,我们仅讨论封装,但是它可以扩展到许多其他面向对象的功能,例如继承和多态性。此类显式功能允许编程语言在编译时而非运行时捕获相关错误。 在运行时修复错误是一场噩梦,因此我们应始终尝试在编译时捕获错误。这是使用面向对象的语言的主要优势,它完全了解我们的面向对象的思维方式。面向对象的语言可以在编译时发现并报告设计中的错误和违规,从而避免在运行时解决许多严重的错误。的确,这就是为什么我们每天都会看到更复杂的编程语言的原因,以便使所有内容都对该语言明确。 不幸的是,并不是所有面向对象的功能在C中都是显式的。这就是为什么很难用C编写面向对象程序。但是,C ++中存在更多的显式功能,这也是它被称为面向对象编程语言的原因。 c语言中,结构体提供封装功能。 typedef struct { int x, y; int red, green, blue; } pixel_t;(_t后缀常用表示类型名,类属性或类模板) 封装需要创建新类型,属性封装在c语言中尤为如此。C,尤其是C++中,类型决定对象属性。类指包含属性与函数的对象容器,C结构仅仅有属性,因此不能与类对应。幸好,C语言中属性与函数可以分开存在,可以隐式将两者关联到代码中。c中每个隐式类引用单结构体与c函数列表。基于预定义属性模板构造对象。对象构造与新变量声明非常相似,声明对象时分配内存与默认初始化属性值(c语言中默认整形为0)。c语言与其他编程语言,使用点访问对象属性(p1.x),箭头间接访问结构体指针对象属性(p1->x)。 封装行为:方法术语通常用于表示对象中逻辑段或是功能,可以认为是有名字、带参数列表、返回类型的c函数。属性传递值,方法传递行为。因此,在系统中对象有列表值和可执行确定行为。 在基于类的面向对象编程语言,C++,在类中十分容易组织多属性和方法,而基于原型对象编程语言,JavaScirpt通常以孔对象开始或克隆其他对象。 JavaScript例子: var clientObj = {};(构造孔对象) clientObj.name = "John";(设置属性) clientObj.surname = "Doe"; clientObj.orderBankAccount = function () {。。。}(增加方法) clientObj.orderBankAccount();(调用方法) C++例子:c++中方法通常脚成员函数,属性脚数据成员。 class Client { public: void orderBankAccount() { ... } std::string name; std::string surname: }; ... Client clientObj; clientObj.name = "John"; clientObj.surname = "Doe"; ... clientObj.orderBankAccount (); 你可以看到著名开源C项目所采用的封装技术都有共性。libcurl库 隐式封装: 使用c结构体保存对象属性,这种结构体也叫属性结构体;行为封装采用C函数,也叫行为函数,必须放在结构体外面;行为函数必须可以接受结构体指针参数(通常为第一个或最后一个参数);行为函数应该由合适的名字暗示与相同对象类相关,坚持这用约定很重要;行为函数与属性结构体在同一个头文件声明,也叫声明头;行为函数定义通常放在一个或多个分开的源文件中。 #ifndef EXTREME_C_EXAMPLES_CHAPTER_6_1_H #define EXTREME_C_EXAMPLES_CHAPTER_6_1_H // This structure keeps all the attributes // related to a car object typedef struct { char name[32]; double speed; double fuel; } car_t;(包含Car属性) // These function declarations are // the behaviors of a car object void car_construct(car_t*, const char*); void car_destruct(car_t*); void car_accelerate(car_t*); void car_brake(car_t*); void car_refuel(car_t*, double); #endif 隐式封装技术重要几点:每个对象由自己唯一的属性结构体变量,所有对象共享相同行为函数。也就是说,每个对象从属性结构体类型创建变量,不同对象调用的行为函数只要写一遍。 car_t car;(创建对象变量) car_construct(&car, "Renault");(构造对象) car_destruct(&car);(析构对象) C++中,显式封装类将看得更清楚: ifndef EXTREME_C_EXAMPLES_CHAPTER_6_2_H #define EXTREME_C_EXAMPLES_CHAPTER_6_2_H class Car { public: Car(const char*);(构造) ~Car();(析构) void Accelerate(); void Brake(); void Refuel(double); // Data Members (Attributes in C) char name[32]; double speed; double fuel; }; #endif 调用:自动析构 Car car("Renault");(创建对象变量调用构造函数) C++与C对象用起来非常相似,除了给类分配内存替换结构体变量。C语言中。不能将属性与行为函数捆绑到一起,之恩给你通过文件组织到一块。但C++有语法支持捆绑,即类定义,允许数据成员和成员函数放到一起。由于C++知道封装,就没必要给行为函数传递指针,如例子中成员函数声明中第一个参数并非指针。 写面向对象程序,C语言是过程编程语言,C++是面向对象语言。属性方法,过程语言调用func(obj, a, b, c, ...),而面向对于语言调用obj.func(a, b, c, ...)。后面可以看到,C++使用相同技术将高级C++函数调用翻译成底层C函数调用。 需要注意的是,C++中每当对象分配到栈顶超出作用域时,自动调用析构函数,这跟栈变量一样,这也是C++内存管理巨大等成功。因为C可能很容易忘记调用析构函数,最终导致内存泄露。 信息隐藏: 封装还有另一个重要的目的或结果,那就是信息隐藏。 信息隐藏是保护(或隐藏)某些属性和行为外部不可见。 所谓外部,是指代码中所有不属于对象行为的部分。 根据此定义,如果某个对象的私有属性或行为不是该类的公共接口的一部分,没有其他代码或其他C函数可以访问对象的私有属性或行为。 需要注意的是,两个对象有相同的行为,例如Car类中的car1和car2,可以访问相同类型的任何对象的属性。 这是因为类为所有对象只写了一次行为函数。 结果,实现细节可以略过。假设您要使用汽车对象。通常,对你来说重要的是,可以给汽车加速车,至于如何完成的并不关系。对象中可能有更多的内部属性辅助加速过程,但是没有充分的理由使他们对操作逻辑可见。 例如,传递给发动机启动器的电流大小可以是属性,但它应仅对对象本身私有。这也适用于对象内部的某些行为。例如,将燃油喷入燃烧室是一种内部行为,这种内部行为对你应不可见,否则可能会干扰你导致打断发动机的正常工作。 从另一个角度来看,实现细节(汽车的工作原理)因汽车制造商而异,但是能够加速汽车行驶是所有汽车制造商都提供的一种行为。我们通常说加速汽车行驶是公共API或Car类的公有接口的一部分。 通常,使用对象的代码取决于该对象的公有属性和行为。这是一个严重的问题,先声明一个内部属性为公有再将其设置为私有来泄漏内部属性,可以快速破坏依赖代码的构建。在更改后,将该属性用作公有的其他部分无法编译。这意味着你已经破坏了向后兼容。 这就是为什么我们选择一种保守的方法并默认情况下每个属性设为私有,直到找到合理的理由将其置为公有。 简单来说,从类中暴露私有代码意味着我们不必依赖轻量级的公有接口, 依赖于严格的实现。 这些后果严重,并且有可能导致项目大量返工。 因此,重要的是尽可能保持属性和行为私有。 struct list_t;(属性结构体没有公开属性,类似与前向声明) struct list_t* list_malloc();(分配内存函数) void list_init(struct list_t*);(构造函数) void list_destroy(struct list_t*);(析构函数) int list_add(struct list_t*, int);(公有行为函数) int list_get(struct list_t*, int, int*);(公有行为函数) void list_clear(struct list_t*);(公有行为函数) size_t list_size(struct list_t*);(公有行为函数) void list_print(struct list_t*);(公有行为函数) 只有结构体声明没有定义,不能访问结构体内部,从而保证信息隐藏,这是相当成功。 另外,在创建和发布头文件之前,必须再次检查我们作为公有暴露某些内容是否必要。 通过暴露公有行为或公有属性,将创建依赖关系,而破坏这些依赖关系的将导致花费时间、开发工作以及金钱来维护。 在源文件定义属性结构、行为,为私有: typedef int bool_t;//定义别名 typedef struct { size_t size; int* items; } list_t; bool_t __list_is_full(list_t* list) {(__前缀来指示私有) return (list->size == MAX_SIZE); } 头文件仅写外部代码所依赖的代码段。源文件可以不包含头文件,只要函数定义头与头文件函数声明匹配即可。可以这样做的条件就是需要保证声明与定义兼容。实际上,链接器将私有定义引入公有声明,使得外部程序可以调用。 使用公有API,可以写程序并编译,但只有提供对应含义私有部分的对象文件,链接后,才可以嵌入到实际运行程序中。 不完整类型(只声明但并未给出内部字段信息,没有定义,编译器不知道其大小),如前向声明,不能使用sizeof来计算需要占用字节大小。真实大小只有当链接时知道实现细节时才能确定。 $ gcc -c ExtremeC_examples_chapter6_3.c -o private.o(私有编译) $ gcc -c ExtremeC_examples_chapter6_3_main.c -o main.o(没有头文件,因为main.o中包含了) $ gcc main.o -o ex6_3.out(链接失败,招不到头文件中声明的函数定义) $ gcc main.o private.o -o ex6_3.out $ gcc main.o private2.o -o ex6_3.out(当实现部分改变时只需要改变的对象文件,并重新链接即可) $ ./ex6_3.out 从用户角度来说,私有实现细节改变,并没有什么太大改变。这对于C工程来说是巨大成功的。如果我们不想重复链接过程,我们可以使共享库(.so文件)包含私有对象文件,运行时动态加载。
第7章:组合与聚合 本章讨论类之间关系,可利用对象关系扩展对象模型: 两个对象类之间存在的类型关系:to-have,to-be 组合就是to-have关系,聚合也是to-have关系,但两者又由不同,可以从内存布局一窥究竟。 类之间关系:对象模型就是相关对象集。虽说关系由很多种,但两对象也就存在几种关系,常见就是to-have与to-be。 对象与类:构造对象由两种方法,一直基于原型,一个是基于类。前者通过呢构造一个空对象(没有属性与行为)或者拷贝一个对象实例与对象等价,因此可以称为基于对象方法;基于类方法,必须要现有类的定义,才能实例化对象。虽说类与对象之间有着细微不同,但还是深入研究。 定义Person类,有name、surname、age属性,C实现: typedef struct { char name[32]; char surname[32]; unsigned int age; } person_t; C++实现: class Person { public: std::string name; std::string family; uint32_t age; }; 类(对象模板)仅决定每个对象必有的属性的蓝图,而不决定对象属性具体值。当基于类创建对象,首先为属性值占位符分配内存,然后初始化属性值。这就是构造过程,由构造函数完成。与构造对应的就是析构函数了,释放分配的内存。 类我创建对象蓝图;不同对象可来自于同一个类;一个类决定创建对象包含的属性;一个类本身不占内存,仅存在于源码层和编译时;对象存在与运行时,且占内存;对象创建时分配内存,最后使用结束释放内存;对象创建,内存分配后构造,内存释放前析构;对象拥有的流、缓存、数组等在对象销毁后必须释放。 组合:从字面上,指一个对象包含或拥有另一个对象,也就是由另一个对象组成,他们之间就是组合关系。如,卡车与发动机之间。组合关系条件为:被包含的对象生命周期与容器对象绑定,即只要容器对象存在被包含对象就必须存在。当容器对象即将析构,被包含的对象必须首先析构。这条件意味着被包含对象通常为容器对象内部或私有。有时,通过公有接口(行为函数)访问被包含对象,但被包含对象生命周期必须由容器对象内部来管理。如果有代码可以析构被包含对象而没有析构容器对象,这违反了组合关系,这种关系也就不再是组合了。 Car类头文件: #ifndef EXTREME_C_EXAMPLES_CHAPTER_7_1_CAR_H #define EXTREME_C_EXAMPLES_CHAPTER_7_1_CAR_H struct car_t; struct car_t* car_new();(内存分配) void car_ctor(struct car_t*);(构造) void car_dtor(struct car_t*);(析构) void car_start(struct car_t*);(行为函数) void car_stop(struct car_t*); double car_get_engine_temperature(struct car_t*); #endif Engine类头文件: #ifndef EXTREME_C_EXAMPLES_CHAPTER_7_1_ENGINE_H #define EXTREME_C_EXAMPLES_CHAPTER_7_1_ENGINE_H struct engine_t; struct engine_t* engine_new();(内存分配) void engine_ctor(struct engine_t*);(构造) void engine_dtor(struct engine_t*);(析构) void engine_turn_on(struct engine_t*);(行为函数) void engine_turn_off(struct engine_t*); double engine_get_temperature(struct engine_t*); #endif Car类源文件: #include <stdlib.h> #include "ExtremeC_examples_chapter7_1_engine.h"(仅能使用发动机引擎公有接口) typedef struct { struct engine_t* engine;(组合关系建立,私有) } car_t; car_t* car_new() { return (car_t*)malloc(sizeof(car_t)); } void car_ctor(car_t* car) { car->engine = engine_new();(引擎分配内存) engine_ctor(car->engine);(构造引擎对象) } void car_dtor(car_t* car) { engine_dtor(car->engine);(析构引擎) free(car->engine);(释放内存) } void car_start(car_t* car) { engine_turn_on(car->engine); } void car_stop(car_t* car) { engine_turn_off(car->engine); } double car_get_engine_temperature(car_t* car) { return engine_get_temperature(car->engine);(公有接口调用) } 大多情况下,两个不同类型对象,一定不能知道对方的实现细节。这就是信息隐藏的意思。这里,Car的行为对于引擎来讲属于外部。 Egnine类实现; #include <stdlib.h> typedef enum { ON, OFF } state_t; typedef struct { state_t state; double temperature; } engine_t; engine_t* engine_new() {(内存分配) return (engine_t*)malloc(sizeof(engine_t)); } void engine_ctor(engine_t* engine) {构造) engine->state = OFF; engine->temperature = 15; } void engine_dtor(engine_t* engine) {(析构) // Nothing to do } void engine_turn_on(engine_t* engine) {(行为函数) if (engine->state == ON) { return; } engine->state = ON; engine->temperature = 75; } void engine_turn_off(engine_t* engine) { if (engine->state == OFF) { return; } engine->state = OFF; engine->temperature = 15; } double engine_get_temperature(engine_t* engine) { return engine->temperature; } 引擎并不知以组合关系包含它的外部对象。当然引擎也有一个指针指向包含者Car对象。 main函数文件: #include <stdio.h> #include <stdlib.h> #include "ExtremeC_examples_chapter7_1_car.h" int main(int argc, char** argv) { struct car_t *car = car_new();(car对象分配内存) car_ctor(car);(构建car对象) printf("Engine temperature before starting the car: %f\n",car_get_engine_temperature(car)); car_start(car); printf("Engine temperature after starting the car: %f\n",car_get_engine_temperature(car)); car_stop(car); printf("Engine temperature after stopping the car: %f\n",car_get_engine_temperature(car)); car_dtor(car);(析构Car对象) free(car);(释放内存) return 0; } 编译生成: $ gcc -c ExtremeC_examples_chapter7_1_engine.c -o engine.o $ gcc -c ExtremeC_examples_chapter7_1_car.c -o car.o $ gcc -c ExtremeC_examples_chapter7_1_main.c -o main.o $ gcc engine.o car.o main.o -o ex7_1.out $ ./ex7_1.out 聚合:容器对象包含另一个对象,被包含对象生命周期不依赖与容器对象的生命周期。聚合关系中,被包含对象可以先与容器对象创建,生命周期可以比容器对象短或者相同。 如游戏场景中玩家拾起枪,多次开火,后丢弃枪。玩家就是容器对象,枪为被包含对象。 gun类头文件: #ifndef EXTREME_C_EXAMPLES_CHAPTER_7_2_GUN_H #define EXTREME_C_EXAMPLES_CHAPTER_7_2_GUN_H typedef int bool_t; struct gun_t;(类型前向声明,不完整类型。不能实例化) struct gun_t* gun_new();(内存分配) void gun_ctor(struct gun_t*, int);(构造) void gun_dtor(struct gun_t*);(析构) bool_t gun_has_bullets(struct gun_t*);(行为函数) void gun_trigger(struct gun_t*); void gun_refill(struct gun_t*); #endif player类头文件: #ifndef EXTREME_C_EXAMPLES_CHAPTER_7_2_PLAYER_H #define EXTREME_C_EXAMPLES_CHAPTER_7_2_PLAYER_H struct player_t;(前向声明) struct gun_t; struct player_t* player_new();(内存分配) void player_ctor(struct player_t*, const char*);(构造) void player_dtor(struct player_t*);(析构) void player_pickup_gun(struct player_t*, struct gun_t*);(行为函数) void player_shoot(struct player_t*); void player_drop_gun(struct player_t*); #endif player类源文件: #include <stdlib.h> #include <string.h> #include <stdio.h> #include "ExtremeC_examples_chapter7_2_gun.h" typedef struct {(属性结构体) char* name; struct gun_t* gun; }player_t; player_t* player_new() {(内存分配) return (player_t*)malloc(sizeof(player_t)); } void player_ctor(player_t* player, const char* name) {(构造) player->name =(char*)malloc((strlen(name) + 1) * sizeof(char)); strcpy(player->name, name); player->gun = NULL;(聚合指针为空) } void player_dtor(player_t* player) {(析构) free(player->name); } ... 如果聚合指针需要在构造中设置值,目标对象地址应该作为构造参数传递进来,这称为强聚合。如果聚合指针构造时置空,为可选聚合。不像组合,容器对象并不拥有被包含对象,因此也就不能控制被包含对象生命周期,即容器对象析构时不能free被包含对象。使用可选聚合,使用聚合指针时应当小心以防没被设置或者为空导致段错误。 在实际项目中创建对象模型,聚合关系通常比组合关系多。同样,聚合关系在外部可见,因为,为了建立聚合关系,至少在容器对象的公有接口中,需要一些专用的行为功能来设置和重置所包含的对象。 聚合关系(弱拥有)是临时的,不像组合关系(强拥有)那么持久。现在,一个问题浮现在脑海。如果聚合关系在两个对象之间是暂时的,那么在它们对应的类之间是否是暂时的?答案是不。类型之间的聚合关系是永久的。如果将来哪怕是有很小的机会基于聚合关系将两种不同类型的两个对象关联起来,则它们的类型应永久处于聚合关系中。这也适用于组合。 即使存在聚集关系的机会很小,也应该使我们在容器对象的属性结构中声明一些指针,这意味着该属性结构将永久更改。当然,这仅适用于基于类的编程语言。 组合和聚合都描述了拥有某些对象。换句话说,这些关系描述了“有”或“有一”的情况。每当你觉得一个对象拥有另一个对象时,这意味着它们(及其对应的类)之间应该存在组成关系或聚集关系,