前面我们已经学习了:
数组
函数
指针
结构体
动态内存
文件操作
随着程序越来越大,会遇到一个问题:
一个 C 文件
↓
几百行
↓
几千行
↓
越来越难维护
例如:
void print_student(...);
void add_score(...);
void save_student(...);
void load_student(...);如果全部写在:
main.c
里面,代码会越来越乱。
这时候就需要:
预处理器
+
头文件
+
多个 .c 文件
一、什么是预处理
C 程序在真正编译之前,会先经过一个:
预处理阶段。
可以简单理解成:
.c 源代码
↓
预处理
↓
预处理后的代码
↓
编译
↓
目标文件
↓
链接
↓
可执行文件
预处理器主要处理:
#开头的指令。
例如:
#include <stdio.h>
#define PI 3.14159这些都属于:
预处理指令。
二、预处理指令
常见的预处理指令:
#include
#define
#undef
#if
#ifdef
#ifndef
#else
#elif
#endif
后面还会看到:
#pragma
#error
本章先掌握主要部分。
三、#include
前面经常看到:
#include <stdio.h>它实际上就是:
让预处理器把指定的头文件内容包含进当前源文件。
例如:
#include <stdio.h>可以理解成:
main.c
+
stdio.h
↓
预处理后的源代码
所以 #include 并不是普通的 C 函数。
它是:
预处理指令。
四、为什么需要 #include
例如:
printf("Hello\n");printf() 的声明在:
stdio.h里面。
因此通常需要:
#include <stdio.h>同样:
malloc()需要:
#include <stdlib.h>例如:
#include <stdio.h>
#include <stdlib.h>五、系统头文件和自己的头文件
常见的系统头文件:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <math.h>使用:
< >
例如:
#include <stdio.h>而自己写的头文件通常使用:
" "
例如:
#include "student.h"可以简单记:
系统头文件
↓
#include <stdio.h>
自己的头文件
↓
#include "student.h"
六、头文件是什么
头文件通常使用:
.h
作为扩展名。
例如:
student.h
math_utils.h
config.h
头文件通常用来放:
函数声明
结构体定义
typedef
宏
常量
枚举
类型声明
例如:
// student.h
typedef struct {
char name[50];
int age;
double score;
} Student;
void print_student(Student *p);
void add_score(Student *p, double score);七、函数声明放进头文件
假设有一个函数:
void hello() {
printf("Hello\n");
}如果其他 .c 文件需要使用这个函数,可以把:
void hello();放到:
hello.h
例如:
// hello.h
void hello();然后:
// hello.c
#include <stdio.h>
#include "hello.h"
void hello() {
printf("Hello\n");
}最后:
// main.c
#include "hello.h"
int main() {
hello();
return 0;
}这样:
main.c
↓
#include "hello.h"
↓
知道 hello() 的声明
↓
调用 hello()
真正的函数实现则在:
hello.c
里面。
八、声明和定义
这个概念非常重要。
例如:
void hello();这是:
声明(declaration)
表示:
有一个叫 hello 的函数
它的参数和返回值是这样
而:
void hello() {
printf("Hello\n");
}这是:
定义(definition)
真正提供函数实现。
可以简单记:
声明
↓
告诉编译器“有这个东西”
定义
↓
真正创建/实现这个东西
九、为什么头文件放声明
假设:
main.c
hello.c
hello.h
其中:
hello.h
↓
声明
hello.c
↓
实现
main.c
↓
使用
结构:
main.c
│
└── include
↓
hello.h
↓
知道函数声明
hello.c
│
└── 实现函数
这样就把代码拆开了。
十、一个完整的多文件例子
目录:
project/
├─ main.c
├─ student.c
└─ student.h
student.h:
#ifndef STUDENT_H
#define STUDENT_H
typedef struct {
char name[50];
int age;
double score;
} Student;
void print_student(Student *p);
void add_score(Student *p, double score);
#endifstudent.c:
#include <stdio.h>
#include "student.h"
void print_student(Student *p) {
printf("姓名: %s\n", p->name);
printf("年龄: %d\n", p->age);
printf("成绩: %.2f\n", p->score);
}
void add_score(Student *p, double score) {
p->score += score;
}main.c:
#include "student.h"
int main() {
Student s = {
"Alice",
18,
95.5
};
print_student(&s);
add_score(&s, 2.0);
print_student(&s);
return 0;
}现在:
main.c
↓
负责主程序
student.c
↓
负责学生相关函数
student.h
↓
负责对外提供声明
这就是最基础的:
多文件编程。
十一、#define
#define 用来定义:
宏(macro)
例如:
#define PI 3.14159之后:
printf("%f\n", PI);预处理阶段会把:
PI
替换成:
3.14159
可以简单理解为:
#define PI 3.14159
PI
↓
3.14159
十二、为什么使用 #define
例如:
#define MAX_SIZE 100之后:
int arr[MAX_SIZE];如果以后想改成:
200
只需要修改:
#define MAX_SIZE 200而不用到处寻找:
100
十三、宏常量
常见写法:
#define MAX_STUDENTS 100
#define NAME_SIZE 50
#define PI 3.1415926例如:
char name[NAME_SIZE];这样可以提高代码的可维护性。
十四、#define 和变量的区别
例如:
#define MAX_SIZE 100和:
int max_size = 100;并不是一回事。
#define:
预处理阶段
↓
文本替换
变量:
编译后
↓
真正存在于程序的数据
例如:
#define MAX_SIZE 100它本身并不是一个 int 变量。
十五、宏函数
#define 还可以定义带参数的宏。
例如:
#define MAX(a, b) ((a) > (b) ? (a) : (b))然后:
printf("%d\n", MAX(10, 20));结果:
20
十六、为什么宏需要括号
不要随意写:
#define SQUARE(x) x * x然后:
SQUARE(2 + 3)展开以后可能变成:
2 + 3 * 2 + 3结果不是:
25
而是:
11
更安全的写法:
#define SQUARE(x) ((x) * (x))这样:
SQUARE(2 + 3)展开为:
((2 + 3) * (2 + 3))结果:
25
十七、宏的另一个问题
例如:
#define SQUARE(x) ((x) * (x))然后:
int i = 3;
SQUARE(i++);展开后相当于:
((i++) * (i++))i 被增加了两次。
所以:
宏函数并不等同于普通函数。
宏只是预处理阶段的替换。
十八、宏和函数怎么选
普通计算通常推荐使用:
int max(int a, int b);而不是到处使用复杂宏。
#define 更适合:
简单常量
条件编译
头文件控制
一些确实需要预处理器的场景
例如:
#define MAX_STUDENTS 100就很常见。
十九、#undef
可以取消一个宏:
#define MAX_SIZE 100
#undef MAX_SIZE之后:
MAX_SIZE
就不再是这个宏。
一般使用不多,但应该知道它存在。
二十、条件编译
预处理器还可以根据条件决定:
哪些代码参与编译。
例如:
#ifdef DEBUG
printf("debug\n");
#endif如果定义了:
DEBUG那么代码会保留。
如果没有定义:
这段代码不会进入后面的编译过程
二十一、#ifdef
例如:
#define DEBUG
#ifdef DEBUG
printf("Debug Mode\n");
#endif因为:
DEBUG已经被定义,所以:
printf("Debug Mode\n");会参与编译。
二十二、#ifndef
#ifndef:
if not defined
意思是:
如果没有定义。
例如:
#ifndef DEBUG
printf("DEBUG 没有定义\n");
#endif二十三、头文件保护
这是头文件非常重要的内容。
例如:
#ifndef STUDENT_H
#define STUDENT_H
// 头文件内容
#endif这叫:
头文件保护(include guard)
二十四、为什么需要头文件保护
假设:
#include "student.h"
#include "student.h"同一个头文件可能被重复包含。
如果头文件里面有:
typedef struct {
...
} Student;就可能出现重复定义问题。
于是使用:
#ifndef STUDENT_H
#define STUDENT_H
...
#endif逻辑是:
第一次包含
↓
STUDENT_H 没有定义
↓
定义 STUDENT_H
↓
处理头文件内容
第二次包含
↓
发现 STUDENT_H 已经定义
↓
跳过内容
二十五、标准头文件为什么经常不用自己写保护
你可能会看到:
#include <stdio.h>却没有看到你自己写:
#ifndef这是因为标准库头文件本身已经按照相应方式处理了重复包含问题。
我们自己写头文件时,应该养成:
#ifndef XXX_H
#define XXX_H
...
#endif的习惯。
二十六、include guard 的命名
例如:
student.h
可以:
#ifndef STUDENT_H
#define STUDENT_H例如:
math_utils.h
可以:
#ifndef MATH_UTILS_H
#define MATH_UTILS_H常见习惯:
文件名
↓
转大写
↓
. 改成 _
↓
加 _H
二十七、条件编译中的 #else
例如:
#ifdef DEBUG
printf("Debug\n");
#else
printf("Release\n");
#endif表示:
DEBUG 已定义
↓
Debug
DEBUG 没定义
↓
Release
二十八、#if
也可以使用:
#if例如:
#define VERSION 2
#if VERSION >= 2
printf("Version 2\n");
#endif预处理器会判断这个条件。
二十九、#elif
可以有多个条件:
#if VERSION == 1
printf("Version 1\n");
#elif VERSION == 2
printf("Version 2\n");
#else
printf("Other Version\n");
#endif结构:
#if
↓
条件 1
#elif
↓
条件 2
#else
↓
其他情况
#endif
三十、defined
可以使用:
defined判断宏是否存在。
例如:
#if defined(DEBUG)
printf("Debug\n");
#endif也可以写:
#ifdef DEBUG两者在很多简单场景下效果相近。
三十一、多个配置
例如:
#define WINDOWS然后:
#ifdef WINDOWS
printf("Windows\n");
#elif defined(LINUX)
printf("Linux\n");
#endif这样可以根据不同平台编译不同代码。
三十二、预处理器不是编程语言运行时
例如:
#define A 100并不是程序运行以后:
A = 100
而是在程序正式编译之前:
A
↓
100
就已经被处理掉了。
所以:
#define
#ifdef
#include都发生在:
预处理阶段。
三十三、头文件中可以放什么
常见:
#ifndef STUDENT_H
#define STUDENT_H
typedef struct {
char name[50];
int age;
double score;
} Student;
void print_student(Student *p);
void add_score(Student *p, double score);
#define MAX_STUDENTS 100
#endif这里有:
结构体定义
函数声明
宏
这三类东西都非常常见。
三十四、头文件中不要随意放函数实现
例如:
// student.h
void print_student(Student *p) {
printf(...);
}对于普通非 static
函数,这样设计很容易在多个源文件包含时造成重复定义。
更常见的做法:
student.h
↓
函数声明
student.c
↓
函数定义
也就是:
// student.h
void print_student(Student *p);// student.c
void print_student(Student *p) {
...
}三十五、extern
如果多个 .c 文件需要访问一个全局变量,可以使用:
extern例如:
// config.h
extern int max_students;然后在某个 .c 文件中真正定义:
// config.c
int max_students = 100;其他地方:
#include "config.h"
printf("%d\n", max_students);这里:
extern
↓
声明这个变量在别的地方定义
三十六、声明和定义再次理解
例如:
extern int max_students;这是:
声明
真正的定义:
int max_students = 100;只有一个。
可以理解为:
config.h
↓
告诉大家
“有一个 max_students”
config.c
↓
真正创建
“max_students”
这和:
函数声明
+
函数定义
是类似思想。
三十七、头文件的典型结构
推荐:
#ifndef STUDENT_H
#define STUDENT_H
#include <stdio.h>
typedef struct {
char name[50];
int age;
double score;
} Student;
void print_student(Student *p);
void add_score(Student *p, double score);
#endif然后:
student.h
专门负责:
Student 类型
函数声明
必要的宏
而:
student.c
负责:
函数实现
三十八、项目结构
例如:
my_project/
│
├─ main.c
│
├─ student.c
├─ student.h
│
├─ file.c
├─ file.h
│
└─ config.h
可以按照功能拆开。
例如:
student
↓
学生相关代码
file
↓
文件相关代码
config
↓
程序配置
main
↓
主程序
这样比全部写在一个:
main.c
里面更加容易维护。
三十九、编译多个 .c 文件
例如项目:
main.c
student.c
使用 GCC 可以:
gcc main.c student.c -o main这里:
main.c
+
student.c
↓
一起编译、链接
↓
main
如果有:
file.c
student.c
main.c
则:
gcc main.c student.c file.c -o main四十、头文件不会单独生成程序
例如:
student.h
不能理解为:
student.h
↓
单独编译成程序
而是:
main.c
↓
#include "student.h"
↓
获得声明等内容
↓
编译
student.c
↓
编译
↓
链接
所以真正参与编译和链接的主体通常是:
.c
而:
.h
主要提供接口和声明。
四十一、预处理、编译、链接
现在可以把整个过程理解得更完整:
main.c
student.c
student.h
│
↓
预处理
│
↓
预处理后的源代码
│
↓
编译
│
↓
目标文件 .o / .obj
│
↓
链接
│
↓
可执行文件
其中:
#include
#define
#if
#ifdef
主要属于:
预处理阶段。
而:
gcc main.c student.c
最终还会进行:
编译
+
链接
四十二、一个完整的多文件项目
目录:
project/
├─ main.c
├─ math.c
└─ math.h
math.h:
#ifndef MATH_H
#define MATH_H
int add(int a, int b);
int multiply(int a, int b);
#endifmath.c:
#include "math.h"
int add(int a, int b) {
return a + b;
}
int multiply(int a, int b) {
return a * b;
}main.c:
#include <stdio.h>
#include "math.h"
int main() {
printf("%d\n", add(10, 20));
printf("%d\n", multiply(10, 20));
return 0;
}编译:
gcc main.c math.c -o main运行:
30
200
现在:
main.c
↓
程序入口
math.c
↓
数学函数实现
math.h
↓
数学函数声明
四十三、与前面知识的连接
到这里:
函数
↓
函数声明
↓
头文件
↓
多个 .c 文件
结构体:
结构体定义
↓
放入 .h
↓
多个 .c 使用
动态内存:
malloc()
↓
可以放在某个功能模块中
文件:
fopen()
fread()
fwrite()
↓
也可以单独放在 file.c 中
最终:
main.c
│
├── student.h
│ ↓
│ student.c
│
├── file.h
│ ↓
│ file.c
│
└── config.h
这就是一个真正开始模块化的 C 项目。
四十四、练习
练习 1
创建:
hello.h
hello.c
main.c
要求:
hello.h
↓
声明 hello()
hello.c
↓
实现 hello()
main.c
↓
调用 hello()
练习 2
创建:
math.h
math.c
main.c
实现:
int add(int a, int b);
int sub(int a, int b);
int mul(int a, int b);然后在 main.c 中调用。
练习 3
使用:
#define MAX_SIZE 100创建一个:
int arr[MAX_SIZE];练习 4
自己写一个:
config.h
定义:
#define MAX_STUDENTS 100
#define NAME_SIZE 50然后在其他 .c 文件中:
#include "config.h"使用。
练习 5
创建:
student.h
student.c
main.c
student.h:
Student 结构体
print_student 声明
add_score 声明
student.c:
实现函数
main.c:
创建 Student
调用函数
练习 6
给头文件加入:
#ifndef
#define
#endif观察重复 #include 是否还会产生重复定义问题。
练习 7
综合练习:
制作一个:
课程表项目
目录:
course/
├─ main.c
├─ course.c
├─ course.h
├─ file.c
├─ file.h
└─ config.h
设计:
course.h
↓
课程结构体
课程相关函数
file.h
↓
文件读写函数
config.h
↓
程序配置
最终:
main.c
↓
调用各个模块
这已经非常接近真实 C 项目的结构了。
Comments NOTHING