iOS基础(六) - 弄懂Object-C中block的实现

banner.jpg

前言:之前写swift进阶(二) - 闭包(Closure),看了大量关于block的文档,寻思着把自己这段时间关于block的理解,整理出来,以后忘了还能找回来。😄

1.基础知识

巩固一下基础知识,大学学的差不多都还给老师了。😂

  • *指针与地址:p VS p VS &p
    看一段代码:
char *p = "abc";
NSLog(@"&p: %p----p: %p----*p: %c", &p, p, *p);
char **p1 = &p;
NSLog(@"&p1: %p----p1: %p----*p1: %s----**p1: %c", &p1, p1, *p1, **p1);
//&p: 0x7ffeefbff5a8----p: 0x100001cad----*p: a
//&p1: 0x7ffeefbff5a0----p1: 0x7ffeefbff5a8----*p1: abc----**p1: a

&是取址操作,以上面代码为例:p是指向字符数组的指针,&p则是指针p在内存的地址,*p是指针p指向内存地址的存放内容,这里是字符数组的首地址也是第一个字符的内存地址,所以返回的是a,而*p1返回abc,是因为*p1其实就是p所指向内存的内容,所以返回的是abc。下面p1是指针的指针,p1是指向指针p的指针,所以,p,*p和&p的关系如下:


p|*p|&p.png

验证一下:

NSLog(@"p1: %p == &(*p1): %p", p1, &(*p1));
NSLog(@"&(*p1): %p == &p: %p", &(*p1), &p);
//p1: 0x7fff5fbff708 == &(*p1): 0x7fff5fbff708
//&(*p1): 0x7fff5fbff708 == &p: 0x7fff5fbff708
//满足p = &(*p)

注意:引用类型(对象)和值类型(基本数据类型,如int,float等等)的储存不一样,引用类型初始化返回的是指向该类型分配内存地址的指针,而基本数据类型返回的是改类型分配的内存地址。

  • iOS编译的程序占用内存分布的结构
    程序占用内存的分布结构如下图:(图片来源
    内存结构.jpg

    栈区:一般存放函数参数,局部变量等值,由系统自动分配和管理,程序员不必关心。存放里面的数据,遵从先进后出的原则。
    堆区:由程序员申请,管理和内存回收。数据储存的结构是链表。
    全局区/静态区:储存全局变量和静态变量。
    文字常量区:主要储存字符串常量。
    程序代码区:存放程序的二进制代码。
    举个例子:(代码来源
//main.cpp
int a = 0; // 全局初始化区
char *p1; // 全局未初始化区
main {
    int b; // 栈
    char s[] = "abc"; // 栈,字符串常量一般在文字常量区,但是该字符串从文字常量区复制到了栈区
    char *p2; // 栈
    char *p3 = "123456"; // 123456\0在常量区,p3在栈上
    static int c =0; // 全局静态初始化区
    p1 = (char *)malloc(10);
    p2 = (char *)malloc(20); // 分配得来的10和20字节的区域就在堆区
    strcpy(p1, "123456"); // 123456\0在常量区,这个函数的作用是将"123456" 这串字符串复制一份放在p1申请的10个字节的堆区域中。
    // p3指向的"123456"与这里的"123456"可能会被编译器优化成一个地址。
}

内存结构从低地址端到高地址端:


内存结构地址.png

2.block数据结构

block的数据结构定义如下图:(图片来源

block-struct.jpg

下面来验证一下block的数据结构,看一段代码:

NSString *str = @"hello";
void(^block)() = ^{
        NSLog(@"%@", str);
};
block();

用clang命令反编译Object-C文件成C++源文件,结果如下:

//字符串str初始化
NSString *str = (NSString *)&__NSConstantStringImpl__var_folders_pn_xqk2zmnx559bmvnwm35rzb4h0000gn_T_main_2cb64e_mi_0;
//block定义
void(*block)() = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, str, 570425344));
//block调用
((void (*)(__block_impl *))((__block_impl *)block)->FuncPtr)((__block_impl *)block);

可以看出,block是一个函数指针,指向_main_block_impl_0的内存地址,并且_main_block_imp_0传入了__main_block_func_0一个空的函数指针,定义了block里的操作;__main_block_desc_0_DATA数据描述;str指针,指向初始化字符串的内存地址,这个后面写block里如何修该外面的值的时候会提到;还有一个标识码
上面提到了__main_block_impl_0__main_block_func_0__main_block_desc_0_DATA,下面一个一个看一下它们是什么。

//__main_block_impl_0
struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  NSString *str;
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, NSString *_str, int flags=0) : str(_str) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};

