iOS 2021 面试前的准备(总结各知识点方便面试前快速复习使用)(八)

1,794 阅读51分钟

64. extension 和 cateogry 区别。

  1. extension 可以添加成员变量,category 不能添加成员变量。运行时加载类到内存以后,才会加载分类,这时类的内存布局已经确定(编译器还会对成员变量顺序做出优化,保证遵循内存对齐原则下类占用内存容量最少),如果再去添加成员变量就会破坏类的内存布局。各个成员变量的访问地址是在编译时确定的,每个成员变量的地址偏移都是固定的(相对于类的起始地址的内存偏移(硬编码))。
  2. extension 在编译期决议(就确定了是类的一部分),category 在运行期决议。extension 在编译期和头文件里的 @interface 以及实现文件里的 @implementation 一起形成一个完整的类,extension 伴随类的产生而产生,亦随之一起消亡。 category 中的方法是在运行时决议的,没有实现也可以运行,而 extension 中的方法是在编译期检查的,没有实现会报错。
  3. extension 一般用来隐藏类的私有信息,无法直接为系统的类扩展,但可以先创建系统类的子类再添加 extension。
  4. category 可以给系统提供的类添加分类。
  5. extension 和 category 都可以添加属性,但是 category 中的属性不能生成对应的成员变量以及 getter 和 setter 方法的实现。
  6. extension 不能像 category 那样拥有独立的实现部分(@implementation 部分),extension 所声明的方法必须依托对应类的实现部分来实现。

65. Category 的一些作用与特点。

 category 是 Objective-C 2.0 之后添加的语言特性,它可以在不改变或不继承原类的情况下,动态地给类添加方法。除此之外还有一些其他的应用场景:

  1. 可以把类的的实现分开在几个不同的文件里面。这样做有几个显而易见的好处:
  • 可以减少单个文件的体积。
  • 可以把不同的功能组织到不同的 category 里面。
  • 可以由多个开发者共同完成一个类。
  • 可以按需加载想要的 category。
  • 声明私有方法。
  1. 另外还衍生出 category 其他几个场景:
  • 模拟多继承(另外可以模拟多继承的还有 protocol)。
  • 把 framework 的私有方法公开。

 category 的一些特点:

  1. category 只能给某个已有的类扩充方法,不能扩充成员变量。
  2. category 中也可以添加属性,只不过 @property 只会生成 setter 和 getter 的声明,不会生成 setter 和 getter 的实现以及成员变量。
  3. 如果 category 中的方法和类中的原用方法同名,运行时会优先调用 category 中的方法,也就是,category 中的方法会覆盖掉类中原有的方法,所以开发中尽量保证不要让分类中的方法和原有类中的方法名相同,避免出现这种情况的解决方案是给类的方法名统一添加前缀,比如 category_。
  4. 如果多个 category 中存在同名的方法,运行时到底调用哪个方法由编译顺序决定,最后一个参与编译的方法会被调用。我们可以在 Compile Sources 中拖动不同分类的顺序来测试。
  5. 调用优先级,category > 本类 > 父类。即优先调用 category 中的方法,然后调用本类方法,最后调用父类方法。注意:category 是在运行时添加的,不是在编译时。

注意:

  • category 的方法没有 “完全替换掉” 原来类已经有的方法,也就是说如果 category 和原来类都有 methodA,那么 category 附加完成之后,类的方法列表里会有两个 methodA。
  • category 的方法被放到了新方法列表的前面,而原来类的方法被放到了新方法列表的后面,这也就是我们平常所说的 category 的方法会 “覆盖” 掉原来类的同名方法,这是因为运行时在查找方法的时候是顺着方法列表的顺序查找的,它只要一找到对应名字的方法,就会罢休,殊不知后面可能还有一样名字的方法。

66. Category 中能添加属性吗?

 category 不能添加成员变量(instance variables),那到底能不能添加属性(@property)呢?下面从 category 的结构体开始分析,category_t 定义:

// classref_t is unremapped class_t*
typedef struct classref * classref_t;

struct category_t {
    const char *name; // 分类的名字
    classref_t cls; // 所属的类 
    struct method_list_t *instanceMethods; // 实例方法列表
    struct method_list_t *classMethods; // 类方法列表
    struct protocol_list_t *protocols; // 协议列表
    struct property_list_t *instanceProperties; // 实例属性列表
    
    // Fields below this point are not always present on disk.
    struct property_list_t *_classProperties; // 类属性列表
    
    // 返回 类/元类 方法列表
    method_list_t *methodsForMeta(bool isMeta) {
        if (isMeta) return classMethods;
        else return instanceMethods;
    }

    property_list_t *propertiesForMeta(bool isMeta, struct header_info *hi);
    
    // 协议列表,元类没有协议列表
    protocol_list_t *protocolsForMeta(bool isMeta) {
        // 这里如果是元类的话会返回 nullptr,但是在 load_categories_nolock 函数中有貌似把 protocols 添加到元类的迹象,
        // 但是在 attachCategories 函数中 protocolsForMeta 函数返回 nullptr,应该是没有实际添加。
        if (isMeta) return nullptr;
        else return protocols;
    }
};

/*
* category_t::propertiesForMeta
* 返回 category 的 实例 或 类 的属性。
* hi 是包含 category 的镜像(images)。
*/
property_list_t *
category_t::propertiesForMeta(bool isMeta, struct header_info *hi)
{
    if (!isMeta) return instanceProperties; // 返回实例属性
    else if (hi->info()->hasCategoryClassProperties()) return _classProperties; // 返回类属性
    else return nil; // 其他情况返回 nil
}

 从 category 定义中可以看出 category 可以添加实例方法、类方法也可以实现协议、添加属性,同时也看到不能添加成员变量。那为什么说不能添加属性呢?实际上,category 允许添加属性,可以使用 @property 添加,但是能添加 @property 不代表可以添加 “完整版的” 属性,通常我们说的添加属性是指编译器为我们生成了对应的成员变量和对应的 setter 和 getter 方法来存取属性。在 category 中虽说可以书写 @property,但是不会生成 _成员变量,也不会生成所添加属性的 getter 和 setter 方法的实现,所以尽管添加了属性,也无法使用点语法调用 setter 和 getter 方法。(实际上,点语法可以写,只不过在运行时调用到这个方法时会报找不到方法的错误直接 crash: unrecognized selector sent to instance ....)。我们此时可以通过 Associated object 来为属性手动实现 setter 和 getter 存取方法。


67. attachLists Category 数据追加到原类中去。

 这时我们大概会有一个疑问,这些定义好的 _category_t 数据什么时候附加到类上去呢?或者是存放在内存哪里等着我们去调用它里面的实例函数或类函数呢?已知分类数据是会全部追加到类本身上去的。 不是类似 weak 机制或者 Associated object 机制等,再另外准备哈希表存放数据,然后根据对象地址去读取处理数据等这样的模式。

 下面我们就开始研究分类的数据是如何追加到本类上去的。

 category 的加载涉及到 runtime 的初始化及加载流程且内容实在过于多,这里只是粗略的了解下。此处只研究 runtime 初始化加载过程中涉及的 category 的加载。Objective-C 的运行是依赖 runtime 来做的,而 runtime 和其他系统库一样,是由 macOS 和 iOS 通过 dyld(the dynamic link editor)来动态加载的。

 map_images_nolock 主要做了 4 件事:

  1. 拿到 dlyd 传过来的 mach_header,封装为 header_info。
  2. 初始化 selector。
  3. arr_init() 内部: 初始化 AutoreleasePoolPage、初始化 SideTablesMap、初始化 AssociationsManager 所使用的数据结构。
void arr_init(void) {
    AutoreleasePoolPage::init(); // 自动释放池初始化
    SideTablesMap.init(); // SideTablesMap 初始化
    _objc_associations_init(); // AssociationsManager::init(); 初始化
}
  1. 读取 images。

 category 中的协议会同时添加到类和元类。objc::unattachedCategories.addForClass(lc, cls) 可理解为操作 key 是 cls、value 是 category_list 的哈希表,当前 cls 还没有实现,那这些 category 的内容什么时候附加到类上的。在上一节我们看 UnattachedCategories 数据结构时,看到 attachToClass 函数就是做这个事情的,把事先保存的 category 数据附加到 cls 上。全局搜索,我们可以发现 attachToClass 只会在 methodizeClass 里面调用,然后全局搜索 methodizeClass 函数,发现只有在 realizeClassWithoutSwift 中调用它。

