Functional Programming in Swift(五)

原文首发于我的blog:https://chengwey.com

Chapter 6 QuickCheck

本文是《Functional Programming in Swift》中第六章的笔记,如果你感兴趣,请购买英文原版。

近些年 testing 变得非常盛行,很多流行的库都能用自动持续集成工具进行测试,本章我们用 swift 来构建一个很小的测试库,并采用迭代的方式一步步地增强功能。我们写单元测试的时候,输入的数据通常是由程序员定义的静态数据,比如写一个加法操作的单元测试,验证 1 + 1 = 2,如果加法操作出问题了,测试就会 fail 掉,再进一步,我们测试加法交换率,也就是 a + b 等于 b + a,为了测试我们需要写出 42 + 7 等于 7 + 42 这样的测试例。

QuickCheck 是 Haskell 的一个用来随机测试的库,相对于单元测试,QuickCheck 每一个测试函数都能应对特殊的输入。QuickCheck 允许你描述你函数的抽象属性,并生成测试来验证这些属性。QuickCheck 的目标是找出每个独立于这些属性的条件边界。这一章我们来创建一个 Swift 版本的 QuickCheck。

同样是用例子来阐述,假设我们要验证加法交换率,先来写个函数来检查:

func plusIsCommutative(x: Int, y: Int) -> Bool { 
    return x + y == y + x
}

用 QuickCheck 来测试只需要调用 check 函数即可

check("Plus should be commutative", plusIsCommutative)

> "Plus should be commutative" passed 100 tests.
> ()

check 方法通过一遍又一遍地传递给 plusIsCommutative 函数两个随机值的方式进行调用。如果 plusIsCommutative 返回 false,则测试失败。这里需要理解的是:我们可以描述代码的抽象属性,就如同这里的加法交换率一样(用函数来描述)**,check 函数使用这个抽象属性来进行单元测试,比使用手写的单元测试能达到更好的代码覆盖率。

当然不是所有的测试都能通过,比如我们要测试减法交换率:

func minusIsCommutative(x: Int, y: Int) -> Bool { 
    return x - y == y - x
}

这个时候在运行 QuickCheck,测试就会 failing

check("Minus should be commutative", minusIsCommutative) 

> "Minus should be commutative" doesn't hold: (0, 1)
> ()

改用 swift 的尾随闭包来写,就不需要定义抽象属性了:

check("Additive identity") { (x: Int) in x + 0 == x } 

> "Additive identity" passed 100 tests.
> ()

下面来介绍一些 QuickCheck 实现的细节。

<h2 id='BuildingQuickCheck'>1. Building QuickCheck</h2>

为了实现 swift 版本的 QuickCheck ,我们需要做下面一些工作:

  • 首先,需要为不同类型生成随机值
  • 把这些随机值作为参数传递给 check 函数
  • 如果测试失败,我们需要尽量缩小测试数据的范围。比如,在测试 100 个元素的数组时失败了,我们需要缩小数组的范围,然后查看到底是哪里出错了。
  • 最后,我们需要做一些工作使 check 函数更加通用(泛型)

Generating Random Values

首先来声明一个 protoocol 来定义如何生成随机值,该协议只有一个方法,返回一个 self:

protocol Arbitrary {
    class func arbitrary() -> Self
}

我们先来写一个 Int 的实例(这里的 arc4random 函数只能生成正数,而真正的实现应该也能生成负数)

extension Int: Arbitrary {
    static func arbitrary() -> Int {
        return Int(arc4random()) 
    }
}

现在能生成随机整数了:

Int.arbitrary()

> 4135064904

为了生成随机字符串,我们需要做更多的工作,我们先从随机字符开始:

extension Character: Arbitrary {
    static func arbitrary() -> Character {
        return Character(UnicodeScalar(random(from: 65, to: 90))) 
    }
    
    func smaller() -> Character? { return nil } 
}

我们使用上面的随机函数生成一个长度 x(0 ~ 40)之间。接着生成 x 个随机字符,然后把他们连成一个字符串。

func tabulate<A>(times: Int, f: Int -> A) -> [A] { 
    return Array(0..<times).map(f)
}
// 给定一个范围,生产一个随机整数
func random(#from: Int, #to: Int) -> Int {
    return from + (Int(arc4random()) % (to-from))
}

