【Rust每周一知】Rust為什麼會有String和&str?!長文預警!

本文是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程序開始,打印參數。

// in `print.c`    #include <stdio.h> // for printf    int main(int argc, char **argv) {      for (int i = 0; i < argc; i++) {          char *arg = argv[i];          printf("%sn", arg);      }        return 0;  }
$ gcc print.c -o print  $ ./print "ready" "set" "go"  ./print  ready  set  go

好的!很簡單。程序使用的是標準的C11主函數簽名,該簽名用int定義參數個數(argc,參數計數),和用char**char *[]「字符串數組」定義參數(argv,參數向量)。然後,使用printf格式說明符%s將每個參數打印為字符串,其後跟n換行符。確實,它將每個參數打印在自己的行上。

在繼續之前,請確保我們對正在發生的事情有正確的了解。修改以上的程序,使用%p格式說明符打印指針!

// in `print.c`    int main(int argc, char **argv) {      printf("argv = %pn", argv); // new!      for (int i = 0; i < argc; i++) {          char *arg = argv[i];          printf("argv[%d] = %pn", i, argv[i]); // new!          printf("%sn", arg);      }        return 0;  }
$ gcc print.c -o print  $ ./print "ready" "set" "go"  argv = 0x7ffcc35d84a8  argv[0] = 0x7ffcc35d9039  ./print  argv[1] = 0x7ffcc35d9041  ready  argv[2] = 0x7ffcc35d9047  set  argv[3] = 0x7ffcc35d904b  go

好的,argv是一個地址數組,在這些地址上有字符串數據。像這樣:

printf%s格式符怎麼知道什麼時候停止打印?因為它只獲得一個地址,而不是起始地址和結束地址,或者起始地址和長度。讓我們嘗試自己打印每個參數:

// in `print.c`    #include <stdio.h> // printf    int main(int argc, char **argv) {      for (int i = 0; i < argc; i++) {          char *arg = argv[i];          // we don't know where to stop, so let's just print 15 characters.          for (int j = 0; j < 15; j++) {              char character = arg[j];              // the %c specifier is for characters              printf("%c", character);          }          printf("n");      }        return 0;  }
$ gcc print.c -o print  $ ./print "ready" "set" "go"  ./printreadys  readysetgoCD  setgoCDPATH=.  goCDPATH=.:/ho

哦哦~我們的命令行參數相互「滲入」。讓我們嘗試將我們的程序通過管道xxd傳輸到一個十六進制的轉儲程序中,以查看發生了什麼事:

$ # note: "-g 1" means "show groups of one byte",  $ # xxd defaults to "-g 2".  $ ./print "ready" "set" "go" | xxd -g 1  00000000: 2e 2f 70 72 69 6e 74 00 72 65 61 64 79 00 73 0a  ./print.ready.s.  00000010: 72 65 61 64 79 00 73 65 74 00 67 6f 00 43 44 0a  ready.set.go.CD.  00000020: 73 65 74 00 67 6f 00 43 44 50 41 54 48 3d 2e 0a  set.go.CDPATH=..  00000030: 67 6f 00 43 44 50 41 54 48 3d 2e 3a 2f 68 6f 0a  go.CDPATH=.:/ho.

啊啊!它們確實彼此跟隨,但是兩者之間有一些區別:這是相同的輸出,用^^進行注釋的位置是分隔符:

00000000: 2e 2f 70 72 69 6e 74 00 72 65 61 64 79 00 73 0a  ./print.ready.s.            .  /  p  r  i  n  t  ^^ r  e  a  d  y  ^^

似乎每個參數都由值0來終止。確實,C具有以null終止的字符串。因此,我們可以「修復」我們的打印程序:

#include <stdio.h> // printf    int main(int argc, char **argv) {      for (int i = 0; i < argc; i++) {          char *arg = argv[i];          // note: the loop condition is gone, we just loop forever.          // well, until a 'break' at least.          for (int j = 0;; j++) {              char character = arg[j];                // technically, we ought to use '' rather than just 0,              // but even `gcc -Wall -Wextra -Wpedantic` doesn't chastise              // us, so let's just go with it.              if (character == 0) {                  break;              }              printf("%c", character);          }          printf("n");      }        return 0;  }
$ gcc print.c -o print  $ ./print "ready" "set" "go"  ./print  ready  set  go

一切都更好!雖然,我們也需要修復圖:

提示:可能已經注意到,當我們的打印程序超出參數範圍時,CDPATH=.:/ho也會顯示出來。那是(一部分)環境變量!這些都在GNU C庫glibc中程序參數旁邊。但是具體細節不在本文討論範圍之內,需要查看製作自己的可執行打包程序系列。

好的!現在我們完全了解發生了什麼,讓我們做一些更有趣的事情:將參數轉換為大寫。因此,如果我們運行./print hello,它應該打印HELLO。我們也將跳過第一個參數,因為它是程序的名稱,現在對我們而言這並不是很有趣。

