在 Swift 中实现字典

作者:Soroush Khanlou,原文链接,原文日期:2016-07-05
译者:jseanj;校对:Prayer;定稿:shanks

虽然 Swift 原生的字典类型实现的很复杂(毫无疑问是为了性能),但是我们可以利用 Swift 提供的工具写出漂亮简洁的实现。我们从一个简单的实现开始,并且逐步添加功能。

我们简要看一下字典的工作原理:它通过任意类型的关键字来设置和获取值。这些值常常存储在一个数组中,当然也可以存储在树型结构中。由于我还不太清楚以树作为字典存储结构的工作原理,所以这篇文章中我们主要探索一个由数组存储值的字典。

由于我们字典中的值是由数组存储,所以我们需要将给定的关键字转换成整数,然后让这个整数落在数组范围内。这两个方法一个是哈希函数,一个是取模操作。通过哈希函数,我们可以将关键字(通常是字符串,也可以是遵循 Hashable 协议的任意类型)转换成一个数值,然后根据数组的长度,通过取模操作我们会得到一个固定位置(consistent slot)来设置和获取值。

译者注: 这里的 consistent slot 是指,字典的 key,通过这种 Hash 和取模运算,每次都会得到相同的数值,从而确保相同的 key 值,每次取得的 value 总是保持一致性。

我从 Mike Ash 的文章 让我们构建 NSMutableDictionary 获取了一些灵感,特别是重新计算数组容量的规则。

让我们开始吧。我们知道 KeyValue 都应该支持泛型,而且 Key 必须遵循 Hashable 协议。遵循 Hashable 协议的也一定同时遵循 Equatable 协议。(译者注,Hashable 协议继承自 Equatable 协议)

struct Dictionary<Key, Value where Key: Hashable> {
    private var storage = //some Array...
    
    subscript(key: Key) -> Value? {
        get {
            return nil
        }
        set {
        }
    }
}

这是我们类型的基本结构。我们知道我们的数组必须是支持泛型的,但是还不知道具体的值是什么。由于两个不同的关键字经过哈希和取模操作后可能会指向数组的同一个位置,这样就会造成冲突,因此每个占位对象需要支持存储多个值。现在让来我们设计 Placeholder

struct Placeholder<Key, Value where Key: Hashable> {
    var values: [(Key, Value)] = []
}

这个对象存储很多经过哈希和取模操作后指向字典同一位置的键和值。如果我们很好的设计了字典,那么每个 Placeholder 包含的键值对将不会超过一个,但是超过一个的情况也会发生。一个比较好的实现是用一个链表来存储 Placeholder 的属性 values。我将留作一个练习给读者。

现在我们大体知道了 Placeholder 是什么样的,接下来我们看看我们的存储器是什么样的。

private var storage = Array(count: 8, repeatedValue: Placeholder<Key, Value>())

初始时我们给数组一个随机的大小。最好选择 2 的整数幂,因为对 2 的幂取模要比其他数更快。除非我们实现如何重新计算数组容量,否则取多大都没有关系。Array(count:repeatedValue:) 构造方法使得数组中的每个位置都有一个可以添加值的占位对象。

为了设置字典的值,我们需要对键进行哈希(然后取绝对值,因为哈希有时会返回负值),然后根据数组大小进行取模操作。

set {
    let position = abs(key.hashValue) % storage.count
    //...
}

在真正给字典添加值之前,我们需要
a)确保新值(newValue)不为 nil
b)找到在 position 位置的占位对象
c)将键和值添加到占位对象中。

set {
    guard let value = newValue else { return }
    let position = abs(key.hashValue) % storage.count
    storage[position].values.append((key, value))
}

这就是基本满足我们需求的设置方法的实现。(我忽略掉了一些小的细节,比如当你试着对同一个键设置两次时会发生什么。我们很快会处理这种情况。)

取值的过程相当简单。对键进行哈希,取绝对值,取模操作,然后我们获取了在那个位置上的占位对象。我将会把从占位对象中获取值的过程交给占位对象自己去做。

get {
    let position = abs(key.hashValue) % storage.count
    return storage[position].firstValue(matchingKey: key)
}

真正神奇的是在下面这个方法。我们需要找到第一个包含那个键的键值对。在 Swift 3 中我们可以使用 SequenceType 协议中的 first(where:) 方法来实现,但目前我们使用普通的写法 lazy.filter( /* block */ ).first 来获取键值对。

