(concat (symbol-name 'GET) (encode-coding-string "我" 'utf-8)) 几个 bytes?

url.el 关于 Multibyte text in HTTP request 的一件怪事 继续讨论:

(string-bytes (concat (symbol-name 'GET) (encode-coding-string "我" 'utf-8)))
;; => 9

GET 3 个 byte,「我」3 个byte,加起来 6 byte,Emacs 却说有 9 个。用的 ASCII 却有正常了:

(string-bytes (concat (symbol-name 'GET) (encode-coding-string "foo" 'utf-8)))
;; => 6

Emacs 好多函数明明 unibyte 就行,不知道为何返回 multibyte:

(multibyte-string-p (symbol-name 'GET))
;; => t

(multibyte-string-p "GET")
;; => nil

(multibyte-string-p (url-host (url-generic-parse-url "http://example.com")))
;; => t

使用 url.el 的时候会遇到这问题,有时 multibyte 没问题,有时又不行:

url-http-create-request: Multibyte text in HTTP request

最保险做法是保证 URL url-request-method url-request-extra-headers url-request-data 全使用 unibyte,但是这有可能比较烦人,上面提到 Emacs 有时会冷不丁地返回 multibyte,更简单的做法是只有在有 url-request-data 的时候,才把全使用 unibyte,因为除了 url-request-data 其它参数都不可能包含 UTF-8 字符。

似乎都转换成utf-8之后就是正常的

(string-bytes (concat (encode-coding-string (symbol-name 'GET) 'utf-8)
                      (encode-coding-string "我" 'utf-8)))
;; => 6
1 个赞

嗯,都转换成 unibyte 之后是 OK 的,问题是不知道 、意识不到、想不通为啥 (symbol-name 'GET) 等等这也要转。

encode-coding-string decode-coding-string 我老是分不清了。

multibyte string(下称string)是字符串抽象,里面保存的是 字符 (character). unibyte string(下称bytes)是字符串编码后在计算机里的存储形式,类似其他语言的字节流。

乍一看可能不清楚,用seq-into 转化为vector或者list就能看明白

(seq-into "我能吞下玻璃而不伤身体" 'vector)
;; => [25105 33021 21534 19979 29627 29827 32780 19981 20260 36523 20307]

(seq-into (encode-coding-string "我能吞下玻璃而不伤身体" 'utf-8) 'vector)
;; =>
;; [230
;;  136
;;  145
;;  232
;;  131
;;  189
;;  229
;;  144
;;  158
;;  228
;;  184
;;  139
;;  231
;;  142
;;  187
;;  231
;;  146
;;  131
;;  232
;;  128
;;  140
;;  228
;;  184
;;  141
;;  228
;;  188
;;  164
;;  232
;;  186
;;  171
;;  228
;;  189
;;  147]

string转换成vector后,每个元素代表了该字符的码点(在Unicode字符集里的位置)。而bytes转换为vector,其元素就是用调用encode-coding-string时所指定的编码形式进行编码后的字节。

Emacs的string使用的编码是utf-8-emacs,其他编码的文件我们在打开时先转码成字符,保存时再编码成字节流写回硬盘。该编码的编码方式与utf-8一致,编码“emacs”字符集。“emacs”字符集是“unicode”字符集的超集,开辟了#x110000..#x3fffff来一些额外字符。[^1][^2][^3]其中#x3fff00..#x3fffff是非ASCII编码范围的裸字节的字符表示形式(128-255

为什么要给裸字节赋予字符码位?我们知道,UTF-8编码并非稠密的,对于任意字节流,我们不一定能将其按UTF-8编码解析为合法字符串。这块额外字符定义就是为了给无法解析为Unicode character的漏网之byte一个身份(像暂住证),使string能正常生成。

或许看起来不太合理,我们更喜欢bytes和string能分开成两个类型(或许用#b"\200\201"这样的reader syntax来表示bytes),对于非法byte转string我们或许希望直接error。但是别忘了Emacs是有40年历史的骨灰级软件,存在出于历史局限性上的设计不完美也无可厚非[^4]。


回到这个问题上,这个问题的本质就是concat在连接string和bytes时,会把bytes强转成string,而且不经任何编码,因为Emacs reader在读取字符串时,会尽量读取成unibyte string(bytes)[^5],除非遇到真正的multibyte character(如汉字)。对于unibyte string是ASCII字符的情况,这样做是符合UTF-8编码的。而如果unibyte里含有raw byte,就会直接使用#x3fff00..#x3fffff的raw byte码位。

再用seq-into大法。

(seq-into (concat (symbol-name 'GET) (encode-coding-string "我" 'utf-8)) 'vector)
;; => [71 69 84 4194278 4194184 4194193]

(seq-into (concat "GET" (encode-coding-string "我" 'utf-8)) 'vector)
;; => [71 69 84 230 136 145]

在这里(symbol-name 'GET) 返回一个multibyte string,因为Emacs内部就是保存为multibyte string,因此大意了,没有转换。而4194278 4194184 4194193都是一个22bit的数字,加上padding,约为3个bytes,因此string-bytes结果返回为9. 同理第二个因为是ASCII字符,默认读取成unibyte,concat后依然是unibyte,不转换为character,返回6.


  1. https://www.gnu.org/software/emacs/manual/html_node/elisp/Text-Representations.html

  2. 对于#x110000..#x3fff00这段码位的用途,猜测是用来处理类似GB18030对Unicode的奇怪映射规则用

  3. https://github.com/emacs-mirror/emacs/blob/master/lisp/international/mule-conf.el#L80

  4. 事实上,像Java和C++的字符串设计也饱受诟病,Java11以前的char是一个UTF-16编码的双字节, 没法处理3字节的字符,因此被迫引入“代理对”这种概念。C++ STL的std::string则完完全全是一个字节流,对字符串相关的处理毫无帮助。而新出的语言则学乖的不少(Go除外)

  5. https://github.com/emacs-mirror/emacs/blob/master/src/lread.c#L3358

10 个赞

我将我的研究发到了emacs-devel。Emacs维护者Stefan Monnier也认可了我的推论,并回答了我提出的一些问题

https://lists.gnu.org/archive/html/emacs-devel/2020-12/msg00892.html

1 个赞

是把230这个code point的字符转成了4194278?byte转string是如何产生这个结果的?

然后 1+1+1+3+3+3=12?为什么答案是9?

(string-bytes (string 4194278))
;; => 2

(string-bytes (string 4194184))
;; => 2

(string-bytes (string 4194193))
;; => 2

当时写的时候这里有点想当然了,这里应该是2 bytes


在mule-conf.el L80里定义的,在这里 https://github.com/emacs-mirror/emacs/blob/master/src/character.h#L111 实现。具体执行转换是concat执行的哈

BYTE8_TO_CHAR (int byte)
{
  return byte + 0x3FFF00;
}

是什么魔法……不过还真是这么算出来的,230+0x3FFF00=4194278

分配给 8bit 字节的代码空间是 0x3fff80~0x3fffff:

https://github.com/emacs-mirror/emacs/blob/759ec257699d734de2ba733bcc204745500b9b23/etc/NEWS.23#L2198-L2202