C# 进阶

C# 学习系列: C# 入门 → C# 基础 → C# 进阶 → C# 高级

接续《C#入门》《C# 基础》,从“会写语句”走向“会组织程序”:理解封装、继承、多态,掌握类的常用成员、Object 与文本 API,并用七项设计原则组织一个按文件夹划分的控制台贪吃蛇项目。

配套项目:.NET 10 SDK,C# 12,无第三方包。语言参考可能展示 C# 13、14 等较新语法,本文采用与配套项目一致的写法。

阅读与运行约定

  1. 已掌握变量、分支、循环、数组、函数、结构体、值与引用、ref / out。面向对象不是替代这些知识,而是把数据、规则和协作关系组织起来。
  2. 类型声明通常放在方法外;调用片段放在 Main 中。各例子按独立章节阅读,遇到重复类名不要全部粘贴进同一命名空间。
  3. “内部类”在 C# 中通常称为“嵌套类”;“析构函数”是常见历史叫法,本文使用更准确的“终结器”说明其运行时行为;“项目事件”按项目实践组织。
  4. 本文的“七大原则”采用常见教学口径:SOLID 五项,加迪米特法则与合成复用原则。这是设计指导,不是 C# 编译器强制的七条语法。
  5. String、StringBuilder 合集按公开 API 家族和常用重载整理,不逐项重复同名方法的全部重载。实际可用 API 取决于目标框架。
  6. 文档给出完整示例类型、项目结构、核心源码和阅读路线。源码包包含两个可运行项目及所有分类文件,贪吃蛇不能只复制某一个代码片段就运行。

目录

配套程序快速运行

安装 .NET SDK 后,解压源码包,在根目录执行:

1
2
3
4
5
6
7
8
9
10
11
12
# 运行本教程示例。
dotnet run --project OopExamples -- all

# 单独运行某类示例。
dotnet run --project OopExamples -- encapsulation
dotnet run --project OopExamples -- inheritance
dotnet run --project OopExamples -- polymorphism
dotnet run --project OopExamples -- text
dotnet run --project OopExamples -- principles

# 启动贪吃蛇。
dotnet run --project SnakeGame

游戏在交互终端运行,窗口至少 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
2
3
4
5
// 反例类型:外部可以随意破坏状态。
public class UnsafePlayer
{
public int Hp;
}

调用方可以写 player.Hp = -999。更合理的接口是让外部读取血量,通过方法表达“受到伤害”这个动作,并在方法里检查负伤害、限制最低血量。

封装不是单纯把字段改成 private,也不是给所有字段机械添加 get/set;它要表达对象允许怎样变化。

1.3 继承:表达可成立的“是一种”

Warrior : Character 表达战士是一种角色。调用方可以把战士作为角色使用,但战士还可以有举起武器等特有行为。

继承适用于共享契约,而不只是“两个类刚好有几行一样的代码”。蛇有身体坐标,棋盘有宽高,但不能因此让棋盘继承蛇。

1.4 多态:由实际对象决定行为

1
2
3
4
5
6
// 调用片段;Character、Warrior、Mage 在第三章给出。
Character[] team = { new Warrior("剑士"), new Mage("法师", 50) };
foreach (Character member in team)
{
Console.WriteLine(member.Act());
}

变量的编译时类型都是 Character,实际对象分别是战士与法师。Act 是虚方法并被重写,因此会调用对应实现。

常见误解 更准确的理解
封装就是把所有成员藏起来 应提供稳定、足够且维护规则的公开入口
继承就是复制父类代码 继承建立类型关系,成员可访问性另有规则
多态就是写很多 if 判断类型 通常通过虚方法或接口,让调用者依赖共同契约
重载、重写、隐藏都一样 重载选参数签名,重写参与运行时分派,隐藏主要按编译时类型选择

1.5 本章练习

描述贪吃蛇项目中:蛇身位置属于谁,终端输出属于谁,开始/游戏/结束场景有哪些共同操作。先划分职责,再考虑类名。

第二章:面向对象之封装

2.1 类与对象

类是类型定义,对象是根据该类型创建的具体实例。一个类可以创建多个独立对象。

1
2
3
4
public class Hero
{
public string Name { get; set; } = "未命名";
}
1
2
3
4
5
Hero first = new Hero();
Hero second = new Hero();
first.Name = "A";
second.Name = "B";
Console.WriteLine(first.Name); // A

类是引用类型,普通赋值复制引用,不会自动复制对象:

1
2
3
4
5
Hero first = new Hero { Name = "A" };
Hero alias = first;
alias.Name = "B";
Console.WriteLine(first.Name); // B
Console.WriteLine(ReferenceEquals(first, alias)); // True

new Hero { Name = "A" } 是对象初始化器:先调用所选构造函数,再对列出的可赋值成员赋值。它不绕过属性 setter 的校验。

2.2 基本语法与成员种类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 结构示意。
public class TypeName
{
private int value; // 字段,保存数据。
public int Value => value; // 属性,提供读取入口。
public TypeName(int initial) // 构造函数。
{
value = initial;
}
public void Increase() // 实例方法。
{
value++;
}
}
成员 主要用途
字段,常称成员变量 保存对象或类型的状态
方法 表达动作、计算或查询
属性 以成员访问语法读写或计算一个值
构造函数 初始化新实例
索引器 用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
using System;

namespace OopExamples.Encapsulation
{
public sealed class Player
{
private string name = "";
public static int CreatedCount { get; private set; }
public static string Description { get; }
public int MaxHp { get; }
public int Hp { get; private set; }
public bool IsAlive => Hp > 0;

public string Name
{
get => name;
set
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException("Name cannot be blank.", nameof(value));
name = value.Trim();
}
}

static Player() => Description = "玩家类型已初始化";
public Player() : this("勇者", 100) { }

public Player(string name, int maxHp)
{
if (maxHp <= 0)
throw new ArgumentOutOfRangeException(nameof(maxHp));
Name = name;
MaxHp = maxHp;
Hp = maxHp;
CreatedCount++;
}

public void TakeDamage(int damage)
{
if (damage < 0)
throw new ArgumentOutOfRangeException(nameof(damage));
Hp = Math.Max(0, Hp - damage);
}

public void Heal(int amount = 10)
{
if (amount < 0)
throw new ArgumentOutOfRangeException(nameof(amount));
Hp = (int)Math.Min((long)MaxHp, (long)Hp + amount);
}
}
}
1
2
3
4
5
6
Player player = new Player("  勇者  ", 100);
player.TakeDamage(25);
Console.WriteLine($"{player.Name}: {player.Hp}/{player.MaxHp}"); // 勇者: 75/100
player.Heal();
Console.WriteLine(player.Hp); // 85
// player.Hp = -1; // 错误:外部不能调用 private set。

Hp 的修改入口少,负伤害被拒绝,治疗不会超过最大值。名字在写入时修剪空白,并拒绝空名字。规则留在对象内部,调用方不需要每次复制一遍检查。

2.5 成员方法与 this

实例方法通过具体对象调用,例如 player.TakeDamage(25)。方法可以使用当前对象的实例字段和属性。

this 表示当前实例,常用于区分同名参数与字段:

1
2
3
4
5
6
7
8
9
public class NameTag
{
private string name;
public NameTag(string name)
{
this.name = name;
}
public string Read() => this.name;
}

静态方法没有当前实例,不能在其中直接使用实例的 this、字段或实例方法;需要接收或创建一个对象。

2.6 构造函数与构造链

构造函数与类同名,没有返回类型。可以重载,用 this(...) 调用同类的另一个构造函数:

1
public Player() : this("勇者", 100) { }

这段来自前面的 Player 类型,把初始化集中到带参构造函数,避免多个构造函数各自维护同一套规则。

如果类完全没有显式声明实例构造函数,编译器通常提供一个无参构造函数;一旦声明了带参构造函数,就不会再自动补出你期望的无参版本。

