字符串为什么要“不可变“:一个“看似麻烦“设计背后的深思熟虑
开场一个越用越憋屈的疑问小王刚搞懂字符串不可变导致 GC 垃圾反而更困惑了“既然不可变会造成拼接产生垃圾这种坑那为什么还要设计成不可变直接让它可以改不就好了这看起来’自找麻烦’的设计到底图什么好处”老鸟笑了“好问题这正是语言设计的权衡艺术不可变确实带来了拼接的垃圾问题但它换来了安全、线程安全、可缓存、可共享等一大堆好处这笔账总体是划算的今天把这个设计的深意讲透” 第一幕先理解可变会带来什么麻烦假设字符串可变的噩梦假设字符串可以随意修改 string a Hello; string b a; // b指向同一个字符串 a[0] J; // 改a → 变成Jello ↓ 问题: b也跟着变了! 因为a和b指向同一块内存! ↓ 你只想改a,b却莫名其妙变了! → 到处是意外的连锁改动!生动理解可变的混乱可变字符串像共享的白板 你写了Hello,借给同事看(ba) 你偷偷改成Jello → 同事手里的也变了! → 他一脸懵:我啥都没动啊? ↓ 共享可变 到处是意外的连锁反应! → 代码充满不可预测的bug! 第二幕好处1——安全性与可预测⭐不可变保证传出去也安心不可变的好处 string a Hello; string b a; // a永远不会被别人改变! ↓ 好处: 你把字符串传给任何函数 都不用担心它被偷偷改掉! ↓ DoSomething(myString); // myString绝对不会变!放心!对比可变的担忧如果可变 传字符串给函数 → 函数可能改了它! → 回来发现变了 → 惊吓! ↓ 不可变 传出去 绝对安全 → 不用防御性拷贝 → 代码简单可靠!生动理解安全性不可变像复印件而非原件 给别人的永远是内容确定的复印件 → 别人怎么处理都不影响你的原件 ↓ 你的字符串永远是你写的那样 谁都改不了 → 安心! 第三幕好处2——天然线程安全⭐多线程下的价值不可变 → 天然线程安全! 多个线程同时读一个字符串: → 因为谁都不能改它 → 不会有一个改一个读的冲突! → 不需要加锁! ↓ 可变字符串多线程: → 一个线程改,一个线程读 → 数据竞争!要加锁!麻烦又慢!生动理解线程安全不可变字符串像只读的公告栏 一堆人同时看公告(多线程读) → 内容固定,谁看都一样 → 不用排队,不会冲突! ↓ 可变的话像边写边看的黑板 → 一人写一人看 → 看到一半改了 → 混乱! → 得排队(加锁) → 慢! 第四幕好处3——可缓存哈希与字典键⭐不可变让哈希可缓存字符串常用作字典的键(Key) Dictionarystring, ... ↓ 字典靠哈希值快速查找 ↓ 不可变 → 字符串内容永不变 → 哈希值也永不变 → 可以缓存哈希值!(算一次就行) ↓ 查找超快!如果可变的问题如果字符串可变 放进字典当key后,又改了它 → 哈希值变了! → 字典里找不到它了!(位置错乱) ↓ 不可变 → 当key绝对安全可靠!生动理解哈希缓存不可变像身份证号固定 字符串的哈希 它的身份证号 → 内容不变 → 号码不变 → 记住号码就能秒查到它! ↓ 可变的话:改了内容号码就变 → 之前记的号码作废 → 找不到人了! 第五幕好处4——字符串驻留(Interning)与共享⭐相同字符串可以共享内存不可变 → 相同内容可以共享同一份! string a Hello; string b Hello; → 可能指向同一块内存!(驻留Interning) → 因为反正谁都改不了,共享很安全! ↓ 节省内存!如果可变就不能共享如果可变: a和b共享 → 改a会影响b → 不敢共享 → 每个都独立存 → 浪费内存! ↓ 不可变 → 放心共享 → 省内存!生动理解共享不可变像共享同一张海报 大家要Hello这张海报 → 反正没人能改它 → 一张海报大家共享看!(省地方) ↓ 可变的话: 怕别人涂改,每人得自己一张 → 浪费! 第六幕好处汇总——权衡的智慧不可变的完整好处清单✅ 不可变换来的好处 ① 安全性: 传出去不怕被改 ② 可预测: 没有意外的连锁改动 ③ 线程安全: 天然支持多线程读,免锁 ④ 哈希缓存: 完美作字典键 ⑤ 内存共享: 相同字符串可驻留复用 ⑥ 简化代码: 不用防御性拷贝 ↓ 这一堆好处 vs 拼接产生垃圾的代价 → 总体划算!设计权衡的本质【语言设计 权衡】 不可变的代价: 拼接产生垃圾(需StringBuilder应对) 不可变的收益: 安全、线程安全、可缓存、可共享... ↓ 设计者判断: 收益 代价 而且代价可以用工具缓解(StringBuilder) ↓ 所以选择不可变!生动理解权衡不可变的设计像用小麻烦换大安心 小麻烦: 拼接要多想想(用StringBuilder) 大安心: 到处传字符串不怕被改 多线程放心用 当key放心用 相同的省内存 ↓ 小代价换大安全 → 划算! 第七幕其他语言的印证不止 C# 这么设计✅ 很多主流语言字符串也不可变 - Java: String不可变 - Python: str不可变 - JavaScript: 字符串不可变 - C#: string不可变 ↓ 这么多语言都选不可变 → 说明这是被验证的好设计!都提供可变的补充方案✅ 它们都提供可变版本应对拼接 - C#: StringBuilder - Java: StringBuilder / StringBuffer - Python: 用list再join,或io.StringIO ↓ 设计模式: 默认不可变(安全) 提供可变工具(应对拼接) → 两全其美!生动理解行业共识这么多语言都选不可变 像大家都用右手写字 不是巧合,是长期实践的共识: 不可变的好处太实在! 拼接问题有StringBuilder兜底! ↓ 被广泛验证的正确设计!✅ 理解检查清单理解可变的问题 □ 明白可变会导致意外的连锁改动 □ 明白共享可变混乱 理解不可变的好处 □ 安全性:传出去不怕被改⭐ □ 可预测:无意外连锁 □ 线程安全:多线程读免锁⭐ □ 哈希缓存:完美当字典键⭐ □ 内存共享:相同字符串可驻留⭐ □ 简化代码:不用防御性拷贝 理解权衡 □ 明白小代价换大好处的权衡 □ 明白StringBuilder是配套解法 □ 知道多语言都这么设计 一句话总结字符串为什么要设计成不可变表面看不可变带来了拼接产生垃圾的麻烦但它换来了一大堆实实在在的好处这是深思熟虑的权衡。核心好处① 安全性——字符串传给任何函数都不怕被偷偷改不用防御性拷贝② 可预测——没有改了 a 结果 b 也变的意外连锁③ 天然线程安全——多线程读同一字符串不会冲突不用加锁④ 哈希可缓存——内容不变哈希就不变完美作字典键⑤ 内存共享驻留——相同内容的字符串可以共享同一份内存省内存。如果可变会怎样共享 可变 到处是意外的连锁改动、多线程要加锁、当字典键会错乱、不敢共享浪费内存——一片混乱这是用小代价换大好处的权衡拼接的垃圾问题可以用 StringBuilder 兜底而安全、线程安全、可缓存、可共享的收益远大于代价。Java、Python、JavaScript 也都这么设计是被广泛验证的正确选择核心口诀不可变虽拼接造垃圾但换来安全线程安全可缓存可共享可变会导致连锁改动加锁错乱浪费内存小代价换大好处StringBuilder兜底多语言共识 不可变好处速查表好处说明若可变的问题安全性传出去不怕被改函数可能偷改可预测无意外连锁改动改a结果b也变线程安全多线程读免锁数据竞争要加锁哈希缓存完美当字典键改了内容找不到内存共享相同串可驻留不敢共享浪费内存简化代码不用防御拷贝处处要防着被改 一句话记住核心不可变 用拼接产生垃圾的小代价换安全、线程安全、可缓存、可共享的大好处。可变会导致连锁改动、加锁、字典错乱、内存浪费——一片混乱。拼接问题有 StringBuilder 兜底所以这笔账划算——Java、Python 也都这么选 延伸从字符串看不可变的编程思想【不可变是现代编程的重要思想】 字符串不可变只是不可变思想的一个应用: 不可变(Immutable)思想在扩大: - 函数式编程推崇不可变数据 - C#的record类型(不可变数据) - readonly struct - 不可变集合(ImmutableList等) ↓ 为什么现代越来越推崇不可变? 同样的理由: ① 更安全(不怕被改) ② 更易推理(状态确定) ③ 天然并发友好(免锁) ④ 更少bug(无意外副作用) ↓ 字符串不可变 → 是这个大思想的经典案例! ↓ 理解它 → 理解现代编程的一个重要方向!