เนื้อหา

เรียนรู้ Ownership, Borrowing และ Lifetimes

Ownership คือหัวใจของ Rust ที่ทำให้ปลอดภัยด้านหน่วยความจำโดยไม่ต้องมี Garbage Collector เชื่อมจากภาค 1 (/posts/rust/rust-getting-started/)

Stack และ Heap

ใน Rust ความจำแบ่งออกเป็นสองส่วนหลัก ได้แก่ Stack และ Heap ซึ่งมีลักษณะการทำงานที่แตกต่างกันอย่างชัดเจน Stack คือหน่วยความจำที่จัดสรรแบบ LIFO (Last In First Out) มีความเร็วสูงและขนาดที่ทราบ ณ เวลาคอมไพล์ เหมาะกับตัวแปรประเภทพื้นฐานเช่น integers, booleans, และข้อมูลที่มีขนาดคงที่ เข้าใช้งานได้เร็วเพราะอยู่ใกล้กับหน่วยประมวลผลกลาง (CPU) มากที่สุด ส่วน Heap คือพื้นที่หน่วยความจำที่ใช้สำหรับข้อมูลที่ขนาดไม่ทราบล่วงหน้าหรืออาจเปลี่ยนแปลงได้ขณะทำงาน เช่น String, Vec หรือโครงสร้างข้อมูลที่ซับซ้อน การจัดสรรและปล่อยคืนหน่วยความจำบน Heap จะมีค่าใช้จ่ายมากกว่าเพราะต้องทำการจัดสรร (allocation) และปล่อยคืน (deallocation) ผ่านระบบจัดการหน่วยความจำ (allocator) ซึ่งอาจเกี่ยวข้องกับการเรียกระบบปฏิบัติการ

Rust จำเป็นต้องติดตามว่าใครเป็นเจ้าของข้อมูลบน Heap เพื่อที่จะสามารถปล่อยคืนหน่วยความจำได้อย่างถูกต้องและปลอดภัยโดยไม่ต้องพึ่ง Garbage Collector หากไม่มีระบบ Ownership โปรแกรมอาจเกิดปัญหาหน่วยความจำรั่ว (memory leak) หรือการชี้ไปยังหน่วยความจำที่ถูกปล่อยไปแล้ว (dangling pointer) ซึ่งเป็นต้นเหตุของบั๊กร้ายแรงและช่องโหว่ด้านความปลอดภัย โดยการกำหนดว่าตัวแปรใดเป็นเจ้าของข้อมูลและเมื่อเจ้าของออกจากขอบเขตการทำงาน (scope) จะทำการ drop (ปล่อยคืน) หน่วยความจำโดยอัตโนมัติ Rust จึงสามารถรับประกันความปลอดภัยด้านหน่วยความจำได้ในเวลาคอมไพล์

ตัวอย่างต่อไปนี้แสดงให้เห็นความแตกต่างระหว่างการจัดสรรบน Stack และ Heap อย่างชัดเจน:

fn main() {
    // ตัวแปรเหล่านี้อยู่บน Stack เพราะมีขนาดคงที่และทราบ ณ คอมไพล์
    let x: i32 = 42;
    let y: bool = true;

    // String เก็บข้อมูลจริงบน Heap เท่านั้น ตัวแปร s อยู่บน Stack แต่เป็นเพียงตัวชี้ (pointer)
    let s = String::from("hello, world");
    // ตัวแปร v เช่นกัน เก็บข้อมูลจริงบน Heap
    let v = vec![1, 2, 3, 4, 5];

    // เมื่อออกจากฟังก์ชัน main ตัวแปรทั้งหมดจะถูก drop ในลำดับย้อนกลับ
    // s และ v จะทำการปล่อยคืนหน่วยความจำบน Heap โดยอัตโนมัติ
    // ส่วน x และ y ที่อยู่บน Stack จะถูกปล่อยคืนโดยไม่ต้องทำงานเพิ่มเติม
}

ในตัวอย่างข้างต้น ตัวแปร x และ y ถูกเก็บไว้บน Stack โดยตรง เพราะมีขนาดคงที่และทราบได้ทันทีเมื่อคอมไพล์ ส่วน String และ Vec<T> นั้น ตัวแปรตัวแปร (s และ v) ที่อยู่บน Stack จะเก็บเพียงตัวชี้ (pointer), ความยาว (length) และความจุ (capacity) ซึ่งชี้ไปยังพื้นที่บน Heap ที่เก็บข้อมูลจริง เมื่อฟังก์ชันสิ้นสุดลง Rust จะเรียกใช้เมธอด drop ของ String และ Vec เพื่อปล่อยคืนหน่วยความจำบน Heap ที่พวกเขาอ้างอิงอยู่ ทำให้มั่นใจได้ว่าไม่มีการรั่วไหลของหน่วยความจำแม้ไม่มีการเก็บขยะแบบอัตโนมัติ (Garbage Collector) เข้ามาเกี่ยวข้อง

ต่อไปเราจะพูดถึงกฎพื้นฐานสามข้อของ Ownership ซึ่งเป็นรากฐานสำคัญของระบบนี้

กฎของ Ownership 3 ข้อ