对象初始化器并不替代构造函数。对于 private set 或只读属性,外部也不能靠初始化器任意赋值。

在构造函数中尽量不要调用可能被派生类重写的虚方法;此时派生类的构造函数体还没有完成,其状态可能尚未准备好。构造函数说明

2.7 析构、终结器与垃圾回收

C# 中常被称为“析构函数”的语法是终结器:

1
2
3
4
5
6
7
8
// 语法示意,不作为资源管理模板,也不用于游戏业务。
public class FinalizerSyntaxDemo
{
~FinalizerSyntaxDemo()
{
// 真实终结器只应承担适合终结阶段的必要兜底清理。
}
}

它没有访问修饰符、参数或返回类型,不能由调用方手动调用。执行时机由运行时决定,不是变量离开作用域就立即执行。

GC 根据可达性判断对象是否仍可使用:局部活跃引用、静态引用等可能让对象继续可达。把一个变量设为 null 只是丢掉这一条引用,其他引用仍可能存在。循环引用本身不会让不可达对象永远不能回收。

机制 主要负责什么 时机
GC 托管对象内存的回收 由运行时安排
终结器 ~Type() 特定资源的非确定性兜底清理 不可依赖具体时刻
IDisposable.Dispose 按明确约定释放资源或恢复状态 调用者显式安排
using 语句/声明 离开作用域时调用 Dispose 正常控制流和异常离开时均安排清理

.NET 5+ 不把进程退出时运行全部终结器作为保证。也不要通过频繁 GC.Collect() 驱动游戏流程或及时关闭文件。大多数只包含托管字段的普通类,不需要自己添加终结器。终结器、垃圾回收基础

托管堆还采用分代管理:普通短寿命小对象通常从第 0 代开始,存活后可能进入第 1、2 代;较长寿命对象不必在每次短期回收中都以相同方式处理。大对象另有管理策略,所以不要把所有分配一律理解为第 0 代。理解分代是为了分析分配与存活,不能据此精确预测某个对象在哪一行被回收。

2.8 确定性清理:IDisposable 与 using

下面用拥有一个 MemoryStream 的对象演示清理模式,不引入真实文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
using System;
using System.IO;
using System.Text;

namespace OopExamples.Encapsulation
{
public sealed class DisposableNote : IDisposable
{
private readonly MemoryStream buffer = new MemoryStream();
public bool IsDisposed { get; private set; }

public void Write(string text)
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
ArgumentNullException.ThrowIfNull(text);
byte[] bytes = Encoding.UTF8.GetBytes(text);
buffer.Write(bytes, 0, bytes.Length);
}

public string Read()
{
ObjectDisposedException.ThrowIf(IsDisposed, this);
return Encoding.UTF8.GetString(buffer.ToArray());
}

public void Dispose()
{
if (IsDisposed)
return;
buffer.Dispose();
IsDisposed = true;
}
}
}
1
2
3
4
5
using (DisposableNote note = new DisposableNote())
{
note.Write("游戏记录");
Console.WriteLine(note.Read());
}

即使块内抛出异常,离开 using 作用域时仍会安排调用 Dispose。Dispose 不等于立即释放对象的托管内存,也不是让对象引用自动变成 null。using 语句

类直接拥有非托管资源时,通常优先使用合适的 SafeHandle;完整的终结器/Dispose 继承模式属于更深入的资源管理主题。这里的类只拥有一个可释放的托管对象,因此没有添加多余终结器。

贪吃蛇的 ConsoleSession 实现 IDisposable,在游戏退出或终端异常时恢复光标和颜色;不用终结器决定何时恢复终端。

2.9 成员属性:get 与 set

1
2
3
4
5
6
7
8
9
10
11
12
13
public class PropertyForms
{
private int score;
public int Score
{
get => score;
set => score = Math.Max(0, value);
}
public string Title { get; set; } = "未命名";
public int Level { get; private set; } = 1;
public int Maximum { get; } = 100;
public bool HasScore => Score > 0;
}
写法 含义
显式 get/set 方法体 可以校验、转换或计算
{ get; set; } 自动属性,编译器生成背后字段
{ get; private set; } 外部可读,当前类型可写
{ get; } 只读自动属性,可在声明或相应构造函数中初始化
=> 表达式 计算属性,不一定有独立保存的字段

setter 中的 value 表示调用方要写入的新值。Score = value; 若写在 Score 自己的 setter 中,会递归调用自身;通常应该赋给背后字段 score。

属性不一定只是一次内存读取,但应保持行为可理解。普通属性通常不能直接作为 ref/out 实参。get 返回可变数组或集合时,也可能泄漏内部状态;只读属性只是限制“替换引用”,不自动冻结引用指向的对象。属性

2.10 索引器:让对象支持方括号

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
using System;

namespace OopExamples.Encapsulation
{
public sealed class Inventory
{
private readonly string?[] slots;
public int Count => slots.Length;

public Inventory(int capacity)
{
if (capacity <= 0)
throw new ArgumentOutOfRangeException(nameof(capacity));
slots = new string?[capacity];
}

public string? this[int index]
{
get { CheckIndex(index); return slots[index]; }
set { CheckIndex(index); slots[index] = value; }
}

private void CheckIndex(int index)
{
if (index < 0 || index >= slots.Length)
throw new ArgumentOutOfRangeException(nameof(index));
}
}
}
1
2
3
4
Inventory bag = new Inventory(3);
bag[0] = "药水";
Console.WriteLine(bag[0]); // 药水
Console.WriteLine(bag.Count); // 3:本例 Count 表示固定槽位数量。

this[int index] 是索引器声明,参数不一定必须是 int;也可以有多个参数或按参数列表重载。索引器让访问方式像数组,但对象仍可控制校验与存储,不需要公开内部数组。索引器

2.11 静态成员与静态类

player.Hp 属于某个实例,Player.CreatedCount 属于 Player 类型。不同玩家对象共享该计数。

1
2
3
Player first = new Player();
Player second = new Player();
Console.WriteLine(Player.CreatedCount); // 当前进程中累计创建的 Player 数量。

示例计数按单线程教学使用,不把简单 ++ 当成多线程安全保证。静态状态也不会因为开启新一局就自动清零,长期保存引用还可能延长对象生命周期。

1
2
3
4
5
6
7
public static class GameMath
{
public static int ClampHp(int hp, int maximum)
{
return Math.Clamp(hp, 0, maximum);
}
}

静态类不能实例化或作为普通基类继承,没有实例成员;常用于无状态工具或固定配置。不要为了调用方便,把输入、地图、蛇身等全部变成全局静态变量,否则对象隔离和测试会变困难。

2.12 静态构造函数

Player 示例中的 static Player() 用于初始化类型级 Description。其特点是:无参数、无访问修饰符、不能直接调用,通常在运行时首次需要初始化该类型时执行,同一类型的静态初始化只安排一次。

静态字段初始化器与静态构造函数共同参与初始化;读取编译时 const 并不需要靠触发静态构造函数。不要依赖多个类型复杂静态初始化之间的偶然顺序。

如果静态初始化抛出异常,类型通常会进入初始化失败状态,后续使用可能得到 TypeInitializationException,而不是每次访问都从头重试。因此避免在其中执行易失败的外部访问、阻塞等待或复杂业务。静态构造函数

2.13 扩展方法

项目用扩展方法把方向转成坐标偏移:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
using System;

namespace SnakeGame.Domain
{
public static class DirectionExtensions
{
public static GridPoint ToOffset(this Direction direction)
{
switch (direction)
{
case Direction.Up: return new GridPoint(0, -1);
case Direction.Down: return new GridPoint(0, 1);
case Direction.Left: return new GridPoint(-1, 0);
case Direction.Right: return new GridPoint(1, 0);
default: throw new ArgumentOutOfRangeException(nameof(direction));
}
}

public static bool IsOppositeTo(this Direction first, Direction second)
{
GridPoint sum = first.ToOffset() + second.ToOffset();
return sum == new GridPoint(0, 0);
}
}
}
1
2
GridPoint offset = Direction.Up.ToOffset();
Console.WriteLine(offset); // (0, -1)

