Fork me on GitHub

Swift进阶

Swift进阶

[TOC]

1.guard的设计理念

什么是 guard

  • 与if语句相同的是,guard也是基于一个表达式的布尔值去判断一段代码是否该被执行。
  • 与if语句不同的是,guard语句判断其后的表达式布尔值为false时,才会执行之后代码块里的代码,如果为true,则跳过整个guard语句,继续执行下面的代码;而且guard语句只会有一个代码块,不像if语句可以if else多个代码块。
  • 可以把guard近似的看做是Assert
  • 使用guard语法,可以先对每个条件逐一做检查,如果不符合条件判断就退出(或者进行其他某些操作)。

设计理念

增强程序可读性。保持代码美观。

很多情况下我们会遇到if金字塔( if 太多,首行缩进太严重,大块代码都被挤到一块儿),尤其是在闭包里面使用 if 嵌套对于可读性来说简直是灾难。guard这时可以帮我们解套。

2.为什么引入值类型

  1. 值类型存储在栈上,引用类型存储在堆上,效率比较高

  2. 使用值类型,有可能在编译器就把问题暴露出来。比如

    1
    2
    3
    4
    5
    6
    7
    8
    9
    // 我们都知道let是常量(常量的值是不允许改变的),引用类型和值类型的let在逻辑上是有本质不同的。

    let arr1 = NSMutableArray.init(array: ["123","123","456"], copyItems: false)
    arr1.removeObject(at: 0)
    //以上编译不会报错,由于arr1是引用类型,存储的是指针,指针不变就没问题,对指向的对象没有限制

    let arr2 = ["123","123","123"]
    arr2.remove(at: 0)
    //以上编译会报错,由于arr2是值类型
  3. 值类型,每个实例保持一份数据拷贝,就不会出现一个实例的值在其他位置被修改的情况。

3.值类型和引用类型的区别

源地址:swift的值类型和引用类型

值类型(Value Type)

值类型,即每个实例保持一份数据拷贝。

在 Swift 中,典型的有 struct,enum,以及 tuple 都是值类型。而平时使用的 IntDoubleFloatStringArrayDictionarySet 其实都是用结构体实现的,也是值类型。

Swift 中,值类型的赋值为深拷贝(Deep Copy),值语义(Value Semantics)即新对象和源对象是独立的,当改变新对象的属性,源对象不会受到影响,反之同理。

如果声明一个值类型的常量,那么就意味着该常量是不可变的(无论内部数据为 var/let)。

引用类型(Reference Type)

引用类型,即所有实例共享一份数据拷贝。

在 Swift 中,class 和闭包是引用类型。引用类型的赋值是浅拷贝(Shallow Copy),引用语义(Reference Semantics)即新对象和源对象的变量名不同,但其引用(指向的内存空间)是一样的,因此当使用新对象操作其内部数据时,源对象的内部数据也会受到影响。

如果声明一个引用类型的常量,那么就意味着该常量的引用不能改变(即不能被同类型变量赋值),但指向的内存中所存储的变量是可以改变的。

函数传参

在 Swift 中,函数的参数默认为常量,即在函数体内只能访问参数,而不能修改参数值。具体来说:

  1. 值类型作为参数传入时,函数体内部不能修改其值
  2. 引用类型作为参数传入时,函数体内部不能修改其指向的内存地址,但是可以修改其内部的变量值

但是如果要改变参数值或引用,那么就可以在函数体内部直接声明同名变量,并把原有变量赋值于新变量,那么这个新的变量就可以更改其值或引用。

当值类型的变量作为参数被传入函数时,相当于创建了新的常量并初始化为传入的变量值,该参数的作用域及生命周期仅存在于函数体内。

当引用类型的变量作为参数被传入函数时,相当于创建了新的常量并初始化为传入的变量引用,当函数体内操作参数指向的数据,函数体外也受到了影响。

嵌套类型

值类型嵌套值类型

值类型嵌套值类型时,赋值时创建了新的变量,两者是独立的,嵌套的值类型变量也会创建新的变量,这两者也是独立的。

值类型嵌套引用类型

值类型嵌套引用类型时,赋值时创建了新的变量,两者是独立的,但嵌套的引用类型指向的是同一块内存空间,当改变值类型内部嵌套的引用类型变量值时(除了重新初始化),其他对象的该属性也会随之改变。

引用类型嵌套值类型

引用类型嵌套值类型时,赋值时创建了新的变量,但是新变量和源变量指向同一块内存,因此改变源变量的内部值,会影响到其他变量的值。

引用类型嵌套引用类型

引用类型嵌套引用类型时,赋值时创建了新的变量,但是新变量和源变量指向同一块内存,内部引用类型变量也指向同一块内存地址,改变引用类型嵌套的引用类型的值,也会影响到其他变量的值。

扩展

我们经常会处理一些需要有明确的生命周期的对象,我们会去初始化这样的对象,改变它,最后摧毁它。举个例子,一个文件句柄 (file handle) 就有着清晰的生命周期:我们会打开它,对其进行一些操作,然后在使用结束后我们需要把它关闭。如果我们想要打开两个拥有不同属性的文件句柄,我们就需要保证它们是独立的。想要比较两个文件句柄,我们可以检查它们是否指向着同样的内存地址。因为我们对地址进行比较,所以文件句柄最好是由引用类型来进行实现。这也正是 Foundation 框架中 FileHandle 类所做的事情。

其他一些类型并不需要生命周期。比如一个 URL 在创建后就不会再被更改。更重要的是,它在被摧毁时并不需要进行额外的操作 (对比文件句柄,在摧毁时你需要将其关闭)。当我们比较两个 URL 变量时,我们并不关心它们是否指向内存中的同一地址,我们所比较的是它们是否指向同样的 URL。因为我们通过它们的属性来比较 URL,我们将其称为值。在 Objective-C 里,我们用 NSURL 来实现一个不可变的对象。不过在 Swift 中对应的 URL 却是一个结构体。

