阅读 1090

函数式编程 - 酷炫Applicative(应用函子) [Swift描述]

Applicative

引言

Applicative functor(应用函子),简称Applicative,作为函数式编程里面一个比较重要的概念,其具有十分酷炫的特性,在工程上的实用性也非常高。这篇文章将会以工程的角度不断深入、层层剖析Applicative,在阐述其概念的同时也会结合小Demo进行实战演示。

很多函数式编程的概念在我之前写的文章中已经介绍过,一些相关的也会将在这篇文章中被重复提及,以加深认识。

Functor(函子)

Applicative本身也是Functor,其基于Functor有一套自己额外的概念,用面向对象的角度去理解,可以认为Applicative继承自Functor。在进一步介绍Applicative之前,我们先来认识一下Functor

概念及引入

什么是Functor?为了说明,这里引入Swift的一个结构:Optional:

Optional代表数据可空,其存在两种状态,要么其中具有数据,要么其为空(nil)

var name: String? = nil
name = "Tangent"
复制代码

让我们来模拟一个场景,假设现在有一个函数,会在传入的字符串前拼接"Hello",返回一个新的字符串,它会是这样:

func sayHello(_ str: String) -> String {
    return "Hello " + str
}
复制代码

对于装有String的Optional类型String?(Optional<String>),它的值将不能直接传入这个函数中,因为String?并不等同于String,而且编译器并不会在我们将Optional值传入非Optional类型变量的时候做自动类型转换的优化(反过来就可以)。要得到结果值,传统的做法我们可能会这么写:

let name: String? = "Tangent"

// 使用变量
var result: String?
if let str = name {
    result = sayHello(str)
}

// 直接闭包调用
let result: String? = {
    guard let str = name else { return nil }
    return sayHello(str)
}()
复制代码

上面的两种写法都是分情况考虑:假如原来的字符串为空,那么结果值也理所当然是空,否则将字符串解包后传入函数中返回结果。比起纯粹地处理非Optional数据,这里我们需要多做判空这一步。

让我们来换个角度思考,对于Optional类型,它不仅要存储数据本身,还要去记录数据是否为空的标志,所以我们对Optional值进行处理时,我们除了要处理其中的数据,还要考虑它所携带的是否为空的标志。它就像一个盒子,盒子里装有值,本身还具备一些额外的元信息(我们也可以称其为副作用)。

当我们操作盒子的时候,我们需要把盒子里的数据拿出来,并且要考虑到盒子其中所携带的额外信息,像上面的代码所示,我们做的不仅要处理Optional中的数据,还需要对Optional进行判空处理。这里还有一个非常重要的点:我们在上面代码所做的处理中,并没有更改到盒子里的额外信息,若原来数据是空的,那么结果值也会是空,同理若原来数据非空,结果值也不可能为空。

现在,我们可以把上面的操作进行抽象:

  • 存在一种像盒子一样的数据类型,除了包含内部的数据本身外,可能还携带一些额外的元信息
  • 需要对这个盒子数据类型内部的数据进行一些处理转换
  • 在处理转换的过程中并不会改变其中额外的元信息

为了表示上面的抽象,我们可以引入Functor(函子)

实现

为了方便描述,对于这种盒子数据类型,我们可以叫做Context<A>,其中,A代表内部所装载数据的类型。Functor中存在一种运算,名字可以叫做mapmap的类型用类似Swift语法的描述可以理解为:map: ((A) -> B) -> Context<A> -> Context<B>,你可以理解为它将一个作用于盒子内部数据的函数提升为一个能作用于盒子的函数,也可以认为它接收一个作用于盒子内部的函数和一个盒子,先将盒子打开,将函数扔进去作用于盒子内部,然后得到一个具有新数据的盒子。若这个盒子实现了此运算,我们可以认为这个盒子实现了Functor,就像Swift中的协议实现一样(对于Functor的实现其实还有一些约定,本篇文章不在此详述,如果你有兴趣可以去查阅Functor相关概念进行深入了解)。

当然Swift作为一门支持面向对象的语言,我们也可以从面向对象的角度去实现Functor,这里拿Optional举个例子:

