首页 > 其他分享 >「OC」NSArray的底层逻辑和遍历方法

「OC」NSArray的底层逻辑和遍历方法

时间:2024-10-10 11:53:29浏览次数:12  
标签:__ 遍历 object OC 数组 NSArray NSMutableArray

「OC」NSArray的底层逻辑和遍历方法

文章目录

前言

最近在看github上大佬写的关于NSArray的博客,正好最近在对NSArray的相关知识进行梳理,于是整理成博客。

NSArray的底层逻辑

我们提供一段代码作为开头

 NSArray *placeholder = [NSArray alloc];
    NSArray *arr1 = [[NSArray alloc] init];
    NSArray *arr2 = [[NSArray alloc] initWithObjects:@0, nil];
    NSArray *arr3 = [[NSArray alloc] initWithObjects:@0, @1, nil];
    NSArray *arr4 = [[NSArray alloc] initWithObjects:@0, @1, @2, nil];
        
    NSLog(@"placeholder: %s", object_getClassName(placeholder));
    NSLog(@"arr1: %s", object_getClassName(arr1));
    NSLog(@"arr2: %s", object_getClassName(arr2));
    NSLog(@"arr3: %s", object_getClassName(arr3));
    NSLog(@"arr4: %s", object_getClassName(arr4));
        
    NSMutableArray *mPlaceholder = [NSMutableArray alloc];
    NSMutableArray *mArr1 = [[NSMutableArray alloc] init];
    NSMutableArray *mArr2 = [[NSMutableArray alloc] initWithObjects:@0, nil];
    NSMutableArray *mArr3 = [[NSMutableArray alloc] initWithObjects:@0, @1, nil];
        
    NSLog(@"mPlaceholder: %s", object_getClassName(mPlaceholder));
    NSLog(@"mArr1: %s", object_getClassName(mArr1));
    NSLog(@"mArr2: %s", object_getClassName(mArr2));
    NSLog(@"mArr3: %s", object_getClassName(mArr3));

我们可以得到以下结果:

image-20240929215722751

了解了上面得出的结果,我们就可以开始进行相关的分析了。

占位符

我们先从第一个开始分析,不论是可变数组还是不可变数组,我们对其只进行alloc的结果得到的都是一个名为 __NSPlaceholderArray的类,顾名思义这个类就是用来进行占位的,我们对其进行再进一步的探究

NSArray *placeholder1 = [NSArray alloc];
NSArray *placeholder2 = [NSArray alloc];
NSLog(@"placeholder1: %p", placeholder1);
NSLog(@"placeholder2: %p", placeholder2);

我们可以看到

NSArray *placeholder1 = [NSArray alloc];
NSArray *placeholder2 = [NSMutableArray alloc];
NSLog(@"placeholder1: %p", placeholder1);
NSLog(@"placeholder2: %p", placeholder2);

image-20240929220323749

我们可以看到生成的占位符地址是一样的

这里我直接引用大佬的结论

可以猜测,这里是生成了一个单例,在执行init之后就被新的实例给更换掉了。该类内部只有一个isa指针,除此之外没有别的东西。 由于苹果没有公开此处的源码,我查阅了别的类似的开源以及资料,得到如下的结论:

当元素为空时,返回的是__NSArray0的单例;

为了区别可变和不可变的情况,在init的时候,会根据是NSArray还是NSMutableArray来创建immutablePlaceholdermutablePlaceholder,它们都是__NSPlaceholderArray类型的。

init后的空NSArray

对于空的NSArray的来说,因为NSArray是不可变的,也就是说,就是空的NSArray有且只有一种,那我们想到可以使用单例来进行实现。我们可以打印出来的类名__NSArray0

关于这个__NSArray0是否为单例我们可以以下程序进行验证

#import <Foundation/Foundation.h>

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSArray *array = [NSArray array];

        NSUInteger rc = [array retainCount];

        NSLog(@"%lu", rc);
    }
    return 0;
}

我们通过打印出来的数字,是一个很大的数字,就足以说呢我们创建的是一个单例了

只有单个元素的NSArray

接下来我们看到只有一个元素的NSArray,由于NSArray是不可变数组,我们可以看到打印出来的类名叫做__NSSingleObjectArrayI

结构定义如下

@interface __NSSingleObjectArrayI : NSArray
{
    id object;
}
@end

大于一个元素的NSArray

我们可以看到大于一个元素的NSArray,程序打印的是__NSArrayI

__NSArrayI的结构如下

@interface __NSArrayI : NSArray
{
    NSUInteger _used;
    id _list[0];
}
@end

_used是数组的元素个数,调用[array count]时,返回的就是_used的值。 这里我们可以把id _list[0]当作id *_list来用,这个id _list[0]其实被叫做柔性数组

当你定义一个结构体并在结构体的末尾放置一个大小为 0 的数组时,编译器会将这个数组成员视为柔性数组。这个柔性数组的大小是在运行时根据需要动态分配内存来确定的。因此,通过访问结构体的柔性数组成员,你实际上可以访问在结构体后面额外分配的内存空间,这样就能实现动态长度的数组。

可变数组NSMutableArray