软件中拥有生命周期的对象非常多 — 比如文件句柄,通知中心,网络接口,数据库连接,view controller 都是很好的例子。对于这些类型,我们想在初始化和销毁的时候进行特定的操作。在对它们进行比较的时候,我们也不是去比较它们的属性,而是检查两者的内存地址是否一样。所有这些类型的实现都使用了对象,它们全都是引用类型。

在大多数软件里值类型也扮演着重要的角色。URL,二进制数据,日期,错误,字符串,通知以及数字等,这些类型只通过它们的属性来定义。当对它们进行比较的时候,我们不关心内存地址。所有这些类型都可以使用结构体来实现。

值永远不会改变,它们具有不可变的特性。这 (在绝大多数情况下) 是一件好事,因为使用不变的数据可以让代码更容易被理解。不可变性也让代码天然地具有线程安全的特性,因为不能改变的东西是可以在线程之间安全地共享的。

Swift 中,结构体是用来构建值类型的。结构体不能通过引用来进行比较,你只能通过它们的属性来比较两个结构体。虽然我们可以用 var 来在结构体中声明可变的变量属性,但是这个可变性只体现在变量本身上,而不是指里面的值。改变一个结构体变量的属性,在概念上来说,和为整个变量赋值一个全新的结构体是等价的。我们总是使用一个新的结构体,并设置被改变的属性值,然后用它替代原来的结构体。

结构体只有一个持有者。比如,当我们将结构体变量传递给一个函数时,函数将接收到结构体的复制,它也只能改变它自己的这份复制。这叫做值语义 (value semantics),有时候也被叫做复制语义。而对于对象来说,它们是通过传递引用来工作的,因此类对象会拥有很多持有者,这被叫做引用语义 (reference semantics)。

因为结构体只有一个持有者,所以它不可能造成引用循环。而对于类和函数这样的引用类型,我们需要特别小心,避免造成引用循环的问题。

值总是需要复制这件事情听来可能有点低效,不过,编译器可以帮助我们进行优化,以避免很多不必要的复制操作。因为结构体非常基础和简单,所以这是可能的。结构体复制的时候发生的是按照字节进行的浅复制。除非结构体中含有类,否则复制时都不需要考虑其中属性的引用计数。当使用 let 来声明结构体时,编译器可以确定之后这个结构体的任何一个字节都不会被改变。另外,和 C++ 中类似的值类型不同,开发者没有办法知道和干预何时会发生结构体的复制。这些简化给了编译器更多的可能性,来排除那些不必要的复制,或者使用传递引用而非值的方式来优化一个常量结构体。

编译器所做的对于值类型的复制优化和值语义类型的写时复制行为并不是一回事儿。写时复制必须由开发者来实现,想要实现写时复制,你需要检测所包含的类是否有共享的引用。

和自动移除不必要的值类型复制不同,写时复制是需要自己实现的。不过编译器会移除那些不必要的“无效”浅复制,以及像是数组这样的类型中的代码会执行“智能的”写时复制,两者互为补充,都是对值类型的优化。我们接下来很快就会看到如何实现你自己的写时复制机制的例子。

如果你的结构体只由其他结构体组成,那编译器可以确保不可变性。同样地,当使用结构体时,编译器也可以生成非常快的代码。举个例子,对一个只含有结构体的数组进行操作的效率,通常要比对一个含有对象的数组进行操作的效率高得多。这是因为结构体通常要更直接:值是直接存储在数组的内存中的。而对象的数组中包含的只是对象的引用。最后,在很多情况下,编译器可以将结构体放到栈上,而不用放在堆里。

当和 Cocoa 以及 Objective-C 交互时,我们可能通常都需要类。比如在实现一个 table view 的代理时,我们除了使用类以外别无它选。Apple 的很多框架都重度依赖于子类,不过在某些问题领域,我们仍然能创建一个对象为值的类。举个例子,在 Core Image 框架里,CIImage 对象是不可变的:它们代表了一个永不变化的图像。

有些时候,决定你的新类型应该是结构体还是类并不容易。两者表现得不太一样,知晓其中的区别将有助于作出决定。

4.类方法 .class 和 .static的区别

  1. 在Swift中 static 和 class 都表示“类型范围作用域”的关键字。在所有类型中(class、static、enum)中,我们可以使用 .static 来描述类型作用域,.class 是专门用于修饰class类型的。
  2. .static 可以修饰属性(计算、存储)和方法,而且所修饰的属性和方法不可以被子类重写。
  3. .class 可以修饰方法和计算属性,但是不能修饰存储属性,而且所修饰的属性和方法是可以被子类重写的,重写的时候可以使用 .class 修饰,也可以使用 .static 修饰。
  4. 在 Protocol 中定义类方法的时候,推荐使用 .static,因为它是通用的。

5.Swift语言的优势

Swift代码更好写

从Objective-C中迁移来的API写法更简洁,更易于阅读和维护。类型推断使代码更简洁,更健壮,去掉了引用头文件并提供名称空间。内存自动管理,不需要键入分号。

  • 类型推断(从此不用在定义时就显示的给出变量类型,编译器可以靠上下文进行推断)
  • 引入了命名空间,从此不用再import其他文件
  • 告别MRC,全面使用ARC
  • 结构体可添加方法、支持拓展、支持协议
  • 函数支持可选参数,支持多返回值,支持函数入参,为函数式编程提供了强大支持。
  • 可嵌套可添加方法可传参的枚举
  • 支持泛型
  • 多种编程范式支持
  • 可选链式调用
  • 支持面向协议编程,复用性更强

Swift更安全

Swift的nil和OC的nil不是同样的概念,在OC中,nil是一个指向不存在对象的指针。在Swift中,nil并不是一个指针,而是代表一个特定类型值不存在。不光是对象,基本类型、结构体、枚举都可以被设置为nil

