【C语言-自定义类型】还能这样整?

前言

拜读了陈皓大佬的《C语言结构体里的成员数组和指针》,受益匪浅


本期概览

  • 前言
  • 1.结构体
    • (1)声明
      • 基本结构
    • (2)定义与初始化
      • 定义
      • 初始化
    • (3)用处
    • (4)内存对齐
      • 为什么要对齐?
      • 对齐规则
      • 例子
    • (5)结构体实现位段
      • 位段
        • 位段有什么用?
        • 例子
        • 位段的内存分配
        • 位段的缺点
        • 位段的应用
    • (6)一段很有趣的结构体代码
      • 结构体和其成员的本质
      • 解惑1
      • 成员数组和成员指针
        • 区别:
      • 总结:
      • 解惑2
    • (7)柔性数组
      • 声明
      • 理解
      • 使用
    • 声明
  • 2.枚举
    • 定义与初始化
      • 定义
      • 初始化
    • 用处
    • 易错
  • 3.联合体(共用体)
    • 声明
    • 定义
    • 实例
    • 联合体是把双刃剑
    • 用处

1.结构体

“有容乃大”,给啥都往里装

(1)声明

基本结构

struct tag
{
	member_list;
}variable_list;
  • tag - 类型名:可以用 struct tag 创造一个此类型的变量
  • member_list - 成员列表:表示 tag 类型里的成员们
  • variable_list - 变量列表:表示用此类型创造的变量们

看看例子:

struct fruit
{
	char name[15];
	float weight;
	float price;
};

(2)定义与初始化

定义

  1. 直接在变量列表的位置创建
struct fruit
{
	char name[15];
	float weight;
	float price;
}apple, melon;
  1. 用类型创建
int main()
{
	struct fruit banana;
	return 0;
}

初始化

一坨结构体用一对花括号初始化

  1. 在变量列表处初始化
struct fruit
{
	char name[15];
	float weight;
	float price;
}apple = { "apple", 1.5, 10 }, melon = { "melon", 4, 40 };
  1. 用类型创建变量并初始化
int main()
{
	struct fruit pineapple = { "pineapple", 1, 30 };
	return 0;
}
  1. 嵌套结构体的初始化
struct f1
{
	char buyer[20];
};
struct fruit
{
	char name[15];
	float weight;
	float price;
	struct f1;
}apple = { "apple", 1.5, 10 }, melon = { "melon", 4, 40 };


int main()
{
	//struct fruit pineapple = { "pineapple", 1, 30 };
	struct fruit mango = { "mango", 0.5, 10, {"bacon"} };
	return 0;
}

(3)用处

描述复杂对象

一个芒果,单纯的用char/int 来描述,达不到我们想要的描述效果

(4)内存对齐

为什么要对齐?

  1. 有些机器只能访问到特定地址处的数据,不能访问任意地址处的数据
  2. 访问未对齐的内存,处理器需要做两次访问;而对齐的内存只需要一次
    所以我们说数据结构应该尽可能地向自然边界对齐

内存对齐其实就是一种 空间换时间 的做法

对齐规则

  1. 结构体的首个成员在结构体偏移量为0的地址处
  2. 每个成员要对齐到 偏移量为对齐数的整数倍 的地址处
  3. 结构体的总大小是最大对齐数的整数倍
  4. 如果嵌套了结构体,总大小就是包括嵌套的结构体在内的所有对齐数中最大的对齐数的整数倍
  • 对齐数:系统默认对齐数(vs上是8,可修改)和成员大小的较小值

例子

struct S1
{
	char c1;
	int i;
	char c2;
};

struct S2
{
 char c1;
 char c2;
 int i;
};


欸?例2和例1明明成员一样,例2的总大小却更小
原来

让大小较小的成员尽量集中在一起可以节省空间

  1. 结构体嵌套
struct ss
{
	char c1;
	int i1;
};
struct SS
{
	char c2;
	int i2;
	struct ss s1;
};
int main()
{
	struct SS s2;
	printf("%d\n", sizeof(s2));
	return 0;
}

(5)结构体实现位段

位段

位段是什么?和结构体很类似,但是有几点要注意:

  1. 位段的成员必须是 整型家族的
  2. 位段在成员后有一个冒号和一个数字,来表示此成员所占的大小(比特位)
  3. 位段的开辟是按char / int 开辟的,也就是一次开辟1/4 字节(看成员)

位段本身就是很不稳定的玩意,所以我们基本不会把int 和 char成员混在一起

位段有什么用?

一定程度上节省空间

例子

struct A
 {
 int _a:2;
 int _b:5;
 int _c:10;
 int _d:30;
};

位段的内存分配

struct S 
{
 char a:3;
 char b:4;
 char c:5;
 char d:4;
};


