2014年,Swift发布后,海量的关于map
、filter
、reduce
方法优点的解释随之而来。但其中确实有一少部分是我们需要重点关注的,所以接下来我们会简要的来看看这几点。
###Map方法
对数组中的所有值做一个变换是很常见的:“新建一个数组,遍历一个已存在的数组的每一个元素,执行一个操作然后放入新数组中”。几乎每个程序员都数百次写过这样的代码:
var squared: [Int] = []
for fib in fibs {
squared.append(fib * fib)
}
复制代码
Swift数组有一个map
方法,它其实受到了函数式编程的启发,同样的功能用map
方法可以这样写:
squared = fibs.map{ fib in fib * fib }
复制代码
关于这种版本的写法,有很多值得说道的地方。毋庸置疑的是这种写法很短,但更关键的是它表达更清晰,没有无关代码。一旦你习惯了在代码的任何地方看到并使用map
方法,它就好像变成了条件反射——你看见map
方法,然后就立刻意识到一个变换函数会被应用在数组的每一个元素上,然后返回一个由变换结果作为元素,构成的新数组。
square
不必被定义为var
,它在map
方法内部被创建并按要求进行合适的初始化,然后才被返回,所以如果合适的话,用let
修饰它就可以。而且,因为square
数组内部的元素类型可以根据传入map
的函数被正确推导出来,所以我们也不必显式地标注集合元素类型。
map
方法并不难写,它只是一个把for循环的样板部分包装成范型函数的问题。这里给出一个可行的实现(事实上,在Swift里,map
方法是SequenceType
的一个拓展,这一部分会在关于编写范型算法部分的章节详细讨论):
extension Array {
func map<U>(transform: Element->U) -> [U]{
var result: [U] = []
result.reserveCapacity(self.count)
for x in self {
result.append(transform(x))
}
return result
}
}
复制代码
Element
是数组所含元素的占位符。U
是一个新的占位符,它用来表示元素变换结果所属的类[1]。这样一来,map
方法并不关心Element
和U
具体表示什么类型。他们本来就可以是任何类型。于是,它们具体是什么类型,已经变换函数具体做什么工作,就由调用者决定了。
提示: 复制代码
实际上
map
方法的方法签名应该是:func map<T>(@noescape transform: (Element) throws -> T) rethrows -> [T]
我们在函数章节会讨论
@noescape
,在“错误”章节会讨论rethrows
,它们都不是必不可少的,而只是让方法调用者更好的调用。
###函数参数化
尽管你已经熟悉map
方法和我们正在讨论的一些基础问题了,不妨回过头来花上几分钟思考一下map
方法的实现代码。看看到底是什么让它在保证通用性的情况下,还十分有用。
map
方法成功的分离了一些模板部分,这些部分在无数次调用中总是保持一成不变。而保留的,则是那些经常发生变化的部分,也即是每一个元素的具体变换逻辑。map
方法让这个变换逻辑,作为方法的参数,由调用者提供。
这种参数化模式充斥着整个标准库。一共有13种不同的函数采用闭包让调用者自定义其中的关键步骤。以下列出了方法名和用户传入的闭包的作用。
map
和flatMap
:如何变换元素filter
:一个元素是否应该被包括进来reduce
:把数组元素组合起来,得到一个聚合值sort
和lexicographicalCompare
[2]:数组中元素以什么样的顺序排列indexOf
和contains
:某个元素是否匹配minElement
和maxElement
:两个元素中哪个是最大值,哪个是最小值elementsEqual
和startsWith
:两个元素是否相等split
:某个元素是否是分隔符。
这些函数的目的在于避免代码给人一种杂乱感,对于代码中我们不感兴趣的部分,比如“新数组的创建”、“for循环遍历数据源”等等,可以封装在方法内部实现,同时对外用一个描述性的单词来替换它。然后,允许核心部分——也就是程序员想表达的变换逻辑,被作为参数传入方法中。
这些方法中,很多都有“默认”操作:
- 当元素可比较的时候
sort
方法会将他们按升序排列,除非由你自己指定排序方式。 - 只要元素是可以相等的[3],
contains
就可以接受一个值并检查它是否存在于数组中。
这些默认操作使我们代码的可读性进一步加强:
- 因为升序排列是很自然的,所以
array.sort()
方法含义也就显而易见了。 array.indexOf("foo")
比array.indexOf{ $0 == "foo" }
的写法更直观、易懂。
但总的来说,这只是在一些常见情况下的简写。总有些时候,元素不必是可比较的或是可相等的,你也不必比较整个元素——也就是说你可以只通过人的年龄来对人进行排序(people.sort { $0.age < $1.age }
)。或者检查有没有人年龄低于18岁(people.contains { $0.age < 18 }
)。你甚至还可以比较元素变换之后的结果,比如一个(低效的)大小写不敏感排序可以这样实现:people.sort { $0.name.uppercaseString < $1.name.uppercaseString }
。
还有一些其他同样有用的函数也会采用闭包作为参数,决定其内部的行为,这些函数不在标准库中。你自己可以很容易地定义他们(或许你会想试一试):
accumulate
:合并数组元素,把每一步的运行结果保存到数组中(类似于reduce
的加强版,因为它保存了每一次的临时结果)
all
和none
:检查数组中是否所有或没有元素满足某个判断标准。(可以基于contains
实现,但是需要小心,在某些时候应该返回false)count
:返回满足某个要求的元素数量(类似于filter
,不过不需要在方法内部新建数组)indicesOf
:返回所有满足某个判断的数组切片(类似于indexOf
但是不会在找到第一个后停止)takeWhile
和takeUntil
:过滤数组元素当(或直到)某一个谓词判断返回true
(类似于filter
不过会提前结束,对于无限序列或懒序列[4]比较有用)
我们会在本书的其他地方定义其中的很多方法。
你可能会认为一个类似于forEach
或repeat
的方法被漏掉了。这个方法会遍历集合并做一些操作,不过并不会返回任何结果。
我们不建议定义这样的方法是出于这样的考虑——它真的不太有用。因为即使使用了这样的方法,它也不会比使用普通的for循环带来任何简洁性方面的提升。千万不要陷入一种“不惜一切代价使用高阶函数”的误区。简单的使用for循环就可以了。
从另一方面来说,如果你确实发现你写的一些代码,不止一次的满足某个模式,比如说这样:
let someArray: [Int] = []
var object:Int?
for oneObject in someArray where oneObject.passesTest() {
object = oneObject
break
}
复制代码
那么你确实可以考虑给SequenceType
写一个拓展,把你的逻辑包装在一个可以接受闭包的函数里:
extension SequenceType {
func findElement (match: Generator.Element -> Bool) -> Generator.Element? {
for element in self where match(element) {
return element
}
return nil
}
}
复制代码
这样就允许你用
let objct = fibs.findElement{ $0.passTest() }
复制代码
来替换之前的for循环了。
这和我们之前描述的map
函数的优点是一样的。它更加简单易读,减少了产生错误的机会,还允许你用let
代替var
来定义变量。
这样写十有八九还会和guard
关键字配合得很好。如果元素没有找到的话你可以提前结束这个流程。
guard let objct = fibs.findElement({ $0.passTest() })
else{ return }
复制代码
##变异和有状态闭包 之所以没有一个类似于forEach
或repeat
的方法还有最后一个原因:既然它不会返回任何结果,那使用它的唯一目的就是对别的变量执行一些有状态的改变。我们不推荐这么做,如果你曾经看到过这样的代码:
array.map{
//用来插入表的代码
}
复制代码
那这就是一个很明显应该用for
循环代替如map
或forEach
方法的例子。
这和故意的把本地状态传进闭包是截然不同的。后者是一项相当有效的技术,也正因如此,才使得闭包——在变量的作用域之外截获并改变它们的函数,在与高阶函数联合使用时,变成一种强有力的工具。比如之前描述过的accumulate
方法,就可以通过map
函数和有状态闭包这样实现:
extension Array {
func accumulate<U>(initial: U, combine:(U,Element) -> U) -> [U] {
var running = initial
return self.map { next in
running = combine(running, next)
return running
}
}
}
复制代码
这段代码用一个临时变量来存储当前正在运行的值,然后使用map
方法,在运行的过程中不断的把这个临时变量放入新数组中。
所以接下来这段代码[5]会返回[1, 3, 6, 10]
[1,2,3,4].accumulate(0, combine: +)
复制代码
注意,要保证这段代码正常工作有一个前提,即map
方法是按顺序遍历原数组中的元素并执行变换的。上面例子中的map
方法确实如此。但确实存在不是如此的可能,比如我们会在并发章节定义的parallel_map
。map
方法在官方的标准库中并没有明确说明它是否是顺序遍历的,尽管目前看上去这么用不会出任何问题。
###Filter
另一个非常常见的操作是对于一个数组,创建新的数组。只包含原来数组中满足某个判断条件的元素。遍历数组元素并判断其是否满足过滤条件这样固定的模式被放在filter
方法内部执行。所以filter
方法可以这么用:
fibs.filter { num in num % 2 == 0 }
复制代码
结合filter
和map
方法,我们可以不用创建一个单独的临时中间数组就对原数组进行很多操作。这会使代码简短易读。
作为缩短代码的终极杀招,我们可以使用Swift内建的参数名缩略表达式。我们甚至可以不用把num
参数列出来:
fibs.filter { $0 % 2 == 0 }
复制代码
对于闭包来说,代码越简短,它的可读性就越强。如果闭包比较复杂,那把参数名显示地列出来会比较合适。这其实还是取决于个人习惯,选择自己一眼看上去,更易读的风格就好。一个比较不错的规则是,如果闭包能在一行里整齐的写出来,那么使用参数名的缩略表达式会更好。
最后,来看一下filter
方法的实现,它和map
方法的实现非常类似:
extension Array {
func filter (includeElement: Element -> Bool) -> [Element] {
var result: [Element] = []
for item in self where includeElement(item) {
result.append(item)
}
return result
}
}
复制代码
关于更多在for循环中使用where
语句的信息,参见可选类型章节。
提高性能的提示: 复制代码
一旦你发现自己写下面这样的代码,立刻停下来!
bigarray.filter { someCondition }.count > 0
复制代码
我们知道filter
方法会创建一个全新的数组,然后把旧数组中的每个元素遍历处理一遍,而这样做是没有必要的。因为我们的目的只是为了判断数组中是否存在某一个满足条件的元素。所以这种情况下,contains
方法是最佳选择。它可以在找到第一个目标后及早返回。总的来说,只要在你真的需要所有结果的时候才使用filter
方法。
###Reduce
map
和filter
方法都是通过一个已存在的数组,生成一个新的、经过修改的数组。然而有时候我们需要把所有元素的值合并成一个新的值,比如我想求数组中所有元素的和,我们可以写下面这样的代码:
var total = 0
for num in fibs {
total = total + num
}
复制代码
reduce
方法就是针对这种模式。它把整个过程抽象成两部分,初始值(在这个例子中是0)和用于把中间值(total)与元素(num)合并的函数。通过使用reduce
方法,我们的代码可以这样写:
fibs.reduce(0) { total, num in total + num }
复制代码
在Swift中,所有的操作符都是函数,所以我们还可以这样写:
fibs.reduce(0) { total, num in total + num }
复制代码
reduce
方法的返回结果不必和输入值的类型相同。比如我们可以把数组中的数字拼成一个字符串,用换行符分隔。代码可以这么写:
fibs.reduce("") { str, num in str + "\(num)\n" }
复制代码
reduce
方法可以这样实现:
extension Array {
func reduce<U> (var initial: U, combine: (U, Element) -> U) -> U {
for item in self {
initial = combine(initial, item)
}
return initial
}
}
复制代码
这里我们每次都是用initial
似乎有些不合理,因为它的命名与它的实际作用不相符。至于是否需要用一个临时变量来保存每次调用combine
后的值,就是一个仁者见仁智者见智的问题了。
另一个提高性能的小提示:
reduce
方法的用途很广,不仅可以用来聚合数组中的元素产生一个新的值,用它构造一个新数组并执行其他操作也是很常见的,比如我们只用reduce
也可以实现map
和filter
方法[6]:
extension Array {
func map2<U> (transform: Element -> U) -> [U] {
return reduce([], combine: { $0 + [transform($1)] })
}
}
复制代码
extension Array {
func filter2 (includeElement: Element -> Bool) -> [Element] {
return reduce([]) { includeElement($1) ? $0 + [$1] : $0 }
}
}
复制代码
这样做的好处在于完全避开了烦人的、但又不可或缺的for
循环。但Swift毕竟不是Haskell[7],Swift的数组也不是列表(list),这样做的问题在于新的map
方法的实现中,每次我们调用combine
方法都会生成一个全新的数组。这意味着这种实现的时间复杂度是O(n^2)而不是O(n)。
###A Flattening Map
在使用map
方法时,有时候你会希望传进去的变换函数返回的是一个数组而不是单个元素。
举例来说,我们有一个extractLinks
函数,它读取一个Markdown文件然后返回一个包含了所有链接的URL的数组。函数原型如下:
func extractLinks(markdownFile: String) -> [NSURL]
复制代码
如果我有很多Markdown文件,又想把所有文件中的URL都提取到某个数组中,我们可以试着写这样的代码:markdownFiles.map(extractLinks)
。但这么做会返回一个数组的数组:每个文件的URL构成一个数组,这个数组又是最终返回的数组的一个元素。于是你还得再用一次循环,把这个数组的数组铺开,把其中所有的结果都放到一个单独的数组中:
let nestedArrays = markdownFiles.map(extractLinks)
var links: [NSURL] = []
for array in nestedArrays {
links.appendContentsOf(array)
}
复制代码
而这样的写法我们似曾相识,当时我们用map
方法替换了这种写法。所以,在这种情况下,我们可以用flatMap
方法。除了flatMap
方法会把返回的数组铺平之外,它和map
方法很像。所以执行markdownFiles.flatMap(extractLinks)
会把所有的URL放到一个单独的数组中,并返回这个数组作为运行结果。
我们来看一下flatMap
的实现,它和map
的实现很像,不过它接受的函数参数的返回结果是数组而不是单个元素。而且它在内部调用的是appendContentsOf
而不是append
extension Array {
func flatMap<U> (tranform: Element -> [U]) -> [U] {
var result: [U] = []
for x in self {
result.appendContentsOf(tranform(x))
}
return result
}
}
复制代码
还有一种情况可以让flatMap
大显身手。比如我们要合并不同数组中的元素到某一个新数组中(相当于求两个集合的笛卡尔积)。我们可以调用其中某一个数组的flatMap
方法,然后再调用另一个数组的map
方法:
let suits = ["", "", "", ""]
let ranks = ["J", "Q", "K", "A"]
let allCombinations = suits.flatMap { suit in
ranks.map{ rank in
(suit, rank)
}
}
复制代码
##数组类型
###数组切片
通过下标脚本,我们不仅能访问某个特定位置的元素,还可以访问某一个区间内的元素。比如,为了得到数组中从第二个元素起,直到最后一个元素,我们可以这样写:
fibs[1..<fibs.endIndex]
复制代码
这样,我们得到了一个开始于数组第二个元素,结束于数组最后一个元素的数组切片。这个切片的类型不是Array
而是ArraySlice
。切片类型是数组类型的一种视图[8]。它以原来的数组为基础,让你从一个切片的视角来看原来的数组,这样你看到的其实是原数组的一部分而不是一个拷贝出的副本。ArraySlice
类型的方法和Array
完全一样,所以你可以把ArraySlice
当做Array
来用。如果你确实需要创建一个新的数组,你可以直接把ArraySlice
传到Array
的构造方法里:
Array(fibs[1..<fibs.endIndex])
复制代码
###桥接
Swift数组可以通过桥接,转换成Objective-C对象,甚至它还可以与C语言混合使用,我们会在稍后的章节讨论这部分知识。转换可能是双向的,Objective-C的NSArray
可以转换成Swift的Array
,当然大多数情况下,还是要把Array
转换成Objective-C的NSArray
。
要想根据Array
创建Objective-C的NSArray
,就必须保证数组中的元素是可以转化为AbyObjec
。在Swift中,不仅对象可以转化,有些结构体比如Int
和Bool
也可以自动转化成对应的OC类型NSNumber
。但另外一些就不行了,比如CGPoint
可能就会被转化成NSValue
。好在如果编译器处理不了自动桥接的话,它会及时给你提示。
比如,我们用Swift创建了整数数组,然后把它转化成NSArray
并检查转化后的类型。我么可以证明整数被自动转化成了NSNumber
类型:
var x = [1,2,3]
let z: NSArray = x
print(z[0] is NSNumber)
复制代码
运行结果:
true
复制代码
##译者注
[1]:整数可以变换整数,也可以变换成其他类型,比如1变换成one,那么U就是String类型的占位符
[2]:原文是lexicographicCompare
,不过查阅苹果资料发现是lexicographicalCompare
。
[3]:也就是遵循Equatable协议
[4]:懒序列的某些操作如map
、filter
、reduce
方法会延迟实现
[5]:如果不熟悉+
作为函数,标准写法如下:
[1,2,3,4].accumulate(0) { (a: Int, b: Int) -> Int in
return a + b
}
复制代码
[6]:map
方法可以理解为一个由空数组A作为初始值,每次都加一个新数组B,B只有唯一的一个元素,也就是原数组中元素变换后的值。最后返回数组A。这个过程正好符合reduce
方法的思想,filter
方法同理。
[7]:一种函数式编程语言
[8]:视图在原文中叫view,Swift中有很多view的概念。你可以把数组的view理解为,看待数组的某种角度。如果你有过数据库编程经验的话,可能理解视图(view)会更容易些。