// Optional在Swift中的定义
enum Optional<Wrapped> {
	case some(Wrapped)
	case none
}

// 为Optional实现Functor
extension Optional {
    // 使用传统的模式匹配来实现
    func map<U>(_ f: (Wrapped) -> U) -> U? {
        guard case .some(let value) = self else { return nil }
        return f(value)
    }
    
    // 使用Swift语法糖来实现
    func map2<U>(_ f: (Wrapped) -> U) -> U? {
        guard let value = self else { return nil }
        return f(value)
    }
}
复制代码

这样,我们就可以使用map运算来重写之前的小例子了:

func sayHello(_ str: String) -> String {
    return "Hello " + str
}

let name: String? = "Tangent"

let result = name.map(sayHello)
复制代码

Swift其实默认已经为Optional定义了map操作,我们在开发中也可以直接拿来使用。

得益于Functor,当我们在遇到类似的情况时,可以只关注于数据处理本身,而不需要花精力于额外的元信息上,代码的实现更简洁优雅。

Swift默认实现了Functor的还有Sequence

let arr = [1, 2, 3, 4, 5]
let result = arr.map { 2 * $0 }
复制代码

运算符

我们可以为map运算定义运算符<^>,以便在后续使用:

precedencegroup FunctorApplicativePrecedence {
    higherThan: AdditionPrecedence
    associativity: left
}

infix operator <^> : FunctorApplicativePrecedence

func <^> <A, B>(lhs: (A) -> B, rhs: A?) -> B? {
    return rhs.map(lhs)
}
复制代码

这样,我们就可以从更函数式的角度来使用Functor

func sayHello(_ str: String) -> String {
    return "Hello " + str
}

let name: String? = "Tangent"
let result = sayHello <^> name
复制代码

值得注意的是,这里<^>运算符左边的类型为函数,右边为盒子类型,看起来跟面向对象的习惯性写法有点相反。

虽说Swift应尽量避免定义一堆奇奇怪怪的运算符,以免导致代码的可读性降低、增加理解成本,但是<^>运算符其实跟Haskell语言中的<$>十分相似,而且它们功能都是相同的,同理,即将在文章后面定义的<*>运算符在Haskell中你也能找到相同功能的<*>,这些运算符所表达的逻辑可以说是约定俗成的。

Applicative

Applicative基于Functor。比起FunctorApplicative更为抽象复杂,为了能容易理解,本篇接下来将先介绍它的概念以及实现,在最后我们才去结合函数式编程的其他概念来分析它的使用场景,进行项目实战。

概念

用回在上文提到的盒子模型,Context<A>是一个内部包含A类型数据的盒子,Functormap操作将传入(A) -> B函数,将盒子打开,作用于里面的数据,返回新的的盒子Context<B>。在这期间,改变的只是盒子内部的数据,而盒子中具有的额外元信息将不受影响。而对于Applicative而言,其具有apply操作,用Swift语法描述其类型可以是:apply: Context<(A) -> B> -> Context<A> -> Context<B>,你可以将它的运算逻辑理解为以下几个步骤:

  1. 传入a盒子Context<A>以及b盒子Context<(A) -> B>,a盒子里面装着单纯的数据,而b盒子里面装有一个处理函数
  2. 将a盒子中的数据取出,将b盒子中的函数取出,然后将函数作用于数据,得到类型为B的新值
  3. 将a盒子和b盒子所具有的额外元信息取出,相互作用得到新的元信息
  4. 把新的值和元信息装入盒子,得到结果Context<B>

由上我们可以发现,FunctormapApplicativeapply其实十分相似,比起mapapply需要接收的是一个包装着函数的盒子,而不是纯粹的函数类型。另外,map在运作的过程中不会对额外的元信息产生影响,apply因为其接收的参数都是盒子,它们都具有各自的元信息,所以这里需要取出这些元信息,让它们相互作用,以产生新的元信息

Applicative还具有一个操作pure,其接收一个普通值作为参数,返回一个盒子。我们可以理解为它将一个原始的数据装在一个盒子里面。它的类型用Swift语法可描述为:pure: (A) -> Context<A>。对于通过pure产生的新盒子,其中的元信息应该处于最初始的状态。

