阅读 177

谈谈我对指针和结构体的理解

为什么要学结构体和指针

最近重学C语言版的数据结构,在动手实现前,觉得好像和Java也差不了太多,直接上手写了一个最基本的顺序存储的线性表,嗯,有几个语法错误,在编译器的提示下,修正并运行起来,棘手的问题才刚刚开始,segment fault,出现了。

segment fault翻译过来也就是段错误属于Runtime Error 当你访问未分配的内存时,会抛出该错误

C中关于内存的问题还有内存泄漏(memory leak), 这些问题最终都有可能抛出段错误,或者程序运行无响应,又或在运行结束后返回像这样的一行语句

Process exited after 5.252 seconds with return value 3221225477

而这些内存问题的根源往往是对指针的使用不够恰当,忽略了指针的初始化,或者弄不清楚指针的指向。由于大一学习C语言时,不够用功,对指针与结构体的基础有相当的缺失,为了弥补这些缺失,同时方便后续数据机构的C实现,我决定重新探究一下C和指针。

在学习的过程中也很感谢C和指针这本书,我重点阅读了6,10,11,12四个章节的内容,pdf版我也会放在文末。

结构体的定义和使用

struct ListNode {
    int a;
    float b;
    char* c;
};
//未define type前生命结构体变量必须跟上struct关键字
struct ListNode ln = {0};  //初始化,a=0,b=0.000000, c = NULL/0
struct ListNode* p;
//typedef可以省去struct,直接用ListNode声明
typedef struct ListNode ListNode;
ListNode ln; //valid
typedef struct ListNode* PtrToListNode;
PtrToListNode p;  //valid
复制代码

结构体(struct)的用途

  1. 类似面向对象中类的功能,方便访问结构化的数据
  2. 结合指针来实现链表,这也许才是结构体的最广泛的用途了吧,毕竟要用到类的话,为什么不选择一门面向对象的语言呢, 因此本文主要强调链表

结构的存储分配,有趣的问题

来看下面这两个结构

struct s1
{
	char a;
	int b;
	char c;
};
struct s2
{
	int b;
	char a;
	char c;
};
复制代码

它们的成员域完全一样,只是生命变量的顺序不一样,那么它们的大小呢?

printf("s1 size:%d\n", sizeof(struct s1));
printf("s2 size:%d\n", sizeof(struct s2));
复制代码

输出结果:

s1 size:12
s2 size:8

这就是这两个结构体存储结构的差异导致的,我们都知道

1个int = 4个字节

1个字符 = 1个字节

  • s1: 先拿1个字节存放a, 而a后面的3个字节空闲,接下来4个字节的b, 最后1个字节的c空闲3个字节,以存放下一个结构体,一共12个字节

  • s2: b占用4个字节,a,c 占用2个连续的字节, 最后空余2个字节,以存放下一个结构体,一共8个字节

指针

1.指针的基本概念

  • 指针也是一个变量
  • 指针的声明并不会自动分配任何内存,指针必须初始化,未清楚指向的先初始化为NULL
  • 指针指向另外一个变量(也可以是另一个指针)的内存地址
  • 指针的内容是它所指向变量的值
  • 指针的值是一个整形数据
  • 指针的大小是一个常数(通常是4个字节,64位操作系统是8个字节,但编译器也许统统默认为32位)
  • 指针的类型(void*/int*/flot*等)决定了间接引用时对值的解析(值在内存中都是由一串2进制的位表示,显然值的类型并非值本身固有得特性,而是取决于它的使用方式)

例:一个32位bit(4个字节byte)值:01100111011011000110111101100010

类型
1个32位整数 1735159650
2个16位整数 26476和28514
4个字符 glob
浮点数 1.116533 * 10^24

2.指针的使用

指针的使用场景:

  1. 建立链表
  2. 作为函数参数传递
  3. 作为函数返回值
  4. 普通数组一样使用指针
  5. 建立变长数组

1.单链表的建立

struct ListNode {
    int val;
    struct ListNode *next; //注意这里的*,想想没有*的话该结构体的定义合法吗?
};
复制代码

链表是C语言,数据结构的难点,关于链表的详细问题,我会在下一篇博客中详细解释。

2.什么时候要用指针作函数参数?

Ans:

  1. 要通过函数改变一个函数外传来的参数的值,只能用址传递,即用指针作为参数传进函数。
  2. 即使不要求对变量修改,也最好用指针作参数。

尤其是当传入参数过大时,例如一个成员众多的结构体,最好用指向该结构体的指针来代替,一个指针最大也就8个字节,不仅如此,C语言传值调用方式要求将参数的一份拷贝传递给函数。