OC允许变量为nil,而nil并不明确的代表没有,而代表指向空对象的指针,它还是个指针,而空指针无法很明确的表示不存在。而Swift提出了可选(Optional)的概念,从此,变量只有存在和不存在两种状态、方法调用因此也只存在调了和没调两种情况,去除了OC对于nil指针的种种不确定性。有了存在和不存在,就可以很明确的指出合法和非法,例如对于存在的对象一定可以调用它的方法、不存在的就一定不去调用了;String到Int类型转换成功就一定会返回一个特定类型的对象,失败就一定返回不存在。而这一强大特性又可以被用在各种类型上,在很大程度上提高了语言的严谨性。

Swift是个类型安全的语言,类型安全的语言需要开发者清楚每个变量的类型。如果你需要一个String类型的参数,就绝不能错误地将Int传递给它。正因为Swift是类型安全的,它在编译期对你的代码进行类型检测并指出错误,可以今早发现代码中的类型错误。

除了“可选”的概念之外,类型安全也是Swift安全性的一大体现。在OC中,如果将一个CGFloat赋值给NSInteger,并不会使代码编译不过,但是冥冥之中就损失了精度。在之后的计算中就有可能因为精度问题而导致Bug,而这些Bug有时候是不好被发现的。在Swift中,不同类型之间的赋值都需要经过类型转换,否则编译器会报错。在写类型转换代码的同时,正是你考虑精度的时刻,这样的设计最开始会让你觉得Swift好烦人、好不智能,就像个老太太一样唠唠叨叨,但就是这样的机制保证了类型安全、使得语言更加严谨,代码运行结果更加可预测。

Swift更快

Swift性能分析

Swift开源

Swift跨平台

6.结构体和类

结构体和类的区别

  • 结构体 (和枚举) 是值类型,而类是引用类型。在设计结构体时,我们可以要求编译器保证不可变性。而对于类来说,我们就得自己来确保这件事情。
  • 使用类,我们可以通过继承来共享代码。而结构体 (以及枚举) 是不能被继承的。想要在不同的结构体或者枚举之间共享代码,我们需要使用不同的技术,比如像是组合、泛型以及协议扩展等。

  • 内存的管理方式有所不同。结构体可以被直接持有及访问,但是类的实例只能通过引用来间接地访问。结构体不会被引用,但是会被复制。也就是说,结构体的持有者是唯一的,但是类却能有很多个持有者。

结构体和类的使用时机

  • 当我们需要一个简单不需要继承、不多变的数据时候我们首选结构体,因为在数据结构上来说结构体的存取效率是高于类的,

  • 反之当我们需要一个数据结构比较大,需要继承,变化比较多的时候我们选择类,因为在变化的过程中结构体可能会发生写时复制,而类不会;

下面举一个简单的例子:以Array和NSMutableArray来说:

  • 当有一个数组,数据量相对比较小,也不用去经常改变它,只是用来存数据和取数据,我们首先Array;

  • 当数组的数据量很大的时候,并且经常要去对他进行添加,删除等操作,并且经常赋值给其他变量的话就推荐使用NSMutableArray。

7.谈谈对swift中extension的理解

  • 首先extension在swift中类似oc的类目,可以扩展方法,计算属性,不能添加存储属性;
  • 可以通过extension让类实现某个协议,一般这个用的也比较多,比如实现Comparable这个协议;
  • 还有一个很重要的,就是可以通过extension对协议进行扩展,添加默认实现,属于黑魔法吧,非常好用。

8.Swift写时复制

为什么会有写时复制

在 Swift 中,典型的有 struct,enum,以及 tuple 都是值类型。而平时使用的 IntDoubleFloatStringArrayDictionarySet 其实都是用结构体实现的,也是值类型。

Swift 中,值类型的赋值为深拷贝(Deep Copy),值语义(Value Semantics)即新对象和源对象是独立的,当改变新对象的属性,源对象不会受到影响,反之同理。

如果声明一个值类型的常量,那么就意味着该常量是不可变的(无论内部数据为 var/let)。

这样看来一切都没问题,但是在写代码的时候,这些值类型每次赋值的时候真的是重新在内存中拷贝一份吗?如果一个数组里存了上万个元素,现在把它赋值给另一个变量,就必须要拷贝所有元素,即使这两个数组的内容是完全一致的,那可以预见这对性能会造成多么糟糕的影响。

既然我们能够想到这样的问题,那苹果的工程师肯定也想到了。如何才能避免不必要的复制呢,Swift给出了优化方案:Copy-On-Write(写时复制),即只有当这个值需要改变时才进行复制行为。在Swift标准库中,Array、Dictionary和Set都是通过写时复制来实现的。

什么是写时复制?

1
2
3
var x = [1,2,3]
var y = x
// 断点

这个时候我们打印一下 x 和 y 的内存地址,这里我用的是 lldb 命令fr v -R [object] 来查看对象内存结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(lldb) fr v -R x
(Swift.Array<Swift.Int>) x = {
_buffer = {
_storage = {
rawValue = 0x0000600001c0ac40 {
......省略无用信息
}
}
}
}
(lldb) fr v -R y
(Swift.Array<Swift.Int>) y = {
_buffer = {
_storage = {
rawValue = 0x0000600001c0ac40 {
......省略无用信息
}
}
}
}

由此我们可以看到 x 和 y 的内存地址都是0x0000600001c0ac40,说明 x 和 y 此时是共享同一个实例。这个时候我们再加上下面的代码:

1
2
y.append(4)
// 断点

然后再打印一下地址:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
(lldb) fr v -R x
(Swift.Array<Swift.Int>) x = {
_buffer = {
_storage = {
rawValue = 0x000060000126b180 {
......省略无用信息
}
}
}
}
(lldb) fr v -R y
(Swift.Array<Swift.Int>) y = {
_buffer = {
_storage = {
rawValue = 0x0000600002301b60 {
......省略无用信息
}
}
}
}

可以看到 x 和 y 的内存地址不在相同了,说明此时它们不再共享同一个实例,y 进行了数据拷贝。