void attachLists(List* const * addedLists, uint32_t addedCount) {
    if (addedCount == 0) return;

    if (hasArray()) {
        // many lists -> many lists
        
        // 记录之前的长度
        uint32_t oldCount = array()->count;
        uint32_t newCount = oldCount + addedCount;
        
        // realloc 原型: extern void *realloc(void *mem_address, unsigned int newsize);
        // 指针名 =(数据类型*)realloc(要改变内存大小的指针名,新的大小)
        // 返回值: 如果重新分配成功则返回指向被分配内存的指针,否则返回空指针 NULL
        
        // 先判断当前的指针是否有足够的连续空间,如果有,扩大 mem_address 指向的地址,
        // 并且将 mem_address 返回,如果空间不够,先按照 newsize 指定的大小分配空间,
        // 将原有数据从头到尾拷贝到新分配的内存区域,
        // 而后释放原来 mem_address 所指内存区域(注意:原来指针是自动释放,不需要使用 free ),
        // 同时返回新分配的内存区域的首地址,即重新分配存储器块的地址。
        
        // 新的大小 可大可小(如果新的大小大于原内存大小,则新分配部分不会被初始化)
        // 如果新的大小 小于原内存大小,可能会导致数据丢失
        // 注意事项: 
        // 重分配成功旧内存会被自动释放,旧指针变成了野指针,当内存不再使用时,应使用free()函数将内存块释放。
        
        // 扩展空间
        setArray((array_t *)realloc(array(), array_t::byteSize(newCount)));
        // 更新 array 长度 
        array()->count = newCount;
        
        // 原型:void *memmove(void* dest, const void* src, size_t count);
        // 由 src 所指内存区域复制 count 个字节到 dest 所指内存区域。
        // memmove 用于拷贝字节,如果目标区域和源区域有重叠的话,
        // memmove 能够保证源串在被覆盖之前将重叠区域的字节拷贝到目标区域中,
        // 但复制后源内容会被更改。但是当目标区域与源区域没有重叠则和 memcpy 函数功能相同。
        
        // 把方法列表向后移动,给 addedLists 留出空间 addedCount 长的空间
        memmove(array()->lists + addedCount, array()->lists, 
                oldCount * sizeof(array()->lists[0]));
        
        // 原型:void *memcpy(void *destin, void *source, unsigned n);
        // 从源 source 所指的内存地址的起始位置开始拷贝 n 个字节到目标 destin 所指的内存地址的起始位置中
        
        // 把 addedLists 复制到 array()->lists 起始的内存空间
        memcpy(array()->lists, addedLists, 
               addedCount * sizeof(array()->lists[0]));
    }
    else if (!list  &&  addedCount == 1) {
        // 0 lists -> 1 list
        // 如果目前为空,赋值操作(这里是赋值操作,这里是赋值操作)
        list = addedLists[0];
    } 
    else {
        // 1 list -> many lists
        List* oldList = list;
        uint32_t oldCount = oldList ? 1 : 0;
        uint32_t newCount = oldCount + addedCount;
        
        // 扩容
        setArray((array_t *)malloc(array_t::byteSize(newCount)));
        // 更新 count 
        array()->count = newCount;
        // 把 oldList 放在 lists 末尾
        if (oldList) array()->lists[addedCount] = oldList;
        // 把 addedLists 复制到 array()->lists 起始的内存空间
        memcpy(array()->lists, addedLists, 
               addedCount * sizeof(array()->lists[0]));
    }
}

68. +load 函数分析。

  • 实现 load 的分类和类是非懒加载分类和非懒加载类,未实现 +load 函数的分类和类,是懒加载分类和懒加载类。懒加载类只有我们第一次用到它们的时候,才会执行实现。
  • load 函数执行是直接由其函数地址直接调用的,不走 objc_msgSend 的函数查找流程的,所以类和分类中的 load 函数是完全不存在 “覆盖” 行为的。它们都会执行,执行的流程的话:首先是 类一定早于分类的,然后父类一定早于子类,分类之间则是谁先编译则谁先执行。(这里刚好和不同分类中的同名函数,后编译的分类中的函数会 “覆盖” 先编译的分类相反。)
  • 正常情况我们都不应该手动调用 load 函数,我们只要要交给系统自己等待调用即可,且全局只会调用一次。

 void load_images(const char *path __unused, const struct mach_header *mh) 处理由 dyld 映射的给定的镜像中的类和分类中的 +load 函数。extern bool hasLoadMethods(const headerType *mhdr) 判断 mach_header 中是否包含非懒加载的类和分类(即实现了 load 函数的类和分类)。extern void prepare_load_methods(const headerType *mhdr) 调用 load 函数前的准备,取出 mhdr 中的 count 个非懒加载类,循环把不同类的 load 函数添加进全局的 load 方法的数组(loadable_classes)中,且首先递归添加父类的 +load 函数,然后取出分类中的 load 函数添加进全局的 load 方法的数组(loadable_classes)中,从这里已经可以看出 load 函数调用类早于分类,父类早于子类。

 具体详细的执行流程例如 load 函数怎么读取的,怎么执行的等等细节可参考:iOS Category 底层实现原理(三):附加+load函数超详细解析


69. 储存弱引用变量所使用到的数据结构一览。

 template class DisguisedPtr 是在 Project Headers/objc-private.h 中定义的一个模版工具类,主要的功能是把 T 指针(T 类型变量的地址)转化为一个 unsigned long,实现指针到整数的相互映射,起到指针伪装的作用,使指针隐藏于系统工具(如 leaks 工具)。在 objc4-781 全局搜索 DisguisedPtr 发现抽象类型 T 仅作为 objc_object 和 objc_object * 使用。而抽象类型 T 是 objc_object * 时,用于隐藏 __weak 变量的地址。DisguisedPtr 类似于指针类型 T *,只是存储的值被伪装成对诸如 leaks 之类的工具隐藏。nil 本身是伪装的,因此 0 值的内存可以按预期工作,让 nil 指针像 non-nil 指针那样正常运行它的操作,而不会让程序崩溃。

 template class StripedMap 从数据结构角度看的话,它是作为一个 Key 是 void *,Value 是 T 的 hash 表来用的。在 objc4-781 中全局搜索 StripedMap 发现 T 作为 SideTable 和 spinlock_t 类型使用。

 SideTables 类型是:StripedMap。SideTables 的使用:SideTable *table = &SideTables()[obj] 它的作用正是根据 objc_object 的指针计算出哈希值,然后从 SideTables 这张全局哈希表中找到 obj 所对应的 SideTable。

 typedef DisguisedPtr<objc_object *> weak_referrer_t; 这里 T 是 objc_object *,那么 DisguisedPtr 里的 T* 就是 objc_object**,即为指针的指针。用于伪装 __weak 变量的地址,即用于伪装 objc_object * 的地址。

 out_of_line_ness 字段与 inline_referrers [1] 的低两位内存空间重叠。inline_referrers [1] 是指针对齐地址的 DisguisedPtr。指针对齐的 DisguisedPtr 的低两位始终为 0b00(8 字节对齐取得的地址的二进制表示的后两位始终是 0)(伪装为 nil 或 0x80..00)或 0b11(任何其他地址)。因此,out_of_line_ness == 0b10 可用于标记 out-of-line 状态,即 struct weak_entry_t 内部是使用哈希表存储 weak_referrer_t 而不再使用那个长度为 4 的 weak_referrer_t 数组。

 weak_entry_t 的功能是保存所有指向某个对象的弱引用变量的地址。weak_entry_t 的哈希数组内存储的数据是 typedef DisguisedPtr<objc_object *> weak_referrer_t,实质上是弱引用变量的地址,即 objc_object **new_referrer,通过操作指针的指针,就可以使得弱引用变量在对象析构后指向 nil。这里必须保存弱引用变量的地址,才能把它的指向置为 nil。

 在 weak_entry_t 中当对象的弱引用数量不超过 4 的时候是使用 weak_referrer_t inline_referrers[WEAK_INLINE_COUNT] 这个固定长度为 4 的数组存放 weak_referrer_t。当长度大于 4 以后使用 weak_referrer_t *referrers 这个哈希数组存放 weak_referrer_t 数据。

 weak_table_t 的哈希数组初始长度是 64,当存储占比超过 3/4 后,哈希数组会扩容为总容量的 2 倍,然后会把之前的数据重新哈希化放在新空间内。当一些数据从哈希数组中移除后,为了提高查找效率势必要对哈希数组总长度做缩小操作,规则是当哈希数组总容量超过 1024 且已使用部分少于总容量 1/16 时,缩小为总容量的 1/8,缩小后同样会把原始数据重新哈希化放在新空间。(缩小和扩展都是使用 calloc 函数开辟新空间,cache_t 扩容后是直接忽略旧数据,这里可以比较记忆。)。牢记以上只是针对 weak_table_t 的哈希数组而言的。

 weak_entry_t 则是首先用固定长度为 4 的数组,当有新的弱引用进来时,会首先判断当前是使用的定长数组还是哈希数组,如果此时使用的还是定长数组的话先判断定长数组还有没有空位,如果没有空位的话会为哈希数组申请长度为 4 的并用一个循环把定长数组中的数据放在哈希数组,这里看似是按下标循环存放,其实下面会重新进行哈希化,然后是判断对哈希数组进行扩容,也是如果超过总占比的 3/4 进行扩容为总容量的 2 倍,所以 weak_entry_t 的哈希数组第一次扩容后是 8。然后下面区别就来了 weak_entry_t 的哈希数组是没有缩小机制的,移除弱引用的操作其实只是把弱引用的指向置为 nil,做移除操作是判断如果定长数组为空或者哈希数组为空,则会把 weak_table_t 哈希数组中的 weak_entry_t 移除,然后就是对 weak_table_t 做一些缩小容量的操作。

 这里 weak_entry_t 之所以不缩小,且起始用定长数组,都是对其的优化,因为本来一个对象的弱引用数量就不会太多。

