本文是Amos博客文章“Working with strings in Rust”的翻译。

原文地址:https://fasterthanli.me/blog/2020/working-with-strings-in-rust/

人们选择Rust编程语言时总会遇到一个问题:为什么会有两种字符串类型?为什么会出现String和&str?

Amos在其另一篇文章"declarative-memory-management"中部分回答了这个问题。但是在本文中又进行了一些实验,看看是否可以为Rust的做法“辩护”。文章主要分为C和Rust两大部分。

C语言部分:

  • print程序示例

  • UTF-8编码

  • print程序处理UTF-8编码

  • 传递字符串

C语言的print程序示例

让我们从简单C程序开始,打印参数。


#  

 ( argc,  **argv) {
     ( i = ; i < argc; i++) {
         *arg = argv[i];
        (, arg);
    }

     ;
}
$ gcc print.c -o print
$ ./print   
./print
ready
set
go

好的!很简单。程序使用的是标准的C11主函数签名,该签名用int定义参数个数(argc,参数计数),和用char**char *[]“字符串数组”定义参数(argv,参数向量)。然后,使用printf格式说明符%s将每个参数打印为字符串,其后跟\n换行符。确实,它将每个参数打印在自己的行上。

在继续之前,请确保我们对正在发生的事情有正确的了解。修改以上的程序,使用%p格式说明符打印指针!


 ( argc,  **argv) {
    (, argv); 
     ( i = ; i < argc; i++) {
         *arg = argv[i];
        (, i, argv[i]); 
        (, arg);
    }

     ;
}
$ gcc print.c -o print
$ ./print   
argv = 
argv[] = 
./print
argv[] = 
ready
argv[] = 
set
argv[] = 
go

好的,argv是一个地址数组,在这些地址上有字符串数据。像这样:

printf%s格式符怎么知道什么时候停止打印?因为它只获得一个地址,而不是起始地址和结束地址,或者起始地址和长度。让我们尝试自己打印每个参数:


#  

 ( argc,  **argv) {
     ( i = ; i < argc; i++) {
         *arg = argv[i];
        
         ( j = ; j < ; j++) {
             character = arg[j];
            
            (, character);
        }
        ();
    }

     ;
}
$ gcc print.c -o print
$ ./print   
./printreadys
readysetgoCD
setgoCDPATH=.
goCDPATH=.:/ho

哦哦~我们的命令行参数相互“渗入”。让我们尝试将我们的程序通过管道xxd传输到一个十六进制的转储程序中,以查看发生了什么事:

$ # note:  means ,
$ # xxd defaults to .
$ ./print    | xxd -g 
: 2e     6e          0a  ./print.ready.s.
:                0a  ready.set.go.CD.
:              3d 2e 0a  set.go.CDPATH=..
:          3d 2e 3a    0a  go.CDPATH=.:/ho.

啊啊!它们确实彼此跟随,但是两者之间有一些区别:这是相同的输出,用^^进行注释的位置是分隔符:

: 2e     6e          0a  ./print.ready.s.
          .  /  p  r  i  n  t  ^^ r  e  a  d  y  ^^

似乎每个参数都由值0来终止。确实,C具有以null终止的字符串。因此,我们可以“修复”我们的打印程序:

#  

 ( argc,  **argv) {
     ( i = ; i < argc; i++) {
         *arg = argv[i];
        
        
         ( j = ;; j++) {
             character = arg[j];

            
            
            
             (character == ) {
                ;
            }
            (, character);
        }
        ();
    }

     ;
}
$ gcc print.c -o print
$ ./print   
./print
ready
set
go

一切都更好!虽然,我们也需要修复图:

提示:可能已经注意到,当我们的打印程序超出参数范围时,CDPATH=.:/ho也会显示出来。那是(一部分)环境变量!这些都在GNU C库glibc中程序参数旁边。但是具体细节不在本文讨论范围之内,需要查看制作自己的可执行打包程序系列。

好的!现在我们完全了解发生了什么,让我们做一些更有趣的事情:将参数转换为大写。因此,如果我们运行./print hello,它应该打印HELLO。我们也将跳过第一个参数,因为它是程序的名称,现在对我们而言这并不是很有趣。

#  
#  

 ( argc,  **argv) {
    
     ( i = ; i < argc; i++) {
         *arg = argv[i];
         ( j = ;; j++) {
             character = arg[j];
             (character == ) {
                ;
            }
            (, (character));
        }
        ();
    }

     ;
}
$ gcc print.c -o print
$ ./print 
HELLO

好的!太好了!在我看来功能齐全,可以发货了。出于谨慎考虑,让我们运行最后一个测试:

$ gcc print.c -o print
$ ./print 
éLéMENT

哦~我们真正想要的是“ÉLÉMENT”,但显然,我们还没有弄清正在发生的一切。好的,也许现在大写字母太复杂了,让我们做些简单的事情:打印每个字符并用空格隔开。


#  

 ( argc,  **argv) {
     ( i = ; i < argc; i++) {
         *arg = argv[i];
         ( j = ;; j++) {
             character = arg[j];
             (character == ) {
                ;
            }
            
            (, character);
        }
        ();
    }

     ;
}
$ gcc print.c -o print
$ ./print 
  l   m e n t

不好了。这不会做,根本不会做。让我们回到最后一个行为良好的版本,该版本仅打印每个字符,中间没有空格,并查看输出的实际内容。



            (, character);
