Swift编程思想(一) —— 函数式编程简介(一)

版本记录

版本号 时间
V1.0 2019.02.23 星期六

前言

Swift作为一门开发语言,它也有自己的特点和对应的编程特点,接下来我们就一起看一下这门语言。让我们一起熟悉和学习它。

开始

首先看下写作环境

Swift 4.2, iOS 12, Xcode 10

在本教程中,您将逐步学习如何开始使用函数式编程以及如何编写声明性代码而不是命令式代码。

Swift 2014年在WWDC上进入编程世界的大门不仅仅是对新语言的介绍。 它为iOS和macOS平台的软件开发提供了便利。

本教程重点介绍其中一种方法:Functional Programming,简称FP。 您将了解FP中使用的各种想法和技术。

在Xcode中创建一个新的空playground,以便您可以通过选择File ▸ New ▸ Playground…来继续教程。

Swift编程思想(一) —— 函数式编程简介(一)_第1张图片

设置您的playground,以便通过拖动分割来查看“结果面板”(Results Panel)和控制台。

Swift编程思想(一) —— 函数式编程简介(一)_第2张图片

现在,从playground上删除所有内容,然后添加以下行:

import Foundation

你将首先回顾一些基本理论来热热身。


Imperative Programming Style

当你第一次学习编码时,你可能学会了命令式(imperative style)的风格。 命令式风格如何运作?

将以下代码添加到您的playground

var thing = 3
//some stuff
thing = 4

该代码是正常和合理的。首先,你创建一个名为thing的变量,它等于3,然后你命令thing变为4。

简而言之,这是势在必行的风格。您使用一些数据创建变量,然后将该变量称为其他变量。


Functional Programming Concepts

在本节中,您将了解FP中的一些关键概念。许多论文讨论了FP的immutable statelack of side effects作为FP最重要的方面,所以你将从那里开始。

1. Immutability and Side Effects

无论您首先学习哪种编程语言,您可能学到的最初概念之一是变量代表数据或状态。如果你退一步思考这个想法,变量看起来很奇怪。

术语“变量”表示随程序运行而变化的量。从数学角度思考数量thing,您已经将时间作为软件行为方式的关键参数。通过更改变量,可以创建可变状态。

要进行演示,请将此代码添加到您的playground

func superHero() {
  print("I'm batman")
  thing = 5
}

print("original state = \(thing)")
superHero()
print("mutated state = \(thing)")

神圣的神秘变化!为什么thing现在是5了?这种变化被称为side effect。函数superHero()更改了一个甚至没有定义自己的变量。

单独或在简单系统中,可变状态不一定是问题。将许多对象连接在一起时会出现问题,例如在大型面向对象系统中。可变状态可能会让人很难理解变量的值以及该值随时间的变化。

例如,在为多线程系统编写代码时,如果两个或多个线程同时访问同一个变量,则它们可能会无序地修改或访问它。这会导致意外行为。这种意外行为包括竞争条件,死锁和许多其他问题。

想象一下,如果您可以编写状态从未发生过变化的代码。并发系统中出现的一大堆问题将会消失。像这样工作的系统具有不可变状态,这意味着不允许状态在程序的过程中发生变化。

使用不可变数据的主要好处是使用它的代码单元没有side effects。代码中的函数不会改变自身之外的元素,并且在发生函数调用时不会出现任何怪异的效果。您的程序可以预测,因为没有副作用,您可以轻松地重现其预期的效果。

本教程涵盖了高级FP,因此在现实环境中考虑概念是有帮助的。在这种情况下,假设您正在为amusement park构建应用程序,并且该公园的后端服务器通过REST API提供乘坐数据。

2. Creating a Model Amusement Park

通过将此代码添加到您的playground来设置数据结构:

enum RideCategory: String, CustomStringConvertible {
  case family
  case kids
  case thrill
  case scary
  case relaxing
  case water

  var description: String {
    return rawValue
  }
}

typealias Minutes = Double
struct Ride: CustomStringConvertible {
  let name: String
  let categories: Set
  let waitTime: Minutes

