【C语言进阶】指针详解

330 阅读14分钟

C语言中的指针是一种非常重要的概念,它允许直接访问内存地址,以及通过这个地址来修改存储在那里的数据。指针在C语言(以及许多其他编程语言)中广泛用于数组操作、动态内存分配、函数参数传递以及很多底层编程任务。

一、指针的基本概念

1.1. 指针是什么?

  • 指针是一个变量:指针本身是一个变量,但它存储的不是普通的数据值,而是另一个变量的内存地址。

  • 地址的概念:在内存中,每个变量都被分配了一个唯一的地址,这个地址用于标识和访问该变量的存储位置。

  • 指针的类型:指针变量本身有类型,这个类型决定了指针所能指向的内存区域中存储的数据的类型。例如,int *p; 表示p是一个指向int类型数据的指针。

  • 指针的值:指针的值(或内容)是它所指向的变量的内存地址。

    type *name;

其中,type 是指针指向的变量类型,*name 是指针变量名。例如,int *p; 定义了一个指向整数的指针变量 p

1.2. 指针的声明

指针的声明通过在变量类型前加上星号(*)来完成。表示该变量是一个指针,用于存储某种类型变量的内存地址。例如,

int *ptr;  // 声明一个指向int类型数据的指针  
char *cPtr; // 声明一个指向char类型数据的指针  
float **ppf; // 声明一个指向float指针的指针,即二级指针

1.3. 指针的初始化

指针变量在声明后需要被初始化,即赋予它一个内存地址。这个地址值通常是通过取地址运算符(&)获得的,该运算符返回其操作数的内存地址。有几种方法可以初始化指针:

int a = 10;  
int *ptr = &a; // 使用取地址运算符&初始化指针  
  
int arr[] = {1, 2, 3};  
int *arrPtr = arr; // 数组名即为数组首元素的地址  
  
// 动态分配内存  
int *dynPtr = (int *)malloc(sizeof(int)); // 分配一个int大小的空间  
if (dynPtr != NULL) {  
    *dynPtr = 20; // 解引用指针,分配值  
}

1.4. 指针的解引用

指针的解引用是通过在指针变量名前加星号(*)来实现的。解引用操作会访问指针所指向的内存地址中的值。

int value = *ptr; // 访问ptr指向的值,并将其赋值给value

1.5. 指针的算术运算

指针的算术运算在C语言中是非常重要且有用的特性,尤其是在处理数组和动态内存时。

①指针的递增和递减

当对指向数组元素的指针进行递增(++)或递减(--)操作时,指针的地址值会根据其所指向的数据类型的大小进行增加或减少。具体来说,如果指针指向一个int类型的数组元素(假设int占用4字节),那么递增该指针会使其地址增加4字节,从而指向数组的下一个int元素。同样,递减操作会使指针地址减少4字节,指向数组的上一个int元素。

int array[5] = {1, 2, 3, 4, 5};  
int *ptr = array; // ptr指向array的第一个元素  
  
ptr++; // ptr现在指向array的第二个元素  
ptr--; // ptr又指回array的第一个元素

②指针的加法和减法

指针的加法和减法运算也是基于指针所指向的数据类型的大小进行的。但是,与递增和递减不同,加法和减法运算允许一次性跳过多个元素。

int *ptr = array; // 假设array和ptr如上定义  
  
ptr += 2; // ptr现在指向array的第三个元素(跳过了两个int)  
ptr -= 1; // ptr现在指向array的第二个元素

也可以使用指针和整数进行加法或减法运算,但结果仍然是一个指针,并且这个指针指向的是原始指针加上或减去指定数量(乘以数据类型大小)后的地址。

int *newPtr = ptr + 2; // newPtr指向array的第三个元素  
int *prevPtr = ptr - 1; // 注意:这通常是未定义行为,因为ptr没有指向array之前的内存  
// 但是,如果ptr原本就指向array内部的一个元素,那么ptr-1在数组界限内是合法的

