iOS的底层探究--------对象的本质

753 阅读12分钟

作为一名iOS开发人员,每天都是面向对象开发,可以说,对象纵横交错穿插在我们的工程项目中,跳跃在指尖的敲击下。平时十分不经意,突然之间,来了个灵魂的拷问,什么是对象?

脑瓜子突然嗡嗡的。。。。

想起刚开始学iOS时,创建的Person对象。。。。好像没啥印象了,😁,那么要探究什么是对象,也就是得探究对象的本质

对象的本质

在将对象之前,我们先了解一个工具-----clang(用来看底层的源码结构)

1 对象的底层是结构体

1.1 什么是clang

Clang是⼀个C语⾔、C++、Objective-C语⾔的轻量级编译器。源代码发布于BSD协议下。Clang将⽀持其普通lambda表达式、返回类型的简化处理以及更好的处理constexpr关键字。

也就是说,Clang是⼀个由Apple主导编写,基于LLVMC/C++/Objective-C编译器。

好,clang就介绍到这里~~biu ~~ biu ~~ biu ~~

1.2 如何获得.cpp文件

下面我们进入主题。老规矩,代码走一波😄

#import <Foundation/Foundation.h>
#import <objc/runtime.h>

@interface TestPerson : NSObject

@property (nonatomic, copy) NSString *test_nickName;

@end

@implementation TestPerson

@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        // insert code here...
        NSLog(@"Hello, World!");
    }
    return 0;
}

建立一个TestPerson对象,然后给这个对象一个成员变量test_nickName

创建好之后,对这个工程show in finder,接着打开终端,如下如: 92F2472F-75F1-4A8D-A548-57E778E933D3.png

clang的终端命令:clang-rewrite-objc main.m -o main.cpp把⽬标⽂件编译成c++⽂件,(这里的目标文件是main.m编译到main.cpp中)

注:如果在我们自己创建的iOS工程中,要想把一个viewcontroller.m文件编译成c++文件,就会报UIKit错误
如果遇到UIKit报错问题,就执行下面这条命令,需要更改iOS的版本,还有最后的文件名(main.m)

clang -rewrite-objc -fobjc-arc -fobjc-runtime=ios-13.0.0-isysroot/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.0.sdk main.m

或者是直接使用 xcrun 命令

xcode 安装的时候顺带安装了 xcrun 命令, xcrun 命令在 clang 的基础上进⾏了⼀些封装,要更好⽤⼀些

下面的两种命令更加的简单
xcrun -sdk iphonesimulator clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp(模拟器)

xcrun -sdk iphoneosclang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp(⼿机)

那么执行clang命令之后,就能得到一个main.cpp文件 272BB691-AE61-4F59-8B20-F91B5CB952D7.png

1.3 对象的底层是结构体

打开main.cpp文件,全文索引之前创建的TestPerson对象 4448A0BF-7F29-4A9F-B91E-FC7E1E4DE08A.png

可以看到,TestPerson对象是有一个结构体组成的。

由这里,就能得出,对象在底层的本质就是一个结构体。

