MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Objective-C运行时机制中的类与对象内部结构

2024-10-133.9k 阅读

类的本质

在 Objective-C 中,类是对象的抽象模板,它定义了对象所具有的属性和行为。从运行时的角度来看,类的本质是一个 objc_class 结构体。在 objc/runtime.h 头文件中,objc_class 结构体的定义大致如下:

struct objc_class : objc_object {
    // Class ISA;
    Class superclass;
    cache_t cache;             // 方法缓存
    class_data_bits_t bits;    // 类的数据,包括属性、方法列表等

    // 省略一些其它方法和属性
};
  1. ISA指针:在 objc_object 结构体中,ISA 指针是对象指向其类的关键。对于类对象来说,ISA 指针指向元类(meta - class),这在后续会详细讲解。
  2. superclass:指向该类的父类,如果该类是根类(如 NSObject),则 superclassnil。通过 superclass,类可以继承父类的属性和方法,实现多态和代码复用。
  3. cache:方法缓存是一个重要的优化机制。当一个对象调用某个方法时,运行时系统首先会在这个缓存中查找,如果找到则直接调用,提高了方法调用的效率。缓存的结构是一个哈希表,以方法选择器(SEL)作为键,以对应的方法实现(IMP)作为值。
  4. bitsclass_data_bits_t 类型的 bits 包含了类的许多重要信息,如属性列表、方法列表、协议列表等。通过 bits,运行时系统可以获取类的详细结构信息。

对象的本质

对象是类的实例,在 Objective-C 中,对象本质上是一个结构体 objc_object,其定义如下:

struct objc_object {
    Class _Nonnull isa  OBJC_ISA_AVAILABILITY;
};

可以看到,objc_object 结构体只有一个成员变量 isa,这个 isa 指针指向对象所属的类。通过 isa 指针,对象可以找到其类的定义,从而获取类中定义的属性和方法。例如,当我们创建一个 NSString 对象时:

NSString *str = @"Hello, World!";

str 本质上就是一个 objc_object 结构体,其 isa 指针指向 NSString 类。当我们调用 str 的方法,如 length 方法时,运行时系统会通过 isa 指针找到 NSString 类,然后在类的方法列表中查找 length 方法的实现。

类与对象的内存布局

  1. 对象的内存布局:对象的内存布局主要由其类定义的属性决定。每个对象在内存中占据一定的空间,其大小至少为 isa 指针的大小(在 64 位系统中为 8 字节)加上所有属性的大小之和。例如,定义一个简单的类:
@interface Person : NSObject {
    NSString *name;
    NSInteger age;
}
@end

@implementation Person
@end

在 64 位系统中,NSString 指针大小为 8 字节,NSInteger 大小也为 8 字节,再加上 isa 指针的 8 字节,一个 Person 对象的大小至少为 8 + 8 + 8 = 24 字节。实际大小可能会因为内存对齐的原因而有所不同。

  1. 类的内存布局:类的内存布局相对复杂,除了前面提到的 superclasscachebits 外,还涉及到类的元数据。类的元数据包括类的属性列表、方法列表、协议列表等。这些信息在运行时用于动态绑定方法、属性访问等操作。

类与对象的关系深入剖析

  1. 类对象与实例对象:类本身也是一个对象,称为类对象。每个类只有一个类对象,而通过类可以创建多个实例对象。例如,NSString 类是一个类对象,而通过 NSString 类创建的多个字符串对象就是实例对象。类对象存储了类级别的信息,如类方法,而实例对象存储了实例级别的信息,如实例变量。
  2. 元类:元类是类对象的类,它存储了类方法。每个类都有一个对应的元类。元类的 isa 指针指向根元类,根元类的 isa 指针指向自身。例如,对于 NSObject 类,其元类存储了 +alloc+init 等类方法。当我们调用 [NSObject alloc] 时,运行时系统通过 NSObject 类对象的 isa 指针找到其元类,然后在元类的方法列表中查找 alloc 方法的实现。

代码示例:探究类与对象的内部结构

  1. 获取类的信息:通过运行时函数,我们可以获取类的各种信息,如属性列表、方法列表等。以下是一个获取类属性列表的示例:
#import <objc/runtime.h>
#import <Foundation/Foundation.h>

@interface Person : NSObject {
    NSString *name;
    NSInteger age;
}
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) NSInteger age;
@end

