7 工程项目开发

课程内容

函数的声明与定义

  • 声明:告诉系统有这个东西

    • 传入参数的变量名是什么不重要,此时不需要标明
  • 定义:具体是怎么实现的

  • 之前函数声明和定义都是同时进行的

  • 编译顺序:从上到下、从左到右

    • 图片
    • 上:gcc报错信息;下:g++报错信息(也许g++报错更友好)

    • 看报错的时候从上往下看,后面的错误可能就是由第一个错误产生的连锁反应

  • 函数未声明和未定义暴露在两个时期

    • 函数未声明错误——编译过程(主要是语法检查)
      • g++ -c *.cpp生成编译后的对象文件
      • 图片
    • 函数未定义错误——链接过程
      • g++ *.o链接生成可执行程序

      • 图片
    • 上面报错信息来自于船长的clang编译器,我们使用的是g++编译器,显示有所不同
  • 函数声明可以多次,但定义只能一次!

头文件与源文件

  • 规范
    • 头文件放声明,源文件放定义
      • 不应该都放在头文件里
    • 头文件与对应的源文件名称一致
  • 头文件中条件式编译,可以避免一次编译过程中重复包含头文件的问题
#ifndef _HEADER1_  // 名称最好与头文件名对应,虽没有硬性要求
#define _HEADER1_
...
#else              // 可以没有
#endif             // 必须有

工程开发规范与静态链接库

  • 可以将#include后的双引号""改成尖角号<>吗?
    • 双引号"":从执行代码所在目录下搜索
    • 尖角号<>:从系统库路径下搜索
    • 使用g++/gcc -I添加头文件路径到系统库路径下
  • 向上开发时
    • 给别人头文件(include文件夹)、源文件对应的对象文件的包(lib文件夹)即可
    • 对象文件打包
      • 静态链接库(.a)

// 打包
ar -r libxxx.a header1.o header2.o header3.o
// 链接 g++ *.o -L -l
g++ test.o -L./lib -lxxx
// xxx对应

makefile工具

  • 文档编译工具,定位类似markdown

  • 封装编译流程,减小程序开发时编译的复杂度

  • 示例

    • 图片
    • .PHONY开一个虚拟的环境,避免使用make clean时与路径中存在的clean文件冲突

    • 可以有封装变量替换操作

初识谷歌测试框架

  • 单元测试

    • 又称模块测试,是对程序模块(软件设计的最小单位)来进行正确性检验的测试工作
      • 在过程化编程中,一个单元就是单个程序、函数、过程等
    • 框架跟着语言走:C++、Python、Java...
  • C++实现的

  • cmake工具

    • 可根据本机的环境生成makefile文件
    • 为什么不直接使用makefile呢?makefile对环境的要求很强
    • 谷歌测试框架先cmake再make即可完成编译,注意打包库的位置
  • 代码(main.cpp)

    • 图片
    • 用的尖括号<>包含的gtest.h头文件

    • add2只是一个标识

    • 断言是什么?

      • 用来捕捉程序员自己的错误:假设某情况发生,但如果没发生则作相应处理
      • ASSERT_*版本的断言失败时会产生致命失败,并结束当前函数
      • EXPECT_*版本的断言产生非致命失败,而不会中止当前函数
  • makefile

    • 图片
    • 可以用 -std=xxx 指定C++版标准,本机其实不需要指定

    • 需要使用-I 添加头文件路径./lib

    • 使用-lpthread额外连接pthread库,mac系统会自动连接

    • 🆗疑问

      • 图片
      • ①根据编译器版本设置了默认标准,c++11是比较低的版本
      • ②可能用了make install之类的操作,将头文件包含到系统库目录里了
  • 结果

  • 图片

⭐实现自己的测试框架

  • C实现的
  • 需要实现以下三个函数或宏
TEST EXPECT_EQ RUN_ALL_TEST
功能 代表一个测试用例 测试用例里的测试点 运行所有TEST
宏/函数 函数或宏 函数或宏
注意点 没有返回值类型;
与后面的花括号{}组成合法的函数定义的形式
一种断言 返回值是0

