这个历史可以追溯到如Haskell(按需调用)和Scala(按名调用)等函数式编程语言,其中延迟评估避免了不必要的计算。Swift采纳了这种模式,以在不牺牲性能的情况下,为断言和控制流操作符(&&、||)提供简洁的语法。问题在于,当参数的计算成本高或存在副作用时,急切的评估强制执行无论是否需要。
编译器通过隐式将参数表达式包装在一个零参数的闭包 { expression } 中来转换调用点。这个闭包(惰性计算)将被传递给函数,而不是已评估的结果。当函数体访问参数时,它调用闭包,在那时触发评估。关于ARC,合成的闭包通过引用捕获外部作用域的变量;如果autoclosure被标记为@escaping,则它会在堆中分配闭包上下文,保留任何被捕获的引用类型,并可能延长它们的生命周期超出原来的作用域。
考虑开发一个高频交易分析仪表板,其中调试记录字符串需要对市场数据对象进行大量JSON序列化。问题在于生产构建禁用了调试日志,然而字符串插值 log("Data: \(heavyObject.serialize())") 在每个市场波动上执行,造成不必要的30% CPU消耗。
一个解决方案是传递显式的尾随闭包:log { "Data: \(heavyObject.serialize())" }。这种推迟评估完美解决了问题,但语法使代码库变得杂乱,数量以百计的大括号降低了可读性,并使得grep搜索变得困难。开发者偶尔也会忘记闭包语法,意外回退到急切评估。
另一种方法使用预处理器宏或构建配置完全剥离日志代码。虽然这消除了运行时开销,但它在生产紧急情况下阻止了调试,并且需要单独的二进制构建, complicating CI/CD 管道。
最终选择的解决方案实现了 @autoclosure 结合 @escaping 作为消息参数:func log(_ message: @autoclosure @escaping () -> String)。这个保留了自然的调用语法——与原来的急切版本完全相同——同时保证了延迟执行。@escaping 允许异步调度到后台日志队列,尽管这需要仔细管理捕获列表以避免在图形更新期间将视图控制器保留得比必要的时间更长。
结果减少了生产CPU使用率28%,成功处理50,000个每秒波动。然而,团队发现在消息闭包通过 self.marketData 隐式捕获 self 时出现了保留循环,使视图控制器在导航过渡中保持活动。显式捕获列表 [weak self] 解决了这个问题,但需要lint规则来防止回归。
为什么@autoclosure默认按引用捕获变量而不是按值,这如何导致如果闭包异步执行而导致意外的变更?
默认情况下,Swift中的闭包按引用捕获变量,以保持与标准闭包语义的一致性。当一个 @autoclosure @escaping 参数捕获一个外部作用域的 var,并且函数稍后执行闭包(例如,在后台队列上)时,调用点和执行时间之间对该变量的变更会在闭包中变得可见。这与急切评估不同,在急切评估中,值在调用点被固定。要强制捕获值,必须在捕获列表中显式阴影该变量,例如 [val = variable],尽管由于其隐式 nature,这种语法在autoclosure中很少使用。
编译器如何在SIL层面优化非escaping的@autoclosure参数,与escaping变体相比有哪些优化限制?
Swift编译器把非escaping的autoclosure视为一个直接的函数指针,具有在栈上分配的上下文,如果被调用者立即调用它,可能会通过函数特化完全内联闭包体。这消除了堆分配和引用计数开销。然而,一旦标记为@escaping,闭包必须在堆中分配其上下文,以使其寿命超出函数作用域,从而产生ARC的保留/释放流量。候选人常常忽视,即使是非escaping的autoclosure,如果闭包传递给另一个非escaping函数,也可以阻止某些优化,形成阻止内联的嵌套惰性计算链。
当autoclosure主体包含抛出表达式时,@autoclosure与rethrows关键字之间发生什么特定交互,这对API设计为什么重要?
当一个函数被标记为rethrows并接受一个抛出的@autoclosure时,编译器会验证唯一的抛出源来自于autoclosure调用。这允许函数在不自身标记为throws的情况下传播错误,保持对非抛出调用点的干净接口。这很重要,因为它启用了短路操作符,例如 try lhs || expensiveFailableRhs() 在左侧为false的情况下,右侧才评估并抛出。候选人经常忽视,rethrows与autoclosure一起使用时,要求闭包是唯一的抛出成分;如果函数主体直接执行其他抛出操作,编译器将拒绝 rethrows 注释。