SwiftUI 1 可选类型与拆包

SwiftUI 1 可选类型与拆包

入门SwiftUI的第五天,作为ObjectiveC老手Swift新手,我用这个系列文章来记录实际项目中遇到的各种问题,这是此系列的第二篇。

0 可选型与拆包

ObjectiveC是个面向接口设计的弱类型语言

因为Swift是强类型语言,所以写OC习惯的朋友一上来用起来一定不会太顺手,OC有很多欺骗编译器的手段,最常见的是强制将对象转成id这种万能动态类型。从设计上看OC的消息机制本质上是面向接口的设计,比如类型ClassA有个方法叫send:,类型ClassB也实现了方法send:,那ClassB的实例,就可以强转成ClassA类型来调用这个方法,声明如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@interface ClassB : NSObject
@end
@interface ClassA : NSObject
@end
@implementation ClassA
- (void)send:(int)i; {
NSLog(@"%d",i);
}
@end
@implementation ClassB
- (void)send:(NSString*)i; {
NSLog(@"%@",i);
}
@end

当执行下述代码的时候,大家可以分析一下会不会崩溃,如果崩了会在哪一行:

1
2
3
4
5
6
7
8
9
10
- (void)testExample {
ClassA* a = [ClassA new];
ClassB* b = (ClassB*)a;
[b send:@"123"];
ClassB* c = [ClassB new];
ClassA* d = (ClassA*)c;
[d send:52];
}

首先这段代码是可以完美骗过OC编译器的,可以编译执行。前面我们说了,ClassA和ClassB虽然是两个不同的类,但是由于他们的send:方法名是完全相同的,所以强转类型也可以相互调用方法。

这里要说明一下,我们使用OC的面向接口特性是为了开发便捷,而不是为了制造Crash,所以强行执行没有声明的方法,已经排除在目的外不加讨论,相信很多OC初学者最常见的Crash就是实例调用了没有实现的方法( unrecognized selector sent to instance)。

这段测试代码可以正常执行到[d send:52];这一行,然后抛出EXE_BAD_ACCESS异常,这里的机制和Runtime的消息发送机制有关,这里不详细探究,想说明的一点是,[b send:@"123"];这行代码执行输出的结果并不是预期结果,实际执行了NSLog(@"%d",@"123"),这种写法在这里骗过了编译器,却没有按照预期输出字符串内容,而是输出了一串随机数字。

所以我们知道了,接口参数声明是对象,我们传了基础类型则会直接崩溃。那如果两个方法的参数声明是不同类型的对象呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@implementation ClassA
- (void)send:(NSNumber*)i; {
NSLog(@"%@",i);
NSLog(@"------------");
}
@end
@implementation ClassB
- (void)send:(NSString*)i; {
NSLog(@"%@",i);
NSLog(@"------------");
}
@end

上述代码可以正常执行,并且参数在方法里也能正常使用。

对于万能动态类型,例如我们声明了id x,x不仅可以绕过编译器限制,调用ClassA和ClassB的全部方法,甚至可以调用所有此文件引用的Cocoa类的所有实例方法和类方法。极端灵活,也极端容易出错,是一把不折不扣的双刃剑,是好是坏全看耍剑人的本事。

Swift是强类型语言

Swift编译器明显比OC编译器要难对付,所有方法调用的参数类型都要对号入座,甚至泛型也要一致。例如声明的参数是字符串数组[String],传入的值就不能是[Int]。如果参数定义的是字典[String : Int],就不能传入[String : String],Key和Value要严格一致。

再举个SwiftUI的例子,Binding<String>Binding<Int>同为Binding结构体,泛型不同不能混用。我们知道OC强转类型是用直接在实例前加括号形式实现,Swift则用一个as关键字和两个符号(?,!)来实现。

使用的方法是let a = b as ClassA,但注意,as是向上转型,如果b本身不是ClassA或ClassA的子类,编译器就会报错;同样对于Struct结构体,强转不能转换的类型也会报错,例如:

1
2
3
4
5
6
7
let x = "1"
let y = x as Int
// 或
let x = 1.1
let y = x as Int

但as其实用在转型的场合非常少,因为传参可以用子类,不需要转换。as的另一个作用是在不显性声明类型的时候,消除类型的二义性,例如let x = 1.1 as CGFloat,这里如果不声明CGFloat,x会被编译器默认为Double类型

向上转型用得少,向下转型确是非常常见,用as!来实现,例如ClassB是ClassA的子类:

1
2
3
4
5
6
7
8
9
func testExample() throws {
let x = classB()
test(x: x)
}
func test(x: classA) {
let y = x as! classB
print(y)
}

这样写是没有问题的,但前提是我们百分之百确定x的类型是ClassA的子类,这里编译器做不到100%的预编纠错,所以使用时候要慎重,我在开发项目时候遇到过Swift和OC混编时,OC返回值类型为空导致as!转换崩溃的情况。

说到空,可能是OC初学者遇到的第二多造成程序崩溃的原因,早期版本OC的Runtime,向nil发送消息是会导致崩溃的。我个人猜测Swift为了避免这种情况,才设计了可选类型。啥叫可选类型?这个概念在OC中的确没有,但OC在为了兼容Swift,在Swift发布后,在系统框架中加入了Nullable、Nonull这些关键字。

在OC中用Nullable修饰的参数,在Swift中声明类型后会加上一个?
在OC中用Nonull修饰的参数,在Swift中则没有这个问号。

后面有问号的类型,就是可选类型。所谓可选类型,就是这个参数既可以是这个类型,也可以是nil。Swift中可选类型(Optional)是使用enum(枚举)定义的,所以String?只是Optional(String)的便捷写法,这里的String是泛型。

Swift初学者最常见编译器错误之一,String?String类型不同不能赋值,那可不是不同么,String是结构体,String?是枚举,大类都不一样。

那我们怎么能把可选类型参数转换为普通类型呢?这个过程就叫做拆包,这里就说一种最常见的拆包方式:

1
2
3
4
let x : String?
if let y = x {
// 执行逻辑
}

这里假设我们不知道x是nil还是已经赋值,如果x是nil就不会执行大括号里的逻辑,反之则执行;大括号中使用变量y,就是x拆包出来的String类型变量。

另一种我自己喜欢用的拆包方式是设置默认值的方式:

1
2
let x : String?
let y = x ?? ""

这样写y就是String类型了,在x为nil时,y是空字符串。可选类型这个东西就像是薛定谔的猫,编译器不知道他存不存在,程序也不知道,只有在拆包时候观察了,才能知道它是不是空。

当然还有强制拆包的手段,就是直接使用!,但这种用法是有风险的,前提也是要确定100%不为空才可以使用,否则程序会崩溃。所以推荐使用前面两种写法,把空和非空的状况都处理了,就好比薛定谔的猫从量子态塌缩,有了确定的状态。

小结

看手机淘宝团队分享Swift重构经验时候,印象深刻的是他们用Swift重写部分模块,上线后崩溃率为0,这个真的让我感到惊叹。产品迭代数十个版本仍然不能解决所有崩溃问题的经历,是我一直绕不过的山。但淘宝团队的分享,犹如一针强心剂,给了我项目0崩溃的希望。对于小团队和个人开发者,swift的强类型语言特性,无疑为我们减少了大量的测试工作。