如何创建具有类似字符串相等语义的.NET不可变类型?
完全可以实现这种类型,不需要依赖字符串的编译/运行时特殊机制,同时能通过合理设计避免过高内存占用。下面是具体的实现思路和代码示例:
核心设计原则
要满足需求,核心要抓两点:
- 类型本身必须不可变:保证实例创建后字段无法修改,这样共享引用才不会出现数据不一致问题
- 线程安全的实例缓存:用缓存存储已创建的唯一值实例,相同值复用引用;同时缓存的设计要兼顾内存效率
具体实现步骤
1. 定义不可变类型
先把类型的字段设为只读,构造器私有化,只通过静态工厂方法对外暴露创建入口,确保外部无法直接修改实例状态:
public sealed class ImmutableUser { // 只读字段,确保不可变 public int Id { get; } public string Name { get; } // 私有构造器,禁止外部直接实例化 private ImmutableUser(int id, string name) { Id = id; Name = name ?? throw new ArgumentNullException(nameof(name)); } }
2. 实现驻留缓存逻辑
用ConcurrentDictionary作为缓存容器(天然线程安全,适合多线程场景),键用ValueTuple来存储字段组合——它默认实现了基于字段的哈希和相等判断,不用自己写额外的键类型,内存开销也小。
加上静态工厂方法Create,负责从缓存获取或创建实例:
// 静态缓存:键是字段组合,值是实例引用 private static readonly ConcurrentDictionary<(int Id, string Name), ImmutableUser> _instanceCache = new(); public static ImmutableUser Create(int id, string name) { var valueKey = (id, name); // GetOrAdd原子操作:缓存中有则返回,没有则创建并加入缓存 return _instanceCache.GetOrAdd(valueKey, key => new ImmutableUser(key.Id, key.Name)); }
这样,只要传入的Id和Name相同,就会返回同一个实例,实现了类似string.Intern的驻留效果。
3. 实现相等比较逻辑
按照要求,先判断引用相等,再回退到值语义。实现IEquatable<ImmutableUser>接口,重写Equals和GetHashCode,同时重载==和!=运算符,和string的相等逻辑对齐:
public bool Equals(ImmutableUser other) { if (ReferenceEquals(null, other)) return false; // 先判断引用是否相同,相同直接返回true if (ReferenceEquals(this, other)) return true; // 引用不同时,再比较字段值 return Id == other.Id && string.Equals(Name, other.Name); } public override bool Equals(object obj) { // 先判断引用,再判断类型并调用强类型Equals return ReferenceEquals(this, obj) || (obj is ImmutableUser other && Equals(other)); } public override int GetHashCode() { // 和缓存键的哈希逻辑保持一致,避免哈希冲突 return (Id, Name).GetHashCode(); } // 重载运算符,符合开发者的使用习惯 public static bool operator ==(ImmutableUser left, ImmutableUser right) { return Equals(left, right); } public static bool operator !=(ImmutableUser left, ImmutableUser right) { return !Equals(left, right); }
内存优化:避免缓存泄漏
如果你的场景中会创建大量实例,且很多实例后续会被废弃,直接用强引用缓存可能导致内存无法回收。这时可以把缓存值改成WeakReference<ImmutableUser>,让GC能回收不再被使用的实例:
private static readonly ConcurrentDictionary<(int Id, string Name), WeakReference<ImmutableUser>> _weakInstanceCache = new(); public static ImmutableUser Create(int id, string name) { var valueKey = (id, name); // 先尝试从弱引用缓存中获取存活的实例 if (_weakInstanceCache.TryGetValue(valueKey, out var weakRef) && weakRef.TryGetTarget(out var existingInstance)) { return existingInstance; } // 缓存中没有存活实例,创建新实例并更新缓存 var newInstance = new ImmutableUser(id, name); _weakInstanceCache.AddOrUpdate(valueKey, _ => new WeakReference<ImmutableUser>(newInstance), (_, _) => new WeakReference<ImmutableUser>(newInstance)); return newInstance; }
这种方式既保留了驻留的优势,又不会因为缓存占用过多内存,适合实例生命周期波动大的场景。
为什么不依赖字符串的特殊机制?
字符串的驻留是CLR层面的特殊处理(比如编译时常量自动驻留、string.Intern的全局池),但上面的方案完全用普通.NET类型和集合实现,所有逻辑都是可控的,没有依赖任何CLR对字符串的专属魔法。
内容的提问来源于stack exchange,提问作者Ivan Petrov

