第 11 章 / 共 58 章 卷三 函数

函数定义与多返回值

把代码切成可以命名、可以复用、可以推理的块——从 func 开始。

本章导读

第 8 章的 grade 函数、第 10 章的 work 函数,我们已经悄悄用过函数了。本卷(第 11–15 章)把函数系统讲透,本章是第一块基石:怎么定义、怎么传参、怎么返回——包括 xlang 一个讨喜的特性:一个函数可以返回多个值,商和余数一次拿回,不用再借道输出参数。

函数是程序组织的分水岭:此前你写"脚本",此后你写"系统"。本章的每个知识点都会在此后 47 章里反复使用。

本章学习目标

掌握 func/as/end 完整语法与返回值规则;理解参数的类型与数量检查;会用 as (T1, T2) 声明多返回值并用解构接收;理解前向引用规则;会写并评估递归;掌握 main 入口的形态、arg 与退出码。

11.1 func 基本形态

函数定义的完整骨架:func 名字(参数列表) as 返回类型 ... end。

basic.xl
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 章的守卫子句模式在这里名正言顺:

grade.xl
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 子句,函数就没有返回值——它因副作用而存在(打印、写文件、改全局状态):

void.xl
func hello()
    println("hello")
end

hello()

无返回值函数里写 return 值 会被编译器拒绝(expected 0, actual 1,见坑 3);反之,声明了 as T 的函数,每条 return 都必须带够值。

一个需要警惕的实测行为:声明了 as 却没有 return 的函数,会静默返回零值(int 得 0),编译器目前不警告:

silent.xl
func answer() as int
    // 忘写 return
end
println(string(answer()))    // 输出 0——不是报错!

这是 RC0 的已知粗糙处(详见拾遗 2)。纪律:写完 as 函数立即检查每条路径都有 return——编译器暂时不替你盯这件事。

11.4 多返回值与解构

xlang 的招牌特性之一:函数可以返回多个值,用 as (T1, T2) 声明,return 时用逗号列出:

divmod.xl
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 章),函数不受此限——调用可以写在定义之前:

forward.xl
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 递归

函数调用自己,就是递归。两要素:基例(不再递归的终止分支)与递推(向基例收缩的自调用)。

recur.xl
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,但它的形态有强制规定:

main.xl
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 层崩溃无诊断
mainfunc main(...) as intarg 接收命令行;return 即退出码

复习题

✍️ 先遮住答案想一想
  1. 为什么函数允许前向引用而变量不允许?
  2. as int 的函数忘写 return 会怎样?由此得出什么编码纪律?
  3. 多返回值和"返回一个数组"都能带回多个结果,差别在哪里?
  4. main 的返回值去了哪里?写命令行工具时这个特性怎么用?
  5. 实测递归 6000 层崩溃且无诊断,这对"深度与输入规模相关"的算法意味着什么?
参考答案
  1. 编译器先收集全部顶层函数签名再解析函数体,函数彼此同时可见;顶层变量按顺序初始化,先用后声明自然 undeclared。
  2. 静默返回零值(int 得 0),不报错。纪律:写完 as 函数立即核查每条路径都有 return。
  3. 多返回值有独立的静态类型(q 是 int、r 是 string),解构即用;数组是同类型元素的运行时容器,取元素是动态 var。编译期安全性与可读性都不同。
  4. 成为进程退出码。工具以 0 表成功、非 0 表失败,调用方(shell、CI)凭退出码判断成败。
  5. 意味着深度不可控时不能把递归当通用方案——要么限制输入规模,要么改迭代加显式栈;崩溃无诊断意味着线上排查只能靠事前约束。

编程练习

练习 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] 知识正好用上。