在程序之中我们可以知道,无论是什么情况,我们创建的NSMutableArray类名都是__NSArrayM,由于可变数组是可以随意添加和删除元素的,其对应的结构如下:

@interface __NSArrayM : NSMutableArray
{
    NSUInteger _used;
    NSUInteger _offset;
    int _size:28;
    int _unused:4;
    uint32_t _mutations;
    id *_list;
}
@end

__NSArrayM稍微复杂一些,但是同样的,它的内部对象数组也是一块连续内存id* _list,正如__NSArrayIid _list[0]一样 _used:当前对象数目 _offset:实际对象数组的起始偏移,这个字段的用处稍后会讨论 _size:已分配的_list大小(能存储的对象个数,不是字节数) _mutations:修改标记,每次对__NSArrayM的修改操作都会使_mutations加1 id *_list是个循环数组.并且在增删操作时会动态地重新分配以符合当前的存储需求。

注:关于int _size:28;之中的28是位域,表示的是在内存之中占28个比特位,28 + 4 = 32 正好是一个int类型的字节大小

简单介绍之后,我们再来着重的讲一下刚刚介绍的offsetunused

__NSArrayM内存存储方式是使用环形缓存区的策略进行存储的,环形缓存区其实就像是一个环形队列,在我们进行增删操作的时候,程序会对这个_size进行动态的修正。

现在我用一个例子来解释__NSArrayM的工作原理,我们现在有一个包含5个对象,总大小_size为6的_list为,初始化完成之后 _offset = 0,_used = 5,_size=6

image

在末端追加3个对象后: _offset = 0,_used = 8,_size=8 _list已重新分配

image

删除对象A: _offset = 1,_used = 7,_size=8

image

删除对象E: _offset = 2,_used = 6,_size=8 B,C往后移动了,E的空缺被填补

image

在末端追加两个对象: _offset = 2,_used = 8,_size=8 _list足够存储新加入的两个对象,因此没有重新分配,而是将两个新对象存储到了_list起始端

image

对于我们环形缓存区来说,如果我们需要从两端任意一端进行增删操作,就不需要我们进行对数组内存进行的移动,如果是需要我们对中间进行操作,那么其实程序只会对操作当前位置元素最少的一端。

总结图片

img

遍历NSArray

前面的内容介绍完了NSArray的相关的底层逻辑了,现在开始我们对于NSArray的遍历进行探究了,下面介绍一下遍历的相关方式

1. for循环

for (int i = 0;  i < array.count; ++i) {
       id object = array[i];
  }

2. 枚举

关于枚举,我们就是使用NSEnumerator进行相关操作

NSEnumerator *enumerator = [array objectEnumerator];
id object;
while((object = [enumerator nextObject])!= nil){

}

3.for—in

for (id object in anArray) {

  }

4. 多线程

通过block回调,在子线程中遍历,对象的回调次序是乱序的,而且调用线程会等待该遍历过程完成:

[array enumerateObjectsWithOptions:NSEnumerationConcurrent usingBlock:^(id  _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
        xxx
  }];

其实关于遍历其实绕来绕去离不开性能二字,下图就是各种遍历方式的性能

