一篇带你玩转C语言指针:从入门到精通

简介: 一篇带你玩转C语言指针:从入门到精通

一篇带你玩转C语言指针:从入门到精通

引言:

在现实生活中,当我们要找一个东西时,我们会思考,这个东西在哪儿,这个东西的地址是什么。同样,在计算机的内部世界中,空间也十分广大,要在这块广大空间中找到一个特定的位置是非常困难的,而为了方便这一操作,我们将这一块空间的每一块区域都设置一个编号,这个编号也叫做地址,在C语言中也被叫做指针。本篇我们就来聊聊关于指针的各种知识。



1. 指针是什么

指针也就是内存地址,指针变量是用来存放内存地址的变量,在同一CPU构架下,不同类型的指针变量所占用的存储单元长度是相同的,而存放数据的变量因数据的类型不同,所占用的存储空间长度也不同。有了指针以后,不仅可以对数据本身,也可以对存储数据的变量地址进行操作。指针描述了数据在内存中的位置,标示了一个占据存储空间的实体,在这一段空间起始位置的相对距离值——百度百科

  • 通俗点来讲,每个内存单元都有一个唯一的编号,这个编号也被称为地址,而内存地址就是指针。
  • 写C语言程序的时候,创建变量、数组等都要在内存上开辟空间。
  • 例如对于如下代码:
int a = 10;
char str[10] = { 0 };
  • 我们创建了一个int型四个字节大小的变量a,一个长度为10的char型数组str,这两个数据都会被存放在内存中,并会有其唯一的编号,如图:

地址的产生

  • 那么可能有小伙伴又会问了,地址是怎么产生的呢?
  • 地址是物理的电线上产生的
  • 在32位机器上,有32根地址线,这32根地址线都可以产生0/1两个二进制数,32个0/1组成二进制序列,我们就把这个二进制序列称为地址
  • 同理,在64位机器上,有64根地址线,就会有64个0/1组成的二进制序列,我们同样也把这个二进制序列称为地址。

1.1 取地址与指针变量

  • 如果我们要用代码来获取一个数据的地址,我们就需要用到取地址符&
  • 取出的地址要放到哪里?要放到指针变量中,我们可以说,指针变量就是存放地址的变量
  • 如果我们想用printf()函数将这个地址打印出来,我们就需要格式化输出符%p
  • 代码如下:
#include<stdio.h>
int main()
{
  int a = 10;
  int* pt = &a;
  printf("变量a的地址是%p\n", pt);
  return 0;
}

1.2 指针变量的解引用

  • 如果想要得到指针变量所指向的地址区域的数据,或改变这块区域所存储的值,那我们就需要解引用操作符*
int num = 10;
int *pi = &num;
*pi = 20;
printf("%d",*pi);

1.3 指针变量的大小

  • 我们前面说到,在32位的机器中,我们需要32个比特位来存储地址,而32个比特位就是4个字节,因此,在32位机器上,指针的大小就是4个字节。
  • 同理,在64位的机器中,我们需要64个比特位来存储地址,而64个比特位就是8个字节,因此,在64位机器上,指针的大小就是8个字节。
  • 我们可以用操作符sizeof()来进行查看:
#include<stdio.h>
int main()
{
  int a = 10;
  int* pt = &a;
  char b = 'a';
  char* pc = &b;
  printf("指针变量pt的大小是%d\n", sizeof(pt));
  printf("指针变量pc的大小是%d\n", sizeof(pc));
  return 0;
}
  • 结果如下:

1.4总结

  • 指针就是地址
  • 指针变量就是存放地址的变量,存放在指针变量的值同意当作地址处理
  • 在32位系统中,指针变量的大小为4个字节;64位系统中,指针变量的大小为8个字节。
  • 操作符&用来取出变量的地址
  • 操作符*用来对指针变量进行解引用

2. 指针类型

我们知道,存储数字时,我们有整型,浮点型等一系列数据类型来对不同类型的数字进行存储,那么指针有没有类型呢?当然有!

  • 例如,对于下面的代码:
int num = 10;
&num;
  • 我们知道取出num的地址后应该将其放入指针变量中,那么这个指针变量的类型应该是什么呢?
  • 事实上,指针变量有如下常见类型:
char*       pc = NULL;
short*      ps = NULL;
int*        pi = NULL;
long*       pl = NULL;
long long* pll = NULL;
float*      pf = NULL;
double*     pd = NULL;
………………
  • 可以总结,指针的定义方式为:type + *
  • 注:这里定义里用到的*不是解引用操作符

2.1 指针类型的作用

那有些小伙伴就会有疑惑了,既然指针变量的大小要么是4个字节,要么是8个字节,那我们定义的指针类型如int*、char*等还有什么意义呢?接下来我们就来探讨指针类型到底是来干嘛的。

  • 我们来看如下代码:
#include<stdio.h>
int main()
{
  int num_1 = 0x11223344;
  int* pi = &num_1;
  int num_2 = 0x11223344;
  char* pc = &num_2;
    *pi = 0;
  *pc = 0;
  return 0;
}
  • 我们将num_1的地址赋给类型为int*的指针变量pi,将num_2的地址赋给类型为char*的指针变量pc,之后再对这两个指针变量进行解引用操作实现对地址存放数据的置零。
  • 我们进行调试,看看内存的变化
  • 注:VS中,存数据是倒着存的,但访问数据是从低地址向高地址访问的。如果小伙伴对整数在内存中的存储还不太了解,建议先看看整数在内存中的存储

  • pi指向的num_1被成功置零,这个好理解,但我们惊讶地发现,pc指向的num_2只有一个字节的数据被置零,最后的结果竟然是0x11223300
  • 由此可以知道,指针类型int*解引用可以访问4个字节,指针类型char*解引用只能访问1个字节,而数据类型int和char的大小又是4个字节和1个字节,由此我们可以得出结论:指针类型可以决定指针解引用的时候访问多少个字节(即指针的权限)
  • 再来看一个例子:
#include<stdio.h>
int main()
{
  int num = 10;
  int* pi = &num;
  char* pc = &num;
  printf("%p\n", pi);
  printf("%p\n", pc);
  printf("%p\n", pi + 1);
  printf("%p\n", pc + 1);
  return 0;
}
  • 得到了这样的结果:

  • 我们发现,指针类型int*加一后,跳过了四个字节,指针类型char*加一后,就跳过了一个字节
  • 由此我们可以得出结论:指针类型决定了指针+1/-1操作时的步长

2.2 总结

现在,我们可以对指针定义的方式type + *做出更多的解释了:


3. 野指针

可能有小伙伴会写出这样的代码:

#include<stdio.h>
int main()
{
  int* p;
  *p = 20;
  printf("%d\n", *p);
  return 0;
}

我们发现编译器会给我们报错:

  • 那为什么我们不允许这个现象存在呢?我们知道,如果局部变量未初始化,那么它指向的就是一个随机值,同理,如果指针变量不初始化,那它指向的就是随即一块地址空间,我们要改变这块随即地址的数据,显然是不被允许的。
  • 这个未初始化的局部变量指针p,我们就称之为野指针
  • 野指针:即指针指向的位置是不可知的(随机的、不正确的的、没有明确限制的)

3.1 野指针的成因

以下几种情况会导致野指针的形成:

  • 指针未初始化
#include<stdio.h>
int main()
{
  int* p;
  *p = 20;
  return 0;
}
  • 指针越界访问
#include<stdio.h>
int main()
{
  int arr[5] = { 1,2,3,4,5 };
  int* pi = arr;  //这段代码是什么意思,后面会讲到
  for (int i = 0; i < 10; i++)
        //当指针指向的范围超出数组arr的范围时,pi就是野指针
    printf("%d ", arr[i]);
  return 0;
}
  • 指针指向的空间被释放
#include<stdio.h>
int* Text()
{
  int a = 10;
  return &a;
}
int main()
{
  int* pi = Text();
  printf("%d\n", *pi);
  return 0;
}
  • 注:Text()函数中,a为局部变量,函数结束后就会被释放,因此返回的地址就会变成野指针,虽然仍可以通过解引用强行得到数字10,但这种做法是极其危险的。

3.2 如何规避野指针

  • 指针初始化
  • 明确知道指针应该初始化为谁的地址,就直接初始化
  • 不知道指针初始化为什么,暂时初始化为NULL
  • 注:如果一个指针pt被置为NULL,就说明这是一个空指针,不存放有效数据,不能被直接使用
  • 避免数组越界
  • 指针指向的空间被释放时,及时置NULL
  • 避免返回局部变量的地址
  • 指针使用前检查其有效性

4. 指针的运算

指针存在以下几种运算

  • 指针+/-整数
  • 指针-指针
  • 指针的关系运算

4.1 指针+/-整数

#include<stdio.h>
int main()
{
  int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
  int* pi = &arr[0];
  for (int i = 0; i < 10; i++)
    printf("%d ", *(pi + i));
  printf("\n");
  return 0;
}

4.2 指针 - 指针

#include<stdio.h>
int main()
{
  int arr[10] = { 1,2,3,4,5,6,7,8,9,10 };
  int* pi = &arr[0];
  int* pc = &arr[9];
  printf("%d\n", pc - pi);
  printf("%d\n", pi - pc);
  return 0;
}

可以得到结果:

为什么呢?

  • 我们可以通过画图来理解:

  • 我们可以看到,指针pc和指针pi之间有9个数据,所以结果就是±9
  • 结论:指针 - 指针结果的绝对值就是他们之间的元素的个数,而不是它们之间的字节数。
  • 注:指针 - 指针的前提是,两个指针必须指向同一块空间

4.3 指针的关系运算

  • 我们知道指针是地址,而地址是有大小的
  • 因此,指针的关系运算,就是地址(指针)大小的比较
  • 我们来看下面的代码:
#include<stdio.h>
#define N 5
int main()
{
  float nums[N];
  float* vp;
  for (vp = &nums[N]; vp > &nums[0]; )
    *--vp = 0;
  return 0;
}

  • 由分析图我们可以知道,该代码实现的是对nums数组置0的功能
  • 注:我们需要清楚的一点是,虽然该代码在大多数编译器上都可以顺利完成任务,但我们还是要避免这样写,因为标准并不确保可行
  • 标准规定:允许指向数组元素的指针与指向数组最后的一个元素后面的那个内存位置的指针(如下图的p2)比较,但是不允许与指向第一个元素之前的那个内存位置的指针(如下图的p0)进行比较。
  • 举个例子:


5. 二级指针

  • 我们需要知道,之前所讲的诸如:
char*       pc = NULL;
short*      ps = NULL;
int*        pi = NULL;
long*       pl = NULL;
long long* pll = NULL;
float*      pf = NULL;
double*     pd = NULL;
………………

都称之为一级指针

  • 那么所谓的二级指针又是什么呢?
  • 我们知道,指针变量也是变量,而变量需要在内存中开辟空间来进行存储,因此是变量就一定有地址,也因此指针变量也有它的地址,我们也可以定义一个变量来对一级指针的地址进行存储,而这个存储一级指针地址的变量就叫二级指针
  • 例如:
#include<stdio.h>
int main()
{
  int num = 10;
  int* p = &num;
  return 0;
}
  • 经过调试,我们可以看到指针变量p的地址为0x006ff93c

  • 那么我们该定义怎样的指针类型来对这个地址进行存储呢?大家也看到了,这个类型就是int **
int **p = &p;
  • 那么,我们这么理解这个int **呢?分析如图:

  • 同理,我们也可以以此类推,得到三级指针,四级指针……
int num = 10;
int * p = &num;
int* *pp = &p;
int** *ppp = &pp;
………………

6. 指针和数组

可能有很多小伙伴对指针变量和数组的关系还不太熟悉,现在我们来梳理一下

首先,我们不能将指针变量和数组混为一谈

  • 指针变量就是指针变量,不是数组,指针变量的大小是4/8个字节,是用来存放地址的
  • 数组就是数组,不是指针,数组是一块连续的空间,可以存放1个或多个类型相同的数据