#include <stdio.h> // printf  #include <ctype.h> // toupper    int main(int argc, char **argv) {      // start from 1, skips program name      for (int i = 1; i < argc; i++) {          char *arg = argv[i];          for (int j = 0;; j++) {              char character = arg[j];              if (character == 0) {                  break;              }              printf("%c", toupper(character));          }          printf("n");      }        return 0;  }
$ gcc print.c -o print  $ ./print "hello"  HELLO

好的!太好了!在我看來功能齊全,可以發貨了。出於謹慎考慮,讓我們運行最後一個測試:

$ gcc print.c -o print  $ ./print "élément"  éLéMENT

哦~我們真正想要的是「ÉLÉMENT」,但顯然,我們還沒有弄清正在發生的一切。好的,也許現在大寫字母太複雜了,讓我們做些簡單的事情:打印每個字符並用空格隔開。

// in `print.c`    #include <stdio.h> // printf    int main(int argc, char **argv) {      for (int i = 1; i < argc; i++) {          char *arg = argv[i];          for (int j = 0;; j++) {              char character = arg[j];              if (character == 0) {                  break;              }              // notice the space following `%c`              printf("%c ", character);          }          printf("n");      }        return 0;  }
$ gcc print.c -o print  $ ./print "élément"    l   m e n t

不好了。這不會做,根本不會做。讓我們回到最後一個行為良好的版本,該版本僅打印每個字符,中間沒有空格,並查看輸出的實際內容。

// in main  // in for  // in second for              printf("%c", character); // notice the lack of space after `%c`
$ gcc print.c -o print  $ ./print "élément" | xxd -g 1  00000000: c3 a9 6c c3 a9 6d 65 6e 74 0a                    ..l..ment.            ^^^^^    ^^^^^

如果正確閱讀此信息,則「é」不是一個char,實際上是2個char。好像…很奇怪。

讓我們快速編寫一個JavaScript程序,並使用Node.js運行它:

// in `print.js`    const { argv, stdout } = process;    // we have to skip *two* arguments: the path to node,  // and the path to our script  for (const arg of argv.slice(2)) {      for (const character of arg) {          stdout.write(character);          stdout.write(" ");      }      stdout.write("n");  }
$ node print.js "élément"  é l é m e n t

啊! 好多了!Node.js能正確轉換為大寫嗎?

// in `print.js`    const { argv, stdout } = process;    for (const arg of argv.slice(2)) {      stdout.write(arg.toUpperCase());      stdout.write("n");  }
$ node print.js "élément"  ÉLÉMENT

它可以。讓我們看一下十六進制轉儲:

$ node print.js "élément" | xxd -g 1  00000000: c3 89 4c c3 89 4d 45 4e 54 0a                    ..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解碼。我們仍然可以嘗試自己做。

// in `print.c`    #include <stdio.h> // printf  #include <stdint.h> // uint8_t    void print_spaced(char *s) {      // start at the beginning      int i = 0;        while (1) {          // we're going to be shifting bytes around,          // so treat them like unsigned 8-bit values          uint8_t c = s[i];          if (c == 0) {              // reached null terminator, stop printing              break;          }            // length of the sequence, ie., number of bytes          // that encode a single Unicode scalar value          int len = 1;          if (c >> 5 == 0b110) {              len = 2;          } else if (c >> 4 == 0b1110) {              len = 3;          } else if (c >> 3 == 0b11110) {              len = 4;          }            // print the entire UTF-8-encoded Unicode scalar value          for (; len > 0; len--) {              printf("%c", s[i]);              i++;          }          // print space separator          printf(" ");      }  }    int main(int argc, char **argv) {      for (int i = 1; i < argc; i++) {          print_spaced(argv[i]);          printf("n");      }        return 0;  }

沒有討論String和&str。關於Rust字符串處理的文章卻沒有Rust代碼,而且已經花了大約十分鐘!

程序有效嗎?

$ gcc print.c -o print  $ ./print "eat the rich"  e a t   t h e   r i c h
$ ./print "platée de rösti"  p l a t é e   d e   r ö s t i
$ ./print "23€ ≈ ¥2731"  2 3 €   ≈   ¥ 2 7 3 1 
$ ./print "text ? encoding"  t e x t   ?   e n c o d i n g

好吧,我不知道每個人都在抱怨什麼,UTF-8超易實現,只花了我們幾分鐘時間,而且100%正確,符合標準,永遠適用於所有輸入,並且始終做正確的事。是嗎?反例來了,考慮以下字符串:

$ echo "noe\u0308l"  noël  

這只是法國的聖誕節!當然,我們的程序可以解決此問題,而且不會費力:

$ ./print $(echo "noe\u0308l")  n o e ̈ l  

哦哦~事實上,U+0308是「組合解析」,是「僅在前一個字符上打兩個點」。實際上,如果需要,我們可以打更多的東西(以增加聖誕節的歡呼聲):

