最近我在把 MoonBit 和 C 语言放在一起做对照实验,快速排序是我首选的测试用例。倒不是因为快排本身多新鲜——它经典得不能再经典了——而是因为同一个算法用两门语言各写一遍,语言的设计哲学、内存模型、类型系统的差异会特别直观地暴露出来。这篇文章既适合想上手 MoonBit 的读者,也适合还在用 C 语言刷算法题、想回头看看"另一种写法长什么样"的朋友。我会把两个版本的完整代码贴出来,也会聊一聊我在编译、调试、测试过程中真实踩过的坑,尽量让这篇文章读起来像旁边坐了个老哥在跟你讲他刚做完的实验,而不是教科书。
1. 为什么我把同一个排序算法写成了两遍
先说背景。我最近在评估一个工具链:底层核心逻辑要用 C 写,最终又要部署到一个对体积和启动速度都比较敏感的环境里,所以我在找一门既能产出高效代码、又比 C 更省心的"新语言"来写应用层。MoonBit 就是在这个背景下进入视野的——它的定位很明确,面向 WebAssembly 场景,语法风格上吸收了函数式语言的不少优点,但又不是 Haskell 那种一上来就让人挠头的存在。
选快速排序作为对照实验,是因为它同时考验了几件最基础的事:数组下标访问、元素交换、递归、循环、函数参数传递。这些听着简单,但两门语言在这里的态度完全不同。C 的做法是"我全都交给你,你自己看着办";MoonBit 的做法是"编译器帮你盯着,但整体操作空间还是很大的"。这种差异,只有代码并排放在一起的时候才看得清楚。
另外我发现一个有意思的现象:网上一搜"快速排序",十有八九是 C 语言版本,各种写法铺天盖地;搜 MoonBit 的排序教程,资料就比较少了。作为最早吃螃蟹的一批人,我把两个版本放在一起做对比,既是给自己留个笔记,也给后来者铺条路。接下来的内容不会讲太高深的理论,快排的核心思想就是分治:选一个基准值,把数组切分成左小右大两块,然后递归处理左右两侧。难点从来不在思想上,而在"怎么写才不会越界、不会死递归、不会在重复元素上翻车"。
2. 先看完整代码:两个版本的快速排序
为了公平对比,两个版本我统一采用 Lomuto 分区方案。Lomuto 分区的思路是:以最右边的元素作为基准,用一个慢指针 i 标记"已经处理好的小于基准的区域边界",然后 j 从头扫到尾,遇到比基准小的元素就跟 i+1 位置交换。这个方案不是交换次数最少的,但它的边界条件最直白,作为教学和对比实验都更合适。Hoare 分区那些更绕的细节,我放到后面单独说。
2.1 C 语言版本
#include <stdio.h> static void swap(int* a, int* b) { int tmp = *a; *a = *b; *b = tmp; } static int partition(int arr[], int lo, int hi) { int pivot = arr[hi]; int i = lo - 1; for (int j = lo; j < hi; j++) { if (arr[j] <= pivot) { i++; swap(&arr[i], &arr[j]); } } swap(&arr[i + 1], &arr[hi]); return i + 1; } void quicksort(int arr[], int lo, int hi) { if (lo >= hi) { return; } int p = partition(arr, lo, hi); quicksort(arr, lo, p - 1); quicksort(arr, p + 1, hi); } int main(void) { int arr[] = {9, 3, 7, 1, 5, 8, 2, 6, 4}; int n = sizeof(arr) / sizeof(arr[0]); quicksort(arr, 0, n - 1); for (int i = 0; i < n; i++) { printf("%d ", arr[i]); } printf("\n"); return 0; }这段代码我尽量写得朴素:没有花里胡哨的优化,就是最基础的递归快排。partition里那个i++是 Lomuto 分区的精髓,它代表的是"比 pivot 小的区域又扩大了一格"。如果有人刚开始学 C 语言快排,我建议先把这个版本吃透,再去折腾三数取中、插入排序混排那些进阶玩法。
2.2 MoonBit 版本
fn swap(arr: Array[Int], i: Int, j: Int) -> Unit { let tmp = arr[i] arr[i] = arr[j] arr[j] = tmp } fn partition(arr: Array[Int], lo: Int, hi: Int) -> Int { let pivot = arr[hi] var i = lo - 1 for j = lo; j < hi; j = j + 1 { if arr[j] <= pivot { i = i + 1 swap(arr, i, j) } } swap(arr, i + 1, hi) return i + 1 } fn quicksort(arr: Array[Int], lo: Int, hi: Int) -> Unit { if lo >= hi { return } let p = partition(arr, lo, hi) quicksort(arr, lo, p - 1) quicksort(arr, p + 1, hi) }MoonBit 版本的整体结构和 C 版本几乎一一对应,这就是我想要的对比效果。你不需要懂 MoonBit 语法也能猜出大概逻辑:fn定义函数,let声明不可变绑定,var声明可变变量,Array[Int]表示整数数组,返回值类型写在->后面。数组下标访问用的是arr[i],跟 C 一样,没有那些奇怪的操作符。
两段代码放在一起,你第一眼会注意到什么?我当时的感受是:MoonBit 的代码长得更"整齐"。这种整齐不是审美问题,而是语言设计带来的——每个函数的类型签名都是明确标出来的,编译器在编译期就能做很多安全检查,后面我会逐个点说清楚。
3. 代码逐段拆解:同一个算法,两套思维
这一节是全文的核心,我把两个版本拆开,按"函数签名、交换元素、循环语法、越界处理、泛型能力"几个维度逐项对比。每个维度都在回答同一个问题:同样的逻辑,为什么两门语言表达方式不同?
3.1 函数签名:显式类型不是啰嗦,而是约束
C 语言的函数签名是void quicksort(int arr[], int lo, int hi)。这里有个 C 语言的经典大坑:arr作为函数参数根本不是数组,它会在编译时退化成指针。你在quicksort函数内部写sizeof(arr),得到的不是数组的字节数,而是指针的字节数——64 位机器上通常是 8。所以我在main函数里用int n = sizeof(arr) / sizeof(arr[0])算出数组长度,然后手动把n - 1传给quicksort。也就是说,C 语言里"数组的长度"和"数组的首地址"是分离的,你必须自己把长度传进去,否则函数根本不知道数组有多长。
MoonBit 的写法是fn quicksort(arr: Array[Int], lo: Int, hi: Int) -> Unit。Array[Int]这个类型本身就携带了长度和元素类型信息,虽然我仍然把lo、hi传进去,但类型系统能够确保传进来的确实是Int,而不是一个因为void*强转导致的"四不像"。对于写惯了 C 的人,这种约束初看有点多余,但当你面对一个几千行的项目时,编译器帮你挡掉的恰恰是那些最隐蔽的类型错误。
3.2 交换元素:一条 let 绑定胜过临时变量
交换两个数组元素,C 语言的做法是:
int tmp = *a; *a = *b; *b = tmp;MoonBit 的版本是:
let tmp = arr[i] arr[i] = arr[j] arr[j] = tmp看起来好像只是语法换了个壳,但这里藏着一个关键差异:C 语言里的int tmp是一个可以随时被改写的变量,而 MoonBit 的let tmp是不可变绑定。什么意思呢?在 MoonBit 里,tmp在绑定之后就不允许再被赋值了。对swap这个函数来说,这个特性特别合理——tmp在逻辑上就应该只是个临时保存的值,不应该被后续代码误改。你在 C 语言里当然也可以写const int tmp,但那是开发者自觉遵守约定,而 MoonBit 是编译器的强制规则。一旦你写了tmp = 100,编译器直接报错。
这种差异本质上反映了两种语言对待变量的态度:C 语言假定开发者知道自己在干什么,所以所有变量默认都可变;MoonBit 假定开发者偶尔会犯错,所以默认不可变,只有当你明确写var的时候才允许修改。写快排的partition函数时,var i = lo - 1就属于必须可变的场景——因为它在循环里要不断自增记录边界位置。
3.3 循环语法的细节差异
C 语言的循环是for (int j = lo; j < hi; j++)。j++是后置自增表达式,意思是"使用 j 的当前值,然后 j 加一"。这个语法糖用起来很爽,但也坑过不少人——比如有人分不清j++和++j的区别,或者在一个表达式里多次使用带副作用的j++,产生未定义行为。
MoonBit 的循环写法则不一样,它长这样:
for j = lo; j < hi; j = j + 1 {你看,没有j++,也没有++j,只有j = j + 1。我第一次写 MoonBit 代码时下意识打了j++,结果编译器直接报错,我的感觉是:这才是对的安全性设计。j++作为一个表达式,在数学意义上是含糊的——它既产生了值又修改了状态,人类阅读代码时很容易产生认知负担。而j = j + 1是纯粹的单步状态推进,语义一目了然。虽然写起来多敲了几个字符,但换来的是代码的确定性。
另外注意 C 语言的循环变量int j是定义在for语句内部的,它的作用域只在循环里;MoonBit 的j也类似,但如果你试图在循环体外访问它,编译器也会拦下来。这种局部变量的作用域把控,两门语言做得都不差,只是 C 语言在某些编译器实现下可能有历史遗留的怪癖,不展开说了。
3.4 越界与未定义行为:一个静默爆炸,一个大声报警
C 语言的数组越界是一个老生常谈但永远值得谈的话题。在partition函数里,当lo = 0时,i初始化成了-1;如果循环第一轮就命中arr[j] <= pivot,那么就会执行swap(&arr[0], &arr[j]),这个没事。但你想想,如果我在代码里不小心把i++放在了if外面,那么当j = lo时,i会从-1变成0,依然没问题——可是当j向前移动时,i可能会超过它应该待的区域位置。这种错误在 C 语言里不会立刻崩溃,它可能在某次运行中恰好工作,换个数组长度、换个数据布局才爆发。一旦爆发,排查起来非常痛苦:是swap写错了?还是partition区间算错了?还是调用方传参传错了?
MoonBit 的数组越界检查就友好得多。虽然 MoonBit 为了性能允许你在某些模式下关闭部分检查,但在开发和调试阶段,越界访问会直接触发运行时错误,并给出明确的堆栈信息。那个错误信息会直接告诉你"我访问了下标 -1"或者"我访问了下标 9,但数组长度是 9"。光凭这一条,MoonBit 在开发效率上就能省掉不少"用 coredump 慢慢找"的时间。当然,这不是说 MoonBit 可以完全避开越界——它仍然是运行时才能发现的错误,但至少它不会像 C 那样静默损坏内存,把问题留到完全无关的地方才暴露。
3.5 从 int 到任意类型:泛型能力的差距
上面的代码都是针对Int数组写的。实际业务里哪有那么多恰好都是 int 的数组?如果要对一个String数组排序,C 语言多半会求助于qsort,那画风是这样的:
#include <stdlib.h> int cmp_str(const void* a, const void* b) { return strcmp(*(const char**)a, *(const char**)b); } qsort(arr, n, sizeof(char*), cmp_str);qsort的签名是void qsort(void* base, size_t nmemb, size_t size, int (*compar)(const void*, const void*))。你必须在调用时传入数组首地址、元素个数、每个元素的大小、比较回调函数,任何一个参数写错都是未定义行为。void*的存在意味着编译器几乎放弃了类型检查,回调里const void*到const char**的转换完全靠开发者的自觉。这是 C 语言实现泛型的方式——用内存字节做抽象,灵活到极致,也危险到极致。
MoonBit 的泛型方案更接近现代语言。我可以把快排函数改成泛型版本:
fn quicksort_generic[T : Compare](arr: Array[T], lo: Int, hi: Int) -> Unit { if lo >= hi { return } let p = partition_generic(arr, lo, hi) quicksort_generic(arr, lo, p - 1) quicksort_generic(arr, p + 1, hi) }这里的[T : Compare]表示"类型 T 必须实现 Compare 这个 trait(特征)"。Compare是 MoonBit 内置的约束,表示这个类型支持比较大小。只要一个类型实现了 Comparable 约束,就能直接放进这个泛型快排里。你不需要像 C 那样手动传一个sizeof和回调函数,编译器会在编译期检查类型是否满足约束。如果T没有实现Compare,你会在编译阶段看到错误提示,而不是在运行时等一个莫名其妙的崩溃。
当然,我在这里写出来的泛型版本,具体到不同 MoonBit 版本的 trait 命名可能略有出入,以官方文档为准。核心思想是:类型一旦写出来,编译器就全程盯着,你不需要在调用处重复交代细节。
3.6 为什么选 Lomuto 分区:Hoare 分区的暗坑
我用 Lomuto 分区是因为它的边界思路直白:一个 i 指针负责维护"已确认小于基准的区域尾部",一个 j 指针负责扫描,代码不容易出错。但它有个缺点,对于大量重复元素的数组,<=的比较会把很多相等的元素也划分到左边,递归深度可能退化,排序性能会变差。严格说,工业级快排通常会用 Hoare 分区加三数取中,交换次数更少,对重复元素的处理也更平衡。
Hoare 分区怎么写呢?它的两个游标一个从左往右找比基准大的元素,一个从右往左找比基准小的元素,找到后交换。边界条件比 Lomuto 麻烦得多——循环结束后两个游标的位置关系、递归区间的划分,稍不注意就会漏掉元素或者死递归。我在两门语言里都试过 Hoare 分区,得到的结论是:写不好 Hoare 分区的人,换什么语言都写不好;写得好的人,C 和 MoonBit 的差别其实没那么大。算法本身的复杂度不会因为语言而消失,语言只能让表达变得清晰或含糊。
4. 实测环节:排序正确性和简单性能观察
代码光看没用,跑起来才知道真相。这一节包括我的测试用例设计、运行结果观察,以及我在编译和运行过程中踩过的一些值得记录的坑。
4.1 测试用例设计
我分别用两个版本的代码跑了一组测试数据,不光测普通乱序数组,还测了几类容易暴露问题的情况:
- 完全有序的数组:
[1, 2, 3, 4, 5, 6, 7, 8]。这种情况下 Lomuto 分区选最右元素当 pivot,每次都选到最大值,分区极度不平衡,递归深度接近 n,快排实际上退化成 O(n^2)。这个用例能测出递归栈的压力。 - 完全逆序的数组:
[8, 7, 6, 5, 4, 3, 2, 1]。有序数组是 Lomuto 的最差情况,逆序其实情况类似,因为 pivot 永远选到数组两端之一。 - 大量重复元素的数组:
[5, 1, 3, 5, 2, 5, 5, 4, 5]。看<=比较符在重复元素上是否稳定,会不会出现结果正确但元素分布诡异的情况。 - 规模为 1 和 2 的数组:确保递归的终止条件
lo >= hi不是写成了lo == hi。如果终止条件只写相等判断,lo > hi的情况会产生死递归,最终栈溢出。 - 空数组:边界值之一,如果调用方传进来的
hi < lo,partition会直接出错,需要调用方判断。
每种用例我都验证了两件事:第一,排序后的数组确实是升序;第二,排序结果和标准库自身的排序函数结果一致。C 语言我用qsort做参照,MoonBit 我用了它标准库里的排序结果做对比。
4.2 性能结果与解释
性能数据我就不贴具体表格了,因为每台机器的差异、编译器版本的差异、数据规模的差异,都会让数字失去意义。但结论方向是稳定的:C 语言开启-O2优化后,在这组数据上跑得快;MoonBit 如果编译到 native 目标,性能很接近 C,差距在同一个数量级内;如果编译到 wasm 目标,在浏览器或者纯 wasm runtime 里跑,会比 native 慢一些。
为什么 C 仍然快?一个重要原因是 C 的编译器经过了几十年的优化打磨,对循环展开、寄存器分配、指令调度的处理非常成熟。Lomuto 分区这种循环主导的算法,恰恰是 C 编译器最喜欢优化的模式。MoonBit 比较年轻,它的 native 后端和 LLVM 的配合还在持续优化中,能到接近 C 的水平已经很不容易。此外,快排本身的性能非常依赖"写法"——如果你在 C 里用三数取中加插入排序剪枝写一个工业级快排,肯定比我这个朴素 Lomuto 版本快一截。语言决定了性能天花板的高度,但具体能摸到多高,还是看算法实现。
4.3 我在编译和运行中踩过的坑
这里我必须记几笔,都是实战里淌出来的:
- C 语言:忘记数组长度参数。快排函数里需要
hi,如果把hi直接写成sizeof(arr) / sizeof(arr[0]) - 1,你会发现排序结果完全随机——因为函数参数里的arr是指针,sizeof(arr)是 8,排序范围变成了 [lo, 1],结果自然不对。正确做法是在main函数里算好长度再传进去。 - C 语言:在 partition 里把
<=写成了<。对于没有重复元素的随机数组,结果有时对有时错,非常迷惑;一旦遇到重复元素,排序结果大概率错乱。排查时最好自己构造一个包含重复元素的用例,专门逼一下这个比较符号。 - MoonBit:一开始把可变变量写成
let。在partition里我想用i做游标,写了let i = lo - 1,循环里再i = i + 1,被编译器报错"cannot assign to immutable variable"。这个报错信息很直白,改成var i就好了。在 C 语言里你根本不会被这类错误提醒,因为 C 的变量默认可变,你爱怎么改怎么改。 - MoonBit:手滑写了
j++。这个上面说过,MoonBit 不提供++运算符,必须写成j = j + 1。第一次遇到会有点不适应,但适应之后反而觉得踏实。
这些坑都不深,但恰恰是它们最能说明两门语言在"帮助开发者"这件事上的不同态度。C 语言是"你写的代码,你自己负责",MoonBit 是"我尽可能在编译期帮你发现低级错误"。
5. 排序之外:这类对比教我的一件事
快速排序只是一个算法,但这个对比实验背后的价值远不止排序本身。通过一个几十行的函数,我能感受到两门语言设计者的取舍——C 语言追求极致的控制权和极小的运行时开销,所以它把内存管理、类型转换、数组边界这些细节全部交给开发者;MoonBit 追求高效开发的体验和安全边界,所以它在保留高性能潜力的同时,增加了一套编译期约束来减少低级错误。
5.1 两门语言各自的"性格"
如果非要拟人化,C 语言像一个给你全部工具但要求你严守操作规程的老师傅,他相信你有能力用指针、宏和位运算造出任何东西,但绝不允许你指望他帮你排查失误。MoonBit 像一个站在你旁边的搭档,你写代码的时候他会不断提醒:"这里变量不可变,那里类型不匹配,这里可能越界。"一开始你会嫌他啰嗦,但当你习惯之后,会发现他把大量潜在的线上事故挡在了编译阶段。
这两者不是谁取代谁的关系。C 语言在操作系统、嵌入式设备、底层库的统治地位短期内不可能动摇,因为 C 的抽象模型离硬件足够近;MoonBit 这类语言适合的则是那些"希望能又快又稳地开发业务逻辑"的场景,尤其是以 wasm 为目标运行环境的应用,比如前端复杂计算、插件沙箱、Serverless 函数等。
5.2 我的选型建议
如果让我给一个实际的建议,我会这样说:
- 你的项目需要精确控制内存布局、需要直接跟操作系统接口打交道、需要保证 ABI 稳定且不依赖运行时,那么 C 语言依然是第一选择,不要因为"C 危险"之类的说法而回避它——它危险,但它的危险正是它的力量来源。
- 你的项目核心逻辑偏业务计算、目标环境是浏览器或 wasm 生态、团队里新成员比较多、希望减少内存安全问题带来的调试成本,那 MoonBit 值得密切跟踪。它的类型系统、模块体系和 wasm 支持正在快速完善,拿来写算法、写工具链、写插件模型都是不错的练手方向。
- 更大的可能性是你像我一样,两门语言都写。C 语言用来夯实底层思维,MoonBit 用来体验现代语言带来的舒适感。这种"跨语言视角"本身就会提高你对编程语言的理解深度。
5.3 给新语言上手者的一个小经验
最后分享一个我反复验证过的小经验:面对一门新语言,别急着去学它的框架和生态,先用最朴素的算法题去测它的"手感"——快速排序就是特别好的试金石。写一遍快排,你就能摸清楚它的变量绑定、循环语法、数组类型、函数签名、泛型用法、编译报错风格。这些全部是后续写真实项目的必要基础设施。一个能让你舒服地把快排写对的编译器,大概率也能让你舒服地写出其他算法代码。
我在做完这个对比实验之后,又顺手把这套"双语言实验"扩展到了链表反转、字符串逆序、二叉搜索树这几个经典题目上。每次实验都会有新的发现,但快排始终是我最推荐的第一站——它的代码量刚好够暴露问题,又不会复杂到让人失去耐心。希望这篇对比能帮你少踩几个我已经踩过的坑,也给你一个判断新语言是否值得入坑的参考样本。