其次,指针变量和数组之间也有一定的联系

  • 一般情况下,我们可以认为,数组名就是数组首元素的地址,是一个指针。但需要排除以下两种情况:
  • sizeof(数组名),这里的数组名不是数组首元素的地址,这里的数组名代表整个数组,sizeof(数组名),计算的是整个数组的大小,单位是字节
  • &数组名,这里的数组名表示整个数组,&数组名取出的是整个数组的地址
  • 例如以下代码:
#include<stdio.h>
int main()
{
  int arr[] = { 1,2,3,4,5 };
  printf("arr = %p\n", arr);
  printf("&arr[0] = %p\n", &arr[0]);
  printf("&arr = %p\n", &arr);
  printf("sizeof(arr) = %d\n", sizeof(arr));
  return 0;
}
  • 我们可以得到如下结果:

  • sizeof(arr) = 20得到在这里arr代表整个数组,我们没有异议,但有小伙伴可能会疑惑,&arrarr代表的地址相等,为什么就说&arr的arr代表的就是整个数组呢?
  • 我们可以通过调试来观察:

  • 可以发现,&arr和&arr[0]的类型分别为int [5] *(实际上写成int (*) [5] 更加准确)、int *,这是两个截然不同的类型。实际上,int *是指向int型数据的指针类型,而int * [5]则是一个指向长度为5的数组的指针类型,简称数组指针。因此,我们说,在&arr中,arr代表的是整个数组。

接下来,我们就来仔细讨论两个容易混淆的概念:数组指针和指针数组

注:学习之前,我们有必要知道,操作符[]的优先级要高于操作符*的优先级

6.1 指针数组

  • 所谓指针数组,就是指针的数组,即存放指针变量的数组
  • 例如int * a[10],a首先和”[10]“结合,说明这是一个存储10个元素的数组,再和前面的”int *“结合,就说明了存储元素的元素类型是int *型的指针类型,即这是一个指针数组。
  • 我们可以用指针数组来模拟二维数组的实现
#include<stdio.h>
int main()
{
  int nums1[3] = { 1,2,3, };
  int nums2[3] = { 4,5,6, };
  int nums3[3] = { 7,8,9, };
    //将一维数组nums1,nums2,nums3的首元素的地址存入指针数组nums中,模拟二维数组
  int* nums[3] = { nums1, nums2, nums3 };
  for (int i = 0; i < 3; i++)
  {
    for (int j = 0; j < 3; j++)
      printf("%d ", nums[i][j]);
    printf("\n");
  }
  return 0;
}
  • 需要清楚的是,尽管这种方法可以模拟实现二维数组,但和真正的二维数组还是有本质上的区别。区别在于:数组是一块连续的空间,而模拟实现的一维数组nums1,nums2,nums3不一定是连续存储的,如图所示:

6.2 数组指针

  • 我们可以通过类比来对数组指针这一概念进行分析,我们知道:
  • 整型指针——指向整型变量的指针,存放整型变量的地址的指针变量
  • 字符指针——指向字符变量的指针,存放字符变量的地址的指针变量
  • 数组指针——指向数组的指针,存放数组的地址的指针变量
  • 我们先来看看下面的例子:
int* p1[10];  //p1是什么?
int(*p2)[10]; //p2是什么?
  • 如果你不能准确自信的说出答案,那接下来你可要好好看了。我们一起来分析:
  • int* p1[10],由运算符的优先级,p1先和[10]结合,说明这是一个数组,再和前面的int*结合,说明数组元素的类型是一个int*型指针,即p1是一个指针数组
  • int(*p2)[10],由于括号的存在,p2先和*结合,说明p2是一个指针,而int [10]则说明了这个指针指向的是大小为10个int型的数组,即p2是一个数组指针
6.2.1 数组指针的特点
  • 我们前面提到,&(数组名)取出的是整个数组的地址,这个地址的指针类型是一个数组指针,那么具体的,这个数组指针有什么特殊之处呢?我们来看下面的代码:
#include<stdio.h>
int main()
{
  int nums[10] = { 0 };
  printf("%p\n", nums);
  printf("%p\n", nums + 1);
  printf("%p\n", &nums[0]);
  printf("%p\n", &nums[0] + 1);
  printf("%p\n", &nums);
  printf("%p\n", &nums + 1);
  return 0;
}
  • 得到了这样的结果:

  • 前面4个打印结果我们没有异议,因为数组名就代表着首元素的地址,类型是int *,因此加1跳过的就是4个字节。那为什么取出数组的整个地址后,加1就跳过了40个字节呢?
  • 这是因为&(数组名)得到的类型是int (*)[10] ,是数组指针,数组指针加1后会跨过整个数组的长度,而这个数组为int [10],因此会跨过40个字节,如图:

  • 总的来说,还是因为指针类型的不同从而决定了指针加1时步长大小的不同。
6.2.2 定义数组指针
  • 知道了什么是数组指针,那么如果我们想要使用数组指针,我们又该如何定义呢?例如:
int nums[] = { 1,2,3,4,5 };
&nums;
//定义一个数组指针来指向数组nums,该如何定义?
  • 我通过画流程图来给大家进行说明:

  • 所以我们可以这样写:int (*p)[10] = &nums,切勿写成int [10] (*p)
6.2.3 数组指针的实际使用

我们用指针来打印一维数组:

  • 我们可以借助数组首元素的地址来进行打印:
#include<stdio.h>
int main()
{
  int nums[] = { 1,2,3,4,5 };
  int* p = nums;
  for (int i = 0; i < 5; i++)
    printf("%d ", *(p + i));
  printf("\n");
  return 0;
}
  • 也可以借助数组指针来打印
#include<stdio.h>
int main()
{
  int nums[] = { 1,2,3,4,5 };
  int(*pi)[5] = &nums;
  for(int i = 0; i < 5; i++)
    printf("%d ", *(*pi + i));
    /*
    由于pi是整个数组的地址,因此要先进行解引用得到这个数组
    此时*pi就是数组名,再通过+i就可以遍历整个数组
    */
  printf("\n");
  return 0;
}