struct weak_entry_t {
    // referent 中存放的是化身为整数的 objc_object 实例的地址,下面保存的一众弱引用变量都指向这个 objc_object 实例
    DisguisedPtr<objc_object> referent;
    
    // 当指向 referent 的弱引用个数小于等于 4 时使用 inline_referrers 数组保存这些弱引用变量的地址,
    // 大于 4 以后用 referrers 这个哈希数组保存。
    
    // 共用 32 个字节内存空间的联合体
    union {
        struct {
            weak_referrer_t *referrers; // 保存 weak_referrer_t 的哈希数组
            
            // out_of_line_ness 和 num_refs 构成位域存储,共占 64 位
            uintptr_t        out_of_line_ness : 2; // 标记使用哈希数组还是 inline_referrers 保存 weak_referrer_t
            uintptr_t        num_refs : PTR_MINUS_2; // 当前 referrers 内保存的 weak_referrer_t 的数量
            uintptr_t        mask; // referrers 哈希数组总长度减 1,会参与哈希函数计算
            
            // 可能会发生 hash 冲突的最大次数,用于判断是否出现了逻辑错误,(hash 表中的冲突次数绝对不会超过该值)
            // 该值在新建 weak_entry_t 和插入新的 weak_referrer_t 时会被更新,它一直记录的都是最大偏移值
            uintptr_t        max_hash_displacement;
        };
        struct {
            // out_of_line_ness 和 inline_referrers[1] 的低两位的内存空间重合
            // 长度为 4 的 weak_referrer_t(Dsiguised<objc_object *>)数组
            weak_referrer_t  inline_referrers[WEAK_INLINE_COUNT];
        };
    };
    
    // 返回 true 表示使用 referrers 哈希数组 false 表示使用 inline_referrers 数组保存 weak_referrer_t
    bool out_of_line() {
        return (out_of_line_ness == REFERRERS_OUT_OF_LINE);
    }
    
    // weak_entry_t 的赋值操作,直接使用 memcpy 函数拷贝 other 内存里面的内容到 this 中,
    // 而不是用复制构造函数什么的形式实现,应该也是为了提高效率考虑的...
    weak_entry_t& operator=(const weak_entry_t& other) {
        memcpy(this, &other, sizeof(other));
        return *this;
    }

    // weak_entry_t 的构造函数
    
    // newReferent 是原始对象的指针,
    // newReferrer 则是指向 newReferent 的弱引用变量的指针。
    
