自定义类型的解析——结构体详解

文章讨论了C语言中的匿名结构体的使用,包括它们在嵌套结构中的作用和简化代码的优点。同时,解释了结构体的自引用问题,指出错误的自引用示例以及如何正确实现自引用。文章还深入介绍了内存对齐的原则和其背后的平台及性能考虑,并提供了实例展示。此外,提到了位段的概念,强调了位段在节省空间上的优势,但同时也指出位段的跨平台问题。最后,文章通过示例探讨了结构体的初始化、传参以及位段的应用。
  1. 匿名结构体类型

结构体的完全声明


struct tag
{
    member_list;
}variable_list;
//结构体的标签:tag
//结构体的类型:struct tag
//结构的成员列表:member_list
//结构体变量列表:variable_list

当不完全声明时,比如说省略结构体的标签,这时,这种结构体就被称为匿名结构体

匿名结构体也称为未命名结构体,由于没有名称,因此不会创建它们的直接对象(或变量),通常我们在嵌套结构或联合中使用它们。

值得注意的是,匿名结构体类型可以说是“一次性用品”,用了一次以后再也用不了了。


struct 
{
    char c;
    int a;
    double d;
}s1;
struct
{
    char c;
    int a;
    double d;
}* p1;//一个看上去指向和s1相同类型的指针
int main()
{
    p1 = &s1;//这样写是不可以的,编译器会认为它是两个不同的类型
    return 0;
}

如上代码是错误的,虽然两个匿名结构体,看上去它们的成员都是一样的,表面上是同一类型,但实际上他们是两种不同的类型。因此,因为在定义的时候没有写结构体类型名称,编译器会把它们当做两种不同的类型,然后报错。

匿名结构体的优点:

嵌套在结构体中的结构体为匿名结构时,可以直接访问其成员。


#include <stdio.h>  
  
struct Stu  
{  
    char* name;  
    char  gender;  
    int   age;    
    struct//嵌套定义了这个匿名结构体
    {  
        int  Student_ID;  
        long phone_number;  
    }; 
};  
  
int main(void)  
{
    struct Stu A= {"A", 'M', 19, {30, 13930422035}};
    printf("%d\n", A.Student_ID);     
} 

如果不使用匿名结构体,则上述例子会变成这样:


#include <stdio.h>  
 
struct Stu_information
{
    int  Student_ID;
    long phone_number;
};
  
struct Stu A  
{  
    char*  name;  
    char   gender;  
    int    age;  
    struct Stu_information;
};  
  
int main(void)  
{
    struct Stu A = {"A", 'M', 19, {30, 13930422035}};
 
    printf("%d\n", A.Stu_information.Student_ID);     
} 

对比上述两个例子可以看出:

使用匿名结构体,结构体对象 A 可以通过 A.Student_ID直接访问匿名结构体成员变量 .Student_ID,代码比较简洁,反之则必须通过A.Stu_information.Student_ID 来访问结构体成员变量 ,比较繁琐。

  1. 结构体的自引用

我们不由地思考一个问题,结构体把自己当作自己的一个成员变量,这是否可行呢?


struct Node
{
    int date;
    struct Node n;//错误!
};

结论是:这样写是绝对错误的!

请想一想,如果我们想要求struct Node 的大小,其中,成员data占4个字节,那么struct Node n成员的大小是多少呢?

这就陷入了一个思维上的死循环:要求它自己的大小,那么首先就要求它自己的大小?这是什么鬼啊?!

所以这样写是绝对不可以的!!!

那么,如果想要正确的自引用,我们应该怎么做呢?

这里要先补充一点关于“数据结构”的知识

数据的存储可以有很多方式,比如“顺序存储”、“二叉树”等等,这些存储方式都可以让我们很顺利地从一个数据找到另一个数据。

所以,关于结构体的自引用,我们可以把它看作是通过一个结构体,可以找到下一个结构体。这样一个一个像线一样访问数据,即“线性数据结构(通过链表方式可以顺序访问内存空间中不相邻的数据)”,我们就要使用到结构体指针。而指针的大小跟其所指向的类型无关,仅跟编译器有关,32位平台上指针大小为4个字节,在64位平台上,指针大小为8个字节。所以,通过指针大小的确定性,我们就可以确定结构体类型的大小了。


struct Node            //结构体的自引用
{
    int date;
    struct Node* next;
};
int main()
{
    struct Node n1;
    struct Node n2;
    n1.next = &n2;//这样就可以通过n1找到n2了.
    return 0;
}

相当于每个结构体,前面存数据,后面下一个结点地址的指针,通过该结点可以找到下一个结构体。

匿名结构体的重命名+自引用:


typedef struct 
{
    int data;
    int d;
}Node;     //一种错误的写法

typedef struct  Node 
{
    int data;
    int d;
}Node;    //正确


