第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

posted @ 2017-08-15 06:42  鳄鱼不怕牙医不怕  阅读(446)  评论(0编辑  收藏  举报