    // 初始化列表 referent(newReferent) 会调用: DisguisedPtr(T* ptr) : value(disguise(ptr)) { } 构造函数,
    // 调用 disguise 函数把 newReferent 转化为一个整数赋值给 value。
    weak_entry_t(objc_object *newReferent, objc_object **newReferrer)
        : referent(newReferent)
    {
        // 把 newReferrer 放在数组 0 位,也会调用 DisguisedPtr 构造函数,把 newReferrer 转化为整数保存
        inline_referrers[0] = newReferrer;
        // 循环把 inline_referrers 数组的剩余 3 位都置为 nil
        for (int i = 1; i < WEAK_INLINE_COUNT; i++) {
            inline_referrers[i] = nil;
        }
    }
};

 weak_entry_t 内部之所以使用 定长数组/哈希数组 的切换,应该是考虑到实例对象的弱引用变量个数一般比较少,这时候使用定长数组不需要再动态的申请内存空间(union 中两个结构体共用 32 个字节内存)而是使用 weak_entry_t 初始化时一次分配的一块连续的内存空间,这会得到运行效率上的提升。

 weak_table_t 是全局的保存弱引用的哈希表。以 object ids 为 keys,以 weak_entry_t 为 values。

 struct SideTable 定义位于 NSObject.mm 文件中。它管理了两块对我们而言超级重要的内容,一块是 RefcountMap refcnts 存储对象的引用计数,一块是 weak_table_t weak_table 存储对象的弱引用变量。

 struct SideTable 结构很清晰,3 个成员变量:

  1. spinlock_t slock; 自旋锁,保证操作 SideTable 时的线程安全。看前面的两大块 weak_table_t 和 weak_entry_t 的时候,看到它们所有的操作函数都没有提及加解锁的事情,如果你仔细观察的话会发现它们的函数名后面都有一个 no_lock 的小尾巴,正是用来提醒我们,它们的操作不是线程安全的。其实它们是把保证它们线程安全的任务交给了 SideTable,可以看到 SideTable 提供的函数都是线程安全的,而这都是由 slock 来完成的。
  2. RefcountMap refcnts: 以 DisguisedPtr<objc_object> 为 key,以 size_t 为 value 的哈希表,用来存储对象的引用计数(仅在未使用 isa 优化或者 isa 优化情况下 isa_t 中保存的引用计数溢出时才会用到,这里涉及到 isa_t 里的 uintptr_t has_sidetable_rc 和 uintptr_t extra_rc 两个字段。作为哈希表,它使用的是平方探测法从哈希表中取值,而 weak_table_t 则是线性探测(开放寻址法)。
  3. weak_table_t weak_table 则是存储对象弱引用的哈希表,是 weak 功能实现的核心数据结构。

 spinlock_t 原本是一个 uint32_t 类型的非公平的自旋锁,(由于其安全问题,目前底层实现已由互斥锁 os_unfair_lock 所替代)。所谓非公平是指,获得锁的顺序和申请锁的顺序无关,也就是说,第一个申请锁的线程有可能会是最后一个获得该锁,或者是刚获得锁的线程会再次立刻获得该锁,造成其它线程忙等(busy-wait)。

 os_unfair_lock 在其成员变量 _os_unfair_lock_opaque 中记录了当前获取它的线程信息,只有获得该锁的线程才能够解开这把锁。

 SideTables 是一个类型是 StripedMap 的静态全局哈希表。通过上面 StripedMap 的学习,已知在 iPhone 下它是固定长度为 8 的哈希数组,在 mac 下是固定长度为 64 的哈希数组,自带一个简单的哈希函数,根据 void * 入参计算哈希值,然后根据哈希值取得哈希数组中对应的 T。SideTables 中则是取得的 T 是 SideTable。

 SideTables() 下面定义了多个与锁相关的全局函数,内部实现是调用 StripedMap 的模版抽象类型 T 所支持的函数接口,对应 SideTables 的 T 类型是 SideTable,而 SideTable 执行对应的函数时正是调用了它的 spinlock_t slock 成员变量的函数。这里采用了分离锁的机制,即一张 SideTable 一把锁,减轻并行处理多个对象时的阻塞压力。


70. 弱引用变量使用到的函数一览。

static weak_entry_t * weak_entry_for_referent(weak_table_t *weak_table, objc_object *referent); 根据给定的 referent(我们的对象变量)和 weak_table_t 哈希表,查找对应的 weak_entry_t(存放所有指向 referent 的弱引用变量的地址的哈希表) 并返回,如果未找到则返回 NULL。

id weak_register_no_lock(weak_table_t *weak_table, id referent, id *referrer, bool crashIfDeallocating); 添加一对(object, weak pointer)到弱引用表里。(即当一个对象存在第一个指向它的 weak 变量时,此时会把对象新注册进 weak_table_t 的哈希表中,同时也会把这第一个 weak 变量的地址保存进对象的 weak_entry_t 哈希表中,如果这个 weak 变量不是第一个的话,表明这个对象此时已经存在于 weak_table_t 哈希表中了,此时只需要把这个指向它的 weak 变量的地址保存进该对象的 weak_entry_t 哈希表中。)

void weak_unregister_no_lock(weak_table_t *weak_table, id referent, id *referrer); 从弱引用表里移除一对(object, weak pointer)。(从对象的 weak_entry_t 哈希表中移除一个 weak 变量的地址。)

void weak_clear_no_lock(weak_table_t *weak_table, id referent); 当对象销毁的时候该函数被调用。设置所有剩余的 __weak 变量指向 nil,此处正对应了我们日常挂在嘴上的:__weak 变量在它指向的对象被销毁后它便会被置为 nil 的机制。

 调整 weak_table_t 哈希数组长度,以 weak_table_t 指针为参数,调用 weak_grow_maybe 和 weak_compact_maybe 这两个函数,用来当 weak_table_t 哈希数组过满或者过空的情况下及时调整其长度,优化内存的使用效率,并提高哈希查找效率。这两个函数都通过调用 weak_resize 函数来调整 weak_table_t 哈希数组的长度。

static void weak_grow_maybe(weak_table_t *weak_table); 该函数用于扩充 weak_table_t 的 weak_entry_t *weak_entries 的长度,扩充条件是 num_entries 超过了 mask + 1 的 3/4。看到 weak_entries 的初始化长度是 64,每次扩充的长度则是 mask + 1 的 2 倍,扩容完毕后会把原哈希数组中的 weak_entry_t 重新哈希化插入到新空间内,并更新 weak_tabl_t 各成员变量。占据的内存空间的总容量则是 (mask + 1) * sizeof(weak_entry_t) 字节。综上 mask + 1 总是 2 的 N 次方。(初始时 N 是 6:2^6 = 64,以后则是 N >= 6)

static void weak_compact_maybe(weak_table_t *weak_table); 该函数会在 weak_entry_remove 函数中调用,旨在 weak_entry_t 从 weak_table_t 的哈希数组中移除后,如果哈希数组中占用比较低的话,缩小 weak_entry_t *weak_entries 的长度,优化内存使用,提高哈希查找效率。缩小 weak_entry_t *weak_entries 长度的条件是当目前的总长度 超过了 1024 并且容量 占用比小于 1/16,weak_entries 空间缩小到当前空间的 1/8

static void weak_resize(weak_table_t *weak_table, size_t new_size); 扩大和缩小空间都会调用这个 weak_resize 公共函数。入参是 weak_table_t 指针和一个指定的长度值。 weak_entry_insert 函数可知 weak_resize 函数的整体作用,该函数对哈希数组长度进行的扩大或缩小,首先根据 new_size 申请相应大小的内存,new_entries 指针指向这块新申请的内存。设置 weak_table 的 mask 为 new_size - 1。mask 的作用是记录 weak_table 总容量的内存边界,此外 mask 还用在哈希函数中保证 index 不会哈希数组越界。weak_table_t 的哈希数组可能会发生哈希碰撞,而 weak_table_t 使用了 开放寻址法 来处理碰撞。如果发生碰撞的话,将寻找相邻(如果已经到最尾端的话,则从头开始)的下一个空位。max_hash_displacement 记录当前 weak_table 发生过的最大的偏移值。此值会在其他地方用到,例如:weak_entry_for_referent 函数,寻找给定的 referent 在弱引用表中的 entry 时,如果在哈希查找过程中 hash_displacement 的值超过了 weak_table->max_hash_displacement 则表示,不存在要找的 weak_entry_t。

 上面主要浏览了 weak 相关的的数据结构,以及从全局的 SideTable->weak_table 中查找保存对象的所有弱引用的地址的 weak_entry_t,以及 weak_table_t->weak_entries 哈希数组的长度调整机制。

static void append_referrer(weak_entry_t *entry, objc_object **new_referrer); 添加给定的 referrer 到 weak_entry_t 的哈希数组(或定长为 4 的内部数组)。

static void remove_referrer(weak_entry_t *entry, objc_object **old_referrer); 从 weak_entry_t 的哈希数组(或定长为 4 的内部数组)中删除弱引用的地址。

static void weak_entry_insert(weak_table_t *weak_table, weak_entry_t *new_entry); 添加一个新的 weak_entry_t 到给定的 weak_table_t 的哈希数组中。

static void weak_entry_remove(weak_table_t *weak_table, weak_entry_t *entry); 从 weak_table_t 的哈希数组中删除指定的 weak_entry_t。

static weak_entry_t * weak_entry_for_referent(weak_table_t *weak_table, objc_object *referent); 从 weak_table_t 的哈希数组中找到 referent 的 weak_entry_t,如果未找到则返回 NULL。

void weak_clear_no_lock(weak_table_t *weak_table, id referent_id); 当对象的 dealloc 函数执行时会调用此函数,主要功能是当对象被释放废弃时,把该对象的弱引用指针全部指向 nil,当对象执行 dealloc 时会调用该函数,首先根据入参 referent_id 找到其在 weak_table 中对应的 weak_entry_t,然后遍历 weak_entry_t 的哈希数组或者 inline_referrers 定长数组通过里面存储的 weak 变量的地址,把 weak 变量指向置为 nil,最后把 weak_entry_t 从 weak_table 中移除。


71. weak 变量初始化、赋值及被置为 nil 的过程。

 与 weak 变量相关函数是: objc_initWeak、objc_storeWeak、objc_destroyWeak,它们分别表示初始化 weak 变量、weak 变量赋值(修改指向)、销毁 weak 变量。

#import <Foundation/Foundation.h>
int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // insert code here...
        id obj = [NSObject new];
        id obj2 = [NSObject new];
        printf("Start tag\n");
        {
            __weak id weakPtr = obj; // 调用 objc_initWeak 进行 weak 变量初始化
            weakPtr = obj2; // 调用 objc_storeWeak 修改 weak 变量指向
        } 
        // 出了这个右边花括号调用 objc_destroyWeak 函数进行 weak 变量销毁
        //(注意这里是 weak 变量的销毁,并不是 weak 变量指向的对象销毁)
        
        printf("End tag\n"); // ⬅️ 断点打在这里
    }
    return 0;
}

id objc_initWeak(id *location, id newObj); 函数接收两个参数:

  1. id *location 即 weak 变量的地址,即示例代码中 weakPtr 变量取地址: &weakPtr,它是一个指针的指针,之所以要存储指针的地址,是因为 weakPtr 变量指向的对象释放后,要把 weakPtr 变量指向置为 nil,如果仅存储指针(即 weakPtr 变量所指向的地址值)的话,是不能够完成这个设置的。

