get/set 属性设计:类库封装中不可忽视的安全门卫

发布时间:2026/10/12 7:50:34
get/set 属性设计:类库封装中不可忽视的安全门卫 类库设计里最容易被低估的往往不是复杂算法反而是两个看起来不起眼的关键字get 和 set。我维护过几个被反复“封印”的内部类库打开源码排雷的时候十有八九是属性设计出了问题——要么直接 public 字段裸奔要么自动生成的 getter/setter 没有任何约束调用方想怎么改就怎么改。05-2 是我自己的类库设计笔记里的一节当时给一个模拟项目做封装被一个public List字段坑到加班才开始认真琢磨属性的门道。这次把 get set 单独拎出来聊因为它不是 IDE 点两下生成样板代码那么简单。在类库里get set 决定了 API 边界是否安全、哪些值能读、哪些值只能在内部改、哪些值必须经过校验才能进来。如果你正在写自己的类库或者想把领域对象的封装做得更讲究这篇文章就是给你准备的。1. 为什么说 get set 是类库设计的“门卫”1.1 一个字段引发的线上故障先讲一个我真实踩过的坑。之前做一个某跨平台系统端上同学反馈用户列表会随机变成空。查了半天定位到一个基础工具库的公共方法里直接clear()了一个List。问题源头是另一个模块拿走了这个List的引用在业务里做了一次removeAll结果底层类库的内部状态被连带清空。表面上是调用方用错了 API但根子出在类库自己身上内部集合直接用 public 字段暴露等于是把仓库钥匙复制给所有外部模块。字段是存储位外部拿到引用后可以绕过任何约束随意篡改。类库一旦发布你不可能守在每一个调用点旁边监督唯一的办法是让 API 结构本身就不允许违规操作。后来我把这个字段改成属性并且返回只读视图同类问题再没出现过。不要高估调用方的自觉性调用方总会用你想不到的方式去修改你的数据。门卫的意义不在抓坏人在让坏行为根本发生不了。1.2 属性不是语法糖而是两个方法新手最容易误解的一点属性好像只是“好看一点的字段”。实际上C# 里的属性会被编译成get_XXX和set_XXX两个方法Java 的 getter/setter 本来就是普通方法。所以你在类库代码里写一句obj.Score 100本质上是一次方法调用。既然是方法就可以在校验、转换、通知、懒加载上做文章普通字段做不到这一点。拿生活类比字段是把钱包直接放桌上谁路过都能抽两张属性是银行柜台业务员核对完再给你取钱。多一道流程是麻烦了点但能挡住大部分风险。类库面对的调用方基数大这道流程尤其值。理解了“属性 方法调用”后面所有的问题都好解释了get set 有性能开销是因为方法调用本身有栈帧开销setter 里抛出的异常调用方能感知到不是因为魔法是因为你就在方法体里抛的调试器会在 getter 上反复触发是因为调试器也要通过 getter 读值。这些都是方法的基本特性只是很多教程没点透。2. 类库里的 get set 到底该怎么写2.1 自动属性与 Java 传统写法先分清场景先看两种语言最“基础”的写法。C# 的自动属性public class Student { public string Name { get; set; } public int Score { get; set; } }Java 的传统写法public class Student { private String name; private int score; public String getName() { return name; } public void setName(String name) { this.name name; } public int getScore() { return score; } public void setScore(int score) { this.score score; } }C# 自动属性和 Java 的 IDE 生成 getter/setter本质上都只是“外部能读能写”的最低需求。它们没有把属性升级成有保护的通道只是省去了手写样板代码的麻烦。我的建议是纯数据容器比如 DTO、接口返回的 Json 模型、配置对象直接上自动属性或 IDE 生成的普通 getter/setter。这类对象不承载业务规则没有不变量要守护给自动属性最合适。反过来说如果这个类有业务逻辑比如Score不能超过 100、Name不能为空字符串那就要考虑下面的写法了。2.2 带校验的 setter别让脏数据流进库类库和普通应用程序不一样调用方可能来自不同团队、不同模块对数据规则的理解各不相同。如果你不在入口做校验脏数据就会流到类库内部。C# 完整属性写法public class Student { private int _score; public int Score { get _score; set { if (value 0 || value 100) { throw new ArgumentOutOfRangeException(nameof(value), 分数必须在 0 到 100 之间); } _score value; } } }Java 写法public class Student { private int score; public int getScore() { return score; } public void setScore(int score) { if (score 0 || score 100) { throw new IllegalArgumentException(分数必须在 0 到 100 之间); } this.score score; } }这里有个关键原则校验失败要抛异常不能静默忽略也不能返回false。静默丢弃会吞掉错误调用方以为写成功了等到读出来发现数据不对排查链路会拉得很长。尽早失败、快速失败问题才好定位。另一个要点是异常类型要明确。C# 用ArgumentOutOfRangeException或ArgumentExceptionJava 用IllegalArgumentException这属于调用方最容易理解的输入类异常。不要抛一个宽泛的Exception调用方没法针对处理。2.3 只读属性、私有 set 与不可变设计很多类库里的对象字段一旦构造完成就不该再被外部改变。比如一个订单的状态快照、一条埋点记录、一个配置项。这时最好的设计是“只可读不可改”。C# 写法public class Order { public string OrderId { get; private set; } public DateTime CreatedAt { get; private set; } public Order(string orderId, DateTime createdAt) { OrderId orderId; CreatedAt createdAt; } }Java 写法public class Order { private final String orderId; private final LocalDateTime createdAt; public Order(String orderId, LocalDateTime createdAt) { this.orderId orderId; this.createdAt createdAt; } public String getOrderId() { return orderId; } public LocalDateTime getCreatedAt() { return createdAt; } }不可变对象的好处是线程安全、天然适合缓存和哈希键。但有一个隐藏的坑很多反序列化框架需要无参构造和可写的 setter。private set或者只有 getter 的类可能在反序列化时直接失败。解决方式是在框架层面绕开比如 .NET 的[JsonConstructor]标记构造器参数Java 的 Jackson 用JsonProperty绑定构造器参数。这样既保持只读外观又能让序列化框架正常工作。注意这里不要为了迁就序列化框架而放弃不可变设计。保护不变量比框架方便性更重要框架总有办法适配。2.4 计算属性与延迟加载的适用边界属性不一定要对应一个私有字段也可以是根据其他字段算出来的结果。比如public class OrderItem { public decimal Price { get; set; } public int Count { get; set; } public decimal Total Price * Count; }C# 里这种只读计算属性很常见Java 里对应一个getTotal()方法。好处是每次读取都是最新值而且不会有字段被改坏的风险。延迟加载也经常用在 getter 里private LazyHeavyService _service new LazyHeavyService(() new HeavyService()); public HeavyService Service _service.Value;这样首次读取时才创建实例之后复用。但这里有一条非常容易踩的线不要在 getter 里做重活。我一再强调“属性是方法”所以 getter 里可以塞任何代码但请你想清楚后果。调试器会频繁调用 getter序列化框架也会调用 getter如果你在 getter 里访问数据库、做网络请求、写日志会让系统慢得无法接受甚至产生副作用。我判断 getter 是否适合做重活有一个简单标准你在这个 getter 里敢不敢放一行日志如果放了日志会导致系统卡顿或日志爆炸那这个操作就不应该放在 getter 里。3. 进阶集合属性、序列化与数据绑定的雷区3.1 集合属性永远别把内部列表直接交给调用方这是类库设计里最常见也是最贵的一课。public class Student { private readonly Liststring _tags new Liststring(); public Liststring Tags _tags; // 危险 }上面这段代码看起来没毛病但Tags返回的是内部_tags的引用调用方拿到后可以Clear()、Add()、Remove()完全绕过你的控制。比你预期更严重的是调用方可能只是读了一下比如foreach (var tag in student.Tags)然后在某个异步操作里顺手做了修改排查起来非常头疼。正确做法是返回只读视图public IReadOnlyListstring Tags _tags;Java 对应写法public ListString getTags() { return Collections.unmodifiableList(tags); }这样调用方可以遍历、可以读数量但没法修改集合内容。如果确实需要修改就提供明确的方法比如AddTag(string tag)、RemoveTag(string tag)。方法的好处是操作入口清晰你能在里面加校验、加日志、触发事件。注意返回只读视图不等于万事大吉。如果你的内部集合是ListT在多线程环境下仍然要保证内部写入是同步的只读视图只是解决了“外部篡改”的问题。3.2 序列化与反射get set 的隐形成本类库被其他模块引用之后属性名就是协议的一部分。对接方的 JSON 字段名、ORM 的列映射、反射框架的类型查找全部依赖属性名。一旦你改了属性名比如把Name改成FullName所有老调用方的序列化逻辑会直接崩掉或丢字段。如果确实要改名可以参考下面的兼容方案。C# 的System.Text.Json用[JsonPropertyName][JsonPropertyName(name)] public string FullName { get; set; }Java 的 Jackson 用JsonPropertyJsonProperty(name) private String fullName;另一个反射的性能坑动态调用 getter/setter 很慢不要在热路径里去GetProperty(Score)Invoke。正确做法是在启动时缓存PropertyInfo和MethodInfo或者直接构建委托表达式树。落到类库设计上就是如果某个属性要被反射框架高频调用一定要加上缓存层否则性能报表会很难看。3.3 数据绑定与通知setter 里的副作用怎么管很多 UI 类库依赖属性通知比如 C# 的INotifyPropertyChangedpublic class ViewModel : INotifyPropertyChanged { private string _name; public event PropertyChangedEventHandler PropertyChanged; public string Name { get _name; set { if (_name value) return; _name value; PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name))); } } }这个写法核心在if (_name value) return;它的价值是只有值真的变化时才触发通知。否则大量无意义的赋值会反复触发绑定、刷新界面甚至造成 A 通知 B、B 又通知 A 的死循环。在类库设计里setter 里的隐藏动作越少越好。如果赋值操作会触发事件、写审计日志、发送消息那应该把语义讲清楚。我的评审习惯是一旦 setter 里有超过两层副作用就该提取成动词方法比如ChangeName、ApplyScore让调用方一眼就知道这次操作不简单。4. 实战一个成绩管理器的 get set 设计复盘4.1 核心类应该长什么样用一个成绩管理器的模拟场景把前面讲的原则落到一个完整的类上。C# 版本public class Student { private readonly Liststring _tags new Liststring(); private int _score; public string Name { get; private set; } public int Score { get _score; set { if (value 0 || value 100) { throw new ArgumentOutOfRangeException(nameof(value), 分数必须在 0 到 100 之间); } _score value; } } public IReadOnlyListstring Tags _tags; public Student(string name) { if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException(姓名不能为空, nameof(name)); Name name; } public void AddTag(string tag) { if (!string.IsNullOrWhiteSpace(tag) !_tags.Contains(tag)) { _tags.Add(tag); } } public void RemoveTag(string tag) { _tags.Remove(tag); } }Java 版本public class Student { private final ListString tags new ArrayList(); private final String name; private int score; public Student(String name) { if (name null || name.isBlank()) { throw new IllegalArgumentException(姓名不能为空); } this.name name; } public String getName() { return name; } public int getScore() { return score; } public void setScore(int score) { if (score 0 || score 100) { throw new IllegalArgumentException(分数必须在 0 到 100 之间); } this.score score; } public ListString getTags() { return Collections.unmodifiableList(tags); } public void addTag(String tag) { if (tag ! null !tag.isBlank() !tags.contains(tag)) { tags.add(tag); } } public void removeTag(String tag) { tags.remove(tag); } }这个设计的细节都在“为什么”上。Name用private set是因为学生姓名在创建后不应该被随意改动。如果业务上真的要允许改名可以单独提供ChangeName的方法在方法里做校验和事件通知而不是开放一个裸的 setter。Score用完整属性是因为分值有边界规则必须在入口校验。为什么不用方法SetScore(int)属性赋值写起来更自然也方便调试器直观查看。Tags返回IReadOnlyList/Collections.unmodifiableList彻底杜绝外部篡改内部集合的可能性。修改操作收敛到AddTag/RemoveTag这两个方法里可以做去重复、加日志、触发事件等逻辑。4.2 外部使用时的行为验证设计完了我会用下面的方式在脑子里过一遍使用场景。正常的读和写var student new Student(小鹿); student.Score 88; Console.WriteLine(student.Score); // 88非法赋值当场失败try { student.Score -10; } catch (ArgumentOutOfRangeException ex) { // 分数必须在 0 到 100 之间 }集合无法被外部修改// student.Tags.Clear() 编译错误IReadOnlyList 没有 Clear 方法 // 或者可以调用的接口上不存在 Clear有效防止误操作 student.AddTag(数学); student.AddTag(数学); // 重复的不会加进去这个场景在 Java 中行为更明显因为unmodifiableList的接口仍然是List调用clear()会在运行期抛UnsupportedOperationException——这也是好的失败方式因为它让问题在第一时间暴露出来。4.3 如果当初直接暴露字段会发生什么把这三种选择放到一起对比结论更清楚设计选择外部表现类库维护成本public 字段读写随意无校验集合引用可被改高任何地方都可能改动内部状态排查困难自动属性无校验读写随意但至少可以加日志、事件中数据合法性仍无保障带校验属性 只读视图读可以写受控集合只读低规则集中在类内部外部绕不过去结合自己的维护经验我会明确推荐第三种。它多写了几行代码但是把“谁在什么时候能改什么数据”这件事变成了类库自己说了算而不是靠人肉自觉。5. 高频问题排查这些年我踩过的 get set 坑5.1 高频问题速查表现象常见原因解决/规避JSON 反序列化后字段丢失类只有带参构造且没有无参构造或 setter 是 private补充无参构造 / 用构造器绑定 Json 属性集合属性被外部清空直接返回内部可变集合的引用改返回只读视图抽象修改入口调试器在 getter 上卡死getter 里做了 IO、重计算、死循环保持 getter 轻量重活放到方法属性赋值后值没变赋值的是旧值setter 里做了相等跳过确认是否是预期语义必要时加审计日志setter 抛异常导致线上报错校验太严格或调用方传入了不合规数据明确校验规则提前在 API 文档中说明边界反射调用 getter/setter 性能极低每次都重新查找 PropertyInfo 并 Invoke启动时缓存 Metadata或用编译期表达式树5.2 排查与验证的三个实用技巧第一个技巧想知道“谁改了我的字段”最快的办法是把它改成属性在 setter 里记调用栈。比如public string Name { get _name; set { if (_name value) return; _name value; System.Diagnostics.Debug.WriteLine(Environment.StackTrace); } }然后运行调试调用栈会直接告诉你是在哪个模块、哪个方法里改的。第二个技巧属性名引用要用nameof不要硬编码字符串。重命名属性的时候nameof可以跟随变更报错字符串不会。类库在重构期尤其吃这个红利。第三个技巧给类库的公开属性做契约测试。把 0、负数、最大值、空集合、重复值全部跑一遍确认校验没有丢。很多项目在早期写好了校验后来某个版本重构的时候不小心删了契约测试能防止这种回归。另外跨模块调用场景下尽量保持属性命名稳定。属性名一旦发布给外部就等价于永久 API。给老属性改名要像给接口方法改名一样慎重能用[JsonPropertyName]或别名兼容就优先兼容。做类库维护的时间越长我越觉得 get set 是你和所有调用方之间的一份契约。契约写得含糊后面就要靠人肉加班来填坑。它看着简单但值得花一个晚上把边界理清楚。最后分享一个我现在还在用的习惯新建类的时候把属性先都写成private set等真需要对外开放写操作时再放行。一开始收紧后面放开很容易一开始就放开等你想收紧的时候等着被调用方堵门投诉吧。如果你也在整理自己的类库笔记强烈建议把 get set 单独列成一小节。这个知识点不深但它是类库设计地基的一部分地基稳了上面盖的楼层才不会塌。