版本一:通过编译,显示测试结果

  • haizei/test.h

    • 图片
    • 无法使用a##.##b

      • 函数名中只能是下划线、字母和数字,不能有"."!
    • a##_haizei_##b

      • 使用_haizei_或之类的特殊标识是为了防止a与b直接连接产生函数重名情况
      • 如(test, funcadd)与(testfunc, add)
    • ⭐__attribute__((constructor))

  • haizei/test.cc

    • 图片
    • 只需要象征性定义一下,通过编译即可

  • main.cpp

    • 图片
    • 三组TEST

  • makefile

    • 图片
    • 使用make可以快速编译

    • 注意-o的使用,让对象文件、可执行程序自定义命名放在指定目录下

    • 注意路径中文件所在文件夹的指明

  • 测试结果

    • 图片
  • ❓当前版本下,main()函数里return的无论是RUN_ALL_TESTS()还是0都会显示测试结果,如何让RUN_ALL_TESTS()控制显示的输出与否呢?

版本二:RUN_ALL_TESTS()开关

  • 实现框架初衷——开关控制

  • 需记录的点

    • 有多少组测试用例
    • 测试用例对应的函数名字
    • 测试用例对应的函数
      • 使用函数指针变量
      • 用数组记录函数指针
  • haizei/test.h

    • 图片
    • TEST中,在main函数执行前使用add_function将函数记录到全局变量里

    • typedef的第二种用法:将变量提升为类型

    • 结构体的使用:封装函数指针和函数名

  • haizei/test.cc

使用malloc()开辟空间拷贝字符串,返回其地址,即字符串指针;
最后记得使用free()释放

    • main.cpp与makefile不变
    • ❗已经实现了开关控制,下面可以对显示、断言等进行优化!

版本三:人性化优化

① 给输出添加颜色

  • 参考带颜色的printf-Blog

  • 将颜色定义封装成宏,在头文件haizei/test.h里定义

    • 图片
    • COLOR 正常

    • COLOR_HL 高亮

    • COLOR_UL 下划线

    • 多个字符串之间用空格即可连接

    • 注意!颜色控制字符中 ";" 左右不能有空格

正确: "\033[1;""31" "m" "%s\n"
设置无效,啥都没有:"\033[1; ""31" "m" "%s\n"

② 添加断言宏

  • 判断不等于、大于、大于等于、小于、小于等于

  • 就事论事型:对每个宏单独实现

  • 统一管理型:类似定义颜色宏,把共同的代码再封装一次

    • 图片
    • 掌握#的使用

③ 统计每组测试的成功和失败的测试点个数,并显示

  • haizei/test.h

    • 图片
    • 图片
    • 定义统计的结构体,统一管理,封装性更好

    • 在断言处进行统计

    • 这里使用extern声明结构体变量,因为

      • 头文件的断言处用到了该变量,需要有该变量的声明

int i 既是声明也是定义,extern int i只是声明
struct FunctionInfo haizei_test_info 既是声明也是定义
只是声明需在前面加extern

  • haizei/test.cc
    • 图片
    • 定义并声明haizei_test_info变量
    • 1.0提升类型,100.0放前面可能会溢出
    • 100%情况判断:用一个极小值和fabs进行浮点数判等;成功数==统计数
    • 居中对齐效果
      • %m.nf:输出共占m列,其中有n位小数,如数值宽度小于m左端补空格
      • %-m.nf:输出共占n列,其中有n位小数,如数值宽度小于m右端补空格