这里联想到了对链表做一些操作时,函数入参会是链表头指针的指针。 这里如果对指针不是特别熟悉的话,可能会有一些迷糊,为什么用指针的指针,我们直接在函数内修改参数的指向时,不是同样也修改了外部指针的指向吗?其实非然! 一定要理清,当函数形参是指针时,实参传入的是一个地址,然后在函数内部创建一个临时指针变量,这个临时指针变量指向的地址是实参传入的地址,此时如果你修改指向的话,修改的只是函数内部的临时指针变量的指向。外部的指针变量是与它无关的,有关的只是初始时它们两个指向的地址是一样的。而我们对这个地址里面内容的所有操作,都是可反应到指向该地址的指针变量那里的。这个地址是指针指向的地址,如果没有 const 限制,我们可以对该地址里面的内容做任何操作即使把内容置空放 0,这些操作都是对这个地址的内存做的,不管怎样这块内存都是存在的,它地址一直都在这里,而我们的原始指针一直就是指向它,此时我们需要的是修改原始指针的指向,那我们只有知道指针自身的地址才行,我们把指针自身的地址的内存空间里面放 0x0, 才能表示把我们的指针指向置为了 nil!

  1. id newObj: 给 weakPtr 赋值的对象,即示例代码中的 obj。 该方法有一个返回值,返回的是 storeWeak 函数的返回值: 返回的其实还是 obj, 但是已经对 obj 的 isa(isa_t) 的 weakly_referenced 位设置为 1,标识该对象有弱引用存在,当该对象销毁时,要处理指向它的那些弱引用,weak 变量被置为 nil 的机制就是从这里实现的。

 看 objc_initWeak 函数实现可知,它内部是调用 storeWeak 函数,且执行时的模版参数是 DontHaveOld(没有旧值),这里是指 weakPtr 之前没有指向任何对象,我们的 weakPtr 是刚刚初始化的,自然没有指向旧值。这里涉及到的是,当 weak 变量改变指向时,要把该 weak 变量地址从它之前指向的对象的 weak_entry_t 的哈希表中移除。DoHaveNew 表示有新值。

 storeWeak 函数实现的核心功能:

  • 将 weak 变量的地址 location 存入 obj 对应的 weak_entry_t 的哈希数组(或定长为 4 的数组)中,用于在 obj 析构时,通过该哈希数组找到其所有的 weak 变量的地址,将 weak 变量指向的地址(*location)置为 nil。
  • 如果启用了 isa 优化,则将 obj 的 isa_t 的 weakly_referenced 位置为 1,置为 1 的作用是标识 obj 存在 weak 引用。当对象 dealloc 时,runtime 会根据 weakly_referenced 标志位来判断是否需要查找 obj 对应的 weak_entry_t,并将它的所有的弱引用置为 nil。

__weak id weakPtr = obj 一句完整的白话理解就是:拿着 weakPtr 的地址和 obj,调用 objc_initWeak 函数,把 weakPtr 的地址添加到 objc 所在的 SideTable 的哈希表中的 weak_entry_t 的哈希数组中,并把 obj 的地址赋给 *location*location = (id)newObj),然后把 obj 的 isa 的 weakly_referenced 字段置为 1,最后返回 obj。

 设置一个对象是否有弱引用分为两种情况:

  1. 当对象的 isa 是优化的 isa 时,更新 newObj 的 isa 的 weakly_referenced bit 标识位。
  2. 另外如果对象的 isa 是原始的 class 指针时,它的引用计数和弱引用标识位等信息都是在 refcount 中的引用计数值内。(不同的位表示不同的信息)需要从 refcount 中找到对象的引用计数值(类型是 size_t),该引用计数值的第一位即标识该对象是否有弱引用(SIDE_TABLE_WEAKLY_REFERENCED)。

 storeWeak 更新一个 weak 变量。如果 HaveOld 为 true,则该 weak 变量具有需要清除的现有值。该值可能为 nil。如果 HaveNew 为 true,则需要将一个新值分配给 weak 变量。该值可能为 nil。如果 CrashIfDeallocating 为 true,如果 newObj 的 isa 已经被标记为 deallocating 或 newObj 所属的类不支持弱引用,程序将 crash。如果 CrashIfDeallocating 为 false,则发生以上问题时只是在 weak 变量中存入 nil。

 到这里我们就已经很清晰了 objc_initWeak 用于 weak 变量的初始化,内部只需要 weak_register_no_lock 相关的调用,然后当对 weak 变量赋新值时,则是先处理它对旧值的指向(weak_unregister_no_lock),然后处理它的新指向。(weak_register_no_lock)

 objc_storeWeak:示例代码中当我们对 weak 变量赋一个新值时,调用了 objc_storeWeak,内部也是直接对 storeWeak 的调用,DoHaveOld 和 DoHaveNew 都为 true,表示这次我们要先处理 weak 变量当前的指向(weak_unregister_no_lock),然后 weak 变量指向新的对象(weak_register_no_lock)。

 objc_destroyWeak:示例代码中作为局部变量的 weak 变量出了右边花括号它的作用域就结束了,必然会进行释放销毁,汇编代码中我们看到了 objc_destroyWeak 函数被调用,看名字它应该是 weak 变量销毁时所调用的函数。如果 weak 变量比它所指向的对象更早销毁,那么它所指向的对象的 weak_entry_t 的哈希数组中存放该 weak 变量的地址要怎么处理呢?那么一探 objc_destroyWeak 函数的究竟应该能找到答案。销毁 weak pointer 和其所指向的对象的弱引用表中的关系。(对象的 weak_entry_t 的哈希数组中保存着该对象的所有弱引用的地址,这里意思是把指定的弱引用的地址从 weak_entry_t 的哈希数组中移除。)如果 weak pointer 未指向任何内容,则无需编辑 weak_entry_t 的哈希数组。对于弱引用的并发修改,此函数不是线程安全的。 (并发进行 weak clear 是线程安全的)可看到 objc_destroyWeak 函数内部是直接对 storeWeak 函数的调用,且模版参数直接表明 DoHaveOld 有旧值、 DontHaveNew 没有新值、DontCrashIfDeallocating 不需要 crash,newObj 为 nil,参数只有 location 要销毁的弱引用的地址,回忆我们分析的 storeWeak 函数:当 haveOld 为真时:weak_unregister_no_lock(&oldTable->weak_table, oldObj, location);。到这里也很清晰了,和上面 weak 变量的初始化和赋值操作对比,这里是做销毁操作,只需处理旧值,调用 weak_unregister_no_lock 函数就好了。

当对象释放销毁后它的所有弱引用都会被置为 nil 大概是我们听了无数遍的一句话,那么它的入口在哪呢?既然是对象销毁后,那么入口肯定在对象的 dealloc 函数,把 weak 变量置为 nil 的调用链:dealloc->_objc_rootDealloc->rootDealloc->object_dispose->objc_destructInstance->clearDeallocating->clearDeallocating_slow。

 当第一次创建某个对象的弱引用时,会以该对象的指针和弱引用的地址创建一个 weak_entry_t,并放在该对象所处的 SideTable 的 weak_table_t 中,然后以后所有指向该对象的弱引用的地址都会保存在该对象的 weak_entry_t 的哈希数组中,当该对象要析构时,遍历 weak_entry_t 中保存的弱引用的地址,将弱引用指向 nil,最后将 weak_entry_t 从 weak_table 中移除。


72. ARC 和 MRC 下读取 weak 变量。

 ARC 下我们想要获取 weak 变量指向的对象是通过:objc_loadWeakRetained 和 objc_release,MRC 下通过: objc_loadWeak(虽然 weak 变量中有对象的地址)。这里要和通过指针直接找到对象内存读取内容的方式作出理解上的区别。通过分析上述函数实现,可以发现只要一个对象被标记为 deallocating,即使此时该对象的弱引用还是指向对象内存且对象没有被完全释放,只要通过该对象的弱引用访问该对象都会得到 nil。

  1. 在 ARC 模式下,获取 weak 变量时,会调用 objc_loadWeakRetained 然后在要出当前作用域时调用了一次 objc_release,之所以这样,是因为在 objc_loadWeakRetained 中会对 weak 指针指向的对象调用 objc_object::rootRetain 函数,使该对象的引用计数加 1,为了抵消这一次加 1,会在即将出作用域之前调用 objc_release 函数(内部实现其实是: objc_object::release)使该对象的引用计数减 1。这个加 1 减 1 的操作其实是为了保证在通过 weak 变量读取其指向的对象时,防止对象中途销毁,毕竟 weak 变量不会强引用所指向的对象。

  2. 在 MRC 模式下,获取 weak 指针时,会调用 objc_loadWeak 函数,其内部实现其实是: objc_autorelease(objc_loadWeakRetained(location)),即通过 objc_autorelease 来抵消 weak 变量读取过程中的引用计数加 1 的操作,保证对象最后能正常释放。

 我们可以直白的把 objc_loadWeakRetained 函数的功能理解为:返回弱引用指向的对象,并把该对象的引用计数加 1,而减 1 的操作 ARC 下则是在其后面由编译器插入一条 objc_release 函数,MRC 下则是把返回的对象放进自动释放池内,两种方式最后都能保证读取的对象正常释放。(验证此结论时,可看到每条 weak 读取,在 ARC 环境下:objc_loadWeakRetained 和 objc_release 一一对应。)

 在把一个 weak 变量赋值给另一个 weak 变量时会调用 objc_copyWeak 函数。

