Swift 2.0: 深入浅出 Map 和 FlatMap 概念

1,741 阅读4分钟
原文链接: www.uraimo.com

Published on October 8, 2015

This is a Swift 2.0 article, get this and other playgrounds from GitHub.

Swift is a language still slightly in flux, with new functionalities and alterations of behavior being introduced in every release. Much has already been written about the functional aspects of Swift and how to approach problems following a more "pure" functional approach.

Bind

Considering that the language is still in its infancy, often, trying to understand some specific topics you'll end up reading a lot of articles referring to old releases of the language, or worst, descriptions that mix up different releases. Sometimes, searching for articles on flatMap, you could even fortuitously find more than one really good articles explaining Monads in the context of Swift.

Add to the lack of comprehensive and recent material the fact that many of these concepts, even with examples or daring metaphors, are not obvious, especially for someone used to the imperative way of thinking.

With this short article I'll try to give a clear and throughout explanation of how map and especially flatMap work for different types in Swift 2.0, with references to the current library headers.

Map

Map has the more obvious behavior of the two *map functions, it simply performs a closure on the input and, like flatMap, it can be applied to Optionals and SequenceTypes (i.e. arrays, dictionaries, etc..).

Map on Optionals

For Optionals, the map function has the following prototype:


public enum Optional : ... {
    ...
    /*
        If `self == nil`, returns `nil`.  Otherwise, returns `f(self!)`.
    */
    public func map(f: (Wrapped) throws -> U) rethrows -> U?
    ...
}

The map function expects a closure with signature (Wrapped) -> U, if the optional has a value applies the function to the unwrapped optional and then wraps the result in an optional to return it (an additional declaration is present for implicitly unwrapped optionals, but this does not introduce any difference in behavior, just be aware of it when map doesn't actually return an optional).

Note that the output type can be different from the type of the input, that is likely the most useful feature.

Straightforward, this does not need additional explanations, let's see some real code from the playground for this post:


var o1:Int? = nil

var o1m = o1.map({$0 * 2})
o1m /* Int? with content nil */

o1 = 1

o1m = o1.map({$0 * 2})
o1m /* Int? with content 2 */

var os1m = o1.map({ (value) -> String in
    String(value * 2)
})
os1m /* String? with content 2 */

os1m = o1.map({ (value) -> String in
    String(value * 2)
}).map({"number "+$0})
os1m /* String? with content "number 2" */

Using map on optionals could save us an if each time we need to modify the original optional (map applies the closure to the content of the optional only if the optional has a value, otherwise it just returns nil), but the most interesting feature we get for free is the ability to concatenate multiple map operations that will be executed sequentially, thanks to the fact that a call to map always return an optional. Interesting, but quite similar and more verbose than what we could get with optional chaining.

Map on SequenceTypes

But it's with SequenceTypes like arrays and dictionaries that the convenience of using map-like functions is hard to miss:


var a1 = [1,2,3,4,5,6]

var a1m = a1.map({$0 * 2})
a1m /* [Int] with content [2, 4, 6, 8, 10, 12] */

let ao1:[Int?] = [1,2,3,4,5,6]

var ao1m = ao1.map({$0! * 2})
ao1m /* [Int] with content [1,2,3,4,5,6]  */

var a1ms = a1.map({ (value) -> String in
    String(value * 2)
}).map { (stringValue) -> Int? in
    Int(stringValue)
}
a1ms /* [Int?] with content [.Some(2),.Some(4),.Some(6),.Some(8),.Some(10),.Some(12)] */

This time we are calling the .map function defined on SequenceType as follow:


/* 
   Return an `Array` containing the results of mapping `transform`
   over `self`.

   - Complexity: O(N).
*/
func map(@noescape transform: (Self.Generator.Element) throws -> T) rethrows -> [T]

The transform closure of type (Self.Generator.Element) -> T is applied to every member of the collection and all the results are then packed in an array with the same type used as output in the closure and returned. As we did in the optionals example, sequential operation can be pipelined invoking map on the result of a previous map operation.

This basically sums up what you can do with map, but before moving to flatMap, let's see two additional examples:



var s1:String? = "1"
var i1 = s1.map {
    Int($0)
}
i1 /* Int?? with content 1 */

var ar1 = ["1","2","3","a"]
var ar1m = ar1.map {
    Int($0)
}
ar1m /* [Int?] with content [.Some(1),.Some(2),.Some(3),nil] */

ar1m = ar1.map {
    Int($0)
    }
    .filter({$0 != nil})
    .map {$0! * 2}
ar1m /* [Int?] with content [.Some(1),.Some(2),.Some(3)] */

Not every String can be converted to an Int, so our integer conversion closure will always return an Int?. What happens in the first example with that Int??, is that we end up with an optional of an optional, for the additional wrapping performed by map. To actually get the contained value will need to unwrap the optional two times, not a big problem, but this starts to get a little inconvenient if we need to chain an additional operation to that map. As we'll see, flatMap will help with this.

In the example with the array, if a String cannot be converted as it happens for the 4th element of ar1 the that element in the resulting array will be nil. But again, what if we want to concatenate an additional map operation after this first map and apply the transformation just to the valid (not nil) elements of our array to obtain a shorter array with only numbers?

Well, we'll just need intermediate filtering to sort out the valid elements and prepare the stream of data to the successive map operations. Wouldn't it be more convenient if this behavior was embedded in map? We'll see that this another use case for flatMap.

FlatMap

The differences between map and flatMap could appear to be minor but they are definitely not.

While flatMap is still a map-like operation, it applies an additional step called flatten right after the mapping phase. Let's analyze flatMap's behavior with some code like we did in the previous section.

FlatMap on Optionals

The definition of the function is a bit different, but the functionality is similar, as the reworded comment implies:


public enum Optional : ... {
    ...
    /*
        Returns `nil` if `self` is nil, `f(self!)` otherwise.
    */
    public func map(f: (Wrapped) throws -> U?) rethrows -> U?
    ...
}

There is a substantial difference regarding the closure, flatMap expects a (Wrapped) -> U?) this time.

