最近有在写一些 TypeScript。虽然在我觉得 Swift 的表达能力要比 TypeScript 好太多,常常会陷入不知该怎么用 TypeScript 表达的状况(当然我并不怎么熟 TypeScript),唯独 TypeScript 的 utility type 和 mapped type 一直让我觉得很神奇。

比如说我有一个用来表示配置的类型:

1
2
3
4
5
6
type Configuration {
foregroundColor: string;
backgroundColor: string;
font: string;
... other_999_properties;
}

本身这个配置会有它们的默认值,我们也允许外部去修改部分配置。鉴于这个配置有 1002 个配置项,让别人直接构造整个配置,再传递给我们略显麻烦。

一种做法是我们可以提供一个默认配置,别人在此基础上做出改动之后再传递给我们。但 TypeScript 中有一种更有趣的方法,我们可以创建一个类型 Partial<Configuration>

1
2
3
4
5
6
7
8
9
type PartialConfiguration = Partial<Configuration>;
// {
// foregroundColor?: string;
// backgroundColor?: string;
// font?: string;
// ... other_999_properties?;
// }

configure({ font: 'Comic Sans' });

就能得到了一个与原本拥有相同属性,但所有属性都为可选的类型。

在 Swift 中实现

那么在 Swift 上是否能做到相同的事情呢?仔细一想,说不定可以用 dynamic member lookup 做到。

1
2
3
4
5
6
7
8
9
10
11
12
@dynamicMemberLookup
struct Partial<T> {
private var storage = [AnyKeyPath: Any]()
subscript<V>(dynamicMember member: WritableKeyPath<T, V>) -> V? {
get { storage[member] as? V }
set { storage[member] = newValue }
}
}

var configuration = Partial<Configuration>()
configuration.font = "Comic Sans"
configure(with: configuration)

但是初始化还是有点麻烦。比较简单的方法是增加这样的一个初始化方法来进行初始化:

1
2
3
4
5
6
7
init(_ configure: (inout Self) -> Void) {
var partial = Self()
configure(&partial)
self = partial
}

configure(with: .init { $0.font = "Comic Sans" })

这样一来我们就可以手动将 1002 个属性复制到我们的配置里面了(

Object.assign?

在 TypeScript 里,我们可以通过 Object.assign 将两个对象合并,在 Swift 中我们又能怎么做呢?

1
2
3
4
struct Partial<T> {
...
func assign(to object: inout T) { ??? }
}

这里最大的问题是,我们要怎么找回 storage 里的值的类型,一番尝试之后,我觉得这大概是很难做到的。在脱离 subscript 方法之后,我们就会丢失储存的值的类型,那么我们是否可以在离开 subscript 之前做些什么呢?

我们可以选择不仅仅储存值,而是同时储存一个将值写回对象的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct Value<Root> {
let value: Any
let assignLogic: (inout Root, AnyKeyPath, Any) -> Void
func assign(to object: inout Root, keyPath: AnyKeyPath) {
assignLogic(&object, keyPath, value)
}
}

struct Partial<T> {
...
subscript<V>(dynamicMember member: WritableKeyPath<T, V>) -> V? {
get { (storage[member] as? Value<T>)?.value as? V }
set {
if let newValue = newValue {
storage[member] = Value<T>(
value: newValue,
assignLogic: { object, keyPath, value in
guard let value = value as? V,
let keyPath = keyPath as? WritableKeyPath<T, V>
else { return }
object[keyPath: keyPath] = value
}
)
} else {
storage[member] = nil
}
}
}
}

那么 assign 方法的实现也就呼之欲出了。

1
2
3
4
5
6
7
8
9
10
func assign(to object: inout T) {
for (keyPath, value) in storage {
guard let value = value as? Value<T> else { continue }
value.assign(to: &object, keyPath: keyPath)
}
}

func configure(with options: Partial<Configuration>) {
options.assign(to: &self.configuration)
}

最后

写着写着就会发现,configure 方法这样写不就好了吗。

1
2
3
func configure(with options: (inout Configuration) -> void) {
options(&self.configuration)
}

Partial 也不是一无是处,至少它限制了你只能去读写可以被修改的配置,避免了你去读取只读的属性甚至是调用方法。如果我们再对 Partial 做进一步定制,还可以借助 property wrapper 选择希望导出的属性。

最后我们可以为 wrapped type 为引用类型的情况做一些适配。也可以想想怎么实现 DeepPartial

我看了下其他的一些 utility type,除了 Readonly 这种之外,觉得在 Swift 中大概都实现不了。