深入剖析 C 语言指针

other 专栏收录该内容
2 篇文章 0 订阅

更多分享内容可访问我的个人博客

https://www.niuiic.top/

最近得空玩了会儿 stm8 单片机。本来还想着能不能用 rust 语言开发,可惜 llvm 不支持该架构,只好捡起许久不用的 C。复习 C 语法的时候正好看到了一本书 ——《Pointers on C》,中文名《C 和指针》。本以为是主讲指针的,还期待着能学到什么骚操作,没想到还是偏基础,指针的内容也不是很多。不过书中讲解指针的部分算是非常详细了,于是取其精华并略加拙见写成此文与各位分享交流经验。

为什么需要指针

程序就是各数据结构按照一定逻辑来运作。基础数据类型是数据结构,比如 int 占 4 个字节,在内存层面可以将其看作 4 个字节的串联,或者长度为 4 的字节数组。复杂的类型,如结构体、类、各种容器等都是数据结构。那么如何高效的管理这些数据结构呢?

比如我有一个整型变量 a。我可以在程序中使用 a 来表示它所占用的那一块内存。那好,结构体变量有变量名,类变量也有变量名。照这样看,那我只需要变量名不就可以管理所有的数据结构了吗。

然而,如果我有一个结构体数组,我想要灵活地调用每一个结构体,就需要给每个结构体取个名字,每个结构体内部还有字段,为了灵活使用字段,还需要给所有字段取个名字。那样的管理效率就会相当低下。所以我们需要指针来管理数据结构所占用的内存区域。

看到这,或许你要反驳,我这个数组只需要一个数组名,其他的用下标全可以找到,结构体字段也是一样的。不好意思,这些也都算是指针的功劳。

C 语言指针的特点

我愿用两个词来形容:简单而强大(这里的特点是相对于其他系统级编程语言而言的)。

简单是结构简单,而不是难度小。相比 rust 语言好几种类型的指针(各对所指区域有不同的权限,除此之外还有其他区别)而言,C 的指针形式很单一,说到底无非就是把一块内存作为变量的值而已。

强大在于 C 语言指针本身没有权限限制。你可以指向一个不应该被触动的区域,并且修改其内容。你也可以将一个函数作为指针指向的内容,从而把函数作为参数传入。

C 语言数据类型

要说指针,先得讲数据类型。首先请回答两个问题。变量 a 是什么类型?98 是什么类型?

int a = 98;

很显然,变量 a 为整型。那么 98 呢?它不是整型,而是没有类型。说这些是什么意思呢,请继续往下看。以下程序中字符型变量 b 的内容是什么?

#include <stdio.h>

int main(int argc, char *argv[]) {
  int a = 98;
  char b = a;
  printf("%c\n", b);
  return 0;
}

打印结果为字符b。这里得出一个结论。

数据类型并不是被变量的值固定的,它取决于你怎么用。

这里a作为整型变量,占据 4 个字节(64 位系统)。其值 98 以二进制数表示为00000000 00000000 00000000 01100010。字符型变量b,占据 1 个字节,也就是01100010。自始至终,所有的二进制数都没有变,但是98变成了b,整型变成了字符型。

进一步,我们可以认为所谓数据类型,无非就是占据的内存大小 + 独有的解析方式。你定义一个字符型变量,编译器就给你 1 个字节,另外用这个变量的时候使用字符型的方式来解析它,比如之前的变量 b 被解析为字符b

比如说,内存中有连续的 8 个字节,其内容为00000000 00000000 00000000 01100010 00000000 00000000 00000000 01100010。这可能是什么,一个210453397602或者两个98,或者一个字符串,或者一个字符数组加一个98

记住这个设定,然后继续往下看。

快速了解指针

#include <stdio.h>

int main(int argc, char *argv[]) {
  int a = 98; // 定义整型号变量 a
  int *b = &a; // 定义整型指针b指向变量 a 所在的位置
  printf("%d\n", b); // 打印出变量 a 的地址,也是变量 b 的值
  printf("%d\n", &b); // 打印出变量 b 的地址
  printf("%d\n", *b); // 打印出变量 a 的值
  return 0;
}

这是一个相当简单的程序。但却暗藏陷阱。

  1. b, *b, &b

* 在《Pointers on C》中被称为 indirect operator,间接操作符。意为间接地从所指向的地址中取得其存放的数据。*b取得 a 的值。

&是取地址符,&b取得 b 的地址。

  1. int *b = a;*b = &ab = &aint *b = &a

后两者正确。

int *b这个*是 b 的吗?是int的吗?都不是。为了后续能够正确的应对复杂的指针类型,这里不能把int *看作整型指针这个类型。而是应该将其分开,*是独立的。那么我们来试一遍。首先找到变量名b。然后找到其周围的运算符,这里就只有**表示b是一个指针。什么指针呢?前面有一个int,ok,整型指针。