Rust กำหนดกฎพื้นฐานสามข้อเกี่ยวกับ Ownership ที่คอมไพเลอร์ตรวจสอบอย่างเข้มงวดเพื่อให้แน่ใจว่าหน่วยความจำถูกจัดการอย่างปลอดภัย โดยไม่ต้องพึ่ง Garbage Collector กฎเหล่านี้คือ:

  1. ทุกค่าใน Rust มีเจ้าของ (owner) หนึ่งและเพียงหนึ่งเดียวเท่านั้น
  2. เจ้าของสามารถมีได้เพียงหนึ่งคนในเวลาใดเวลาหนึ่ง
  3. เมื่อเจ้าของออกจากขอบเขตการทำงาน (scope) ค่าจะถูก drop (ปล่อยคืนหน่วยความจำ) โดยอัตโนมัติ

กฎเหล่านี้ดูเรียบง่ายแต่มีผลกระทบอย่างลึกซึ้งต่อวิธีการเขียนโปรแกรมใน Rust มาดูตัวอย่างและคำอธิบายแต่ละข้อกัน

ข้อที่ 1: ทุกค่ามีเจ้าของหนึ่งและเพียงหนึ่งเดียว

ทุกชนิดข้อมูลใน Rust ไม่ว่าจะเป็นประเภทพื้นฐานเช่น i32, bool หรือประเภทที่ซับซ้อนเช่น String, Vec<T> จะต้องมีตัวแปรที่ทำหน้าที่เป็นเจ้าของ (owner) หนึ่งเดียว เจ้าของนี้มีหน้าที่รับผิดชอบในการปล่อยคืนหน่วยความจำเมื่อค่านั้นไม่จำเป็นอีกต่อไป ตัวอย่างเช่น:

fn main() {
    let s = String::from("hello"); // s คือเจ้าของของข้อมูล String
    // ที่นี่ s มีสิทธิ์แต่เพียงผู้เดียวในการควบคุมข้อมูล "hello"
    // เมื่อ s ออกจาก scope มันจะถูก drop
}

ในตัวอย่างข้างต้น ตัวแปร s เป็นเจ้าของเพียงหนึ่งเดียวของข้อมูล String ที่เก็บข้อความ “hello” ไม่สามารถมีตัวแปรอื่นมาเป็นเจ้าของข้อมูลชิ้นนี้ได้พร้อมกัน หากเราพยายามมอบเจ้าของให้กับตัวแปรอื่น จะเกิดการย้าย (move) ซึ่งเราจะพูดถึงในหัวข้อถัดไป

ข้อที่ 2: เจ้าของมีได้เพียงหนึ่งคนในเวลาใดเวลาหนึ่ง

กฎนี้หมายความว่า ณ ช่วงเวลาใดเวลาหนึ่ง จะมีตัวแปรเพียงหนึ่งตัวที่ถือสิทธิ์เป็นเจ้าของของข้อมูลนั้น หากเราพยายามมอบเจ้าของให้กับตัวแปรอื่น (เช่น การกำหนดค่าให้ตัวแปรใหม่) เจ้าของเดิมจะสูญเสียสิทธิ์นั้นไปทันที กระบวนการนี้เรียกว่าการย้าย (move) และเป็นพื้นฐานของความปลอดภัยใน Rust ตัวอย่าง:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1; // s1 ย้ายกรรมสิทธิ์ไปให้ s2
    // println!("{}", s1); // ข้อผิดพลาด: ค่า s1 ถูกย้ายไปแล้ว
    println!("{}", s2); // ใช้งานได้เพราะ s2 คือเจ้าของใหม่
}

เมื่อบรรทัด let s2 = s1; ทำงาน กรรมสิทธิ์ของข้อมูล String จะถูกย้ายจาก s1 ไปยัง s2 ทันที หลังจากนั้น s1 จะไม่สามารถเข้าถึงข้อมูลนั้นได้อีก ซึ่งช่วยป้องกันปัญหาการชี้ไปยังหน่วยความจำที่ถูกปล่อยไปแล้ว (dangling pointer) หากเราพยายามใช้ s1 หลังจากการย้าย คอมไพเลอร์จะรายงานข้อผิดพลาดทันที

ข้อที่ 3: เมื่อเจ้าของออกจาก scope ค่าจะถูก drop

เมื่อเจ้าของออกจากขอบเขตการทำงาน (scope) ที่มันถูกกำหนดไว้ Rust จะเรียกใช้ฟังก์ชัน drop โดยอัตโนมัติเพื่อปล่อยคืนหน่วยความจำที่เจ้าของครอบครองอยู่ กระบวนการนี้เกิดขึ้นอย่างกำหนดและไม่มีค่าใช้จ่ายแฝง นี่คือเหตุผลที่ Rust ไม่ต้องการ Garbage Collector เราสามารถเห็นได้จากตัวอย่างต่อไปนี้:

{
    let s = String::from("hello"); // s เข้าสู่ scope
    // ใช้งาน s ได้อย่างอิสระที่นี่
    println!("{}", s);
} // s ออกจาก scope ที่นี่ → เรียก drop โดยอัตโนมัติ
// ที่นี่ s ไม่สามารถใช้งานได้อีกต่อไป

