1. 为什么一个“三子棋”值得花三天重写五版?
上周帮某高校实验室带本科生做编程入门实训,布置的第一个小项目就是“三子棋小游戏”。原以为这是教科书级的“Hello World”式练习——二维数组、循环判断、字符打印,顶多两小时收工。结果第三天下午,我坐在机房后排,看着屏幕上密密麻麻的报错:IndexError: list index out of range、UnboundLocalError: local variable 'winner' referenced before assignment、还有学生把'X'和'O'写成'x'和'o'导致逻辑全崩……更棘手的是,当有学生尝试加个“悔棋”功能时,整个状态管理直接塌方。
那一刻我意识到:三子棋不是玩具,而是一面照妖镜——它能瞬间照出初学者在状态建模、边界控制、输入校验和可维护性设计上的全部盲区。它表面简单,实则暗藏四重关卡:第一关是“坐标映射混乱”(用户说“第2行第3列”,代码里到底是board[1][2]还是board[2][3]);第二关是“胜利判定歧义”(对角线只判主对角?副对角漏检?空格未清导致误判平局?);第三关是“交互反馈失焦”(输赢提示混在乱码里,用户根本看不出哪步赢了);第四关也是最致命的——“功能扩展瘫痪”(想加AI、计分、历史回放?现有代码像一捆湿面条,一扯就散)。
这正是我决定重写五版的核心动因:不是为了炫技,而是要亲手拆解出一套可教学、可调试、可延展的最小可行结构。第五版最终跑通时,我把核心逻辑压缩进不到80行Python,但每行都承担明确职责——输入解析归输入层,胜负判定归规则层,界面渲染归视图层,状态流转由统一控制器调度。学生第一次看到“悔棋”按钮点击后,棋盘自动回退两步且光标精准定位到上一步位置时,那种“原来代码真能听懂人话”的眼神,比任何考试分数都真实。
提示:别急着写
print("X wins!")。先问自己三个问题:① 这个“赢”是基于当前棋盘快照计算的,还是依赖历史操作链推导的?② 如果用户输错坐标,程序是静默忽略、报错退出,还是引导重试?③ 当前代码改一行就能支持4×4棋盘吗?答不出,就先停笔。
2. 从“硬编码棋盘”到“状态驱动引擎”的四次重构
2.1 第一版:教科书陷阱——用二维列表硬扛所有逻辑
最初版本完全遵循《Python编程入门》的示范写法:
board = [[' ', ' ', ' '], [' ', ' ', ' '], [' ', ' ', ' ']]然后所有操作直奔board[row][col]。看似清爽,实则埋下三颗雷:
- 坐标系统撕裂:用户输入
1 1(习惯性从1开始编号),代码却要转成board[0][0]。学生常忘减1,或减两次,导致board[-1][-1]指向末尾元素,形成“幽灵落子”。 - 状态污染:胜负判定函数
check_win()直接遍历board,但没人检查board是否被其他函数意外修改。某次调试中,一个忘记加return的函数让board[1][1]被赋值为None,check_win()却还在拿None == 'X'做比较。 - 扩展性归零:想加“禁手规则”(如禁止三三禁手)?得在
check_win()里塞新分支;想记录每步耗时?得在每个board[row][col] = player前后插计时代码——这种“缝合怪”式修改,三天后连作者都看不懂调用链。
注意:硬编码二维列表适合演示单次运行,但绝不适合作为教学基线。它把“数据结构”和“业务逻辑”焊死在一起,学生学到的不是编程思维,而是“抄代码肌肉记忆”。
2.2 第二版:引入坐标转换器——用类封装解决第一重撕裂
我让学生用class Position封装坐标操作:
class Position: def __init__(self, row: int, col: int): self.row = row # 内部存储0基索引 self.col = col @classmethod def from_user_input(cls, user_str: str) -> 'Position': try: r, c = map(int, user_str.split()) # 用户输入1-3,转为0-2 if not (1 <= r <= 3 and 1 <= c <= 3): raise ValueError("坐标必须在1-3范围内") return cls(r-1, c-1) except (ValueError, IndexError): raise ValueError("请输入两个空格分隔的数字,如:2 3")关键改进在于:所有用户输入入口强制走from_user_input(),所有内部计算只认Position实例。这样board[move.row][move.col]的row/col永远是合法索引,再无越界风险。更重要的是,当后续要支持5×5棋盘时,只需改from_user_input()的范围检查,Position类本身完全不用动。
但第二版仍卡在“状态污染”上。check_win()依然直接读board,而board是全局变量。于是第三版来了。
2.3 第三版:状态容器化——用GameBoard类隔离数据与行为
class GameBoard: def __init__(self, size: int = 3): self.size = size self._cells = [[' ' for _ in range(size)] for _ in range(size)] self._move_history = [] # 记录每步Position和玩家 def place(self, pos: Position, player: str) -> bool: if not self._is_valid(pos): return False if self._cells[pos.row][pos.col] != ' ': return False self._cells[pos.row][pos.col] = player self._move_history.append((pos, player)) return True def get_cell(self, pos: Position) -> str: return self._cells[pos.row][pos.col] def is_full(self) -> bool: return all(cell != ' ' for row in self._cells for cell in row)此时board不再是裸列表,而是GameBoard实例。所有对棋盘的读写必须通过其方法,_cells被设为私有属性。check_win()函数也升级为GameBoard.check_win(),它只依赖get_cell()获取数据,不碰_cells本体。这意味着:
- 若未来想加“棋盘快照回滚”,只需在
place()中保存_cells深拷贝; - 若想加“高亮获胜连线”,
check_win()可返回(winning_positions, winner)元组,而非仅布尔值; - 若想支持不同棋盘形状(如六边形),只需重写
is_valid()和get_cell(),核心逻辑复用。
2.4 第四版:事件驱动架构——用MoveEvent解耦操作与响应
前三版解决了数据安全,但交互依然僵硬。用户落子后,程序立刻执行胜负判定、打印结果、询问是否继续——所有动作强耦合在main()循环里。一旦要加“音效”“动画”“网络同步”,就得在main()里塞新代码。
第四版引入事件总线概念:
from typing import Callable, List class MoveEvent: def __init__(self, position: Position, player: str): self.position = position self.player = player class GameEngine: def __init__(self): self.board = GameBoard() self._event_handlers = { 'move_placed': [], 'game_won': [], 'game_drawn': [] } def on(self, event_name: str, handler: Callable): self._event_handlers[event_name].append(handler) def _emit(self, event_name: str, *args, **kwargs): for handler in self._event_handlers[event_name]: handler(*args, **kwargs) def make_move(self, pos: Position, player: str) -> bool: if not self.board.place(pos, player): return False self._emit('move_placed', MoveEvent(pos, player)) # 后续判定逻辑移至事件处理器 return True现在,胜负判定不再是make_move()的责任,而是注册到'move_placed'事件的独立函数:
def check_victory(engine: GameEngine, event: MoveEvent): winner = engine.board.check_win() if winner: engine._emit('game_won', winner, event.position) engine.on('move_placed', lambda e: check_victory(engine, e))这种解耦带来质变:
- 加音效?注册新处理器
play_sound_on_win; - 加AI?注册
ai_make_move_on_game_won; - 加日志?注册
log_move_to_file; - 所有新增功能互不干扰,
GameEngine核心代码零修改。
2.5 第五版:函数式状态流——用不可变数据+纯函数消除隐式依赖
最终版放弃类封装,转向函数式范式。核心思想:每一次落子,都生成一个全新棋盘状态,旧状态永不修改。
from typing import Tuple, List, Optional from dataclasses import dataclass @dataclass(frozen=True) class GameState: board: Tuple[Tuple[str, ...], ...] # 不可变元组 current_player: str move_count: int def create_initial_state() -> GameState: empty_row = (' ', ' ', ' ') return GameState( board=(empty_row, empty_row, empty_row), current_player='X', move_count=0 ) def apply_move(state: GameState, pos: Position) -> Optional[GameState]: if not (0 <= pos.row < 3 and 0 <= pos.col < 3): return None if state.board[pos.row][pos.col] != ' ': return None # 构建新board:将指定位置替换为current_player new_board = [] for i, row in enumerate(state.board): if i == pos.row: new_row = tuple( state.current_player if j == pos.col else cell for j, cell in enumerate(row) ) new_board.append(new_row) else: new_board.append(row) return GameState( board=tuple(new_board), current_player='O' if state.current_player == 'X' else 'X', move_count=state.move_count + 1 )优势极其显著:
- 调试友好:任意时刻可打印
state.board查看完整快照,无需追踪变量变化; - 回溯天然:
move_history存储的就是一串GameState,悔棋=取前一个状态; - 并发安全:状态不可变,多线程读取零风险;
- 测试极简:
apply_move(initial_state, Position(0,0))的输出可精确断言,无副作用干扰。
实操心得:函数式版本初期学生接受度低,因需理解“状态即值”的概念。我的破冰技巧是:让学生用纸笔模拟——每次落子,都在新纸上画完整棋盘,旧纸不涂改。三天后,90%的学生自发用
copy.deepcopy()模拟不可变状态,说明思维已切换。
3. 胜利判定的七种实现路径与性能真相
三子棋的胜负判定看似简单,但实现方式直接影响代码可读性、可维护性和扩展性。我带学生对比了七种主流方案,用真实数据说话(测试环境:Python 3.11,i5-1135G7):
| 方案 | 核心思路 | 代码行数 | 平均耗时(万次调用) | 可读性 | 扩展至4×4难度 |
|---|---|---|---|---|---|
| 暴力遍历 | 遍历所有行/列/对角线,检查是否全同 | 22 | 1.8ms | ★★★☆☆ | ★★☆☆☆(需重写全部循环) |
| 模式匹配 | 预存8种获胜模式(如[(0,0),(0,1),(0,2)]),检查当前落子是否在任一模式中 | 35 | 2.3ms | ★★★★☆ | ★★★★☆(只需增模式) |
| 位运算 | 用32位整数表示棋盘,X/O各占16位,用掩码0b111,0b111000等快速检测 | 41 | 0.4ms | ★★☆☆☆ | ★☆☆☆☆(位宽溢出) |
| 增量更新 | 仅检查刚落子的行/列/对角线,维护行/列/对角线计数器 | 28 | 0.6ms | ★★★☆☆ | ★★★★☆(计数器维度扩展) |
| 哈希映射 | 为每个位置预计算其所属的行/列/对角线索引,落子后查表获取关联线 | 31 | 0.9ms | ★★★★☆ | ★★★☆☆(需重算哈希表) |
| 生成器表达式 | 用all()+ 生成器遍历目标线,短路退出 | 18 | 1.5ms | ★★★★★ | ★★★☆☆(逻辑清晰但需重写) |
| 函数式折叠 | 将棋盘视为列表,用reduce()对每条线执行lambda acc, x: acc and x==player | 25 | 2.1ms | ★★☆☆☆ | ★★★☆☆(抽象层过厚) |
最终选定“增量更新+模式匹配”混合方案,原因如下:
- 增量更新解决性能瓶颈:90%的落子不会触发胜利,只需检查3条线(所在行、列、可能的两条对角线),避免全盘扫描;
- 模式匹配保障可读性:预存的8种模式用注释标明含义(如
"主对角线": [(0,0),(1,1),(2,2)]),学生一眼看懂覆盖范围; - 组合后代码仅26行,且逻辑分离:
get_lines_to_check(pos)负责找关联线,check_line(board, line, player)负责单线判定。
关键代码片段:
WINNING_PATTERNS = [ [(0,0), (0,1), (0,2)], # 第0行 [(1,0), (1,1), (1,2)], # 第1行 [(2,0), (2,1), (2,2)], # 第2行 [(0,0), (1,0), (2,0)], # 第0列 [(0,1), (1,1), (2,1)], # 第1列 [(0,2), (1,2), (2,2)], # 第2列 [(0,0), (1,1), (2,2)], # 主对角线 [(0,2), (1,1), (2,0)], # 副对角线 ] def get_lines_to_check(pos: Position) -> List[List[Tuple[int, int]]]: """返回与pos相关的所有获胜模式(最多4条)""" lines = [] # 所在行 lines.append([(pos.row, c) for c in range(3)]) # 所在列 lines.append([(r, pos.col) for r in range(3)]) # 主对角线(仅当pos在对角线上) if pos.row == pos.col: lines.append([(i, i) for i in range(3)]) # 副对角线(仅当pos在副对角线上) if pos.row + pos.col == 2: lines.append([(i, 2-i) for i in range(3)]) return lines def check_win_for_player(board: List[List[str]], player: str, lines_to_check: List[List[Tuple[int, int]]]) -> bool: for line in lines_to_check: if all(board[r][c] == player for r, c in line): return True return False踩坑实录:曾有学生用
set(line) == {player}判定,结果发现line是坐标元组,set([(0,0),(0,1),(0,2)])返回{(0,0),(0,1),(0,2)},永远不等于{'X'}。这个错误暴露了对数据类型本质的理解偏差——提醒学生:永远先确认你操作的对象是什么,再选方法。
4. 从命令行到跨平台:UI层的三次跃迁与交互设计铁律
三子棋的UI常被当作“随便print就行”的环节,但恰恰是这里,暴露出工程能力与用户体验思维的巨大鸿沟。我带学生经历了三次UI重构,每次都是认知升级:
4.1 第一次跃迁:告别原始print,拥抱ANSI转义序列
初始版用print(" X | O | ")拼接棋盘,问题扎堆:
- 行列对齐靠空格,字体不等宽时歪斜;
- 无法高亮当前落子位置;
- 平局提示淹没在棋盘下方,用户滚动才能看到。
升级为ANSI转义序列后:
def render_board(board: List[List[str]], highlight_pos: Optional[Position] = None): print("\033[2J\033[H") # 清屏+回到原点 for i, row in enumerate(board): row_str = "" for j, cell in enumerate(row): if highlight_pos and highlight_pos.row == i and highlight_pos.col == j: row_str += f"\033[42m {cell} \033[0m" # 绿色背景 else: row_str += f" {cell} " if j < 2: row_str += "|" print(row_str) if i < 2: print("-----------")效果立竿见影:
- 每次渲染自动清屏,棋盘始终居中;
- 当前落子位置绿色高亮,视觉焦点明确;
- 支持Windows Terminal和macOS Terminal,无需额外依赖。
注意:ANSI序列在老旧终端可能失效。生产环境需加降级逻辑——检测
os.getenv('TERM'),若不支持则退回纯文本模式,并用─│┌┐└┘├┤┬┴┼字符绘制更美观的边框。
4.2 第二次跃迁:抽象View层,支持多端口输出
当学生提出“想用网页玩”,我暂停开发,先重构UI层:
from abc import ABC, abstractmethod class View(ABC): @abstractmethod def show_board(self, board: List[List[str]], highlight_pos: Optional[Position] = None): pass @abstractmethod def show_message(self, message: str, level: str = "info"): pass @abstractmethod def get_user_input(self) -> str: pass class ConsoleView(View): def show_board(self, board, highlight_pos=None): # ANSI实现... class WebView(View): def __init__(self, port: int = 8000): self.port = port # 启动轻量Web服务器... def show_board(self, board, highlight_pos=None): # 生成HTML字符串,通过WebSocket推送到浏览器此时GameEngine只依赖View抽象,不关心具体实现。添加Web支持,只需:
- 实现
WebView类; - 在
main()中传入WebView()替代ConsoleView(); GameEngine代码零修改。
学生第一次在手机浏览器打开http://localhost:8000看到实时棋盘时,终于理解“抽象”的力量——它让“写一次,到处运行”成为可能,而非空谈。
4.3 第三次跃迁:引入状态机驱动UI,解决“输入阻塞”顽疾
最大痛点:传统input()会阻塞整个程序,无法在等待用户输入时播放音效、刷新倒计时、或处理AI思考。我们引入状态机:
class GameStateMachine: def __init__(self, view: View): self.view = view self.state = 'WAITING_FOR_PLAYER_MOVE' self.pending_input = None def update(self): if self.state == 'WAITING_FOR_PLAYER_MOVE': # 非阻塞检查输入 user_input = self.view.try_get_input() if user_input: self.handle_player_move(user_input) else: # 可在此处执行其他任务:检查AI超时、播放待机动画 self.view.show_message("轮到你了...", level="hint") def handle_player_move(self, user_input: str): try: pos = Position.from_user_input(user_input) if self.engine.make_move(pos, self.current_player): self.state = 'AI_THINKING' if self.is_ai_opponent else 'CHECKING_WIN' except ValueError as e: self.view.show_message(f"输入错误:{e}", level="error")try_get_input()在ConsoleView中用sys.stdin.readline()(配合select检测可读性),在WebView中用WebSocket消息监听。状态机让UI真正“活”起来——它不再被动等待,而是主动管理交互节奏。
实操心得:状态机初期学生易陷入“状态爆炸”。我的简化原则是:只定义用户可感知的状态(如
WAITING_FOR_MOVE,SHOWING_RESULT,ANIMATING_WIN),忽略技术细节状态(如READING_INPUT_BUFFER)。用UML状态图辅助设计,每个状态标注“进入动作”“持续动作”“退出动作”,避免遗漏。
5. AI对手的三种实现深度与教学价值
给三子棋加AI,绝非炫技,而是绝佳的算法教学载体。我按认知负荷递进,设计了三层AI实现:
5.1 第一层:随机AI(RandomAI)——理解“决策空间”概念
最简实现:
import random class RandomAI: def choose_move(self, board: List[List[str]]) -> Optional[Position]: empty_positions = [] for i in range(3): for j in range(3): if board[i][j] == ' ': empty_positions.append(Position(i, j)) return random.choice(empty_positions) if empty_positions else None教学价值:
- 引入“决策空间”概念:当前棋盘有多少合法走法?如何枚举?
- 揭示“随机≠无策略”:即使随机,也要确保不落子到已占位置;
- 为后续AI提供基线:任何高级AI必须显著优于RandomAI。
注意:
random.choice([])会抛异常,必须前置检查。这个细节教会学生——所有外部输入(包括AI决策)都需防御性编程。
5.2 第二层:规则AI(RuleBasedAI)——掌握“启发式搜索”思维
规则AI按优先级执行:
- 若能赢,立即落子;
- 若对手能赢,立即拦截;
- 若中心空,落中心;
- 若角落空,落角落;
- 否则落边。
class RuleBasedAI: def choose_move(self, board, player): opponent = 'O' if player == 'X' else 'X' # 1. 检查自己能否一步获胜 for pos in self._get_all_empty_positions(board): board_copy = self._deep_copy_board(board) board_copy[pos.row][pos.col] = player if self._check_win(board_copy, player): return pos # 2. 检查对手能否一步获胜,需拦截 for pos in self._get_all_empty_positions(board): board_copy = self._deep_copy_board(board) board_copy[pos.row][pos.col] = opponent if self._check_win(board_copy, opponent): return pos # 3. 优先中心 if board[1][1] == ' ': return Position(1, 1) # 4. 优先角落 corners = [Position(0,0), Position(0,2), Position(2,0), Position(2,2)] empty_corners = [p for p in corners if board[p.row][p.col] == ' '] if empty_corners: return random.choice(empty_corners) # 5. 落边 edges = [Position(0,1), Position(1,0), Position(1,2), Position(2,1)] empty_edges = [p for p in edges if board[p.row][p.col] == ' '] return random.choice(empty_edges) if empty_edges else None教学价值:
- 理解“启发式”本质:用经验规则替代穷举,牺牲最优性换取实时性;
- 掌握“模拟落子”技巧:深拷贝棋盘、临时修改、验证结果,再丢弃;
- 体会“规则优先级”的权衡:为何先检查获胜再检查拦截?因为获胜收益更高。
5.3 第三层:MiniMaxAI——构建“博弈树”底层认知
MiniMax是经典算法,但教学中常被过度复杂化。我简化为三要素:
- 评估函数(Evaluate):
+10(X赢),-10(O赢),0(平局); - 递归深度(Depth):限制搜索层数,避免指数爆炸;
- 极小化极大(Minimax):X最大化得分,O最小化得分。
def minimax(self, board, depth, is_maximizing, player): opponent = 'O' if player == 'X' else 'X' # 终止条件 winner = self._check_win(board) if winner == player: return 10 - depth # 深度越小,胜得越早,分越高 elif winner == opponent: return depth - 10 # 对手胜得越晚,对我越有利 elif self._is_board_full(board): return 0 if depth >= 5: # 限制深度 return self._heuristic_eval(board, player) if is_maximizing: best_score = -float('inf') for pos in self._get_all_empty_positions(board): board[pos.row][pos.col] = player score = self.minimax(board, depth + 1, False, player) board[pos.row][pos.col] = ' ' # 回溯 best_score = max(score, best_score) return best_score else: best_score = float('inf') for pos in self._get_all_empty_positions(board): board[pos.row][pos.col] = opponent score = self.minimax(board, depth + 1, True, player) board[pos.row][pos.col] = ' ' best_score = min(score, best_score) return best_score教学价值:
- 建立“博弈树”直观认知:每个节点是棋盘状态,每条边是落子动作;
- 理解“递归+回溯”如何探索所有可能路径;
- 认识“剪枝”的必要性:实际项目中必加Alpha-Beta剪枝,否则3×3都卡顿。
关键洞察:MiniMax的难点不在代码,而在状态空间理解。我让学生手动画出深度为2的博弈树(共9×8=72个节点),再标出哪些节点被Alpha-Beta剪掉。当他们亲眼看到“剪枝让节点减少60%”时,算法才真正落地。
6. 工程化交付:打包、测试与可维护性设计
一个能交付的教学项目,必须经得起真实使用考验。我们为三子棋建立了完整的工程化流程:
6.1 测试金字塔:从单元到集成
- 单元测试(65%):覆盖
Position.from_user_input()、GameBoard.place()、check_win_for_player()等纯函数,用pytest参数化测试边界值(如0,0、3,3、-1,-1); - 集成测试(25%):模拟完整对局,验证
GameEngine.make_move()是否正确触发事件、更新状态; - E2E测试(10%):用
pexpect自动化控制ConsoleView,输入指令并断言输出包含“X wins!”。
关键测试用例:
def test_position_parsing(): assert Position.from_user_input("1 1") == Position(0, 0) assert Position.from_user_input("3 3") == Position(2, 2) with pytest.raises(ValueError, match="坐标必须在1-3范围内"): Position.from_user_input("0 1") with pytest.raises(ValueError, match="请输入两个空格分隔的数字"): Position.from_user_input("abc") def test_win_detection(): board = [['X', 'X', 'X'], [' ', 'O', ' '], [' ', ' ', 'O']] assert check_win_for_player(board, 'X', get_lines_to_check(Position(0,0))) is True6.2 打包分发:一键安装与跨平台兼容
用setuptools构建:
# setup.py from setuptools import setup, find_packages setup( name="tic-tac-toe-core", version="1.0.0", packages=find_packages(), entry_points={ "console_scripts": [ "tictactoe=src.cli:main", # 命令行入口 "tictactoe-web=src.web:main", # Web服务入口 ] }, python_requires=">=3.8", install_requires=[ "Flask>=2.0.0", # Web依赖 ], extras_require={ "dev": ["pytest>=6.0", "black>=22.0"], } )安装命令:
pip install . # 本地安装 pip install git+https://github.com/xxx/tic-tac-toe.git # GitHub安装 tictactoe # 直接运行6.3 可维护性设计:文档、配置与监控
- 配置化:棋盘大小、AI难度、超时时间等参数从代码中剥离,放入
config.yaml; - 日志化:关键路径(如落子、胜负判定)打日志,级别可调,便于排查学生报告的问题;
- 文档化:用Sphinx生成API文档,重点标注
GameEngine的事件钩子,方便二次开发。
最后交付物:
- 一个
pip install即可用的包; - 一份含“快速开始”“API参考”“贡献指南”的Readme;
- 一套覆盖核心路径的测试用例;
- 一个Dockerfile,一键启动Web版。
我的体会:教学项目的终极价值,不在于学生写出多少行代码,而在于他们离开项目后,能否独立搭建下一个类似系统。当学生第一次成功修改
config.yaml调高AI难度,或自己写了个DiscordView类接入机器人时,这个项目才算真正完成。