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 ด้วยสตริง (Storing UTF-8 Encoded Text with Strings)

เราได้พูดคุยเกี่ยวกับสตริงไปแล้วในบทที่ 4 แต่ตอนนี้เราจะมาดูกันในรายละเอียดที่ลึกขึ้น ผู้ที่เริ่มศึกษาภาษา Rust (New Rustaceans) มักจะเจอปัญหาและสับสนเกี่ยวกับเรื่องสตริงบ่อยครั้งด้วยเหตุผล 3 ประการรวมกัน ได้แก่ แนวโน้มของ Rust ที่จะเปิดเผยข้อผิดพลาดที่อาจเกิดขึ้นทั้งหมด, ความเป็นจริงที่ว่าสตริงเป็นโครงสร้างข้อมูลที่ซับซ้อนกว่าที่นักโปรแกรมหลายคนคิดไว้, และการเข้ารหัสแบบ UTF-8 ปัจจัยเหล่านี้รวมกันทำให้สตริงในภาษา Rust ดูยากเมื่อเทียบกับภาษาโปรแกรมอื่น ๆ ที่คุณคุ้นเคย

เรานำสตริงมาพูดคุยกันในบริบทของคอลเลกชัน เนื่องจากสตริงถูกพัฒนาขึ้นมาจากคอลเลกชันของไบต์ (bytes) บวกกับเมธอดบางตัวที่ช่วยให้การทำงานสะดวกขึ้นเมื่อแปลไบต์เหล่านั้นเป็นข้อความ ในส่วนนี้ เราจะมาคุยเกี่ยวกับแนวทางการทำงานกับ String ที่คอลเลกชันทุกประเภทมี เช่น การสร้าง การอัปเดต และการอ่านข้อมูล นอกจากนี้ เรายังจะมาดูกันว่า String มีความแตกต่างจากคอลเลกชันอื่น ๆ อย่างไร โดยเฉพาะอย่างยิ่งในเรื่องของการเข้าถึงข้อมูลด้วยดัชนี (indexing) ของ String นั้นมีความซับซ้อนขึ้นเนื่องจากความต่างระหว่างการแปลความหมายข้อมูลสตริงของมนุษย์กับคอมพิวเตอร์

การนิยามสตริง (Defining Strings)

ขั้นแรกเราจะมานิยามความหมายของคำว่า สตริง (string) กันก่อน ภาษา Rust มีประเภทข้อมูลสตริงเพียงแบบเดียวในภาษาแกนหลัก (core language) ซึ่งก็คือส่วนย่อยของสตริง (string slice) str ซึ่งมักจะเห็นในรูปแบบของการยืมใช้งานนั่นคือ &str ในบทที่ 4 เราได้คุยกันเรื่อง string slices ซึ่งเป็นข้อมูลอ้างอิงไปยังข้อมูลสตริงที่เข้ารหัสแบบ UTF-8 ที่ถูกจัดเก็บไว้ที่อื่น ตัวอย่างเช่น ข้อความตายตัว (string literals) จะถูกเก็บไว้ในไบนารีของโปรแกรม ดังนั้นจึงมีฐานะเป็น string slices

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

การสร้างสตริงใหม่ (Creating a New String)

การทำงานหลาย ๆ อย่างที่ใช้กับ Vec<T> ได้นั้นก็สามารถใช้กับ String ได้เช่นเดียวกัน เนื่องจากแท้จริงแล้ว String ถูกสร้างขึ้นโดยครอบทับอยู่บนเวกเตอร์ของไบต์ (vector of bytes) พร้อมกับมีการรับประกัน ข้อจำกัด และความสามารถเพิ่มเติมบางประการ ตัวอย่างหนึ่งของฟังก์ชันที่ทำงานเหมือนกันใน Vec<T> และ String คือฟังก์ชัน new ที่ใช้สร้างออบเจกต์ขึ้นมาใหม่ ดังแสดงในตัวอย่างที่ 8-11

fn main() {
    let mut s = String::new();
}

บรรทัดนี้จะสร้างสตริงใหม่ที่ว่างเปล่าชื่อว่า s ซึ่งหลังจากนั้นเราจะสามารถนำข้อมูลมาใส่ได้ บ่อยครั้งที่เราต้องการสร้างสตริงโดยมีข้อมูลเริ่มต้นมาด้วย สำหรับกรณีนี้เราจะใช้เมธอด to_string ซึ่งใช้ได้กับประเภทข้อมูลใด ๆ ก็ตามที่นำเสนอ (implement) ทริต Display เช่นเดียวกับที่ข้อความตายตัว (string literals) ทำได้ ตัวอย่างที่ 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();
}

