C语言教程-13_1-初识指针


title: C语言教程-13_1-初识指针
tags: [C]
categories: C语言教程
description: 接触C语言的灵魂-指针

概要:

  1. 简要讲解内存地址与内存模型
  2. 简单介绍C语言的指针这一数据类型
  3. 掌握指针相关最基本的两种互逆运算

前置知识:

  1. 理解能力和想象能力
  2. 耐心和实验精神
  3. 数组与函数的知识

交换两个变量的问题

我们从一个问题开始引入指针.

考虑这个问题:在main()函数中有两个int变量a和b,我该如何交换这两个变量的值?

如果我们要求仅在一个函数中解决这个问题,那么很容易想到,最简单的办法就是新建一个int类型的中间变量,比如命名为temp.那么我们就有如下操作进行交换(十分简单不详细解释):

#include <stdio.h>
int main() {
    int a=3,b=4;
    int c; // 中间变量
    // 经典3步进行交换
    c = a;
    a = b;
    b = c;
    printf("a=%d,b=%d\n",a,b); // 输出结果 a=4,b=3   
    return 0;
}

只需要这3步即可进行交换.


现在问题来了,如果我们要求创建一个函数swap()来实现这个操作,该如何实现?

也许我们可以这样:

#include <stdio.h>
void swap(int a, int b) {
    int temp;
    temp = a;
    a = b;
    b = temp;
}
int main() {
    int a = 3, b = 4;
    swap(a, b);
    printf("a=%d,b=%d\n", a, b); // 输出错误的结果:a=3,b=4   
    return 0;
}

我们尝试简单地把a和b传递给swap()函数,运行一下,结果显然是错误的,a和b的值并没有交换.

回顾前面函数的知识,前面讲过,C函数的参数都是按值传递,这里也就是将main()中的a和b的值简单地复制给swap()的两个参数a和b,换句话说,此a,b非彼a,b.

结果就是,在swap()中的a,b确实被成功地交换了,但是main()中的a,b完全没有变化.


我们想要在swap()函数中交换main()中的a和b,根本的问题在于我们需要访问到他们,C语言的指针类型提供了这种功能.

地址和指针

计算机内存与地址

计算机运行时需要的各种数据都存储于内存中(就是平时说的内存条),从逻辑上来看,一整个内存可以视为一个超级巨大的数组,例如我们的内存是4GB,那么这个数组的总大小就是4GB.我们仅仅讲解内存地址这个概念,具体的内存结构这里并不关心.

程序的相关数据就存储在内存中,例如执行的机器代码,局部变量,全局变量(定义在函数外的变量),常量字面值.他们以某种特定的模式进行存储,存储的位置各不相同,为了找到他们,我们需要以字节为单位为整个内存进行编号.也就是所谓的内存地址.需要注意的是,我们通常以16进制来表示地址值(毕竟内存如此巨大,2,10进制是不够方便的).

以4GB内存举例,我们需要8个16进制位来完整编号,即16^8==4,294,967,296,也就是4GB的大小.从0开始,第一个字节编号为0x00000000,最后一个字节编号为0xFFFFFFFF.当然,如此庞大的内存范围不可能全部让我们任意取用,实际上我们自己的应用程序只能使用操作系统(例如Windows)规定的一块内存,当然这完全够用.

例如,我们在内存地址0x2~0x8存储了一些特定的数据,内容如下:

image-20231121002855398

可以看到每一个地址都指向内存中的一个特定字节,这个1字节大小的空间中存储了某些特定的数据,程序根据数据的地址从内存中找到他们,以进行运算.


另一方面,尽管我们对每一个字节都进行了编号,但是往往我们将若干个字节组合起来使用,例如一个int变量,就直接占用了4个字节,此时我们将4个连续的字节视为一个整体,取最开头的那个字节作为代表,指代整个int变量.

例如我们有int i=2;则i在内存中的布局如下:

image-20231121004333759