extension String: Arbitrary {
    static func arbitrary() -> String {
    // 生成 0 到 40 的随机整数
        let randomLength = random(from: 0, to: 40)
        // 生成数量为 randomLength 的字符,每个字符都是随机的
        let randomCharacters = tabulate(randomLength) { _ in
            Character.arbitrary() 
        }
        // 将数组中所有的字符组合成一个字符串
        return reduce(randomCharacters, "") { $0 + String($1) } 
    }
}

上面我们先使用 tabulate function 来填充一个长度从 0 到 times - 1 的数组,使用 map 函数来生成数组元素:f(0), f(1),...,f(times-1)。而 String 的 arbitrary extension 使用 tabulate 函数来组成包含随机字符的数组。这样就能通过调用 String 的类方法来实现随机字符串:

String.arbitrary()

> VPWSHMNM

Implementing the check Function

先实现第一个 check 函数,该函数遍历输入的参数,如果找到反例,打印并返回,否则测试通过

func check1<A: Arbitrary>(message: String, prop: A -> Bool) -> () { 
    for _ in 0..<numberOfIterations {
        let value = A.arbitrary() 
        if !prop(value) {
            println("\"\(message)\" doesn't hold: \(value)")
            return
        } 
    }
    println("\"\(message)\" passed \(numberOfIterations) tests.") 
}

这里虽然可以使用 map 或 reduce ,但用 for loop 显然更加明了。下面我们这样来测试:

func area(size: CGSize) -> CGFloat { 
    return size.width * size.height
}

check1("Area should be at least 0") { size in area(size) >= 0 }

> "Area should be at least 0" doesn't hold: (4403.19046829901,-3215.23175766115)
> ()

这个例子使用 QuickCheck 找出条件边界,如果 size 中有一部分为负值,最终结果就返回一个负值。当作为 CGRect 的一部分使用,CGSize 是可以有负值的,当我们写原始的单元测试,可以很容易的检视到这种情况,因为sizes一般通常都为正值。

<h2 id='MakingValuesSmaller'>2. Making Values Smaller</h2>

使用 check1 在 字符串上,将会收到很长的错误信息

check1("Every string starts with Hello") { (s: String) in
    s.hasPrefix("Hello")
}

> "Every string starts with Hello" doesn't hold: 
> ()

通常,更小的 counterexample ,更容易定位代码中的错误。一个主要原则是,尝试缩减输入来把问题定位在一个较小的范围之内。我们使用程序来自动完成这一缩减过程,而不是将重担抛给用户。要做到这一点,我们需要创建一个额外的 Smaller protocol,它只做一件事情,尝试缩减 counterexample:

protocol Smaller {
    func smaller() -> Self?
}

注意返回类型是一个可选类型。

在我们的实例中,对于整数,我们尝试用 2 来整除,直到接近 0:

extension Int: Smaller { 
    func smaller() -> Int? {
    // 削减一半
        return self == 0 ? nil : self / 2  
    }
}

// 测试一下
100.smaller()

> Optional(50)

对于字符串,我们仅仅丢弃首字符(触发字符为空)

extension String: Smaller { 
    func smaller() -> String? {
        return self.isEmpty ? nil : dropFirst(self)
    } 
}

为了使用在 check 函数中使用 Smaller protocol,我们需要缩减由 check 函数所产生的 test data,要做到这一点,我们需要重新定义我们的 Arbitrary protocol 来扩展 Smaller protocol:

protocol Arbitrary: Smaller { 
    class func arbitrary() -> Self
}

Repeatedly Shrinking

我们现在重定义 check 函数,来缩减触发失败 test data 的范围。

要做到这一点首先需要一个 iterateWhile 函数,该函数带一个条件和一个初始值,然后反复的递归调用。

func iterateWhile<A>(condition: A -> Bool, initialValue: A, next: A -> A?) -> A {
    if let x = next(initialValue) { 
        if condition(x) {
            return iterateWhile(condition, x, next) 
        }
    }
    return initialValue 
}

使用 iterateWhile 我们可以反复缩减 counterexamples

func check2<A: Arbitrary>(message: String, prop: A -> Bool) -> () { 
    for _ in 0..<numberOfIterations {
        let value = A.arbitrary() 
        // 不断削减直到找出不满足 prop 的
        if !prop(value) {
            let smallerValue = iterateWhile({ !prop($0) }, value) { 
                $0.smaller()
            }
            println("\"\(message)\" doesn't hold: \(smallerValue)") 
            return
        } 
    }
    println("\"\(message)\" passed \(numberOfIterations) tests.") 
}

