Switch语句完备性问题最佳实践

背景

最近在开发过程中遇到一个崩溃。代码咋一看没啥问题,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

扫描二维码关注公众号,回复: 13470660 查看本文章

但当我们覆盖了所有 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 协议的,所以也不会崩溃。

这里也就解释了为啥这个问题一直没有暴露出来。


如果觉得本文不错,给我点个赞吧~❤️

猜你喜欢

转载自juejin.im/post/7036616493588545543