ในบล็อกโค้ดด้านบน ตัวแปร s จะมีอยู่อย่างถูกต้องเฉพาะภายในวงเล็บปีกกา {} เมื่อการควบคุมออกจากบล็อกนี้ Rust จะเรียกใช้ drop(s) เพื่อปล่อยคืนหน่วยความจำที่ String ถือครองอยู่ หากเราพยายามใช้ s หลังจากปิดวงเล็บปีกกา คอมไพเลอร์จะตรวจพบว่าเป็นการยืมค่าที่ถูกปล่อยไปแล้วและรายงานข้อผิดพลาด

กฎทั้งสามนี้ทำงานร่วมกันเพื่อให้ Rust สามารถรับประกันความปลอดภัยด้านหน่วยความจำได้โดยไม่มีต้นทุนรันไทม์จาก Garbage Collector ต่อไปเราจะดูรายละเอียดของการย้ายกรรมสิทธิ์ (Move Semantics) ซึ่งเป็นผลโดยตรงจากกฎข้อที่สอง

Move Semantics

ใน Rust เมื่อเรากำหนดค่าของตัวแปรหนึ่งให้กับตัวแปรอื่นด้วยการใช้ let หรือการส่งผ่านเป็นอาร์กิวเมนต์ให้กับฟังก์ชัน เกิดสิ่งที่เรียกว่าการย้าย (move) แทนการคัดลอก (copy) สำหรับประเภทที่ไม่มีการ implement trait Copy เช่น String, Vec<T> และโครงสร้างข้อมูลส่วนใหญ่ที่จัดสรรหน่วยความจำบน Heap การย้ายนี้หมายถึงการถ่ายโอนกรรมสิทธิ์ (ownership) จากตัวแปรเดิมไปยังตัวแปรใหม่ ทำให้ตัวแปรเดิมไม่สามารถเข้าถึงข้อมูลนั้นได้อีกต่อไป ซึ่งแตกต่างจากภาษาส่วนใหญ่ที่มักจะทำการคัดลอกข้อมูลโดยอัตโนมัติ

มาดูตัวอย่างพื้นฐานของการย้ายกันก่อน:

let s1 = String::from("hello");
let s2 = s1; // การย้ายกรรมสิทธิ์จาก s1 ไปยัง s2

// println!("{}", s1); // ข้อผิดพลาด: ค่า s1 ถูกย้ายไปแล้ว
println!("{}", s2);    // ใช้งานได้ปกติ เพราะ s2 คือเจ้าของใหม่

ในตัวอย่างข้างต้น หลังจากบรรทัด let s2 = s1; ตัวแปร s1 จะถือว่าว่างเปล่าในแง่ของกรรมสิทธิ์ และไม่สามารถเข้าถึงข้อมูล String ได้อีก หากเราพยายามพิมพ์ค่าของ s1 คอมไพเลอร์จะแสดงข้อผิดพลาดว่า “borrow of moved value: s1” นี่คือกลไกที่ Rust ใช้ป้องกันการเกิด dangling pointer และ double free โดยไม่ต้องมี Garbage Collector

อย่างไรก็ตาม ไม่ใช่ทุกประเภทที่จะเกิดการย้ายเสมอไป ประเภทที่มีขนาดเล็กและทราบขนาดได้ ณ เวลาคอมไพล์ เช่น i32, bool, char และทูเปิลที่ประกอบด้วยประเภทเหล่านี้ จะทำการคัดลอก (copy) แทนการย้าย เพราะการคัดลอกข้อมูลเหล่านี้มีค่าใช้จ่ายต่ำมาก พฤติกรรมนี้ถูกควบคุมโดย trait Copy ซึ่งหากประเภทหนึ่ง implement trait นี้ไว้ การกำหนดค่าจะทำการคัดลอกแทนการย้าย ตัวอย่าง:

let x = 5;
let y = x; // การคัดลอก เพราะ i32 implement Copy
println!("x = {}, y = {}", x, y); // ใช้งานได้ทั้ง x และ y

ในกรณีนี้ หลังจาก let y = x; ตัวแปรทั้ง x และ y ยังคงถือค่า 5 ไว้ได้ทั้งคู่ เพราะข้อมูลถูกคัดลอกไปยังตำแหน่งใหม่บน Stack ไม่มีการย้ายกรรมสิทธิ์เกิดขึ้น

เราสามารถบังคับให้เกิดการคัดลอกลึก (deep copy) แม้กับประเภทที่ปกติจะย้ายได้ เช่น String โดยใช้เมธอด clone() ซึ่งจะทำการสำเนาข้อมูลจริงบน Heap ทำให้ได้สองอ็อบเจกต์ที่เป็นอิสระต่อกัน ตัวอย่าง:

let s1 = String::from("hello");
let s2 = s1.clone(); // การสร้างสำเนาลึกของ String

println!("s1 = {}, s2 = {}", s1, s2); // ใช้งานได้ทั้ง s1 และ s2

แม้ว่า clone() จะทำให้เราได้สำเนาของข้อมูล แต่ก็มาพร้อมกับค่าใช้จ่ายในการจัดสรรหน่วยความจำใหม่และการคัดลอกข้อมูลจริง ซึ่งอาจมีค่าใช้จ่ายสูงหากข้อมูลมีขนาดใหญ่ ดังนั้น Rust จึงส่งเสริมให้ใช้การยืม (borrowing) แทนการคัดลอกเมื่อเป็นไปได้ เพื่อประสิทธิภาพที่ดีกว่า

ต่อไปเราจะดูว่าการยืม (borrowing) และการอ้างอิง (references) ทำงานอย่างไรภายใต้กฎของ Ownership