Array 结构体内部含有指向某块内存的引用。这块内存就是用来存储数组中元素。x 和 y 两个数组一开始是共享同一块内存。不过,当我们改变 y 的时候,这个共享会被检测到,内存中的数据被拷贝出来,改变以后赋值给了 y。昂贵的元素复制操作只在必要的时候发生,这就是写时复制。

内部实现

在结构体内部存储了一个指向实际数据的引用,在不进行修改操作的普通传递过程中,都是将内部的引用的引用计数+1,在进行修改时,对内部的引用做一次copy操作,再在这个复制出来的数据上进行真正的修改,从而保持其他的引用者不受影响。

值类型嵌套引用类型

上面我们提到的 Array 内部元素是 Int,两者都是值类型,那么如果 Array 内部的元素是引用类型呢,情况会不会发生变化?我们一起来看一下~

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Student {
var name: String
init(name: String) {
self.name = name
}
}

struct School {
var student = Student(name: "小明")
}

let school1 = School()
var school2 = school1
print(school1.student.name)
print(school2.student.name)
// 断点1

school2.student.name = "小红"
print(school1.student.name)
print(school2.student.name)
// 断点2

school2.student = Student(name: "小李")
print(school1.student.name)
print(school2.student.name)
// 断点3

输出结果:
小明
小明
小红
小红
小红
小李

断点1,school1 和 school2 的内存结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(lldb) fr v -R school1
(Copy_On_WriteTest.ViewController.School) school1 = {
student = 0x00006000024053e0 {
name = {
_guts = {
_object = {
_object = 0x9000000105a02356
}
}
}
}
}
(lldb) fr v -R school2
(Copy_On_WriteTest.ViewController.School) school2 = {
student = 0x00006000024053e0 {
name = {
_guts = {
_object = {
_object = 0x9000000105a02356
}
}
}
}
}

school1 赋值给school2 后,school1.studentschool2.student的内存地址都是0x00006000024053e0,其引用类型实例变量 name 的地址也都是 0x9000000105a02356 ,它们共享同一个实例,其引用类型的实例变量也共享。

1.修改结构体内引用类型的实例变量的值

断点2,school1 和 school2 的内存结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(lldb) fr v -R school1
(Copy_On_WriteTest.ViewController.School) school1 = {
student = 0x00006000024053e0 {
name = {
_guts = {
_object = {
_object = 0x9000000105a0235c
}
}
}
}
}
(lldb) fr v -R school2
(Copy_On_WriteTest.ViewController.School) school2 = {
student = 0x00006000024053e0 {
name = {
_guts = {
_object = {
_object = 0x9000000105a0235c
}
}
}
}
}

而执行school2.student.name = "小红" 后,school1.studentschool2.student 的内存地址不变,其实例变量 name 内存地址都发生改变且相同,还是共享同一个实例变量,也就是说,虽然对值类型有所修改,但没有发生拷贝行为。

2.修改结构体内引用类型的值

断点3,school1 和 school2 的内存结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
(lldb) fr v -R school1
(Copy_On_WriteTest.ViewController.School) school1 = {
student = 0x00006000024053e0 {
name = {
_guts = {
_object = {
_object = 0x9000000105a0235c
}
}
}
}
}
(lldb) fr v -R school2
(Copy_On_WriteTest.ViewController.School) school2 = {
student = 0x0000600002405400 {
name = {
_guts = {
_object = {
_object = 0x9000000105a02362
}
}
}
}
}

school2.studentschool2.student.name 的内存地址都发生变化,而school1.studentschool1.student.name 的内存地址不变,说明,此时对结构体进行了拷贝行为,而student 这个引用类型是直接指向另一个实例,而不是对原实例进行修改。

自定义Struct如何实现写时复制

作为一个结构体的作者,你并不能免费获得这种特性,你需要自己进行实现。当你自己的类型内部含有一个或多个可变引用,同时你想要保持值语义,并且避免不必要的复制时,为你的类型实现写时复制是有意义的。

在 Swift 中,我们可以使用 isKnownUniquelyReferenced 函数来检查某个引用只有一个持有者。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct School {
private var student = Student(name: "小明")

var studentName: String {
get {
return student.name
}
set {
if isKnownUniquelyReferenced(&student) {
student.name = newValue
}
else {
student = Student(name: newValue)
}
}
}
}

9.Swift可选类型

为什么引入可选类型?

Swift的nil和OC的nil不是同样的概念,在OC中,nil是一个指向不存在对象的指针。在Swift中,nil并不是一个指针,而是代表一个特定类型值不存在。不光是对象,基本类型、结构体、枚举都可以被设置为nil

OC允许变量为nil,而nil并不明确的代表没有,而代表指向空对象的指针,它还是个指针,而空指针无法很明确的表示不存在。而Swift提出了可选(Optional)的概念,从此,变量只有存在和不存在两种状态、方法调用因此也只存在调了和没调两种情况,去除了OC对于nil指针的种种不确定性。有了存在和不存在,就可以很明确的指出合法和非法,例如对于存在的对象一定可以调用它的方法、不存在的就一定不去调用了;String到Int类型转换成功就一定会返回一个特定类型的对象,失败就一定返回不存在。而这一强大特性又可以被用在各种类型上,在很大程度上提高了语言的严谨性。

可选类型的底层逻辑

Swift实际上是使用枚举来实现可选类型:

1
2
3
4
5
6
7
8
9
10
11
enum Optional<T> : Reflectable, NilLiteralConvertible {
case None
case Some(T)
init()
init(_ some: T)

/// Haskell's fmap, which was mis-named
func map<U>(f: (T) -> U) -> U?
func getMirror() -> MirrorType
static func convertFromNilLiteral() -> T?
}

当Optional没有值时,返回的 nil 其实就是Optional.None,即没有值。除了None以外,还有一个Some,当有值时就是被Some<T>包装的真正的值,所以我们拆包的动作其实就是将Some里面的值取出来。

1
2
3
4
5
6
7
var array = ["one","two","three"]
switch array.index(of: "four") {
case .some(let idx):
array.remove(at: idx)
case .none:
break // 什么都不做
}

