Swift
语言旨在帮助开发人员通过采用安全的编程模式来避免错误。但是,不可避免的是,如此雄心勃勃的事业产生的工件中也难免会有一些粗糙的地方(至少目前是这样),一些陷阱可能会将错误引入到程序中,而编译器却不会发出任何警告。Swift
手册中提到了一部分陷阱,但并不全面(就我所知)。以下是七个陷阱,在过去的一年中,我曾陷入过其中的大部分陷阱。这些陷阱涉及到
Swift 的协议扩展、可选链接和函数式编程。
Swift 正在完成一个惊人的壮举;Swift 正在改变我们为苹果设备编程的思路,带来了更现代的范例(比如,函数式编程),以及比
Smalltalk 所启发的纯面向对象式模型 Objective-C 更丰富的类型检查。
Swift 语言旨在帮助开发人员通过采用安全的编程模式来避免错误。但是,不可避免的是,如此雄心勃勃的事业产生的工件中也难免会有一些粗糙的地方(至少目前是这样),一些陷阱可能会将错误引入到程序中,而编译器却不会发出任何警告。Swift
手册中提到了一部分陷阱,但并不全面(就我所知)。以下是七个陷阱,在过去的一年中,我曾陷入过其中的大部分陷阱。这些陷阱涉及到
Swift 的协议扩展、可选链接和函数式编程。
协议扩展:功能强大,但请谨慎使用
在编程人员的武器库中,一个 Swift 类继承另一个 Swift 类的能力是一个非常强大的武器,因为它使得特殊化的关系变得非常明确,而且支持细粒度的代码共享。但是,与
Swift 的引用类型不同,Swift 的值类型(即结构和枚举)不能相互继承。但是,可以从一个协议继承值类型,而协议又可以继承另一个协议。虽然协议不能包含代码,只能包含类型信息,但协议扩展可以包含代码。借助这种方式,可以通过创建一个层次结构来共享代码,其中的叶子就是值类型,内部节点和根节点是协议及其相应的扩展。
但从某种程度上讲,Swift 的协议扩展实现是一个崭新的处女地,该协议扩展存在几个问题。代码的行为并不总是与预期相符。因为在
Swift 中的陷阱涉及与协议相结合的结构和枚举值类型,我们会用包含类的示例来说明这一点,并避开这些陷阱。在改写成值类型和协议时,会一些让人感到意外的地方。
示例介绍:好吃的比萨
假设用两种谷物制成三种比萨:
enum Grain { case Wheat, Corn } class NewYorkPizza { let crustGrain:Grain = .Wheat } class ChicagoPizza { let crustGrain:Grain = .Wheat } class CornmealPizza { let crustGrain:Grain = .Corn } |
每一种比萨都可以响应有关其饼皮的查询:
NewYorkPizza().crustGrain // returns Wheat ChicagoPizza().crustGrain // returns Wheat CornmealPizza().crustGrain // returns Corn |
由于大多数比萨是用小麦 (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 通过强制代码明确声明类中的重写方法,防止这样的错误成为漏网之鱼。在本例中,代码声明了一个
override,担拼写错误的 crustGain 没有重写任何东西。改正如下:
class CornmealPizza:Pizza { override var crustGrain:Grain { return .Corn } } |
现在,它可以编译和工作:
NewYorkPizza().crustGrain // returns Wheat ChicagoPizza().crustGrain // returns Wheat CornmealPizza().crustGrain // returns Corn |
除了分析出公共代码之外,Pizza 超类还允许代码在不了解具体的比萨类型的情况下对比萨进行运算,因为可以声明一个变量来代表一般的比萨:
但是,仍然可以使用一般的比萨引用来获取特定的信息:
pie = NewYorkPizza();pie.crustGrain // returns Wheat pie = ChicagoPizza(); pie.crustGrain // returns Wheat pie = CornmealPizza(); pie.crustGrain // returns Corn |
Swift 的引用类型(即类)在这个示例中已经很好地发挥了作用。但是,如果该程序涉及并发性,可以通过使用
Swift 的值类型及其对不可变性的语言支持来避免竞争条件。让我们尝试一下具有值类型的比萨。
简单的比萨值
使用值类型来表示三种比萨类型和使用引用类型一样简单,只需使用 struct
替换 class:
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 |
用一个协议概括各种比萨,有一个未检测到的错误
使用引用类型,我们能够通过增加一个公共超类来引入更笼统的“比萨”概念。对值类型进行相同的概括需要创建两个新的项目(而不是一个):一个协议用于声明公共类型:
另一个 协议扩展用于 定义新类型的属性:
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?! |
有问题:玉米面比萨不是用小麦制成的!哎呀,我又忘了 crustGain 里面的 ‘ r ’。但是,在使用值类型的时候,没有关键字
override 来帮助编译器发现我的错误。这一种语言在设计中包括了足够的冗余来帮助发现错误,而出现这种遗漏则显得格格不入。没有编译器的帮助,我们必须更加小心。作为基本规则,
仔细检查重写协议扩展的属性名称。
好吧,让我们来解决这个排印错误:
struct CornmealPizza:Pizza { let crustGrain:Grain = .Corn } |
再试一次:
NewYorkPizza().crustGrain // returns Wheat ChicagoPizza().crustGrain // returns Wheat CornmealPizza().crustGrain // returns Corn Hooray! |
用一个变量表示比萨,有一个错误的答案
为了在讨论比萨时不必考虑纽约、芝加哥或玉米面之类的东西,可以使用 Pizza
协议作为一个变量类型:
可以用这个变量来回答有关不同比萨的问题:
pie = NewYorkPizza(); pie.crustGrain // returns Wheat pie = ChicagoPizza(); pie.crustGrain // returns Wheat pie = CornmealPizza(); pie.crustGrain // returns Wheat Not again?! |
为什么程序撒谎说玉米面比萨中含有小麦? Swift 为 crustGrain 查询编译的代码忽略了该变量的实际值。编译器允许编译的代码使用的惟一信息是,在编译该程序时已知的信息,而不是在运行该程序时出现的信息。在编译时可以知道的信息就是,pie
是 Pizza,并且 Pizza 协议扩展提到了 Wheat,所以,在向 pie 要结果的时候,CornmealPizza
结构中的玉米面饼皮的声明没有任何作用。尽管编译器可能警告过,要使用静态分配,而不是动态分配,否则可能产生错误,但事实并非如此。我相信,这里潜伏着一个陷阱,等待粗心大意的人踩下去,我将这个陷阱称为一个
大陷阱。
在本例中,Swift 提供一个修补程序。除了在扩展中定义 crustGrain
属性:
protocol Pizza {} extension Pizza { var crustGrain:Grain { return .Wheat } } |
代码还可以在协议中 声明属性:
protocol Pizza { var crustGrain:Grain { get } } extension Pizza { var crustGrain:Grain { return Wheat } } |
以这种方式向 Swift 同时提供一个声明和一个定义,让编译器注意到 pie 变量的运行时值。(但不总是这样,如果我们没有在扩展中定义
crustGrain,协议中的 crustGrain 声明将意味着,继承 Pizza 的每一个聚集 [
结构、类或枚举 ] 都必须实现 crustGrain。)
在协议中的属性声明有两种不同的含义,静态分配与动态分配,具体采用哪种分配取决于是否在扩展中定义了该属性。
添加了声明后,代码可以正常工作:
pie = NewYorkPizza(); pie.crustGrain // returns Wheat pie = ChicagoPizza(); pie.crustGrain // returns Wheat pie = CornmealPizza(); pie.crustGrain // returns Corn Whew! |
Swift 的这个方面是一个危险的陷阱;即使已经很清楚产生该陷阱的原因,它仍然会继续带着错误来骚扰我的代码。感谢
Alexandros Salazar对这个问题做了很好的书面记录。没有针对这个错误的编译时检查(截至撰写本文时没有,2015
年 12 月 23 日,Xcode 7.2)。为了避免这一缺陷:
对于在协议扩展中定义的每个属性,都要在协议本身中声明它。
然而,并不总是能够进行这种规避 ...
导入的协议不能被完全扩展
框架(和库)允许程序将接口导入代码,但这并不包括所有的实现。例如,苹果提供很多框架来实现用户体验、系统工具和其他功能。Swift
的扩展功能允许一个程序将自己的属性添加到导入的类、结构、枚举和协议中。对于具体的类型(类、结构和枚举),通过扩展添加的属性的效果就像它已经存在于原始定义中一样。但在协议扩展中定义的属性并不是该协议的一等公民,因为不可能用协议扩展来添加一个声明。
让我们尝试导入一个框架:定义比萨,并扩展它来处理饼皮。该框架定义了协议和具体的类型:
// 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 } |
让我们用比萨做一顿饭。遗憾的是,并非所有的饭菜都包含比萨,所以我们会用一个通用的
Meal 结构的类型参数来包含不同的菜品类型:
struct Meal<MainDishOfMeal>:MealProtocol { let mainDish:MainDishOfMeal } |
Meal 继承了 MealProtocol 协议,让代码可以测试膳食中是否不含麸质。我们使用一个协议,让不含麸质的代码与具有其他表示的膳食(例如,没有主菜的膳食)进行共享。
protocol MealProtocol { typealias MainDish_OfMealProtocol var mainDish:MainDish_OfMealProtocol {get} var isGlutenFree:Bool {get} } |
为了避免人们食物中毒(做好安全措施总比事后遗憾好),代码中包含一个保守的默认值:
extension MealProtocol { var isGlutenFree:Bool { return false } } |
令人高兴的是,有一种菜品是没问题的:用玉米替代小麦制成的比萨。Swift
的 where 构造函数提供一种方法来将这种情况表达为一个受限制的协议扩展。当主菜是比萨的时候,我们知道它有饼皮,因此向它提出有关饼皮的问题是安全的。如果没有限制性的
where 子句,代码将是不安全的:
extension MealProtocol where MainDish_OfMealProtocol:Pizza { var isGlutenFree:Bool { return mainDish.crustGrain == .Corn } } |
带有一个 where 子句的扩展被称为受限制的扩展。
让我们做一个漂亮的玉米面比萨!
let meal<Pizza>:Meal = Meal(mainDish:CornmealPizza()) |
并仔细检查我们的菜品:
meal.isGlutenFree // returns false // But there is no gluten! Why can ’ t I have that pizza? |
如上一节所示,协议中的声明足以导致对在协议扩展中 已定义的相应属性进行动态分配。但是,受限制的扩展中的定义
始终是静态分配的。为了防止意外的静态分配所产生的错误:
如果新的属性可能需要动态分配,请避免使用某一限制条件来扩展协议。
即使我们可以避免与协议扩展有关的陷阱,Swift
中还会有其他可能产生问题的构造函数。在苹果的 Swift 书籍中,提到了其中的大部分陷阱,但由于这些陷阱在孤立的时候可能更加突出,所以我们在下文中进行了相关的讨论。
用于赋值的可选链接和副作用
Swift 的可选类型可以通过对零值的可能性提供一个静态检查来防止错误。它提供了一个方便的速记(可选链接)来处理可以忽略零值的情况,这在
Objective-C 中是默认处理方法。遗憾的是,当赋值的目标可能是零值引用时,Swift
对可选链接的语义说明可能会导致错误。假设有一个对象保存了一个整数、一个指向它的可能为零的指针和一个赋值:
class Holder { var x = 0 } var n = 1 var h:Holder?= ... h?.x = n++ n // 1 or 2? |
最终的 n 值取决于 h 到底是不是零!如果 h 不是零,则执行赋值,执行增量运算符,n 最终为 2。但是,如果
h 是零,不仅赋值会被跳过,增量运算符也会被跳过,那么 n 最终是 1。为了避免因缺少副作用而产生的意外,
避免将有副作用的表达式结果赋值给有可选链接的左侧。
Swift 中的函数式编程陷阱
Swift 对函数式编程的支持为苹果的生态系统带来了利用该模式的优势的能力。Swift 函数与闭包都是一流的实体,使用方便,而且具有强大的能力。遗憾的是,这里要避免几个陷阱。
输入输出参数在闭包中静默地失败
Swift 的输入输出参数允许函数从调用程序的变量中获得一个值,然后设置该变量的值。Swift 的闭包支持引用在执行过程中捕获的函数。两者均有助于代码的整洁和表达,所以您可能会想一起使用它们,但这个组合可能存在问题。
让我们重写 crustGrain 属性来说明一个输入输出参数。我们从简单的、没有闭包的函数开始:
enum Grain { case Wheat, Corn } struct CornmealPizza { func setCrustGrain(inout grain:Grain) { grain = .Corn } } |
要使用此函数,需要将一个变量传递给它。函数返回后,该变量的值已从 Wheat
更改为 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?!? |
输入输出参数在被传递到闭包的外部范围时不起作用,所以
避免在闭包中使用输入输出参数。
这个问题在 Swift 书籍中已经提到过,但在创建闭包时有一个与柯里化 (currying) 的等效性相关的问题。
输入输出参数曝露了与柯里化的不一致性
对于创建并返回闭包的函数,Swift 为函数的类型和主体提供了简洁的语法。虽然这种柯里化语法应该只是单纯的简写,但它在与输入输出参数一起使用时,隐藏着一个意外。为了阐述这个意外,让我们用特殊的柯里化语法来尝试相同的例子:代码没有将函数类型声明为返回一个函数,而是在第一个参数列表之后还有第二个参数列表,它省略了明确的闭包创建:
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 |
柯里化对输入输出参数可以正常工作,而明确的闭包创建却失败了。
避免用输入输出参数进行柯里化,因为如果以后将它改为明确创建闭包,代码将会失败。
结束语
苹果的 Swift 语言已经过精心雕琢,用于优化软件的创建。与任何雄心勃勃的事业一样,难免会有一些粗糙的地方导致程序无法按照预期正常工作。为了避免出现这种不愉快的意外,让我们回顾一下这些陷阱,让您能够在自己的代码中避免它们:
仔细检查重写协议扩展的属性名称。
对于在协议扩展中定义的每个属性,都要在协议本身中声明它。
不要用可能需要动态分配的新属性来扩展导入的协议。
如果新的属性可能需要动态分配,避免使用一个限制条件来扩展协议。
避免将有副作用的表达式结果赋值给有可选链接的左侧。
避免在闭包中使用输入输出参数。
避免用输入输出参数进行柯里化,因为如果以后将它改为明确创建闭包,代码将会失败。
|