实现

接下来我们以面向对象的角度来为Optional实现Applicative

extension Optional {
    static func pure(_ value: Wrapped) -> Wrapped? {
        return value
    }
    
    func apply<U>(_ f: ((Wrapped) -> U)?) -> U? {
        switch (self, f) {
        case let (value?, fun?):
            return fun(value)
        default:
            return nil
        }
    }
}
复制代码

对于pure,我们定义了一个static方法,直接将接收到的值返回,Swift编译器会自动帮我们用Optional包装起来。

对于apply,我们首先看元信息部分,因为Optional所包含的元信息是一个判断数据是否为空的标志,这里将Optional实例本身与传入的包含处理函数的Optional参数双方的元信息进行相互作用,作用的逻辑为:假如任意一方的元信息表示为空,那么apply所返回Optional结果的元信息也一样是空。再来看数据部分,这里所做的就是把双方盒子里的数据取出来,分别是一个函数以及一个普通的值,再将函数作用于值,得到新的结果装入盒子。

请不要疑惑:“为什么Optionalnil时明明已经没有值了为什么还要从值的角度去考虑?”,因为上面盒子模型中对于元信息和值的描述是基于抽象的角度来进行思考的。

我们下面就可以来把玩一下:

typealias Function = (String) -> String
let sayHello: Function? = {
    return "Hello " + $0
}

let name: String? = "Tangent"

let result = name.apply(sayHello)
复制代码

运算符

我们使用<*>来作为apply的运算符,让代码编写起来更函数式:

infix operator <*> : FunctorApplicativePrecedence

func <*> <A, B>(lhs: ((A) -> B)?, rhs: A?) -> B? {
    return rhs.apply(lhs)
}
复制代码

这里需要注意的是:FunctorApplicativePrecedence已在文章前面定义,它规定了运算符的结合性是左结合的,所以<^><*>都具有左结合的特性。

下面就来使用一下:

typealias Function = (String) -> String
let sayHello: Function? = {
    return "Hello " + $0
}

let name: String? = "Tangent"

let result = sayHello <*> name
复制代码

Curry(柯里化)

Applicative的使用场景离不开函数式编程中另一个重要的概念:Curry(函数柯里化)Curry就是将一个接收多个参数的函数转变为只接收单一参数的高阶函数。像类型为(A, B) -> C的函数,经过Curry后,它的类型就变成了(A) -> (B) -> C。举个例子,我们有函数add,能够接收两个Int类型的参数,并返回两个参数相加的结果:

func add(_ a: Int, _ b: Int) -> Int {
    return a + b
}

let three = add(1, 2)
复制代码

Curry后的add只需接收一个参数,返回的是一个闭包,这里闭包也需要接收一个参数,最终返回结果值:

func add(_ a: Int) -> (Int) -> Int {
    return { b in a + b }
}

// 连续调用
let three = add(1)(2)

// 将返回的闭包保存起来,后续再调用
let inc = add2(1)
let three2 = inc(2)
复制代码

为了方便,我们可以构造若干个帮助我们进行Curry的函数,这些函数也叫做curry

func curry<A, B, C>(_ fun: @escaping (A, B) -> C) -> (A) -> (B) -> C {
    return { a in { b in fun(a, b) } }
}

func curry<A, B, C, D>(_ fun: @escaping (A, B, C) -> D) -> (A) -> (B) -> (C) -> D {
    return { a in { b in { c in fun(a, b, c) } } }
}

func curry<A, B, C, D, E>(_ fun: @escaping (A, B, C, D) -> E) -> (A) -> (B) -> (C) -> (D) -> E {
    return { a in { b in { c in { d in fun(a, b, c, d) } } } }
}

// 更多参数的情况 ...
复制代码

现在我们可以用一个例子来使用这些curry函数:

struct User {
    let name: String
    let age: Int
    let bio: String
}

let createUser = curry(User.init)
let tangent = createUser("Tangent")(22)("I'm Tangent!")
复制代码