经典扩展方法写在非嵌套、非泛型静态类中,方法是静态的,第一个参数带 this。direction.ToOffset() 本质上仍是静态方法调用的便捷语法。

使用方需要导入扩展方法所在命名空间。扩展方法不能因此访问目标对象的 private 字段,也不能当作虚方法重写;同名适用的实例方法优先。

C# 14 引入 extension 块等新形式;本文固定 C# 12,使用传统 this 参数写法。扩展成员

2.14 运算符重载

坐标相加可以表达“当前位置加方向偏移”。项目的坐标类型同时提供了值相等语义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
using System;

namespace SnakeGame.Domain
{
public readonly struct GridPoint : IEquatable<GridPoint>
{
public int X { get; }
public int Y { get; }

public GridPoint(int x, int y)
{
X = x;
Y = y;
}

public static GridPoint operator +(GridPoint first, GridPoint second)
=> new GridPoint(first.X + second.X, first.Y + second.Y);

public bool Equals(GridPoint other) => X == other.X && Y == other.Y;
public override bool Equals(object? value) => value is GridPoint other && Equals(other);
public override int GetHashCode() => HashCode.Combine(X, Y);
public static bool operator ==(GridPoint first, GridPoint second) => first.Equals(second);
public static bool operator !=(GridPoint first, GridPoint second) => !first.Equals(second);
public override string ToString() => $"({X}, {Y})";
}
}
1
2
3
4
GridPoint current = new GridPoint(2, 3);
GridPoint next = current + new GridPoint(1, 0);
Console.WriteLine(next); // (3, 3)
Console.WriteLine(next == new GridPoint(3, 3)); // True

本文使用经典 public static ... operator ... 声明,至少有一个操作数应是当前定义类型。不能任意创造新的运算符,不能改变优先级,也不能重载普通赋值 =。

== 与 != 要配对定义,并与 Equals、GetHashCode 保持一致;< 与 >、<= 与 >= 等也有配对要求。不要让 + 执行删除文件等与读者预期完全不同的行为。运算符重载

2.15 内部类:更准确地说是嵌套类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
using System;

namespace OopExamples.Encapsulation
{
public sealed class PlayerProfile
{
private readonly string note;
public PlayerProfile(string note) => this.note = note;

public sealed class Reader
{
private readonly PlayerProfile owner;
public Reader(PlayerProfile owner)
=> this.owner = owner ?? throw new ArgumentNullException(nameof(owner));
public string Read() => owner.note;
}
}
}
1
2
3
PlayerProfile profile = new PlayerProfile("内部笔记");
PlayerProfile.Reader reader = new PlayerProfile.Reader(profile);
Console.WriteLine(reader.Read()); // 内部笔记

嵌套类可以访问包含类型的私有成员,但不会自动绑定某个外层对象。示例显式传入 owner,才能读取该实例的 note。

类中的嵌套类型默认 private,也可以明确声明可见性。命名为“内部类”并不等于必须使用 internal 关键字。项目的 Canvas 是绘制器的 private 嵌套类,规则层完全不需要知道它。嵌套类型

2.16 分部类:多个文件组成一个类型

VisitCounter.State.cs:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
using System;

namespace OopExamples.Encapsulation
{
public sealed partial class VisitCounter
{
private int count;
public VisitCounter(int initial = 0)
{
if (initial < 0)
throw new ArgumentOutOfRangeException(nameof(initial));
count = initial;
}
}
}

VisitCounter.Behavior.cs:

1
2
3
4
5
6
7
8
namespace OopExamples.Encapsulation
{
public sealed partial class VisitCounter
{
public int Count => count;
public void Increment() => count = checked(count + 1);
}
}
1
2
3
VisitCounter counter = new VisitCounter();
counter.Increment();
Console.WriteLine(counter.Count); // 1

两个文件定义的是同一个类型,共享 private 字段;不是创建两个类,也不是通过继承连接。各部分要使用相同类型名、命名空间与兼容的声明,并在同一编译中组成同一个类型。

项目把 ConsoleRenderer 拆成公共画布、菜单、游戏三份文件,便于阅读。partial 只是文件组织工具;若职责已经完全不同,仍应拆成不同类型。分部类

2.17 本章练习

  1. 给玩家增加经验值,外部只能读取,通过 AddExperience 方法增加,拒绝负数。
  2. 给背包添加只读索引器版本,比较它与公开数组的差异。
  3. 解释为什么 ConsoleSession 使用 Dispose,而不是等待终结器恢复光标。
  4. 将一个类分成 State 与 Behavior 两份文件,并验证 private 字段仍然共享。

第三章:面向对象之继承

3.1 概念与语法

1
2
public class BaseType { }
public class DerivedType : BaseType { }

派生类只能有一个直接基类,但可以实现多个接口;继承关系具有传递性。构造函数与终结器不能被当成普通成员继承后直接调用。

基类的私有状态仍可能是派生对象的一部分,但派生类不能因此直接访问 private 字段。通过基类公开或受保护的操作使用它,不是简单地认为“private 数据在派生对象中消失了”。继承

3.2 可运行示例:角色、战士与法师

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
using System;

namespace OopExamples.Inheritance
{
public class Character
{
public string Name { get; }
public Character(string name)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("Name cannot be blank.", nameof(name));
Name = name;
}
public virtual string Act() => Name + "待命";
public override string ToString() => $"Character: {Name}";
}

public sealed class Warrior : Character
{
public Warrior(string name) : base(name) { }
public override string Act() => Name + "挥剑";
public string RaiseWeapon() => Name + "举起武器";
}

public sealed class Mage : Character
{
public int Mana { get; private set; }
public Mage(string name, int mana) : base(name)
{
if (mana < 0)
throw new ArgumentOutOfRangeException(nameof(mana));
Mana = mana;
}
public override string Act() => Name + "施法";
}

public class BaseLabel
{
public string Label() => "Base";
}

public sealed class DerivedLabel : BaseLabel
{
public new string Label() => "Derived";
}
}
1
2
3
Character character = new Warrior("剑士");
Console.WriteLine(character.Name); // 剑士
Console.WriteLine(character.Act()); // 剑士挥剑

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
2
3
4
5
6
7
8
9
Character character = new Warrior("剑士");

if (character is Warrior warrior)
{
Console.WriteLine(warrior.RaiseWeapon());
}

Mage? mage = character as Mage;
Console.WriteLine(mage is null); // True
写法 用途 不兼容时
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
2
3
4
object number = 100;
int? maybeNumber = number as int?;
Console.WriteLine(maybeNumber); // 100
// int invalid = number as int; // 错误:as 的目标不能是普通 int。

GetType() == typeof(Warrior) 检查精确运行时类型;is 检查类型兼容关系,也可能匹配其进一步派生类型。对可能重载 == 的类,value is null 是不依赖该相等运算符的空引用检查。类型检查与转换

3.5 继承中的构造函数

Mage 构造函数使用:

1
2
3
4
5
6
7
// 来自 Mage 类型的构造声明片段。
public Mage(string name, int mana) : base(name)
{
if (mana < 0)
throw new ArgumentOutOfRangeException(nameof(mana));
Mana = mana;
}

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
2
3
4
int value = 100;
object boxed = value;
value = 80;
Console.WriteLine((int)boxed); // 100:装箱时复制了原值。

拆箱要匹配实际装箱的值类型:

1
2
3
4
object boxed = 100;
// long wrong = (long)boxed; // 实际装箱为 int,直接拆成 long 会失败。
long correct = (int)boxed;
Console.WriteLine(correct); // 100

把普通类引用赋给 object 不叫装箱;把普通结构体赋给其实现的接口变量也可能发生装箱。泛型有时能避免不必要的装箱,但需要结合具体调用分析。装箱与拆箱

