作者:Radek Pietruszewski,原文链接,原文日期:2015-08-31
译者:walkingway;校对:Cee;定稿:小锅

一年前,在 Swift 推出不久后,我观察到许多 iOS 开发者仍然以 Objective-C 的开发习惯来写 Swift。而在我眼中,Swift 是一门全新的语言,有别于 Objective-C 的语法、设计哲学乃至发展潜力,因此我们更应探索出一条属于 Swift 独有风格的发展道路。我在之前的文章 Swifty methods 中已经探讨过在 Swift 中如何清晰、明确地对方法进行命名,随后我开始连载 Swifty API 系列文章,同时将这一想法付诸实践,探索如何设计更加简单易用的接口 API。

在该系列(Swifty API)第一篇文章中,我们对 NSUserDefaults API 进行了改造:

NSUserDefaults.standardUserDefaults().stringForKey("color")
NSUserDefaults.standardUserDefaults().setObject("red", forKey: "color")

改造之后看上去像这样:

Defaults["color"].string
Defaults["color"] = "red"

相较之前的 get 和 set 方法,改造后的结果更加简单明了,同时也修正了一致性问题,使其更符合 Swift 的使用风格。这看上去是相当大的改进。

但是,随着我对 Swift 深入学习,以及真正在项目中使用这些由我亲手缔造的 API 后,我才意识到这些 API 离真正的原生 Swift 风格还有相当大的差距。在之前的 API 设计中,我从 Ruby 和 Swift 的语法中汲取灵感来构建自己的 API,这一点虽然值得肯定,但是我们并没有将其真正提升到语义学的高度。仅仅是在外表裹了层 Swift 风格的外衣,而内部机制仍然是以 Objective-C 的形式在运作。

缺点

「API 不是那么 Swift 化」,听上去并不是一个让我们从头开始的好理由,虽然相似的 API 更容易学习,但我们不想这么教条。我们不仅仅想要设计出来的 API 看上去更加 Swift 化,还希望能在 Swift 的运行机制下更好地工作。这样看来,我们之前设计的 NSUserDefaults 存在一些小问题:

假设你有一个关于用户喜好颜色的设置选项:

Defaults["color"] = "red"
// App 中的其他一处:
Defaults["colour"].string // => nil

