如何确保C#结构体字段始终有效?音符存储场景下的方案抉择
你想用只读结构体存储音符(核心是一个int值,同时附带音符名称、八度等派生字段),但C#中结构体可通过默认构造(如default(Note)或直接声明未初始化变量)生成无效状态。针对你列出的四种方案,下面逐个分析并给出最优选择:
方案1:无参构造函数初始化默认值
readonly struct Number { public readonly int Value; public readonly int ValueTimesTen; public Number() { this = new(1); } public Number(int value) { if (value is > 0 and <= 10) { Value = value; ValueTimesTen = Value * 10; } else { throw new ArgumentException("Number has to be between 1 and 10."); } } }
缺陷:这种方法只能覆盖手动调用构造的场景,C#仍允许通过default(Number)或Number num;创建默认实例,此时所有字段会被强制设为默认值(Value=0、ValueTimesTen=0),完全绕开你的无参构造,无效状态依然存在,无法彻底解决问题。
方案2:属性拦截无效值
readonly struct Number { private readonly int _value; public int Value { get { if (GetValid(_value)) { return _value; } else { return 1; } } } public int ValueTimesTen => Value * 10; private static bool GetValid(int number) { return (number is > 0 and <= 10); } public Number(int value) { if (GetValid(value)) { _value = value; } else { throw new ArgumentException("Number has to be between 1 and 10."); } } }
缺陷:如你所说,实际场景中派生字段(音符名称、八度等)的计算开销很大,每次访问属性都重新计算会严重影响性能。而且这种方案只是“掩盖”了无效状态,结构体私有字段_value依然可能是无效值(比如默认实例的_value=0),逻辑上存在不一致性,不是彻底的解决方案。
方案3:改用类
优势:类的实例必须通过构造函数创建,不存在默认构造绕开的问题(除非你显式编写无参构造),能完全保证实例始终处于有效状态。对于不可变的小对象,虽然结构体栈分配有性能优势,但现代.NET的GC对小对象回收效率极高,性能差异可忽略不计。
你可以把类设计为不可变值类(所有字段设为readonly,只提供构造函数和只读属性),完全保留结构体的不可变特性,同时彻底规避无效实例问题。
方案4:允许无效状态,靠外部代码保证
缺陷:这是最危险的方案,完全依赖开发人员自律,后续维护或多人协作时极易出现疏漏,导致无效实例流入业务逻辑,引发难以排查的bug。除非代码范围极小且完全由你自己维护,否则绝不推荐。
总结:最优选择是方案3(改用类)
虽然结构体看起来更贴合“值类型”语义,但C#值类型的默认构造行为导致无法彻底避免无效实例。改用不可变类能彻底解决问题,性能上的差异在绝大多数场景下可以忽略。如果实在想保留值类型特性,.NET 7+的required修饰符或工厂方法也无法阻止default实例生成,所以类的方案依然是最可靠的。
内容的提问来源于stack exchange,提问作者Xavier Evans