上面我们定义了一个结构体User,其具有三个成员。这里Swift编译器默认已经帮我们创建了一个User的构造方法:User.init,方法的类型为(String, Int, String) -> User。通过把这个构造方法传入curry函数,我们得到一个高价的函数(闭包)(String) -> (Int) -> (String) -> User

通过结合CurryApplicative将能发挥强大的作用。

使用场景

大家可能从上面的概念中还摸不清Applicative到底能用来做什么,下面就来揭露Applicative的实用范围:

假设现在有一个Dictionary,里面可能装有与User相关的信息,我们想在里面找寻能构造User的字段信息,从而构造出实例:

struct User {
    let name: String
    let age: Int
    let bio: String
}

let dic: [String: Any] = [
    "name": "Tangent",
    "age": 22,
    "bio": "Hello, I'm Tangent!"
]
复制代码

在运行时中,dic里面是否具备构造User的全部字段信息我们是不知道的,所以最终的结果为一个被Optional包起来的User,也就是User?,传统的做法可以这样写:

// 使用变量
var tangent: User?
if let name = dic["name"] as? String,
    let age = dic["age"] as? Int,
    let bio = dic["bio"] as? String {
    tangent = User(name: name, age: age, bio: bio)
}

// 直接闭包调用
let tangent: User? = {
    guard
        let name = dic["name"] as? String,
        let age = dic["age"] as? Int,
        let bio = dic["bio"] as? String
    else { return nil }
    return User(name: name, age: age, bio: bio)
}()
复制代码

在日常的开发中我们是不是也经常会写出跟上面相似的代码呢?这样写没毛病,但是总感觉有点繁杂了...

这时候Applicative粉墨登场了:

let tangent = curry(User.init)
    <^> (dic["name"] as? String)
    <*> (dic["age"] as? Int)
    <*> (dic["bio"] as? String)
复制代码

等等,这上面发生了什么?让我们来一步步分析:

  1. curry(User.init)生成了一个类型为(String) -> (Int) -> (String) -> User的高阶函数(闭包)

    let createUser = curry(User.init)
    复制代码
  2. 我们将这个闭包与dic["name"] as? String通过<^>运算符连接:

    let step1 = createUser <^> (dic["name"] as? String)
    复制代码

    step1的类型是什么?回忆一下<^>,它来源于Functormap操作,左边接收一个函数(A) -> B,右边则是一个盒子Context<A>,返回盒子Context<B>。现在我们把实际的类型代入:盒子是OptionalAString,因为<^>左边传入的函数类型为(String) -> (Int) -> (String) -> User,我们可以理解为(String) -> ((Int) -> (String) -> User),所以这里B就是(Int) -> (String) -> User,于是,<^>运算结果step1的类型就是Optional<(Int) -> (String) -> User>step1: Optional<(Int) -> (String) -> User>

  3. <*>运算应用于step1dic["age"] as? Int

    let step2 = step1 <*> (dic["age"] as? Int)
    复制代码

    <*>来源于Applicativeapply操作,左边接收一个装有函数的盒子Context<(A) -> B>,右边接收一个盒子Context<A>,返回盒子Context<B>。把实际的类型代入:盒子是OptionalAInt,因为我们把step1应用于<*>的左边,step1是一个装有(Int) -> (String) -> User函数的Optional盒子,(Int) -> (String) -> User可以理解为(Int) -> ((String) -> User),其作用于A(Int),所以B就是(String) -> User。于是,step2的类型就是Optional<(String) -> User>step2: Optional<(String) -> User>

  4. <*>运算应用于step2dic["String"] as? String,得到结果:

    let tangent = step2 <*> (dic["bio"] as? String)
    复制代码

    和上面同理,<*>左边接收的类型为Context<(A) -> B>,右边为Context<A>,返回Context<B>,代入实际类型:盒子是OptionalAStringstep2作为一个Optional盒子,装有类型为(String) -> User的函数,所以B就是User。于是tangent的类型就是Optional<User>tangent: Optional

这就是上方Applicative例子运作的整个过程。比起传统的写法,使用Applicative能让代码更加简洁优雅。

