乐于分享
好东西不私藏

【iOS开发By李】struct方法改自己 为什么要加mutating

【iOS开发By李】struct方法改自己 为什么要加mutating

📌 开头先说结论

写 struct 时最常撞到的报错:
error: cannot use mutating member on immutable variable: 'self' is immutable
self.name = "李" 明明很正常,编译器为什么不让你改?答案藏在 Swift 的值语义底层。
本文在基础之上,继续深挖 8 个进阶层面:
  1. inout 的真身是 copy-in / copy-out,不是简单"传指针"

  2. mutating 方法会触发 willSet / didSet

  3. 下标 subscript 也能 mutating

  4. nonmutating set:一个"不修改自己"的 setter

  5. enum 的状态机写法

  6. mutating 与 COW(写时复制) 的联动

  7. protocol 里的 mutating 会让 class "偷渡"绕过 let

  8. Swift 5/6 的独占访问(Exclusivity):mutating 会"霸占"整个 self


一、值 vs 引用:一切的起点

classPersonClass{ var name = ”小李”}let p = PersonClass()p.name = ”大王” // ✅ class 是引用类型,let 只锁引用,属性随便改struct PersonStruct { var name = ”小李”}let p2 = PersonStruct()p2.name = ”大王” // ❌ struct 是值类型,let 锁整个对象,属性也不能动
📌关键:let 对 class 锁的是"指针地址",对 struct 锁的是"整个值"。这是 mutating 存在的根本原因——值类型要在方法里改自己,必须显式告诉编译器"我会改这个值"。

二、mutating 是什么?

