Objective-C高级编程之block篇

block是C语言的一项重要的特性。在很多其他计算机语言中都有类似的概念,比如lamda表达式,闭包等。那么block是什么?简而言之,block是带有自动变量的匿名函数。本文将以这句话展开,对block进行深入解析。

block语法

block可以认为是匿名的C函数,它的语法格式是这样的:

^ int (int i){
  return 0;
}

block变量

当不需要返回值或函数参数时,也可以将这两部分都省略掉。我们可以将block代码块赋值给block类型的变量,用法是这样的:

void (^blk)(int) = ^ void (int i) {
  printf("I am block.");
}

blk即为block类型的变量,它与普通的C语言变量是一样的,可以用作自动变量、函数参数、静态局部变量、静态全局变量,全局变量。可以看到,block类型的变量定义的方式跟其他变量很是不同,直观上不易阅读,所以我们可以使用typedef来给block类型起一个别名,例如这样:

typedef void (^block_t)(int);
//使用block_t类型定义block变量
block_t blk;

这样block类型的变量定义就和普通的C语言变量定义看起来一致了,代码也变得更加直观。

block截获的自动变量

理解了block是匿名函数,再来看看带有自动变量是怎么回事。事实上,这里指的是在block内部可以截获外部的自动变量值,示例如下:

typedef void (^block_t)();
int main () 
{
    int a = 0;
    int b = 1;
    block_t blk = ^ () {
        printf("block: %d", a);
    }
    a = 2;
    blk();
    return 0;
}

在这里,block截获了自动变量a的值,即便之后a值改变,并不会影响block中截获的值,换句话说,上述代码输出的结果是0,而不是2。当我们想在block中修改自动变量的值时,会报编译错误,因为默认情况下,block内是不能修改自动变量的值的。如果我们想要修改,需要在自动变量前加__block修饰符。为什么加了此修饰符就可以实现修改自动变量呢,这个我们后面详述。我们先来看个例子来深入理解“不能修改自动变量值”这句话。

typedef void (^block_t)();
int main () 
{
    NSMutableArray *array = [[NSMutableArray alloc] init];
    block_t blk = ^ () {
        id object = [[NSObject alloc] init];
        [array addObject:object]; //正确 
        array = otherArray; //错误
    }
    blk();
    return 0;
}

虽然block内不允许修改自动变量的值,这里截获的是类的对象,用C语言来描述的话,这里截获的是类对象对应的结构体实例的指针,我们不能修改指针,但使用指针是没有问题的。需要指出的是,block并未实现截获C语言字符数组,所以我们无法在block内使用外部定义的C语言字符数组,不过我们可以用字符指针char*类型代替。

block实现原理

接下来我们通过分析源码这种激动人心的方式来理解block的实现原理。我们可以利用clang这个工具将block代码转换成C++源码来分析。我们在main.m文件中实现一段简单的block代码:

int main () {
    void (^blk)() = ^() {
        int a = 0;
    };
    blk();
}

接下来,我们使用clang -rewrite-objc main.m命令转换为C++源码,由于源码太长,我们将关键部分摘出来:

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

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int flags=0) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {

  int a = 0;
 }

static struct __main_block_desc_0 {
  size_t reserved;
  size_t Block_size;
} __main_block_desc_0_DATA = { 0, sizeof(struct __main_block_impl_0)};
int main () {
 void (*blk)() = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA));
 ((void (*)(__block_impl *))((__block_impl *)blk)->FuncPtr)((__block_impl *)blk);
}

通过对比,很容易发现我们的block代码块实际上转换为了普通C语言函数__main_block_func_0,函数命名上表示该block是main函数中第一个block。函数的参数是一个指向__main_block_impl_0结构体的指针,事实上这个结构体就是block对应的结构体实例。这个结构体中只有两个成员变量,其类型分别是__block_impl和__main_block_desc_0,__block_impl结构体中记录了block的一些信息,例如函数指针等,__main_block_desc_0结构体中表示了block的size。另外还有一个构造函数,对__main_block_impl_0的成员变量做初始化。

最后我们来看下main函数。我们去掉里面的类型转换部分,简化如下:

struct __main_block_impl_0 tmp = &__main_block_impl_0(__main_block_func_0, &__main_block_desc_0_DATA);
struct __main_block_impl_0 *blk = &tmp;

(*blk->impl.FuncPtr)(blk);

对比我们的block代码,前两行表示了将block实现赋值给block类型的变量,从第三行可以看出,blk的调用变成了函数指针的调用,同时函数指针中传入了block变量作为参数。现在我们已经明白,block其实是用C语言函数和结构体来实现的。