我们也可以在其中使用Applicativepure

let tangent = .pure(curry(User.init))
    <*> (dic["name"] as? String)
    <*> (dic["age"] as? Int)
    <*> (dic["bio"] as? String)
复制代码

若使用了pure,我们就不需要Functor<^>了,因为pure已经将函数用盒子装了起来,后面就需要全部用<*>运算进行操作。不过这样写就需要多调用了一个函数。

可能有人会疑惑:“使用Applicative的代码其实也就是比起传统的写法优雅一点点而已,差别不大,为什么这里还要大费周章去引入一个新的概念去完成这一件小事?”

因为这里的例子只是为了方便理解而从Optional的角度去讲解,Swift已经为Optional定义了一套语法糖,所以以传统的写法来使用Optional已足够简洁。但是Applicative并不只局限于Optional,它足够强大,能完成更多的事情。

下面将引入其他功能更加强大的Applicative,它们的实用性也非常高。

Result

Result这个概念对于Swifter们来说应该不会陌生,Swift也计划将它纳入到标准库中了。Result表示了一个可能失败的操作结果:若操作成功,Result中将装有结果数据,若失败,Result中也会装有表示失败原因的错误信息。

enum Result<Value, Err> {
    case success(Value)
    case failure(Err)
}
复制代码

得益于Swift对代数数据类型的支持,这里Result将作为一个枚举,包含两种状态(成功和失败),每个状态都具有一个关联数据,对于成功的状态,其关联着一个结果值,对于失败,其关联了一个错误信息。这里对Result的实现中,我们也为错误信息配有泛型参数,而不单纯是一个实现了Error协议的任意类型。Result以一种非错误抛出的形式来向操作调用方反馈错误信息,在一些不能使用错误抛出的地方(异步回调)中起到非常重要的作用。

引入

让我们来编写一个小型的JSON解析函数,它通过一个特定的key将数据从JSON Object(以Dictionary的形式呈现)中取出来,并将其转换成一个特定的类型:

enum JSONError {
    case keyNotFound(key: String)
    case valueNotFound(key: String)
    case typeMismatch(type: Any.Type, value: Any)
}

extension Dictionary where Key == String, Value == Any {
    func parse<T>(_ key: String) -> Result<T, JSONError> {
        guard let value = self[key] else {
            return .failure(.keyNotFound(key: key))
        }
        guard !(value is NSNull) else {
            return .failure(.valueNotFound(key: key))
        }
        guard let result = value as? T else {
            return .failure(.typeMismatch(type: T.self, value: value))
        }
        return .success(result)
    }
}
复制代码

parse方法返回一个Result作为解析的结果,若解析失败,Result处于failure状态并包含JSONError类型的错误信息。

下面来使用看看:

let jsonObj: [String: Any] = [
    "name": NSNull(),
    "age": "error value",
    "bio": "Hello",
]

typealias JSONResult<T> = Result<T, JSONError>
// valueNotFound
let name: JSONResult<String> = jsonObj.parse("name")
// typeMismatch
let age: JSONResult<Int> = jsonObj.parse("age")
// keyNotFound
let gender: JSONResult<String> = jsonObj.parse("gender")
// success!
let bio: JSONResult<String> = jsonObj.parse("bio")
复制代码

假设我们要通过一个JSON Object来构造User实例,按照User中声明的顺序来依次解析每个字段,当解析到某个字段发生错误的时候,我们返回装有错误信息的Result,如果全部字段解析成功,我们得到一个包含User实例的Result。按照传统的做法,我们需要这样编写代码:

typealias JSONResult<T> = Result<T, JSONError>

func createUser(jsonObj: [String: Any]) -> JSONResult<User> {
    // name
    let nameResult: JSONResult<String> = jsonObj.parse("name")
    switch nameResult {
    case .success(let name):
        
        // age
        let ageResult: JSONResult<Int> = jsonObj.parse("age")
        switch ageResult {
        case .success(let age):
            
            // bio
            let bioResult: JSONResult<String> = jsonObj.parse("bio")
            switch bioResult {
            case .success(let bio):
                return .success(User(name: name, age: age, bio: bio))
                
            case .failure(let error):
                return .failure(error)
            }
            
        case .failure(let error):
            return .failure(error)
        }
        
    case .failure(let error):
        return .failure(error)
    }
}
复制代码

