# 字符串表示 Deft 字符串是在字节后备存储之上按码点索引的——即 Emacs 模型。`len` 对字符计数,`$s(a..b)` 按字符切片,且切片永远不会落在字符中间;在内部,字符串是一个不可变的 UTF-8 字节数组,同时也能安全地容纳任意二进制数据。本文解释这些坐标系、为什么字节层可以通过 `bytes/*` 保持可见,以及显示宽度如何融入其中——使诸如“为什么会有三个类似长度的函数”之类的问题读起来是设计行为。 ## 一种类型,三套坐标系 | 坐标 | 单位 | 操作 | |---|---|---| | 字符索引 | 码点 | `len`, `$s(a..b)` / `slice`, `nth`, `char-at`, `index-of`, `split ""`, `reverse`, `chars/count`, `chars/slice`, `re/find` 起始/结束 | | 显示列 | 终端单元格(东亚宽 = 2) | `width`, `cells`, `truncate-width`, `pad-width-left/right`, `wrap-width` | | 字节偏移 | 原始字节 | `bytes/len`, `bytes/slice`, `byte-at` | 字符索引是编辑坐标——编辑器的光标、选区和查找偏移都是字符索引,所有字符串动词在这一点上彼此一致。显示列之所以存在,是因为单元格网格是等宽的:一个宽字形(中文)占用两个单元格,所以“屏幕上的列”与“字符索引”是不同的数字,布局动词负责计算它。字节偏移之所以存在,是因为字符串兼任二进制类型:PNG 的 `fs/read`、加密数据块、协议体。 ## 为什么索引基于码点 在此设计之前,字符串索引基于字节——继承自字节数组——这意味着对 `─`(三个 UTF-8 字节)切片会产生其首字节 `0xE2`(渲染为 `â`):一个字符可能变成三段乱码。每个使用方都必须手动弥合。这次迁移把语义移入语言本身: - `s[i]`、`$s(a..b)`、`index-of` 的结果以及 `len` 都以字符为单位——桥接消失了,编辑器的光标模型变成了普通整数,无需边界吸附。 - 乱码在结构上不可能出现:本会落在字符内部的切片边界会解析到字符的边缘。 接受的权衡是:索引一个字符位置需要把它解析为字节偏移(一次扫描——O(n);在行/标签规模下没问题,而 `len` 本身是 O(1),通过记忆在字符串对象上的计数实现,和哈希缓存完全一样)。而且“字符”是码点,不是字素簇——写作 `e` + 组合重音的 `é` 是两个字符,emoji ZWJ 序列则算多个字符。字素分割是未来显示层可能的增补,而不是索引方式的改变。 ## 二进制层 字符串可容纳任意字节,这一点不会改变(没有单独的 bytes 类型)。规则是:二进制数据使用 `bytes/*` 动词处理偏移和大小:
set bin [unwrap [fs/read "image.png"]]
set chunk [bytes/slice $bin 0 4096]   # exact bytes — never re-aligned
if ([bytes/len $bin] < 16) { ... }
原因很尖锐:二进制数据可能包含偶然的合法 UTF-8 字节序列(PNG 中的三个字节 `e4 b8 80` 会解码为 中)。在按字符切片之下,这三个字节是一个字符——按字符计数计算出的数据块边界会发生偏移并损坏数据。对二进制做字节运算就必须是字节运算。包的各个部分已经在使用的健康模式是:把二进制字符串整体传给原生函数(加密、编解码器、媒体),绝不在 Deft 中索引它们。 反过来,文本绝不应接触 `bytes/*`——它通过字符读取文本,通过宽度进行布局。 ## 不可变性与缓存 字符串在创建后不可变(单次分配:头部 + 内联字节)。有两个值以相同模式惰性地记忆在对象上:`hash`(用于映射键)和字符计数(用于 `len`)。两者在每个字符串上至多计算一次,位于拥有它的堆上,并通过 const 转换写入——这是安全的,因为该值是这些不可变字节的纯函数。 ## 渲染:字符如何变成列 TUI 宿主把文本绘制为等宽单元格组成的网格。渲染器遍历一行的 `[cells]`——对每个字符:其索引、其字符串、其显示宽度——把宽字形写成两单元格对。编辑器通过相同的单元格把光标(字符索引)映射到屏幕列;点击则反向映射。关于该流水线的图集一侧,见字体渲染。