int *b是一个整型指针,它的值应该是a的地址,所以int *b = &a*b是什么,b指向的地址处存放的值,b 要指向 a,*b就应该是 a 的值,而不是 a 的地址。所以*b = &a错误,b = &a正确(b 的值是 a 的地址)。那么*b = a正确吗?在让 b 指向 a 的条件下,是错误的。这条语句的意义是让 b 指向的地址上存放的内容变成 a 变量的值。那 b 指向的地址会变成 a 吗?显然不是,不过是地址上存放的值与 a 相同而已。

讨论指针的本质

#include <stdio.h>

int main(int argc, char *argv[]) {
  int nums[] = {1, 2, 3, 4, 5};
  int *pn = nums;
  for (int i = 0; i < 5; i++) {
    printf("%d", *pn++);
  }

  printf("\n");

  double dnums[] = {1.1, 2.2, 3.3, 4.4, 5.5};
  double *pdn = dnums;
  for (int i = 0; i < 5; i++) {
    printf("%lf\n", *pdn++);
  }
}

看以上程序,两个部分同样是用指针对数组进行了遍历。你可以从中发现什么?

无论是pn还是pdn,它们使用了++运算符来指向下一个元素。可是问题是什么?int 和 double 类型的大小并不一致。那为什么两个指针+1后都可以正确地指向下一个元素呢?

回顾一下上文对数据类型的设定。我们来对指针也进行设定。

首先指针是一种数据类型,所以它也是占据的内存大小 + 独有的解析方式。指针在 64 位系统上的大小是 8 个字节。不管是int *还是double *,大小都是一样的。这里就要指出,不要把int *看作一种类型。指针就是指针,其本身就是一种类型。你可以把整型指针中整型看作指针类型的修饰符,或者更形象一点,整型指针属于指针科整型属。

然后,指针所管辖的单位内存长度由修饰符int决定。假设int修饰的指针指向内存 100,单位操作区域长度为 4 个字节,为 100、101、102、103。当指针加 1 时,就进入下一个单位操作区域,也就是前进 4 个字节。这里就可以解释为什么说不要把int *看作一种类型。假设我有一个类型叫tni,它也是 4 个字节。那tni *int *的区别在哪里呢?其他都一样,只是独有的解析方式有差异。但这种差异是可以通过使用方式抹平的(抹平是有限制的)。比如int要用%d打印,tni也可以用%d打印,只要两者的二进制值一样,且编译器支持两种类型相互转换,打印出来就是一样的。

这里举几个例子。

#include <stdio.h>

typedef struct chars {
  char a;
  char b;
  char c;
  char d;
} * chars;

int main() {
  int num = 0x61626364;
  chars s = &num;
  printf("s.a=%c s.b=%c s.c=%c s.d=%c", s->a, s->b, s->c, s->d);
  return 0;
}

结果为s.a=d s.b=c s.c=b s.d=a。这里结构体的字段与对应的地址是低位到高位排列的。

#include <stdio.h>

typedef struct chars {
  char a;
  char b;
  char c;
  char d;
} * chars;

int main() {
  int nums[] = {1, 2, 3, 4, 5, 6, 7};
  chars s = nums;
  for (int i = 0; i < 7; i++) {
    printf("%d\n", *s++);
  }
  return 0;
}

在这里printf语法是无法正确执行的,因为chars类型不能用这种格式打印。但是s++每次的地址依旧是整型数组中每一个元素的地址。

如果你用int *指针指定该位置,然后再打印就是可以的。比如以下程序。

#include <stdio.h>

typedef struct chars {
  char a;
  char b;
  char c;
  char d;
} * chars;

int main() {
  int nums[] = {1, 2, 3, 4, 5, 6, 7};
  chars s = nums;
  int *j = nums;
  for (int i = 0; i < 7; i++) {
    j = s;
    printf("%p ", s++);
    printf("%p ", &nums[i]);
    printf("%d\n", *j);
  }
  return 0;
}

注意chars不能转化为int,如果写成这样就是不行的。printf("%d\n", (int)*s);

这里其实可以看作使用指针将chars转化为int。虽然没有什么意义,但是体现了 C 指针的强大之处。你完全可以据此为两个无法互相转化的类型定义转化函数。

再比如以下程序。

#include <stdio.h>
#include <stdlib.h>

typedef struct chars {
  char a;
  char b;
  char c;
  char d;
} * chars;

int main(int argc, char *argv[]) {
  chars s = malloc(4);
  s->a = 97;
  s->b = 98;
  s->c = 99;
  s->d = 100;

  char *j = s;

  int *k = s;

  for (int i = 0; i < 4; i++) {
    printf("%p ", j);
    printf("%c\n", *j);
    j++;
  }

  printf("%d\n", *k);
  return 0;
}