上面的代码层层嵌套、非常繁杂,每一个字段解析完毕后我们还要分情况做考虑:当解析成功,继续解析下一个字段,当解析失败,返回失败值。如果后期User需要添加或修改字段,这里的代码改起来就非常麻烦。

使用Applicative就能够更加优雅地实现上面的需求。

实现

现在为Result实现Applicative。因为Applicative基于Functor,这里首先让Result成为一个Functor

// Functor
extension Result {
    func map<U>(_ f: (Value) -> U) -> Result<U, Err> {
        switch self {
        case .success(let value):
            return .success(f(value))
        case .failure(let error):
            return .failure(error)
        }
    }
}

func <^> <T, U, E>(lhs: (T) -> U, rhs: Result<T, E>) -> Result<U, E> {
    return rhs.map(lhs)
}

func testFunctor() {
    let value: Result<String, Never> = .success("Hello")
    let result = value.map { $0 + " World" }
}
复制代码

Result盒子的元信息表明了操作过程中可能产生的错误信息,因为map不会影响到盒子的元信息,所以如果原来的Result是失败的,那么得到的结果也处于失败的状态。整个过程就如文章之前所述,将盒子内的数据拿出来应用于函数中,再将得到的结果装回盒子。

接着就可以让Result成为一个Applicative,首先我们先来看下面的代码,下面的代码是完全按照Applicative的规定来编写的,但是存在一个非常有趣的问题

// Applicative
extension Result {
    static func pure(_ value: Value) -> Result {
        return .success(value)
    }
    
    func apply<U>(_ f: Result<(Value) -> U, Err>) -> Result<U, Err> {
        switch f {
        case .success(let fun):
            switch self {
            case .success(let value):
                return .success(fun(value))
            case .failure(let error):
                return .failure(error)
            }
        case .failure(let error):
            return .failure(error)
        }
    }
}

func <*> <T, U, E>(lhs: Result<(T) -> U, E>, rhs: Result<T, E>) -> Result<U, E> {
    return rhs.apply(lhs)
}

func testApplicative() {
    let function: Result<(String) -> String, Never> = .success { $0 + " World!" }
    let value: Result<String, Never> = .success("Hello")
    let result = value.apply(function)
}
复制代码

apply方法中,我们依次判断装有函数和装有值的Result是否处于失败状态,如果是,那么直接返回失败结果,否则继续进行。

上面的代码问题在哪里呢?试想我们设计Result的初衷:我们希望能够依次按照User中每个字段的顺序去解析JSON,当遇到其中一个字段解析失败时,直接把错误信息封装在Result返回,并停止后续的解析操作。可以说,这是一种“短路”的逻辑,但是因为Swift并不是一门原生支持惰性求值的语言,而如果我们按照上面的写法来为Result实现Applicative,程序将会把所有的解析逻辑都执行一遍,这样就违背了我们的初衷。所以这里我们就需要对其进行修改:

// Applicative
extension Result {
    static func pure(_ value: Value) -> Result {
        return .success(value)
    }
}

func <*> <T, U, E>(lhs: Result<(T) -> U, E>, rhs: @autoclosure () -> Result<T, E>) -> Result<U, E> {
    switch lhs {
    case .success(let fun):
        switch rhs() {
        case .success(let value):
            return .success(fun(value))
        case .failure(let error):
            return .failure(error)
        }
    case .failure(let error):
        return .failure(error)
    }
}
复制代码

为了实现惰性求值,我们把Applicative基于面向对象角度编写的apply方法去掉,把全部实现都放在了<*>运算符的定义中,然后把运算符右边原本接收的类型Result<T, E>改成了以Autoclosure形式存在的闭包类型() -> Result<T, E>。这样,当前一个解析操作失败时,下面的操作将不会进行,实现了短路的效果。

使用