提示:顯示單個「形狀」的多個標量值的組合被稱為「字素簇」,了解更多有關內容閱讀Henri Sivonen的文章 "??‍♂️".length == 7。

另外,由於作者Amos是法國人,整篇文章都帶有Latin-1偏愛。了解更多有關內容閱讀Manish Goregaokar的文章Breaking Our Latin-1 Assumptions。

因此,也許我們的程序並未實現UTF-8編碼的所有微妙之處,但是我們已經接近了。我們現在暫時不考慮字符的組合,而將重點放在Unicode標量值上。我們想要的是:

  • 解碼我們的輸入,將其從UTF-8轉換為一系列Unicode標量值(我們將選擇uint32_t)
  • 將標量值轉換為大寫對應值
  • 重新編碼為UTF-8
  • 打印到控制台

因此,讓我們從一個decode_utf8函數開始。我們將只處理2個位元組的序列:

// in `upper.c`    #include <stdio.h> // printf  #include <stdint.h> // uint8_t, uint32_t  #include <stdlib.h> // exit    void decode_utf8(char *src, uint32_t *dst) {      int i = 0;      int j = 0;        while (1) {          uint8_t c = src[i];          if (c == 0) {              dst[j] = 0;              break; // null terminator          }            uint32_t scalar;          int len;            if (c >> 3 == 0b11110) {              fprintf(stderr, "decode_utf8: 4-byte sequences are not supported!n");              exit(1);          } if (c >> 4 == 0b1110) {              fprintf(stderr, "decode_utf8: 3-byte sequences are not supported!n");              exit(1);          } else if (c >> 5 == 0b110) {              // 2-byte sequence              uint32_t b1 = (uint32_t) src[i];              uint32_t b2 = (uint32_t) src[i + 1];              uint32_t mask1 = 0b0000011111000000;              uint32_t mask2 = 0b0000000000111111;                scalar = ((b1 << 6) & mask1) | ((b2 << 0) & mask2);              len = 2;          } else {              // 1-byte sequence              scalar = (uint32_t) c;              len = 1;          }          dst[j++] = scalar;          i += len;      }  }    int main(int argc, char **argv) {      uint32_t scalars[1024]; // hopefully that's enough      decode_utf8(argv[1], scalars);        for (int i = 0;; i++) {          if (scalars[i] == 0) {              break;          }          printf("U+%04X ", scalars[i]);      }      printf("n");        return 0;  }
$ gcc upper.c -o upper  $ ./upper "noël"  U+006E U+006F U+00EB U+006C

從邏輯上講,U+00EB應該是「ë」的代碼位置,確實是的!

它的全名是「帶Diaeresis的拉丁文小寫字母E」。因此,現在我們只需要進行反向轉換即可!

// in `upper.c`    void encode_utf8(uint32_t *src, char *dst) {      int i = 0;      int j = 0;        while (1) {          uint32_t scalar = src[i];            if (scalar == 0) {              dst[j] = 0; // null terminator              break;          }            if (scalar > 0b11111111111) {              fprintf(stderr, "Can only encode codepoints <= 0x%x", 0b11111111111);              exit(1);          }            if (scalar > 0b1111111) { // 7 bits              // 2-byte sequence                uint8_t b1 = 0b11000000 | ((uint8_t) ((scalar & 0b11111000000) >> 6));              //           2-byte marker              first 5 of 11 bits                uint8_t b2 = 0b10000000 | ((uint8_t) (scalar & 0b111111));              //           continuation               last 6 of 11 bits                dst[j + 0] = b1;              dst[j + 1] = b2;              j += 2;          } else {              // 1-byte sequence              dst[j] = (char) scalar;              j++;          }            i++;      }  }    // omitted: decode_utf8    int main(int argc, char **argv) {      uint32_t scalars[1024]; // hopefully that's enough      decode_utf8(argv[1], scalars);        for (int i = 0;; i++) {          if (scalars[i] == 0) {              break;          }          printf("U+%04X ", scalars[i]);      }      printf("n");        uint8_t result[1024]; // yolo      encode_utf8(scalars, result);        printf("%sn", result);        return 0;  }
$ gcc upper.c -o upper  $ ./upper "noël"  U+006E U+006F U+00EB U+006C  noël

太棒了!現在,我們需要的只是某種轉換表!從小寫的代碼位置到大寫的對應值。我們將編寫足以支持法語的內容:

#include <ctype.h> // toupper    int main(int argc, char **argv) {      uint32_t scalars[1024]; // hopefully that's enough      decode_utf8(argv[1], scalars);        for (int i = 0;; i++) {          if (scalars[i] == 0) {              break;          }          printf("U+%04X ", scalars[i]);      }      printf("n");        // this is the highest codepoint we can decode/encode successfully      const size_t table_size = 0b11111111111;      uint32_t lower_to_upper[table_size];      // initialize the table to just return the codepoint unchanged      for (uint32_t cp = 0; cp < table_size; cp++) {          lower_to_upper[cp] = cp;      }      // set a-z => A-Z      for (int c = 97; c <= 122; c++) { // ha.          lower_to_upper[(uint32_t) c] = (uint32_t) toupper(c);      }        // note: nested functions is a GNU extension!      void set(char *lower, char *upper) {          uint32_t lower_s[1024];          uint32_t upper_s[1024];          decode_utf8(lower, lower_s);          decode_utf8(upper, upper_s);          for (int i = 0;; i++) {              if (lower_s[i] == 0) {                  break;              }              lower_to_upper[lower_s[i]] = upper_s[i];          }      }      // set a few more      set(          "éêèàâëüöïÿôîçæœ",          "ÉÊÈÀÂËÜÖÏŸÔÎÇÆŒ"      );        // now convert our scalars to upper-case      for (int i = 0;; i++) {          if (scalars[i] == 0) {              break;          }          scalars[i] = lower_to_upper[scalars[i]];      }        uint8_t result[1024]; // yolo      encode_utf8(scalars, result);        printf("%sn", result);        return 0;  }
$ gcc upper.c -o upper  $ ./upper "Voix ambiguë d'un cœur qui, au zéphyr, préfère les jattes de kiwis"  U+0056 U+006F U+0069 U+0078 U+0020 U+0061 U+006D U+0062 U+0069 U+0067 U+0075 U+00EB U+0020 U+0064 U+0027 U+0075 U+006E U+0020 U+0063 U+0153 U+0075 U+0072 U+0020 U+0071 U+0075 U+0069 U+002C U+0020 U+0061 U+0075 U+0020 U+007A U+00E9 U+0070 U+0068 U+0079 U+0072 U+002C U+0020 U+0070 U+0072 U+00E9 U+0066 U+00E8 U+0072 U+0065 U+0020 U+006C U+0065 U+0073 U+0020 U+006A U+0061 U+0074 U+0074 U+0065 U+0073 U+0020 U+0064 U+0065 U+0020 U+006B U+0069 U+0077 U+0069 U+0073  VOIX AMBIGUË D'UN CŒUR QUI, AU ZÉPHYR, PRÉFÈRE LES JATTES DE KIWIS

傳遞字符串

首先,是C程序,C很容易!只需使用char *

// in `woops.c`    #include <stdio.h>    int len(char *s) {      int l = 0;      while (s[l]) {          l++;      }      return l;  }    int main(int argc, char **argv) {      char *arg = argv[1];      int l = len(arg);      printf("length of "%s" = %dn", arg, l);  }
$ # we're back into the parent of the "rustre" directory  $ # (in case you're following along)  $ gcc woops.c -o woops  $ ./woops "dog"  length of "dog" = 3

看到?簡單!沒什麼String/&str。回到現實。首先,這實際上不是字符串的長度。它是使用UTF-8對其進行編碼所需的位元組數。因此,例如:

$ ./woops "née"  length of "née" = 4    $ ./woops "?"  length of "?" = 4

我們不會花費一半的文章來實現UTF-8解碼器和編碼器,只是感到驚訝的是,我們無法正確地計算字符數。而且,那不是現在困擾我的事情。現在困擾我的是,編譯器沒有採取任何措施阻止我們執行此操作:

#include <stdio.h>    int len(char *s) {      s[0] = '';      return 0;  }    int main(int argc, char **argv) {      char *arg = argv[1];      int l = len(arg);      printf("length of "%s" = %dn", arg, l);  }
$ gcc woops.c -o woops  $ ./woops "some user input"  length of "" = 0

len()是正確的,將通過單元測試。通過它執行完成時,字符串的長度是零。如果沒有人願意去看len函數本身,例如,如果它在第三方庫中,或更糟的是在專有的第三方庫中,那麼調試將很有趣。當然,C有const

int len(const char *s) {      s[0] = '';      return 0;  }

但它不會通過編譯:

woops.c: In function 『len』:  woops.c:4:10: error: assignment of read-only location 『*s』      4 |     s[0] = '';        |

修改下:

int len(const char *s) {      char *S = (void *) s;      S[0] = '';      return 0;  }

現在它再次通過編譯,運行它,它會默默地覆蓋我們的輸入字符串,就像之前一樣。

Rust程序部分:

  • print程序
  • 錯誤處理
  • 迭代
  • 傳遞字符串轉換成大寫
  • 索引

print程序

讓我們看看實現打印參數,Rust程序是怎樣實現的:

$ cargo new rustre       Created binary (application) `rustre` package  $ cd rustre
fn main() {      let arg = std::env::args()          .skip(1)          .next()          .expect("should have one argument");      println!("{}", arg.to_uppercase());  }

