
接手一个维护了三年的iOS项目你最想吐槽的往往不是架构本身而是散落在代码各个角落的魔法字符串和魔法数字。同一个key四处复制隔三差五手滑拼错改一处漏三处。这时候大家才会想起来Swift里有个再基础不过的东西——常量用好了能省下大量无谓的调试时间。但常量这个话题搁在Swift里远不止“let xxx 1”这么简单。它牵扯到编译期求值、类型推断、并发安全、静态分发甚至能影响App启动性能。这篇就掰开揉碎聊一聊Swift常量的正确打开方式我尽量把原理讲透把实践方案给全适合刚入门的iOS开发者也适合写过两年Swift但一直没系统梳理过的朋友。1. 从一道编译错误说起Swift常量到底是什么1.1 常量不是“不能变的变量”那么简单很多教材喜欢把let翻译成“常量”然后跟“变量”var做对比说前者赋值后不可修改。这个解释不能算错但放在Swift语境下它掩盖了一个更关键的事实let声明的是一种绑定关系而不是一个具体值。什么叫绑定关系你可以把let理解成一根绳子把名字和某个值系在一起。这根绳子一旦系上就不能再改绑到别的值上。但这里有个重要的坑如果被绑定的值本身是引用类型比如一个类的实例那么名字不能换绑不代表这个实例的内部状态不能变。class Counter { var count 0 } let counter Counter() counter.count 1 // 这是合法的很多Swift新手在这里栽过跟头觉得既然let声明了常量counter.count应该也不能改。其实Swift的let约束的是“名字能不能重新赋值”不是“对象内部数据能不能修改”。所以准确说let命名的是一个不可重新绑定的引用。这个认知直接影响你对后续所有常量和并发问题的理解。那var和let到底怎么选我的经验是三个字默认let。能写成let就不要用var编译器不会骗你它会帮你检查这个值到底有没有被改写。如果编译器没有报错而且逻辑上没有改写的需要写let是更安全的选择。这背后还有一层编译器优化的逻辑后面讲编译期求值的时候细说。1.2 声明常量的两种姿势与类型推断细节Swift里声明常量最常见的写法当然是let加初始化值let maxRetryCount 3 let appName SwiftHub这里的类型是编译器帮你推断出来的Int和String。你也可以显式标注类型let maxRetryCount: Int 3 let appName: String SwiftHub显式标注的好处是代码意图更清楚尤其在结合字面量协议的时候能避免一些推断上的意外。比如let value 1默认推断成Int但如果你需要UInt8不显式标注的话后面拿去做参数可能就得来回转换。日常开发里我倾向在类型可能被误读、或者API边界处显式标注其余内部临时量交给推断。但真正的细节藏在一个容易被忽略的语法现象里常量声明和一次性初始化是不同的。下面的代码你能看出问题吗let answer: Int if condition { answer 42 } else { answer 0 } print(answer)这其实是合法的Swift代码。常量的值不需要在声明那一行就确定只要编译器能确认它在首次使用前恰好被赋值一次这条路就走得通。Swift用一种叫“确定初始化”的静态分析手段来保证安全性。这跟Java里“final变量必须在使用前赋值”的思想很接近但Swift的分析更细它能理解分支结构里的所有路径。这个特性在处理复杂条件初始化的时候很舒服因为你不再需要用var先给个占位值了。不过要提醒一句这种写法只在所有分支都覆盖的情况下成立。如果有一条路径没赋值编译器会直接报错这是好事它把潜在漏洞挡在了编译阶段。1.3 热词里的“表达式必须含有常量值”到底指什么网上有个热搜词叫“表达式必须含有常量值”这句话实际上是Swift编译器在某些场景下给出的错误提示很多人在写default参数值、属性包装器、或者某些泛型约束的时候会遇到。示例是这样的struct Config { // 这是错的表达 let timeout: Int 30 } func fetchData(timeout: Int Config.timeout) { // Error? }真正报“expression must be a constant value”这类错误的往往是你想在属性包装或者协议约束里使用一个由计算属性、实例属性派生出来的值。编译器需要的是一个编译期就能确定的值而常量的某些用法看起来是常量实际却不够“常”。后面第2章会把这个概念彻底拆开。2. 常量与编译器的那点默契字面量、编译期求值与性能2.1 let的编译期求值到底能走多远一个非常重要但很多人不清楚的点是Swift里的let并不保证编译期求值它只是保证「不可变绑定」。你写let x calculateSomething()x的确是常量但calculateSomething()仍然是在运行时才执行完的x的值运行时才知道。那什么情况下Swift会真正在编译期把值算出来答案是当右侧是一个字面量或者由字面量组成的常量表达式并且编译器认为可以做常量折叠时。举个例子let secondsPerHour 60 * 60这里60 * 60会被编译器在编译期折叠成3600运行时根本没有乘法指令。像这种优化属于LLVM编译器的常量折叠能力不止Swift有C、C、Objective-C都有类似机制。但要注意如果表达式里掺杂了函数调用比如let value compute()编译器原则上就不再折叠了除非compute是一个inline(__always)且内部全是字面量运算的极简函数还能被SIL分析出纯函数特征但这种情况比较少见。所以写let不代表白嫖编译期算力想获得编译期求值得把表达式控制在“纯字面量、无副作用”的范围内。2.2 字符串常量、String与“转换为字符串常量”的胡思乱想热搜词里有一组“转换为住字符串常量matlab”我就不深究这原始搜索词了但它让我想起Swift字符串常量里一个值得展开的话题字符串字面量到底如何变成String对象。你在Swift里写let key userName编译器处理这个字面量的机制是通过ExpressibleByStringLiteral协议。String遵守这个协议所以你才能用双引号直接生成一个字符串。这也意味着你完全可以给自己定义的类型也加上这个协议让这个Type也能用字符串字面量初始化struct UserID: ExpressibleByStringLiteral { let rawValue: String init(stringLiteral value: String) { rawValue value } } let uid: UserID u_12345这种能力在Swift里叫“字面量协议”它让自定义类型在书写体验上非常接近原生类型。但这里有个隐患如果你把自定义类型用在性能敏感路径上每一次字面量初始化都意味着一次字符串拷贝或引用计数操作。字符串常量本身在Swift中通常被实现为静态存储的UTF-8视图但你自己的包装类型可能会增加额外开销。实测下来String在Swift里的存储很有意思短字符串会被内联存在栈上超过一定长度才在堆上分配。所以大量使用短字符串字面量做常量并不会有特别夸张的堆分配压力。但如果你把字符串常量截断、拼接、插值情况就变了。插值\(appName)-2024会在运行时创建新字符串而不是复用某个静态字面量。这在热路径上会产生可以测量的开销。所以当你在设计一个缓存Key、通知名、本地化Key系统时不要天真地以为String常量没有成本它的比较成本怎么说也是O(n)起步。对于高频比较的字符串常量更务实的方式是换成枚举或者RawRepresentable的struct比较时用底层整数类型。这一点在后文的实战案例里会再看到。2.3 与Java、MATLAB横向对比一下编译期常量热搜词里出现了“java编译期计算常量”正好可以做个横向对比。Java里有一个static final常量如果它是基本类型或String类型并且是编译期常量表达式那么在使用它的地方会直接被替换成字面量这叫常量内联。因此Java的常量在跨类使用时具备很强的优化效果。Swift里的let没有完全等同的规范级承诺。Swift的let被定义成一种不可变存储属性主要语义是保证不可变编译器可以自行决定是否内联优化并不保证每一次都内联。所以你说“let等于Java static final”是不严谨的。不过Swift在普通结构体场景下静态存储属性如果初始化为字面量也会被LLVM做掉大部分优化。差异主要体现在两种语言的语义契约上Java明确告诉你可以把static final当作编译期宏替换Swift更强调“值绑定”的语义稳定。这在写跨语言代码时很有指导意义你不能指望let一定换来性能提升它的第一价值是安全和清晰性能收益只是副产品。MATLAB那边字符串常量的问题完全不在一个量级。MATLAB的字符串如果你写了一个字符串字面量它运行时可能还涉及到字符数组的内存管理。MATLAB里没有Swift这种强类型let概念。所以“转换为字符串常量matlab”这种搜索更多可能是想解决MATLAB代码里字符串拼接性能问题跟Swift没关系但核心教训是一样的能提前确定的字符串最好在编译期或初始化阶段就处理好不要运行时反复构建。3. 常量在并发安全中的价值let为什么能当“护身符”3.1 值类型与引用类型的并发差异Swift里let做并发安全这个事得分类型讨论。前面说了let只是“不能重新绑定”如果绑的是引用类型对象内部仍然可能被多个线程改得乱七八糟。但如果绑的是值类型比如struct、enum、Int、StringString虽然在底层有引用存储但语义上是值类型那么整个值的生命周期里它是不可通过这个名字修改的。这意味着只要你只拿着这个let读它就不会因为写操作产生数据竞争。这个差异本质上就是Swift大力推行值类型的底层逻辑。值类型加let几乎等于把并发风险关进了笼子。你不需要加锁不需要队列保护因为根本没有写入口。而引用类型加let只保证了指针本身不变没保证指向的屋子不乱。那为什么很多并发问题还是会在let环境下出现你看看下面的场景class HTTPClient { let session URLSession.shared } let client HTTPClient()client是letsession也是let但这俩都是引用类型。URLSession内部会不会被多线程使用会。只不过URLSession本身被设计为线程安全苹果内部处理好了。换成你自己写的类let就保护不了内部状态了。所以常量提供的并发安全是“引用层面”的不是“对象内部”的。明白这一点你再去设计并发代码就会把可变状态往值类型里赶把稳定配置放到let里。这是Swift并发安全的一个很实用的心法。3.2 用let缩小可变状态写出更稳的并发代码并发编程最怕的就是共享可变状态。反过来如果你把尽可能多的数据声明为let就等于主动缩小了可变状态的面积。听起来像废话做起来却很难因为很多人习惯了var打天下改需求时随手把let换成var直到哪天线上崩了才回头纠结。我总结了一个实操习惯写一个类或结构体时先问一句“这个属性创建后会不会变”不会变一律let。只有确实需要变化的才用var。如果var多到一个类里超过一两个我就会怀疑这个类的设计是否合理。你想想一个类如果有一堆var它内部的状态变化路径就会非常多多线程环境下去找bug找得你头大。更进一步的做法是在并发场景下让let持有闭包、锁、actor的引用。比如actor Cache { private let maxSize: Int private var storage: [String: Data] [:] init(maxSize: Int) { self.maxSize maxSize } func set(_ data: Data, for key: String) { // actor隔离保证storage安全 } }这里maxSize是let是稳定配置storage是var但被actor隔离。这种组合是Swift并发时代的推荐姿势。你不需要每个属性都是不变量但你必须让常量承担“稳定配置”的角色让变量只出现在必须可变的地方。3.3 static let单例模式的实战注意事项在Swift里实现单例业界最常见的写法就是final class AppSettings { static let shared AppSettings() private init() {} }这个写法之所以好就是因为Swift保证static let是懒初始化的只在第一次访问该属性时才创建实例同时它的初始化过程是线程安全的。你不需要加dispatch_once不需要手动加锁编译器在底层帮你做了保证。很多人从OC转过来还习惯写各种加锁单例其实在Swift里根本没这个必要。但有几个细节还是要提醒第一static let初始化是线程安全的这条保证依赖于你不在init里触发对自身的递归访问。你要是写出static let shared Foo(value: shared.value)这种代码死锁或者异常是意料之中的。第二单例作为引用类型它的属性可变性依然存在。如果你在单例内部放了一堆var属性外部多线程访问时依然会踩雷。所以单例本身不是并发安全魔法它只是解决了“创建过程”的安全问题。真正要安全还是得配合actor、派发队列或者不可变属性。第三不要动不动就用static let造全局可访问的单例。单例模式本质上是共享可变状态的温床用得越多依赖越乱。SwiftUI环境对象、actor、依赖注入都能替代一部分单例需求。我的原则是能传就传别全局。4. 实战案例一个缓存Key管理器的设计与重构4.1 最初版本到处飘的字符串字面量拿我自己负责的一个播放器App里的缓存模块说吧。最早版本里UserDefaults的key是这样满天飞的UserDefaults.standard.set(true, forKey: hasSeenGuide) UserDefaults.standard.set(v2.1, forKey: lastVersion) UserDefaults.standard.set(123, forKey: launchCount)这种代码最要命的地方在于字符串字面量散布在十几个文件里想统一改个key前缀都得全局搜索。更坑的是如果两处key拼写不一致比如一处是lastVersion另一处是lastversion你半天都查不出问题因为编译器不会帮你检查字符串拼写。常量在这种场景下的价值正是把魔法字符串抽成有名字的、编译期可检查的符号。4.2 第一次重构用static let把key集中管理第一次重构比较简单我把所有key集中到一个枚举命名空间下enum UserDefaultsKey { static let hasSeenGuide hasSeenGuide static let lastVersion lastVersion static let launchCount launchCount }注意这里我用的是enum加static let没有用struct加static let也没有用case。为什么因为enum没有实例天然就不能被初始化非常适合做纯命名空间。这种做法既避免了case和String之间的转换也不需要实现RawRepresentable轻量干净。使用的时候就变成了UserDefaults.standard.set(true, forKey: UserDefaultsKey.hasSeenGuide)好处显而易见所有key都有唯一的管理出处拼写错误不会发生在调用方因为编译器会检查符号拼写。以后想统一修改前缀只需要在这个文件里改一次。这个模式我建议所有项目都立刻用起来成本极低收益立竿见影。4.3 更进一步泛型Key与性能权衡字符串常量集中之后我又遇到了新的问题很多配置项需要做类型转换。比如存Int的做读取时要as? Int存Bool的要as? Bool容易出现类型不匹配。于是我把Key系统升级成泛型版本struct DefaultsKeyValue { let name: String } extension DefaultsKey { static var hasSeenGuide: DefaultsKeyBool { .init(name: hasSeenGuide) } static var lastVersion: DefaultsKeyString { .init(name: lastVersion) } static var launchCount: DefaultsKeyInt { .init(name: launchCount) } }配合泛型读写方法extension UserDefaults { func setValue(_ value: Value, for key: DefaultsKeyValue) { set(value, forKey: key.name) } func valueValue(for key: DefaultsKeyValue) - Value? { object(forKey: key.name) as? Value } }这样调用方既不需要手写魔法字符串也不需要反复做as?类型转换。类型安全、集中管理、调用简洁三样全占。代价是什么每次读取都会生成实例属性访问的中间量嘛。但因为每次访问其实都是String的静态存储成本可以接受。如果你特别极端追求性能可以把DefaultsKey定义成struct并加上inline(__always)之类的优化标记不过一般App根本不需要走到这一步。这里要特别强调一点我不建议在key系统里直接把枚举的rawValue拿来当哈希Key而不是字符串除非底层存储要求你只存整数。UserDefaults本身以字符串为主用泛型Key的字符串名是自然选择。如果后续缓存转到数据库泛型Key的设计依然能平滑迁移只换底层存储不改调用方。5. 常见错误与排查技巧实录5.1 “表达式必须含有常量值”的排查思路前面热搜词里的“表达式必须含有常量值”我接触到的真实场景往往出现在属性包装器里。例如你写了个属性包装器用来设置默认值propertyWrapper struct Trimmed { private var value: String var wrappedValue: String { get { value } set { value newValue.trimmingCharacters(in: .whitespacesAndNewlines) } } init(wrappedValue: String) { self.wrappedValue wrappedValue } } struct Form { Trimmed var name Swift }这种写法本身没问题但如果你想在init的默认参数里用某个包装器支持的表达式就会出现“无法用非常量表达式初始化”之类的提示。排查思路就一个把需要编译期确定的表达式拆成字面量或在init里计算别指望属性包装器的存储能折叠成编译期常量。遇到这类报错先确认是不是在静态上下文中引用了实例属性或计算属性再看是不是字面量协议实现里做了运行时转换。大部分时候把复杂的常量表达式提前算好、写成字面量问题就消失了。5.2 为什么我改了常量没生效静态分发的坑有次项目里改了某个全局常量结果运行起来数值完全没变化排查了大半天最后发现是编译优化在作怪。全局let常量如果被定义在某个大模块里同时开启了Release模式优化引用方可能已经把值内联扩散了。你改了常量的定义但某些模块没有重新编译旧的常量值就一直被内联在二进制里。解决方案是clean build一下让全量重新编译。这个坑在大型项目里特别容易踩尤其是依赖了预编译框架的。另外Swift里的全局let未必是一种好设计。全局变量哪怕是不可变的let在某些场景下也可能带来初始化顺序的问题比如两个全局常量互相引用。能用enum static let就尽量不要裸let飘在顶层。这样既有命名空间兜底也便于模块隔离。5.3 let与lazy、计算属性到底怎么选很多朋友分不清let、lazy var、var计算属性的适用场景。我列个速查表场景选择原因值不变且初始化成本低let语义清晰天然线程安全值计算成本高但首次访问后不变lazy var延迟到首次访问省启动时间值每次需要动态计算计算属性每次访问重新计算不存储初始化依赖外部环境、且只需一次通过init赋值的let既保持不可变又允许运行时确定值特别注意lazy var是线程不安全的。多线程同时访问一个还没有被初始化的lazy varSwift不保证它的初始化和读取是原子的。如果你在并发路径上用lazy var最好提前在单线程环境触发一次或者改用static let模式。这是一个很多书上没写明、但是实测会炸的坑。计算属性也要警惕很多人误以为var value: Int { 42 }和let value 42没区别。当然有区别计算属性每次访问都要走getter即便getter只返回常量它在最优情况下也可能被内联掉但语义上它是“可读可计算”的不能保证编译期折叠更不具备存储一致性。如果你想要真正的常量就用let不要用计算属性伪装。5.4 用枚举命名空间组织常量的小技巧最后分享一个实际最有用的技巧用枚举组织常量但不局限在字符串key。像通知名、动画时长、ViewController重用标识、网络请求的HTTP配置都可以集中管理。enum AppConstants { enum Animation { static let short 0.2 static let medium 0.35 static let long 0.55 } enum NotificationName { static let userDidLogin userDidLogin static let userDidLogout userDidLogout } enum HTTP { static let timeoutInterval: TimeInterval 30 static let maxRetryCount 3 static let defaultPageSize 20 } }这种做法让常量带着“归属”一看就知道属于哪个模块。以后UI调整时间曲线只需要动Animation一段。配合自动补全写代码的时候能直接补出常量名比手敲字符串安全太多。有一点要注意不能把常量组织做成另一个“上帝类”把几千个常量堆在一个文件里。应该按业务模块分成多个enum文件。否则这个集中管理又会变成新的混乱源头。保持“聚合有界”才是这个模式的精髓。我在实际项目里最受益的一个习惯是每次新写一个存储到UserDefaults或数据库的字段时第一反应就是打开对应的常量文件加一个定义而不是直接在调用处顺手写字符串。坚持一段时间后你会发现搜索全局字符串的次数急剧减少review代码时也很少再揪出类型错配和拼写类问题。常量这个东西表面看是个不起眼的基础语法但它真正考验的是你对“可变性边界”的直觉。把不变量隔离出来、把可变性缩小到必要范围这既是对编译器的友善也是对未来维护者的善意。希望这篇能帮你把let用得更有章法。