โค้ดนี้จะสร้างสตริงที่ประกอบไปด้วยข้อความ initial contents

นอกจากนี้เรายังสามารถใช้ฟังก์ชัน String::from เพื่อสร้าง String จากข้อความตายตัวได้เช่นกัน โค้ดในตัวอย่างที่ 8-13 มีผลลัพธ์เทียบเท่ากับโค้ดในตัวอย่างที่ 8-12 ที่ใช้เมธอด to_string

fn main() {
    let s = String::from("initial contents");
}

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

โปรดจำไว้ว่าสตริงเข้ารหัสแบบ UTF-8 ดังนั้นเราจึงสามารถใส่ข้อมูลที่เข้ารหัสอย่างถูกต้องลงไปได้ ดังที่แสดงในตัวอย่างที่ 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");
}

ค่าทั้งหมดข้างต้นนี้ถือเป็นค่า String ที่ถูกต้องสมบูรณ์

การอัปเดตสตริง (Updating a String)

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

การต่อท้ายข้อความด้วย push_str หรือ push (Appending with push_str or push)

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

fn main() {
    let mut s = String::from("foo");
    s.push_str("bar");
}

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

fn main() {
    let mut s1 = String::from("foo");
    let s2 = "bar";
    s1.push_str(s2);
    println!("s2 is {s2}");
}

หากเมธอด push_str ดึงความเป็นเจ้าของของ s2 ไป เราก็จะไม่สามารถแสดงผลค่าของมันในบรรทัดสุดท้ายได้ อย่างไรก็ตาม โค้ดนี้ทำงานได้ตามปกติที่เราคาดหวังไว้!

เมธอด push จะรับพารามิเตอร์เป็นตัวอักษรเดียว (single character) แล้วเพิ่มตัวอักษรนั้นต่อท้ายเข้าไปใน String ตัวอย่างที่ 8-17 จะเพิ่มตัวอักษร l ลงใน String โดยใช้เมธอด push

fn main() {
    let mut s = String::from("lo");
    s.push('l');
}

ผลลัพธ์คือตัวแปร s จะเก็บข้อความ lol

การรวมข้อความด้วย + หรือ format! (Concatenating with + or format!)

บ่อยครั้งที่คุณต้องการรวมสองสตริงเข้าด้วยกัน วิธีหนึ่งคือการใช้ตัวดำเนินการ + ดังแสดงในตัวอย่างที่ 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
}

ตัวแปร s3 จะเก็บข้อความ Hello, world! เหตุผลที่ตัวแปร s1 จะใช้งานไม่ได้อีกต่อไปหลังจากการบวกนี้ และเหตุผลที่เราต้องส่งข้อมูลอ้างอิงไปยัง s2 (คือการใส่เครื่องหมาย &) มีความเกี่ยวโยงกับโครงสร้างซิกเนเจอร์ (signature) ของเมธอดที่จะถูกเรียกเมื่อใช้ตัวดำเนินการ + ตัวดำเนินการ + นี้ทำงานผ่านเมธอด add ซึ่งมีหน้าตาซิกเนเจอร์ประมาณนี้:

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

ในไลบรารีมาตรฐาน คุณจะเห็นว่าเมธอด add ถูกนิยามโดยใช้เจเนอริกและประเภทข้อมูลที่เชื่อมโยงกัน (associated types) แต่ในที่นี้เรานำประเภทข้อมูลรูปธรรม (concrete types) มาใส่แทนเพื่อให้เห็นภาพเวลาเราเรียกใช้งานเมธอดนี้ด้วยค่าที่เป็น String เราจะมาพูดคุยเกี่ยวกับเจเนอริกกันต่อในบทที่ 10 โครงสร้างซิกเนเจอร์นี้ช่วยบอกใบ้ให้เราเข้าใจจุดที่ค่อนข้างซับซ้อนของตัวดำเนินการ + ได้อย่างชัดเจน

