背景
最近在开发过程中遇到一个崩溃。代码咋一看没啥问题,typeString 函数将枚举映射为文案。既没有警告,也没有错误,咋就崩溃了呢,而且还是给 MyObject 实例对象发了一个 copy 消息?
下面我们来分析一下这个问题。
问题分析
问题1 Crash如何解决?
这里问题其实很简单,枚举的定义为
typedef NS_ENUM(NSUInteger, MyEnum) {
MyEnumValueA = 0,
MyEnumValueB = 1,
MyEnumValueC = 2,
};
复制代码
但我们生成对象的时候,用了一个没有定义的枚举值
NSArray<MyObject *> *array = @[
[MyObject objWithType:0],
[MyObject objWithType:1],
[MyObject objWithType:2],
[MyObject objWithType:3],
];
复制代码
我们的 typeString 函数是没有这个枚举的
NSString *typeString(MyEnum type)
{
switch (type) {
case MyEnumValueA:
return @"MyEnumValueA";
case MyEnumValueB:
return @"MyEnumValueB";
case MyEnumValueC:
return @"MyEnumValueC";
}
}
复制代码
不符合 switch 语句的任何一个 case ,造成了后续逻辑的不完备。
那么,1⃣️ 为什么当时要这么写,2⃣️ 又为什么既没有警告,也没有错误呢?
其实,这里想利用编译器的一个机制。当我们新增一个枚举后,没有 switch 语句中没有覆盖所有 case 就会有警告,工程中又 treat warning as error
就会产生一个错误,可以很好地提醒修改代码的同学需要覆盖这些场景。
Enumeration value 'MyEnumValueC' not handled in switch
![](/qrcode.jpg)
但当我们覆盖了所有 case 时,这个警告是没有的,编译器认为我们覆盖了所有路径。
这里需要注意的是,OC 不同于 Swift,并不是一个强类型的语言。这里虽然定义的是 MyEnum 类型,但其本质还是 NSUInteger ,所以我们可以随意传入数字,而不是只能使用 MyEnum 中定义的类型,所以产生了纰漏。
有同学可能要说了,为什么不使用 default 关键字呢?
如果加入了 default ,那么我们新加枚举类型的时候就无法利用 warning 来进行提醒了。
⬇️ 这个是推荐的最佳实践。
但如果我们将 default 的逻辑加在最后,既可以保留 warning 来进行提醒,又保证了逻辑的完备性。
⬆️ 这个是推荐的最佳实践。
问题2 为啥给 MyObject 实例对象发了一个 copy 消息?
这里要看一下汇编了。
重新理解 switch 语句
这里先看看原始的 switch 代码。
其实 switch 语句的形式将我们骗了。 你以为的执行过程是 type 拿到后进入某个 case 然后遇到 return 就返回了。
汇编这里可以看到,每次都是先进行了一个减法,再进行跳转的。
大致是 rax - 0、rax - 1、rax - 2 然后通过 je 比较,跳转到对应语句执行赋值,然后跳转到最后再进行返回的。
代码大致是这样:
只是大概逻辑,没有准确翻译。这里主要想说,return 都是在最后执行的。
NSString *typeString2(MyEnum type)
{
NSString *result;
if (type - 0 == 0) {
result = @"MyEnumValueA";
goto final;
} else if (type - 1 == 0) {
result = @"MyEnumValueB";
goto final;
} else if (type - 2 == 0) {
result = @"MyEnumValueC";
goto final;
} else {
// 这里缺失了 result 赋值的逻辑
goto final;
}
final:
return result;
}
复制代码
如果没法命中 case 那么我们就可能缺了一些逻辑,导致寄存器中的值不对。
问题复现
rax 寄存器中会存储返回值,正常情况下,我们可以看到这个寄存器的值是符合预期的。
出错时,可以看到 rax 的值是 1,也不是我们的 MyObject 实例对象啊?
这里可以看到,还会调用一个 objc_autoreleaseReturnValue 方法。因为这里寄存器的状态不对,所以调用 objc_autoreleaseReturnValue 方法时,得到的结果就可能有问题。
我们设置一个符号断点,再在返回 ret 处设置一个断点,可以看到 rax 的值为 0x6000030ac400 。
我们再在返回后设置一个断点,可以看到我们的临时变量指针指向的正是 0x6000030ac400,也就是我们的 MyObject 实例对象。
又因为 NSDictionary 的 key 是要求遵循 NSCopying 协议的,所以 dic[str]
这里触发了copy 操作,给 MyObject 实例对象发送了 -[MyObject copyWithZone:]
最终导致了崩溃。
问题总结
问题的原因很明确了,就是 switch 语句如果没法命中 case,导致缺了一些寄存器赋值的逻辑,蝴蝶效应后返回了不该返回的值。
总结
如果想利用 switch 编译器的提醒功能,我们需要将 default 的逻辑加在最后,既可以保留 warning 来进行提醒,又保证了逻辑的完备性,不会产生异常问题。
扩展
扩展1 代码优化
我们将代码优化开启后,汇编代码不一样,返回值也可能不一样。
这里可以看到,有问题的枚举值,返回了 @"MyEnumValueA"
虽然不正确,但 NSString 是遵循 NSCopying 协议的,所以也不会崩溃。
工程实践中,这个会导致 NSMutableDictionary 之前给
@"MyEnumValueA"
的赋值被覆盖。
扩展2 真机 vs 模拟器
真机上返回的是一个 NSStackBlock 对象(其实就是我们迭代的block),block 也是遵循 NSCopying 协议的,所以也不会崩溃。
这里也就解释了为啥这个问题一直没有暴露出来。
如果觉得本文不错,给我点个赞吧~❤️