③ 注意事项

  • 类型匹配:指针的算术运算只在指向相同类型的数据时才有意义。因为不同类型的数据可能占用不同大小的内存空间,所以混合使用可能会导致不可预测的结果。

  • 越界访问:在进行指针算术运算时,必须确保运算后的指针不会指向数组或分配的内存块之外的内存。否则,可能会导致未定义行为,包括程序崩溃或数据损坏。

  • 指针减法:两个指向同一数组(或同一内存块)内元素的指针可以进行减法运算,结果是一个整数,表示两个指针之间相隔的元素数量(不是字节数)。

    int *start = array;
    int *end = array + 4; // 指向数组的最后一个元素
    int diff = end - start; // diff的值是4,表示end和start之间相隔4个int元素

1.6. 指针与数组

  • 在C语言中,数组名在大多数情况下会被当作指向数组首元素的指针。因此,可以使用指针来遍历和操作数组中的元素。

  • 指针算术在数组操作中特别有用,因为可以通过指针的加减来访问数组中的不同元素。

    int arr[5] = {1, 2, 3, 4, 5};
    int *p = arr; // p指向arr[0]
    for (int i = 0; i < 5; i++) {
    printf("%d ", *(p + i)); // 通过指针访问数组元素
    }

1.7. 指针与函数

  • **传递指针给函数:**通过传递指针给函数,函数可以修改外部变量的值。

    void modifyValue(int *x) {
    *x = *x * 2; // 修改x指向的值
    }

    int main() {
    int val = 5;
    int *ptr = &val;
    modifyValue(ptr); // 等同于modifyValue(&val)
    printf("%d\n", val); // 输出10
    return 0;
    }

  • **函数返回指针:**函数可以返回指针,常用于返回动态分配的内存、数组或结构体等。

    int* createArray(int size) {
    int arr = (int)malloc(size * sizeof(int)); // 动态分配内存
    for (int i = 0; i < size; i++) {
    arr[i] = i * 2; // 初始化数组
    }
    return arr; // 返回指向数组的指针
    }

    int main() {
    int *myArray = createArray(5);
    for (int i = 0; i < 5; i++) {
    printf("%d ", myArray[i]); // 访问并打印数组元素
    }
    free(myArray); // 释放动态分配的内存
    return 0;
    }

1.8. 指针与结构体

结构体(struct)和指针在C语言中经常一起使用,以提供灵活且强大的数据结构处理能力。结构体允许我们将多个不同类型的数据项组合成一个单一的类型,而指针则允许动态地访问和操作这些结构体实例及其成员。

①结构体与指针的结合使用

1. 指向结构体实例的指针:可以创建一个指向结构体实例的指针。这样,就可以通过指针来访问和修改结构体的成员,而不需要每次都写出结构体的名称。

struct Person {  
    char name[50];  
    int age;  
};  
 
struct Person person = {"Alice", 30};  
struct Person *ptr = &person; // ptr指向person实例  
 
printf("Name: %s, Age: %d\n", ptr->name, ptr->age);

ptr是一个指向Person类型实例的指针。使用->运算符来访问ptr所指向的结构体实例的成员。

2. 通过指针访问结构体成员->运算符是专门用于通过指针访问结构体成员的。它首先解引用指针以获取结构体实例,然后访问该实例的成员。

ptr->age = 31; // 修改ptr所指向的Person实例的年龄

3. 动态分配的结构体数组:当处理大量结构体实例时,动态内存分配变得非常有用。可以使用malloc(或callocrealloc)来分配一个结构体数组的内存,并使用指针来访问这些实例。

#include <stdio.h>
#include <stdlib.h>
 
struct Person {
    char name[50];
    int age;
};
 
int main() {
    int n = 5; // 假设我们要创建5个Person实例
    struct Person *people = (struct Person *)malloc(n * sizeof(struct Person));
 
    if (people == NULL) {
        // 处理内存分配失败的情况
    }
 
    // 初始化people数组
    for (int i = 0; i < n; i++) {
        sprintf(people[i].name, "Person%d", i + 1);
        people[i].age = 20 + i;
    }
 
    // 访问并打印people数组中的元素
    for (int i = 0; i < n; i++) {
        printf("Name: %s, Age: %d\n", people[i].name, people[i].age);
    }
 
    // 释放内存
    free(people);
}