3.7 密封类 sealed

1
2
public sealed class ClosedType { }
// public class Child : ClosedType { } // 错误:不能继承密封类。

sealed 用于表达“不提供继续派生的扩展点”。字符串 String 本身也是密封类。密封不表示对象内容自动不可变,也不表示所有方法都变成静态方法。

如果需要为密封类型增加工具行为,可以考虑扩展方法或组合包装,不一定需要继承。

sealed 官方说明 同时涵盖密封类与后文的 sealed override。

3.8 本章练习

  1. 用父类型数组保存 Warrior 与 Mage,比较 Name、Act 与 GetType 的结果。
  2. 写出 is 模式匹配、as 与显式转换的失败行为。
  3. 解释为什么“企鹅在 Fly 中抛出不支持异常”可能破坏原本的飞行契约。
  4. 给基类只保留带参构造函数,观察派生类如何通过 base 初始化。

第四章:面向对象之多态

4.1 virtual 与 override

基类把可定制的操作声明为 virtual,派生类用 override 重写。同一段基类调用,根据实际对象找到对应实现。

1
2
3
4
5
Character[] characters = { new Warrior("剑士"), new Mage("法师", 30) };
foreach (Character character in characters)
{
Console.WriteLine(character.Act());
}

输出分别为“剑士挥剑”“法师施法”。基类调用方不必写一串 if (character is ...) 才能选择动作。多态

4.2 重载、重写与隐藏

第三章的 BaseLabel 与 DerivedLabel 演示了隐藏:

1
2
3
4
BaseLabel first = new DerivedLabel();
DerivedLabel second = (DerivedLabel)first;
Console.WriteLine(first.Label()); // Base
Console.WriteLine(second.Label()); // Derived

new 明确表示隐藏同名成员,并不建立 override 的虚调用关系。

特性 典型关键字 主要选择依据
重载 overload 同名、不同参数列表 编译时可见的参数类型与重载规则
重写 override virtual/abstract 与 override 虚调用时的实际运行时类型
隐藏 hiding new 通常按编译时所见类型选择该成员

不能只改返回类型就重载,也不能把普通非虚实例方法直接声明为 override。这里讨论的是普通类型化调用;dynamic 的运行时绑定是另一个主题。

4.3 抽象类与抽象方法

抽象类不能直接 new,但可以保存字段、提供构造函数、实现普通方法,并把部分操作留给派生类。

下面同时展示抽象类、具体类、接口与密封方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
using System;

namespace OopExamples.Polymorphism
{
public interface IHealable
{
void Heal(int amount);
}

public abstract class Enemy
{
public string Name { get; }
public int Hp { get; protected set; }
protected Enemy(string name, int hp)
{
if (string.IsNullOrWhiteSpace(name) || hp <= 0)
throw new ArgumentException("A name and positive HP are required.");
Name = name;
Hp = hp;
}
public abstract int GetAttack();
public virtual string Speak() => Name + "出现了";
}

public sealed class Slime : Enemy
{
public Slime() : base("史莱姆", 30) { }
public override int GetAttack() => 5;
}

public class Dragon : Enemy, IHealable
{
public Dragon() : base("巨龙", 100) { }
public override int GetAttack() => 20;
public sealed override string Speak() => "巨龙咆哮";
public void Heal(int amount)
{
if (amount < 0)
throw new ArgumentOutOfRangeException(nameof(amount));
Hp = (int)Math.Min(100L, (long)Hp + amount);
}
}

public sealed class AncientDragon : Dragon
{
public override int GetAttack() => 40;
// 不能再 override Speak,但仍能重写未密封的 GetAttack。
}
}
1
2
3
4
5
Enemy[] enemies = { new Slime(), new Dragon(), new AncientDragon() };
foreach (Enemy enemy in enemies)
{
Console.WriteLine($"{enemy.Name}: {enemy.GetAttack()}");
}

得到攻击力 5、20、40。GetAttack 是抽象方法,没有方法体;非抽象派生类必须完成尚未实现的抽象成员。抽象类可以没有抽象成员,但只要包含抽象方法,类本身也必须是抽象的。

4.4 接口:表达能力与协作契约

IHealable 表达可治疗能力。调用者关心 Heal 的契约,不必关心对象是否是 Dragon。

1
2
3
4
5
Enemy enemy = new Dragon();
if (enemy is IHealable healable)
{
healable.Heal(10);
}

类可以实现多个接口,结构体也可以实现接口。接口可以包含方法、属性、索引器、事件等契约;本文主要使用实例方法和只读属性。

不要把接口说成“任何版本都不能有实现”。C# 8 起支持默认接口实现,较新版本还支持静态抽象成员等能力。本文为了保持模型清楚,采用经典的无方法体实例契约。接口

对比 抽象类 接口
表达重点 相关类型共享的基础状态与行为 一种能力或协作角色
普通实例字段 可以 不作为接口实例状态提供
实例构造初始化 可以由派生构造链调用 不用于直接创建接口实例
类型关系 类只能有一个直接基类 类型可以实现多个接口
是否可直接实例化 不可 不可
本项目用途 Scene 抽象场景 输入、绘制、时间、食物生成等契约

接口不是实现细节的“万能隐藏器”。如果接口方法仍让调用方知道所有内部数组布局,就没有真正改善边界。

4.5 隐式与显式接口实现

前面的 Dragon 使用 public Heal,属于隐式实现。显式实现则把入口限定在接口视角:

1
2
3
4
5
6
7
public sealed class ExplicitHealer : IHealable
{
void IHealable.Heal(int amount)
{
Console.WriteLine($"恢复 {amount}");
}
}
1
2
3
IHealable healer = new ExplicitHealer();
healer.Heal(10);
// new ExplicitHealer().Heal(10); // 错误:需要通过接口入口调用。

显式实现没有再写 public。它适合区分不同接口中的同名成员,或避免把某项能力直接放进类的普通调用界面。

4.6 密封方法 sealed override

Dragon 的 Speak 声明为:

1
public sealed override string Speak() => "巨龙咆哮";

AncientDragon 仍可以继承 Dragon、重写 GetAttack,但不能再 override Speak。sealed 在这个上下文中与 override 配合,锁定已有的虚方法实现。

它不禁止派生类以 new 隐藏同名成员;隐藏仍不能改变通过原虚方法槽发出的调用。因此不要把“密封某方法”误说成“所有同名写法都无法出现”。

4.7 在贪吃蛇中的多态

GameApplication 字段保存 Scene:开始、游戏、结束三个具体场景都实现 Handle 与 Render。主循环只调用:

1
2
3
4
// 说明片段,scene 是当前 Scene 对象。
scene.Handle(command);
scene.Update(elapsed);
scene.Render();

扩展一个新的帮助场景时,新增 Scene 实现,再在导航处接入,而不是把每个按键、更新和绘制操作都堆进同一个巨大 switch。

4.8 本章练习

  1. 给 Enemy 增加一个具体派生类,调用方仍使用 Enemy 数组。
  2. 比较把 Act 写成 override 与 new 后,基类变量调用的差别。
  3. 给某类添加 IHealable 显式实现,观察通过类变量与接口变量的调用区别。
  4. 解释为什么密封 Speak 不会让 Dragon 整个类都不能继承。

第五章:其他知识与文本 API 合集

5.1 命名空间:组织名称,而不是自动组织文件

1
2
3
4
namespace Demo.Domain
{
public class Hero { }
}

调用时可以写完整名称,或导入命名空间:

1
Demo.Domain.Hero hero = new Demo.Domain.Hero();
1
2
3
// using 放在文件相应作用域的类型声明之前。
using Demo.Domain;
// 调用时就可以写 Hero。
语法/概念 作用
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
2
3
4
Character character = new Warrior("剑士");
Console.WriteLine(character.GetType().Name); // Warrior
Console.WriteLine(character.ToString()); // Character: 剑士
Console.WriteLine(typeof(Character).Name); // Character