啊哦,一旦不小心把键名(key name)写错了,就会出现 Bug :(

同理我们放一个 date 对象到 defaults 中:

Defaults["deadline"] = NSDate.distantFuture()
Defaults["deadline"].data // => nil

这一次在 getter 方法中把 date 拼错成了 data,结果是 nil,又获得 bug 一枚。你或许认为这种情况并不会经常发生,但为什么每次我们要对取回(getter)的对象指定类型呢?这确实有点烦人。

再来一个例子(这个例子我们在赋值的时候就错了,取回的结果当然是 nil):

Defaults["deadline"] = NSData()
Defaults["deadline"].date // => nil

显然我们想要「现在的时间」的日期而不是一个「空的data值」!

最后观察下面的代码:

Defaults["magic"] = 3.14
Defaults["magic"] += 10
Defaults["magic"] // => 13

在第一篇文章中,我们重新定义了 +=,使其能够在我的新 API 下正常工作,但这儿有个缺陷:只能从传递进来的参数进行类型推断(Int or Double)。也就是说如果你参数传一个整数(Int)10,运算后的结果是 13.14(Double)类型,但最终返回的结果还是会以上一次传入的参数类型为基准,决定最终的返回值。这个例子最后返回值为 13,砍掉了小数部分,显然是个 bug。

你又或许认为以上都是纯理论问题,在真实世界里并不会发生。先别着急下结论,仔细想想,这些拼错变量名、方法名和传递一个错误类型的参数其实都可以归为同一类型的 bug,而这些 bug 在日常开发中是常有之事。如果你正在使用一门需要编译的静态类型语言,那么你更应该依赖编译器给你的反馈而不是事后去测试,更重要的是,花费精力在编译期进行检查也会在将来给你带来丰厚的红利,这不仅仅是在首次写代码时才能享受这种编译器检查带来的好处,在之后的重构中,你也能减少很多不必要的 bug。这里提供一些小建议,可以让未来的你免受 bug 之苦。

静态类型的力量

导致这些问题的根源在于:没有定义关于 user defaults 的静态结构。

在早先的设计中,我意识到这个问题,于是将各种类型封装在了 NSUserDefaults 内部的 Proxy 对象中。调用时你可以通过下标(subscript)获得一个 Proxy 对象,然后再通过 Proxy 提供的访问方法来实现特定类型的访问。

Defaults["color"].string
Defaults["launchCount"].int

采用上面这种方式,比你自己实现 getter 方法或手动对 AnyObject 类型转换要好很多。

但这是一个 hack,并不算真正的解决方案。为了对 API 实现真正意义上的改进,我们需要收集有关 user default keys 的信息,之后提供给编译器。

现在回想下那位长者传授给我们的人生经验。通常不变的常量字符串,为了避免拼写错误,会在一开始就以 string keys 的形式定义,随后使用时编译器也会自动补全:

let colorKey = "color"

让我们带上类型信息:

class DefaultsKey<ValueType> {
let key: String

init(_ key: String) {
self.key = key
}
}

let colorKey = DefaultsKey<String?>("color")

我们将 key name 封装在一个对象中,并且将值类型植入到泛型参数中。现在我们可以定义一个新的 NSUserDefaults 下标,用来接收这些 keys:

extension NSUserDefaults {
subscript(key: DefaultsKey<String?>) -> String? {
get { return stringForKey(key.key) }
set { setObject(newValue, forKey: key.key) }
}
}

这里是结果:

let key = DefaultsKey<String?>("color")
Defaults[key] = "green"
Defaults[key] // => "green", typed as String?

没错,就这么简单,语法和功能稍后再来完善。我们通过这个小技巧,修复了许多问题。比如没办法再轻易拼错 key name 了,因为他只能定义一次。也不能随便就赋值一个不匹配的类型了,因为你这么做编译器会报错。最后也不必写 .string,因为编译器已经知道我们想要的类型了。

此外,我们或许应该使用泛型来定义 NSUserDefaults 的下标(subscripts),而不是手动输入所有需要的类型。不过想法总是美好的,现实却是残酷的,Swift 的编译器目前还不支持泛型下标。(╯‵□′)╯︵┻━┻ 方括号可能看上去还不错,别再纠结语法了,我们让 setting 和 getting 方法更加泛型化就好了。

等等,你还没有见识过下标 subscripts 的能耐!

令人振奋的下标

考虑下面这种写法:

var array = [1, 2, 3]
array.first! += 10

完全不能通过编译!我们尝试对数组内部的整数进行加法操作,但这对于 Swift 来说是做不到的。整数具有值语义,是不可变的。当他们从某些地方返回时,你不能直接去修改他们的值,这是因为他们并不存在于表达式之外,仅算是瞬时状态下的一份拷贝罢了。

改换变量来做就没问题:

var number = 1
number += 10

注意,实际并没有真正意义上改变 1 这个整数,而只是修改了变量,为其分配了一个新值而已。

再来看看下面这段代码:

var array = [1, 2, 3]
array[0] += 10
array // => [11, 2, 3]

结果终于如你所愿了,不是吗?这和你想象中的一样,可是为什么这么做就可以了呢?

观察一下,在 Swift 中,下标和里面的值类型似乎也合作地非常愉快。我们可以通过下标来修改数组里的值,是因为他在内部实现了 gettersetter 方法。编译器层面所做的工作是将 array[0] += 10 重写为 array[0] = array[0] + 10。如果你只实现了 getter 下标 subscript,而没有实现 setter,是不会正常工作的。

这不仅仅是数组(Array)特有的黑魔法,这是下标(subscript)语义精心设计后的结果,我们可以在自己实现的 subscripts 免费获得这种特性,比如我们还可以这么玩:

Defaults[launchCountKey]++
Defaults[volumeKey] -= 0.1
Defaults[favoriteColorsKey].append("green")
Defaults[stringKey] += "… can easily be extended!"

有意思吧,要知道在 API 1.0 版本,我们仅仅模仿字典那样使用下标,并没有利用上面介绍的这种语义。

我们还添加了一些 +=++ 这样的操作符,但是这种行为比较危险,主要依赖于编译器的魔法实现。在这里我们通过将类型信息封装在 key 中,然后定义了 subscriptgettersetter 方法,现在整个世界看上去运转正常。

捷径

在老版本 API 设计中,使用字符串 key 的好处在于你可以按需使用,而不用去创建任何中间对象。

而在目前改进的新版本中,每次使用前都要创建键对象key object)好像没什么道理,况且这会带来可怕的重复以及抵消掉静态类型带来的好处。所以让我们再想想如何能够更好地组织 defaults keys

一种解决方案就是在类层级(class level)定义这些 keys:

class Foo {
struct Keys {
static let color = DefaultsKey<String>("color")
static let counter = DefaultsKey<Int>("counter")
}

func fooify() {
let color = Defaults[Keys.color]
let counter = Defaults[Keys.counter]
}
}