//__main_block_func_0
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
  NSString *str = __cself->str; // bound by copy

            NSLog((NSString *)&__NSConstantStringImpl__var_folders_pn_xqk2zmnx559bmvnwm35rzb4h0000gn_T_main_2cb64e_mi_1, str);
        }

//__main_block_desc_0_DATA
static struct __main_block_desc_0 {
  size_t reserved;
  size_t Block_size;
  void (*copy)(struct __main_block_impl_0*, struct __main_block_impl_0*);
  void (*dispose)(struct __main_block_impl_0*);
} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0), __main_block_copy_0, __main_block_dispose_0};

//__block_impl
struct __block_impl {
  void *isa;
  int Flags;
  int Reserved;
  void *FuncPtr;
};

block初始化返回来的是结构体 __main_block_impl_0的内存地址,该结构体的构造函数接收了四个参数,分别是__main_block_func_0,__main_block_desc_0_DATA,str和标识符。因为block里捕获的是该变量指向的内存地址,而不是直接把当前的对象的地址,所以,理论上是两个指向同一个内存地址的变量,所以,修改block里面的变量地址,并不能改变外面的变量,编译器就干脆禁止了。看一下__block_impl这个结构体,是不是和图block-struct中的Block_layout很像,把结构体__block_impl里面的变量接在结构体__main_block_impl_0上,就一模一样了,Block_descriptor结构体就是__main_block_desc_0结构体,主要储存block的大小等信息。再看一下__main_block_func_0,相信大家都看出来了吧,这就是block执行的操作,打印获取的str。

3.block的类型

  1. _NSConcreteGlobalBlock全局静态block,不访问任何外部变量
  2. _NSConcreteStackBlock保存在栈中的block,当函数返回时会被销毁
  3. _NSConcreteMallocBlock保存在堆中的block,当引用计数为0时会被销毁

举例说明:
_NSConcreteGlobalBlock

void(^block)() = ^{
        };
block();
NSLog(@"%@", block);    
//<__NSGlobalBlock__: 0x1000030d0>

上面的block在ARC和非ARC打印出来都是NSGlobalBlock,但是clang一下C++源文件确是_NSConcreteStackBlock,不清楚为什么?有可能编译出的偏差也有可能是苹果系统做了什么,知道的同学请告诉我一声,感激不尽。

_NSConcreteStackBlock/ _NSConcreteMallocBlock

NSString *str = @"hello";
void(^block)() = ^{
       NSLog(@"%@", str);
};
block();
NSLog(@"%@", block);
//ARC:<__NSMallocBlock__: 0x100208f40>
//非ARC:<__NSStackBlock__: 0x7fff5fbff6d8>

ARC环境下会自动把栈里的block复制到堆里,非ARC环境下需要自己调用copy,复制到堆里,例:Block myBlock = [[block copy] autorelease]。

4.block获取外部变量

1.没有__block修饰的外部变量,block里面不能修改
在前面讲block的结构的时候,提到block里面获取的变量并不是外部变量的本身内存地址,而是指向的内存地址,下面代码验证一下:

NSString *str = @"hello";
NSLog(@"before----&str: %p----str: %p", &str, str);
void(^block)() = ^{
        NSLog(@"block----&str: %p----str: %p", &str, str);
};
block();
NSLog(@"after----&str: %p----str: %p", &str, str);
2017-03-27 10:05:33.557243 BasicTest[35056:2385069] before----&str: 0x7fff5fbff708----str: 0x100003100
2017-03-27 10:05:33.557494 BasicTest[35056:2385069] block----&str: 0x7fff5fbff6f8----str: 0x100003100
2017-03-27 10:05:33.557516 BasicTest[35056:2385069] after----&str: 0x7fff5fbff708----str: 0x100003100

block里面的str内存地址和外部变量不一样,但是block里面的str指向的内存地址则和外部str指向的内存地址一样,所以我们修改不了外部变量的内存地址。
2.有__block修饰的外部变量,block里面可以修改外部变量的内存地址
代码验证:

__block NSString *str = @"hello";
NSLog(@"before----&str: %p----str: %p", &str, str);
void(^block)() = ^{
        NSLog(@"block----&str: %p----str: %p", &str, str);
        str = nil;
};
block();
NSLog(@"after----&str: %p----str: %p", &str, str);
2017-03-27 10:10:42.929639 BasicTest[35092:2391935] before----&str: 0x7fff5fbff708----str: 0x100003110
2017-03-27 10:10:42.929831 BasicTest[35092:2391935] block----&str: 0x7fff5fbff708----str: 0x100003110
2017-03-27 10:10:42.929853 BasicTest[35092:2391935] after----&str: 0x7fff5fbff708----str: 0x0

外部变量使用__block修饰之后,block里面访问的变量地址和外部变量的地址是一样的,也就是说,外部变量和block里面的捕获的变量是同一个,所以修改block里面的变量地址,外部的变量地址也会发生变化。这到底是怎么实现的呢?我们反编译一下面的代码:

__block NSMutableString *str = [NSMutableString stringWithString: @"hello"];
__block NSInteger number = 1;
void(^block)() = ^{
       NSLog(@"str: %@----number: %ld", str, number);
};
block();

得到:

//str
__attribute__((__blocks__(byref))) __Block_byref_str_0 str = {(void*)0,(__Block_byref_str_0 *)&str, 33554432, sizeof(__Block_byref_str_0), __Block_byref_id_object_copy_131, __Block_byref_id_object_dispose_131, ((NSMutableString *(*)(id, SEL, NSString *))(void *)objc_msgSend)((id)objc_getClass("NSMutableString"), sel_registerName("stringWithString:"), (NSString *)&__NSConstantStringImpl__var_folders_pn_xqk2zmnx559bmvnwm35rzb4h0000gn_T_main_95b039_mi_0)};
//number
__attribute__((__blocks__(byref))) __Block_byref_number_1 number = {(void*)0,(__Block_byref_number_1 *)&number, 0, sizeof(__Block_byref_number_1), 1};
//block
void(*block)() = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, (__Block_byref_str_0 *)&str, (__Block_byref_number_1 *)&number, 570425344));
//block()
((void (*)(__block_impl *))((__block_impl *)block)->FuncPtr)((__block_impl *)block);

__block将字符串str和整形number变成了__Block_byref_str_0和__Block_byref_number_1类型,并且block传进去的是&str和&number,也就是外部变量的内存地址,block里捕获的外部变量就是外部变量本身,所以,block里面可以修改外部变量的地址。我们来看一下__Block_byref_str_0和__Block_byref_number_1是什么类型:

struct __Block_byref_str_0 {
  void *__isa;
__Block_byref_str_0 *__forwarding;
 int __flags;
 int __size;
 void (*__Block_byref_id_object_copy)(void*, void*);
 void (*__Block_byref_id_object_dispose)(void*);
 NSMutableString *str;
};
struct __Block_byref_number_1 {
  void *__isa;
__Block_byref_number_1 *__forwarding;
 int __flags;
 int __size;
 NSInteger number;
};