typeof(Character) 由你写出的类型决定;character.GetType() 由实际对象决定。对 null 直接调用 GetType 会失败,需要先检查。

GridPoint 把相等定义成 X、Y 都相等,并同时提供 Equals、==、!=、GetHashCode。

1
2
3
4
5
GridPoint first = new GridPoint(2, 3);
GridPoint second = new GridPoint(2, 3);
Console.WriteLine(first.Equals(second)); // True
Console.WriteLine(first == second); // True
Console.WriteLine(first.GetHashCode() == second.GetHashCode()); // True

只知道哈希码相同,不能反推对象一定相等。 哈希码不是永久 ID,也不应作为跨进程持久标识。用可变字段计算哈希时,再修改这些字段可能破坏它作为哈希集合键的使用;不可变坐标更容易维护这项约定。GetHashCode 契约

5.4 MemberwiseClone:浅拷贝示例

1
2
3
4
5
6
7
8
9
10
11
public sealed class SaveSlot
{
public int[] Scores { get; private set; } = new int[] { 10, 20 };
public SaveSlot ShallowCopy() => (SaveSlot)MemberwiseClone();
public SaveSlot CopyScores()
{
SaveSlot copy = ShallowCopy();
copy.Scores = (int[])Scores.Clone();
return copy;
}
}
1
2
3
4
5
6
7
8
SaveSlot original = new SaveSlot();
SaveSlot shallow = original.ShallowCopy();
shallow.Scores[0] = 99;
Console.WriteLine(original.Scores[0]); // 99

SaveSlot independent = original.CopyScores();
independent.Scores[0] = 7;
Console.WriteLine(original.Scores[0]); // 99

只读属性 Scores 禁止外部替换该属性,但并没有禁止修改内部数组元素。复制多层对象时,要按业务逐层决定复制还是共享。

5.5 String:不变文本与索引规则

string 是 System.String 的别名,是引用类型,字符串对象内容不可变。看似“修改”的方法通常返回新的字符串结果,不会原地改写当前对象。

1
2
3
4
5
string text = "snake";
text.ToUpperInvariant();
Console.WriteLine(text); // snake:丢弃返回值不会修改原变量。
text = text.ToUpperInvariant();
Console.WriteLine(text); // SNAKE

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
2
3
4
5
string name = "SnakeGame";
Console.WriteLine(name.Contains("Game", StringComparison.Ordinal)); // True
Console.WriteLine(name.IndexOf("Game", StringComparison.Ordinal)); // 5
Console.WriteLine(name.IndexOf("Apple", StringComparison.Ordinal)); // -1
Console.WriteLine(string.Equals("SNAKE", "snake", StringComparison.OrdinalIgnoreCase)); // True

程序标识符、固定命令等通常应明确使用 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
2
3
4
5
6
7
8
9
string raw = "  Snake, Apple, , Score  ";
string[] parts = raw.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
Console.WriteLine(string.Join(" / ", parts)); // Snake / Apple / Score

string text = "SnakeGame";
Console.WriteLine(text.Substring(5)); // Game
Console.WriteLine(text.Substring(0, 5)); // Snake
Console.WriteLine(text.Insert(5, "-")); // Snake-Game
Console.WriteLine(text.Remove(5, 4)); // Snake

查找后再截取时,要先处理 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
2
3
4
string combined = "e\u0301";
string precomposed = "é";
Console.WriteLine(combined == precomposed); // False,代码单元不同。
Console.WriteLine(combined.Normalize() == precomposed.Normalize()); // True

需要 UTF-8 字节时使用 Encoding.UTF8.GetBytes(text);字节编码与字符串对象中的 UTF-16 表示不是同一个层次。

5.9 StringBuilder:逐步构造文本

循环中不断 text += piece 可能反复分配和复制较长字符串。StringBuilder 提供可变缓冲,适合逐步组装日志、报表或每帧字符画面。

1
2
3
4
5
StringBuilder builder = new StringBuilder(128);
builder.Append("Score: ").Append(10).AppendLine();
builder.Append("Length: ").Append(4);
string result = builder.ToString();
Console.WriteLine(result);

需要 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
using System;
using System.Globalization;
using System.Text;

namespace OopExamples.Text
{
public static class TextDemo
{
public static void Run()
{
string raw = " Snake, Apple, , Score ";
string[] parts = raw.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
Console.WriteLine(string.Join(" / ", parts));
Console.WriteLine("SNAKE".Equals("snake", StringComparison.OrdinalIgnoreCase));
Console.WriteLine("SnakeGame".Contains("Game", StringComparison.Ordinal));
Console.WriteLine("SnakeGame".Substring(5));
Console.WriteLine("hello".Replace("l", "L", StringComparison.Ordinal));
Console.WriteLine("😀".Length); // 2 个 UTF-16 代码单元。

StringBuilder builder = new StringBuilder(64);
builder.Append("Score:").Append(10);
builder.AppendJoin(',', new int[] { 1, 2, 3 });
builder.Insert(0, '[').Append(']');
builder.Replace("Score", "Points");
Console.WriteLine(builder.ToString());
builder.Clear();
builder.AppendFormat(CultureInfo.InvariantCulture, "HP:{0:F1}", 12.5);
Console.WriteLine(builder.ToString());

StringBuilder first = new StringBuilder("abc");
StringBuilder second = new StringBuilder("abc", 64);
Console.WriteLine(first.Equals(second)); // .NET 10: True,比较内容。
Console.WriteLine(((object)first).Equals(second)); // False,Object.Equals 的引用语义。
}
}
}

特别留意:

1
2
3
4
StringBuilder first = new StringBuilder("abc");
StringBuilder second = new StringBuilder("abc", 64);
Console.WriteLine(first.Equals(second)); // .NET 10:True
Console.WriteLine(((object)first).Equals(second)); // False

前一条选择 StringBuilder 的类型化重载;后一条以 object 视角选择继承的相等入口,不能认为方法名字相同就会自动选择相同规则。旧 .NET Framework 对类型化比较还涉及容量等规则,因此跨运行时阅读示例要注意具体目标。StringBuilder.Equals

需求 常用选择
少量已知片段,代码清楚 插值或简单拼接
已有一组元素,添加分隔符 string.Join
循环逐步构建大段文本 StringBuilder
只读取局部字符,且需要减少复制 学习只读 Span 等更深入 API

5.12 本章练习

  1. 对含多余空格和空项的逗号文本做 Split,再用 Join 输出。
  2. 比较 Equals、ReferenceEquals 与 GetHashCode 的用途。
  3. 为一个小型值对象设计一致的 Equals 与 GetHashCode。
  4. 使用 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
using System;

namespace OopExamples.Design
{
public interface IScoreRule
{
int PointsPerFood { get; }
}

public sealed class NormalScoreRule : IScoreRule
{
public int PointsPerFood => 10;
}

public sealed class BonusScoreRule : IScoreRule
{
public int PointsPerFood => 20;
}

public sealed class ScoreCounter
{
private readonly IScoreRule rule;
public int Total { get; private set; }
public ScoreCounter(IScoreRule rule)
=> this.rule = rule ?? throw new ArgumentNullException(nameof(rule));

public void Eat()
{
int gain = rule.PointsPerFood;
if (gain <= 0)
throw new InvalidOperationException("A score rule must award positive points.");
Total = checked(Total + gain);
}
}
}
1
2
3
4
5
ScoreCounter normal = new ScoreCounter(new NormalScoreRule());
ScoreCounter bonus = new ScoreCounter(new BonusScoreRule());
normal.Eat();
bonus.Eat();
Console.WriteLine($"{normal.Total}, {bonus.Total}"); // 10, 20

添加另一种计分策略时,ScoreCounter 无需修改;组装位置选择新策略即可。游戏已经对食物生成和绘制提供了类似扩展点。

开闭不意味着旧代码永远不能修改。发现错误、契约不合理或需求确实改变时,仍应修改;目标是减少每次扩展都侵入稳定规则的情况。OCP 原作者说明