$ gcc print.c -o print
$ ./print  | xxd -g 
: c3 a9 6c c3 a9 6d  6e  0a                    ..l..ment.
          ^^^^^    ^^^^^

如果正确阅读此信息,则“é”不是一个char,实际上是2个char。好像...很奇怪。

让我们快速编写一个JavaScript程序,并使用Node.js运行它:


  argv stdout
$ node print.js "élément"
é l é m e n t

啊! 好多了!Node.js能正确转换为大写吗?


  argv stdout
$ node print.js "élément"
ÉLÉMENT

它可以。让我们看一下十六进制转储:

    |   
:                              
          ^^^^^    ^^^^^

虽然Node.js程序行为与预期相同,但我们可以看到,É也与其他字母不同,“c3 a9”的大写字母对应为“c3 89”。

C程序没有正常工作,因为它将“c3”和“a9”独立对待,它应将其看作一个单一的“Unicode值”。为什么将“é”编码为“c3 a9”?现在是时候进行快速的UTF-8编码入门了。

快速的UTF-8入门

“abcdefghijklmnopqrstuvwxyz”,“ABCDEFGHIJKLMNOPQRSTUVWXYZ”和“123456789”以及“!@#$%^&*()”等字符都有对应的数字值。例如,“A”的数字值是65。为什么会这样呢?这是个惯例,计算机只知道数字,而我们经常使用字节作为最小单位,因此很久以前人们决定,如果一个字节的值为65,则它表示字母“A”。

由于ASCII是7位编码,因此它具有128个可能的值:0到127(含0)。但是在现代机器上,一个字节为8位,因此还有“另外”128个可能的值。大家都以为。我们可以在其中填充“特殊字符”:

不只是ASCII,而是ASCII加我们选择的128个字符。当然有很多语言,因此并非每种语言的非ASCII字符都可以容纳这些额外的128个值,因此对于那些大于127的值,有几种替代的解释。这些解释被称为“代码页”。上面的图片是Codepage 437,也称为CP437,OEM-US,OEM 437,PC-8或DOS Latin US。

如果不关心大写字母,那么对于法语这样的语言来说已经足够了。但是对所有东欧语言,这是不够的,甚至一开始没覆盖亚洲语言。因此,日本想出了自己的办法,他们用日元符号代替了ASCII的反斜杠,并用上划线代替了波浪号,并引入了双字节字符,因为有128个额外的字符对他们来说还不够。

对于使用小字母的语言,人们使用诸如Windows-1252之类的代码页已有多年了,西方世界中的大多数文本仍然有点像ASCII,也称为“扩展ASCII”。但是最终,世界集体开始整理他们的事务,并决定采用UTF-8,该UTF-8:

  • 看起来像ASCII字符的ASCII(未扩展),并且使用相同的空格。

  • 允许更多的字符,多字节序列。

在这之前人们会问:两个字节还不够吗?(或者是两个双字节字符的序列?),当然也可以是四个字节,但是最终,由于诸如紧凑性之类的重要原因,并为使大多数C程序保持half-broken而不是完全不可用,采用了UTF-8。

除了微软。他们做了,但感觉太少,太迟了。内部一切仍然是UTF-16。RIP。

那么,ASCII加多字节字符序列,它如何工作?相同的基本原理,每个字符都有一个值,因此在Unicode中,“é”的数字是“e9”,我们通常这样写“U+00E9”。0xE9是十进制,其大于127,所以它不是ASCII 233,而我们需要做多字节编码。

UTF-8如何进行多字节编码?使用位序列!

  • 如果一个字节以110开头,则意味着我们需要两个字节

  • 如果一个字节以1110开头,则意味着我们需要三个字节

  • 如果一个字节以11110开头,则意味着我们需要四个字节

  • 如果一个字节以10开头,则表示它是多字节字符序列的延续。

因此,对于具有“U+00E9”的“é”,其二进制表示形式为“11101001”,并且我们知道我们将需要两个字节,因此我们应该具有以下内容:

我们可以看到两个字节的UTF-8序列为我们提供11位存储空间:第一个字节为5位,第二个字节为6位。我们只需要8位,因此我们从右到左填充它们,首先是最后6位:

然后是剩下的2位:

其余的位填充零:

大功告成!0b11000011是0xC3和0b10101001是0xA9。与我们之前看到的相对应:“é”是“c3 a9”。

返回C的print程序

所以C程序,如果要真正分离字符,则必须进行一些UTF-8解码。我们仍然可以尝试自己做。


#  
#  

 ( *s) {
    
     i = ;

     () {
        
        
         c = s[i];
         (c == ) {
            
            ;
        }

        
        
         len = ;
         (c >>  == ) {
            len = ;
        }   (c >>  == ) {
            len = ;
        }   (c >>  == ) {
            len = ;
        }

        
         (; len > ; len--) {
            (, s[i]);
            i++;
        }
        
        ();
    }
}

 ( argc,  **argv) {
     ( i = ; i < argc; i++) {
        (argv[i]);
        ();
    }

     ;
}

没有讨论String和&str。关于Rust字符串处理的文章却没有Rust代码,而且已经花了大约十分钟!

程序有效吗?

$ gcc print.c -o print
$ ./print 
e a t   t h e   r i c h
$ ./print 
p l a t é e   d e   r ö s t i
$ ./print 
  €   ≈   ¥
$ ./print 
t e x t