References และ Borrowing

แทนที่จะย้ายกรรมสิทธิ์ Rust อนุญาตให้เราสร้างการอ้างอิง (reference) ไปยังค่าโดยไม่รับกรรมสิทธิ์นั้น การอ้างอิงช่วยให้เราสามารถอ่านหรือแก้ไขข้อมูลได้โดยไม่ต้องถ่ายโอนความเป็นเจ้าของ ซึ่งช่วยหลีกเลี่ยงการคัดลอกข้อมูลที่มีค่าใช้จ่ายสูง การอ้างอิงแบ่งออกเป็นสองประเภทหลัก ได้แก่ การอ้างอิงแบบไม่เปลี่ยนแปลง (&T) และการอ้างอิงแบบเปลี่ยนแปลงได้ (&mut T)

กฎการยืม (borrowing rules) มีดังนี้:

  1. ณ จุดใดจุดหนึ่ง คุณสามารถมีได้either (a) การอ้างอิงแบบไม่เปลี่ยนแปลงได้หลายอัน หรือ (b) การอ้างอิงแบบเปลี่ยนแปลงได้เพียงหนึ่งอันเท่านั้น
  2. การอ้างอิงจะต้องไม่มีอายุยาวนานไปกว่าข้อมูลที่มันอ้างอิง

มาดูตัวอย่างการอ้างอิงแบบไม่เปลี่ยนแปลงก่อน:

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1); // ผ่านการอ้างอิงแบบไม่เปลี่ยนแปลง

    println!("ความยาวของ '{}' คือ {}", s1, len); // s1 ยังใช้งานได้อยู่
}

fn calculate_length(s: &String) -> usize {
    s.len() // s คือการอ้างอิง ไม่ใช่เจ้าของ
}

ในฟังก์ชัน calculate_length พารามิเตอร์ s มีประเภท &String ซึ่งหมายถึงการอ้างอิงแบบไม่เปลี่ยนแปลงไปยัง String เนื่องจากไม่ได้รับกรรมสิทธิ์ ฟังก์ชันนี้จึงไม่สามารถแก้ไขข้อมูลที่ s ชี้ไปได้ หลังจากที่ฟังก์ชันคืนค่า การอ้างอิง s จะหมดอายุและเจ้าของเดิม (s1 ในฟังก์ชัน main) จะกลับมาใช้งานได้อย่างเต็มที่

หากเราพยายามสร้างการอ้างอิงแบบเปลี่ยนแปลงได้สองอันไปยังข้อมูลเดียวกันในเวลาเดียวกัน คอมไพเลอร์จะปฏิเสธ เนื่องจากละเมิดกฎการยืม ดังตัวอย่าง:

let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s; // ข้อผิดพลาด: ไม่สามารถมีการอ้างอิงแบบ mutable มากกว่าหนึ่งอันได้
println!("{}, {}", r1, r2);

ข้อผิดพลาดที่เกิดขึ้นคือ “cannot borrow s as mutable more than once at a time” ซึ่งเป็นการป้องกันไม่ให้เกิดการแข่งขันในการเขียนข้อมูล (data race) ที่อาจนำไปสู่พฤติกรรมที่ไม่กำหนด (undefined behavior) หากเราต้องการแก้ไขข้อมูล เราสามารถมีการอ้างอิงแบบเปลี่ยนแปลงได้เพียงหนึ่งอันในเวลาใดเวลาหนึ่ง และเมื่อการอ้างอิงนั้นสิ้นสุดอายุ เราจึงสามารถสร้างการอ้างอิงแบบเปลี่ยนแปลงได้อีกครั้ง ตัวอย่างการใช้งานที่ถูกต้อง:

let mut s = String::from("hello");
{
    let r1 = &mut s;
    r1.push_str(", world");
} // r1 หมดอายุติอายุที่นี่
let r2 = &mut s;
r2.push_str("!");
println!("{}", s); // พิมพ์ "hello, world!"

นอกจากนี้ เรายังไม่สามารถมีการอ้างอิงแบบไม่เปลี่ยนแปลงและแบบเปลี่ยนแปลงได้พร้อมกันไปยังข้อมูลเดียวกันได้ เช่น:

let mut s = String::from("hello");
let r1 = &s; // ไม่เปลี่ยนแปลง
let r2 = &s; // ไม่เปลี่ยนแปลง ได้
let r3 = &mut s; // ข้อผิดพลาด: ไม่สามารถมี mutable reference พร้อมกับ immutable reference
println!("{}, {}, {}", r1, r2, r3);

คอมไพเลอร์จะรายงานข้อผิดพลาดว่า “cannot borrow s as mutable because it is also borrowed as immutable” ซึ่งเป็นการป้องกันไม่ให้เกิดสถานการณ์ที่ข้อมูลถูกอ่านขณะที่กำลังถูกเขียน ทำให้มั่นใจได้ว่าการเข้าถึงข้อมูลจะเป็นไปอย่างปลอดภัยและมีความสอดคล้องกัน

