深入理解C语言指针

  12 mins to read  

写在最前

在C语言中,指针能为代码带来高灵活高性能。对于指针的理解以及灵活运用是对C语言掌握程度的最好体现,且深入理解指针可以对理解Python的许多行为提供便利(最常用的为CPython解释器)。在此总结指针的一些tips


首先贴一个基础题:

#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;
}

问:此代码的输出

虽然工程代码中不可能写这样的三重指针(三维数组除外),但这个题目可以考察基础

输出:

POINT
ER
NT
EW

解析:

首先明确c是一个char*数组,即c={&'E', &'N', &'P', &'F'}

作为一个指针数组,c可以退化为二重指针,且cp为二重指针数组,所以赋值成立

接着cp为一个二重指针数组,可退化为三重指针,故可赋值给cpp

  • 第一问printf("%s\n",**++cpp);cpp自加后解引,相当于cp[1],推及cp数组定义,为c+2*(c+2)即为c[2]"POINT"首字母的地址,当以%s格式打印它时打印出字符串POINT
  • 第二问printf("%s\n",*--*++cpp+3);,执行顺序为(*--(*++cpp))+3。因cpp在前一问已自加1,故*++cpp等于cp[2]c+1c+1自减后解引为*cc[0],最后加3相当于从字符串首字母指针偏移3并一直打印到字符串结尾,即ER
  • 第三问printf("%s\n",*cpp[-2]-3);cpp在前两问两次自加,cpp[-2]等于cp[0]等于c+3,最后-3,即从FIRST首字母开始往前偏移3,由于在数组内元素是连续的,且sizeof(char)等于1,所以不会有字节对齐产生的空内存,加上字符串结尾的\0c中四个元素的长度分别为{6, 4, 6, 6}。故这里-3即偏移到POINTN,打印出NT
  • 第四问printf("%s\n",cpp[-1][-1]+1);,和前三问完全相同,打印出EW

C语言在编译后,会以三种方式使用内存:

  • 静态/全局内存
    • 作用域:声明它的函数内部
    • 生命周期:整个程序
    • 代码段:静态/全局数据区
  • 自动内存
    • 作用域:声明它的函数内部
    • 生命周期:函数执行结束前
    • 代码段:栈stack
  • 动态内存
    • 作用域:全局
    • 生命周期:内存被free
    • 代码段:堆heap

指针与数据初始化

以下会造成难以预期的错误

int* pointer;
*pointer = 12345;

当声明指针时,会初始化地址的内存,但不会分配指针指向数据的内存。故,在对指针进行解引操作之前,必须将一个恰当的地址赋值给它


重载过的操作符”*”

对于指针的声明,有两种方式:

int n
int *pit
pit = &n
int n
int *pit = &n

按照我最开始学习的理解,”*“号是寻找指针所引用的值,”&”是变量的地址。那么第一种声明指针的方式很好理解,但是第二种方式: int *pit = &n 赋值符号左边是变量值,右边是变量地址,它们之间为什么可以赋值且编译器不报错

解释:

int *pit里的”*“是对指针的声明,类似于int lit[]对于数组的声明;而a = *pit里的”*“则是对指针的解引操作,即dereference。C语言中的”*“号是经过操作符重载的,它还有第三种用法就是算数运算的乘法


指针的类型转换

对于int *pit语句,”*“两旁的空格是没有限制的,也就是说,下面三条语句在编译器看来是完全相同的:

int* pit;
int * pit;
int *pit;

所以在对指针类型进行转换的时候。我们可以把(int*) pit看作是变量转换一样:int num。即将(int*)看作指针声明的特定操作符


打印指针的值

我这里说的是打印”指针”的值,即打印变量的地址。在printf()函数中可以有不同的结果

int num = 1;
int *pit = &num;
printf("%d",pit);   //十进制
printf("%x",pit);   //十六进制
printf("%p",pit);   //指针格式,一般是Hex

数组

