惰性属性

在 Swift 中,可以使用 lazy 惰性属性来延迟变量或常量的初始化,直到第一次使用它。这在其初始值计算成本较高或非立即需要时很有用。

一般声明的变量,不管后面用不用,都会在一开始初始化,这样会占用存储;但如果不声明该变量,改用函数方法返回,则每次使用时都要计算一次该方法,这样会影响性能。所以惰性属性 lazy 是一种中间地带:它们仅在需要使用时计算一次,您只需支付一次性能成本;如果它们从未使用过,则代码永远不会运行。这是双赢的!

惰性属性的使用

在Swift中,结构体或类的惰性属性是指:只有在首次访问时才会被计算和初始化的储存属性。这对于需要延迟初始化的属性非常有用,可以节省内存和性能。惰性属性可以通过 = 等号来辨认,这点和储存属性类似。

惰性属性的具体写法:

在下面示例中,Circle 定义了一个惰性属性 diameter,它的值是根据 radius 计算得出的圆的直径。

// 以下是一个示例代码,演示了如何在结构体中使用惰性属性:
struct Circle {
    let radius: Double
    
    // 惰性属性,diameter 它需要在首次访问时才初始化,而不是在“类或结构初始化”时就初始化
    lazy var diameter: Double = {
        print("Calculating diameter")
        // Expensive computation to create array
        return self.radius * 2
    }()
}

var myCircle = Circle(radius: 5.0)
print(myCircle.diameter) // 第一次访问,会计算和初始化惰性属性
print(myCircle.diameter) // 后续访问,不会重新计算和初始化

惰性属性和计算属性的区别

在 Swift 中 Lazy 关键字主要用于储存属性,而不适用于计算属性。因为计算属性的值是动态计算的,且每次访问时都会重新计算,因此不需要使用 Lazy 关键字。计算属性可以通过 : 冒号辨认。

<aside> 💡

惰性属性 与 计算属性 最大的不同是: 惰性属性:只有在首次访问时才会被计算和初始化,之后惰性属性会存储计算的结果,因此后续的访问就不需要重新计算了 计算属性:每次访问都要重新计算,和方法调用一样

</aside>

// 注意惰性属性的等号 = 和闭包后面的括号(),它是要在访问时,运行闭包返回一个值,再赋值给属性
lazy var diameter: Double = {
    print("Calculating diameter")
    return self.radius * 2
}()

// 而计算属性没有等号 = ,也没有闭包后面的括号,它是每次调用的时候都重新计算一遍
var exampleStringValue: String {
   get {
     return exampleString
   }
   set {
     exampleString = newValue
   }
}

惰性函数

使用 lazy 时常见的抱怨是它们会使代码变得混乱:因为 lazy 属性没有将属性和方法分离,而更像是属性和功能混合在一起的灰色区域。有一个简单的解决方案,可以创建一个方法,将你的惰性属性与它们所依赖的代码分开。如果你想使用这种方法,最好将创建的这个独立方法标记为 private,这样它就不会被意外使用。

class Singer {

		let name: String
		
		init(name: String) {
				self.name = name
		}
		
		// 声明惰性属性,其值是从一个私有方法中返回的
		lazy var reversedName: String = self.getReversedName()
		
		// 把功能逻辑放到方法中,这样 惰性属性 就和 逻辑方法 分离开了
		private func getReversedName() -> String {
				return "\\(self.name.uppercaseString) backwards is \\
				(String(self.name.uppercaseString.characters.reverse()))!"
		}
		
}

let taylor = Singer(name: "Taylor Swift")
print(taylor.reversedName)

惰性单例

关于单例模式的说明详情见这里:Swift 实现单例模式

例如我们要创建一个 Singer 类,里面包含一个属性是 MusicPlayer 类。该属性需要是单例模式,因为不管我们创建多少个 Singer ,我们希望它们播放歌曲的时候,都是通过同一个 MusicPlayer 进行播放,这样才不会出现多个声音重叠的情况。

// 这是 MusicPlayer 类的代码,它在创建出来时会打印一条消息
class MusicPlayer {
		init() {
				print("Ready to play songs!")
		}
}

