第 12 章 / 共 58 章 卷三 函数

参数体系

一个函数的"接口素养",全写在参数表里。

本章导读

第 11 章的函数参数都是"老实人":几个参数、什么类型,一一对应。真实世界的函数接口要灵活得多——"这个参数大多数时候是 1,偶尔要改";"二十个配置项,调用时只想写其中三个";"参数个数事先不知道";"我要函数把我的变量直接改掉"。本章把 xlang 参数表的全部武器摆出来:默认参数、命名参数、两种可变参数、三种显式传递模式,以及每种武器背后的语义。

本章学习目标

掌握默认参数的声明与省略规则;会用命名参数乱序调用并理解与位置参数混用的边界;掌握 ... 与 *** 两种可变参数及 arg 访问;理解标量按值、容器共享引用、静态→var 转换隔离三种默认传递语义;会用 byval/byref/out 并遵守调用点标记纪律;了解 c_vararg 对接 C 可变参。

12.1 默认参数

形参写上默认值,调用时就可以省略它:

default.xl
func greet(string name, string greeting = "你好")
    println(greeting + ", " + name)
end

greet("小明")              // 你好, 小明
greet("小红", "早上好")   // 早上好, 小红

一条硬规则:可选参数(带默认值)必须排在必选参数之后,否则编译期报错:

# bad.xl 内容:func f(int a = 1, int b) > xl run bad.xl bad.xl:1:23: [sem] 必选参数不能出现在可选参数之后

这条规则的存在理由很直白:如果可选参数后面还能跟必选参数,省略中间参数时编译器无法对齐位置。默认值可以是任意编译期能确定的表达式;调用时带默认值参数按位置省略,想"跳过一个可选参数只改后面那个",用 12.2 节的命名参数。

12.2 命名参数

调用时写出形参名 名字 = 值,参数顺序就自由了:

named.xl
func move(int x, int y)
    println(string(x) + "," + string(y))
end

move(y = 2, x = 1)       // 1,2:乱序命名
move(1, y = 2)          // 1,2:位置 + 命名混用

混用三条边界(全部实测):

  • 位置参数必须在命名参数之前——move(x = 1, 2) 报 [sem] 位置参数不能出现在命名参数之后;
  • 同一个形参不能给两次——move(1, x = 2) 报 [sem] 重复传入同一个命名参数;
  • 名字必须真实存在——move(1, z = 2) 报 [sem] 未知命名参数(除非函数声明了 ***,见 12.4 节)。

命名参数最大的价值不是乱序,而是自文档化:connect(host, 8080, true, false) 天知道后两个 bool 是什么;connect(host, 8080, tls = true, retry = false) 一目了然。布尔和字面量密集的长参数表,强烈建议命名调用。

12.3 数组可变参 ...

参数个数事先不定,用 ... 收尾;函数体内通过内建 arg 数组访问全部多余实参:

sum.xl
func sum(...) as int
    int total = 0
    for v in arg
        total += v as int       // arg 的元素是 var,取出要 as
    end
    return total
end

println(string(sum(1, 2, 3)))        // 6
println(string(sum()))               // 0:不传也行
println(string(sum(10, 20, 30, 40))) // 100

三个要点:

  • arg 的元素类型是 var——参与静态类型运算前必须 as 取出(第 8 章 is/as 模式),这是可变参灵活性的代价;
  • ... 前可以有固定形参:func f(int first, ...) 中 first 吃掉第一个实参,arg.length 只数可变部分(f(1, 2, 3) 时 first=1、arg.length=2);
  • ... 必须在参数表最后,与 *** 二选一:func both(..., ***) 报 [sem] 可变参数必须位于参数表最后。

典型用途:printf 风格的聚合函数、参数个数不定的便捷入口(max(...)、log(...))。

12.4 字典可变参 ***

... 收"多余的位置参数",*** 收"多余的命名参数",装进 arg 字典:

open.xl
func open(string path, ***) as var
    println(path)
    return arg["mode"]
end

println(string(open("f.txt", mode = "r", flags = 0)))
> xl run open.xl f.txt r

匹配规则(功能清单口径,已实测):命名实参优先匹配已声明的形参,剩下的全部进入 arg 字典。这就是 12.2 节"未知命名参数"报错的豁免通道——函数声明了 ***,任何额外的 name = value 都合法且可查。arg 的键是 string、值是 var,遍历用第 9 章的 for k, v in arg。

典型用途:配置袋(options bag)——函数有十几个可选配置项时,与其声明十几个默认参数,不如一个 *** 收进来按需读取,接口永远不必加参数。

12.5 传递模式:值、共享与转换

函数内修改参数,调用方看不看得到?答案分三类,全部实测:

参数类型默认语义函数内修改对调用方
bool / 整数 / 浮点 / time / type / union按值复制不可见(改的是副本)
array<T> 等静态泛型容器共享受管理句柄可见(同一 backing)
var 动态容器([...]、{...})共享引用可见(同一 backing)
array<T> 传 var 形参转换复制不可见(静态→动态转换产生隔离副本)
class / object(第 27 章预告)共享受管理引用成员修改可见;重绑定局部
passing.xl
// 1. 标量:按值
func bump(int n)
    n += 100
end
int x = 1
bump(x)
println(string(x))              // 1:调用方纹丝不动

// 2. 静态数组:共享
func pushStatic(array<int> a)
    a.push(99)
end
array<int> sa = array<int>[1, 2, 3]
pushStatic(sa)
println(string(sa.length))      // 4:调用方看得见!

// 3. 动态 var 数组:同样共享,修改可见
func pushDyn(var a)
    a.push(99)
end
var da = [1, 2, 3]
pushDyn(da)
println(string(da.length))      // 4:var 传 var 共享,调用方看得见

// 4. 静态 array 传 var 形参:转换隔离(唯一"不可见")
auto sa2 = [1, 2, 3]
pushDyn(sa2)
println(string(sa2.length))     // 3:静态→动态转换产生隔离副本

本节的结论经 2026-07-18 复测更正:var 动态容器与静态泛型容器一样,都是共享引用——赋值、传参不复制,函数内修改对调用方可见;var b = a 之后改 b,a 同样变。唯一的"不可见"出现在静态 → var 形参的跨形态传递:编译器插入一次转换,函数内拿到的是转换副本,与调用方隔离。早先版本此处误记为"写时复制(COW)"——规范文档确有 COW 口径,但 RC0 在简单场景从未触发分离,一切以实测为准。反过来,var 容器传给静态 array<T> 形参不被允许,需 as array<int> 显式转换。

12.6 byval / byref / out

默认语义之外,三个显式模式标记精确控制传递行为:

modes.xl
func bump(byref int n)
    n += 100                 // 直接改写调用方变量
end
func fill(out int n)
    n = 42                   // out:必须赋值后才能返回
end

int x = 1
bump(byref x)              // 调用点必须写同名标记
println(string(x))          // 101
int y = 0
fill(out y)
println(string(y))          // 42

三个模式各自的语义(逐条实测):

  • byval:对标量与 union 复制值——与默认相同;对容器/class 仍保留同一受管理引用(成员修改可见)。byval 的意义是"显式声明我要值语义",不是对象复制语法——要独立副本用 copy()/clone()(第 16 章);
  • byref:绑定调用方的变量槽,函数内对参数的重新赋值都直接写回调用方——rebind(byref arr) 后调用方的 arr 已是新数组;
  • out:专用于"函数产出":进入函数时不可读取([sem] out 参数在整体赋值前不能读取),且每条返回路径都必须完成赋值([sem] out 参数必须在函数正常结束前整体赋值)。

最重要的纪律:byref 和 out 在调用点必须写出同名标记——bump(x) 而不是 bump(byref x) 会报 [sem] 没有匹配的函数重载(见坑 4)。标记让"这个调用会改我的变量"在代码阅读时无处遁形,这是 xlang 显式哲学的又一次体现。byref/out 还有一串禁区:不能带默认值、不能进可变参数、不能被匿名函数捕获、不能穿越 async/线程边界。

12.7 c_vararg 接 C 可变参

xlang 的 ... 是语言级参数包(走 xvalue 数组),不能直接对接 C 的原生可变参。调用 printf 这类 C 函数要用专用标记 c_vararg:

cprintf.xl
extern "printf" func printf(string fmt, c_vararg) as c_int

printf("value=%d text=%s pi=%.2f\n", c_int(42), "hello", c_double(3.14159))
> xl run cprintf.xl value=42 text=hello pi=3.14

三条规则:c_vararg 只能出现在顶层 extern "符号" func 声明里(普通函数用它报 [sem] c_vararg 只能用于 extern C 函数声明);必须是最后一个参数;调用方必须用 c_int()、c_double() 等 ABI 类型显式包裹——C vararg 没有类型信息,宽度全靠调用方保证,%d 配 c_int、%f 配 c_double,配错就是未定义行为。本节只需认识写法,extern/export 的完整 C 混编体系是第 38 章的专题。

深入细节

拾遗 1:转换隔离与 COW 口径的实测记录

规范文档对 var 容器描述过写时复制(COW)模型:共享 backing + 引用计数,首次写入分离。但 RC0 实测矩阵显示该行为在简单场景一律未触发:var 传 var、var 赋值 var、函数内局部 var 接收全局容器,修改全部对原持有者可见。真实发生的隔离只有一处——静态 → 动态的转换隔离:array<int> 传给 var 形参时编译器生成一次容器转换,转换结果是新 backing,函数内的 push 与调用方无关。早先版本正是误把这个转换隔离当成了 COW 证据。实务建议:需要独立副本时在函数入口手工复制(var local = [] ; for v in src { local.push(v) }),不要指望自动分离;若后续版本 COW 落地,本章会按新实测再更正。

拾遗 2:参数模式是签名的一部分

byval/byref/out 不只是"调用约定小贴士"——功能清单明确:参数模式属于函数签名、重载身份、XAPI 和 ABI 元数据的一部分。两个现实推论:其一,f(int) 与 f(byref int) 是两个不同签名(重载视角),这就是 byref 调用忘写标记时报"没有匹配的函数重载"而非"缺少标记"的原因;其二,C 混编时(第 38 章)byref/out 会映射为指针参数出现在生成的头文件里,C 侧看到的接口与 xlang 侧严格对应。模式标记是接口契约,不是实现细节。

拾遗 3:... 与 *** 在 C 边界的样子

把带 ... 的函数导出给 C 时,两种可变参不会变成 C 的 vararg——C 侧收到的是一个 xvalue arg:... 对应 array<var>,*** 对应 dict<var>。也就是说 xlang 可变参是"打包成容器传递",与 c_vararg 的"原生 C ..."是两套完全不同的机制:前者安全、带类型信息(var 包裹),后者零包装、类型靠格式串约定。选型一目了然:xlang 内部用 .../***,跨 C 边界的原生互操作用 c_vararg。

⚠️ 常见错误

以下报错全部来自真实编译器输出(文件路径已缩短)。

坑 1:必选参数写在可选参数后面
# bad.xl 内容:func f(int a = 1, int b) > xl run bad.xl bad.xl:1:23: [sem] 必选参数不能出现在可选参数之后

原因:可选参数必须整体殿后。把 b 提前,或给 b 也加默认值。

坑 2:命名参数后面又跟位置参数
# bad.xl 内容:move(x = 1, 2) > xl run bad.xl bad.xl:4:13: [sem] 位置参数不能出现在命名参数之后

原因:命名参数一旦出场,后面只能继续命名——move(x = 1, y = 2)。这条规则保证参数对齐永远无歧义。

坑 3:对 arg 元素直接做静态运算
# bad.xl 内容: # func sum(...) as int # int total = 0 # for v in arg # total += v ← v 是 var > xl run bad.xl bad.xl:4:15: [sem] compound assignment var operand is not supported: op +=, left int, right var

原因:可变参 arg 的元素是 var,进 int 累加器前要 v as int。可变参函数的内部纪律就是第 8 章的 is/as 模式:先判断、再取出。

坑 4:byref 调用忘写标记,报错却像"函数不存在"
# bad.xl 内容: # func bump(byref int n) # n += 100 # end # bump(x) ← 忘了 byref > xl run bad.xl bad.xl:5:5: [sem] 没有匹配的函数重载

原因:参数模式是签名的一部分,bump(x) 匹配的是"无 byref 签名",找不到就报重载不匹配——报错不提 byref 半个字,极具迷惑性。正确做法:bump(byref x)。看到"没有匹配的函数重载"先检查三件事:参数数量、参数类型、模式标记。

本章小结

机制写法要点
默认参数int level = 1可选必须殿后;跳过用命名调用
命名参数f(y = 2, x = 1)位置在前命名在后;不重复、不虚构
数组可变参func f(...) + argarg 元素是 var;必须殿后;与 *** 互斥
字典可变参func f(***) + arg收未匹配命名参数;配置袋模式
默认传递标量按值 / 容器共享引用 / 静态→var 转换隔离三类语义背熟
byvalbyval T x标量复制;容器仍共享,非对象复制
byref / out声明与调用点双标记byref 可重绑调用方;out 必赋值、不可先读
c_varargextern 专用,末位c_int/c_double 包裹保证宽度

复习题

✍️ 先遮住答案想一想
  1. 为什么"必选参数不能出现在可选参数之后"?如果允许会怎样?
  2. 静态 array<int> 和动态 var 数组传入函数后,函数内 push 对调用方的可见性有何不同?背后的机制是什么?
  3. byval 对容器参数意味着什么?想要函数拿到独立容器副本该怎么办?
  4. out 与 byref 都能"把值带出来",语义差异在哪里?
  5. ... 与 c_vararg 都是"可变参",为什么不能互相替代?
参考答案
  1. 省略中间可选参数时位置无法对齐,实参会错误地灌进后面的必选参数。规则让"省略"永远从尾部发生,对齐无歧义。
  2. 两者都是共享引用,push 对调用方都可见(2026-07-18 复测更正:早先"var 写时复制不可见"的结论有误)。唯一例外是静态 array<int> 传给 var 形参——转换产生隔离副本,函数内修改不可见。机制:同构传递共享 backing;静态→动态跨形态传递插入转换。
  3. byval 对容器只保留同一受管理引用,成员修改仍可见,不复制对象。要独立副本用 copy()/clone()。
  4. out 入函数不可读、每条返回路径必须赋值,语义是"纯产出";byref 绑定已初始化的变量槽,可读可写,语义是"原地修改"。
  5. ... 打包为 array<var> 走 xvalue,带类型信息、安全;c_vararg 是 C 原生 ...,零包装、宽度靠调用方 c_int/c_double 保证。一个面向 xlang 内部,一个面向 C ABI 边界。

编程练习

练习 1:日志函数

编写 logger.xl:函数 log(string text, int level = 1, string tag = "app") 按 [level][tag] text 格式打印。分别用全省略、省一个、命名乱序、命名跳过的方式调用四次,验证四种形态输出。

参考思路:命名调用 log("msg", tag = "net") 跳过 level——这就是"默认参数 + 命名参数"的黄金搭档。

练习 2:可变参最大值

编写 maxof.xl:max(...) as int 返回所有实参中的最大值,零参数时 throw ValueError "max needs at least one argument"(回链第 10 章)。用 1 个、3 个、7 个参数及 0 个参数分别测试。

参考思路:先查 arg.length 再遍历;元素是 var,比较前 as int。体会"灵活接口 + 严格校验"的组合。

练习 3:交换与累加器

编写 swapacc.xl:① swap(byref int a, byref int b) 交换两变量;② acc(int x, out int total) 把 x 乘 10 后写入 total。顶层声明变量调用验证,并刻意试一次 swap(a, b) 不带标记,观察报错。

参考思路:体会调用点标记的自我文档价值——读代码的人一眼看出哪两个变量会被改。