struct Counter { var count = 0 mutating func increment() { count += 1 } // 不加 mutating 编译报错}var counter = Counter()counter.increment() // ✅
class 不需要 mutating,因为 class 是引用类型,方法里的 self 是指向堆对象的指针,改属性只是"通过指针改内存",指针本身没变:
classCounterClass{ var count = 0 func increment() { count += 1 } // 不需要 mutating}
📌深度点:mutating 的本质是给方法加上"我会修改 self 的值语义"的契约。编译器据此决定能否在 let 上调用、以及传参方式(见下一节)。

三、底层真相:inout 是 copy-in / copy-out,不是传指针

加了 mutating 的方法,编译器会把它变成inout Self参数
// 你写的:struct Counter { var count: Int mutating func increment() { count += 1 }}// 编译器大致生成:struct Counter { var count: Int func increment(_ selfinout Counter) { self.count = self.count + 1 }}
但 inout 的语义模型不是"C 指针",而是copy-in / copy-out(拷入 / 拷出)
调用 counter.increment() 时:1. 把 counter 当前的值【拷入】函数内部的 self2. 函数在 self 的副本上做所有修改3. 函数返回时,把修改后的 self【拷出】写回原变量 counter
📌深度点(重要)
大多数情况下优化器会把 inout 直接优化成指针传递,没有真实拷贝;
但当参数不是直接变量时(比如 self 是通过计算属性、属性观察器、下标、或类的属性间接访问的),编译器无法拿到稳定地址,就会真的做一次 load + store——这就是后续"大 struct 隐藏拷贝成本"的来源。
因为拷出发生在函数返回时,方法内部对 self 的修改在返回前不会反映到原变量(在单纯变量场景里你感知不到,但在间接存储场景会暴露差异)。

四、mutating 会触发 willSet / didSet

如果存储属性带属性观察器,在 mutating 方法里给它赋值,观察器会照常触发
struct User { var name: String { willSet { print(”willSet -> \(newValue)”) } didSet { print(”didSet <- \(oldValue)”) } } init(nameString) { self.name = name } // init 里赋值不触发观察器 mutating func rename(to newNameString) { name = newName // ✅ 这里赋值会触发 willSet + didSet }}var u = User(name: ”小李”)u.rename(to: ”大王”)// 输出:// willSet -> 大王// didSet <- 小李
📌深度点:init 里赋值不触发观察器(初始化阶段对象尚未完整),但 mutating 方法里的赋值会触发——这是很多人踩的坑,尤其在做 KVO 风格的副作用逻辑时。

五、mutating subscript:下标也能"改自己"

subscript 同样可以标 mutating,用于"通过下标修改集合内部状态":
struct Grid { private var cells: [Int] let size: Int init(sizeInt) { self.size = size self.cells = Array(repeating: 0, count: size * size) } mutating subscript(rowIntcolInt) -> Int { get { cells[row * size + col] } set { cells[row * size + col] = newValue } // setter 需要 mutating }}var g = Grid(size: 3)g[11= 9 // ✅ 通过 mutating subscript 改自己print(g[11]) // 9
📌深度点:subscript 的 setter 默认要求 mutating(因为它改了 struct 内部存储)。如果你在 let 常量上用下标赋值,照样报错——和 mutating 方法同一个道理。

六、nonmutating set:一个"不修改自己"的 setter

这是极少被讲到的高级用法。当计算属性的 setter并不修改 self,而是修改 self 所引用的外部对象时,可以标 nonmutating,从而让该属性在不可变上下文也能赋值:
class Backing { var value = 0}struct Wrapper { private var backing: Backing init(_ backingBacking) { self.backing = backing } var value: Int { get { backing.value } nonmutating set { backing.value = newValue } // 没改 struct,改的是 backing 对象 }}let w = Wrapper(Backing()) // w 是 letw.value = 42 // ✅ 因为 setter 是 nonmutating,let 也能调用print(w.value) // 42
📌深度点:nonmutating 告诉编译器"这个 setter 不会动 self 的值语义,只是动了 self 指向的外部东西"。前提是你确实没改 struct 自身——如果你在里面改了 struct 的存储属性,却不标 mutating,编译器会报错。典型应用场景:struct 包装一个 class(如 Core Data 对象、监听器等),对外暴露可写的"代理属性"。

七、enum 里的 mutating:状态机写法

enum 也是值类型,同样遵守 mutating 规则。它最适合写状态机
enumNetworkState{ case idle case loading case success(data: Data) case failed(Error) mutating func transition(to next: NetworkState) { self = next // ✅ mutating 里可以给 self 整体赋值(重新构造) }}var state = NetworkState.idlestate.transition(to: .loading)state.transition(to: .success(dataData()))
📌深度点:mutating 方法里给 self 整体赋值是合法的——因为 enum 的"改自己"本质上是"换一个 case",等价于重新构造整个值。这也是 enum 实现状态流转最干净的方式。

八、mutating 与 COW(写时复制)的联动

之前《Array 为什么大数组拷贝不慢》讲过 COW。这里补一层联动:COW 的"写时复制"判断,正是在 mutating 方法修改内部存储属性那一刻发生的
struct MyBuffer { private var _storage: ManagedBuffer // 内部是引用类型缓冲区 mutating func append(_ xInt) { // 当 _storage 被修改时,COW 检查 isKnownUniquelyReferenced // 若有其他副本共享,先复制缓冲区,再写入 _storage.append(x) }}
📌深度点:mutating 是 COW 的"触发器"——只有当你用一个 mutating 操作试图修改内部引用缓冲时,才会做"是否独占"的检查并决定是否复制。如果你只用非 mutating 的只读方法访问,永远不会触发复制。这就是为什么"值类型 + 内部引用 + mutating 才复制"能兼具值语义安全和性能。

九、protocol 里的 mutating:class 的"偷渡"陷阱

protocol 声明方法时,若想让 struct 也能实现,必须写 mutating:
protocol Resettable { mutating func reset()}struct SResettable { var count = 0 mutating func reset() { count = 0 } // struct 必须 mutating}class CResettable { var count = 0 func reset() { count = 0 } // class 可省略 mutating}
但这是个隐藏陷阱:当 class 实现 mutating 协议要求时,因为 class 是引用类型,let 常量也能调用这个"mutating"方法:
let cResettable = C() // c 是 letc.reset() // ✅ 居然能调用!因为 C 是 class,reset 不会改引用
更细思极恐的是存在型(existential):
let sany Resettable = S(count5// 值类型包进存在型// s.reset() // ❌ 报错:s 是 let,值类型不能调 mutatinglet cany Resettable = C() // 引用类型包进存在型c.reset() // ✅ 通过:class 不受 let 限制
📌深度点:mutating 在 protocol 里是"值类型需要"的契约,但对 class 而言它是空约束。结果就是:同一个mutating协议方法,作用在值类型上受let限制,作用在引用类型上能绕过let。设计 API 时若希望"无论实现类型,调用方都明确知道会改状态",要意识到 class 实现会悄悄打破值语义的不可变预期。

十、Swift 5/6 独占访问:mutating 会"霸占"整个 self

Swift 会强制独占访问(Exclusivity):当一个 mutating 方法运行期间,它对 self 持有独占的可写访问,期间不允许其他访问重叠。
最经典的翻车写法:
struct Cell { var value = 0 mutating func increment(by otherInt) { value += other }}var c = Cell()c.increment(by: c.value)// ❌ Error: overlapping accesses to 'c', but modification requires exclusive access
为什么会报错?c.increment(by: c.value) 同时做了两件事:
把c作为inout self传入(mutating,需要独占写访问)
在传参时又读取c.value(需要读访问)
读和写在同一时刻重叠在 c 上,Swift 的独占访问规则直接拒绝。
更底层的 inout 别名冲突:
func swap(_ ainout Int_ binout Int) {}var x = 1swap(&x, &x) // ❌ Error: inout arguments are not allowed to alias each other
📌深度点:独占访问是 Swift 内存安全的基石之一。它保证了"mutating 方法执行时,没有人能同时读到半修改状态的中间值"。这条规则在 Swift 4.2 默认开启(调试期),Swift 5 起更严格。理解它能帮你避免一类"看似无害、实则数据竞争"的 bug。

十一、性能:大 struct 调 mutating 的隐藏拷贝成本

回到第三节的 copy-in/copy-out。对小 struct + 直接变量存储,优化器会把 inout 变成指针,零拷贝。但以下情况会触发真实拷贝
classHolder{ var point = Point(x0, y0// struct 作为类的属性存储}// 当 Point 是拥有上千字段的大 struct,且通过类的属性间接访问:holder.point.move(dx1, dy1)// 真实发生:load 整个 point → 改 → store 整个 point 回 holder// 上千字段 = 上千次内存搬运,每次 mutating 方法调用都来一遍
📌实战建议
小 struct(几个字段)随便用 mutating,性能无感;
大 struct 若频繁作为类的属性被 mutating 修改,考虑拆小、或改用 class、或用 COW 容器(Array/Dictionary/String 自带 COW,无此问题);
多数业务里 struct 字段不多,不必过早优化,但要知道"mutating 不是零成本"的边界在哪。

十二、常见坑 & 反模式

❌ 坑 1:let 上调用 mutating

let p = Point(x0, y0)p.move(dx1, dy1// ❌ 改用 var

❌ 坑 2:闭包里调 mutating self(真实报错)

struct S { var count = 0 mutating func makeClosure() -> () -> Void { return { self.increment() // ❌ Error: escaping closure captures mutating 'self' parameter } } mutating func increment() { count += 1 }}
原因:mutating 方法的 self 是 inout Self,inout 参数不能被逃逸闭包捕获解法:用局部副本、把类型改成 class、或把闭包标 @noescape 且不逃逸。

❌ 坑 3:mutating 里给 self 整体赋值

struct Rectangle { var width = 0, height = 0 mutating func scale(by fInt) { self = Rectangle(width: width * f, height: height * f) // ✅ 合法,重新构造 }}

❌ 坑 4:误以为 class 里写 mutating 有意义

classFoo{ var x = 0 mutating func bar() { x += 1 } // ⚠️ 能编译,但 mutating 对 class 无意义}
class 天然可变,mutating 被编译器忽略,写出来反而误导读者——别在 class 里写 mutating

十三、面试高频追问

Q1:为什么 struct 要 mutating,class 不要?
struct 是值类型,let 锁整个值;mutating 通过 inout 让方法"借到可写副本"
class 是引用类型,self 是指针,改属性不动指针,天然可变
Q2:inout 的真身是什么?
语义是 copy-in / copy-out:拷入函数、返回时拷出写回
直接变量场景下优化为指针,零拷贝;间接存储场景会真实 load/store
Q3:mutating 会触发 willSet/didSet 吗?
会。mutating 方法里给带观察器的属性赋值会触发;但 init 里赋值不触发
Q4:协议里的 mutating 对 class 实现意味着什么?
class 实现时可省略 mutating,但因为引用语义,let 常量也能调用——这是"偷渡"陷阱
Q5:Swift 独占访问和 mutating 的关系?
mutating 方法运行期间对 self 持有独占访问,期间不允许其他读/写重叠
c.increment(by: c.value) 这类"边读边改自己"会编译报错
Q6:nonmutating set 什么场景用?
计算属性 setter 不改 self、只改 self 引用的外部对象(如 struct 包装 class)时
让 let 常量也能通过该属性写外部状态

💡 终极总结

mutating = 告诉编译器"我会改值语义的 self",底层走 inout(copy-in / copy-out)

class 不需要 mutating,因为引用类型的 self 是指针,改属性不动指针

mutating 方法会触发 willSet/didSet,会对 self 做独占访问,可能触发 COW

protocol 里的 mutating 对 class 是空约束——class 实现会绕过 let,是隐藏陷阱⚡ let 的 struct 调不了 mutating,改用 var

🚫 class 里别写 mutating(无意义)

🔒 闭包不能捕获 mutating self(inout 不能逃逸)

📊 大 struct 作为类属性被频繁 mutating,注意真实拷贝成本

一句话口诀:

struct 改自己要 mutating,本质是 inout 把"值的副本"借给方法改完写回;class 的 self 是指针,所以永远不用 mutating。


📚 更多阅读

[weak vs unowned 终于讲清楚了]
[class vs struct vs enum用一个故事讲透底层]
[Swift高阶函数 map filter reduce 实战讲透]
[Any / AnyObject / AnyHashable:Swift 类型系统的三把钥匙]
[可选值 vs 隐式解包 你真的用对了吗?]
[=== vs ==:身份 vs 相等,你真的分清楚了吗?]
[什么时候闭包要加 @escaping?escape 到底是什么?]
[Array 是 struct,为什么大数组拷贝不慢?COW 原理深度剖析]
[defer 不是 finally:离开作用域那一刻,它就触发了]
[static和class方法 都能继承到底有什么区别]

关注「iOS开发By李」专注 iOS 底层原理 · Swift 进阶技巧 · 易混点对比

💬 *有疑问?评论区见!