Doios daniel steinberg cover

拥抱未来:更优雅的 Swift 写法

Swift 是一个非常年轻的编程语言。我们应该如何做,才能够写出符合 Swift 语言习惯的代码呢?另一方面,由于 Objective-C 已经有三十多年的历史了。我们知道 Objective-C 应有的样子,以及代码带给我们的感觉应该是怎样的。不过,对我们这些 iOS 的先驱者来说,那时候的 Objective-C 和今日的 Objective-C 也有很大的不同。由此看来,Swift 的发展速度可能超乎您的想象。

在这个由 Daniel Steinberg 带来的 do {iOS} 演讲中,我们会在 Objective-C 代码以及其他在 Swift 之前出现的语言的基础之上,来探究 Swift 代码的编写方式,然后学习如何才能够写出让别人乐意去阅读的代码。快来加入我们,伴随着 Daniel 美味的、可阅读的以及可测试的“食谱 (recipe)” 比喻,一起来制作美味的 “意大利千层面 (lasagne)” 。


以“意大利千层面”做比喻 (0:00)

我想要用一个“意大利千层面”的食谱来作为示例。意大利千层面是拥有特定的上下文环境的:它的制作取决于您对制作步骤的了解。对于一名厨师来说,制作步骤中的每一个小步骤都意味着一件需要去精心准备的事情;而对于家庭烹饪来说,这可能又意味着别的涵义了。例如,对于家庭烹饪说,他们可能会去自行制作 “意式肉酱 (Bolognese Sauce)”,当然也有可能会直接去买一瓶肉酱来代替。

白酱 (Béchamel Sauce) 却又是另一回事了。事实上,白酱对于烹饪来说,是一个非常重要的酱料,用瓶装的并不是一个好主意。这就是我们常说的“妈妈的味道”(在传统的法式烹饪当中,有五种所谓的“基础调味酱”,在此基础之上又会延伸出一系列的酱汁)。

我们现在有了意大利千层面的食谱了,然后我们会开始制作所说的白酱。首先,在制作掺油面粉糊 (Roux) 的过程,放入黄油和面粉,然后倒上牛奶,等待其冷却之后品尝一下。这会给意大利千层面带来一个很美味的调料。

Objectice-C 上下文 (3:02)

不管您在网上看过什么言论,有一点是毋庸置疑的,Objective-C 并没有到风烛残年的年纪。Objective-C 本身并没有什么错。这是一门很可爱的语言,但是如果您死守这门语言的话,您很可能会找不到工作。在我教的课程中有一个例子,制作一个简单的计时器。我想要给大家展示一下这个模型。

有这样一个 elapsedTime 方法,它会返回正在显示的时间。也就是得到回调。一般来说,我们可能会使用 alloc init 来创建 NSDate 的一个新实例。各位写 Swift 的还记得 alloc init 么?然后,我们会计算所用的时间。在 Swift 中我怀念的一个东西就是星号 (*) 了,因为我可以看一眼这段代码,然后立刻说出 NSDate 是引用类型,而 NSTimeInterval 不是。在 Swift 中,我们并没有这样的线索来清晰明了的看出引用类型和值类型的分别,这真的很遗憾。

- (NSTimeInterval)elapsedTime {
    NSDate *now = [[NSDate alloc] init]
    NSTimeInterval elapsedTime = [now timeIntervalSinceDate: self.startTime];
    return elapsedTime;
}

我们计算完所用的时间后,将其返回,这就是我们这个 elapsedTime 方法所做的。如果您喜欢精简的话,那么上面这三行代码可以用一行代码来代替:

return [self.startTime timeIntervalSinceNow];

我们使用了 timeIntervalSinceNow 这个方法,这非常的赞,只不过我们得到的结果是负数而已。

我们想要摒除负号。我们可以在前面放上一个负号来将这个负号消灭掉,但是每次有人看到这行代码的时候,他们必须要停下来思考:“为什么这前面出现了一个负号?”。

return -[self.startTime timeIntervalSinceNow];

这个负号所属的上下文环境缺失了。当您在查看这段代码的时候,您不是总能够记住这个负号添加在此的涵义。这个问题出在 Cocoa 所拥有的这个方法:timeIntervalSinceNow 当中,我们切实希望有这样一个名为 timeIntervalUntilNow 的方法。

我们知道如何使用类别来进行扩展,所以让我们一起来实现它。我们现在就不用担心命名冲突 (Name Spacing) 的问题了。

Receive news and updates from Realm straight to your inbox

@interface NSDate (TimeCalculations)
- (NSTimeInterval)timeIntervalUntilNow;
@end

这是我们的头文件,这也是我怀念的 Objective-C 特性。它会告诉我可以调用的具体部分。除此之外,我还是很喜欢 Swift 的,么么哒。

在这个 NSDate 类别的顶部,我用 TimerCalculations 来为其命名。我声明了我想要用的方法,然后跳转到实现文件当中去实现这个方法。我从 timeIntervalUntilNow 方法中所得到的就是 timeIntervalSinceNow 的负值。

@implementation NSDate (TimeCalculations)
- (NSTimeInterval)timeIntervalUntilNow {
    return -[self timeIntervalSinceNow]
  }
@end

您可能会问了:“好吧,Daniel,这里不是有同样的负号么?”。没错,不过现在它就位于上下文环境当中了。这个负号已经完全得到说明了。我可以说 timeIntervalUntilNowtimeIntervalSinceNow 的负值,它对于我以及来看我代码的其他人来说,是具有完整的意义的。通过提供上下文环境,您就可以编写清晰明了的代码了,就像意大利千层面一样,每个馅料都包含在“面饼”里面

这里没有什么让人困惑的东西;我只需要调用 timeIntervalUntilNow,一切清晰明了。

Swift 计时器示例 (7:22)

现在,如果我们不需要使用 self、方括号以及分号的话,代码会变得更加简洁,但是或许我们可以更加简洁一些。让我们来看一看在 Swift 中这个模型会是什么样子的。

struct Timer {
    let startTime = NSDate()

    var elapsedTime: NSTimeInterval {
        return startTime.timeIntervalUntilNow
    }
}

在 Swift 中,这个模型变得着实简洁不少。我很喜欢 Swift 中的属性。在 Objective-C 中,我必须要导入头文件,然后声明属性,然后在 viewDidLoad 或者是别的某个地方对其进行初始化。而在 Swift 中我就可以像这样创建一个新的 NSDate 实例。

在我的 Timer 结构体中,我创建了 elapsedTime 属性。我不知道您有没有注意到,在 Swift 中我们倾向于使用计算属性来替代简单的方法实现。

extension NSDate {
    var timeIntervalUntilNow: NSTimeInterval {
        return -timeIntervalSinceNow
    }
}

我们只需要调用 startTimetimeIntervalUntilNow 即可,我们不需要再在类别中再次实现了。我们在同一个文件中放入这个扩展,这样它就不会污染我们的代码库,这样我们就完成了 NSDate 的扩展,然后返回 timeIntervalSinceNow 的相反值。

对我来说,这就是所谓的“调味酱汁 (Sauce on the side)” 的最好说明。这是我所用的“调味汁”当中的一种。在同一个文件当中,我提供了详情信息,因此我们在这里就完成了一个美味的“意大利千层面”的制作工作,因为代码是干净、结构紧凑、清晰并且可测试的。这有一点点作弊,因为我们是以 Objective-C 开始的,然后将代码转换为 Swift 而已,因此我们实际上没有做任何困难或者机智的东西,因此您可能仍然还是很困惑。

当您从 Objective-C 转来第一次学习 Swift 的时候,您觉得您只是用一种新的语法在编写 Objective-C 而已。但是,这并不是 Swift 的观念;您所做的只不过是“音译”而已。要去理解 Swift 的理念的话,我们必须要做一些不同的事情。

应用销售清单:Swift 的现场示例 (9:46)

假设,我想要跟踪一周内的应用销售情况。我需要 7 个数据点。我会使用 GameplayKit 来随机产生这些数据,然后我要去创建一个 SequenceType

import GameplayKit
struct AppSales: SequenceType {
    let numberOfDays: Int
    let randomDistribution = GKGaussianDistribution(lowestValue: 0, highestValue: 10)

    func generate() -> AnyGenerator<Int> {
        var count = 0
        return anyGenerator({
            if count++ < self.numberOfDays {
                return self.randomDistribution.nextInt()
            } else {
                return nil
            }
        })
    }
}

我喜欢这个来自 GameplayKit 的调用。它的意思是说:“我要创建一个从 0 到 10 之间的高斯分布 (Gaussian Distribution),这意味着大部分时间我会卖出五份拷贝。”我可以得到一个波斯分布,它有一个波峰,两侧的值稍微少一点。因此,我大概每天会卖掉五份拷贝。

这个 generate 方法是我们用来生成这些模拟数据的地方,从而完成我们 SequenceType 的构建。因此,如果一旦我没有获取到我指定的天数的话,我就会继续前进,从我的高斯分布中拿到下一个整数。否则,如果您跑完了一周的时间,那么就会返回 nil。

let lastWeeksSales = AppSales(numberOfDays: 7)

SequenceType 对于 Apple 来说着实非常重要。Map、filter、reduce 这些方法都被迁移到了 SequenceType 协议扩展当中。

我创建 lastWeeksSales 这个实例的感觉就跟创建数组一样,不过它还没有完全完成。记住,这是一个 SequenceType,因此有些事情我仍然是还可以做的。

SequenceTpye 当中实现的快速枚举是一个非常好的方式;它只是不停地调用 next、next,直到其返回 nil 为止,枚举才会结束。这就是快速枚举的工作方式。因此 for in 工作的确实很棒。一旦我获取到上周的销售情况后,我就可以使用快速枚举来对其进行处理了。

let lastWeeksSales = AppSales(numberOfDays: 7)
for dailySales in lastWeeksSales {
    print(dailySales)
}

-> 6, 5, 6, 4, 6, 4, 3

我将我的日常销售记录打印了出来。注意到,它们是基本是围绕这 5 这个数字来创建的。现在我创建完了我的类型了。

另一件事是,由于 AppSales 是一个 SequenceType,它支持所有的 map、filter、reduce 以及 flatMap 操作,但是如果你传递给它一个 SequenceType,它返回的却是一个数组。如果您传递给的是一个 Int 序列的 SequenceType,那么它会故意给您返回一个 Int 数组。

通过上下文来进行映射 (12:04)

let lastWeeksSales = AppSales(numberOfDays: 7)
let lastWeeksRevenues = lastWeeksSales.map { dailySales in
    Double(dailySales) * 1.99 * 0.70
}

我们来看一下 lastWeeksSales,我们希望从这个销售记录中生成相应的收入。我们以 Int 数组开始,这个数组事实上还不是一个真正意义上的数组,而是一个 SequenceType,我们通过闭包对其进行映射。我们会获取今日的销售额,然后将其转换成 Double 类型(您无法在 Swift 中使用自动类型推断)。

我卖出的价格是 $1.99,我由此获得了 70% 的利润。当我查看结果的时候,我所得到的 Double 类型会拥有一长串的小数位,这如果放到表格当中将会十分的丑陋。

我觉得如果我将 $1.99 指明是什么东西的话,这看起来会更好一些,因为对我来说 $1.99 意味着是美刀而已,而 70 则是意味着百分比。这对我来说要记忆的话是比较困难的,因为这里有两个 Double 值类型。我们没办法保证所有人的想法都是完全一致的,因为这里没有将我们的上下文环境带入进去。

let lastWeeksSales = AppSales(numberOfDays: 7)

let unitPrice = 1.99
let sellersPercentage = 0.70

let lastWeeksRevenues = lastWeeksSales.map { dailySales in
    Double(dailySales) * 1.99 * 0.70
}

我将这个 $1.99 拿出来,然后使用一个常量来作为解释。unitPrice 意味着单位价格,对应着 1.99 美刀,所以我用这个 dailySales 来乘以这个 unitPrice 完成计算。

顺便一提,您无法在 Swift 输入 “.70” 这种形式,也无需明确指明这是一个 Double 类型。我需要对其进行解释。突然之间,这个计算变得更为清晰明了了。我获取我的日常销售额,然后乘以单位价格,然后再除乘以利润率,我所得到的就是我要的利润。

对单位价格和利润率,我分别创建了一个用以解释的常量。我们为什么不在闭包中执行相同的操作呢?为什么不将这个闭包单独提取出来,然后创建一个名为 revenuesForDCopiesSold 的函数呢?我通过对日常销售额进行映射,然后计算得到上一周拷贝销售的利润,这样我就完全解释了一切事情。我相信,当我再次回到这段代码的时候,我可以深入挖掘并找到我想要的东西。这给了我一个大致的概念,如果我想要深入探究的时候,我不会面临任何障碍。

let lastWeeksSales = AppSales(numberOfDays: 7)

let unitPrice = 1.99
let sellersPercentage = 0.70

func revenuesForCopiesSold(numberOfCopies: Int) -> Double {
    return Double(numberOfCopies) * unitPrice * sellersPercentage
}

let lastWeeksRevenues = lastWeeksSales.map { dailySales in
    revenuesForCopiesSold(dailySales)
}

编写可阅读的代码 (14:39)

如果您没有使用过 $0 的话,您就无法称自己是一个 Swift 开发者。实际上,我很喜欢在我使用括号的地方使用这个用法。我知道这不是一个尾随闭包,但是我只是将方法名放到那里,我觉得这个做法看起来很不错。

let lastWeeksRevenues = lastWeeksSales.map( revenuesForCopiesSold )

如果对于一个映射来说大家觉得还不错的话,那么两个映射明显会更好一些。我准备去获得一个分布范围。它会从 -5 起步,到 15 为止,因此它的平均值仍然还是 5,不过我想要进一步从 5 这个数当中获取更多的值。

let randomDistribution = GKGaussianDistribution(lowestValue: -5, highestValue: 15)

Negative sales don’t make me happy, so I’d like 负数的销售额让我感到很不愉快,因此我想要将这些负数变为 0。我可以对拷贝进行匹配,只保留那些大于 0 的值。我只需要对元素进行匹配,保留大于 0 的值即可。非常棒,我在这里使用了 $0,这让我感觉自己像是一个棒棒的程序员。

let lastWeeksRevenues = lastWeeksSales.filter{$0 > 0}
                                      .map( revenuesForCopiesSold )

解决难以阅读的痛楚 (16:49)

我现在有了一周的销售额了,我对其进行匹配,这样我可能拥有的值会少于 7 个。这或许会产生一些不好的影响,也许不会。在这种情况下,它并不会出问题(不过它很可能会出问题!)。我想要保证括号当中的数值始终保证有 7 个。因为匹配会改变数组的大小,因此我想要使用 filter 来替代,映射所有小于 0 的数据。

我只是改变了任何小于 0 的数值而已,然后我告诉自己“不要经常使用这个方法,这只是外部的标准偏差而已……”。我真的如此做了,所以我可以告诉大家我的做法:

let lastWeeksRevenues = lastWeeksSales.filter{$0 > 0 ? $0 : 0}
                                      .map( revenuesForCopiesSold )

因为到了现在,我真的感觉自己像是一个程序员了。但是我很容易将 $00 混淆,我很难记住什么是什么,因此很容易搞错,我在这里还使用了三元运算符。我担心在我的团队中会有人不能理解这段代码,因此我又进行了修改。

func negativeNumbersToZero(number: Int) -> Int {
    return max(0, number)
}

let lastWeeksRevenues = lastWeeksSales.map( negativeNumbersToZero )
                                      .map( revenuesForCopiesSold )

再说一次,我将计算、映射的对象提取了出来,放到一个单独的函数当中。negativeNumbersToZero 函数将会返回 0 和传入的数值相比,更大的那个数,现在 lastWeeksRevenues 看起来就很棒了。我在第一个映射中将负数变为了 0,然后在后面找到对应拷贝销售额的利润。

我知道这看起来过于简化了,不过我很喜欢这样做。这对我来说很容易阅读,就感觉像是在看制作意大利千层面的食谱一样。然而,我很不喜欢这里的映射。

为什么我要将这个过程向外部暴露出来呢?为什么您需要知道我寻找这些东西的使用过程呢?为什么不将这些东西放到一个单独的函数当中呢?因此,我需要回到这个“食谱”当中,告诉大家我是怎么处理的。

首先,虽然我想要返回这个白酱(因为我超爱这个比喻)。再说一遍,我们使用这个函数:revenuesForCopiesSold,它会返回一个表示不是很清晰的 Double 值。在 Swift 中,我们要为其创建一个新类型别名,因为和 Objective-C 相比,这里所做的代价花费得将会很小、也更容易。

typealias USDollars = Double