在这个 switch 语句中我们使用了完整的可选值枚举语法,在当值为 some 的时候,将其中的“关联类型”进行了解包。这种做法非常安全,但是写起来和读起来都不是很顺畅。Swift 2.0 中引入了使用 ? 作为在 switch 中对 some 进行匹配的模式后缀的语法,另外,可选值遵守 NilLiteralConvertible 协议,因此你可以用 nil 来替代 .none:

1
2
3
4
5
6
switch array.index(of: "four") {
case let idx?:
array.remove(at: idx)
case nil:
break // 什么都不做
}

可选值解包

可选绑定

1
2
3
4
5
6
7
8
9
let str: String? = "sss"
if let s = str {
print(s)
}

guard let s = str else {
return
}
print(s)

强制解包

当你确定自定义的可选类型一定有值时,可以使用操作符(!)进行强制解析,拿到数据,叹号表示”我知道一定有值,请使用它”,但是当你判断错误,可选值为nil时使用(!)进行强制解析,会有运行错误。

1
2
3
var myStr:String? = nil
myStr="强制解析,一定有值,否则运行出错"
print(myStr!)

规则:当你能确定你的某个值不可能为 nil 时可以使用强制解包,你应当会希望如果它不巧意外的是 nil 的话,程序直接挂掉。

改进强制解包的错误信息

使用强制解包的时候,如果程序出错,你从输出中无法知道发生问题的原因是什么。当然你实际上可以加上注释来提醒这里为什么需要强制解包,那么为什么不考虑把这个注释直接作为错误信息呢?

1
2
3
4
5
6
7
8
infix operator !!

func !!<T>(wrapped:T?, failureText:@autoclosure() -> String) -> T {
if let x = wrapped {
return x
}
fatalError(failureText())
}

这样在出错的时候我们就可以在控制台看到错误信息了

1
2
let s = "foo"
let i = Int(s) !! "Expecting integer, got\"\(s)\""
在调试版本中进行断言

在调试版本中我们可以让程序崩溃,但是在发布版本中,最好还是不要,而是提供一个默认值。我们可以选择在调试版本中使用断言,让程序崩溃,而在最终版本中,将它替换为默认值。

我们可以实现一个 !? 操作符来代表这个行为,我们将这个操作定义为对失败的解包进行断言,并且在断言不触发的发布版本中将值替换为默认值。

1
2
3
4
5
6
7
infix operator !?

func !?<T: ExpressibleByIntegerLiteral>(wrapped: T?, failureText: @autoclosure () -> String) -> T
{
assert(wrapped != nil, failureText())
return wrapped ?? 0
}

现在,下面的代码将在调试时触发断言,但是在发布版本中打印 0:

1
let i = Int(s) !? "Expecting integer, got \"\(s)\"

如果你想要显式地提供一个不同的默认值,或者是为非标准的类型提供这个操作符,我们可以定义一个接受多元组为参数的版本,多元组中包含默认值和错误信息:

1
2
3
4
5
6
7
8
func !?<T>(wrapped: T?, nilDefault: @autoclosure () -> (value: T, text: String)) -> T
{
assert(wrapped != nil, nilDefault().text)
return wrapped ?? nilDefault().value
}

// 调试版本中断言,发布版本中返回 5
Int(s) !? (5, "Expected integer")

隐式解包

通过在声明时的数据类型后面加一个感叹号(!)来实现:

1
2
var str: String! = "Hello World!"
print(str) // Hello World!

可以看到没有使用(?)进行显式的折包也得到了Some中的值,这个语法相当于告诉编译器:在我们使用Optional值前,这个Optional值就会被初始化,并且总是会有值,所以当我们使用时,编译器就帮我做了一次拆包。如果你确信你的变量能保证被正确初始化,那就可以这么做,否则还是不要尝试为好。

可选值map和flatMap

map

我们现在有一个字符数组,我们想要将第一个字符转换为字符串:

1
2
let characters: [Character] = ["a", "b", "c"]
String(characters[0]) // a

不过,如果 characters 可能为空的话,我们在就需要用 if let,只在数组不为空的时候创建字符串:

1
2
3
4
var firstCharAsString: String? = nil
if let char = characters.first {
firstCharAsString = String(char)
}

这样一来,当字符数组至少含有一个元素时,firstCharAsString 将会是一个含有该元素的 String。如果字符数组为空的话,firstCharAsString 将会为 nil。

这种获取一个可选值,并且在当它不是 nil 的时候进行转换的模式十分常见。Swift 中的可选值里专门有一个方法来处理这种情况,它叫做 map。这个方法接受一个闭包,如果可选值有内容,则调用这个闭包对其进行转换。上面的函数用 map 可以重写成:

1
let firstChar = characters.first.map { String($0) } // Optional("a")

显然,这个 map 和数组以及其他序列里的 map 方法非常类似。但是与序列中操作一系列值所不同的是,可选值的 map 方法只会操作一个值,那就是该可选值中的那个可能的值。你可以把可选值当作一个包含零个或者一个值的集合,这样 map 要么在零值的情况下不做处理,要么在有值的时候会对其进行转换。

flatmap

如果你对一个可选值调用 map,但是你的转换函数本身也返回可选值结果的话,最终结果将是一个双重嵌套的可选值。举个例子,比如你想要获取数组的第一个字符串元素,并将它转换为数字。首先你使用数组上的 first,然后用 map 将它转换为数字:

1
2
let stringNumbers = ["1", "2", "3", "foo"]
let x = stringNumbers.first.map { Int($0) } // Optional(Optional(1))

问题在于,map 返回可选值 (first 可能会是 nil),Int(String) 也返回可选值 (字符串可能不是一个整数),最后 x 的结果将会是 Int??

flatMap 可以把结果展平为单个可选值:

1
let y = stringNumbers.first.flatMap { Int($0) } // Optional(1)

这么做得到的结果 y 将是 Int?类型。

可选链

在 Objective-C 中,对 nil 发消息什么都不会发生。Swift 里,我们可以通过“可选链 (optional chaining)”来达到同样的效果。

