1

作者: David Ungar,时间:2016/1/27
翻译:BigNerdCoding, 如有错误欢迎指出。原文链接

前言

伴随着Swift语言的快速发展,我们对于苹果设备编程的认识也发生着变化。与原来的Objective-C语言相比,Swift语言带来的更多现代化的特征,例如函数式编程和更多的类型检查。

Swift语言采用一些安全的编程模式来帮助开发者避免一些bug。然而不可避免的是,这种雄心勃勃的做法也会让我们的程序中引入一些陷阱(至少目前是这样),并且在编译的时候编译器无法检查出来并给出任何警告提示。这其中的一些陷阱在官方的Swift书里面,但是还有一些书中并没有提及。下面会介绍7个陷阱,其中的大部分坑我都进过。它们涉及到Swift的协议扩展(protocol extensions),可选链(optional chaining),以及函数式编程(functional programming)

协议扩展:强大,但是小心使用

对于程序员来说,Swift中类的继承特性是自己武器库里的一件有力武器,因为它能让类之间的特殊关系变的明确,而且让代码的分享和复用变的可行。但是Swift中的值类型(value types)并不能和引用类型(reference types)一样能相互之间进行继承。然而,一个值类型却可以继承自一个协议,反过来协议还能继承自另一个协议。虽然协议里面不能包含代码只能含有类型信息,但是类型的扩展却可以包含代码。通过这种方式,代码可以以树形层级结构来说实现继承共享:值类型作为叶子节点,而根节点和中间节点则是协议于相应的协议扩展。

但是协议扩展的实现,作为一个新的处女地,还存在几个问题。代码可能不会总是如我们所期待的那样运行。因为Swift中结构和枚举都是值类型于协议在一起的使用的时候会有坑,我们在示例中先使用类来避免这个坑,然后再对比看看前者的一下神奇的坑。

简单实例:类类型的pizza

我们首先假设,这里有三种pizza,是由两种谷物制作的:

enum Grain  { case Wheat, Corn }
class  NewYorkPizza  { let crustGrain: Grain = .Wheat }
class  ChicagoPizza  { let crustGrain: Grain = .Wheat }
class CornmealPizza  { let crustGrain: Grain = .Corn  }   

每一种pizza都能返回制作的原料:

NewYorkPizza().crustGrain     // returns Wheat
ChicagoPizza().crustGrain     // returns Wheat
CornmealPizza().crustGrain     // returns Corn  

因为大部分的pizza都是由Wheat制作的,我们可以将通用的代码分解出来放在一个超类的默认的实现里面:

enum Grain { case Wheat, Corn }
class Pizza {
    var crustGrain: Grain { return .Wheat }
    // other common pizza behavior
}
class NewYorkPizza: Pizza {}
class ChicagoPizza: Pizza {}

其它情况可以使用重载来解决:

class CornmealPizza: Pizza {
    override var crustGain: Grain { return .Corn }
}

哎呀!这里代码是错的,幸好编译器检查出来了。我们在写变化crustGain遗漏了一个字符'r'。Swift通过强制类中重载的代码必须明确对应来避免这种错误。因此,这里代码声明为重载,拼写错误就会被检查出来。修改后:


class CornmealPizza: Pizza {
    override var crustGrain: Grain { return .Corn }
}  

现在,编译器会通过:


NewYorkPizza().crustGrain         // returns Wheat
ChicagoPizza().crustGrain         // returns Wheat
CornmealPizza().crustGrain         // returns Corn  

我们可以更进一步分解出通用代码,父项Pizza允许我们不必知道具体的pizza类型就可以进行操作,因为我们可以声明一个通用的pizza变量。

var pie: Pizza  

通用型的pizza变量依然可以如下获得具体的信息:

pie =  NewYorkPizza();        pie.crustGrain     // returns Wheat
pie =  ChicagoPizza();      pie.crustGrain     // returns Wheat
pie = CornmealPizza();      pie.crustGrain     // returns Corn  
  