func revenuesForCopiesSold(numberOfCopies: Int) -> USDollars {
    return Double(numberOfCopies) * unitPrice * sellersPercentage
}

我会说,请使用 USDollars 来替代 Double,现在我就可以明确知道它表示的是什么了。我的 revenuesForCopiesSold 将会返回 USDollars,因此我知道它衡量的是什么。

func toTheNearestPenny(dollarAmount: USDollars) -> USDollars {
    return round(dollarAmount * 100)/100
}

我还可能要将其转换成对应的便士。

现在,我们可以将它们整合到一个函数当中:revenuesInDollarsForCopiesSold,它会获取您所卖出的拷贝数量,然后返回对应的 USDollars,它会在底行执行相应的计算,使用拷贝的数量来计算拷贝销售的利润,然后使用对应的函数来将其转换为最近的便士。

func revenuesInDollarsForCopiesSold(numberOfCopies: Int) -> USDollars {
    return toTheNearestPenny(revenuesForCopiesSold(numberOfCopies))
}

当我阅读这段代码的时候,可能会要将阅读方向反过来。我们从内部开始,然后逐步往外阅读。

有意义的自定义运算符及泛型 (21:58)

现在我很不情愿地向大家展示一个自定义运算符。

infix operator » {associativity left}

您可能会记得,这货像是意大利千层面食谱当中的流程符号。我打算定义一个中缀运算符 (infix operator),它会放在需要操作的两个东西之间。如果在一行文字中描述了一大堆步骤,我就对告诉您该以什么次序来将���行这���步骤,所以我会将其与左边进行关联。当您从左向右阅读的时候,我会先执行第一个步骤,然后它的结果会被传递给下一个步骤,这样就完成了与左边元素的关联。

func »<T, U>(input: T,
         transform: T -> U) -> U {
    return transform(input)
}

哦天,这里出现了泛型。中缀运算符需要获取两种输入:一些数据和一些函数,然后根据这两种输入来执行某些功能。我们可以跟踪这些类型,然后查看 T 类型输入是什么,以及 T -> U 类型是如何执行转换映射的。

这里的泛型用得很赞。也就是说,无论您输入的类型是什么,它都必须要匹配对应函数的参数。并且,无论函数的输出是什么,它都会成为这个运算符的返回类型。

在这基础上,教授使用泛型来进行函数式编程的人们会说:“好吧,显然这个函数只能够返回一个东西”。您必须要记住,“清晰明了”在于您的看法是如何的。在您仔细阅读并习惯之前,这并不是很清晰的。一旦有人向您解释之后,然后您再仔细查看,通过逐步熟悉之后,您就会豁然开朗了。

您唯一能做的就是将这个函数应用到输入当中,然后您就可以得到 U 类型的东西。然后,如果我在这个运算符的两边使用了某种元素以及某种函数的话,我就可以把这个函数应用到这个元素当汇总,最后它会返回输出给我。这样,我将依据次序来执行我所需要做的事情。

我觉得这有点尴尬,因为我创建完一个自定义运算符之后告诉大家,不要使用这玩意儿。但是很快您就会对此感觉良好了,因为我们会开始使用它。就像很多东西一样,用了才知道它好不好。

func revenuesInDollarsForCopiesSold(numberOfCopies: Int) -> USDollars {
    return numberOfCopies
        » revenuesForCopiesSold
        » toTheNearestPenny
}

现在,我以我的拷贝数量开始,然后使用这个运算符来将我销售的拷贝利润放到队列当中,然后它会返回我计算后的某个东西,然后与左边的值进行关联。然后我再将 toTheNearestPenny 放到队列当中,最后就得到了结果。现在我感觉非常良好。

回到千层面样式的代码中 (25:35)

我计算完 lastWeeksRevenues 后,我就依次使用这两个映射函数。我想要将它转变为某种看起来像是队列清单一样的东西。我必须要考虑一些映射做了哪些事情,为什么要在这里使用映射,因此我打算移除映射函数。我会将这两个映射提取到一个函数当中。

func replaceNagiveSalesWithZeroSales(sales: [Int]) -> [Int] {
    return sales.map(negativeNumbersToZero)
}

func calculateRevenuesFromSales(sales: [Int]) -> [USDollars] {
    return sales.map(revenuesInDollarsForCopiesSold)
}

