首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >指针的终极奥义(4):手撕 qsort 源码,揭秘 C 语言通用排序的黑魔法!

指针的终极奥义(4):手撕 qsort 源码,揭秘 C 语言通用排序的黑魔法!

作者头像
Extreme35
发布2025-12-23 18:19:23
发布2025-12-23 18:19:23
170
举报
文章被收录于专栏:DLDL

在本篇文章中,我们将探讨 C 语言中一个强大而灵活的机制:回调函数。我们将从回调函数的基本概念入手,并通过一个实际例子——模拟实现标准库中的 qsort 排序函数(使用冒泡排序算法),来深入理解它的用法和 void* 指针的威力。

一、回调函数

1.1 概念

回调函数本质上是一个通过函数指针调用的函数

  • 如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,被调用的函数就是回调函数。回调函数不是由该函数的实现方直接调用,而是在特定的事件或条件发生时由另外的一方调用的,用于对该事件或条件进行响应。

核心思想: 将变化的部分(如不同数据的比较逻辑)从不变的部分(如排序算法的骨架)中分离出来。

1.2 qsort库函数

在 C 语言中,qsort 函数是一个非常重要的标准库函数,它使用快速排序算法来对数组进行排序。它的强大之处在于可以使用回调函数来定义任意类型的比较规则。 在通用排序中的作用: 它实现了 排序的通用逻辑,但它不知道如何比较不同类型的数据(如整数 vs. 结构体)。它通过接收一个比较函数指针(即回调函数)作为参数,将具体的比较任务回调给使用者来实现。

1.2.1 函数原型

qsort 函数定义在 <stdlib.h> 头文件中:

代码语言:javascript
复制
void qsort(void *base, size_t nmemb, size_t size, int (*compar)(const void *, const void *));
在这里插入图片描述
在这里插入图片描述
1.2.2 参数理解

参数 (Parameters)

解释 (Meaning)

void* base

指向待排序数组的起始地址。void* 表示可指向任何类型数据。

size_t num

数组中元素的数量。size_t 是一个无符号整型。

size_t size

数组中每个元素的大小(以字节为单位)。size_t 是一个无符号整型。

int (*compar)(const void*, const void*)

指向一个用于比较两个元素的函数(即回调函数)的指针。这个函数会被 qsort 重复调用。

回调函数 compar 的返回值要求

compar 函数定义了元素的排序顺序。它接收两个 const void* 指针作为参数。

返回值 (Return Value)

含义 (Meaning)

< 0

p1 所指向的元素应在 p2 所指向的元素之前。

0

p1 所指向的元素与 p2 所指向的元素等价。

> 0

p1 所指向的元素应在 p2 所指向的元素之后。

1.3 使用示例

这段代码演示了如何使用标准库 qsort 函数分别对整数数组、以及按结构体成员变量对结构体数组进行排序。

代码语言:javascript
复制
// 结构体定义
struct Stu //学生
{
    char name[20]; //名字
    int age; //年龄
};

// 1. 比较整数的回调函数 (int_cmp)
// 实现升序排序
int int_cmp(const void* p1, const void* p2)
{
    // 将void*指针强制转换为int*,然后解引用相减。
    return (*(int*)p1 - *(int*)p2);
}

// 2. 比较结构体年龄的回调函数 (cmp_stu_by_age)
// 实现按年龄升序排序
int cmp_stu_by_age(const void* e1, const void* e2)
{
    // 转换为struct Stu*,访问age成员后相减
    return ((struct Stu*)e1)->age - ((struct Stu*)e2)->age;
}

// 3. 比较结构体姓名的回调函数 (cmp_stu_by_name)
// 实现按姓名(字符串)升序排序
int cmp_stu_by_name(const void* e1, const void* e2)
{
    // 转换为struct Stu*,访问name成员,使用strcmp比较字符串大小
    return strcmp(((struct Stu*)e1)->name, ((struct Stu*)e2)->name);
}

// 打印结构体数组的辅助函数
void PrintStu(struct Stu s[], int sz)
{
    for (int i = 0; i < sz; i++)
    {
        printf("{%s, %d} ", s[i].name, s[i].age);
    }
    printf("\n");
}

