2020-01-15

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



结构体是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位系统下的尺寸了:

image.png

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

  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;
};复制代码

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

image.png

位置调整后的

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

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->body

free(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类中还会有一个隐式的数据成员isa,这是一个指针类型的数据成员,并且是作为类的第一个数据成员被定义。 因此下面的OC类定义:

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

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

struct Student {
  void *isa;
  BOOL _sex;
  int _age;
  float  _grade;
  NSString *_address;
};复制代码

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

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

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

  1. 64位系统中是所有数据成员的总和并且是8的倍数,32位系统中是所有数据成员的总和并且是4的倍数。

  2. 最小为16个字节。

结构体中的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对象数据成员。