1. 再谈头文件与编译
头文件类似于一个索引,当在一个文件中引入一个头文件时,这个文件就可以直接调用头文件中定义过的函数。
头文件编写需要注意:
- 组织性:通过头文件可以清晰组织其他文件,一个头文件应该专注处理特定部分的内容;
- 重用性:一个头文件中的函数功能,在其他项目或功能中也可以引入并使用;
- 编译效率:文件非常多时,应当注意把声明函数放在头文件中,把实现函数放在源文件中,提高增量编译效率;
- 避免重复包含:避免出现重定义函数。
2. 编写头文件:函数声明和函数实现
编译时,会先编译头文件,如果头文件没有发生变化,则跳过其编译,可以节省资源和时间。
函数在.h文件中声明,在.c文件中定义。在Visual Studio中,可以在“Header Files”中添加.h头,在“Source Files”中添加.c文件。
规范的头文件需要前后写#ifndef与#endif等宏定义,例如math_operations.h文件:
1 |
|
写标准宏定义的目的是做头文件保护(include guard),防止math_operations.h被重复包含导致重定义错误。
作用流程:
- 第一次包含时,
MATH_OPERATIONS_H未定义,进入文件内容并定义它。 - 再次包含时,
MATH_OPERATIONS_H已定义,整个头文件内容被跳过。
在Visual Studio里也可用#pragma once达到同类目的。
通常来说,标准的头文件前面需要有规范的注释,包括头文件名、公司名、头文件作用等。
1 | /** |
在同名.c文件中,需要写被定义的函数的具体实现,此时需要先引入对应的头文件,引入自定义编写的头文件时要用双引号。例如math_operations.c文件:
1 | #include "math_operations.h" |
在其他文件中想要调用这些函数,需要在对应的文件中包含头文件,同样使用双引号。
1 |
|
3. 泛型编程:比较与排序
设计概述:
- 抽象层
- 策略模式
- 模块化
- 安全性和跨平台
泛型编程可以编写完全一般化并可重复使用的算法,其效率与针对某特定数据类型而设计的算法相同。
例如要编写具有比较两个数大小功能的函数,按照以前的写法,比较整型和浮点型的函数需要分别写,这样两个函数实现的功能有重复,效率低下。这时使用泛型编程可以解决这个问题,使代码具有高可用性。
在泛型编程中,将参数写成无类型指针void*类型,代表此参数可以为任意类型。
1 |
|
可以使用泛型编程来实现对任意类型的数组进行排序,排序函数传入泛型数组、元素大小、元素个数与指向与比较函数类型相同的函数指针。
1 |
|
qsort是C标准库提供的通用排序函数,可对任意类型数组排序,它通过“比较函数”决定排序规则(升序、降序、按某字段排序等)。名字来自“quick sort”,但标准并不要求必须使用快速排序实现。
函数原型:
1 | void qsort( |
base:数组首地址nmemb:元素个数size:每个元素大小(字节)compar:比较函数
比较函数返回规则(比较函数必须返回):
<0:前者排在后者前面=0:两者相等>0:前者排在后者后面
4. 企业案例:自定义函数处理比较器
这个案例将创建几个自定义的比较函数,用来根据一定的规则对输入的数组进行排序。
使用泛型编程可以实现一个排序函数接收多种不同类型的数组,提高了代码的重用性。
某些时候架构师会预留一些参数,例如比较函数指针定义中,除默认的两个比较值外,可以多写一个泛型参数context,作为用户自定义的上下文,可以在比较函数中使用,在需要时提供额外的信息或参数。
如果不需要使用context参数,可以直接传递NULL。
在sort.h文件中,定义比较函数指针与声明排序函数:
1 |
|
在person.h中,定义需要排序的Person结构体:
1 |
|
在compare.h中,声明各种需要用到的比较函数,函数结构要和sort.h文件中定义的函数指针类型匹配:
1 |
|
在compare.c中,写头文件中声明过的比较函数的具体实现:
1 |
|
在使用
NULL时,需要包含stddef.h头文件。
最后,在sort.c文件中实现通用排序函数,需要考虑跨平台运行的问题,Windows平台有排序函数qsort_s而其他平台需要用qsort_r函数进行排序。
qsort_s是带“用户上下文”的通用排序函数,相比qsort多了一个context参数,便于比较函数拿到额外信息。
函数签名为:
1 | void qsort_s(void* base, rsize_t num, rsize_t size, compare_fn cmp, void* context); |
参数含义:
base:数组首地址(void*,可排序任意类型)num:元素个数size:每个元素字节数cmp:比较函数context:透传给比较函数的用户上下文
比较函数的签名是:
1 | int cmp(void* context, const void* a, const void* b); |
返回值约定:
<0:a 在前=0:等价>0:b 在前
工作原理:
qsort_s接收base/num/size/cmp/context- 排序算法内部不断挑两个元素比较
- 每次比较时,调用:
cmp(context, elemA, elemB) elemA/elemB是指向数组元素的地址(const void*)- 比较函数里把它们强转回真实类型再比较
因此,完整的排序实现为:
1 |
|
在main函数中,包含需要的头文件后即可使用排序函数对多种类型的数组进行排序:
1 |
|
这样,就实现了一个比较规范的自定义函数处理比较器。
5. 指针的作用域和生命周期
在函数中定义的是局部指针,只能在函数内部使用,这个函数运行结束后,这个指针就会被销毁。
在文件开始,函数之外声明的指针为全局指针,可以在整个文件中被赋值与使用。
1 |
|
6. 悬挂指针(Dangling pointer)
在使用free释放在堆上的内存区域时,指向这块内存区域的指针将变成悬挂指针,如果此时再次访问悬挂指针,非常危险,会出现未定义行为,不应该在实际编程中出现。
因此,为避免悬挂指针的出现,在free后应当将指针设置为NULL。
1 |
|
7. 可变参数(Variadic function final)
C语言中的参数可以设置以为动态可变,用于向函数中传递不固定数量的多个参数,典型的例子是printf函数。
可变参数依赖头文件stdarg.h,有四个核心宏定义,其中参数访问变量ap必须声明为va_list类型,并配合以下接口使用:
va_list:保存参数遍历状态va_start(ap, last):从最后一个固定参数last后开始取可变参数va_arg(ap, type):按指定类型取下一个参数va_en(ap):结束清理
可选的还有va_copy(dst, src),用于复制一个参数遍历状态。
例如一个接收可变参数的计算多个数的平均值函数。
1 |
|
常用到可变参数的函数包括格式化输出(例如printf),字符串拼接(例如snprintf),数学和统计中的一些函数(例如平均值),图形编程中的一些函数,一些输出log日志的函数,错误处理、异常捕获相关的函数以及自定义API框架等。
8. 练习:自定义日志函数
可以自定义一个日志函数来练习可变参数的使用。
日志函数需要包含日志级别、格式化字符串、自动添加时间戳等功能。
可变参数中,可以使用vprintf(format, args)函数将接收到的可变参数格式化并打印出来。
1 |
|
9. assert断言
assert是C标准库assert.h提供的运行时断言,用于检查“程序在此处必须成立的条件”。
当条件为假时,assert会输出失败信息(表达式、文件名、行号等)并终止程序;条件为真时不做任何事。
常见用途:
- 校验函数前置条件(如指针非空)
- 校验不应该被破坏的不变量
- 在开发/调试阶段快速暴露逻辑错误
需要注意的是,assert主要用于开发期的检查,不是用户输入的错误处理机制。
断言仅在“Debug”模式下生效,在将项目生成的版本改为“Release”后,断言将不再生效。
在“Debug”模式下,在文件开头定义宏#define NDEBUG后,assert(...)会被编译为空操作,断言也会失效。
1 |
|
在第二次断言时,断言失败,程序运行后直接弹窗报错,并输入断言失败的相关信息:
1 | Assertion failed: num == 5, file E:\HC\Practice\C\learn\main.c, line 12 |
10. 断言的debug与练习
练习:在一个给定整数数组中寻找到元素最大值。
1 |
|
由此可见,在开发过程中判断问题出现的位置时,有时候断言比打印输出更有用,更容易找到问题出现的位置。
11. 企业案例:日志系统与指针问题处理的架构设计
企业案例:日志错误系统
包含模块:
- 跨平台的基础数据类型
- 日志模块
- 日志级别模块
- 封装内存分配和释放安全操作
- 统一错误处理策略,记录错误、警告、致命错误
- 检查指针,根据指针错误类型不同记录不同的日志
- 包含主要运行逻辑
在开始之前,对于跨平台的基础数据类型,可以写一个types.h头文件重新定义一下,方便后续程序中提升代码可读性与书写效率。
1 |
|
11.1 logger
接下来,就可以开始写日志系统的核心逻辑。
先创建声明日志相关函数的头文件logger.h:
1 |
|
在logger.c中写对这些声明函数的具体实现,这里用到很多前面学过的知识,包含文件流、时间戳的使用等。
1 | /** |
11.2 内存管理
在用于内存管理的memory_manager.h头文件中声明相关函数:
1 |
|
在memory_manager.c中对已声明的函数进行实现:
1 | /** |
11.3 error_handling
头文件error_handling.h:
1 |
|
函数实现文件error_handling.c:
1 | /** |
11.4 pointer_safety空指针、野指针、悬挂指针的处理
此处的头文件pointer_safety.h用到了函数式宏,具有以下优点:
- 零调用开销:预处理阶段直接展开,没有函数调用栈开销(尤其在小逻辑里常用)。
- 类型泛化:不依赖固定参数类型,同一个宏可用于多种指针类型(
int、float等)。 - 可嵌入表达式:可以像普通表达式一样写在赋值、返回、条件中,使用灵活。
不过也有代价:可读性和调试性较差、可能重复求值,带副作用参数要特别小心。
如果宏太长,一个单行容纳不下,则使用宏延续运算符
\。需要把一个宏的参数转换为字符串常量时,使用字符串常量化运算符
#。例如:
1
2在
main函数中:
1 message_for(Bob, Alice);输出:
1 Bob和Alice是好朋友!
1 |
|
在pointer_safety.c文件中实现声明的函数。
1 | /** |
11.5 application_logic模块
一般比较规范的程序的启动逻辑不会都放在main函数中,会有专门的启动逻辑函数。
头文件application_logic.h:
1 |
|
在函数实现application_logic.c中模拟数据的处理和日志的输出。
1 | /** |
11.6 测试
所有工具函数库都编写完成后,即可对程序功能进行一个简单的测试。
在main函数中:
1 |
|
测试成功,程序正常运行,控制台打印出了正确的日志。
11.7 写入文件
在application_logic.c中,为文件输出路径写宏定义:#define PATH "application.log",使用相对路径时,如果只写文件名,则log文件会在项目的根目录生成。
修改app_init()函数,将PATH传入logger_init函数,即可将日志输出到文件中。
运行程序进行测试,检查项目根目录,发现成功生成application.log文件,文件中成功输出了正确的日志。
12. 环境变量的读写
环境变量的读取需要用到getenv_s函数,包含在标准库头文件stdlib.h中。
函数原型:
1 | errno_t getenv_s( |
参数说明:
pReturnValue:返回写入所需字符数(包含结尾\0)buffer:输出缓冲区。可传NULL(用于只查询长度)numberOfElements:buffer的容量(字符数,不是字节数;char下通常等同字节数)varname:环境变量名,如"PATH"
返回值和含义:
返回errno_t:
- 0:调用成功(不代表变量一定存在)
- 常见非0:
ERANGE(常见值34):缓冲区太小EINVAL:参数非法
结合*pReturnValue判断状态:
err == 0 && required > 0:变量存在,已读取err == 0 && required == 0:变量不存在或为空(按实现语义处理)
例如读取读取常见的环境变量PATH:
1 |
|
需要注意的是,此时的缓冲区buffer为固定值,当环境变量的长度大于buffer的长度时,则报错误码34,超出缓冲区,无法读取。
因此,正确的用法应该是先传递NULL,查询要读取环境变量的长度,按照读取到的长度分配内存空间后再读取。
1 |
|
写入或删除环境变量时,可以使用_putenv_s函数,也包含在标准库头文件stdlib.h中。
函数原型:
1 | errno_t _putenv_s(const char* name, const char* value); |
参数语义:
name:变量名(如“MY_KEY”)value:变量值- 普通字符串:设置/覆盖变量
- 空字符串
“”:删除变量
返回值为0时,成功,否则失败。
作用范围:
- 只影响当前进程的环境变量表
- 对当前进程后续创建的子进程可见
- 不会永久写入系统/用户环境(程序结束后通常失效)
1 |
|
13. 命令行参数
在命令行中使用一些命令,例如ipconfig时,可以通过-向软件传递一些参数,为用户提供相应的功能。
C语言命令行参数通过main函数的参数接收,常见写法:
1 | int main(int argc, char* argv[]) |
argc:参数个数(至少为1)argv:参数字符串数组argv[0]:程序名/路径argv[1]...argv[argc-1]:传入的自定义参数
一个简单的打印所有输入参数的示例:
1 |
|
程序编译完成后,在命令行中向exe程序传递参数后运行,即可将传递的参数打印出来。
14. 小案例:命令行程序的编写
案例:一个命令行程序,当输入参数-a时,程序会把后面输入的所有数字加起来并输出它们的和。
atoi是C标准库函数,用于把字符串转换成int类型的数据。
函数原型:int atoi(const char* str);
行为:
- 跳过前导空白
- 识别可选正负号
+/- - 读取后续数字并转换
- 遇到第一个非数字字符停止
用例:
- “123”->123
- “-42abc”->-42
- “abc”->0
1 |
|
在命令行中测试成功。
15. 案例:自定义泛型队列
前面学过,如果函数返回或传入类型为void*类型的参数,称之为泛型。
除此之外,结构体也属于自己定义的类型,因此如果一个函数返回一个结构体,也可以称之为泛型。
15.1 节点结构模块
可以先写一个类型定义头文件type_definitions.h,定义一些常用数据类型的别名以及数据类型枚举和联合体。
1 | /** |
在generic_queue.h中声明泛型队列结构体与公共接口函数:
1 |
|
在generic_queue.c中实现头文件中的函数:
1 | /** |
这些函数已经覆盖了队列的核心操作(创建、销毁、入队、出队、判空、获取长度),理解它们的实现逻辑有助于掌握队列这种数据结构及其常见使用方式。
15.2 安全内存处理模块
在memory_management.h中进行内存管理模块公共接口声明。
1 |
|
在memory_management.c中进行内存管理模块实现。
在Windows平台下,可以使用_msize检查内存块的大小。
1 | /** |
在type_safety_and_error_handling.h中进行类型安全与错误处理模块公共接口声明。
1 |
|
在type_safety_and_error_handling.c中进行类型安全与错误处理模块实现。
1 | /** |
15.3 测试运行模块
实际开发中,进行一个功能的测试,需要写专门的测试用例,用可重复、可验证的方式证明代码行为正确。
在test_and_validation.h中进行测试与验证模块公共接口声明。
1 |
|
在test_and_validation.c中进行测试与验证模块实现。
1 | /** |
最后,在main函数中调用测试函数,并将需要测试的测试用例传入,即可对程序功能进行完整测试。
1 |
|
本文链接: https://hanqingjiang.com/2026/03/25/20260325_C_genericAndComprehensive/
版权声明: 本作品采用 CC BY-NC-SA 4.0 进行许可。转载请注明出处!
