指针

206 阅读7分钟

指针是C语言的核心概念,也是C语言的特色和精华所在,只有掌握了指针,才算的上真正地掌握了C语言。

0 指针简介

指针是一个值为内存地址的变量,其声明形式如下:

int *pi;            // pi is a pointer point to a value of type int
char *pc;           // pc is a pointer point to a value of type char
struct Array *ps;   // ps is a pointer point to a value of type struct Array

可以总结指针的声明格式如下:

Type *ptr;			// ptr是指向type类型变量的指针

这里有几点需要声明:

  • 指针是一个变量,这个变量存储的是一个内存地址,这个变量的类型就是指针,绝不是Type;
  • Type指的是这个内存地址(指针变量)上存储的变量类型;
  • 星号(*)表明声明的变量是一个指针,和解引用运算符二元乘法运算符 有区别;

所以我认为可以用以下声明格式来更好地理解指针(个人理解),Pointer表明是指针类型;Type表示类型,如int、char;ptr就是变量。

Pointer <Type> ptr;

0.1 取址运算符(&)

取址运算符(&)用来取得其操作数的地址,如下,操作数a的类型为Type,则表达式&a的类型是Type类型的指针。取址运算符的操作数必须是在内存中可以寻址的变量。

Type a;
Type *p = &a;

0.2 解引用运算符(*)

解引用运算符(*)用来取出指向地址上存储的值,其操作数必须是指针类型,如下,b的值和即为a,记住第三行的星号(*)为解引用运算符,第二行的星号(*)是声明指针。

Type a = ...;
Type *p = &a;
Type b = *p;

0.2.1 不能解引用未初始化的指针!!!

如下,第二行的意思是把100这个数存储在pi指向的位置,但是pi没有被初始化,其值是一个随机地址(局部变量,在栈区,值为随机值),这是一件十分危险的事情,因为地址是随机的,所以这个操作可能会擦写数据或代码,导致程序崩溃。

int *pi;
*pi = 100;

切记:创建一个指针时,系统只分配了储存指针本身的内存,并未分配存储数据的内存,因此,在使用指针前,必须使用已分配的地址进行初始化,同理,同样不可以访问未初始化指针指向的结构体的成员。

0.3 指针运算

0.3.1 指针与整数相加减

可以用+运算符把指针与整数相加,或整数与指针相加(加法符合交换律)。无论何种情况,整数都会和指针所指向的类型大小(字节为单位)相乘,然后把结果与初始指针(初始地址)相加,如果相加的结果超出了初始指针指向的有效范围,计算结果则是未定义的。

可以用-从一个指针中减去一个整数(减法不符合交换律)。该整数将乘以指针所指向的类型大小(字节为单位),然后用初始化地址减去乘积。如果相减的结果超出了初始指针所指向的有效范围,计算结果则是未定义的。

在增减指针时还要注意一些问题,编译器不会检查指针是否越界,所以指针运算的时候应当注意指针是否越界。

0.3.2 指针求差

可以计算两个指针的差值,如下,p1和p2是指向同意数组不同元素的指针,注意:其相减的值表示两个元素相隔的是2个int,而不是2个字节。只要两个指针都指向相同的数组(或者其中一个指针指向数组后面的第1一个地址),C都能保证运算有效。如果指向两个不同数组,则求差可能会得到一个值,也可能会导致运行时错误。

#include <stdio.h>

int main()
{
    int a[5] = {100, 85, 1554, 4575, 25};
    int *p1, *p2;
    p1 = a;
    p2 = &a[2];
    printf("p1 = %p\n", p1);
    printf("p2 = %p\n", p2);
    printf("p1 - p2 = %ld\n", p1 - p2);
    printf("p2 - p1 = %ld\n", p2 - p1);
    return 0;
}

结果如下:

$ ./main
p1 = 0x7fff88e01700
p2 = 0x7fff88e01708
p1 - p2 = -2
p2 - p1 = 2

0.4 NULL指针

NULL指针在C语言中表示空指针,如下第2行所示,NULL被定义为是void*类型指针,大小为0,由于C语言是弱类型语言,可以隐式转换,如int *p = NULL或者char *p = NULL都是合法的。

#ifndef __cplusplus
#define NULL ((void *)0)
#else   /* C++ */
#define NULL 0
#endif  /* C++ */

但是在C++中,NULL被定义为0,因为C++不能将void*类型指针隐式转换为其它类型的指针,为了兼容,C++中直接将NULL定义为0.