1
delegate?.callback()

和 Objective-C 不同的是,Swift 编译器会在你的值是可选值的时候警告你。如果你的可选值值中确实有值,那么编译器能够保证方法肯定会被实际调用。如果没有值的话,这里的问号对代码的读者来说是一个清晰地信号,表示方法可能会不被调用。

当你通过调用可选链得到一个返回值时,这个返回值本身也会是可选值。

多次调用被链接在一起形成一个链,如果任何一个节点为空(nil)将导致整个链失效。

空和运算符 ??

很多时候,你会想要解包一个可选值,如果可选值是 nil 时,就用一个默认值来替代它。你可以使用 ?? 空合运算符来完成这件事:

1
2
let stringteger = "1"
let number = Int(stringteger) ?? 0

10.Swift函数式编程

Swift函数

要理解 Swift 里面的函数和闭包,你需要切实弄明白三件事情,我们把这三件事按照重要程度进行了大致排序:

  1. 函数可以被赋值给变量,也能够作为函数的输入和输出
  2. 函数可以捕获存在于他们作用范围之外的变量
  3. 函数可以使用 {} 来声明为闭包表达式

Swift对函数进行简化的特性:

  1. 如果你将闭包作为参数传递,并且你不再用这个闭包做其他事情的话,就没有必要现将它存储到一个局部变量中。
  2. 如果编译器可以从上下文中推断出类型的话,你就不需要指明它了。
  3. 如果闭包表达式的主体部分只包括一个单一的表达式的话,它将自动返回这个表达式的结果,你可以不写 return。
  4. Swift 会自动为函数的参数提供简写形式,$0 代表第一个参数,$1 代表第二个参数,以此类推。
  5. 如果函数的最后一个参数是闭包表达式的话,你可以将这个闭包表达式移到函数调用的圆括号的外部。
  6. 最后,如果一个函数除了闭包表达式外没有别的参数,那么方法名后面的调用时的圆括号也可以一并省略。
1
2
3
4
5
6
[1, 2, 3].map( { (i: Int) -> Int in return i * 2 } )
[1, 2, 3].map( { i in return i * 2 } )
[1, 2, 3].map( { i in i * 2 } )
[1, 2, 3].map( { $0 * 2 } )
[1, 2, 3].map() { $0 * 2 }
[1, 2, 3].map { $0 * 2 }

高阶函数

map

可以对序列中的每一个元素做一次处理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 计算字符串的长度
let stringArray = ["Objective-C", "Swift", "HTML", "CSS", "JavaScript"]
func stringCount(string: String) -> Int {
return string.characters.count
}
stringArray.map(stringCount)

stringArray.map({string -> Int in
return string.characters.count
})

// $0代表数组中的每一个元素
stringArray.map{
return $0.characters.count
}

Map 的实现:

1
2
3
4
5
6
7
8
9
10
extension Array {
func map<T>(_ transform: (Element) -> T) -> [T] {
var result: [T] = []
result.reserveCapacity(count)
for x in self {
result.append(transform(x))
}
return result
}
}

flatmap(compactMap)

和 map 一样,也是对序列元素进行变换,但是和 map 有几点不同:

1.flatMap返回后的数组中不存在nil,同时它会把Optional解包
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let array = ["Apple", "Orange", "Puple", ""]

let arr1 = array.map { a -> Int? in
let length = a.characters.count
guard length > 0 else { return nil }
return length
}
arr1 // [{some 5}, {some 6}, {some 5}, nil]

let arr2 = array.flatMap { a -> Int? in
let length = a.characters.count
guard length > 0 else { return nil}
return length
}
arr2 // [5, 6, 5]
2.flatMap还能把数组中存有数组的数组(二维数组、N维数组)一同打开变成一个新的数组
1
2
3
4
5
6
7
let array = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

let arr1 = array.map{ $0 }
arr1 // [[1, 2, 3], [4, 5, 6], [7, 8, 9]]

let arr2 = array.flatMap{ $0 }
arr2 // [1, 2, 3, 4, 5, 6, 7, 8, 9]
3.flatMap也能把两个不同的数组合并成一个数组,这个合并的数组元素个数是前面两个数组元素个数的乘积
1
2
3
4
5
6
7
8
9
let fruits = ["Apple", "Orange", "Puple"]
let counts = [2, 3, 5]

let array = counts.flatMap { count in
fruits.map ({ fruit in
return fruit + " \(count)"
})
}
array // ["Apple 2", "Orange 2", "Puple 2", "Apple 3", "Orange 3", "Puple 3", "Apple 5", "Orange 5", "Puple 5"]

flatmap 实现:(Swift 3)

1
2
3
4
5
6
7
8
9
extension Array {
func flatMap<T>(_ transform: (Element) -> [T]) -> [T] {
var result: [T] = []
for x in self {
result.append(contentsOf: transform(x))
}
return result
}
}

filter

过滤,可以对序列中的元素按照某种规则进行一次过滤

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 筛选出字符串的长度小于10的字符串
let stringArray = ["Objective-C", "Swift", "HTML", "CSS", "JavaScript"]
func stringCountLess10(string: String) -> Bool {
return string.characters.count < 10
}
stringArray.filter(stringCountLess10)

stringArray.filter({string -> Bool in
return string.characters.count < 10
})

// $0表示数组中的每一个元素
stringArray.filter{
return $0.characters.count < 10
}

filter 实现:

1
2
3
4
5
6
7
8
9
extension Array {
func filter(_ isIncluded: (Element) -> Bool) -> [Element] {
var result: [Element] = []
for x in self where isIncluded(x) {
result.append(x)
}
return result
}
}

reduce

如何将序列元素合并到一个总和的值中

map 和 filter 都作用在一个数组上,并产生另一个新的、经过修改的数组。不过有时候,你可能会想把所有元素合并为一个新的值。比如,要是我们想将元素的值全部加起来,可以这样写:

1
2
3
4
5
6
let fibs = [1,1,2,3,5]
var total = 0
for num in fibs {
total = total + num
}
total = 12

reduce 方法对应这种模式,它把一个初始值 (在这里是 0) 以及一个将中间值 (total) 与序列中的元素 (num) 进行合并的函数进行了抽象。使用 reduce,我们可以将上面的例子重写为这样:

1
2
3
let sum = fibs.reduce(0) { total, num in total + num }
// 运算符也是函数,所以我们也可以把上面的例子写成这样
fibs.reduce(0, +)

reduce 的输出值的类型可以和输入的类型不同。举个例子,我们可以将一个整数的列表转换为一个字符串,这个字符串中每个数字后面跟一个空格:

1
2
let s = fibs.reduce("") { str, num in str + "\(num) " }
print(s) // "0 1 1 2 3 5"

reduce 实现:

1
2
3
4
5
6
7
8
9
extension Array {
func reduce<Result>(_ initialResult: Result, _ nextPartialResult: (Result, Element) -> Result) -> Result {
var result = initialResult
for x in self {
result = nextPartialResult(result, x)
}
return result
}
}

11.Swift风格指南

  • 对于命名,在使用时能清晰表意是最重要。因为 API 被使用的次数要远远多于被声明的次数,所以我们应当从使用者的角度来考虑它们的名字。尽快熟悉 Swift API 设计准则,并且在你自己的代码中坚持使用这些准则。

  • 简洁经常有助于代码清晰,但是简洁本身不应该独自成为我们编码的目标。

  • 务必为函数添加文档注释 — 特别是泛型函数。

  • 类型使用大写字母开头,函数、变量和枚举成员使用小写字母开头,两者都使用驼峰式命名法。

  • 使用类型推断。省略掉显而易见的类型会有助于提高可读性。

  • 如果存在歧义或者在进行定义的时候不要使用类型推断。(比如 func 就需要显式地指定返回类型)
  • 优先选择结构体,只在确实需要使用到类特有的特性或者是引用语义时才使用类。
  • 除非你的设计就是希望某个类被继承使用,否则都应该将它们标记为 final。
  • 除非一个闭包后面立即跟随有左括号,否则都应该使用尾随闭包 (trailing closure) 的语法。
  • 使用 guard 来提早退出方法。
  • 避免对可选值进行强制解包和隐式强制解包。它们偶尔有用,但是经常需要使用它们的话往往意味着有其他不妥的地方。
  • 不要写重复的代码。如果你发现你写了好几次类似的代码片段的话,试着将它们提取到一个函数里,并且考虑将这个函数转化为协议扩展的可能性。
  • 试着去使用 map 和 reduce,但这不是强制的。当合适的时候,使用 for 循环也无可厚非。高阶函数的意义是让代码可读性更高。但是如果使用 reduce 的场景难以理解的话,强行使用往往事与愿违,这种时候简单的 for 循环可能会更清晰。

  • 试着去使用不可变值:除非你需要改变某个值,否则都应该使用 let 来声明变量。不过如果能让代码更加清晰高效的话,也可以选择使用可变的版本。用函数将可变的部分封装起来,可以把它带来的副作用进行隔离。

  • Swift 的泛型可能会导致非常长的函数签名。坏消息是我们现在除了将函数声明强制写成几行以外,对此并没有什么好办法。我们会在示例代码中在这点上保持一贯性,这样你能看到我们是如何处理这个问题的。

  • 除非你确实需要,否则不要使用 self.。在闭包表达式中,使用 self 是一个清晰的信号,表明闭包将会捕获 self。

  • 尽可能地对现有的类型和协议进行扩展,而不是写一些全局函数。这有助于提高可读性,让别人更容易发现你的代码。

12.Swift泛型

使用泛型进行代码设计

我们已经看到了很多将泛型用来为同样的功能提供多种实现的例子。我们可以编写泛型函数,但是却对某些特定的类型提供不同的实现。同样,使用协议扩展,我们还可以编写同时作用于很多类型的泛型算法。

泛型在你进行程序设计时会非常有用,它能帮助你提取共通的功能,并且减少模板代码。在这一节中,我们会将一段普通的代码进行重构,使用泛型的方式提取出共通部分。除了可以创建泛型的方法以外,我们也可以创建泛型的数据类型。

让我们来写一些与网络服务交互的函数。比如,获取用户列表的数据,并将它解析为 User 数据类型。我们创建一个 loadUsers 函数,它可以从网上异步加载用户,并且在完成后通过一个回调来传递获取到的用户列表。

当我们用最原始的方式来实现的话,首先我们要创建 URL,然后我们同步地加载数据 (这里只是为了简化我们的例子,所以使用了同步方式。在你的产品中,你应当始终用异步方式加载你的数据)。接下来,我们解析 JSON,得到一个含有字典的数组。最后,我们将这些 JSON 对象变形为 User 结构体:

1
2
3
4
5
6
7
8
9
10
11
func loadUsers(callback: ([User]?) -> ()) {
let usersURL = webserviceURL.appendingPathComponent("/users")
let data = try? Data(contentsOf: usersURL)
let json = data.flatMap {
try? JSONSerialization.jsonObject(with: $0, options: [])
}
let users = (json as? [Any]).flatMap { jsonObject in
jsonObject.flatMap(User.init)
}
callback(users)
}

loadUsers 函数有三种可能发生错误的情况:URL 加载可能失败,JSON 解析可能失败,通过 JSON 数组构建用户对象也可能失败。在这三种情况下,我们都返回 nil。通过对可选值使用 flatMap,我们能确保只对那些成功的对象进行接下来的操作。不这么做的话,第一个失败操作造成的 nil 值将传播到接下来的操作,直至结束。我们在结束的时候会调用回调,传回一个有效的用户数组,或者传回 nil。

现在,如果我们想要写一个相同的函数来加载其他资源,我们可能需要复制这里的大部分代码。打个比方,我们需要一个加载博客文章的函数,它看起来是这样的:

1
func loadBlogPosts(callback: ([BlogPost])? -> ())

