谈谈集合遍历与 NSFastEnumeration

1,415 阅读6分钟
数组遍历或集合遍历可能是程序员每天都会接触到的事情。图灵完备中也要求程序必须可以有条件进行跳转,而集合遍历的过程一定离不开条件跳转,所以这种循环结构就是计算机中非常优美的地方。

到目前为止如果谈到数组遍历问题,我会想到下面几种方法:

1. 面向过程的循环,这是最最基本的方式,可能我们接触的第一种数组遍历方式就是这样:

for (int i = 0; i < arr.count(); i++)
{
   cout << arr[i] << endl;
}

2. 函数式 + 模式匹配 + 递归。函数式编程语言中没有循环语句,所以需要借助递归来完成遍历。我这里以一个 Haskell 程序举例,让列表每项自增的函数可能是这样写:

incEach :: (Num a) => [a] -> [a]

incEach ([])   = []
incEach (x:[]) = [x + 1]
incEach (x:xs) = (x + 1):(incEach xs)

incEach [1, 2, 3, 4]    -- Output: [2, 3, 4, 5]

3. 迭代器的方式。C# 中有 yield 和 foreach 这样的语句,Objective-C 中有 for...in 这样的语句,其实很多语言都支持这样的迭代器遍历方式。

本文我们就来谈谈 Objective-C 中 for...in 背后的秘密。

NSFastEnumeration 是什么?

说到 for...in,一个与其紧密相关的 protocol 就是 NSFastEnumeration,直接实现它的抽象类有 NSEnumerator。如果你试图去看 NSFastEnumeration 的文档,会发现它只有一个方法需要去实现:

countByEnumeratingWithState:objects:count:

那这个方法是如何 work 的呢?

通过 Google 和读官方的 Sample Code 发现它有两种工作方式。

首先我们看这个方法的第一个参数,它是一个 NSFastEnumerationState 结构体,实际上我们只需关心里面的 stateitemsPtr 变量,不管是何种工作方式,遍历的结果都需要体现在 itemsPtr 变量中,它是一个 id 的 C 数组,这个数组的长度应当等于方法的返回值。

首先我们看第一种实现方式:

这种方式需要你的自定义集合类有自己的 backing store,也就是集合对象是连续地存放在你自己管理的一个数组中的,那么我们在这个函数中的核心工作就是将 NSFastEnumerationState 结构体的 itemsPtr 变量设为你自己的数组,然后返回数组长度,无需理会其他参数。

但还没有结束,NSFastEnumerationState 结构体中有一个 mutationsPtr,这是一个无符号长整形的指针,它指向的数据表示了遍历过程中集合是否发生了变化,如果数据变化了,那么集合也发生了变化。通常来讲,我们是不允许在遍历过程中修改集合的,所以如果你不允许这个操作可以将 mutationsPtr 指向一个不会改变的变量。苹果要求这个指针是非 NULL 的,所以一定要设置一个有效的值,我们这里可以简单地设置成 self 的指针。

当方法返回的时候,如果返回值非零,则表示遍历并没有结束,Objective-C 还会再次调用这个方法,并且复用上次的 NSFastEnumerationState 结构体参数。但是当我们采用第一种实现方式的时候,我们会在第一次该方法被调用时返回所有数据,第二次我们只想返回 0 来终止遍历。这时状态结构体中的 state 变量就发挥作用了,由于每次该方法被调用时,这个变量是相同的,所以我们可以在第一次返回之前将 state 设置为一个固定值,然后第二次调用时判断 state 是否为那个固定值,如果是就直接返回 0。总体的实现方法如下:

- (NSUInteger)countByEnumeratingWithState:(NSFastEnumerationState *)state objects:(id *)stackbuf count:(NSUInteger)len {
    if (state->state >= ENUM_FINISHED) {
        return 0;
    }

    state->itemsPtr = _backingArray;
    state->state = ENUM_FINISHED;
    state->mutationsPtr = (unsigned long *) self;
    
    return _backingArrayCount;
}

很简单吧。