自定义函数打印二维数组

  • 最简单的方式莫过于
#include<stdio.h>
void Print(int nums[][4], int row, int col)
{
  for (int i = 0; i < row; i++)
  {
    for (int j = 0; j < col; j++)
      printf("%d ", nums[i][j]);
    printf("\n");
  }
  printf("\n");
}
int main()
{
  int nums[3][4] = { {1,2,3,4},{5,6,7,8},{9,10,11,12} };
  Print(nums, 3, 4);
  return 0;
}
  • 同样,我们也可以利用数组指针
#include<stdio.h>
void Print(int (*p)[4], int row, int col)
{
  for (int i = 0; i < row; i++)
  {
    for (int j = 0; j < col; j++)
      printf("%d ", *(*(p + i) + j));
        /*
        p是一个数组指针代表组成二维数组的每个一维数组的地址
        p+i代表跳过i行,即跳过i个一维数组的地址,
        *(p+i),即对一维数组的地址解引用,得到这个一维数组,做到对二维数组每行的遍历
        可以将*(p+i)看成一维数组的数组名,再加j就是每次跳过一维数组的一个元素
        *(*(p + i) + j)就可以做到对数组列的遍历
        */
    printf("\n");
  }
}
int main()
{
  int nums[3][4] = { {1,2,3,4},{5,6,7,8},{9,10,11,12} };
    /*
    nums为二维数组的数组名,代表首元素的地址,而二维数组的首元素为一个一维数组
    则nums代表了一个一维数组的地址,是一个数组指针,指针类型为int(*)[3]
    */
  Print(nums, 3, 4);
  return 0;
}

6.3 总结&练习

  • 这一节,有以下知识需要我们着重掌握:
  1. 数组名就是数组首元素的地址,是一个指针。但需要排除以下两种情况:
  • sizeof(数组名),这里的数组名不是数组首元素的地址,这里的数组名代表整个数组,sizeof(数组名),计算的是整个数组的大小,单位是字节
  • &数组名,这里的数组名表示整个数组,&数组名取出的是整个数组的地址
  1. 指针数组,是存放指针变量的数组,是一个数组
  2. 数组指针,即指向数组的指针,是存放数组的地址的指针变量,我们需要掌握并理解数组指针的特性,并学会实际的使用

接下来,我们来做一些练习题来加深对数组和指针的理解

6.3.1 一维数组传参
#include<stdio.h>
/*
` 正确
  数组传参时,不会真实的创建数组,因此可以不传入数组的大小
*/
void test1(int arr[])
{}
//正确,不必多说
void test1(int arr[10])
{}
/*
  正确
  传入的是int *型指针故可以用int *型的指针变量接收
*/
void test1(int *arr)
{}
/*
  正确
  传入的arr2为指针数组的首地址,用指针数组接受没问题
*/
void test2(int *arr[20])
{}
/*
  正确
  arr2为指针数组的首地址,指针类型为int **,故可用指针类型为int **的形参接收
*/
void test2(int **arr)
{}
int main()
{
  int arr1[10] = { 0 };
  int* arr2[20] = { 0 };
  test1(arr1);  //传入的是首元素地址,类型为int *
  test2(arr2);  //传入的是首元素地址,类型为int **
  return 0;
}
6.3.2 二维数组传参
#include<stdio.h>
//正确
void test(int arr[3][5])
{}
//错误
void test(int arr[][])
{}
//正确
void test(int arr [][5])
{}
/*
  总结:二维数组传参,函数形参设计只能省略第一个[]数字
  因为对一个二维数组,可以不知道有多少行,但是必须知道一行有多少列
  这样才方便运算
*/
/*
  错误
  应该用一个数组指针来接收
*/
void test(int *arr)
{}
/*
  错误
  int *arr[5]是一个指针数组,而不是数组指针
*/
void test(int *arr[5])
{}
/*
  正确
  int (*arr)[5]是一个指向存放5个int型数组的数组指针,符合条件
*/
void test(int (*arr)[5])
{}
/*
  错误
  传入的是数组指针而不是二级指针
*/
void test(int **arr)
{}
int main()
{
  int arr[3][5] = { 0 };
  test(arr);  //arr是数组名,即首元素的地址,即一维数组的地址,是一个数组指针
  return 0;
}
6.2.3 一维数组和sizeof()
#include<stdio.h>
int main()
{
  int a[] = { 1,2,3,4, };
  printf("%d\n", sizeof(a));      //16.sizeof(数组名)计算整个数组的大小,故打印4*4 = 16
  printf("%d\n", sizeof(a + 0));    //4/8.在这里a为数组首元素的地址,加0后还是首元素地址,是地址大小就是4/8
  printf("%d\n", sizeof(*a));     //4.*a == *(a + 0) == a[0] = 1,即数组第一个元素,故打印sizeof(int) = 4
  printf("%d\n", sizeof(a + 1));    //4/8.在这里a为数组首元素的地址,加1后是第二个元素地址,是地址大小就是4/8
  printf("%d\n", sizeof(a[1]));   //4.a[1]即数组第一个元素,故打印sizeof(int) = 4
  printf("%d\n", sizeof(&a));     //4/8.&a即取出整个数组的地址,是地址大小就是4/8
  printf("%d\n", sizeof(*&a));    //16.对数组取地址再解引用,得到的还是原来的数组,即*&a == a,故打印4*4 = 16
  printf("%d\n", sizeof(&a + 1));   //4/8.&a取出数组的地址后再加1,得到的还是地址,是地址大小就是4/8
  printf("%d\n", sizeof(&a[0]));    //4/8.&a[0]得到的是地址,是地址大小就是4/8
  printf("%d\n", sizeof(&a[0] + 1));  //4/8.地址加1,得到的还是地址,是地址大小就是4/8
  return 0;
}
6.2.4 二维数组和sizeof
#include<stdio.h>
int main()
{
  int a[3][4] = { 0 };
  printf("%d\n", sizeof(a));  
  //48
  //sizeof(数组名)计算的是整个数组的大小
  //故打印3*4*4 = 48
  printf("%d\n", sizeof(a[0][0]));  
  //4
  //a[0][0]是数组第一个元素
  //故打印sizeof(int) = 4
  printf("%d\n", sizeof(a[0])); 
  //16
  //a[0]是组成二维数组的第一个一维数组的数组名
  //sizeof(数组名)计算的是整个数组的大小,故为16
  printf("%d\n", sizeof(a[0] + 1)); 
  //4/8
  //a[0]是组成二维数组的第一个一维数组的数组名,是首元素的个地址,
  //加1则跳过一个元素,指向一维数组的第二个元素,也就是整个数组的第二个元素
  //地址加1后还是地址,是地址大小就是4/8
  printf("%d\n", sizeof(*(a[0] + 1)));
  //4
  //由上面的分析,a[0] + 1指向整个数组的第二个元素
  //故打印sizeof(int)
  printf("%d\n", sizeof(a + 1));
  //4/8
  //这里a为二维数组首元素即第一个一维数组的地址,加1后即第二个一维数组的地址
  //是地址大小就是4/8
  printf("%d\n", sizeof(*(a + 1)));
  //16
  //由上面的分析,a + 1为第二个一维数组的地址,解引用后就是第二个一维数组的数组名,即a[1]
  //sizeof(数组名)计算的是整个一维数组的大小
  //故打印4 * 4 = 16
  printf("%d\n", sizeof(&a[0] + 1));
  //4/8
  //a[0]即第一个一维数组的数组名,&数组名即得到整个数组的地址,加1后即跳过整个数组
  //&a[0] + 1指向的就是第二个一维数组,是第二个一维数组的地址
  //地址加一后还是地址,是地址大小就是4/8
  printf("%d\n", sizeof(*(&a[0] + 1)));
  //16
  //由上面的分析/&a[0] + 1是第二个一维数组的地址,解引用后就是第二个一维数组的数组名
  //sizeof(数组名)计算的是整个一维数组的大小
  //故打印4 * 4 = 16
  printf("%d\n", sizeof(*a));
  //16
  //*a == *(a + 0) == a[0],即第一个一维数组的数组名
  //sizeof(数组名)计算的是整个一维数组的大小
  //故打印4 * 4 = 16
  printf("%d\n", sizeof(a[3]));
  //16
  //a[3],即第四个一维数组的数组名
  //sizeof(数组名)计算的是整个一维数组的大小
  //故打印4 * 4 = 16
  return 0;
}

