游戏开发中如何避免只读State类返回可写引用并安全暴露可读内部数据
解决方案
一、安全暴露深层只读数据,解决State类臃肿问题
核心思路
通过只读视图接口+分层封装的方式,把原本堆在State类里的查询逻辑下放到对应的子模块封装类中,同时完全避免泄漏内部可写引用。
具体实现步骤
- 为每一层内部数据定义只读接口,仅暴露允许读取的属性和查询方法,不包含任何修改逻辑。
- State类仅对外暴露这些只读接口类型的属性,不直接返回内部的可写集合或类实例。
- 把对应的查询逻辑封装到只读接口的内部实现类中,这些实现类作为State的私有嵌套类,能访问State的私有成员,但对外完全隐藏实现细节。
代码示例
// 定义公开的只读接口,外部仅能看到这些成员 public interface IReadOnlyUnitMap { bool IsUnitAt(Vector2 pos); int GetUnitOwnerId(Vector2 pos); int GetUnitRemainingMobility(Vector2 pos); } public partial class State { public int Turn {get; private set;} = 1; private Dictionary<Vector2, FactionsMgr.Faction> _unit_map = new(); // 对外仅暴露只读接口 public IReadOnlyUnitMap UnitMap { get; } public State() { // 初始化内部封装的只读实现 UnitMap = new UnitMapImpl(this); } // 私有嵌套类实现只读接口,持有State的引用访问私有成员 private class UnitMapImpl : IReadOnlyUnitMap { private readonly State _state; public UnitMapImpl(State state) => _state = state; public bool IsUnitAt(Vector2 pos) => _state._unit_map.ContainsKey(pos); public int GetUnitRemainingMobility(Vector2 pos) { if (!IsUnitAt(pos)) { GD.Print("Warning: GetUnitRemainingMobility asked for unknown unit: ", pos); return -1; } var owner = _state._unit_map[pos]; var unit = owner.units[pos]; return UnitsMgr.GetMaxMobility(unit.type) - unit._taken_movement; } public int GetUnitOwnerId(Vector2 pos) { return IsUnitAt(pos) ? _state._unit_map[pos].Id : -1; } } }
调用方式
外部读取数据时直接通过分层的只读属性访问即可,逻辑归属清晰:
// 外部调用示例 int mobility = state.UnitMap.GetUnitRemainingMobility(pos); int ownerId = state.UnitMap.GetUnitOwnerId(pos);
二、Command类权限优化,无需嵌套在State内部
核心思路
通过接口隔离权限的方式,拆分只读和可写两个State接口,仅将可写接口开放给Command使用,既保证修改权限可控,又能把Command类从State内部拆分出去。
具体实现步骤
- 定义公开的
IReadOnlyState接口,包含所有对外暴露的只读属性和子视图(比如上面的UnitMap),供UI、逻辑查询等模块使用。 - 定义内部的
IWritableState接口,仅包含允许Command调用的修改方法,不对外公开。 - State类同时实现这两个接口,修改方法用显式接口实现,避免外部正常调用时看到修改接口。
- Command接口的Execute方法仅接收
IWritableState参数,所有具体Command类直接实现该接口即可,无需嵌套在State内部。
代码示例
// 公开的只读接口,给所有非修改模块使用 public interface IReadOnlyState { int Turn { get; } IReadOnlyUnitMap UnitMap { get; } } // 内部的可写接口,仅给Command使用 internal interface IWritableState { void ConsumeUnitMobility(Vector2 pos, int cost); void SetTurn(int turn); } // State实现两个接口 public partial class State : IReadOnlyState, IWritableState { // 显式实现可写接口的方法,外部正常访问State实例时看不到这些方法 void IWritableState.ConsumeUnitMobility(Vector2 pos, int cost) { if (_unit_map.TryGetValue(pos, out var owner)) { owner.units[pos]._taken_movement += cost; } } void IWritableState.SetTurn(int turn) => Turn = turn; } // Command接口独立定义,不需要放在State内部 public interface ICommand { void Execute(IWritableState state); } // 具体命令实现 public class MoveUnitCommand : ICommand { private readonly Vector2 _fromPos; private readonly int _cost; public MoveUnitCommand(Vector2 fromPos, int cost) { _fromPos = fromPos; _cost = cost; } public void Execute(IWritableState state) { state.ConsumeUnitMobility(_fromPos, _cost); // 其他移动逻辑 } }
额外优化
如果还是希望保持Command的访问权限控制,又不想拆接口,可以利用C#的分部类特性,把所有Command相关的代码放到单独的State.Commands.cs文件中,既保持嵌套类的访问权限,又不会让State的主文件过于臃肿。
注意事项
- 所有对外返回的引用类型都必须是只读接口或者值类型,禁止直接返回内部的可写类实例、原始集合(List/Dictionary等),如果需要返回集合请用
ReadOnlyCollection/ReadOnlyDictionary封装。 - 深层嵌套的类(比如Faction、Unit)也需要对应做只读接口封装,避免返回内部可写实例导致意外修改。
内容的提问来源于stack exchange,提问作者Quintus
相关产品推荐
相关产品推荐