上面的引用类型是个很好的例子。但是当程序涉及到并发的时候,就会面临一些条件竞争,而值类型则由于不可变的语言特性支持而不会出现这些情况。接下来看看值类型下的pizza。

简单的值类型的例子

pizza的三种种类和原料可以使用struct之类的值类型表示,就像引用类型一样简单。

enum Grain { case Wheat, Corn }
struct  NewYorkPizza     { let crustGrain: Grain = .Wheat }
struct  ChicagoPizza     { let crustGrain: Grain = .Wheat }
struct CornmealPizza     { let crustGrain: Grain = .Corn  }  

如下调用:


NewYorkPizza()    .crustGrain     // returns Wheat
ChicagoPizza()    .crustGrain     // returns Wheat
CornmealPizza()    .crustGrain     // returns Corn     

包含所有pizza的协议和一个无法检测到的错误

使用引用类型,我们可以声明一个公共的父类来表示一个通用的"pizza"概念。在值类型中要实现相同的功能,我们需要两个部分而不是一个:一个声明通用类型的协议和定义新类型属性的协议扩展。


protocol Pizza {}
extension Pizza {  var crustGrain: Grain { return .Wheat }  }

struct  NewYorkPizza: Pizza { }
struct  ChicagoPizza: Pizza { }
struct  CornmealPizza: Pizza {  let crustGain: Grain = .Corn }

编译如下代码做测试:

NewYorkPizza().crustGrain         // returns Wheat
ChicagoPizza().crustGrain         // returns Wheat
CornmealPizza().crustGrain         // returns Wheat  What?!

这里发生了错误,与上面提到的错误一样也是忘记了字符'r'。但是在值类型,这里没有override关键字去帮助编译器发现错误。在语言中出现这样的遗漏是很不合适的,否则你需要提供足够的冗余去发现这个错误。没有了编译器的帮助,我们只能自己更加小心一点。第一个坑的准则:

⚠️ 在重载协议扩展的属性时候移动要复查,属性名称。

好了,让我们修复这个问题并再次测试:

struct CornmealPizza: Pizza {  let crustGrain: Grain = .Corn }
NewYorkPizza().crustGrain         // returns Wheat
ChicagoPizza().crustGrain         // returns Wheat
CornmealPizza().crustGrain         // returns Corn  Hooray!

Pizza变量,但是错误的答案

为了讨论一个通用的pizza而不关心具体的类型,我们可以使用Pizza协议作为一个变量的类型。然后我们使用变量来获得不同pizza的原料:

var pie: Pizza

pie =  NewYorkPizza(); pie.crustGrain  // returns Wheat
pie =  ChicagoPizza(); pie.crustGrain  // returns Wheat
pie = CornmealPizza(); pie.crustGrain  // returns Wheat    Not again?!

为什么对于cornmeal pizza程序返回给我们的是wheat?Swift编译后的代码忽略了其真实的值。编译器能提供给编译后的代码信息就是程序编译时的信息,而不是代码运行时的信息。这里,我们在编译时(compile-time)能知道的就是pie是一个pizza,并且在pizza的扩展里面声明了是Wheat,所以CornmealPizza里面的声明并不会起到任何作用,调用的时候自然无法返回我们希望的结果。尽管便一起可会对这个使用静态而不是动态调用的潜在错误提出警告,但是这里没有。我相信一不小心你就会掉进去,我称之为大圈套。

这里提供了一个方案可以修复这个问题。除了在协议扩展里面定义属性外:

protocol  Pizza {}
extension Pizza {  var crustGrain: Grain { return .Wheat }  }

我们还在协议里进行声明:

protocol  Pizza {  var crustGrain: Grain { get }  }
extension Pizza {  var crustGrain: Grain { return Wheat }  }