它们都是保存了block捕获的外部变量内存地址的结构体,Object-C中,带有isa指针的结构体可以看作对象,所以,它们都是对象。

打印一下__block修饰的变量,编译成C代码,会看到:

//Objective-C
__block NSInteger number = 123;
NSLog(@"number2: %ld", number);

//C
__attribute__((__blocks__(byref))) __Block_byref_number_0 number = {(void*)0,(__Block_byref_number_0 *)&number, 0, sizeof(__Block_byref_number_0), 123};
NSLog((NSString *)&__NSConstantStringImpl__var_folders_pn_xqk2zmnx559bmvnwm35rzb4h0000gn_T_main_7d3b3c_mi_1, (number.__forwarding->number));

可以看到,number并不是直接访问__Block_byref_number_0结构体里面的number,而是通过指向本身的指针__forwarding->number获取对应的值。那么?这个有什么意义?为什么要再绕一圈取值?别急,先来看一组代码:

//ARC
@interface Person : NSObject
@property (nonatomic, assign) void(^block)(void);
@end

@implementation Person
- (void)test {
    self.object = [NSObject new];
    NSLog(@"%p--retainCount1: %ld", &self, CFGetRetainCount((CFTypeRef)self));
    self.block = ^{
        NSLog(@"%p--retainCount2: %ld", &self, CFGetRetainCount((CFTypeRef)self));
    };
    self.block();
    NSLog(@"%p--retainCount3: %ld", &self, CFGetRetainCount((CFTypeRef)self));
}
@end

//输出
2018-04-12 15:00:07.908685+0800 BasicDemo[18910:2888235] 0x7ffeefbff538--retainCount1: 1
2018-04-12 15:00:07.909099+0800 BasicDemo[18910:2888235] 0x7ffeefbff528--retainCount2: 2
2018-04-12 15:00:07.909129+0800 BasicDemo[18910:2888235] 0x7ffeefbff538--retainCount3: 2

将block的修饰关键字换成copy,再编译
//输出
2018-04-12 15:02:42.246133+0800 BasicDemo[18923:2891037] 0x7ffeefbff538--retainCount1: 1
2018-04-12 15:02:42.246526+0800 BasicDemo[18923:2891037] 0x100526c40--retainCount2: 3
2018-04-12 15:02:42.246557+0800 BasicDemo[18923:2891037] 0x7ffeefbff538--retainCount3: 3

看到了什么?嘿嘿,当使用assign修饰block时,该block不会被复制到堆上去,所以,该block捕获的self会放在栈上,所以retain了一下当前对象,retainCount=2;当使用copy修饰block时,该block会被复制到堆上去,所以,该block先捕获的self放在栈上,然后被复制到堆上再次retain当前对象,所以retainCount=3。想一想使用assign修饰的block会不会造成循环引用?下面解释循环引用再回答。

回到上一个问题:为什么用_forwarding -> number?
上面解释了,当用copy/strong修饰block时,block不仅在栈上,还会被复制到堆上,那么会有两个block对象。所以,为了保证访问的变量都是同一个,就会把栈上的_forwarding指向堆block里的对象,保证两个block都是同一个对象。

5.block在ARC和MRC的区别

ARC和MRC最主要的区别就是,ARC是系统自动为对象插入-retain和-release方法,MRC则需要使用者自己插入。先上代码:

NSMutableString *str = [NSMutableString stringWithString: @"hello"];
NSInteger number = 1;
NSLog(@"&str: %p----str: %p----&number: %p", &str, str, &number);
void(^block)() = ^{
        NSLog(@"&str: %p----str: %p----&number: %p", &str, str, &number);
};
block();
//
//MRC下的输出:
2017-03-27 11:28:11.835575 BasicTest[36692:2513331] &str: 0x7fff5fbff708----str: 0x100209080----&number: 0x7fff5fbff700
2017-03-27 11:28:11.835770 BasicTest[36692:2513331] &str: 0x7fff5fbff6e8----str: 0x100209080----&number: 0x7fff5fbff6f0
//block外部:&str存放在栈区,而str则存放在堆区(因为&str内存地址比str要大很多,栈区在高地址,堆区在低地址,并且一般栈区比较小,1~2M左右,这个不用的操作系统分配的内存大小不一样,但是不会差很远)
//block内部:&str地址也在栈区,但是和外部的&str地址不一样,但是str的地址确实一样的,这也验证了外部变量在没有__block关键字修饰的情况下,block里捕获该变量会复制一份,而不是直接引用
//&number也一样
//
//ARC下的输出:
2017-03-27 11:35:26.885352 BasicTest[36731:2523310] &str: 0x7fff5fbff708----str: 0x100302f30----&number: 0x7fff5fbff700
2017-03-27 11:35:26.885547 BasicTest[36731:2523310] &str: 0x100400170----str: 0x100302f30----&number: 0x100400178
//block里面捕获的变量也会复制一份,和MRC环境下一样,但是不同的是,复制的内存地址从栈区跑到了堆区
//&str = 0x100400170. &number = 0x100400178
//因此,该block不会随着函数的调用而被释放掉,而MRC则需要自己把block从栈区copy到堆区

注意:
__block在MRC下不会retain对象,在ARC下会retain对象,MRC可以通过__block来解决循环引用问题。

6.block的循环引用

先看图:


cycle-retain.png

上面最简单的循环引用,A引用B,B引用A或者A对象内部拥有block,block里对A强引用。还有更复杂的情况,就是多个对象引用形成一个环,比如:A->B->C->D->A。循环引用有什么坏处?造成资源浪费,因为双方都不能释放,类似线程的死锁。

  • 为什么会造成循环引用?举个例子:
- (void)test: (NSString *)str {
    self.name = str;
    self.block = ^{
        NSLog(@"name: %@", self.name);
    };
}
//编译器报警告:
Capturing 'self' strongly in this block is likely to lead to a retain cycle

上面的代码在ARC下会报警告,但是在MRC下却不会报警告,为什么呢?
在第四小节block获取外部变量可以知道,__block的区别在于对象的内存地址还是对象指向的内存地址,而ARC和MRC的区别除了把栈区的block自动复制到堆区,还有一个重要区别就是自动插入-retain和-release函数,所以,ARC是强引用,MRC是弱引用,因为MRC并没有自动将block获取的外部变量的引用计数器的值增加。

  • 如何解决循环引用
    1.在执行完操作,将其中一个对象设置为nil
    2.其中一个对象的引用设置为弱引用,比如上面的例子,可以将self设置为弱引用
    3.先设置弱引用,在block里再强引用,防止该引用提前被回收掉

回答:使用assign修饰的block会不会造成循环引用?
答案不会
想想block是在哪分配的内存?是在栈上,不是由程序员管理内存,由系统管理,所以,block出了作用域就会被系统释放掉,所以,不会造成循环引用的问题。

针对第三点,说说个人理解,为什么要先weak,再strong,不会引起循环引用,而且在block使用期间,weak引用不会被提前释放掉。先看图:


block-cycle-retain.png

首先,对象A和block之间是不会形成循环引用的,这点是明确的。在block内部再对对象A强引用,看起来好像是形成了循环引用,其实,别忘记这是在block里面,这里的强引用只是一个局部变量,存储在栈上,也就是说,一旦出了函数域,该强引用就会被系统释放掉,也就不存在循环引用的问题。

总结:

Object-C因为有了block使本身语言更为便利,block及时返回的特性,也让代码的上下文结构更为清晰,某种程度比delegate更适合用在网络请求的功能实现。

参考
谈Objective-C block的实现
对Objective-C中Block的追探
block源码
Ry’s Objective-C Tutorial/blocks
Objective-C中的Block
《iOS 与OS X多线程和内存管理》笔记:Blocks实现(二)

推荐阅读更多精彩内容