注意,在这个例子中,我们实际上并没有直接使用指向结构体成员的指针(即没有成员指针),但我们使用了指向结构体数组的指针people。然而,即使在这种情况下,->运算符仍然可以通过与数组索引结合使用来模拟访问特定结构体实例的成员,尽管更常见的是直接使用数组索引和.运算符(如people[i].name)。

4. 成员指针:虽然上面的例子没有直接展示,但C语言也允许创建指向结构体成员的指针。通常用于更高级的场景,比如需要动态地访问结构体中不同成员时。但是,由于C语言的结构体成员在内存中是连续存储的,并且没有直接的方式来获取成员在结构体中的偏移量(除了通过编译器特定的扩展或手动计算),因此成员指针的使用相对较少见,并且通常涉及到复杂的指针算术和类型转换。

二、指针的使用场景及示例

C语言指针的使用场景非常广泛,涵盖了从基础的数据结构操作到高级的系统编程等各个方面。下面将列举一些常见的使用场景,并为每个场景提供一个简单的示例。

2.1. 数组遍历与操作

场景描述:使用指针遍历数组元素,并对元素进行操作。

示例

#include <stdio.h>  
  
void printArray(int *arr, int size) {  
    for (int i = 0; i < size; i++) {  
        printf("%d ", *(arr + i)); // 使用指针算术访问数组元素  
    }  
    printf("\n");  
}  
  
int main() {  
    int arr[] = {1, 2, 3, 4, 5};  
    printArray(arr, 5); // 传递数组名(作为指向首元素的指针)和数组大小  
    return 0;  
}

运行结果:

2.2. 字符串处理

场景描述:使用指针来操作C语言中的字符串(字符数组)。

示例

#include <stdio.h>  
#include <string.h>  
  
void reverseString(char *str) {  
    char *start = str;  
    char *end = str + strlen(str) - 1;  
    while (start < end) {  
        char temp = *start;  
        *start = *end;  
        *end = temp;  
        start++;  
        end--;  
    }  
}  
  
int main() {  
    char str[] = "Hello, World!";  
    reverseString(str);  
    printf("%s\n", str); // 输出:!dlroW ,olleH  
    return 0;  
}

运行结果:

2.3. 结构体成员访问与修改

场景描述:通过指针访问和修改结构体的成员。

示例

#include <stdio.h>  
  
typedef struct {  
    int id;  
    char name[50];  
} Person;  
  
void printPerson(Person *person) {  
    printf("ID: %d, Name: %s\n", person->id, person->name);  
}  
  
int main() {  
    Person p = {1, "Alice"};  
    printPerson(&p); // 传递指向Person结构体的指针  
    return 0;  
}

运行结果:

2.4. 函数参数传递与返回值

场景描述:通过指针在函数间传递大型数据结构或实现按引用传递。

示例1(按引用传递):

#include <stdio.h>  
  
void addOne(int *num) {  
    (*num)++;  
}  
  
int main() {  
    int x = 5;  
    addOne(&x); // 传递x的地址  
    printf("%d\n", x); // 输出:6  
    return 0;  
}

运行结果:

示例2(通过指针返回多个值):

#include <stdio.h>  
  
void maxMin(int *arr, int size, int *max, int *min) {  
    *max = *min = arr[0];  
    for (int i = 1; i < size; i++) {  
        if (arr[i] > *max) *max = arr[i];  
        if (arr[i] < *min) *min = arr[i];  
    }  
}  
  
int main() {  
    int arr[] = {3, 5, 1, 4, 2};  
    int max, min;  
    maxMin(arr, 5, &max, &min);  
    printf("Max: %d, Min: %d\n", max, min); // 输出:Max: 5, Min: 1  
    return 0;  
}

运行结果:

2.5. 动态内存分配

场景描述:在运行时动态地分配和释放内存。

示例