struct 
{
    int data;
    struct Node* next;
}Node1;
//结构体在未定义Node之前就定义了一个Node*的指针,这样是错误的!!!

typedef struct             
{
    int date;
    struct Node* next;//在此之前,都没有出现Node
}Node;
//把一个匿名结构体重命名为Node?不可以!


//正确的写法是这样的:
typedef struct Node
{
int data;
struct Node* next;
}Node;
  1. 结构体的初始化:


struct S
{
    int a;
    char c;
};
struct F
{
    int a;
    char arr[10];
    struct S;
};
int main()
{
    struct F pd= { 12,"sgdkjklaj",{345,'o'}};//结构体的嵌套初始化
    struct S s2 = { 2000,'p'};//默认顺序初始化
    struct S s3 = { .c = 'r',.a = 2000 };//自己确定初始化的顺序
}
  1. 结构体的内存对齐原则:

1.第一个成员永远在结构体变量偏移量为0的地址处。

2.从第二个成员开始,以后每个成员变量都要对齐到某个数字(对齐数)的整数倍的地址处。

这个对齐数=编译器默认的一个对齐数 与 该成员大小的较小值。

  • vs2017环境中,VS编译器默认的值为8

  • 一个是Linux环境,gcc编译器下,没有默认对齐数,对齐数就是成员自身的大小

3、当成员全部存放进去后,结构体总大小必须是所有成员的对齐数中最大对齐数(每个成员变量都有一个对齐数)的整数倍。如果不够,则浪费空间。

4、如果嵌套了结构体的情况,嵌套的结构体成员要对齐到自己成员的最大对齐数的整数倍处。结构体的整体大小就是所有最大对齐数(含嵌套结构体的对齐数)的整数倍。

接下来,让我们举一些例子:

为什么会存在内存对齐呢?

1.平台原因(移植原因)∶不是所有的硬件平台都能访问任意地址上的任意数据的; 某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常。(所以我们对齐到能够被硬件访问的位置,在对齐位置进行存储数据)

⒉性能原因∶数据结构(尤其是栈)应该尽可能地在自然边界上对齐。

原因在于,为了访问未对齐的内存,处理器需要作两次内存访问;

而对齐的内存访问仅需要一次访问。它能够提高效率。

总体来说︰ 结构体的内存对齐是拿空间来换取时间的做法。

64位平台上,编译器一次访问8个字节。

但是,节省空间不能无脑地节省空间啊。

如果既要满足对齐,又要节省空间,怎么办呢?

解决方案:把占用空间较小的数据集中在一起。


struct s1
{
    char c1;
    int i;
    char c2;
};//12个字节
struct s2
{
    char c1;
    char c2;
    int i;
    
};//8个字节

设置默认对齐数:

结构体在对齐方式不合适的时候,我们可以自己更改默认对齐数来满足需求。

如何修改默认对齐数呢?通过#pragma修改。


#include <stdio.h>
#pragma pack(8)//设置默认对齐数为8
struct s1
{
    char c1;
    int i;
    char c2;
};
#pragma pack()//取消设置的默认对齐数,还原为默认

#pragma pack(1)//设置默认对齐数为1,这样其实相当于根本没有对齐数了。
struct s2
{
    char c1;
    int i;
    char c2;
};
#pragma pack()//取消设置的默认对齐数,还原为默认
int main()
{
    printf("%d\n", sizeof(struct s1));
    printf("%d\n", sizeof(struct s2));
    return 0;
}

offsetof,这是一个宏,用来计算结构体成员相对于结构体起始位置的偏移量


#include<stddef.h>
struct S
{
    char c1;
    char c2;
    int i;
};
int main()
{
    struct S s = {0};
    printf("%d\n",offsetof(struct S,c1));  //0
    printf("%d\n", offsetof(struct S, c2));//1
    printf("%d\n", offsetof(struct S, i)); //4
    return 0;
}

  1. 结构体传参


#include <stdio.h>
struct S
{
    int data[1000];
    int num;
};
struct S s = { {1, 2, 3, 4}, 1000 };
//结构体传参
void print1(struct S s)
{
    printf("%d \n", s.num);
}
// 结构体地址传参
void print2(struct S* ps)
{
    printf("%d \n", ps->num);
}
int main()
{
    print1(s);//传结构体
    print2(&s);//传结构体地址,用一个结构体指针接收
    return 0;
}

上面的print1和print2哪一个更好呢?

print2更好一点

对于结构体的传参首选传递地址,原因如下:

函数传参的时候,参数是需要压栈,会有时间和空间上的系统开销。

如果传递一个结构体对象的时候,结构体过大,参数压栈的的系统开销比较大,所以会导致性能的下降。

那么如何应对传地址的不安全性呢?很简单,用一个const 就可以了。

  1. 位段

什么是位段 ?