With optionals, flatMap applies the closure returning an optional to the content of the input optional and after the result has been "flattened" it's wrapped in another optional.

Essentially, compared to what map did, flatMap also unwraps one layer of optionals.


var fo1:Int? = nil

var fo1m = fo1.flatMap({$0 * 2})
fo1m /* Int? with content nil */

fo1 = 1

fo1m = fo1.flatMap({$0 * 2})
fo1m /* Int? with content 2 */

var fos1m = fo1.flatMap({ (value) -> String? in
    String(value * 2)
})
fos1m /* String? with content "2" */

var fs1:String? = "1"

var fi1 = fs1.flatMap {
    Int($0)
}
fi1 /* Int? with content "1" */

var fi2 = fs1.flatMap {
    Int($0)
    }.map {$0*2}

fi2 /* Int? with content "2" */

The last snippet contains and example of chaining, no additional unwrapping is needed using flatMap.

As we'll see again when we describe the behavior with SequenceTypes, this is the result of applying the flattening step.

The flatten operation has the sole function of "unboxing" nested containers. A container can be an array, an optional or any other type capable of containing a value with a container type. Think of an optional containing another optional as we've just seen or array containing other array as we'll see in the next section.

This behavior adheres to what happens with the bind operation on Monads, to learn more about them, read here and here.

FlatMap on SequenceTypes

SequenceType provides the following implementations of flatMap:


    /// Return an `Array` containing the concatenated results of mapping
    /// `transform` over `self`.
    ///
    ///     s.flatMap(transform)
    ///
    /// is equivalent to
    ///
    ///     Array(s.map(transform).flatten())
    ///
    /// - Complexity: O(*M* + *N*), where *M* is the length of `self`
    ///   and *N* is the length of the result.
    func flatMap(transform: (Self.Generator.Element) throws -> S) rethrows -> [S.Generator.Element]

    /// Return an `Array` containing the non-nil results of mapping
    /// `transform` over `self`.
    ///
    /// - Complexity: O(*M* + *N*), where *M* is the length of `self`
    ///   and *N* is the length of the result.
    func flatMap(transform: (Self.Generator.Element) throws -> T?) rethrows -> [T]

flatMap applies those transform closures to each element of the sequence and then pack them in a new array with the same type of the input value.

These two comments blocks describe two functionalities of flatMap: sequence flattening and nil optionals filtering.

Let's see what this means:


var fa1 = [1,2,3,4,5,6]

var fa1m = fa1.flatMap({$0 * 2})
fa1m /*[Int] with content [2, 4, 6, 8, 10, 12] */

var fao1:[Int?] = [1,2,3,4,nil,6]

var fao1m = fao1.flatMap({$0})
fao1m /*[Int] with content [1, 2, 3, 4, 6] */

var fa2 = [[1,2],[3],[4,5,6]]

var fa2m = fa2.flatMap({$0})
fa2m /*[Int] with content [1, 2, 3, 4, 6] */

While the result of the first example doesn't differ from what we obtained using map, it's clear that the next two snippets show something that could have useful practical uses, saving us the need for convoluted manual flattening or filtering.

In the real world, there will be many instances where using flatMap will make your code way more readable and less error-prone.

And an example of all this is the last snippet from the previous section, that we can now improve with the use of flatMap:


var far1 = ["1","2","3","a"]
var far1m = far1.flatMap {
    Int($0)
}
far1m /* [Int] with content [1, 2, 3] */

far1m = far1.flatMap {
        Int($0)
    }
    .map {$0 * 2}
far1m /* [Int] with content [2, 4, 6] */

I may look just a minimal improvement in this context, but with longer chain it would become something that greatly improves readability.

And let me reiterate this again, in this context too, the behavior of swift flatMap is aligned to the bind operation on Monads (and "flatMap" is usually used as a synonym of "bind"), you can learn more about this reading here and here.

Drawing inspired by emacs-utils documentation.