前言
这是一道很有意思的题,题目来自群友,据说原题出自sunny。自以为是的解答这道题后,群友抛出一个新的问题,发现之前的解释行不通,遂有此文。
0x00 Code
你可能见过这题,但后面有变形,别急着离开。
@interface A : NSObject
@property (copy) NSString *name;
@end
@implementation A
- (void)print {
NSLog(@"%@", _name);
}
@end
@interface ViewController ()
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
id cls = [A class];
void *obj = &cls;
[((__bridge id)obj) print];
}
@end
这题的考察点很多,我当时的解释大概是这样的:
OC对象的内存分布是 isa+ivars
,对象的起始地址存储着isa,即对象对应类的地址,通过isa可以找到对象的instanceMethods,所以题中的obj可以调用到print方法。至于name属性的值,还是因为isa+ivars
,在函数栈中,先申请的内存地址高于后申请的内存地址,由于A类中只有一个属性,所以紧挨着cls的上一个变量就是对应name的值,当obj之上没有显示变量时,会找到方法对应的隐式参数 self与SEL,由于参数是从右向左压栈的,所以SEL先于self入栈,所以self与cls相邻,此时name的值就是方法中对应的隐藏参数self,即vc实例。
0x01 变形的由来
在答完这题后,群友的一句话让我石化了:如果去掉[super viewDidLoad]
调用会崩。
如果之前的解释是正确的,这里不应该Crash,因为隐式参数还在,但确实崩了。
0x02 探索
如图,去掉了super调用,并在print处打上断点,运行后作如下操作:
先通过po
查看_name
,发现是个地址并非对象,所以NSLog中通过%@
按照对象来解析显然是无法打印的,查看这个地址后发现里面存储着viewDidLoad
对应的ASCII码。
既然是ASCII码,如果用%s
应该就可以正常显示了,再次做如下尝试:
这里有几个注意点:
- 不能使用
_name.UTF8String
来转成char *
,因为_name
并不是NSString *
类型 - 不能使用
(__bridge char *)_name
来转成char *
,编译器会告诉你不兼容 - 通过
(__bridge void *)_name
将_name
转成通用类型指针,再通过%s
控制符解析成字符串,此时程序是可以正常运行的并输出viewDidLoad
回到问题本身,去掉super调用后,_name
中的数据究竟是什么?由于内部存储着viewDidLoad 对应的ASCII码,猜测 _name 中的数据应该是viewDidLoad 对应的 SEL,再次运行程序后做如下操作验证:
显然这个猜测是正确的。
0x03 Xcode中参数压栈顺序
做个简单的测试:
void test(int a, int b) {
int c = 1;
}
test(2, 3);
控制台作如下操作:
可以看到地址大小关系为a>b>c
,并且这3个变量的地址是连续的。可见Xcode中的参数是从左往右压栈的。
上文已经验证去掉super时,得到的其实是调用者的SEL,这就是隐藏参数中的SEL(因为参数从左往右压栈,self先入栈SEL后入栈,索引SEL与cls相邻)
0x04 回归
既然如此,那么为什么调用super的时候会打印出 vc的实例,按照隐藏参数来说,不应该是SEL吗?
将代码改成原貌,并在[((__bridge id)obj) print]
处打上断点,运行程序后做如下操作:
获取obj地址,查看偏移8字节后的值(跳过isa)。发现图中两处
1、2
对应标记的地址相同,而且就是self与ViewController的类。此处偏移8字节后的值其实就是super,具体可见我的另一篇文章 《对super关键字的小验证》
0x05 小结
本题调用super时,对应找到的是super关键字生成的结构体的第一个成员变量,即消息接收者self。不调用super时找到的是对应调用函数的SEL(参数从左向右压栈,这里的SEL就是隐藏参数中的SEL),由于print中使用的是%@
控制符,但此时对应找到的是字符串,由此导致崩溃,如果改成NSLog(@"%s", (__bridge void *)_name)
,仍然可以正常调用,此时输出viewDidLoad
。
Have fun!