在本篇文章中,主要是针对C语言中指针部分进行基础知识的讲解,分为指针的概念,指针变量,指针的运算,const关键字,野指针,assert断言以及函数的传址调用7个板块。
目录
一 指针的概念
1 指针
首先呢,我们通过一个日常生活中的例子来引入指针。假设有一栋大楼,里面有许多房间,每个房间上呢都有门牌号,比如101,201,301等,你的朋友告诉你他所在房间是101,那么你到了那所大楼,就能通过101门牌号快速找到你的朋友。再比如,一栋学生宿舍楼,里面每个宿舍房间是8人间,且每个房间都有宿舍号,102,303等,每个学生就能通过宿舍号快速找到自己所住的宿舍。
类比以上生活中的例子,内存就像那一栋大楼或者学生宿舍,然后内存也被划分为了一个一个的小房间,每个小房间被称为内存单元,每个内存单元就像一个8人间的学生宿舍,是一个字节(8个比特位),为了像上面快速找到房间一样,每个内存单元也有一个编号,比如0x0023ff20(十六进制),这个编号就叫做地址,而在C语言中,就给这个地址起了一个特殊的名字——指针,所以在C语言中,指针 == 地址 == 内存单元的编号。
2 编址
有了以上关于指针的理解,那么内存单元的编号又该如果理解呢?其实在内存中,并不会真正写着内存单元的编号,而是通过硬件设计来完成的。
在计算机中,一共有三条线,分别是控制总线,数据总线和地址总线,显然根据名字来看,控制总线是用来控制数据的写入与读取的,数据总线是用来传输数据的,而地址总线就是用来传输地址的。地址总线呢,根据计算机位数不同,线数不同,32位的机器上有32根地址线,64位的机器上有64根地址线,每根地址线都有两种状态,电脉冲有或者无,反映在计算机上就是0或者1,通过这些地址线的状态,就可以实现地址的传输,然后通过硬件的协同工作就可以实现数据的传输,如图:
所以通过以上描述我们可以看到,在32位机器上,因为有32根地址线,所以一个内存的编号就是32位,也就是4个字节,所以内存单元的地址,也就是指针在32个机器上就是4个字节,同样的,在64位机器上就是8个字节。
二 指针变量
1 取地址操作符 -- &
在C语言中,其实创建变量的本质就是像内存申请一块空间,那么这就意味着每个变量都是有地址的,那么怎么取出一个变量的地址呢?这时候就不得不提到一个单目操作符,&,这个操作符叫做去地址操作符(地址用%p占位符打印),如:
#include<stdio.h>
int main()
{
int a = 10;
printf("%p\n", &a);
return 0;
}
运行结果:
x64(64位)环境下:
x86(86位)环境下:
值得注意的一点是这些地址都是以16进制的形式打印的,所以我们可以看到在64位环境下,地址确实是8个字节,在32位环境下,地址是4个字节。
2 指针变量
1) 概念
在C语言中,整型变量用来存储整型,字符变量用来存储字符,浮点型变量用来存储小数,那么可以类比其他变量类型,有一种变量,可以专门用来存储地址,这种变量就叫指针变量,如:
#include<stdio.h>
int main()
{
int a = 10;
int* pa = &a;
return 0;
}
在以上代码中,我们可以看到把a变量的地址赋给了pa变量,所以pa变量就叫做指针变量。
我们知道一个整型有4个字节,那就对应着有4个地址,那么指针变量中存储的是哪一个地址呢?
我们可以打开调试界面的内存窗口看一下:
可以看到, 内存窗口中有四个地方变红了,所有那四个地址所对应的内存单元就是为a变量开辟的4个字节的内存空间,再看下pa中的内容:
可以在调试的监视窗口中看到pa中存放的是a的四个地址中较小的那个地址,所以指针变量中存放的是它所指向对象的地址中较小的那一个。
2) 指针变量的类型
(1) 普通类型
我们知道在进行一个变量的创建时,要包括一个变量的类型和标识符,所以除去一个变量的标识符,剩下的就是它的类型。所以在上面那个代码中,pa指针变量的类型就是int*,那么指针变量的类型我们该如何理解呢?我们可以这样来理解指针的类型,就比如:
int a = 10;
int* pa = &a;
*代表其后面的变量pa是一个指针变量,前面的int代表指针所指向的对象是一个整型,这样就可以举一反三出其他指针变量类型,如:字符指针类型就是char*,结构体指针类型(假设这里结构体是struct Stu)就是struct Stu*,长整型指针类型就是long*等等,有了指针类型就可以创建指针变量了,如:
#include<stdio.h>
struct Stu
{
char name[20];
int age;
};
int main()
{
char a = 'x';
long b = 200;
struct Stu s = {"zhangsan", 18};
char* pa = &a;
long* pb = &b;
struct Stu* ps = &s;
return 0;
}
需要注意的一点是指针变量的类型具有独特的意义,这部分到指针加减那部分再详细讲解。
(2) void* 类型
在这里呢有一个特殊的指针类型是void*类型,其叫做无具体类型的指针(泛型指针),其特殊就特殊在这个指针类型的变量可以接受任意类型的地址,比如:int*,char*,float*,struct Stu*等等,这种指针类型的指针变量一般用在函数参数的部分,用来实现泛型编程,但这种指针类型也有其局限性,到后面再详细讲解。
3) 指针变量的大小
既然指针变量是一个变量,那么指针变量就有大小,比如整型变量是4个字节,字符变量是一个字节,那么指针变量是几个字节呢?我们这样来看,指针中既然存储的是地址,那么地址多大,一个指针变量就应该多大,所以指针变量的大小是跟环境有关的,在32位环境下,一个指针变量大小是4个字节,在64位环境下,一个指针变量是8个字节。
还有一点需要提醒的是,既然指针变量的大小只跟地址有关,而地址的字节数只跟环境有关,所以指针变量的大小跟其指针变量的类型是没有关系的,无论是什么类型的指针变量,其大小都是4/8个字节。
3 解引用操作符 -- *
地址存储到了指针变量中,那么我们日后如果想使用指针变量所指向的内容,就需要使用一个单目操作符,叫做解引用操作符,*,只要把这个操作符加在指针变量或者地址前面,就可以拿到那个指针变量或者地址所指向的内容了,如:
#include<stdio.h>
int main()
{
int a = 10;
int* pa = &a;
printf("%d\n",*pa);
printf("%d\n",*&a);
return 0;
}
输出结果是:
所以,解引用操作符(*)和取地址操作符(&)就像两个负号一样,能够相互抵消,但是这里的抵消关系是单向的,只能是*抵消&,而不能&抵消*。
三 指针的运算
在C语言中,指针是可以进行运算的,包括三种运算,分别是指针加/减整数,指针减去指针,以及指针的关系运算。
1 指针加/减整数
1) 指针加/减整数的理解
对于指针加/减整数,有些人是有些疑惑的,指针不是地址吗,地址怎么能加或者减去整数呢?其实,这里可以把加或者减去的整数理解为一个偏移量,由于内存空间是连续的,所以指针通过加或者减偏移量,就可以找到不同的地址,进而访问不同的内存空间,比如:
#include<stdio.h>
int main()
{
int arr[10] = {1,2,3,4,5,6,7,8,9,10};
int sz = sizeof(arr)/sizeof(arr[0]);
int* p = &arr[0];
int i = 0;
for (i = 0;i < sz; i++)
{
printf("%d ",*(p + i));
}
return 0;
}
运行结果是:
在上面那个代码中,首先创建了10个元素的整型数组,然后把首元素的地址赋给了整型指针变量p,接下来用了一个循环来进行数组元素的打印,由于数组元素在内存中是连续存放的,所以每次p通过加循环变量i这个整数来找到数组中的每个元素,然后解引用,打印出数组元素,那么大家到这里会不会有一个问题就是,为什么指针变量p加上一个整数i会找到数组中的每个元素,接下里我们就来解决这个问题。
2) 指针类型的特殊意义
首先,我们先看一段代码:
#include<stdio.h>
int main()
{
int a = 10;
char b = 'x';
int* pa = &a;
char* pb = &b;
printf("pa = %p\n", pa);
printf("pa+1 = %p\n", pa + 1);
printf("pb = %p\n", pb);
printf("pb+1 = %p\n", pb + 1);
return 0;
}
运行结果是:
在这里我们可以看到,pa是int*类型的指针变量,加1呢是跳过了四个字节,而pb是char*类型的变量,加1跳过了1个字节,所以通过这个例子,我们可以看到指针类型呢,决定了指针加1或者减1跳过几个字节。
我们再来看一段代码:
#include<stdio.h>
int main()
{
int a = 0x11223344;
int b = 0x11223344;
int* pa = &a;
char* pb = &b;
*pa = 0;
*pb = 0;
printf("%x\n", a);
printf("%x\n", b);
return 0;
}
运行结果是:
在上面那个代码我们可以看到,对pa解引用,再赋值0,把a变量的四个字节全都改为了0,而对pb解引用再赋值0,只改了一个字节,所以通过整个例子,我们也可以看到指针类型可以决定指针变量解引用访问多少个字节。
总结一下,就是指针类型决定了指针变量加减整数是,向前向后走多大距离,比如int*类型就一次走4个字节,也决定了对指针变量解引用时访问多少个字节。
知道了指针类型的特殊意义,那么我们就可以来讲讲void*类型指针变量的局限性了。由于这个指针类型是无具体类型的指针,所以在对void*类型的指针变量加减整数或者解引用操作时,编译器并不知道要跳过多少个字节或者访问多少个字节,故void*类型的指针变量的局限性就是:void*类型的指针是不能进行加减整数和解引用操作的。
2) 指针 - 指针
我们先来看一段代码;
#include<stdio.h>
int main()
{
int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
printf("%d\n", &arr[9] - &arr[0]);
return 0;
}
运行结果是:
我们画图来理解一下:
假设图中绿色的是内存空间,红色的是在内存中开辟的数组的存储空间,&arr[9]和&arr[0]分别是图中绿色指针所指向的位置,我们可以看到指针之间是隔了9个数组的元素的,所以指针减去指针的结果就是两个指针之间相差的元素个数,当然这个运算的前提是这两个指针必须指向同一块空间。
3) 指针的关系运算
指针变量中存储的是变量的地址,地址也是有大有小的,所以指针之间是可以进行关系运算的,比如,用指针的关系运算来打印数组:
#include<stdio.h>
int main()
{
int arr[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int sz = sizeof(arr)/sizeof(arr[0]);
int* p = &arr[0];
while(p < &arr[0] + sz)//p < &arr[sz]
{
printf("%d ",*p);
p++;
}
return 0;
}
四 const关键字
1 const修饰普通变量
const关键字来源于英文单词constant,意思是“常量,恒定的”,所以const关键字修饰变量是类似于改变了一个变量的可变属性的一个作用,如:、
#include<stdio.h>
int main()
{
const int a = 10;
a = 20;
printf("%d\n", a);
return 0;
}
运行时可以看到编译器报了错误:
所以const修饰变量,那个变量是不可被修改的,但是在本质上那个变量还是个变量,而在C++语言中,const修饰变量,就使那个变量变为了常量。
既然const修饰变量不可通过变量本身修改,有没有别的办法修改呢?这时候就想到指针了,比如下面这个代码:
#include<stdio.h>
int main()
{
const int a = 10;
int* p = &a;
*p = 20;
printf("%d\n", a);
return 0;
}
运行结果是:
我们可以看到结果确实被修改了,那么这就违反了初衷,本来const修饰变量是不想它被修改,现在却可以通过指针来修改它,所以就出现了const修饰指针。
2 const修饰指针
const修饰指针变量有两种,一种是放在*之前,一种是放在修饰之后。
1) const放在 * 之前
const放在*之前有两种写法:
const int* p;
int const * p;
这两种写法呢,都是代表一个含义,就是const修饰*p,代表指针变量本身是可以修改的,其所指向的对象不可通过指针本身来修改,但是可以通过变量本身来修改,如:
#include<stdio.h>
int main()
{
int a = 10;
const int* p = &a;
*p = 20;
printf("%d\n", a);
return 0;
}
代码运行时,报了错误:
但是如果改为以下代码:
#include<stdio.h>
int main()
{
int a = 10;
int b = 20;
const int* p = &a;
a = 20;
p = &b;
printf("%d\n", a);
printf("%d\n", *p);
return 0;
}
运行结果是:
2)const放在 * 之后
对比上面那种情况,const放在*后面就代表const修饰的是p指针变量本身,如:
#include<stdio.h>
int main()
{
int a = 10;
int b = 20;
int* const pa = &a;
pa = &b;
return 0;
}
运行时,编译器报了错误:
3) 模拟实现strlen函数
学习了const关键字和指针减去指针之后我们就可以模拟实现strlen函数了,先看一下strlen函数的原型:
可以看到strlen函数的参数是const char*类型的指针变量,为什么要用const关键字呢?是因为strlen函数只希望数出字符串'\0'之前的字符个数,并不希望字符串本身被修改,返回类型为size_t类型,是因为字符串的长度是不会为负数的。
我们知道指针减去指针是指针之间字符的个数,所以只要用指向'\0'的那个指针减去指向字符串首元素的指针,就可以得到正确的字符串个数了,所以代码就可以这样写:
size_t my_strlen(const char* str)
{
char* start = str;
while(*str)
{
str++;
}
return str - start;
}
五 野指针
1 野指针的概念
如果一个指针所指向的空间是不可知的,或者指向的空间不属于自己,那么这个指针就叫做野指针。
2 野指针的成因
1) 指针未初始化
一个指针在创建后,如果不初始化,那么那个指针所指向的空间就是未知的,就造成了野指针,如:
#include<stdio.h>
int main()
{
int* p;
*p = 20;
return 0;
}
上面代码如果运行的话,在VS编译器好中,会报出警告和错误:
2) 指针越界访问
指针如果越界访问,就会使得指针访问不属于自己的空间,就会造成野指针,如:
#include<stdio.h>
int main()
{
int arr[10] = {0};
int* p = &arr[0];
int i = 0;
for (i = 0;i <= 11;i++)
{
*(p++) = i;
}
return 0;
}
3) 指针指向的空间被释放
这个成因呢,是比较隐蔽的,比较难以发现,一不小心就可能造成野指针,如:
#include<stdio.h>
int* test()
{
int n = 100;
return &n;
}
int main()
{
int* p = test();
printf("%d\n", *p);
return 0;
}
在上面那个代码中,是把test函数的返回值也就是n的地址赋给了p指针变量,然后打印p所指向的对象,但是由于test函数调用完后,原本属于test函数的空间也被释放了,所以p所指向的那块空间就不属于main函数本身了,所以就造成了野指针。
3 如何规避野指针
根据以上成因我们可以提出规避野指针的措施:
1) 对指针变量进行初始化
我们在创建指针变量是,就要对其进行初始化,如果不知道指针指向哪里就可以对其赋值一个指针NULL,这是C语言中定义的一个符号常量:
可以看到,NULL在C语言中是被被强制转换成void*类型的0的地址,所以它的值是0,而且由于是void*类型的指针,所以是不能进行指针运算和解引用操作的。
如果不再使用指针,也要把指针变量赋值为NULL,因为有一个约定俗成的规定就是:只要是NULL指针,我们就不去访问,使用指针之前也要检查是否是NULL指针。
2) 避免越界访问
在使用指针时,一定要注意不要让指针越界访问,否则指针就访问了不属于自己的空间了。
3) 避免返回局部变量的地址
由于局部变量的作用域只是其所对应的那个代码块,所以局部变量的空间很容易在代码运行过程中被释放等,所以不要返回局部变量的地址。
六 assert断言
在野指针部分我们说了在使用指针之前,要检查指针是否是NULL指针,所以assert.h头文件中定义了一个宏assert(),专门供程序员来检查某些情况,避免出现问题。
assert()来源于英文单词assert,意思是“断言”,assert()宏接受一个表达式作为参数,如果表达式为真(返回值非0),那么程序就继续运行,如果表达式为假(返回值为0),那么就会直接报错,终止程序的运行,如:
#include<stdio.h>
#include<assert.h>
int main()
{
int* p = NULL;
assert(p);
return 0;
}
运行结果:
上面代码中给指针变量p赋值了一个NULL变量,由于NULL值为0,所以assert()宏中结果为假,所以会终止程序,并在控制台中显示哪个文件,那一行出现的错误。
assert也可以用来判断其他条件,并不只能用来判断是否是NULL指针。assert对于程序员来说可以是非常友好的,可以用来避免一些错误的产生,但同时也增加了程序的运行时间。
如果无需再断言,就直接在#include<assert.h>语句之前,加一句#define NDEBUG即可,如:
#include<stdio.h>
#define NDEBUG
#include<assert.h>
int main()
{
int* p = NULL;
assert(p);
return 0;
}
运行结果是:
可以看到,加上之后,就不会报错了。
会了assert之后,我们就可以对上面strlen模拟实现的代码进行优化一下了,可以在使用str指针之前断言一下,判断str是否是NULL指针:
size_t my_strlen(const char* str)
{
assert(str);
char* start = str;
while(*str)
{
str++;
}
return str - start;
}
七 传值调用与传址调用
在函数调用时,有两种函数参数传递方式,一种是传值调用,顾名思义,就是把实参的值传递给形参,另一种是传址调用,是把实参的地址传递给形参,显然上面那个strlen函数的模拟实现就是用的传址调用。
我们通过一个函数来理解传值调用与传址调用,这个函数是实现两个变量的交换。
1) 传值调用
传值调用的代码是这样写的:
#include<stdio.h>
void Swap(int x,int y)
{
int z = x;
x = y;
y = z;
}
int main()
{
int a = 10;
int b = 20;
Swap(a,b);
printf("%d %d\n", a, b);
return 0;
}
运行结果是:
我们看到变量并未交换,因为形参只是实参的一块临时拷贝,形参的存储空间与实参的存储空间并不是同一块空间,所以形参的交换并不会影响形参。
2) 传址调用
传址调用的代码是这样的:
#include<stdio.h>
void Swap(int* p1,int* p2)
{
int z = *p1;
*p1 = *p2;
*p2 = z;
}
int main()
{
int a = 10;
int b = 20;
Swap(&a,&b);
printf("%d %d\n", a, b);
return 0;
}
运行结果是:
我们可以看到结果交换了,因为传的是a,b变量的地址,所以Swap函数中交换就是a,b两个变量。
总结一下,就是如果以后函数中要涉及到实参,那就要用传址调用,如果不涉及到实参,那就要用传值调用。