因此,值传递对空间和时间都是一个极大的浪费,以后可以看到将指针作为参数的例子将会很常见,址传递唯一的缺陷在于存在函数修改该变量,可以将其设为常指针的方式避免这种情况的发生。

void print_large_struct(large_struct const * st){}

这行语句的作用是,告诉编译器我的st这个指针是一个常指针,它指向的内容不能被改变,如果我在函数不小心改变了它的内容,请报错给我。

只能用指针的例子--调用函数改变a的值

//改变参数的值的两种方式
void changeByValue(int a){
	a = 666;
	printf("in the func:%d\n", a);
}
void changeByAddr(int* a){
	*a = 666;
}
int main()
{
	int a = 0;
	
	changeByValue(a);  //a直接作参数
	printf("out the func:%d\n", a);

	changeByAddr(&a); //取a的地址作参数
	printf("after changeByAddr:%d\n", a);

	return 0;
}
复制代码

输出结果:

in the func:666 out the func:0
after changeByAddr:666

3.为什么用指针来作为返回值?

Ans:
我的意思是,你有时可以这么做

4.指针与普通数组

  • 指针指向数组
int a[3] = {1, 2, 3};
int* pa = a;
for (int i = 0; i < 3; ++i)
	printf("%d\n", *pa++);
复制代码

*pa++实际上是先对pa间接引用,即*pa,再执行pa = pa + 1,注意这里的1不是指针运算上移动一个字节,编译器会根据指针的类型进行移动,例如这里类型,是整型实际上移动4个字节,一个int的长度。

  • int
for (int i = 0; i < 3; ++i)
	printf("%d\n", pa++);
复制代码

输出结果:

6487600
6487604
6487608

  • double
double b[3] = {1, 2, 3};
double *pb = b;
for (int i = 0; i < 3; ++i)
  printf("%d\n", pb++);
复制代码

输出结果:

6487552
6487560
6487568

与此同时,数组变量本身就是指向数组第一个元素也就是a[0]的指针,它包含了第一个元素的地址,因此也完全可以把a当成一个指针来用,以下的引用都是合法的。

p = a;
p = &a[0]; //与上面相同都是将p指向a数组

//a++不合法,数组名不能作左值进行自增运算,采用*(a+i)的方式推进
for (int i = 0; i < 3; ++i)
	printf("%d\n", *(a+i));
复制代码

5.操作指针来自定义一个变长数组?

写下这一点的我又看了翁恺老师的mooc(c语言进阶),其中的4.1非常的经典,基本是线性表的雏形了。

  • 变长数组
//Q1
typedef struct Array{
	int * array;
	int size;
}Array;
//Q2
Array arrary_create(int init_size){
	Array a;
	a.size = init_size;
	a.array = (int*)malloc(init_size * sizeof(int));
	return a;
}

void array_free(Array* a){
	free(a->array);
	a->array = NULL;
	a->size = 0;
}
//Q3
void array_inflate(Array* a, int more_size){
	int* p = (int*)malloc((a->size + more_size) * sizeof(int));
	for (int i = 0; i < a->size; ++i)
		p[i] = a->array[i];
	free(a->array);
	a->array = p;
	a->size = a->size + more_size;
}
//Q4
int array_at(Array const * a, int index){
	return a->array[index];
}
复制代码

在以上代码,我分别作了4个标记,它们对应着4个问题。

Q1:Why Array not Array* ?

  • typedef struct Array{...}* Array

这么做?我将无法得到一个结构体的本地变量,我只能操作指向这个结构体的指针,却无法生成一个结构体,这是一个可笑的问题,我的指针该指向谁呢? 同时,看到Array a;你能想到a它是一个指针吗?

Q2:Again ?Why Array not Array* ?

Array* array_create(int init_size){
	Array a;
	a.size = init_size;
	a.array = (int*)malloc(init_size * sizeof(int));
	return &a;
}
复制代码

这样做?注意到这个a是在array_create函数里面定义的局部变量哦。让我们来看一下C的回收机制,你就会明白,为什么这样做行不通。

  1. 如果是在函数内定义的,称为 局部 变量,存储在栈空间内。它的空间会在函数调用结束后自行释放。
  2. 如果是全局变量,存储在DATA段或者BSS段,它的空间是始终存在的,直至程序结束运行。
  3. 如果是new或者malloc得到的空间,它存储在HEAP(堆)中,除非手动delete或free,否则空间会一直占用直至进程结束。