以上內容的說明:std::env::args()返回一個Iterator字符串。skip(1)忽略程序名稱(通常是第一個參數),next()獲取迭代器中的下一個元素(第一個「實際」)參數。可能有下一個參數,也可能沒有。如果沒有,.expect(msg)通過停止程序打印msg。如果有,就有了一個Option<String>

$ cargo run      Finished dev [unoptimized + debuginfo] target(s) in 0.01s       Running `target/debug/rustre`  thread 'main' panicked at 'should have one argument', src/libcore/option.rs:1188:5  note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.

好的!因此,當我們不傳遞參數時,運行程序會有如上輸出。讓我們傳遞一些測試字符串:

$ cargo run --quiet -- "noël"  NOËL  $ cargo run --quiet -- "trans rights"  TRANS RIGHTS  $ cargo run --quiet -- "voix ambiguë d'un cœur qui, au zéphyr, préfère les jattes de kiwis"  VOIX AMBIGUË D'UN CŒUR QUI, AU ZÉPHYR, PRÉFÈRE LES JATTES DE KIWIS  $ cargo run --quiet -- "heinz große"  HEINZ GROSSE

一切都測試了!最後一個特別酷,用德語的「ß」,確實是「ss」的連字。好吧,這很複雜,但這就是要點。

錯誤處理

因此Rust的行為就像字符串是UTF-8一樣,這意味着它必須在某個時刻解碼我們的命令行參數,意味着這可能會失敗。但是,只在沒有參數的情況下看到錯誤處理,而對於參數無效的UTF-8則看不到錯誤處理。什麼是無效的UTF-8?好吧,我們已經看到「é」被編碼為「c3 e9」,所以可以這樣工作:

$ cargo run --quiet -- $(printf "\xC3\xA9")  É

我們已經看到一個雙位元組的UTF-8序列具有:

  • 在第一個位元組中指示它是一個雙位元組的序列(前三個位,110)
  • 在第二個位元組中指示它是多位元組序列的延續(前兩個位10)

如果我們開始讀取一個雙位元組的序列,然後突然停止怎麼辦?如果我們傳入了C3,但未傳入A9呢?

$ cargo run --quiet -- $(printf "\xC3")  thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: "xC3"', src/libcore/result.rs:1188:5  note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.

查看錯誤堆棧信息。

$ RUST_BACKTRACE=1 cargo run --quiet -- $(printf "\xC3")  thread 'main' panicked at 'called `Result::unwrap()` on an `Err` value: "xC3"', src/libcore/result.rs:1188:5  stack backtrace:  (cut)    13: core::result::unwrap_failed               at src/libcore/result.rs:1188    14: core::result::Result<T,E>::unwrap               at /rustc/5e1a799842ba6ed4a57e91f7ab9435947482f7d8/src/libcore/result.rs:956    15: <std::env::Args as core::iter::traits::iterator::Iterator>::next::{{closure}}               at src/libstd/env.rs:789    16: core::option::Option<T>::map               at /rustc/5e1a799842ba6ed4a57e91f7ab9435947482f7d8/src/libcore/option.rs:450    17: <std::env::Args as core::iter::traits::iterator::Iterator>::next               at src/libstd/env.rs:789    18: <&mut I as core::iter::traits::iterator::Iterator>::next               at /rustc/5e1a799842ba6ed4a57e91f7ab9435947482f7d8/src/libcore/iter/traits/iterator.rs:2991    19: core::iter::traits::iterator::Iterator::nth               at /rustc/5e1a799842ba6ed4a57e91f7ab9435947482f7d8/src/libcore/iter/traits/iterator.rs:323    20: <core::iter::adapters::Skip<I> as core::iter::traits::iterator::Iterator>::next               at /rustc/5e1a799842ba6ed4a57e91f7ab9435947482f7d8/src/libcore/iter/adapters/mod.rs:1657    21: rustre::main               at src/main.rs:2  (cut)

基本上是這樣:

  • main()
  • 我們調用Iterator.next()
  • 最後調用Result.unwrap()
  • 此時panicked

這意味着只有當我們嘗試將參數作為String獲取時,它才會出現panic。如果我們將其作為OsString,就不會panic:

fn main() {      let arg = std::env::args_os()          .skip(1)          .next()          .expect("should have one argument");      println!("{:?}", arg)  }
$ cargo run --quiet -- hello  "hello"  $ cargo run --quiet $(printf "\xC3")  "xC3"

但是它沒有.to_uppercase()方法。因為它是一個OsString,它是一系列位元組。C程序如何處理無效的UTF-8輸入?

