ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

用孔明棋练透面向对象:从封装到多态的项目实战

用孔明棋练透面向对象:从封装到多态的项目实战 简介面向对象编程与孔明棋规则结合的 C# 练习项目适合正在学习 OOP 的初学者或需要课程设计的开发者。项目以棋盘、棋子、游戏三个核心类为主线分别处理状态存储、移动判定和回合流程并借助接口、事件与委托实现界面交互清晰展示了封装、继承和多态的实际应用。压缩包共 35 个文件以 cs 源代码为主附带 csproj/sln 工程配置、resx/resources 资源文件及可直接运行的 exe整体仅 70KB轻量便于阅读和二次修改。已有 498 人学习适合用来对照理解面向对象设计思路也可作为孔明棋小游戏开发的参考框架。 面向对象编程怎么练才不虚我见过太多人卡在“类和对象”这一章讲封装、继承、多态都有点头头是道一让他自己写一个稍大点的项目交上来的还是“一个类从头写到尾”或者干脆退回C语言式的函数堆叠。后来我带学生做孔明棋练习发现这个游戏是天然的面向对象试金石——规则简单三五分钟就能讲明白但要把棋盘、棋子、走法、规则、求解器组织得清清楚楚封装、多态、状态机、递归回溯全都得用上。这篇就把完整的建模思路和实操过程写出来适合学过Java/C语法、想真正把面向对象落到代码里的同学。1. 孔明棋里藏着哪些“非用对象不可”的模型1.1 先把这个游戏拆成“状态”和“动作”孔明棋Peg Solitaire的经典棋盘是十字形一共33个孔位开局时中间那个孔是空的其余32个位置都放棋子。走法只有一种让一颗棋子跳过相邻的一颗棋子落到后面那个空位上被跳过的那颗棋子从棋盘上移除。目标也很简单——不断跳直到棋盘上剩下的棋子尽可能少最理想的结局是剩一颗而且正好留在棋盘正中央。听上去就是数组加循环的事很多人第一次写就直接定义int[] board new int[33]1表示有棋子0表示空。判断一步能不能走就写一个函数遍历方向走一步就改三个下标。说实话这个版本跑起来没问题20分钟就能写完。但它有个致命问题你写的不是孔明棋而是一堆针对“33个int”的操作。第4步想加悔棋手写栈存数组副本。想换三角棋盘改所有硬编码的下标。想写自动求解器递归里复制数组复制到怀疑人生。1.2 为什么函数式写法在这里会越来越痛我见过不少学员卡在第2步扩展功能时越写越乱。核心原因是“状态”和“规则”纠缠在一起。数组版本里检测跳跃合法性、执行跳跃、判断游戏结束全是直接操作同一个数组的散装函数任何一个函数想复用都得把数组传进传出。表面上看代码很短可一旦递归搜索需要“试一步再退回”你就得手动把数组恢复到之前的样子——这一步特别容易漏恢复漏一次就出现玄学bug。面向对象的第一课恰恰在这里体现把会变化的东西和会操作它的东西分别封装成对象。棋盘是状态走法是动作规则是约束。它们不应该是三个函数而应该是三个各司其职的类。孔明棋因为规则少、状态简单拆起来不费劲但你又能清楚地感觉到拆分前后代码的可维护性差别。1.3 从规则里找名词但不能看到名词就建类建模时最朴素的做法是“找名词”。孔明棋里有棋盘、孔洞、棋子、走法、玩家、游戏。但如果把每个名词都变成类就会造出Peg棋子类里只有一个boolean aliveHole孔类里只有一个boolean occupied满屏的getter/setter没有任何行为。这是初学面向对象最容易踩的坑看起来面向对象了实际上是给全局变量穿了层类的外衣。正确的思路是先问这个名词有没有独立的行为棋子本身不会动移动是游戏规则驱动它发生的孔位本身也不会判断它只是被查询的格子。所以在这个项目里棋子不必有类孔位也不必是复杂对象。真正有行为的是“棋盘”它管理格子的状态是“走法”它描述一次跳动的三个位置是“规则”它判断一次跳跃行不行。先抓住这几个核心对象后面所有逻辑才有地方放。2. 核心类设计Board、Move、RuleChecker、Game各管一件事2.1 Board只管“棋盘长什么样”不管“游戏怎么玩”第一版设计我会建一个Board类它只关心棋盘的几何结构和当前哪些位置有棋子。棋盘用二维数组int[][] grid存最直观行和列直接对应坐标也可以定义ListPosition holes表示所有有效孔位方便以后换成三角棋盘、菱形棋盘。public class Board { private final int[][] grid; private final int rows; private final int cols; public Board(int rows, int cols) { this.rows rows; this.cols cols; this.grid new int[rows][cols]; } public boolean isInside(Position pos) { return pos.row() 0 pos.row() rows pos.col() 0 pos.col() cols; } public boolean isOccupied(Position pos) { return isInside(pos) grid[pos.row()][pos.col()] 1; } public void setPeg(Position pos, boolean occupied) { if (!isInside(pos)) return; grid[pos.row()][pos.col()] occupied ? 1 : 0; } }这个类里没有任何“跳跃”“跳过”“移除棋子”的语义。为什么这么分因为棋盘就是一张地图地图不负责制定交通规则。把规则塞进Board比如写一个jump(from, over, to)方法乍一看挺方便但以后如果要支持不同的游戏变体就得不断改Board。现在让Board只提供最底层的“某个位置有没有棋子”“放一颗棋子”“拿走一颗棋子”规则引擎自然就能在这上面搭出来。2.2 Move一个“值对象”描述一次跳跃的三个坐标一次跳跃涉及三个位置起点、跳过的棋子、落点。很多人会直接写成jump(int from, int over, int to)三个int传参调用多了根本分不清谁是谁。更好的做法是定义一个Move类型它只负责把这三个坐标打包不包含任何判断逻辑。public record Move(Position from, Position over, Position to) { public static Move of(int fromRow, int fromCol, int overRow, int overCol, int toRow, int toCol) { return new Move( new Position(fromRow, fromCol), new Position(overRow, overCol), new Position(toRow, toCol) ); } }为什么要为三个坐标单独建类因为在递归搜索的时候你要反复执行“选一个Move、尝试、撤销”Move作为参数传来传去非常方便而且record自带equals和hashCode以后要存储历史走法、实现悔棋、比较两种解法都直接可用。这种“只包含数据、没有行为”的对象叫值对象在面向对象设计里很常见它解决的显然不是“类的数量焦虑”而是“参数可读性”。2.3 RuleChecker把“规则”和“棋盘状态”分开有了Board和Move谁来判断 Move 合不合法有些人的第一反应是让Board来做因为Board手里有数据嘛。但仔细想合法性判断是“游戏规则”而“棋盘”只是被规则审视的对象。一旦把规则放进BoardBoard就有两个职责维护几何状态、判断动作合法性。今天孔明棋规则简单还好明天要出个“只能向右跳”的变体你就得在Board里加一个boolean allowLeftJump这个类会越来越臃肿。所以我把规则抽成RuleCheckerpublic class RuleChecker { private static final int[][] DIRECTIONS { {-2, 0}, {2, 0}, {0, -2}, {0, 2} }; private static final int[][] OVER_DIRECTIONS { {-1, 0}, {1, 0}, {0, -1}, {0, 1} }; public boolean isValidMove(Board board, Move move) { return board.isInside(move.from()) board.isInside(move.over()) board.isInside(move.to()) board.isOccupied(move.from()) board.isOccupied(move.over()) !board.isOccupied(move.to()) isAdjacentInLine(move); } public ListMove getAllValidMoves(Board board) { ListMove moves new ArrayList(); for (Position pos : board.allPositions()) { if (!board.isOccupied(pos)) continue; for (int d 0; d 4; d) { Position over pos.plus(OVER_DIRECTIONS[d]); Position to pos.plus(DIRECTIONS[d]); Move move new Move(pos, over, to); if (isValidMove(board, move)) { moves.add(move); } } } return moves; } }注意DIRECTIONS和OVER_DIRECTIONS的配合横竖两个方向共4种跳法每次位移2格被跳过的棋子正好在中间1格。这样规则引擎和棋盘的边界就很清楚了RuleChecker不断调用board.isOccupied()、board.isInside()但从不直接修改棋盘。2.4 Game只做状态的编排不做具体计算最后需要一个门面类把Board和RuleChecker串起来。Game负责持有当前棋盘、当前规则、走法历史并提供play(Move)、undo()、isFinished()这些面向调用者的方法。public class Game { private final Board board; private final RuleChecker ruleChecker; private final DequeMove history new ArrayDeque(); public Game(Board board, RuleChecker ruleChecker) { this.board board; this.ruleChecker ruleChecker; } public boolean play(Move move) { if (!ruleChecker.isValidMove(board, move)) { return false; } board.setPeg(move.from(), false); board.setPeg(move.over(), false); board.setPeg(move.to(), true); history.push(move); return true; } public boolean undo() { if (history.isEmpty()) return false; Move move history.pop(); board.setPeg(move.from(), true); board.setPeg(move.over(), true); board.setPeg(move.to(), false); return true; } }这个类里没有一行代码是检测跳跃规则的它只做三件事调用规则判断、更新棋盘状态、记录历史。Controller或UI层只需要跟Game打交道完全不用关心底层是数组还是链表。这就是“协调者”角色的价值——不是什么都自己做而是把活分给最合适的人。3. 用接口和多态把“规则”与“算法”解耦3.1 RuleChecker 改成接口规则变化不再是灾难上一节的RuleChecker是一个具体类。如果练习只写到这里其实也够了。但面向对象的核心优势之一是应对变化——孔明棋最常见的变体是三角棋盘、六角棋盘甚至有些版本要求两步连跳才算一步。要支持这些最好把“规则”抽象成接口。public interface MoveRule { boolean isValidMove(Board board, Move move); ListMove getAllValidMoves(Board board); }原来的RuleChecker改成ClassicPegMoveRule实现这个接口。以后想加一个新规则比如“每走10步必须清空最外圈棋子”增加一个ScoringPegMoveRule去实现MoveRule就好不需要动 Board也不需要动 Game。调用方只需要面向接口写代码public class Game { private final MoveRule moveRule; public Game(Board board, MoveRule moveRule) { this.board board; this.moveRule moveRule; } }这样写底层规则怎么变Game类都稳如泰山。这个过程就体现了面向对象里的“依赖倒置”思想高层模块不依赖低层模块两者都依赖抽象。练习项目里刻意迈出这一步比背诵概念有用得多。3.2 状态机用枚举 委托处理游戏阶段孔明棋虽然单人但游戏阶段是存在的开局摆子、游玩中、无子可动结束、达成目标胜利。以前很多同学写游戏直接用一个int status 0表示状态然后switch(status)判断。状态一多每个方法里都塞一堆if (status 2)改起来极其痛苦。这里可以用一个轻量状态机。先把状态枚举列出来public enum GameState { READY, PLAYING, FINISHED, SOLVED }然后Game里的动作在关键点切换状态。比如play成功之后检查剩余棋子数如果是1且在最中央进入SOLVED如果getAllValidMoves().isEmpty()进入FINISHED。状态判断凝聚在Game类内部的两个私有方法里外部调用者只需要读取currentState()决定界面显示什么。更高阶一点可以把状态定义成接口用状态对象完成不同阶段的行为差异但对于孔明棋这种规模枚举版的简单状态机就够用了重点是让状态流转的所有逻辑都集中在一个地方不要散落在UI里。3.3 接口不是越多越好听到“用接口”有人会走向另一个极端每个类都抽一个接口IBoard、IMove、IGame前缀满天飞。这是过度设计。接口是给“变化点”准备的——在这个项目里真正的变化点是规则MoveRule和搜索策略后面会讲Solver这两个值得抽象。至于Move本身就是简单数据再抽接口纯属制造噪音。初学者要练的是判断“哪里会变”而不是“把一切抽象化”。建模能力本质上就是一种“取舍能力”既要看到未来扩展的可能又要为当下需求保持足够的直接和简单。4. 跳跃检测与自动求解对象之间怎么协作4.1 遍历棋盘时对象协作比下标飞来飞去舒服太多检测“当前所有合法走法”如果是在数组版本里你得写三层for循环还要小心边界。现在有了Board和MoveRule逻辑变得非常直白public ListMove getAllValidMoves(Board board) { ListMove moves new ArrayList(); for (Position pos : board.allPositions()) { if (!board.isOccupied(pos)) continue; for (int[] dir : DIRECTIONS) { Position over pos.plus(dir[0] / 2, dir[1] / 2); Position to pos.plus(dir[0], dir[1]); Move candidate new Move(pos, over, to); if (isValidMove(board, candidate)) { moves.add(candidate); } } } return moves; }你不需要在外部关心grid的边界因为Board.isInside()替你把关你也不需要手工判断中间有没有棋子因为isValidMove里已经处理了。这就是封装的回报调用者只描述“我想做什么”具体细节由对象自己回答“能不能做”。代码阅读体验完全不一样。4.2 递归求解器回退时如何做到“不破坏现场”自动求解孔明棋本质上是深度优先搜索从当前棋盘出发遍历所有合法Move尝试走一步然后递归搜索如果走不通就撤销。这个“尝试—撤销”的过程在函数式写法里需要手动复制数组在面向对象设计里最自然的做法是用一个Solver类它接收Board和MoveRule内部维护当前搜索路径。public class PegSolitaireSolver { private final MoveRule moveRule; public PegSolitaireSolver(MoveRule moveRule) { this.moveRule moveRule; } public ListMove solve(Board board) { ListMove path new ArrayList(); if (dfs(board, path, 32)) { return path; } return List.of(); } private boolean dfs(Board board, ListMove path, int remainingPegs) { if (remainingPegs 1) return true; ListMove moves moveRule.getAllValidMoves(board); if (moves.isEmpty()) return false; for (Move move : moves) { applyMove(board, move); path.add(move); if (dfs(board, path, remainingPegs - 1)) { return true; } path.remove(path.size() - 1); undoMove(board, move); } return false; } }solve方法里表面上看只是递归调用但它依赖两个关键对象MoveRule负责找所有可行走法Board负责记录当前状态。过程中每次尝试都直接修改同一个Board对象递归返回后再把修改撤销回去。这里最容易出bug的地方是“撤销不完整”所以我在Game类里就写好了apply和undo的对称逻辑Solver直接复用Game会更稳。4.3 用 Memento 模式保存现场递归搜索更安心如果不想手动一步步撤销也可以引入备忘录模式Board提供一个snapshot()返回只读副本需要恢复时再调restore(snapshot)。对于棋盘较小、状态机不复杂的场景把所有历史棋盘快照存在栈里甚至比写对称撤销还要稳妥。public class BoardMemento { private final int[][] gridCopy; BoardMemento(Board board) { this.gridCopy new int[board.rows()][board.cols()]; for (int i 0; i board.rows(); i) { System.arraycopy(board.getGridRow(i), 0, gridCopy[i], 0, board.cols()); } } public void restore(Board board) { for (int i 0; i board.rows(); i) { System.arraycopy(gridCopy[i], 0, board.getGridRow(i), 0, board.cols()); } } }面向对象到这里你已经不是为了写类而写类了Memento把你“保存现场”这个意图封装成了一个对象而不是散落的数组复制代码。练习项目中刻意用一次Memento比课堂示例里的“恢复存档”要直观得多。5. 测试先行让面向对象设计现出原形5.1 给 RuleChecker 写单元测试最不亏的投入设计得再好没有测试兜底仍然不敢重构。孔明棋规则检测非常适合写单元测试因为输入输出都非常明确。比如开局棋盘中间空、周围有棋子中间左侧的棋子不能直接跳到中间左侧两格需要精确写几个用例Test void shouldAllowJumpWhenFromAndOverOccupiedAndToEmpty() { Board board new Board(7, 7); // 构造一个具体局面from(3,3), over(3,2), to(3,1) board.setPeg(new Position(3, 3), true); board.setPeg(new Position(3, 2), true); board.setPeg(new Position(3, 1), false); MoveRule rule new ClassicPegMoveRule(); assertTrue(rule.isValidMove(board, Move.of(3, 3, 3, 2, 3, 1))); }别小看这些测试。写测试的时候你会发现如果MoveRule和Board耦合很深比如MoveRule直接访问grid内部数组那测试用例很难构造反之当每个类的依赖边界清楚时测试代码写起来就像在陈述规则而不是在绕过设计缺陷。单元测试其实是面向对象设计的质检员。5.2 从控制台到GUIMVC模式是水到渠成完成核心逻辑后可以加一个控制台界面每次打印棋盘走一步再往后想升级成窗口界面面向对象的好处就很明显。把棋盘的显示抽成BoardView它只负责从Game里读取状态并绘制用户的鼠标点击事件交给GameController由它把坐标转换成Move再调用game.play(move)。逻辑和界面彻底分离加GUI不影响核心类换个主题配色也只是改View。这里推荐练手时用Java Swing或JavaFX。界面不需要多花哨能点格子走棋就行。这一层的价值在于让你体验“逻辑层完全不依赖UI层”是怎么做到的你现在删掉UI核心求解器照样能在命令行跑反过来换一个UI框架核心类一行都不用改。5.3 后续扩展一次开闭原则的实战项目做完这一版可以顺手加几个扩展练手。第一个是“三角棋盘”只需实现一个TriangleBoard让Board支持不规则形状的孔位MoveRule和Solver不用动第二个是“自动求解步数统计”给Solver加一个solveWithMinSteps()可以用BFS找最短解第三个是“保存 / 加载棋局”把Board序列化或转成文本格式下次启动读回来继续玩。每一扩展都会逼你回头审视设计如果需要改动的地方很少说明之前的抽象边界划对了如果到处都要打补丁那正好复盘是哪一层职责没分清楚。练习不是做完就完重写一遍、扩展一遍面向对象的感觉才能长在身上。从我个人的经验看孔明棋最值得练的不是“写出一个能跑的游戏”而是“在代码量很小的情况下感受到了设计的力量”。数组版本我20分钟写完面向对象版本前前后后重构了三次。第一次觉得类太多第二次觉得接口多余第三次才找到那个“多一个嫌胖、少一个嫌瘦”的平衡点。所以如果你也在练面向对象别急着追求一步到位。先写一版能跑的再按这篇文章的思路拆类、抽接口、加测试每一步重构都会比上一版更接近“清晰”二字。最后建议从命令行版本起步把MoveRule的测试全部写绿再考虑界面和求解器这个顺序最稳。本文还有配套的精品资源点击获取
返回列表