阅读 728

iOS底层分析之类的探究-cache之 insert、objc_msgSend

承接文章iOS底层分析之类的探究-cache篇继续:

前面我们说了cache方法缓存数据通过insert方法来插入的,但从始自终,这都是我们的猜测以及一些推导,我们并没有看到明确的流程走向,接下来我们借助objc源码,查看整个cache从写入到读取的构成,了解整个cache的完整链走向。由于我们目前的切入点只有insert方法,我们也不知道到底是谁调用了insert,所以我们干脆在insert方法打个断点,然后通过bt指令打印堆栈信息:

01.png

02.png

我们发现了一个跟cache有关系的方法,复制并项目里全局搜索一下“log_and_fill_cache”,看看这个方法是不是我们insert的上一层调用者:

03.png 果不其然,通过comand+单机insert可以确定,log_and_fill_cache就是insert的上层调用者,接下来再搜一下“log_and_fill_cache”找到它的上层调用者:

IMP lookUpImpOrForward(id inst, SEL sel, Class cls, int behavior)
{
    ...
    log_and_fill_cache(cls, imp, sel, inst, curClass);
    ...
};
复制代码

lookUpImpOrForward这个里面就涉及到消息的转发知识点,在那之前我们要先知道,我们平时发起消息,有三种方法:oc层面的发起、runtime API层面的发起、framework层面的发起,

06.png 针对以上三种,下面我们来一一测试下:

第一种:

void test02(void){
    
    Student *stu = [Student alloc];
    
    [stu run];//实例方法
}

int main(int argc, char * argv[]) {
    test02();
    ...
};
复制代码

首先,终端或iTerm里cd到main.m所在目录;然后输入以下命令
$xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc main.m
目的是在目录下生成一个main.cpp文件,然后我们打开它,全局搜索“test02(void)”:

void test02(void){

    Student *stu = ((Student *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("Student"), sel_registerName("alloc"));

    ((void (*)(id, SEL))(void *)objc_msgSend)((id)stu, sel_registerName("run"));
}
复制代码

我们可以看到[Student alloc]在底层变成了

((Student *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("Student"), sel_registerName("alloc"))
复制代码

分析可以得到,方法在底层的调用是通过objc_msgSend进行消息的发送,而它需要两个参数:
1、消息接收者
2、方法名

我们有时候会将一些通用的方法,写在父类里,子类在需要的时候直接调用父类方法就好了,又或者调用一些在子类声明却未实现,但在父类里有实现的方法,又会是什么情况呢?

@interface StudentChild:Student
- (void)run;
- (void)sleep;
- (void)SwimmingWithSpeed:(NSInteger)speed sex:(NSInteger)sex;
+ (void)eat;
@end
@implementation StudentChild
- (void)sleep{
    NSLog(@"StudentChild--sleep");
}
- (void)SwimmingWithSpeed:(NSInteger)speed sex:(NSInteger)sex{
    NSLog(@"Swimming Speed is %ld ;sex is %@",(long)speed,sex==0?@"boy":@"girl");
}
@end


void test03(void){
    
    StudentChild *stuChild = [StudentChild alloc];
    
    [stuChild SwimmingWithSpeed:100 sex:0];//实例方法,    有声明 已经实现
    
    [stuChild run];//实例方法,      有声明 未实现
    
    [stuChild dump];//实例方法       未声明 未实现
    
    [StudentChild eat];//类方法       有声明 未实现
}
复制代码

打印结果如下

image.png

我们执行同上面一样的xcrun命令,可能会报警高,因为我们没有在StudentChild里实现方法提,暂时不必理会警告,我们继续查看main.cpp文件:

void test03(void){

    StudentChild *stuChild = ((StudentChild *(*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("StudentChild"), sel_registerName("alloc"));

    ((void (*)(id, SEL, NSInteger, NSInteger))(void *)objc_msgSend)((id)stuChild, sel_registerName("SwimmingWithSpeed:sex:"), (NSInteger)100, (NSInteger)0);

    ((void (*)(id, SEL))(void *)objc_msgSend)((id)stuChild, sel_registerName("run"));

    ((void (*)(id, SEL))(void *)objc_msgSend)((id)stuChild, sel_registerName("dump"));

    ((void (*)(id, SEL))(void *)objc_msgSend)((id)objc_getClass("StudentChild"), sel_registerName("eat"));
}
复制代码

分析得出,方法的调用依旧是objc_msgSend消息发送,内部参数按照:接收者、方法名、参数1、参数2...依次追加。

第二种

既然我们已经知道了方法的发送在底层是消息发送,那么我们何不直接调用objc_msgSend发送消息,来模拟方法的调用呢?

记得引入头文件:#import <objc/message.h>

void test04(void){
    StudentChild *stuChild = [StudentChild alloc];
    objc_msgSend(stuChild,sel_registerName("run"));
    objc_msgSend(stuChild,sel_registerName("SwimmingWithSpeed:sex:"),98,1);
}
复制代码

如果你们没有对工程进行设置,应该会编译报错:

Too many arguments to function call, expected 0, have 2
复制代码

image.png 需要设置一下 target > Build Settings > Enable Strict Checking of objc_msgSend Calls 修改为 No,默认情况下Yes. 运行结果如下

image.png

第三种

void test05(void){
    StudentChild *stuChild = [StudentChild alloc];
    objc_msgSend(stuChild,@selector(run));
    objc_msgSend(stuChild,@selector(SwimmingWithSpeed:sex:),66,1);
}
复制代码

image.png

我们在查看main.cpp文件,发现除了objc_msgSend方法,还有一个objc_msgSendSuper方法,顾名思义是给父类发送方法,那么我们也来测试下吧:

//找到objc_msgSendSuper定义,发现有两个参数objc_super结构体指针,以及SEL
objc_msgSendSuper(struct objc_super * _Nonnull super, SEL _Nonnull op, ...)
复制代码

点击查看objc_super定义如下:

struct objc_super {
    /// Specifies an instance of a class.
    __unsafe_unretained _Nonnull id receiver;

    /// Specifies the particular superclass of the instance to message. 
#if !defined(__cplusplus)  &&  !__OBJC2__
    /* For compatibility with old objc-runtime.h header */
    __unsafe_unretained _Nonnull Class class;
#else
    __unsafe_unretained _Nonnull Class super_class;
#endif
    /* super_class is the first class to search */
};
复制代码

当前是OBJC2环境,简化代码如下:

struct objc_super {
    __unsafe_unretained _Nonnull id receiver;
    __unsafe_unretained _Nonnull Class super_class;
};
复制代码

所以测试代码如下:

//测试objc_msgSendSuper
void test06(void){
    StudentChild *stuChild = [StudentChild alloc];//实例化对象
    ///结构体
    struct objc_super ssjobjc;
    ssjobjc.receiver = stuChild;
    ssjobjc.super_class = Student.class;
    
    objc_msgSendSuper(&ssjobjc,@selector(sleep));
}
复制代码

打印结果:

image.png

接下来我们看一下objc_msgSend里面是如何走的


打开 Debug->Debug workflow 选中always show Disassembly

image.png 然后运行

image.png

会进入这个界面 image.png

由此我们得知objc_msgSend要去到objc源码去看:

image.png

按上图操作,左边的方法列表就被折叠起来了。我们知道,objc_msgSend是用汇编写的,所以需要关注后缀是.s的几个文件,又因为我们主要研究的环境是iPhone真机,所以我们需要关注的是arm64相关名字的.s文件,我们发现还是不能定位哪个objc_msgSend方法,但是我们留意到有个ENTRY _objc_msgSend

image.png 没错,这里就是_objc_msgSend相关代码,我们来分析一下:

	ENTRY _objc_msgSend
	UNWIND _objc_msgSend, NoFrame
        /**
            cmp:compare,比较的意思
            p0:p0寄存器,存放的是第一个参数,也就是消息接收者
            #0:常量0
            意思就是p0和0进行比较,判断消息接收者是否为空(0就表示空)
        */
	cmp	p0, #0			// nil check and tagged pointer check
        
        /**
        tagged pointer:为了节省内存和提高执行效率,苹果提出的概念,感兴趣可自行查阅。
        当p0为空,如果支持tagged pointer,就会进入b.le	LNilOrTagged,否则return 空。
        */
#if SUPPORT_TAGGED_POINTERS
	b.le	LNilOrTagged		//  (MSB tagged pointer looks negative)
#else
	b.eq	LReturnZero
#endif
        /** p0不为空就进入这里
        ldr	p13, [x0] 解释如下:
            从x0开始读取,把它对应的执行码给p13;
        x0即消息接收者,即消息接收者首地址,即消息接收者的isa.
        */
	ldr	p13, [x0]		// p13 = isa
        //关于GetClassFromIsa_p16,请继续往下看~
	GetClassFromIsa_p16 p13, 1, x0	// p16 = class
    LGetIsaDone:
    // calls imp or objc_msgSend_uncached
    CacheLookup NORMAL, _objc_msgSend, __objc_msgSend_uncached
        
复制代码

当前文件搜索“GetClassFromIsa_p16”,找到一个宏定义:

.macro GetClassFromIsa_p16 src, needs_auth, auth_address /* note: auth_address is not required if !needs_auth */
//---- 此处SUPPORT_INDEXED_ISA用于watchOS,不用看它
#if SUPPORT_INDEXED_ISA
	...
        
/**我们分析一下__LP64__*/
#elif __LP64__

.if \needs_auth == 0 // _cache_getImp takes an authed class already
    /**
    1、前面调用 GetClassFromIsa_p16 p13, 1, x0,所以
        src存放的就是isa,
        needs_auth存放的就是1,needs_auth == 0不成立,这个if判断可以不用看了;
    2、mov	p16, \src的意思就是把src传给p16,所以p16就是isa;
    */
	mov	p16, \src
.else
	// 64-bit packed isa
        /**
        ExtractISA p16, \src, \auth_address根据ExtractISA定义,可以理解为:
        and	$0, $1, #ISA_MASK
        也就是把$1与上ISA_MASK地址,结构给$0
        即p16 = isa &  ISA_MASK,也就是p16存的是Class地址
        */
	ExtractISA p16, \src, \auth_address
.endif
#else
	// 32-bit raw isa
	mov	p16, \src

#endif

.endmacro
复制代码

objc搜索"ExtractISA":

image.png 解读:

  • __has_feature:判断编译器是否支持某个功能。
  • ptrauth_calls:指针身份验证,针对 arm64e 架构;使用 Apple A12 或更高版本 A 系列处理器的设备(如 iPhone XS以后的设备)支持 arm64e 架构。

a12它并非普遍版本,所以我们要看下面那个:

.macro ExtractISA
	and    $0, $1, #ISA_MASK
.endmacro
复制代码

快速查找分析

看到这里,隐约间有了这么一个概念:objc_msgSend通过消息接收者,去获取它的Class类。那么为什么要这么做呢?首先,我们知道objc_msgSend之后,会有一个cache_t里面的insert方法,而cache_t存在于Class类里面。

我们继续看上面的CacheLookup NORMAL, _objc_msgSend, __objc_msgSend_uncached
文件全局搜索“CacheLookup”:

.macro CacheLookup Mode, Function, MissLabelDynamic, MissLabelConstant

///把x16传给x15,x16就是p16,也就是class地址
mov	x15, x16
...
//只需要关心iPhone真机模式即可
#elif CACHE_MASK_STORAGE == CACHE_MASK_STORAGE_HIGH_16
        /**
        ldr	p11, [x16, #CACHE]意思是x16平移CACHE大小个单位
            CACHE全局搜素可以看到是:#define CACHE     (2 * __SIZEOF_POINTER__),即16字节大小;
            x16是Class地址,平移16字节就是cache_t地址;
            p11 = cache_t地址
        */
	ldr	p11, [x16, #CACHE]		// p11 = mask|buckets

    //CONFIG_USE_PREOPT_CACHES的解释看下面,反正等于1,会进入if语句块
    #if CONFIG_USE_PREOPT_CACHES
    //__has_feature(ptrauth_calls)上面解释过,A12及以后才会进入,直接看else
        #if __has_feature(ptrauth_calls)
            tbnz	p11, #0, LLookupPreopt\Function
            and	p10, p11, #0x0000ffffffffffff	// p10 = buckets
        #else
        /** 看这里
        and	p10, p11, #0x0000fffffffffffe意思是
            p11与上48位,得到bucket地址,然后传给p10
        */
            and	p10, p11, #0x0000fffffffffffe	// p10 = buckets
            ///tbnz是比较运算:p11存在,就走LLookupPreopt;不存在就走下面的endif
            tbnz	p11, #0, LLookupPreopt\Function
        #endif
        /**
        关于为什么要右移7位,再右移48位,下面会有说明
        */
            //p1右移7个位置,然后传给p12(p1就是SEL)
            eor	p12, p1, p1, LSR #7
            //p11右移48个位置得到mask地址,然后与上p12得到sel-imp的index下标并传给p12;
            and	p12, p12, p11, LSR #48		// x12 = (_cmd ^ (_cmd >> 7)) & mask
    #else
        and	p10, p11, #0x0000ffffffffffff	// p10 = buckets
        and	p12, p1, p11, LSR #48		// x12 = _cmd & mask
#endif // CONFIG_USE_PREOPT_CACHES
...
#endif
/**
PTRSHIFT项目全局搜索发现等于3;
p10是buckets
p12是下标
解释:p13 = p10+(p12左移4个单位,即内存平移4个单位),即insert函数里的b[i]
【下文有图解“buckets首地址平移得到index下标对应bucket”】
*/
	add	p13, p10, p12, LSL #(1+PTRSHIFT)
						// p13 = buckets + ((_cmd & mask) << (1+PTRSHIFT))

	//b[i]平移-bucket单位大小,得到b[i]里的内容,
        //并将sel赋值给p9,imp赋值给p17;
					// do {
1:	ldp	p17, p9, [x13], #-BUCKET_SIZE	//     {imp, sel} = *bucket--
    //如果p9对应的sel和p1对应的sel一样,一样就进入2对应的那块代码,否则就进入3对应代码
	cmp	p9, p1				//     if (sel != _cmd) {
	b.ne	3f				//         scan more
						//     } else {
//能进入语句块2说明cache缓存找到,CacheHit相关代码看下面,mode参数就是前面的normal
2:	CacheHit \Mode				// hit:    call or return imp
						//     }
//判断p9对应的sel是否为空,为空就p13和p10(buckets首地址)地址进行比较,
//如果p13地址大于p10地址,就回到1语句块。
//这个循环会一直持续,直到对应的缓存找到。

//如果最终都没找到,就会进入MissLabelDynamic,MissLabelDynamic就是CacheLookup函数的第三个参数,也就是__objc_msgSend_uncached
//关于__objc_msgSend_uncached的分析,下面会讲到;
3:	cbz	p9, \MissLabelDynamic		//     if (sel == 0) goto Miss;
	cmp	p13, p10			// } while (bucket >= buckets)
	b.hs	1b
...
.endmacro
复制代码

全局搜索“CacheHit”, 项目全局搜索"TailCallCachedImp"

// CacheHit: x17 = cached IMP, x10 = address of buckets, x1 = SEL, x16 = isa
.macro CacheHit
.if $0 == NORMAL
	TailCallCachedImp x17, x10, x1, x16	// authenticate and call imp
...				// return imp via x17
.else
.abort oops
.endif
.endmacro

//TailCallCachedImp搜索结果不止一个,具体看宏定义if else判断
.macro TailCallCachedImp
	// $0 = cached imp, $1 = address of cached imp, $2 = SEL, $3 = isa
	//$3相当于一个Class,$0 ^ $3,就得到了imp
        eor	$0, $0, $3
        ///跳转执行imp
	br	$0
.endmacro


复制代码

项目全局搜索“CONFIG_USE_PREOPT_CACHES”:

/**
defined(__arm64__)    是否支持arm64
TARGET_OS_IOS         是否支持iOS系统
TARGET_OS_SIMULATOR   是否MacOS模拟器
TARGET_OS_MACCATALYST 是否兼容MacOS代码

所以if语句,判断条件就是 arm64环境+iOS系统+非MacOS模拟器+不兼容MacOS代码,
也就是iOS系统arm64下的真机模式
*/
#if defined(__arm64__) && TARGET_OS_IOS && !TARGET_OS_SIMULATOR && !TARGET_OS_MACCATALYST
#define CONFIG_USE_PREOPT_CACHES 1
#else
#define CONFIG_USE_PREOPT_CACHES 0
#endif
复制代码

慢速查找分析 __objc_msgSend_uncached

当前objc-msg-arm64.s文件全局搜索“__objc_msgSend_uncached”:

STATIC_ENTRY __objc_msgSend_uncached
	UNWIND __objc_msgSend_uncached, FrameWithNoSaves

	MethodTableLookup //方法列表查找
	TailCallFunctionPointer x17

	END_ENTRY __objc_msgSend_uncached
复制代码

文件全局搜索“MethodTableLookup”:

.macro MethodTableLookup
	
	SAVE_REGS MSGSEND

	// receiver and selector already in x0 and x1
	mov	x2, x16
	mov	x3, #3
	bl	_lookUpImpOrForward

	// IMP in x0
	mov	x17, x0

	RESTORE_REGS MSGSEND

.endmacro

/**
MethodTableLookup函数就是做了数据的move赋值,还调用了_lookUpImpOrForward函数;
lookUpImpOrForward,顾名思义是查找imp,再结合下面x0,我们知道x0寄存器亦可用于存放返回值。
*/
复制代码

我们文件内搜索_lookUpImpOrForward找不到,改为搜索“lookUpImpOrForward”:

NEVER_INLINE
IMP lookUpImpOrForward(id inst, SEL sel, Class cls, int behavior)
{
    const IMP forward_imp = (IMP)_objc_msgForward_impcache;
    IMP imp = nil;
    ...
    checkIsKnownClass(cls);//检查类是否已经注册,已经注册类才会继续查找方法
    ...
    for (unsigned attempts = unreasonableClassCount();;) {
        if (curClass->cache.isConstantOptimizedCache(/* strict */true)) {
        ///判断是否有缓存方法(可能在查找的过程中,又插入了新方法)
#if CONFIG_USE_PREOPT_CACHES
            imp = cache_getImp(curClass, sel);
            if (imp) goto done_unlock;
            curClass = curClass->cache.preoptFallbackClass();
#endif
        } else {
            // curClass method list.
            //getMethodNoSuper_nolock -> 二分法查找方法
            Method meth = getMethodNoSuper_nolock(curClass, sel);
            if (meth) {
            //找到了方法就找到了imp,然后goto done跳出死循环,
            //往下翻一点,可以看到done函数
                imp = meth->imp(false);
                goto done;
            }
            /**查询当前类的父类并赋值给curClass,
            如果curClass为空,则给imp默认赋值forward_imp并跳出循环。
            */
            if (slowpath((curClass = curClass->getSuperclass()) == nil)) {
                // No implementation found, and method resolver didn't help.
                // Use forwarding.
                imp = forward_imp;
                break;
            }
        }

        // Halt if there is a cycle in the superclass chain.
        if (slowpath(--attempts == 0)) {
            _objc_fatal("Memory corruption in class list.");
        }

        // Superclass cache.
        /**进入父类的快速查找->慢速查找流程,
        父类找不到就继续找父类的上层父类,递归查找,直到curClass为空*/
        imp = cache_getImp(curClass, sel);
        if (slowpath(imp == forward_imp)) {
            // Found a forward:: entry in a superclass.
            // Stop searching, but don't cache yet; call method
            // resolver for this class first.
            break;
        }
        if (fastpath(imp)) {
            // Found the method in a superclass. Cache it in this class.
            goto done;
        }
    }
    //看这里
     done:
    if (fastpath((behavior & LOOKUP_NOCACHE) == 0)) {
#if CONFIG_USE_PREOPT_CACHES
        while (cls->cache.isConstantOptimizedCache(/* strict */true)) {
            cls = cls->cache.preoptFallbackClass();
        }
#endif
        //方法填入缓存,下次就直接从缓存里读取,不需要再二分法查找
        log_and_fill_cache(cls, imp, sel, inst, curClass);
    }
    
    return imp;
};
复制代码

二分法查找

template<class getNameFunc>
ALWAYS_INLINE static method_t *
findMethodInSortedMethodList(SEL key, const method_list_t *list, const getNameFunc &getName)
{
    //list是class的data里的methods方法列表,可以查看getMethodNoSuper_nolock函数内部的调用
    ASSERT(list);
    auto first = list->begin();
    //这里base是第一个元素
    auto base = first;
    decltype(first) probe;

    uintptr_t keyValue = (uintptr_t)key;
    uint32_t count;
    //count是list方法列表的个数
    for (count = list->count; count != 0; count >>= 1) {
        //count >> 1 就是 count / 2
        //probe = probe +
        probe = base + (count >> 1);
        ///获取probe对应的名字,也就是sel方法名
        uintptr_t probeValue = (uintptr_t)getName(probe);
        //判断查找的cmd等于sel
        if (keyValue == probeValue) {
            // `probe` is a match.
            // Rewind looking for the *first* occurrence of this value.
            // This is required for correct category overrides.
            /**因为分类的重写方法在内存中,顺序是插入在类的方法之前,
                所以如果前面一个sel与之也是匹配,就返回前面一个sel
            */
            while (probe > first && keyValue == (uintptr_t)getName((probe - 1))) {
                probe--;
            }
            ///走到这里,说明前面一个sel没匹配上,那就返回当前这个sel
            return &*probe;
        }
        //如果keyValue比probeValue大,base就+1
        if (keyValue > probeValue) {
            base = probe + 1;
            count--;
        }
    }
    
    return nil;
}
复制代码

假设我们要找的sel别名是6,如图进入查找:

WX20210708-220652@2x.png

下面解释,为什么p11与上#0x0000fffffffffffe等于bucket地址:

#0x0000fffffffffffe用计算器可以算出刚好等于48

image.png

struct cache_t {
    // _bucketsAndMaybeMask is a buckets_t pointer in the low 48 bits
    // _maybeMask is unused, the mask is stored in the top 16 bits.
    // How much the mask is shifted by.
    //翻译:
    //_bucketsAndMaybeMask是低48位的buckets_t指针
    //_maybeMask未使用,mask存储在高16位
    //面具移动了多少
    static constexpr uintptr_t maskShift = 48;
};
//所以,cache_t地址平移48位就得到了bucket地址
复制代码
下面解释为什么要先右移7位,再右移48位:

我们在cache_t里的insert方法里,bucket_t赋值sel和imp是通过一个下标来定位是哪个bucket,下标的获取则是通过哈希函数来得到,具体代码请看:

void cache_t::insert(SEL sel, IMP imp, id receiver){
bucket_t *b = buckets();
    mask_t m = capacity - 1;
    mask_t begin = cache_hash(sel, m);
    mask_t i = begin;
    do {
        if (fastpath(b[i].sel() == 0)) {
            incrementOccupied();
            b[i].set<Atomic, Encoded>(b, sel, imp, cls());
            return;
        }
        if (b[i].sel() == sel) {
            return;
        }
    } while (fastpath((i = cache_next(i, m)) != begin));
};

/** 函数内部实现,sel先右移7位,然后再与上一个mask,经过哈希算法得到sel-imp下标;
    mask则是通过cache_t右移48位得到,
*/
static inline mask_t cache_hash(SEL sel, mask_t mask) 
{
    uintptr_t value = (uintptr_t)sel;
#if CONFIG_USE_PREOPT_CACHES
    value ^= value >> 7;
#endif
    return (mask_t)(value & mask);
}
复制代码

buckets首地址平移得到index下标对应bucket

截屏2021-07-05 下午11.55.09.png

简单总结

到此,我们对objc_msgSend(reciver,_cmd)到找到imp做一个简单总结:

  1. class位移16位得到cache_t;
  2. cache_t与上mask掩码得到mask地址,
  3. cache_t与上bucket掩码得到bucket地址;
  4. mask与上sel得到sel-imp下标index,
  5. 通过bucket地址内存平移,可以得到第index位置的bucket;
  6. bucket里面有sel、imp;然后拿bucket里的sel和msg_msgSend的_cmd参数进行比较是否相等;
  7. 如果相等就执行cacheHit,cacheHit里面做的就是拿到sel对应的imp,然后进行imp^Class,得到真正的imp地址,最后调用imp函数 。
  8. 如果不相等,就拿到bucket进行- - (减减)平移,找到下一个bucket进行比较,如果找到了就进入7,否则就继续缓存查找。如果一直找不到,就进入__objc_msgSend_uncached慢速查找函数。
  9. 慢速查找流程:lookUpImpOrForward二分法查找imp,找到了就写入缓存;
    当前类找遍了没有,就进入递归循环:
  • 再从父类开始,快速查找、慢速查找。还是没找到,就从父类的父类开始循环这一步;
  • 递归结束条件是class为空,然后给imp一个默认值。

【更新时间】:2021/7/8
【更新内容】:追加->慢速查找分析
文章分类
iOS
文章标签