$ ../upper $(printf "\xC3")  U+00C0 U+0043 U+0044 U+0050 U+0041 U+0054 U+0048 U+003D U+002E U+003A U+002F U+0068 U+006F U+006D U+0065 U+002F U+0061 U+006D U+006F U+0073 U+002F U+0072 U+0075 U+0073 U+0074 U+003A U+002F U+0068 U+006F U+006D U+0065 U+002F U+0061 U+006D U+006F U+0073 U+002F U+0067 U+006F U+003A U+002F U+0068 U+006F U+006D U+0065 U+002F U+0061 U+006D U+006F U+0073 U+002F U+0066 U+0074 U+006C U+003A U+002F U+0068 U+006F U+006D U+0065 U+002F U+0061 U+006D U+006F U+0073 U+002F U+0070 U+0065 U+0072 U+0073 U+006F U+003A U+002F U+0068 U+006F U+006D U+0065 U+002F U+0061 U+006D U+006F U+0073 U+002F U+0077 U+006F U+0072 U+006B  ÀCDPATH=.:/HOME/AMOS/RUST:/HOME/AMOS/GO:/HOME/AMOS/FTL:/HOME/AMOS/PERSO:/HOME/AMOS/WORK

答案是:不好。實際上一點也不好。UTF-8解碼器首先讀取C3,然後讀取下一個位元組(是空終止符),結果應為「à」。但它不再停下來,而是讀完參數末尾,直接進入環境塊,找到第一個環境變量。現在,在這種情況下,這似乎很溫和。但是如果該C程序被用作Web服務器的一部分,並且其輸出直接顯示給用戶怎麼辦?如果第一個環境變量不是CDPATH,而是 SECRET_API_TOKEN怎麼辦?那將是一場災難。

但如果命令行參數是無效的UTF-8,Rust程序就會儘早panic。如果想優雅地處理這種情況怎麼辦?可以使用OsStr::to_str,它返回一個Option值。

fn main() {      let arg = std::env::args_os()          .skip(1)          .next()          .expect("should have one argument");        match arg.to_str() {          Some(arg) => println!("valid UTF-8: {}", arg),          None => println!("not valid UTF-8: {:?}", arg),      }  }
$ cargo run --quiet -- "é"  valid UTF-8: é  $ cargo run --quiet -- $(printf "\xC3")  not valid UTF-8: "xC3"

精彩。我們學到了什麼?

在Rust中,只要你不明確地用unsafe,類型String的值永遠是有效的UTF-8。如果嘗試使用無效的UTF-8構建String,則會出現錯誤。一些程序,像std::env::args()會隱藏錯誤處理,因為錯誤的情況非常少。但它仍然會檢查錯誤,並會檢查是否發生錯誤,因為這樣做是安全的。

相比之下,C沒有字符串類型。它甚至沒有真正的字符類型。char是一個ASCII字符加上一個附加位,實際上,它只是一個帶符號的8位整數:int8_t。絕對不能保證char *其中的任何內容都是有效的UTF-8。沒有與char *關聯的編碼,只是內存中的地址。也沒有關聯的長度,計算其長度涉及找到空終止符。空終止字符也是一個嚴重的安全問題,更不用說NUL是有效的Unicode字符,因此以空字符結尾的字符串不能表示所有有效的UTF-8字符串。

迭代 Iteration

我們將如何用空格分隔字符?

fn main() {      let arg = std::env::args()          .skip(1)          .next()          .expect("should have one argument");        for c in arg.chars() {          print!("{} ", c);      }      println!()  }
$ cargo run --quiet -- "cup of tea"  c u p   o f   t e a

很簡單!讓我們嘗試使用非ASCII字符:

$ cargo run --quiet -- "23€ ≈ ¥2731"  2 3 €   ≈   ¥ 2 7 3 1  $ cargo run --quiet -- "memory safety ? please ?"  m e m o r y   s a f e t y   ?   p l e a s e   ?

一切似乎都很好。如果我們要打印Unicode標量值的數字而不是它們的字形,該怎麼辦?

fn main() {      let arg = std::env::args()          .skip(1)          .next()          .expect("should have one argument");        for c in arg.chars() {          print!("{} (U+{:04X}) ", c, c as u32);      }      println!()  }
$ cargo run --quiet -- "aimée"  a (U+0061) i (U+0069) m (U+006D) é (U+00E9) e (U+0065)

酷!如果我們想顯示其為UTF-8編碼怎麼辦?我的意思是打印單個位元組?

fn main() {      let arg = std::env::args()          .skip(1)          .next()          .expect("should have one argument");        for b in arg.bytes() {          print!("{:02X} ", b);      }      println!()  }
$ cargo run --quiet -- "aimée"  61 69 6D C3 A9 65 

有我們的"c3 a9"!很簡單。目前為止,我們還沒對類型的擔心,在我們的Rust程序中還沒有一個String&str。所以,讓我們去尋找麻煩。

傳遞字符串轉換成大寫

fn main() {      let arg = std::env::args()          .skip(1)          .next()          .expect("should have one argument");        println!("upp = {}", uppercase(arg));      println!("arg = {}", arg);  }    fn uppercase(s: String) -> String {      s.to_uppercase()  }
$ cargo build --quiet  error[E0382]: borrow of moved value: `arg`   --> src/main.rs:8:26    |  2 |     let arg = std::env::args()    |         --- move occurs because `arg` has type `std::string::String`, which does not implement the `Copy` trait  ...  7 |     println!("upp = {}", uppercase(arg));    |                                    --- value moved here  8 |     println!("arg = {}", arg);    |                          ^^^ value borrowed here after move    error: aborting due to previous error    For more information about this error, try `rustc --explain E0382`.  error: could not compile `rustre`.

