15 宏与调试
13 分钟阅读
15 宏与调试
宏:编译期帮你写代码
宏不是运行期的魔法,它是编译器在编译你的代码时,调用另一段代码生成源代码。所以它有几个和函数完全不同的性质:
- 宏在编译期展开,运行期没有任何额外开销;
- 宏的声明和实现是分开的:声明告诉编译器"这个宏叫什么、能长在哪里",实现在一个单独的编译期插件里;
- 宏只能"生成代码",不能"猜"运行期的值——
#stringify(a + b)能拿到"a + b"这段源码,但拿不到a + b的值。
两种形态
| 形态 | 长这样 | 名字风格 | 例子 |
|---|---|---|---|
| 自由宏(freestanding) | #名字(...) | 小驼峰 | #stringify(x)、#function、#warning("...") |
| 附加宏(attached) | @名字 | 大驼峰 | @Observable、@Test、@DebugDescription |
⚠️ 别把老教程里的 @OptionSet 照抄过来:它已经从标准库里移除了,现在写会报 unknown attribute 'OptionSet'。它如今只是 swift-syntax 仓库里的一个示例宏。想要位掩码,老老实实手写 struct X: OptionSet(见 11 标准库)。
📘 官方对宏的完整介绍在 The Swift Programming Language · Macros。
💭 顺带澄清一个常见误会:#file、#line、#function、#warning、#error 这些以 # 开头的东西,官方文档从 Swift 5.9 起把它们算作标准库提供的自由宏(见 01 起步 里那张表)。而 #if、#available、#selector、#keyPath 不是宏,它们是编译器自带的语法。
先用别人写好的宏
不需要自己写,也能立刻享受宏的好处。下面这个用的是 Observation 模块里的 @Observable,实测跑得通:
| |
这几行在编译期被展开成了一大段代码:一个 ObservationRegistrar、两个 access / withMutation 方法,以及每个属性上的 @ObservationTracked。手写这些很容易漏,所以这类"必须保持一致"的样板代码正是宏的主场。
🚧 Foundation 的 #Predicate 也是宏,但它要借助 Xcode 附带的 FoundationMacros 插件。只装 Command Line Tools 的机器上会报 plugin for module 'FoundationMacros' not found——这是环境问题,不是你的代码写错了。
想知道宏到底生成了什么
让编译器把展开结果打出来:
| |
🔥 宏出错时,这条命令几乎是唯一能让你看懂现场的工具:报错位置常常落在展开出来的代码里,而不是你写的那一行。
自己写一个宏
先建骨架,模板里自带一个能跑的 #stringify:
| |
💭 建出来的包里有两处值得先看一眼:Package.swift 里锁着 swift-syntax 的版本(swift-tools-version: 6.4 的模板写的是 from: "604.0.0-latest",实测解析到 604.0.0),以及模板自带的那个 #stringify —— 它就是下面这个例子的原型。swift-syntax 的版本决定了宏实现能用哪套 API 签名,所以它同时也是升级时第一个要看的东西。
一个宏要三份东西,分别住在三个地方:
| 角色 | 放在哪 | 长什么样 |
|---|---|---|
| 声明 | 普通库 target | @freestanding(expression) public macro ... = #externalMacro(module:type:) |
| 实现 | .macro target(编译期插件) | 一个遵守 ExpressionMacro / MemberMacro … 的结构体 |
| 注册 | 插件的 @main 类型 | CompilerPlugin.providingMacros 数组 |
先看一个自由宏。声明部分(放在能被 import 的库里):
| |
实现在 .macro target 里,干的事就是"把表达式和它的源码一起返回":
| |
再补一个附加宏。附加宏是加在类型或成员身上的,角色写在声明前面:
| |
| |
⚠️ 那个 conformingTo protocols: [TypeSyntax] 是新签名的一部分(swift-syntax 600 起)。省掉它、写成老的 expansion(of:providingMembersOf:in:) 也还能编过,但会收到一条警告:
| |
💭 所以升级 swift-syntax 时,这条 #DeprecatedDeclaration 就是最省事的迁移清单:编译器把"该改成哪个签名"直接写在警告里了。
🝖 顺带一说,附加宏的角色比你想象的细:除了上表的 member,还有 @attached(memberAttribute)(给成员加属性,@Observable 给每个属性挂 @ObservationTracked 用的就是它)、@attached(accessor)、@attached(peer)、@attached(extension)、@attached(conformance)。写宏声明时要按"你到底往哪里加东西"挑角色,挑错了编译器会拒绝展开。
最后把两个宏登记进插件,并写一个调用方:
| |
| |
🔥 上面这段是实测跑过的:swift package init --type macro 建包、把两个宏登记进 providingMacros、swift run 之后,输出就是注释里那两行。宏包首次构建要从 swift-syntax 拉一堆源码,慢是正常的。
常见的角色
| 角色 | 加在哪 | 干什么 |
|---|---|---|
@freestanding(expression) | 表达式位置 | 产出一个值,例如 #stringify(x) |
@freestanding(declaration) | 声明位置 | 产出一段声明 |
@attached(peer) | 类型 / 成员旁边 | 在旁边补一个新声明 |
@attached(member) | 类型里面 | 往里加成员(@Observable 加的注册器就在这里) |
@attached(accessor) | 属性上 | 给属性加 get / set |
@attached(extension, conformances:) | 类型上 | 加一个遵守协议的扩展 |
⚠️ 宏不能读取自己声明之外的运行期状态,也不能凭空发明类型:它只能根据你写下的语法节点生成新代码。所以"宏能做什么"的边界很清晰——凡是需要"模板化的样板代码",它都行;凡是需要"运行期才知道的信息",它就不行。
断言家族:什么时候该崩
四种"出事了"的写法,区别只在在哪种构建下仍然生效:
| 写法 | 用于 | -Onone(调试) | -O(发布) | -Ounchecked |
|---|---|---|---|---|
assert(_:_:) | 开发者自检,比如"这个数组不该是空的" | ✅ 生效 | ❌ 被去掉 | ❌ 被去掉 |
assertionFailure(_:) | 走到这里就说明逻辑错了 | ✅ 生效 | ❌ 被去掉 | ⚠️ 还是生效(见下) |
precondition(_:_:) | 调用方的错,比如参数越界 | ✅ 生效 | ✅ 生效(不再打印消息) | ❌ 被去掉 |
preconditionFailure(_:) | 调用方给的组合不可能成立 | ✅ 生效 | ✅ 生效(不再打印消息) | ✅ 生效(不再打印消息) |
fatalError(_:) | 彻底没救了,必须停下 | ✅ 生效 | ✅ 生效 | ✅ 生效 |
⚠️ 第二行那个 -Ounchecked 的 ⚠️ 是整张表里唯一值得背一下的例外,实测数据摆在这里(每一格都是真的跑一遍拿到的,只看退出码 133 就是崩了):
| 表达式 | -Onone | -O | -Ounchecked |
|---|---|---|---|
assert(false, "M") | 崩,Assertion failed: M | 不崩 | 不崩 |
assertionFailure("M") | 崩,Fatal error: M | 不崩 | 崩(退出码 133) |
precondition(false, "M") | 崩,Precondition failed: M | 崩,无消息 | 不崩 |
preconditionFailure("M") | 崩,Fatal error: M | 崩,无消息 | 崩,无消息 |
fatalError("M") | 崩,Fatal error: M | 崩,Fatal error: M | 崩,Fatal error: M |
💭 为什么会歪成这样?翻一眼标准库源码就清楚了:assertionFailure 是"调试配置下报错,快速配置(-Ounchecked)下走 _conditionallyUnreachable()";而 precondition 反过来,是"调试配置下报错,发布配置下把条件交给 Builtin.condfail_message",那个分支在 -Ounchecked 里被当成"不可能发生"优化掉了。所以这三个函数在三种构建下的组合并不是"从弱到强"的一条线——要写"任何构建下都拦得住"的检查,只有 preconditionFailure 和 fatalError 靠得住。
| |
实测的崩溃长这样:
| |
格式固定为 <模块名>/<文件名>.swift:<行号>: <种类>: <消息>。种类只有三种:assert 给 Assertion failed,precondition 给 Precondition failed,assertionFailure 与 fatalError、preconditionFailure 给 Fatal error。文件与行号取自 #fileID / #line。
⚠️ 第三条是最容易写错的:assert 在发布版本里会被整个删掉,所以断言表达式里不要写有副作用的东西——assert(cleanup()) 在 debug 里会清理,在 release 里不会,这种 bug 只在发布版本出现。
⚠️ -Ounchecked 是把安全带剪掉:连数组越界都不再检查。实测 let a = [1, 2, 3]; print(a[5]) 在 -Onone 下报 Fatal error: Index out of range,在 -Ounchecked 下不报错、直接给你一段垃圾数据。它只适合"性能优先级压过一切、且已经压测过"的场景。
💭 选哪个的一句话版本:自己的逻辑错了用 assert,别人传错了用 precondition,世界末日用 fatalError。 想要"文档里写明的、必须成立的契约",就用 precondition——它在 -O 的发布版本里还拦得住(但挡不住 -Ounchecked,那种构建下它和 assert 一样消失;真要绝对拦得住,用 preconditionFailure)。
打印与调试输出
同一个结构体,四种输出方式看到的东西不一样:
| |
同一个值,四种写法打出来的东西并不一样(下面 demo 是模块名,也就是你的 target 名):
| |
先把四种 print 的区别记清楚,就不会再说"print 怎么打不出类型名":
| 写法 | 看什么 | 特点 |
|---|---|---|
print(x) | CustomStringConvertible.description | 面向用户,最漂亮;没实现就退回到反射 |
debugPrint(x) | CustomDebugStringConvertible.debugDescription | 面向调试,会带模块名、给字符串加引号 |
String(reflecting: x) | 和 debugPrint 同一套(debugDescription) | 只是产出字符串而不是打印,想拼日志时用它 |
dump(x) | 反射出来的 Mirror 树 | ⚠️ 唯一走反射的那个,递归展开层级,不看上面两个协议 |
⚠️ 上表第三、四行经常被写成"两者一样",实测并不一样。拿一个嵌套结构体跑一遍:
| |
dump 给的是带 ▿ 的层级树,String(reflecting:) 给的是一整行——它跟 debugPrint 是一家人,只是不直接往 stdout 写。
想让自己的类型输出好看,实现对应协议就行:
| |
💭 小程序里 print 就够;工具链和库代码还是用 os.Logger / swift-log(见 13 工具链与工程),能分级、能过滤、发布版本里也能优雅地关掉。
调试时常用的几件小事
| 想干什么 | 怎么做 |
|---|---|
| 让编译器把宏展开打出来 | swiftc -typecheck -Xfrontend -dump-macro-expansions 文件.swift |
| 让警告直接变成错误 | swiftc -warnings-as-errors,CI 上必备 |
| 只做类型检查(最快) | swiftc -typecheck 文件.swift ⚠️ 少数诊断它看不到,见 13 工具链 |
| 构造一个"绝对不会走到"的分支 | fatalError("分支不该到这里") |
| 主动产生编译警告 / 错误 | #warning("...")、#error("...") |
| 单测里断言并解包 | try #require(...)(Swift Testing)、XCTUnwrap(XCTest) |
属性(@)速查
# 是"编译期的表达式 / 指令",@ 是"贴在声明上的开关"。Swift 的属性不多,一张表能装下:
声明与可用性
| 属性 | 贴在 | 作用 |
|---|---|---|
@main | 类型上 | 指定程序入口(static func main() 或 SwiftUI 的 App) |
@available(...) | 任意声明 | 版本门槛:@available(macOS 13, *)、@available(*, deprecated, message: "…") |
@discardableResult | 函数上 | 忽略返回值时不再报警告 |
@warn_unqualified_access | 成员上 | 不写 self. / 类型名就调用它时警告,专治"同名成员打架" |
@testable import X | import 上 | 让测试代码能看见 internal 成员 |
| |
性能与 ABI
| 属性 | 贴在 | 作用 |
|---|---|---|
@inline(__always) / @inline(never) | 函数上 | 建议内联 / 禁止内联(只是建议,编译器仍可无视) |
@inlinable | public 函数上 | 把实现体一起暴露给调用方,跨模块也能优化 🔥 |
@usableFromInline | internal 声明上 | 给 @inlinable 代码开一扇内部的门 |
@frozen | public 枚举 / 结构体上 | 承诺"以后不再加 case / 改字段",换来调用方更好的优化 |
@backDeployed(before:) | public 函数上 | 新 API 在老系统上也能用:实现被复制进 App |
⚠️ 两个实测会挡路的规矩:
| 你写的 | 报什么 |
|---|---|
给非 public 的枚举加 @frozen | warning: @frozen has no effect on non-public enums(只是没效果,能编过) |
给非 public 的结构体加 @frozen | error: '@frozen' attribute can only be applied to '@usableFromInline', package, or public declarations, but 'S' is internal(直接报错,两者行为不一样) |
给 internal 函数加 @backDeployed | error: '@backDeployed' may not be used on internal declarations |
并发与互操作
| 属性 | 贴在 | 作用 | 详见 |
|---|---|---|---|
@MainActor / @globalActor | 类型 / 函数 / 属性 | 把它钉在某个执行器上 | 09 并发 |
@Sendable | 闭包 / 函数类型 | 允许跨并发域传递 | 09 并发 |
@unchecked Sendable | 类上 | “我保证它是线程安全的”,编译器不再检查 | 09 并发 |
@preconcurrency | import / 协议 / 声明 | 给还没适配并发检查的老代码降一档 | 09 并发 |
@objc / @objcMembers / @nonobjc | 类 / 成员 | 暴露给 Objective-C,或者反过来拦住 | 14 互操作 |
@retroactive | 扩展上 | 声明"这是在给别人的类型补协议",避免重复遵循冲突 | 见下 |
@retroactive 是 Swift 6 新加的礼貌用语。标准库和你的模块都管不着的两个类型要凑一起时,编译器会提醒你"这可能是别人也在做的事",明确写出来就不再抱怨:
| |
扩展语言能力
| 属性 | 贴在 | 作用 | 详见 |
|---|---|---|---|
@propertyWrapper | 类型上 | 做出 @Published、@State 那类东西 | 07 自定义类型 |
@resultBuilder | 类型上 | 做出 SwiftUI 那种"花括号里写列表"的 DSL | 12 语法糖 |
@freestanding / @attached | 宏声明上 | 声明宏的角色 | 本章上文 |
@dynamicMemberLookup | 类型上 | 让 obj.任意名字 走 subscript(dynamicMember:) | 07 自定义类型 |
@dynamicCallable | 类型上 | 让实例能像函数一样被调用 | 🔍 用得上再查 |
@autoclosure / @escaping | 参数上 | 控制求值时机与逃逸 | 05 函数与闭包 |
@Observable / @ObservationIgnored | 类型 / 属性上 | 让类型可被观察;让某个属性不参与观察 | 11 标准库 |
💭 下划线开头的属性(@_spi、@_exported、@_disfavoredOverload、@_specialize)都是非正式 API:它们没有稳定性承诺,工具链升级就可能导致行为变化。写库的时候尽量避开,实在需要就在注释里写明依赖哪个版本。
陷阱速查
| 陷阱 | 说明 |
|---|---|
| 断言里有副作用 | assert 在 -O 下会被删掉,副作用跟着消失 |
用 assert 校验用户输入 | 发布版本里它不在了,用 precondition 或正经的错误处理 |
以为 fatalError 会被优化掉 | 它永远生效,是"我就是不跑了"的意思 |
用 -Ounchecked 换性能 | 越界检查也没了,实测会安静地给你垃圾数据 |
把 @frozen 写在非 public 的类型上 | 枚举只给一句 @frozen has no effect on non-public enums(警告),结构体则是硬错误,见上文 |
照抄老教程里的 @OptionSet | 它已从标准库移除,报 unknown attribute 'OptionSet',改成手写 OptionSet |
以为 @inline(__always) 一定内联 | 它是建议不是命令,跨模块还要配合 @inlinable 才有意义 |
忘了写 @retroactive | 两个"外来"类型凑一起时,编译器会为重复遵循的隐患提醒你 |
把 @objc 那种"有运行期代价"的假设套到宏上 | 宏在编译期就展开完了,运行期没有开销 |
| 自己写宏却忘了注册 | 报 external macro implementation type ... could not be found,检查 providingMacros |
用 Mirror / dump 输出生产日志 | 它们会暴露内部结构,日志请用 Logger |
| 把宏当"万能代码生成器" | 它只能按语法节点生成代码,运行期的值它看不到 |