但是有的时候我们并不能一次性将集合内容全部返回出来,也就是说我们的数据是不连续的,比如链表结构或者二叉树结构,我们需要一个一个地将它取出来。这就需要用到第二种实现方法了。

首先假设我们有如下的自定义集合类:

typedef struct sMyLinkedNode {
    void *data;
    struct sMyLinkedNode *next;
} MyLinkedNode, *MyLinkedNodeRef;

@interface MyLinkedList : NSObject <NSFastEnumeration>

- (void)addObject:(id)object;

@end

@implementation MyLinkedList {
    MyLinkedNodeRef _head;
    MyLinkedNodeRef _tail;
}

@end

然后我们简单实现一下 addObject: 方法:

- (void)addObject:(id)object {
    MyLinkedNodeRef newNode = (typeof(newNode)) malloc(sizeof(*newNode));
    newNode->data = (void *) object;
    newNode->next = NULL;
    [object retain];
    
    if (!_head) {
        _head = _tail = newNode;
    } else {
        _tail->next = newNode;
        _tail = newNode;
    }
}

那么这时我们想遍历怎么办呢?我这里先贴出完成后的代码,然后再做解释:

- (NSUInteger)countByEnumeratingWithState:(NSFastEnumerationState *)state objects:(id  _Nullable [])buffer count:(NSUInteger)len {
    MyLinkedNodeRef currentNode;
    // Phase 1:
    state->itemsPtr = buffer;
    state->mutationsPtr = (unsigned long *) self;
    
    // Phase 2:
    if (!state->state) {
        currentNode = _head;
    } else if ((typeof(self)) state->state == self) {
        return 0;
    } else {
        currentNode = (typeof(currentNode)) state->state;
    }
    
    // Phase 3:
    unsigned long c = 0;
    while (currentNode && c < len) {
        buffer[c++] = [[(id) currentNode->data retain] autorelease];
        if (currentNode == _tail) {
            state->state = (unsigned long) self;
            return c;
        } else {
            currentNode = currentNode->next;
        }
    }
    
    state->state = (unsigned long) currentNode;
    
    return c;
}

首先我们需要理会一下方法中 buffer 这个参数了,它实际上是 Objective-C 提供给我们的一个缓冲区,大小通常比较小,实际长度在 len 这个参数中,我们可以将遍历出来的对象先存到这个 buffer 数组中,但要注意一次存放的个数不能超过 len

上面这一个过程对应的就是代码中 Phase 3 的部分,就是一个简单的链表遍历,要注意两点:第一就是如果你采用 MRC,需要的 ownership 管理应该是 retain 后再 autorelease,遍历出来的对象需要开发者自行 retain,如果需要的话。另外一点就是如果节点是最后一项,将 state 置为 self 后直接返回,因为我们需要借助这个节点来处理终止的情况。后面我会说到。

接下来我们看 Phase 1 做的事情,同样的,itemsPtr 需要指向返回值,由于我们使用了缓冲区,所以直接将它设为缓冲区的地址,mutationsPtr 和上面一样。

然后来看 Phase 2,该方法首次调用时 state 是 0,所以我们将 currentNode 设为链表的头指针;而如果 stateself 指针,那么说明遍历结束,直接返回 0 终止遍历;最后一种情况就是遍历未完成而缓冲区慢了,所以这次调用继续上次的节点往下遍历,将 currentNode 设为 state(即上次返回时遍历到的那个节点)。


至此,第二种实现方式也介绍完了。相信大家现在应该明白实现的时候该如何选择了。最后再点明一下,我全文所说的方法第几次调用时针对一次 for...in 遍历的,也就是说每个 for...in 语句块中,这个方法可能会被调用 n 次,state 会被复用来维护遍历状态,一般来讲:

  • 用第一种实现方法,该方法一次遍历会被调用两次:第一次返回所有数据,第二次直接返回 0 退出。
  • 用第二种实现方法,该方法被调用次数视集合长度和缓冲区长度而定。

跳出一个 for...in 块后,state 都将重置。


结束了。