App 开发之旅(一):摩诃不思议的 Swift

✍🏼 写于 2025年03月27日    💡 更新于 2025年04月08日
❗️ 注意:离本文创建时间已经过去了 天,请注意时效性
🖥  说明:本系列是本人作为 Web 前端学习苹果应用开发过程中的一些记录。

说明

有人问为什么不是 JavaScript,因为它是脚本语言,没有类型系统等,完全没法儿跟 Swift 比较。

本人前端开发,对 C、Java、Python 语言仅限入门了解水平,而对 TypeScript(以下简称 TS) 的特性和使用达到了高级水平(自认),因此有些让我震惊的点,可能在各位眼中感觉「只不过是平平无奇的语言特性罢了」,亦或者「莫非设计 TypeScript 的人是天才?」。

本人不一定会对所有 Swift 中与 TS 有差异的地方强行震惊,因为有些 Swift 跟 TS 的差异是因为 TS 的自身不足,且有些设计在计算机语言中司空见惯(如数字区分 Int 和 Double 而不是只有一种 Number 类型),只是 JS 的设计让人震惊(毕竟是一周内赶工设计出来的),而不是 Swift。

本人认为:如果一个语言规则太多、特例太多、保留字太多,那就不是一门好语言。

前言

本文按照 Swift 官方语言介绍的顺序进行有序震惊,省略了不震惊或者不懂的的内容,如 附加宏

基础知识

注释

震惊点:XCode 没有 `/** */` 块注释快捷键,只有 `cmd + /` 的行注释。

VSCode 的块注释快捷键也比较难按,是 Alt + Shift + A ,难道大家都不常用这个功能?