函数的实现和前面的用户函数几乎相同。不仅代码重复,两个方法同时也都很难测试,我们需要确保网络服务可以在测试是被访问到,或者是找到一个模拟这些请求的方法。因为函数接受并使用回调,我们还需要保证我们的测试是异步运行的。

提取共通功能

相比于复制粘贴,将函数中 User 相关的部分提取出来,将其他部分进行重用,会是更好的方式。我们可以将 URL 路径和解析转换的函数作为参数传入。因为我们希望可以传入不同的转换函数,所以我们将 loadResource 声明为 A 的泛型:

1
2
3
4
5
6
7
8
9
func loadResource<A>(at path: String, parse: (Any) -> A?, callback: (A?) -> ())
{
let resourceURL = webserviceURL.appendingPathComponent(path)
let data = try? Data(contentsOf: resourceURL)
let json = data.flatMap {
try? JSONSerialization.jsonObject(with: $0, options: [])
}
callback(json.flatMap(parse))
}

现在,我们可以将 loadUsers 函数基于 loadResource 重写:

1
2
3
func loadUsers(callback: ([User]?) -> ()) {
loadResource(at: "/users", parse: jsonArray(User.init), callback: callback)
}

我们使用了一个辅助函数,jsonArray,它首先尝试将一个 Any 转换为一个 Any 的数组,接着对每个元素用提供的解析函数进行解析,如果期间任何一步发生了错误,则返回 nil:

1
2
3
4
5
6
7
8
func jsonArray<A>(_ transform: @escaping (Any) -> A?) -> (Any) -> [A]? {
return { array in
guard let arr = array as? [Any] else {
return nil
}
return arr.flatMap(transform)
}
}

对于加载博客文章的函数,我们只需要替换请求路径和解析函数就行了:

1
2
3
func loadBlogPosts(callback: ([BlogPost]?) -> ()) {
loadResource(at: "/posts", parse: jsonArray(BlogPost.init), callback: callback)
}

这让我们能少写很多重复的代码。如果之后我们决定将同步 URL 处理重构为异步加载时,就不再需要分别更新 loadUsers 或者 loadBlogPosts 了。虽然这些方法现在很短,但是想测试它们也并不容易:它们基于回调,并且需要网络服务处于可用状态。

创建泛型数据类型

loadResource 函数中的 path 和 parse 耦合非常紧密,一旦你改变了其中一个,你很可能也需要改变另一个。我们可以将它们打包进一个结构体中,用来描述要加载的资源。和函数一样,这个结构体也可以是泛型的:

1
2
3
4
struct Resource<A> {
let path: String
let parse: (Any) -> A?
}

现在,我们可以在 Resource 上定义一个新的 loadResource 方法。它使用 resource 的属性来确定要加载的内容以及如何解析结果,这样一来,方法的参数就只剩回调函数了:

1
2
3
4
5
6
7
8
9
10
extension Resource {
func loadSynchronously(callback: (A?) -> ()) {
let resourceURL = webserviceURL.appendingPathComponent(path)
let data = try? Data(contentsOf: resourceURL)
let json = data.flatMap {
try? JSONSerialization.jsonObject(with: $0, options: [])
}
callback(json.flatMap(parse))
}
}

相比于之前的用顶层函数来定义资源,我们现在可以定义 Resource 结构体实例,这让我们可以很容易地添加新的资源,而不必创建新的函数:

1
2
let usersResource: Resource<[User]> = Resource(path: "/users", parse: jsonArray(User.init))
let postsResource: Resource<[BlogPost]> = Resource(path: "/posts", parse: jsonArray(BlogPost.init))

现在,添加一个异步的处理方法就非常简单了,我们不需要改变任何现有的描述 API 接入点的代码:

1
2
3
4
5
6
7
8
9
10
11
12
extension Resource {
func loadAsynchronously(callback: @escaping (A?) -> ()) {
let resourceURL = webserviceURL.appendingPathComponent(path)
let session = URLSession.shared
session.dataTask(with: resourceURL) { data, response, error in
let json = data.flatMap {
try? JSONSerialization.jsonObject(with: $0, options: [])
}
callback(json.flatMap(self.parse))
}.resume()
}
}

除了使用了异步的 URLSession API 以外,和同步版本相比,还有一个本质上的不同是回调函数现在将从方法作用域中逃逸出来,所以它必须被标记为@escaping

现在,我们将接入点和网络请求完全解耦了。我们将 usersResource 和 postResource 归结为它们的最小版本,它们只负责描述去哪里寻找资源,以及如何解析它们。这种设计也是可扩展的:你可以进行更多配置,比如添加 HTTP 请求方法或是为请求加上一些 POST 数据等,你只需要简单地在 Resouce 上增加额外属性就可以了 (为了保持代码干净,你应该指定一些默认值。比如对 HTTP 请求方法,可以设定默认值为 GET)。

测试也变得容易很多。Resource 结构体是完全同步,并且和网络解耦的。测试 Resource 是否配置正确是很简单的一件事。不过网络部分的代码依然难以测试,当然了,因为它天生就是异步的,并且依赖于网络。但是这个复杂度现在被很好地隔离到了 loadAsynchronously 方法中,而代码的其他部分都很简单,也没有受到异步代码的影响。

在本节中,我们从一个非泛型的从网络加载数据的函数开始,接下来,我们用多个参数创建了一个泛型函数,允许我们用简短得多的方式重写代码。最后,我们把这些参数打包到一个单独的 Resource 数据类型中,这让代码的解耦更加彻底。对于具体资源类型的专用逻辑是于网络代码完全解耦的。更改网络层的内容不会对资源层有任何影响。

-------------本文结束感谢您的阅读-------------

本文作者:乔羽 / FightingJoey

发布时间:2019年03月02日 - 11:08

最后更新:2019年03月05日 - 23:04

原始链接:https://fightingjoey.github.io/2019/03/02/开发/Swift进阶/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

坚持原创技术分享,您的支持将鼓励我继续创作!