Swift函数式编程教程

前言

本文翻译自Swift Functional Programming Tutorial
翻译的不对的地方还请多多包涵指正,谢谢~

Swift函数式编程教程

当从Objective-C(文章其余地方将简称OC)编程转移到Swift过程中,将OC中的概念映射到Swfit是非常符合逻辑的。你知道在OC中如何创建类,那在Swift也是一样。当然,Swfit有一些完全新的特性诸如泛型和范围操作数,但仍然还有你已经知道一些小的非常精妙的技术。(OK,可能也不那么小!)

但,Swfit不仅仅是为应用提供了一个更好的语法。使用这门新语言,你能有机会改变解决问题及编码的思路。结合Swift,函数式编程技术在你的编程武器中成为了一个可行的重要的部分。

函数式编程是一个理论性强的话题,因此这篇教程将通过例子来说明它。你将看到许多的函数编程的例子,这些例子看起来是熟悉、命令式的编程方式,之后你可以尽所能在解决相同问题的时考虑使用函数式编程技术。

注意:这篇Swfit教程是假定你已有Swift编程的基础。如果你对Swfit很陌生,我们建议你先看看其他Swfit的教程

什么是函数式编程?

简洁的说,函数式编程是是一种编程范式,强调通过数学式的函数来计算,函数具有永恒不可变性及表达式语法,尽可能少地使用参数和状态位的特性。

因为极少有共享的状态且每个函数像是应用代码海洋里的一座孤岛,所以它更加容易测试。函数式编程之所以流行是因为它使得异步和并行更加简单地协同工作。为当今多核时代提供了一种提高性能的途径。

是时候开始将方法变为函数式方式了!

简单的数组筛选

以一个非常简单的事情作为开始:一个简单的数学位计算。你的第一个任务是写一个简单的Swift写的用于找到1到10中的偶数的函数。虽是一个非常小的任务,确是对一个函数式编程很棒的介绍。

老的筛选方法

创建一个Swift的工作环境,保存在你喜欢的任何地方。然后贴下面的代码到你新创建的Swift文件中:

var evens = [Int]()
for i in 1...10 {
  if i % 2 == 0 {
    evens.append(i)
  }
}
println(evens)

该函数生成出了我们预先想要的结果

[2, 4, 6, 8, 10]

(如果你看不到控制台的输出,记得通过View/Assistant Editor/Show Assistant Editor选项打开辅助编辑)

这块小代码很简单,核心算法是这样的:

  1. 创建一个空数组;
  2. 1-10的循环迭代器(记得是1...10是包含1和10的);
  3. 若符合条件(数字是偶数),则添加到数组内;

上述代码是实质上是命令式编程。给出明确的使用一些基本的控制命令诸如if,for-in指令告诉计算机找出偶数。

代码运行的很好,只是有一点验证数字是否是偶数是隐藏在循环当中的。而且存在一些严重耦合,将偶数添加到数组的动作包含在条件当中。如果你希望在App的其他地方打印偶数数组,只能通过拷贝粘贴的方式来重用代码。

让我们来改成函数式吧~

函数式筛选

将以下代码贴到你的文件中:

func isEven(number: Int) -> Bool {
  return number % 2 == 0
}
evens = Array(1...10).filter(isEven)
println(evens)

可以看到,函数式编程的结果跟命令式编程一样:

[2, 4, 6, 8, 10]

让我们看的更仔细点。它由两部分组成:

  1. 数组代码段是一个简单方便生成包含1到10的数组方式。范围操作数...创建一个包含两端点的范围;
  2. 筛选声明处是函数式编程模仿所在。这个筛选方法,对数组是显示的(基础方法),创建并返回了一个新的数组,该数组包含了只有通过筛选器里的函数返回True的元素。在这个例子中,isEven提供给了filter

isEven函数作为参数传递给了filter函数,但记住函数仅仅是有名字的闭包。试试添加以下更加简明的代码到你的文件中:

evens = Array(1...10).filter { (number) in number % 2 == 0 }
println(evens)

再一次,确认三个方法的返回结果是一致的。以上代码可以说明编译器从使用上下文推断出参数number的类型并且返回闭包的类型。

如果你想让你的代码更加简明,再做一步这么来写:

evens = Array(1...10).filter { $0 % 2 == 0 }
println(evens)

上述代码使用了参数简写,隐式返回,类型推荐...且成功了!

使用简写的参数表达是一种习惯或者偏好。个人来说,觉得像上面简单的例子,简写参数很好。但是,对于更加复杂的情况我更倾向于显示的参数表达。编译器虽然不关心变量名字,但它们可以创建一个与人类不同的世界!(原文连续的意思是说,不写参数名计算机可以任意翻译人们写的代码,构建出不同的含义,这并不是我们想要的。)

函数式的写法显然比命令式的更加简洁。这个简单的例子展示出了所有函数式语言所具有的一些有趣的特性:

  1. 高阶函数:这种函数的参数一个函数,或者返回值是一个函数。在这个例子中,filter就是一个高阶函数,它可以接收一个函数作为参数;
  2. 一级函数:你可以将函数当做是任意变量,可以将它们赋值给变量,也可以将它们作为参数传给其他函数;
  3. 闭包:实际上就是匿名函数;

你可能注意到OC中的block也有一些类似的特性。但swfit在函数编程上走的更远,它通过混合使用更加简明的语法和内建的函数诸如filter

Filter筛选器背后的魔法

Swift有很多功能化方法,例如map, join, reduce...。那么在这些方法的背后是怎么实现的呢?

我们来看看filter背后的魔法并加上我们自己的实现。

在相同的文件中,添加以下代码:

func myFilter<T>(source: [T], predicate:(T) -> Bool) -> [T] {
  var result = [T]()
  for i in source {
    if predicate(i) {
      result.append(i)
    }
  }
  return result
}

上述代码是一个泛型函数,它接受一个包含类型T的数组的源(source)和一个以T类型实例为入参并返回bool值得判定函数(predicate)。

myFilter函数看起来更像是我们刚开始写的命令式函数。主要区别在于你可以提供一个检查条件的函数而不是硬编码在函数中。

试试新添加的实现,添加以下代码:

evens = myFilter(Array(1...10)) { $0 % 2 == 0 }
println(evens)

再一次说明,输出结果是一样的!

挑战:上述方法是全局的,看看你是否能让它变成数组的一个方法。

  1. 可以通过扩展Array添加myFilter方法;
  2. 可以扩展Array,但不是Array[T](泛型扩展)。这意味着需要通过self遍历数组且需要强制转换类型。

Reducing

之前的是一个很简单的例子,只用了一个函数式方法。接下来,在上面的基础上,运用函数式编程技术实现更加复杂的逻辑。

创建一个新的工作文件,准备下一个任务吧~

Manual reduction

本阶段你的任务会复杂一点点:取出1到10的偶数并求和。这就是熟知的reduce函数,接收一组输入且返回一个输出。

我相信你有能力自己写完这个逻辑,但我已经写好了~ 添加以下代码到工作文件:

var evens = [Int]()
for i in 1...10 {
  if i % 2 == 0 {
    evens.append(i)
  }
}
 
var evenSum = 0
for i in evens {
  evenSum += i
}
 
println(evenSum)

结果如下:

30

上面的命令行代码还是和之前的例子一样,在for-in循环语句中做加法。

让我们来看看函数式会如何编写吧~

函数式Reduce(Functional Reduce)

添加如下代码到你的工作区:

evenSum = Array(1...10)
    .filter { (number) in number % 2 == 0 }
    .reduce(0) { (total, number) in total + number }
 
println(evenSum)

你会看到结果是:

30

上述代码段包含了数组的创建和filter的使用。这两个操作的结果是五个数字的数组[2, 4, 6, 8, 10]。最后一步使用的是reduce

reduce是功能极其丰富的数组方法,可以为数组的每一个元素执行一个方法,并累加结果。

为了理解reduce是如何工作的,看看它的签名是有帮助的。

func reduce<U>(initial: U, combine: (U, T) -> U) -> U

第一个参数是类型为U的初始值。在目前的代码中,初始值是0且是Int型的(这里的U就一直是Int了)。第二个参数是combine函数,这个函数会对每个数组元素执行一遍。

combine接收连个参数,第一个是类型是U,且是上一个combine函数的调用结果;第二个参数是正在执行combine函数的数组元素。reduce执行的返回结果就是最后一个combine函数的返回结果。