现在,我们来使用已经实现ApplicativeResult来重写上面的JSON解析代码:

typealias JSONResult<T> = Result<T, JSONError>

func createUser(jsonObj: [String: Any]) -> JSONResult<User> {
    return curry(User.init)
        <^> jsonObj.parse("name")
        <*> jsonObj.parse("age")
        <*> jsonObj.parse("bio")
}
复制代码

怎么样,现在是不是瞬间感觉代码优雅了很多!这里也多亏了Swift的类型自动推导机制,让我们少写了类型的声明代码。

Validation

引入

Validation用于表示某种验证操作的结果,跟上面提到的Result非常相似,它也是拥有两种状态,分别代表验证成功和验证失败。当结果验证成功,则包含结果数据,当验证失败,则包含错误信息。ValidationResult不同的地方在于对错误的处理上。

对于Result,当我们进行一系列可能产生错误的操作时,若前一个操作产生了错误,那么接下来后面所有的操作将不能够被执行,程序直接将错误再向上返回,这是一种“短路”的逻辑。但是有些时候我们想让全部操作都能够被执行,最终再将各个操作中产生的全部错误信息汇总。Validation就是用于解决这种问题。

实现

enum Validation<T, Err: Monoid> {
    case valid(T)
    case invalid(Err)
}
复制代码

仔细看Validation的定义,我们会发现其中表示错误信息的泛型Err具有Monoid协议的约束,这就说明Validation中的错误信息是Monoid(单位半群)Monoid在我的上一篇文章《函数式编程 - 有趣的Monoid(单位半群)》中已进行非常详细的说明,若大家对Monoid的认识比较模糊,可以查看此文章或者翻阅其他资料,Monoid的概念在这里就不再展开说明。

下面是Monoid的定义:

infix operator <> : AdditionPrecedence

protocol Semigroup {
    static func <> (lhs: Self, rhs: Self) -> Self
}

protocol Monoid: Semigroup {
    static var empty: Self { get }
}

// 为String实现Monoid
extension String: Semigroup {
    static func <> (lhs: String, rhs: String) -> String {
        return lhs + rhs
    }
}

extension String: Monoid {
    static var empty: String {
        return ""
    }
}

// 为Array实现Monoid
extension Array: Semigroup {
    static func <> (lhs: [Element], rhs: [Element]) -> [Element] {
        return lhs + rhs
    }
}

extension Array: Monoid {
    static var empty: Array<Element> {
        return []
    }
}
复制代码

Functor的实现上,ValidationResult并无太大区别,我们可以以Result的角度去理解:

// Functor
extension Validation {
    func map<U>(_ f: (T) -> U) -> Validation<U, Err> {
        switch self {
        case .valid(let value):
            return .valid(f(value))
        case .invalid(let error):
            return .invalid(error)
        }
    }
}

func <^> <T, U, E: Monoid>(lhs: (T) -> U, rhs: Validation<T, E>) -> Validation<U, E> {
    return rhs.map(lhs)
}
复制代码

ValidationApplicative的实现上则比起Result大有不同。文章上面提到:Functormap不会对盒子元信息产生影响,而Applicativeapply需要将双方盒子的元信息进行相互作用,以产生新的元信息。而ValidationResult的区别是在于错误信息的处理,这属于的元信息范畴,所以对于map操作ResultValidation无区别,但是apply操作则有所不同。

// Applicative
extension Validation {
    static func pure(_ value: T) -> Validation<T, Err> {
        return .valid(value)
    }
    
    func apply<U>(_ f: Validation<(T) -> U, Err>) -> Validation<U, Err> {
        switch (self, f) {
        case (.valid(let value), .valid(let fun)):
            return .valid(fun(value))
        case (.invalid(let errorA), .invalid(let errorB)):
            return .invalid(errorA <> errorB)
        case (.invalid(let error), _), (_, .invalid(let error)):
            return .invalid(error)
        }
    }
}

func <*> <T, U, E: Monoid>(lhs: Validation<(T) -> U, E>, rhs: Validation<T, E>) -> Validation<U, E> {
    return rhs.apply(lhs)
}
复制代码