④ ⭐显示失败测试点的详细信息

  • 主要编写头文件中,断言宏中要执行的LOG宏

  • haizei/test.h

    • 图片
    • ⭐actual部分的结果值类型不确定,定义泛型宏
      • _Generic(a, 替换规则):根据a的返回类型实现对应的替换
      • _Generic是C语言中的关键字,不是宏!在预处理阶段不会替换成对应类型
        • ① 在与COLOR宏连用时要十分小心!
          • 在编译阶段,字符串与一个不知道是什么的东西(_Generic())无法拼接
        • ② 不能使用C++编译器
        • ❗ 详见下面的报错一和报错二
      • 参考cpp_reference
    • 使用typeof定义额外变量
      • 所有运算部分通过额外变量,避免++操作带来的多次运算
    • 报错一(编译阶段 -c)
      • 图片
      • 对应错误写法:把TYPE(a)写在YELLOW_HL宏里
        • 图片
        • 红框②可以正常输出,但是会没有颜色
        • 如果像红框①那样在外层套一个颜色宏,编译会报错
          • 对main.c进行预处理,不会报错
          • 查看上面红框②预处理后的代码,如下
          • 图片
          • 原因:对于宏替换后的代码,("字符串" _Generic() "字符串")在编译时报错,连接不上,因为编译器此时并不知道_Generic()是啥
          • _Generic()需要在运行时才能知道结果,语法检查时字符串和莫名的东西连接,所以报错
          • 和printf()的原型的第一个输入参数类型为const char*没有很大关系,但是类型不匹配会报警告
          • 看下面这个简单的例子也许就明白了:
            • 头文件
            • 图片
            • 源文件
            • 图片
            • 编译
            • 图片
            • 同样的报错
            • 因为在编译检查语法阶段,编译器不知道s是个啥,和字符串"a"连接就会出错
            • 报错提示的是想让你把s踢出去,直接在s前面加括号
      • 所以通过sprintf()将_Generic()包装起来的方式很巧妙,在编译阶段没有问题,在运行阶段有值了自然也正常
    • 报错二(编译阶段 -c)
      • 图片
      • 图片
      • 关键信息在第二张图的error

error: '_Generic' was not declared in this scope

