指针进阶

内容简介

指针进阶关注的是工程里真正会用到的指针能力:数组接口、输出参数、动态内存、函数指针、const 指针、void* 通用接口,以及如何把“地址”变成可维护的函数边界。

本篇不追求展示指针的所有规则细节。数组退化例外、尾后指针、严格别名、对齐、&arrarr 的类型差异、复杂声明阅读等内容,放在 C语言指针细节。那些内容对理解 C 很有帮助,但多数不应该成为日常工程代码的主要写法。

1. 指针参数的基本工程模型

函数参数使用指针,通常表达三种意图:

  • 输入:函数只读取调用者传入的数据。
  • 输出:函数通过指针把结果写回调用者。
  • 输入输出:函数既读取旧值,也修改它。

输入参数:只读数据用 const

#include <stddef.h>
 
int sum_array(const int *data, size_t count)
{
    int sum = 0;
 
    if (data == NULL) {
        return 0;
    }
 
    for (size_t i = 0; i < count; ++i) {
        sum += data[i];
    }
 
    return sum;
}

const int *data 表示函数不会通过 data 修改数组内容。这个约束很有价值:调用者能放心传入只读数据,编译器也能帮你发现误改。

输出参数:让函数写回一个结果

int parse_int(const char *text, int *out_value)
{
    int value = 0;
 
    if (text == NULL || out_value == NULL) {
        return 0;
    }
 
    for (size_t i = 0; text[i] != '\0'; ++i) {
        if (text[i] < '0' || text[i] > '9') {
            return 0;
        }
 
        value = value * 10 + (text[i] - '0');
    }
 
    *out_value = value;
    return 1;
}

调用方式:

int value = 0;
 
if (parse_int("123", &value)) {
    printf("%d\n", value);
}

输出参数常和返回值配合使用:返回值表示成功或失败,输出参数保存真正的结果。

输入输出参数:修改调用者已有对象

void clamp(int *value, int low, int high)
{
    if (value == NULL) {
        return;
    }
 
    if (*value < low) {
        *value = low;
    } else if (*value > high) {
        *value = high;
    }
}

这类函数要特别注意命名。const int * 通常暗示只读,int * 通常暗示可能修改。

2. 数组接口:指针加长度

C 函数拿不到数组本身,常规工程写法是显式传入“首元素指针 + 元素个数”。

void print_array(const int *data, size_t count)
{
    if (data == NULL) {
        return;
    }
 
    for (size_t i = 0; i < count; ++i) {
        printf("%d\n", data[i]);
    }
}

调用:

int values[] = {10, 20, 30};
print_array(values, sizeof(values) / sizeof(values[0]));

在函数内部不要用 sizeof(data) 计算数组长度。函数参数里的 data 是指针,sizeof(data) 得到的是指针大小,不是数组总大小。

修改数组内容

void fill_array(int *data, size_t count, int value)
{
    if (data == NULL) {
        return;
    }
 
    for (size_t i = 0; i < count; ++i) {
        data[i] = value;
    }
}

只读数组用 const int *,可写数组用 int *。这是接口语义的一部分,不只是语法细节。

缓冲区接口:字节数要说清楚

处理内存缓冲区时,size 通常表示字节数,count 通常表示元素个数。不要混用。

#include <stddef.h>
 
void zero_buffer(void *buffer, size_t size)
{
    unsigned char *bytes = buffer;
 
    if (bytes == NULL) {
        return;
    }
 
    for (size_t i = 0; i < size; ++i) {
        bytes[i] = 0;
    }
}

推荐命名:

  • data, count:元素指针和元素个数。
  • buffer, size:字节缓冲区和字节数。
  • items, item_count:对象数组和对象数量。

3. 二级指针:修改调用者的指针

二级指针最常见的工程用途是“函数内部分配资源,然后把指针交给调用者”。

#include <stdlib.h>
 
int create_int(int **out)
{
    int *p = NULL;
 
    if (out == NULL) {
        return 0;
    }
 
    p = malloc(sizeof(*p));
    if (p == NULL) {
        *out = NULL;
        return 0;
    }
 
    *p = 10;
    *out = p;
    return 1;
}

调用:

int *p = NULL;
 
if (create_int(&p)) {
    printf("%d\n", *p);
    free(p);
    p = NULL;
}

这里 out 是“调用者的指针变量的地址”。函数通过 *out = p; 修改调用者手里的指针。

用结构体降低二级指针的混乱

如果要返回的不只是一个指针,还包括长度、容量或状态,优先用结构体承载。

typedef struct IntBuffer {
    int *data;
    size_t count;
} IntBuffer;
 
int create_buffer(size_t count, IntBuffer *out)
{
    if (out == NULL) {
        return 0;
    }
 
    out->data = calloc(count, sizeof(out->data[0]));
    if (out->data == NULL) {
        out->count = 0;
        return 0;
    }
 
    out->count = count;
    return 1;
}
 
void destroy_buffer(IntBuffer *buffer)
{
    if (buffer == NULL) {
        return;
    }
 
    free(buffer->data);
    buffer->data = NULL;
    buffer->count = 0;
}

这种写法比到处传 int ** 更容易维护。

4. 指针数组和数组指针

这两个名字相似,但工程含义完全不同。

指针数组:数组里放多个指针

const char *names[] = {"tom", "jerry", "alice"};
 
for (size_t i = 0; i < 3; ++i) {
    printf("%s\n", names[i]);
}