在C++中,如果继续使用NULL代替空指针,那么C++的重载特性会使得NULL存在这二义性的问题,为了防止此类问题出现,在C++11中特意引进了nullptr这一关键字以表征空指针。因此,在C++中,还是用nullptr代替NULL会更好。

1 指针与数组

数组和指针的关系十分密切,C语言实际上借助指针去描述数组的表示法,对于指向数组的指针,我们既可以使用数组描述法去使用指针,也可以使用指针描述的方式去使用数组,如下所示。

#include <stdio.h>

int main()
{
    int a[5] = {100, 85, 1554, 4575, 25};
    int *p = a;
    printf("*p = %d, p[0] = %d, a[0] = %d, *a = %d\n", *p, p[0], a[0], *a);
    printf("*(p + 3) = %d, p[3] = %d, a[3] = %d, *(a + 3) = %d\n", *(p + 3), p[3], a[3], *(a + 3));
    return 0;
}

执行结果如下所示:

$ ./main
*p = 100, p[0] = 100, a[0] = 100, *a = 100
*(p + 3) = 4575, p[3] = 4575, a[3] = 4575, *(a + 3) = 4575

看以上的例子,看上去a就是一个指针,它指向了一个数组内存,但是他和指针是有区别的。指针是一个变量,其自身是有地址的,其值存的是a的地址;a是这个数组的标识,它并非声明出来的指针变量,所以其自身的地址就是其本身,也是整个数组的起始地址。但是对其本身取址,则和指针是不一样的概念了,如下。

#include <stdio.h>

int main()
{
    int a[5] = {100, 85, 1554, 4575, 25};
    int *p = a;
    printf("p = %p, &p = %p\n", p, &p);
    printf("a = %p, &a = %p\n", a, &a);
    return 0;
}

执行结果如下:

$ ./main
p = 0x7fff15d99290, &p = 0x7fff15d992a8
a = 0x7fff15d99290, &a = 0x7fff15d99290

可以看到,p和a的值都是一样的,这个地址也是数组的起始地址;&p指的是指针p这个变量的地址,而&a没有什么实际意义,但是大多数编译器将其设置为与a的值一样。

1.1 指针数组

我们将形如以下形式的定义称为指针数组,指针数组的每一个元素都是指针。

Type *ptr[n];

在上述声明中,由于“[]”的优先级比“*”高,故ptr首先与[]结合,成为一个数组,再由Type指明这是一个Type类型的指针数组,数组中的元素都是Type类型的指针,可以看例子如下,声明一个char类型的指针数组,每个指针指向一个常量字符串,然后将其打印出来。

#include <stdio.h>

int main()
{
    int i;
    char *a[3] = {"Hello", "Everyone","!"};

    for (i=0; i < 3; i++)
        printf("%s ", a[i]);
    printf("\n");

    return 0;
}

执行结果如下:

$ ./main
Hello Everyone ! 

1.2 数组指针

数组指针是一个指针,它指向一个数组,声明的方式如下:

Type (*ptr)[n];

ptr是一个指针,它指向一个Type类型的数组,这个数组的长度是n,这也是指针ptr的步长,即执行p+1时,会跨过n个Type类型数据的长度。数组指针和二维数组的联系密切,可以用数组指针指向一个二维数组,如下:

#include <stdio.h>

int main()
{
    int a[2][3] = {1, 2, 3, 4, 5, 6};
    int (*p)[3] = a;

    printf("(*p)[0] = %d, (*p)[1] = %d, (*p)[2] = %d\n", (*p)[0], (*p)[1], (*p)[2]);
    printf("(*(p+1))[0] = %d, (*(p+1))[1] = %d, (*(p+1))[2] = %d\n", (*(p+1))[0], (*(p+1))[1], (*(p+1))[2]);

    return 0;
}

执行结果如下:

$ ./main
(*p)[0] = 1, (*p)[1] = 2, (*p)[2] = 3
(*(p+1))[0] = 4, (*(p+1))[1] = 5, (*(p+1))[2] = 6

注意以上用法,(*p)指的是数组。

2 指针和结构体

结构体指针是指向结构体的指针,结构体访问成员时用的是.,而指针访问成员用的是->,如下:

#include <stdio.h>

typedef struct A {
    int a;
    char b;
} A_t;


int main()
{
    A_t x = {
        .a = 10,
        .b = 'A',
    };
    A_t *p = &x;

    printf("x.a = %d, x.b = %c\n", x.a, x.b);
    printf("p->a = %d, p->b = %c\n", p->a, p->b);
    return 0;
}

