MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

C 语言指针使用需要注意的关键点

2021-02-161.5k 阅读

指针的基础概念与内存关联

指针是什么

在C语言中,指针是一种特殊类型的变量,它存储的是内存地址。简单来说,普通变量存储的是具体的数据值,比如一个整数 int num = 5; 这里 num 变量存储的就是数值 5。而指针变量存储的是其他变量在内存中的地址。例如:

int num = 5;
int *ptr;  // 声明一个指向 int 类型的指针
ptr = # // 将 num 的地址赋给指针 ptr

在上述代码中,int *ptr; 声明了一个名为 ptr 的指针变量,它可以指向 int 类型的数据。& 是取地址运算符,ptr = # 这行代码将 num 变量的内存地址赋给了 ptr

指针与内存的紧密联系

内存可以看作是一个大的字节数组,每个字节都有一个唯一的地址。当我们声明一个变量时,系统会在内存中为其分配一定数量的字节空间来存储其值。例如,int 类型在大多数系统中占用4个字节。指针通过存储变量的地址,就像是一把指向特定内存位置的钥匙。通过指针,我们可以直接访问和修改该内存位置的数据。

#include <stdio.h>

int main() {
    int num = 10;
    int *ptr = &num;

    printf("The value of num is: %d\n", num);
    printf("The address of num is: %p\n", (void *)&num);
    printf("The value stored in ptr is: %p\n", (void *)ptr);
    printf("The value at the address ptr points to is: %d\n", *ptr);

    return 0;
}

在这段代码中,(void *)&num(void *)ptr 将地址转换为 void * 类型以便正确打印。*ptr 是解引用操作符,用于获取指针 ptr 所指向内存位置的值。从输出结果可以清晰看到变量的值、变量的地址以及指针所存储的地址和指针指向的值之间的关系。

指针声明与初始化的关键要点

指针声明的语法细节

指针声明的一般形式为:type *pointer_name;,其中 type 是指针所指向的数据类型,pointer_name 是指针变量的名称。例如,char *char_ptr; 声明了一个指向 char 类型的指针,float *float_ptr; 声明了一个指向 float 类型的指针。 需要注意的是,指针声明中的 * 只是表明这是一个指针变量声明,与解引用操作符 * 的含义不同。例如:

int *ptr1, ptr2;

这里 ptr1 是一个指向 int 类型的指针,而 ptr2 只是一个普通的 int 变量,因为 * 只对紧跟其后的变量名起作用。如果要声明多个指针变量,每个变量名前都需要加上 *,如 int *ptr1, *ptr2;

初始化的必要性

指针声明后必须进行初始化,否则它将是一个野指针。野指针指向的是不确定的内存位置,访问野指针会导致未定义行为,这可能会引发程序崩溃、数据损坏等严重问题。例如:

// 未初始化指针的错误示例
int *ptr;
// 这里试图使用未初始化的 ptr 是错误的
// *ptr = 10;  // 这行代码会导致未定义行为

正确的做法是在声明指针后立即初始化它,或者将其赋值为 NULL(在C语言中,NULL 是一个表示空指针的常量)。例如:

int num = 20;
int *ptr = &num;  // 直接初始化为变量的地址
// 或者先声明,后初始化
int *ptr2;
ptr2 = &num;

// 若暂时没有合适的地址可指向,可初始化为 NULL
int *ptr3 = NULL;

当指针被初始化为 NULL 时,对其进行解引用操作同样会导致未定义行为,但相比野指针,这种情况更容易调试,因为现代的调试工具通常会在解引用 NULL 指针时给出明确的错误提示。

指针运算的注意事项

指针算术运算

指针可以进行算术运算,但这种运算与普通数值运算有很大区别。指针算术运算主要包括指针与整数的加法和减法,以及两个指针之间的减法(仅当两个指针指向同一数组的元素时才有意义)。

  1. 指针与整数的加法:当一个指针加上一个整数 n 时,实际上是将指针移动了 n * sizeof(type) 个字节,其中 type 是指针所指向的数据类型。例如:
int arr[5] = {1, 2, 3, 4, 5};
int *ptr = arr;  // 数组名本身就是指向数组首元素的指针
ptr = ptr + 2;   // 指针移动两个 int 类型的大小,即 8 个字节(假设 int 占 4 字节)
printf("The value at the new position is: %d\n", *ptr);

在这段代码中,ptr 最初指向 arr[0]ptr + 2 使 ptr 指向了 arr[2],所以输出为 3。 2. 指针与整数的减法:与加法类似,指针减去一个整数 n 会将指针向低地址方向移动 n * sizeof(type) 个字节。 3. 两个指针之间的减法:仅当两个指针指向同一数组的元素时,它们之间的减法才有意义。结果是两个指针之间的元素个数,而不是字节数。例如:

int arr[5] = {1, 2, 3, 4, 5};
int *ptr1 = &arr[0];
int *ptr2 = &arr[3];
int diff = ptr2 - ptr1;
printf("The number of elements between ptr1 and ptr2 is: %d\n", diff);

这里 ptr2 - ptr1 的结果为 3,表示 ptr2ptr1 之间有3个元素。

指针关系运算

指针之间可以进行关系运算,如 ==!=<> 等。但这些运算通常只在两个指针指向同一数组的元素时才有实际意义。例如:

int arr[5] = {1, 2, 3, 4, 5};
int *ptr1 = &arr[0];
int *ptr2 = &arr[2];

if (ptr1 < ptr2) {
    printf("ptr1 points to an element before ptr2\n");
}

在上述代码中,因为 ptr1 指向的元素在数组中的位置比 ptr2 靠前,所以 ptr1 < ptr2 的比较结果为真。

指针与数组的复杂关系

数组名作为指针

在C语言中,数组名在大多数情况下会被自动转换为指向数组首元素的指针。例如:

int arr[5] = {1, 2, 3, 4, 5};
int *ptr = arr;  // 这里 arr 被隐式转换为指向 arr[0] 的指针

通过指针访问数组元素与通过数组下标访问是等价的。即 arr[i] 等价于 *(arr + i),也等价于 *(ptr + i),其中 ptr 是指向数组首元素的指针。例如:

#include <stdio.h>

int main() {
    int arr[5] = {1, 2, 3, 4, 5};
    int *ptr = arr;

    for (int i = 0; i < 5; i++) {
        printf("arr[%d] = %d, *(arr + %d) = %d, *(ptr + %d) = %d\n", i, arr[i], i, *(arr + i), i, *(ptr + i));
    }

    return 0;
}

这段代码展示了三种访问数组元素方式的等价性。

指针数组与数组指针

  1. 指针数组:指针数组是一个数组,数组中的每个元素都是指针。其声明形式为 type *array_name[size];,例如 int *ptr_array[3]; 声明了一个包含3个指向 int 类型的指针的数组。指针数组常用于处理多个字符串,因为每个字符串可以用一个指针表示,然后将这些指针放入数组中。例如:
#include <stdio.h>

int main() {
    char *strings[3] = {"apple", "banana", "cherry"};

    for (int i = 0; i < 3; i++) {
        printf("strings[%d] = %s\n", i, strings[i]);
    }

    return 0;
}

在这个例子中,strings 是一个指针数组,每个元素都是一个指向 char 类型的指针,分别指向不同的字符串。 2. 数组指针:数组指针是一个指针,它指向一个数组。其声明形式为 type (*pointer_name)[size];,例如 int (*arr_ptr)[5]; 声明了一个指向包含5个 int 类型元素的数组的指针。数组指针常用于二维数组的处理。例如:

#include <stdio.h>

int main() {
    int arr[3][5] = {
        {1, 2, 3, 4, 5},
        {6, 7, 8, 9, 10},
        {11, 12, 13, 14, 15}
    };
    int (*arr_ptr)[5] = arr;

    for (int i = 0; i < 3; i++) {
        for (int j = 0; j < 5; j++) {
            printf("%d ", *(*(arr_ptr + i) + j));
        }
        printf("\n");
    }

    return 0;
}