#include <stdio.h>  
#include <stdlib.h>  
  
int main() {  
    int *ptr = (int *)malloc(sizeof(int) * 10); // 分配10个int大小的内存  
    if (ptr != NULL) {  
        for (int i = 0; i < 10; i++) {  
            ptr[i] = i * 2; // 使用指针算术访问分配的内存  
        }  
        // ... 使用ptr指向的内存 ...  
        free(ptr); // 释放内存  
    }  
    return 0;  
}

三、指针使用注意事项

C语言中的指针是强大但也需要谨慎使用的工具。

3.1. 初始化指针

在使用指针之前,一定要确保它已经被初始化。未初始化的指针可能指向任意内存位置,访问这样的位置是未定义行为,可能导致程序崩溃或数据损坏。

在声明指针时,应该立即将其初始化为一个有效的地址,如**NULL**或某个实际对象的地址。

示例

#include <stdio.h>  
  
int main() {  
    int a = 10;  
    int *p; // 未初始化的指针  
  
    // 正确的做法是先初始化指针  
    p = &a; // 指向变量a的地址  
  
    printf("%d\n", *p); // 输出:10  
  
    // 避免使用未初始化的指针  
    // int *q;  
    // printf("%d\n", *q); // 未定义行为  
  
    return 0;  
}

运行结果:

3.2. 指针的算术运算

指针的算术运算(如加法、减法)是基于指针所指向的数据类型的大小进行的。务必确保指针算术不会导致指针指向无效的内存区域。

示例

#include <stdio.h>  
  
int main() {  
    int arr[5] = {1, 2, 3, 4, 5};  
    int *p = arr;  
  
    // 指针算术  
    printf("%d\n", *(p + 2)); // 输出:3  
  
    // 注意不要越界  
    // printf("%d\n", *(p + 10)); // 未定义行为  
  
    return 0;  
}

运行结果:

3.3. 空指针

空指针(NULL0)不指向任何有效的内存地址。在将指针用作参数或返回值之前,检查它是否为空是一个好习惯。

示例

#include <stdio.h>  
  
void printValue(int *p) {  
    if (p != NULL) {  
        printf("%d\n", *p);  
    } else {  
        printf("Pointer is NULL\n");  
    }  
}  
  
int main() {  
    int *p = NULL;  
    printValue(p); // 输出:Pointer is NULL  
  
    int a = 20;  
    p = &a;  
    printValue(p); // 输出:20  
  
    return 0;  
}

运行结果:

3.4. 动态内存分配与释放

使用malloccallocrealloc动态分配内存后,一定要记得使用free来释放这块内存,以避免内存泄漏。

示例

#include <stdio.h>  
#include <stdlib.h>  
  
int main() {  
    int *p = (int *)malloc(sizeof(int)); // 动态分配内存  
    if (p != NULL) {  
        *p = 10;  
        printf("%d\n", *p); // 输出:10  
  
        free(p); // 释放内存  
    }  
  
    // 注意:不要再次访问p指向的内存,因为它已经被释放了  
    // *p = 20; // 未定义行为  
  
    return 0;  
}

运行结果:

3.5. 指针与数组名

在大多数情况下,数组名在表达式中会被转换为指向数组首元素的指针。但是,数组名并不是指针变量,它不能被修改以指向其他内存位置。

示例

#include <stdio.h>  
  
int main() {  
    int arr[5] = {1, 2, 3, 4, 5};  
    int *p = arr; // 数组名转换为指向首元素的指针  
  
    printf("%d\n", *(p + 2)); // 输出:3  
  
    // arr = &arr[3]; // 错误:数组名不是左值,不能赋值  
  
    return 0;  
}

运行结果:

3.6. 指针与函数

当指针作为函数参数传递时,修改指针所指向的值将影响原始数据。但是,如果函数内部改变了指针本身的值(即指针的指向),那么这个改变不会反映到函数外部。

示例

#include <stdio.h>  
  
void modifyValue(int *p) {  
    *p = 100; // 修改指针所指向的值  
}  
  