使用既提供一个声明并且加上定义这种做法给Swift,会让它通知编译器变量的运行时的类型和值。(但并不全是这样,当我们没有在协议扩展里面定义crustGrain的话,协议里的crustGrain声明必须在每一个继承Pizza类型里面[structure, class, or enumeration]实现。)

协议里面声明的属性意味这两件不同的事,静态和动态分发,而这取决于属性有没有在协议扩展里面进行定义。

协议里面添加声明后,代码正常工作了:


pie =  NewYorkPizza();  pie.crustGrain     // returns Wheat
pie =  ChicagoPizza();  pie.crustGrain     // returns Wheat
pie = CornmealPizza();  pie.crustGrain     // returns Corn    Whew!  

这是一个很严重的坑;即使我们已经弄清它了,但是这依然可能给我们程序带来bug。这里要感谢一些这篇文章作者Alexandros Salazar。就像文章中提到的一样这里没有任何编译时检查,为了避免这个坑:

⚠️ 每一个协议扩展中定义的属性,请在协议中进行声明。

但是这种规避并不是总是可能的。

导入的协议并不能完全被扩展

框架和类库为程序代码提供了接口以供使用,而且不需要知道框架代码实现的细节。例如苹果提供了实现的用户体验,系统工具等功能很多框架。Swift语言的扩展功能允许程序添加自己的属性到导入的类、结构、枚举和协议中。对于具体的类型(类、结构、枚举),能够很好的工作,这些属性就像是导入框架中自己原有的定义一样。但是对于协仪扩展来说,她定义的属性并没有一等公民的待遇。因为在协议扩展里面添加一个属性并不能在协议里面进行声明。

下面我们导定义了pizzas的协议框架。框架里面定义了协议和具体类型:

// PizzaFramework:

public protocol Pizza { }

public struct  NewYorkPizza: Pizza  { public init() {} }
public struct  ChicagoPizza: Pizza  { public init() {} }
public struct CornmealPizza: Pizza  { public init() {} }  

接下来,我们导入框架并且进行扩展:


import PizzaFramework

public enum Grain { case Wheat, Corn }

extension Pizza         { var crustGrain: Grain { return .Wheat    } }
extension CornmealPizza { var crustGrain: Grain { return .Corn    } }  

与前面一样,静态分发导致了一个错误的答案:


var pie: Pizza = CornmealPizza()
pie.crustGrain                            // returns Wheat   Wrong!

与前面解释的原因一样,crustGrain属性只进行了定义而没有在协议中声明。然而,我们不能修改框架里面的源码,所以我们无法修复这里的问题。因此无法安全的从其它框架中扩展一个协议(除非你确信它永远都不会需要动态分发)。为了避免这个问题:

⚠️ 永远不要从导入的框架中扩展一个包含可能需要动态分发的属性的协议

正如在任何大型系统一样,Swift中的特性数量会导致一个与之数量匹配的潜在不良后果。如刚刚描述的,框架与协议扩展相互作用限制了后者的作用。但框架是不是唯一的问题,类型限制也会对协议扩展产生不利影响。

在受限的协议拓展中:声明变量已经不够

当我们拓展一个通用的协议,而该协议里面的某些属性只在某些类型里面使用时,我们可以在一个受限的协议里面。但是语言可能并不是我们所期待的那样。回顾一下我们前面的实例代码:

enum Grain { case Wheat, Corn }
protocol  Pizza { var crustGrain: Grain { get }  }
extension Pizza { var crustGrain: Grain { return .Wheat }  }
struct  NewYorkPizza: Pizza  { }
struct  ChicagoPizza: Pizza  { }
struct CornmealPizza: Pizza  { let crustGrain: Grain = .Corn }  

我们可能做了一顿饭主食时pizza。但是并不是每次饭食里面都有pizza,所以我们将不同种类的食物定义为一个通用的膳食结构类型的变量类型:

struct Meal<MainDishOfMeal>: MealProtocol {
    let mainDish: MainDishOfMeal
}

