f"..." 让字符串拼接近乎隐形,string.format 的迷你语言负责精确控制——但两者都有几条与 Python/printf 直觉相反的边界,本章逐一量出来。
第 18 章解决了"字符串怎么加工",本章解决"字符串怎么生成"。xlang 给出两件工具:f-string——在字符串里直接写 {表达式};string.format 的格式化迷你语言——宽度、补零、精度、千分位、百分比。两件工具各司其职:f-string 管"把值嵌进文本",format 管"把值捏成指定形状",两者常组合使用(f"{string.format(pi, '.2f')}")。本章把插值表达式的能力边界、插值的类型处理、迷你语言的每个开关逐一实测,并用 cgen 证据展示 f-string 编译成了什么。
掌握 f"..." 插值语法与 {expr} 的能力边界;会用 {{ }} 输出字面量大括号;掌握 rf"..." raw f-string 与多行 f-string;背下各类型插值的输出形态(特别是 null 与容器两个特例);掌握 format 整数/浮点迷你语言的全部开关与边界;会用 time.format 与 time.formatPattern 输出时间;理解 f-string 的 fstr_join 实现。
字符串前缀 f 即开启插值:{表达式} 在运行期求值并转字符串嵌入。f-string 是普通 string 值,可赋值、可传参:
string name = "world" int a = 3 int b = 4 println(f"hello {name}") // hello world println(f"{a} + {b} = {a + b}") // 3 + 4 = 7 string s = f"result: {a * b}" // f-string 就是 string println(s) // result: 12
与第 5 章的 + 拼接相比,f-string 省去了满屏的 string() 转换和引号切换——插值自动完成"值 → 字符串"的转换,这是它的全部意义。
{ } 里能放多复杂的表达式?实测答案:几乎是任意表达式——运算、函数调用、命名空间调用、三元、下标全部可用:
func twice(int n) as int return n * 2 end auto a = 3 auto b = 10 println(f"mul {a * 2}, mod {b % 3}") // mul 6, mod 1 println(f"len {string.len('abc')}") // len 3:命名空间调用 println(f"func {twice(21)}") // func 42:自定义函数调用 println(f"cond {(a > b) ? 'big' : 'small'}") // cond small:三元 auto arr = [10, 20] println(f"arr0 {arr[0]}") // arr0 10:下标 var d = {"k": 5} println(f"dict {d['k']}") // dict 5:字典下标
四条边界,全部实测:
f"len {string.len("abc")}" 报 [parse] 未闭合的 f-string 插值表达式;插值内的字符串改用单引号即可。f"{[1, 2, 3]}" 报 [parse] 语法错误;先赋给变量再插值。f"{}" 报 [parse] f-string 插值表达式不能为空;f"{1 + }" 报 [parse] invalid f-string interpolation expression。{pi:.2f} 写法不存在,报 [parse] invalid f-string interpolation expression;格式化请组合 string.format(19.5/19.6 节)。不同类型的值插值后长什么样?逐个实测:
println(f"int {42}, float {3.5}, bool {true}") // int 42, float 3.5, bool true println(f"float {3.0} {0.1 + 0.2}") // float 3.0 0.3:浮点输出整洁 println(f"null {null}") // "null ":null 插值为空串! time t = #2026-06-18 12:30:45# println(f"time {t}") // time 2026-06-18 12:30:45 var s = "str" println(f"varstr {s}") // varstr str:var 标量自然输出
两个必须记住的特例:
f"值: {maybeNull}" 在缺值时留下"值: ",不打 null 字样;调试空值时改用 println 直接打印。[table:000000...],var 数组输出 [array:000000...];静态容器则直接编译报错 [sem] f-string 插值表达式不能转换为 string。对比:println 直接打印容器是 JSON 紧凑形态({"k":1}),两者不同!想插值容器内容,用 join、循环或 println 单独打印。string name = "xlang" println(f"literal {{name}} vs {name}") // literal {name} vs xlang println(f"100%") // 100%:% 无需转义 println(rf"c:\tmp\{name}.txt") // c:\tmp\xlang.txt:raw 保斜杠,插值照常 println(rf"backslash \n stays, {name}") // \n 原样两个字符 println(r"c:\no\interp {name}") // {name} 原样:纯 raw 不插值(第 3 章)
三行对比记住整个家族:f 插值但处理转义;rf 插值且不处理转义(Windows 路径、正则场景的主力);r 既不插值也不处理转义。多行形态同样支持:f""" ... """ 与 f''' ... ''' 里插值正常工作。
string.format 第二参数是格式描述,不是 printf 格式串。整数侧的实测开关:
println(string.format(42, "d")) // 42:十进制 println(string.format(42, "05d")) // 00042:宽度即补零 println(string.format(42, "5d")) // 00042:宽度天生带补零,无空格填充 println(string.format(42, "+d")) // +42:强制符号 println(string.format(255, "x")) // ff:十六进制小写 println(string.format(255, "X")) // FF:大写 println(string.format(255, "b")) // 11111111:二进制 println(string.format(65, "c")) // A:码点转字符 println(string.format(1234567, ",d")) // 1,234,567:千分位
两个边界:-5d(左对齐)实测无效——仍输出右对齐的 00042;迷你语言里宽度 = 补零宽度,没有空格填充与对齐概念。整数格式对 % 前缀宽容("%d" 等价于 "d"),与浮点完全不同(下节)。
println(string.format(3.14159, "f")) // 3.141590:默认 6 位小数 println(string.format(3.14159, ".2f")) // 3.14:.N 控制精度 println(string.format(3.14159, ".0f")) // 3:四舍五入 println(string.format(3.14159, "+.2f")) // +3.14:强制符号 println(string.format(0.25, "%")) // 25.000000%:% 是百分比标志 println(string.format(0.25, ".1%")) // 25.0%:精度与百分比可组合 println(string.format(1234567.89, ",.2f")) // 1,234,567.89:千分位 println(string.format(31415.9, "e")) // 31415.900000:e 无效,退化为 f println(string.format(3.14159, "10.2f")) // 3.14:宽度对浮点无效
边界总结:% 是百分比(数值 ×100、末尾加 %)而不是格式引导符——第 18 章拾遗 3 的陷阱在此正式展开;没有科学计数法(e/E 被忽略);宽度无效(浮点不补零不填充);非法格式串(如 "zz")静默回退默认输出,不报错。迷你语言的完整可用字符集:+(符号)、%(百分比)、,(千分位)、.N(精度)、f(可省略)。
time 值插值输出标准形态,但展示层常需要自定义格式——两个函数:
time t = #2026-06-18 12:30:45# println(time.format(t)) // 2026-06-18 12:30:45:format 0 默认 println(time.format(t, 1)) // 2026-06-18:仅日期 println(time.format(t, 2)) // 12:30:45:仅时间 println(time.formatPattern(t, "yyyy/mm/dd hh:mm:ss")) // 2026/06/18 12:30:45 println(time.formatPattern(t, "yyyy年mm月dd日")) // 2026年06月18日:中文模板任意混排 println(time.formatPattern(t, "yy-m-d h:m:s")) // 26-6-18 12:6:45:单字母不补零 println(f"date {time.formatPattern(t, 'yyyy-mm-dd')}") // date 2026-06-18:与 f-string 组合
模式字母规则(实测):yyyy/yy 年、dd/d 日、hh/h 时、ss/s 秒、mm/m 按语境二义解析——日期部分是月、时间部分是分("yyyy-mm-dd" 得 2026-06-18,"hh:mm:ss" 得 12:30:45)。大写 MM 不被识别,原样输出("yyyy-MM-dd" 得 "2026-MM-18")——月份务必小写。time 函数族共 67 个,本章只用格式化两个,其余是第 20 章的主场。
用 xl cgen 观察 println(f"{name}:{n}"),生成的核心代码是:
f-string 被降低为单次 fstr_join 调用:第一参数是段数,随后字面段原样传入、每个插值按静态类型走专属 to_string 转换(int 用 i64_to_string)。这解释了 19.3 的类型行为:转换分派发生在编译期,var 值只能走通用 xvalue 路径(容器因此落得指针形态),静态容器连通用路径都没有(直接 [sem] 报错)。也解释了性能特征:一个 f-string 就是一次 join,没有逐个 += 的 O(n²) 问题(对照第 18 章拾遗 1)。
Python 用户会怀念 {pi:.2f}。xlang 的选择是把"插值"与"格式化"拆成两个正交机制:f-string 只管嵌值,形状控制全归 string.format 函数。代价是多写一层组合(f"{string.format(pi, '.2f')}"),收益是插值语法永远简单(不用学第二套微型 DSL),且格式化能力可以独立演进。从 fstr_join 的实现看这也是必然:格式说明符需要在编译期侵入每个插值的转换路径,而函数组合零侵入。标准库设计文档另注明:即便未来实现 printf 风格多值 format,也不会引入 {} 占位语法——f-string 已经占了那个生态位。
给 C/printf 用户的翻译表:"%05d" → "05d";"%x" → "x";"%+.2f" → "+.2f";"%e" → 不存在;"%-10s" → 不存在(无字符串宽度);"%" 字面量 → 无需转义直接写。心智模型的根本差异:printf 的 % 是"占位引导符",xlang 迷你语言里每个字符都是修饰开关,% 恰好被分配给百分比。两体系的另一个分野:迷你语言是单值格式化(一次一个值),多值混排交给 f-string——分工而非替代。
以下报错全部来自真实编译器输出(文件路径已缩短)。
原因:内层双引号提前终结了 f-string。正确做法:插值内的字符串一律单引号:f"len {string.len('abc')}"。
原因:xlang 插值不支持 : 格式说明符。正确做法:f"{string.format(pi, '.2f')}"——插值与格式化组合使用。
原因:var 容器走 xvalue 通用转换路径,输出内部表示;静态容器(array<int> 等)插值则直接编译报 [sem] f-string 插值表达式不能转换为 string。正确做法:println(d) 直接打印(JSON 紧凑形态);要嵌进文本就 join/循环构造内容。
原因:迷你语言中 % = 百分比(×100 加 % 号),不是格式引导符。正确做法:去掉 % 写 ".2f";整数格式对 % 前缀宽容是另一套规则,别混淆。
| 主题 | 规则 | 要点 |
|---|---|---|
| f-string 基础 | f"...{expr}..." | 普通 string 值,自动转字符串 |
| 插值能力 | 运算/调用/三元/下标均可 | 同种引号不嵌套;无容器字面量;无 : 说明符 |
| 插值类型 | int/float/bool/time/var 标量自然 | null→空串;var 容器→指针形态;静态容器 [sem] 报错 |
| 转义家族 | {{ }} / f / rf / r | f 插值、rf 插值+raw、r 纯 raw;支持三引号多行 |
| 整数 format | d / 宽度补零 / x X b / c / + / , | 宽度即补零;无空格填充无左对齐;% 前缀宽容 |
| 浮点 format | .Nf 精度 / + / % / , | % 是百分比;无科学计数法;宽度无效;非法串静默回退 |
| time | format(t, 0/1/2) / formatPattern | 模式字母小写(mm 月/分按语境);单字母不补零 |
| 实现 | 单次 fstr_join 调用 | 无 O(n²) 拼接问题;转换在编译期分派 |
编写 report.xl:给定三组(名称, 数量, 单价)数据,用 f-string + string.format 打印销售报表:名称左起原样、数量用 "05d" 补零、金额用 ",.2f" 千分位两位小数,最后打印合计行。
参考思路:金额合计用 float 累加;f-string 里组合 format:f"{name} qty={string.format(qty, '05d')} amt={string.format(amt, ',.2f')}";浮点无宽度概念,如需视觉对齐自己用 string.len 计算补空格。
编写 logline.xl:用 time.formatPattern(time.now(), "yyyy-mm-dd hh:mm:ss") 生成时间戳,拼出 "[时间] [LEVEL] 消息" 格式的三行日志(INFO/WARN/ERROR 各一),级别字段用 padRight 补齐到 5 字符宽。
参考思路:string.padRight(level, 5);f-string 组合:f"[{ts}] [{lv}] {msg}";time.now() 返回当前时间(第 20 章详解,本章直接用)。
编写 tpl.xl:实现 render(string tpl, var data)——把模板里的 {key} 替换为 data 字典中对应键的值(var dict,第 17 章)。用 "你好,{name}!你有 {count} 条新消息。" 验证。
参考思路:f-string 的模板是编译期写死的,运行期模板必须手工替换——for k, v in data 循环 string.replace(tpl, "{" + (k as string) + "}", string(v));值是 var 时用 f"{v}" 或分类型 string() 处理(注意容器值会变指针形态,练习里约定只用标量值)。