iOS进阶 — 对象的本质

对象的本质

前言

之前的两篇文章 对象alloc流程对象及结构体内存对齐 分别探究了对象的开辟以及对象的内存对齐相关的内容,但是关于对象的本质还未探索过,本篇文章我们一起探究下对象到底是什么。本篇文章的主线是对象的底层本质及如何与类进行关联,主要分为以下几部分:

  • 对象的本质

    • Clangxcrun 简介及简单使用
    • 对象在底层的表现形式
    • 验证那就是我们要找的对象
  • 对象如何与类进行关联

    • 对象的 isa 指针
    • 联合体及位域简介
    • isa_t 实现原理简介
    • isa推导类的两种方法 — mask 与运算和 isa 位运算

一、对象的本质

首先创建一个工程(maxOS或者iOS工程均可),然后创建 BPPerson 类,其 .h 文件 和 .m文件如下图所示:

Snip20210615_1.png

Snip20210615_2.png
这是一个简单的类,包含几个属性和成员变量以及一个实例方法和类方法。对于这样一个类该如何分析呢,首先我们看一下其父类 NSObject,可以看到其包含一个 Class 类型的 isa 变量。

Snip20210615_5.png
当我们想跟进去看看 Class 是什么类型时,却跟不进去了(在objc源码可以查看,但此处没有源码)。

不过,我们还可以通过 Clang 将 BPPerson.m 编译成 cpp 文件,查看其 C++ 底层的实现。这里先简单介绍下 Clangxcrun

1.1 Clang 及 xcrun 简介

当我们写好代码后,完成build之后,就会生成一个 Mach-O 格式的可执行文件,这个过程其实是由编译器完成。编译器主要完成的工作包括:

  • 词法分析
  • 语法分析
  • 语义分析
  • 生成中间代码
  • 代码优化
  • 生成目标代码

关于编译器,我们平时总是听到两个词,编译器前端编译器后端。这里 “前端” 是指编译器对程序代码进行理解分析的过程,这一阶段主要跟程序语言有关,比如 OC 和 Swift。而 “后端” 是指生成目标代码的过程,这一阶段与目标机器有关。

由以上工作流程可以看出,编译器前端主要完成的是 生成中间代码之前 的工作,而 编译器后端主要是完成 中间代码之后 的工作。

我们在进行iOS开发时所使用的编译器是 LLVM,而 Clang 是基于 LLVM 实现的编译器前端,百度百科解释如下:

Clang是一个由Apple主导,由C++编写、基于LLVM、发布于LLVM BSD许可证下的C/C++/Objective-C/Objective-C++编译器。它与GNU C语言规范几乎完全兼容(当然,也有部分不兼容的内容,包括编译命令选项也会有点差异),并在此基础上增加了额外的语法特性,比如C函数重载(通过__attribute__((overloadable))来修饰函数),其目标(之一)就是超越GCC。

弄懂了什么是 Clang,那 xcrun 是什么呢? 其实通过 xc 这个缩写,我们就可以看出 xcrun 应该跟 XCode 有关。事实上两者确有关系, xcrun 是在 Clang 的基础上封装了一些命令,XCode 在安装时就会顺带安装这些 xcrun 命令。我们可以在终端中输入 man clang/xcrun 查看命令的意义及参数,下面看几个常用的命令:

// clang 命令
clang -rewrite-objc // 语言是 OC
      -fobjc-arc    // 支持 ARC
      -fobjc-runtime=ios-13.0.0 // 系统版本 
      -isysroot / Applications/Xcode.app/Contents/Developer/Platforms/ iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator13.0.sdk  // SDK版本,如果是真机这里替换成真机的路径即可
      BPPerson.m   // 要编译的文件
      
// xcrun 命令
// -sdk:使用真机或模拟器的sdk
// -arch:使用什么架构 x86_64 或者 arm64 等
// xxx -o XXX :-o是以什么格式输出, xxx是要编译的文件,XXX是编译生成什么文件
xcrun -sdk iphonesimulator clang -arch arm64 -rewrite-objc BPPerson.m -o BPPerson.cpp // 模拟器命令
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc BPPerson.m -o BPPerson.cpp // 真机命令
复制代码

