effective-Objective-C 第二章阅读笔记

文章目录

对象、消息、运行期

简介

在面向对象语言编程中,对象就是"基本构造单元",可以通过对象来存储并传递数据。在对象之间传递数据并执行任务的过程就叫做消息传递。程序运行起来后,为其提供相关支持的代码叫做"OC运行期环境",他提供了一些使得对象之间可以传递消息的重要函数,并且包含创建类实例的全部逻辑

属性

什么是偏移量?在面向对象语言中,每个对象在内存中是一个连续的结构体,假设某个person类包含name和age属性,那么他们在内存中的结构可能如下所示:

|NSObject数据|name|age|

这里name与age在内存中的位置是相对于对象起始地址的偏移量

如果我们后续更改了类的定义,例如中间插入了一个address实例变量,那么当前的内存布局就变成了:

| NSObject数据 | name | address | age |

如果仍旧使用之前的编译期计算的偏移量,那么在修改类定义之后必须重新编译,否则会报错

在OC中

将实例变量作为一种由类对象管理的特殊变量。就是说偏移量不是编译器固定的值,而是由运行时系统动态查找的。也就是说:

  • 每个类对象在运行时保存了实例变量表
  • 表中记录了每个实例变量的名字、类型、偏移量等信息
  • 当我们访问某个实例变量时,运行时根据最新的类定义查找正确的偏移量

所以即使类定义变了,偏移量也会随之更新。

通过这种形式我们在修改类定义后不需要重新编译所有代码,同时还可以动态添加实例变量。

使用@dynamic关键字会告诉编译器不要自动创建实现属性所用的实例变量,也不要为其创建存取方法。

在对象内部尽量直接直接访问实例变量

我们该如何在对象的内部访问实例变量?我们通过下面这个示例看一下

objc 复制代码
@interface EOCPerson : NSObject
@property (nonatomic, copy)NSString* firstName;
@property (nonatomic, copy)NSString* lastName;

- (NSString* )fullName;
- (void)setFullName:(NSString* )fullName;
@end

他们的存取方法有如下两种形式:

objc 复制代码
- (NSString* )fullName {
	return [NSString stringWithFormat:@"%@ %@", self.firstNAme, self.lastName];
}

- (void)setFullName:(NSString* )fullName {
	NSArray* components = [fullName compomentsSeperqtedByString:@" "];
  self.firstname = [components objectAtIndex:0];
  self.lastName = [components objectAtIndex:1];
}

上述实现过程中我们使用了点语法,通过存取方法访问相关实例变量。下面我们不经存取方法,直接访问实例变量

objc 复制代码
- (NSString* )fullName {
	return [NSString stringWithFormat:@"%@ %@", _firstNAme, _lastName];
}

- (void)setFullName:(NSString* )fullName {
	NSArray* components = [fullName compomentsSeperqtedByString:@" "];
  _firstname = [components objectAtIndex:0];
  _lastName = [components objectAtIndex:1];
}

这两种方法有几个区别:

绕过属性系统,直接访问内存地址:

  • 由于**++不经过OC的方法派发步骤++**,所以直接访问实例变量的速度比较快,在这种情况下编译器生成的代码会直接访问保存对象实例变量的那块内存
  • 直接访问实例变量时,++不会调用其设置方法++ ,这样就饶过了为相关属性所定义的"内存管理语句",比如说,在ARC下直接访问一个声明为copy的属性,那么并不拷贝该属性,只会保留旧值并释放新值
  • 如果直接访问实例变量,那么不会触发KVO
  • 通过属性来访问有助于排查相关的错误,因为可以在读写方法中添加断点,监控调用时机啥的

我们可以在写入实例变量时通过setter来实现,而在读取时则直接访问。这样既可以确保相关属性的内存管理语义得以执行,同时也提高了读取操作的速度。

在初始化方法中,我们应该直接访问实例变量,避免子类覆写setter方法。

原因:

但因为 Objective-C 的动态派发机制,使用点语法(即 setter)会调用子类重写的方法,

而此时子类可能尚未初始化完全,导致逻辑错误或崩溃。

因此父类的 init 内应直接访问实例变量以避免动态派发。」

我们详细讲解一下这个问题:

假设我们有一个Student子类,他是继承于Person类的。

objc 复制代码
@interface Person : NSObject
@property (nonatomic, strong) NSString *name;
@end

@implementation Person

- (instancetype)init {
    if (self = [super init]) {
        self.name = @"Tom";   // ❌ 使用 setter
    }
    return self;
}