6.4 里氏替换原则:不仅签名相同,行为也要守约

本项目 IFoodSpawner 约定:有空格时输出棋盘内且不在蛇身上的食物;没有可用格时返回 false。

一种替代实现如果返回蛇头位置,虽然能通过编译,却不能正确替代 RandomFoodSpawner。GameEngine 会检查这项契约,并报告实现错误。

可以改变随机策略,例如偏好边缘或使用固定序列做测试,但不能随意破坏“食物在合法空格”这项保证。替代实现还应遵守约定的输入范围、失败行为与状态规则。

6.5 接口隔离原则:按调用角色拆分

如果所有场景都依赖包含“读取按键、画游戏、写菜单、保存文件、播放声音”的大接口,简单菜单也被迫知道许多无关操作。

项目把绘制划成两个调用角色:

1
2
3
4
5
6
7
8
9
10
public interface IMenuRenderer
{
void DrawStart();
void DrawEnd(IGameView result);
}

public interface IGameRenderer
{
void DrawGame(IGameView game, bool paused, IReadOnlyList<string> messages);
}

StartScene 和 EndScene 只需要菜单绘制,PlayScene 只需要游戏绘制。具体 ConsoleRenderer 可以同时实现两种角色;接口隔离不要求每种能力都必须由不同对象实现。

6.6 依赖倒置原则:规则依赖抽象

GameEngine 接收 IFoodSpawner,而不是在 Tick 内写 new RandomFoodSpawner(...);GameApplication 接收 IInputSource、IRenderer、IGameClock 等契约,而不是把 Console 与 Thread 调用写进每个场景。

1
2
3
// 组装位置的调用片段。
IFoodSpawner spawner = new RandomFoodSpawner(new Random());
IGameFactory factory = new DefaultGameFactory(spawner);

测试时可以换成固定位置的生成器,稳定重现“下一步吃到食物”或“最后一格填满”的情况。

依赖注入是从外部传入协作者的做法;依赖倒置关注规则与实现之间的依赖方向。二者相关,但不是同一个词,也不需要先安装 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 本章练习

  1. 把一个同时负责计分与控制台输出的类拆开,说明各自的变化原因。
  2. 增加一种计分策略,保持 ScoreCounter 不变。
  3. 为 IFoodSpawner 写一段契约说明,列出一项违反契约的实现。
  4. 解释为什么“小项目不用 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
2
3
// 映射公式片段,point 为逻辑坐标。
int screenX = 6 + point.X * 2;
int screenY = 5 + point.Y;

这让蛇的横向与纵向视觉比例更接近,同时使 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
using System.Collections.Generic;
using SnakeGame.Domain;

namespace SnakeGame.Contracts
{
public interface IGameView
{
int Columns { get; }
int Rows { get; }
int Score { get; }
RoundStatus Status { get; }
string EndMessage { get; }
IReadOnlyList<GridPoint> Body { get; }
GridPoint? FoodPosition { get; }
}
}

绘制器得到这个接口,只能读取必要状态。Body 虽然表示一段正在变化的游戏数据,但外部不能通过该接口直接 Insert、Remove 或随意给某项赋值。

Snake 使用私有 List 保存身体,并对外提供只读包装;GameEngine 接收初始 Snake 时复制它的身体状态,避免创建方保留的可变引用直接修改正在运行的一局。

注意“只读视图”不是“历史快照”:引擎前进后,读取该视图仍会看到新状态。若要保存回放中的某一帧,应明确复制。

7.6 蛇的实现:转向、索引器和尾巴

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
using System;
using System.Collections.Generic;

namespace SnakeGame.Domain
{
public sealed class Snake
{
private readonly List<GridPoint> body;
private readonly IReadOnlyList<GridPoint> view;
private Direction? pendingDirection;
public Direction Direction { get; private set; }
public IReadOnlyList<GridPoint> Segments => view;
public GridPoint Head => body[0];
public int Length => body.Count;
public GridPoint this[int index] => body[index];
public GridPoint NextHead => Head + (pendingDirection ?? Direction).ToOffset();

public Snake(IEnumerable<GridPoint> segments, Direction direction)
{
ArgumentNullException.ThrowIfNull(segments);
_ = direction.ToOffset();
body = new List<GridPoint>(segments);
if (body.Count == 0)
throw new ArgumentException("A snake needs at least one segment.", nameof(segments));
for (int i = 0; i < body.Count; i++)
{
for (int j = 0; j < i; j++)
if (body[i] == body[j])
throw new ArgumentException("Body segments must be unique.", nameof(segments));
if (i > 0 && Math.Abs(body[i].X - body[i - 1].X) + Math.Abs(body[i].Y - body[i - 1].Y) != 1)
throw new ArgumentException("Body segments must be adjacent.", nameof(segments));
}
if (body.Count > 1 && Head + direction.ToOffset() == body[1])
throw new ArgumentException("Initial direction cannot point into the neck.", nameof(direction));
Direction = direction;
view = body.AsReadOnly();
}

public bool RequestTurn(Direction next)
{
_ = next.ToOffset();
// 每个移动节拍只接收一次有效转向,避免快速输入两次绕过禁止掉头。
if (pendingDirection.HasValue || next == Direction || next.IsOppositeTo(Direction))
return false;
pendingDirection = next;
return true;
}

public bool Occupies(GridPoint point, bool ignoreTail = false)
{
int count = body.Count - (ignoreTail ? 1 : 0);
for (int i = 0; i < count; i++)
if (body[i] == point)
return true;
return false;
}

public void Advance(bool grow)
{
GridPoint next = NextHead;
Direction = pendingDirection ?? Direction;
pendingDirection = null;
body.Insert(0, next);
if (!grow)
body.RemoveAt(body.Count - 1);
}
}
}

这里用到少量集合语法作为衔接:List<GridPoint> 是元素类型为 GridPoint 的可变长度列表;Count 是元素数,Insert/RemoveAt 修改内容,AsReadOnly 提供只读访问包装。前一册数组增删需要新建数组,List 则替调用方管理容量。

转向不是立刻移动,而是先记录为 pendingDirection。下一次移动采用这个方向,再清空待处理状态。

如果只检查“本次输入是否与当前方向相反”,快速按上、左可能在一次移动前绕过限制。因此一旦接收了一个有效转向,同一节拍内的后续转向全部忽略;移动完成后才重新接受。

7.7 一局引擎:碰撞、增长与胜负

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
using System;
using System.Collections.Generic;
using SnakeGame.Contracts;
using SnakeGame.Domain;

namespace SnakeGame.Core
{
public sealed class GameEngine : IGameView
{
private readonly Board board;
private readonly Snake snake;
private readonly IFoodSpawner foodSpawner;
private Food? food;
public int Columns => board.Columns;
public int Rows => board.Rows;
public int Score { get; private set; }
public RoundStatus Status { get; private set; } = RoundStatus.Running;
public string EndMessage { get; private set; } = "";
public IReadOnlyList<GridPoint> Body => snake.Segments;
public GridPoint? FoodPosition => food?.Position;

public GameEngine(Board board, Snake snake, IFoodSpawner foodSpawner)
{
this.board = board ?? throw new ArgumentNullException(nameof(board));
ArgumentNullException.ThrowIfNull(snake);
this.snake = new Snake(snake.Segments, snake.Direction);
this.foodSpawner = foodSpawner ?? throw new ArgumentNullException(nameof(foodSpawner));
foreach (GridPoint point in snake.Segments)
if (!board.Contains(point))
throw new ArgumentException("Snake must start inside the board.", nameof(snake));
SpawnFoodOrWin();
}

public bool RequestTurn(Direction direction)
=> Status == RoundStatus.Running && snake.RequestTurn(direction);

public void Tick()
{
if (Status != RoundStatus.Running)
return;
GridPoint next = snake.NextHead;
bool willGrow = food is not null && next == food.Position;
if (!board.Contains(next))
{
Lose("You hit the wall.");
return;
}
// 不生长时,尾巴本回合会腾空;允许进入旧尾格。
if (snake.Occupies(next, ignoreTail: !willGrow))
{
Lose("You hit your body.");
return;
}
snake.Advance(willGrow);
if (willGrow)
{
Score += GameSettings.FoodScore;
SpawnFoodOrWin();
}
}

private void Lose(string message)
{
Status = RoundStatus.Lost;
EndMessage = message;
}

private void SpawnFoodOrWin()
{
if (snake.Length == board.CellCount)
{
food = null;
Status = RoundStatus.Won;
EndMessage = "Board filled. You win!";
return;
}
if (!foodSpawner.TrySpawn(board, snake.Segments, out GridPoint point))
throw new InvalidOperationException("Food spawner returned false while free cells remain.");
if (!board.Contains(point) || snake.Occupies(point))
throw new InvalidOperationException("Food spawner broke its position contract.");
food = new Food(point);
}
}
}