  var description: String {
    return "Ride –\"\(name)\", wait: \(waitTime) mins, " +
      "categories: \(categories)\n"
  }
}

下面使用那个模型创建一些数据

let parkRides = [
  Ride(name: "Raging Rapids",
       categories: [.family, .thrill, .water],
       waitTime: 45.0),
  Ride(name: "Crazy Funhouse", categories: [.family], waitTime: 10.0),
  Ride(name: "Spinning Tea Cups", categories: [.kids], waitTime: 15.0),
  Ride(name: "Spooky Hollow", categories: [.scary], waitTime: 30.0),
  Ride(name: "Thunder Coaster",
       categories: [.family, .thrill],
       waitTime: 60.0),
  Ride(name: "Grand Carousel", categories: [.family, .kids], waitTime: 15.0),
  Ride(name: "Bumper Boats", categories: [.family, .water], waitTime: 25.0),
  Ride(name: "Mountain Railroad",
       categories: [.family, .relaxing],
       waitTime: 0.0)
]

由于使用let而不是var声明parkRides,因此数组及其内容都是不可变的。

尝试通过以下方法修改数组中的一个项目:

parkRides[0] = Ride(name: "Functional Programming",
                    categories: [.thrill], waitTime: 5.0)

这会产生编译器错误,这很好。 您希望Swift编译器阻止您更改数据。

现在,删除这些行,以便继续学习本教程。

3. Modularity

使用模块化就像玩儿童积木一样。 你有一盒简单的砖块,可以通过将它们连接在一起来构建一个庞大而复杂的系统。 每块砖都有一份工作。 您希望您的代码具有相同的效果。

假设您需要一个按字母顺序排列的所有游乐设施名称列表。 从命令性地开始这样做,这意味着利用可变状态。 将以下函数添加到playground的底部:

func sortedNamesImp(of rides: [Ride]) -> [String] {

  // 1
  var sortedRides = rides
  var key: Ride

  // 2
  for i in (0..

下面进行细分

  • 1) 创建一个变量来保存已排序的游乐设施。
  • 2) 遍历传递给函数的所有游乐设施。
  • 3) 使用“插入排序”(Insertion Sort)排序算法对游乐设施进行排序。
  • 4) 遍历已排序的游乐设施以收集名称。

将以下测试添加到playground中以验证此函数是否按预期运行:

func testSortedNames(_ names: [String]) {
  let expected = ["Bumper Boats",
                  "Crazy Funhouse",
                  "Grand Carousel",
                  "Mountain Railroad",
                  "Raging Rapids",
                  "Spinning Tea Cups",
                  "Spooky Hollow",
                  "Thunder Coaster"]
  assert(names == expected)
  print("✅ test sorted names = PASS\n-")
}

print(sortedNames1)
testSortedNames(sortedNames1)

您现在知道,如果您将来更改排序例程,您可以检测到发生的任何错误。

从调用者到sortedNamesImp(of :)的角度来看,它提供了一个游乐设施列表,然后输出了已排序名称的列表。 sortedNamesImp(of :)之外的任何内容都没有变化。

你可以用另一个测试证明这一点。 将以下代码添加到playground的末尾:

var originalNames: [String] = []
for ride in parkRides {
  originalNames.append(ride.name)
}

func testOriginalNameOrder(_ names: [String]) {
  let expected = ["Raging Rapids",
                  "Crazy Funhouse",
                  "Spinning Tea Cups",
                  "Spooky Hollow",
                  "Thunder Coaster",
                  "Grand Carousel",
                  "Bumper Boats",
                  "Mountain Railroad"]
  assert(names == expected)
  print("✅ test original name order = PASS\n-")
}

print(originalNames)
testOriginalNameOrder(originalNames)

在此测试中,您将收集作为参数传递的游乐设施列表的名称,并根据预期的顺序测试该顺序。

在结果区域和控制台中,您将看到sortedNamesImp(of :)内的排序骑行不会影响输入列表。您创建的模块化功能是半功能的。按名称排序游乐设施的逻辑是单一,可测试,模块化和可重复使用的功能。

