那道值得思考的iOS面试题

前言

记得去年7,8月份的时候就看到过这么一篇文章,当时没花多少时间,看得懵懵懂懂的。结果昨天机缘巧合又在cocoachina上看到有关这个题目的另一篇帖子.这两篇文章解释的都是sunnyxx出的神经病院objc runtime入院考试中的第四道题。看完之后鄙人更懵逼了,可能是作者确实没有描述清楚,又或者是本人没有get到作者的点,反正对第二步的理解就是感觉不得要领,没有那种清楚知道的感觉。所以花了点时间好好梳理了一下,这里做下记录。


题目我就直接摘抄了:

//MNPerson
@interface MNPerson : NSObject
 
@property (nonatomic, copy)NSString *name;
 
- (void)print;
 
@end
 
@implementation MNPerson
 
- (void)print{
    NSLog(@"self.name = %@",self.name);
}
 
@end
 
---------------------------------------------------
 
@implementation ViewController
 
- (void)viewDidLoad {
 
    [super viewDidLoad];
     
    id cls = [MNPerson class];
     
    void *obj = &cls;
     
    [(__bridge id)obj print];
     
}

问输出结果是啥,会不会崩溃。


自己实验一下,就知道不会崩溃,打印结果是

2019-04-13 12:09:40.678424+0800 ZZTest[5431:86117] self.name = 

那么分析可以分两步进行:

  • 1.为啥能正常调用,不会崩溃?
  • 2.为啥打印的会是当前视图控制器?

一、先研究第1步:
先定义一个概念,组合键点击 = 按住control + command + 鼠标左键点击;
组合键点击[MNPerson class]中的class,可以看到这个类方法返回的是Class:

+ (Class)class OBJC_SWIFT_UNAVAILABLE("use 'aClass.self' instead");

再组合键点击Class,可以看到这些:

#if !OBJC_TYPES_DEFINED
/// An opaque type that represents an Objective-C class.
typedef struct objc_class *Class;

/// Represents an instance of a class.
struct objc_object {
    Class _Nonnull isa  OBJC_ISA_AVAILABILITY;
};

/// A pointer to an instance of a class.
typedef struct objc_object *id;
#endif

其实就是类和对象的基本定义了。可以看到id是objc_object结构体指针,而结构体本身只包含一个Class类型的isa成员变量。而Class又是objc_class结构体指针。再组合键点击objc_class,看看它的具体定义:

struct objc_class {
    Class _Nonnull isa  OBJC_ISA_AVAILABILITY;

#if !__OBJC2__
    Class _Nullable super_class                              OBJC2_UNAVAILABLE;
    const char * _Nonnull name                               OBJC2_UNAVAILABLE;
    long version                                             OBJC2_UNAVAILABLE;
    long info                                                OBJC2_UNAVAILABLE;
    long instance_size                                       OBJC2_UNAVAILABLE;
    struct objc_ivar_list * _Nullable ivars                  OBJC2_UNAVAILABLE;
    struct objc_method_list * _Nullable * _Nullable methodLists                    OBJC2_UNAVAILABLE;
    struct objc_cache * _Nonnull cache                       OBJC2_UNAVAILABLE;
    struct objc_protocol_list * _Nullable protocols          OBJC2_UNAVAILABLE;
#endif

为什么id是通用对象类型,可以强转任何对象?因为一个实例包含的全部信息都存储在它所属的类里,而每个类也是对象,即isa指针。也就是说isa指针如果指向A,那么它就是A类实例,如果指向B,那么它就是B实例。那么题目里的第二句代码:

id cls = [MNPerson class];

就可以解释为:将MNPerson类对象赋值给了cls。
题目的后两句句代码:

void *obj = &cls;
[(__bridge id)obj print];

理解为:C语言的obj指针指向了cls即MNPerson类对象,经过桥接转换为OC的id类型后调用print实例方法。我们知道一个类的实例对象的实例方法都是存在当前类中的,既然有了指向这个类对象的指针,那么调用类对象中存储的实例方法自然是顺理成章的了。

接下来分析第2步,为啥会打印当前视图控制器。
这里我们再分两小步进行分析:

  • 2.1实例方法print中,访问成员变量name的逻辑是怎么样的。
  • 2.2怎么访问到的self,即当前控制器变量。

这里说一个前提,一个类定义完成以后,那么它的内存布局就已经确定了。这也就是为什么不能给类别增加属性的原因。
2.1那么一个类定义的属性或者成员变量是怎么访问到的呢?这里做个小的试验:

@interface MNPerson : NSObject

@property (nonatomic, copy)NSString *name;
@property (nonatomic, copy)NSString *name2;

- (void)print;
- (void)test;
@end

@implementation MNPerson

- (void)print{
    NSLog(@"self.name = %@",self.name);
}

- (void)test {
    NSLog(@"self:%p",self);
    NSLog(@"self.name:%p",&_name);
    NSLog(@"self.name2:%p",&_name2);
}

给MNPerson新增一个name2属性和test实例方法,在viewDidLoad方法中访问:

- (void)viewDidLoad {
    [super viewDidLoad];
    
   MNPerson *p = [[MNPerson alloc] init];
    [p test];
    
//    NSString *test = @"777";

//    id cls = [MNPerson class];
//
//    void *obj = &cls;

//    NSLog(@"test:%p", &test);
//    NSLog(@"cls:%p", &cls);
//    NSLog(@"obj:%p", obj);

//    [(__bridge id)obj print];

}

运行结果为:

2019-04-13 15:15:38.910967+0800 ZZTest[7755:188927] self:0x600000d86180
2019-04-13 15:15:38.911093+0800 ZZTest[7755:188927] self.name:0x600000d86188
2019-04-13 15:15:38.911159+0800 ZZTest[7755:188927] self.name2:0x600000d86190

可以看到对象属性的内存地址跟对象的内存地址是连续的,每个占8字节,这里通过alloc生成的OC对象是占用的堆空间。这里访问属性的过程可以做一个合理的抽象,即通过当前对象的内存地址偏移n*8位去访问第n个成员变量。假如当前对象地址是0x600000d86180,那么它第一个成员变量的内存地址就是0x600000d86180 + 1 * 8 = 0x600000d86188。实例方法中访问成员变量的逻辑我们已经理清楚了。接着看2.2。

2.2 接下来,得另外做一个小试验。在[super viewDidLoad]后面插入一个test变量,将viewDidLoad方法中改成:

- (void)viewDidLoad {
    [super viewDidLoad];
    
    NSString *test = @"777";

    id cls = [MNPerson class];

    void *obj = &cls;

    NSLog(@"test:%p", &test);
    NSLog(@"cls:%p", &cls);
    NSLog(@"obj:%p", &obj);

    [(__bridge id)obj print];

}

打印结果为:

2019-04-13 15:47:33.303760+0800 ZZTest[8215:213054] test:0x7ffee7118308
2019-04-13 15:47:33.303911+0800 ZZTest[8215:213054] cls:0x7ffee7118300
2019-04-13 15:47:33.303983+0800 ZZTest[8215:213054] obj:0x7ffee7118300
2019-04-13 15:47:33.304062+0800 ZZTest[8215:213054] self.name = 777

我们知道函数或者方法中的局部变量是在栈中生成的,可以看到test,cls,obj地址是连续递减的,obj中装的是cls的内存地址,所以最后一句代码[(__bridge id)obj print];的意思就是将cls的内存地址作为入参调用print实例方法,根据第1步得出的经验,print实例方法中的访问逻辑是根据入参内存地址偏移即增加8位去访问,那么访问的地址就是0x7ffee7118300 + 8 = 0x7ffee7118308,即test变量。所以最后打印self.name = 777就解释通了。

那么回到最原始的题目,为什么cls的内存地址偏移8位会访问到self呢??
到目前为止,第一句代码[super viewDidLoad];还没有深究,那它的底层做了什么东西呢?
底层 - objc_msgSendSuper,
objc_msgSendSuper({ self, [ViewController class] },@selector(ViewDidLoad)),
等价于

struct temp = {
    self,
    [ViewController class] 
}
 
objc_msgSendSuper(temp, @selector(ViewDidLoad))

所以等于有个局部变量 - 结构体 temp,
结构体的地址 = 他的第一个成员的内存地址,这里的第一个成员是self。所以现在栈内元素内存地址由高到低就是:temp = self, cls,obj。根据cls的地址偏移8位,访问到的就是self。那么整个问题就解释通了。


cls变量直接指向了MNPerson类对象,所以能够调用到print实例方法;而实例方法中访问成员变量的逻辑是根据入参内存地址偏移8*n位来访问第n个成员变量;cls是在栈内生成的,栈的内存地址是从高位向低位排列。根据cls的地址偏移8位,刚好访问到了[super viewDidLoad]的隐藏参数局部变量结构体temp,temp的内存地址等于它的第一个成员变量的内存地址,即self的内存地址。所以最终访问到了self。

最后的疑惑
2019-04-13 16:22:34.490522+0800 ZZTest[8647:237822] cls:0x7ffeedb20308
2019-04-13 16:22:34.490632+0800 ZZTest[8647:237822] obj:0x7ffeedb20300
2019-04-13 16:22:34.490734+0800 ZZTest[8647:237822] self.name = 

可以看到self的内存地址跟cls的内存地址并不是偏移8位。。。这点目前我还没想通,个中原委还望知道的大神指点一二。

你可能感兴趣的:(那道值得思考的iOS面试题)