string 复杂在哪?(字符串与Unicode编码)

--- create 6:58 PM 2/3/2023

--- update 6:58 PM 2/3/2023

 更干净的文章链接:string 复杂在哪? (qq.com)

--- 0 ---

在编程语言中,string都比看起来要复杂,它让编程语言在设计时需要做出很多妥协,从而使编程中固有的一致性被打破。

很多语言在设计string组件时,往往以vector<char>做底本,但是又不能完全遵守vector组件的接口,以index操作为例:


// cpp
...
std::string str{"hello cpp"};
std::cout << str[0];  // h

这种操作在cpp中是允许的,但这时书的侧栏往往会有一个大大的叹号,告诉你这样做往往出乎你的意料,这是有道理的;先来看看更为激进 ; ) 一些的语言是怎样对待string-index操作的:


// rust
...
let str = String::from("hello rust");
println!("{}", str[0]);  // compile error !!!

 rust基本上是把安全刻在了语言的语法里,所以rust编译器会直接拒绝这种string-index操作。我们接着写一种rust可能接受的string操作方式:


// 【1】accept
...
let str = String::from("hello rust");  
let str_slice = &str[..1];
println!("{str_slice}");  // h

// 【2】reject
let cjk_str: String = String::from("你好");
let str_slice = &cjk_str[..1];
println!("{str_slice}");  // error: thread 'main' panicked at 'byte index 1 is not a char boundary;...

// 【3】accept
let str_slice = &cjk_str[..3];
println!("{str_slice}");  // 你

与string-index不同,rust编译器支持string-slice操作,但是某些时候会编译失败(上面代码情况【2】),给我们的错误是索引1处并不是一个字符(char)边界。这里的字符并非指一个单字节的char,更确切的说是一个人类认可的一个字符单位(Grapheme Clusters)。

A grapheme cluster is a sequence of one or more Unicode code points that should be treated as a single unit by various processes: Text-editing software should generally allow placement of the cursor only at grapheme cluster boundaries.

Unicode Demystified by Richard Gillam

--- 1 ---

大家不必要看懂上面的语言细节,但我们可以感觉到string在语言中是一个区别的对待。另外,我们应该能提出两个问题:

- 为什么有些语言(cpp...)支持string-index操作,却给你一个小心使用的警告,而另一些语言(如rust)干脆禁止你如此操作?

- rust的string-slice操作我们不必理会,但rust编译器给出的错误提示是有价值的,这里贴一个完整的输出:

 


thread 'main' panicked at 'byte index 1 is not a char boundary; it is inside '你' (bytes 0..3) of `你好`'

第二个问题,边界(boundary)是什么含义,在字符串"你好"中,为什么它否定我们写的[0..1]却给定[0..3]是正确的边界?

理解了这两个问题,我们对string的认识可能会更进一步。

--- 2 --- 

世界上文字符号众多,于是我们有了unicode,伴随着unicode的不同实现(utf8,utf16,utf32...),于是混沌产生了...,没错string问题终究还是编码问题。

Charles Petzold在《Code》一书中从晚上睡不着的孩子们使用手电开关来携带交流的信息开始,引出编码,而现在最流行的编码方案无疑是unicode。在ASCII时代,计算机用8位(1字节)来为字母编码,大家或多或少都知道了,这对于世界范围内的符号完全不够,何况神秘的东方还有CJK(中日韩象形文字字符)。

- 1字节不够就再加1个字节,我们总共需要几个字节?

- 同一个编码方案里,对所有不同字符的编码字节数必须一样吗?

utf8给出的回答是,可以多借用几个字节(1~6字节),而且不同字符可以用不同的字节数编码(变字节编码)。比如,我们仍然用1个字节来编码原来ASCII的那些字符,这样就保证与ASCII完全兼容;用2个字节表示字符集更大一些的语言,像中文这种更庞大的字符集使用3个字节表示;......

--- 3 ---

看看开头那段cpp代码:


// cpp
...
// 【1】
std::string str{"hello cpp"};
std::cout << str[0];  // h

// 【2】乱码(utf8下)
std::string cjk_str{ "你好" };
std::cout << cjk_str[0] << '\n';  // �
printf_s("0x%X\n", static_cast<uint8_t>(cjk_str[0]));  // 0xE4

// 【3】
const int utf8_han_code_len = 3;
std::string han_ni(cjk_str.cbegin(), cjk_str.cbegin() + utf8_han_code_len);
std::cout << han_ni << '\n';  // 你

小心使用的警告主要针对第二种情况,我们或许想用【2】的代码来期待它输出汉字“你”,问题在于,utf8编码下汉字是占3个字节的,cjk_str[0]这种string-index方式只会拿到一个字节,在utf8编码下,3个字节编码的有效符号中的其中一个字节一定是一个无意义符号(乱码)。

