内容简介:拥抱未来:更优雅的 Swift 写法
Swift 是一个非常年轻的编程语言。我们应该如何做,才能够写出符合 Swift 语言习惯的代码呢?另一方面,由于 Objective-C 已经有三十多年的历史了。我们知道 Objective-C 应有的样子,以及代码带给我们的感觉应该是怎样的。不过,对我们这些 iOS 的先驱者来说,那时候的 Objective-C 和今日的 Objective-C 也有很大的不同。由此看来,Swift 的发展速度可能超乎您的想象。
在这个由 Daniel Steinberg 带来的 do {iOS} 演讲中,我们会在 Objective-C 代码以及其他在 Swift 之前出现的语言的基础之上,来探究 Swift 代码的编写方式,然后学习如何才能够写出让别人乐意去阅读的代码。快来加入我们,伴随着 Daniel 美味的、可阅读的以及可测试的“食谱 (recipe)” 比喻,一起来制作美味的 “意大利千层面 (lasagne)” 。
以“意大利千层面”做比喻
我想要用一个“意大利千层面”的食谱来作为示例。意大利千层面是拥有特定的上下文环境的:它的制作取决于您对制作步骤的了解。对于一名厨师来说,制作步骤中的每一个小步骤都意味着一件需要去精心准备的事情;而对于家庭烹饪来说,这可能又意味着别的涵义了。例如,对于家庭烹饪说,他们可能会去自行制作 “意式肉酱 (Bolognese Sauce)”,当然也有可能会直接去买一瓶肉酱来代替。
白酱 (Béchamel Sauce) 却又是另一回事了。事实上,白酱对于烹饪来说,是一个非常重要的酱料,用瓶装的并不是一个好主意。这就是我们常说的“妈妈的味道”(在传统的法式烹饪当中,有五种所谓的“基础调味酱”,在此基础之上又会延伸出一系列的酱汁)。
我们现在有了意大利千层面的食谱了,然后我们会开始制作所说的白酱。首先,在制作掺油面粉糊 (Roux) 的过程,放入黄油和面粉,然后倒上牛奶,等待其冷却之后品尝一下。这会给意大利千层面带来一个很美味的调料。
不管您在网上看过什么言论,有一点是毋庸置疑的,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,这里不是有同样的负号么?”。没错,不过现在它就位于 上下文环境
当中了。这个负号已经完全得到说明了。我可以说 timeIntervalUntilNow
是 timeIntervalSinceNow
的负值,它对于我以及来看我代码的其他人来说,是具有完整的意义的。 通过提供上下文环境,您就可以编写清晰明了的代码了,就像意大利千层面一样,每个馅料都包含在“面饼”里面
。
这里没有什么让人困惑的东西;我只需要调用 timeIntervalUntilNow
,一切清晰明了。
现在,如果我们不需要使用 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 } }
我们只需要调用 startTime
和 timeIntervalUntilNow
即可,我们不需要再在类别中再次实现了。我们在同一个文件中放入这个扩展,这样它就不会污染我们的代码库,这样我们就完成了 NSDate
的扩展,然后返回 timeIntervalSinceNow
的相反值。
对我来说,这就是所谓的“调味酱汁 (Sauce on the side)” 的最好说明。这是我所用的“调味汁”当中的一种。在同一个文件当中,我提供了详情信息,因此我们在这里就完成了一个美味的“意大利千层面”的制作工作,因为代码是干净、结构紧凑、清晰并且可测试的。这有一点点作弊,因为我们是以 Objective-C 开始的,然后将代码转换为 Swift 而已,因此我们实际上没有做任何困难或者机智的东西,因此您可能仍然还是很困惑。
当您从 Objective-C 转来第一次学习 Swift 的时候,您觉得您只是用一种新的语法在编写 Objective-C 而已。但是,这并不是 Swift 的观念;您所做的只不过是“音译”而已。要去理解 Swift 的理念的话,我们必须要做一些不同的事情。
应用销售清单:Swift 的现场示例
假设,我想要跟踪一周内的应用销售情况。我需要 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
数组。
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) }
如果您没有使用过 $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 )
我现在有了一周的销售额了,我对其进行匹配,这样我可能拥有的值会少于 7 个。这或许会产生一些不好的影响,也许不会。在这种情况下,它并不会出问题(不过它很可能会出问题!)。我想要保证括号当中的数值始终保证有 7 个。因为匹配会改变数组的大小,因此我想要使用 filter
来替代,映射所有小于 0 的数据。
我只是改变了任何小于 0 的数值而已,然后我告诉自己“不要经常使用这个方法,这只是外部的标准偏差而已……”。我真的如此做了,所以我可以告诉大家我的做法:
let lastWeeksRevenues = lastWeeksSales.filter{$0 > 0 ? $0 : 0} .map( revenuesForCopiesSold )
因为到了现在,我真的感觉自己像是一个 程序员 了。但是我很容易将 $0
和 0
混淆,我很难记住什么是什么,因此很容易搞错,我在这里还使用了三元运算符。我担心在我的团队中会有人不能理解这段代码,因此我又进行了修改。
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)) }
当我阅读这段代码的时候,可能会要将阅读方向反过来。我们从内部开始,然后逐步往外阅读。
有意义的自定义运算符及泛型
现在我很不情愿地向大家展示一个自定义运算符。
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
放到队列当中,最后就得到了结果。现在我感觉非常良好。
回到千层面样式的代码中
我计算完 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 }
每个部分都只关心自己的事情。当我在思考的时候,对我来说每个部分都非常的清晰。我可以在我的头脑中保持对代码的理解,但是我并不想要在我的头脑中放入太多的信息。如果有东西出了问题,我可以很快追溯到哪里出了问题,只需要发现测试失败就可以了。我们可以不停地进行精炼,直到代码完全清晰明了。
我们的代码现在变得非常的 干净 、清晰,并且 可以测试 。我对其感觉非常良好,因为我们为每个部分都提供了相应的上下文信息。
关于如何移除关于传感器方面的自定义运算符的有关内容,您可以查看文章顶部的视频中的此时间戳:
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。