这个函数生成随机输入值,检查是否满足属性参数,并且对反复缩减直到找出那个不满足的反例。使用 iterateWhile 而不是 简单的 loop 是因为可以保持 code 更加易读。

<h2 id='ArbitraryArrays'>3. Arbitrary Arrays</h2>

当前,check2 只支持 Int 和 String,我们定义一个新的 extensions 来扩展到更多的类型。比如 Bool 以及生成更复杂类型的随机数组。作为启发,我们先写一个快排

func qsort(var array: [Int]) -> [Int] {
    if array.isEmpty { return [] }
    let pivot = array.removeAtIndex(0)
    let lesser = array.filter { $0 < pivot }
    let greater = array.filter { $0 >= pivot } 
    return qsort(lesser) +  [pivot] + qsort(greater)
}

可以写一个属性来检查我们这个 快排

check2("qsort should behave like sort") { (x: [Int]) in 
    return qsort(x) == x.sorted(<)
}

编译器会警告上面的 [Int] 没有遵守 Arbitrary protocol,再实现他之前,我们先来实现 Smaller。

extension Array: Smaller { 
    func smaller() -> [T]? {
        if !self.isEmpty {
        // 去掉数组的第一个元素
            return Array(dropFirst(self))
        }
        return nil
    } 
}

再写一个函数,能够生成一个随机长度的数组,包含任意类型,且都遵守 Arbitrary protocol

func arbitraryArray<X: Arbitrary>() -> [X] {
    let randomLength = Int(arc4random() % 50)
    return tabulate(randomLength) { _ in return X.arbitrary() }
}

现在,我们想要定义一个 extension,使用上面的 arbitraryArray 来提供随机数组。但是,为了定义这个数组实例,需使数组内的所有元素类型都要是 Arbitrary 的实例。也就是说,为了生成一个包含随机数的数组,我们第一步需要确认的是:我们有能力生成随机数,要满足这些理论上需这么写:数组的这些元素都应该遵循 arbitrary protocol:

extension Array<T: Arbitrary>: Arbitrary { 
    static func arbitrary() -> [T] {
    ... 
    }
}

不幸的是,写出这样的 extension 让是不大可能的,我们可以让数组元素遵守Arbitrary
但没办法说让数组类型遵守Arbitrary。我们需要另辟蹊径,比如说修改 check2

我们已经明确问题的所在:check2<A> 需要 A 的 type 是 Arbitrary,一个解决方案是放弃这一类型要求,而用声明必要( necessary )函数来解决,这里是 smaller and arbitrary,他们将作为参数传递。

第一步先定义一个辅助结构体包含这两个函数:

struct ArbitraryI<T> {
    let arbitrary: () -> T 
    let smaller: T -> T?
}

第二步,写一个 helper function ,并且将上面的 ArbitraryI 作为参数。这个 checkHelper 与之前的 check2 非常相似,唯一不同的是 arbitrary 和 smaller 函数定义的位置,在 check2 中,他们被限定在 泛型 <A: Arbitrary> 中,而在 checkHelper 中,他们被明确地通过 ArbitraryI 结构体进行传递。

func checkHelper<A>(arbitraryInstance: ArbitraryI<A>,
                        prop: A -> Bool, message: String) -> () {
    for _ in 0..<numberOfIterations {
        let value = arbitraryInstance.arbitrary() 
        if !prop(value) {
            let smallerValue = iterateWhile({ !prop($0) }, 
                    value, arbitraryInstance.smaller)
            println("\"\(message)\" doesn't hold: \(smallerValue)")
            return
        } 
    }
    println("\"\(message)\" passed \(numberOfIterations) tests.") 
}

这也算是一项标准技术:我们将需要的信息明确地通过参数传递,而不是使用 protocol 中定义的 functions。这样做的好处是,不再依赖 Swift 去推断所有必需信息,而是拥有完全的控制权。

第三步当然是用 checkHelper 来重新定义 check2 函数。如果明确需要定义的 Arbitrary ,那么我们就能用 ArbitraryI 结构体来封装然后调用 checkHelper

func check<X: Arbitrary>(message: String,
                        prop: X -> Bool) -> () {
    let instance = ArbitraryI(arbitrary: { X.arbitrary() }, 
                                smaller: { $0.smaller() })
    checkHelper(instance, prop, message) 
}