Meal继承了MealProtocol协议,该协议可以检查食物是否有谷蛋白。为了是无谷蛋白的代码能过在不同的meal中分享(如在没有主食的meal),我们使用如下协议:

protocol MealProtocol {
    typealias MainDish_OfMealProtocol
    var mainDish: MainDish_OfMealProtocol {get}
    var isGlutenFree: Bool {get}
}

为了防止有人中毒,做到有备无患,代码需要设定一个安全保守的默认值:

extension MealProtocol {
    var isGlutenFree: Bool  { return false }
}  

很高兴,有一道菜是没问题的:用corn而不是wheat做成的pizza。Swift中的where结构提供了一个方法将这个情况表示为一个受限的协议扩展。如果主食是pizza的话,我们知道它有一个crust,我们可以很安全的获取该属性。如果不使用where的话代码是不安全的:

extension MealProtocol  where  MainDish_OfMealProtocol: Pizza {
    var isGlutenFree: Bool  { return mainDish.crustGrain == .Corn }
}

这个带where的扩展被称为受限扩展(restricted extension)。

接下来,我们看一下cornmeal pizza!

let meal: Meal<Pizza> = Meal(mainDish: CornmealPizza())

meal.isGlutenFree // returns false
// But there is no gluten! Why can’t I have that pizza?

就像前面提到的一样,在协议中进行属性声明,并在协议扩展里面进行相应定义会导致动态分发。但是在受限的协议扩展里面的定义永远都是静态分发的。为了避免这个坑带来的bug:

⚠️ 避免对一个协议进行受限扩展,特别是当扩展里面有个新的属性需要动态分发的时候。

即使你避免了上面于雨协议相关的坑,Swift中还有一些其它的坑。其中大部分都在官方的书籍里面提到了,但是当我们将它单独拿出来分析的时候会更加的突出、明显,这其中就包括接下来要讨论的。

可选链赋值以及相应的一些副作用

注:这里的副作用side-effects是这样理解的,就是发生了一些用户意料意外的事。总之,“side effects”指的就是那些本不应有或者用户意料之外的作用。

Swift中的可选类型使用对可能是nil值静态检查,避免了可能存在的错误。它提供了一个方便速记、可选链,来处理什么时候nil值需要忽略操作,就像Objective-C中默认的一昂。不幸的是,Swift可选链的一些细节可能导致错误的发生,那就是当我们对潜在的空应用变量进行赋值的时候。考虑如下情况,一个对象包含一个整型变量,有一个可能是空指针指向该对象,并且进行赋值:

class Holder  { var x = 0 }
var n = 1
var h: Holder? = ...
h?.x = n++
n  // 1 or 2?

上面代码中n的值,取决于h是不是空值。如果不是空值,那么赋值语句执行,然后n会自增,结束的时候n就为2。反之,赋值语句不执行,自增语句可回跳过,结束的时候n就为1。为了避免这个坑照成的困惑:

⚠️ 不要将一个可能带有副作用的语句表达式赋值给左侧的可选链。

Swift函数式编程的坑

Swift对函数式编程的支持,让这一编程模式的有点更够应用于整个苹果生态系统中。Swift中的函数和闭包是语言中的第一等实体,它们容易使用且功能强大。但是,这里面也有坑在等你。

闭包中输入输出参数会失效

在Swift中输入输出参数,允许在调用函数时接收一个变量的值,并且设置该变量的值。而闭包则可以捕获和抓取上下文中的常量和变量的引用。这两个特性会让代码变得更加的优雅和容易理解。所有你可能同时使用这两个特性,但是当他们一起使用的时候会导致一些问题。

首先让我们来重写crustGrain属性来理解输入输出参数。我们以简单的例子开始,不包含闭包:

enum Grain { case Wheat, Corn }
struct CornmealPizza {
    func setCrustGrain(inout grain: Grain)  { grain = .Corn }
}  

