面试题
这道面试题如下,问最后print方法能不能调用成功?如果能最后打印什么?
@interface Person: NSObject
@property (copy,nonatomic) NSString * name;
-(void)print;
@end
@implementation Person
-(void)print{
NSLog(@"my name is %@",self.name);
}
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
id cls = [Person class];
void * obj = &cls;
[(__bridge id)obj print];
}
@end
这是一道非常好的面试题,主要考察了iOS底层的函数调用机制以及函数调用栈的问题。
解答
pint可以调用成功
首先我们先看print函数是否可以调用成功的问题,[Person class]返回的是Person的类对象,在底层类对象是一个结构体,结构体里首个变量是isa指针,接下来是superclass指针,cache的方法缓存指针以及具体的类信息指针,都指向的是具体结构体,类对象在底层的结构体结构大概如下:
struct objc_class {
Class isa;
Class superclass;
cache_t cache;
class_data_bits bits;
}
struct class_rw_t {
units32_t flags;
units32_t version;
const class_ro_t *ro;
method_list_t *methods;// 方法列表
property_list_t *properties;//属性列表
const protocol_list_t *protocols;//协议列表
Class firstSubclass;
Class nextSiblingClass;
char *demangledName;
}
struct class_ro_t {
unit32_t flags;
unit32_t instanceStart;
unit32_t instanceSize;
#ifdef __LP64__
unit32_t reserved;
#endif
const unit8_t *ivarLayout;
const char *name;//类名
method_list_t *baseMethodlist;
protocol_list_t *baseProtocols;
const ivar_list_t *ivars;//成员变量列表
const unit8_t *weakIvarLayout;
property_list_t *baseProperties;
}
从上面的代码可以看出来obj存放的是cls的地址,同时cls的地址指向的是Person类对象。根据Runtime的底层原理,iOS的函数调用在底层是通过objc_msgSend函数来给函数调用者发送消息,objc_msgSend的执行流程有三大阶段:
消息发送
动态方法解析
消息转发
这里我们重点看消息发送阶段,下面这张经典的图基本阐述了消息发送阶段:

首先实例通过isa指针找到类对象,查看类对象的方法列表里是否存在对应的方法,如果没有则通过类对象里面的superclass找到其父类的类对象,在父类对象的类对象里继续查找,直至到顶层的NSObject。
那么在回头看看[(__bridge id)obj print]的调用,同样是消息发送,首先找到obj的isa指针,从上面的分析可以看出来,obj的前8个字节存放的应该就是isa指针,因为不管是实例对象还是类对象其底层的struct结构体中,前8个字节就是isa指针,由于obj存放的是cls的地址,所以这里的isa其实就是cls的地址,而这个isa指向的是Person的类对象,所以最后能在Person类对象的方法列表中找到print方法。
最后的打印
既然能调用方法,那么最后打印什么呢?其实最后需要确定的是self->_name这个成员变量的值是什么。在底层实例对象的成员变量是紧挨着isa指针的,在isa指针的下面的一段连续的存储空间中,所以我们需要弄清楚上面的isa指针紧挨着的8个字节的存储空间中到底是什么?
栈空间
viewDidLoad调用时会开辟一段栈空间在作为函数调用的临时空间,函数调用完毕后就回收此空间,当然函数调用时里面的局部变量也是存在这个栈空间里的,里面的[super viewDidLoad]会继续开辟一段栈空间,二段栈空间是连续的,栈空间的回收是先开辟的后回收,这也符合栈数据结构的特点,[super viewDidLoad]方法在底层是通过objc_msgSendSuper2来调用的,其需要接受二个参数:
- struct objc_super2
- SEL
其中objc_super2结构体如下:
struct objc_super2 {
id receiver;
Class current_class;
}
receiver是消息接受者,current_class是receiver的Class对象,由于此结构体要当参数传入方法,所以在开辟的栈空间内会存放receiver和current_class这二个临时变量,在这里receiver为self,current_class为ViewController class。由于栈空间是从高地址到地址的,占空间的内部大致如下:

最后按照上面寻找成员变量的方式,跳过isa指针就是成员变量,由于上面已经分析指导isa指针就是cls,所以self就是找到的第一个成员变量,由于person只有一个成员变量_name,所以这里self就等于_name这个成员变量,最后的打印结果为:my name is <ViewController: 0x13a110720>。
调试打印
首先我们打印出obj的地址值,然后打印后面的连续4个8字节的地址,分别打印地址的内容:

这很好的证明了cls后面的8个字节存储的是viewController,在往后8个字节存放的是viewController的类对象。
思考
如果代码为下面这种情况打印什么?
- (void)viewDidLoad {
[super viewDidLoad];
NSString * str = @"mamba";
id cls = [Person class];
void * obj = &cls;
[(__bridge id)obj print];
}
通过上面的分析,str这个字符串局部变量会紧挨着cls的地址,所以最后输出是my name is mamba,如果注释掉
[super viewDidLoad]的调用,则会发生坏内存访问,程序崩溃。
总结
本文根据一个实际的面试题来回复了Runtime中的函数调用消息机制以及函数调用栈的相关知识,通过这个面试题能到加深对iOS底层知识的理解。