iOS中编写高效能结构体的7个要点

  • 时间:2020-11-08 03:19 作者:欧阳大哥2013 来源: 阅读:62
  • 扫一扫,手机访问
摘要:结构体是C/C++两种语言中的基础语法, C语言中的结构体只是一个存粹的数据集合类型的形容,它只有数据成员而没有成员方法。C++中的结构体则被赋予为一个类定义的角色,它可以有数据成员也可以有成员方法。OC语言源自于C语言,它是面向对象的C语言,自然结构体的概念就和C语言中的定义保持一致。结构体中的

结构体是C/C++两种语言中的基础语法, C语言中的结构体只是一个存粹的数据集合类型的形容,它只有数据成员而没有成员方法。C++中的结构体则被赋予为一个类定义的角色,它可以有数据成员也可以有成员方法。OC语言源自于C语言,它是面向对象的C语言,自然结构体的概念就和C语言中的定义保持一致。

结构体中的数据成员可以是基本类型,也可以是数组,也可以是指针,还可以是其余的结构体。下面是一个结构体的定义示例:

struct Student {  bool sex;  short int age;  char *address;  float grade;  char  name[9];};

结构体尺寸

一个被经常探讨的问题就是求结构体的尺寸(Size)大小,也就是结构体实例占用的内存字节数。结构体的尺寸受操作系统字长、编译器、对齐方式等众多因素的影响。因而要确认一个结构体的尺寸时假如没有上述的束缚前提则是没有统一结果的。一般情况下计算结构体尺寸大小有如下规则:

  1. 结构体中每个数据成员的偏移位置是数据成员本身尺寸的倍数。
  2. 结构体的尺寸是最大基础类型数据成员尺寸的倍数。
  3. 假如有结构体嵌套时,被嵌套的结构体成员的偏移位置就是被嵌套结构体中尺寸最大的基础类型数据成员尺寸的倍数。嵌套结构体的尺寸则是所有被嵌套中的以及自身中的最大基础类型数据成员尺寸的倍数。

按照上述的规则,即可以得出上面示例结构体在64位系统下的尺寸了:

64位结构体的内存布局

在上面的布局图中可以看出:

  1. sex数据成员是bool型,它占用1个字节的内存,而且是结构体中的第一个数据成员,第一个数据成员的偏移位置总是从0开始(0是任何数据类型尺寸的倍数)。
  2. age数据成员是short int,它占用2个字节的内存,它的偏移位置是2(2是2的倍数)。同时我们看到在第一个数据成员和第二个数据成员之间留下了一个字节的空隙,我们称之为padding。
  3. address数据成员是void *, 它占用8个字节的内存,它的偏移位置是8(8是8的倍数)。这个数据成员为了对齐留出了4个字节的padding空隙。
  4. grade数据成员是float, 它占用4个字节的内存,它的偏移量是16(16是4的倍数)。这个成员没有留下padding。
  5. name数据成员是char[9],它占用9个字节,它的偏移位置是20(20是1的倍数)。它也没有留下padding。
  6. 整个结构体中最大数据成员的尺寸是void*,它占用8个字节的内存,因而结构体的尺寸是8的倍数也就是32个字节。同时看到在尾部留下了3个字节的padding。

从上面的例子可以看出由于需要对齐,结构体中的数据成员并不肯定是连续保存的,而是有可能会存在少量padding空隙。 这也引出了另外一个问题就是: 当我们在定义结构体时假如数据成员的定义顺序安排的不正当就有可能会导致多余内存空间的占用和白费。 为了达到最佳内存空间占用,可以将上述结构体中数据成员的定义顺序进行调整如下:

struct Student {  bool sex;  char  name[9];  short int age;  float grade;  char *address;};

即可以得出优化后的内存布局:

位置调整后的

那么如何才能得到最优的数据成员布局顺序呢?一个建议就是:按基础数据类型的尺寸从小到大的顺序进行排列。

💡OC类中属性的定义顺序会引发内存占用的差异吗?这个问题留在后面详细说明。

最后再来看看结构体有嵌套的情况下尺寸的计算规则,以下面的结构体定义为例:

struct A {    int a1;    char a2;};struct B {    char b1;    struct A b2;};

结构体A的尺寸在64位系统下占用8个字节,那么结构体B的尺寸以及b2的偏移又是多少呢?

根据前面的嵌套规则定义可以得出: 所有结构体中最大的基础数据类型是A中的int a1 ,它占用了4个字节。因而得出B的尺寸是12,而b2的偏移则是int长度的倍数,这里应该是4。

结构体中的位域

结构体中除了可以定义基本数据类型外,还可以使用位域来构建数据成员,也就是说某个数据成员可能只占用结构体中某几个bit位的存储空间。结构体中定义位域的目的主要是为了节省内存空间。如果某个结构体中有8个BOOL类型的数据成员用来形容8种状态。那么我们需要定义8个BOOL类型的数据成员,这样这个结构体实例就占用了8个字节的内存空间,而假如我们使用位域来定义的话则可以用一个字节的内存空间即可以表述出来。定义位域的格式如下:

struct Test {  int a:1;   //冒号后面指定数据成员占用的bit位的位数。  int b:2;};

您也可以参考这篇文章:https://www.cnblogs.com/zzy-frisrtblog/p/6198088.html 有对位域的详细详情。

在使用位域时需要注意两点:

  1. 数据成员的值不能超过定义的bit位数,否则就有可能出现覆盖其余数据成员的情况。
  2. 位域数据成员不能跨越两个数据类型。

使用位域结构的一个经典应用就是用它来定义CPU指令。下面是用位域结构体来定义一条arm64的add加法指令:

//定义add立即数指令结构struct arm64_add_immediate {    uint32_t Rd:5;  //目标    uint32_t Rn:5;    uint32_t imm12:12;    uint32_t shift:2;  //00    uint32_t opS:7; //0010001    uint32_t sf:1;  //1};

变长结构体

在通信领域最常见的就是报文传输了。一般情况下报文的结构由报文头和报文体组成。报文头的结构通常是固定的而且具备特定的格式,而报文体则通常是长度是可变的一串数据。报文头结构中会有一个数据成员来指定报文体的长度,而报文体则通常是跟在报文头后面。
对于这种报文头和报文体的定义我们依然可以用一个结构体来进行统一形容。这时候称这种结构体为变长结构体。变长结构体一般定义如下:

struct Test {    //其余任意字段    int bodySize;    unsigned char body[0];};

可以看到结构体的最后定义的是一个长度为0的字节数组数据成员,同时还定义了一个bodySize数据成员来指定body所占用的字节。对于这种可变长度的结构体实例通常按如下方式来构建的:

int bodySize = 100;//为结构体实例pTest分配内存,内存的大小为结构体的固定长度和body中的数据长度。 struct Test *pTest = (struct Test*) malloc (sizeof( struct Test) + bodySize);//赋值可变长度pTest->bodySize = bodySize;//我们即可以通访问其余数据成员一样来访问body数据成员了。pTest->bodyfree(pTest);

定义变长结构体的规则要求可变长部分的数据成员必需放到最后位置,同时结构体中还应该有一个数据成员来指定这个可变长度成员的所占用的内存字节数。

结构体在跨平台通信中的限制

当我们用结构体来形容通信的数据包信息时,即可能会由于不同操作系统中字长的差异或者者CPU体系结构体的差异而导致发送方和接收方无法匹配而出现异常。

出现这种问题的起因之一就是不同平台对数据类型的定义是不一样的
,比方int和long这两种类型是平台相关的类型。因而当我们在开发跨平台通信的应用时就不能使用平台相关的基本数据类型作为结构体的数据成员,而应该明确的指定固定宽度的类型以及平台无关的类型来定义数据成员。

除了数据类型的束缚外,还有就是对齐的问题。就如上面详情的对齐规则,由于不同系统或者者编译器的对齐规则不一致,就会导致当我们将结构体序列化进行传输时出现异常。因而最佳的实践是将结构体中的padding进行统一的去除。这需要在结构体定义中加入如下:

//告诉编译器保存当前的对齐方式,并将对齐方式设置为1字节#pragma  pack(push,1)struct Student {  bool sex;  short int age;  char *address;  float grade;  char  name[9];};//告诉编译器恢复保存的对齐方式#pragma pack(pop)           

上述的编译指令#pragma pack,可以用来设置和恢复一个结构体成员的对齐方式。通过上述的编译指令设置后最终的Student结构体的数据成员中将不会再出现padding空间了。结构体的尺寸就等于所有数据成员的尺寸之和了。

除此之外,不同的CPU在解决整数的字节序上也有差异,有的是Big Endian有的是Little Endian的。因而假如结构体中定义有整数数据成员时,也会出现由于双方字节序不一致而出现异常。因而在通信时假如结构体中有整数数据类型,一般情况下我们都会商定为某种统一的字节序进行解决(最常见的就是商定为Big Endian来解决)。

正是由于上述的总总限制,因而一般我们在传输数据时很少直接对结构体进行序列化和反序列化解决。而是借助少量平台无关的数据组织格式来进行传输,比方JSON、XML、PB、ASN等等。当然假如通信的双方都是用C/C++语言来编写的那么序列化和反序列化效率最高的还是结构体!!

OC类的数据成员和尺寸

OC类的属性

无论是结构体还是类其实都是少量数据的集合的公告和形容,OC类也是如此。只不过在OC类中除了公告数据成员外,还可以定义方法。当然方法本身是不会占用对象的存储空间的。

在OC类中公告的实体属性最终会转化为数据成员。每个OC类中还会有一个隐式的数据成员isa,这是一个指针类型的数据成员,并且是作为类的第一个数据成员被定义。 因而下面的OC类定义:

@interface Student  @property short int age;  @property NSString *address;  @property float grade;  @property BOOL sex;@end

假如转化为结构体的话就会变成:

struct Student {  void *isa;  BOOL _sex;  short int _age;  float  _grade;  NSString *_address;};

从上面的定义中可以看出,除了会多出一个isa数据成员外,数据成员的顺序也发生了变化,它不再是按OC中定义的属性顺序进行排列了。编译器会自动优化OC类中属性的排列顺序, 也就是说:
OC类中定义的属性顺序会在编译时进行优化调整,其调整的规则就是先按数据类型的尺寸从小到大进行排列,相同尺寸的数据成员则按字母顺序进行排列

因而我们在定义OC类时不需要考虑属性的定义顺序,系统会优化这些顺序以便达到最小的内存占用。

最后再来说说OC类实例对象的内存占用问题。OC类的对象内存尺寸占用按如下规则进行计算:

  1. 64位系统中是所有数据成员的总和并且是8的倍数,32位系统中是所有数据成员的总和并且是4的倍数。
  2. 最小为16个字节。
OC类的内部数据成员

OC类中定义的实例属性系统在编译时会默认转化为一个带下划线的数据成员,属性数据成员的内存排列顺序会被优化解决。在实际中我们还可以在OC类中直接定义内部的数据成员,比方下面的形式:

@interface Student  @property NSString *address;  @property BOOL sex;@end@implementation Student {   //内部的数据成员    BOOL a[7];    NSString  *b;}@end

上面的实现中定义了两个内部数据成员a,b。当出现这种情况时编译器不会对这些内部数据成员的顺序进行优化,而是按定义的顺序在内存中进行排列,并且是优先于属性数据成员进行排列。因而上面的例子最终的内存布局结构为:

struct Student {  void *isa;  BOOL a[7];  NSString *b;  BOOL _sex;  NSString *_address;};

因而个人不建议在OC类中定义内部数据成员,由于它会影响最终的对象内存占用情况。假如实在是要定义的话就需要考虑这些内部数据成员的定义顺序以便达到最佳的内存占用布局来减少对象内存实例的占用。就以上面的代码为例,在64位系统下的最佳定义顺序应该如下:

@interface Student  @property NSString *address;  @property BOOL sex;@end@implementation Student {   //内部的数据成员   NSString  *b;   BOOL a[7];}@end

结构体中的OC对象数据成员

OC语言中的对象基本是基于堆内存来构造的,因而我们所访问和操作的对象其实是一个指针。在MRC时代这个指针对象是由程序员负责其生命周期的控制,到了ARC时代OC对象的生命周期控制被编译器托管。

C语言的结构体对象没有所谓的构造和析构的概念,所以结构体中的数据成员的生命周期必需由程序员来控制。在当前的Xcode编译器中可以支持将一个OC对象定义为一个结构体的数据成员。为理解决结构体中OC对象数据成员的生命周期问题。编译器会为每个包含了OC对象数据成员的结构体自动生成一个隐式的构造函数和隐式的析构函数。每当一个结构体对象实例被创立时系统自动会调用这个结构体的隐式构造函数,隐式构造函数的实现也很简单,就是将结构体中的所有数据成员的值清零解决。而每当一个结构体对象实例被销毁时则会自动调用隐式的析构函数,隐式的析构函数的内部实现是会将其中的OC对象数据成员置为nil来减少对象的引用计数。

需要明确的是结构体对象的构造和析构调用只会发生在栈内存中创立的结构体实例中。而通过堆内存构造的结构体对象是不会调用构造函数和析构函数的。比方下面的代码:

struct A {      NSString *a1;      int a2;}; void main() {   //当函数结束后将会调用结构体A的默认析构函数,析构函数会将a1的引用计数减1,是的a1所指的对象会在合适的时机被释放。   struct A  a;   a.a1 =  @"Hello world!";   a.a2 = 10;  struct A *pA = (struct A *)malloc(sizeof(struct A));  pA->a1 = @"Hello, world!";  pA->a2 = 20;//pA在销毁时并不会调用析构函数,这样就使得a1所指向的OC对象不会被释放,从而导致内存泄露的发生。//除非我们在销毁pA前,手动调用pA->a1 = nil;  来减少引用计数。 free(pA);}

因而假如我们在结构体中定义OC对象数据成员时有如下的使用限制:

  1. 结构体对象的实例只能在栈内存中建立,而不能在堆内存中建立。
  2. 结构体对象不能以值的形式进行函数参数的传递以及作为函数的返回。
  3. 结构体对象是可以以指针的形式作为参数传递。
  4. 假如我们在堆中建立了一个结构体实例对象,那么请在销毁结构体内存之前,先手动将所有OC数据成员置为nil。

C++类中的OC对象数据成员

C++类中可以将一个OC对象公告为其数据成员。与结构体不同的是C++类中假如有OC对象数据成员时,总是会在构造函数中将OC对象数据成员值设置为nil, 同时会在析构函数中再次将OC对象数据成员设为nil并减少引用计数。 并且无论你能否重写了构造函数和析构函数,上述的两个行为都会被插入到构造和析构代码中。因而在C++类中可以放心的使用OC对象数据成员。


要理解更多的东西请关注我的:【Github】、【掘金】、【简书

  • 全部评论(0)
最新发布的资讯信息
【系统环境|】极客时间-数据分析实战45讲【完结】(2021-09-02 16:26)
【系统环境|windows】字节跳动前台面试题解析:盛最多水的容器(2021-03-20 21:27)
【系统环境|windows】DevOps敏捷60问,肯定有你想理解的问题(2021-03-20 21:27)
【系统环境|windows】字节跳动最爱考的前台面试题:JavaScript 基础(2021-03-20 21:27)
【系统环境|windows】JavaScript 的 switch 条件语句(2021-03-20 21:27)
【系统环境|windows】解决 XML 数据应用实践(2021-03-20 21:26)
【系统环境|windows】20个编写现代CSS代码的建议(2021-03-20 21:26)
【系统环境|windows】《vue 3.0探险记》- 运行报错:Error:To install them, you can run: npm install --save core-js/modules/es.arra...(2021-03-20 21:24)
【系统环境|windows】浅谈前台可视化编辑器的实现(2021-03-20 21:24)
【系统环境|windows】产品经理入门迁移学习指南(2021-03-20 21:23)
血鸟云
手机二维码手机访问领取大礼包
返回顶部