美文网首页
C 语言指针怎么理解?

C 语言指针怎么理解?

作者: 小小何先生 | 来源:发表于2021-02-27 22:55 被阅读0次

    [toc]

      如果是计算机小白的话,对指针是什么都不太了解的话,强烈推荐我之前的一个回答,从一个生活中的故事,抛开程序员思维,但是直觉理解指针的本质:如何向计算机小白解释C语言指针?

      接下来开始上猛药了!

      指针提供了动态操控内存的机制,强化了数据结构的支持,且实现了访问硬件的功能。不过,也正是由于指针这种直接操控变量的地址机制,使得它灵活,能减少很多不必要的拷贝,提升程序性能,但难以掌握。因为指针包含的就是内存地址,因此理解指针的关键在于理解C程序如何管理内存

      指针是C++中一个非常核心的东西,其它的高级语言并不是没有指针,而是将这块屏蔽掉了。比如python,实例化一个类:class = myclass(),这里的class就是一个指针,它指向某个对象,可以为空,也可以修改其指向。尤其是在python中,如果复制某个数据直接用的=号,那修改的就是指针,这是非常危险的,复制最好使用copy,这里还会涉及到深拷贝和浅拷贝,感兴趣的可以自行了解。

    指针的定义

      指针就是地址,指针变量是一个存放内存地址的变量。指针变量包含的是内存中别的变量、对象或函数的地址。(这里说的对象就是内存分配,比如malloc中分配的内存)。通常我们说的指针就约等于指针变量。

    内存地址是用十六进制表示出来的一串数字编号,是给内存标号的(程序员直接操作的是虚拟内存,而不是真正的物理内存)。在32位系统中,这个编号是4byte(32个bit),64位系统下是8byte(64bite)。

      指针变量本质还是一个变量,所以这个指针变量存放的是num1的地址还是num2的地址,都是可以改变的,但是num1的地址和num2的实际地址是不动的

      与其它变量一样,在使用之前需要对其进行声明,来告诉编译器,这个变量里面存放的是一个地址。然而,指针本身并没有包含所引用数据的类型信息,指针只包含地址。依据这个地址里面存放的数据类型不同,可以定义不同的类型指针:

    int* p; // 定义一个整数类型的指针
    char* p1; // 定义一个字符类型指针
    float* p2; // 定义一个浮点类型指针
    

    注意 :指针指向的是内存地址,那我们为什么需要定义其数据类型呢? 因为指针的大小是固定的,里面存放的是一个变量的首个字节地址,那拿到首地址之后,往后走多少字节才是这个数据呢?这个时候就需要数据类型的辅助。

    指针的操作

      在了解指针的操作之前,我们首先需要知道并深刻理解如何定义一个指针:

    int num = 5; // 定义了一个整数变量num,并赋值为5。
    int* p; // 定义了一个指针变量p,指针变量p需要存一个值。
    p = # // 指针变量p中存的值被赋值为num的地址。
    

      星号两边的空白符无关紧要。星号将变量声明为指针。这是一个重载过的符号,因为它也用在乘法和解引指针上。

      上述代码在int* p这一步其实就已经给p赋了一个随机的初始值,或者将其称之为垃圾数据,因为这个刚分配的内存中存储的东西可能是之前存储的任何东西。之后再将num这块数据的内存地址赋给了p也可以在定义指针变量的时候直接给赋上地址值

    直接定义一个不赋值的指针,会使得这个指针变成野指针。在初始化指针的时候,如果不知道给什么值,可以给NULL

    int num = 5;
    int *p = #
    

      如果你是初学者,这里可能会有一个疑惑:指针前加*代表解引用,找到指针指向的内存中的数据。可能这里int *p = #会有一些同学有一些疑惑,*p代表解引用,那不应该直接解出个int类型数据出来嘛,怎么还把地址赋给它了?

      没错!*是解引用运算符 &是取地址运算符*p = &a这样写是不正确的(除非两种情况:1. p是指向指针的指针;2. 这时候*p前面要有类型符,比如int等,这个时候int* p表示p是一个指向int类型数据的指针。)通常的情况是这样用的int *p = &a;这一句作用相当于int *p;,p=&a;两句,这句话的意思是定义一个int类型指针p,然后用a的地址给p赋值。所以*前面加数据类型其实表示的是定义了这种类型的一个指针,而不代表解引用。

    int main(){
        int num = 0;
        int *pi = #
        cout << "address of num: " << (int)&num << " num is: " << num << endl;
        cout << "address of pi: " << (int)&pi << " pi is: " << (int)pi << endl;
    }
    

      上述代码的输出结果为:

    address of num: 6422268 num is: 0
    address of pi: 6422264 pi is: 6422268
    

      可以看到指针变量pi里面存的是num的地址,而这个地址也是需要存在内存中的,因此pi也是实实在在的一个变量,不过这个变量比较特殊,存别的变量的地址而已。

    在虚拟操作系统上显示的指针地址一般不是真实的物理内存地址。虚拟操作系统允许程序分布在机器的物理地址空间上。应用程序分为页(或帧),这些页表示内存中的区域。应用程序的页被分配在不同的(可能是不相邻的)内存区域上,而且可能不是同时处于内存中。如果操作系统需要占用被某一页占据的内存,可以将这些内存交换到二级存储器中,待将来需要时再装载进内存中(内存地址一般都会与之前的不同)。这种能力为虚拟操作系统管理内存提供了相当大的灵活性。程序使用的地址是虚拟地址。操作系统会在需要时把虚拟地址映射为物理内存地址。

      间接引用操作符(*)返回指针变量指向的值,一般称为解引指针。解引用操作符的结果可以用做左值。(左值,是指赋值操作符左边的操作数,所有的左值都必须可以修改,因为它们会被赋值。)

    指针操作符

      除了上述两种解引和取地址操作符外,指针还有如下操作符:

    操作符 名称 含义
    * 用来声明指针
    * 解引 用来解引指针
    -> 指向 用来访问指针引用的结构的字段
    + 用于对指针做加法
    - 用于对指针做减法
    == != 相当、不等 比较两个指针
    > >= < <= 大于、大于等于、小于、小于等于 比较两个指针
    (数据类型) 转换 改变指针的类型

      给指针加上一个整数,其实加的是:整数与指针数据类型对应字节数的乘积,减法类似。如下述代码:

    #include<bits/stdc++.h>
    using namespace std;
    int main(){
        int vector[] = {28, 41, 17};
        int *pi = vector; 
        cout << *pi << endl; // 输出 28
        pi += 1;
        cout << *pi << endl; // 输出 41
    }
    

      在做指针算数运算时,需要小心,访问超出数组范围的内存是一个非常危险的操作。并且,没有什么能够保证被访问的内存是有效变量,因此存取无效或无用的地址的情况是很容易发生的。

      一个指针减去另一个指针会得到两个地址的差值,这个插值通常没什么用,但是可以判断数组中的元素顺序,当然,这一点也可以用标准的比较操作符来进行比较。指针之间的差值是它们之间相差的“单位”数,差的符号取决于操作数的顺序。

    #include<bits/stdc++.h>
    using namespace std;
    int main(){
        int vector[] = {28, 41, 17};
        int *p0 = vector;
        int *p1 = vector + 1;
        int *p2 = vector + 2;
        cout << p2 - p0 << endl; // 输出 2
        cout << p0 - p1 << endl; // 输出 -1
    }
    

    空指针和野指针

    • 空指针:指针变量指向内存中编号为0的空间。例如:int* p = NULL。一般用来初始化指针,空指针指向的内存是不可以访问的NULL被赋值给指针就意味着指针不指向任何东西。null概念是指指针包含了一个特殊的值,和别的指针不一样,它没有指向任何内存区域。两个null指针总是相等的
    • 野指针:就是指向一个已删除的对象或者未申请访问受限内存区域的指针。
    1. C语言中的指针可以指向一块内存,如果指针所指向的内存稍后被系统回收(被释放),但是指针仍然指向这块内存,那么此时该指针会变成一个悬空指针。悬空指针在悬空之前是个正常指针,之后所指向的空间被free或者delete掉了,就变成了一个悬空指针。
    2. 指针变量指向非法的内存空间。比如int *p = (int*)0x1100,但是事先并未申请这样一个内存空间,就会报错。如果定义一个指针,这个指针变量未初始化的话,这个指针也将会变成一个野指针

      野指针的危害比悬空指针还要可怕,野指针可能指向任意内存字段,也有可能破坏正常的数据。野指针很难被debug,因此通常在释放内存之后,常常将指针赋值为NULL。所以很多人都会自己封装一个free宏,在释放内存的同时将这个指针置NULL。还有些书籍里面会把这个称作迷途指针,叫法不一样,东西都是一个东西。

      指针如果被声明为全局或静态,就会在程序启动时被初始化为NULL

    void指针

      void指针是通用指针,用来存放任何数据类型的引用。void指针和别的指针永远不会相等,不过,两个赋值为NULLvoid指针是相等的。

      任何指针都可以被赋给void指针,它可以被转换回原来的指针类型,这样的话指针的值和原指针的值是相等的。

    #include<bits/stdc++.h>
    using namespace std;
    int main(){
        int num = 0;
        int* pi = &num;
        cout << "value of pi: " << (int)pi << endl;
        void* pv = pi;
        pi = (int*)pv; // 从void* 转换回int*
        cout << "value of pi: " << (int)pi << endl;
    }
    

      上述代码输出结果,表示指针地址是一样的:

    value of pi: 6422276
    value of pi: 6422276
    

      void指针只用做数据指针,而不能用做函数指针

      用void指针的时候要小心。如果把任意指针转换为void指针,那就没有什么能阻止你再把它转换成不同的指针类型了。

      sizeof操作符可以用在void指针上,不过我们无法把这个操作符用在void上,sizeof(void*)是合法的,但是sizeof(void)是非法的。

    const修饰指针

      const也可以和指针变量一起使用,这样可以限制指针变量本身,也可以限制指针指向的数据const修饰指针有三种情况:

    1. const修饰指针 -- 常量指针:特点是:指针的指向可以修改,但是指针指向的值不可以改。
    int a =10;
    int b =20;
    int* p = &a 
    const int* p =10; 
    //*p = 20; //错误,指针指向的值不可以更改。
    p = &b; //正确,指针指向可以改。
    

      这里const修饰的是*p,所以指针所指向的数据是只读的,我们还是可以通过b变量来修改其值,只是不能用p来修改。也就是说:我们不能解引指向常量的指针并改变指针所引用的值,但可以改变指针。但是指针的值不是常量。指针可以改为引用另一个整数常量,或者普通整数。这样做不会有问题。声明只是限制我们不能通过指针来修改引用的值。

    1. const修饰常量 --指针常量:指针指向不可以改,指针指向的值可以改。
    int num = 10
    int* const p = &num; 
    *p = 20; //正确,指针指向的值可以更改。
    //p = &b; //错误,指针指向不可以改。
    

      指针是只读的,也就是p本身的值不能被修改。把地址&num赋值给p之后,就不能再给它赋一个新值&b

    1. const既修饰指针,又修饰常量:指针指向和指针指向的值都不可以改。
    const int* const p = 10;
    *p = 20; //错误,指针指向的值不可以更改。
    p = &b; //错误,指针指向不可以改。
    

    C语言中,单独定义const变量没有明显的优势,完全可以使用#define命令代替。const通常用在函数形参中,如果形参是一个指针,为了防止在函数内部修改指针指向的数据,就可以用const来限制。

    • 当一个指针变量str1const限制时(类似const char* str1这种形式),说明指针指向的数据不能被修改;如果将str1赋值给另外一个未被const修饰的指针变量str2,就有可能发生危险。因为通过str1不能修改数据,而赋值后通过str2能够修改数据了,意义发生了转变,所以编译器不提倡这种行为,会给出错误或警告。
    • 也就是说,const char*char*是不同的类型,不能将const char*类型的数据赋值给char*类型的变量。但反过来是可以的,编译器允许将char*类型的数据赋值给const char *类型的变量。这种限制很容易理解,char *指向的数据有读取和写入权限,而const char *指向的数据只有读取权限,降低数据的权限不会带来任何问题,但提升数据的权限就有可能发生危险。

    传值与传址、形参与实参

      传递指针可以让多个函数访问指针所引用的对象,而不用把对象声明为全局可访问。也能够减少一些没有必要的复制,尤其当涉及大型数据结构时,传递参数的指针会更加高效。如果需要在函数中修改数据的话,用指针传递数据就是一种很好的方式,通过传递一个指向常量的指针,可以使指针传递的数据禁止被修改。

    1. 传值参数(非指针参数):
    #include <iostream>
    int inc(int input){
        return ++input;
    }
    int main(int argc, const char * argv[]) {
        int num = 10;
        printf("the num is %d \n", num);
        inc(num);
        printf("the num is %d after call \n", num); //num并不会改变,
    }
    

      用作函数参数传过去只是赋值。

    1. 传址: 参数是指针、参数是地址:

      将一维数组作为参数传递给函数实际是通过值来传递数组的地址,这样信息传递就更高效,因为我们不需要传递整个数组,从而也就不需要在栈上分配内存。除非数组内部有信息告诉我们数组的边界,否则在传递数组时也需要传递长度信息。如果数组内存储的是字符串,我们可以依赖NUL字符来判断何时停止处理数组。

    #include <iostream>
    int inc(int *input){
        return ++*input;
    }
    int main(int argc, const char * argv[]) {
        int num = 10;
        printf("the num is %d \n", num);
        inc(&num);
        printf("the num is %d after call \n", num); // num通过地址修改了值
    }
    

      将指针传递给函数时,使用之前先判断它是否为空是个好习惯。

      将指针传递给函数时,传递的是值。如果我们想修改原指针而不是指针的副本,就需要传递指针的指针。

      传递了一个整数数组的指针,为该数组分配内存并将其初始化。函数会用第一个参数返回分配的内存。在函数中,我们先分配内存,然后初始化。所分配的内存地址应该被赋给一个整数指针。为了在调用函数中修改这个指针,我们需要传入指针的地址。所以,参数被声明为int指针的指针。在调用函数中,我们需要传递指针的地址:

    void allocateArray(int** arr, int size, int value){
        *arr = (int*)malloc(size * sizeof(int));
        if(*arr != NULL){
            for(int i=0; i<size; i++){
                *(*arr+i) = value;
            }
        }
    }
    int* vector = NULL;
    allocateArray(&vector, 5, 45);
    

      allocateArray的第一个参数以整数指针的指针的形式传递。当我们调用这个函数时,需要传递这种类型的值。这是通过传递vector地址做到的。malloc返回的地址被赋给arr。解引整数指针的指针得到的是整数指针。因为这是vector的地址,所以我们修改了vector

      如果只传递一个指针是不会起作用的。因为将vector传递给函数时,它的值被复制到了参数arr中,修改arrvector没有影响。

    1. 返回指针

      我们定义一个函数,为其传递一个整数数组的长度和一个值来初始化每个元素。函数为整数数组分配内存,用传入的值进行初始化,然后返回数组地址:

    int* allocateArray(int size, int value){
        int* arr = (int*)malloc(size * sizeof(int));
        for(int i=0; i<size; i++){
            arr[i] = value;
        }
        return arr;
    }
    int* vector = allocateArray(5, 45);
    for(int i=0; i<5; i++){
        printf("%d\n", vector[i]);
    }
    
    image

      左图显示return语句执行前的程序状态,右图显示函数返回后的程序状态。vector变量包含了函数内分配的内存的地址。当函数终止时arr变量也会消失,但是指针所引用的内存还在,这部分内存最终需要释放。

      如果不为数组动态分配内存,而用一个局部数组,就会发生一些错误:

    int* allocateArray(int size, int value){
        int arr[size];
        for(int i=0; i<size; i++){
            arr[i] = value;
        }
        return arr;
    }
    

      一旦函数返回,返回的数组地址也就无效了,因为函数的栈帧从栈中弹出了。还有一种方法是把arr变量声明为staticstatic int arr[5]。这样会把变量的作用域限制在函数内部,但是分配在栈帧外面,避免其他函数覆写变量值。

    多级指针和多级指针的解引用

      把握一个核心本质,指针就是地址,指针变量就是存放地址的变量

    int num = 9;
    int *p = &num;
    int **sp = &p;
    int ***ssp = &sp;
    

      上述代码只是个举例示意,多级指针一般并不是这种用法。举个实际的例子:

    int arr[3][3] = {{1,2,3},{4,5,6},{7,8,9}};
    int **p = arr;
    

      多级指针通常用来作为函数的形参,比如常见的main函数声明如下:

    int main(int argc,char** argv)
    

      因为当数组用作函数的形参的时候,会退化为指针来处理,所以上面的形式和下面是一样的。

    int main(int argc,char* argv[])
    

      多级指针的另一种常见用法是,假设用户想调用一个函数分配一段内存,那么分配的内存地址可以有两种方式拿到:

    1. 通过函数的返回值
    void* get_memery(int size){
        void* p =malloc(size);
        return p;
    }
    
    1. 使用二级指针
    #include <iostream>
    int GetMemory(int** buf, int size){
        *buf = (int*)malloc(size);
        if(*buf==NULL) return -1;
        else return 0;
    }
    int main(int argc, const char* argv[]) {
        int* p = NULL;
        GetMemory(&p, 10);
        return 0;
    }
    

      上述代码先定义一个指针变量*pp里面存的是一个地址,之后传入函数GetMemory中的&p是地址的地址,所以它的类型是int**。对其一次解引用之后*buf里面还是存的地址,那存的是谁的地址呢?我们知道p里面存了一个地址,而&p就是p这个地址的地址,把这个值传给了buf,因此对buf第一次解引用之后,得到的就是p的地址。

      两次解引用**buf才能获取到值,也就是*p的值。

    字符串基础

      字符串是以ASCII字符NUL结尾的字符序列。ASCII字符NUL表示为\0。字符串通常存储在数组或者从堆上分配的内存中。不过,并非所有的字符数组都是字符串,字符数组可能没有NUL字符。字符数组也用来表示布尔值等小的整数单元,以节省内存空间。

      字符串的长度是字符串中除了NUL字符之外的字符数。为字符串分配内存时,要记得为所有的字符再加上NUL字符分配足够的空间。NULLNUL不同。NULL用来表示特殊的指针,通常定义为((void*)0),而NUL是一个char,定义为\0,两者不能混用。

      声明字符串的方式有三种:字面量、字符数组和字符指针。字符串字面量是用双引号引起来的字符序列,常用来进行初始化,它们位于字符串字面量池中。这里要注意不要把字符串字面量和单引号引起来的字符搞混。

      下面是一个字符数组的例子,我们声明了一个header数组,最多可以持有31个字符。因为字符串需要以NUL结尾,所以如果我们声明一个数组拥有32个字符,那么只能用31个元素来保存实际字符串的文本。

    char header[32];
    

    字符串初始化

      有两种方法初始化字符串,这取决于变量是被声明为字符数组还是字符指针,字符串所用的内存要么是数组要么是指针指向的一块内存

    1. 初始化操作符初始化char数组
    char header[] = "Media Player";
    

      字面量"Media Player"的长度为12个字符,表示这个字面量需要13字节,我们就为数组分配了13字节来持有字符串。初始化操作会把这些字符复制到数组中,以NUL结尾。

      也可以使用strcpy函数来初始化数组:

    char header[13];
    strcpy(header, "Media Player");
    
    1. 初始化char指针

      动态内存分配可以提供更多的灵活性,当然也可能会让内存存在得更久。

    char *header = (char*)malloc(strlen("Media Player")+1);
    strcpy(header, "Media Player");
    

      在使用malloc函数对字符串开辟内存的时候,一定要记得算上终结符NUL。不要使用sizeof操作符,而是用strlen函数来确定已有字符串的长度。sizeof操作符会返回数组和指针的长度,而不是字符串的长度。

      这里特别要注意,如果用字符字面量来初始化char指针不会起作用,因为字符字面量是int类型,这其实是尝试把整数赋给字符指针。

    char* prefix = '+'; // 不合法
    

      正确的做法是像下面这样用malloc函数:

    prefix = (char*)malloc(2);
    *prefix = '+';
    *(prefix+1) = 0;
    

    指针与结构体

      通常对结构体命名约定以下划线开头:

    struct _person{
        char* firstName;
        char* lastName;
        char* title;
        unsigned int age;
    };
    

      结构体的声明常用typedef关键字简化:

    typedef struct _person{
        char* firstName;
        char* lastName;
        char* title;
        unsigned int age;
    }Person;
    

      person的实例声明如下:

    Person person;
    

      我们也可以声明一个Person指针并为它分配内存,如下所示:

    Person *ptrPerson;
    ptrPerson = (Person*)malloc(sizeof(Person));
    ptrPerson->firstName = (char*)malloc(strlen("Emily")+1);
    strcpy(ptrPerson->firstName, "Emily");
    ptrPerson->age = 23;
    

      使用结构体的话,我们可以采用上述的箭头操作符,或者解引用之后用点操作符,像:

    (*ptrPerson).age = 23;
    

      结构体用完之后记得用free释放内存:

    free(person->firstName);
    

    相关文章

      网友评论

          本文标题:C 语言指针怎么理解?

          本文链接:https://www.haomeiwen.com/subject/emgyfltx.html