Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

เก็บข้อความ UTF-8 ด้วย String

เราพูดถึง string ในบทที่ 4 แต่เราจะดูในรายละเอียดเพิ่มเติมตอนนี้ Rustacean ใหม่มักติดที่ string ด้วยเหตุผลสามอย่างรวมกัน — แนวโน้มของ Rust ในการเปิดเผย error ที่เป็นไปได้, string เป็นโครงสร้างข้อมูลที่ซับซ้อนกว่า โปรแกรมเมอร์หลายคนให้เครดิตมัน, และ UTF-8 ปัจจัยเหล่านี้รวมกันในแบบที่ดู ยากเมื่อคุณมาจากภาษาโปรแกรมอื่น

เราพูดถึง string ในบริบทของ collection เพราะ string ถูก implement เป็น collection ของ byte บวกบางเมธอดเพื่อให้ functionality ที่มีประโยชน์เมื่อ byte เหล่านั้นถูกตีความเป็นข้อความ ในส่วนนี้เราจะพูดถึง operation บน String ที่ทุก collection type มี เช่นการสร้าง, update และอ่าน เราจะ พูดถึงวิธีที่ String ต่างจาก collection อื่น คือวิธีที่การ index เข้า String ซับซ้อนเพราะความต่างระหว่างวิธีที่คนและคอมพิวเตอร์ตีความข้อมูล String

นิยาม String

เราจะนิยามก่อนว่าเราหมายถึงอะไรด้วยคำว่า string Rust มี type string เดียวในภาษาแกน ซึ่งคือ string slice str ที่มักเห็นในรูป borrow ของมัน &str ในบทที่ 4 เราพูดถึง string slice ซึ่งเป็น reference ของข้อมูล string ที่ encode UTF-8 ที่เก็บที่อื่น String literal ถูกเก็บใน binary ของโปรแกรม จึงเป็น string slice

Type String ที่ standard library ของ Rust ให้ ไม่ใช่ถูก code เข้าไป ภาษาแกน เป็น string type ที่เติบโตได้, mutable, owned และ encode UTF-8 เมื่อ Rustacean อ้างถึง “string” ใน Rust พวกเขาอาจหมายถึง type String หรือ string slice &str ไม่ใช่แค่ตัวเดียว แม้ส่วนนี้ส่วนใหญ่เกี่ยวกับ String ทั้งสอง type ใช้บ่อยใน standard library ของ Rust และทั้ง String และ string slice encode UTF-8

สร้าง String ใหม่

operation จำนวนมากที่เหมือนกันที่มีกับ Vec<T> มีกับ String ด้วย เพราะ String จริง ๆ ถูก implement เป็น wrapper รอบ vector ของ byte พร้อมการรับประกัน, ข้อจำกัด และ capability เพิ่มเติม ตัวอย่างฟังก์ชันที่ ทำงานแบบเดียวกันกับ Vec<T> และ String คือฟังก์ชัน new เพื่อสร้าง instance ดังที่แสดงใน Listing 8-11

fn main() {
    let mut s = String::new();
}
Listing 8-11: สร้าง String ว่างใหม่

บรรทัดนี้สร้าง string ว่างใหม่ชื่อ s ที่เรา load ข้อมูลเข้าได้ บ่อย ครั้งเราจะมีข้อมูลเริ่มต้นที่เราอยากเริ่ม string ด้วย สำหรับนั้น เราใช้ เมธอด to_string ซึ่งมีให้บน type ใดที่ implement trait Display เหมือนที่ string literal ทำ Listing 8-12 แสดงสองตัวอย่าง

fn main() {
    let data = "initial contents";

    let s = data.to_string();

    // The method also works on a literal directly:
    let s = "initial contents".to_string();
}
Listing 8-12: ใช้เมธอด to_string สร้าง String จาก string literal

โค้ดนี้สร้าง string ที่มี initial contents

เรายังใช้ฟังก์ชัน String::from สร้าง String จาก string literal ได้ โค้ดใน Listing 8-13 เทียบเท่ากับโค้ดใน Listing 8-12 ที่ใช้ to_string

fn main() {
    let s = String::from("initial contents");
}
Listing 8-13: ใช้ฟังก์ชัน String::from สร้าง String จาก string literal

เพราะ string ใช้สำหรับหลายสิ่ง เราใช้ generic API ต่าง ๆ มากมายสำหรับ string ได้ ให้ตัวเลือกเราเยอะ บางอันอาจดูซ้ำซ้อน แต่ทั้งหมดมีที่ของมัน! ในกรณีนี้ String::from และ to_string ทำสิ่งเดียวกัน เลือกตัวไหนจึง เป็นเรื่องสไตล์และความอ่านง่าย

จำไว้ว่า string encode UTF-8 เรารวมข้อมูลที่ encode ถูกต้องใด ๆ ในนั้น ได้ ดังที่แสดงใน Listing 8-14

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}
Listing 8-14: เก็บคำทักทายในภาษาต่าง ๆ ใน string

ทั้งหมดเหล่านี้เป็นค่า String ที่ valid

Update String

String เติบโตในขนาดและเนื้อหาของมันเปลี่ยนได้ เหมือนเนื้อหาของ Vec<T> ถ้าคุณ push ข้อมูลเพิ่มเข้ามัน นอกจากนี้ คุณใช้ operator + หรือ macro format! เพื่อต่อค่า String ได้สะดวก

ต่อท้ายด้วย push_str หรือ push

เราขยาย String โดยใช้เมธอด push_str เพื่อต่อ string slice ดังที่แสดง ใน Listing 8-15

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}
Listing 8-15: ต่อ string slice เข้า String ด้วยเมธอด push_str

หลังสองบรรทัดนี้ s จะมี foobar เมธอด push_str รับ string slice เพราะเราไม่จำเป็นต้องรับ ownership ของ parameter เช่น ในโค้ดใน Listing 8-16 เราอยากใช้ s2 หลังต่อเนื้อหาของมันเข้า s1

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {s2}");
}
Listing 8-16: ใช้ string slice หลังต่อเนื้อหาเข้า String

ถ้าเมธอด push_str รับ ownership ของ s2 เราจะพิมพ์ค่าในบรรทัดสุดท้าย ไม่ได้ อย่างไรก็ตาม โค้ดนี้ทำงานตามที่เราคาด!

เมธอด push รับอักขระเดียวเป็น parameter และเพิ่มมันเข้า String Listing 8-17 เพิ่มตัวอักษร l เข้า String โดยใช้เมธอด push

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}
Listing 8-17: เพิ่มอักขระหนึ่งเข้าค่า String โดยใช้ push

ผลลัพธ์ s จะมี lol

ต่อด้วย + หรือ format!

บ่อยครั้งคุณจะอยากรวม string สองตัวที่มีอยู่ วิธีหนึ่งในการทำคือใช้ operator + ดังที่แสดงใน Listing 8-18

fn main() {
    let s1 = String::from("Hello, ");
    let s2 = String::from("world!");
    let s3 = s1 + &s2; // note s1 has been moved here and can no longer be used
}
Listing 8-18: ใช้ operator + รวมค่า String สองตัวเป็นค่า String ใหม่

string s3 จะมี Hello, world! เหตุผลที่ s1 ไม่ valid อีกหลังการบวก และเหตุผลที่เราใช้ reference ของ s2 เกี่ยวกับ signature ของเมธอดที่ ถูกเรียกเมื่อเราใช้ operator + Operator + ใช้เมธอด add ซึ่ง signature ดูประมาณนี้:

fn add(self, s: &str) -> String {

ใน standard library คุณจะเห็น add ประกาศโดยใช้ generic และ associated type ที่นี่เราแทนด้วย type คอนกรีต ซึ่งเป็นสิ่งที่เกิดขึ้นเมื่อเราเรียก เมธอดนี้ด้วยค่า String เราจะพูดถึง generic ในบทที่ 10 Signature นี้ให้ เบาะแสที่เราต้องการเพื่อเข้าใจส่วนยาก ๆ ของ operator +

ขั้นแรก s2 มี & หมายความว่าเรากำลังเพิ่ม reference ของ string ที่สอง เข้า string แรก นี่เพราะ parameter s ในฟังก์ชัน add — เราเพิ่มได้แค่ string slice เข้า String เราเพิ่มค่า String สองตัวเข้าด้วยกันไม่ได้ แต่เดี๋ยว — type ของ &s2 คือ &String ไม่ใช่ &str ตามที่ระบุใน parameter ที่สองของ add แล้วทำไม Listing 8-18 compile ผ่าน?

เหตุผลที่เราใช้ &s2 ในการเรียก add ได้คือ compiler บีบ argument &String เป็น &str ได้ เมื่อเราเรียกเมธอด add Rust ใช้ deref coercion ซึ่งที่นี่เปลี่ยน &s2 เป็น &s2[..] เราจะพูดถึง deref coercion ในรายละเอียดเพิ่มเติมในบทที่ 15 เพราะ add ไม่รับ ownership ของ parameter s s2 จะยังเป็น String valid หลัง operation นี้

ขั้นที่สอง เราเห็นใน signature ว่า add รับ ownership ของ self เพราะ self ไม่ มี & นี่หมายความว่า s1 ใน Listing 8-18 จะถูก move เข้า การเรียก add และจะไม่ valid หลังจากนั้น ดังนั้นแม้ let s3 = s1 + &s2; ดูเหมือนจะคัดลอกทั้งสอง string และสร้างใหม่ statement นี้จริง ๆ รับ ownership ของ s1 ต่อสำเนาเนื้อหาของ s2 แล้ว return ownership ของผล พูดอีกอย่าง มันดูเหมือนทำสำเนาเยอะ แต่ไม่ — implementation มีประสิทธิภาพ กว่าการคัดลอก

ถ้าเราต้องต่อ string หลายตัว พฤติกรรมของ operator + กลายเป็นไม่สะดวก:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = s1 + "-" + &s2 + "-" + &s3;
}

ณ จุดนี้ s จะเป็น tic-tac-toe ด้วยอักขระ + และ " ทั้งหมด ยากที่ จะเห็นสิ่งที่เกิดขึ้น สำหรับการรวม string ในแบบที่ซับซ้อนกว่า เราใช้ macro format! แทนได้:

fn main() {
    let s1 = String::from("tic");
    let s2 = String::from("tac");
    let s3 = String::from("toe");

    let s = format!("{s1}-{s2}-{s3}");
}

โค้ดนี้ก็ set s เป็น tic-tac-toe macro format! ทำงานเหมือน println! แต่แทนการพิมพ์ output ออกหน้าจอ มัน return String ที่มี เนื้อหา version ของโค้ดที่ใช้ format! อ่านง่ายกว่ามาก และโค้ดที่ generate โดย macro format! ใช้ reference การเรียกนี้จึงไม่รับ ownership ของ parameter ใด ๆ

Index เข้า String

ในภาษาโปรแกรมอื่นหลายภาษา การเข้าถึงอักขระแต่ละตัวใน string โดยอ้างถึง ด้วย index เป็น operation ที่ valid และใช้บ่อย อย่างไรก็ตาม ถ้าคุณ พยายามเข้าถึงส่วนของ String โดยใช้ indexing syntax ใน Rust คุณจะได้ error พิจารณาโค้ด invalid ใน Listing 8-19

fn main() {
    let s1 = String::from("hi");
    let h = s1[0];
}
Listing 8-19: พยายามใช้ indexing syntax กับ String

โค้ดนี้จะให้ error ต่อไปนี้:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
error[E0277]: the type `str` cannot be indexed by `{integer}`
 --> src/main.rs:3:16
  |
3 |     let h = s1[0];
  |                ^ string indices are ranges of `usize`
  |
  = help: the trait `SliceIndex<str>` is not implemented for `{integer}`
  = note: you can use `.chars().nth()` or `.bytes().nth()`
          for more information, see chapter 8 in The Book: <https://doc.rust-lang.org/book/ch08-02-strings.html#indexing-into-strings>
  = help: the following other types implement trait `SliceIndex<T>`:
            `usize` implements `SliceIndex<ByteStr>`
            `usize` implements `SliceIndex<[T]>`
  = note: required for `String` to implement `Index<{integer}>`

For more information about this error, try `rustc --explain E0277`.
error: could not compile `collections` (bin "collections") due to 1 previous error

Error บอกเรื่อง — string ของ Rust ไม่รองรับ indexing แต่ทำไม? ในการตอบ คำถามนั้น เราต้องพูดถึงวิธีที่ Rust เก็บ string ในหน่วยความจำ

Representation ภายใน

String คือ wrapper ของ Vec<u8> มาดู string ตัวอย่างที่ encode UTF-8 อย่างถูกต้องของเราจาก Listing 8-14 ก่อน ตัวนี้:

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

ในกรณีนี้ len จะเป็น 4 หมายความว่า vector ที่เก็บ string "Hola" ยาว 4 byte แต่ละตัวอักษรเหล่านี้กิน 1 byte เมื่อ encode ใน UTF-8 อย่างไร ก็ตาม บรรทัดต่อไปนี้อาจทำให้คุณประหลาดใจ (หมายเหตุว่า string นี้ขึ้นต้น ด้วยตัวอักษร Cyrillic capital Ze ไม่ใช่เลข 3):

fn main() {
    let hello = String::from("السلام عليكم");
    let hello = String::from("Dobrý den");
    let hello = String::from("Hello");
    let hello = String::from("שלום");
    let hello = String::from("नमस्ते");
    let hello = String::from("こんにちは");
    let hello = String::from("안녕하세요");
    let hello = String::from("你好");
    let hello = String::from("Olá");
    let hello = String::from("Здравствуйте");
    let hello = String::from("Hola");
}

ถ้าถามว่า string ยาวเท่าไร คุณอาจตอบ 12 จริง ๆ คำตอบของ Rust คือ 24 — นั่นคือจำนวน byte ที่ใช้ encode “Здравствуйте” ใน UTF-8 เพราะแต่ละ Unicode scalar value ใน string นั้นกิน 2 byte ของ storage ดังนั้น index เข้า byte ของ string จะไม่สอดคล้องกับ Unicode scalar value ที่ valid เสมอ เพื่อแสดง พิจารณาโค้ด Rust invalid นี้:

let hello = "Здравствуйте";
let answer = &hello[0];

คุณรู้แล้วว่า answer จะไม่ใช่ З ตัวอักษรแรก เมื่อ encode ใน UTF-8 byte แรกของ З คือ 208 และที่สองคือ 151 จึงดูเหมือน answer ควร เป็น 208 จริง ๆ แต่ 208 ไม่ใช่อักขระ valid เอง ๆ การ return 208 คงไม่ใช่สิ่งที่ user อยากได้ถ้าเขาขอตัวอักษรแรกของ string นี้ แต่นั่น คือข้อมูลเดียวที่ Rust มีที่ byte index 0 user โดยทั่วไปไม่อยากให้ค่า byte return แม้ string มีแค่ตัวอักษร Latin — ถ้า &"hi"[0] เป็นโค้ด valid ที่ return ค่า byte มันจะ return 104 ไม่ใช่ h

คำตอบจึงคือ เพื่อหลีกเลี่ยงการ return ค่าที่ไม่คาด และทำให้เกิด bug ที่ อาจไม่ค้นพบทันที Rust ไม่ compile โค้ดนี้เลย และป้องกันความเข้าใจผิดแต่ เนิ่นในกระบวนการพัฒนา

Byte, Scalar Value และ Grapheme Cluster

อีกจุดเรื่อง UTF-8 คือมีจริง ๆ สามวิธีที่เกี่ยวข้องในการดู string จาก มุมมองของ Rust — เป็น byte, scalar value และ grapheme cluster (สิ่งที่ ใกล้ที่สุดกับสิ่งที่เราจะเรียก ตัวอักษร)

ถ้าเราดูคำฮินดี “नमस्ते” ที่เขียนใน Devanagari script มันถูกเก็บเป็น vector ของค่า u8 ที่ดูเป็นแบบนี้:

[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135]

นั่น 18 byte และเป็นวิธีที่คอมพิวเตอร์เก็บข้อมูลนี้ในที่สุด ถ้าเราดูพวก มันเป็น Unicode scalar value ซึ่งเป็นสิ่งที่ type char ของ Rust เป็น byte เหล่านั้นดูเป็นแบบนี้:

['न', 'म', 'स', '्', 'त', 'े']

มีค่า char หกตัวที่นี่ แต่ตัวที่สี่และหกไม่ใช่ตัวอักษร — พวกมันเป็น diacritic ที่ไม่มีความหมายเอง ๆ สุดท้าย ถ้าเราดูพวกมันเป็น grapheme cluster เราจะได้สิ่งที่คนจะเรียกตัวอักษรสี่ตัวที่ประกอบเป็นคำฮินดี:

["न", "म", "स्", "ते"]

Rust ให้วิธีต่างกันในการตีความข้อมูล string ดิบที่คอมพิวเตอร์เก็บ เพื่อ ให้แต่ละโปรแกรมเลือกการตีความที่ต้องการ ไม่ว่าข้อมูลเป็นภาษามนุษย์ใด

เหตุผลสุดท้ายที่ Rust ไม่ให้เรา index เข้า String เพื่อรับอักขระคือ operation indexing คาดว่าจะใช้เวลา constant เสมอ (O(1)) แต่เป็นไปไม่ได้ ที่จะรับประกัน performance นั้นกับ String เพราะ Rust จะต้องเดินผ่าน เนื้อหาจากต้นถึง index เพื่อกำหนดว่ามีกี่อักขระ valid

Slice String

การ index เข้า string มักเป็นความคิดไม่ดี เพราะไม่ชัดเจนว่า return type ของ operation string-indexing ควรเป็น — byte value, อักขระ, grapheme cluster หรือ string slice ถ้าคุณต้องการใช้ index จริง ๆ เพื่อสร้าง string slice ดังนั้น Rust ขอให้คุณเฉพาะมากขึ้น

แทนการ index โดยใช้ [] กับตัวเลขเดียว คุณใช้ [] กับ range เพื่อสร้าง string slice ที่มี byte เฉพาะได้:

#![allow(unused)]
fn main() {
let hello = "Здравствуйте";

let s = &hello[0..4];
}

ที่นี่ s จะเป็น &str ที่มี byte 4 ตัวแรกของ string ก่อนหน้านี้เราเอ่ย ว่าแต่ละอักขระเหล่านี้คือ 2 byte ซึ่งหมายความว่า s จะเป็น Зд

ถ้าเราลอง slice แค่ส่วนของ byte ของอักขระด้วยอย่าง &hello[0..1] Rust จะ panic ตอน runtime ในแบบเดียวกับถ้า index invalid ถูกเข้าถึงใน vector:

$ cargo run
   Compiling collections v0.1.0 (file:///projects/collections)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.43s
     Running `target/debug/collections`

thread 'main' panicked at src/main.rs:4:19:
byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2) of `Здравствуйте`
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

คุณควรระวังเมื่อสร้าง string slice ด้วย range เพราะการทำเช่นนั้นอาจ crash โปรแกรมของคุณ

Iterate ผ่าน String

วิธีดีที่สุดในการ operate บนชิ้นของ string คือ explicit ว่าคุณอยากได้ อักขระหรือ byte สำหรับ Unicode scalar value แต่ละค่า ใช้เมธอด chars การเรียก chars บน “Зд” แยกและ return ค่าสองค่าของ type char และคุณ iterate ผ่านผลเพื่อเข้าถึงแต่ละ element ได้:

#![allow(unused)]
fn main() {
for c in "Зд".chars() {
    println!("{c}");
}
}

โค้ดนี้จะพิมพ์ต่อไปนี้:

З
д

ทางเลือก เมธอด bytes return แต่ละ byte ดิบ ซึ่งอาจเหมาะกับ domain ของ คุณ:

#![allow(unused)]
fn main() {
for b in "Зд".bytes() {
    println!("{b}");
}
}

โค้ดนี้จะพิมพ์ 4 byte ที่ประกอบเป็น string นี้:

208
151
208
180

แต่อย่าลืมจำว่า Unicode scalar value ที่ valid อาจประกอบจาก byte มากกว่า 1 ตัว

การรับ grapheme cluster จาก string เหมือนกับ Devanagari script ซับซ้อน functionality นี้จึงไม่ให้โดย standard library Crate มีให้ที่ crates.io ถ้าคุณต้องการ functionality นี้

จัดการความซับซ้อนของ String

สรุป string ซับซ้อน ภาษาโปรแกรมต่างกันเลือกต่างกันในการนำเสนอความซับซ้อน นี้ให้โปรแกรมเมอร์ Rust เลือกทำให้การจัดการข้อมูล String ที่ถูกต้องเป็น พฤติกรรม default สำหรับโปรแกรม Rust ทั้งหมด ซึ่งหมายความว่าโปรแกรมเมอร์ ต้องคิดมากขึ้นเรื่องการจัดการข้อมูล UTF-8 ตั้งแต่ต้น Trade-off นี้เปิด เผยความซับซ้อนของ string มากกว่าที่เห็นในภาษาโปรแกรมอื่น แต่มันป้องกัน คุณจากการต้องจัดการ error ที่เกี่ยวกับอักขระไม่ใช่ ASCII ทีหลังในวงจรการ พัฒนาของคุณ

ข่าวดีคือ standard library มี functionality มากมายที่สร้างจาก type String และ &str เพื่อช่วยจัดการสถานการณ์ซับซ้อนเหล่านี้อย่างถูกต้อง อย่าลืมเช็ค documentation สำหรับเมธอดที่มีประโยชน์อย่าง contains สำหรับ ค้นหาใน string และ replace สำหรับแทนที่ส่วนของ string ด้วย string อื่น

มาเปลี่ยนไปสิ่งที่ซับซ้อนน้อยกว่านิดหน่อย — hash map!