2-6 将井字棋拆分成多个文件
⏱ 本节预计用时:30分钟
本节意义:上一节的井字棋最终版有300多行,读起来让人头皮发麻。这一节,我们把一个文件拆成好几个小文件,每个文件只管一件事。
这不只是"代码变整洁",更是真实开发的基本功------真实项目里一个团队很多个人同时写一个项目,每人维护自己的文件,最后合到一起编译。为了团队合作方便,多文件模块化开发是必须的。
本节目标 :理解头文件(.h)和源文件(.c)的分工;会用 #include 和头文件保护 #ifndef;了解 Model-View 架构;把上一节的井字棋拆成多文件。
1 C语言中的两类文件:头文件和源文件
一个C项目里,文件分成两种:
- 头文件(.h):只放"声明"------告诉别人"我这里有什么"。比如类型定义(enum、struct)、函数声明、全局变量声明。
- 源文件(.c):放"实现"------真正干活的代码。函数的函数体、全局变量的定义。
类比 :头文件是图书馆的目录 ,告诉你"这本书在几楼几号架";源文件是书本身,目录不会把整本书抄一遍,但你有目录就能找到书。
为什么要分开?还是为了方便开发,别人要用你的代码时,只需要看头文件就知道能调用哪些函数(这个模块有哪些功能),不需要看几百行长长的具体函数逻辑。
你在给别人代码时,不用给源文件,只需要给头文件和对应的二进制文件就能编译运行了,这样代码就不会泄密。
2 单文件拆成多文件要掌握的语法
1.1 #include
#include 的本质就是复制粘贴,就是"把后边跟着的头文件的内容,原封不动地粘贴到这里"。这是编译器在正式编译之前,做的第一步工作。
c
#include <stdio.h>
等价于把 stdio.h 这个文件的所有内容复制粘贴到这个文件里。
你现在知道为什么printf 能用了吧?因为 stdio.h 里声明了 printf 这个函数,你把它复制进来,编译器就知道 printf 存在。
如果你不include stdio.h,编译器在编译main.c时,就不知道有printf这个函数,就会报错。
如图所示:

1.2 将单文件程序改成多文件
ok,接下来让我们举一个简单的单文件程序例子:
c
#include <stdio.h>
int add(int, int);
int main() {
printf("%d",add(3, 5));
return 0;
}
int add(int a, int b) {
return a + b;
}
运行结果:

拆分成多文件是这样:
main.c:
c
#include "add.h" /* 双引号:先在"当前文件夹"找,找不到再去系统目录,这是自己写的头文件 */
#include <stdio.h> /* 尖括号:去"系统目录"找,这是标准库的头文件 */
int main() {
printf("%d",add(3, 5));
return 0;
}
add.h:
c
int add(int, int);
add.c:
c
#include "add.h"
int add(int a, int b) {
return a + b;
}
运行结果:

1.3 头文件保护:别重复粘贴
#include 是复制粘贴,那问题来了------如果两个文件都 #include 了同一个头文件,而这个头文件又被第三个文件包含,会不会出现"同一个东西被定义了两次"?
会的。比如你写了一个结构体Student定义在 struct.h里,再让test1.h和test2.h这两个.h文件同时导入这个头文件,然后再让test1.h和test2.h同时被main包含。
在编译开始时,main.c开头就会出现两个一样的Student定义,编译器就会报"重复定义"错误。
如图:


解决办法就是头文件保护(也叫 include guard),每个头文件里加三行话:
c
/* struct.h */
#ifndef STRUCT_H /* 1. 如果 STRUCT_H 还没被定义过 */
#define STRUCT_H /* 2. 那就定义它 */
struct Student {
int id;
int scores[3];
};
#endif /* 3. 结束 */
工作原理:
- 第一次被包含:
STRUCT_H没定义过 → 定义它 → 粘贴结构体。 - 第二次被包含:
STRUCT_H已经定义了 →#ifndef条件不成立 → 整个文件内容跳过,不重复粘贴。
#ifndef 的意思是 if not defined(如果没定义过)。这套 #ifndef / #define / #endif 组合,你可以在以后所有头文件里看到------这是每个头文件的标配。
如图:


1.4 使用其他文件里的变量:extern 声明
这里的变量指的是全局变量,即写在所有函数外边的变量。
函数可以只声明不定义(int add(int, int); 只是说"有这个东西"),全局变量也可以:
c
extern int score; /* 声明:告诉编译器"有个叫 score 的 int 变量,在别的文件里定义" */
extern 的意思是"外部的"------这个变量的实体在别的文件里。声明可以写很多次(在多个文件里各写一次),但定义只能有一次:
c
int score = 100; /* 定义:真正分配内存,只能写一次 */
3 Model-View 架构
ok,我们现在知道要拆文件,知道了c语言的文件类型,知道了拆分文件所必须的语法......现在有一个最关键的问题,怎么拆?
四个字,按功能来。
3.1 把井字棋的代码按功能分类
把所有函数和变量过一遍脑子,你会发现它们天然分成三类:
第一类:数据和规则(Model,模型)
board(棋盘数据)、players(玩家数据)、currentPlayer(轮到谁)initBoard(初始化)、initPlayers(初始化玩家)checkWin(判断胜负)、checkDraw(判断平局)
这些是游戏的"内核"------没有它们,游戏规则就不存在。
第二类:显示(View,视图)
showBoard(画棋盘)、showMainMenu(主菜单)、showGameMenu(游戏菜单)showPlayerInfo(玩家信息)、showResult(结果画面)
这些只做一件事:把数据画到屏幕上。它们完全不关心"怎么算赢"。
第三类:流程指挥(main 和 playGame 等)
main、playGame、inputPosition、readChoice
这些不干活,只安排别人干活:"先初始化,再画棋盘,然后让玩家输入......"
3.2 为什么叫 Model-View 架构
前两类合起来,就是经典的 Model-View 架构:
- Model(模型):数据和规则,是程序的"灵魂"。
- View(视图):显示,是程序的"脸"。
- 主控(main 等):流程,是程序的"大脑",负责串起 Model 和 View。
c
┌─────────────────────────────────────────────┐
│ main.c(大脑) │
│ 安排:初始化 -> 显示 -> 落子 -> 判断 │
└───────────┬──────────────────┬──────────────┘
│ │
┌────────▼───────┐ ┌──────▼────────┐
│ Model(灵魂) │ │ View(脸) │
│ game.h/game.c │ │ view.h/view.c │
│ 数据 + 规则 │ │ 只管显示 │
└────────────────┘ └───────────────┘
这样拆有什么好处?
- 改界面不动逻辑 :想把棋盘画得更好看?只改
view.c,game.c一个字母都不用动,游戏规则不会因为换了个皮肤而坏掉。 - 查错方便 :出 bug 了,先判断是"逻辑错了"(去
game.c)还是"显示错了"(去view.c)。 - 多人协作 :张三写
game.c,李四写view.c,两人同时干活不打架------只要先说好game.h里有什么接口。
4 把井字棋拆分成多个文件
按上面的架构,我们把上一章的井字棋(code/5-11井字棋最终版.c)拆成5个文件:
c
code/3-1多文件井字棋/
├── game.h ← Model 的头文件:类型定义 + 全局变量声明 + 函数声明
├── game.c ← Model 的实现:数据 + 规则
├── view.h ← View 的头文件
├── view.c ← View 的实现:所有显示
└── main.c ← 主控:游戏流程
4.1 Model层:数据+规则
4.1.1 game.h
任何文件只要 #include "game.h",就知道游戏有哪些数据,它的核心逻辑是什么样的。这就是头文件的意义。
c
/* =====================================================
* 3-1 多文件井字棋 -- 模型头文件(Model)
* 头文件只放"声明",不放"实现"。
* #ifndef / #define / #endif 是头文件保护,防止被重复包含。
* ===================================================== */
#ifndef GAME_H
#define GAME_H
/* 棋子的类型:空格、X、O */
typedef enum { CELL_EMPTY = 0, CELL_X = 1, CELL_O = 2 } CellType;
/* 玩家信息:用的棋子 + 显示符号 */
typedef struct {
CellType piece; /* 棋子在棋盘上的值:CELL_X 或 CELL_O */
char mark; /* 显示用的符号:'X' 或 'O' */
} PlayerInfo;
/* 落子位置:行号 + 列号 */
typedef struct {
int row; /* 行号 0~2 */
int col; /* 列号 0~2 */
} Position;
/* 全局变量:真正的"实体"在 game.c 里定义。
* 其他文件想用它们,只要 include 本文件(extern 声明),就能看见。 */
extern CellType board[3][3];
extern PlayerInfo players[2];
extern CellType currentPlayer;
/* 函数声明:告诉编译器"这些函数存在,实现写在 game.c 里" */
void initBoard(void);
void initPlayers(void);
int checkWin(CellType player); // 这里把参数从上一章的 int改成了CellType。
int checkDraw(void);
#endif
4.1.2 game.c
开头 #include "game.h",然后定义全局变量、实现那4个函数。
需要注意的是:board、players、currentPlayer 这三个全局变量的"实体"只在这里定义一次 。别的文件要用,靠的是 game.h 里的 extern 声明。
c
/* =====================================================
* 3-1 多文件井字棋 -- 模型实现(Model)
* 只关心"数据"和"规则",完全不负责显示。
* ===================================================== */
#include "game.h"
/* 全局变量的真正定义在这里 */
CellType board[3][3];
PlayerInfo players[2];
CellType currentPlayer;
/* 把棋盘全部清空 */
void initBoard(void)
{
int row, col;
for (row = 0; row < 3; row = row + 1) {
for (col = 0; col < 3; col = col + 1) {
board[row][col] = CELL_EMPTY;
}
}
}
/* 初始化两位玩家的信息 */
void initPlayers(void)
{
players[0].piece = CELL_X;
players[0].mark = 'X';
players[1].piece = CELL_O;
players[1].mark = 'O';
}
/* 判断指定玩家是否赢了,赢了返回 1,否则返回 0 */
int checkWin(CellType player)
{
int i;
/* 检查每一行 */
for (i = 0; i < 3; i = i + 1) {
if (board[i][0] == player && board[i][1] == player && board[i][2] == player) {
return 1;
}
}
/* 检查每一列 */
for (i = 0; i < 3; i = i + 1) {
if (board[0][i] == player && board[1][i] == player && board[2][i] == player) {
return 1;
}
}
/* 检查两条对角线 */
if (board[0][0] == player && board[1][1] == player && board[2][2] == player) {
return 1;
}
if (board[0][2] == player && board[1][1] == player && board[2][0] == player) {
return 1;
}
return 0;
}
/* 判断是否平局:棋盘全部下满且没有人赢 */
int checkDraw(void)
{
int row, col;
for (row = 0; row < 3; row = row + 1) {
for (col = 0; col < 3; col = col + 1) {
if (board[row][col] == CELL_EMPTY) {
return 0; /* 还有空位置,不算平局 */
}
}
}
return 1; /* 全部下满,平局 */
}
4.2 View层:显示界面
4.2.1 view.h
c
#ifndef VIEW_H
#define VIEW_H
#include "game.h" /* 要用 CellType、board、players,先包含模型头文件 */
void showMainMenu(void);
void showGameMenu(void);
void showBoard(void);
void showPlayerInfo(void);
void showResult(CellType winner);
#endif
4.2.2 view.c
里边一堆 printf,把 board 和 players 画出来。
c
#include <stdio.h>
#include "view.h"
/* 显示主菜单 */
void showMainMenu(void)
{
printf("=================================\n");
printf(" 井 字 棋 -- 主菜单\n");
printf("=================================\n");
printf(" 1. 开始新游戏\n");
printf(" 2. 退出游戏\n");
printf("请输入数字(1-2):");
}
/* 显示游戏菜单 */
void showGameMenu(void)
{
printf("---------------------------------\n");
printf(" 游戏菜单\n");
printf(" 1. 继续游戏\n");
printf(" 2. 返回主菜单\n");
printf("请输入数字(1-2):");
}
/* 显示棋盘 */
void showBoard(void)
{
int row, col;
printf("\n 1 2 3 <- 列号\n");
for (row = 0; row < 3; row = row + 1) {
for (col = 0; col < 3; col = col + 1) {
if (board[row][col] == CELL_X) {
printf(" X ");
} else if (board[row][col] == CELL_O) {
printf(" O ");
} else {
printf(" ");
}
if (col < 2) {
printf("|");
}
}
printf("\n");
if (row < 2) {
printf("---+---+---\n");
}
}
printf("\n");
}
/* 用结构体数组里的信息显示两位玩家 */
void showPlayerInfo(void)
{
int i;
for (i = 0; i < 2; i = i + 1) {
printf("玩家%d:玩家%d (使用 %c)\n", i + 1, i + 1, players[i].mark);
}
printf("\n");
}
/* 显示比赛结果:winner 是获胜的棋子,传 CELL_EMPTY 表示平局 */
void showResult(CellType winner)
{
printf("=================================\n");
if (winner == CELL_EMPTY) {
printf(" 棋盘已满!\n");
printf("=================================\n");
printf("平局!\n");
} else {
printf(" 恭喜!\n");
printf("=================================\n");
printf("玩家%d (%c) 获胜!\n", winner, players[winner - 1].mark);
}
}
注意 :view.c 用了 board、CELL_X,但它自己没定义------它是通过 #include "view.h" → #include "game.h" 看到这些的。这就是"目录"的作用。
以后我们要改棋盘外观的话,只用动 view.c 这一个文件。
4.3 main.c:流程指挥
main.c 是总指挥,只负责"按顺序安排":
c
/* =====================================================
* 3-1 多文件井字棋 -- 主控(Controller)
* 要数据/规则 -> 调 game.c 的函数
* 要显示 -> 调 view.c 的函数
* ===================================================== */
#include <stdio.h>
#include "game.h"
#include "view.h"
/* 主菜单的选择 */
typedef enum { MENU_NEWGAME = 1, MENU_EXIT = 2 } MainMenuChoice;
/* 游戏菜单的选择 */
typedef enum { GAME_CONTINUE = 1, GAME_MAIN = 2 } GameMenuChoice;
/* 读取一个 min 到 max 之间的数字,不合法就重新输入 */
int readChoice(int min, int max)
{
int choice;
do {
scanf("%d", &choice);
if (choice < min || choice > max) {
printf("输入不合法!请重新输入(%d-%d):", min, max);
}
} while (choice < min || choice > max);
return choice;
}
/* 让当前玩家输入落子位置,返回一个 Position 结构体。
* 位置越界或者已经有棋子,就重新输入。 */
Position inputPosition(void)
{
int row, col;
int ok;
Position pos;
do {
printf("请输入落子位置(行号 列号,例如:1 1):");
scanf("%d %d", &row, &col);
ok = 1;
if (row < 1 || row > 3 || col < 1 || col > 3) {
printf("位置超出棋盘范围,请重新输入!\n");
ok = 0;
} else if (board[row - 1][col - 1] != CELL_EMPTY) {
printf("这个位置已经有棋子了,请重新输入!\n");
ok = 0;
}
} while (ok == 0);
pos.row = row - 1;
pos.col = col - 1;
return pos;
}
/* 完整地玩一局游戏 */
void playGame(void)
{
Position pos;
int finished;
initBoard();
initPlayers();
currentPlayer = CELL_X;
finished = 0;
printf("=================================\n");
printf(" 井 字 棋 游 戏\n");
printf("=================================\n");
showPlayerInfo();
while (finished == 0) {
showBoard();
printf(">>> 轮到 [玩家%d] (%c) 落子\n", currentPlayer, players[currentPlayer - 1].mark);
showGameMenu();
if (readChoice(GAME_CONTINUE, GAME_MAIN) == GAME_MAIN) {
printf("\n已返回主菜单。\n\n");
return;
}
pos = inputPosition();
board[pos.row][pos.col] = currentPlayer;
if (checkWin(currentPlayer) == 1) {
showBoard();
showResult(currentPlayer);
finished = 1;
} else if (checkDraw() == 1) {
showBoard();
showResult(CELL_EMPTY);
finished = 1;
} else {
if (currentPlayer == CELL_X) {
currentPlayer = CELL_O;
} else {
currentPlayer = CELL_X;
}
}
}
printf("\n游戏结束,返回主菜单。\n\n");
}
/* 程序入口 */
int main(void)
{
int choice;
while (1) {
showMainMenu();
choice = readChoice(MENU_NEWGAME, MENU_EXIT);
if (choice == MENU_EXIT) {
printf("\n再见!\n");
break;
}
printf("\n");
playGame();
}
return 0;
}
编译运行,得到结果:

和上一章一模一样,玩法、画面、功能完全没变。
5 总结
- 头文件只放声明,源文件放实现 ------
.h是目录,.c是书,#include是复制粘贴 - 每个头文件都要有保护 ------
#ifndef XXX_H / #define XXX_H / #endif - 全局变量实体定义一次,别的文件用 extern 声明 ------ 定义和声明是两回事
- 按职责拆文件 ------ 数据规则(Model)、显示(View)、流程(main)分开,改一处不动其他地方