这里面繁盛了很多事情,我们可以一步步拆解它。

在代码中,第一个reduce后的结果如下:

first
first

combine第一个入参的初始值是0,入参的第二个参数是数组的第一个值2。combine函数将它们两想加,返回2。

第二个循环如下图介绍:

在第二个循环,combine的入参是第一个combine的返回值和下一个数组元素值。将他们两想加即2 + 4 = 6。

继续循环处理所有的数组元素可以得到下面这张表:

加粗且边上有星号的值就是最终的执行结果。

这是一个简单例子;事实上,使用reduce你可以执行各种各样有趣的功能强大的转换。下面是几个运用例子。

添加以下代码到你的工作区:

let maxNumber = Array(1...10)
            .reduce(0) { (total, number) in max(total, number) }
println(maxNumber)

这段代码用于找出数组中的最大值。这个例子中,返回结果显而易见~ 在这里要记住,结果就是最终reduce执行的计算max数值的循环结果。

如果你还在努力思索为何是这么运行的,为什么创建类似于上述表格来记录每次combine的入参和返回值呢。

至今为止你所见到的reduce都是将一个整型数组转换为一个整型数字。显然,reduce有两个参数,U和T,是不同的,而且必须得他们没必要是整型。这意味着你能将包含一个类型的数组转换成另一种类型的数。

添加以下代码到你的工作文件中:

let numbers = Array(1...10)
    .reduce("numbers: ") {(total, number) in total + "\(number) "}
println(numbers)

执行结果如下:

numbers: 1 2 3 4 5 6 7 8 9 10

这个例子展示了reduce将一个整型数组转换成一个String数字。
通过一些练习,你会发现可以用各种各样的有趣且好玩的方式使用reduce

挑战:看看你是否能将一个包含位的数组转换成一个整型值。输入如下:

let digits = ["3", "1", "4", "1"]

你的reduce结果需要返回3141的整形值。

Reduce背后的魔法

在之前的段落中,你实现了自己的简单的filter的方法。现在我们来实现自己的reduce方法。

添加如下代码到工作区:

extension Array {
  func myReduce<T, U>(seed:U, combiner:(U, T) -> U) -> U {
    var current = seed
    for item in self {
      current = combiner(current, item as T)
    }
    return current
  }
}

上述代码添加了myReduce方法到数组中,模仿了内建的reduce函数。这个函数简单的遍历每个数组元素,每一次遍历都会调用combiner函数。

为测试上述代码,可以用myReduce替换掉当前工作区内的reduce函数。

此时,你也许会想,“我为什么会想要自己实现filterreduce方法?”。回答是,“和很可能不会~”

但是,你可能会想要在Swfit中扩展当前的函数范式,实现你自己想要的函数式方法。看明白并且理解函数式多么容易实现对于你自己去实现一些强大的诸如reduce样的函数是非常重要且有激励性的。

建立索引

是时候解决一些更复杂的问题了,意味着你要新建另一个新的工作区啦。你明白你想这么做~

在本段中,你将使用函数式编程技术把一组单词通过他们的首字母分成不同的组。
在你新创建的工作区内添加如下代码:

import Foundation
 
let words = ["Cat", "Chicken", "fish", "Dog",
                      "Mouse", "Guinea Pig", "monkey"]

为了完成本阶段的任务,你将通过首字母将他们分组(大小写不敏感)。添加以下代码做准备:

typealias Entry = (Character, [String])
 
func buildIndex(words: [String]) -> [Entry] {
  return [Entry]()
}
println(buildIndex(words))

Entry为每个entry定义了元祖类型。这个例子中使用类型重命名是代码更易读,全篇不用重复的说明元祖类型。你将会在buildIndex函数内添加建立索引的代码。

命令式建立索引

命令式索引方法如下:

func buildIndex(words: [String]) -> [Entry] {
  var result = [Entry]()
 
  var letters = [Character]()
  for word in words {
    let firstLetter = Character(word.substringToIndex(
      advance(word.startIndex, 1)).uppercaseString)
 
    if !contains(letters, firstLetter) {
      letters.append(firstLetter)
    }
  }
 
  for letter in letters {
    var wordsForLetter = [String]()
    for word in words {
      let firstLetter = Character(word.substringToIndex(
        advance(word.startIndex, 1)).uppercaseString)
 
      if firstLetter == letter {
        wordsForLetter.append(word)
      }
    }
    result.append((letter, wordsForLetter))
  }
  return result
}