注意,我们上面的地址值仅仅作为演示,在现在流行的x64机器中并不是这样的:

#include <stdio.h>
int main() {
    int i = 2;
    // 使用循环,逐字节输出i在内存中的值(十六进制),同时输出每个字节的地址
    for (int j = 0; j < sizeof(i); j++) {
        printf("0x%p %02x\n", (char *)&i + j, *((char *)&i + j));
    }
    return 0;
}

在我的笔记本电脑运行如下:

image-20231121004620266

先不管上面的代码是什么原理,仅仅看一下结果,输出了i占用的这4个字节内部的值,同时可以看到前面的内存是连续的.如果我们使用printf("%p",&i);输出i的地址,结果将会是第一个地址,这意味着使用最小的那个地址指代整个变量i.

读者无需关心为什么是02 00 00 00而不是00 00 00 02,这涉及到大小端序的问题.

处理地址-C语言的指针

现在我们已经了解了最基本的内存常识,并举了一个int变量的例子,了解了变量的存储.下面引入指针.

用最简单的一句话概括指针就是:指针就是地址.我们有时候需要在程序中获取到某个变量的地址,C语言提供了指针这一数据类型,用指针类型声明的变量就叫指针变量,其内部存储一个无符号的整数(往往是4或8字节大小),代表一个地址.

顾名思义,指针,就像一个箭头指向一个地方,和地址的作用相同.只不过前面说的地址是指计算机内存的编址,而指针,是C语言为了能够处理内存地址而引入的一种机制.某种角度而言,地址值仍然是一个整数,所以我们想要存储他,和普通的整数无异,但是为了特殊化,C语言引入了指针类型这种数据类型,这种(类)类型的变量存储的是一个特殊整数代表一个指针(地址).


获取一个变量的地址十分简单,使用&取地址运算符,输入一个指针也很简单,在printf()中使用%p即可:

#include <stdio.h>
int main() {
    int i = 3; // 声明并初始化为3一个int类型变量i
    printf("%p",&i); // &i这个表达式代表获取到i在内存中的地址
    return 0;
}

输出如下:

image-20231121005931082

这代表着变量i就存储在这个地址值指向的内存块中.


如果我们想要将这个地址存储下来,那么就需要使用C语言的指针.

声明一个最简单的指针变量仅仅需要在变量名前多加一个*,语法如下:

#include <stdio.h>
int main() {
    int i = 3; // 声明并初始化为3一个int类型变量i
    int *p; // 声明一个变量p,其类型为int*,代表它是一个指针
    p = &i; // 将i的地址赋值给p
    printf("%p",p); // 此时不再需要&,因为p存储的就是i的地址
    return 0;
}

输出结果同样,也是一个地址.


再进一步,我们既然存储了某个变量的地址(指针),那么就意味着我们想要根据这个地址(指针)去访问其指向的内存单元,我们使用另一种运算符,即*解引用运算符(或者叫指针运算符),与&相反,*用于对一个地址进行访问,反向获取到此处存储的具体变量(值),这种相对于取地址操作的逆操作称为解引用操作.

仍然是上面的例子,我们尝试使用p存储的地址去间接访问变量i:

#include <stdio.h>
int main() {
    int i = 3; // 声明并初始化为3一个int类型变量i
    int *p; // 声明一个变量p,其类型为int*,代表它是一个指针
    p = &i; // 将i的地址赋值给p
    printf("%d",*p); // 此时对p的值进行*运算,也就是解引用,效果就是将p中的值作为一个
    // 指针,去访问对应的内存,从而取出变量i的值
    // 换句话说,这里的*p和i是等价的.
    return 0;
}

运行结果:

image-20231121011323454

注意:在对一个指针进行解引用时,一定要确保其指向了有效的地址!!!这是一个十分重要的问题!对未正确赋值的指针进行解引用(访问)是十分危险的行为.

指针声明的问题

我们在声明指针的时候,一定要注意和普通类型进行区分.普通类型与其对应的指针可以在一个声明中出现:

int *p1,a,*p2;

这里声明了2个int*类型的指针p1和p2,和一个int类型的变量a.

要注意的是,p2前面仍然需要一个*代表它是一个指针,另外,尽管p1前面已经有了一个*,但是a仍然仅仅是一个int的变量而已.

解决交换问题

现在我们可以尝试使用指针进行交换两个变量的值.

我们既然要使用函数交换两个变量,那么就要求函数能够访问到这两个变量,现在,我们可以使用两个指针参数实现.

// 在函数中使用指针进行交换两个变量的值
#include <stdio.h>
void swap(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}
int main() {
    int a = 10, b = 20;
    printf("a = %d, b = %d\n", a, b);
    swap(&a, &b);
    printf("a = %d, b = %d\n", a, b);
    return 0;
}

我们的swap()函数的两个参数不再是两个int类型的参数,而是int *类型的指针,代表着这个指针可以指向一个int变量.

main()函数中,swap(&a,&b);使用&取地址运算符计算a和b的地址,传递给swap的两个形参.

接下来,在swap()中使用一个中间变量(temp仍然需要),进行交换,对指针变量使用*进行解引用,获取到两个要交换的int值,然后进行交换即可.

运行结果如下:

image-20231124172141354


上面的例子我们了解了如下内容:

  1. 如何获取一个地址(取地址运算符)
  2. 如何存储一个地址(指针变量)
  3. 如何使用一个地址去访问内存(解引用运算符)

接下来探究指针类型.

指针的类型

不同基本类型的指针

前面的例子都是使用了int *这个类型,代表着对应的指针变量(应该)指向的是一个int类型的变量.

其他类型的变量同理,如果我们需要指向一个float类型的变量,那么就使用float *即可:

// 其他类型的变量同理,如果我们需要指向一个float类型的变量,那么就使用`float *`即可:
#include <stdio.h>
int main(){
    float var = 3.1415;
    float *ptr = &var;
    printf("var == %f\n", var);
    return 0;
}

使用ptr指针就能访问到var.

此外,指针类型不匹配是不允许的操作:

// 此外,指针类型不匹配是不允许的操作
#include <stdio.h>
int main(){
    int a = 10;
    int *p = &a;
    char *q = p; // 这里报错: cannot convert 'int*' to 'char*' in initialization
    return 0;
}

代表着两个指针不兼容,即一个int *的指针不能赋值给一个char *的指针.

空类型指针

有时候,我们可能仅仅想要存一个地址,而不关心其类型,那么可以使用void *类型,即空类型指针,任何类型的指针都能赋值给void *:

#include <stdio.h>
int main() {
    int a = 1024;
    void *p = &a; // &a为指针,其类型为int*,可以直接赋值给void*而无需任何处理
    printf("%d\n", *(int *)p); // void*指针不允许直接解引用,必须进行强制类型转换
    return 0;
}

上面的代码使用void*指针p存储了a的地址,在使用p访问a的时候,必须使用强制类型转换void*转换为int*才能进行解引用.因为对void*解引用的话,无法判断实际占用了多少内存,所以下面的代码编译器报错"不允许使用不完整的类型":

#include <stdio.h>
int main() {
    int a = 1024;
    void *p = &a;
    printf("%d\n", *p);
    return 0;
}
image-20231124231605327

这就是一个重要的问题:指针指向的数据类型的大小.

后面我们会慢慢的接触到void* 指针的重要作用.

特殊的指针值-NULL

还有的时候,我们希望一个指针变量不指向任何有效的地址,那么我们可以对其赋值为NULL空指针值.

#include <stdio.h>
int main() {
    int *p = NULL;
    printf("%p\n", p);
    // 实际上, NULL 就是 0
    return 0;
}

运行结果:

image-20231124232132101

可以看到,p的值就是0. NULL是一个(宏定义),定义在stdio.h中(宏定义将在后面的头文件部分讲解):