block截获自动变量的原理

那么block是怎么截获自动变量的呢?我们修改下block代码,再用clang看下源码实现:

int main () {
    int a = 0;
    void (^blk)() = ^() {
        int b = a + 1;
    };
    blk();
}

转换后我们来看下源码相比之前有何变化:

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  int a;
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, int _a, int flags=0) : a(_a) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
  int a = __cself->a; // bound by copy

  int b = a + 1;
 }

可以发现,在block对应的结构体__main_block_impl_0中多了一个int型成员a,这个成员正是保存了截获的自动变量a的值,然后在__main_block_func_0函数中,通过指向block自身的指针访问成员a来获取自动变量a的值。这下便清楚了,block是将自动变量的值保存在block结构体实例中,使用时再通过指向block的指针访问,这也就解释了为什么block内无法修改外部自动变量值的问题。

__block修改自动变量值的原理

我们再来通过源码分析:

int main () {
    int __block a = 0;
    void (^blk)() = ^() {
        a = 2;
    };
    blk();
}

转换后发现源码骤然增多,我们摘出关键代码来分析:

struct __Block_byref_a_0 {
  void *__isa;
__Block_byref_a_0 *__forwarding;
 int __flags;
 int __size;
 int a;
};

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  __Block_byref_a_0 *a; // by ref
  __main_block_impl_0(void *fp, struct __main_block_desc_0 *desc, __Block_byref_a_0 *_a, int flags=0) : a(_a->__forwarding) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};
static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
  __Block_byref_a_0 *a = __cself->a; // bound by ref

        (a->__forwarding->a) = 2;
    }
static void __main_block_copy_0(struct __main_block_impl_0*dst, struct __main_block_impl_0*src) {_Block_object_assign((void*)&dst->a, (void*)src->a, 8/*BLOCK_FIELD_IS_BYREF*/);}

static void __main_block_dispose_0(struct __main_block_impl_0*src) {_Block_object_dispose((void*)src->a, 8/*BLOCK_FIELD_IS_BYREF*/);}

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};
int main () {
    __Block_byref_a_0 a = {(void*)0,(__Block_byref_a_0 *)&a, 0, sizeof(__Block_byref_a_0), 0};
    void (*blk)() = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0, &__main_block_desc_0_DATA, (__Block_byref_a_0 *)&a, 570425344));
    ((void (*)(__block_impl *))((__block_impl *)blk)->FuncPtr)((__block_impl *)blk);
}

我们来看引入__block后源码发生的变化。我们依旧从__main_block_func_0这个函数入手,可以发现原本的自动变量竟然转化成了一个结构体!再去看main函数,第一行就是使用自动变量a初始化了一个结构体__Block_byref_a_0,在这个结构体中,有一个成员变量a,它保存了外部自动变量a的值,__forwarding是指向结构体自身的指针。而在__main_block_impl_0结构体中则是有一个指向__Block_byref_a_0结构体的指针。这里就清楚了,__block变量转换成结构体时其地址也通过指针的方式被保存了下来,通过指针当然可以实现修改自动变量值的目的(当然前提是该自动变量没有被废弃,因为一旦作用域结束,栈上的变量就会被废弃,那么指针就成为野指针了。但事实上,block往往能跨其作用域而存在,并修改自动变量的值。这是为什么?我们后面详细来说)。

block修改静态变量值的原理

值得一提的是,在block内,除了可以修改__block变量的值外,还可以修改全局变量,静态全局变量,静态局部变量的值。我们重点看一下静态局部变量在源码中是怎么处理的:

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

        (*a) = 2;
    }

可以看出,源码里通过指向静态局部变量的指针进行变量访问和赋值。那为什么自动变量不采用这种方式来改值呢?道理很简单,静态局部变量存在于整个应用程序生命周期中,不用担心会出现野指针的问题,而自动变量在函数作用域结束时就被废弃了,指针也就无法访问了。

现在还剩下的问题

到目前为止,我们还有两个问题没有详细提及:

  • block为何可以超出其作用域而存在?
  • 加入__block修饰符后源码中新增的copy与dispose方法是什么作用?

block超出作用域存在的原因