函数分为两个部分,每一个部分都是for循环。前一部分是根据单词数组的第一个字母建立首字母数组;后半部分遍历首字母数组,把找到以首字母开头的单词加入到对应的数组。

这个实现得到我们期待的结果如下:

[(C, [Cat, Chicken]),
 (F, [fish]),
 (D, [Dog]),
 (M, [Mouse, monkey]),
 (G, [Guinea Pig])]

(上面的输出稍微做了整理)

该命令式实现花了很多的步数和嵌套循环,这使得它很难理解。让我们来看看函数式编程是如何怎样的。

函数式编程建立索引

创建一个新的工作区间并添加如下代码:

import Foundation
 
let words = ["Cat", "Chicken", "fish", "Dog",
                      "Mouse", "Guinea Pig", "monkey"]
 
typealias Entry = (Character, [String])
 
func buildIndex(words: [String]) -> [Entry] {
  return [Entry]();
}
 
println(buildIndex(words))

这时输出是个空数组:

[]

第一步是建立索引,将单词数组转换成包含单词首字母的数组。更新buildIndex函数如下:

func buildIndex(words: [String]) -> [Entry] {
  let letters = words.map {
    (word) -> Character in
    Character(word.substringToIndex(advance(word.startIndex, 1)
      ).uppercaseString)
  }
  println(letters)
 
  return [Entry]()
}

工作区会输出大写字母的数组,每一个都对应输入单次数组的单次的每个元素。

[C, C, F, D, M, G, M]

在之前的段落中,我们遇到了filter, reduce等函数。上面的代码中,我们来介绍map,另一个数组内建API。

map函数对给定的数组的每个元素调用提供的闭包,并用每次调用的结果生成一个新的数组。你可以使用map来做转换;在这个例子中,mapString数组转换成Character数组。

目前首字母数组包含了一些重复元素,但你期望的数组是没有重复元素的。不幸的是,swift数组并没有提供内建函数去重。意味着你将要自己写一个~

在之前的段落中,你发现重新实现filter, reduce还是很简单的。所以毫无疑问添加一个去重函数也是小菜一碟~

添加如下代码在buildIndex上边:

func distinct<T: Equatable>(source: [T]) -> [T] {
  var unique = [T]()
  for item in source {
    if !contains(unique, item) {
      unique.append(item)
    }
  }
  return unique
}

distinct遍历数组,建立一个元素唯一的新数组。
distinct函数运用到buildIndex中:

func buildIndex(words: [String]) -> [Entry] {
  let letters = words.map {
    (word) -> Character in
    Character(word.substringToIndex(advance(word.startIndex, 1)
      ).uppercaseString)
  }
  let distinctLetters = distinct(letters)
  println(distinctLetters)
 
  return [Entry]()
}

你的工作区将会输出去重后的字母:

[C, F, D, M, G]

现在你有了首字母去重后的数组,下一步就是将字母转变成Entry元祖了。听起来是不是很像一个转换?这将是map的另一个工作啦~

更新buildIndex如下:

func buildIndex(words: [String]) -> [Entry] {
  let letters = words.map {
    (word) -> Character in
    Character(word.substringToIndex(advance(word.startIndex, 1)
      ).uppercaseString)
  }
  let distinctLetters = distinct(letters)
 
  return distinctLetters.map {
    (letter) -> Entry in
    return (letter, [])
  }
}

第二次调用map的目的是将字符数组装换成元祖数组。目前输出:

[(C, []), 
 (F, []), 
 (D, []), 
 (M, []), 
 (G, [])]

(再次,上面也是整理过啦~)

就快完成了。最后一步工作用给定字符开头的单词生成对应的Entry。更新函数并添加嵌套的filter如下:

func buildIndex(words: [String]) -> [Entry] {
  let letters = words.map {
    (word) -> Character in
    Character(word.substringToIndex(advance(word.startIndex, 1)
      ).uppercaseString)
  }
  let distinctLetters = distinct(letters)
 
  return distinctLetters.map {
    (letter) -> Entry in
    return (letter, words.filter {
      (word) -> Bool in
     Character(word.substringToIndex(advance(word.startIndex, 1)
       ).uppercaseString) == letter
    })
  }
}