ประการแรก s2 มีเครื่องหมาย & หมายความว่าเรากำลังบวกข้อมูลอ้างอิงของสตริงที่สองเข้ากับสตริงแรก เนื่องจากพารามิเตอร์ s ในฟังก์ชัน add กำหนดไว้ว่า เราสามารถบวกได้เฉพาะ string slice เข้ากับ String เท่านั้น เราไม่สามารถนำสองค่าที่เป็น String มาบวกกันตรง ๆ ได้ แต่เดี๋ยวก่อน ประเภทของ &s2 คือ &String ไม่ใช่ &str ตามที่ระบุไว้ในพารามิเตอร์ตัวที่สองของ add แล้วเหตุใดตัวอย่างที่ 8-18 ถึงคอมไพล์ผ่านล่ะ?

เหตุผลที่เราสามารถนำ &s2 มาใส่ในเมธอด add ได้นั้นเป็นเพราะคอมไพเลอร์สามารถแปลงค่าอาร์กิวเมนต์แบบ &String ให้กลายเป็น &str ได้โดยอัตโนมัติ (coerce) เมื่อเราเรียกใช้เมธอด add ตัวภาษา Rust จะใช้กระบวนการแปลงชนิดข้อมูลอ้างอิง (deref coercion) ซึ่งในกรณีนี้จะเปลี่ยน &s2 ให้กลายเป็น &s2[..] เราจะมาเรียนรู้เรื่อง deref coercion อย่างละเอียดในบทที่ 15 และเนื่องจากเมธอด add ไม่ได้แย่งสิทธิ์ความเป็นเจ้าของของพารามิเตอร์ s ไป ตัวแปร s2 จึงยังคงเป็น String ที่ใช้การได้ต่อไปหลังจากการคำนวณนี้

ประการที่สอง เราจะเห็นจากโครงสร้างซิกเนเจอร์ว่าเมธอด add จะยึดสิทธิ์ความเป็นเจ้าของของ self ไป เนื่องจาก self นั้น ไม่มี เครื่องหมาย & อยู่ข้างหน้า นั่นหมายความว่า s1 ในตัวอย่างที่ 8-18 จะถูกย้าย (move) เข้าไปในเมธอด add และจะไม่สามารถใช้งานได้อีกหลังจากนั้น ดังนั้นถึงแม้โค้ดบรรทัด let s3 = s1 + &s2; จะดูเหมือนการคัดลอกสตริงทั้งสองเพื่อสร้างสตริงตัวใหม่ขึ้นมา แต่ความจริงแล้วคำสั่งนี้จะยึดสิทธิ์ความเป็นเจ้าของของ s1 ไป แล้วนำเนื้อหาของ s2 มาคัดลอกเพิ่มเข้าไปที่ด้านท้าย จากนั้นจึงส่งสิทธิ์ความเป็นเจ้าของของผลลัพธ์กลับคืนมา กล่าวอีกนัยหนึ่งคือ มันดูเหมือนจะสร้างข้อมูลชุดใหม่หลายชุด แต่ความจริงแล้วไม่ใช่ กระบวนการทำงานจริงมีประสิทธิภาพสูงกว่าการสร้างสำเนามาก

หากเราต้องการเชื่อมต่อสตริงหลาย ๆ ตัวเข้าด้วยกัน พฤติกรรมของตัวดำเนินการ + จะเริ่มอ่านยากและวุ่นวาย:

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 ซึ่งการมองหาตัวดำเนินการ + และเครื่องหมายคำพูด " ที่กระจายอยู่เต็มไปหมดแบบนี้ทำให้ดูออกยากว่าโค้ดทำอะไร สำหรับการรวมสตริงที่มีโครงสร้างซับซ้อนกว่านี้ เราสามารถเปลี่ยนมาใช้มาโคร format! ได้แทน:

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

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

โค้ดนี้จะกำหนดให้ตัวแปร s เก็บค่า tic-tac-toe เช่นเดียวกัน มาโคร format! มีหลักการทำงานเหมือนกับ println! เพียงแต่แทนที่จะพ่นผลลัพธ์ออกมาแสดงบนหน้าจอ มันจะส่งกลับคืนค่ามาเป็น String ที่มีเนื้อหานั้น ๆ แทน ซึ่งการเขียนโค้ดเวอร์ชันที่ใช้ format! จะช่วยให้อ่านง่ายขึ้นมาก อีกทั้งโค้ดที่สร้างขึ้นจากมาโคร format! จะใช้ข้อมูลอ้างอิง (references) ดังนั้นการเรียกใช้คำสั่งนี้จะไม่ไปแย่งความเป็นเจ้าของของพารามิเตอร์ใด ๆ ของมันเลย