// 测试整数排序
void test_int_sort()
{
    int arr[] = { 1, 23, 15, 73, 29, 72, 24, 6, 8, 90 };
    int sz = sizeof(arr) / sizeof(arr[0]);

    printf("Original array: ");
    for (int i = 0; i < sz; i++) {
        printf("%d ", arr[i]);
    }
    printf("\n");

    // 调用 qsort 排序整数
    qsort(arr, sz, sizeof(int), int_cmp);

    printf("Sorted array (int): ");
    for (int i = 0; i < sz; i++)
    {
        printf("%d ", arr[i]);
    }
    printf("\n\n");
}

// 测试结构体排序
void test_struct_sort()
{
    struct Stu s[] = { {"zhangsan", 20}, {"lisi", 30}, {"wangwu", 15} };
    int sz = sizeof(s) / sizeof(s[0]);

    printf("Original Structs: ");
    PrintStu(s, sz);

    // 1. 按年龄排序
    // 注意:qsort是in-place排序,会修改原数组s
    qsort(s, sz, sizeof(s[0]), cmp_stu_by_age);
    printf("Sorted Structs (by age): ");
    PrintStu(s, sz);

    // 2. 按姓名排序 (需要重新初始化或使用一个新数组进行演示)
    struct Stu s_name[] = { {"extreme35", 20}, {"jack", 21}, {"daiwei", 17} };
    printf("Original Structs (for name sort): ");
    PrintStu(s_name, sz);

    qsort(s_name, sz, sizeof(s_name[0]), cmp_stu_by_name);
    printf("Sorted Structs (by name): ");
    PrintStu(s_name, sz);
    printf("\n");
}

结果展示:

在这里插入图片描述
在这里插入图片描述

运行结果有力地证明了回调函数机制在实现通用排序中的作用:

  • 代码通用性: 无论是排序基本数据类型(int)还是复杂数据类型(struct Stu),qsort(或其模拟实现)的底层排序逻辑保持不变。
  • 灵活性: 通过简单地更换传入的回调函数,即可实现不同的排序规则——例如从“按年龄排序”切换到“按姓名排序”。
  • 指针应用: 成功的运行结果体现了 void* 指针在 C 语言中处理异构数据和实现通用接口的强大能力。

二、冒泡排序

冒泡排序是一种重复遍历待排序列表,比较相邻元素并根据需要交换位置,直到所有元素都按指定顺序排列好的排序方法。

核心思想:

  1. 它重复地走访过要排序的数列,一次比较两个相邻的元素。
  2. 如果它们的顺序错误(例如,在升序中前一个大于后一个),就交换它们的位置。
  3. 每一次遍历(趟)都会将当次循环中最大(或最小)的元素“冒”到数组的末尾(或开头),就像水中的气泡上升一样。
  4. n 个元素的数组需要进行至多 n-1 趟遍历。

C语言经典实现:

代码语言:javascript
复制
void bubble_sort(int* arr, int n) 
{
    for (int i = 0; i < n - 1; i++) 
    {
        // 最后i个元素已经排好序
        for (int j = 0; j < n - 1 - i; j++) 
        {
            if (arr[j] > arr[j + 1]) 
            {
                // 交换元素
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
}

这里还可以进行优化,核心优化思路是通过引入交换标志位来检测单轮遍历中是否发生元素交换,如果某一轮没有发生任何交换,说明数组已经有序,即可提前终止排序过程。这里就不写了,重点在模拟实现回调函数qsort结果展示:

在这里插入图片描述
在这里插入图片描述

动图演示:

在这里插入图片描述
在这里插入图片描述

三、回调函数实现通用冒泡排序

现在来到本文最核心部分:让冒泡排序能够像 qsort 一样排序任意类型的数据,必须将上述代码中的数据访问、比较和交换操作抽象化

3.1 核心代码结构

为了便于实现,把最普通的冒泡排序参数也修改到与qsort库函数一致,并且将原来写死的部分进行抽象成普遍适用的。

代码语言:javascript
复制
void bubble_sort_qsort(void* base, size_t num, size_t size, int (*compar)(const void*, const void*))

3.2 关键抽象步骤

3.2.1 抽象一:通用函数签名

这是实现通用性的基础,即函数必须能够接收和处理任何类型的数据。之前只是接收了整型数组,现在我们不仅要接收内置数据类型的数组,还要接收用户千姿百态的自定义结构体数据类型,那将参数类型可以修改为接受任何数据类型的指针就可以了,为了与库函数保持一致,也顺便将元素个数与元素大小直接定义为size_t类型。 关键参数:

参数

作用

抽象原理

void* base

数组的起始地址。

void* 是 C 语言中"万能"的指针类型,它可以指向任何数据类型的地址。通用函数不知道要排序的是整数还是结构体,因此使用 void* 来接收一切数据地址。

size_t size

数组中每个元素占据的字节大小。

void* 失去了类型信息,也就失去了"步长"信息。size 参数告诉通用函数:一个元素占多少字节,这是计算下一个元素地址所必需的。

3.2.2 抽象二:通用元素寻址与访问

这一步需要保证通过通用排序函数需要能够定位数组中的任意第

j

个元素,这需要克服 void* 不能直接进行指针算术运算的问题。 寻址原理:

  1. 为什么不能直接 base + j? 如果 base 是 void*,编译器不知道一个元素占多少字节,无法确定 base + 1 应该跳过多少内存。
  2. 如何解决?void* 强制转换为 char*char 在 C 语言中总是占用 1 个字节。
  3. 计算偏移: 一旦转换为 char*,每移动一步(加 1),就精确地移动 1 个字节。要找到第 j 个元素,需要跳过 j 个元素,总共移动的字节数是:
\text{偏移量} = j \times \text{size}

这里为什么不转成别的,可以假设一下,如果转换为比内存存储中最小单位的类型,那每一次跳过字节数的时候,就不能保证一个一个字节去比较了,也就达不到一开始期望这个函数实现的功能了。

有了上述理解后,在函数内部就可以进行具体实现了,参考代码如下:

代码语言:javascript
复制
void* current_ele = (char*)base + j * size;
void* next_ele = (char*)base + (j + 1) * size;

// 示例:如果 size=4(int),j=2,则 (char*)base + 2 * 4,即跳过 8 个字节找到第三个元素。
// 示例:如果 size=32(struct),j=1,则 (char*)base + 1 * 32,即跳过 32 个字节找到第二个元素。
3.2.3 抽象三:通用比较逻辑

这是通过回调函数实现的抽象,将“如何比较”的决定权交给使用者。

通用排序函数只负责冒泡排序的循环和交换逻辑。每当需要比较两个相邻元素时,它就调用 compar 函数:

  1. bubble_sort_gsort 传入两个元素的地址(仍是 void*)。
  2. compar 函数接收这两个 void* 指针。
  3. compar 函数内部,使用者将 void* 强制转换为原始数据类型(例如 int*struct Student*),然后执行特定于该类型的比较操作(例如相减或调用 strcmp)。
  4. compar 返回比较结果(负数、零、正数),与库函数一致,这里参考上面库函数的返回值含义进行实现即可。
3.2.4 抽象四:通用交换操作

这一步应该是整个函数实现最难的部分了,由于元素可能是任何类型(从 1 字节的 char 到几百字节的复杂结构体),交换操作必须是通用的。

可以先进行简单思考一下:

  1. 抽象三的比较逻辑都是从一个字节一个字节开始的,那就大胆假设这里实现也是按照字节进行交换。
  2. 我们还有个参数一直没用呢,既然给它传进去,那就一定是起到作用的,通过库函数参数含义可以知道这是每个元素大小的参数,那它为什么给出每个元素参数大小呢?再次大胆假设,这不就是按字节交换的边界条件吗,代码不对大不了删了重写,电脑炸不了的。

OK,有了这些大胆的假设,现在开始上手: swap 函数原理 交换函数 swap(或 _swap)接收两个元素的地址(p1,p2)和元素大小(size):

  1. 它将 void* 转换为 char*
  2. 它启动一个循环,从 0 到 size - 1
  3. 在循环内,它逐个字节地将 p1 和 p2 指向的内存内容进行交换。
代码语言:javascript
复制
void swap(void* p1, void* p2, size_t size)
{
	for (int i = 0; i < size; i++)
	{
        // 1. 读取 p1 的第 i 个字节到临时变量 temp
		char temp = *((char*)p1 + i);
        // 2. 将 p2 的第 i 个字节写入 p1 的第 i 个位置
		*((char*)p1 + i) = *((char*)p2 + i);
        // 3. 将临时变量 temp 写入 p2 的第 i 个位置
		*((char*)p2 + i) = temp;
	}
}
// 通过这个循环,无论元素是 int (size=4) 还是 struct (size=N),都能被完整地交换。

3.3 模拟实现qsort源码

直到这里,我们已经实现完所有的抽象,现在可以去进行测试了,这里给出所有抽象的源码:

代码语言:javascript
复制
//通用交换函数
void swap(void* p1, void* p2, size_t size)
{
    for (int i = 0; i < size; i++)
    {
        char temp = *((char*)p1 + i);
        *((char*)p1 + i) = *((char*)p2 + i);
        *((char*)p2 + i) = temp;
    }
}
//通用排序函数
void bubble_sort_qsort(void* base, size_t num, size_t size, int (*compar)(const void*, const void*)) // PDF 中为 bubble 函数
{
    int i = 0;
    for (i = 0; i < num - 1; i++) // 外层循环:趟数
    {
        int j = 0;
        for (j = 0; j < num - 1 - i; j++) // 内层循环:比较未归位元素
        {
            // 元素寻址
            void* current_ele = (char*)base + j * size;
            void* next_ele = (char*)base + (j + 1) * size;

            // 调用回调函数进行比较
            if (compar(current_ele, next_ele) > 0)
            {
                // 执行通用交换
                swap(current_ele, next_ele, size);
            }
        }
    }
}

这里只进行函数测试,那么为了验证函数逻辑是否正确,我们使用已经正确执行的比较函数(免得比较函数写错,半天以为自己函数实现错了),那就继续沿用使用qsort函数的示例,只需要把测试函数里的qsort换成我们自己实现的就好了:

代码语言:javascript
复制
bubble_sort_qsort(arr, sz, sizeof(int), int_cmp);
bubble_sort_qsort(s, sz, sizeof(s[0]), cmp_stu_by_age);
bubble_sort_qsort(s_name, sz, sizeof(s_name[0]), cmp_stu_by_name);

结果展示

在这里插入图片描述
在这里插入图片描述

这里可以看见跟之前调用qsort函数实现的效果一致,那就证明我们的大胆假设是对的。

4. 总结与展望

本文通过对 qsort 库函数的学习和对 bubble_sort_qsort 的模拟实现,系统性地掌握了 C 语言中实现代码通用性的核心手段:回调函数void* 指针

4.1 核心成果回顾

我们成功地将排序算法(冒泡排序)与数据类型细节(intstruct Stu)彻底解耦,这依赖于四个关键抽象的协同工作:

  1. 通用函数签名void*size_t):使得排序函数可以接受任何数据的起始地址和大小。
  2. 通用元素寻址:利用 (char*)base + j * size 的方式,实现了精确的、按字节的内存地址定位。
  3. 通用比较逻辑:将数据比较这一核心变化点完全外包给 compar 回调函数,实现了排序规则的自定义。
  4. 通用交换操作swap 函数通过逐字节(char*)操作,保证了无论元素是 4 字节还是 32 字节,都能正确、安全地完成数据交换。

4.2 最终运行结果的验证

运行结果有力地证明了该模拟实现的正确性与通用性:

  • 对整数数组的排序,结果符合数值升序。
  • 对结构体数组的排序,通过切换回调函数,分别实现了按年龄升序和按姓名(字典序)升序,无一错误。

这套机制的成功运行,不仅是 C 语言指针操作的胜利,更是函数式编程思维在过程语言中的体现:将行为(比较)作为参数传递给逻辑(排序),极大地提升了代码的复用性和可扩展性。

掌握了 qsort 的原理,就掌握了 C 语言库函数设计中的精髓。在未来的编程实践中,可以用相同的思路,实现通用的查找函数、遍历函数等高级工具。

本文参与 腾讯云自媒体同步曝光计划,分享自作者个人站点/博客。
原始发表:2025-11-18,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、回调函数
    • 1.1 概念
    • 1.2 qsort库函数
      • 1.2.1 函数原型
      • 1.2.2 参数理解
    • 1.3 使用示例
  • 二、冒泡排序
  • 三、回调函数实现通用冒泡排序
    • 3.1 核心代码结构
    • 3.2 关键抽象步骤
      • 3.2.1 抽象一:通用函数签名
      • 3.2.2 抽象二:通用元素寻址与访问
      • 3.2.3 抽象三:通用比较逻辑
      • 3.2.4 抽象四:通用交换操作
    • 3.3 模拟实现qsort源码
  • 4. 总结与展望
    • 4.1 核心成果回顾
    • 4.2 最终运行结果的验证
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档