其实这里从右向左还是从左向右是标准未定义的,不过vs2019是右到左

位段的缺点

跨平台问题

  1. int的位段被当成有符号或是无符号是不确定的
  2. 位段中最大为的数目不能确定(对于16位、32位机器,27在32位可行;在16位不行)
  3. 位段的成员在内存中从左到右分配还是从右向左是未定义的
  4. 当一个位段成员剩余的比特位不够容纳下一个位段成员时,剩下的位舍弃或是利用,不确定

位段的应用

【C语言-自定义类型】还能这样整?_第1张图片

(6)一段很有趣的结构体代码

这一段代码来自于“左耳朵耗子”大佬的博客,我叫它“ 不是bug的‘bug’ ”

这个程序在运行起来之后会在第18行crash

  • 为什么打印 00000004 ?
  • 为什么不是在第16行,16行的判断条件用了个空指针呢!
#include 
struct str
{
    int len;
    char s[0];
};

struct foo
{
    struct str* a;
};

int main(int argc, char** argv) 
{
    struct foo f = { 0 };
    if (f.a->s) 
    {
        printf("%p\n", f.a->s);
    }
    return 0;
}
:
00000004

解释一下这段代码:
【C语言-自定义类型】还能这样整?_第2张图片
访问 空结构体指针 的成员,结果打印了 00000004

接下来,我们好好剖析一下结构体到底是怎么回事

结构体和其成员的本质

首先我们要了解:任何变量的本质其实都是地址,是内存地址的抽象名字,因为机器只认地址,变量在编译的时候会被转成地址

struct test
{
	int i;
	char* p;
};

int main()
{
	struct test t;
	return 0;
}

引用陈皓大佬的代码,gdb进来:

// t实例中的p就是一个野指针
(gdb) p t
$1 = {i = 0, c = 0 '\000', d = 0 '\000', p = 0x4003e0 "1\355I\211\..."}

// 输出t的地址
(gdb) p &t
$2 = (struct test *) 0x7fffffffe5f0

//输出(t.i)的地址
(gdb) p &(t.i)
$3 = (char **) 0x7fffffffe5f0

//输出(t.p)的地址
(gdb) p &(t.p)
$4 = (char **) 0x7fffffffe5f4

可以看到:

  • t 这个结构体的地址是 0x7fffffffe5f0

  • t.i 这个结构体成员的地址是 0x7fffffffe5f0

  • t.p 这个结构体成员的地址是 0x7fffffffe5f4

也就是:

  • t.i = (&t) + 0x0
  • t.p = (&p) + 0x4

得出:

  • 结构体的成员访问就是 加偏移量(相对地址)

*偏移量通过内存对齐得到

再看一段可以体现此本质的代码

struct test
{
	int i;
	short c;
	char* p;
};

int main()
{
	struct test* t = NULL;
	return 0;
}

引用陈皓大佬的代码,gdb进来:

(gdb) p pt
$1 = (struct test *) 0x0
(gdb) p pt->i
Cannot access memory at address 0x0
(gdb) p pt->c
Cannot access memory at address 0x4
(gdb) p pt->p
Cannot access memory at address 0x8

解惑1

可以看到,即使 t 是 NULL,还是通过偏移量(相对地址)访问,也相当于访问 pt 的内址

现在就能够理解为什么访问空结构体指针的成员能打印个 00000004 出来了吧

成员数组和成员指针

区别:

通过汇编来看 “ 不是bug的’bug’ ”

  • 对于char s[0]来说,汇编代码用了lea指令,lea 0x04(%rax), %rdx
  • 对于char*s来说,汇编代码用了mov指令,mov 0x04(%rax), %rdx
lea  0x04(%rax),   %rdx //对于 char s[0]
mov  0x04(%rax),   %rdx//对于 char* s

lea(loading effective address): 加载有效地址(把地址放进去,不访问其中的数据)
mov:把地址里的数据放进去(访问地址中的数据)

有个很有意思的比喻,lea 好比到银行门口张望,但是不抢银行,也不犯法;mov 好比在警察眼皮子底下抢银行

总结:

  • 访问成员数组只是拿到 相对地址 ——没访问
  • 访问成员指针则是拿到 相对地址里的数据 ——访问了

解惑2

到此,咱们也能解释为什么不是在第16行报错
:我们可以拿到 00 00 00 04 这个地址,但绝对不能访问其中的数据

(7)柔性数组

柔性数组:结构中最后一个未知大小的数组

声明

struct S
{
	char c;
	int i[];
};

或是

struct S
{
	char c;
	int i[0];
};

理解

柔性数组在结构中到底是什么存在呢?

引陈皓大佬