注:

  • 有小伙伴看到printf("%d\n", sizeof(a[3]));这串代码可能会认为a[3]不是越界了吗,为什么还可以正常打印呢?
  • 在探讨这个问题之前,我们先来看一串代码:
#include<stdio.h>
int main()
{
  int a = 7;
  short s = 4;
  printf("%d\n", sizeof(s = a + 2));
  printf("%d\n", s);
  return 0;
}
  • 得到了这样的结果:

  • 是不是和预想的有点不一样?为什么呢?
  • 这是因为,操作符sizeof()在计算时,只会关注()内是什么类型,并不会进行具体的计算
  • 例如上面的,将int型数据赋给short型,显然得到的是short型,故不会进行具体的运算,直接计算大小,s的值也不会改变
  • 上面的a[3]显然是一个一维数组的数组名,故sizeof()不会对a[3]进行具体的运算,而是直接计算大小。

7. 字符指针

字符指针和整型指针类似,有很多共同之处,但当涉及到字符数组时,还是要对某些情况进行说明,来看下面的代码

#include<stdio.h>
int main()
{
  char str[] = "abcde";
  char* pc = "abcde";
  return 0;
}
  • 可能有小伙伴会认为字符串str和字符指针pc是等价的,但这种理解是绝对错误的
  • 字符数组str存储的是字符串“abcde”,我们可以像整型数组一样对数组内部的值随意进行修改

  • 但字符指针pc指向的是字符串“abcde”的第一个字符‘a’,而字符串“abcde”是常量字符串,我们不能修改常量的值,因此这串代码写成const char* pc = "abcde"更加准确

7.1 练习

7.1.1 字符串和指向常量字符串的指针
#include<stdio.h>
int main()
{
  char str1[] = "hello world";
  char str2[] = "hello world";
  const char* p1 = "hello world";
  const char* p2 = "hello world";
  if (str1 == str2)
    printf("str1 and str2 are same\n");
  else
    printf("str1 and str2 are not same\n");
  if (p1 == p2)
    printf("p1 and p2 are same\n");
  else
    printf("p1 and p2  are not same\n");
  return 0;
}
  • 可以得到这样的结果:

  • 我们先来看str1和str2的比较。str1和str2分别是字符数组的数组名,表示数组首元素的地址,千万不要以为两个字符数组内容一样这两个数组就完全等价了,这是两个独立的数组,因此它们在内存中所站的位置就一定不一样,数组首元素的地址也就不一样。另外,可能有小伙伴会认为这是在比较两个字符串,但我们应该用库函数strcmp()来实现对两个字符串的比较,而不是直接拿数组名来比。
  • 再来看p1和p2的比较。由上面的分析我们知道,p1和p2指向的都是字符串常量“abcde”的首元素的地址,而字符串常量是不允许被修改的,故系统也就没必要创建两个相同的字符串常量,故p1和p2是相等的。
