结构体中的 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<T> {
    case NotYetComputed(() -> T)
    case Computed(T)
}

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

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

    private var _value: LazyValue<T>

    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<Int> {
    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<T> {
    init(computation: () -> T) {
        _value = .NotYetComputed(computation)
    }

    private var _value: LazyValue<T>

    /// 所有对于 `_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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,684评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,143评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,214评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,788评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,796评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,665评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,027评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,679评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 41,346评论 1 299
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,664评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,766评论 1 331
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,412评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,015评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,974评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,203评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,073评论 2 350
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,501评论 2 343

推荐阅读更多精彩内容