第37条:理解“块”这一概念
第6章
block 与 GCD
当前在开发应用程序时,每位程序员都应该留意多线程问题。你可能会说自己要开发的应用程序用不到多线程,即便如此,它也很可能是多线程的,因为系统框架通常会在 UI 线程之外再使用一些线程来执行任务。开发应用程序时,最糟糕的事情莫过于程序因 UI 线程阻塞而挂起了。在 Mac OS X 系统中,这将使鼠标指针一直呈现令人焦急的旋转彩球状;而在 iOS 系统中,阻塞过久的程序可能会终止执行。
所幸苹果公司以全新方式设计了多线程。当前多线程编程的核心就是 “块”(block)与 GCD(Grand Central Dispatch, GCD)。这虽然是两种不同的技术,但它们是一并引入的。“块”是一种可在 C 、C++及 Objective-C 代码中使用的 “词法闭包”(lexical closure),它极为有用,这主要是因为借由此机制,开发者可将代码像对象一样传递,令其在不同环境(context)下运行。还有个关键的地方是,在定义“块”的范围内,它可以访问到其中的全部变量。
GCD 是一种与块有关的技术,它提供了对线程的抽象,而这种抽象则基于“派发队列”(dispatch queue)(亦称调度队列)。开发者可将块排入队列中,由 GCD 负责处理所有的调度事宜。GCD 会根据系统资源情况,适时地创建、复用、摧毁后台线程(background thread),以便处理每个队列。此外,使用 GCD 还可以方便地完成常见编程任务,比如编写 “只执行一次的线程安全代码”(thread-safe single-code execution),或者根据可用的系统资源来并发执行多个操作。
block 与 GCD 都是当前 Objective-C 编程的基石。因此,必须理解其工作原理及功能。
本条要点:(作者总结)
- 块是C、C++、Objective-C 中的词法闭包。
- 块可接受参数,也可返回值。
- 块可以分配在栈或堆上,也可以是全局的。分配在栈上的块可拷贝到堆里,这样的话,就和标准的 Objective-C 对象一样,具备引用计数了。
块可以实现闭包。这项语言特性是作为“扩展”(extension)而加入 GCC 编译器中的,在近期版本的 Clang 中都可以使用 (Clang 是开发 Mac OS X 及iOS 程序所用的编译器)。10.4 版及其后的 Mac OS X 系统,与 4.0 版及其后的iOS 系统中,都含有正常执行块所需的运行期组件。从技术上讲,这是个位于C 语言层面的特性,因此,只要有支持此特性的编译器,以及能执行块的运行期组件,就可以在 C、C++、Objective-C 、Objective-C++ 代码中使用它。
块的基础知识
块与函数类似,只不过是直接定义在另一个函数里的,和定义它的那个函数共享同一个范围内的东西。块用 “^” 符号(caret,可称为脱字符或插入符)来表示,后面跟着一对花括号,括号里面是块的实现代码,例如,下面就是个简单的块:
1 ^{ 2 3 // Block implementation here 4 }
块其实就是个值,而且自有其相关类型。与 int、float 或 Objective-C 对象一样,也可以把块赋给变量,然后像使用其他变量那样使用它。块类型的语法与函数指针近似。下面列出的这个块很简单,没有参数,也不返回值:
1 void (^someBlock)() = ^{ 2 3 // Block implementation here 4 };
这段代码定义了一个名为 someBlock 的变量。由于变量名写在正中间,所以看上去也许有点怪,不过一旦理解了语法,很容易就能读懂。块类型的语法结构如下:
1 return_type (^block_name) (parameters)
下面这种写法所定义的块,返回 int 值,并且接受两个 int 做参数:
1 int (^addBlock)(int a, int b) = ^(int a, int b){ 2 3 return a + b; 4 }
定义好之后,就可以像函数那样使用了。比方说,addBlock 块可以这样用:
int add = addBlock(2, 5); // < add = 7
块的强大之处是:在声明它的范围里。所有变量都可以为其所捕获。这也就是说,那个范围里的全部变量,在块里依然可用。比如,下面这段代码所定义的块,就使用了块以外的变量:
1 int additional = 5; 2 3 int (^addBlock)(int a, int b) = ^(int a, int b){ 4 5 return a + b + addItional; 6 }; 7 8 int add = addBlock(2, 5); // < add = 12
默认情况下,为块所捕获的变量,是不可以在块里修改的。在本例中,假如块内的代码改动了 additional 变量的值,那么编译器就会报错。不过,声明变量的时候可以加上 __block 修饰符,这样就可以在块内修改了。例如,可以用下面这个块来枚举数组中的元素,以判断其中有多少个小于 2 的数:
1 NSArray *array = @[@0, @1, @2, @3, @4, @5]; 2 3 __block NSInteger count = 0; 4 5 [array enumerateObjectsUsingBlock: 6 7 ^(NSNumber *number, NSUInteger idx, BooL *stop) { 8 9 if([number compare:@2] == NSOrderedAscending) { 10 count++; 11 } 12 }] ; 13 // count = 2
这段范例代码也演示了 “内联块”(inline Block)的用法。传给 “numerateObjectsUsingBlock:” 方法的块并未先赋给局部变量,而是直接内联在函数调用里了。由这种常见的编码习惯也可以看出块为何如此有用。在 Objective-C 语言引入块这一特性之前,想要编出与刚才那段代码相同的功能,就必须传入函数指针或选择子的名称,以供枚举方法调用。状态必须手工传入和传出,这一般通过 “不透明的 void 指针”(opaque void pointer)实现,如此一来,就得再写几行代码了,而且还会令方法变得有些松散。与之相反,若声明内联形式的块,则可把所有业务逻辑都放在一处。
如果块所捕获的变量是对象类型,那么就会自动保留它。系统在释放这个块的时候,也会将其一并释放。这就引出了一个与块有关的重要问题。块本身可视为对象。实际上,在其他 Objective-C 对象所能响应的选择子中,有很多块也可以响应的。而最重要之处则在于,块本身也和其他对象一样,有引用计数。当最后一个指向块的引用移走之后,块就回收了。回收时也会释放块所捕获的变量,以便平衡捕获时所执行的保留操作。
如果将块定义在 Objective-C 类的实例方法中,那么除了可以访问类的所有实例变量之外,还可以使用 self 变量。块总能修改实例变量,所以在声明时无须加 __block 。不过,如果通过读取或写入操作捕获了实例变量,那么也会自动把 self 变量一并捕获了,因为实例变量是与 self 所指代的实例关联在一起的。例如,下面这个块声明在 EOCClass 类的方法中:
1 @interface EOCClass 2 3 - (void)anInstanceMethod { 4 5 // ... 6 void (^someBlock)() = ^{ 7 _anInstanceVariable = @"Something"; 8 NSlog(@"_anInstanceVariable = %@", _anInstanceVariable); 9 }; 10 // ... 11 12 } 13 14 @end
如果某个 EOCClass 实例正在执行 anInstanceMethod 方法,那么 self 变量就指向此实例。由于块里没有明确使用 self 变量,所以很容易就会忘记 self 变量其实也为块所捕获了。直接访问实例变量和通过 self 来访问是等效的:
self->_anInstanceVariable = @" Something";
之所以要捕获 self 变量,原因正在于此。我们经常通过属性(参见第6条)访问实例变量,在这种情况下,就要指明 self 了:
1 self.aProperty = @" Something";
然而,一定要记住:self 也是个对象,因而块在捕获它时也会将其保留。如果 self 所指代的那个对象同时保留了块,那么这种情况通常就会导致 "保留环"。更多内容请参阅第 40 条。
块的内部结构
每个 Objective-C 对象都占据着某个内存区域。因为实例变量的个数及对象所包含的关联数据互不相同,所以每个对象所占的内存区域也有大有小。块本身也是对象,在存放块对象的内存区域中,首个变量是指向 Class 对象的指针,该指针叫做 isa(参见第14条)。其余内存里含有块对象正常运转所需要的各种信息。图详细描述了块对象的内存布局。
块对象的内存布局
在内存布局中,最重要的就是 invoke 变量,这是个函数指针,指向块的实现代码。函数原型至少要接受一个 void * 型的参数,此参数代表块。刚才说过,块其实就是一种代替函数指针的语法结构,原来是用函数指针时,需要用 “不透明的 void 指针” 来传递状态。而改用块之后,则可以把原来用标准 C 语言特性所编写的代码封装成简明且易用的接口。
descriptor 变量是指向结构体的指针,每个块里都包含此结构体,其中声明了块对象的总体大小,还声明了 copy 与 dispose 这两个辅助函数所对应的函数指针。辅助函数在拷贝及丢弃块对象时运行,其中会执行一些操作,比方说,前者要保留捕获的对象,而后者则将之释放。
块还会把它所捕获的所有变量都拷贝一份。这些拷贝放在 descriptor 变量后面,捕获了多少个变量,就要占据多少内存空间。请注意,拷贝的并不是对象本身,而是指向这些对象的指针变量。invoke 函数为何需要把块对象作为参数传进来呢?原因在于,执行块时,要从内存中把这些捕获到的变量读出来。
全局块、栈块及堆块
定义块的时候,其所占的内存区域是分配在栈中的。这就是说,块只在定义它的那个范围内有效。例如,下面这段代码就有危险:
1 void (^block)(); 2 3 if (/*some condition*/) { 4 5 block = ^{ 6 NSLog(@"Block A"); 7 }; 8 } else { 9 10 block = ^{ 11 NSLog(@"Block B"); 12 }; 13 } 14 15 block();
定义在 if 及 else 语句中的两个块都分配在栈内存中。编译器会给每个块分配好栈内存,然而等离开了相应的范围之后,编译器有可能把分配给块的内存覆写掉。于是,这两个块只能保证在对应的 if 或 else 语句范围内有效。这样写出来的代码可以编译,但是运行起来时而正确,时而错误。若编译器未覆写待执行的块,则程序照常运行。若覆写,则程序崩溃。
为解决此问题,可给块对象发送 copy 消息以拷贝之。这样的话,就可以把块从栈复制到堆了。拷贝后的块,可以在定义它的那个范围之外使用。而且,一旦复制到堆上,块就成了带引用计数的对象了。后续的复制操作都不会真的执行复制,只是递增块对象的引用计数。如果不再使用这个块,那就应将其释放,在 ARC 环境下会自动释放,而手动管理引用计数时则需要自己来调用 release 方法。当引用计数降为 0 后,“分配在堆上的块”(heap block)会像其他对象一样,为系统所回收。而“分配在栈上的块”(stack block)则无须明确释放,因为栈内存本来就会自动回收,刚才那个范例代码之所以有危险,原因也在于此。
明白这一点后,我们只需给代码加上两个copy 方法调用,就可令其变得安全了:
1 void (^block)(); 2 3 if (/* some condition*/) { 4 5 block = [^{ 6 NSLog(@"Block A"); 7 } copy]; 8 } else { 9 10 blok = [^{ 11 12 NSLog(@"Boock B"); 13 } copy]; 14 } 15 16 block();
现在代码安全了。如果手动管理引用计数,那么在用完块之后还需要将其释放。
除了 “栈块” 和 “堆块” 之外,还有一类块叫做 “全局块”(global block)。这种块不会捕捉任何状态(比如外围的变量等),运行时也无须有状态来参与。块所使用的整个内存区域,在编译期已经完全确定了,因此,全局块可以声明在全局内存里,而不需要在每次用到的时候于栈中创建。另外,全局块的拷贝操作是个空操作,因为全局块决不可能为系统所回收。这种块实际上相当于单例。下面是个全局块:
1 void (^block)() = ^{ 2 3 NSLog(@"This is a block"); 4 }
由于运行该块所需的全部信息都能在编译期确定,所以可把它做成全局块。这完全是种优化技术:若把如此简单的块当成复杂的块来处理,那就会在复制及丢弃该块时执行一些无谓的操作。
END