char *pit[] = {"Hello","Ivan"};
printf("%s",pit[0]);    //这里pit[0]为指向"Hello"的指针,以string形式打印指针时会打印"Hello"
printf("%p",pit[0]);    //以%p格式打印时,会打印出'H'的地址,因为数组的地址是第一个元素的地址

关于void指针

void指针也叫万能指针,用来存放任何类型的引用,常用于类型转换时。

  • void指针具有与char指针相同的内存对齐方式(指针内存会边界对齐,如int类型占四字节,指针的内存地址为4的倍数)
  • void指针和别的指针永远不会相等
  • void指针只用于作数据指针,而不能用于函数指针
  • 可以用sizeof操作符返回void指针长度,但不能返回void类型长度
    • size_t n = sizeof(void*); //正确
    • size_t n = sizeof(void); //错误

与指针相关的预定义类型

  • size_t无符号整数,表示C语言中任何对象能达到的最大长度,使用它可以解决一些缓冲区溢出漏洞。用于安全的保存长度
  • intptr_t用于保存指针地址,用一种可移植且安全的方法声明指针

指针的算数运算

给指针加上整数:等同与给指针加上此整数与指针类型字节数的乘积

int n[] = {1,2,3};
int *pit = n;
printf("%d",pit);   //假如打印出10000
++pit;
printf("%d",pit);   //此处会打印出10004

指针相减,运算结果为指针想差的单位数

int n[] = {1,2,3};
int *pit = n;
int *pit_t = n+1;   //指向2
printf("%d",pit_t-pit);  //打印1

指针与常量

  • 指向常量的指针
    • 指针声明:const int n = 1;int *pit = &n;
  • 指向非常量的常量指针
    • 指针声明:int n;int *const pit = &n;
  • 指向常量的常量指针

此处解释第二种声明方式

int* const pit = &n;    //const修饰pit,即常量指针
const int* pit = &n;    //const修饰*pit,即指向常量的指针

C语言的动态内存管理

之所以说C/Cpp相较其他高级语言更底层,因为C可以访问地址以及动态管理内存

malloc函数Cpp:new

int *pit = (int*) malloc(sizeof(int));  //malloc()执行成功返回地址;失败返回NULL
*pit = 1;

注意:

malloc函数返回的指针是没有清空的,里面可能包含垃圾数据。与calloc函数的区别在此。

realloc函数

在之前分配的内存块的基础上,将内存重新分配为更大或更小的部分

int n = 1;
int *pit = (int*) malloc(2);
realloc(pit,4);
pit = &n;

calloc函数

返回清空过的指针,不包含垃圾数据。但执行效率低于malloc函数

free函数Cpp:delete

用于释放内存

int *pit = (int*) malloc(4);
free(pit);

释放后的指针仍可能指向原值,此情况称为迷途指针


指针与函数

用指针传递数据

  • 在为函数传入参数时,如果传入变量,那么将会为该变量分配一块自动内存,即原变量的副本,当传递的变量所占内存非常大时将会影响程序性能,这时需要传入指针
  • 当我们需要在函数内部对变量进行修改时,也需要传入变量指针

如定义一个函数交换变量的值:

void change(int *n1,int *n2){
    int t;
    t = *n1;
    *n1 = *n2;
    *n2 = t;
}
void main(){
    int n1 = 1;
    int n2 = 2;
    change(&n1,&n2);
}

假如在这里我们不用指针传参的话,那么交换不会发生

在VB以及Python中:

  • VB中函数传参会有显式声明by val或by ref,即传值或传参;如没有显示声明则程序默认传值
  • Python中函数传参为传递对象的引用,对于可变对象,函数中的修改在函数内外都可见;对不可变对象,函数内修改会创建函数内局部变量,函数外不可见。实践中发现:函数中对列表操作,”+”操作符默认以函数局部变量操作,如需操作列表,需调用list对象的成员函数,另:Python中对象赋值也为传递对象的引用,如a = 1;b = a,当修改b的值时,才会重新为b分配一块内存

在函数传参外的对象机制,写一个简单的程序来直观展示Python的逻辑:

print("Ivan" == "Ivan")   #True
print("Ivan" is "Ivan")   #True

a = "1 1"
b = "1 1"
print(a is b)   #False

print(1 == 1)   #True
print(1 is 1)   #True

print(257 ==257)    #True
print(257 is 257)   #False

print([1,2] == [1,2])   #True
print([1,2] is [1,2])   #False

上面例子中,由于字符串驻留机制,”Ivan”字符串被重复使用;字符串驻留机制仅限于/[a-zA-Z0-9_]/字符,不限长度;整数驻留,仅限于-5~256的小整数;列表对象每一次创建都会新建对象,id值不同;

函数指针

声明: void (*foo)();

这里假如不加括号写成void *foo();,则为函数接受void类型,返回void类型指针

int (*pit)(int);
int foo(int n){
    return n*n;
}
void main(){
    int n =2;
    pit = foo;      //*************
    printf("%d",pit(n));    //##########
}

在我标注*的位置:将函数直接赋值给指针。这是C语言特定的语法,编译后的函数就是以地址表示,所以编译器忽略了&操作符。但还是建议写成pit = &foo;,以免给别人或自己以后读代码时造成误解

在我标注#的位置:可以直接写成示例中的样子,原因同上。建议写成printf("%d",(*pit)(n));,原因依旧同上:增加代码可读性

举一个Python中引用函数指针的例子:

dic_c = {'a':1,'b':2,'c':3}
c = max(dic_c,key = dic_c.get)      #将字典对象的成员函数get传入max函数的key值,此处更常用的是调用lambda表达式

以上代码是依照字典的值找出最大值对应的键,如不传入key参数则依照字典的键找最大值

函数指针的常见用法

利用typedef进行函数指针声明

typedef int (*foo)(int,int);
int add(int n1,int n2){
    return n1+n2;
}
int sub(int n1,int n2){
    return n1-n2;
}
int process(foo function,int n1,int n2){
    return (*function)(n1,n2);
}
void main(){
    printf("%d",process(&add,1,2));
}

函数指针用来实现callback fuction以及OOP中的多态


指针与数组

指针与数组关系密切

数组名是指向数组第一个元素的常量指针,当对数组名进行解引等操作时,数组名退化为了一个指针

int array[5] = {1,2,3,4,5};
int *pit = array;

首先我定义了一个数组和一个指针,在对它们进行数组元素访问时,以下的代码等价:

//pit == array == &array[0] == &pit[0]
//*(pit + 1) == pit[1] == array[1] == *(array + 1)

只要不调用sizeof操作符,在这里数组名和指针是无异的。

在调用sizeof操作符时,数组名返回值为数组类型长度*数组元素个数;指针返回值为指针类型长度

一下代码说明了指针与数组的一些使用:

int array[5] = {1,2,3,4,5};
int *pit = array;
int v = 2;
for (int i = 0;i < 5;++i){
    *pit++ *= v;
}

变长数组

在C99之前没有变长数组,只能只用realloc()函数改变数组长度

字符串数组

我的常用法:

char *string[2] = {"hello","ivan"};
printf("%s",string[1]);

指针与字符串

其实在大多数编程语言里,字符串就是数组

声明字符串:

  • 赋值给变量
  • 字符数组
  • 字符指针

使用strcpy函数

下例展示了如何声明内存,并指向字符串

char *pit = (char*) malloc(strlen("hello,ivan")+1);
strcpy(pit,"hello,ivan")

这里strlen函数返回值加1的原因是:字符串结尾有’\0’,即ascii码的NUL,结束符,类似文件结束符EOF

下例展示一个将字符串改为小写的函数:

char *lower(char * string){
    char *pit = (char*) malloc(strlen(string)+1);
    char *start = pit;
    while (*string != 0){
        *pit++ = tolower(*string++);
    }
    *pit = 0;
    return start;
}

指针与结构体

结构体是将一些想关的变量,值或函数(函数指针)集合起来的类型。个人感觉可以算作是OOP的雏形,但没有OOP的多态和继承