7.1.2 字符串和sizeof()
#include<stdio.h>
int main()
{
  char arr[] = "abcdef";
  printf("%d\n", sizeof(arr));
  //7
  //sizeof(数组名)计算的是整个数组的大小(注意要加上'\0')
  printf("%d\n", sizeof(arr + 0));
  //4/8
  //这里的数组名代表整个数组的地址,地址加减整数还是地址
  //是地址大小就是4/8
  printf("%d\n", sizeof(*arr));
  //1
  //这里的arr代表数组首元素的地址,解引用后得到数组首元素'a'
  //故打印sizeof(char) = 1
  printf("%d\n", sizeof(arr[1]));
  //1
  //arr[1]代表数组第二个元素
  //故打印sizeof(char) = 1
  printf("%d\n", sizeof(&arr));
  //4/8
  //&arr表示取出整个数组的地址
  //是地址大小就是4/8
  printf("%d\n", sizeof(&arr + 1));
  //4/8
  //地址加减整数还是地址
  //是地址大小就是4/8
  printf("%d\n", sizeof(&arr[0] + 1));
  //4/8
  //&arr[0]取出第一个元素的地址后加一,得到第二个元素的地址
  //是地址大小就是4/8
  return 0;
}
7.1.3字符数组和sizeof()
#include<stdio.h>
int main()
{
  char arr[] = { 'a','b','c','d','e','f' };
  printf("%d\n", sizeof(arr));
  //6
  //sizeof(数组名)计算的是整个数组的大小
  printf("%d\n", sizeof(arr + 0));
  //4/8
  //这里的数组名代表整个数组的地址,地址加减整数还是地址
  //是地址大小就是4/8
  printf("%d\n", sizeof(*arr));
  //1
  //这里的arr代表数组首元素的地址,解引用后得到数组首元素'a'
  //故打印sizeof(char) = 1
  printf("%d\n", sizeof(arr[1]));
  //1
  //arr[1]代表数组第二个元素
  //故打印sizeof(char) = 1
  printf("%d\n", sizeof(&arr));
  //4/8
  //&arr表示取出整个数组的地址
  //是地址大小就是4/8
  printf("%d\n", sizeof(&arr + 1));
  //4/8
  //地址加减整数还是地址
  //是地址大小就是4/8
  printf("%d\n", sizeof(&arr[0] + 1));
  //4/8
  //&arr[0]取出第一个元素的地址后加一,得到第二个元素的地址
  //是地址大小就是4/8
  return 0;
}
7.1.4 指向字符串常量的指针和sizeof
#include<stdio.h>
int main()
{
  char *p = "abcdef";
  printf("%d\n", sizeof(p));
  //4/8
  //p是一个指针变量,是指针变量大小就是4/8
  printf("%d\n", sizeof(p + 1));
  //4/8
  //指针变量加减整数还是指针变量
  //是指针变量大小就是4/8
  printf("%d\n", sizeof(*p));
  //1
  //p指向字符串首元素'a',解引用后得到首元素'a'
  //故打印sizeof(char) = 1
  printf("%d\n", sizeof(p[0]));
  //1
  //arr[0]代表数组第1个元素
  //故打印sizeof(char) = 1
  printf("%d\n", sizeof(&p));
  //4/8
  //&p表示取出字符指针p的地址
  //是地址大小就是4/8
  printf("%d\n", sizeof(&p + 1));
  //4/8
  //地址加减整数还是地址
  //是地址大小就是4/8
  printf("%d\n", sizeof(&p[0] + 1));
  //4/8
  //&p[0]取出第一个元素的地址后加一,得到第二个元素的地址
  //是地址大小就是4/8
  return 0;
}

  • 在这里我觉得有必要再多问大家一个问题,大家认为,&p + 1后,指向的是哪个位置呢?
  • 是字符‘a’的后面一个,还是‘\0’的后面一个?
  • 都不是!!!我们必须清楚,我们是拿一个字符指针p来指向一个字符串的首元素地址,而不是将一个字符串存到这个里面,因此指针p的地址和字符串“abcdef”的地址没有任何直接的联系,所以&p + 1后,跳过了一个char *类型,得到的位置应该在指针p的后一个位置

7.1.5 字符串和strlen()
#include<stdio.h>
#include<string.h>
int main()
{
  char arr[] = "abcdef";
  printf("%d\n", strlen(arr));
  //6
  //计算字符串长度,为6
  printf("%d\n", strlen(arr + 0));
  //6
  //arr+0还是首元素地址,计算的还是原来的字符串长度,为6
  printf("%d\n", strlen(*arr));
  //无法运行
  //strlen()操作的必须是指向字符串的地址,*arr代表该字符数组的首元素'a',不是地址
  //如果我们将字符'a'作为参数传入,字符‘a'的ASCII码为97,那么strlen()就会从97这个地址开始访问
  //这就形成了内存的非法访问
  printf("%d\n", strlen(arr[1]));
  //无法运行
  //分析如上
  printf("%d\n", strlen(&arr));
  //6
  //&arr地址的开始值和数组首元素地址相同,计算的还是原来的字符串长度
  //故打印6
  printf("%d\n", strlen(&arr + 1));
  //随机值
  //取出整个数组的地址后加一,将跳过整个字符串,指向一块位置空间,'\0'的位置也未知
  //故打印随机值
  printf("%d\n", strlen(&arr[0] + 1));
  //5
  //&arr[0]取出第一个元素的地址后加一,得到第二个元素的地址
  //现在字符串长度比原来少1
  //故5
  return 0;
}
7.1.6 字符数组和strlen()
#include<stdio.h>
#include<string.h>
int main()
{
  char arr[] = { 'a','b','c','d','e','f' };
  printf("%d\n", strlen(arr));
  //随机值
  //该字符数组没有结束符'\0',要strlen()要读到'\0'才会停止
  //故打印随机值
  printf("%d\n", strlen(arr + 0));
  //随机值
  //该字符数组没有结束符'\0',要strlen()要读到'\0'才会停止
  //故打印随机值
  printf("%d\n", strlen(*arr));
  //无法运行
  //strlen()操作的必须是指向字符串的地址,*arr代表该字符数组的首元素'a',不是地址
  //如果我们将字符'a'作为参数传入,字符‘a'的ASCII码为97,那么strlen()就会从97这个地址开始访问
  //这就形成了内存的非法访问
  printf("%d\n", strlen(arr[1]));
  //无法运行
  //分析如上
  printf("%d\n", strlen(&arr));
  //随机值
  //该字符数组没有结束符'\0',要strlen()要读到'\0'才会停止
  //故打印随机值
  printf("%d\n", strlen(&arr + 1));
  //随机值
  //取出整个数组的地址后加一,将指向一块位置空间,'\0'的位置也未知
  //故打印随机值
  printf("%d\n", strlen(&arr[0] + 1));
  //随机值
  //&arr[0]取出第一个元素的地址后加一,得到第二个元素的地址
  //但该字符数组没有结束符'\0',要strlen()要读到'\0'才会停止
  //故打印随机值
  return 0;
}
7.1.7 指向字符串常量的指针和strlen()
#include<stdio.h>
#include<string.h>
int main()
{
  char *p = "abcdef";
  printf("%d\n", strlen(p));
  //6
  //p指向的值是字符串首元素地址
  //计算字符串长度,为6
  printf("%d\n", strlen(p + 1));
  //5
  //p指向的值是字符串首元素地址,加1后则指向第二个元素的地址
  //则长度为5
  printf("%d\n", strlen(*p));
  //无法运行
  //strlen()操作的必须是指向字符串的地址,*p代表该字符数组的首元素'a',不是地址
  //如果我们将字符'a'作为参数传入,字符‘a'的ASCII码为97,那么strlen()就会从97这个地址开始访问
  //这就形成了内存的非法访问
  printf("%d\n", strlen(p[0]));
  //无法运行
  //分析如上
  printf("%d\n", strlen(&p));
  //随机值
  //字符指针p和其指向的字符串常量的地址没有直接关联,&p后的地址为一块未知空间
  //故打印随机值
  printf("%d\n", strlen(&p + 1));
  //随机值
  //分析如上
  printf("%d\n", strlen(&p[0] + 1));
  //5
  //&ap[0]取出第一个元素的地址后加一,得到第二个元素的地址
  //现在字符串长度比原来少1
  //故5
  return 0;
}

