走近科学:Combine 的 Map 为何会有两种不同的行为?

一个奇怪的现象

在一次日常研究 Combine 代码的过程中,无意中发现了一个不大符合认知的行为,示例代码如下:

let combineRandom3Publisher = ["It", "doesn't", "matter"].publisher
    .map { _ in Int.random(in: 0...100) }

combineRandom3Publisher.sink { print($0) }
combineRandom3Publisher.sink { print($0) }

按照正常的理解来说,map 作为一个普通且自信常用的 Operator,只负责用持有的 transform 处理上游传来的 value,然后传递到下游,不应该对传递的过程产生影响。然而。。。

combineRandom3Publisher.sink { print($0) } // Output: 13 79 26
combineRandom3Publisher.sink { print($0) } // Output: 13 79 26

这说明了 map 操作的行为是 eager 的,具体来说:map 在没有收到下游订阅者的前提下,从上游索取了全部的 value,提前进行了 transform 并储存。这是一种非常不合理的行为,因为上游产生一个新 value 的过程对下游来说是不可知的,上游可能只是从某个数组中取了某个值,也可能是通过复杂的不可重复的操作从网络获取了某些信息,类似的 eager 行为模式会造成不可预期的后果。

我感觉自己似乎发现了 Combine 的一个重大缺陷,于是当我把 map 接入到真正的网络请求上时:

let url = URL(string: "http://httpbin.org/uuid")!
let randomUUIDPublisher = URLSession.shared
    .dataTaskPublisher(for: url)
    .map { String(data: $0.data, encoding: .utf8)! }

let sub1 = randomUUIDPublisher.sink { completion in
    print(completion)
} receiveValue: { value in
    print(value) // "uuid": "704ded90-117a-45ea-a081-29d1c9cdd185"
}

let sub2 = randomUUIDPublisher.sink { completion in
    print(completion)
} receiveValue: { value in
    print(value) // "uuid": "993045c7-4815-45e7-ba5b-a4af4c2e2715"
}

map 两次不同的订阅产生了两个不同的结果,这说明 map 的行为又变回了预期的 lazyURLSession.shared.dataTaskPublisher 确实收到了两个订阅,发了两次网络请求。同样的 map 操作符居然会有不同的行为,一时间我有点不知道该怎么解释这种现象。

知识点补充:SequenceLazySequence

在猜测这种现象产生的原因之前,先忘掉 Combine,回顾一下 Swift 中对应的概念: SequenceLazySequence

在 Swift 标准库中,mapfilterreduce 等针对 Sequence 的操作符默认行为都是 eager 的,这符合大多数人对这些操作的理解。

let eagerSequence = [1, 2, 3, 4]
    .filter { $0.isMultiple(of: 2) } // [2, 4]
    .map { $0 * 2 } // [4, 8]

eagerSequence.forEach { print($0) } // Output: 4, 8
eagerSequence.forEach { print($0) } // Output: 4, 8

/*
filter: 1
filter: 2
filter: 3
filter: 4
map: 2
map: 4
4
8
4
8
*/

eager 作为默认行为的优势不言而喻,以 map 操作为例:每个 `map` 操作对应的 `transform` 对于 Sequence 中的每个元素只会执行一次,后续对 `map` 结果的多次获取不会再进行 `transform` 操作。这是一种用空间换时间的常见做法。

这种做法当然不是没有缺点,假设我对一个很大的数组进行 map 操作,且只需要结果的第一个元素:

let eagerSequence = Array(1...1000)
    .map { value -> Int in
        print("map:", value)
        return value * 2
    }

print(eagerSequence.first!) // Output: 2

/*
map: 1
map: 2
...
map: 999
map: 1000
2
*/

这里对于除了第一个元素之外的其他 transform 操作的开销都是多余的。因此,Swift 也提供了 lazy 操作符用于在出现类似需求是将行为变成 lazy 的:

let lazySequence = Array(1...1000).lazy
    .map { value -> Int in
        print("map:", value)
        return value * 2
    }

print(lazySequence.first!) // Output: 2

/*
map: 1
2
*/

简单来说,lazy 之后的 map 操作延迟了 transform 的执行时间到真正的取值开始。从具体实现上看,lazy 操作将 Sequence<T> 类型包装成了 LazySequence<Sequence<T>> 类型,在 LazySequence 上的 map 操作进一步将类型包装成 LazyMapSequence<LazySequence<Sequence<T>>, U>。类型一旦嵌套起来看起来可能会有点复杂,但只需要记住这些类型都是遵守原始的 Sequence 协议的,只是在行为上略有差别而已。

使用 lazy 行为当然也不是没有缺点的,同样是以 map 操作为例:每个 `map` 操作对应的 `transform` 在真正的取值操作前不会执行,但后续对 `map` 结果的每次获取都会再进行一次 `transform` 操作。可以理解为这是在用时间换空间。

从另一个角度也可以证明这两种行为的不一致:

// Sequence.map
func map<T>(_ transform: (Self.Element) throws -> T) rethrows -> [T]

// LazySequence.map
func map<U>(_ transform: @escaping (Base.Element) -> U) -> LazyMapSequence<Base, U>

LazySequence.maptransform 操作是 escaping 的,这表示它可以被持有,在函数之外发挥作用。而 Sequence.map 只会在函数内执行。

阶段总结