这似乎已经是 Swift 关于字符型 keys 的标准实践了。

另一种解决方案是利用 Swift 的隐式成员表达式,此功能的最常见用途是枚举。当一个方法需要一个枚举类型 Direction 作为参数,你可以传递 .Right。编译器能够推断出真正的参数类型 Direction.Right。这里有个冷知识:这种特性(隐式成员表达式)同样适用于方法参数是静态成员类型的情形,例如你可以在一个需要 CGRect 类型做参数的方法中,使用 .zeroRect 来代替 CGRect.zeroRect

事实上,我们可以通过把键定义为 DefaultsKey 上的静态常量来做相同的事情。好啦,差不多了,最后为了消除编译器上的限制,我们需要一个稍微不同的定义:

class DefaultsKeys {}
class DefaultsKey<ValueType>: DefaultsKeys { ... }

extension DefaultsKeys {
static let color = DefaultsKey<String>("color")
}

试一下效果,哇,不错哦!

Defaults[.color] = "red"

是不是很炫酷?站在调用者的角度,现在比之前用传统字符串的方式显得不再那么冗余,开发者的代码量减少了,读起来也更直观。有没有感到很兴奋,如果我告诉你这一切都是免费获得的,你会不会更开心。

(这项技术的一个缺陷就是没有命名空间机制,在大工程中还是老实采用键结构体 Keys struct 的方式更好一些。)

可选值难题

在前一版设计的 API 中,我们让所有的 getters 都返回可选值,不过我不大喜欢 NSUserDefaults 处理不同类型时缺乏一致性,对于字符串,缺失值将返回 nil,但是对于数字和布尔值,你将会得到 0false

我很快意识到这种方式缺点是太冗长。大多数时候我们并不关心 nil 的情形,只希望在这种情况下得到一个默认值,仅此而已。而每次我们通过下标(subscripts)获得一个可选值后,都要先解封包做判断,再决定返回解包值还是预设的默认值。

Oleg Kokhtenko 针对这个问题提出了解决方案,除了标准的可选返回值的 getter 方法,我们还添加了一组 getter 方法,这些方法都以标志性的 -Value 结尾,并且结果为 nil 时会返回默认值代替,这样类型更加明确:

Defaults["color"].stringValue            // 默认得到""
Defaults["launchCount"].intValue // 默认得到0
Defaults["loggingEnabled"].boolValue // 默认得到false
Defaults["lastPaths"].arrayValue // 默认得到[]
Defaults["credentials"].dictionaryValue // 默认得到[:]
Defaults["hotkey"].dataValue // 默认得到NSData()

我们可以在静态类型体制下做同样的事情,下面为 optional 和非 optional 类型各提供一个 subscript 变体。

extension NSUserDefaults {
subscript(key: DefaultsKey<NSData?>) -> NSData? {
get { return dataForKey(key.key) }
set { setObject(newValue, forKey: key.key) }
}

subscript(key: DefaultsKey<NSData>) -> NSData {
get { return dataForKey(key.key) ?? NSData() }
set { setObject(newValue, forKey: key.key) }
}
}

我喜欢这么做,因为这样就不用依赖协定约定(typetypeValue),将空值转换为各种类型的默认值。而是使用已经在 user defaults key 中定义好的类型,剩下的工作就交给编译器吧。

更多的类型

我通过添加这些类型的下标来扩大支持的类型范围:StringIntDoubleBoolNSData[AnyObject][String: AnyObject]NSStringNSArrayNSDictionary(还包含他们的可选变体,注意 NSDate?NSURL?AnyObject? 没有对应的非可选部分,因为这些类型的默认值没有意义)。

还要注意一点,字符串(strings)、字典(dictionaries)和数组(arrays)同时存在于 Swift 基本库和 Cocoa Foundation 框架中。而我们优先考虑 Swift 原生类型,但这些类型并不具备他们在 Cocoa 框架中的一些能力,不过如果真正需要,我会让事情简单一些。

提到数组,为什么把我们只限制没有类型化的数组?因为在大多数情况下,user defaults 中存储的数组里面的元素都是同一类型的,比如 StringIntNSData

因为不能定义泛型下标,我们来创建一对泛型 helper 方法:

extension NSUserDefaults {
func getArray<T>(key: DefaultsKey<[T]>) -> [T] {
return arrayForKey(key.key) as? [T] ?? []
}

func getArray<T>(key: DefaultsKey<[T]?>) -> [T]? {
return arrayForKey(key.key) as? [T]
}
}

复制、粘贴,然后参照下面这段代码改写所有我们感兴趣的类型:

extension NSUserDefaults {
subscript(key: DefaultsKey<[String]?>) -> [String]? {
get { return getArray(key) }
set { set(key, newValue) }
}
}

现在可以这样调用:

let key = DefaultsKey<[String]>("colors")
Defaults[key].append("red")
let red = Defaults[key][0]

我们通过数组下标返回一个 String,然后为其添加了一个字符串,整个验证过程发生在了编译期(编译器会对进行的操作进行类型检查),这样做更加安全便捷。

归档

NSUserDefaults 还有一个缺点是支持的类型并不多,如果我们想存储自定义的类型,通用的解决办法是用 NSKeyedArchiver 来序列化你的自定义对象。

接下来我们努力把世界变得更美好一点,类似于 getArray 的 helper 方法,我定义了 archive()unarchive() 的泛型方法,这样我就能很容易地设计一段下标代码来处理各种自定义类型(前提是这些类型遵循 NSCoding 协议)。

extension NSUserDefaults {
subscript(key: DefaultsKey<NSColor?>) -> NSColor? {
get { return unarchive(key) }
set { archive(key, newValue) }
}
}

extension DefaultsKeys {
static let color = DefaultsKey<NSColor?>("color")
}

Defaults[.color] // => nil
Defaults[.color] = NSColor.whiteColor()
Defaults[.color] // => w 1.0, a 1.0
Defaults[.color]?.whiteComponent // => 1.0

(译者注:NSColor 遵循 NSSecureCoding 协议,而该协议继承自 NSCoding

看上去并不十分完美,但我们仅用了几行代码就让 NSUserDefaults 很好地支持了自定义类型。

结果和结论

万事俱备,下面有请我们新的 API 登场:

// 提前定义键名
extension DefaultsKeys {
static let username = DefaultsKey<String?>("username")
static let launchCount = DefaultsKey<Int>("launchCount")
static let libraries = DefaultsKey<[String]>("libraries")
static let color = DefaultsKey<NSColor?>("color")
}

// 使用点语法来获取 user defaults
Defaults[.username]

// 使用非可选的键来获取默认值而非可选值
Defaults[.launchCount] // Int, 默认值是0

// 就地更新 value 的值
Defaults[.launchCount]++
Defaults[.volume] += 0.1
Defaults[.strings] += "… can easily be extended!"

// 使用和修改数组类型
Defaults[.libraries].append("SwiftyUserDefaults")
Defaults[.libraries][0] += " 2.0"

// 方便地使用序列化的自定义类型
Defaults[.color] = NSColor.whiteColor()
Defaults[.color]?.whiteComponent // => 1.0

Swift 中使用起来不再痛苦的静态类型

希望你已经看到这种静态类型带来的好处,我们只付出了很小的代价,包括提前定义 DefaultsKey,遵从 Swift 的类型系统。而作为回报,编译器向我们献上一份大礼:

  • 编译期检查(键名,读、写的类型检查)
  • 键名(key names)自动补全
  • 类型推断——不必在末尾输入 .string 或手动对 AnyObject 进行类型转换
  • 我们可以直接操作 user defaults 里面的值,而不需要通过中间步骤或魔法运算符
  • 一致性——抛开怪异的 keys,Defaults 看上去更像是一个定义了类型的字典

这里还有一个潜在优势:可以自动享受到今后 Swift 的发展红利。

真正的 Swift 的 API 也利用了静态类型特性,这里不是要教条主义,条条大路通罗马,肯定还有其他的最佳解决方案。但当你决定回到 Objective-C 或 JavaScript 的编码习惯时,重新考虑一下静态类型所带来的好处,还要明白一点,这种静态类型不是你前辈所熟悉的静态类型,Swift 丰富的类型系统允许你创造出极具表现力和易用的 API,而实现这一切的开销却可以忽略不计。

试试看

一如既往,我将以上所有的探索整理成了一个库,放在 GitHub 上,如果感兴趣,可以采取下面的方式引用:

# with CocoaPods:
pod 'SwiftyUserDefaults'

# with Carthage:
github "radex/SwiftyUserDefaults"

同样也鼓励你去试用我改造的另一个 Swifty API(NSTimer),关于如何清晰命名请看我这篇文章 Swifty methods

最后如果你对本文有什么好的想法或建议,请务必联系我 Twitter 或提出 issue

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

文章目录
  1. 1. 缺点
  2. 2. 静态类型的力量
  3. 3. 令人振奋的下标
  4. 4. 捷径
  5. 5. 可选值难题
  6. 6. 更多的类型
  7. 7. 归档
  8. 8. 结果和结论
    1. 8.1. Swift 中使用起来不再痛苦的静态类型
    2. 8.2. 试试看