[img

横轴为遍历的对象数目,纵轴为耗时,单位us. 从图中看出,在对象数目很小的时候,各种方式的性能差别微乎其微。随着对象数目的增大, 性能差异才体现出来. 其中for in的耗时一直都是最低的,当对象数高达100万的时候,for in耗时也没有超过5ms. 其次是for循环耗时较低. 反而,直觉上应该非常快速的多线程遍历方式却是性能最差的。

其中关于他们之间的性能差异产生的原因,我找到了这篇文章 Objective-C 数组遍历的性能及原理,有兴趣的读者可以自行阅读,由于笔者能力还有所欠缺,还无法完整的内容学完,这里就直接引用作者得到的结论:

1.for 循环&for in

以for—in为例。forin遵从了NSFastEnumeration协议,它只有一个方法:

- (NSUInteger)countByEnumeratingWithState:
                        (NSFastEnumerationState *)state
                             objects:(id *)stackbuffer 
                                  count:(NSUInteger)len;

它直接从C数组中取对象。对于可变数组来说,它最多只需要两次就可以获取全部全速。如果数组还没有构成循环,那么第一次就获得了全部元素,跟不可变数组一样。但是如果数组构成了循环,那么就需要两次,第一次获取对象数组的起始偏移到循环数组末端的元素,第二次获取存放在循环数组起始处的剩余元素。 而for循环之所以慢一点,是因为for循环的时候每次都要调用objectAtIndex: 假如我们遍历的时候不需要获取当前遍历操作所针对的下标,我们就可以选择forin。

2.block循环

这种循环虽然是最慢的,但是我们在遍历的时候可以直接从block中获取更多的信息,并且可以修改块的方法签名,以免进行类型转换操作。

for(NSString *key in aDictionary){
    NSString *object = (NSString *)aDictionary[key];
}
NSDictionary *aDictionary = /*...*/;
[aDictionary enumerateKeysAndObjectsUsingBlock:
  ^(NSString *key,NSString *obj,BOOL *stop){
  
  }];

NSEnumerationConcurrent+Block的方式耗时最大,我认为是因为它采用了多线程,就这个方法来讲,多线程的优势并不在于遍历有多快,而是在于它的回调在各个子线程,如果有遍历+分别耗时计算的场景,这个方法应该是最适合的,只是此处只测遍历速度,它光启动分发管理线程就耗时不少,所以性能落后了.

并且如果需要需要并发的时候,也可以方便的使用dispatch_group/dispatch_apply。

另外还有一点:如果数组的数量过多,除了block遍历,其他的遍历方法都需要添加autoreleasePool方法来优化。block遍历就不需要,因为系统在实现它的时候就已经实现了相关处理。

参考文章

NSArray原理及遍历方法探究

Exposing NSMutableArray

标签:__,遍历,object,OC,数组,NSArray,NSMutableArray
From: https://blog.csdn.net/aa2002aa/article/details/142817185

相关文章

  • OCR+PDF解析配套前端工具开源详解!
    面对日常生活和工作中常见的OCR识别、PDF解析、翻译、校对等场景,配套的可视化工具能够极大地提升我们的使用体验和工作效率。通过可视化界面,我们可以直观地看到文本识别、解析和翻译的结果,便捷评估产品效果。今天来跟大家分享一个非常棒的开源项目——TextInParseX-Frontend,帮......
  • Clock ndr的作用
    clockndr一般会使用2W2S或者3W3S,那么是为什么呢?在clock的signal是非常重要(高翻转)且脆弱的信号,容易受到SI等的干扰,所以要进行NDR(特殊处理)。增大W(width)是为了提高抗EM的能力,因为时钟的翻转频率很高,驱动很大,容易有EM问题。增大S(spacing)是为了减小串扰,因为串扰和耦合电容有关,在版......
  • 12G-SDI高清视频开发案例,让4K视频采集更便捷!基于Xilinx MPSoC高性能平台
    本文主要介绍基于XilinxUltraScale+MPSoCXCZU7EV的12G-SDI高清视频开发案例,适用开发环境如下:Windows开发环境:Windows764bit、Windows1064bitLinux开发环境:Ubuntu18.04.464bit开发工具包:XilinxUnified2022.2硬件平台:创龙科技TLZU-EVM评估板(基于XilinxUltraScale+......
  • python程序停到这个地方 client_socket, address = server_socket.accept()
    停到这个地方 client_socket,address=server_socket.accept() 这行代码是在等待客户端的连接请求。server_socket.accept()是一个阻塞调用,它会一直等待有客户端连接上来,然后返回一个新的套接字对象client_socket和客户端的地址address。如果你的程序在这一行停止,可能......
  • Linux网络(二)——socket、BIO、epoll原理
    二、内核如何与用户进程协作//创建Socket的c语言程序...intmain(){ intsk=socket(PF_INET,SOCK_STREAM,0); //忽略bind和accept ... } 2.1读取视角:Linuxsocket结构2.1.1socket源码//代码:/include/linux/net.hstructsocket{ socket_state state; shor......
  • P7394 「TOCO Round 1」History
    操作树加二分,目前题解区没有这种做法。发现操作一可逆,可以用操作树,操作三解决。操作一单点修改没什么好说的。接下来看操作二。令\(fa_{x,k}\)为\(x\)的\(k\)级祖先。发现对于每个询问中,如果\(y\)为奇数那么答案为\(0\)。如果\(y\)为偶数,那么答案就是\(fa_{x,y/2}......
  • docker常见命令
    Docker是一个常用的容器化工具,以下是一些常见的Docker命令:1.镜像管理列出本地镜像:dockerimages从DockerHub拉取镜像:dockerpull<image-name>构建镜像:dockerbuild-t<image-name>:<tag>.查看镜像详情:dockerinspect<image-id>删除镜像:dockerrmi<image-id......
  • 43 C 程序动态内存分配:内存区域划分、void 指针、内存分配相关函数(malloc、calloc、re
    目录1 C程序内存区域划分1.1代码区(CodeSection)1.2全局/静态区(Global/StaticSection)1.3栈区(StackSection)1.4 堆区(HeapSection)1.5动态内存分配2void指针(无类型指针)2.1void指针介绍2.2void指针的作用2.3void指针的特点2.4 void指针类......
  • docker详解介绍+基础操作 (三)
    1.docker存储引擎Overlay:一种UnionFS文件系统,Linux内核3.18后支持Overlay2:Overlay的升级版,docker的默认存储引擎,需要磁盘分区支持d-type功能,因此需要系统磁盘的额外支持。关于d-type传送门 docker详解介绍+基础操作(二)-CSDN博客由于centos8及ubuntu1604版本均支持,其......
  • 进入容器:掌控Docker的世界
    进入容器:掌控Docker的世界在这个快速发展的技术时代,你是否曾被Docker的庞大生态所吸引?那么,有没有想过在这个容器化的世界里,如何快速高效地“进入”这些隐藏在虚拟墙后的容器呢?容器就如同魔法箱,装载着应用与服务,而你,通过探索这些容器,能够更好地管理、排除故障甚至优化性能。......