names 是数组,数组元素是 const char *。它常用于字符串列表、命令表、配置项表。

数组指针:指向整个数组

void print4(const int (*arr)[4])
{
    if (arr == NULL) {
        return;
    }
 
    for (size_t i = 0; i < 4; ++i) {
        printf("%d\n", (*arr)[i]);
    }
}
 
int values[4] = {1, 2, 3, 4};
print4(&values);

int (*arr)[4] 表示“指向 4 个 int 的数组的指针”。这种写法适合表达固定长度约束,但日常接口更常用“指针 + 长度”。

5. 二维数组接口

普通二维数组不是 int **。如果数据是连续的二维数组,函数参数需要知道列数。

void print_matrix(size_t rows, size_t cols, const int matrix[rows][cols])
{
    for (size_t r = 0; r < rows; ++r) {
        for (size_t c = 0; c < cols; ++c) {
            printf("%d ", matrix[r][c]);
        }
        puts("");
    }
}

调用:

int matrix[2][3] = {
    {1, 2, 3},
    {4, 5, 6}
};
 
print_matrix(2, 3, matrix);

如果项目不使用 C99 变长数组参数,可以把二维数据摊平成一维数组:

int get_cell(const int *data, size_t cols, size_t row, size_t col)
{
    return data[row * cols + col];
}

这种写法在图像处理、矩阵计算、网络缓冲区解析里很常见。

6. 函数指针和回调

函数指针用于把“要执行的动作”作为参数传给另一个函数。

int greater_than(int a, int b)
{
    return a > b;
}
 
int find_first(const int *data, size_t count, int (*pred)(int, int), int target)
{
    if (data == NULL || pred == NULL) {
        return -1;
    }
 
    for (size_t i = 0; i < count; ++i) {
        if (pred(data[i], target)) {
            return (int)i;
        }
    }
 
    return -1;
}

调用:

int values[] = {1, 3, 5, 7};
int index = find_first(values, 4, greater_than, 4);
printf("%d\n", index);  // 2

函数指针常用于:

  • 排序比较函数。
  • 事件回调。
  • 驱动层操作表。
  • 状态机处理函数表。

操作表示例

typedef int (*BinaryOp)(int, int);
 
typedef struct Command {
    const char *name;
    BinaryOp op;
} Command;
 
int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
 
Command commands[] = {
    {"add", add},
    {"sub", sub}
};

C++ 工程中,普通业务回调优先考虑 lambda、函数对象或 std::function。只有和 C 接口交互、需要固定 ABI 或追求极低开销时,函数指针才是首选。

7. void*:通用接口的代价

void* 可以保存任意对象地址,但它不保存类型、长度和所有权信息。使用它时必须额外传入这些信息。

void swap_bytes(void *lhs, void *rhs, size_t size)
{
    unsigned char *a = lhs;
    unsigned char *b = rhs;
 
    if (a == NULL || b == NULL) {
        return;
    }
 
    for (size_t i = 0; i < size; ++i) {
        unsigned char tmp = a[i];
        a[i] = b[i];
        b[i] = tmp;
    }
}

调用:

int x = 10;
int y = 20;
 
swap_bytes(&x, &y, sizeof(x));

void* 适合写底层通用工具,但会牺牲类型检查。业务代码里不要为了“通用”滥用 void*

8. const 指针快速判断

int a = 10;
int b = 20;
 
const int *p1 = &a;        // 不能通过 p1 改 a,p1 可以指向 b
p1 = &b;
 
int *const p2 = &a;        // p2 不能改指向,可以通过 p2 改 a
*p2 = 30;
 
const int *const p3 = &a;  // 既不能通过 p3 改值,也不能改 p3 指向

阅读方法:从变量名开始向外读。p2 先遇到 const,所以指针本身不可改;p1 指向的是 const int,所以不能通过它改值。

详细规则见 CandCppconst

最佳代码实践

  • 指针参数只读时写成 const T *
  • 数组参数总是额外传长度,不在函数内部用 sizeof(pointer) 猜长度。
  • 输出参数先检查是否为 NULL,失败时尽量给输出参数写入安全状态。
  • 动态资源用成对函数管理,例如 create_xxx / destroy_xxx
  • 复杂数据返回优先用结构体,不要滥用多层指针。
  • void* 接口必须同时说明类型约定、元素大小、元素个数或字节数。
  • C++ 新代码优先用引用、std::spanstd::vector、智能指针和 RAII;裸指针主要用于非拥有关系、C 接口和底层边界。

常见错误

void clear(int *data)
{
    memset(data, 0, sizeof(data));  // 错误:sizeof(data) 是指针大小
}
int *bad(void)
{
    int value = 10;
    return &value;  // 错误:返回局部变量地址
}
void print_value(void *raw)
{
    printf("%d\n", *raw);  // 错误:void* 不能直接解引用
}
void set_first(int **matrix)
{
    matrix[0][0] = 1;
}
 
int matrix[3][4];
set_first(matrix);  // 错误:普通二维数组不是 int**

注意事项

  • “能用指针写出来”不等于“应该用指针写出来”。
  • 工程接口优先表达清楚所有权、长度、是否可写和失败行为。
  • 指针是否为 NULL 只是安全条件之一,生命周期、范围、类型兼容同样重要。
  • 更细的 C 指针规则继续看 C语言指针细节;资源所有权继续看 C语言MallocFree所有权模型

学习路径