其实它连内存空间都不占,可以把它看作一个占位的标识,等我们通过这个标识,给其开辟的空间,它才从标识变成了有长度的数组

使用

声明

struct S
{
	char c;
	int i[0];
};

int main()
{
	struct S* p = (struct S*)
		malloc(sizeof(struct S) + 100 * sizeof(int));
	if (NULL == p)
	{
		perror("malloc");
		return 1;
	}

	int i = 0;
	for (i = 0; i < 100; i++)
	{
		p->i[i] = i;
	}
	for (i = 0; i < 100; i++)
	{
		printf("%d ", p->i[i]);
	}
	return 0;
}

既然柔性数组在没开辟空间的时候是 标识,那我们为他开辟空间的时候就要算上结构体内其他的成员啦

所以有了 malloc(sizeof(struct S) + 10 * sizeof(int))

读到这可能很多人有疑问:我拿个指针不一样可以代替这破柔性数组吗?可以,一起看看

struct S
{
	char c;
	int* pi;
};

int main()
{
	struct S* p = (struct S*)malloc(sizeof(struct S));
	p->pi = (int*)malloc(100 * sizeof(int));
	if (NULL == p)
	{
		perror("malloc");
		return 1;
	}

	int i = 0;
	for (i = 0; i < 100; i++)
	{
		p->pi[i] = i;
	}
	for (i = 0; i < 100; i++)
	{
		printf("%d ", p->pi[i]);
	}

	free(p->pi);
	p->pi = NULL;
	free(p);
	p = NULL;
	return 0;
}

两次开辟,两次释放,也能完成柔性数组的工作

但是柔性数组就没有它的优点吗?和这种指针实现比较,有两个优点:

  • 一次开辟,空间是连续的,没有内存碎片;一次释放,方便
  • 访问速度更高一点,可是基于我们研究过的结构体成员数组和成员指针,都知道到底还是要偏移访问,也快不了太多
    但是不管如何,分配了空间的柔性数组,它原地就是数据,而指针还要再解引用,总归快点

2.枚举

枚举:一一列举

enum day
{
	//枚举类型day 包含的枚举常量
};

定义与初始化

定义

给出枚举常量

初始化

默认初始化:从第一个到最后一个枚举常量的值为 0—n-1

每个枚举常量的值都是前一个+1

enum day
{
	//枚举类型day 包含的枚举常量
	Mon,//0
	Tues,//1
	Wed = 5,//5
	Thur,//6
	Fri,//7
	Sat,//8
	Sun//9
};

用处

  1. 增加代码可读性
int main()
{
	int input = 0;
	scanf("%d", &input);
	switch (input)
	{
	case 0://不直观
	case Mon://直观
		printf("Mon\n");
		break;

	case 1://不直观
	case Tues://直观
		printf("Tues\n");
	}(input == 8);
	return 0;
}
  1. 增加了“类型检查”,比#define定义的常量更严谨
  2. 用起来效率高(不指执行效率),可以一次定义多个常量
  3. 防止命名污染(把相近的常量封装起来了)
enum day
{
	Mon,
	Tues,
	Wed,
	Thur,
	Fri,
	Sat,
	Sun
};
----------------
#define Mon 1
#define Tues 2
#define Wed 3
#define Thur 4
#define Fri 5
#define Sat 6
#define Sun 7

易错

  • 对枚举常量的赋值

常量也能赋值吗?二次赋值,对于枚举常量和枚举常量是可以的

enum color
{
	Red,//0
	Green,//1
	Blue//2
};

int main()
{
	enum color clr = Red;
	clr = Green;//(1)
	clr = 2;//(2)
	return 0;
}

(1)没问题:同类型的枚举常量可以互相赋值
(2)错误:常量2 不是和 clr 同类型的枚举常量,不能赋值(C语言是强类型的语言)


3.联合体(共用体)

联合体的成员共用一块内存空间(都保存在同一块内存)

声明

union un
{
	char a;
	char b;
};

定义

union un
{
	char a;
	char b;
};

实例

用联合体来判断机器大小端:

union un
{
	int i;
	char c;
};

int main()
{
	union un u;
	u.i = 0x11223344;
	u.c = 0x00;
	printf("%x\n", u.i);
	return 0;
}11223300

【C语言-自定义类型】还能这样整?_第3张图片

轻松判断:小端

联合体是把双刃剑

  • 共用一块内存空间,代表有超多不同的访问途径(就像 p[i] 和 *(p+i) 都没两样),对于char 成员,通过int来访问就出事

我们在使用的时候要通过正确的途径,保证不同时使用联合体成员

用处

节省空间


本期分享就到这啦,不足之处望请斧正

培根的blog,和你共同进步!

你可能感兴趣的:(C语言,c语言)