哦,上帝,編譯器來了。問題在於我們將arg傳入uppercase(),然後又再次使用它。我們可以先打印arg,然後再調用uppercase()。那行得通嗎?可以。但是,假設我們就是需要先調用uppercase呢?

fn main() {      let arg = std::env::args()          .skip(1)          .next()          .expect("should have one argument");        println!("upp = {}", uppercase(arg.clone()));      println!("arg = {}", arg);  }    fn uppercase(s: String) -> String {      s.to_uppercase()  }
$ cargo run --quiet -- "dog"  upp = DOG  arg = dog

但是這有點愚蠢。為什麼我們需要克隆arg?只是傳入uppercase,我們不需要在內存中有第二個拷貝。現在在內存中,我們有:

  • arg(「dog」)
  • arg的拷貝,我們傳入uppercase()(「dog」)
  • uppercase()返回值(「DOG」)

我猜這是&str存在的意義吧?讓我們嘗試一下:

fn main() {      let arg = std::env::args()          .skip(1)          .next()          .expect("should have one argument");        println!("upp = {}", uppercase(arg));      println!("arg = {}", arg);  }    fn uppercase(s: &str) -> String {      s.to_uppercase()  }
cargo run --quiet -- "dog"  error[E0308]: mismatched types   --> src/main.rs:7:36    |  7 |     println!("upp = {}", uppercase(arg));    |                                    ^^^    |                                    |    |                                    expected `&str`, found struct `std::string::String`    |                                    help: consider borrowing here: `&arg`

根據編譯器的提示修改:

println!("upp = {}", uppercase(&arg));
$ cargo run --quiet -- "dog"  upp = DOG  arg = dog

為了使其更接近於C代碼,我們應該:

  • 分配一個「目標」
  • 傳遞「目標」到uppercase()
  • uppercase()遍歷每個字符,將其轉換為大寫,並將其附加到"目標"
fn main() {      let arg = std::env::args()          .skip(1)          .next()          .expect("should have one argument");        let mut upp = String::new();      println!("upp = {}", uppercase(&arg, upp));      println!("arg = {}", arg);  }    fn uppercase(src: &str, dst: String) -> String {      for c in src.chars() {          dst.push(c.to_uppercase());      }      dst  }
$ cargo run --quiet -- "dog"  error[E0308]: mismatched types    --> src/main.rs:14:18     |  14 |         dst.push(c.to_uppercase());     |                  ^^^^^^^^^^^^^^^^ expected `char`, found struct `std::char::ToUppercase`

ToUppercase,該結構由char上的to_uppercase方法創建,返回一個迭代器,該迭代器生成char的大寫等效項。

迭代器,知道這一點,我們可以使用for x in y

