C语言学习笔记:指针

xeonds

2021.08.13 19:26:47

对于初学者而言,这是一个很麻烦的东西;对于大佬而言,这是无所不能的屠龙宝刀。作为C语言中最重要的概念之一,掌握它,是通往C语言高阶应用的一条必经之路。

简介

指针(pointer)是一个用来存储内存地址的变量/数据对象。缩句:指针是变量。即指针具备变量的通性。指针还有两个地址运算符:(解引用运算符)和&(引用运算符)。pointer给出指针pointer指向地址的值,&argument给出变量argument所在的地址。

指针可以这样赋值:ptr = &var;即把var的地址赋给ptr。此时,ptr指向var。地址只能被存储在指针类型的变量中。

观察下面的程序:

ptr = &var_a;
result = *ptr;

这两句等价于result = var_a;。既然等价,为啥不直接用后者?因为同样是赋值,前者使用指针,从而可用来函数间通信时直接修改原数据而无需返回值再赋值。

这里注意,不要解引用未初始化的指针。像这样:

int * pt;
*pt = 5;

这样做的后果可能什么事都没有,也可能擦写数据或代码,甚至是程序崩溃。因为pt没有被地址初始化,所以它指向的是未知地址,而对未知地址赋值的后果是未知的。

声明指针

语法:[数据类型] * [变量1], * [变量2], ... , * [变量n];

不能像其他变量一样pointer [变量名];是因为指针声明时必须知道指针指向变量的类型和大小。

指针的转换说明是%p。转换说明就是printf("%p",ptr1);这样被使用的表示特定类型数据的占位符。

const关键字可以被用来声明指针。它和普通指针唯一区别是:前者不能被用来更改其指向地址的值。

指针与数组

数组名是数组首元素的地址,即:arr = &arr[0];。先说一元数组:使用指针也可以遍历数组元素。对于上面提到的arr,就可以用arr+=1;的方式访问后面的元素。也就是说,这里的+1实际上是增加一个(相应数据类型的)存储单元。同时,指针可以用来分配数组空间:看下面这个例子。

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

int main(void)
{
    int *a;

    a=(int*)malloc(sizeof(int)*2);
    scanf("%d %d",&a[1],&a[2]);

    return 0;
}

请问这个例子合法吗?合法。因为数组名对应的值就是数组首元素的地址。这里a中存储的就是第一个数的地址,所以其用法和数组的用法是一致的。

指针的指针

int a=12;
int *b=&a;
int **c=&b;

那么这里的c是什么?

指向指针的指针

其中,*c表示c所指向的位置,也就是b。也就是说,**c==*b,*b==a;

指针表达式

char ch='a';
char *cp = &ch;

现在,我们有了两个变量。接下来,我们会以它为基础,讨论一些指针表达式。

先来个简单的:

ch

它可以当右值使用,此时表示ch中的值。但是当它作为左值使用时,它表示的是ch的地址

&ch

它表示ch的地址,这个值和cp的值一样。它可以作为右值使用,但不能作为左值。因为它是一个数值,并没有指明一个计算机的内存地址。

cp

它的右值就是cp的值,左值就是cp所处的内存位置。

&cp

和第二个一样,可以作为右值,而不能作为左值。

*cp

作为右值时指ch处存储的值,作为左值时表示ch的内存位置。

*cp+1

等价于(*cp)+1。即把cp的值再加一。既然是值,那么就只能作为右值使用。

*(cp+1)

作为右值时,表示在cp+[一个该存储单元长度]处存储的值;作为左值使用时,表示cp的下一个存储单元的地址。

++cp

表示cp的下一个位置的内存地址的值。因此不能作为左值使用,只能作为右值使用。但是注意,++操作符的前缀形式表示cp增值后再拷贝一份,并作为返回值

cp++

表示cp的下一个位置的内存地址的值。同样不能作为左值使用。但是注意,++的后缀形式表示先拷贝一份cp并作为返回值,然后再将cp增值

*++cp

作为右值时,它表示cp的下一个内存地址的值;作为左值时,它表示cp的下一个内存地址。这里注意下,++的前缀形式和*都是右结合的。这里因为++离得近所以先自增再间接访问。