结构体的声明通常使用typedef关键字:

typedef struct _ivan{
    char *name;
    int age;
} ivan;
ivan ivan_name;

还可以使用结构体指针来指向声明的结构体:

ivan *pitivan;
pitivan = (ivan*) malloc(sizeof(ivan));

访问结构体对象:

  • 声明结构体:用”.”操作符,ivan.age
  • 声明结构体指针:用”->”操作符或先解引再用”.”操作符,ivanpit->age

定义函数来简便初始化结构体:

typedef struct _ivan{       //ivan前加了下划线,在大多数OOP语言中意为表明这是私有对象以隔绝外部访问。这里是一种命名规范,可以节约变量名空间
    char *name;
    int age;
} ivan;
void init_struct(ivan *nnn,char *name,int age){
    nnn->name = (char*) malloc(strlen(name)+1);
    strcpy(nnn->name,name);
    nnn->age = age;
}
void main(){
    ivan ivan_name;
    init_struct(&ivan_name,"eddie",19);
}

用结构体创建常用数据结构:

  • 链表
  • 队列
  • 堆栈

太复杂了不想写,参见《数据结构(C/Cpp语言描述)》


指针的安全使用

不同于其他高级语言,C语言相对更接近系统底层。C语言不会阻止越过数组边界,这带来了很多风险。缓冲区溢出漏洞的EXP/POC基本都是用C/Cpp写的。有一个段子:The C(VE) Programming Language,CVE大多数都是C语言的功劳

错误的指针声明:

//声明两个int指针的错误写法
int* pit1,pit2;
//正确写法
int *pit1,*pit2;

上面的那种写法中,pit1为指针,pit2为int类型;下面的写法才是声明了两个指针。在类型转换中可以把int*当做操作符,但在这里得注意

使用指针前为初始化:

int *pit;
printf("%d",*pit);

未初始化的指针可能包含垃圾数据,导致严重后果。避免此类错误:在指针声明时即赋值NULL

int *pit =NULL;
if(pit == NULL){
    //wrong
}else{
    //use pointer
}

数组越界访问:

char a_data[8] = "1234567";
char b_data[8] = "1234567";
char c_data[8] = "1234567";

b_data[-2] = X;
b_data[10] = X;
b_data[0] = X;

如果打印以上三个数组会发现,a和c的数据也被修改了。根本原因是C语言通过下标访问元素时不会检查索引值

使用restrict关键字声明不存在别名的指针:

int num =1;
int* restrict pit = &num;

这样做可以提高程序效率,C语言标准库中的很多函数就使用了restrict关键字来声明指针


联合union和结构体struct

联合和结构体无论声明还是赋值都很相像,但两者有很大区别

联合体中所有成员共用一片内存,结构体中则是集合了一些元素

联合体:

  • union中可以定义多个成员,union的大小由最大的成员的大小决定。
  • union成员共享同一块大小的内存,一次只能使用其中的一个成员。
  • 对某一个成员赋值,会覆盖其他成员的值

字符串指针操作的坑

一下两种错误编译器不会报错,程序会因为堆栈溢出而崩溃

在定义一个字符串的时候,以下是合法的:

char* str[] = "ivan";

以下是非法的:

char* str[];
str = "ivan";
*str = "ivan";

相较于指针声明字符串也是一样:

char* str = (char*) malloc(20);
str = "ivan";         //非法

使用strcpy函数才是正确的方式

char* str = (char*) malloc(20);
strcpy(str,"ivan");

操作字符串时,strcat函数需要动态分配内存:

char* str1 = "hello";
char* str2 = " ivan";
strcat(str1,str2);          //堆栈溢出 

正确的做法:

char* str1 = (char*) malloc(40);
memset(str1,0,40);
strcpy(str1,"hello");
strcat(str1," ivan");

或者在拼接的时候调用remalloc函数重新分配内存


大概总结的就这么多了,滚去复习电工和计算方法了–>_–>