在这段代码中,arr_ptr 是一个数组指针,*(*(arr_ptr + i) + j) 用于访问二维数组中的元素,arr_ptr + i 指向第 i 行数组,*(arr_ptr + i) 是指向该行首元素的指针,*(arr_ptr + i) + j 指向该行第 j 个元素,最后 *(*(arr_ptr + i) + j) 取出该元素的值。

指针在函数中的复杂应用

指针作为函数参数

指针作为函数参数是C语言中非常重要的特性,它允许函数修改调用者提供的变量的值,而不仅仅是处理变量的副本。例如:

#include <stdio.h>

void swap(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

int main() {
    int num1 = 5, num2 = 10;
    printf("Before swap: num1 = %d, num2 = %d\n", num1, num2);
    swap(&num1, &num2);
    printf("After swap: num1 = %d, num2 = %d\n", num1, num2);

    return 0;
}

swap 函数中,int *aint *b 是指向 int 类型的指针参数。通过解引用这些指针,函数可以直接修改调用者传入的变量的值。如果不使用指针作为参数,函数只能操作变量的副本,无法实现真正的交换。

函数指针

函数指针是一种指向函数的指针变量。它的声明形式为 return_type (*pointer_name)(parameter_list);,例如 int (*func_ptr)(int, int); 声明了一个指向返回 int 类型、接受两个 int 类型参数的函数的指针。函数指针常用于实现回调函数、函数表等功能。例如:

#include <stdio.h>

int add(int a, int b) {
    return a + b;
}

int subtract(int a, int b) {
    return a - b;
}

void calculate(int (*func)(int, int), int a, int b) {
    int result = func(a, b);
    printf("The result is: %d\n", result);
}

int main() {
    int num1 = 5, num2 = 3;
    calculate(add, num1, num2);
    calculate(subtract, num1, num2);

    return 0;
}

在这段代码中,calculate 函数接受一个函数指针 func 以及两个整数参数 ab。通过传递不同的函数指针(如 addsubtract),calculate 函数可以执行不同的计算操作。

动态内存分配与指针

malloc 与 free 的使用要点

在C语言中,malloc 函数用于在堆上动态分配内存,free 函数用于释放由 malloc 分配的内存。malloc 的原型为 void *malloc(size_t size);,它接受一个参数 size,表示要分配的字节数,并返回一个指向分配内存起始地址的 void * 类型指针。例如:

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

int main() {
    int *ptr = (int *)malloc(5 * sizeof(int));
    if (ptr == NULL) {
        printf("Memory allocation failed\n");
        return 1;
    }

    for (int i = 0; i < 5; i++) {
        ptr[i] = i * 2;
    }

    for (int i = 0; i < 5; i++) {
        printf("ptr[%d] = %d\n", i, ptr[i]);
    }

    free(ptr);
    ptr = NULL;

    return 0;
}

在上述代码中,(int *)malloc(5 * sizeof(int)) 分配了足够存储5个 int 类型数据的内存空间,并将返回的 void * 指针转换为 int * 类型。在使用完分配的内存后,必须调用 free(ptr) 释放内存,防止内存泄漏。并且在释放后,将 ptr 赋值为 NULL,避免成为野指针。

动态内存分配中的指针运算与边界问题

在动态分配的内存区域中使用指针运算时,同样要注意边界问题。由于动态分配的内存不像数组那样有固定的边界检查,一旦指针越界,可能会导致访问非法内存,引发未定义行为。例如:

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

int main() {
    int *ptr = (int *)malloc(3 * sizeof(int));
    if (ptr == NULL) {
        printf("Memory allocation failed\n");
        return 1;
    }

    // 正确访问
    for (int i = 0; i < 3; i++) {
        ptr[i] = i;
    }

    // 错误的越界访问
    // ptr[3] = 4;  // 这会导致未定义行为

    free(ptr);
    ptr = NULL;

    return 0;
}

在这个例子中,分配了能存储3个 int 类型数据的内存,但如果试图访问 ptr[3],就会越界,因为有效范围是 ptr[0]ptr[2]

多级指针

二级指针

二级指针是指向指针的指针。其声明形式为 type **pointer_name;,例如 int **ptr2; 声明了一个二级指针 ptr2,它指向一个指向 int 类型的指针。二级指针常用于处理指针数组或需要动态分配指针的情况。例如:

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

int main() {
    int num = 10;
    int *ptr = &num;
    int **ptr2 = &ptr;

    printf("The value of num is: %d\n", num);
    printf("The value through ptr is: %d\n", *ptr);
    printf("The value through ptr2 is: %d\n", **ptr2);

    // 动态分配指针数组
    int *arr[3];
    for (int i = 0; i < 3; i++) {
        arr[i] = (int *)malloc(sizeof(int));
        *arr[i] = i * 10;
    }

    int **ptr3 = arr;
    for (int i = 0; i < 3; i++) {
        printf("Value at arr[%d] is: %d\n", i, **(ptr3 + i));
        free(arr[i]);
    }

    return 0;
}

在这段代码中,首先展示了二级指针 ptr2 如何通过两次解引用访问到最终的值。然后通过动态分配指针数组,并使用二级指针 ptr3 来访问数组中的指针所指向的值。

多级指针的复杂应用场景

多级指针在更复杂的数据结构,如链表的链表、树结构等中有广泛应用。例如,在实现一个链表的链表时,每个节点的指针域可能是一个指针数组,而这个指针数组中的元素又指向其他链表节点。这就需要多级指针来正确处理和操作这些数据结构。例如:

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

// 定义链表节点结构
typedef struct Node {
    int data;
    struct Node *next;
} Node;

// 定义链表的链表节点结构
typedef struct ListNode {
    Node *list_head;
    struct ListNode *next;
} ListNode;

// 创建新的链表节点
Node *createNode(int data) {
    Node *new_node = (Node *)malloc(sizeof(Node));
    new_node->data = data;
    new_node->next = NULL;
    return new_node;
}

// 创建新的链表的链表节点
ListNode *createListNode() {
    ListNode *new_list_node = (ListNode *)malloc(sizeof(ListNode));
    new_list_node->list_head = NULL;
    new_list_node->next = NULL;
    return new_list_node;
}

// 将节点添加到链表中
void addNodeToList(Node **head, int data) {
    Node *new_node = createNode(data);
    if (*head == NULL) {
        *head = new_node;
    } else {
        Node *current = *head;
        while (current->next != NULL) {
            current = current->next;
        }
        current->next = new_node;
    }
}

// 将链表添加到链表的链表中
void addListToList(ListNode **list_head, Node *new_list_head) {
    ListNode *new_list_node = createListNode();
    new_list_node->list_head = new_list_head;
    if (*list_head == NULL) {
        *list_head = new_list_node;
    } else {
        ListNode *current = *list_head;
        while (current->next != NULL) {
            current = current->next;
        }
        current->next = new_list_node;
    }
}

// 打印链表
void printList(Node *head) {
    Node *current = head;
    while (current != NULL) {
        printf("%d -> ", current->data);
        current = current->next;
    }
    printf("NULL\n");
}

// 打印链表的链表
void printListOfLists(ListNode *list_head) {
    ListNode *current_list = list_head;
    while (current_list != NULL) {
        printf("List: ");
        printList(current_list->list_head);
        current_list = current_list->next;
    }
}

int main() {
    ListNode *list_of_lists = NULL;

    // 创建第一个链表
    Node *list1 = NULL;
    addNodeToList(&list1, 1);
    addNodeToList(&list1, 2);

    // 创建第二个链表
    Node *list2 = NULL;
    addNodeToList(&list2, 3);
    addNodeToList(&list2, 4);

    addListToList(&list_of_lists, list1);
    addListToList(&list_of_lists, list2);

    printListOfLists(list_of_lists);

    // 释放内存(这里省略具体实现,实际中需要编写释放链表和链表的链表内存的函数)

    return 0;
}

在这个例子中,addNodeToList 函数中的 Node **head 是二级指针,用于修改链表的头指针。addListToList 函数中的 ListNode **list_head 是二级指针,用于修改链表的链表的头指针。通过多级指针,我们可以灵活地构建和操作复杂的数据结构。

指针与结构体的深度结合

结构体中的指针成员

结构体可以包含指针成员,这在许多实际应用中非常有用。例如,在链表节点结构体中,通常会有一个指针成员指向下一个节点。例如:

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

// 定义链表节点结构体
typedef struct Node {
    int data;
    struct Node *next;
} Node;

// 创建新的链表节点
Node *createNode(int data) {
    Node *new_node = (Node *)malloc(sizeof(Node));
    new_node->data = data;
    new_node->next = NULL;
    return new_node;
}

// 打印链表
void printList(Node *head) {
    Node *current = head;
    while (current != NULL) {
        printf("%d -> ", current->data);
        current = current->next;
    }
    printf("NULL\n");
}

int main() {
    Node *head = createNode(1);
    Node *node2 = createNode(2);
    Node *node3 = createNode(3);

    head->next = node2;
    node2->next = node3;

    printList(head);

    // 释放内存(这里省略具体实现,实际中需要编写释放链表内存的函数)

    return 0;
}

在这个链表节点结构体 Node 中,next 是一个指向 Node 类型的指针,通过它可以将多个节点连接成链表。

指向结构体的指针

我们可以声明指向结构体的指针,通过这种指针来访问结构体成员。使用 -> 运算符可以方便地访问结构体指针所指向的结构体的成员。例如:

#include <stdio.h>

// 定义结构体
typedef struct {
    char name[20];
    int age;
} Person;

int main() {
    Person person1 = {"Alice", 25};
    Person *ptr = &person1;

    printf("Name: %s, Age: %d\n", ptr->name, ptr->age);

    return 0;
}

在这段代码中,ptr 是指向 Person 结构体的指针,通过 ptr->nameptr->age 可以访问结构体中的成员。这种方式在处理结构体数组或动态分配的结构体时非常方便,避免了每次访问成员时都要使用结构体变量名。

避免指针相关的错误

内存泄漏

内存泄漏是指针使用中常见的严重问题。当动态分配的内存不再被使用,但没有调用 free 函数释放时,就会发生内存泄漏。例如:

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

int main() {
    int *ptr = (int *)malloc(10 * sizeof(int));
    // 这里忘记调用 free(ptr)
    // 随着程序运行,这块内存将无法再被访问,导致内存泄漏
    return 0;
}

为了避免内存泄漏,在使用完动态分配的内存后,一定要记得调用 free 函数。并且,为了防止重复释放内存(这也会导致未定义行为),可以在释放内存后将指针赋值为 NULL。例如:

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

int main() {
    int *ptr = (int *)malloc(10 * sizeof(int));
    if (ptr != NULL) {
        // 使用内存
        free(ptr);
        ptr = NULL;
    }

    return 0;
}

悬空指针

悬空指针是指指针所指向的内存已经被释放,但指针仍然存在且指向已释放的内存。例如:

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

int main() {
    int *ptr = (int *)malloc(sizeof(int));
    *ptr = 10;
    free(ptr);
    // 这里 ptr 成为悬空指针
    // 如果再次使用 *ptr,会导致未定义行为
    // int value = *ptr;  // 这行代码是错误的

    return 0;
}

为了避免悬空指针,在释放内存后将指针赋值为 NULL 是一个好习惯。这样,当试图再次使用指针时,现代的调试工具通常会检测到对 NULL 指针的非法访问,从而更容易发现和修复问题。

野指针

野指针是未初始化的指针,如前文所述,访问野指针会导致未定义行为。为了避免野指针,在声明指针后,要么立即初始化它,要么将其赋值为 NULL。例如:

#include <stdio.h>

int main() {
    int *ptr = NULL;
    // 这里 ptr 初始化为 NULL,避免成为野指针
    // 在后续有合适的地址时再进行赋值
    int num = 5;
    ptr = &num;

    return 0;
}

通过这种方式,可以有效避免野指针带来的风险。在实际编程中,尤其是在复杂的代码逻辑中,要时刻注意指针的初始化和生命周期管理,以确保程序的正确性和稳定性。