输出结果如下:

[(C, [Cat, Chicken]),
 (F, [fish]),
 (D, [Dog]),
 (M, [Mouse, monkey]),
 (G, [Guinea Pig])]

在第二部分,使用的嵌套调用是filter而不是mapfilter能位不同的字符筛选出对应的单次数组,并且是根据首字母来定位的。

以上实现已经比命令式更加简洁明了,但是仍有提高空间;上述代码取出操作和大写转换操作太多次了。移除这些重复性是非常好的。

如果这是OC代码,你可能会有几种方式来优化:你可以创建公共方法,将方法直接通过category添加到NSString类中。但是,如果你仅仅是希望在buildIndex使用,一个公共方法显示不够清晰且有些过度。

幸运的是,使用swift,有更好的方法~

更新代码如下:

func buildIndex(words: [String]) -> [Entry] {
  func firstLetter(str: String) -> Character {
    return Character(str.substringToIndex(
            advance(str.startIndex, 1)).uppercaseString)
  }
 
  let letters = words.map {
    (word) -> Character in
    firstLetter(word)
  }
  let distinctLetters = distinct(letters)
 
  return distinctLetters.map {
    (letter) -> Entry in
    return (letter, words.filter {
      (word) -> Bool in
      firstLetter(word) == letter
    })
  }
}

上面的代码添加了firstLetter函数,该函数嵌套在buildIndex中,而且对于外部函数是隐藏的(本地函数)。利用swift一阶函数的特性,你可以想使用变量一样使用它。

新的代码合并了重复逻辑,但还有很多可做的事来整理buildIndex

第一步map是使用(String) 转 Character签名闭包生成字母数组。你可能会发现这个函数跟添加的firstLetter是一样的,这意味着你可以直接将它传给map

使用这个知识点后,可以将函数写成如下:

func buildIndex(words: [String]) -> [Entry] {
  func firstLetter(str: String) -> Character {
    return Character(str.substringToIndex(
            advance(str.startIndex, 1)).uppercaseString)
  }
 
  return distinct(words.map(firstLetter))
    .map {
      (letter) -> Entry in
      return (letter, words.filter {
        (word) -> Bool in
        firstLetter(word) == letter
      })
    }
}

最终结果是很简洁的,表达清晰的。
也许现在你注意到函数式编程有趣的一面~ 命令式解决方案需要依赖于变量(变量关键词var),而对应的在函数式里定义所有值都是常量(通过let)。

你应该更多积极地使用常量,常量易于测试且方便并行。函数式编程和不可变类型往往是紧密相连的。结果,你的代码会更加简明同时也不易出错。而且代码看起来很酷,使你的朋友刮目相看。

挑战:目前,buildIndex返回了一个未排序的索引;Entry的顺序取决于输入单词数组的元素顺序。你的任务是将Entry数组按照它的字母排序。对于上面的例子,你需要输入如下:

[(C, [Cat, Chicken]),
 (D, [Dog]),
 (F, [fish]),
 (G, [Guinea Pig]),
 (M, [Mouse, monkey])]

答案:

swift数组有个sort函数,但是这个方法会改变操作的数组而不是返回一个新的排序好的实例,并且它需要操作的数组是一个可变数组。总之,处理不可变数据更加安全,因此建议你不要使用该函数!作为替代,使用sorted方法会返回一个新的数组。

何去何从

这里是函数式编程教程所有完整的代码

恭喜~ 你已经有swift函数式编程的实战经验了。你不仅学会了如何使用函数式方法诸如:map, reduce等,还知道如何自己实现这些方法,而且学会了用函数式思考。

如果你希望学到更多函数式编程的知识,可以查看整个章节,那里会讲的更加深入也包括部分应用功能。

希望看到你自己的App中使用了函数式编程技术~~~

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,482评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,377评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,762评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,273评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,289评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,046评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,351评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,988评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,476评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,948评论 2 324
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,064评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,712评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,261评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,264评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,486评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,511评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,802评论 2 345

推荐阅读更多精彩内容