*cp++

作为右值时,它表示cp的内存地址的值;作为左值时,它表示cp的内存地址。注意,此处++的优先级是高于*的。但是因为前面说过的: >但是注意,++的后缀形式表示先拷贝一份cp并作为返回值,然后再将cp增值

所以,cp的值实际上已经自增了。之所以还表示ch处的地址/地址的值,是因为++返回原值的拷贝再将cp自增

++*cp

看了上面的例子,你应该很清楚了:它表示将ch处的值自增,并返回该值的拷贝。

(*cp)++

表示将cp处的值拷贝一份再返回,再自增cp处的值。所以只能作为右值使用。

++*++cp

表示将cp的值(也就是ch的地址)自增并返回一份拷贝(即ch的下一个内存地址的指针),再对这份拷贝进行间接访问操作,再对此处(ch的下一个内存地址处)存储的值自增并返回一份拷贝。同样,因为是值,所以只能作为右值。

++*cp++

此处注意,++后缀形式的优先级较高,因此先返回cp的值再将cp自增,*得到++返回的cp的值(即ch),并对其进行间接访问,再由前缀的++ch处的值自增,并返回一份拷贝。

弄清了这些,对于指针的操作应该就熟悉了。

指针和数组

首先声明一个数组。

int array[32];

array表示指向首元素的指针。所以这两种形式等价:

printf("%d",array[15]==*(array+(15)));  //输出1

多元数组同样,只需要反复嵌套即可。

这里注意,对于数组的下标,由于C实现下标的方法,实际上有两种合法形式:array[1]1[array]都是合法的。 但是很显然,后一种的可读性极差,违反直觉。所以不应被使用。

同样,函数声明也有一种旧式的K&R风格:int func(a,b,c)int a;char b;float c;。它的使用也应避免:参数传递之前,charshort类型会被提升成int类型,float会被提升为double类型。这称作缺省参数提升。所以应尽量避免使用这种风格的声明。

指向数组的指针

先看这个语句:

int matrix[3][10], *mp=matrix;

这是错误的。因为matrix是指向整型数组的指针。要声明这样的指针,需要加上下标:

int (*p)[10] = matrix;

它指向matrix的第一个整型数组。

此处注意优先级:下标引用高于间接访问。但是因为加了括号,所以实际还是间接访问先执行。

如果需要一个指针逐个访问整型元素,则可以这样:

int *pi = &matrix[0][0];
int *pi = matrix[0];    //等价形式

此时,pi++会使它指向下一个整型元素。

指针数组

看这个声明:

int* api[10];

它表示一个数组,它的每个元素都是指针:指向整型的指针。这个可以根据前面的优先级顺序推导出来。

指针和字符串常量

一个字符串常量的值是什么?是一个指针常量,一个指向它第一个字符的指针常量。为什么是常量呢?因为它的(偏移)地址是编译时编译器指定的。
下面来看几个似乎有点离谱的……表达式?

"xyz"+1

看起来似乎没有意义?但结合前面所说,我们可以推知,这是一个指向它本身第二个字符的指针。

*"xyz"

对这个指向第一个字符x的指针,执行间接访问,结果是什么?就是它指向的字符'x'

"xyz"[2]

这表示字符'z'
但是这技巧有什么用呢?看看这个:

void print_process_bar(int n)
{
    n+=5;
    n/=10;
    printf("%s\n","**********"+10-n);
}

这个函数接收一个0-100间的值,输出相应数量除以10的*。像不像一个进度条呢?

如果我们用for循环来实现,那么100%就需要循环100次。效率远不如这个函数。当然,还是可读性和可维护性更重要一些。

还有这个进制转换的方法:

putchar("0123456789ABCDEF"[value%16]);

它比传统的进制转换或许会更快一些,但是你应该写清楚注释,确保它的可读性。

指针和函数:函数指针

首先,在介绍更高级的指针类型之前,很有必要看看它们是如何声明的。