上面对于apply的实现分了三种情况:

  • 若装有函数和装有值的Validation盒子都处于成功状态,那么将函数应用于值后的结果封装到一个成功状态的Validation中。
  • 若两个Validation其中有一个处于成功状态,一个处于失败状态,那么将错误信息封装到一个失败状态的Validation中。
  • 若两个Validation都处于失败状态,因为Validation中的错误信息是Monoid,所以此时将它们的错误信息通过<>组合,再将组合结果封装到一个失败状态的Validation中。

使用

假设现在我们需要完成一个用户注册界面的逻辑,用户需要输入的内容以及对应的规则限制为:

  • 用户名 | 不能为空
  • 电话号码 | 长度为11的数字
  • 密码 | 长度大于6

如果用户输入的内容全部合规,点击注册按钮则可以向服务器发起提交请求,若用户输入的内容存在不合规,则需要把全部不合规的原因汇总起来并提醒用户。

首先编写模型类和按钮点击的触发方法:

struct Info {
    let name: String
    let phone: Int
    let password: String
}

func signIn(name: String?, phone: String?, password: String?) {
    // TODO ...
}
复制代码

Info模型用于保存合规的用户输入内容,最终作为服务器请求的参数。

当按钮点击后,signIn方法将会被调用,我们从UITextField中分别取出用户输入的内容name、phone、password,传入,它们的类型都是String?。这个方法剩下的逻辑将会在后面补上。

此时我们就要针对不同的内容编写规则判断以及转换逻辑,这里我们就可以用到Validation

func validate(name: String?) -> Validation<String, String> {
    guard let name = name, !name.isEmpty else {
        return .invalid(" 用户名不能为空 ")
    }
    return .valid(name)
}

func validate(phone: String?) -> Validation<Int, String> {
    guard let phone = phone, !phone.isEmpty else {
        return .invalid(" 电话号码不能为空 ")
    }
    guard phone.count == 11, let num = Int(phone) else {
        return .invalid(" 电话号码格式有误 ")
    }
    return .valid(num)
}

func validate(password: String?) -> Validation<String, String> {
    guard let password = password, !password.isEmpty else {
        return .invalid(" 密码不能为空 ")
    }
    guard password.count >= 6 else {
        return .invalid(" 密码长度需大于6 ")
    }
    return .valid(password)
}

复制代码

在这里,我们用String类型来表示Validation中的错误信息,文章上面已经为String实现了Monoid,它的append操作就是将两个字符串相连接,empty则是一个空字符串。

对于每种输入内容,我们会进行不同的合规判断,如果输入不合规,那么将返回装有错误信息的失败Validation,否则将返回装有结果的成功Validation

现在,我们就可以通过Validation来将用户输入的内容进行合规检查和数据换行了:

let info = curry(Info.init)
    <^> validate(name: name)
    <*> validate(phone: phone)
    <*> validate(password: password)
复制代码

info的类型为Validation<Info>,我们将通过它来判断究竟需要提醒用户输入不合规还是直接发起服务器请求。

最终signIn方法的代码为:

func signIn(name: String?, phone: String?, password: String?) {
    let info = curry(Info.init)
        <^> validate(name: name)
        <*> validate(phone: phone)
        <*> validate(password: password)

    switch info {
    case .invalid(let error):
        print("Error: \(error)")
        // TODO: 向用户展示错误信息(可通过UILabel)
    case .valid(let info):
        print(info)
        // TODO: 发起网络请求
    }
}
复制代码

下面就来测试一下这个方法:

signIn(name: "Tangent", phone: "123", password: "123")
复制代码

上面的执行最终会在控制台打印出结果:Error: 密码长度需大于6 电话号码格式有误

除了上文谈到的ResultValidationApplicative还有其他很多实现,我们甚至可以将它用于构造响应式的小型工具上。由于篇幅问题,文章在此就不再细讲,有兴趣的小伙伴可以查阅相关资料进行了解,而说不定未来我也会再出一篇文章进行介绍。

若大家对文章有疑惑,欢迎在评论区留言。

关注下面的标签,发现更多相似文章
评论