所谓函数式编程方法,是借助函数式思想对真实问题进行分析和简化,继而构建一系列简单、实用的函数,再“装配”成最终的程序,以解决问题的方法。

本章关键词

请带着以下关键词阅读本文:

  • 一等值(一等函数)
  • 模块化
  • 类型驱动

案例:Battleship

本章案例是一个关于战舰攻击范围计算的问题,描述如下:

  • 战舰能够攻击到射程范围内的敌船
  • 攻击时不能距离自身太近
  • 攻击时不能距离友船太近

问题:计算某敌船是否在安全射程范围内。

对于这个问题,我们换一种描述:

  • 输入:目标(Ship)
  • 处理:计算战舰到敌船的距离、敌船到友船的距离,判断敌船距离是否在射程内,且敌船到友船距离足够大
  • 输出:是否(Bool)

看上去问题并不复杂,我们可以产出以下代码:

typealias Distance = Double

struct Position {
    var x: Double
    var y: Double
}

struct Ship {
    var position: Position
    var firingRange: Distance
    var unsafeRange: Distance
}

extension Ship {
    func canSafelyEngageShip(target: Ship, friendly: Ship) -> Bool {
        let dx = target.position.x - position.x
        let dy = target.position.y - position.y
        let targetDistance = sqrt(dx * dx + dy * dy)
        let friendlyDx = friendly.position.x - target.position.x
        let friendlyDy = friendly.position.y - target.position.y
        let friendlyDistance = sqrt(friendlyDx * friendlyDx + friendlyDy * friendlyDy)
        return targetDistance <= firingRange
            && targetDistance > unsafeRange 
            && friendlyDistance > unsafeRange
    }
}复制代码

可以看出,canSafelyEngageShip 方法分别计算了我们需要的两个距离:targetDistancefriendlyDistance,随后与战舰的射程 firingRange 和安全距离 unsafeRange 进行比较。

功能看上去没有什么问题了,如果觉得 canSafelyEngageShip 方法过于繁琐,还可以添加一些辅助函数:

extension Position {
    func minus(p: Position) -> Position {
        return Position(x: x - p.x, y: y - p.y)
    }
    var length: Double {
        return sqrt(x * x + y * y)
    }
}

extension Ship {
    func canSafelyEngageShip(target: Ship, friendly: Ship) -> Bool {
        let targetDistance = target.position.minus(p: position).length
        let friendlyDistance = friendly.position.minus(p: target.position).length
        return targetDistance <= firingRange
            && targetDistance > unsafeRange
            && (friendlyDistance > unsafeRange)
    }
}复制代码

到此,我们编写了一段比较直观且容易理解的代码,但由于我们使用了非常“过程式”的思维方式,所以扩展起来就不太容易了。比如,再添加一个友船,我们就需要再计算一个 friendlyDistance_2,这样下去,代码会变得很复杂、难理解。

为了更好解决这个问题,我们先介绍一个概念:一等值(First-class Value),或者称为 一等函数(First-class Function)

我们来看看维基上的解释:

In computer science, a programming language is said to have first-class functions if it treats functions as first-class citizens. Specifically, this means the language supports passing functions as arguments to other functions, returning them as the values from other functions, and assigning them to variables or storing them in data structures.

简单来说,就是函数与普通变量相比没有什么特殊之处,可以作为参数进行传递,也可以作为函数的返回值。在 Swift 中,函数是一等值。带着这个思维,我们尝试使用更加声明式的方式来思考这个问题。

归根结底,就是定义一个函数来判断一个点是否在范围内,所以我们要的就是一个输入 Position,输出 Bool 的函数:

func pointInRange(point: Position) -> Bool {
    ...
}复制代码

然后,我们就可以用这个能够判断一个点是否在区域内的函数来表示一个区域,为了更容易理解,我们给这个函数起个名字(因为函数是一等值,所以我们可以像变量一样为其设置别名):

typealias Region = (Position) -> Bool复制代码

我们将攻击范围理解为可见区域,超出攻击范围或处于不安全范围均视为不可见区域,那么可知:

有效区域 = 可见区域 - 不可见区域

如此,问题从距离运算演变成了区域运算。明确问题后,我们可以定义以下区域:

// 圆心为原点,半径为 radius 的圆形区域
func circle(radius: Distance) -> Region {
    return { point in point.length <= radius }
}