观察block结构体实例的实现,可以发现在__block_impl结构体中有一ISA指针。我们知道每一个OC对象的底层实现是一个包含了isa成员变量的结构体,而block结构体正好符合了这一点。事实上,block也是一个OC的对象,其isa指向了block所属的class,从源码看,该class是_NSConcreteStackBlock,顾名思义,该class表示存在于栈上的block,事实上,block根据其存储域可分为三类:

  • _NSConcreteGlobalBlock,存在于静态数据区的block
  • _NSConcreteStackBlock,存在于栈上的block
  • _NSConcreteMallocBlock,存在于堆上的block

我们前面看到的block都是_NSConcreteStackBlock类型的,那么什么时候block会在数据区存储呢?当block不去截获自动变量,其结构体实例内容不必在运行时才能确定,此时就会以单例形式存在数据区。而_NSConcreteMallocBlock正是block可以超出其作用域而存在的原因。举个例子,block作为函数返回值返回时,栈上的block会因作用域结束而被释放,故此时运行时会将block从栈上拷贝到堆上一份,在这个过程中,堆上的block的ISA指针会被赋值_NSConcreteMallocBlock。这样,栈上的block废弃后,我们依旧可以正确使用block。堆上的block的持有与释放是服从引用计数的管理方式,和普通OC对象无异。

在ARC下,多数情况编译器可以自动判断在合适的时候对block进行拷贝,例如像上面举得例子。但是当block作为方法的参数传递时,编译器不会自动拷贝block,我们需要自己判断是否要对block对象调用copy方法,将栈上的block拷贝到堆上。例如以下情形拷贝就是必要的:

{
     void(^blk1)() = ^() {NSLog(@"block 1");};
     void(^blk2)() = ^() {NSLog(@"block 2");};
     NSArray *array = [NSArray arrayWithObjects:[blk1 copy], [blk2 copy], nil];
     return array;
}

不过如果在方法内已经对block进行了适当的复制,那么传递的时候我们就不必在拷贝了,比如cocoa框架中含有usingBlock的方法以及GCD中的方法。
如果对已经在堆上的block执行copy方法,按照引用计数的管理方式,block的引用计数会增加,而如果是静态数据区的block,那么调用copy方法不会做任何事情,所以必要的时候执行copy操作总是安全的。

__block变量的情况是类似的,当栈上的block执行copy操作时,其所持有的栈上的__block变量也会随之拷贝到堆上,并被堆上的block所持有。从源码可以知道,__block变量对应的结构体中有一指向自身结构体的指针__forwarding,当__block变量拷贝到堆上时,堆上的__block变量的__forwarding指针指向自己,而栈上的__block变量的__forwarding指向堆上的__block变量结构体,故无论什么情况下,程序都可以正确找到__block变量对应的结构体。因此当栈上的block和__block变量被废弃时,程序依旧可以使用堆上的block和__block变量,这就是block和__block变量可以超出作用域存在的原因。

copy与dispose方法的作用

OC中的对象类型和id类型也可以被block所截获,这种情况下,在block对应的结构体实例中会有一个OC对象类型的成员变量,换句话说,OC对象成为了结构体的成员变量,在内存管理篇我们说过,OC对象类型不能作为结构体的成员变量,因为编译器无法正确判断结构体的持有和废弃的生命周期,无法很好地管理OC对象的内存,但是运行时可以准确把握block从栈拷贝到堆以及在堆上被废弃的时机,因此这里OC对象也可以放入block的结构体中。当block从栈拷贝到堆时,会调用copy方法,当堆上的block不再被持有,要被废弃时,调用dispose方法。
如果OC对象被指定为__block变量,那么和之前类似,会对应生成一个__block变量的结构体,结构体中会保存OC对象,同时还会有该对象自己的copy和dispose方法,当block被拷贝到堆时,赋值给__block变量的OC对象会通过此copy方法被持有,而当堆上的block要被废弃时,__block变量持有的对象也应该释放,这时会通过此dispose方法进行释放,这和block本身的copy和dispose逻辑是一样的。

PS:关于block引起循环引用的问题,在内存管理篇已经提及,这里不再详述了。

更新@20170624

关于目前ARC下苹果对于block的处理方式,事实上,被__strong修饰的block在栈上生成后运行时会自动将其拷贝到堆上,并非前文所说只是在必要的时候才将block从栈拷贝到堆,即便是block仅在某个函数内使用,不超出函数作用域。而被__weak修饰的block则在栈上生成后依旧会保持在栈上。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,015评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,262评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,727评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,986评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,363评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,610评论 1 219
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,871评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,582评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,297评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,551评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,053评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,385评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,035评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,079评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,841评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,648评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,550评论 2 270

推荐阅读更多精彩内容