เรียนรู้ 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 กฎเหล่านี้คือ:
- ทุกค่าใน Rust มีเจ้าของ (owner) หนึ่งและเพียงหนึ่งเดียวเท่านั้น
- เจ้าของสามารถมีได้เพียงหนึ่งคนในเวลาใดเวลาหนึ่ง
- เมื่อเจ้าของออกจากขอบเขตการทำงาน (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) มีดังนี้:
- ณ จุดใดจุดหนึ่ง คุณสามารถมีได้either (a) การอ้างอิงแบบไม่เปลี่ยนแปลงได้หลายอัน หรือ (b) การอ้างอิงแบบเปลี่ยนแปลงได้เพียงหนึ่งอันเท่านั้น
- การอ้างอิงจะต้องไม่มีอายุยาวนานไปกว่าข้อมูลที่มันอ้างอิง
มาดูตัวอย่างการอ้างอิงแบบไม่เปลี่ยนแปลงก่อน:
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) ที่ช่วยให้เราไม่ต้องเขียนอายุการใช้งานอย่างชัดเจนในกรณีที่พบบ่อย ได้แก่:
- แต่ละพารามิเตอร์ที่เป็นการอ้างอิงจะได้รับอายุการใช้งานของตัวเอง
- หากมีพารามิเตอร์การอ้างอิงเพียงหนึ่งตัวนั้น อายุการใช้งานนั้นจะถูกส่งต่อไปยังค่าที่คืนกลับ (หากคืนกลับเป็นการอ้างอิง)
- หากมีพารามิเตอร์หลายตัวแต่หนึ่งในนั้นคือ
&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