// 圆心为 center,半径为 radius 的圆形区域
func circle2(radius: Distance, center: Position) -> Region {
    return { point in point.minus(p: center).length <= radius }
}

// 区域变换函数
func shift(region: @escaping Region, offset: Position) -> Region {
    return { point in region(point.minus(p: offset)) }
}复制代码

前两个函数很容易理解,但第三个区域有些特别,它将一个输入的 region 通过 offset 变化后返回一个新的 region。为什么要有这样一个特殊“区域”呢?其实,这是函数式编程的一个核心概念,为了避免产生 circle2 这样会不断扩展然后变复杂的函数,通过一个函数来改变另一个函数的方式更加合理。例如,一个圆心为 (5,5) 半径为 10 的圆就可以用下面的方式来表示了:

shift(region: circle(radius: 10), offset: Position(x: 5, y: 5))复制代码

掌握了 shift 式的区域定义方法,我们可以继续定义以下“区域”:

// 将原区域取反得到新区域
func invert(region: @escaping Region) -> Region {
    return { point in !region(point) }
}

// 取两个区域的交集作为新区域
func intersection(region1: @escaping Region, _ region2: @escaping Region) -> Region {
    return { point in region1(point) && region2(point) }
}

// 取两个区域的并集作为新区域
func union(region1: @escaping Region, _ region2: @escaping Region) -> Region {
    return { point in region1(point) || region2(point) }
}

// 取在一个区域,且不在另一个区域,得到新区域
func difference(region: @escaping Region, minus: @escaping Region) -> Region {
    return intersection(region1: region, invert(region: minus))
}复制代码

很轻松有木有!

基于这个小型工具库,我们来改写案例中的代码,并与之前的代码进行对比:

// After
func canSafelyEngageShip(target: Ship, friendly: Ship) -> Bool {
    let rangeRegion = difference(region: circle(radius: firingRange),
     minus: circle(radius: unsafeRange))
    let firingRegion = shift(region: rangeRegion, offset: position)
    let friendlyRegion = shift(region: circle(radius: unsafeRange),
     offset: friendly.position)
    let resultRegion = difference(region: firingRegion, minus: friendlyRegion)
    return resultRegion(target.position)
}

// Before
func canSafelyEngageShip(target: Ship, friendly: Ship) -> Bool {
    let targetDistance = target.position.minus(p: position).length
    let friendlyDistance = friendly.position.minus(p: target.position).length
    return targetDistance <= firingRange
      && targetDistance > unsafeRange
      && (friendlyDistance > unsafeRange)
}复制代码

借助以上函数式的思维方式,我们避开了具体问题中一系列的复杂数值计算,得到了易读、易维护、易迁移的代码。


思考

一等值(一等函数)

一等值这个名词我们可能较少听到,但其概念却是渗透在我们日常开发过程中的。将函数与普通变量对齐,是很重要的一项语言特性,不仅是编码过程,在简化问题上也能为我们带来巨大的收益。

例如本文案例,我们使用函数描述区域,然后使用区域运算代替距离运算,在区域运算中,又使用了诸如 shift 的函数式思想,进而将问题进行简化并最终解决。

模块化

在《函数式 Swift》的前言部分,有一段对模块化的描述:

相比于把程序认为是一系列赋值和方法调用,函数式开发者更倾向于强调每个程序都能被反复分解为越来越小的模块单元,而所有这些模块可以通过函数装配起来,以定义一个完整的程序。

模块化是一个听上去很酷,遇到真实问题后有时又会变得难以下手,本文案例中,原始问题看上去目标简单并且明确,只需要一定的数值计算就可以得到最终结果,但当我们借助函数式思维,将问题的解决转变为区域运算后,关注点就转变为区域的定义上,然后进一步分解为区域变换、交集、并集、差集等模块,最后,将这些模块“装配”起来,问题的解决也就顺理成章了。

类型驱动

这里的类型,对应着前文中我们定义的 Region,因为我们选用了 Region 这个函数式定义来描述案例中的基本问题单元,即判断一个点是否在区域内,从而使我们的问题转变为了区域运算。

可见,我们是一种“类型驱动”的问题解决方式,或者说是编码方式,类型的选择决定了我们解决问题的方向,假如我们坚持使用 PositionDistance,那么解决问题的方向必然陷入此类数值运算中,显然,函数式的类型定义帮助我们简化并且更加优雅的解决了问题。