谈Objective-C类成员变量

#我是前言

Objective-C 是一门动态语言,所以它总是将一些决定工作从编译延迟到运行时,也就是说只有编译器是不够的,还需要一个运行时系统来执行编译后的代码。这就是 runtime 存在的意义,它是 Objective-C 框架的一块基石。
runtime 有两个版本:modeen 和 leagcy,我们现在使用的是 modern 版的。
本文 runtime 源码为objc4-646.tar.gz版本

在老版本的 runtime 中,如果修改了基类的成员变量布局(比如增加成员变量),子类需要重新编译。

父类NSObject,子类MyObject成员变量布局

如果苹果发布了新的 iOS SDK,NSObject 增加了几个成员变量,那么我们原先的代码将无法运行。因为 MyObject 成员变量布局在编译时就确定了,父类新增的成员变量的地址跟子类成员变量的内存区域重叠了。此时,我们只能重新编译 MyObject 的代码,程序才能在新版本系统上运行。如果 MyObject 存在于别人编写的静态库,那我们只能希望作者快点发布新版本了。

新版本后NSObject,MyObject的成员变量布局

非脆弱[Non-fragile]实例变量是新版 Objective-C 的一个新功能,应用于iPhone和64位Mac上。它们提供给框架开发者更多的灵活性,且不会失去二进制的兼容性

非脆弱成员变量

#如何寻址成员变量

点开 runtime 的源码,让我们找到 ivar 的定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
typedef struct objc_class *Class;
typedef struct objc_object *id;

// 类实例
struct objc_object {
private:
isa_t isa;
// ...省略
}

union isa_t
{
isa_t() { }
isa_t(uintptr_t value) : bits(value) { }

Class cls;
uintptr_t bits;
// ...省略
}

// 类定义
struct objc_class : objc_object {
// Class ISA;
Class superclass;
cache_t cache; // formerly cache pointer and vtable
class_data_bits_t bits;
// ...省略
}

struct class_data_bits_t {

// ...省略
public:
class_rw_t* data() {
return (class_rw_t *)(bits & FAST_DATA_MASK);
}
// ...省略
}

struct class_rw_t {
uint32_t flags;
uint32_t version;

const class_ro_t *ro;
// ...省略
}

struct class_ro_t {
uint32_t flags;
uint32_t instanceStart;
uint32_t instanceSize;
#ifdef __LP64__
uint32_t reserved;
#endif

const uint8_t * ivarLayout;

const char * name;
const method_list_t * baseMethods;
const protocol_list_t * baseProtocols;
const ivar_list_t * ivars;

const uint8_t * weakIvarLayout;
const property_list_t *baseProperties;
};

image

  • 每个 OC 类实例实际上都是一个内存上指向objc_object结构体的指针,成员变量 isa 有指向objc_class结构体的指针Class cls;
  • class_ro_t结构体中可以找到成员变量 const ivar_list_t * ivars,这个就是存储类所有成员变量的列表
  • class_ro_t结构体中成员变量const uint8_t * ivarLayout;const uint8_t * weakIvarLayout;的作用可以看一下孙源的这篇博客
1
2
3
4
@interface MyObject : NSObject {
NSString *_age;
}
@end

使用 clang -rewrite-objc MyObject.h 将代码转化成 C++ 实现,你可以看到编译后的 MyObject 实例的内存布局:

1
2
3
4
5
6
7
struct MyObject_IMPL {
struct NSObject_IMPL NSObject_IVARS;
NSString *__strong _age;
};
struct NSObject_IMPL {
__unsafe_unretained Class isa;
};

类实例内存布局

ivar_list_t 结构体的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
struct ivar_list_t {
uint32_t entsize;
uint32_t count;
ivar_t first;
};

struct ivar_t {
int32_t *offset;
const char *name;
const char *type;
// ...省略
};

我们可以看到ivar_t有名为offset的成员变量,这个就是成员变量在对象中的位置偏移量。在应用启动时,如果父类size变大时,runtime 会通过修改 offset,更新成员变量的偏移量,来正确的找到成员变量的地址。


1
2
3
4
5
6
7
8
9
10
11
@interface MyObject : NSObject {
NSString *_age;
}
@end

@implementation MyObject
- (void)test
{
self -> _age = @"hhh";
}
@end

使用命令行clang -F -cc1 -S -emit-llvm -fblocks MyObject.m,将代码编译成 IR(intermediate representation)。
注意要加*-F*,好多人的博客里面都少了这个标志,会报错。在 stackoverflow 找到答案。
下面是编译后的代码:

1
2
3
4
5
6
7
8
@"OBJC_IVAR_$_MyObject._age" = hidden global i64 8, section "__DATA, __objc_ivar", align 8
// ...
%6 = load i64, i64* @"OBJC_IVAR_$_MyObject._age", align 8, !invariant.load !8
%7 = bitcast %0* %5 to i8*
%8 = getelementptr inbounds i8, i8* %7, i64 %6
%9 = bitcast i8* %8 to %1**
store %1* bitcast (%struct.__NSConstantString_tag* @_unnamed_cfstring_ to %1*), %1** %9, align 8

可以简化成如下的代码

1
2
3
int32_t g_ivar_MyClass_age = 8;  // 全局变量
*(NSString *)((uint8_t *)obj + g_ivar_MyObject_age) = @"hhh";

  • 编译时,LLVM 为每各类的每一个成员变量定义一个全局变量,用于存储该成员变量的偏移量
  • 根据成员变量的偏移量,可以直接找到成员变量的地址并赋值

这也是为什么结构体ivar_t的成员变量offsetint32_t *类型,因为保存的是该全局变量的地址。

#Non Fragile ivars

在前面部分我们已经知道该如何寻址成员变量,那么当基类的size变化时,runtime 是如何更新子类成员变量的offset呢?

在应用程序启动后,main 函数执行之前,runtime 在加载类的时候,会使用static Class realizeClass(Class cls)函数对类进行初始化,分配其读写数据的内存,返回类的真实结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
/* realizeClass
* Performs first-time initialization on class cls,
* including allocating its read-write data.
* Returns the real class structure for the class.
* Locking: runtimeLock must be write-locked by the caller
*/
static Class realizeClass(Class cls) {
class_rw_t *rw = cls->data();
//...省略
if (ro->instanceStart < super_ro->instanceSize) {
// Superclass has changed size. This class's ivars must move.
// Also slide layout bits in parallel.
// This code is incapable of compacting the subclass to
// compensate for a superclass that shrunk, so don't do that.
class_ro_t *ro_w = make_ro_writeable(rw);
ro = rw->ro;
moveIvars(ro_w, super_ro->instanceSize,
mergeLayouts ? &ivarBitmap : nil,
mergeLayouts ? &weakBitmap : nil);
gdb_objc_class_changed(cls, OBJC_CLASS_IVARS_CHANGED, ro->name);
layoutsChanged = YES;
}
// ...省略
}

struct class_ro_t {
uint32_t flags;
uint32_t instanceStart;
uint32_t instanceSize;
const ivar_list_t * ivars;
// ...省略
};

  • rw 是当前类的可读数据,ro 是类的 Ivar Layout,ro 的结构体定义在上面
  • 在初始化类时,如果父类 ro 的instanceSize比子类的instanceStart大的话,那么会调用moveIvars函数更新子类的instanceSize以及子类成员变量的偏移量