下面我们来简单的测试一下上面的函数,我们传递一个变量过去。当程序返回的时候,该变量的值会从Whwat变成了Corn

let pizza = CornmealPizza()
var grain: Grain = .Wheat
pizza.setCrustGrain(&grain)
grain      // returns Corn  

现在,我们写一个函数,该函数会返回一个闭包,而这个闭包可以设置grain变量的值:

struct CornmealPizza {
    func getCrustGrainSetter()   ->   (inout grain: Grain) -> Void {
        return { (inout grain: Grain) in grain = .Corn }
    }
}   

使用这个闭包的话会需要更多的调用步骤:

var grain: Grain = .Wheat
let pizza = CornmealPizza()
let aClosure = pizza.getCrustGrainSetter()
grain   // returns Wheat (We have not run the closure yet)
aClosure(grain: &grain)
grain   // returns Corn  

到目前为止,代码运行良好没有出现问题。但是如果我们把输入输出参数grain传递给返回闭包的函数而不是闭包本身的时候会发生什么呢?

struct CornmealPizza {
    func getCrustGrainSetter(inout grain: Grain)  ->  () -> Void {
        return { grain = .Corn }
        }
}  

我们试着测试一下代码:

var grain: Grain = .Wheat
let pizza = CornmealPizza()
let aClosure = pizza.getCrustGrainSetter(&grain)
grain    // returns Wheat (We have not run the closure yet)
aClosure()
grain    // returns Wheat  What?!?

输入输出参数传递到闭包外部的时候,没有起到任何作用,因此:

⚠️ 尽量在闭包里面避免使用输入输出参数

这个问题在官方书籍中有提到,但是这里存在一个与之相关的问题。那就是使用Currring创建闭包的时候。

Currying 里面使用输入输出参数会导致与上面不一致的问题

对于创建和返回一个闭包的函数,Swift提供了一个紧凑的语法和结构。虽然这个Currying的语法很简短紧凑,但是当在里面使用输出输入参数时会有一个隐藏的错误。为了揭示这个问题,我们使用一个带有Curring语法的相同例子。不同于声明一个返回函数类型的函数,这里在第一个参数列表后面还有另一个参数列表,而这也隐藏了闭包的创建:

struct CornmealPizza {
    func getCrustGrainSetterWithCurry(inout grain: Grain)() -> Void {
        grain = .Corn
    }
}

与显式的创建闭包一样,调用该函数也会返回一个闭包:

var grain: Grain = .Wheat
let pizza = CornmealPizza()
let aClosure = pizza.getCrustGrainSetterWithCurry(&grain) 

但是与前面的设置失败不同,这里成功了:

aClosure()
grain    // returns Corn 

显示构造闭包失败的地方,Curring能够成功起到作用

⚠️ 不要在Curring里面使用输入输出参数,因为如果你以后将代码改为显示创建闭包的话,代码会不起作用而失效

总结

针对苹果设备上的软件编程,Swift语言进行了进行了精心的设计。就像任何雄心勃勃的承诺一样,总有一些边缘问题会导致程序不按我们的意愿运行。为了避免这些坑:

  • ⚠️ 在重载协议扩展的属性时候移动要复查,属性名称。

  • ⚠️ 每一个协议扩展中定义的属性,请在协议中进行声明。

  • ⚠️ 永远不要从导入的框架中扩展一个包含可能需要动态分发的属性的协议

  • ⚠️ 避免对一个协议进行受限扩展,特别是当扩展里面有个新的属性需要动态分发的时候。

  • ⚠️ 不要将一个可能带有副作用的语句表达式赋值给左侧的可选链。

  • ⚠️ 尽量在闭包里面避免使用输入输出参数

  • ⚠️ 不要在Curring里面使用输入输出参数,因为如果你以后将代码改为显示创建闭包的话,代码会不起作用而失效


BigNerdCoding
1.2k 声望125 粉丝

个人寄语: