本章导读
第 8 章的 grade 函数、第 10 章的 work 函数,我们已经悄悄用过函数了。本卷(第 11–15 章)把函数系统讲透,本章是第一块基石:怎么定义、怎么传参、怎么返回——包括 xlang 一个讨喜的特性:一个函数可以返回多个值,商和余数一次拿回,不用再借道输出参数。
函数是程序组织的分水岭:此前你写"脚本",此后你写"系统"。本章的每个知识点都会在此后 47 章里反复使用。
本章学习目标
掌握 func/as/end 完整语法与返回值规则;理解参数的类型与数量检查;会用 as (T1, T2) 声明多返回值并用解构接收;理解前向引用规则;会写并评估递归;掌握 main 入口的形态、arg 与退出码。
11.1 func 基本形态
函数定义的完整骨架:func 名字(参数列表) as 返回类型 ... end。
func add(int a, int b) as int
return a + b
end
println(string(add(3, 4))) // 7
拆解四个部分:
- 参数列表:每个参数写类型,
int a、string name;无参就空括号 ();
as 返回类型:声明这个函数承诺返回什么;
- 函数体:换行开始,遵守块级作用域(参数与体内变量都只在函数内有效);
end:与 if/while 同款的显式收尾。
return 把值交还给调用方并立即结束函数。第 8 章的守卫子句模式在这里名正言顺:
func grade(int s) as string
if s >= 90 then
return "A"
elseif s >= 60 then
return "B"
else
return "C"
end
end
一个当前版本的形态限制:函数不能嵌套定义——func 体内再写 func 是语法错误。所有函数都定义在顶层,彼此平级。想在一处逻辑里"藏"一个小函数?第 13 章的匿名函数与闭包才是正解。
11.2 参数与类型检查
xlang 的函数调用是静态检查的:实参的数量和类型都必须与形参匹配,不匹配在编译期就被拦下,错误信息直白:
# 少传一个参数:add(1)
bad.xl:4:19: [sem] 函数调用实参数量和重载签名不匹配: expected 2, actual 1
# 类型不对:add(1, "x")
bad.xl:4:23: [sem] 函数调用实参类型和参数类型不匹配: target int, value string
注意"重载签名"这个措辞——xlang 支持同名函数的重载(第 14 章专题),编译器按实参找匹配签名,找不到才报错。本章先记住:调用不匹配的函数,错误在编译期暴露,不会拖到运行时。
参数在函数体内是普通局部变量,遵守第 4 章的作用域规则,且不能再同名声明:
# func f(int x) as int
# int x = 99 ← 与参数重名
bad.xl:2:9: [sem] 同一作用域内重复声明
参数传递的细节——值传递还是引用传递、byval/byref/out——内容较多,第 12 章参数体系专题讲解,本章先用最普通的传法。
11.3 无返回值的函数
不写 as 子句,函数就没有返回值——它因副作用而存在(打印、写文件、改全局状态):
func hello()
println("hello")
end
hello()
无返回值函数里写 return 值 会被编译器拒绝(expected 0, actual 1,见坑 3);反之,声明了 as T 的函数,每条 return 都必须带够值。
一个需要警惕的实测行为:声明了 as 却没有 return 的函数,会静默返回零值(int 得 0),编译器目前不警告:
func answer() as int
// 忘写 return
end
println(string(answer())) // 输出 0——不是报错!
这是 RC0 的已知粗糙处(详见拾遗 2)。纪律:写完 as 函数立即检查每条路径都有 return——编译器暂时不替你盯这件事。
11.4 多返回值与解构
xlang 的招牌特性之一:函数可以返回多个值,用 as (T1, T2) 声明,return 时用逗号列出:
func divmod(int a, int b) as (int, int)
return a / b, a % b
end
auto q, r = divmod(17, 5)
println(string(q) + " ... " + string(r))
> xl run divmod.xl
3 ... 2
接收侧的规矩(每条都有实测报错撑腰):
- 必须多值接收——
auto a = divmod(...) 单变量接多返回,报 [sem] 多返回值函数调用必须使用多值接收;
- 数量必须相等——
auto a, b, c = divmod(...) 报 [sem] 多返回值声明数量和函数返回值数量不匹配: expected 2, actual 3;
- 用 auto 解构——第 3 章已确立:
auto q, r = f() 合法,而 auto q, r = 1, 2 这类并列字面量非法。解构专为多返回值设计。
解构出的 q、r 类型分别推断为声明的返回类型(int、int),此后就是普通变量。
多返回值的经典用武之地:同时给结果和状态(商与余数、值与是否成功、解析结果与错误描述)。第 10 章的异常负责"意外",多返回值负责"预期内的多路输出"——两者互补而非竞争。
11.5 声明顺序与前向引用
变量必须"先声明后使用"(第 4 章),函数不受此限——调用可以写在定义之前:
println(string(later(2, 3))) // 6:此处 later 还没定义
func later(int a, int b) as int
return a * b
end
> xl run forward.xl
6
原因在编译模型:编译器第一遍收集全部顶层函数签名,第二遍才解析函数体——所以函数彼此"同时可见",互相调用、环形调用(A 调 B、B 调 A)都合法,这也是递归(下节)能写的前提。变量则不同:顶层变量按顺序初始化,顺序错了照样报 undeclared。
工程建议:虽然顺序自由,还是按"自底向上"或"入口在前、细节在后"的固定风格组织文件——读者不该为找函数定义翻遍全文。
11.6 递归
函数调用自己,就是递归。两要素:基例(不再递归的终止分支)与递推(向基例收缩的自调用)。
func fact(int n) as int
if n <= 1 then
return 1 // 基例
end
return n * fact(n - 1) // 递推
end
println(string(fact(10))) // 3628800
func fib(int n) as int
if n < 2 then
return n
end
return fib(n - 1) + fib(n - 2)
end
println(string(fib(25))) // 75025
递归深度的边界必须实测才知道:笔者用"每层只加 1 的裸递归"探测,本机 5500 层正常返回,6000 层进程崩溃(退出码 127,无任何诊断信息)——没有 Python 那种 RecursionError,栈耗尽就是硬着陆。
工程推论:
- 业务递归(树遍历、分治)深度通常几十层,放心用;
- 深度与输入规模线性相关的算法(如"处理 n 层嵌套数据"),深递归改迭代(第 9 章循环 + 显式栈),别赌输入不会变长;
- fib 这种指数级重复计算的递归只是教学演示,实战请写循环版——第 9 章练习 1 你写过。
11.7 main 入口的特殊性
第 2 章说过:顶层语句本身就是程序入口。xlang 还支持显式入口函数 main,但它的形态有强制规定:
func main(...) as int
println("argc=" + string(arg.length))
return 42
end
> xl run main.xl -- a b c
argc=4
# 进程退出码为 42
三条特殊性:
- 参数必须是
...(可变参数,第 12 章详解),写成 func main() as int 报 [sem] main 入口参数必须是 ...,由 arg 接收 argc/argv;
- 命令行参数经
arg 数组访问——arg[0] 是脚本路径(第 5 章已实测),所以上例 3 个用户参数对应 argc=4;
- main 的 return 值就是进程退出码——上例退出码恰为 42。写命令行工具时,0 表示成功、非 0 表示失败的惯例就靠它落地。
顶层语句与 main 的关系:两者都是入口形态,小程序用顶层语句足够;需要规范退出码或系统处理命令行参数时,显式 main 更正式。
深入细节
拾遗 1:函数调用在生成代码里长什么样
xlang 的具名函数调用是静态分派:xl cgen 生成的 C 代码里,add(3, 4) 就是对一个静态 C 函数的直接调用,参数按 ABI 直传,没有查表、没有装箱——这是"函数调用零抽象成本"的承诺。与之对照的是第 13 章将学的函数值(var 装的函数):那条路径经过 XRT 值与动态调用帧,灵活但有开销。规则很直白:名字够用时写具名函数,把动态性留给真正需要它的地方。
拾遗 2:两个 RC0 粗糙处的体检报告
本章实测发现两处"编译器没拦住"的边缘:其一,as int 函数忘写 return,静默返回 0——C 语义"跌出函数体"的残留,调用方拿到一个看似合法的假值,危害性高;其二,auto q, r = pair() 解构出的变量可以参与运算、可以赋给已声明变量,但不能用作新声明的初始化器(int qi = q 报 [sem] undeclared identifier 'q')——语义检查器的作用域登记在声明初始化位置有疏漏。变通:先声明再赋值(int qi = 0; qi = q)。两条都已按实测记录,后续版本若修复以新版本为准。
拾遗 3:函数是一等公民——本章的边界,第 13 章的起点
本章的函数都是"具名、静态"的。但 xlang 里函数本身也是值:可以赋给变量(func(int, int) as int proc = add)、可以当参数传递、可以匿名创建(func(int a) as int => a + 1)、可以捕获外层变量形成闭包。这构成函数式风格的基础,也是第 26 章回调、第 33 章任务并发的语法前提。现在只需记住一个分界面:本章学的定义语法解决"组织代码",第 13 章的函数值解决"传递行为"。
⚠️ 常见错误
以下报错全部来自真实编译器输出(文件路径已缩短)。
坑 1:把 main 写成 func main() as int
# bad.xl 内容:
# func main() as int
# return 0
# end
> xl run bad.xl
bad.xl:1:1: [sem] main 入口参数必须是 ...,由 arg 接收 argc/argv
原因:main 的参数形态是强制的 ...,命令行参数统一经 arg 数组传递。改成 func main(...) as int 即可。
坑 2:单变量接收多返回值
# bad.xl 内容:
# func pair() as (int, string)
# return 1, "one"
# end
# auto a = pair() ← 想用一个变量装两个返回值
> xl run bad.xl
bad.xl:4:14: [sem] 多返回值函数调用必须使用多值接收
原因:多返回值不是"打包成一个元组",而是真正的多路输出,必须逐位解构:auto q, r = pair(),且数量必须一一对应。
坑 3:return 的数量与声明不符
# 情况一:as int 的函数 return 1, 2
bad.xl:2:5: [sem] return 返回值数量和函数声明不匹配: expected 1, actual 2
# 情况二:无 as 的函数 return 1
bad.xl:2:5: [sem] return 返回值数量和函数声明不匹配: expected 0, actual 1
# 情况三:as (int, string) 只 return 1
bad.xl:2:5: [sem] return 返回值数量和函数声明不匹配: expected 2, actual 1
原因:return 的 arity 与 as 声明严格对账,多退少退都不行。报错把 expected/actual 写得清清楚楚,照着数即可。
坑 4:把无返回值函数的结果用起来
# bad.xl 内容:
# func g()
# println("side")
# end
# int x = g() ← g 没有返回值
> xl run bad.xl
tcc: __xlvfs__/module.c:458: error: cannot convert 'void' to 'extern static long long'
原因:这是个 RC0 缺陷——语义检查本该给出干净的 [sem] 报错,实际却漏到了 C 编译层,暴露出 tcc 的原始错误。正确做法:无返回值函数只能作为语句调用,别让它出现在表达式里;看到 tcc 字样的报错,先回头检查这类漏网之鱼。
本章小结
| 知识点 | 写法/规则 | 要点 |
| 定义 | func 名(参数) as T ... end | 无 as = 无返回值;不可嵌套定义 |
| 调用检查 | 数量、类型静态匹配 | 编译期 [sem] 报错,含 expected/actual |
| return | 数量与 as 严格对账 | 缺 return 静默得零值(RC0 缺陷) |
| 多返回值 | as (T1, T2) + auto a, b = f() | 必须多值接收、数量相等 |
| 前向引用 | 调用可在定义前 | 函数签名全局可见;变量仍按顺序 |
| 递归 | 基例 + 递推 | 实测约 5500 层安全,6000 层崩溃无诊断 |
| main | func main(...) as int | arg 接收命令行;return 即退出码 |
复习题
✍️ 先遮住答案想一想
- 为什么函数允许前向引用而变量不允许?
as int 的函数忘写 return 会怎样?由此得出什么编码纪律?
- 多返回值和"返回一个数组"都能带回多个结果,差别在哪里?
- main 的返回值去了哪里?写命令行工具时这个特性怎么用?
- 实测递归 6000 层崩溃且无诊断,这对"深度与输入规模相关"的算法意味着什么?
参考答案
- 编译器先收集全部顶层函数签名再解析函数体,函数彼此同时可见;顶层变量按顺序初始化,先用后声明自然 undeclared。
- 静默返回零值(int 得 0),不报错。纪律:写完 as 函数立即核查每条路径都有 return。
- 多返回值有独立的静态类型(q 是 int、r 是 string),解构即用;数组是同类型元素的运行时容器,取元素是动态 var。编译期安全性与可读性都不同。
- 成为进程退出码。工具以 0 表成功、非 0 表失败,调用方(shell、CI)凭退出码判断成败。
- 意味着深度不可控时不能把递归当通用方案——要么限制输入规模,要么改迭代加显式栈;崩溃无诊断意味着线上排查只能靠事前约束。
编程练习
练习 1:统计三件套
编写 stats.xl:函数 stats(int a, int b, int c) as (int, int, int) 一次返回三数中的最大值、最小值、总和。调用方解构接收并逐项打印。
参考思路:体内用局部变量逐步更新 max/min;return 一行列三个值。体会"一次调用、三路输出"的爽感。
练习 2:迭代与递归对照
编写 power.xl:分别用循环和递归实现 power(int base, int n) as int(n 次幂,n ≥ 0),对 2¹⁰、5³、7⁰ 验证两版结果一致。
参考思路:递归版基例 n == 0 return 1,递推 base * power(base, n - 1)。对照体会:任何递归都能改写成循环,反之亦然。
练习 3:退出码工具
编写 check.xl:用 func main(...) as int 实现一个参数检查工具——arg.length 小于 2 时打印用法说明并 return 1,否则打印收到的参数个数并 return 0。在命令行分别带参数、不带参数运行,用 echo %ERRORLEVEL%(Windows)或 echo $?(Linux/macOS)验证退出码。
参考思路:这是真实命令行工具的最小骨架——arg 接收输入、退出码报告成败,第 5 章的 arg[0] 知识正好用上。