การเข้าถึงข้อมูลสตริงด้วยดัชนี (Indexing into Strings)

ในภาษาโปรแกรมอื่น ๆ จำนวนมาก การเข้าถึงแต่ละตัวอักษรในสตริงผ่านการอ้างอิงดัชนีเป็นเรื่องปกติธรรมดาและทำได้ทั่วไป อย่างไรก็ตาม หากคุณพยายามเข้าถึงบางส่วนของ String โดยใช้ไวยากรณ์ดัชนี (indexing syntax) ในภาษา Rust คุณจะเจอกับข้อผิดพลาด ลองดูโค้ดที่ไม่ถูกต้องในตัวอย่างที่ 8-19

fn main() {
    let s1 = String::from("hi");
    let h = s1[0];
}

โค้ดนี้จะส่งผลให้เกิดข้อผิดพลาดต่อไปนี้:

$ 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: `usize` implements trait `SliceIndex<T>`
 --> /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/slice/index.rs:214:0
  |
  = note: `SliceIndex<[T]>`
 --> /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/bstr/traits.rs:197:0
  |
  = note: `SliceIndex<ByteStr>`
  = 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

ข้อความแจ้งข้อผิดพลาดได้บอกเหตุผลไว้ชัดเจนแล้วว่า สตริงในภาษา Rust ไม่รองรับการเข้าถึงด้วยดัชนี (indexing) แต่ทำไมล่ะ? เพื่อจะตอบคำถามนั้น เราจำเป็นต้องลงลึกไปดูรูปแบบที่ Rust จัดเก็บสตริงไว้ในหน่วยความจำกัน

การแทนข้อมูลภายใน (Internal Representation)

String แท้จริงแล้วเป็นโครงสร้างครอบทับอยู่บนเวกเตอร์ประเภท Vec<u8> ลองมาดูตัวอย่างข้อความสตริงที่เข้ารหัสแบบ UTF-8 อย่างถูกต้องจากตัวอย่างที่ 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 ซึ่งหมายความว่าเวกเตอร์ที่เก็บสตริง "Hola" มีความยาวเท่ากับ 4 ไบต์ ตัวอักษรแต่ละตัวจะใช้พื้นที่ 1 ไบต์เมื่อเข้ารหัสเป็น UTF-8 อย่างไรก็ตาม ตัวอย่างบรรทัดถัดไปอาจทำให้คุณประหลาดใจ (สังเกตว่าสตริงนี้เริ่มต้นด้วยอักษรซีริลลิกตัวพิมพ์ใหญ่ 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");
}

หากถามว่าสตริงนี้มีความยาวเท่าใด คุณอาจจะตอบว่า 12 ตัวอักษร แต่แท้จริงแล้วคำตอบของ Rust คือ 24 นั่นคือจำนวนไบต์ที่ใช้ในการเข้ารหัสคำว่า “Здравствуйте” ในแบบ UTF-8 เนื่องจากค่า Unicode scalar value แต่ละตัวในสตริงนั้นต้องการพื้นที่จัดเก็บ 2 ไบต์ ดังนั้น ดัชนีที่ชี้เข้าไปยังไบต์ของสตริงจึงไม่ได้ตรงกับค่า Unicode scalar value ที่ถูกต้องเสมอไป เพื่อแสดงให้เห็นภาพ ลองพิจารณาโค้ด Rust ที่ไม่ถูกต้องบรรทัดนี้:

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

คุณรู้อยู่แล้วว่าค่าของ answer จะไม่ใช่ตัวอักษรแรกนั่นคือ З เมื่อเข้ารหัสในแบบ UTF-8 ไบต์แรกของ З จะมีค่าเป็น 208 และไบต์ที่สองคือ 151 ดังนั้นดูเหมือนว่า answer ควรจะเป็นค่า 208 แต่ทว่าค่า 208 เดี่ยว ๆ นั้นไม่ใช่ตัวอักษรที่สมบูรณ์และใช้งานได้ในตัวมันเอง การส่งค่ากลับมาเป็น 208 จึงไม่ใช่สิ่งที่ผู้ใช้อ้างอิงต้องการแน่นอนหากพวกเขาถามหาอักษรตัวแรกของข้อความนี้ แต่อย่างไรก็ดี นั่นคือข้อมูลเดียวที่ Rust มีเก็บไว้ที่ดัชนีไบต์ตำแหน่ง 0 ปกติแล้วผู้ใช้ไม่ได้ต้องการค่าไบต์ส่งกลับมา ถึงแม้ว่าสตริงนั้นจะมีแต่ตัวอักษรละตินก็ตาม: หากโค้ด &"hi"[0] ทำงานได้และส่งค่าระดับไบต์กลับมา มันจะส่งค่า 104 ไม่ใช่ตัวอักษร h

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

ไบต์, ค่าสเกลาร์, และคลัสเตอร์กราฟีม (Bytes, Scalar Values, and Grapheme Clusters)

อีกแง่มุมหนึ่งเกี่ยวกับ UTF-8 คือความจริงแล้ว มี 3 มุมมองหลัก ๆ ในการมองข้อมูลสตริงจากมุมมองของภาษา Rust ได้แก่ ไบต์ (bytes), ค่าสเกลาร์ (scalar values), และคลัสเตอร์กราฟีม (grapheme clusters - สิ่งที่ใกล้เคียงที่สุดกับคำว่า ตัวอักษร ในความเข้าใจของมนุษย์)

หากเราพิจารณาคำในภาษาฮินดี “नमस्ते” ซึ่งเขียนด้วยอักษรเทวนาครี มันจะถูกจัดเก็บเป็นเวกเตอร์ของค่า u8 หน้าตาแบบนี้:

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

นั่นคือทั้งหมด 18 ไบต์ และนี่คือวิธีที่คอมพิวเตอร์เก็บข้อมูลนี้ไว้เบื้องหลัง แต่ถ้าเราพิจารณาพวกมันในฐานะ Unicode scalar values ซึ่งก็คือข้อมูลประเภท char ในภาษา Rust ไบต์เหล่านั้นจะมีหน้าตาแบบนี้:

['न', 'ม', 'ส', '्', 'ต', 'े']

มีค่า char ทั้งหมด 6 ตัวอยู่ตรงนี้ แต่ทว่าตัวที่สี่และตัวที่หกนั้นไม่ใช่ตัวอักษรเดี่ยว ๆ: พวกมันคือเครื่องหมายกำกับการออกเสียง (diacritics) ที่ไม่มีความหมายหากแสดงผลแยกอยู่ตัวคนเดียว สุดท้ายนี้ หากเราพิจารณาข้อมูลในฐานะคลัสเตอร์กราฟีม (grapheme clusters) เราจะได้คำตอบที่เป็นตัวอักษร 4 ตัวตามความเข้าใจของมนุษย์ที่ประกอบกันเป็นคำภาษาฮินดีนี้:

["न", "ม", "สฺ", "เต"]

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

เหตุผลสุดท้ายที่ Rust ไม่อนุญาตให้เราเข้าถึงสตริงด้วยดัชนีเพื่อดึงตัวอักษรออกมาก็คือ คำสั่งการเข้าถึงด้วยดัชนี (indexing operations) ควรจะทำงานด้วยเวลาคงที่ (O(1)) เสมอ แต่ทว่ามันเป็นไปไม่ได้ที่จะรับประกันประสิทธิภาพเช่นนั้นกับประเภทข้อมูล String เนื่องจาก Rust จำเป็นต้องไล่ตรวจเช็คข้อมูลตั้งแต่จุดเริ่มต้นไปจนถึงดัชนีเป้าหมายเพื่อหาว่ามีอักษรที่สมบูรณ์กี่ตัวอักษร

การหั่นแบ่งสตริง (Slicing Strings)

การดึงข้อมูลตัวอักษรจากสตริงผ่านดัชนีตรง ๆ มักจะเป็นสิ่งที่ไม่ควรทำเนื่องจากไม่ชัดเจนว่าค่าที่ส่งกลับจากคำสั่งดึงดัชนีสตริงควรเป็นประเภทใดกันแน่: ค่าไบต์, ตัวอักษร, คลัสเตอร์กราฟีม หรือ string slice ดังนั้น หากคุณมีความจำเป็นต้องใช้ค่าดัชนีเพื่อทำสตริงส่วนย่อย (string slices) จริง ๆ Rust จะกำหนดให้คุณระบุรายละเอียดที่เจาะจงยิ่งขึ้น