注意到,这仍然还是在执行相同的映射操作,但是我现在可以通过它的名称推断出它做了些什么。我会传递一个 Int 数组,然后我会通过映射将负数转为 0。这不仅变得更轻巧、更清晰,并且我还可以为其编写单元测试。

第二个方法同样也会接受一个 Int 数组,然后计算拷贝销售的利润。每个小方法都只做一件事。小的方法我可以通过输入进行相应的测试,然后看看它们的执行是怎么样的。除了……类型不匹配之外。

这就是为什么我之前使用的是 SequenceType,因为 lastWeeksSales 是一个 SequenceType,并不是 Int 数组。我的第一个方法需要的是 Int 数组。如果两个映射没关系的话,那么三个映射也是可以的。

func anArrayOfDailySales(rawSales: AppSales) -> [Int] {
    return rawSales.map{$0}
}

这会输出一个数组,然后它会获取您放入的每一个元素,然后将其作为下一个元素。所以它只需要执行转换就可以了。

我可以将这些函数链接在一起,这样看起来就非常像是一块千层面:

let lastWeeksRevenues = lastWeeksSales
                        » anArrayOfDailySales
                        » replaceNagiveSalesWithZeroSales
                        » calculateRevenuesFromSales

我会获取到 lastWeeksSales,然后从中计算出日常销售额的数组,然后将负数替换为 0,然后计算这些销售额的利润,这样看起来就跟千层面一样。我感觉就是如此。这是一个可阅读的代码,我知道这让大家感觉很不习惯,不过我觉得大家应该学会爱上它。

当有人来看您的代码的话,他会很容易发现您在做什么。我知道您不会在工作中这样做,所以,您可以在小项目当中试一试。如果您像这样写的话,我是不知道您在说什么的:

let lastWeeksRevenues = lastWeeksSales
                        .map{$0 > 0 ? $0 : 0}
                        .map{round(Double($0) * unitPrice * sellersPercentage * 100) / 100}

您写的这个代码可能会让您感觉良好。我把这个代码放在这了,如果你理解不了,你就卷铺盖走人吧。有些人总是抱有这种想法,写出别人看不懂的代码出来。

如果您执意这么做的话,那么我不会想要在您的团队中工作的。我想要在一个能够写出大家可轻松阅读代码的团队当中,并且,这个代码我还乐意去读。我宁愿使用我的这个代码,当我需要知道更多的时候,我只需要将其放大:

let lastWeeksRevenues = lastWeeksSales
                        » anArrayOfDailySales
                        » replaceNagiveSalesWithZeroSales
                        » calculateRevenuesFromSales

func calculateRevenuesFromSales(sales: [Int]) -> [USDollars] {
    return sales.map(revenuesInDollarsForCopiesSold)
}

func revenuesInDollarsForCopiesSold(numberOfCopies: Int) -> USDollars {
    return numberOfCopies
        » revenuesForCopiesSold
        » toTheNearestPenny
}

func revenuesForCopiesSold(numberOfCopies: Int) -> USDollars {
    return Double(numberOfCopies) * unitPrice * sellersPercentage
}

每个部分都只关心自己的事情。当我在思考的时候,对我来说每个部分都非常的清晰。我可以在我的头脑中保持对代码的理解,但是我并不想要在我的头脑中放入太多的信息。如果有东西出了问题,我可以很快追溯到哪里出了问题,只需要发现测试失败就可以了。我们可以不停地进行精炼,直到代码完全清晰明了。

我们的代码现在变得非常的干净、清晰,并且可以测试。我对其感觉非常良好,因为我们为每个部分都提供了相应的上下文信息。

关于如何移除关于传感器方面的自定义运算符的有关内容,您可以查看文章顶部的视频中的此时间戳: (29:48)

About the content

This talk was delivered live in November 2015 at DO iOS Conference. The video was transcribed by Realm and is published here with the permission of the conference organizers.

Daniel Steinberg

Daniel is the author of the books ‘A Swift Kickstart’ and ‘Developing iOS 7 Apps for iPad and iPhone’, the official companion book to the popular iTunes U series from Stanford University. Daniel presents iPhone, Cocoa, and Swift training and consults through his company Dim Sum Thinking. He is also the host of the CocoaConf Podcast.

4 design patterns for a RESTless mobile integration »

close