再让我们看一下 moveIvars 的源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
/***********************************************************************
* moveIvars
* Slides a class's ivars to accommodate the given superclass size.
* Also slides ivar and weak GC layouts if provided.
* Ivars are NOT compacted to compensate for a superclass that shrunk.
* Locking: runtimeLock must be held by the caller.
**********************************************************************/
static void moveIvars(class_ro_t *ro, uint32_t superSize,
layout_bitmap *ivarBitmap, layout_bitmap *weakBitmap)
{
rwlock_assert_writing(&runtimeLock);

uint32_t diff;
uint32_t i;

assert(superSize > ro->instanceStart);
diff = superSize - ro->instanceStart;

if (ro->ivars) {
// Find maximum alignment in this class's ivars
uint32_t maxAlignment = 1;
for (i = 0; i < ro->ivars->count; i++) {
ivar_t *ivar = ivar_list_nth(ro->ivars, i);
if (!ivar->offset) continue; // anonymous bitfield

uint32_t alignment = ivar->alignment();
if (alignment > maxAlignment) maxAlignment = alignment;
}

// Compute a slide value that preserves that alignment
uint32_t alignMask = maxAlignment - 1;
if (diff & alignMask) diff = (diff + alignMask) & ~alignMask;

// Slide all of this class's ivars en masse
for (i = 0; i < ro->ivars->count; i++) {
ivar_t *ivar = ivar_list_nth(ro->ivars, i);
if (!ivar->offset) continue; // anonymous bitfield

uint32_t oldOffset = (uint32_t)*ivar->offset;
uint32_t newOffset = oldOffset + diff;
*ivar->offset = newOffset;

if (PrintIvars) {
_objc_inform("IVARS: offset %u -> %u for %s (size %u, align %u)",
oldOffset, newOffset, ivar->name,
ivar->size, ivar->alignment());
}
}

// Slide GC layouts
uint32_t oldOffset = ro->instanceStart;
uint32_t newOffset = ro->instanceStart + diff;

if (ivarBitmap) {
layout_bitmap_slide(ivarBitmap,
oldOffset >> WORD_SHIFT,
newOffset >> WORD_SHIFT);
}
if (weakBitmap) {
layout_bitmap_slide(weakBitmap,
oldOffset >> WORD_SHIFT,
newOffset >> WORD_SHIFT);
}
}

*(uint32_t *)&ro->instanceStart += diff;
*(uint32_t *)&ro->instanceSize += diff;

if (!ro->ivars) {
// No ivars slid, but superclass changed size.
// Expand bitmap in preparation for layout_bitmap_splat().
if (ivarBitmap) layout_bitmap_grow(ivarBitmap, ro->instanceSize >> WORD_SHIFT);
if (weakBitmap) layout_bitmap_grow(weakBitmap, ro->instanceSize >> WORD_SHIFT);
}
}
  • 首先计算 superSize 与 instanceStart 之间的差值 diff
  • 得到结构体中最大的成员变量的size:maxAlignment, 然后赋值:alignMask = maxAlignment - 1
  • 比较 diff 和 alignMask,通过算法 if (diff & alignMask) diff = (diff + alignMask) & ~alignMask; 对diff重新赋值

编译器在给结构体开辟空间时,首先找到结构体中最大的基本数据类型,然后寻找内存地址能是该基本数据类型的整倍的位置,作为结构体的首地址。将这个最宽的基本数据类型的大小作为对齐模数。
为结构体的一个成员开辟空间之前,编译器首先检查预开辟空间的首地址相对于结构体首地址的偏移是否是本成员的整数倍,若是,则存放本成员,反之,则在本成员和上一个成员之间填充一定的字节,以达到整数倍的要求,也就是将预开辟空间的首地址后移几个字节。了解更多可以看这篇博客

  • 更新成员变量的 offset,ivar.newOffset = diff + ivar.oldOffset
  • 更新子类 ro 的 instanceStart 和 instanceSize,ro.newinstanceStart = ro.oldinstanceStart + diff,ro.newinstanceSize = ro.oldinstanceSize + diff
  • 当父类变大时会调用该函数来移动子类ivar,当父类变小时则子类ivar不变化

通过这个函数,即使父类size变大了,我们还是可以通过子类的 ro.instanceStart + ivar.offset 访问到成员变量

#不能动态添加成员变量

在 runtime 中有一个函数 class_addIvar()可以为类添加成员变量, 下面是该方法的一部分注释:

his function may only be called after objc_allocateClassPair and before objc_registerClassPair. Adding an instance variable to an existing class is not supported.
The class must not be a metaclass. Adding an instance variable to a metaclass is not supported.

上面的大致意思是该函数只能在类注册之前使用,且不能为元类添加成员变量。

让我们设想一下如果 OC 允许动态增加成员变量:

1
2
3
4
5
6
7
8
9
@interface Father : NSObject
@property (nonatomic, strong) NSString *name;
@property (nonatomic, strong) NSString *age;
@end

@interface Son : Father
@property (nonatomic, copy) NSArray *toys;
@end

当Father初始化之后,instanceStart,instanceSize,offset已经确定
为 Father 添加新的成员变量 sex,则使用 Son 的实例对象 son 会出错误,因为 son.instanceStart < Father.instanceSize,即 father 成员变量的 sex 的内存区域会跟 son 的一部分重合

我们有时会在类目中动态的为类添加关联对象(添加对象),为什么可以添加关联对象呢?
具体的你可以看一下我的另一篇博客 谈Objective-C关联对象
这里我简单解释一下:关联对象被保存在一个静态的 map 中,以类实例的指针地址为映射,而不是保存在类实例的结构体中,不影响对象中成员变量的使用,所以可以在运行时添加成员变量。

#引用

Objective-C类成员变量深度剖析

作者

千行

发布于

2019-06-27

更新于

2022-10-21

许可协议

评论