แทนที่จะใช้วงเล็บ [] ร่วมกับตัวเลขเพียงตัวเดียว คุณสามารถใช้ [] ร่วมกับช่วงตัวเลข (range) เพื่อสร้างส่วนย่อยของสตริงที่มีขอบเขตเฉพาะไบต์ที่ต้องการได้:

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

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

ในกรณีนี้ s จะมีฐานะเป็น &str ที่เก็บข้อมูล 4 ไบต์แรกของสตริง ก่อนหน้านี้เราได้กล่าวไปแล้วว่าตัวอักษรเหล่านี้แต่ละตัวใช้พื้นที่ 2 ไบต์ นั่นหมายความว่าค่าใน s จะเท่ากับ Зд

หากเราพยายามดึงส่วนย่อยเฉพาะแค่บางส่วนของไบต์ในตัวอักษรตัวเดียวกัน เช่น &hello[0..1] ตัวภาษา Rust จะเกิดข้อผิดพลาดกะทันหัน (panic) ในตอนรันโปรแกรม (runtime) เช่นเดียวกับกรณีที่คุณเข้าถึงดัชนีที่ไม่มีจริงในเวกเตอร์:

$ 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' (6017738) panicked at src/main.rs:4:19:
end byte index 1 is not a char boundary; it is inside 'З' (bytes 0..2 of string)
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

ดังนั้นคุณควรระมัดระวังอย่างมากเมื่อสร้าง string slices ด้วยช่วงตัวเลข (ranges) เพราะการทำเช่นนั้นอาจทำให้โปรแกรมของคุณพังได้

การวนลูปผ่านสตริง (Iterating Over Strings)

วิธีที่ดีที่สุดในการจัดการกับบางส่วนของสตริงคือการเจาะจงลงไปให้ชัดเจนว่าคุณต้องการข้อมูลในรูปแบบตัวอักษรหรือรูปแบบไบต์ สำหรับค่า Unicode scalar value แต่ละตัว ให้ใช้เมธอด chars การเรียกใช้ chars บนข้อความ “Зд” จะแยกองค์ประกอบและส่งกลับค่าออกมาสองตัวซึ่งมีประเภทข้อมูลเป็น char และคุณสามารถวนลูปผ่านผลลัพธ์ดังกล่าวเพื่อเข้าถึงแต่ละสมาชิกได้:

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

โค้ดนี้จะแสดงผลดังต่อไปนี้:

З
д

อีกทางเลือกหนึ่ง เมธอด bytes จะส่งค่าแต่ละไบต์ดิบกลับมาให้ ซึ่งอาจมีความเหมาะสมกับการนำไปใช้งานในโปรแกรมของคุณ:

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

โค้ดนี้จะแสดงผลค่าไบต์ทั้ง 4 ไบต์ที่รวมกันกลายเป็นข้อความนี้:

208
151
208
180

แต่อย่าลืมว่า ค่า Unicode scalar value ที่ถูกต้องสามารถประกอบขึ้นจากข้อมูลได้มากกว่า 1 ไบต์

การดึงคลัสเตอร์กราฟีม (grapheme clusters) จากสตริง เช่น ในภาษาเทวนาครี มีความซับซ้อนอย่างมาก ดังนั้นฟังก์ชันนี้จึงไม่ได้มีจัดเตรียมไว้ให้ในไลบรารีมาตรฐาน อย่างไรก็ตาม มีเคตส์ (crates) อื่น ๆ ที่พร้อมใช้งานบน crates.io หากนี่เป็นฟังก์ชันการทำงานที่คุณต้องการใช้

การรับมือกับความซับซ้อนของสตริง (Handling the Complexities of Strings)

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

ข่าวดีคือไลบรารีมาตรฐานมีการจัดเตรียมเมธอดการใช้งานไว้มากมายบนรากฐานของประเภทข้อมูล String และ &str เพื่อช่วยจัดการกับสถานการณ์อันซับซ้อนเหล่านี้ได้อย่างถูกต้อง อย่าลืมเข้าไปศึกษาเอกสารอ้างอิงเพื่อเรียนรู้เมธอดที่มีประโยชน์อย่าง contains สำหรับใช้ค้นหาคำในสตริง และ replace สำหรับใช้แทนที่บางส่วนของสตริงด้วยสตริงอื่น

ทีนี้เราลองเปลี่ยนไปดูหัวข้อที่มีความซับซ้อนน้อยกว่านี้กันสักเล็กน้อย นั่นก็คือ แฮชแมป (hash maps) กันครับ!