7.2 总结

做了这么多题,想必小伙伴们对于字符指针,字符串,字符数组,字符串常量有了较深刻的理解,我们有必要做一些总结:

  • 要清楚sizeof()strlen()之间的区别,sizeof()是一个操作符,计算的是括号内所占字节的大小;strlen()是字符串操作函数,求的是字符串的有效长度,且必须遇到‘\0’才会停止。
  • 符串一定是字符数组,但字符数组不一定是字符串,因为字符数组可能不包含‘\0’,而字符串必须有‘\0’作为结束符
  • 用字符指针来指向一个字符串常量时,我们不能通过指针来改变字符串常量的值;同时要清楚,这个字符指针的地址和字符串常量的地址没有直接联系

8.函数指针

顾名思义,函数指针就是指向函数的指针

我们不妨先来思考这样一个问题:函数是否也有地址呢?来看下面的代码:

#include<stdio.h>
void Text(int num, char *ch)
{}
int main()
{
  printf("%p\n", &Text);
  return 0;
}

可以得到:

  • 我们发现,对函数名取地址就可以得到这个函数的地址,那么函数名是否和数组名一样,代表着这个函数的地址呢?我们来一探究竟
#include<stdio.h>
void Text(int num, char *ch)
{}
int main()
{
  printf("%p\n", Text);
  printf("%p\n", &Text);
  return 0;
}
  • 可以得到:

  • 由此我们可以得出结论:
  • 函数名和&函数名都是函数的地址

8.1 定义函数指针

  • 例如上面的函数Text(),如果我们要将它的地址&Text存入变量pf中,我们要如何定义这个函数指针pf呢?同样,我通过画流程图来分析:

  • 因此,我们可以大致做出总结,函数指针我们可以这样定义:

8.2 运用函数指针调用函数

我们通过下面的例子来讲解:

#include<stdio.h>
void Print(int nums[10], int numsSize)
{
  for (int i = 0; i < numsSize; i++)
    printf("%d ", nums[i]);
  printf("\n");
}
int main()
{
  int nums[10] = { 1,2,3,4,5,6,7,8,9,10 };
    //定义函数指针
  void (*p)(int[10], int) = Print;
  //不用函数指针,直接调用
  Print(nums, 10);
  //用函数指针调用
  (*p)(nums, 10); //可以先对函数指针解引用再调用
  p(nums, 10);  //也可以直接调用
  return 0;
}

都可以得到正确的结果:


9. 函数指针数组

顾名思义,函数指针数组就是存放函数指针的数组

9.1 函数指针数组的定义

例如,我们要将下面这三个函数存入函数指针数组中,我们该如何定义?

void Text1(int num1, int num2)
{}
void Text2(int num1, int num2)
{}
void Text3(int num1, int num2)
{}
  • 我们可在函数指针的基础上改造:
  • 我们知道,这三个函数的函数指针类型都是void (*)(int, int),我们假设数组名为p,由于这是一个指针数组,因此p要先和[3]结合再和*结合,又因为[]的优先级高于*,因此我们可以这样写void (* p[3])(int, int),这样这个函数指针数组就定义好了**,这个数组代表的是存放着3个指针类型为void (*)(int, int)的函数指针数组**

9.2 函数指针数组的实际使用

现在问大家一个问题:如果要实现一个简单的两个整数之间的计算器(加、减、乘、除、取模、相与……),大家要如何实现呢?用switch case语句?那未免太过繁杂,如果我们用刚刚所学习的函数指针数组来实现,那就可以让我们的代码精简许多

#include<stdio.h>
int add(int num1, int num2)
{
  return num1 + num2;
}
int sub(int num1, int num2)
{
  return num1 - num2;
}
int mul(int num1, int num2)
{
  return num1 * num2;
}
int div(int num1, int num2)
{
  return num1 / num2;
}
void meau()
{
  printf("***************************\n");
  printf("*****  1->add   2->sub  ***\n");
  printf("*****  3->mul   4->div  ***\n");
  printf("*****  0->exit          ***\n");
}
int main()
{
  int (*pArr[5])(int, int) = { 0,add,sub,mul,div };
  int input;
  int num1, num2;
  do
  {
    printf("要进行怎样的运算:\n");
    meau();
    scanf_s("%d", &input);
    if (1 <= input && 4 >= input)
    {
      printf("请输入两个整数:");
      scanf_s("%d %d", &num1, &num2);
      int ret = pArr[input](num1, num2);  //利用函数指针数组调用函数,简化代码
      printf("结果为:%d\n", ret);
    }
    else if (input == 0)
      printf("退出\n");
    else
      printf("输入错误,重新输入\n");
  } while (input);
  return 0;
}