การใช้อ้างอิงช่วยให้เราสามารถเขียนฟังก์ชันที่รับข้อมูลมาโดยไม่ต้องรับกรรมสิทธิ์ ซึ่งเป็นประโยชน์อย่างมากเมื่อเราต้องการเพียงแค่อ่านหรือแก้ไขข้อมูลชั่วคราว ตัวอย่างฟังก์ชันที่รับ &str แทนการรับ String:

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let s = String::from("hello world");
    let word = first_word(&s); // ส่งอ้างอิง ไม่ย้ายกรรมสิทธิ์
    println!("คำแรก: {}", word);
    // s ยังใช้งานได้หลังจากเรียก first_word
}

ฟังก์ชัน first_word รับพารามิเตอร์ s: &str ซึ่งเป็นการอ้างอิงไปยังสตริงสไลซ์ ทำให้เราสามารถทำงานกับสตริงได้โดยไม่ต้องเป็นเจ้าของมัน หลังจากที่ฟังก์ชันคืนค่า การอ้างอิงจะสิ้นสุดอายุและเจ้าของเดิมจะกลับมาใช้งานได้เต็มที่

ต่อไปเราจะพูดถึงสไลซ์ (slices) ซึ่งเป็นประเภทของการอ้างอิงที่ชี้ไปยังส่วนหนึ่งของข้อมูลโดยไม่มีกรรมสิทธิ์

Slices: &str และ &[T]

สไลซ์ (slice) เป็นประเภทข้อมูลที่ช่วยให้เราสามารถอ้างอิงไปยังส่วนหนึ่งของคอลเลกชันเช่นสตริงหรืออาร์เรย์โดยไม่ต้องเป็นเจ้าของข้อมูลนั้น สไลซ์ถูกแทนที่ด้วยการอ้างอิง (&) และประกอบด้วยสองส่วนสำคัญ ได้แก่ ตัวชี้ (pointer) ที่ชี้ไปยังตำแหน่งเริ่มต้นของสไลซ์ และความยาว (length) ที่บอกจำนวนองค์ประกอบในสไลซ์ โครงสร้างนี้ทำให้สไลซ์เป็นประเภทที่มีขนาดคงที่ (fat pointer) และสามารถถูกคัดลอกได้อย่างปลอดภัยโดยไม่ต้องคัดลอกข้อมูลจริง

เรามาเริ่มที่สตริงสไลซ์ (&str) ซึ่งเป็นประเภทที่ใช้บ่อยที่สุดใน Rust สตริงสไลซ์ชี้ไปยังส่วนหนึ่งของสตริงที่เข้ารหัส UTF-8 โดยไม่ต้องเป็นเจ้าของมัน ตัวอย่างการใช้งาน:

fn main() {
    let s = String::from("hello world");
    let hello = &s[0..5]; // สไลซ์จากดัชนี 0 ถึง 4 (ไม่นับ 5)
    let world = &s[6..11]; // สไลซ์จากดัชนี 6 ถึง 10

    println!("{} {}", hello, world); // พิมพ์ "hello world"

    // สไลซ์ทั้งสตริง
    let whole = &s[..]; // เทียบเท่ากับ &s[0..s.len()]
    println!("ทั้งหมด: {}", whole);
}

ในตัวอย่างข้างต้น เราใช้ไวยากรณ์ [start..end] เพื่อสร้างสไลซ์ โดยที่ start เป็นดัชนีเริ่มต้น (รวม) และ end เป็นดัชนีสิ้นสุด (ไม่รวม) หากเราละเว้น start จะเริ่มจากดัชนีที่ 0 และหากเราละเว้น end จะไปจนถึงความยาวของสตริง สตริงสไลซ์ &str เป็นประเภทที่มีขนาดคงที่เนื่องจากประกอบด้วยตัวชี้ไปยังข้อมูลและความยาว ทำให้สามารถส่งผ่านโดยการคัดลอกได้อย่างปลอดภัย

สไลซ์ยังสามารถใช้กับอาร์เรย์และเวกเตอร์ได้เช่นกัน ตัวอย่างกับอาร์เรย์ของจำนวนเต็ม:

fn main() {
    let a = [1, 2, 3, 4, 5];
    let slice = &a[1..4]; // ชี้ไปยังองค์ประกอบที่ 2, 3, และ 4 (ดัชนี 1, 2, 3)

    println!("slice: {:?}", slice); // พิมพ์ [2, 3, 4]

    // สามารถทำงานกับสไลซ์เหมือนกับอาร์เรย์ทั่วไป
    for i in slice.iter() {
        println!("ค่า: {}", i);
    }
}

ในตัวอย่างนี้ slice มีประเภท &[i32] ซึ่งเป็นการอ้างอิงไปยังส่วนหนึ่งของอาร์เรย์ a เช่นเดียวกับสตริงสไลซ์ สไลซ์ของอาร์เรย์ประกอบด้วยตัวชี้ไปยังองค์ประกอบแรกของสไลซ์และความยาวของสไลซ์ ทำให้เราสามารถทำงานกับสไลซ์ได้โดยไม่ต้องเป็นเจ้าของอาร์เรย์ทั้งหมด

สไลซ์มีประโยชน์อย่างมากเมื่อเราต้องการส่งส่วนหนึ่งของข้อมูลไปให้ฟังก์ชันโดยไม่ต้องคัดลอกหรือย้ายกรรมสิทธิ์ ตัวอย่างฟังก์ชันที่รับสตริงสไลซ์เป็นพารามิเตอร์:

fn first_word(s: &str) -> &str {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return &s[0..i];
        }
    }

    &s[..]
}