@implementation Person
@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        unsigned int count;
        objc_property_t *properties = class_copyPropertyList([Person class], &count);
        for (unsigned int i = 0; i < count; i++) {
            objc_property_t property = properties[i];
            const char *propertyName = property_getName(property);
            NSLog(@"Property Name: %s", propertyName);
        }
        free(properties);
    }
    return 0;
}

在上述代码中,class_copyPropertyList 函数用于获取类的属性列表,property_getName 函数用于获取属性的名称。运行该代码,可以看到输出 Person 类的属性名称。

  1. 方法缓存的验证:为了验证方法缓存的存在和作用,我们可以通过多次调用同一个方法,并观察调用时间。如果方法缓存起作用,后续调用同一个方法的时间应该会明显缩短。
#import <objc/runtime.h>
#import <Foundation/Foundation.h>
#import <mach/mach_time.h>

@interface Calculator : NSObject
- (NSInteger)add:(NSInteger)a b:(NSInteger)b;
@end

@implementation Calculator
- (NSInteger)add:(NSInteger)a b:(NSInteger)b {
    return a + b;
}
@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        Calculator *calc = [[Calculator alloc] init];
        uint64_t startTime = mach_absolute_time();
        for (int i = 0; i < 1000000; i++) {
            [calc add:1 b:2];
        }
        uint64_t endTime = mach_absolute_time();
        uint64_t duration1 = endTime - startTime;

        startTime = mach_absolute_time();
        for (int i = 0; i < 1000000; i++) {
            [calc add:1 b:2];
        }
        endTime = mach_absolute_time();
        uint64_t duration2 = endTime - startTime;

        NSLog(@"First call duration: %llu", (unsigned long long)duration1);
        NSLog(@"Second call duration: %llu", (unsigned long long)duration2);
    }
    return 0;
}

在上述代码中,通过 mach_absolute_time 函数记录方法调用的时间。可以发现,第二次调用的时间会比第一次短,这证明了方法缓存的优化作用。

类与对象内部结构对内存管理的影响

  1. 对象的内存分配:当我们通过 alloc 方法创建一个对象时,运行时系统会根据类的定义为对象分配内存。内存大小包括 isa 指针和所有实例变量的大小。例如,对于 Person 类的对象,内存分配会考虑 isa 指针、nameage 变量的大小。
  2. 对象的内存释放:当对象不再被使用时,需要释放其占用的内存。在 ARC(自动引用计数)环境下,运行时系统会根据对象的引用计数自动管理内存。当引用计数为 0 时,对象的内存会被释放。而在 MRC(手动引用计数)环境下,开发者需要手动调用 release 方法来减少对象的引用计数,当引用计数为 0 时,对象内存同样会被释放。了解类与对象的内部结构有助于我们更好地理解内存管理的原理,避免内存泄漏等问题。

类与对象内部结构在消息传递中的作用

  1. 消息传递流程:在 Objective-C 中,方法调用本质上是消息传递。当一个对象接收到一个消息时,运行时系统首先通过对象的 isa 指针找到其类,然后在类的方法缓存中查找对应的方法实现。如果缓存中没有找到,则在类的方法列表中查找。如果类中没有找到,则通过 superclass 指针在父类中查找,直到找到方法实现或到达根类。
  2. 动态方法解析:如果在方法列表中没有找到对应的方法实现,运行时系统会启动动态方法解析机制。在这个机制中,运行时会给类一次机会,让类动态地添加方法实现。例如,我们可以通过 class_addMethod 函数在运行时为类添加方法。这一过程依赖于类的内部结构,如方法列表的可扩展性。

类与对象内部结构的拓展 - 关联对象

  1. 关联对象的原理:在 Objective-C 中,我们可以通过关联对象为对象动态添加属性。关联对象的实现依赖于运行时系统的一些函数,如 objc_setAssociatedObjectobjc_getAssociatedObject。从内部结构来看,关联对象并没有直接存储在对象的内存空间中,而是通过一个全局的哈希表来管理。这个哈希表以对象的 isa 指针和关联的键作为键,以关联的值作为值。
  2. 代码示例
#import <objc/runtime.h>
#import <Foundation/Foundation.h>

@interface MyClass : NSObject
@end

@implementation MyClass
@end

static char associatedKey;

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        MyClass *obj = [[MyClass alloc] init];
        NSString *value = @"Associated Value";
        objc_setAssociatedObject(obj, &associatedKey, value, OBJC_ASSOCIATION_COPY_NONATOMIC);
        NSString *retrievedValue = objc_getAssociatedObject(obj, &associatedKey);
        NSLog(@"Retrieved Value: %@", retrievedValue);
    }
    return 0;
}

