(Martin Fowler's Example)
1. 积极使用 Guard Clause(保护语句)
"如果条件不满足,立即返回。将核心逻辑放在最少缩进的地方。"
概念定义
Guard Clause(保护语句) 是一种在函数开头检查特定条件是否满足,如果不满足则立即退出(return) 的方法。
它的目的是减少不必要的 if
嵌套,使代码更加线性和平坦(flat) 。
Before(不好的示例)
if (user != null)
{
if (user.IsActive)
{
Process(user);
}
}
代码嵌套过多,核心逻辑
Process()
被隐藏在最内层。随着条件的增加,代码会形成“金字塔代码(Pyramid of Doom)”,变得越来越复杂。
当测试条件增多时,代码覆盖率和调试的可读性都会变差。
After(好的示例)
if (user == null) { return; }
if (!user.IsActive) { return; }
Process(user);
在条件不满足的情况下,通过提前返回 快速整理流程。
核心逻辑
Process()
位于最少缩进的中央部分 ,可读性更高。在测试/重构时,可以轻松追踪条件与结果 。
反对意见:“否定条件违背直觉”
一些开发者可能会提出以下观点:
“比起
if (!condition) return;
,if (condition) { ... }
更加直观。
否定条件会让代码难以理解。”
回应反对意见:
关键在于条件的正/负,而不是‘意图’和‘重要性’的排序。
如果“在这种情况下不应该执行操作”是明确的,那么否定条件更为清晰 。
指南:
不重要的条件 = 否定形式 + 提前返回
重要的条件 = 肯定形式 + 执行核心逻辑
补充提示:Guard 子句不仅可以使用 return
,还可以使用 throw
或 continue
if (value == null) { throw new ArgumentNullException(nameof(value)); }
foreach (var item in collection)
{
if (item.IsEmpty) { continue; }
Process(item);
}
什么时候应该积极使用 Guard 子句?
情况 |
描述 |
---|---|
包含大量验证的函数 |
通过提前返回来清理条件 |
包含许多状态分支的循环 |
使用 |
存在未检查的异常风险 |
清理 |
方法开始变长时 |
使用 Guard 子句整理条件过滤,简化代码 |
实战技巧
Guard Clause 通过去除不必要的嵌套 + 提前退出 ,使代码变得更加平坦(flat) 。
这是一种设计策略,旨在将核心逻辑放在缩进最少、最显眼的位置。
条件越多 、失败案例越明确 、测试越复杂 ,Guard Clause 的优势就越明显。
Dictionary<TKey, TValue> 在内部使用哈希表(Hash Table) 实现。
因此,键查找的平均时间复杂度为 O(1) ,这在很多情况下比 switch-case 更快
2. 战略性地将 Switch 转换为 Dictionary 映射
"条件分支越复杂,代码的责任应被分解得越清晰。"
概念定义
在 C# 中,switch-case
语句对简单的分支处理非常有用,但当分支数量增加时,在维护性和扩展性方面会暴露出局限性 。
在这种情况下,使用 Dictionary<Enum, Action>
或 Dictionary<Enum, Func<T>>
构建显式映射 ,可以显著提升可读性和功能扩展性 。
Before(不好的示例)
switch (state)
{
case UserState.Idle:
HandleIdle();
break;
case UserState.Running:
HandleRunning();
break;
case UserState.Dead:
HandleDead();
break;
default:
throw new InvalidOperationException();
}
随着分支增多,代码变得冗长。
如果在
switch
内部处理过多逻辑,容易违反单一职责原则(SRP) 。当新增状态时,需要找到对应的分支、添加逻辑并测试,修改分散且繁琐。
After(好的示例)
private static readonly Dictionary<UserState, Action> stateHandlers = new()
{
{ UserState.Idle, HandleIdle },
{ UserState.Running, HandleRunning },
{ UserState.Dead, HandleDead }
};
public void Handle(UserState state)
{
if (stateHandlers.TryGetValue(state, out Action action))
{
action.Invoke();
}
else
{
throw new InvalidOperationException($"No handler for {state}");
}
}
状态与操作的关系通过映射明确管理 。
新增状态时只需在字典中注册即可完成。
测试时可以独立验证每个处理器。
使用场景示例
UI 状态机(State Machine)
Dictionary<GameState, Action> renderState = new()
{
{ GameState.MainMenu, DrawMainMenu },
{ GameState.InGame, DrawGame },
{ GameState.Paused, DrawPauseScreen },
};
什么时候适合使用?
情况 |
描述 |
---|---|
基于 Enum 的分支较多时 |
|
命令/输入处理分支 |
键/按钮输入 → 动作映射 |
状态机 |
状态 → 行为对应结构 |
需要分离出可测试的逻辑时 |
|
缺点及注意事项
缺点 |
应对措施 |
---|---|
未注册的键没有对应处理 |
|
不保证顺序 |
使用 |
复杂条件分支难以处理 |
仅适用于简单条件逻辑,复杂逻辑仍需使用 |
实战技巧
命令模式(Command Pattern)的简易实现
可以像Dictionary<string, ICommand>
一样,将命令和执行对象进行映射。向函数式编程(FP)靠拢的信号
基于字典的映射实际上是将代码转换为数据驱动的表格式结构 ,这与 FP(函数式风格命令调度)的理念更为接近。
(MSDN Magazine Issues Volume 32 Number 3)
(Read only, frozen, and immutable collections - Developer Support)
3. 不可变数据(Immutable Data)习惯
“调试地狱从何开始?——正是从那些意外的值变更开始。”
概念定义
不可变(Immutable)数据 是指一旦定义后,其值不会改变的状态 。
在 C# 中,可以通过 readonly
、const
、record
等方式实现有意图的不可变设计 。
Before(不好的示例)
public class Player
{
public int health;
public void TakeDamage(int amount)
{
health -= amount;
}
}
- 在这种结构中,很难追踪
health
是在哪里被修改的。 - 特别是在多线程或事件驱动系统中,副作用 的累积会使调试难度急剧上升。
After(好的示例)
public class Player
{
private readonly int maxHealth = 100;
private int currentHealth;
public int GetHealth() => currentHealth;
public void SetHealth(int value)
{
currentHealth = Math.Clamp(value, 0, maxHealth);
}
}
maxHealth
是一个永远不会改变的常量 → 使用readonly
声明。- 状态修改仅通过
SetHealth()
方法完成 → 访问控制与不可变性分离 。
为什么在游戏开发中很重要?
如果状态(State)变更以不透明的方式扩散 :
- UI 更新延迟
- Bug 不规则发生
- 多人环境中同步问题
解决方法:将状态本身建模为不可变对象 进行管理。
public readonly struct PlayerState
{
public readonly int health;
public readonly bool isDead;
public PlayerState(int health, bool isDead)
{
this.health = health;
this.isDead = isDead;
}
}
状态不是用来修改的,而是‘重新创建’的。→ 函数式编程模式
Unity 开发者可以这样使用
- 使用
ScriptableObject
存储配置数据时 → 只读结构化 - 避免使用
public int value;
形式,改为没有 setter 的SerializedField
- 推荐将状态对象存储在不可变的
struct + Copy-on-Write
模式中,而不是放在可变的 MonoBehaviour 中。
[SerializeField] private int initialHealth = 100;
public int InitialHealth => initialHealth; // 只允许读取
从 C# 9 开始引入了 record
record
默认是不可变对象 。- 使用
with
表达式进行修改时会创建新对象(immutable-safe) 。
var newStatus = status with { Health = status.Health - 10 };
高级技巧:并行处理与架构设计建议
readonly
+ volatile
组合
private volatile bool isGameOver;
private readonly object lock = new();
public void SetGameOver()
{
lock (lock)
{
isGameOver = true;
}
}
- 将看似不可变的值在多线程环境中保持一致性保护 。
- 这种组合也常用于双重检查模式。
注意:所有内容都必须不可变吗?
- 如果在游戏循环中性能至关重要的结构 ,需要考虑
struct
不可变对象的创建成本。 - 对于需要频繁状态变更的对象,可以采用“内部不可变性(internal immutability)”作为折衷方案。
什么时候适合使用不可变模式?
场景 |
描述 |
---|---|
需要跟踪状态时 |
难以追踪状态变更的结构会导致调试噩梦 |
线程间共享数据 |
不可变性设计可以在无锁的情况下保证稳定性 |
可测试的状态建模 |
基于对象复制的测试和时间点比较更加容易 |
UI 状态更新 |
在 ViewModel 中便于变更跟踪和绑定 |
实战技巧
- 不可变数据是降低调试成本的最佳结构
- 状态不应修改,而应替换 :覆盖对象的方式对追踪和恢复更有利
- 在 C# 中,可以通过
readonly
、record
和ScriptableObject + Getter
设计来实现。
结论:
我们了解了在 C# 中经常使用的基础重构方法。
实际上,详细撰写这种入门级别的文章对我也有帮助,因此我重新整理了一遍。
除此之外,还有很多其他模式,但我选出了三个我认为重要的基础概念。