一次 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
2
3
4
5
6
7
8
9
10
11
using System.Collections.Generic;
using SnakeGame.Domain;

namespace SnakeGame.Contracts
{
public interface IFoodSpawner
{
// true:输出棋盘内且不在蛇身上的位置;false:没有可用空格。
bool TrySpawn(Board board, IReadOnlyList<GridPoint> occupied, out GridPoint position);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
using System;
using System.Collections.Generic;
using SnakeGame.Contracts;
using SnakeGame.Domain;

namespace SnakeGame.Infrastructure
{
public sealed class RandomFoodSpawner : IFoodSpawner
{
private readonly Random random;
public RandomFoodSpawner(Random random)
=> this.random = random ?? throw new ArgumentNullException(nameof(random));

public bool TrySpawn(Board board, IReadOnlyList<GridPoint> occupied, out GridPoint position)
{
ArgumentNullException.ThrowIfNull(board);
ArgumentNullException.ThrowIfNull(occupied);
List<GridPoint> free = new List<GridPoint>();
for (int y = 0; y < board.Rows; y++)
{
for (int x = 0; x < board.Columns; x++)
{
GridPoint candidate = new GridPoint(x, y);
bool blocked = false;
for (int i = 0; i < occupied.Count; i++)
if (occupied[i] == candidate)
{
blocked = true;
break;
}
if (!blocked)
free.Add(candidate);
}
}
position = default;
if (free.Count == 0)
return false;
position = free[random.Next(free.Count)];
return true;
}
}
}

这份实现先列出所有合法空格,再从中选择;不会在棋盘几乎填满时依靠“随机撞运气直到成功”。棋盘全部占满时返回 false;引擎会在填满时直接进入胜利,不继续请求食物。

这是针对 480 格教学棋盘的直观算法,主要成本来自检查候选格是否被占用。后续可以使用占用集合或维护空格表优化,但不用为了入门项目先引入复杂索引结构。

7.9 抽象场景与游戏场景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using System;
using SnakeGame.Contracts;

namespace SnakeGame.Scenes
{
public abstract class Scene
{
protected ISceneNavigator Navigator { get; }
protected Scene(ISceneNavigator navigator)
=> Navigator = navigator ?? throw new ArgumentNullException(nameof(navigator));
public virtual void Enter() { }
public virtual void Exit() { }
public abstract void Handle(InputCommand command);
public virtual void Update(TimeSpan elapsed) { }
public abstract void Render();
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
using System;
using System.Collections.Generic;
using SnakeGame.Contracts;
using SnakeGame.Core;
using SnakeGame.Domain;

namespace SnakeGame.Scenes
{
public sealed class PlayScene : Scene
{
private readonly GameEngine game;
private readonly IGameRenderer renderer;
private readonly List<string> messages = new List<string>();
private TimeSpan accumulated;
public bool Paused { get; private set; }

public PlayScene(ISceneNavigator navigator, IGameRenderer renderer, GameEngine game) : base(navigator)
{
this.renderer = renderer;
this.game = game;
}

public override void Enter()
{
messages.Clear();
AddMessage("WASD / arrows: turn. P: pause. ESC: menu.");
AddMessage("Eat * for +10. Avoid walls and your body.");
}

public override void Handle(InputCommand command)
{
if (command == InputCommand.Escape)
{
Navigator.ShowMenu();
return;
}
if (command == InputCommand.Pause)
{
Paused = !Paused;
accumulated = TimeSpan.Zero;
AddMessage(Paused ? "Paused. Press P to resume." : "Resumed.");
return;
}
if (Paused)
return;
switch (command)
{
case InputCommand.Up: game.RequestTurn(Direction.Up); break;
case InputCommand.Down: game.RequestTurn(Direction.Down); break;
case InputCommand.Left: game.RequestTurn(Direction.Left); break;
case InputCommand.Right: game.RequestTurn(Direction.Right); break;
}
}

public override void Update(TimeSpan elapsed)
{
if (Paused)
return;
accumulated += elapsed;
TimeSpan interval = TimeSpan.FromMilliseconds(GameSettings.TickMilliseconds);
while (accumulated >= interval && game.Status == RoundStatus.Running)
{
int previousScore = game.Score;
game.Tick();
accumulated -= interval;
if (game.Score != previousScore)
AddMessage($"Food eaten! Score: {game.Score}. Length: {game.Body.Count}.");
}
if (game.Status != RoundStatus.Running)
Navigator.EndRound(game);
}

private void AddMessage(string message)
{
if (messages.Count == 5)
messages.RemoveAt(0);
messages.Add(message);
}

public override void Render() => renderer.DrawGame(game, Paused, messages.AsReadOnly());
}
}

PlayScene 不直接调用 Console.ReadKey,也不自己拼字符画面;它接收命令、按累计时间调用引擎,再通过 IGameRenderer 显示。

暂停时不累计游戏时间。恢复时清空旧累计量,避免把暂停了几秒误解释为“应立即连续走几十步”。菜单和结束场景不推进蛇。

StartScene 与 EndScene 的完整源码也在 Scenes 文件夹中;它们实现同一个 Scene 契约,分别处理 Enter、R、Esc。

7.10 主循环与场景切换

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
using System;
using SnakeGame.Contracts;
using SnakeGame.Scenes;

namespace SnakeGame.Application
{
public sealed class GameApplication : ISceneNavigator
{
private readonly IInputSource input;
private readonly IRenderer renderer;
private readonly IGameClock clock;
private readonly IViewport viewport;
private readonly IGameFactory factory;
private Scene scene;
private bool running = true;

public GameApplication(IInputSource input, IRenderer renderer, IGameClock clock,
IViewport viewport, IGameFactory factory)
{
this.input = input ?? throw new ArgumentNullException(nameof(input));
this.renderer = renderer ?? throw new ArgumentNullException(nameof(renderer));
this.clock = clock ?? throw new ArgumentNullException(nameof(clock));
this.viewport = viewport ?? throw new ArgumentNullException(nameof(viewport));
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
scene = new StartScene(this, renderer);
}

public void Run()
{
scene.Enter();
TimeSpan last = clock.Elapsed;
while (running)
{
if (!viewport.IsReady)
{
viewport.ShowResizeHint();
if (input.TryRead(out InputCommand smallWindowCommand) && smallWindowCommand == InputCommand.Escape)
Quit();
input.ClearPending();
clock.Sleep(50);
last = clock.Elapsed;
continue;
}

for (int count = 0; count < 8 && input.TryRead(out InputCommand command); count++)
{
Scene previous = scene;
scene.Handle(command);
if (!running || !ReferenceEquals(previous, scene))
{
input.ClearPending();
last = clock.Elapsed;
break;
}
}
if (!running)
break;
TimeSpan now = clock.Elapsed;
TimeSpan elapsed = now - last;
last = now;
// 长时间停顿后限制追赶量,避免恢复显示时瞬间走出很多步。
if (elapsed > TimeSpan.FromMilliseconds(250))
elapsed = TimeSpan.FromMilliseconds(250);
scene.Update(elapsed);
scene.Render();
clock.Sleep(16);
}
scene.Exit();
}

private void ChangeScene(Scene next)
{
scene.Exit();
scene = next;
scene.Enter();
}

public void StartRound() => ChangeScene(new PlayScene(this, renderer, factory.Create()));
public void ShowMenu() => ChangeScene(new StartScene(this, renderer));
public void EndRound(IGameView result) => ChangeScene(new EndScene(this, renderer, result));
public void Quit() => running = false;
}
}

输入读取与逻辑节拍是分开的:主循环较频繁地检查按键,而蛇按 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
using System;
using System.IO;
using SnakeGame.Application;
using SnakeGame.Core;
using SnakeGame.Infrastructure;
using SnakeGame.Presentation;

namespace SnakeGame
{
internal static class Program
{
private static int Main()
{
if (Console.IsInputRedirected || Console.IsOutputRedirected)
{
Console.Error.WriteLine("Run this game in an interactive terminal.");
return 2;
}
try
{
using (ConsoleSession session = new ConsoleSession())
{
ConsoleRenderer renderer = new ConsoleRenderer();
DefaultGameFactory factory = new DefaultGameFactory(new RandomFoodSpawner(new Random()));
GameApplication application = new GameApplication(new ConsoleInputSource(), renderer,
new SystemGameClock(), renderer, factory);
application.Run();
}
return 0;
}
catch (IOException ex)
{
Console.Error.WriteLine("Terminal I/O error: " + ex.Message);
return 1;
}
catch (ArgumentOutOfRangeException)
{
Console.Error.WriteLine("Terminal resized during redraw. Resize and restart.");
return 1;
}
}
}
}

这里是具体对象的组装位置:创建真实键盘、终端绘制、时钟、食物生成器与工厂,再把它们传给 GameApplication。核心规则没有承担这些构造选择。

using (ConsoleSession ...) 确保离开运行作用域时恢复光标与颜色。终端被重定向时,程序会提示使用交互终端;不尝试在普通输出面板中伪造即时按键。

7.13 实际游玩与检查