10. 回调函数

回调函数就是一个通过函数指针调用的函数。如果把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,我们就说这是回调函数。回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另一方调用,用于对该事件或条件进行响应。

对于上面的简易计算器的实现,我们也可以用回调函数来实现:

include<stdio.h>
int add(int num1, int num2)
{
  return num1 + num2;
}
int sub(int num1, int num2)
{
  return num1 - num2;
}
int mul(int num1, int num2)
{
  return num1 * num2;
}
int div(int num1, int num2)
{
  return num1 / num2;
}
//将实现计算的函数作为参数,再利用函数指针实现回调函数,从而避免代码的冗余
void calculator(int (*p)(int, int))
{
  int num1, num2;
  printf("请输入两个整数:");
  scanf_s("%d %d", &num1, &num2);
  int ret = p(num1, num2);
  printf("结果为:%d\n", ret);
}
void meau()
{
  printf("***************************\n");
  printf("*****  1->add   2->sub  ***\n");
  printf("*****  3->mul   4->div  ***\n");
  printf("*****  0->exit          ***\n");
}
int main()
{
  int input;
  do
  {
    printf("要进行怎样的运算:\n");
    meau();
    scanf_s("%d", &input);
    switch (input)
    {
    case 1:
      calculator(add);
      break;
    case 2:
      calculator(sub);
      break;
    case 3:
      calculator(mul);
      break;
    case 4:
      calculator(div);
      break;
    case 0:
      printf("退出\n");
      break;
    default:
      printf("输入错误,重新输入\n");
    }
  } while (input);
  return 0;
}
  • 回调函数的作用十分广泛,例如我们经常使用的库函数qsort()也是使用回调函数的方式来实现的
  • 注:qsort()的函数原型为:void qsort (void* base, size_t num, size_t size, int (*compar)(const void*,const void*));,其中int (*compar)(const void*,const void*)就是利用了回调函数,而具体的qsort()的工作原理,怎么使用,这里便不再讲述。

11. 总结

通过这一章的学习,相信小伙伴们对于指针这一概念以及指针和数组、字符串、函数等之间的关系有了较为深刻的认识,也应该可以将指针这一利器运用自如了,最后我们以一道笔试题来结束这一次的学习,同时也检验一下大家的学习成果。

#include<stdio.h>
int main()
{
  char* c[] = { "ENTER","NEW","POINT","FIRST" };
  char** cp[] = { c + 3, c + 2, c + 1, c };
  char*** cpp = cp;
  printf("%s\n", **++cpp);
  printf("%s\n", *-- * ++cpp + 3);
  printf("%s\n", *cpp[-2] + 3);
  printf("%s\n", cpp[-1][-1] + 1);
  return 0;
}

解析戳这里


最后的最后,如果觉得本篇文章对你有所帮助的话,还请点个小小的赞支持一下喔

共勉!!!( ̄y▽ ̄)╭ Ohohoho…

相关文章
|
1月前
|
安全 编译器 C语言
C++入门1——从C语言到C++的过渡
C++入门1——从C语言到C++的过渡
52 2
|
27天前
|
C语言
【c语言】指针就该这么学(1)
本文详细介绍了C语言中的指针概念及其基本操作。首先通过生活中的例子解释了指针的概念,即内存地址。接着,文章逐步讲解了指针变量的定义、取地址操作符`&`、解引用操作符`*`、指针变量的大小以及不同类型的指针变量的意义。此外,还介绍了`const`修饰符在指针中的应用,指针的运算(包括指针加减整数、指针相减和指针的大小比较),以及野指针的概念和如何规避野指针。最后,通过具体的代码示例帮助读者更好地理解和掌握指针的使用方法。
45 0
|
26天前
|
C语言
【c语言】指针就该这么学(3)
本文介绍了C语言中的函数指针、typedef关键字及函数指针数组的概念与应用。首先讲解了函数指针的创建与使用,接着通过typedef简化复杂类型定义,最后探讨了函数指针数组及其在转移表中的应用,通过实例展示了如何利用这些特性实现更简洁高效的代码。
15 2
|
27天前
|
C语言
如何避免 C 语言中的野指针问题?
在C语言中,野指针是指向未知内存地址的指针,可能引发程序崩溃或数据损坏。避免野指针的方法包括:初始化指针为NULL、使用完毕后将指针置为NULL、检查指针是否为空以及合理管理动态分配的内存。
|
27天前
|
C语言
C语言:哪些情况下会出现野指针
C语言中,野指针是指指向未知地址的指针,通常由以下情况产生:1) 指针被声明但未初始化;2) 指针指向的内存已被释放或重新分配;3) 指针指向局部变量,而该变量已超出作用域。使用野指针可能导致程序崩溃或不可预测的行为。
|
1月前
|
存储 C语言
C语言32位或64位平台下指针的大小
在32位平台上,C语言中指针的大小通常为4字节;而在64位平台上,指针的大小通常为8字节。这反映了不同平台对内存地址空间的不同处理方式。
|
1月前
|
存储 算法 C语言
C语言:什么是指针数组,它有什么用
指针数组是C语言中一种特殊的数据结构,每个元素都是一个指针。它用于存储多个内存地址,方便对多个变量或数组进行操作,常用于字符串处理、动态内存分配等场景。
|
1月前
|
存储 C语言
C语言指针与指针变量的区别指针
指针是C语言中的重要概念,用于存储内存地址。指针变量是一种特殊的变量,用于存放其他变量的内存地址,通过指针可以间接访问和修改该变量的值。指针与指针变量的主要区别在于:指针是一个泛指的概念,而指针变量是具体的实现形式。
|
1月前
|
C语言
C语言指针(3)
C语言指针(3)
12 1
|
1月前
|
C语言
C语言指针(2)
C语言指针(2)
14 1