func firstValue(matchingKey key: Key) -> Value? {
    let matchingKeyValuePair = values.lazy.filter({ $0.0 == key }).first
    //...
}

一旦我们拿到表示键值对的元组,我们就可以直接调用 .1 获取对应的值。

func firstValue(matchingKey key: Key) -> Value? {
    return values.lazy.filter({ $0.0 == key }).first?.1
}

这几乎是 Dictionary 的基本实现了。23 行 Swift 代码。所有代码在这个 gist 中。

还有几个有意思的事情还没有实现。首先,需要有惰性求值的 generate(),这样的话 Dictionary 就遵循 SequenceType 协议了。

译者注: 遵循 SequenceType 协议之后,就可以使用 for (key, value) in 的方式来遍历字典了

extension Dictionary: SequenceType {
    typealias Generator = IndexingGenerator<[(Key, Value)]>
    func generate() -> Dictionary.Generator {
        return storage.flatMap({ $0.values }).generate()
    }
}

接下来是删除键。首先,从占位对象中删除:

extension Placeholder {
    mutating func removeValue(key key: Key) {
        values = values.filter({ $0.0 != key })
    }
}

然后从字典中删除

extension Dictionary {
    mutating func remove(key key: Key) {
        let position = abs(key.hashValue) % storage.count
        storage[position].removeValue(key: key)
    }
}

在设置新值时先调用 remove(key:)。这样确保同一个键不会映射到不同的值上。

最后,我们来看看如何重新计算数组容量。当字典包含非常多对象时,它的实际存储结构需要调整自身大小,可以把“非常多对象”看成一个大于2/3或者3/4的负载系数(对象数量除以数组长度)。我选择 0.7。

extension Dictionary {
    private let maxLoadFactor = 0.7
    
    private var size: Int {
        return storage.count
    }
    
    var count: Int {
        return storage.flatMap({ $0.values }).count
    }
    
    var currentLoadFactor: Double {
        return Double(count) / Double(size)
    }
}

count 的实现还是懒求值。理想情况下,Dictionary 会记录有多少对象被添加和被删除,但实际上很难记录)

mutating func resizeIfNeeded() {
    if currentLoadFactor > maxLoadFactor {
        //resize storage
    }
}

这就是 Swift 的值语法非常奇怪的地方。Mike Ash构建NSMutableDictionary 文章中,创建了一个固定大小的字典,并且把它封装成一个可变大小的字典。当需要调整大小时,他创建一个新的固定大小的字典(大小加倍),然后手动的把所有元素拷贝到新的字典当中。

在 Swift 中我们不必如此。Swift 中的结构体对象赋值给一个变量会进行一次完整拷贝。

let oldDictionary = self
//...

一旦我们拷贝完字典,我们可以重置 storage 变量为原先数组大小的两倍。(大小加倍确保数组大小仍然是2的幂。)

//...
storage = Array<Placeholder<Key, Value>>(count: size*2, repeatedValue: Placeholder<Key, Value>())
//...

一旦设置完成,字典会变成空的,我们必须把 oldDictionary 的所有值拷贝到当前字典中:

//...
for (key, value) in oldDictionary {
    self[key] = value
}

这是完整的 resizeIfNeeded 函数:

mutating func resizeIfNeeded() {
    if Double(count) / Double(size) > maxLoadFactor {
        let oldDictionary = self
        storage = Array<Placeholder<Key, Value>>(count: size*2, repeatedValue: Placeholder<Key, Value>())
        for (key, value) in oldDictionary {
            self[key] = value
        }
    }
}

在 Swift 的结构体中,self 可以访问当前类型的值和函数,但是它也是可变的。你可以对它设置新值,拷贝它,或者就把它当成另一个变量的引用。

两周前 我开玩笑说 Swift 是比 Objective-C ,Ruby 或者 Python 更动态的语言。因为你可以改变它的错误行为,但是这里有另一种情况,即你可以在 Swift 中修改一些在 Objective-C 中不能修改的东西:self 本身的引用。我们本可以在该方法中这样写 self = Dictionary(size: size*2),这在 Swift 是绝对有效的。对于那些觉得对象标识是最重要的面向对象开发者而言,这非常奇怪。

包含排序,删除,调整数组大小的完整的实现可以在 gist 中找到。除了 count/generate() 懒求值的实现,我非常喜欢这个小工程的表达方式。

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

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

推荐阅读更多精彩内容