如果有个类型无法定义成所渴望的 Arbitrary 实例,比如数组,我们可以覆盖 check 函数然后构造我们需要的 ArbitraryI 结构体:

func check<X: Arbitrary>(message: String,
                            prop: [X] -> Bool) -> () {
                            
    let instance = ArbitraryI(arbitrary: arbitraryArray,
                                smaller: { (x: [X]) in x.smaller() })   checkHelper(instance, prop, message)
}

现在,我们最终可以运行验证我们的快排实现了,生产大量的随机数组并传递给我们的测试:

check("qsort should behave like sort") { (x: [Int]) in 
    return qsort(x) == x.sorted(<)
}

> "qsort should behave like sort" passed 100 tests. 
> ()

<h2 id='UsingQuickCheck'>4. Using QuickCheck</h2>

虽然 TDD 有点违反直觉,但有证据表明人们依赖测试驱动开发不仅仅是验证代码的正确性,他还影响代码的设计风格,能够使代码趋于简单明了。有这么一个结论:如果能针对某个类写出简单的测试代码,那么意味着这个类是充分去耦合的。

对于我们的 QuickCheck 来说一样适用,通常利用现有代码事后添加 QuickCheck 测试并不是很容易,尤其当你使用面向对象架构,要依赖其他类或使用可变状态时。但是一旦反转顺序,利用 QuickCheck 驱动开发,你将会看到,他是如何影响你的代码设计的。 QuickCheck 强迫你去思考那些 functions 必须满足的抽象属性。单元测试只能够断言 3 + 0 是否等于 3 + 0,而使用 QuickCheck 检查则更加通用。

一开始就在 high-level 的 QuickCheck,能够使你的代码更加 模块化引用透明referential transparency

QuickCheck 在有状态的函数和 APIs 下工作的并不好。更适合函数式编程么?

这样一来,把你的测试代码写在 QuickCheck 前面能够使你的最终代码保持简洁。

<h2 id='NextSteps'>5. Next Steps</h2>

这个库虽然远没有完成,但目前已经相当有用了,下面有几个地方今后可以持续改进一下:

  • 缩减还是太简单了:比如对于数组,我们仅仅移除了第一个元素,但想要移除某个元素呢,再或要使数组中的元素变得更小呢。当前是返回了一个可选的被缩减过的值,但要想生产一系列值呢,在之后本书最后一章,我们看到如何生成一个 lazy list 的结果,这里我们可以使用同样的技术。
  • 随机实例太简单:对于不同的数据类型,我们想要更复杂的随机实例,比如当生成随机枚举类型时,我们想要根据某种频率生成特定的 cases。另外,我们想要生成一些限定的实例,比如排过序的非空数组。当我们写这些随机实例( Arbitrary instances )时,可以定义一些 help function 来帮助我们达成目标。
  • 为生成的测试数据分类:如果我们生成了很多长度为 1 的数组,我们可以将其归为 “trivial” (无价值)的测试例下,Haskell 的类库提供了这样的分级,所以这些想法可以直接进行移植。
  • 我们想要更好地控制生成的随机对象的尺寸。在 Haskell 版本中的 QuickCheck,Arbitrary 协议拥有一个额外的 size 参数,用来限制生成的随机对象尺寸。这样 check function 一开始就从测试 small values 开始,随着测试的深入,check function 逐步增大输入参数的尺寸,寻找更大的边界和反例(这和我们本章找到反例再逐步缩小定位不同)
  • 我们想要用特定 seed 初始化随机函数,这样就能重复地生成 test case,方便我们更容易地找出令程序失败的测试例。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269

推荐阅读更多精彩内容

  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,099评论 18 139
  • 第5章 引用类型(返回首页) 本章内容 使用对象 创建并操作数组 理解基本的JavaScript类型 使用基本类型...
    大学一百阅读 3,151评论 0 4
  • 进入TW者几年来,一路被自己的buddy,sponsor,以及公司的各种session所帮助,到后来自己开始成为b...
    贾三胖阅读 901评论 0 1
  • 人生或许就这样,你永远都不知道下一秒会发生什么,但你只有去面对它,去打败他,才能得到你所要的。 毕业了,每个人都在...
    Hiiuy阅读 377评论 2 3
  • “好好虚度时光”是我喜欢的一个公众号的名字,也是一首诗的名字,现在拿来做这篇文章的标题,因为这句话最契合我现在的心...
    一江春水1990阅读 790评论 9 20