当前的结构体里面,又嵌套了一个结构体,相当于一个伪继承(不是真正的继承,但是在c++里面是可以的),它继承了NSObject_IMPL,那么NSObject_IVARS就是成员变量的isa(这里可以全文索引 NSObject_IMPL { ,就能找到这个结构体struct71EFF76E-DB11-440D-BFB4-CD0B177B9DDD.png

到这里,就能确定TestPerson对象里面有一个isa指针,还有自己声明的成员变量。

再根据TestPerson是结构体的这张截图,在113495行,根据这行代码:typedef struct objc_object TestPerson;可以看出TestPerson它本质的类的类型是objc_object,而我们OC层面上看到的,TestPerson是继承NSObject,但是在底层上面,其实就是 objc_object

与此同理,就像class类型,我们可以索引class { 就能找到 B40D7DA4-C5B1-4C85-8939-F8F2CC7E8BDE.png

他的底层竟然也是带着objc_ 的,那么再接着索引objc_ class 14A03AFA-D69E-4AF4-A986-EED4871E289F.png 从这里就可看到class类型,其实就是objc_class * 类型,意味着当前的class是一个结构体指针,而Class只是一个别名而已

再比如id类型,使用id引用的时候,是不需要加*的,为什么了? EAD2C7F0-7B78-4771-9979-E53921DC2079.png 因为它本身就是objc_object *

那么到了这里,是不是就很清晰了。 111623724138_.pic.jpg

1.4 setter方法和getter方法的底层

来,再回到我们的TestPerson上面来,在main.cpp文件中,还能找到成员变量test_nickNamegetter方法和setter方法。 A3DC4EB2-20F4-4428-B058-EFAD0F1FE36F.png113514行和113517行,其实就是test_nickNamegetter方法和setter方法。

就比如,113514行这个getter方法,我们在上层进行调用的时候,是不显示(TestPerson * self, SEL _cmd)这些参数的,那么这些参数,其实就是这个方法的隐藏参数。从这个getter方法的返回值的方式 return (*(NSString **)((char *)self + OBJC_IVAR_$_TestPerson$_test_nickName)); 是通过self(TestPerson)加上一个test_nickName指针地址的偏移值,来获取test_nickName的值,再通过(*(NSString **)还原为string类型。下面这个草图,就更加形象明确了:

4854A73C-6972-4AA0-81B5-2553663A7224.png

取值的过程就是:先拿到当前成员变变量的地址,再去取这个地址里面所存的值

113517行的setter方法,也是一样的方式进行存值。

分析到这里,是不是有种恍然的感觉,心底想呼喊一下: 131623724770_.pic.jpg

嗯哼、嗯哼、嗯哼。。。稍安、勿躁、静坐 ~ ~ ~ ~ 😁,精彩继续 biu ~

我们从刚刚的分析,知道TestPerson对象结构体中,除了变量test_nickName外,还有个isa指针。这个isa指针里面有什么了?,接下来就开始isa的表演(无广告连接😁)

2 ISA指针

2.1 结构体、联合体、位域

结构体我们比较熟悉,那么联合体和位域是啥子嘞?嘿嘿,直接上代码: E64412F8-E145-4968-871A-740D022FFFE9.png 从打印中可以看出,这个TestCar1结构体的内存大小为4,因为每个布尔值的内存大小为1,所以总内存为4

注:这里和成员变量字节内部对齐是有区别的,像通常说的8字节对齐,是关于成员变量在OC层面对齐。而在结构体里面,是最大的成员变量。所以,结构体和成员变量是不一样的。

结构体TestCar1,共4字节,每字节8位,共32(bit),如:0000 0000 0000 0000 0000 0000 0000 1111,开辟了32(bit),然而只使用了4bit用来存储,那么就剩余了28bit空着,这样就造成了浪费。只需要半个字节的空间,就足够了,也就是:0000 1111。那么就需要进行优化,就引出了一个词:位域

2.1.1 位域

既然系统是自动分配了这的大的内存出来,假如,给结构体里面的布尔值变量指定位置大小了?会不会有所优化?根据下图来看下: C896D058-9D5C-4C89-A7A4-29BC1917679B.png 从打印结果可以看出,相比于结构体TestCar1,结构体TestCar2占用的内存空间更小,就是:0000 1111,这样就大大的优化了内存空间。

再进行测试下,把TestCar2改成 1C014E96-B068-47EF-A99D-3EF1FBFC390C.png 打印的结果就为: 5C8959E2-4ABB-4E93-AC16-2B8D79D9707C.png

结构体TestCar2占用了2字节内存,我们在通过二进制码看一下4个布尔值分别占据的位置: 0AF9234F-D64A-467E-B153-F52FD3B93B77.png

注:单个变量的最大位置数只能是8(bit)。

举个实例: 在使用代理的时候:

@protocol TestProtocol <NSObject>

@optional

- (void)methodA;
- (void)methodB;
- (void)methodC;

@end

我们在进行调用的时候通常都要判断代理对象是否已经实现该方法,代码如下:

- (void)methodA_CallBack {
    if (_delegate && [_delegate respondsToSelector:@selector(methodA)]) {
        [_delegate performSelector:@selector(methodA)];
    }
}

这样会存在一个性能问题,就是每次都要通过消息机制去确认delegate是否已经实现该方法,虽然OC的消息机制中会将方法实现缓存到类对象的方法缓存中,但如果调用的比较频繁的时候,还是会影响性能。 所以我们可以通过位域进行改进

改进后的代码:

@interface TestClass ()
{
    struct {
        unsigned int methodAFlag : 1;
        unsigned int methodBFlag : 1;
        unsigned int methodCFlag : 1;
    } _delegateFlags;
}

@end

@implementation TestClass

- (void)setDelegate:(id<TestProtocol>)delegate {
    _delegate = delegate;
    _delegateFlags.methodAFlag = [_delegate respondsToSelector:@selector(methodA)];
    _delegateFlags.methodBFlag = [_delegate respondsToSelector:@selector(methodB)];
    _delegateFlags.methodCFlag = [_delegate respondsToSelector:@selector(methodC)];
}

- (void)methodA_CallBack {
    if (_delegateFlags.methodAFlag) {
        [_delegate performSelector:@selector(methodA)];
    }
}

- (void)methodB_CallBack {
    if (_delegateFlags.methodBFlag) {
        [_delegate performSelector:@selector(methodB)];
    }
}

- (void)methodC_CallBack {
    if (_delegateFlags.methodCFlag) {
        [_delegate performSelector:@selector(methodC)];
    }
}
@end

这样的话,方法判断只会在设置代理的时候进行一次,并将值保存在了缓存(位域)中,省去了很多次的方法查询,提高了效率。

2.1.2 联合体

讲完位域,接着引入另一个知识点:联合体。创建一个TestTeacher1结构体: C8CEDBAE-01A6-4136-BA8A-47861B1F53B1.png 通过3个断点,还有断点处的打印来看,最开始,都是值为0的,然后再进行赋值。

接着,使用联合体创建TestTeacher2 E7A4BBD6-58D0-4C19-8572-4161F13A627D.png 同样的3个断点,当执行到断点①时,和TestTeacher1的情况是差不多的,但是从断点②开始,就有了比较大的差别。此时只是给name赋了值,ageheight都没有,但是这两者却有值存在,那么这些值,就是我们通常说的脏数据了(也就是占位符)。然而执行到断点③age赋值了,但是name的值,却被置空了。

这个就是联合体的特性:变量之间是互斥的

结构体(struct)中:所有变量是“共存”的
                           优点是“有容乃⼤”,全⾯;
                           缺点是struct内存空间的分配是粗放的,不管⽤不⽤,全分配。

联合体(union)中:是各变量是“互斥”的
                            缺点就是不够“包容”;
                            但优点是内存使⽤更为精细灵活,也节省了内存空间;

看到这里,也许会有老铁问:不是isa的表演吗?咋还跟我们扯啥联合体、位域。这不是挂羊头卖狗肉吗?(抵制黑心商😄😄)

商家郑重承诺:并不是的啊,那是因为isa里面涉及到了这些。 161623728209_.pic.jpg

2.2 isa指针

2.2.1 nonPointerIsa的分析

nonPointerlsa的分析,就得到objc的源码中去(源码下载地址,在iOS的底层探究--------alloc文章里面)。前面,我们讲的alloc的底层源码的时候,alloc --> _objc_rootAlloc --> callAlloc --> _objc_rootAllocWithZone --> _class_createInstanceFromZone这么一个流程。

_class_createInstanceFromZone还有一个重要的点,那就是 obj->initIsa(cls);,这句代码,它把从堆内存申请的结构体指针和当前的cls绑定在一起。看底层代码: 50196BBF-8A38-434B-B639-74E3DEEDA444.png

进入initIsa方法里面 2BDB6D15-EE57-484B-82D6-10EC819C86C4.png 在这个方法的结尾: 7FD51109-C735-4024-BB35-CC3D53533706.png

在这里,就出现了isa就是isa_t类型的,在进入isa_t A91ADF93-5412-4ADB-BC42-76017BDF1FAF.png

可以看出,isa是一个联合体。

在普遍的表现一个类的地址时,会出现一个词:nonPointerIsa。就比如一个类,也就可以作为一个指针,类上面是可以有很多内容是能够被存储的。类的指针是8字节,8字节 * 8 bit = 64 bit(64位)。那么如果只是用来存储一个指针,就会造成大大的浪费,因为每个类都有一个isa指针。苹果就对这个isa做了优化,就把和类息息相关的一些内容存在里面,比如:是否正在释放、引用计数、weak、关联对象、析构函数等等(所以,OC在底层,就是C++,像OC的释放,并不是真正的释放,而是其下层的C++释放,才是真正的释放)。这些都和类先关,所以,可以把这些内容存储到那64位里面去。那么就出现了nonPointerIsa。nonPointerIsa也不是一个简单的地址。我们可以通过查看isa_t的位域,来了解里面存的是什么。

X86_64中: D2947E6C-DC23-4EA7-92A1-01007F13B02F.png

arm64中:

#if__arm64__
#define ISA_MASK         0x0000000ffffffff8ULL 
#define ISA_MAGIC_MASK   0x000003f000000001ULL 
#define ISA_MAGIC_VALUE  0x000001a000000001ULL 
#define ISA_BITFIELD 
uintptr_t nonpointer:1;
uintptr_t has_assoc:1;
uintptr_t has_cxx_dtor:1;
uintptr_t shiftcls:33;
uintptr_t magic:6;
uintptr_t weakly_referenced:1;
uintptr_t deallocating:1;
uintptr_t has_sidetable_rc:1;
uintptr_t extra_rc:19 
#define RC_ONE (1ULL<<45)
#define RC_HALF (1ULL<<18)

nonpointer:表示是否对isa指针开启指针优化 0:纯isa指针,1:不⽌是类对象地址,isa中包含了类信息、对象的引⽤计数等;

has_assoc:关联对象标志位,0没有,1存在;

has_cxx_dtor:该对象是否有C++或者Objc的析构器,如果有析构函数,则需要做析构逻辑,如果没有,则可以更快的释放对象;

shiftcls:存储类指针的值。开启指针优化的情况下,在arm64架构中有33位⽤来存储类指针;

magic:⽤于调试器判断当前对象是真的对象还是没有初始化的空间;

weakly_referenced:志对象是否被指向或者曾经指向⼀个ARC的弱变量,没有弱引⽤的对象可以更快释放;

deallocating:标志对象是否正在释放内存;

has_sidetable_rc:当对象引⽤技术⼤于10时,则需要借⽤该变量存储进位;

extra_rc:当表示该对象的引⽤计数值,实际上是引⽤计数值减1,例如,如果对象的引⽤计数为10,那么extra_rc9。如果引⽤计数⼤于10,则需要使⽤到下⾯的has_sidetable_rc

以arm64为例,堆中,8字节对齐排列,8字节 * 8 bit = 64 bit(64位)

那么,nonpointer[1]号位置,has_assoc[2]号位置,has_cxx_dtor[3]号位置,shiftcls[4 ~ 36]号位置,magic[37~42]号位置,weakly_referenced[43]号位置,deallocating[44]号位置,has_sidetable_rc[45]号位置,extra_rc[46 ~ 64]号位置。

2.2.2 isa的位运算

X86_64为参照: D2947E6C-DC23-4EA7-92A1-01007F13B02F.png 根据isa的位域存储内容,类的主体部分内容存储在shiftcls位置上,那么在shiftcls位置段的一边是3个位置,另外一边是17个位置。想要查看shiftcls里面是什么,通过平移可以得到,下面通过一个图来说明下: 37824FE1-70B3-4946-988F-5A7E71D634C7.png 那么根据这个步骤,在工程中进行操作: 576388E6-BF09-48AE-ACF7-1563DA614BCE.png 可以得出,shiftcls存放的是对象的TestMan.class的全部信息了。

3 initnew

3.1 init方法底层

objc的源码中,main文件里面,初始化一个TestPerson类:TestPerson *p = [[TestPerson alloc] init] ;,那么可以通过点击init进入到其底层源码中。因为已经进过alloc了,所以是一个对象了 B34E0EB3-FECC-4A06-A526-B106DBA7CA09.png

接着进入_objc_rootInit方法 7CF0A888-3BF0-47A8-B81E-B6B4D52E4017.png 看上去,好像什么都没做,直接返回了自己。

那么init具体做了什么了? init他是一个初始化方法、工厂设计模式、构造函数,用来给子类进行重写。在我们平常的开发过程中,经常会重写init方法。使得初始化方法可以根据不同情况进行重新构造。其实就是提供接口,便于扩展。

3.2 new方法的底层

同样的方式方法,或者直接在源码中搜索new {,就能找到new的底层实现,如图: 240ECAF1-56B1-4991-AEBA-1004D0BFE8E7.png 其实,new就是alloc + init

就比如,创建一个TestPerson类: 09B828F1-D43E-453F-88BD-826BB4BB3E2D.png

main.m文件里面,分别用alloc + init方式和new方式初始化TestPerson279D7798-1D0B-41C6-80F2-08A0938B212C.png 打印的结果,无论是地址,还是赋值,都是一样的,new = alloc + init

通过汇编调试,也是一样的: EBBA5BAD-F9A4-46AC-AB04-3EF7BA83A608.png

objc源码中搜索objc_opt_new image.png 也是一样的。

到了此处,欧耶,大功告成,对象的本质的探究,就完成了,有木有点收获啊,(不许没有啊<( ̄▽ ̄)/)

感谢各位的光临~ ~ ~ ~ ~ ~ 71623057628_.pic.jpg