1.2 BPPerson.cpp文件探究

这里使用真机的 xcrun 来对 BPPerson 进行探究。进入工程目录下 BPPerson.m 所在目录,在终端中运行上面的真机命令,这时会发现新增了一个 BPPerson.cpp 文件,如下图所示:
Snip20210615_28.png
打开这个 cpp 文件,可以发现文件很大很大,有几万行代码,但是我们没有必要一行行的阅读,只需要从中找到我们的关键信息 BPPerson 即可。在文件中全局搜索 BPPerson,我们可以发现如下一段代码:
Snip20210615_30.png
这段代码中,我们发现在 BPPerson_IMPL 结构体中包含了 grade、bgName、bpTitle、bpAge 等成员,这些成员与我们在类中定义的属性和成员变量是一致的,我们可以猜测这里就是 BPPersonC++ 层的表现形式。为了证明不是巧合,我们给 BPPerson 再增加一个 double 类型的属性 weight,然后重新执行命令,再次编译,得到结果如下:
Snip20210615_32.png
我们发现这里确实就是 BPPerson 的定义,并非巧合。在上面两个结果图中,我们都发现有一个 NSObject_IVARS 成员,这个成员也是一个结构体,但是具体是做什么的呢,我们通过 NSObject_IMPL { 搜索下其结构体定义,发现如下:
Snip20210615_33.png
这里可以看出 NSObject_IVARS 其实就是包含 isa 的结构体。并且我们还发现了 NSObject 和 BPPerson 都有一个类型定义 typedef struct objc_object NSObject;typedef struct objc_object BPPerson;,都对应了 objc_object 结构体。我们继续搜索其定义,又可以发现如下代码:
Snip20210615_34.png
这里我们发现我们平时所用的 Class 其实就是一个 objc_class 指针,而 id 之所以可以不带 * 符号,也是因为它是一个 objc_object 指针。

以上是编译生成的 BPPerson.cpp 文件中看到的,对象在底层的定义,下面我们打开 objc源码,查看对象的具体定义。在 cpp 文件中,我们最终探究到了 objc_objectobjc_class,在源码中全局搜索 objc_object {,寻找其定义位置,我们发下如下结果:
Snip20210615_36.png
我们发现 objc_class 其实是继承自 objc_object,点进 objc_object 的定义,发现其只包含一个 isa 指针:
Snip20210615_37.png
由此也验证了我们在 BPPerson.cpp 中的探索。

结论 : 对象在底层其实也是一个结构体。BPPerson继承自NSObject。NSObject包含一个 Class isa 成员变量,Class其实是一个 objc_class 指针, objc_class继承自 objc_object

二、isa — 对象和类的关联

在上一节的探索中,我们发现有一个成员变量屡次被提到,这个变量即 isa,而我们至今却不知道 isa 的具体作用。

还记得在探索对象的alloc流程时,有一个 isa的初始化流程我们并未探索,我们就以此为切入点来探索下 isa 。打开源码,从isa 初始化开始,一步步进入最终,我们发现了 initIsa 函数的实现,代码如下:

inline void 
objc_object::initIsa(Class cls, bool nonpointer, UNUSED_WITHOUT_INDEXED_ISA_AND_DTOR_BIT bool hasCxxDtor)
{ 
    ASSERT(!isTaggedPointer()); 
    
    isa_t newisa(0);

    if (!nonpointer) {
        newisa.setClass(cls, this);
    } else {
        ASSERT(!DisableNonpointerIsa);
        ASSERT(!cls->instancesRequireRawIsa());


#if SUPPORT_INDEXED_ISA
        ASSERT(cls->classArrayIndex() > 0);
        newisa.bits = ISA_INDEX_MAGIC_VALUE;
        // isa.magic is part of ISA_MAGIC_VALUE
        // isa.nonpointer is part of ISA_MAGIC_VALUE
        newisa.has_cxx_dtor = hasCxxDtor;
        newisa.indexcls = (uintptr_t)cls->classArrayIndex();
#else
        newisa.bits = ISA_MAGIC_VALUE;
        // isa.magic is part of ISA_MAGIC_VALUE
        // isa.nonpointer is part of ISA_MAGIC_VALUE
#   if ISA_HAS_CXX_DTOR_BIT
        newisa.has_cxx_dtor = hasCxxDtor;
#   endif
        newisa.setClass(cls, this);
#endif
        newisa.extra_rc = 1;
    }
    isa = newisa;
}
复制代码

在这一段源码中,我们可发现两个比较重要的点 nonpointerIsaisa_t,我们先来介绍一下 isa_t,点进去可以看到源码如下:
Snip20210615_38.png
在进行这部分的解读前,我们先介绍两个概念 共用体union位域

2.1 共用体和位域

1、共用体

共用体是一种数据格式,它能够存储不同的数据类型,但只能同时存储其中的一种类型。共用的的声明语法与结构体类似,我们新建一个共用体和一个结构体,通过对比发现共用的的特点。

Snip20210616_40.png
Snip20210616_39.png

通过对比发现,结构体可以同时存储 int、long 和 double 等不同类型的成员,并且会给这些成员都分配内存,具体可见结构体内存对齐,但是共用体虽然也能存储不同类型成员,但是这些成员共用一块内存,而且共用体的大小是最大成员的大小。还有一个特点就是当执行 a = 97时,b的值也被改为97;当执行b = 20时,a的值也被改为20,即共用体成员是互斥的,同一时间只能保存一成员的值。

总结下共用体特点:

  • 共用体可以存储不同类型的成员,且成员间共用一块内存
  • 共用体共用体大小即为内部最大成员的大小
  • 因为共用一块内存,所以共用体成员互斥,同一时间只能保存一个成员的值

2、位域
如果一个结构体的成员只是表示开关量,即只表示一个布尔值,那么其实只需要一个 bit 位即可表示,但是实际上却要占用更大的空间。比如下面的例子:

Snip20210616_7.png

Snip20210616_8.png

Snip20210616_9.png

由上面的事例可以看出,结构体 StructDirect 占用的大小为 16 字节,而实际上其成员表示的各个方向可能只占用一个 bit 即可,这样就大大浪费了空间。这是我们就可以采用 Direct 这种位域的方式,只占用 4 个字节,合理利用了内存。

位域的声明和结构体类似,不过在每个成员的后面可以用 : 指定该成员占用多少位,最终位域的大小与成员所占据的大小有关。
比如假定例子中 Direct 成员占据 bit 不超过 32 个,则占据内存就是 4 字节,如果超过 32bit 位,哪怕只包含 33bit,则会占用 8 字节空间。

2.2 isa_t 和 nonpointerIsa

介绍完 共用体 和 位域 的概念,我们再来看下 isa_t。 isa_t 内部包含两个成员 Class cls;ISA_BITFIELD。 根据共用体的特性,isa要么只表示一个 Class 指针,要么就是一个位域 ISA_BITFIELD

这两个成员有什么区别呢?其实在最开始 isa 只表示一个指向,其作用是将对象和类关联起来,也就是一个 Class isa 就够了。但是仅仅表示一个指向,就占用了 8 个字节,实在有些浪费内存。后来苹果就做了优化,isa 不单单只表示一个指向,而是采用一个位域 ISA_BITFIELD 的方式,由其中的 某一段bit位 表示这个指向关系,其它的 bit位 可以存储其它的信息,这就是前文所说的 nonpointerIsa

这里 nonpointerIsa 是可以配置的,通过设置环境变量 OBJC_DISABLE_NONPOINTER_ISA(设置方式如下图),可以决定是否禁用 nonpointerIsa。如果禁用,则 isa 就表示一个指向关系,即 Class isa,否则就表示位域 ISA_BITFIELD

Snip20210617_41.png

2.3 通过 isa 如何找到对应的类

上一小节说到 ISA_BITFIELD 会包含指向信息及其它的信息,本节我们就探究一下。由于不同架构下,所占用到bit位大小不同,ISA_BITFIELD 的定义在不同架构下也是不同的,这里我们以 x86_64 下为例进行探索,关于 ISA_BITFIELD 的定义如下:

#   define ISA_MASK        0x00007ffffffffff8ULL
#   define ISA_MAGIC_MASK  0x001f800000000001ULL
#   define ISA_MAGIC_VALUE 0x001d800000000001ULL
#   define ISA_HAS_CXX_DTOR_BIT 1
#   define ISA_BITFIELD                                                        \
      uintptr_t nonpointer        : 1;                                         \
      uintptr_t has_assoc         : 1;                                         \
      uintptr_t has_cxx_dtor      : 1;                                         \
      uintptr_t shiftcls          : 44; /*MACH_VM_MAX_ADDRESS 0x7fffffe00000*/ \
      uintptr_t magic             : 6;                                         \
      uintptr_t weakly_referenced : 1;                                         \
      uintptr_t unused            : 1;                                         \
      uintptr_t has_sidetable_rc  : 1;                                         \
      uintptr_t extra_rc          : 8
#   define RC_ONE   (1ULL<<56)
#   define RC_HALF  (1ULL<<7)
复制代码

根据源码定义,我们可以看到在 arm64 的真机下,ISA_BITFIELD 各部分占位情况。

  • nonpointer : 标记位,表示是否是nonpointer
  • has_assoc : 标记位,表示是否有关联对象
  • has_cxx_dtor :表示是否有析构函数,这个函数指的是在 C++ 层的函数,当我们在 OC 层是否对象时,对象不一定马上释放,只有调用了 C++ 层的析构函数时,才是真正释放
  • shiftcls : 类信息
  • magic : 魔数,用于调试器判断当前对象是真的对象还是没有初始化的空间
  • weakly_referenced : 弱引用,标志对象是否被指向或者曾经指向一个 ARC 的弱变量,

没有弱引用的对象可以更快释放。

  • unused :对象是否被释放
  • has_sidetable_rc : 当对象引用技术大于 10 时,则需要借用该变量存储进位
  • extra_rc :表示该对象的引用计数值

本小节探索 isa 如何关联到类,所以我们只要取到 shiftcls 的值,就可以验证如何是否关联到对应的类。还是以 BPPerson 为例,验证方式及结果如下图:

Snip20210617_43.png

Snip20210617_42.png

由结果看通过对 isa 进行 右移 & 左移 操作,最终确实验证了 shiftcls 代表的对象与类的关联关系,下面我们来分析下这一过程,分析流程图如下:

Snip20210617_45.png

其实系统获取的方式更加简单,我们通过阅读系统的 getClass 方法,看下系统如何获取类信息,其源码如下:
Snip20210617_46.png

我们发现系统是通过简单的与运算来获取的,将 isa 与 ISA_MASK 做一次与运算就可以得到类信息,在 x86_64ISA_MASK 的值为 0x00007ffffffffff8ULL,ULL表示 unsigned long long,所以其值为 0x00007ffffffffff8,通过 p/t 我们看下这个值的二进制的值如下:

Snip20210617_47.png
也就是说 0x00007ffffffffff8ULL 的低3位和高17位为0,中间44位为1,正好对应 shiftcls 的44位。我们都知道任何数与 1 做与运算,最后得到的是这个数自己,所以通过与 0x00007ffffffffff8ULL 做与运算就的得到了 shiftcls 的 44 位信息,最终结果也证明了这一结论。
Snip20210617_48.png

三、总结

本篇主要探索了一下几点:

  • 1、通过 objc源码 及 编译的 cpp 文件,得到对象的本质是结构体
  • 2、通过 objc源码 找到了 isa 的定义
  • 3、通过 位移运算与运算 验证了isa 如何将对象与类关联。

以上即为本篇探索的内容,至此对于对象的探索告一段落,后续将展开关于类的探索。欢迎大家继续关注,也欢迎大家的建议和指正。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享