C# 进阶
C# 学习系列: C# 入门 → C# 基础 → C# 进阶 → C# 高级
接续《C#入门》《C# 基础》,从“会写语句”走向“会组织程序”:理解封装、继承、多态,掌握类的常用成员、Object 与文本 API,并用七项设计原则组织一个按文件夹划分的控制台贪吃蛇项目。
配套项目:.NET 10 SDK,C# 12,无第三方包。语言参考可能展示 C# 13、14 等较新语法,本文采用与配套项目一致的写法。
阅读与运行约定
- 已掌握变量、分支、循环、数组、函数、结构体、值与引用、
ref/out。面向对象不是替代这些知识,而是把数据、规则和协作关系组织起来。 - 类型声明通常放在方法外;调用片段放在
Main中。各例子按独立章节阅读,遇到重复类名不要全部粘贴进同一命名空间。 - “内部类”在 C# 中通常称为“嵌套类”;“析构函数”是常见历史叫法,本文使用更准确的“终结器”说明其运行时行为;“项目事件”按项目实践组织。
- 本文的“七大原则”采用常见教学口径:SOLID 五项,加迪米特法则与合成复用原则。这是设计指导,不是 C# 编译器强制的七条语法。
String、StringBuilder合集按公开 API 家族和常用重载整理,不逐项重复同名方法的全部重载。实际可用 API 取决于目标框架。- 文档给出完整示例类型、项目结构、核心源码和阅读路线。源码包包含两个可运行项目及所有分类文件,贪吃蛇不能只复制某一个代码片段就运行。
目录
- 第一章:面向对象三大特性
- 第二章:面向对象之封装
- 第三章:面向对象之继承
- 第四章:面向对象之多态
- 第五章:其他知识与文本 API 合集
- 第六章:面向对象七大原则
- 第七章:项目实践——贪吃蛇
- 附录 A:练习与参考答案
- 附录 B:运行、排查与验证
- 附录 C:官方与原始参考资料
配套程序快速运行
安装 .NET SDK 后,解压源码包,在根目录执行:
1 | |
游戏在交互终端运行,窗口至少 73 列 × 33 行;Enter 开始,WASD 或方向键转向,P 暂停,Esc 返回菜单。
第一章:面向对象三大特性
1.1 从“几个变量”到“一个角色”
最初可以用 name、hp、attack 三个变量描述玩家。但玩家数量增加后,容易把 A 的名字与 B 的血量混在一起;任何代码都能随意把血量改为负数,规则也会四处重复。
面向对象把相关数据和允许的操作组织在类型中:玩家对象负责保存自己的状态,通过受控的方法受伤、恢复;绘制对象负责输出画面;输入对象负责把按键转换成命令。
| 概念 | 关注的问题 | 在本教程中的例子 |
|---|---|---|
| 封装 | 谁可以读写状态,哪些操作才合法 | 外部可读取 Hp,通过 TakeDamage 修改 |
| 继承 | 哪些类型确实具有父类型的共同契约 | StartScene、PlayScene 都是一种 Scene |
| 多态 | 同一个调用如何执行不同实现 | Scene.Render 根据具体场景执行不同绘制 |
三者经常一起出现,但并不是“必须先继承一大串类,才能称为面向对象”。组合对象、定义接口,同样是组织程序的重要方式。面向对象概览
1.2 封装:维护状态与规则的边界
假设血量范围是 0~100。直接公开字段:
1 | |
调用方可以写 player.Hp = -999。更合理的接口是让外部读取血量,通过方法表达“受到伤害”这个动作,并在方法里检查负伤害、限制最低血量。
封装不是单纯把字段改成 private,也不是给所有字段机械添加 get/set;它要表达对象允许怎样变化。
1.3 继承:表达可成立的“是一种”
Warrior : Character 表达战士是一种角色。调用方可以把战士作为角色使用,但战士还可以有举起武器等特有行为。
继承适用于共享契约,而不只是“两个类刚好有几行一样的代码”。蛇有身体坐标,棋盘有宽高,但不能因此让棋盘继承蛇。
1.4 多态:由实际对象决定行为
1 | |
变量的编译时类型都是 Character,实际对象分别是战士与法师。Act 是虚方法并被重写,因此会调用对应实现。
| 常见误解 | 更准确的理解 |
|---|---|
| 封装就是把所有成员藏起来 | 应提供稳定、足够且维护规则的公开入口 |
| 继承就是复制父类代码 | 继承建立类型关系,成员可访问性另有规则 |
| 多态就是写很多 if 判断类型 | 通常通过虚方法或接口,让调用者依赖共同契约 |
| 重载、重写、隐藏都一样 | 重载选参数签名,重写参与运行时分派,隐藏主要按编译时类型选择 |
1.5 本章练习
描述贪吃蛇项目中:蛇身位置属于谁,终端输出属于谁,开始/游戏/结束场景有哪些共同操作。先划分职责,再考虑类名。
第二章:面向对象之封装
2.1 类与对象
类是类型定义,对象是根据该类型创建的具体实例。一个类可以创建多个独立对象。
1 | |
1 | |
类是引用类型,普通赋值复制引用,不会自动复制对象:
1 | |
new Hero { Name = "A" } 是对象初始化器:先调用所选构造函数,再对列出的可赋值成员赋值。它不绕过属性 setter 的校验。
2.2 基本语法与成员种类
1 | |
| 成员 | 主要用途 |
|---|---|
| 字段,常称成员变量 | 保存对象或类型的状态 |
| 方法 | 表达动作、计算或查询 |
| 属性 | 以成员访问语法读写或计算一个值 |
| 构造函数 | 初始化新实例 |
| 索引器 | 用 obj[index] 访问对象中的数据 |
| 静态成员 | 不依赖某一个实例的类型级成员 |
| 嵌套类型 | 定义仅在该类型附近有意义的辅助类型 |
2.3 成员变量与访问修饰符
实例字段每个对象各有一份;静态字段由同一类型的实例共享。字段会进行默认初始化,但仍应主动表达业务初始状态。
下面以类成员为例概括可见范围:
| 修饰符 | 当前类 | 同程序集派生类 | 同程序集非派生类 | 其他程序集派生类 | 其他程序集非派生类 |
|---|---|---|---|---|---|
public |
可 | 可 | 可 | 可 | 可 |
private |
可 | 不可 | 不可 | 不可 | 不可 |
internal |
可 | 可 | 可 | 不可 | 不可 |
protected |
可 | 可 | 不可 | 可 | 不可 |
protected internal |
可 | 可 | 可 | 可 | 不可 |
private protected |
可 | 可 | 不可 | 不可 | 不可 |
protected internal 是“同程序集 或 派生关系”,private protected 是“同程序集 且 派生关系”。对 protected 成员通过哪一个对象引用访问,还有进一步限制,不是任何拿到父类引用的派生类代码都能随便访问。
命名空间直接包含的普通类默认 internal;类内普通字段和方法默认 private。public 成员不能突破其包含类型本身的可访问范围。访问修饰符
2.4 完整封装例子:Player
以下类型在配套项目 OopExamples/Encapsulation/Player.cs 中:
1 | |
1 | |
Hp 的修改入口少,负伤害被拒绝,治疗不会超过最大值。名字在写入时修剪空白,并拒绝空名字。规则留在对象内部,调用方不需要每次复制一遍检查。
2.5 成员方法与 this
实例方法通过具体对象调用,例如 player.TakeDamage(25)。方法可以使用当前对象的实例字段和属性。
this 表示当前实例,常用于区分同名参数与字段:
1 | |
静态方法没有当前实例,不能在其中直接使用实例的 this、字段或实例方法;需要接收或创建一个对象。
2.6 构造函数与构造链
构造函数与类同名,没有返回类型。可以重载,用 this(...) 调用同类的另一个构造函数:
1 | |
这段来自前面的 Player 类型,把初始化集中到带参构造函数,避免多个构造函数各自维护同一套规则。
如果类完全没有显式声明实例构造函数,编译器通常提供一个无参构造函数;一旦声明了带参构造函数,就不会再自动补出你期望的无参版本。
对象初始化器并不替代构造函数。对于 private set 或只读属性,外部也不能靠初始化器任意赋值。
在构造函数中尽量不要调用可能被派生类重写的虚方法;此时派生类的构造函数体还没有完成,其状态可能尚未准备好。构造函数说明
2.7 析构、终结器与垃圾回收
C# 中常被称为“析构函数”的语法是终结器:
1 | |
它没有访问修饰符、参数或返回类型,不能由调用方手动调用。执行时机由运行时决定,不是变量离开作用域就立即执行。
GC 根据可达性判断对象是否仍可使用:局部活跃引用、静态引用等可能让对象继续可达。把一个变量设为 null 只是丢掉这一条引用,其他引用仍可能存在。循环引用本身不会让不可达对象永远不能回收。
| 机制 | 主要负责什么 | 时机 |
|---|---|---|
| GC | 托管对象内存的回收 | 由运行时安排 |
终结器 ~Type() |
特定资源的非确定性兜底清理 | 不可依赖具体时刻 |
IDisposable.Dispose |
按明确约定释放资源或恢复状态 | 调用者显式安排 |
using 语句/声明 |
离开作用域时调用 Dispose | 正常控制流和异常离开时均安排清理 |
.NET 5+ 不把进程退出时运行全部终结器作为保证。也不要通过频繁 GC.Collect() 驱动游戏流程或及时关闭文件。大多数只包含托管字段的普通类,不需要自己添加终结器。终结器、垃圾回收基础
托管堆还采用分代管理:普通短寿命小对象通常从第 0 代开始,存活后可能进入第 1、2 代;较长寿命对象不必在每次短期回收中都以相同方式处理。大对象另有管理策略,所以不要把所有分配一律理解为第 0 代。理解分代是为了分析分配与存活,不能据此精确预测某个对象在哪一行被回收。
2.8 确定性清理:IDisposable 与 using
下面用拥有一个 MemoryStream 的对象演示清理模式,不引入真实文件:
1 | |
1 | |
即使块内抛出异常,离开 using 作用域时仍会安排调用 Dispose。Dispose 不等于立即释放对象的托管内存,也不是让对象引用自动变成 null。using 语句
类直接拥有非托管资源时,通常优先使用合适的 SafeHandle;完整的终结器/Dispose 继承模式属于更深入的资源管理主题。这里的类只拥有一个可释放的托管对象,因此没有添加多余终结器。
贪吃蛇的 ConsoleSession 实现 IDisposable,在游戏退出或终端异常时恢复光标和颜色;不用终结器决定何时恢复终端。
2.9 成员属性:get 与 set
1 | |
| 写法 | 含义 |
|---|---|
显式 get/set 方法体 |
可以校验、转换或计算 |
{ get; set; } |
自动属性,编译器生成背后字段 |
{ get; private set; } |
外部可读,当前类型可写 |
{ get; } |
只读自动属性,可在声明或相应构造函数中初始化 |
=> 表达式 |
计算属性,不一定有独立保存的字段 |
setter 中的 value 表示调用方要写入的新值。Score = value; 若写在 Score 自己的 setter 中,会递归调用自身;通常应该赋给背后字段 score。
属性不一定只是一次内存读取,但应保持行为可理解。普通属性通常不能直接作为 ref/out 实参。get 返回可变数组或集合时,也可能泄漏内部状态;只读属性只是限制“替换引用”,不自动冻结引用指向的对象。属性
2.10 索引器:让对象支持方括号
1 | |
1 | |
this[int index] 是索引器声明,参数不一定必须是 int;也可以有多个参数或按参数列表重载。索引器让访问方式像数组,但对象仍可控制校验与存储,不需要公开内部数组。索引器
2.11 静态成员与静态类
player.Hp 属于某个实例,Player.CreatedCount 属于 Player 类型。不同玩家对象共享该计数。
1 | |
示例计数按单线程教学使用,不把简单 ++ 当成多线程安全保证。静态状态也不会因为开启新一局就自动清零,长期保存引用还可能延长对象生命周期。
1 | |
静态类不能实例化或作为普通基类继承,没有实例成员;常用于无状态工具或固定配置。不要为了调用方便,把输入、地图、蛇身等全部变成全局静态变量,否则对象隔离和测试会变困难。
2.12 静态构造函数
Player 示例中的 static Player() 用于初始化类型级 Description。其特点是:无参数、无访问修饰符、不能直接调用,通常在运行时首次需要初始化该类型时执行,同一类型的静态初始化只安排一次。
静态字段初始化器与静态构造函数共同参与初始化;读取编译时 const 并不需要靠触发静态构造函数。不要依赖多个类型复杂静态初始化之间的偶然顺序。
如果静态初始化抛出异常,类型通常会进入初始化失败状态,后续使用可能得到 TypeInitializationException,而不是每次访问都从头重试。因此避免在其中执行易失败的外部访问、阻塞等待或复杂业务。静态构造函数
2.13 扩展方法
项目用扩展方法把方向转成坐标偏移:
1 | |
1 | |
经典扩展方法写在非嵌套、非泛型静态类中,方法是静态的,第一个参数带 this。direction.ToOffset() 本质上仍是静态方法调用的便捷语法。
使用方需要导入扩展方法所在命名空间。扩展方法不能因此访问目标对象的 private 字段,也不能当作虚方法重写;同名适用的实例方法优先。
C# 14 引入 extension 块等新形式;本文固定 C# 12,使用传统 this 参数写法。扩展成员
2.14 运算符重载
坐标相加可以表达“当前位置加方向偏移”。项目的坐标类型同时提供了值相等语义:
1 | |
1 | |
本文使用经典 public static ... operator ... 声明,至少有一个操作数应是当前定义类型。不能任意创造新的运算符,不能改变优先级,也不能重载普通赋值 =。
== 与 != 要配对定义,并与 Equals、GetHashCode 保持一致;< 与 >、<= 与 >= 等也有配对要求。不要让 + 执行删除文件等与读者预期完全不同的行为。运算符重载
2.15 内部类:更准确地说是嵌套类
1 | |
1 | |
嵌套类可以访问包含类型的私有成员,但不会自动绑定某个外层对象。示例显式传入 owner,才能读取该实例的 note。
类中的嵌套类型默认 private,也可以明确声明可见性。命名为“内部类”并不等于必须使用 internal 关键字。项目的 Canvas 是绘制器的 private 嵌套类,规则层完全不需要知道它。嵌套类型
2.16 分部类:多个文件组成一个类型
VisitCounter.State.cs:
1 | |
VisitCounter.Behavior.cs:
1 | |
1 | |
两个文件定义的是同一个类型,共享 private 字段;不是创建两个类,也不是通过继承连接。各部分要使用相同类型名、命名空间与兼容的声明,并在同一编译中组成同一个类型。
项目把 ConsoleRenderer 拆成公共画布、菜单、游戏三份文件,便于阅读。partial 只是文件组织工具;若职责已经完全不同,仍应拆成不同类型。分部类
2.17 本章练习
- 给玩家增加经验值,外部只能读取,通过 AddExperience 方法增加,拒绝负数。
- 给背包添加只读索引器版本,比较它与公开数组的差异。
- 解释为什么 ConsoleSession 使用 Dispose,而不是等待终结器恢复光标。
- 将一个类分成 State 与 Behavior 两份文件,并验证 private 字段仍然共享。
第三章:面向对象之继承
3.1 概念与语法
1 | |
派生类只能有一个直接基类,但可以实现多个接口;继承关系具有传递性。构造函数与终结器不能被当成普通成员继承后直接调用。
基类的私有状态仍可能是派生对象的一部分,但派生类不能因此直接访问 private 字段。通过基类公开或受保护的操作使用它,不是简单地认为“private 数据在派生对象中消失了”。继承
3.2 可运行示例:角色、战士与法师
1 | |
1 | |
Character 是变量的编译时类型,Warrior 是对象的实际运行时类型。这个赋值不复制出一个新的 Character 对象,也不会把战士对象“切掉一半”。
通过 Character 变量,只能直接访问编译器认为 Character 提供的成员;Warrior 特有的 RaiseWeapon 需要先获得相应类型的引用。
3.3 里氏替换原则与 is/as 的关系
里氏替换原则要求:把父类型对象替换成子类型对象时,使用者依赖的正确性与行为契约仍应成立。
它比“能赋值给父类变量”更强。C# 类型检查可以接受继承语法,却不保证业务契约一定合理。子类型通常不应擅自加强调用前提、削弱结果保证,或破坏父类型的不变量。Liskov 与 Wing 的行为子类型论文
例如,若某个“可飞行的鸟”基类承诺 Fly 能飞行,让企鹅继承后直接抛出“不支持飞行”,就会让依赖这项承诺的调用失败。更好的设计是把“鸟”与“飞行能力”分开:普通鸟共享合适的鸟类行为,能飞的类型再实现 IFlyer。
is、as 是类型检查和转换工具,不是里氏替换原则本身。不要把“会写 is/as”当成已经满足该原则。
3.4 is、模式匹配、as 与强制转换
1 | |
| 写法 | 用途 | 不兼容时 |
|---|---|---|
value is Warrior |
检查是否可作为该类型使用 | 返回 false |
value is Warrior warrior |
检查成功后提供类型化变量 | 条件为 false,不进入成功分支 |
value as Mage |
尝试引用或可空值类型转换 | 返回 null |
(Mage)value |
显式要求进行允许的转换 | 运行时不兼容可能抛 InvalidCastException |
null is Warrior 为 false;as 不适用于普通非可空 int,也不执行字符串解析或任意数值转换。as int? 可以用于适合的可空值类型转换场景。
1 | |
GetType() == typeof(Warrior) 检查精确运行时类型;is 检查类型兼容关系,也可能匹配其进一步派生类型。对可能重载 == 的类,value is null 是不依赖该相等运算符的空引用检查。类型检查与转换
3.5 继承中的构造函数
Mage 构造函数使用:
1 | |
base(name) 选择基类构造函数,this(...) 则选择同类的另一个构造函数。若没有明确写 base,编译器会尝试相应的无参基类构造;基类没有可访问的无参构造时,需要显式传入合适参数。
对于简单构造链,基类构造函数体先执行,之后才执行派生类构造函数体。这个说法专指构造函数体,不应扩展成“所有字段初始化在所有层级中的顺序都一律如此”。
在派生构造函数中,基类 Name 已由基类处理,Mage 只负责它新增的 Mana 规则。
3.6 万物之父 Object:适用范围与装箱拆箱
普通托管类最终从 System.Object 继承,未显式写基类的普通类也有这个基础。C# 的 object 是 System.Object 的别名。
值类型通过运行时的 ValueType 体系及装箱,也能放进 object 变量。不能把这句教学概括扩张到指针、普通 ref struct 的装箱等所有底层类型和受限场景。
flowchart TD
A[System.Object] --> B[Character]
A --> C[System.ValueType]
B --> D[Warrior]
B --> E[Mage]
C --> F[GridPoint 结构体]
图中的值类型关系是运行时类型体系示意,不是让你手工写 struct GridPoint : ValueType。
1 | |
拆箱要匹配实际装箱的值类型:
1 | |
把普通类引用赋给 object 不叫装箱;把普通结构体赋给其实现的接口变量也可能发生装箱。泛型有时能避免不必要的装箱,但需要结合具体调用分析。装箱与拆箱
3.7 密封类 sealed
1 | |
sealed 用于表达“不提供继续派生的扩展点”。字符串 String 本身也是密封类。密封不表示对象内容自动不可变,也不表示所有方法都变成静态方法。
如果需要为密封类型增加工具行为,可以考虑扩展方法或组合包装,不一定需要继承。
sealed 官方说明 同时涵盖密封类与后文的 sealed override。
3.8 本章练习
- 用父类型数组保存 Warrior 与 Mage,比较 Name、Act 与 GetType 的结果。
- 写出 is 模式匹配、as 与显式转换的失败行为。
- 解释为什么“企鹅在 Fly 中抛出不支持异常”可能破坏原本的飞行契约。
- 给基类只保留带参构造函数,观察派生类如何通过 base 初始化。
第四章:面向对象之多态
4.1 virtual 与 override
基类把可定制的操作声明为 virtual,派生类用 override 重写。同一段基类调用,根据实际对象找到对应实现。
1 | |
输出分别为“剑士挥剑”“法师施法”。基类调用方不必写一串 if (character is ...) 才能选择动作。多态
4.2 重载、重写与隐藏
第三章的 BaseLabel 与 DerivedLabel 演示了隐藏:
1 | |
new 明确表示隐藏同名成员,并不建立 override 的虚调用关系。
| 特性 | 典型关键字 | 主要选择依据 |
|---|---|---|
| 重载 overload | 同名、不同参数列表 | 编译时可见的参数类型与重载规则 |
| 重写 override | virtual/abstract 与 override | 虚调用时的实际运行时类型 |
| 隐藏 hiding | new | 通常按编译时所见类型选择该成员 |
不能只改返回类型就重载,也不能把普通非虚实例方法直接声明为 override。这里讨论的是普通类型化调用;dynamic 的运行时绑定是另一个主题。
4.3 抽象类与抽象方法
抽象类不能直接 new,但可以保存字段、提供构造函数、实现普通方法,并把部分操作留给派生类。
下面同时展示抽象类、具体类、接口与密封方法:
1 | |
1 | |
得到攻击力 5、20、40。GetAttack 是抽象方法,没有方法体;非抽象派生类必须完成尚未实现的抽象成员。抽象类可以没有抽象成员,但只要包含抽象方法,类本身也必须是抽象的。
4.4 接口:表达能力与协作契约
IHealable 表达可治疗能力。调用者关心 Heal 的契约,不必关心对象是否是 Dragon。
1 | |
类可以实现多个接口,结构体也可以实现接口。接口可以包含方法、属性、索引器、事件等契约;本文主要使用实例方法和只读属性。
不要把接口说成“任何版本都不能有实现”。C# 8 起支持默认接口实现,较新版本还支持静态抽象成员等能力。本文为了保持模型清楚,采用经典的无方法体实例契约。接口
| 对比 | 抽象类 | 接口 |
|---|---|---|
| 表达重点 | 相关类型共享的基础状态与行为 | 一种能力或协作角色 |
| 普通实例字段 | 可以 | 不作为接口实例状态提供 |
| 实例构造初始化 | 可以由派生构造链调用 | 不用于直接创建接口实例 |
| 类型关系 | 类只能有一个直接基类 | 类型可以实现多个接口 |
| 是否可直接实例化 | 不可 | 不可 |
| 本项目用途 | Scene 抽象场景 | 输入、绘制、时间、食物生成等契约 |
接口不是实现细节的“万能隐藏器”。如果接口方法仍让调用方知道所有内部数组布局,就没有真正改善边界。
4.5 隐式与显式接口实现
前面的 Dragon 使用 public Heal,属于隐式实现。显式实现则把入口限定在接口视角:
1 | |
1 | |
显式实现没有再写 public。它适合区分不同接口中的同名成员,或避免把某项能力直接放进类的普通调用界面。
4.6 密封方法 sealed override
Dragon 的 Speak 声明为:
1 | |
AncientDragon 仍可以继承 Dragon、重写 GetAttack,但不能再 override Speak。sealed 在这个上下文中与 override 配合,锁定已有的虚方法实现。
它不禁止派生类以 new 隐藏同名成员;隐藏仍不能改变通过原虚方法槽发出的调用。因此不要把“密封某方法”误说成“所有同名写法都无法出现”。
4.7 在贪吃蛇中的多态
GameApplication 字段保存 Scene:开始、游戏、结束三个具体场景都实现 Handle 与 Render。主循环只调用:
1 | |
扩展一个新的帮助场景时,新增 Scene 实现,再在导航处接入,而不是把每个按键、更新和绘制操作都堆进同一个巨大 switch。
4.8 本章练习
- 给 Enemy 增加一个具体派生类,调用方仍使用 Enemy 数组。
- 比较把 Act 写成 override 与 new 后,基类变量调用的差别。
- 给某类添加 IHealable 显式实现,观察通过类变量与接口变量的调用区别。
- 解释为什么密封 Speak 不会让 Dragon 整个类都不能继承。
第五章:其他知识与文本 API 合集
5.1 命名空间:组织名称,而不是自动组织文件
1 | |
调用时可以写完整名称,或导入命名空间:
1 | |
1 | |
| 语法/概念 | 作用 |
|---|---|
namespace A.B { ... } |
定义命名空间 |
namespace A.B; |
文件范围命名空间,C# 10 起支持 |
using A.B; |
在当前作用域使用该命名空间中的类型简名 |
using Alias = A.B.SomeType; |
为类型或命名空间起别名 |
using static SomeType; |
导入可使用的静态成员 |
global::A.B.Type |
从全局命名空间根开始解析名称 |
.csproj 的 ProjectReference |
添加另一个项目的程序集引用 |
using 不会自动下载包、添加项目引用,也不会让子命名空间中的类型自动全部可见。文件夹不强制决定命名空间,不过保持两者大致对应能让项目更好找。命名空间
源码包中的 OopExamples 引用了 SnakeGame 项目,所以能够使用它公开的 GridPoint;只写 using SnakeGame.Domain; 却没有相应项目引用,不足以完成这个依赖。
5.2 Object 方法全览
| 方法 | 典型用途 | 关键点 |
|---|---|---|
ToString() |
生成对象的文字描述 | 可重写;默认通常是类型名,不会自动列出所有字段 |
Equals(object?) |
判断按该类型规则是否相等 | 普通类默认以引用相等为基础;类型可以重写 |
object.Equals(a, b) |
处理空值后调用适当的相等逻辑 | 不等于永远只比较引用 |
ReferenceEquals(a, b) |
判断是否是同一个引用 | 静态方法;不要拿它当所有值类型的相等判断 |
GetHashCode() |
为哈希集合提供哈希码 | 相等必须同码,同码不代表相等 |
GetType() |
得到实际运行时 Type | 不是变量声明中的基类类型;不能普通 override |
MemberwiseClone() |
创建当前对象的浅拷贝 | protected,复制字段值,不递归复制所有引用对象 |
Finalize() |
终结机制对应的受保护成员 | C# 用终结器语法参与,不手动直接调用 |
Object 是这些成员的基础来源,但具体类型可能重写其中部分行为。Object 官方 API
5.3 ToString、GetType 与相等性
1 | |
typeof(Character) 由你写出的类型决定;character.GetType() 由实际对象决定。对 null 直接调用 GetType 会失败,需要先检查。
GridPoint 把相等定义成 X、Y 都相等,并同时提供 Equals、==、!=、GetHashCode。
1 | |
只知道哈希码相同,不能反推对象一定相等。 哈希码不是永久 ID,也不应作为跨进程持久标识。用可变字段计算哈希时,再修改这些字段可能破坏它作为哈希集合键的使用;不可变坐标更容易维护这项约定。GetHashCode 契约
5.4 MemberwiseClone:浅拷贝示例
1 | |
1 | |
只读属性 Scores 禁止外部替换该属性,但并没有禁止修改内部数组元素。复制多层对象时,要按业务逐层决定复制还是共享。
5.5 String:不变文本与索引规则
string 是 System.String 的别名,是引用类型,字符串对象内容不可变。看似“修改”的方法通常返回新的字符串结果,不会原地改写当前对象。
1 | |
Length 与索引按 UTF-16 代码单元计算,不等于屏幕上的字形数:"😀".Length 是 2。Substring 等按 char 索引切割时,有可能截断代理项对;真正按 Unicode 码点或用户感知字符处理,需要对应的 Rune、StringInfo 等工具。
下面按用途整理 String 的常用公开 API 家族。String 官方 API
5.6 String API 合集:检查、比较与查找
| API | 用途 | 示例/注意 |
|---|---|---|
string.Empty |
长度为零的字符串 | 是静态只读字段,不是方法,也不是 null |
.Length |
UTF-16 代码单元数 | 属性,不写括号 |
text[index] |
读取指定 char | 只读索引器;需在范围内 |
string.IsNullOrEmpty |
null 或空字符串 | 空格串不算空 |
string.IsNullOrWhiteSpace |
null、空串或全空白 | 常用于输入校验 |
.Equals / string.Equals |
按指定规则比较内容 | 建议明确 StringComparison |
== / != |
字符串内容相等/不等 | 不是简单比较对象引用 |
string.Compare |
按文化/比较选项比较顺序 | 返回负、零、正,不保证只有 -1/0/1 |
string.CompareOrdinal |
按序号比较 | 适合非语言规则的确定性比较 |
.CompareTo |
与另一值比较顺序 | 默认规则需注意文化与重载 |
.Contains |
是否包含子串 | 可选择 StringComparison |
.StartsWith |
是否有指定前缀 | 路径、协议、标识符通常需明确比较规则 |
.EndsWith |
是否有指定后缀 | 不代表完整格式校验 |
.IndexOf |
第一个匹配位置 | 未找到为 -1,部分重载可限定范围 |
.LastIndexOf |
最后一个匹配位置 | 搜索方向与范围参数要分清 |
.IndexOfAny |
第一个属于给定字符集合的位置 | 常用于寻找分隔符 |
.LastIndexOfAny |
最后一个属于字符集合的位置 | 与上者相对 |
1 | |
程序标识符、固定命令等通常应明确使用 Ordinal/OrdinalIgnoreCase;自然语言排序和显示则可能需要相应文化规则。不要为了比较一次大小写无关的标识符,先把双方整串转小写并依赖默认文化。
5.7 String API 合集:截取、拆分与生成
| API | 用途 | 关键规则 |
|---|---|---|
.Substring(start) |
从指定索引到结尾 | 索引从零开始 |
.Substring(start, length) |
截取固定数量 char | 第二个参数是长度,不是结束下标 |
.Split |
按分隔符拆分成数组 | 可配 RemoveEmptyEntries、TrimEntries |
string.Join |
用分隔符连接元素 | 适合数组/集合显示 |
string.Concat |
拼接多个值 | 不自动添加分隔符 |
string.Format |
按占位格式生成文字 | 例如 {0:F2};可指定文化 |
$"...{value}..." |
插值表达式 | 语言语法,不是 String 的实例方法 |
.Insert(index, value) |
在指定位置插入文字 | 返回结果,不修改原对象 |
.Remove(start, count) |
移除指定范围 | count 是移除数量 |
.Replace(old, new) |
替换匹配内容 | 普通 Replace 不把参数当正则表达式 |
.PadLeft(width) |
左侧补齐到指定总宽度 | 已足够宽时不会自动截断 |
.PadRight(width) |
右侧补齐到指定总宽度 | char 宽度不等于终端显示列宽 |
new string(char, count) |
生成重复字符文本 | 例如横边框 |
1 | |
查找后再截取时,要先处理 IndexOf 返回 -1 的情况,不能把它直接传给 Substring。Split 的返回结果可能是空数组,也不应默认一定存在第零项。
5.8 String API 合集:清理、编码与其他操作
| API | 用途 | 注意 |
|---|---|---|
.Trim |
去除两端指定字符或空白 | 不删除中间空白 |
.TrimStart / .TrimEnd |
只处理开头或结尾 | 自定义字符参数是字符集合,不是一个必须完整匹配的词 |
.ToUpper / .ToLower |
按文化转换大小写 | 可受文化影响 |
.ToUpperInvariant / .ToLowerInvariant |
按固定文化规则转换 | 仍不是“任意文字的通用身份规范化” |
.ToCharArray |
复制为 char 数组 | 可以修改数组,不会改原字符串 |
.CopyTo |
复制指定范围到目标字符存储 | 明确来源索引与目标容量 |
.Normalize / .IsNormalized |
Unicode 规范化与检查 | 规范化不等于输入过滤 |
.GetEnumerator |
逐个枚举 char | foreach (char c in text) 使用这一类能力 |
.EnumerateRunes() |
按 Unicode 标量枚举 | 由相应文本 API 提供;码点数仍不等于字形数 |
.AsSpan() |
获取只读字符视图 | 是相关扩展 API,不是可写字符串入口 |
string.Intern / string.IsInterned |
认识字符串驻留池 | 高级用途,不作为普通业务优化起点 |
.Clone() |
String 的克隆接口行为 | 字符串不可变,不应假设它创建可独立修改的字符缓冲 |
.ToString() |
返回字符串表现 | 字符串本身已是文本 |
string.Copy |
历史复制接口 | 已弃用,不作为新代码的复制方案 |
1 | |
需要 UTF-8 字节时使用 Encoding.UTF8.GetBytes(text);字节编码与字符串对象中的 UTF-16 表示不是同一个层次。
5.9 StringBuilder:逐步构造文本
循环中不断 text += piece 可能反复分配和复制较长字符串。StringBuilder 提供可变缓冲,适合逐步组装日志、报表或每帧字符画面。
1 | |
需要 using System.Text;。StringBuilder 本身是引用类型、可变对象;多个变量引用同一个 builder 时会看到相同修改。它也不是无需协调就能被多个线程同时写入的容器。StringBuilder API
5.10 StringBuilder 属性与方法合集
| 成员 | 用途 | 注意 |
|---|---|---|
new StringBuilder() |
创建默认缓冲 | 不表示已包含某段文字 |
new StringBuilder(capacity) |
提示预期容量 | 容量不等于当前文本长度 |
new StringBuilder(text, capacity) |
初始文字加容量 | capacity 需满足相应构造约定 |
.Length |
当前字符数,可调整 | 增长 Length 会补空字符,不是普通空格 |
.Capacity |
当前容量 | 不应小于当前长度 |
.MaxCapacity |
容量相关上限属性 | 不把它当作可靠的业务输入长度限制 |
builder[index] |
读写一个 char | 仍是 UTF-16 索引,需在有效范围内 |
.Append |
追加文字、char、数值等 | 常返回自身,支持连续调用 |
.AppendLine |
追加文字和平台换行符 | 无参版本只追加换行 |
.AppendFormat |
按复合格式追加 | 可传文化提供者 |
.AppendJoin |
把多个元素用分隔符追加 | 不必先创建 Join 的完整中间结果 |
.Insert |
在指定位置插入 | 后部内容需要移动,非所有操作均为常数成本 |
.Remove |
移除指定范围 | 返回同一 builder 方便链式调用 |
.Replace |
替换内容 | 也可选择部分范围 |
.Clear |
清空当前文本 | 不应假定立即把全部容量交还系统 |
.EnsureCapacity |
确保至少有指定容量 | 实际容量可能更大 |
.ToString() |
生成最终不可变字符串 | 会产生文本结果,别在循环中每次都调用来抵消收益 |
.ToString(start, length) |
只生成指定范围 | 范围按字符计算 |
.CopyTo |
复制字符到外部目标 | 需要正确安排目标存储 |
.GetChunks |
枚举内部字符块的只读视图 | 高级读取;枚举期间不要修改 builder |
.Equals(StringBuilder) |
当前 .NET 下比较内容 | 与 object 重载视角要区分 |
.Equals(ReadOnlySpan<char>) |
按字符内容比较 | 支持情况按目标框架确认 |
StringBuilder 的 MaxCapacity 不等于可以省略业务长度检查,某些追加路径和运行时行为允许超出直观预期。限制聊天文本或文件格式长度,应由业务明确校验。
5.11 完整文本示例与重载陷阱
1 | |
特别留意:
1 | |
前一条选择 StringBuilder 的类型化重载;后一条以 object 视角选择继承的相等入口,不能认为方法名字相同就会自动选择相同规则。旧 .NET Framework 对类型化比较还涉及容量等规则,因此跨运行时阅读示例要注意具体目标。StringBuilder.Equals
| 需求 | 常用选择 |
|---|---|
| 少量已知片段,代码清楚 | 插值或简单拼接 |
| 已有一组元素,添加分隔符 | string.Join |
| 循环逐步构建大段文本 | StringBuilder |
| 只读取局部字符,且需要减少复制 | 学习只读 Span 等更深入 API |
5.12 本章练习
- 对含多余空格和空项的逗号文本做 Split,再用 Join 输出。
- 比较 Equals、ReferenceEquals 与 GetHashCode 的用途。
- 为一个小型值对象设计一致的 Equals 与 GetHashCode。
- 使用 StringBuilder 生成多行分数表,最后只调用一次 ToString。
第六章:面向对象七大原则
6.1 七项原则与应用范围
| 原则 | 常用缩写 | 主要关注 |
|---|---|---|
| 单一职责原则 | SRP | 把因不同原因变化的职责分开 |
| 开闭原则 | OCP | 为预期变化提供扩展点,减少修改稳定核心 |
| 里氏替换原则 | LSP | 子类型遵守使用者依赖的父类型契约 |
| 接口隔离原则 | ISP | 客户只依赖它真正需要的接口能力 |
| 依赖倒置原则 | DIP | 高层规则与底层实现通过抽象协作 |
| 迪米特法则/最少知识原则 | LoD | 减少对象对远处内部结构的了解 |
| 合成复用原则 | CRP | 合适时通过组合、委托复用,而非强行继承 |
前五项通常合称 SOLID。它们是评估变化与耦合的工具,不要求为每个小方法单独创建接口,也不保证类越多设计越好。
6.2 单一职责原则:按变化原因划分
问题: 一个 Game 类同时读键、计算蛇碰撞、拼字符串、写终端,还负责菜单。修改终端宽度,容易碰到规则代码;修改碰撞规则,又要在输出逻辑中找位置。
实现:
| 职责 | 本项目类型/目录 |
|---|---|
| 蛇的身体和转向状态 | Domain/Snake |
| 一局移动、增长、胜负规则 | Core/GameEngine |
| 键盘到命令 | Infrastructure/ConsoleInputSource |
| 终端布局和字符输出 | Presentation/ConsoleRenderer |
| 开始、游戏、结束的交互 | Scenes |
| 时间与循环调度 | Application + IGameClock |
单一职责不等于一个类只能有一个方法。GameEngine 的移动、吃食物、死亡判定都围绕一局规则共同变化,合理地放在一起。SRP 原作者说明
6.3 开闭原则:对预期变化开放扩展
问题: 计分代码通过 if (foodType == ...) 到处判断,新加一种规则就要修改许多调用点。
实现: 把变化部分抽象成 IScoreRule,ScoreCounter 只读取它的契约。
1 | |
1 | |
添加另一种计分策略时,ScoreCounter 无需修改;组装位置选择新策略即可。游戏已经对食物生成和绘制提供了类似扩展点。
开闭不意味着旧代码永远不能修改。发现错误、契约不合理或需求确实改变时,仍应修改;目标是减少每次扩展都侵入稳定规则的情况。OCP 原作者说明
6.4 里氏替换原则:不仅签名相同,行为也要守约
本项目 IFoodSpawner 约定:有空格时输出棋盘内且不在蛇身上的食物;没有可用格时返回 false。
一种替代实现如果返回蛇头位置,虽然能通过编译,却不能正确替代 RandomFoodSpawner。GameEngine 会检查这项契约,并报告实现错误。
可以改变随机策略,例如偏好边缘或使用固定序列做测试,但不能随意破坏“食物在合法空格”这项保证。替代实现还应遵守约定的输入范围、失败行为与状态规则。
6.5 接口隔离原则:按调用角色拆分
如果所有场景都依赖包含“读取按键、画游戏、写菜单、保存文件、播放声音”的大接口,简单菜单也被迫知道许多无关操作。
项目把绘制划成两个调用角色:
1 | |
StartScene 和 EndScene 只需要菜单绘制,PlayScene 只需要游戏绘制。具体 ConsoleRenderer 可以同时实现两种角色;接口隔离不要求每种能力都必须由不同对象实现。
6.6 依赖倒置原则:规则依赖抽象
GameEngine 接收 IFoodSpawner,而不是在 Tick 内写 new RandomFoodSpawner(...);GameApplication 接收 IInputSource、IRenderer、IGameClock 等契约,而不是把 Console 与 Thread 调用写进每个场景。
1 | |
测试时可以换成固定位置的生成器,稳定重现“下一步吃到食物”或“最后一格填满”的情况。
依赖注入是从外部传入协作者的做法;依赖倒置关注规则与实现之间的依赖方向。二者相关,但不是同一个词,也不需要先安装 DI 容器才能实践。
具体 new 最终仍需存在。本项目集中在 Program 与工厂中组装对象,使核心规则不负责选择所有底层实现。
6.7 迪米特法则:少知道远处的内部结构
如果绘制器需要写 application.Scene.Engine.Snake.InternalBody[...],它就知道了整条对象内部关系。任何中间结构变化都可能影响它。
项目直接给绘制器 IGameView:它读取 Body、FoodPosition、Score 等必要信息,不需要穿过应用、场景和实体的多层内部对象。
场景通过 Navigator.StartRound() 表达开始一局,也不需要自己创建时钟、食物生成器和下一套场景。
这条法则不是机械的“表达式里不能有两个点”。StringBuilder 的连续 Append、清楚的值对象访问或合理的链式 API,并不自动等于违反它;重点是是否依赖了不必要的内部结构。LoD 原作者资料
6.8 合成复用原则:把协作者作为成员
GameEngine 拥有 Board、Snake 与 IFoodSpawner 协作者;它并不继承 ConsoleRenderer 来获得绘制功能,也不继承 Random 来获得随机数。
替换生成策略只需提供另一个 IFoodSpawner。它复用了协作契约,避免把“游戏规则”和“某种随机实现”锁进一个继承体系。
继承仍然适用于 Scene:每个具体场景都能履行 Handle、Update、Render 的场景契约。组合与继承不是只能选一项,本项目同时使用二者,并让它们各自表达合适关系。
6.9 七项原则落地检查
| 自查问题 | 对应原则 |
|---|---|
| 修改终端显示会不会迫使我修改碰撞规则? | SRP |
| 换一个食物生成策略,需要修改 GameEngine 吗? | OCP、DIP |
| 新生成器是否仍只返回合法空格? | LSP |
| 菜单是否必须依赖游戏专用绘制方法? | ISP |
| 测试是否能不接真实键盘和真实时间? | DIP |
| 调用方是否需要摸到蛇的可变内部 List? | LoD、封装 |
| 我是否只为复用一个方法就建立了不合理继承? | 合成复用、LSP |
6.10 本章练习
- 把一个同时负责计分与控制台输出的类拆开,说明各自的变化原因。
- 增加一种计分策略,保持 ScoreCounter 不变。
- 为 IFoodSpawner 写一段契约说明,列出一项违反契约的实现。
- 解释为什么“小项目不用 DI 容器”不妨碍构造函数注入。
第七章:项目实践——贪吃蛇
7.1 功能与规则
| 项目内容 | 实现规则 |
|---|---|
| 开始场景 | 标题、玩法;Enter 开始,Esc 退出 |
| 棋盘 | 固定 30 列 × 16 行逻辑网格,有完整边框 |
| 初始蛇 | 长度 3,位于中部,向右移动 |
| 自动移动 | 每 150 毫秒前进一步,按键不直接决定前进次数 |
| 转向 | WASD 或方向键;禁止直接反向 |
| 连续输入 | 每个移动节拍只接受一次有效转向 |
| 食物 | 随机生成在合法空格,不覆盖蛇身 |
| 吃食物 | 长度加一,得 10 分,再生成新食物 |
| 碰撞 | 撞墙或撞身体则结束本局 |
| 尾格规则 | 不生长时尾巴本轮会腾空,允许进入旧尾格 |
| 暂停 | P 暂停/恢复;暂停时忽略方向输入 |
| 胜利 | 蛇填满棋盘,显示胜利,不再尝试无限生成食物 |
| 结束场景 | 显示原因、分数、长度;R 重开,Enter 回菜单,Esc 退出 |
| 游戏中返回 | Esc 回菜单,本局不保存 |
| 终端过小 | 暂停更新,等待恢复窗口尺寸;此时 Esc 可退出 |
这是单线程控制台程序。业务规则与终端输入、画面输出分开,方便更换实现和进行不依赖真实终端的规则检查。
7.2 画面与坐标
总画面为 72 列 × 32 行。为了减少终端中文宽度差异,游戏内部显示英文与单列宽符号,教程提供中文解释。
| 符号 | 意义 |
|---|---|
@ |
蛇头 |
o |
蛇身 |
* |
食物 |
+、-、竖线 |
边框 |
| 屏幕行号,从 0 开始 | 内容 |
|---|---|
| 0~23 | 游戏区域,共 24 行,包括分数、操作提示、棋盘和边框 |
| 24~31 | 日志区域,共 8 行,显示最近五条记录 |
逻辑坐标 (0,0) 是棋盘内部左上角,X 向右、Y 向下。绘制时每个逻辑格占两列字符:
1 | |
这让蛇的横向与纵向视觉比例更接近,同时使 30×16 的地图能放进固定画面。逻辑移动不能直接用屏幕列号计算。
终端至少 73 列 × 33 行,比实际画面多留一列一行,避免最右列自动换行与末行滚屏。
7.3 按文件夹与职责分类
源码包中的 SnakeGame 项目按下表组织,括号中是主要作用:
| 文件夹 | 文件 | 作用 |
|---|---|---|
| 根目录 | Program.cs |
程序入口与具体对象组装 |
| 根目录 | SnakeGame.csproj |
目标框架、语言版本、可空分析配置 |
Domain |
GridPoint.cs |
不可变坐标、运算符、相等性 |
Domain |
Direction.cs |
方向与本局状态枚举 |
Domain |
DirectionExtensions.cs |
方向到偏移的扩展方法 |
Domain |
Board.cs |
棋盘尺寸与边界判断 |
Domain |
Snake.cs |
蛇身、索引器、转向与移动数据 |
Domain |
Food.cs |
食物位置 |
Contracts |
IGameView.cs |
绘制层需要的只读游戏视图 |
Contracts |
IFoodSpawner.cs |
食物生成策略契约 |
Contracts |
PlatformContracts.cs |
输入、时钟、窗口、工厂接口 |
Contracts |
RenderingContracts.cs |
绘制角色与场景导航接口 |
Core |
GameSettings.cs |
固定规则配置 |
Core |
GameEngine.cs |
一局规则、得分、碰撞、胜负 |
Core |
DefaultGameFactory.cs |
创建一局全新棋盘与蛇 |
Application |
GameApplication.cs |
主循环与场景切换 |
Scenes |
Scene.cs |
抽象场景基类 |
Scenes |
StartScene.cs |
开始界面输入与绘制 |
Scenes |
PlayScene.cs |
游戏输入、固定节拍、暂停 |
Scenes |
EndScene.cs |
结束界面输入与绘制 |
Infrastructure |
ConsoleInputSource.cs |
非阻塞按键读取 |
Infrastructure |
SystemGameClock.cs |
Stopwatch 时间与短暂休眠 |
Infrastructure |
RandomFoodSpawner.cs |
枚举合法空格并随机挑选 |
Infrastructure |
ConsoleSession.cs |
using / Dispose 恢复终端状态 |
Presentation |
ConsoleRenderer.cs |
分部绘制器与私有 Canvas 嵌套类 |
Presentation |
ConsoleRenderer.Menus.cs |
开始与结束画面 |
Presentation |
ConsoleRenderer.Play.cs |
棋盘、角色、食物与日志 |
OopExamples 是另一个控制台项目,内部也按 Encapsulation、Inheritance、Polymorphism、Design、Text、Extras 分类。它引用 SnakeGame 的公开类型,用于运行前六章示例,不会自动启动游戏。
7.4 类型之间如何协作
classDiagram
direction TB
Scene <|-- StartScene
Scene <|-- PlayScene
Scene <|-- EndScene
Scene : +Handle(command)
Scene : +Update(elapsed)
Scene : +Render()
PlayScene --> GameEngine
PlayScene --> IGameRenderer
IGameRenderer <|.. ConsoleRenderer
场景负责“现在如何交互”,GameEngine 负责“这一局规则如何变化”,ConsoleRenderer 负责“状态怎样显示”。场景可被替换,绘制器不直接改蛇身。
7.5 只读视图与封装
1 | |
绘制器得到这个接口,只能读取必要状态。Body 虽然表示一段正在变化的游戏数据,但外部不能通过该接口直接 Insert、Remove 或随意给某项赋值。
Snake 使用私有 List 保存身体,并对外提供只读包装;GameEngine 接收初始 Snake 时复制它的身体状态,避免创建方保留的可变引用直接修改正在运行的一局。
注意“只读视图”不是“历史快照”:引擎前进后,读取该视图仍会看到新状态。若要保存回放中的某一帧,应明确复制。
7.6 蛇的实现:转向、索引器和尾巴
1 | |
这里用到少量集合语法作为衔接:List<GridPoint> 是元素类型为 GridPoint 的可变长度列表;Count 是元素数,Insert/RemoveAt 修改内容,AsReadOnly 提供只读访问包装。前一册数组增删需要新建数组,List 则替调用方管理容量。
转向不是立刻移动,而是先记录为 pendingDirection。下一次移动采用这个方向,再清空待处理状态。
如果只检查“本次输入是否与当前方向相反”,快速按上、左可能在一次移动前绕过限制。因此一旦接收了一个有效转向,同一节拍内的后续转向全部忽略;移动完成后才重新接受。
7.7 一局引擎:碰撞、增长与胜负
1 | |
一次 Tick 的顺序是:预测新蛇头 → 判断是否吃食物 → 检查墙与身体 → 提交移动 → 更新得分与食物。
flowchart TD
A[预测新蛇头] --> B{"撞墙或撞有效身体?"}
B -->|是| C[记为失败并停止]
B -->|否| D[提交本轮移动]
D --> E{"吃到食物?"}
E -->|否| F[等待下一节拍]
E -->|是| G[增长并加分]
G --> H{"棋盘填满?"}
H -->|是| I[胜利并清除食物]
H -->|否| J[生成新食物]
J --> F
“有效身体”的范围很重要:不生长时,旧尾格本轮会腾空,所以碰撞判断跳过尾巴;生长时则保留尾巴。若不区分,蛇沿小回环进入原尾格时会被错误判死。
撞墙时先改变的是结局,不把无效坐标插入身体,因此结束画面与测试仍能读取最后一组合法位置。
7.8 食物生成:避免无限重试
1 | |
1 | |
这份实现先列出所有合法空格,再从中选择;不会在棋盘几乎填满时依靠“随机撞运气直到成功”。棋盘全部占满时返回 false;引擎会在填满时直接进入胜利,不继续请求食物。
这是针对 480 格教学棋盘的直观算法,主要成本来自检查候选格是否被占用。后续可以使用占用集合或维护空格表优化,但不用为了入门项目先引入复杂索引结构。
7.9 抽象场景与游戏场景
1 | |
1 | |
PlayScene 不直接调用 Console.ReadKey,也不自己拼字符画面;它接收命令、按累计时间调用引擎,再通过 IGameRenderer 显示。
暂停时不累计游戏时间。恢复时清空旧累计量,避免把暂停了几秒误解释为“应立即连续走几十步”。菜单和结束场景不推进蛇。
StartScene 与 EndScene 的完整源码也在 Scenes 文件夹中;它们实现同一个 Scene 契约,分别处理 Enter、R、Esc。
7.10 主循环与场景切换
1 | |
输入读取与逻辑节拍是分开的:主循环较频繁地检查按键,而蛇按 150 毫秒节拍前进。如果用阻塞的 ReadKey 驱动每一步,蛇就会变成“按一下走一步”,无法自动移动。
ConsoleInputSource 使用 KeyAvailable 先判断,再 ReadKey,避免没有按键时卡住;SystemGameClock 使用 Stopwatch 提供经过时间,而不是依赖可能跳变的日历时间。
切换场景时清理残留输入并重置时间基准。窗口过小时暂停更新,恢复后不把整个等待时间一次性补成大量移动;对其他长停顿,单次时间增量还限制为 250 毫秒。
7.11 分部绘制器、嵌套 Canvas 与 StringBuilder
ConsoleRenderer 的三份文件组成同一个类。其私有嵌套 Canvas 保存二维 char 数组,提供 Put、Line、Box,再通过重写 ToString 生成整帧文字。
Canvas 内使用 StringBuilder 按行追加字符,只在最终输出前转为字符串;最后一行不添加额外换行,减少末行滚屏。
绘制游戏时,先画边框、状态与食物,再从尾到头画蛇,让蛇头符号保持可见。这里的坐标转换全部留在 Presentation,不写入碰撞规则。
同一个对象既实现 IMenuRenderer,又实现 IGameRenderer 和窗口检测契约;不同场景只接收它需要的接口视角。
7.12 程序入口与资源恢复
1 | |
这里是具体对象的组装位置:创建真实键盘、终端绘制、时钟、食物生成器与工厂,再把它们传给 GameApplication。核心规则没有承担这些构造选择。
using (ConsoleSession ...) 确保离开运行作用域时恢复光标与颜色。终端被重定向时,程序会提示使用交互终端;不尝试在普通输出面板中伪造即时按键。
7.13 实际游玩与检查
- 在终端运行
dotnet run --project SnakeGame,按 Enter 开始。 - 观察蛇自动向右前进,按 W 或上方向键转向;不要等到已经撞墙才输入。
- 朝食物移动,观察增长与每次 10 分的变化。
- 按 P 暂停,确认蛇停住;再次按 P 恢复。
- 尝试直接反向,确认输入被忽略。
- 撞墙或身体后进入结束场景,按 R 开启新局,分数恢复为零、长度为三。
- 游戏中 Esc 回菜单,菜单或结束界面 Esc 退出。
| 验收项 | 预期结果 |
|---|---|
| 无按键时 | 蛇仍按固定节拍移动 |
| 方向输入 | 改下一步方向,不直接多走一步 |
| 同节拍快速转两次 | 只接收一次有效转向 |
| 进入旧尾格且不生长 | 允许移动,不误判碰撞 |
| 食物刷新 | 在棋盘内且不覆盖蛇身 |
| 蛇填满棋盘 | 直接胜利,不进入无限随机循环 |
| 暂停与恢复 | 停止逻辑推进,恢复不补跑整段暂停时间 |
| 新一局 | 新蛇、新分数、新食物,不复用上一局残局 |
| 显示实现替换 | 规则层不需要重新依赖 Console |
7.14 可以继续做的扩展
- 添加 HelpScene,复用 Scene 的输入与绘制契约。
- 添加障碍物,但先明确障碍物、蛇身、食物的占用规则。
- 把计分值抽象成类似 IScoreRule 的策略,对照第六章练习。
- 添加可调速度,把规则配置从固定常量改成经过校验的配置对象。
- 更换输入或绘制实现,保留 GameEngine;迁移到 Unity 时由引擎负责输入、时间更新与渲染。
- 记录每次有效转向与食物位置,生成可重复回放,再学习文件格式与资源管理。
这些扩展不要求把终结器、复杂继承树或所有设计模式都塞进游戏。选用能表达真实规则的机制即可。
附录 A:练习与参考答案
A.1 经验值的受控修改
下面是完整的小类型。也可以把相同的属性和方法加入 Player,而不是通过继承密封的 Player 强行扩展。
1 | |
1 | |
private set 控制修改入口;负值校验表达业务约束;checked 避免加法超出整数范围时静默环绕。
A.2 只读索引器与只读视图
| 方案 | 外部能否替换元素 | 内部后续变化能否被观察 |
|---|---|---|
公开 List<GridPoint> |
能 | 能 |
| 只提供只读索引器 | 不能通过该入口直接替换 | 通常能 |
| 包装为只读集合接口且不泄漏可变对象 | 不能通过该入口直接增删改 | 通常能 |
| 独立复制成快照 | 是否可改由快照接口决定 | 不随原集合继续变化 |
只读、不可变、快照是不同概念。还要考虑元素本身是否是可变引用对象,不能只看外层接口名字。
A.3 继承与多态判断
| 问题 | 答案 |
|---|---|
Character c = new Warrior("A") 是否复制对象 |
否,只建立基类型引用 |
c.GetType() 得到什么 |
运行时 Warrior 类型 |
c.Act() 如何选择 |
Act 为虚方法,执行 Warrior 的 override |
c as Mage 结果 |
null |
| 把 incompatible 引用显式转换成 Mage | 可能抛 InvalidCastException |
| BaseLabel 引用调用隐藏的 Label | 选择 BaseLabel 可见的版本 |
| Dragon.Speak 被 sealed override | 后续派生类不能重写该虚方法槽 |
| AncientDragon 是否仍可继承 Dragon | 可以,Dragon 类本身未 sealed |
里氏替换关注行为保证。换成子类型后若原来合法的调用开始莫名失败,即使语法可以编译,也应重新考虑类型关系。
A.4 文本拆分与生成
1 | |
追加过程中使用同一个 builder,最后只生成一次最终字符串。格式化要求不受当前文化影响时,应明确选择相应 CultureInfo。
A.5 资源与 GC 判断
| 问题 | 答案 |
|---|---|
| 局部变量离开作用域是否立刻执行终结器 | 不保证 |
| 把变量置 null 是否立即释放文件 | 不会因此保证释放,应使用 Dispose/using |
| using 是否释放对象全部托管内存 | 不负责保证这件事,主要安排 Dispose |
| 只有普通托管字段是否必须写终结器 | 通常不需要 |
| ConsoleSession 为什么实现 IDisposable | 运行作用域结束时及时恢复终端状态 |
| GC 能否回收不可达的循环引用 | 可以,不能把循环引用本身当作永远无法回收的理由 |
A.6 原则与项目设计
- 碰撞规则不依赖 Console:体现职责划分和依赖方向。
- 换食物生成策略不改引擎:体现抽象扩展点。
- 生成器不得把食物放在蛇身:体现行为契约,而不只是签名一致。
- 开始场景只接收 IMenuRenderer:按客户所需角色限制依赖。
- GameEngine 组合 Snake 与生成器:复用协作者,不强行继承其实现。
- 分部类能整理文件,但不会自动完成职责分离;不相关的责任仍应拆类型。
A.7 贪吃蛇边界题
- 蛇向右,在同一节拍先按上、再按左:只保留第一次有效的上转,不允许借第二次输入掉头。
- 下一格是旧尾格,但本步不吃食物:尾巴会移动,允许进入。
- 蛇吃到最后一块空地上的食物:增长、加分、进入胜利,清除食物。
- 游戏暂停五秒后恢复:不应立即补走五秒对应的全部步数。
- 给游戏换成固定食物生成器:仍须遵守棋盘内且不覆盖蛇身的契约。
附录 B:运行、排查与验证
B.1 示例文件位置与 using
源码包已设置目标框架和项目引用。运行某章节前不需要把它的全部类型重新粘贴到 Program。
如果自己创建练习文件,常用导入如下,按片段需要选择:
1 | |
章节中的补充类型和浅拷贝、显式接口等例子还整理在 OopExamples/Extras/AdditionalExamples.cs,可以运行:
1 | |
文档中的“构造声明片段”或“主循环说明片段”依赖其所属类,不是独立入口;完整对应文件在项目中。
B.2 常见问题
| 现象 | 检查方向 |
|---|---|
| 找不到 dotnet 或不支持 net10.0 | 是否安装 .NET 10 SDK,安装后重新打开终端 |
| 找不到某个类型 | using 是否正确,项目引用是否存在,可访问性是否足够 |
| 属性 setter 无限调用 | 是否在自己的 setter 中再次给该属性赋值,而不是赋给背后字段 |
| 不存在无参构造函数 | 声明带参构造后是否误以为编译器还会自动提供无参版本 |
| 派生类构造失败 | 基类是否只有带参构造,是否需要 base(…) |
| override 报错 | 基类成员是否 virtual/abstract,签名与可见性是否匹配 |
| 基类引用调用了“旧版本” | 是否用了 new 隐藏而非 override |
| 显式接口方法不能通过类变量调用 | 先转为对应接口视角 |
| 多个 partial 文件不合并 | 命名空间、类型名、partial 声明与编译项目是否一致 |
| 内部数组仍可从外部修改 | 是否只做了只读属性,却直接返回了可变数组 |
| String 方法没有改变文字 | 是否丢弃了返回的新字符串 |
| StringBuilder 内容比较不一致 | 是否调用了不同 Equals 重载,或运行在不同 .NET 实现 |
| 游戏只有按键时才移动 | 是否把阻塞 ReadKey 放进了逻辑推进路径 |
| 快速输入可以掉头 | 是否遗漏每个节拍只接收一次有效转向的限制 |
| 进入旧尾格被判死亡 | 是否在不生长时跳过即将腾空的尾格 |
| 棋盘快满时生成食物卡住 | 是否使用无上界随机重试,而没有处理空格耗尽 |
| 终端滚动或错位 | 是否至少 73×33,是否使用等宽字体,是否在末行额外换行 |
B.3 验证范围
配套程序在 .NET 10 环境完成编译,并进行了规则与示例检查:
文档中 77 段 C# 代码在各自需要的类型、方法或调用上下文中编译检查通过。
规则与示例检查共通过 2,043 项断言;两个可运行项目编译时均无警告、无错误。
坐标运算符、Equals/GetHashCode、方向扩展方法。
禁止反向、每节拍一次有效转向、普通移动、增长、计分。
墙碰撞、身体碰撞、进入本轮腾空尾格、结束后停止更新。
食物生成范围与占用排除、生成器契约检查、填满棋盘胜利。
暂停、恢复、节拍阈值、场景的多态导航。
开始、游戏、结束画面均为 72×32 个单列宽字符。
属性与索引器校验、异常离开 using 的释放、继承调用、方法隐藏、密封重写、文本重载示例。
交互终端实际跑通开始、自动移动、暂停、转向、撞墙结束、重新开始、返回菜单和退出,检查窗口过小时的恢复。
这些检查针对当前规则与终端流程,不把一种系统上的显示结果推广为所有字体与终端的完全一致保证。规则层可以在不打开终端的情况下验证。
附录 C:官方与原始参考资料
示例与项目为本文的教学实现。语言与 API 以 Microsoft Learn 为主要参考,设计原则参考原作者说明及行为子类型论文;阅读参考页时注意目标框架与语言版本。
| 编号 | 参考资料 | 主题 |
|---|---|---|
| R01 | .NET 安装指南 | SDK |
| R02 | C# 面向对象概览 | 封装、继承与类型 |
| R03 | 访问修饰符 | 成员可见性 |
| R04 | 构造函数 | this/base 构造链 |
| R05 | 终结器 | 终结语法与时机 |
| R06 | GC 基础 | 可达性与内存管理 |
| R07 | using 语句 | 确定性资源清理 |
| R08 | 属性 | get/set |
| R09 | 索引器 | this[…] |
| R10 | 静态构造函数 | 类型初始化 |
| R11 | 扩展成员 | 传统扩展方法与版本区别 |
| R12 | 运算符重载 | operator |
| R13 | 嵌套类型 | 内部类 |
| R14 | 分部类和方法 | partial |
| R15 | 继承 | 基类与派生类 |
| R16 | Liskov、Wing:A Behavioral Notion of Subtyping | 行为子类型与替换 |
| R17 | 类型检查与转换运算符 | is、as、显式转换 |
| R18 | 装箱与拆箱 | object 与值类型 |
| R19 | 多态 | virtual/override |
| R20 | 接口 | 实例契约与版本能力 |
| R21 | sealed | 密封类与成员 |
| R22 | 命名空间 | namespace 与 using |
| R23 | Object API | 基础方法 |
| R24 | GetHashCode | 相等与哈希契约 |
| R25 | String API | 字符串方法 |
| R26 | StringBuilder API | 可变文本构造 |
| R27 | StringBuilder.Equals | 重载与运行时差异 |
| R28 | Robert C. Martin:Single Responsibility Principle | 单一职责 |
| R29 | Robert C. Martin:Open Closed Principle | 开闭原则 |
| R30 | Karl Lieberherr 原作者主页与 Demeter 工作 | 最少知识与耦合控制 |