2-6 将井字棋拆分成多个文件

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. 结束 */

工作原理:

  1. 第一次被包含:STRUCT_H 没定义过 → 定义它 → 粘贴结构体。
  2. 第二次被包含: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 │
   │  数据 + 规则    │  │  只管显示      │
   └────────────────┘  └───────────────┘

这样拆有什么好处?

  1. 改界面不动逻辑 :想把棋盘画得更好看?只改 view.c,game.c 一个字母都不用动,游戏规则不会因为换了个皮肤而坏掉。
  2. 查错方便 :出 bug 了,先判断是"逻辑错了"(去 game.c)还是"显示错了"(去 view.c)。
  3. 多人协作 :张三写 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 总结

  1. 头文件只放声明,源文件放实现 ------ .h 是目录,.c 是书,#include是复制粘贴
  2. 每个头文件都要有保护 ------ #ifndef XXX_H / #define XXX_H / #endif
  3. 全局变量实体定义一次,别的文件用 extern 声明 ------ 定义和声明是两回事
  4. 按职责拆文件 ------ 数据规则(Model)、显示(View)、流程(main)分开,改一处不动其他地方
相关推荐
C++ 老炮儿的技术栈1 天前
static的作用
c语言·c++·人工智能·单片机·c
C++ 老炮儿的技术栈1 天前
char (*csConnectName)[256]; 和 char csConnectName [256] 有什么区别
java·c语言·开发语言·c++·人工智能·算法·c
C++ 老炮儿的技术栈1 天前
C++ const 的3个核心作用
服务器·c语言·c++·算法·c
从小白到软工3 天前
2-3 结构体
c
从小白到软工3 天前
2-2 函数
c
从小白到软工3 天前
2-1 二维数组
c
书生执笔画浮沉3 天前
Linux桌面端应用向鸿蒙PC迁移适配纪实
c++·qt·c·信创·海思·libc·ohos
带土13 天前
15. sscanf 函数详解
c
C++ 老炮儿的技术栈4 天前
sizeof操作符
c语言·c++·人工智能·mfc·c