函数的确返回了一个指针,但在函数返回的同时,a就会被回收,那么你返回的a的地址就是一个指向未知位置的指针,是一个意义不明确的值,不再是你所认为的指向那个你当初在函数里创造的结构体了哦。

另一种做法?

Array* array_create(Array* a, int init_size){
	a->size = init_size;
	a->array = (int*)malloc(init_size * sizeof(int));
	return a;
}
复制代码

这么做不是不可以,但它有两个潜在的风险

  1. 如果a == NULL ,那么这必然引发内存访问错误;
  2. a已经指向了某个已经存在的结构体,那你在新建的是不是要对a->array进行free呢?

与其这样复杂,我们不妨采用更为简单的办法,返回一个结构体本身。

Q3: 每次inflate都要将原来array里的元素复制到新申请的空间里面太复杂?

当然,你也可以这样做:

void array_inflate(Array* a, int more_size){
	a->array = (int*)realloc(a->array, (a->size + more_size) * sizeof(int));
	a->size = a->size + more_size;
}
复制代码

那么既然都已经接触到malloc,realloc了,不妨在此总结以下这几个函数吧!

malloc calloc realloc 和 free

它们都是从堆上获取可用的(连续?至少逻辑上是连续的,物理上根据操作系统, 很可能不是连续的)的内存块的首地址,返回的都是void*类型的指针,都需要强制类型转换。
它们申请的内存有可能比你的请求略多一点,内存库为空时返回NULL指针。
现实是存在这个可能的!因此用到动态内存分配时一定要检查返回是不是NULL啊

  • realloc与malloc不同的在于,realloc需要一个原内存的地址,和一个扩大后的size,如果原内存后面接着有可用的内存块,就将这一部分也分给原地址,否则寻找一个足够大的内存,返回新的地址并且自动将数据复制到新的内存
  • calloc第一个参数为申请的个数,第二个参数为每个单元的大小,例如calloc(100,sizeof(int))申请100个int大小的内存。注意,calloc最大的不同在于它会自动为这些内存初始化,指针初始化为NULL, 很大程度上避免了一些未初始化的错误。
  • free接受一个指针类型的参数,这个参数要么是NULL,free(NULL)是安全的。 要么就只能是上面三兄弟从堆里分配来的内存了。

Q4: Why const* ?

这算是指针作为函数参数传入的例子了吧,const是因为我不希望我访问a中元素时,a被修改掉了,所以告诉编译器帮我盯着一下。事实上我们看到函数里面很安全,并没有对*a进行修改,函数足够简单时,我们完全可以去掉const

3. 指针的运算

需要注意的是,当指针指向的并不是一个数组时,指针的运算是无意义的

//指针是整型的数据,它们之间当然可以运算,但下面是无意义的
int a = 3;
int b = 1;
int* pa = &a;
int* pb = &b;
printf("%d\n", pb - pa);
复制代码

但当指针指向一个数组时,减法运算的意义就是两个指针的距离,这个距离也是一个逻辑上的距离

int a[5];
int *pa, *pb;
pa = &a[0], pb = &a[3];
int distance = pb - pa;
复制代码

得到的distance是16/4(1个int4个字节)为4; 再看一个有趣的例子,指针的关系运算

//让a中元素全部变成5
int a[3] = {1, 2, 3};
int* p;
for(p = &a[0]; p < &a[3]; *p ++ = 5); //长得有点奇怪却合法的for循环
复制代码

a++ 和 ++a的相同点都是给a+1,不同点是a++是先参加程序的运行再+1,而++a则是先+1再参加程序的运行。

在这里我们访问了数组最后一个元素后面那个地址,并与之作比较来决定推进的边界, 这居然是合法的,事实上在最后一次比较时我们的p已经指向了那个位置,但我们没有对其进行间接访问,因此这组循环是完全合法的。 再看下面这个例子:

//将a中元素全部变成0
for(p = &a[2]; p >= &a[0]; p--)
  *p = 0;
复制代码

在最后一次比较时,p已经从a[0]的位置自减了1,也就是说它移到了数组之外,与上一个例子不一样的是,他将与a[0]的地址进行比较,这是无意义的,其中涉及到的标准如下:

标准允许指向数组元素的指针与数组最后一个元素后面的那个内存位置的指针进行比较,但不允许与指向数组第一个元素之前的那个内存位置的指针进行比较

关于C中的指针,内容确实太多,在以后的学习中边踩坑,边总结,写作本文的原因也是在于将自己犯过的错误做一个记录,在总结中积累经验,不断前行,总之,加油吧~

关注下面的标签,发现更多相似文章
评论