*j会打印出什么?abcd*k会打印出什么?1684234849

所以这里做个总结,将指针type *设定为占据内存长度为8个字节的变量,其单位操作区域为 sizeof(type) 个字节,其管辖区域存放的值将用type类型的形式进行解析

注意之前提到的单位操作区域不同于管辖区域单位操作区域之前已经解释了,所谓管辖区域就是我指针指到哪,哪就是我的管辖区域,至于会不会管到无人区或者别人那,就是使用者的事了。

注意这只是一个设定,方便理解指针,并不依照底层原理。

指针进阶

指针与其他操作符的结合

有了前文的两个设定,下面来看一些有意思的较复杂的指针。

  1. int *f, g

f是指针,g不是。

* 是独立的,不要把int *看作一个类型。

  1. int *f()

f是一个返回整型指针的函数。

  • *f是指针,f()是函数,f[]是数组。
  • * 是一个操作符,() 的优先级高于*
  • 依据上面使用过的规则,再来一遍。首先找到变量名 f,然后看其周围的运算符,()优先级高,那么 f 是一个函数,什么函数看前面的,很容易看出 f 是一个返回整型指针的函数。
  1. int (*f)()

f是一个指向返回整型的函数的指针。

  1. int *f[]

f是一个数组,其元素为整型指针。

  1. int f[]()

f是一个函数的数组。

[]()优先级相同,因而从左往右看。

  1. int f()[]

f是一个函数,返回整型数组。

这不是合法的语法。

  1. int (*f[])()

f是一个数组,数组的元素是指向函数的指针。

  1. int *(*f[])(int, float)

f是一个数组,数组的元素是指向函数的指针,函数的参数有两个,分别是 int 和 float 类型,函数的返回值是整型指针。

还有更多例子就不一一列举了。另外因为 C 指针的灵活性很高,用法也有很多,这里只给出一个函数指针的简单使用案例,看看如何在 C 语言编程中应用简单工厂模式。

按一般的说法,C 语言是面向过程编程的语言,但当你掌握指针之后,完全可以进行面向接口编程。甚至如果不嫌麻烦,也可以面向对象编程。以下是一个简单的面向接口编程的案例。

#include <stdio.h>

typedef struct Input {
  double leftNum;
  double rightNum;
  char operator;
} Input;

double (*calculate)(Input *input);

double add(Input *input) { return input->leftNum + input->rightNum; }
double minus(Input *input) { return input->leftNum - input->rightNum; }
double multiply(Input *input) { return input->leftNum * input->rightNum; }
double divide(Input *input) {
  if (input->rightNum != 0) {
    return input->leftNum / input->rightNum;
  } else {
    printf("Error : the right number is 0.\n");
    return 0;
  }
}

// Return the concrete calculation function pointer.
double (*getCalculationFunction(char operator))(Input *input) {
  switch (operator) {
  case '+':
    return add;
  case '-':
    return minus;
  case '*':
    return multiply;
  case '/':
    return divide;
  default:
    printf("Error : invalid operator.\n");
    return NULL;
  }
}

int main(int argc, char *argv[]) {
  Input input = {0, 0, '*'};

  printf("Please input left number\n");
  scanf("%lf", &input.leftNum);
  printf("Please input right number\n");
  scanf("%lf", &input.rightNum);
  getchar();
  printf("Please input the operator\n");
  scanf("%c", &input.operator);

  calculate = getCalculationFunction(input.operator);

  if (calculate != NULL) {
    double result = calculate(&input);
    printf("The result is %lf.\n", result);
  }

  return 0;
}

指针与 const

C 语言的声明绝对是非常可怕的。原因就在于你无法正常地从左向右理解声明的含义。比如以下三个声明。

const int * a;
int const * a;
int * const a;

前两者表示指针所指的对象是只读的,最后一个表示指针本身是只读的。那如果要使得两者皆为只读,又要怎么办呢?

const int * const a;
int const * const a;

初学者看到这肯定已经晕头转向了。不过不要慌,规律很好找,const会和左边紧邻的*结合,前两个的const左边都没有*,那么是和int结合,表示所指的对象是只读的,第三个* const表示指针只读。再把两者结合一下就是后面两个了。

如果你还行的话,知道这是什么类型吗?char * const *(*next)();

稍微解释一下,依旧是之前的逻辑。先找到变量名next。然后是(*next),所以该变量是一个指针。再然后是(*next)(),指向函数的指针。再是char * const *,函数返回值,显然是一个二级指针,指向一个只读的指向 char 的指针。所以合起来就是next是一个指向函数的指针,该函数返回一个指针,该指针指向一个只读的字符型指针。

