iOS 代码里逻辑分支的处理

iOS 代码里逻辑分支的处理

我们大致上可以将代码按执行方式分解为三类:Sequence,Selection,Iteration。

Sequence

Sequence 即为按前后顺序依次执行,从第一行按序一直执行到第 n 行。比如:

NSString *name = @"default"; //definition
name = @"peak"; //assignment
NSLog(@"name is %@", name); //send message

3 行代码包含 Definition,Assignment,Send Message 不同类型的指令,但他们被运行的时候作为一个整体是依照 Sequence 模式依次执行。

Selection

Selection 即为条件模式,说的简单一点就是平常我们写代码时所用的 if else,switch。这是我们代码的逻辑产生分支的地方,也是这篇文章的主题。记得之前读到过一句话,大意说是当我们想要重构代码的时候,if else 总会是个好的着手点,或者说 if else 是我们代码最容易出错的地方。

按我个人理解,逻辑分支之所以容易出错在于两点。

其一是所依赖的条件不确定,或者不稳定。比如:

if ([users objectAtIndex:0] == currentUser) {
    ...
}

看似简单的条件代码 [users objectAtIndex:0] == currentUser 会在各种情况下出错,比如 users 当中没有任何元素会发生越界,比如 users 已被释放导致内存访问异常,同样的情况也会发生在 currentUser 身上,一个条件语句所包含的状态越多,出错的可能性也就越大。

其二是遗漏某个条件分支。比如:

typedef enum : NSUInteger {
  EUserLoginStatusLoggedIn,
  EUserLoginStatusLoggedOut,
  EUserLoginStatusKickedOut,
} EUserLoginStatus;

EUserLoginStatus userStatus;
...
if (userStatus == EUserLoginStatusLoggedIn) {
    ...
} else if (userStatus == EUserLoginStatusLoggedOut) {
    ...
}

比如上面代码忘记处理 EUserLoginStatusKickedOut, 当然如果代码是同一个人所写,一般不会遗漏。但如果代码交由后面的人维护,EUserLoginStatus 新增了 status,而 if else 的处理有散落的工程的各个角落,忘记处理新的分支就很容易发生了。

Iteration

Iteration 发生在我们需要循环或多次处理某些数据的时候,比如我们常见的 while,for 循环。iteration 有时也会依赖某些数据或者某些条件语句,在处理的时候也会存在 Selection 语句容易遇到的状态不稳定问题。

Sequence,Selection,Iteration 可以概括我们所写的全部代码。其中 Selection 是最容易出错的地方,也是我个人平时 review 代码的重点。

Selection 第一个所依赖状态不稳定的问题,多注意数据或者对象的生命周期,不可变性,多线程安全即可。可以参考下我之前的两篇文章 [书写高质量代码之状态维护], [iOS多线程到底不安全在哪里?],里面有一些我的相关思考和总结,或许会对你有一些帮助。

分支遗留

第二个分支遗漏的问题,出现的概率比大多数人想象的要高,尤其是随着项目代码的膨胀,工程师的更替。所以从代码层面做一些限制可以有效的避免这一问题出现。

一种常见的做法是针对多分支的逻辑处理,尽量使用 switch 而非 if else,比如工程师 A 先写了如下代码:

// File A
typedef enum : NSUInteger {
  EUserLoginStatusLoggedIn,
  EUserLoginStatusLoggedOut,
} EUserLoginStatus;

// File B
EUserLoginStatus userStatus;
...
switch (userStatus) {
  case EUserLoginStatusLoggedIn:
  {

  }
  break;
  case EUserLoginStatusLoggedOut:
  {

  }
  break;
}

之后工程师 B 在 File A 中又加了一种 enum 值 EUserLoginStatusKickedOut,那么此时编译器会以警告的方式,帮助我们检查遗漏的类型,这里的关键在于写 switch 时不要写 default case,否则编译器会认为新增的 enum 值有默认的处理逻辑了。

如果没写 default case,Xcode 会给出如下警告:


这几乎可以看做是 iOS 下处理逻辑分支的 best practice 了。

Match

除此之外,我们还有另一种更“激进”的方式来避免这类问题,match pattern。过去一年看到越来越多的代码采用这种方式。使用 match pattern 代码如下:

// File A
typedef enum : NSUInteger {
  EUserLoginStatusLoggedIn,
  EUserLoginStatusLoggedOut,
} EUserLoginStatus;

// File B
typedef void (^UserLoggedInBlock)(void);
typedef void (^UserLoggedoutBlock)(void);

- (void)someMatchUserStatusLogic
{
  [self matchUserStatusLoggedIn:^{
    //...
  } loggedOut:^{
    //...
  }];
}

- (void)matchUserStatusLoggedIn:(UserLoggedInBlock)loggedInBlock loggedOut:(UserLoggedoutBlock)loggedoutBlock
{
  EUserLoginStatus userStatus = EUserLoginStatusLoggedIn;
  switch (userStatus) {
    case EUserLoginStatusLoggedIn:
    {
      loggedInBlock();
    }
      break;
    case EUserLoginStatusLoggedOut:
    {
      loggedoutBlock();
    }
      break;
  }
}

这种方式在 switch 的基础之上再封装了一层函数调用,将分支的处理写进函数签名里面,好处很明显,当你新增 EUserLoginStatusKickedOut case 的时候,只要更改 matchUserStatusLoggedIn 函数,新增一个参数:

// File B
typedef void (^UserLoggedInBlock)(void);
typedef void (^UserLoggedoutBlock)(void);
typedef void (^UserKickedoutBlock)(void);

- (void)matchUserStatusLoggedIn:(UserLoggedInBlock)loggedInBlock loggedOut:(UserLoggedoutBlock)loggedoutBlock kickedOut:(UserKickedoutBlock)kickedoutBlock;

那么所有被影响的代码只要一编译都会报错,改起来相当方便,相比较于 warning,compile error 显然更能借助编译器来避免我们代码上的分支遗漏。即使代码被第二个人接手,改动起来也一目了然。

这种写法如果不明白目的所在,第一眼看上去显得笨重且多余。我个人感觉,有时候如果多写的代码模式固定且简单容易理解,同时这种多出来的代码可以让逻辑更健壮,那么这些多余的代码就并不多余。尤其是当项目代码量过于庞大且参与人数众多的情况下,优质的代码书写避免代码产生意料之外的降级。

编辑于 2017-11-20 08:12