sortedNamesImp(of :)中的命令式代码用于长而笨重的函数。该功能难以阅读,您无法轻松理解它的功能。在下一节中,您将学习如何进一步简化sortedNamesImp(of :)等函数中的代码。


First-Class and Higher-Order Functions

在FP语言中,functions are first-class citizens,您可以将函数视为可以分配给变量的其他对象。

因此,函数也可以接受其他函数作为参数或返回其他函数。接受或返回其他函数的函数称为高阶函数。

在本节中,您将使用FP语言中三种最常见的高阶函数:filtermapreduce

1. Filter

在Swift中,filter(_ :)Collection类型的方法,例如Swift数组。它接受另一个函数作为参数。此另一个函数接受来自数组的单个值作为输入,检查该值是否属于并返回Bool

filter(_ :)将输入函数应用于调用数组的每个元素,并返回另一个数组。输出数组仅包含参数函数返回true的数组元素。

试试这个简单的例子:

let apples = ["", "", "", "", ""]
let greenapples = apples.filter { $0 == ""}
print(greenapples)

输入列表中有三个绿苹果,因此您将在输出中看到三个绿苹果。

回想一下sortedNamesImp(of :)执行的操作列表:

  • 1) 传递给函数的所有游乐设施的循环。
  • 2) 按名称对游乐设施进行排序。
  • 3) 收集已排序的游乐设施的名称。

不要命令式的方式考虑这一点,而是以声明的方式思考它,即只考虑你想要发生什么而不是如何发生。 首先创建一个函数,该函数将Ride对象作为函数的输入参数:

func waitTimeIsShort(_ ride: Ride) -> Bool {
  return ride.waitTime < 15.0
}

函数waitTimeIsShort(_ :)接受Ride,如果等待时间小于15分钟则返回true;否则,它返回false

在您的公园骑行中调用filter(_:)并传入您刚刚创建的新函数:

let shortWaitTimeRides = parkRides.filter(waitTimeIsShort)
print("rides with a short wait time:\n\(shortWaitTimeRides)")

playground输出中,你只能在调用filter(_:)的输出中看到Crazy FunhouseMountain Railroad,这是正确的。

由于Swift函数也称为闭包,因此可以通过将尾随闭包传递给filter并使用闭包语法来生成相同的结果:

let shortWaitTimeRides2 = parkRides.filter { $0.waitTime < 15.0 }
print(shortWaitTimeRides2)

在这里,filter(_ :)将每次乘坐parkRides - 由$ 0表示 - 查看其waitTime属性并测试它是否少于15分钟。 你是声明性的并告诉程序你想要它做什么。 在你使用它的前几次看起来相当神秘。

2. Map

Collection方法map(_:)接受单个函数作为参数。 在将该函数应用于集合的每个元素之后,它输出一个相同长度的数组。 映射函数的返回类型不必与集合元素的类型相同。

试试这个:

let oranges = apples.map { _ in "" }
print(oranges)

你将每个苹果映射成橙色,产生一个橘子盛宴。

您可以将map(_ :)应用于parkRides数组的元素,以获取所有骑行名称的列表作为字符串:

let rideNames = parkRides.map { $0.name }
print(rideNames)
testOriginalNameOrder(rideNames)

你已经证明使用map(_ :)获取骑行名称与迭代整个集合的内容相同,就像你之前做的那样。

当您使用Collection类型上的sorted(by :)方法执行排序时,您还可以按如下所示对骑行名称进行排序:

print(rideNames.sorted(by: <))

Collection方法sorted(by:)采用一个比较两个元素的函数,并返回一个Bool作为参数。 因为运算符<是花哨的函数,所以可以使用Swift简写作为尾随闭包{$ 0 <$ 1}。 Swift默认提供左侧和右侧。

您现在可以减少代码,以便将行程名称提取并排序为仅两行,这要归功于map(_ :)sorted(by :)

使用以下代码将sortedNamesImp(_ :)重新实现为sortedNamesFP(_ :)