void
objc_copyWeak(id *dst, id *src)
{
    // 首先从 src weak 变量获取所指对象,并引用计数加 1
    id obj = objc_loadWeakRetained(src);
    
    // 初始化 dst weak 变量
    objc_initWeak(dst, obj);
    
    // obj 引用计数减 1,与上面读取时 +1 相对应,保证对象能正常释放
    objc_release(obj);
}

73. block 定义。

 block 是 C 语言的扩充功能。可以用一句话来表示 block 的扩充功能:带有自动变量(局部变量)(带有自动变量 在 block 中表现为截获外部变量值)的匿名函数。(对于程序员而言,命名就是工作的本质。)

 block 定义范式如下: ^ 返回值类型 参数列表 表达式 “返回值类型” 同 C 语言函数的返回值类型,“参数列表” 同 C 语言函数的参数列表,“表达式” 同 C 语言函数中允许使用的表达式。

 block 类型变量可完全像通常的 C 语言变量一样使用,因此也可以使用指向 block 类型变量的指针,即 block 的指针类型变量。

typedef int (^blk_t)(int);
blk_t blk = ^(int count) { return count + 1; };

// 指针赋值
blk_t* blkPtr = &blk;

// 执行 block
(*blkPrt)(10);

 无论 block 定义在哪,啥时候执行。当 block 执行时,用的值都是它定义时截获的外部基本变量值或者是截获的内存地址,如果是内存地址的话,从定义到执行这段时间,不管里面保存的值有没有被修改, block 执行时,使用的都是当时内存里面保存的值,如果是基本变量的话,那执行时使用的值就仅仅是 block 结构体初始化时使用的瞬时值。(定义可理解为生成 block 结构体实例,截获可理解为拿外部变量初始化 block 结构体实例的成员变量)


74. block 的本质。

 block 是带有自动变量的匿名函数,但 block 究竟是什么呢?语法看上去很特别,但它实际上是作为 极普通的 C 语言源码 来处理的。通过支持 block 的编译器,含有 block 语法的源代码转换为一般 C 语言编译器能够处理的源代码,并作为极为普通的 C 语言源代码被编译。通过 clang -rewrite-objc 源代码文件名,如下源代码可变换为:

int main() {
    void (^blk)(void) = ^{ printf("Block\n"); };
    blk();

    return 0;
}
  • __block_impl
struct __block_impl {
  void *isa;
  int Flags;
  int Reserved;
  void *FuncPtr;
};
  • __main_block_impl_0
 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;
   }
 };
  • __main_block_func_0
 static void __main_block_func_0(struct __main_block_impl_0 *__cself) {
     printf("Block\n");
 }
  • __main_block_desc_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)};

 如变换后的源代码所示,通过 block 使用的匿名函数实际上 被作为简单的 C 语言函数来处理: __main_block_func_0。另外,根据 block 语法所属的函数名(此处为 main)和该 block 语法在该函数出现的顺序值(此处为 0)来给经 clang 变换的函数命名。该函数的参数 __cself 是一个指向 block 结构体实例的指针,相当于 C++ 实例方法中指向实例自身的变量 this,或是 Objective-c 实例方法中指向对象自身的变量 self。


75. block 截获外部变量值的本质。

 上一节为了观察 block 的最简单的形态在 block 中没有截获任何外部变量,下面我们看一下 block 截获外部变量时的转换结果,通过 clang -rewrite-objc 转换如下 block 定义:

int dmy = 256; // 此变量是为了对比,未使用的变量不会被 block 截获
int val = 10;
int* valPtr = &val;
const char* fmt = "val = %d\n";

void (^blk)(void) = ^{
    // block 截获了三个变量,类型分别是: int、int *、const char *
    printf(fmt, val);
    printf("valPtr = %d\n", *valPtr);
};

 转换后的代码: __block_impl 结构体保持不变。__main_block_impl_0 成员变量增加了,block 语法表达式中使用的外部变量(看似是同一个变量,其实只是同名)被作为成员变量追加到了 __main_block_impl_0 结构体中,且类型与外部变量完全相同(局部静态变量会被转换为对应的指针类型)。__main_block_impl_0 构造函数具体内容就是对 impl 中相应的内容进行赋值,要说明的是 impl.isa = &_NSConcreteStackBlock 这个是指 block 的存储域 和 当前 block 的类型(isa 实际是运行时动态指定的,此处显示的并不准确),被 block 截获的外部变量值被存储到该结构体的成员变量中,构造函数也发生了变化,初始化列表内要给 fmt、val、valPtr 赋值,这里我们就能大概猜出截获外部变量的原理了,被使用的外部变量值(是值呀)会被存入 block 结构体变量中,而在 block 表达式中看似是使用外部变量其实是使用了一个名字一模一样的 block 结构体实例的成员变量,所以我们不能对它进行直接赋值操作。

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  
  // Block 截获三个外部变量,然后 __main_block_impl_0 增加了自己对应的成员变量,
  // 且和外部的自动变量的类型是完全一致的(局部静态变量除外),(这里要加深记忆,后面学习 __block 变量被转化为结构体时可与其进行比较)
  const char *fmt;
  int val;
  int *valPtr;
  
  // 初始化列表里面 : fmt(_fmt), val(_val), valPtr(_valPtr)
  // 构造结构体实例时会用截获的外部变量的值进行初始化,看到参数类型也与外部变量完全相同
  __main_block_impl_0(void *fp,
                      struct __main_block_desc_0 *desc,
                      const char *_fmt,
                      int _val,
                      int *_valPtr,
                      int flags=0) : fmt(_fmt), val(_val), valPtr(_valPtr) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};

__main_block_func_0 函数内也使用到了 __cself 参数:

static void __main_block_func_0(struct __main_block_impl_0 *__cself) {

    // 可以看到通过函数传入 __main_block_impl_0 实例,读取对应的截获的外部变量的值 
    const char *fmt = __cself->fmt; // bound by copy
    int val = __cself->val; // bound by copy
    int *valPtr = __cself->valPtr; // bound by copy

    printf(fmt, val);
    printf("valPtr = %d\n", *valPtr);
}

 main 函数里面,__main_block_impl_0 结构体实例构建和 __main_block_func_0 函数执行保持不变:

int main(int argc, const char * argv[]) {
    /* @autoreleasepool */ { __AtAutoreleasePool __autoreleasepool; 

        NSLog((NSString *)&__NSConstantStringImpl__var_folders_24_5w9yv8jx63bgfg69gvgclmm40000gn_T_main_4ea116_mi_0);

        int dmy = 256;
        int val = 10;
        int* valPtr = &val;
        const char* fmt = "val = %d\n";
        
        // 根据传递给构造函数的参数对 struct __main_block_impl_0 中由自动变量追加的成员变量进行初始化
        void (*blk)(void) = ((void (*)())&__main_block_impl_0((void *)__main_block_func_0,
                                                              &__main_block_desc_0_DATA,
                                                              fmt,
                                                              val,
                                                              valPtr));

        val = 2;
        fmt = "These values were changed. val = %d\n";
        
        // 执行 __block_impl 中的 FuncPtr 函数,入参正是 __main_block_impl_0 实例变量 blk
        ((void (*)(__block_impl *))((__block_impl *)blk)->FuncPtr)((__block_impl *)blk);
    }

    return 0;
}

 总的来说,所谓 “截获外部变量值” 意味着在执行 block 时,block 语法表达式使用的与外部变量同名的变量其实是 block 的结构体实例(即 block 自身)的成员变量,而这些成员变量的初始化值则来自于截获的外部变量的值。block 不能直接使用 C 语言数组类型的自动变量,如前所述,截获外部变量时,将值传递给结构体的构造函数进行保存,如果传入的是 C 数组,假设是 a[10],那构造函数内部发生的赋值是 int a[10] = a 这是 C 语言规范所不允许的(数组不能直接赋值,可用 char* 代替),block 是完全遵循 C 语言语法规范的。

 block 截获外部变量值,截获的是 block 语法定义时此外部变量瞬间的值,保存后就不能改写该值。这个不能改写该值是 block 的语法规定,如果截获的是指针变量的话,可以通过指针来修改内存空间里面的值。比如传入 NSMutableArray 变量,可以往里面添加对象,但是不能对该 NSMutableArray 变量进行赋值。传入 int* val 也可以直接用 *val = 20 来修改 val 指针指向的内存里面保存的值,并且如果截获的是指针变量的话,在 block 内部修改其指向内存里面的内容后,在 block 外部读取该指针指向的值时也与 block 内部的修改都是同步的。毕竟它们操作的本来就是同一块内存空间

 这里之所以语法定为不能修改,可能的原因是因为修改了值以后是无法传出去的,只是在 block 内部使用,是没有意义的。就比如 block 定义里面截获了变量 val,你看着这时用的是 val 这个变量,其实只是把 val 变量的值赋值给了 block 结构体的 val 成员变量。这时在 block 内部修改 val 的值,可以理解为只是修改 block 结构体 val 成员变量的值,与 block 外部的 val 已经完全无瓜葛了,然后截获指针变量也是一样的,其实截获的只是指针变量所指向的地址,在 block 内部修改的只是 block 结构体成员变量的指向,这种修改针对外部变量而言都是毫无瓜葛的。

 当试图在 block 表达式内部改变同名于外部变量的成员变量时,会发生编译错误。因为在实现上不能改写被截获外部变量的值,所以当编译器在编译过程中检出给被截获外部变量赋值的操作时,便产生编译错误。理论上 block 内部的成员变量已经和外部变量完全无瓜葛了,理论上 block 结构体的成员变量是能修改的,但是这里修改的仅是结构体自己的成员变量,且又和外部完全同名,如果修改了内部成员变量开发者会误以为连带外部变量一起修改了,索性直接发生编译错误更好(一个猜想)!(而 __block 变量就是为了在 block 表达式内修改外部变量而生的)。


76. 在 block 表达式中修改外部变量的办法有哪些。

 (这里忽略前面很多例子中出现的直接传递指针来修改变量的值)

  1. C 语言中有变量类型允许 block 改写值: 静态变量、静态全局变量、全局变量。

 虽然 block 语法的匿名函数部分简单转换为了 C 语言函数,但从这个变换的函数中访问 静态全局变量/全局变量 并没有任何改变,可直接访问。但是静态局部变量的情况下,转换后的函数原本就设置在含有 block 语法的函数之外,所以无法从变量作用域直接访问静态局部变量。在我们用 clang -rewrite-objc 转换的 C 代码中可以清楚的看到静态局部变量定义在 main 函数内,而 static void __main_block_func_0(struct __main_block_impl_0 *__cself){ ... } 则是完全在外部定义的一个静态函数。

这里的静态变量的访问,作用域之外,应该深入思考下,虽然代码写在了一起,但是转换后并不在同一个作用域内,能跨作用域访问数据只能靠指针了。(这是一个猜想)

 block 截获的静态局部变量,对应到 block 结构体中会被声明为一个原类型的指针成员变量,然后当 block 的结构体初始化时使用静态局部变量的地址来赋初值。可看到在 __main_block_func_0 内 global_val 和 static_global_val 的访问和使用与在 block 外部使用完全相同。静态变量 static_val 则是通过指针对其进行访问修改,在 __main_block_impl_0 结构体的构造函数的初始化列表中 &static_val 赋值给 struct __main_block_impl_0 的 int *static_val 这个成员变量,这种方式是通过地址在超出变量作用域的地方访问和修改变量。

静态变量的这种方法似乎也适用于外部变量的访问,但是为什么没有这么做呢?

 实际上,在由 block 语法生成的值 block 上,可以存有超过其变量作用域的被截获对象的外部变量,但是如果 block 不持有该变量的话,例如 bock 截获的是 weak 、unsafe_unretained 变量,当变量作用域结束的同时,该自动变量很可能会释放并销毁,而此时再去访问该自动变量的话,如果是 weak 变量则已被置为 nil,而如果是 unsafe_unretained 变量,则会直接因为野指针访问而 crash。而访问静态局部变量则不会出现这种问题,静态变量是存储在静态变量区的,在程序结束前它一直都会存在,之所以会被称为局部,只是说出了作用域无法直接通过变量名访问它了(对比全局变量在整个模块的任何位置都可以直接访问),并不是说这块数据不存在了,只要我们有一个指向该静态变量的指针,那么出了作用域依然能正常访问到它,所以针对外部变量 block 并不能采用和静态局部变量一样的处理方式。

  1. 第二种是使用 __block 说明符。更准确的表达方式为 "__block 存储域说明符"(__block storage-class-specifier)。对于使用 __block 修饰的变量,不管在 block 中有没有使用它,都会相应的给它生成一个结构体实例。

 根据 clang -rewrite-objc 转换结果发现,__block val 被转化为了 struct __Block_byref_val_0 (0 表示当前是第几个 __block 变量)结构体实例。 (__Block_byref_val_0 命名规则是 __Block 做前缀,然后是 byref 表示是被 __block 修饰的变量,val 表示原始的变量名,0 表示当前是第几个 __block 变量)

struct __Block_byref_val_0 {
  void *__isa;
__Block_byref_val_0 *__forwarding; // 指向自己的指针
 int __flags;
 int __size;
 int val;
};

 __Block_byref_val_0 单独拿出来的定义,这样可以在多个 block 中重用。

 如果 __block 修饰的是对象类型的话,则 struct __Block_byref_val_0 会多两个函数指针类型的成员变量:__Block_byref_id_object_copy、__Block_byref_id_object_dispose,用于把 __block 变量复制到堆区和释放。

struct __main_block_impl_0 {
  struct __block_impl impl;
  struct __main_block_desc_0* Desc;
  
  // 看到新增了两个成员变量,已知在 block 定义中截获了 fmt 和 val 两个外部变量 fmt 和前面的转换没有区别
  const char *fmt;
  
  // val 是一个 __Block_byref_val_0 结构体指针
  __Block_byref_val_0 *val; // by ref
  
  // 首先看到的是 __Block_byref_val_0 * _val 参数,但是在初始化列表中用的是 val(_val->forwarding),初始化用的 _val->forwarding
  
  __main_block_impl_0(void *fp,
                      struct __main_block_desc_0 *desc,
                      const char *_fmt,
                      __Block_byref_val_0 *_val,
                      int flags=0) : fmt(_fmt), val(_val->__forwarding) {
    impl.isa = &_NSConcreteStackBlock;
    impl.Flags = flags;
    impl.FuncPtr = fp;
    Desc = desc;
  }
};

 刚刚在 block 中向静态变量赋值时只是使用了指向该静态变量的指针,而向 __block 变量赋值更复杂,__main_block_impl_0 结构体实例持有指向 __block 变量的 __Block_byref_val_0 结构体实例的指针。__Block_byref_val_0 结构体实例的成员变量 __forwarding 持有指向该实例自身的指针,通过成员变量 __forwarding 访问成员变量 val。(成员变量 val 是该实例自身持有的变量,它相当于原外部变量。)

 当 block 表达式内使用外部对象变量和外部 __block 变量,以及外部 block 时会生成这一对 copy 和 dispose 函数。


77. block 存储域。

 通过前面的学习可知,block 转换为 block 的结构体实例,__block 变量转换为 __block 变量结构体实例。

block 也可作为 OC 对象看待。将 block 当作 OC 对象来看时,该 block 的类为 _NSConcreteStackBlock、_NSConcreteGlobalBlock、_NSConcreteMallocBlock 三种类型之一。 由名称中含有 stack 可知,该类的对象 block 设置在栈上,同样由 global 可知,与全局变量一样,设置在程序的数据区域(.data 区)中,malloc 设置在由 malloc 函数分配的内存块(即堆)中。

设置对象的存储域
_NSConcreteStackBlock
_NSConcreteGlobalBlock程序的数据区域(.data 区)
_NSConcreteMallocBlock

 在记述全局变量的地方使用 block 语法时,生成的 block 为 _NSConcreteGlobalBlock 类型。block 具体属于哪种类型,不能通过 clang 转换代码看出, block 的实际的 isa 指向是在运行时来动态确定的。

void (^blk)(void) = ^{ printf("全局区的 _NSConcreteGlobalBlock Block!\n"); };