当然,VSCode 和 XCode 中,都可以通过按 /** 后回车,自动生成块注释。

可选绑定

震惊点:我调试的时候想在 `if` 写一个始终为 `true` 的值测试用都不行,`if` 语句的可选绑定,必须是一个可选类型的值。

1
if let a = 2 { } // 错误!

有必要这样?

集合类型

震惊点:数组方法中,有个 `sort` 表示原地排序,还有一个 `sorted` 返回新数组。

一个语言,干了框架干的活儿,很好,很符合苹果的风格,诸如此类的还有很多,就不一一震惊了。

控制流

if 表达式

震惊点:为了解决 `if-else` 给同一个变量赋值的情况,它提供了 `if` 表达式的写法,同理,`switch` 也有类似的表达式形式。

Swift 还是做的太多了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 平平无奇的写法
let a = 25
let str: String
if a <= 0 {
str = "小"
} else if a >= 30 {
str = "大"
} else {
str = "中"
}
// 简写法
let str = if a <= 0 {
"小"
} else if a >= 30 {
"大"
} else {
"中"
}

Switch 的牛逼判断

震惊点:switch 没有隐式贯穿

不过这倒可以理解而且更合理,正常人谁没事儿希望 case 1 如果不额外 break 会自动跑到 case 2 中去啊?

震惊点:switch 可以判断对象值!

这其实算是 feature,而且直觉上更合理,简直内行。

在 TS 中,因为 switchcase 语句使用的是全等(===)判断,因此你的 switch 的括号内一般不会传入一个对象,因为对象判断的是引用,而在 TS 中很少有需要判断对象相等的情况,更别提使用 switch 语句来判断了。

但是在 Swift 中,你可以在 case 语句中「捕获」判定的值(正式称呼为「模式」):

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
let somePoint = (1, 1) // 一个平平无奇的元组罢了
// 我现在,开 始 判 断:
switch somePoint {
case (0, 0):
print("\(somePoint) 在原点")
case (_, 0): // 忽略第一个值
print("\(somePoint) 在 x 轴")
case (0, _): // 忽略第二个值
print("\(somePoint) 在 y 轴")
case (-2...2, -2...2): // 判断元组的两个值是否分别在给定区间,在就匹配成功
print("\(somePoint) 在盒子内部")
default:
print("\(somePoint) 在盒子外部")
}

// 还可以值绑定!
switch somePoint {
case (let x, 0): // 匹配第二个值,捕获第一个值
print("x轴值:\(x)")
case (0, let y): // 匹配第一个值,捕获第二个值
print("y 轴值:\(y)")
case (let x, let y): // 兜底,因为 x 和 y 都是 let,还可以写作 case let (x, y)
print("\(x)\(y)")
}
// 还可以加 where 限定:
case let (x, y) where x == y:
//还可以多个匹配
case "a", "b", "c":

需要注意的是,在 TS 中也支持「多个匹配」但跟 Swift 中的 case 多个匹配完全不同。

Swift 中的多个匹配,逗号分割的内容只要有一个匹配,就会执行 case 语句,但是 TS 的逗号分割的匹配,它的本质是只匹配最后一个项,因为 TS 中的逗号分割的语句只返回最后一个值:

1
2
3
4
5
var a = "d"
switch (a) {
case "d", "c", "b":
console.log("OK")// 此处不会执行,因为此 case 匹配 "b"
}

函数

函数的标签参数

震惊点:形参(标签参数)可以同名???而且规定(限制又来了,服了):可变参数的后面的参数必须有参数标签(如果只有一个那么实参就是形参)不能省略。

1
2
3
4
5
6
7
// 下面两个 a 标签参数,完全合法(因为是 label 无所谓)
// 而且 a b 后面的参数 a c(或者单独的一个参数)必须存在,不能用 _ 省略,
// 也可以理解,要不咋知道是第二个参数,而不是前面的可变参数来的?
func a (a b: Double..., a c: Double) -> Double {
return b.reduce(0, +) + c
}
a(a: 1, 2, 3, 4, 5, a: 6)

闭包

闭包的N中简写形式

震惊点:闭包的语法糖太多这里就不一一列举,最离谱的是只需要一个 `>` 符号的形式。

可以这么写的本质是 String 有一个叫做 > 的函数,是的你没有看错,符号也可以是函数!这一点下面再震惊。

1
2
var num = ["9", "2", "1"]
var sortedNum = num.sroted(by: >)

真的离谱。

省略 return 的返回

震惊点:也许你可以理解「单行返回可以省略 return」 ,但是你绝对无法理解「多行也可以省略 return」

TS 中的省略 return 的语句,跟 Swift 普通的写法一样,单行省略 return:

1
2
3
4
5
6
// 省略写法
var a = () => 2
// 正常写法是:
var a = () => {
return 2
}

普通的 Swift 语法:

1
2
3
func a()-> Int {
2 // -> 因为单行,所以省略了 return
}

但是!SwiftUI 中的写法:

1
2
3
4
VStack {
Text("Hello")
Text("World")
}

你没看错,这也是一个尾随闭包函数,其中 VStack 是一个函数,闭包参数作为最后且唯一的参数,可以省略 VStack 的括号,但是!它返回了两个 Text 函数调用,却没有写 return,为什么?

因为它用了 ViewBuilder,这个东西跟后面的 resultBuilder 一样的语法糖。

自动闭包

震惊点:本来一个平平无奇的闭包赋值没有什么,但是它的延迟计算能力让我不得不为之震惊。

刚看自动闭包的时候,是拿这个例子介绍的,说什么「延迟计算」能力:

1
2
3
4
5
6
7
8
9
10
var customersInLine = ["Chris", "Alex", "Ewa", "Barry", "Daniella"]
print(customersInLine.count)
// 打印 “5”
let customerProvider = { customersInLine.remove(at: 0) }
print(customersInLine.count)
// 打印 ”5“
print("Now serving \(customerProvider())!")
// 打印 “Now serving Chris!”
print(customersInLine.count)
// 打印 “4”

我寻思这不就是一个平平无奇的闭包,跟 TS 一样只是 customerProvider 变量被赋值给了闭包而已,相同实现在 TS 中是这样的:

1
2
3
let customerProvider = () => {
// 省略逻辑
}

然后当然是在它调用的时候才会执行闭包的逻辑,这算什么延迟计算!

但是,它的延迟计算形式在闭包作为参数的时候才是真牛逼:

平平无奇的显式闭包调用,跟 TS 调用方式基本类似,闭包作为参数,写法还是写在一个大括号中:

1
2
3
4
5
6
// customersInLine 是 ["Alex", "Ewa", "Barry", "Daniella"]
func serve(customer customerProvider: () -> String) {
print("Now serving \(customerProvider())!")
}
serve(customer: { customersInLine.remove(at: 0) } )
// 打印 “Now serving Alex!”

但是一旦把 customerProvider 标记为 @autoclosuer 情况就不一样了,此时你的 serve 函数调用可以这么写:

1
2
3
4
5
6
// customersInLine 是 ["Ewa", "Barry", "Daniella"]
func serve(customer customerProvider: @autoclosure () -> String) {
print("Now serving \(customerProvider())!")
}
serve(customer: customersInLine.remove(at: 0))
// 打印 “Now serving Ewa!”

注意最后的 serve 函数的调用,它的参数 customer 是一个语句 customersInLine.remove(at: 0) ,在 TS 中,无论什么情况,调用栈都会先求这个值然后再调用 serve 函数。但在 Swift 中,这种写法跟上面的形式仅仅是形式不同,逻辑是一样的,也就是说会先执行 serve 函数,然后再去内部执行这个语句(当 customerProvider 调用的时候)。这才是传说中的,延迟计算吧。

这种情况如果你写多了,会遇到很多诸如下面的代码:

1
2
a(b.remove(2))
c(d.add4())

此时你难以区分,是先执行括号内的语句,还是外层的函数。因此,Swift 官方文档也做了说明:

过度使用自动闭包可能会使您的代码难以理解。上下文和函数名称应明确表示计算正在被推迟。

真的离谱,既然不推荐,就不要设计出来啊喂!

枚举

震惊点: 枚举在其他语言只是为了方便的一种可有可无的用来状态机判断的类型,但是在 Swift 中却是最常用的一等类型,可以在一定功能上取代 Struct 你敢信?

震惊点2: 枚举是值类型。

枚举在 TS 就是一个平平无奇的「枚举」罢了,仅仅是把值给列出来,大多数用来在状态机中描述状态:

1
2
3
4
enum A {
B = "B"
C = "c"
}

当然,TS 整的花活,在运行时、编译时的一些差异就不说了。在 TS 中,你完全可以使用一个对象来代替枚举:

1
2
3
4
5
6
7
8
9
const enum A {
B = 0,
C = 1,
}

const A = {
B: 0,
C: 1,
} as const;

关联值

震惊点: Swift 你这么设计枚举,是要 干!什!么!

在 Swift 中,枚举的重要性是第一位的,你可以遍历它的所有 case(需要遵循 CaseIterable 协议)、可以将 case 视作一个函数,然后在调用的时候传值,以让枚举实例处理,此谓之「关联值」:

1
2
3
4
enum A {
case b(Int, Int) // 声明方式好像一个协议(也就是抽象类)
case c(String)
}

然后在用的时候可以传值:

1
let a = A.b(1024, 9527)

枚举最常用的就是在 switch 语句中,结合震惊的 switch 同样震惊的 case,你可以这么做:

1
2
3
4
5
6
switch a {
case .b(let d, let f):
print("A.b 关联值为:\(d) 和 \(f)")
case let .c(g): // 上面提到的另一种 switch case 写法
print("A.c:\(g)")
}

第一次看反正我是觉得挺抽象的,也想不明白有什么实际使用场景(毕竟我没这么干过)。

隐式赋值

震惊点: 枚举的类型声明,声明的不是枚举本身的类型(键值对),而是 case 的类型。

Swift 中的隐式赋值,倒是跟 TS 中或者其他类 C 语言的一致,都是第一个数字是 n,后面的就是 n + 1。

但 Swift 更近一步的,它会根据你声明的类型才隐式赋值,默认是不会的,比如:

1
2
3
4
5
6
enum A: Int { // <- 声明了 Int 类型,所以隐式赋值了
case b, c, d // b c d 分别为 0 1 2(需要使用 A.b.rawValue 才能访问,这又是另一个震惊点了)
}
enum B: String { // <- 声明了 String 类型,所以隐式赋值了
case bb, cc, dd // bb cc dd分别为 "bb" "cc" "dd"
}

TS 中,你甚至不能指定 Swift 中指定的那个 Int 类型(这个类型其实指的是 case 的类型),而只能指定 enum 的类型(一般是 Record 别处的键值对)。

结构体和类

震惊点:结构体居然是值类型???

这个浓眉大眼的结构体,居然是值类型(使用了 Immutable 的那一套东西来优化性能):

1
2
3
4
struct a {
var b: Int
var c: String
}

带着花括号的东西,怎么看也不像值啊!离谱!震惊!

属性

各种xx属性/包装器/观察器

震惊点:Swift 做的太多了,类似于计算属性、存储属性、属性包装器(TS 也有)、属性观察器(TS 也有,跟包装器一起的)这类概念,一般都是框架带的,比如 Vue 的 `computed` 、`watch` 等,Swift 自己在 Struct 和 类中实现了,离谱!

1
2
3
4
5
6
struct A {
var b = 2 // <- 存储属性,直接有值,在 struct 实例化的时候就确定了
var c: Int {
return b * 2 // <- 一个只读计算属性,不存储值,因为单行,return 可以省略
}
}

另:属性包装器对全局变量不可用。

下标

震惊点: 这个没什么好说的,有「下标」这个概念本身就已经让人震惊了,它提供了一种访问集合、列表、字典元素的快捷方式。甚至,下标可以传多个值,就像函数一样。

基本上,下标的调用方式可以认为是对数据结构(上述的集合、列表、字典)的函数调用来访问值,不同的是函数调用使用 () ,而下标使用 []

继承

震惊点: 跟之前所有的震惊点都类似,Swift 为了实现各种目的、效果,似乎很随意的添加各种关键字,因此与继承相关的保留字(当然,也不能叫「保留字」,因为 Swift 中保留字都可以拿来用,使用反引号包裹即可,下面再震惊)多不胜数。如: `final`、`override`、`open`、`required`,等等。

构造过程

Struct 的逐一成员构造器

震惊点: 因为 Struct 是值类型,所以它跟传统意义的 Class 的构造器规则在初始化的时候有所差异。

本来,Struct 和 Class 一样,都是一种平平无常的数据结构,和 TS 中的 Class 没什么不同。但是,Swift 在这里又整出幺蛾子。

因为 Struct 是值类型,因此特殊一点,它有一个叫做「成员逐一构造器」的构造器形式,就是说当一个 Struct 没有任何 init 的时候,它就可以通过传入参数的形式来 init 它的属性,而不用显式写出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 此 Struct 没有 init,因此适用逐一成员构造器
struct A {
var a: Int
var b: Int
}
// 上述效果等同于:
struct A {
var a: Int
var b: Int
init(a: Int, b: Int) {
self.a = a
self.b = b
}
}
// 上面两个都可以这么用:
A(a: 1, b: 2)

但是这个规则不适用于 Class,Class 必须显式的拥有 init 构造器方法来初始化属性。

类的指定构造器和便利构造器

震惊点: 什么?构造器还分两种???

类的指定构造器就是跟 TS 中的普通 construct 一样作用的 init 方法。但是它的便利构造器…是在 init 前加个 convenience 关键字(又来!):

1
convenience init() {}

其实这里 Swift 文档没有明确告诉读者,为什么需要便利构造器,而是直接讲起了类的构造、继承过程和构造器代理。我在这里给小白门解释一下为什么需要存在便利构造器这个东西的存在,很简单的原因:类中的多个指定构造器不允许互相调用。就这么简单的原因,按耐不住想调用?想简化初始化过程?用便利构造器去吧你!

常规写法:

1
2
3
4
5
6
7
8
9
10
11
class A {
var b: Int
var c: String
init(b: Int, c: String) {
self.b = b
}
init() {
self.b = 0
self.c = "UnKnown"
}
}

以上是不是感觉 self.b 这种的写了两遍,麻烦?所以想在第二个 init() 中,这样干:

1
2
3
init() {
self.init(b: 0, c: "UnKnown")
}

抱歉,编译器报错:Designated initializer for 'A' cannot delegate (with 'self.init'); did you mean this to be a convenience initializer?

这种情况,必须使用便利构造器:

1
2
3
converience init() {
self.init(b: 0, c: "UnKnown")
}

图的就是一个「便利」~

可失败构造器

震惊点: 构造器还能构造失败的?

其实所谓的可失败构造器,就是指在构造过程(init 函数调用过程)可能抛错。

因此,如果构造器抛错,你不用像 TS 一样,在外层 try-catch,而是直接说明即可,方法就是在 init 后面加个问号变成 init?,然后在可能抛错的地方返回 nil ,在实例化的时候判断是否为 nil 即可。

Swift 中正常构造器不返回值,这跟 TS 一样——但 TS 的构造器可以返回值,如果返回值是对象类型则会替代 this 对象,这似乎更让人震惊。

可选链式调用

震惊点: 问:在什么情况下,函数写了 () 但是却没有执行?答:可选链式调用的场景下。

话不多说,看例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func A() -> Int {
print("OK")
return 1
}
class B {
var c: C?
}
class C {
var d: Int?
}
var d = B()
// 震惊的事情发生了:
// 这里,A 函数未执行,不会打印 OK,赋值也不会成功,因为 c 是 nil,这个语句返回 nil(通常 Swift 的赋值语句都返回 Void 的,也就是空元组)
// 返回 nil 意味着你可以使用 if-let 语句判断
d.c?.d = A()

错误处理

震惊点:跟枚举一样,Swfit 的 try-catch(实际是 do-catch)设计的很复杂。

catch 语句不但可以捕获任意错误,还可以捕获特定错误类型,使用 is 或者跟 switch 一样,有多个 catch 分支进行进行匹配,离谱:

1
2
3
4
5
6
7
do {
try someError()
} catch is ErrorA {
// xxx
} catch ErrorB, ErrorC, ErrorD.a {
// xxx
}

并发

震惊点:看文档的时候,我一开始并未意识到 `withTaskGroup` 是一个内置的进行 Group Task 的方法。。。

至于 await 跟 TS 中的 await 完全一样的用法,爽!

扩展

震惊点:扩展应该算是 Swift 中最强的设计了,任何对象,无论内置还是三方,都可以随意的、低成本的、无需任何复杂声明任何复杂关键字的,进行扩展。

相比于 TS 中,你想扩展一个已有的类,你需要在原型链上做一些操作,然后再修改对象的 this 指向这个类。

但是在 Swift 中,你只需要一个 extension 然后就可以肆无忌惮的写任何你想写的方法、属性,他们的 this 都指向实例,或者扩展类型(也就是静态)方法、属性。

简直炫酷狂拽屌炸天。

协议

震惊点:我觉得协议的出现是为了解决 Struct 的抽象问题,因为任何语言中,类本身都是可以继承(可能说的绝对了),不需要再多余实现一个「协议」,使用抽象类即可。只是顺带的,Swift 限制了类只能继承一个父类的同时,让协议更多的在类和 Struct 和枚举上同时发挥作用。

不说了,想说的都在上面。

泛型

震惊点:泛型整了个「关联类型」,其实发挥的作用就是声明的时候带的泛型,但是更强大。

Swift 的泛型关联类型是这样子的:

1
2
3
4
5
6
protocol Container {
associatedtype Item
mutating func append(_ item: Item)
var count: Int { get }
subscript(i: Int) -> Item { get }
}

至于为什么不设计成这样:

1
2
3
4
5
protocol Container<Item> {
mutating func append(_ item: Item)
var count: Int { get }
subscript(i: Int) -> Item { get }
}

估计是因为这个 Item 可能会很长,不优雅吧,如:

1
2
3
4
protocol SuffixableContainer: Container {
associatedtype Suffix: SuffixableContainer where Suffix.Item == Item
func suffix(_ size: Int) -> Suffix
}

写到名字的后面的话,那么泛型声明会经常超出屏幕,难顶。

不透明类型和封装协议

震惊点:你可以实现一个效果,即只让编译器知道你的类型是什么,但是调用端不知道具体类型,而只知道它遵守某项协议。

基本上,不透明类型和封装协议想实现的效果是,一方面对调用者隐藏实现细节,一方面可以允许重构代码的时候不用修改太多地方,举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
protocol Shape {
func area() -> Double
}

struct Circle: Shape {
var radius: Double
func area() -> Double {
return .pi * radius * radius
}
}

func makeShape() -> some Shape {
return Circle(radius: 5)
}

这里,makeShape 返回的类型,只要遵守 Shape 协议类型的任意类型都可以,不用明确返回 Circle 类型。如此一来,后续添加新的遵守 Shape 类型的 Struct,该函数都可以返回(这也是 SwiftUI 中所常用的一种方式,比如 some View等)。

内存安全

震惊点:Swift 因为可以按引用传递值,所以会产生同时对一块内存区域的读写(这很合理对吧?)

1
2
3
4
5
var a = 1
func add(_ b: inout Int) {
b += a
}
add(&a)

上述运行会报错,但是编译器不会有提醒。诸如此类的还有很多,在这种情况下要特别注意。因为 TS 中不存在按引用传值的情况,所以不会有这种问题,easy~

高级运算符

震惊点:你可以重写/自定义实现一个以符号为函数名的函数,以此来实现相同类、结构体的实例之间的相互操作。

举个例子:

1
2
3
4
5
6
7
8
9
10
11
struct A {
var b = 1
var c = 2
static func +(left: A, right: A) -> A {
return A(b: left.b + right.b, c: left.c + right.c)
}
}
// 然后你就可以
let a = A()
let b = A()
let c = a + b // <- 是的你没看错,struct 实例可以相加!

这个设计好,我怎么没想到呢?牛逼牛逼。

需要注意的是,运算符方法是有自身属性的,如 + 是二元操作符也是中缀运算符,所以接受两个函数,- 既可以是中缀运算符(二元操作符),也可以是前缀运算符(一元操作符,func 前需要加 prefix),因此可以重载。

但是 Swift 又规定了赋值运算符= 不能被重载,三元条件运算符(a ? b : c)也不能。

这个运算符是如此的常见,以至于你可以在任意的 Swift 内置对象/Foundation 对象中,看到诸如 == 相等判断的运算符函数。

你甚至可以实现自己运算符!

1
2
3
4
5
6
7
8
9
10
11
// 需要先声明,这是一个操作符,因为有两个值在操作符,所以这是个中缀操作符(infix)
infix operator +++++++
// 然后扩展一下整数类型
extension Int {
static func +++++++(left: Int, right: Int) -> Int {
return (left + right) * 7 // <- 因为有 7 个 + 号,所以我写了乘以 7
}
}
// 这么用:
2 +++++++ 7 // <- 63

真牛逼。

结果构建器

震惊点:Swift 为了「优雅」、「复用」,无所不用其极。

为了让下面这段代码看上去好看,Swfit 「发明」了 @resultBuilder 这个神奇的东西,如下:

普通写法:

1
2
3
4
5
6
7
8
9
var a = 1
var b = 2

struct A {
var a: Int
}

// 这里会有一个三元判断
var c = A(a: a > b ? 666 : 999 )

Swift 说,上面的三元判断太麻烦,而且复杂判断的话会很长不好阅读,希望优雅点,于是,结果构建器诞生了(如果我没理解错的话):

1
2
3
4
5
6
7
8
9
10
11
12
@resultBuilder
struct BBuilder {
static func buildBlock(_ b: Int) -> Int { // 对应 if/else 分支的执行结果
return b
}
static func buildEither(aaa: Int) -> Int { // 对应 if 分支
return aaa
}
static func buildEither(bbb: Int) -> Int { // 对应 else 分支
return bbb
}
}

然后在上面的 c 变量这么赋值:

1
2
3
4
5
6
7
8
9
10
11
func BB(@BBuilder b: () -> Int) -> A {
return A(a: b())
}
// 终于等到 c 了,这里的 c 和最开始的 c 完全一样
var c = BB {
if a > b {
return 666
} else {
return 999
}
}

复用,优雅,高效,绝!

词法结构

震惊点:尽管 Swift 中的保留字多不胜数,但是它允许你将保留字用作标识符。

方法就是,使用反引号扩住即可:

1
2
3
func `func`() {} // <- 一个叫做 `func` 的函数
// 调用也是
`func`()

但除了关键字外,x 和 x 是同一个变量。

- EOF -
本文最先发布在: App 开发之旅(一):摩诃不思议的 Swift - Xheldon Blog