书写高质量代码之状态维护

维护程序状态的一些小心得。

状态之始

我们第一眼接触新事物所触发的思考方式,决定了以后我们看待这样事物的角度,进而影响更深层次的理解和行为。

编程相对于人类历史的进程而言,不过是个六七岁孩童偶然捡到的新玩具,因为新鲜好玩到现在都还爱不释手。这个玩具于我们的大脑会产生怎么样的化学反应是个未知数,每个个体都不同。你第一眼见到色彩或形状直接关系到你的兴趣点或是以后会怎样去把玩这个玩具。

小朋友拿到新玩具往往急不可耐去动手试玩,成年人面对编程的时候应该理智的去建立自己的知识观。编程到底是件什么样的玩具,它的本质是什么?

编程是和我们平行的宇宙,有自己的世界和规则。它的基础元素是名词和动词。名词即数据(data),动词即行为(action)。理解这两个基础元素是建立编程世界观的基石。

我们经常会谈论data,只不过形式各异。data是个宽泛的概念集,它可以是:变量,状态,model,数据,属性等等。同行在谈论app架构如何设计model layer的时候,我反应:哦,是在说怎么维护app的状态。

所以你看,将概念归类很重要,理解状态的本质和表现形式很重要,怎么去维护状态很重要。

状态生命周期

每一个变量的诞生,无论身份如何都有一个生命周期。

函数内部变量:

int i = 0

生于函数内部,存在内存栈上。一旦函数结束,i也随着结束生命。

类的属性:

@property (nonatomic, assign) int count;

生于某个类的实例,存在实例的内存堆上。一旦对象被销毁,count也被回收。

全局变量:

int index = 0;

生于程序初始化之刻,存于内存data区。程序被退出,index才会随之消失。

Model实例:

User* user = [User new];

model实例的诞生一般散落在各个模块,注重架构的程序员会把model的创建都放在同一个layer或者module。model一般依附于cache或者某个业务对象,生命周期较之一般状态更难把控确定。

状态还有更多的表现形式,无论其形式如何,明确我们所创造每一个状态的生命周期,对于书写高质量代码至关重要。生命周期越短,能够访问状态的对象越少,我们的代码就越可控,越安全。你所写app当中的每一个状态是否安全?

安全的状态

状态是否安全十分重要,如果条件允许,我们总是应该尝试尽可能创造“无害”的状态。

状态的安全性可以从两个角度去理解。

访问权限安全:

一个状态生命周期越短,能够访问(read和write)它的对象越少,我们可以认为这个状态越安全。

在类当中创建新的property的时候,将property定义在.m当中是个好习惯。放在.h当中意味着任何对象都可以访问。

确实需要被其他对象访问(read)之时,我们应该吝啬的只提供get方法。

当你觉得实在需要被外部对象修改(write)状态的时候,这很有可能是一个代码开始降级的消极信号,我们需要反复审视这个“需求”的合理性,在找不到其他设计来规避之时,可以惶恐的提供一个set方法。但必须记住,这个set方法被调用的越频繁,这个状态越危险。

if else或者switch,是bug最容易生长的土壤。当我们尝试在if语句中判断状态的时候,不稳定的状态会让我们原本以为清晰简单的判断,变得不可控而且难以调试。

每次书写if else之时,谦卑谨慎的去审视我们所依赖状态的安全性,会让我们的代码更健康,更容易发现问题症结所在。

近几年炙手可热的函数式编程强调“无状态”,无状态并不是禁止我们去定义变量,声明状态。状态是编程宇宙中的基础元素,没有状态谈何逻辑。无状态是指将状态“锁在”函数的内部,使其生命周期仅存于某个函数个体之内。所以函数成了函数式编程当中的第一公民,函数可以返回状态,不过这个状态更像一个结果,一个数学公式运算的结果,每次公式运算都是一份新的结果,一个结果决不被多个对象共同持有。无状态其实是在强化状态的安全性。

多线程安全:

多线程访问较之于多对象访问是另一个维度,一个和人脑运行方式迥异的维度。

多线程问题复杂度在于执行的时序不确定性,结合状态被write的场景,如果不仔细设计,很容易让你的代码变得一团糟。甚至有时候debug多线程状态问题,所费时间不亚于开发投入的时间。

多线程read状态不需要过于担心,read操作几乎没有副作用。需要谨慎对待的是write操作。write和read在多线程的场景下,同时发生在集合类(比如数组)对象之时,代码会变得十分脆弱。数组类对象是我们代码当中常用的状态,也是很多疑难杂症bug产生的源头。比如如下代码:

- (void)initTableArray
{
    if (_tableArr == nil) {
        _tableArr = @[].mutableCopy;
    }
}
- (void)renderTableArray
{
    for (NSObject* item in _tableArr) {
        //render
    }
}
- (void)insertTableItem:(NSObject*)item
{
    [_tableArr addObject:item];
}

上面三段代码分别对应数组状态的三种操作:创建状态,读取状态,修改状态。看似简单的代码,如果放在多线程的场景之下问题很容易变得复杂起来。

多线程并发下,_tableArr可能会被创建多次。

多线程并发下,_tableArr在遍历之时可能被修改,直接导致crash。

多线程并发下,_tableArr中item插入的顺序变得不确定。

此时我们需要“锁技”来应对数组类状态,锁可以让多线程场景下,我们的状态得以“原子”的粒度被访问或被修改。OC这类高级语言使得加锁变得轻而易举:

- (void)initTableArray
{
    @synchronized (self) {
        if (_tableArr == nil) {
            _tableArr = @[].mutableCopy;
        }
    }
}
- (void)renderTableArray
{
    @synchronized (self) {
        for (NSObject* item in _tableArr) {
            //render
        }
    }
}
- (void)insertTableItem:(NSObject*)item
{
    @synchronized (self) {
        [_tableArr addObject:item];
    }
}

如果觉得synchronized性能不够好,可以换成dispatch_semaphore_t,但绝大部分业务场景下,这点性能的损耗是无法被感知的。

我们还可以采用“缩短状态生命周期”的方式,来规避多线程带来的风险。比如:

- (void)renderTableArray
{
    NSMutableArray* arr = [self createNewRanderArr];
    @synchronized (self) {
        for (NSObject* item in arr) {
            //render
        }
    }
}

- (NSMutableArray*)createNewRanderArr
{
    return @[].mutableCopy;
}

每次渲染的array都是重新生成的,不会被其他对象访问修改,render之后array就可以被废弃。通过这种方式我们也可以尽量避免多个线程同时修改状态,所引入的不稳定性。

清理状态

对于函数内部的临时变量,函数退出之时,状态也就随着被清理。更多的场景下,状态由我们自己生成,并存放于heap上。如果不手动清理,状态就会一直存在,并带来可能的风险。

如果可以,我们应该总是尽可能缩短一个状态的生命周期,减少状态暴露给其他对象的机会。适时的清理状态会让我们的代码更加健壮。

状态皆有其所依赖的业务场景。购物车里的商品在完成购买之后就失去了依附的业务环境,用户的购买记录在用户退出登录之后也应该被清除,更不应该影响到下一个登录的新用户。

所以在使用新状态描述业务的时候,我们总是需要考虑以下收尾工作:

- (void)onUserLogout
{
    //clear state
}

结束语

每一个新的状态就像程序王国里的新子民,其所扮演的角色,影响范围,生命周期都需要被程序员以上帝视角反复的推敲设计。