// 这是 Singer 类的代码,它在创建出来时也会打印一条消息
class Singer {
		init() {
				print("Creating a new singer")
		}
}

那如果我们想给 Singer 类添加一个 MusicPlayer 单例属性,我们只需要在 Singer 类中添加一行代码:

class Singer {

		static let musicPlayer = MusicPlayer()
		
		init() {
				print("Creating a new singer")
		}
		
}

let taylor = Singer()

// static 部分意味着此属性是类的静态属性,在类中可以共享访问,而不是在类的实例中
// 这意味着是通过 Singer.musicPlayer 访问,而不是 taylor.musicPlayer 访问
// let 部分当然意味着它是一个常数,引用不会发生变化

你可能想知道这一切与惰性属性有什么关系?运行这段代码可以发现:输出的是 “Creating a new singer” ,而 “Ready to play songs!” 消息并没有打印出来。除非你再添加一条命令 Singer.musicPlayer ,才会打印该消息。

所以结论是:Swift 所有的 static let 单例都是自动惰性的 —— 它们只在需要时被创建。这很容易做到,但也非常高效。


惰性序列

惰性序列 Lazy Sequences 是 Swift 中的一种性能优化技术,类似于惰性属性。它允许我们延迟计算序列中的元素,直到真正需要使用时才进行计算。这对于处理大型或无限序列特别有用。

惰性序列的优势

例如经典的斐波那契数列递归算法(它递归不断往下拆,直到拆到 0 或 1),如果不用惰性序列,它是这样的:

func fibonacci(num: Int) -> Int {
    if num < 2 {
        return num
    } else {
        return fibonacci(num - 1) + fibonacci(num - 2)
    }
}

let fibonacciSequence = (0...20).map(fibonacci)
print(fibonacciSequence[10])

由于 fibonacci 是某种成本很高的运算,所以可改用惰性序列:

let fibonacciSequence = (0...199).lazy.map(fibonacci)
print(fibonacciSequence[19])

以上代码会遇到错误,是因为惰性序列的工作方式有些特殊。当对一个惰性序列使用下标访问(如 fibonacciSequenceA[19])时,Swift 无法直接提供该功能;因为惰性序列是按需计算的,它并没有预先计算好所有的元素,因此不支持直接的下标访问。以下是可行的修改方法:

使用 dropFirst()first 来获取特定位置的元素:

// 去掉前面 19 个元素,再取第一个元素(那就是原来的20位)
let element = fibonacciSequence.dropFirst(19).first!
print(element)

使用 enumerated()first(where:) 来找到特定索引的元素:

let element = fibonacciSequence.enumerated().first(where: { $0.offset == 19 })!.element
print(element)

这些方法保持了惰性计算的特性,只计算到需要的元素为止。通过这些修改,你应该能够成功访问惰性序列中的特定元素了。记住,惰性序列的设计目的是为了提高大型数据集处理的效率,所以在使用时要考虑到它的特性和局限性。

// 修改后的完整代码:
func fibonacci(_ num: Int) -> Int {
    if num < 2 {
        return num
    } else {
        return fibonacci(num - 1) + fibonacci(num - 2)
    }
}

let fibonacciSequenceA = (0...199).lazy.map(fibonacci)
let element = fibonacciSequenceA.dropFirst(19).first!
print(element)

// 使用 `!` 强制解包假定序列中一定有足够的元素。在实际应用中,你可能需要添加错误处理逻辑

惰性序列的局限性

然而,惰性序列也有其局限性,这在下面的代码中表现出来:

let fibonacciSequence = (0...199).lazy.map(fibonacci)
print(fibonacciSequence[19])
print(fibonacciSequence[19])
print(fibonacciSequence[19])

<aside> 💡

以上代码在每次访问 fibonacciSequence[19] 时,都会重新计算第20个斐波那契数,这是因为惰性序列不会缓存结果。因此惰性序列的局限性表现在缺乏记忆:每次访问同一元素都会重新计算,不会缓存结果。因此对于重复访问同一元素的场景,最好使用普通序列或实现自定义的缓存机制。鉴于惰性序列在多次访问同一元素时,性能可能比普通序列更慢。因此使用时要权衡计算延迟与潜在的重复计算成本。

</aside>