void modifyPointer(int **pp) {  
    *pp = &someGlobalVar; // 修改指针本身(假设someGlobalVar已定义)  
}  
  
int main() {  
    int value = 5;  
    int *ptr = &value;  
  
    modifyValue(ptr);  
    printf("%d\n", value); // 输出:100  
  
    // 假设存在某个全局变量someGlobalVar  
    // modifyPointer(&ptr); // 修改ptr本身(如果需要)  
  
    return 0;  
}  
  
// 注意:示例中的someGlobalVar未在此代码中定义,仅用于说明目的。

3.7. 指针解引用

在解引用指针(即使用*操作符访问指针所指向的值)之前,确保指针不是NULL。解引用NULL指针会导致程序崩溃。

示例(避免):

#include <stdio.h>  
  
int main() {  
    int *ptr = NULL;  
    printf("%d\n", *ptr); // 危险:解引用NULL指针  
  
    return 0;  
}

C语言中的指针是强大的工具,允许直接访问和操作内存地址。它们不仅提高了程序效率,还增强了灵活性。指针变量存储的是变量的内存地址,通过解引用可访问该地址的值。指针与数组紧密相关,常用于动态内存分配、函数参数传递及复杂数据结构如链表的处理。然而,使用指针需谨慎,以避免内存泄露、野指针和越界访问等错误。深入理解指针及其内存管理机制是成为高效C程序员的关键。

四、测试

问题:数组名和指针的核心区别是什么? (字节跳动2023年C语言面试题)

答案

① 数组名是常量(不可修改指向),指针是变量(可重新赋值指向其他地址);

② sizeof(数组名) 得到数组总字节数,sizeof(指针) 得到指针本身大小(如64位系统8字节);

③ &数组名得到数组地址(类型为数组指针),&指针得到指针变量的地址(类型为二级指针)。

问题:如何通过指针实现两个整数的交换,且不使用临时变量? (腾讯2022年C语言面试题)

答案

void swap(int *a, int *b) {
    if (a == NULL || b == NULL || a == b) return;
    *a = *a ^ *b;  // a存储a^b结果
    *b = *a ^ *b;  // b = (a^b)^b = a
    *a = *a ^ *b;  // a = (a^b)^a = b
}

原理:利用异或运算自反性,通过指针解引用直接操作原始变量,避免值传递局限。

问题:以下代码存在什么问题?如何修复?

int* getNum() {
    int num = 10;
    return &num;
}
int main() {
    int *p = getNum();
    printf("%d", *p);
    return 0;
}

答案:问题是返回局部变量地址,函数调用结束后局部变量num内存释放,p成为野指针,解引用为未定义行为。修复:① 改用静态变量(static int num=10;);② 动态分配内存(int *num=malloc(sizeof(int)); *num=10; return num;,主函数需free(p))。

题目int a[5]; 请问 a&a 有什么区别?

答案

a 是数组名,在大多数表达式中会退化为指向数组首元素的指针,类型为 int*&a 表示取整个数组的地址,其类型为 int (*)[5]。二者数值(地址值)相同,但指针类型所代表的步长不同。a+1 指向下一个int元素(地址加4字节),&a+1 则跳过整个数组(地址加20字节)。

题目:请解释 const int *pint const *pint * const pconst int * const p 的区别。

答案

  • const int *pint const *p 等价:指针指向的内容是常量,内容不可变,但指针本身可以指向其他地址。

  • int * const p:指针本身是常量,指向不可变,但指向的内容可以修改。

  • const int * const p:指针本身和其指向的内容都是常量,都不可变。

题目:使用 malloc 动态分配内存后,为什么要用 free 释放?什么是“野指针”和“内存泄漏”?如何避免?

答案:

free 用于释放不再使用的堆内存,将其归还系统,避免内存泄漏。“野指针”是指向已释放或无效内存的指针,对其操作会导致未定义行为。“内存泄漏”是分配的内存未释放,导致程序持续占用内存。

避免方法:分配后检查指针是否为NULL;使用后立即 free 并将指针置为NULL;确保分配和释放的配对。