结果如下:

$ ./main 
x.a = 10, x.b = A
p->a = 10, p->b = A

3 指针和函数

3.1 指针传参

C语言所有的参数均是以“传值调用”的方式进行传递的,这意味者函数将获得参数值的一份拷贝。

指针传参并不违背以上原则,只是传递的参数变成了指针变量,而函数只不过拷贝了一份指针变量,但是指针变量的值是地址,所以函数操作的是这个值,即地址,从而对存储在此地址值的任何操作都会改变指针指向的参数值。所以如果我们需要被调函数回传一个值给调用函数,我们可以使用指针传参。

另外,指针传参也能大大提高函数执行的效率,在值传递时,需要把数据的一份拷贝传递入函数的形参表,并存储会栈中,函数返回后弹出栈,拷贝被删除,这就是深拷贝。而指针传递则不然,函数会在执行是直接去指针指向的地址中获取数据直接进行操作,这也就是浅拷贝。这对于参数是一些复杂的结构体的函数而言,会大大的提升效率。

指针传参的例子许多C语言的书上都会有,这里我就不赘言了,需要注意的是,对于数组而言,数组名是该数组首元素的地址,所以利用数组名传参等同于指针传参,如下例所示:

#include <stdio.h>

void printb(int b[])
{
    printf("b = %p, &b = %p\n", b, &b);
}

void printc(int *c)
{
    printf("c = %p, &c = %p\n", c, &c);
}


int main()
{
    int a[5] = {100, 85, 1554, 4575, 25};
    printf("a = %p, &a = %p\n", a, &a);
    printb(a);
    printc(a);
    return 0;
}

结果如下,值得注意的是,虽然在printb函数中,形参用的数组形式,但是其和使用指针形式的printc函数一样,都相当于利用指针传值将a的地址传递过来,但是这个指针却是在各自的函数中却是有地址的。主要记住这两种函数定义或声明的形式是等价的。

$ ./main 
a = 0x7fffaa13c240, &a = 0x7fffaa13c240
b = 0x7fffaa13c240, &b = 0x7fffaa13c228
c = 0x7fffaa13c240, &c = 0x7fffaa13c228

3.2 函数指针

在C语言中,函数指针指向函数的入口地址,声明一个函数指针的形式如下所示。函数指针在回调函数中比较常见。

Type (*ptr)(Type1 *, Type2 *)

以上声明了一个函数指针ptr,指向一个函数,函数具有两个参数为Type1*和Type2*的参数,且返回值类型为Type。

下面举一个例子,在我的博客《链表(C语言实现)》里,曾经声明了一种函数指针的类型,具体如下:

typedef int (*match_t)(void *, void *);

void *list_find(list_t *list, void *key, match_t match)
{
    node_t *node;

    if (!list || !match) return NULL;

    for (node = list->head; node; node = node->next)
        if (match(node->data, key))
            break;

    return node ? node->data : NULL;
}

我们实现如下的主函数:

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


int match(void *data, void *key)
{
    if (*(int*)data + 10 == *(int*)key)
        return 1;

    return 0;
}

int main()
{
    int ret, i, a[100], *data;
    list_t *list;
    match_t p = match;

    for (i = 0; i < 100; i++)
        a[i] = i;


    list = list_create();
    if (!list) {
        printf("create failed.\n");
        return 1;
    }

    for (i = 0; i < 10; i++) {
        ret = list_append(list, a + i);
        if (ret < 0) {
            printf("append failed.\n");
            return 1;
        }
    }

    if ((data = list_find(list, &a[19], p)) == NULL) {
        printf("Find failed.\n");
        return 1;
    }
    printf("data = %d\n", *data);
    return 0;
}

可以看到,函数指针p指向函数match,其实将第37行的p指针直接替换为match传入函数亦可,和数组一样,函数名也可以理解为是指针。执行结果如下:

$ ./main
data = 9

4 总结

有关指针的概念和用法还有很多,譬如多级指针等。初学C语言的时候,总会被指针这个概念搞懵,网上的很多解释也是停留在指针的用法上,很难找到一种统一的、从根本上的解释。

我觉得明白一个核心概念:指针变量也是变量,和一般的变量没有区别,只是此变量的值是其它变量的地址,甚至也可以是指针的地址(那就是二级指针咯!)。明白这个概念后,对指针的特性也就理解的更彻底,有些疑问也就迎刃而解了。