// stdio.h
#define NULL ((void *)0)

这个宏意味着NULL预处理的时候直接替换为((void *)0)

也就是说,当一个指针值为NULL时,我们认为他不指向任何地址,并且认为NULL是安全的—我们检查一个指针是否等于NULL来判断这个指针是否被初始化等…

后面会深入强调初始化的问题.

强制类型转换

指针本质上还是一个整数(无符号的),但是指针类型仍然不能和普通的整型互相赋值,如果我们想要将某个数值作为指针值进行赋值,可以使用强制类型转换.

int value=0x7fffffff;
int *p = (int *)value;

这样,指针p就指向了0x7fffffff这个内存地址对应的内存单元.

此外,这里也能看出,intint*是完全不同的两种类型!

多级指针

指针用于指向某种类型的变量(地址),同样,指针变量也可以被另外一个指针变量所指向,即指向指针的指针,这就是多级指针.

使用二级指针

可以这样声明一个二级指针:

#include <stdio.h>

int main(){
    int a = 3,*p = &a;
    int **p2 = &p; // p2是一个二级指针,指向p
    printf("%d\n",**p2); // 输出3, **p2 == *p == a == 3
    return 0;
}

运行结果:

image-20240111225130328

声明int **p2就等价于int *(*p2);,也就是说p2是一个指针,指向的类型为int*,因此显然p2是一个二级指针,int *p可以称为一级指针.

二级指针仍然是一个指针,只不过我们可以对它进行2次解引用:

#include <stdio.h>

int main() {
    int a = 3, *p = &a; // 声明一个int类型的指针变量p,指向a
    int **p2 = &p; // 声明一个int类型的二级指针变量p2,指向p
    /* 输出a的值 */
    printf("a = %d\n", a); // a = 3
    printf("*p = %d\n", *p); // *p = 3
    printf("**p2 = %d\n", **p2); // **p2 = 3
    /* 输出a的地址 */
    printf("&a = %p\n", &a); // &a即为a的地址
    printf("p = %p\n", p); // p存储的值即为a的地址
    /* 输出指针变量p的地址 */
    printf("&p = %p\n", &p); // &p即为p的地址
    printf("p2 = %p\n", p2); // p2存储的值即为p的地址
    return 0;
}

运行结果:

image-20240111225215625

对二级指针的解引用

我们可以看出,二级指针可以进行2次解引用,第一次解引用的结果是访问其指向的变量,例如上面的例子中,

*p2即为p,p仍然是一个指针,指向整型变量a,则对其再次解引用**p即可访问到a.

换言之,**p2可以视为*(*p2),读者应该清楚地意识到,这里的**完全是2步操作,你甚至可以在中间加一个空格.

#include <stdio.h>

int main() {
    int a = 3, *p = &a; // 声明一个int类型的指针变量p,指向a
    int **p2 = &p; // 声明一个int类型的二级指针变量p2,指向p
    /* 输出a的值 */
    printf("* *p2 = %d\n", * *p2); // **p2 = 3
    
    return 0;
}

运行结果:

image-20240111225239103

以上使用二级指针进行了举例,三级指针等更"高级"的指针同理,只不过可以指向级数更高的指针而已,实际应用中,基本只用到二级指针.

#include <stdio.h>

int main() {
    int a = 3, *p = &a; // 声明一个int类型的指针变量p,指向a
    int **p2 = &p; // 声明一个int类型的二级指针变量p2,指向p
    int ***p3 = &p2; // 声明一个int类型的三级指针变量p3,指向p2
    return 0;
}

二级指针十分重要,特别是在使用C实现各种数据结构时,需要修改某些指针的指向时非常关键,后面的学习会频繁遇到.


指针是C语言的"灵魂",指针的内容几乎占有了C语言的半壁江山,本部分简单讲解了指针的基本概念和使用方法,后面会详细展开讲解.

---WAHAHA

注:文章原文在本人博客https://gngtwhh.github.io/上发布

  • 8
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值