int f;        //一个整型变量
int *f;      //一个指向整型的指针
int f();     //一个函数f
int *f();   //一个返回值为指向整型的指针的函数
/*
上面那个语句中,(),也就是
函数调用操作符,优先级高于
间接访问操作符。所以f是一个
函数,它的返回值是一个
指向整型的指针。
*/
int (*f)();  //一个指向函数的指针
/*
需要分清的是括号的含义。第一对括号
就是普通的括号,最先执行计算,表示
f是一个指针。然后是第二个括号,表示
函数调用,所以*f是一个返回值为int的
函数,f则是指向这个函数的指针。
*/
int *(*f)();    //一个指向返回值为整型指针的函数指针
int f[];          //一个数组
int *f[];        //一个元素为整型指针的数组
int (*f[])();  //一个成员为返回值为整型的函数指针的指针数组
int *(*f[])(); //一个指针数组,指针所指向的类型是返回值为整型指针的函数
int *(*f[])(int, float); //标准ANSI C风格的函数指针数组的声明

有一个叫做cdecl的程序,可以解释一个现存的C语言声明,不妨百度一下。

函数指针

作为一种技巧,它会降低代码的可读性,但是也会提升效率。最常用的两个用法就是转换表和作为参数传给另一个函数,即:回调函数。

回调函数

下面看一个程序。

#include <stdio.h>
#include "node.h"

Node * search_list(Node *node, 
                void const *value, 
                int (*compare)(void const *, void const *))
{
    while(node!=NULL)
    {
        if(compare(&node->value, value)==0)
            break;
        node=node->link;
    }

    return node;
}

这是一个类型无关的链表查找函数。它的第三个参数是一个指向比较函数的指针,所以在调用的时候,我们需要编写一个对应链表数据类型的比较函数:

int cmp_int(void const *a, void const *b)
{
    return !(*(int*)a==*(int*)b);
}

注意这个函数。为了使上面的查找函数类型无关,所以它调用的函数的参数也必须是类型无关的。

也是因此,在编写比较函数时,我们需要对指针进行强制类型转换,然后再解引用,才能得到正确的值。

顺便注意一下我写的比较函数,用了一些方法简写了。

转移表

考虑一个计算器程序。对于一个功能很多的计算器,我们要对它的运算符编一个很长的switch语句。很繁琐,对吧?

假设操作符是从0开始的,则可以用转移表来替换掉这个大大的switch:

#include <stdio.h>

int add(int a, int b);
int sub(int a, int b);
int mul(int a, int b);
int div(int a, int b);

int (*oper_func[])(int, int) = {
    add, sub, mul, div
};

int main(void)
{
    int a,b,oper;
    int result;
    
    scanf("%d %s %d",&a,&oper,&b);
    result = oper_func[oper](a, b);
    printf("%d", result);

    return 0;
}

int add(int a, int b)
{
    return a+b;
}
int sub(int a, int b)
{
    return a-b;
}
int mul(int a, int b)
{
    return a*b;
}
int div(int a, int b)
{
    return a/b;
}

借用函数指针数组,我们就可以根据输入的运算符编号来调用函数指针数组中对应序号的函数。

一定要注意,函数原型必须声明在函数指针数组之前

同样的,在这里也存在下标越界的问题。但是这里的越界更难诊断出来,程序可能会直接终止,但报错的位置可能是下标越界,也可能是很奇怪的位置,因为指针可能飞到一个数据段中去了,数据被当做指令执行,肯定会出错。

更离谱点,如果这个指针刚好飞到一个函数体中,那个函数可能会快乐地执行,并且修改谁也不知道的值。这时候要找出bug就难如登天了。

实例

这啥

我一个哥们问我的

int *(*a[5])(int, char*);

比较麻烦。。不过还能看出来,区分好结构就行了。

这是一个函数指针数组的指针,指针指向的每个函数返回一个int类型的指针。

首先看大体结构。int* xxx(int,char)应是一个函数的样子。然后再细看:

*a[5]又是啥?我们先看下a[5]。这是一个被初始化的,含有5个元素的数组。*表示该数组每个元素都是指针。所以,这是一个函数指针数组。

字符串长度统计

#include <stdlib.h>

size_t strlen(char *string)
{
    int length=0;
    
    while(*string++!='\0')
        length++;

    return length;
}