func sortedNamesFP(_ rides: [Ride]) -> [String] {
  let rideNames = parkRides.map { $0.name }
  return rideNames.sorted(by: <)
}

let sortedNames2 = sortedNamesFP(parkRides)
testSortedNames(sortedNames2)

您的声明性代码更易于阅读,您可以毫不费力地弄清楚它是如何工作的。 测试证明sortedNamesFP(_ :)sortedNamesImp(_ :)完全相同。

3. Reduce

Collection方法reduce(_:_ :)有两个参数:第一个是任意类型T的起始值,第二个是将相同类型T的值与集合中的元素组合以产生另一个值的函数T型。

输入函数一个接一个地应用于调用集合的每个元素,直到它到达集合的末尾并产生最终的累计值。

例如,您可以将这些橙子减少为一些果汁:

let juice = oranges.reduce("") { juice, orange in juice + ""}
print("fresh  juice is served – \(juice)")

在这里,您从一个空字符串开始。 然后为每个橙色的字符串添加。 这段代码可以取代任何数组,所以要小心你放入它。

更实际的是,添加以下方法,让您知道公园内所有游乐设施的总等待时间。

let totalWaitTime = parkRides.reduce(0.0) { (total, ride) in 
  total + ride.waitTime 
}
print("total wait time for all rides = \(totalWaitTime) minutes")

此函数的工作原理是将0.0的起始值传递给reduce并使用尾随闭包语法来增加每次乘坐对总等待时间的贡献。 代码再次使用Swift简写来省略return关键字。 默认情况下,返回total + ride.waitTime的结果。

在此示例中,迭代看起来像这样:

Iteration    initial    ride.waitTime    resulting total
    1          0            45            0 + 45 = 45
    2         45            10            45 + 10 = 55
    …
    8        200             0            200 + 0 = 200

如您所见,结果总计将作为后续迭代的初始值。 这种情况一直持续到reduce迭代遍历parkRides中的每个Ride。 这允许您通过一行代码获得总数!


Advanced Techniques

您已经了解了一些常见的FP方法。 现在是时候用一些更多的函数理论来进一步研究。

1. Partial Functions

Partial函数允许您将一个函数封装在另一个函数中。 要查看其工作原理,请将以下方法添加到playground中:

func filter(for category: RideCategory) -> ([Ride]) -> [Ride] {
  return { rides in
    rides.filter { $0.categories.contains(category) }
  }
}

这里,filter(for :)接受一个RideCategory作为参数,并返回一个类型([Ride]) - > [Ride]的函数。 输出函数接受一个Ride对象数组,并返回由提供的类别过滤的Ride对象数组。

通过寻找适合小孩的游乐设施来检查过滤器:

let kidRideFilter = filter(for: .kids)
print("some good rides for kids are:\n\(kidRideFilter(parkRides))")

您应该在控制台输出中看到Spinning Tea CupsGrand Carousel

2. Pure Functions

FP中的一个主要概念是让您了解程序结构以及测试程序结果,这是纯函数(pure function)的概念。

如果符合两个标准,则函数是纯粹的:

  • 当给定相同的输入时,该函数总是产生相同的输出,例如,输出仅取决于其输入。
  • 该函数在其外部创建零副作用。

将以下纯函数添加到您的playground

func ridesWithWaitTimeUnder(_ waitTime: Minutes, 
    from rides: [Ride]) -> [Ride] {
  return rides.filter { $0.waitTime < waitTime }
}

ridesWithWaitTimeUnder(_:from :)是一个纯函数,因为当给定相同的等待时间和相同的游乐列表时,它的输出总是相同的。

使用纯函数,可以很容易地针对函数编写好的单元测试。 将以下测试添加到您的playground

let shortWaitRides = ridesWithWaitTimeUnder(15, from: parkRides)

