结构体中的 Lazy 属性探究

作者:Ole Begemann,原文链接,原文日期:2015-12-17
译者:pmst;校对:Cee;定稿:小锅

定稿注:原文没有提供源码,作为一个走心的翻译组,我们已经将本篇文章的最终版源码作成 Playground,可以到这个地址进行下载。

更新:

2015-12-17 提到 Swift evolution 邮件列表中一个关于行为属性的新提案,如果这个提案被采取,则本篇文章中的大部分将成为过时的内容

Swift 中的 lazy 关键字允许你定义这么一个属性:它的初始值在当它被首次访问的时候才计算。举个例子,试想通过一个结构体来描述一副图像。该图像的元数据(metadata)字典创建操作所需要的开销代价也许很大,因此我们更倾向于推迟这个行为直至需要该数据的时候。我们可以像这样声明一个 lazy var 属性:

struct Image {
    lazy var metadata: [String:AnyObject] = {
        // 加载图片和解析 metadata,相当占内存
        // ...
        return ...
    }()
}

注意我们必须使用 var 关键字声明属性。let 关键字声明的常量必须在实例初始化完成前拥有一个值,显然对于 lazy 变量无法保证初始化前有值。(译者注:实例指结构体实例)

访问 lazy 属性是一个 mutating 操作,因为属性的初始值只在第一次访问时才计算确定。当结构体(属于值类型)包含一个 lazy 属性时,任何该结构体的拥有者同样必须声明为一个变量(var 关键字声明),因为访问该属性意味着可能改变结构体内容。所以以下行为是不被允许的:

let image = Image()
print(image.metadata)
// error: Cannot use mutating getter on immutable value: 'image' is a 'let' constant.

你可以强制要求用户使用 var 关键字来声明 Image 类型,但是这可能有些不合适(比如,函数中作为变量的情况)或容易混淆(因为 getter 方法通常并不是可变的)。

使用Box包装类型

另外一种选择是将 lazy 值封装到一个类中,有点类似经常使用的 Box type。由于类是引用类型,结构体可以包含一个 let 常量指向一个类实例,如此结构体依旧是不可变的,但它指向的引用对象本身是可以改变的。

首先我们定义一个枚举类型,命名为 LazyValue,用于表示值类型 T 能够被懒加载(有时又称延迟加载)。它具有两个可能状态:要么还未执行计算操作,要么已经执行计算并得到结果值。在前一种情况中,它存储用于执行计算的函数(闭包)。后一种情况中,它存储计算值。

private enum LazyValue {
    case NotYetComputed(() -> T)
    case Computed(T)
}

现在我们将封装该枚举到一个类中,命名为 LazyBox,这样我们就能够独立地在它内部做一些改变。而 LazyBox实例的拥有者依旧能够保持不可变状态。实现如下:

final class LazyBox {
    init(computation: () -> T) {
        _value = .NotYetComputed(computation)
    }

    private var _value: LazyValue

    var value: T {
        switch self._value {
        case .NotYetComputed(let computation):
            let result = computation()
            self._value = .Computed(result)
            return result
        case .Computed(let result):
            return result
        }
    }
}

LazyBox 类构造方法接收一个闭包作为变量,用于计算值。我们将该函数存储到一个私有的 LazyValue 属性中,等待需要时进行访问。该类的公有接口是一个只读属性 value。在 valuegetter 方法中我们检查是否已经拥有一个计算属性,如果是则返回该值。否则我们执行一次计算函数并缓存结果值到 _value 等待之后的读取操作。

我们可以像这样使用 LazyBox,并且验证计算函数确实仅被执行了一次:

var counter = 0
let box = LazyBox {
    counter += 1;
    return counter * 10
}
assert(box.value == 10)
assert(box.value == 10)
assert(counter == 1)// 倘若你把 1 改成 2,就会报错

上面这种方式的优势在于它能够应用到常量结构体中。而在其他方面,它相比较 lazy 变量使用略逊一筹,因为用户需要通过 value 属性才能访问结果值。如果感觉这样不直观的话,我们可以隐藏具体的实现,而向用户提供另外一个计算属性返回 LazyBox.value,同时我们将 LazyBox 属性设置为私有。

struct Image {
    // 延迟存储
    private let _metadata = LazyBox<[String:AnyObject]> {
        // 加载图片和解析 metadata,相当占内存
        // ...
        return ...
    }

    var metadata: [String:AnyObject] {
        return _metadata.value
    }
}

let image = Image()
print(image.metadata) // 不报错

这样该结构体依旧保留了它的值语义。值类型内部使用引用类型是惯用伎俩,以这种方式实现能够保证值语义。标准库中许多集合类型都是以这种方式实现的。

并发性

这种实现方式还存在最后一个潜在问题,也就是未考虑并发性。倘若 LazyBox.value 还未完成值计算操作,同时被多个线程访问,那么计算函数就将被执行多次。这些都是你需要避免的情况,万一计算函数会产生副作用或相当耗内存呢,对吧?

我们可以将内部 _value 属性的所有读写操作都安排到一个私有串行队列中,这样就能够保证函数仅被执行一次。下面是新的实现方式:

final class LazyBox {
    init(computation: () -> T) {
        _value = .NotYetComputed(computation)
    }

    private var _value: LazyValue

    /// 所有对于 `_value` 的读写都在这个线程队列中。
    private let queue = dispatch_queue_create(
        "LazyBox._value", DISPATCH_QUEUE_SERIAL)

    var value: T {
        var returnValue: T? = nil
        dispatch_sync(queue) {
            switch self._value {
            case .NotYetComputed(let computation):
                let result = computation()
                self._value = .Computed(result)
                returnValue = result
            case .Computed(let result):
                returnValue = result
            }
        }
        assert(returnValue != nil)
        return returnValue!
    }
}

使用这种方式的缺点是每次访问 value 时都会造成一个小的性能影响,倘若多个线程同时读取该值时可能引起争用,毕竟它们都要经过同一个串行队列。鉴于每次缓存值被计算后队列中要执行的工作量相当少,在大多数情况中都可以忽略不计。

值得注意的是 Swift 并没有为 lazy 关键字提供上述保证。官方文档里是这么说的:

注意:倘若一个标记为 lazy 的属性同时被多个线程修改时,且该属性还未进行初始化操作,那么将无法保证该属性仅被初始化一次。

最新更新:就在我发布该文章之后的一个小时,Joe Groff 在 Swift evolution 中为属性行为发布了一个影响深远的提议,邮件内容请点击这里,一旦被采纳,将实现我在本文中讨论的内容(且远远不止),并以一个更自然的方式来实现。我强烈建议你看一下。

本文由 SwiftGG 翻译组翻译,已经获得作者翻译授权,最新文章请访问 http://swift.gg。

你可能感兴趣的:(结构体中的 Lazy 属性探究)