(原文出处:Objective-C Internals | Always Processing)
Objective-C类的实例变量可能会影响ABI稳定性。在Objective-C 2中,苹果引入了"健壮"布局,以保持在某些类型的更改中类的实例变量的ABI稳定性。
Objective-C运行时的某些部分对ABI有影响,这可以通过Objective-C类实例变量的演变来说明。
脆弱的实例变量
32位版本的macOS中的Objective-C类实例变量具有"脆弱"布局,这意味着针对此部署目标的实例变量通过从类的开始计算它们的偏移量来访问,就好像类层次结构中的实例变量被连接到C结构中一样。而且,就像C结构中的字段一样,每个实例变量的偏移量在每次读取或写入时都会被硬编码到机器代码中,并且一旦部署后就不能更改。
因此,每个实例变量的大小、对齐方式和偏移量都是其类的ABI的一部分。在公共面向的Objective-C类中添加或删除实例变量会导致在运行时破坏子类,因此称之为"脆弱"。(将实例变量的类型更改为删除和添加操作可以被视为一种操作。)
当链接到二进制库(例如,链接到AppKit框架的应用程序)时,链接到二进制库的实体依赖于其ABI保持"稳定"。为了保持稳定的ABI,对二进制库所做的所有更改都不能使编译器和链接器用于与二进制库进行互操作的协议失效。如果二进制库的新版本更改了协议,之前链接到它的任何内容都可能会出现问题。
为了探索在Objective-C 2之前苹果如何处理ABI稳定性的约束,让我们来看看macOS 10.13 SDK中以下经过删节和注释的类定义。
C
// #import <objc/NSObject.h>
@interface NSObject <NSObject> {
Class isa; // 0x00
}
@end
// #import <AppKit/NSResponder.h>
@interface NSResponder: NSObject {
id _nextResponder; // 0x04
}
@end
// #import <AppKit/NSView.h>
typedef struct __VFlags {
unsigned int flags;
} _VFlags;
@class _NSViewAuxiliary;
@interface NSView: NSResponder {
/* All instance variables are private */
NSRect _frame; // 0x08
NSRect _bounds; // 0x18
NSView *_superview; // 0x28
NSArray *_subviews; // 0x2c
NSWindow *_window; // 0x30
id _unused_was_gState; // 0x34
id _frameMatrix; // 0x38
CALayer *_layer; // 0x3c
id _dragTypes; // 0x40
_NSViewAuxiliary *_viewAuxiliary; // 0x44
_VFlags _vFlags; // 0x48
struct __VFlags2 {
unsigned int flags;
} _vFlags2; // 0x4c
}
@end
上面每个实例变量右侧的注释指示了从self指针开始的硬编码偏移量,编译器会发出这个偏移量来读取或写入它。
以下代表了NSView的实例变量ABI,反映了其内存布局以及编译器如何将实例变量视为C结构中的字段。
C
struct NSViewHeapLayout {
Class isa; // 0x00
id _nextResponder; // 0x04
NSRect _frame; // 0x08
NSRect _bounds; // 0x18
NSView *_superview; // 0x28
NSArray *_subviews; // 0x2c
NSWindow *_window; // 0x30
id _unused_was_gState; // 0x34
id _frameMatrix; // 0x38
CALayer *_layer; // 0x3c
id _dragTypes; // 0x40
_NSViewAuxiliary *_viewAuxiliary; // 0x44
_VFlags _vFlags; // 0x48
struct __VFlags2 _vFlags2; // 0x4c
};
如果我们在Mac OS X 10.0发布以来的所有公共SDK中查找,我们可以观察到NSObject和NSResponder的实例变量没有发生变化(因此保留了ABI稳定性)。但是,NSView有两个奇怪的实例变量,作为保持ABI稳定性的产物:
_unused_was_gState
在Mac OS X的早期版本中,NSView有一个_gState实例变量,用于支持其集成到图形堆栈中。这种状态在后来的版本中变得过时,因此AppKit的维护者将实例变量的名称更改为反映它被故意不使用的事实。
AppKit的维护者不能删除实例变量,因为这会改变随后的实例变量的偏移量,包括子类中的实例变量。
我怀疑维护者没有重新用途实例变量,因为一些应用程序可能会读取(甚至写入)该变量。对于这些应用程序来说,通常不可能正确处理指向完全不同的不透明类型的指针,因此重新用途可能会破坏这些应用程序。然而,任何受影响的应用程序可能会足够正常运行,具有变量的默认/占位符值。
在引入访问控制之前,Objective-C语言同时添加了@private访问修饰符,约定是防止子类直接访问超类状态的唯一工具(因此在NSView实例变量块的开头有注释)。
_viewAuxiliary
当类需要ABI稳定性时,使用私有的辅助类是一个典型的模式,以保留在每个版本中添加或删除实例变量的能力。因此,每个NSView的实例都会分配一个_NSViewAuxiliary的实例,用于存储实例变量和状态,而不影响NSView的ABI。
当类需要ABI稳定性但没有使用私有的辅助类的选项(例如NSObject或NSResponder),另一种典型的模式是使用side table。 (Mac OS X 10.6和iPhoneOS 3.1中的Objective-C运行时增加了与其关联引用特性一起的通用支持。)
健壮的实例变量
在Objective-C 2中,苹果改变了Objective-C运行时和ABI,以支持"健壮"布局,该布局在所有版本的iOS、tvOS、watchOS和64位版本的macOS中都可用。此功能在向类添加实例变量和删除非公共实例变量时保持ABI稳定性。因此,使用上述模式(无用的实例变量、私有辅助类和side table存储)不再需要保持ABI稳定性。
健壮实例变量布局有两个主要要求以保持ABI稳定性:
- 向类添加实例变量需要更新用于访问其后续所有实例变量的偏移量,包括所有子类中的实例变量。
- 从类中删除实例变量要求这些实例变量在类的二进制图像之外不能被访问。
在编译Objective-C 2代码时,编译器会为每个实例变量发出一个偏移量符号,其使用满足ABI稳定性要求:
- 当Objective-C运行时检测到类的超类已经增加时,它会更新类的实例变量偏移量符号,以适应更大的超类大小。
- 具有@package或@private访问权限的实例变量发出的符号在目标文件中具有私有外部可见性,因此不会从二进制图像中导出。删除这些非公共实例变量是ABI稳定的更改,因为先前尝试访问它们的任何代码都将无法链接。
Objective-C运行时不会(目前)在其超类缩小时减少类的实例变量偏移量。这种方法更倾向于通过增加受影响类的实例来减少Objective-C运行时的堆使用和应用程序启动时间,而以增加堆使用的代价为代价。
作为一种优化,每个偏移量符号的初始值是构建时的实例变量偏移量,这使得Objective-C运行时在每次应用程序启动时跳过计算偏移量,如果基类不增长,则可以跳过这个过程。
脆弱 vs. 健壮示例
为了说明编译器在Objective-C 1和Objective-C 2之间生成的代码之间的区别,让我们看一些用于加载_superview实例变量的简单代码。
C
NSView *superview = aView->_superview;
无论我们是编译NSView本身还是构建第三方应用程序(假设实例变量仍然是隐式@public),编译器发出的代码都是相同的。
在Objective-C 1中,使用脆弱布局,编译器只是将实例变量的编译时观察到的偏移量添加到对象实例指针,以计算从中加载实例变量的地址。
C
NSView *superview = *(NSView **)((intptr_t)aView + 0x28);
如果NSView的布局在此编译之后发生更改,硬编码偏移量的加载结果可能会变为未定义。
在Objective-C 2中,使用健壮布局,编译器将实例变量偏移量符号的值添加到对象实例指针,以计算从中加载实例变量的地址。
C
extern uint32_t OBJC_IVAR_$_NSView._superview;
NSView *superview = *(NSView **)((intptr_t)aView + OBJC_IVAR_$_NSView._superview);
NSView的布局对这个编译是不透明的,因此只要实例变量存在,加载的结果就会保持良好定义。然而,如果删除了实例变量,dyld将无法加载二进制图像,因为实例变量偏移量符号将无法解析。(我认为这比未定义的运行时行为要好!)
Objective-C运行时实现
更新类的实例变量偏移量是在realizeClassWithoutSwift()的类首次初始化的一部分中进行的。
C
static Class realizeClassWithoutSwift(Class cls, Class previously) {
// ...
// 调解实例变量偏移量/布局。
// 如果需要更新类的实例变量偏移量,则可能重新分配class_ro_t,更新ro变量。
if (supercls && !isMeta) reconcileInstanceVariables(cls, supercls, ro);
// ...
}
只有在超类已"成长为"子类时才需要更新。超类的大小可能会增加,因为它添加了实例变量,将实例变量更改为更大尺寸的类型,将字段添加到作为实例变量存储的结构中,或其超类增加了。 (如果超类缩小了,回忆运行时会无操作。)
reconcileInstanceVariables()函数首先确保是否需要将类的class_ro_t[1]数据结构复制到堆中,因为初始数据结构值从可执行文件的只读部分映射而来。所需的可写副本是为了运行时可以存储更新的类布局元数据。
C
static void reconcileInstanceVariables(Class cls, Class supercls, const class_ro_t*& ro) {
// ...
if (ro->instanceStart >= super_ro->instanceSize) {
// 超类没有超过其空间。我们完成了。
return;
}
if (ro->instanceStart < super_ro->instanceSize) {
// 超类的大小已更改。这个类的ivar必须移动。
// 同时并行滑动布局位。
// 这段代码无法压缩子类以弥补超类缩小,因此不要这样做。
class_ro_t *ro_w = make_ro_writeable(rw);
ro = rw->ro();
moveIvars(ro_w, super_ro->instanceSize);
}
}
moveIvars()函数对类的实例变量偏移量符号应用必要的移位,以适应超类的增长。
C
static void moveIvars(class_ro_t *ro, uint32_t superSize) {
uint32_t diff = superSize - ro->instanceStart;
if (ro->ivars) {
// 找到这个类ivar的最大对齐方式
uint32_t maxAlignment = 1;
for (const auto& ivar : *ro->ivars) {
if (!ivar.offset) continue; // 匿名位域
uint32_t alignment = ivar.alignment();
if (alignment > maxAlignment) maxAlignment = alignment;
}
// 计算保持该对齐方式的滑动值
uint32_t alignMask = maxAlignment - 1;
diff = (diff + alignMask) & ~alignMask;
// 将所有这个类的ivar一次性滑动
for (const auto& ivar : *ro->ivars) {
if (!ivar.offset) continue; // 匿名位域
uint32_t oldOffset = (uint32_t)*ivar.offset;
uint32_t newOffset = oldOffset + diff;
*ivar.offset = newOffset;
}
}
*(uint32_t *)&ro->instanceStart += diff;
*(uint32_t *)&ro->instanceSize += diff;
}
上面的循环将实例变量的偏移量从类的开始滑到以适应基类的增长。它写入的ivar变量是实例变量偏移量符号,就像前面部分中讨论的OBJC_IVAR_$_NSView._superview。
因此,通过在读取或写入实例变量值时添加一个间接步骤,并且通过小小的潜在启动惩罚,Objective-C运行时能够优雅地消除重大的ABI兼容性问题,并且开销和复杂性都很小。