func testShortWaitRides(_ testFilter:(Minutes, [Ride]) -> [Ride]) {
  let limit = Minutes(15)
  let result = testFilter(limit, parkRides)
  print("rides with wait less than 15 minutes:\n\(result)")
  let names = result.map { $0.name }.sorted(by: <)
  let expected = ["Crazy Funhouse",
                  "Mountain Railroad"]
  assert(names == expected)
  print("✅ test rides with wait time under 15 = PASS\n-")
}

testShortWaitRides(ridesWithWaitTimeUnder(_:from:))

注意如何将函数ridesWithWaitTimeUnder(_:from :)传递给测试。 请记住,functions are first-class citizen,您可以像其他任何数据一样传递它们。 这在下一部分会派上用场。

此外,您再次使用map(_ :)sorted(by :)来提取名称以运行您的测试。 您正在使用FP来测试您的FP技能。

3. Referential Transparency

纯函数与引用透明度(referential transparency)的概念有关。 如果您可以使用其定义替换它,并且始终产生相同的结果,则程序的元素是引用透明的。 它产生可预测的代码并允许编译器执行优化。 纯函数满足这种条件。

您可以通过将函数传递给testShortWaitRides(_ :)来验证函数ridesWithWaitTimeUnder(_:from :)是否是引用透明的:

testShortWaitRides({ waitTime, rides in
    return rides.filter{ $0.waitTime < waitTime }
})

在这段代码中,您获取了ridesWithWaitTimeUnder(_:from :)的主体并将其直接传递给封装语法中包含的测试testShortWaitRides(_ :)。这证明ridesWithWaitTimeUnder(_:from :)是参考透明的。

当你重构一些代码并且你想要确保你没有破坏任何东西时,引用透明度会派上用场。引用透明的代码不仅易于测试,而且还允许您移动代码而无需验证实现。

4. Recursion

讨论的最后一个概念是递归。只要函数将自身称为其函数体的一部分,就会发生递归。在函数式语言中,递归取代了在命令式语言中使用的许多循环结构。

当函数的输入导致调用自身的函数时,你有一个递归的情况。为了避免无限的函数调用堆栈,递归函数需要一个基本案例来结束它们。

您将为您的游乐设施添加递归排序功能。首先使用以下扩展使Ride符合Comparable

extension Ride: Comparable {
  public static func <(lhs: Ride, rhs: Ride) -> Bool {
    return lhs.waitTime < rhs.waitTime
  }

  public static func ==(lhs: Ride, rhs: Ride) -> Bool {
    return lhs.name == rhs.name
  }
}

在此扩展中,您使用运算符重载来创建允许您比较两个游乐设施的功能。 您还可以看到之前在sorted(by :)中使用的<运算符的完整函数声明。

如果等待时间较短,则一次乘车少于另一次乘坐,如果乘坐具有相同名称,则乘坐次数相等。

现在,扩展Array以包含quickSorted方法:

extension Array where Element: Comparable {
  func quickSorted() -> [Element] {
    if self.count > 1 {
      let (pivot, remaining) = (self[0], dropFirst())
      let lhs = remaining.filter { $0 <= pivot }
      let rhs = remaining.filter { $0 > pivot }
      return lhs.quickSorted() + [pivot] + rhs.quickSorted()
    }
    return self
  }
}

只要元素是Comparable,此扩展允许您对数组进行排序。

快速排序(Quick Sort)算法首先选择一个枢轴(pivot)元素。 然后将集合分为两部分。 一部分保持所有小于或等于枢轴的元素,另一部分保持剩余元素大于枢轴。 然后使用递归对两部分进行排序。 请注意,通过使用递归,您不需要使用可变状态。

输入以下代码验证您的函数是否有效:

let quickSortedRides = parkRides.quickSorted()
print("\(quickSortedRides)")


func testSortedByWaitRides(_ rides: [Ride]) {
  let expected = rides.sorted(by:  { $0.waitTime < $1.waitTime })
  assert(rides == expected, "unexpected order")
  print("✅ test sorted by wait time = PASS\n-")
}

testSortedByWaitRides(quickSortedRides)

在这里,您检查您的解决方案是否与受信任的Swift标准库函数中的预期值匹配。

