第 19 章 / 共 58 章 卷四 容器与文本

f-string 与格式化

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 实现。

19.1 f-string 基础

字符串前缀 f 即开启插值:{表达式} 在运行期求值并转字符串嵌入。f-string 是普通 string 值,可赋值、可传参:

basic.xl
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() 转换和引号切换——插值自动完成"值 → 字符串"的转换,这是它的全部意义。

19.2 插值表达式的能力边界

{ } 里能放多复杂的表达式?实测答案:几乎是任意表达式——运算、函数调用、命名空间调用、三元、下标全部可用:

expr.xl
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。
  • 没有格式说明符——Python 的 {pi:.2f} 写法不存在,报 [parse] invalid f-string interpolation expression;格式化请组合 string.format(19.5/19.6 节)。

19.3 插值的类型处理

不同类型的值插值后长什么样?逐个实测:

types.xl
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 标量自然输出

两个必须记住的特例:

  • null 插值为空字符串——f"值: {maybeNull}" 在缺值时留下"值: ",不打 null 字样;调试空值时改用 println 直接打印。
  • 容器插值为指针形态——var dict 插值输出 [table:000000...],var 数组输出 [array:000000...];静态容器则直接编译报错 [sem] f-string 插值表达式不能转换为 string。对比:println 直接打印容器是 JSON 紧凑形态({"k":1}),两者不同!想插值容器内容,用 join、循环或 println 单独打印。

19.4 {{ }} 转义与 raw f-string

escape.xl
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''' ... ''' 里插值正常工作。

19.5 format 迷你语言·整数

string.format 第二参数是格式描述,不是 printf 格式串。整数侧的实测开关:

fmtint.xl
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"),与浮点完全不同(下节)。

19.6 format 迷你语言·浮点

fmtfloat.xl
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(可省略)。

19.7 time 与格式化

time 值插值输出标准形态,但展示层常需要自定义格式——两个函数:

timefmt.xl
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 章的主场。

深入细节

拾遗 1:f-string 编译成了什么——fstr_join 证据

用 xl cgen 观察 println(f"{name}:{n}"),生成的核心代码是:

xl6_xrt_fstr_join(3, __xl6_user_global_name, ":", xl6_xrt_i64_to_string((long long)(__xl6_user_global_n)))

f-string 被降低为单次 fstr_join 调用:第一参数是段数,随后字面段原样传入、每个插值按静态类型走专属 to_string 转换(int 用 i64_to_string)。这解释了 19.3 的类型行为:转换分派发生在编译期,var 值只能走通用 xvalue 路径(容器因此落得指针形态),静态容器连通用路径都没有(直接 [sem] 报错)。也解释了性能特征:一个 f-string 就是一次 join,没有逐个 += 的 O(n²) 问题(对照第 18 章拾遗 1)。

拾遗 2:为什么没有 {x:.2f} 格式说明符

Python 用户会怀念 {pi:.2f}。xlang 的选择是把"插值"与"格式化"拆成两个正交机制:f-string 只管嵌值,形状控制全归 string.format 函数。代价是多写一层组合(f"{string.format(pi, '.2f')}"),收益是插值语法永远简单(不用学第二套微型 DSL),且格式化能力可以独立演进。从 fstr_join 的实现看这也是必然:格式说明符需要在编译期侵入每个插值的转换路径,而函数组合零侵入。标准库设计文档另注明:即便未来实现 printf 风格多值 format,也不会引入 {} 占位语法——f-string 已经占了那个生态位。

拾遗 3:迷你语言与 printf 的对照地图

给 C/printf 用户的翻译表:"%05d" → "05d";"%x" → "x";"%+.2f" → "+.2f";"%e" → 不存在;"%-10s" → 不存在(无字符串宽度);"%" 字面量 → 无需转义直接写。心智模型的根本差异:printf 的 % 是"占位引导符",xlang 迷你语言里每个字符都是修饰开关,% 恰好被分配给百分比。两体系的另一个分野:迷你语言是单值格式化(一次一个值),多值混排交给 f-string——分工而非替代。

⚠️ 常见错误

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

坑 1:插值里嵌同种引号
# bad.xl 内容: # println(f"len {string.len("abc")}") > xl run bad.xl bad.xl:1:9: [parse] 未闭合的 f-string 插值表达式

原因:内层双引号提前终结了 f-string。正确做法:插值内的字符串一律单引号:f"len {string.len('abc')}"。

坑 2:Python 式的格式说明符
# bad.xl 内容: # float pi = 3.14159 # println(f"{pi:.2f}") > xl run bad.xl bad.xl:2:9: [parse] invalid f-string interpolation expression

原因:xlang 插值不支持 : 格式说明符。正确做法:f"{string.format(pi, '.2f')}"——插值与格式化组合使用。

坑 3:插值容器
# bad.xl 内容: # var d = {"k": 1} # println(f"dict {d}") > xl run bad.xl dict [table:00000000025a2b18](指针形态,不是 {"k":1}!)

原因:var 容器走 xvalue 通用转换路径,输出内部表示;静态容器(array<int> 等)插值则直接编译报 [sem] f-string 插值表达式不能转换为 string。正确做法:println(d) 直接打印(JSON 紧凑形态);要嵌进文本就 join/循环构造内容。

坑 4:printf 直觉写 "%.2f"
# bad.xl 内容: # println(string.format(3.14159, "%.2f")) > xl run bad.xl 314.16%(% 被当成百分比标志!)

原因:迷你语言中 % = 百分比(×100 加 % 号),不是格式引导符。正确做法:去掉 % 写 ".2f";整数格式对 % 前缀宽容是另一套规则,别混淆。

本章小结

主题规则要点
f-string 基础f"...{expr}..."普通 string 值,自动转字符串
插值能力运算/调用/三元/下标均可同种引号不嵌套;无容器字面量;无 : 说明符
插值类型int/float/bool/time/var 标量自然null→空串;var 容器→指针形态;静态容器 [sem] 报错
转义家族{{ }} / f / rf / rf 插值、rf 插值+raw、r 纯 raw;支持三引号多行
整数 formatd / 宽度补零 / x X b / c / + / ,宽度即补零;无空格填充无左对齐;% 前缀宽容
浮点 format.Nf 精度 / + / % / ,% 是百分比;无科学计数法;宽度无效;非法串静默回退
timeformat(t, 0/1/2) / formatPattern模式字母小写(mm 月/分按语境);单字母不补零
实现单次 fstr_join 调用无 O(n²) 拼接问题;转换在编译期分派

复习题

✍️ 先遮住答案想一想
  1. f"{string.len("abc")}" 为什么报错?怎么改?
  2. f"{pi:.2f}" 的结果是什么?xlang 的等价写法?
  3. f"值: {null}" 与 var dict 插值分别输出什么?与 println 直接打印容器有何不同?
  4. rf"c:\tmp\{name}.txt" 与 r"c:\tmp\{name}.txt" 的输出差别?
  5. string.format(42, "5d") 与 string.format(3.14159, "10.2f") 的输出?说明宽度在整数与浮点上的差异。
  6. string.format(0.25, ".1%") 输出什么?time.formatPattern(t, "yyyy-MM-dd") 为什么得不到月份?
参考答案
  1. 内层双引号提前终结 f-string,报 [parse] 未闭合的 f-string 插值表达式;插值内字符串改单引号:f"{string.len('abc')}"。
  2. [parse] invalid f-string interpolation expression——无 : 格式说明符;等价写法 f"{string.format(pi, '.2f')}"。
  3. null 插值为空串("值: ");var dict 插值为指针形态 [table:...];println 直接打印容器是 JSON 紧凑形态 {"k":1}——插值与打印走不同的转换路径。
  4. rf 版输出 c:\tmp\xlang.txt(raw 保斜杠、插值照常);r 版原样输出 c:\tmp\{name}.txt(不插值)。
  5. "00042"——整数宽度即补零;"3.14"——浮点宽度无效,只认精度。整数的填充语义不适用于浮点。
  6. "25.0%"(百分比 ×100 + 一位小数 + % 号);大写 MM 不被识别会原样输出,月份必须小写 mm(日期语境下 mm 解析为月)。

编程练习

练习 1:对齐报表

编写 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 计算补空格。

练习 2:日志行格式化

编写 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 章详解,本章直接用)。

练习 3:简易模板引擎

编写 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() 处理(注意容器值会变指针形态,练习里约定只用标量值)。