【3】中的代码是我们上面描述的验证。如果你在做这个试验,一定要确保你的输出控制台所用编码也是utf8,否则就像你用英语程序去翻译俄语文件,结果一定不是你想要的。同时,【3】的cpp代码片段也等同于开头的rust string-slice的【3】accept代码。


std::string cjk_str{ "你好" };

for (char8_t c : cjk_str)
  printf_s("0x%X, ", c);
// bytes: 0xE4, 0xBD, 0xA0, 0xE5, 0xA5, 0xBD, 
// oct: 228, 189, 160, 229, 165, 189,

for (auto c : cjk_str)
  std::cout << c << ", ";
// Unicode scalar values: �, �, �, �, �, �, 

const int utf8_han_code_len = 3;
for (size_t i = 0; i < cjk_str.length(); i += utf8_han_code_len)
  std::cout << std::string(cjk_str.cbegin() + i, cjk_str.cbegin() + i + utf8_han_code_len) << ", ";
// grapheme clusters: 你, 好,
// 注意输出控件台编码也应该是utf8,windows控件台默认编码一般为gbk

仔细看上面的代码输出,bytes,Unicode scalar values和grapheme clusters是理解unicode编码的关键。在进一步解释之前,我们还需要看点另一种unicode实现——utf16的代码。


std::u16string cjk_u16str(u"你好");

for (char16_t c : cjk_u16str)
  printf_s("0x%X, ", c);
// double-bytes: 0x4F60, 0x597D,
// oct: 20320, 22909,

bytesUnicode scalar valuesgrapheme clusters建立了从原始数据到人类文字符号的纵向理解过程,任何文字符号的底层数据都是用bytes表达的;不同的unicode实现(utf8,utf16,utf32...)提出了自己看待这些原始字节的方式,这引出了Unicode scalar values概念,它表示的是不同实现方式对字节数解释的最小单位,所以这个概念的另一个名字叫code unit(编码单元),对于utf8实现,一个文字符号可能由1~6个字节编码,所以utf8的Unicode scalar values是8位,对于utf16(看上面的代码),最小编码字节数是2个字节,所以它的code unit为16;grapheme clusters是代表一个个文字符号,如'你','好','a','b'等。从宏观来看待,编码的目的是给每个文字符号一个唯一对应的数字id,这个数字id在unicode中也叫code point(码点),所以:


0xE4, 0xBD, 0xA0, 0xE5, 0xA5, 0xBD   bytes
----|------|-----|-----|-----|----   unicode scalar values/code unit
|
v
0xE4, 0xBD, 0xA0, 0xE5, 0xA5, 0xBD
\--------------/  \--------------/   code point
|
v
"你好" 
--|--   grapheme clusters
^
|
0x4F60, 0x597D,
\----/  \----/  code point
^
|
0x4F, 0x60, 0x59, 0x7D, 
\--------/  \--------/  unicode scalar values/code unit(为了解释而拆开)
^
|
0x4F, 0x60, 0x59, 0x7D, 
----|-----|-----|------   bytes

有了上面冗长的背景,现在让我们回想开头rust代码string-slice引出的问题,为什么编译器会给出[..1]是一个无效边界而[..3]才是"你好"的一个有效边界,其实在上面我们间接地回答了这个问题,rust中所有字符串使用utf8编码,中文在此编码方式下的code point(回忆一下?)是3,所以boundary(边界)是unicode码点的边界,更确切的说是grapheme clusters的边界。

让我们多想一点,rust语言真的没有提供string-index的能力吗,就算rust的String使用utf8这种变字节长度的编码,从可以正确格式化输出字符串来看,rust肯定知道utf8编码的边界,从utf8这种编码来看,它必定提供了定界能力,否则这种编码也失去意义。

rust不提供有意义的string-index操作或许在于这会给我们一种性能上的错觉,从utf8编码的字符串中准确索引出你想要的那个字符需要从字符串的开头数出来(O(n)),这与index操作的常量时间复杂度的常识相悖,这种似是而非的特性正是rust极力避免的。多说一句,如果rust选择使用utf32作为字符串的编码,的确可以提供常量级的string-index操作,但这样又会导致空间的浪费。

Everything is a trade-off in language design.

by Steve Klabnik

参考:

https://stackoverflow.com/questions/27331819/whats-the-difference-between-a-character-a-code-point-a-glyph-and-a-grapheme

https://www.compart.com/en/unicode/U+4F60      

https://www.compart.com/en/unicode/U+597D      

---

之后会逐步在公众号上写(而不是在此平台上),感兴趣的话可以关注:

Dawo

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值