fn main() {
    let s = String::from("hello world");
    let word = first_word(&s); // ส่งอ้างอิงไปยังสตริงทั้งหมด
    println!("คำแรก: {}", word);
    // s ยังคงใช้งานได้หลังจากเรียกฟังก์ชัน
}

ฟังก์ชัน first_word คืนค่าเป็น &str ซึ่งเป็นการอ้างอิงไปยังส่วนหนึ่งของสตริงต้นทาง หมายความว่าข้อมูลที่คืนกลับมายังคงเป็นของเจ้าของเดิมและจะถูกต้องตราบเท่าที่เจ้าของเดิมยังมีอายุอยู่ นี่คือเหตุผลที่เราจำเป็นต้องเข้าใจเกี่ยวกับอายุการใช้งาน (lifetimes) ซึ่งเราจะพูดถึงในหัวข้อถัดไป

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

Lifetimes

อายุการใช้งาน (lifetime) ใน Rust เป็นแนวคิดที่คอมไพเลอร์ใช้เพื่อให้แน่ใจว่าการอ้างอิงทั้งหมดมีความถูกต้อง กล่าวคือ ชี้ไปยังข้อมูลที่ยังไม่ถูกปล่อยคืน กล่าวอีกนัยหนึ่ง ลำดับชีวิตอธิบายว่าการอ้างอิงนั้นมีอายุยืนยาวเพียงใดสัมพันธ์กับข้อมูลที่มันอ้างอิง ในกรณีส่วนใหญ่ คอมไพเลอร์สามารถอนุมานอายุการใช้งานได้โดยอัตโนมัติผ่านกลไกที่เรียกว่า lifetime elision แต่ในบางสถานการณ์ โดยเฉพาะเมื่อฟังก์ชันรับการอ้างอิงหลายตัวและคืนค่าเป็นการอ้างอิง เราจำเป็นต้องระบุอายุการใช้งานอย่างชัดเจนด้วยไวยากรณ์ <'a> โดยที่ ' นำหน้าชื่ออายุการใช้งาน (เช่น 'a, 'b) และชื่ออายุการใช้งานนั้นสามารถเป็นชื่อใดก็ได้ตามต้องการ

มาดูตัวอย่างฟังก์ชันที่จำเป็นต้องมีการระบุอายุการใช้งานอย่างชัดเจน:

fn longest<'a>(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

fn main() {
    let string1 = String::from("abcd");
    let string2 = String::from("xyz");

    let result = longest(&string1, &string2);
    println!("สายยาวที่สุดคือ: {}", result);
}

ในฟังก์ชัน longest พารามิเตอร์ x และ y มีประเภท &str ซึ่งหมายถึงการอ้างอิงไปยังข้อมูลสตริง เนื่องจากฟังก์ชันคืนค่าเป็นการอ้างอิง (&str) คอมไพเลอร์จึงต้องทราบว่าการอ้างอิงที่คืนกลับมานั้นมีอายุการใช้งานยาวนานเท่าใด โดยไม่มีการระบุอายุการใช้งานอย่างชัดเจน คอมไพเลอร์จะไม่สามารถทราบได้ว่าการอ้างอิงที่คืนกลับมานั้นชี้ไปยัง x หรือ y และจึงไม่สามารถรับประกันได้ว่าการอ้างอิงนั้นจะยังคงถูกต้องหลังจากที่ฟังก์ชันสิ้นสุดลง ดังนั้นเราจึงต้องใช้พารามิเตอร์อายุการใช้งาน 'a เพื่อบอกว่าการอ้างอิงที่ส่งเข้ามาและการอ้างอิงที่คืนกลับไปนั้นมีอายุการใช้งานอย่างน้อยเท่ากับอายุการใช้งาน 'a กล่าวคือ การอ้างอิงที่คืนกลับมาจะมีอายุการใช้งานไม่น้อยกว่าการอ้างอิงที่สั้นที่สุดระหว่าง x และ y

หากเราลองเรียกฟังก์ชันนี้ด้วยอาร์กิวเมนต์ที่มีอายุการใช้งานต่างกัน เช่น:

fn main() {
    let string1 = String::from("abcd");
    let result;
    {
        let string2 = String::from("xyz");
        result = longest(&string1, &string2);
        // string2 ออกจาก scope ที่นี่
    }
    // พยายามใช้ result ซึ่งชี้ไปยัง string2 ที่ถูกปล่อยไปแล้ว
    println!("{}", result); // ข้อผิดพลาด: การอ้างอิงชี้ไปยังข้อมูลที่ไม่ถูกต้อง
}

คอมไพเลอร์จะตรวจพบข้อผิดพลาดเนื่องจาก result พยายามชี้ไปยังข้อมูลของ string2 ซึ่งถูกปล่อยคืนเมื่อออกจาก scope ภายในบล็อก {} ระบบอายุการใช้งานจะป้องกันไม่ให้เกิดสถานการณ์เช่นนี้ขึ้น

นอกจากการกำหนดอายุการใช้งานทั่วไปแล้ว Rust ยังมีอายุการใช้งานพิเศษที่เรียกว่า 'static ซึ่งหมายถึงการอ้างอิงที่มีอายุการใช้งานตลอดทั้งโปรแกรม ตัวอย่างทั่วไปของ &'static str คือสตริงลิเทอรัลเช่น "hello" ซึ่งถูกฝังอยู่ในไบนารีของโปรแกรมและมีอยู่ตลอดเวลาที่โปรแกรมทำงาน ตัวอย่าง:

let s: &'static str = "ฉันมีอายุการใช้งานตลอดโปรแกรม";
println!("{}", s); // ปลอดภัยเสมอ

นอกจากนี้ Rust ยังมีกฎการอนุมานอายุการใช้งาน (lifetime elision) ที่ช่วยให้เราไม่ต้องเขียนอายุการใช้งานอย่างชัดเจนในกรณีที่พบบ่อย ได้แก่:

  1. แต่ละพารามิเตอร์ที่เป็นการอ้างอิงจะได้รับอายุการใช้งานของตัวเอง
  2. หากมีพารามิเตอร์การอ้างอิงเพียงหนึ่งตัวนั้น อายุการใช้งานนั้นจะถูกส่งต่อไปยังค่าที่คืนกลับ (หากคืนกลับเป็นการอ้างอิง)
  3. หากมีพารามิเตอร์หลายตัวแต่หนึ่งในนั้นคือ &self หรือ &mut self (เป็นเมธอดของโครงสร้างหรืออินเทอร์เฟซ) อายุการใช้งานของ self จะถูกใช้สำหรับค่าที่คืนกลับ

กฎเหล่านี้ช่วยให้เราเขียนโค้ดที่สะอาดและอ่านง่ายขึ้นโดยไม่ต้องเขียนอายุการใช้งานซ้ำซ้อนในกรณีที่คอมไพเลอร์สามารถอนุมานได้อย่างถูกต้อง

การเข้าใจอายุการใช้งานเป็นสิ่งสำคัญเมื่อทำงานกับการอ้างอิงและการยืมใน Rust โดยเฉพาะเมื่อเราเริ่มสร้างโครงสร้างข้อมูลที่เก็บการอ้างอิงไว้ภายใน เช่น โครงสร้างที่มีฟิลด์เป็น &str หรือ &T ในกรณีเหล่านี้ เราจำเป็นต้องระบุอายุการใช้งานให้ชัดเจนเพื่อบอกคอมไพเลอร์ว่าการอ้างอิงภายในโครงสร้างนั้นมีอายุการใช้งานสัมพันธ์กับอะไรบ้าง

ต่อไปเราจะดูว่าความเข้าใจใน Ownership, Borrowing และ Lifetimes นั้นถูกนำไปใช้จริงในโครงการอย่าง rs-wsProxy อย่างไร

เชื่อมโยงกับโปรเจกต์ rs-wsProxy

ในโครงการ rs-wsProxy ซึ่งเป็นเว็บโซเก็ตพร็อกซีที่เขียนด้วย Rust แนวคิดเรื่อง Ownership, Borrowing และ Lifetimes มีบทบาทสำคัญในการออกแบบให้ปลอดภัยและมีประสิทธิภาพ ตัวอย่างเช่น ฟังก์ชันที่จัดการการเชื่อมต่อ TCP มักจะรับที่อยู่เซิร์ฟเวอร์เป็นพารามิเตอร์ประเภท &str แทนที่จะเป็น String เพื่อหลีกเลี่ยงการคัดลอกข้อมูลที่ไม่จำเป็น เนื่องจากที่อยู่นั้นมักจะเป็นสตริงลิเทอรัลหรือสตริงที่ถูกสร้างขึ้นที่อื่นและมีอายุการใช้งานยาวพอที่จะถูกอ้างอิงได้อย่างปลอดภัย ตัวอย่างการประกาศฟังก์ชันดังกล่าวอาจเป็นดังนี้:

fn connect_tcp(addr: &str) -> std::io::Result<TcpStream> {
    TcpStream::connect(addr)
}

ฟังก์ชัน connect_tcp รับพารามิเตอร์ addr: &str ซึ่งเป็นการอ้างอิงไปยังสตริงที่มีข้อมูลที่อยู่เซิร์ฟเวอร์ เช่น "127.0.0.1:8080" โดยการใช้ &str แทน String ทำให้เราไม่ต้องเกิดการอะโลเคทหน่วยความจำใหม่หรือคัดลอกข้อมูลเมื่อเรียกฟังก์ชันนี้ ซึ่งเป็นประโยชน์อย่างยิ่งในบริบทที่มีการเรียกใช้บ่อยครั้ง เช่น ในลูปที่จัดการการเชื่อมต่อหลายครั้งต่อวินาที

ในทำนองเดียวกัน ฟังก์ชันที่ตรวจสอบความถูกต้องของเป้าหมาย (target) อาจมีหน้าตาเช่นนี้:

fn validate_target(target: &str) -> bool {
    !target.is_empty() && !target.contains("..") && !target.contains(":")
}

ฟังก์ชันนี้รับ &str เพื่อตรวจสอบว่าสตริงที่ส่งมาไม่ว่างเปล่าและไม่มีลำดับอักขระที่อาจเป็นอันตราย เช่น .. หรือ : ซึ่งอาจใช้ในการโจมตีแบบพาธทราซาลซัล (path traversal) การใช้การอ้างอิงช่วยให้ฟังก์ชันนี้ทำงานได้โดยไม่ต้องเป็นเจ้าของข้อมูล input ทำให้สามารถเรียกใช้กับสตริงที่มาจากแหล่งต่างๆ ได้อย่างยืดหยุ่น ไม่ว่าจะเป็นสตริงจากคอนฟิกิวเรชัน อินพุตจากผู้ใช้ หรือข้อมูลที่ได้จากการถอดรหัสโปรโตคอล