请记住,递归函数具有额外的内存使用和运行时开销。在数据集变得更大之前,您不必担心这些问题。


Imperative vs. Declarative Code Style

在本节中,您将结合您对FP的了解,以清楚地演示函数式编程的优势。

考虑以下情况:

A family with young kids wants to go on as many rides as possible between frequent bathroom breaks. They need to find which kid-friendly rides have the shortest lines. Help them out by finding all family rides with wait times less than 20 minutes and sort them by the shortest to longest wait time.

一个有小孩的家庭希望在频繁的浴室休息之间尽可能多地骑行。 他们需要找到哪些适合儿童的游乐设施有最短的线路。 通过找到等待时间少于20分钟的所有家庭游乐设施帮助他们,并按最短到最长的等待时间对其进行排序。

1. Solving the Problem with the Imperative Approach

考虑如何使用命令式算法解决此问题。试着实现自己的问题解决方案。

您的解决方案可能类似于:

var ridesOfInterest: [Ride] = []
for ride in parkRides where ride.waitTime < 20 {
  for category in ride.categories where category == .family {
    ridesOfInterest.append(ride)
    break
  }
}

let sortedRidesOfInterest1 = ridesOfInterest.quickSorted()
print(sortedRidesOfInterest1)

将其添加到您的playground并执行它。 你应该看到Mountain Railroad,Crazy FunhouseGrand Carousel是最好的骑行选择,而且这个列表是增加等待时间的顺序。

正如所写,命令式代码很好,但快速浏览并不能清楚,直接地了解它正在做什么。 您必须暂停以详细查看算法才能掌握它。 当您在六个月后返回维护时,或者如果您将其交给新开发人员时,代码是否易于理解?

添加此测试以将FP方法与您的命令式解决方案进行比较:

func testSortedRidesOfInterest(_ rides: [Ride]) {
  let names = rides.map { $0.name }.sorted(by: <)
  let expected = ["Crazy Funhouse",
                  "Grand Carousel",
                  "Mountain Railroad"]
  assert(names == expected)
  print("✅ test rides of interest = PASS\n-")
}

testSortedRidesOfInterest(sortedRidesOfInterest1)

2. Solving the Problem with a Functional Approach

使用FP解决方案,您可以使代码更加不言自明。 将以下代码添加到您的playground

let sortedRidesOfInterest2 = parkRides
    .filter { $0.categories.contains(.family) && $0.waitTime < 20 }
    .sorted(by: <)

通过添加以下内容,验证此代码行是否产生与命令式代码相同的输出:

testSortedRidesOfInterest(sortedRidesOfInterest2)

在一行代码中,你告诉Swift要计算什么。您想要将parkRides过滤到.family,等待时间少于20分钟,然后对它们进行排序。这完全解决了上述问题。

生成的代码是声明性的,这意味着它是不言自明的,并且读起来就像它解决的问题陈述。

这与命令式代码不同,命令式代码类似于计算机为解决问题陈述而必须采取的步骤。


The When and Why of Functional Programming

Swift不仅仅是一种功能语言,但它确实结合了多种编程范例,为您提供了应用程序开发的灵活性。

开始使用FP技术的好地方是您的模型层以及应用程序的业务逻辑出现的任何位置。您已经看到为该逻辑创建离散测试是多么容易。

对于用户界面,不太清楚您要在哪里使用FP技术。反应式编程(Reactive programming)是用于UI开发的类似FP的方法的示例。例如,RxSwift是用于iOS和macOS编程的反应库。

通过采用功能性的声明式方法,您的代码变得更加简洁明了。此外,当代码被隔离为没有副作用的模块化功能时,您的代码将更容易测试。

当您希望最大限度地发挥多核CPU的全部潜力时,最大限度地减少副作用和并发问题非常重要。对于那些问题,FP是一个很好的工具。

后记

本篇主要讲述了Swift 函数式编程简介,感兴趣的给个赞或者关注~~~

Swift编程思想(一) —— 函数式编程简介(一)_第3张图片

你可能感兴趣的:(Swift编程思想(一) —— 函数式编程简介(一))