// 打印:
全局区的 _NSConcreteGlobalBlock Block!
❄️❄️❄️ block isa: <__NSGlobalBlock__: 0x100002068>

 在全局区定义的 block,即该 block 结构体实例存储在程序的数据区域中,因为在使用全局变量的地方不能使用自动变量,所以不存在对自动变量进行截获。由此 block 用结构体实例的内容不依赖于执行时的状态,所以整个程序中只需要一个实例。因此将 block 用结构体实例设置在与全局变量相同的数据区域中即可。

 只在截获自动变量时,block 结构体实例截获的值才会根据执行时的状态变化,即使在函数内而不在记述全局变量的地方定义 block,只要 block 不截获自动变量,就可以将 block 用结构体实例设置在程序的数据区域,即为全局 block。

 对于没有要截获自动变量的 block,我们不需要依赖于其运行时的状态--捕获的变量,这样我们就不涉及到 blockcopy 情况,因此是放在数据区。**

 此外要注意的是,通过 clang 编译出来的 isa 在第二种情况下会显示成 stackblock,这是因为 OC 是一门动态语言,真正的类型还是在运行时确定的,这种情况下可以使用 lldb 调试器查看。虽然通过 clang 转换的源代码通常是 _NSConcreteStackBlock 类型,但实际运行时却有不同。总结如下:

  • 记述全局变量的地方有 block 语法时
  • block 语法的表达式中不截获外部自动变量时

 以上情况下,block 为 _NSConcreteGlobalBlock 类型,即 block 配置在程序的数据区域中。除此之外 block 语法生成的 block 为 _NSConcreteStackBlock 类型,存储在栈上。

 配置在全局变量区的 block,从任何地方都可以通过指针安全的使用,但设置在栈上的 block,如果其所属的变量作用域结束,该 block 就被废弃,由于 __Block 变量也配置在栈上,同样的,如果其所属的变量作用域结束,则该 __block 变量也会被废弃。block 提供了将 block 和 __block 结构体实例从栈上复制到堆上的方法来解决这个问题。将配置在栈上的 block 复制到堆上,这样即使 block 语法记述的变量作用域结束,堆上的 block 还可以继续存在。

 在这里要思考一个问题:在栈上和堆上同时有一个 block 的情况下,我们的赋值,修改,废弃操作应该怎样管理?复制到堆上的 block isa 会指向 _NSConcreteMallocBlock,即 impl.isa = &_NSConcreteMallocBlock;,栈上的 __block 结构体实例成员变量 __forwarding 指向堆上 __block 结构体实例,堆上的 __block 结构体实例成员变量 __forwarding 指向它自己,那么不管是从栈上的 __block 变量还是从堆上的 __block 变量都能够访问同一块 __block 实例内容。

 block 提供的复制方法究竟是什么呢?实际上在 ARC 下,大多数情形下编译器会恰当的进行判断,自动生成将 block 从栈复制到堆上的代码。

 赋值时 block 自动从栈区复制到堆区的两个场景:

// 场景一:
// 用 clang -rewrite-objc 能转换成功
typedef int(^BLK)(int);

BLK func(int rate) {
    // 右边栈区 block 复制到堆区,并被 temp 持有
    BLK temp = ^(int count){ return rate * count; };
    return temp;
}

// 下面的代码用 clang -rewrite-objc 转换失败,改成上面就会成功,(用中间变量接收一下,(ARC 自动调用一次 copy 函数))
typedef int(^BLK)(int);

BLK func(int rate) {
    // 此时直接返回栈区 block 不行 
    return ^(int count){ return rate * count; };
}

// 失败描述,用 clang 转换失败,但是直接执行该函数是正常的
// clang 转换错误描述说返回一个位于栈区的 block,
// 栈区 block 出了下面花括号就被释放了,所以不能返回,
// 同时也说明了 clang 不能动态的把栈区 block 复制到堆区,
// 而上面有临时变量赋值时,则已经把等号右边的 block 复制到堆区,并赋值给了 temp。

// 而执行时正常,是编译器能动态的把栈区 block 复制到堆区。

returning block that lives on the local stack
return ^(int count){ return rate * count; };
           ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
64 warnings and 1 error generated.

// 场景二:
BLK __weak blk;
{
    NSObject *object = [[NSObject alloc] init];
    
    // NSObject * __weak object2 = object;
    
    void (^strongBlk)(void) = ^{
        NSLog(@"object = %@", object);
    };
    
    // blk 是一个弱引用变量,用一个 strong 赋值给他,
    // 它不持有该 strong 变量
    blk = strongBlk;
}

// blk();
printf("blk = %p\n", blk);

// 打印正常,出了花括号,block 结构体实例已经释放了:
blk = 0x0

BLK __weak blk;
{
    NSObject *object = [[NSObject alloc] init];
    // NSObject * __weak object2 = object;
    // void (^strongBlk)(void) = ^{
    // NSLog(@"object = %@", object);
    // };

    // 这里给了警告: 
    // Assigning block literal to a weak variable; object will be released after assignment
    blk = ^{
        NSLog(@"object = %@", object);
    };
    
    printf("内部 blk = %p\n", blk);
}

// blk();
printf("blk = %p\n", blk);

// 打印:出了花括号,打印了 blk 不为 0x0,还是栈区 block 的地址
// 打印了一个栈区 block 地址(即等号右边的栈区 block 地址)

// 这里的原因是 拿一个栈区的 block 结构体实例去给一个 weak 变量赋值,并不会走真正的 weak 流程(OC 对象 dealloc 时执行 weak 的清理工作)

内部 blk = 0x7ffeefbff538
blk = 0x7ffeefbff538

 在 ARC 下,将 block 作为函数返回值返回时,编译器会自动生成复制到堆上的代码。

 前面说大部分情况下编译器会适当的进行判断,不过在此之外的情况下需要手动生成代码(自己调用 copy 函数),将 block 从栈上复制到堆上(_Block_copy 函数的注释已经说了,它是创建基于堆的 block 副本),即我们自己主动调用 copy 实例方法。

 编译器不能进行判断时是什么样的状况呢?

  • 向函数的参数中传递 block 时,但是如果在函数 适当的复制了传递过来的参数,那么就不必在调用该方法或函数前手动复制了。

 以下方法或函数不用手动复制,编译器会给进行自动复制:

  • Cocoa 框架的方法且方法名中含有 usingBlock 等时。
  • Grand Central Dispatch 的 API。
  • 将 block 赋值给类的附有 __strong 修饰符的 id 类型或 block 类型成员变量时【当然这种情况就是最多的,只要赋值一个 block 变量就会自动进行复制】

 NSArray 的 enumerateObjectsUsingBlock 以及 dispatch_async 函数就不用手动复制。NSArray 的 initWithObjects 上传递 block 时需要手动复制。

 下面是个 🌰,对添加到数组中的 block 主动执行 copy:

id obj = [Son getBlockArray];
void (^blk)(void) = [obj objectAtIndex:0];
blk();

// 对 block 主动调用 copy 函数,能正常运行 
+ (id)getBlockArray {
    int val = 10;
    return [[NSArray alloc] initWithObjects:[^{NSLog(@"blk0: %d", val);} copy], [^{NSLog(@"blk1: %d", val);} copy], nil];
}

// 如下如果不加 copy 函数,则运行崩溃
+ (id)getBlockArray {
    int val = 10;
    return [[NSArray alloc] initWithObjects:^{NSLog(@"blk0: %d", val);}, ^{NSLog(@"blk1: %d", val);}, nil];
}

// 崩溃原因: 不主动调用 copy 时,getBlockArray 函数执行结束后,栈上的 block 被废弃了,编译器对此种情况不能判断是否需要复制。
// 也可以不判断全部情况都复制,但是将 block 从栈复制到堆是相当消耗 CPU 的,当 block 在栈上也能使用时,从栈上复制到堆上,就只是浪费 CPU 资源。
// 此时需要我们判断,自行手动复制。
Block 的类副本源的配置存储域复制效果
_NSConcreteStackBlock从栈复制到堆
_NSConcreteGlobalBlock程序的数据区域什么也不做
_NSConcreteMallocBlock引用计数增加

 不管 block 配置在何处,用 copy 方法复制都不会引起任何问题,在不确定是否需要执行复制时,主动调用 copy 方法即可。

🎉🎉🎉 未完待续...