นอกจากการใช้การอ้างอิงแบบงั้นใน rs-wsProxy ยังมีการใช้ Arc<AppState> เพื่อแชร์สถานะร่วมกันระหว่างงานอะซิงโครนัสหลายงาน (tasks) อย่างปลอดภัย ที่นี่ Arc ย่อมาจาก Atomic Reference Counting ซึ่งเป็นประเภทชี้อัจฉริยะที่ช่วยให้หลายๆ คนสามารถเป็นเจ้าของร่วมกันได้โดยใช้การนับอ้างอิงแบบอะโตมิก ส่วน AppState คือโครงสร้างที่เก็บข้อมูลร่วมกัน เช่น การตั้งค่าพร็อกซี สถิติการเชื่อมต่อ หรือแหล่งทรัพยากรที่ใช้ร่วมกัน ตัวอย่างการใช้งานอาจเป็นดังนี้:

use std::sync::Arc;

struct AppState {
    proxy_addr: String,
    timeout: u64,
    // ฟิลด์อื่นๆ ที่จำเป็น
}

#[tokio::main]
async fn main() {
    let state = Arc::new(AppState {
        proxy_addr: String::from("127.0.0.1:8080"),
        timeout: 30,
        // เริ่มต้นฟิลด์อื่นๆ
    });

    // สร้างงานหลายงานที่แชร์ state เดียวกัน
    let mut handles = vec![];
    for i in 0..4 {
        let state_clone = Arc::clone(&state); // เพิ่มจำนวนการอ้างอิง
        let handle = tokio::spawn(async move {
            // แต่ละงานสามารถเข้าถึง state ได้อย่างปลอดภัย
            connect_to_proxy(&state_clone.proxy_addr, state_clone.timeout).await;
        });
        handles.push(handle);
    }

    // รอให้ทุกงานเสร็จสิ้น
    for handle in handles {
        let _ = handle.await;
    }
}

ในตัวอย่างข้างต้น เราใช้ Arc<AppState> เพื่อให้หลายงานที่รันบนโทคิโอ (Tokio) สามารถอ้างอิงถึงสถานะเดียวกันได้โดยไม่ต้องกังวลเกี่ยวกับการแข่งขันในการเข้าถึงข้อมูล (data race) เนื่องจาก Arc ให้การนับอ้างอิงแบบอะโตมิก เมื่อแต่ละงานทำการ clone อาร์ค จะเพิ่มจำนวนการอ้างอิง และเมื่องานสิ้นสุดลง การอ้างอิงจะถูกลดลงโดยอัตโนมัติ เมื่อจำนวนการอ้างอิงลดลงเป็นศูนย์ AppState จะถูกทำลายและปล่อยคืนทรัพยากรทั้งหมดอย่างปลอดภัย

รูปแบบนี้ช่วยให้เราสามารถออกแบบระบบที่มีส่วนประกอบหลายส่วนทำงานพร้อมกันโดยไม่ต้องใช้การล็อก (mutex) ที่อาจทำให้เกิดความล่าช้าหรือความซับซ้อนเพิ่มเติม นอกจากนี้ยังสอดคล้องกับปรัชญาของ Rust ที่ต้องการให้ความปลอดภัยเป็นค่าเริ่มต้นโดยไม่ต้องเสียสละประสิทธิภาพ

จากตัวอย่างเหล่านี้จะเห็นได้ว่าแนวคิดพื้นฐานของ Ownership, Borrowing และ Lifetimes ไม่ใช่เพียงทฤษฎีเท่านั้น แต่เป็นเครื่องมือเชิงปฏิบัติที่ช่วยให้เราเขียนโค้ดที่ปลอดภัย มีประสิทธิภาพ และบบำรุงรักษาได้จริงในโครงการขนาดใหญ่อย่าง rs-wsProxy

สรุป

Ownership, Borrowing และ Lifetimes คือหัวใจสำคัญที่ทำให้ Rust สามารถรับประกันความปลอดภัยด้านหน่วยความจำได้โดยไม่ต้องพึ่ง Garbage Collector ผ่านกฎที่เข้มงวดแต่ทรงพลังเหล่านี้ Rust ช่วยให้เราสามารถเขียนโค้ดที่มีประสิทธิภาพเทียบเท่ากับภาษาระดับต่ำเช่น C หรือ C++ แต่มีความปลอดภัยในระดับที่สูงกว่ามาก การเข้าใจแนวคิดเหล่านี้จึงเป็นสิ่งจำเป็นสำหรับนักพัฒนาที่ต้องการเชี่ยวชาญภาษานี้อย่างแท้จริง

หากคุณต้องการทบทวนพื้นฐาน Rust ก่อนเริ่มต้นหัวข้อนี้ โปรดดูบทความก่อนหน้า: เริ่มต้นกับ Rust

และหากคุณต้องการเรียนรู้ต่อไปในหัวข้อถัดไปเกี่ยวกับการจัดการข้อมูลด้วย Structs, Enums และ Pattern Matching ใน Rust โปรดดูบทความถัดไป: Structs, Enums และ Pattern Matching