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

但当我们覆盖了所有 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