在上述代码中,通过 objc_setAssociatedObjectMyClass 对象添加了一个关联对象,通过 objc_getAssociatedObject 获取关联对象的值。关联对象的实现利用了类与对象的 isa 指针等内部结构信息,为对象的属性扩展提供了一种灵活的方式。

类与对象内部结构在分类和协议中的应用

  1. 分类:分类是 Objective-C 中一种为现有类添加方法的机制。分类在运行时会将其方法列表合并到类的方法列表中。由于类的方法列表是可扩展的,这使得分类的实现成为可能。例如,我们可以为 NSString 类创建一个分类,添加一个新的方法:
@interface NSString (MyCategory)
- (NSString *)reverseString;
@end

@implementation NSString (MyCategory)
- (NSString *)reverseString {
    NSMutableString *reversed = [NSMutableString stringWithCapacity:self.length];
    [self enumerateSubstringsInRange:NSMakeRange(0, self.length) options:NSStringEnumerationReverse usingBlock:^(NSString *substring, NSRange substringRange, NSRange enclosingRange, BOOL *stop) {
        [reversed appendString:substring];
    }];
    return [NSString stringWithString:reversed];
}
@end
  1. 协议:协议定义了一组方法的声明,类通过遵守协议来表明其支持这些方法。从运行时角度看,类的内部结构中的协议列表记录了类所遵守的协议。当我们检查一个对象是否遵守某个协议时,运行时系统会在类的协议列表中查找。例如:
@protocol MyProtocol <NSObject>
- (void)myMethod;
@end

@interface MyClass : NSObject <MyProtocol>
@end

@implementation MyClass
- (void)myMethod {
    NSLog(@"My Method");
}
@end

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        MyClass *obj = [[MyClass alloc] init];
        if ([obj conformsToProtocol:@protocol(MyProtocol)]) {
            NSLog(@"Object conforms to MyProtocol");
        }
    }
    return 0;
}

在上述代码中,conformsToProtocol 方法通过类的协议列表来判断对象是否遵守指定的协议。

类与对象内部结构在runtime编程中的实际应用场景

  1. 框架开发:在开发框架时,深入了解类与对象的内部结构可以帮助开发者实现更高效、更灵活的功能。例如,在实现一个类似于依赖注入的功能时,可以通过运行时获取类的属性列表,动态地为对象注入依赖。
  2. 性能优化:通过对方法缓存的理解,我们可以优化方法调用的性能。例如,在一些性能敏感的代码中,可以尽量避免频繁替换类的方法列表,以免影响方法缓存的命中率。同时,对于一些频繁调用的方法,可以考虑提前将其缓存到对象的方法缓存中,以提高调用效率。
  3. 代码调试与分析:了解类与对象的内部结构有助于我们进行代码调试和分析。当出现一些难以定位的问题,如方法未找到或内存泄漏时,通过运行时函数获取类与对象的详细信息,可以帮助我们快速定位问题。例如,通过 class_getInstanceSize 函数获取对象的实际大小,有助于分析内存占用情况。

类与对象内部结构的演进与未来发展

随着 iOS 和 macOS 系统的不断发展,Objective-C 的运行时机制也在不断演进。一方面,为了提高性能和安全性,运行时系统可能会对类与对象的内部结构进行优化。例如,可能会进一步改进方法缓存的结构和算法,以提高方法查找的效率。另一方面,随着新的编程范式和需求的出现,类与对象的内部结构可能会增加新的特性。例如,可能会引入更灵活的属性存储和访问方式,以支持一些新兴的编程场景。

同时,随着 Swift 语言的发展,虽然 Swift 与 Objective-C 有不同的运行时机制,但 Objective-C 的运行时机制中的一些概念,如类与对象的关系、消息传递等,仍然对 Swift 的设计和实现产生了影响。未来,Objective-C 的运行时机制可能会与 Swift 的运行时机制在某些方面进行融合和互补,为开发者提供更强大的编程能力。

总之,深入理解 Objective-C 运行时机制中的类与对象内部结构,不仅有助于我们更好地掌握这门语言,还能为我们在实际开发中解决各种问题、优化代码性能提供有力的支持。无论是在传统的 iOS 和 macOS 应用开发中,还是在新兴的跨平台开发等领域,对类与对象内部结构的理解都具有重要的价值。