  1. 在终端运行 dotnet run --project SnakeGame,按 Enter 开始。
  2. 观察蛇自动向右前进,按 W 或上方向键转向;不要等到已经撞墙才输入。
  3. 朝食物移动,观察增长与每次 10 分的变化。
  4. 按 P 暂停,确认蛇停住;再次按 P 恢复。
  5. 尝试直接反向,确认输入被忽略。
  6. 撞墙或身体后进入结束场景,按 R 开启新局,分数恢复为零、长度为三。
  7. 游戏中 Esc 回菜单,菜单或结束界面 Esc 退出。
验收项 预期结果
无按键时 蛇仍按固定节拍移动
方向输入 改下一步方向,不直接多走一步
同节拍快速转两次 只接收一次有效转向
进入旧尾格且不生长 允许移动,不误判碰撞
食物刷新 在棋盘内且不覆盖蛇身
蛇填满棋盘 直接胜利,不进入无限随机循环
暂停与恢复 停止逻辑推进,恢复不补跑整段暂停时间
新一局 新蛇、新分数、新食物,不复用上一局残局
显示实现替换 规则层不需要重新依赖 Console

7.14 可以继续做的扩展

  • 添加 HelpScene,复用 Scene 的输入与绘制契约。
  • 添加障碍物,但先明确障碍物、蛇身、食物的占用规则。
  • 把计分值抽象成类似 IScoreRule 的策略,对照第六章练习。
  • 添加可调速度,把规则配置从固定常量改成经过校验的配置对象。
  • 更换输入或绘制实现,保留 GameEngine;迁移到 Unity 时由引擎负责输入、时间更新与渲染。
  • 记录每次有效转向与食物位置,生成可重复回放,再学习文件格式与资源管理。

这些扩展不要求把终结器、复杂继承树或所有设计模式都塞进游戏。选用能表达真实规则的机制即可。

附录 A:练习与参考答案

A.1 经验值的受控修改

下面是完整的小类型。也可以把相同的属性和方法加入 Player,而不是通过继承密封的 Player 强行扩展。

1
2
3
4
5
6
7
8
9
10
11
public sealed class ExperienceTracker
{
public int Experience { get; private set; }

public void AddExperience(int amount)
{
if (amount < 0)
throw new ArgumentOutOfRangeException(nameof(amount));
Experience = checked(Experience + amount);
}
}
1
2
3
ExperienceTracker tracker = new ExperienceTracker();
tracker.AddExperience(10);
Console.WriteLine(tracker.Experience); // 10

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
2
3
4
5
6
7
8
9
10
11
string raw = "  A, B, , C  ";
string[] names = raw.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
Console.WriteLine(string.Join(" / ", names)); // A / B / C

StringBuilder builder = new StringBuilder(128);
for (int i = 1; i <= 3; i++)
{
builder.Append("Round ").Append(i).Append(": ").Append(i * 10).AppendLine();
}
string report = builder.ToString();
Console.Write(report);

追加过程中使用同一个 builder,最后只生成一次最终字符串。格式化要求不受当前文化影响时,应明确选择相应 CultureInfo。

A.5 资源与 GC 判断

问题 答案
局部变量离开作用域是否立刻执行终结器 不保证
把变量置 null 是否立即释放文件 不会因此保证释放,应使用 Dispose/using
using 是否释放对象全部托管内存 不负责保证这件事,主要安排 Dispose
只有普通托管字段是否必须写终结器 通常不需要
ConsoleSession 为什么实现 IDisposable 运行作用域结束时及时恢复终端状态
GC 能否回收不可达的循环引用 可以,不能把循环引用本身当作永远无法回收的理由

A.6 原则与项目设计

  • 碰撞规则不依赖 Console:体现职责划分和依赖方向。
  • 换食物生成策略不改引擎:体现抽象扩展点。
  • 生成器不得把食物放在蛇身:体现行为契约,而不只是签名一致。
  • 开始场景只接收 IMenuRenderer:按客户所需角色限制依赖。
  • GameEngine 组合 Snake 与生成器:复用协作者,不强行继承其实现。
  • 分部类能整理文件,但不会自动完成职责分离;不相关的责任仍应拆类型。

A.7 贪吃蛇边界题

  1. 蛇向右,在同一节拍先按上、再按左:只保留第一次有效的上转,不允许借第二次输入掉头。
  2. 下一格是旧尾格,但本步不吃食物:尾巴会移动,允许进入。
  3. 蛇吃到最后一块空地上的食物:增长、加分、进入胜利,清除食物。
  4. 游戏暂停五秒后恢复:不应立即补走五秒对应的全部步数。
  5. 给游戏换成固定食物生成器:仍须遵守棋盘内且不覆盖蛇身的契约。

附录 B:运行、排查与验证

B.1 示例文件位置与 using

源码包已设置目标框架和项目引用。运行某章节前不需要把它的全部类型重新粘贴到 Program。

如果自己创建练习文件,常用导入如下,按片段需要选择:

1
2
3
4
5
6
7
8
9
10
11
12
using System;
using System.Collections.Generic;
using System.Text;
using OopExamples.Encapsulation;
using OopExamples.Inheritance;
using OopExamples.Polymorphism;
using OopExamples.Design;
using OopExamples.Extras;
using SnakeGame.Domain;
using SnakeGame.Contracts;
using SnakeGame.Core;
using SnakeGame.Infrastructure;

章节中的补充类型和浅拷贝、显式接口等例子还整理在 OopExamples/Extras/AdditionalExamples.cs,可以运行:

1
dotnet run --project OopExamples -- extra

文档中的“构造声明片段”或“主循环说明片段”依赖其所属类,不是独立入口;完整对应文件在项目中。

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 工作 最少知识与耦合控制

C# 学习系列: C# 入门 → C# 基础 → C# 进阶 → C# 高级