@end
objc 复制代码
@interface Student : Person
@property (nonatomic, strong) NSArray *courses;
@end

@implementation Student

- (void)setName:(NSString *)name {
    [super setName:name];

    // 子类逻辑
    NSLog(@"课程数量:%@", self.courses.count);  // ❌
}

@end
objc 复制代码
Student* stu = [[Student alloc] init];

执行流程

  1. objc_alloc(Student)

    -> 分配内存

    -> ivar 全部 = 0

  2. objc_msgSend(s, init)

    -> 实际执行 Person.init

  3. Person.init 中:

    self.name = @"Tom"

  4. 编译后:

    objc_msgSend(self, setName:, @"Tom")

  5. runtime 查找 setName:

    -> Student.setName: (多态命中子类)

  6. 执行 Student.setName:

然而此时student的别的属性还没有初始化,调用会抛出异常

这段代码的底层步骤大概是:

  1. +alloc被调用,由于是Student子类,alloc会发生如下操作:

    1. 在堆上分配一块足够存放Student实例的内存,包含子类与父类的实例变量。
    2. 在这个内存的起始处写入isa指针,isa指向Student的Class结构
    3. 返回一个指向这块内存的指针
    4. 此时这块内存就是一个Student对象,此时ivars还未初始化,但是是子类类型
  2. 然后调用-init,可能会调用到父类实现(如果子类没有重写,或者子类调用了[super init],

    -init方法的内部的self参数就是上面alloc返回的指针,指向那块被标记为Student的内存,所以在父类的- init方法中,self仍然指向子类实例的指针

说了这么多,不管是直接访问实例变量还是使用存取方法访问,区别关键在于访问路径的安全性

  • 当我们直接访问实例变量时,不会调用任何方法,不会触发消息发送,不会执行子类逻辑,仅仅是将内存里的值读出来或者写进去。编译器会直接生成汇编指令操作内存偏移量,不会触发任何动态机制。
  • 点语法会触发objc_msgSend机制,动态查找当前self的isa(即使是在父类的init方法中),就会调用子类重写的setter方法

@dynamic

关键字会告诉器不要自动创建实现属性所用的实例变量也不要为其创建存取方法,如果代码访问其中的属性,编译器也不会发出警示信息

使用方法:

objc 复制代码
@property NSString* name;
objc 复制代码
@dynamic name;

属性特质

总体可以分为四类

原子性

默认情况下,编译器所合成的方法会通过锁定机制确保其原子性(atomicity),如果使用nonatomic则不会使用同步锁。

拓展

在OS层,线程调度+锁的两种模型:

  • spin自旋锁
objc 复制代码
while (lock == 1) {}
//不让出CPU,不切换线程,空转等待,适合短临界区
  • sleep阻塞锁
objc 复制代码
//抢不到锁 → 阻塞 → 调度器切线程
//线程切换,内核调度,高开销,不占用CPU
读写权限

决定属性是否具有读写权限,readwritereadonly,如果属性只读的话,只有当该属性由@synthesize实现时,编译器才会合成存取方法,可以实现对外只读,对内读写

内存管理语意

编译器需要此特质来决定生成的代码:

  1. assign:只针对"纯量类型"的简单赋值操作
  2. strong:设置方法会先保留新值,并释放旧值,在设置新值
  3. weak:非拥有关系,方法既不保留新值,也不释放旧值。当属性所指向的对象销毁时,属性值自动清空
  4. unsafe_unretained:非拥有关系,当对象被摧毁时,属性值不会自动清空。
  5. copy:所属关系与strong类似,但是设置方法不保留新值,而是将其拷贝。
方法名

指定存取方法的方法名

objc 复制代码
@property (nonatomic, getter=isOn) BOOL on;

我们可以通过该特质微调编译器合成的存取方法,需要注意如果自己实现存取方法时们需要保证其具备相关属性所声明的特质。

在对象内部尽量直接访问实例变量

写入实例变量时通过设置方法,读取实例变量时,则直接访问,可以提高读取操作效率

  • 如果待初始化的实例变量声明在超类中,子类中无法直接访问,则调用设置方法。(方法的继承)
  • 惰性初始化必须通过存取方法来访问属性,否则实例变量永远不会初始化

理解"对象等同性"概念

==操作符比较的是两个指针本身,应该使用isEqual:方法判断对象的等同性

NSObject协议中有两个判断对象等同性的关键方法:

objc 复制代码
- (BOOL)isEqual:(id)object;
- (NSUInteger)hash;

NSObject类对以上方法的默认实现是,当且仅当其指针值完全相等时,这两个对象才相等

如果isEqual:判定两个对象相等,那么其hash方法必须返回同一个值,如果两个对象的hash方法返回同一个值,那么isEqual:方法未必会认为两者相等

在编写hash方法时,应当用当前对象做做实验,以便于减少碰撞频度与降低运算复杂程度之间取舍

特定类所具有的等同性判定方法

在编写判定方法时,也应一并覆写isEqual:方法,如果受测参数与接收该消息的对象都同属于同一个类,那么就调用自己编写的判定方法,否则就调用超类来判断。

等同性判定的深度

创建等同性判定方法时,需要决定是根据整个对象来判断等同性还是根据其中几个字段来判断。NSArray的判定方式先看两个数组所含对象的个数是否相同,相同再看每个位置,即为"深度等同性判定"。可以创建标识符来辅助判定

容器中的可变类的等同性

将某个对象放入collection之后,就不应该改变其哈希码了。避免位置与对象不一致

以类族模式隐藏实现细节

class cluster可以隐藏抽象基类背后实现细节,保持接口简洁。类族中的所有类基于一个基类,并且一般不允许直接创建。

创建类族

基类中使用枚举,实现类方法,根据方法分配对应子类实例
判断是否为类族中的类,不要检测两个类对象是否等同,应该采用**isKindOfClass:**方法

Cocoa中的类族

我们如果想要向类族中新增实体子类时,如果没有工厂方法的源代码,就无法实现。

向NSArray新增子类需要遵守:

  1. 子类应该继承自类族中的抽象基类
  2. 子类应该定义自己的数据存储方式
  3. 子类应当覆写超类文档中指明需要覆写的方法

eg:编写NSArray子类时,必须实现count以及objectAtIndex:方法,像是lastObject方法,基类可以依据前两个方法实现

在既有类这种使用关联对象存放自定义数据

我们可以使用Associated Object给某个对象关联许多其他对象,这些对象通过键区分,存储对象时可以指明存储策略,以维护相应的内存管理语义。

objc_AssociationPolicy枚举如下存储策略:

使用

  • void objc_setAssociationObject(id object, void* key, id value, objc_AssociationPolicy policy)通过此方法以给定键和策略为某对象设置关联对象值
  • id objc_getAssociatedObject(id object, void* key)通过此方法根据给定键从某个对象中获取关联对象值
  • void objc_removeAssociationObjects(id object)此方法指定对象的全部关联对象

如果想要两个键匹配到同一个值,则二者必须是完全相同的指针才行,所以在设置关联对象时,通常使用静态全局变量做键

理解objc_msgSend的作用

OC调用方法叫做消息传递,消息有名称或选择子,可以接受参数,可以有返回值
C语言是静态绑定,在编译期就能决定运行时所应调用的函数

objc 复制代码
id returnValue = [someObject messageName:parameter];

上述例子中,someObject叫做接收者,messageName叫做选择子,选择子与参数合起来称为消息,编译器 看到消息之后,将其转换为一条标准的C语言函数调用,所调用的函数即为消息传递机制中的核心函数,叫做objc_msgSend

prototype如下

objc 复制代码
void objc_msgSend(id self, SEL cmd, ...)

objc_msgSend函数为了完成该操作,需要在接受者所属的类中搜寻其方法列表,如果找到了就跳转实现该方法,如果没找到就沿着继承链向上查找,如果最终还是找不到就执消息转发操作。

objc_msgSend会将匹配的结果缓存在快速映射表里,每一个类都有这么一块缓存。虽然没有静态绑定快,但是缓存后也差不多

部分情况调用如下(不同返回值类型在CPU层面是不同的)

CPU返回数据有四种形式:寄存器返回、浮点寄存器返回、内存返回、结构体内存拷贝返回

不同CPU的架构规则不同

Objc_msgSend将每个OC方法都可以视为简单的C函数,原型如下:

objc 复制代码
<return_type> Class_selector(id self, SEL_cmd, ...)

每个类中都有一张表格,其中的指针都会指向这种函数,选择子的名称就是查表时所用的键,objc_masSend就是通过该表格实现方法跳转的,其样子於objc_msgSend函数很像是利用了尾调用优化技术,简化跳转过程。如果某个函数的最后一项操作是调用另一个函数,就可以使用尾调用优化技术。编译器会生成跳转至另一函数所需要的指令码,不会向调用堆栈中推入新的栈帧,可以避免过早的出现栈溢出现象

理解消息转发机制

当对象接收到无法解读的消息后,就会启动消息转发机制,我们可以在此过程告诉对象如何处理未知消息

上面这个异常就是经典的例子,当我们向某个对象发送一条无法解读的消息,从而启动了消息转发机制,将此消息转发给了NSObject的默认实现。由NSObject的doesNotReconizeSelector:方法抛出。我们在开发过程中可以设置挂钩,用来执行预定的逻辑,而不使应用程序崩溃。

消息转发分为两大阶段,第一阶段先征询接受者,看能否动态添加方法,这叫做动态方法解析。第二阶段设计完整的消息转发机制,如果运行期系统已经将第一阶段执行完了,那么接收者自己就无法再以动态新增方法的手段来相应该选择子的消息了,此时运行期系统会请求接受者以其他手段来处理消息相关方法的调用,细分为两小步:首先让接受者检查是否有其他对象可以处理这条消息,有就转发,没有就启动完整的消息转发机制,运行期系统会将所有的细节全部封装到NSInvocation对象中。令对象最后一次让接受者设法解决当前还未处理的消息

动态方法解析

对象在收到无法解读的消息后,首先调用所属类的下列方法:

objc 复制代码
+ (BOOL)resolveInstanceMethod:(SEL)selector;//resolveClassMethod:

该方法的返回值表示这个类能否新增一个实例方法用于处理该选择子,使用前提是相关方法的代码已经写好,只需要在运行时动态插在类中,内部绑定的是C函数,因为Runtime只能绑定C函数。@dynamic用

备援接收者

当接收者还有第二次机会响应未知的选择子,运行期系统会判断是否将消息转给其他接收者来处理,调用方法如下:

objc 复制代码
- (id)forwardTargetForSelector:(SEL)selector;

如果能找到备援对象就将其返回,如果找不到就返回nil

我们无法操作经由这一步所转发的消息,如果想在发送给备援接收者之前修改消息内容,那就得通过完整的消息转发机制来实现。

完整的消息转发

首先创建NSInovacation对象,将尚未处理的那条消息有关的全部细节封装其中,消息派发系统将消息指派给目标对象,调用方法如下:

objc 复制代码
- (void)forwardInvocationL:(NSInvocation*)invocationl

方法实现只需要改变调用目标,使消息在新目标上得以调用即可,可以在此方法中操作消息,可以使用超类实现在继承体系中的每个类都有机会处理此调用请求,直至NSObject

消息转发全流程

接收者在其中的每一步都有机会处理消息,步骤越往后消息处理的代价就越大

用方法调配技术调试黑盒方法

通过方法调配技术我们既不需要源代码,也不需要通过继承自类来覆写方法就能改变这个类本身的功能。新功能将在本类的所有实例中生效

通过下面方法可以实现两个方法实现的交换:

eg:

通过该方法可以为某些黑盒方法增加日志记录功能,有助于调试

理解类对象的用意

每个对象结构体的首个成员是Class类的变量,该变量定义了对象所属的类,通常称为isa指针

此结构体存放类的元数据,Class本身也是OC对象,其中的super_class定义了本类的超类,类对象所属的类型是元类,用来表述类对象本身所具有的元数据,例如类方法

在查询类型信息时,我们尽量使用类型信息查询方法,不要直接比较两个类对象是否等同。如果出现代理对象的情况,直接比较不行(这些类都继承於NSproxy,是一个纯消息转发类),但是通过isKindOfClass:可以触发消息转发机制。

相关推荐
代码游侠7 小时前
复习——Linux设备驱动开发笔记
linux·arm开发·驱动开发·笔记·嵌入式硬件·架构
恣逍信点7 小时前
《凌微经 · 理悖相涵》第七章 形性一体——本然如是之元观
人工智能·科技·学习·程序人生·生活·交友·哲学
stars-he7 小时前
AI工具配置学习笔记
人工智能·笔记·学习
Master_oid7 小时前
机器学习32:机器终生学习(Life Long Learning)
人工智能·学习·机器学习
袁气满满~_~7 小时前
深度学习笔记三
人工智能·笔记·深度学习
我的xiaodoujiao7 小时前
使用 Python 语言 从 0 到 1 搭建完整 Web UI自动化测试学习系列 47--设置Selenium以无头模式运行代码
python·学习·selenium·测试工具·pytest
执笔论英雄16 小时前
【大模型学习cuda】入们第一个例子-向量和
学习
wdfk_prog16 小时前
[Linux]学习笔记系列 -- [drivers][input]input
linux·笔记·学习
未来侦察班16 小时前
一晃13年过去了,苹果的Airdrop依然很坚挺。
macos·ios·苹果vision pro