这两种行为没有优劣之分,一切都取决于后续的行为。简单的判断标准是:后续操作会用到序列中的大部分元素,例如 max()joined(),用 eager。后续操作只会用到序列中的部分元素,例如 firstprefix(),用 lazy

回到正题

既然已经了解了 eagerlazy 的不同行为模式,那么 Combine 中的 map 纠结为什么会有两种不同的行为模式呢?

猜想1: Publisher 无视 back pressure1 强行发送元素

首先怀疑的就是 ["It", "doesn't", "matter"].publisher 作为 Publisher 会不会强行向下游发送数据呢,创建一个不向上游获取元素的 Subscriber 确认一下:

class IDontNeedAnythingSubsciber<Input, Failure: Error>: Subscriber {
    func receive(subscription: Subscription) {
        // do nothing
    }

    func receive(_ input: Input) -> Subscribers.Demand {
        assertionFailure("should't receive input")
        return .none
    }

    func receive(completion: Subscribers.Completion<Failure>) {
        assertionFailure("should't receive completion")
    }
}

let subsciber = IDontNeedAnythingSubsciber<String, Never>()
["It", "doesn't", "matter"].publisher
    .subscribe(subsciber)

IDontNeedAnythingSubsciberfunc receive(subscription: Subscription) 实现中,在收到订阅时不通过订阅向上游获取元素,用来测试猜想。答案是否定的,IDontNeedAnythingSubsciber 不会收到任何元素,排除了 Publisher 强行发送的可能性。

猜想2:在 Reactive 的 定义中,Map 的行为本来就是 eager 的

使用 RxSwift 实现一个同样的逻辑试验一下:

let rxRandom3Observable = Observable.from(["It", "doesn't", "matter"])
    .map { _ in Int.random(in: 0...100) }

rxRandom3Observable.subscribe(onNext: { print($0) }) // Output: 17 36 92
rxRandom3Observable.subscribe(onNext: { print($0) }) // Output: 22 5 53

答案也是否定的,MapRxSwift 中的行为确实是 lazy 的。

猜想3:Map 有能力根据上游来源的不同,决定自己的行为方式

想要验证这种猜想也很简单,尝试在 map 操作之前抹掉上游的类型:

let combineRandom3Publisher = ["It", "doesn't", "matter"].publisher
    .eraseToAnyPublisher()
    .map { _ in Int.random(in: 0...100) }

combineRandom3Publisher.sink { print($0) } // Output: 11 46 69
combineRandom3Publisher.sink { print($0) } // Output: 19 90 93

这次的结果果然发生了变化,map 的行为变成了 lazy 的。

那么,一切是怎么实现的呢?

对比一下两次的代码:

// eager
let combineRandom3Publisher = ["It", "doesn't", "matter"].publisher
    .map { _ in Int.random(in: 0...100) }
    
// lazy
let combineRandom3Publisher = ["It", "doesn't", "matter"].publisher
    .eraseToAnyPublisher()
    .map { _ in Int.random(in: 0...100) }

看来一切的根源在于,两次调用的并不是同一个 map 方法!

// eager
func map<T>(_ transform: (Elements.Element) -> T) -> Publishers.Sequence<[T], Failure>

// lazy
func map<T>(_ transform: @escaping (String) -> T) -> Publishers.Map<AnyPublisher<Publishers.Sequence<[String], Never>.Output, Never>, T>

Publishers.Sequence 上调用的 map 方法依然返回了一个 Publishers.Sequence 类型的 Publisher,这个 map 的行为是 eager 的,transform 参数也没有 @escaping 修饰。

AnyPublisher 上调用的 map 方法返回的是一个 Publishers.Map 类型的 Publisher,这次的行为是 lazy 的,transform 参数被 @escaping 修饰,表示其可能会被持有留到后续使用。

还有其他的类型有类似的特性吗?

有。以下类型重写了 Publish 协议中 map 方法的默认实现,并改变了行为方式:

// Just
func map<T>(_ transform: (Output) -> T) -> Just<T>

// Publishers.Sequence
func map<T>(_ transform: (Elements.Element) -> T) -> Publishers.Sequence<[T], Failure>

以下类型重写了方法,但是只是为了减少类型的嵌套,没有修改 map 行为方式:

// Publishers.CompactMap
func map<T>(_ transform: @escaping (Output) -> T) -> Publishers.CompactMap<Upstream, T>

// Publishers.Map
func map<T>(_ transform: @escaping (Output) -> T) -> Publishers.Map<Upstream, T>

// Publishers.TryMap
func map<T>(_ transform: @escaping (Output) -> T) -> Publishers.TryMap<Upstream, T>

还有其他操作符被重写吗?

也有,但主要集中在 JustPublishers.Sequence 两个类型中。二者重写了大部分对 value 进行操作的方法,修改了类似 transform 参数的行为,同时将返回值修改为了具体的类型。所以在使用这两种类型的时候,优先考虑操作符的行为是否和默认行为有差异。如果有需要默认行为的地方,可以考虑使用 eraseToAnyPublisher 操作符抹掉具体的类型。

参考资料

一篇介绍集合 lazy 行为的文章:https://www.avanderlee.com/swift/lazy-collections-arrays/

Combine 中的各种 Publisher:https://developer.apple.com/documentation/combine/publishers

  1. Apple 关于 back pressure 的官方文档:https://developer.apple.com/documentation/combine/processing-published-elements-with-subscribers