关于 const 指针的特性看几个例子就明白了。

#include <stdio.h>
int main(int argc, char *argv[]) {
  const int b = 1;
  int *a = &b;
  *a = 2;
  printf("%d\n", b); // 2
  return 0;
}
#include <stdio.h>
int main(int argc, char *argv[]) {
  const int b = 1;
  const int *a = &b;
  *a = 2; // error
  printf("%d\n", b);
  return 0;
}
#include <stdio.h>
int main(int argc, char *argv[]) {
  int b = 1;
  int c = 2;
  int *const a = &b;
  a = &c; // error
  printf("%d\n", *a);
  return 0;
}
#include <stdio.h>
int main(int argc, char *argv[]) {
  int b = 1;
  int c = 2;
  int *const a = &b;
  int **d = &a;
  *d = &c;
  printf("%d\n", *a); // 2
  return 0;
}

多级指针

多级指针无非就是指向指针的指针。上文已经说了指针本身是个变量,所以指向指针的指针与指向其他变量的指针没什么区别。此处不再详述。

指针的生命周期

所谓生命周期就是指针存在的时间,什么时候生,什么时候死。生命周期的概念在 rust 语言中得到广泛应用,这也是支撑 rust 内存安全特性最重要的内容。如果想对这方面做深入了解,可以去学习 rust 语言。

C 指针的生存周期其实很明确,但由于编译器不帮忙排查问题,不会提醒用户,所以这些工作以及风险全部由用户承担。

用一个词来概括指针的生与死就是有借有还。详细点就是指针生来就需要“借”一块内存来管理,如果指针所管辖的这块区域不用了,就应该将内存还回去,并且这个指针应该消亡。

举个例子,看以下程序。

int main(int argc, char *argv[]) {
  int *a;
  int b = 2;
  *a = b;
  return 0;
}

a生来就管理了一块内存(虽然没有指定,但是实际是有的,有些是编译器给的,有些是 a 的地址上残留的数据)。然后a向那块内存中写入了一个 2。好了,问题来了,写哪去了?正好写到我要的位置了(真是绝了,这编译器真的懂我),还是写到系统敏感部位直接把系统崩了?好吧,都没可能,运行在系统上的程序都是被内核限制的,就这种写法崩不了系统,也几乎不可能写到想要的位置。

这里违反了有借原则,咱们是主动地合理地借一块内存,不是编译器施舍给你一块,也不是借到奇奇怪怪的地方去。关于这个,可以这样理解。在栈上,假设 a 指向变量 b。b 所占的内存本来是他独享的,这会儿 a 指了过去,变成 ab 共享了,这算是,注意a只是借来了使用权,没有所有权。这块内存释不释放还得 b 说了算。但是如果在堆上,你就可以用free函数通过指针释放内存,这会儿是指针说了算了。

另外说一下这个NULL的事,众所周知,这表示空指针。但是你查看其定义就会发现#define NULL ((void*)0),它其实是个 0。在 PC 上基本上这个地址是不能被操作的,但有些机器上却是可以的。所以操作空指针不一定是程序中断了,在某些地方可能会悄无声息地给你造成麻烦。

再看一个例子。

#include <stdlib.h>
int main(int argc, char *argv[]) {
  while (1) {
    int *a = (int *)malloc(100000);
  }
}

这会儿一直在借内存,但就是不还。用不了几秒钟,你的内存就会被跑满。

所以记住有借有还的原则,不要不给内存就要求指针去干活,更不要有借无还,这样下次就没人肯借了。另外及时清理指针的尸体(已经不需要的指针)也是有必要的。所谓清理尸体,一种是退栈的时候程序顺道扫了(这种时候指针本身在栈上),一种是指针变量不被清除(比如你将其设置为全局变量了,或者指针本身在堆上),那么堆上的指针可以直接释放,栈上的指针设为NULL,使用的时候进行判断是否可以用。

记住原则是不够的,有时候负债多了,自己都记不得债主有哪些了。这种就只能靠经验和一些辅助方法(比如把所有债主写下来)了。

总结一下。

  1. 创建指针变量后,使用前必须将指针指向有效位置。
  2. 内存使用完毕后及时释放。栈空间在退栈的时候自动释放,堆空间需要手动释放。
  3. 不再使用的指针(所指区域已被释放,或者不想用这个指针了)必须置NULL或将其清除。

最后,如果你真的懂了,来看看以下程序有什么问题。

int * generateArray(int a, int b, int c){
    int num[] = {a, b ,c};
    return num;
}
  • 0
    点赞
  • 2
    评论
  • 4
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

©️2020 CSDN 皮肤主题: 黑客帝国 设计师:白松林 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值