位段的声明和结构是类似的,但是有两点不同︰

1.位段的成员必须是int、unsigned int,signed int、char这些整型家族的类型。

2.位段的成员名后边有一个冒号和一个数字。


#include<stdio.h>
struct A
{
    int _a : 2;       //int _a; 
    int _b : 5;       //int _b;
    int _c : 10;      //int _c;
    int _d : 30;      //int _d;这是结构体
};
int main()
{
    struct A sa={0};
    printf("%d\n", sizeof(sa));
    return 0;
}

位段位段,“位”指的是二进制位

如果_a只会存储0,1,2,3(00,01,10,11),那么这个时候2个比特位就够了。所以位段是一种更节省空间的方法。


#include<stdio.h>
struct A
{
    int _a : 2;        
    int _b : 5;       
    int _c : 10;      
    int _d : 30;      
};//47个比特位
//实际上占了8个字节
//当然,这已经很节省空间了!

位段的空间上是按照需要以4个字节(int )或者1个字节(char)来开辟的。

位段涉及很多不确定因素,位段是不跨平台的,注重可移植的程序应该避免使用位段。(所以一些细节方面不能够确定下来)

现在还有一些问题待确定:

1、空间是从左向右使用还是从右向左使用?

2、当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用呢?


#include<stdio.h>
        struct S
{
    char a : 4;
    char b : 3;
    char c : 5;
    char d : 4;
}s;
int main()
{
    struct S s = { 0 };
    s.a = 10;
    s.b = 12;
    s.c = 3;
    s.d = 4;
    return 0;
}

但是,这些在不同编译器上都是不同的!

位段的跨平台问题:

1.int位段被当成有符号数还是无符号数是不确定的。

2.位段中最大位的数目不能确定。(16位机器最大16,32位机器最大32),写成27,在16位机器会出问题。

3.位段中的成员在内存中从左向右分配,还是从右向左分配标准尚未定义。

4.当一个结构包含两个位段,第二个位段成员比较大,无法容纳于第一个位段剩余的位时,是舍弃剩余的位还是利用,这是不确定的。

总的来说,跟结构体相比,位段可以达到同样的效果,并且可以很好地节省空间,但是有跨平台问题。

位段的应用:

关于位段的一些练习题:


#define MAX_SIZE A+B
struct _Record_Struct
{
  unsigned char Env_Alarm_ID : 4;
  unsigned char Para1 : 2;
  unsigned char state;
  unsigned char avail : 1;
}*Env_Alarm_Record;
struct _Record_Struct *pointer =
 (struct _Record_Struct*)malloc(sizeof(struct _Record_Struct) * MAX_SIZE);

问:当A=2, B=3时,pointer分配( )个字节的空间?

位段成员作为unsighed char类型,是按1个字节开辟内存的,第一个成员Env_Alarm_ID开辟一个字节等于8个bit位,被Env_Alarm_ID用去4个bit,还剩4个bit可以给Para1用。Para1用去2个bit,故位段Env_Alarm_ID , Para1一共用一个字节;

state不是位段重新开辟一个字节的内存,它直接占一个字节;

位段avail只占1个bit,但也要开辟1个字节的空间用来存放。

所以前面的算下来总共开辟了3个字节的空间。

(sizeof(struct _Record_Struct) * MAX_SIZE)=3*2+3(这里一定要注意宏定义并没有写成(A+B)的形式,所以并不是先计算A+B)=9.

故pointer分配的空间为9个字节


int main()
{
  unsigned char puc[4];
  struct tagPIM
  {
    unsigned char ucPim1;
    unsigned char ucData0 : 1;
    unsigned char ucData1 : 2;
    unsigned char ucData2 : 3;
  }*pstPimData;
  pstPimData = (struct tagPIM*)puc;
  memset(puc,0,4);
  pstPimData->ucPim1 = 2; 
  pstPimData->ucData0 = 3;
  pstPimData->ucData1 = 4;
  pstPimData->ucData2 = 5;
  printf("%02x %02x %02x %02x\n",puc[0], puc[1], puc[2], puc[3]);
  return 0;
}

代码结果是?

画个图:

最后,想要补充一个非常易错的练习题:


#include<stdio.h>
struct ord
{
    int x,y
}dt[2] = {1,2,3,4};
int main()
{
    struct ord* p = dt;
    printf("%d,", ++p->x);
    printf("%d\n", ++p->y);
    return 0;
}

这个程序的运行结果是什么?

是不是有点意想不到?

让我们来简单分析一下:

结构体其实也是可以这样赋值的,和数组是很类似的。

printf("%d,", ++p->x);

printf("%d\n", ++p->y);

这两条语句中,->这个操作符的优先级是高于++的,所以其实是先取出p指向的x和y这两个数值,然后再对这两个数++。

所以实际上,p这个指针自始至终都指向dt[1]。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值