fn uppercase(src: &str, dst: String) -> String {      for c in src.chars() {          for c in c.to_uppercase() {              dst.push(c);          }      }      dst  }
$ error[E0596]: cannot borrow `dst` as mutable, as it is not declared as mutable    --> src/main.rs:15:13     |  12 | fn uppercase(src: &str, dst: String) -> String {     |                         --- help: consider changing this to be mutable: `mut dst`  ...  15 |             dst.push(c);     |             ^^^ cannot borrow as mutable

讓我們看一下String::push的聲明:

pub fn push(&mut self, ch: char)

因此dst.push(c)String::push(&mut dst, c)完全相同。根據編譯器建議修改:

fn uppercase(src: &str, mut dst: String) -> String {  	...  }
$ cargo run --quiet -- "dog"  upp = DOG  arg = dog

uppercase沒有返回值呢?

fn uppercase(src: &str, mut dst: String) {      for c in src.chars() {          for c in c.to_uppercase() {              dst.push(c);          }      }  }
cargo run --quiet -- "dog"  error[E0382]: borrow of moved value: `upp`    --> src/main.rs:10:26     |  7  |     let upp = String::new();     |         --- move occurs because `upp` has type `std::string::String`, which does not implement the `Copy` trait  8  |     uppercase(&arg, upp);     |                     --- value moved here  9  |  10 |     println!("upp = {}", upp);     |                          ^^^ value borrowed here after move

我們需要讓upp可變地借用。

fn main() {      let arg = std::env::args()          .skip(1)          .next()          .expect("should have one argument");        let mut upp = String::new();      // was just `upp`      uppercase(&arg, &mut upp);        println!("upp = {}", upp);      println!("arg = {}", arg);  }    // was `mut dst: String`  fn uppercase(src: &str, dst: &mut String) {      for c in src.chars() {          for c in c.to_uppercase() {              dst.push(c);          }      }  }
$ cargo run --quiet -- "dog"  upp = DOG  arg = dog

現在又可以使用了!可增長的字符串,這是否意味着我們可以預分配合理大小的String,然後將其重新用於多個uppercase 調用?

索引

C允許我們直接索引,Rust允許我們這樣做嗎?

fn main() {      for arg in std::env::args().skip(1) {          for i in 0..arg.len() {              println!("arg[{}] = {}", i, arg[i]);          }      }  }
$ cargo run --quiet -- "dog"  error[E0277]: the type `std::string::String` cannot be indexed by `usize`   --> src/main.rs:4:41    |  4 |             println!("arg[{}] = {}", i, arg[i]);    |                                         ^^^^^^ `std::string::String` cannot be indexed by `usize`    |    = help: the trait `std::ops::Index<usize>` is not implemented for `std::string::String`

我們不可以。我們可以先將其轉換為Unicode標量值數組,然後對其進行索引:

fn main() {      for arg in std::env::args().skip(1) {          let scalars: Vec<char> = arg.chars().collect();          for i in 0..scalars.len() {              println!("arg[{}] = {}", i, scalars[i]);          }      }  }
$ cargo run --quiet -- "dog"  arg[0] = d  arg[1] = o  arg[2] = g

是的,行得通!老實說,這樣比較好,因為我們只需要解碼一次UTF-8字符串,然後我們就可以進行隨機訪問了。這可能就是為什麼它沒有實現Index<usize>的原因。

有趣的事情:Index<Range<usize>>

fn main() {      for arg in std::env::args().skip(1) {          let mut stripped = &arg[..];          while stripped.starts_with(" ") {              stripped = &stripped[1..]          }          while stripped.ends_with(" ") {              stripped = &stripped[..stripped.len() - 1]          }          println!("     arg = {:?}", arg);          println!("stripped = {:?}", stripped);      }  }
$ cargo run --quiet -- "  floating in space   "       arg = "  floating in space   "  stripped = "floating in space"

String是堆分配的,因為它是可增長的。而&str可以從任何地方引用數據:堆,棧,甚至程序的數據段。

&str,它是不同的,它指向相同的內存區域,只是在不同的偏移量處開始和結束。實際上,我們可以使其成為一個函數:

fn main() {      for arg in std::env::args().skip(1) {          let stripped = strip(&arg);          println!("     arg = {:?}", arg);          println!("stripped = {:?}", stripped);      }  }    fn strip(src: &str) -> &str {      let mut dst = &src[..];      while dst.starts_with(" ") {          dst = &dst[1..]      }      while dst.ends_with(" ") {          dst = &dst[..dst.len() - 1]      }      dst  }

而且效果也一樣。不過,這似乎很危險。如果原始字符串的內存被釋放怎麼辦?

fn main() {      let stripped;      {          let original = String::from("  floating in space  ");          stripped = strip(&original);      }      println!("stripped = {:?}", stripped);  }
$ cargo run --quiet -- "  floating in space   "  error[E0597]: `original` does not live long enough   --> src/main.rs:5:26    |  5 |         stripped = strip(&original);    |                          ^^^^^^^^^ borrowed value does not live long enough  6 |     }    |     - `original` dropped here while still borrowed  7 |     println!("stripped = {:?}", stripped);    |                                 -------- borrow later used here

在Rust中?編譯器將檢查所有的"惡作劇"。

最後,String用範圍索引,很酷,但是..是字符範圍嗎?

fn main() {      for arg in std::env::args().skip(1) {          println!("first four = {:?}", &arg[..4]);      }  }
$ cargo run --quiet -- "want safety?"  first four = "want"  $ cargo run --quiet -- "????"  first four = "?"

位元組範圍。我以為所有Rust字符串都是UTF-8?但是使用切片,我們可以得到部分多位元組序列,或無效的UTF-8?假如:

fn main() {      for arg in std::env::args().skip(1) {          println!("first two = {:?}", &arg[..2]);      }  }
$ cargo run --quiet -- "????"  thread 'main' panicked at 'byte index 2 is not a char boundary; it is inside '?' (bytes 0..4) of `????`', src/libcore/str/mod.rs:2069:5  note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace.

那太好了。它會panic,這是安全的事情。

結束語

無論如何,這篇文章已經很長了。希望它對Rust中的字符串處理有足夠的介紹,以及Rust為什麼同時具有String和&str。

答案當然依舊是安全性,正確性和性能。

在我們編寫的最後一個Rust字符串操作程序時,確實遇到了障礙,但是它們主要是編譯時錯誤或panic。我們沒有一次:

  • 從無效地址讀取
  • 寫入無效的地址
  • 忘了釋放東西
  • 覆蓋了其他一些數據
  • 需要一個額外的工具來告訴我們問題出在哪裡

而且,加上令人驚嘆的編譯器信息以及社區,這就是Rust的美。

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