* 

    * _Generic只支持C语言(C11),不支持C++
        * 参考[如何启用_Generic关键字](https://www.thinbug.com/q/28253867)-ThinBug
    * 将所有文件后缀改为C语言的
        * main.cpp → main.c;test.cc→test.c
    * 修改makefile,见后
  • main.c

    • 图片
    • 测试double类型数据,验证泛型宏作用
      • 修改了函数的参数类型为double
      • 其实double判等不能直接用==,在头文件里要判等方式,用差值与极小值
  • makefile

    • 图片
    • 改用gcc

  • 输出

    • 图片

⑤ 存储函数的全局变量没有测试用例数量限制

  • 静态数组:在运行之前就开辟好了固定的空间大小,且存储的物理空间连续
  • 链表:思维上是顺序的,但在物理存储上不需要顺序
    • 由节点组成,包含:数据域、指针域
    • 占用空间动态变化
    • 但是更厉害是的是下面的方式:可以给任何结构体安上链表的外骨骼
  • ⭐⭐链表外骨骼
    • haizei/test.h
      • 图片
      • 直接在一个结构体中添加节点结构体变量node,即链表结构的外骨骼
      • node记录下一个节点(下一个TEST的node)的地址
      • 包含链表节点的头文件haizei/linklist.h
    • haizei/linklist.h
      • image-20210329094033910
      • next指向下一个节点的地址
        • 但实际是想访问下一个TEST的func和str字段
        • 可以通过访问下一个结构体的首地址再间接访问两个字段来完成
      • 如何得到一个结构体的首地址
        • 通过指针p对应字段name在结构体T中的偏移量来计算
        • offset宏!
          • 用空指针得到name字段所在的地址
            • (T *)(NULL)->name得到的是name变量
            • &得到的是T *类型指针,存的是地址
          • 转long整型即可得到偏移量
            • long类型会根据系统位数改变其范围,对应指针大小
        • Head宏!
          • 将p指针的地址转成char *类型
          • 这样±1是按照最小单位1字节来偏移的
          • p是一个指针,name是指针p在结构体T中对应的字段名
    • haizei/test.c
      • 图片
      • 尾插法,定义一个尾节点指针func_tail
      • 得到结构体首地址,利用->间接访问变量
      • malloc() 和calloc()的主要区别
        • 前者不能初始化所分配的内存空间,而后者默认将开辟的空间初始化为0

// 在堆区动态地分配一块指定大小 size 的内存空间,用来存放数据
void* malloc (size_t size);
// 在堆区动态地分配 num 个长度为 size 的连续空间,并将每一个字节都初始化为 0
void* calloc (size_t num, size_t size);

      • 同样适用strdup,复制一份字符串在新开辟的空间上,并返回其地址
      • calloc、strdup的空间需要自己去free
        • 图片
        • 上图供参考
        • ①free calloc的func空间前先保存下一节点的地址
          • 利用p->next即可
        • ⭐②从里到外free结构体变量
          • func->str strdup通过malloc出来的
          • func calloc出来的
        • ③free完让指针指向NULL,避免成为野指针
        • 释放strdup的func->str指向的空间时,需使用(void *)强转一下
        • free结构体要注意细节,详见后文思考点:free结构体的细节
        • 查看func里变量的地址
          • 图片
          • 图片
          • 按8字节对齐
          • 打印func->str打印的是strdup出来的地址,打印&(func->str) 打印的是结构体对象中成员str的地址

⑥函数指针变量和函数名定义时的宏优化

附加知识点

  • 函数声明和主函数往上放,函数定义放后面,可以让代码框架、逻辑更清晰

  • 简易工程文件结构规范

    • 使用tree工具
  • 图片
  • make的规律

    • 如果makefile中有依赖文件做了修改
      • 直接make,相关文件就会自动重新编译,而可以不需要make clean做清理
    • 如果只是修改了makefile,而想重新生成对象文件
      • 一般要先make clean,再使用make重新生成新的对象文件,否则只是重新生成最顶层all的输出
  • 可执行程序一般放在一个固定的目录下:bin

  • 宏内注释

    • 单行宏:可以在后面直接使用 // 注释
    • 多行宏:只能使用 /*...*/ 注释
  • 头文件只写函数的声明

  • 宏嵌套宏

  • ⭐__attribute__((constructor)),详见实现自己的测试框架-版本一

  • C语言一行太长的换行处理-CSDN

  • ❗ 宏定义中#的细节

    • 字符串化操作符
    • 作用:将宏定义中的传入参数名转换成用一对双引号括起来参数名字符串
    • 只能用于有传入参数的宏定义中,且必须置于宏定义体中的参数名前
  • __typeof__()、__typeof()、typeof() 的区别-CSDN

    • 推荐使用带下划线的

思考点

  • 宏函数可不可以重复定义
    • 函数定义放头文件里,不同文件多次编译会出现函数重复定义的情况
    • 而把定义成函数的宏扔在头文件里就没事?
      • 没事

      • 宏函数重复定义没问题,如下

        • 图片
        • 对于这种情况,不要发生函数重名(a##__haizei__##b)的情况

    • 但是!宏不可以重定义,即不能修改之前的定义
  • 宏定义不用考虑先后顺序!&&宏嵌套问题
#define _ToStr(x) #x 
#define __ToStr(x) _ToStr(x) 
#define EarthQuake 9.0 
  
int main(){ 
    printf("%s\n", _ToStr(EarthQuake);   // EarthQuake
    printf("%s\n", __ToStr(EarthQuake);  // 9.0
    return 0;
} 
    • 替换顺序
      • 从外层到里层,但遇到#或者##就会停止展开
      • 第一种:→#EarthQuake→"EarthQuake"
      • 第二种
        • 先替换第一层:→_ToStr(EarthQuake)→_ToStr(9.0)
        • 再替换第二层:_ToStr(9.0)→"9.0"
    • 嵌套定义:#define __ToStr(x) _ToStr(x)
    • 嵌套调用:__ToStr(EarthQuake)
  • ❗free结构体的细节
    • free(p)不会改变p变量本身的值,调用free()后它仍然会指向相同的内存空间,但是此时该内存已无效,不能被使用
    • 所有动态分配的空间都需单独释放,从结构体里到外释放
      • 结构体在堆空间,而结构体内也有变量在堆空间,需要先free成员变量,最后free这个结构体

Tips

  • aka的中文意思是“又名”
  • 不只是看船长秀,自己要思考怎么优化?怎么开发?怎么变成自己的知识点?
  • 坚持不住,才要坚持,往往这是最有价值的
  • 编译报错时,从上到下看报错信息,后面的报错也可能源于前面

课程速记