การตรวจสอบความถูกต้องของข้อมูลอ้างอิงด้วยอายุการใช้งาน (Validating References with Lifetimes)
อายุการใช้งาน หรือ ไลฟ์ไทม์ (lifetimes) คือเจเนอริกอีกประเภทหนึ่งที่เราได้เขียนใช้งานกันไปก่อนหน้านี้แล้ว แทนที่จะคอยตรวจสอบเงื่อนไขว่าประเภทข้อมูลมีพฤติกรรมตามที่เราต้องการหรือไม่ ไลฟ์ไทม์จะทำหน้าที่ช่วยรับประกันว่า ข้อมูลอ้างอิง (references) จะยังคงมีความถูกต้องสมบูรณ์ (valid) ตราบเท่านานที่เรามีความจำเป็นต้องใช้งานมัน
รายละเอียดประการหนึ่งที่เราไม่ได้พูดคุยกันในหัวข้อ “ข้อมูลอ้างอิงและการยืมใช้งาน (References and Borrowing)” ในบทที่ 4 ก็คือ ทุก ๆ ข้อมูลอ้างอิงในภาษา Rust ต่างก็มีอายุการใช้งานเป็นของตัวเอง ซึ่งก็คือขอบเขตการใช้งาน (scope) ที่ข้อมูลอ้างอิงดังกล่าวจะยังคงมีความถูกต้องสมบูรณ์ โดยปกติทั่วไป ไลฟ์ไทม์จะมีลักษณะแฝงและถูกคาดเดา (infer) โดยอัตโนมัติ เช่นเดียวกับกระบวนการวิเคราะห์ประเภทข้อมูลทั่วไป เรามีหน้าที่ต้องเขียนระบุประเภทข้อมูลเฉพาะตอนที่มีความเป็นไปได้ว่าข้อมูลอาจเป็นได้หลายประเภท ในลักษณะเดียวกัน เราจำเป็นต้องเขียนระบุไลฟ์ไทม์เฉพาะเมื่ออายุการใช้งานของข้อมูลอ้างอิงแต่ละตัวมีความเชื่อมโยงเกี่ยวพันกันได้หลายรูปแบบ ภาษา Rust กำหนดให้เราเขียนระบุความสัมพันธ์นี้ผ่านทางพารามิเตอร์ไลฟ์ไทม์เจเนอริก (generic lifetime parameters) เพื่อให้มั่นใจได้อย่างเด็ดขาดว่าข้อมูลอ้างอิงที่จะถูกนำไปใช้งานจริงในตอนรันไทม์ (runtime) จะใช้งานได้จริงและถูกต้องสมบูรณ์
การระบุไลฟ์ไทม์เป็นแนวคิดที่ภาษาโปรแกรมอื่นส่วนใหญ่ไม่มีเลยด้วยซ้ำ ดังนั้นจึงอาจทำให้รู้สึกไม่คุ้นเคยในช่วงแรก แม้ว่าในบทนี้เราจะไม่ได้เจาะลึกรายละเอียดไลฟ์ไทม์อย่างครบถ้วนสมบูรณ์ แต่เราจะนำเสนอและอภิปรายถึงไวยากรณ์ไลฟ์ไทม์รูปแบบทั่วไปที่คุณมีโอกาสพบบ่อย เพื่อให้คุณมีความคุ้นเคยกับแนวคิดนี้มากขึ้น
ข้อมูลอ้างอิงที่ห้อยเคว้ง (Dangling References)
เป้าหมายหลักของการนำไลฟ์ไทม์มาใช้งานคือเพื่อป้องกันการเกิดข้อมูลอ้างอิงห้อยเคว้ง (dangling references) ซึ่งหากปล่อยให้เกิดขึ้นมา จะส่งผลให้ตัวโปรแกรมชี้ข้อมูลอ้างอิงไปยังตำแหน่งหน่วยความจำอื่นนอกเหนือจากข้อมูลที่ควรจะเป็น ลองพิจารณาตัวโปรแกรมในตัวอย่างที่ 10-16 ซึ่งมีขอบเขตการทำงานด้านนอก (outer scope) และขอบเขตการทำงานด้านใน (inner scope)
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r: {r}");
}
หมายเหตุ: ตัวอย่างในบทเรียนที่ 10-16, 10-17, และ 10-23 มีการเขียนประกาศตัวแปรโดยไม่มีการป้อนค่าเริ่มต้น ส่งผลให้ชื่อตัวแปรนั้นมีตัวตนอยู่ในขอบเขตด้านนอก ในแวบแรก โครงสร้างนี้อาจดูเหมือนขัดแย้งกับหลักการของ Rust ที่ห้ามไม่ให้มีค่าว่างเปล่า (null values) อย่างไรก็ตาม หากเราพยายามดึงตัวแปรมาใช้งานก่อนที่จะทำการป้อนค่าให้แก่ตัวแปรนั้น ระบบจะแสดงข้อความผิดพลาดในขั้นตอนคอมไพล์ ซึ่งเป็นสิ่งยืนยันว่า Rust ไม่อนุญาตให้มีค่า null จริง ๆ
ขอบเขตด้านนอกทำการประกาศตัวแปรชื่อว่า r โดยยังไม่มีการกำหนดค่าเริ่มต้น และขอบเขตด้านในประกาศตัวแปรชื่อว่า x พร้อมป้อนค่าเริ่มต้นเป็น 5 ภายในขอบเขตด้านใน เราพยายามสั่งกำหนดให้ r อ้างอิงชี้ไปยัง x จากนั้น เมื่อสิ้นสุดการทำงานของขอบเขตด้านใน เราพยายามจะพิมพ์ค่าในตัวแปร r ออกมาแสดงผล โค้ดนี้จะไม่สามารถคอมไพล์ผ่านได้ เนื่องจากค่าต้นทางที่ r ชี้อยู่นั้นได้หลุดออกนอกขอบเขตการใช้งานไปเรียบร้อยแล้วก่อนที่เราจะสั่งดึงมาใช้งาน ข้อความแจ้งข้อผิดพลาดเป็นดังนี้:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `x` does not live long enough
--> src/main.rs:6:13
|
5 | let x = 5;
| - binding `x` declared here
6 | r = &x;
| ^^ borrowed value does not live long enough
7 | }
| - `x` dropped here while still borrowed
8 |
9 | println!("r: {r}");
| - borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
ข้อความผิดพลาดระบุว่าตัวแปร x “มีอายุไม่ยาวนานพอ” (does not live long enough) เหตุผลคือ x จะหลุดออกนอกขอบเขตเมื่อขอบเขตด้านในสิ้นสุดลงที่บรรทัดที่ 7 แต่ทว่าตัวแปร r ยังคงมีสถานะใช้งานได้ในขอบเขตด้านนอก เนื่องจากขอบเขตของมันมีขนาดกว้างใหญ่กว่า เราจึงเรียกว่ามัน “มีอายุยาวนานกว่า” หาก Rust ยอมปล่อยให้โค้ดชุดนี้ทำงานได้ ตัวแปร r จะไปอ้างอิงตำแหน่งหน่วยความจำที่ถูกคืนสิทธิ์คืนระบบ (deallocated) ไปแล้วเมื่อตอน x หลุดขอบเขต และขั้นตอนการทำงานใด ๆ ที่เราพยายามประมวลผลต่อกับ r จะไม่สามารถทำงานได้อย่างถูกต้อง ดังนั้น Rust ตรวจวิเคราะห์ได้อย่างไรว่าโค้ดนี้ไม่มีความถูกต้อง? คำตอบคือมันใช้งานระบบตรวจสอบสิทธิ์การยืม (borrow checker)
ระบบตรวจสอบสิทธิ์การยืม (The Borrow Checker)
ตัวคอมไพเลอร์ Rust มีระบบหนึ่งชื่อว่า borrow checker ซึ่งทำหน้าที่เปรียบเทียบขนาดของขอบเขตข้อมูลเพื่อวิเคราะห์ว่าการยืมใช้งาน (borrows) ทั้งหมดในโปรแกรมมีความถูกต้องดีหรือไม่ ตัวอย่างที่ 10-17 แสดงโค้ดชุดเดิมจากตัวอย่างที่ 10-16 แต่มีการเขียนบันทึกจำลองให้เห็นขอบเขตอายุการใช้งานของตัวแปรแต่ละตัว
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {r}"); // |
} // ---------+
ที่จุดนี้ เราสมมติให้อายุการใช้งานของ r แทนด้วย 'a และอายุการใช้งานของ x แทนด้วย 'b ดังที่คุณสังเกตเห็น บล็อกด้านใน 'b มีขนาดเล็กกว่าบล็อกไลฟ์ไทม์ด้านนอก 'a เป็นอย่างมาก ในขั้นตอนคอมไพล์ Rust จะเปรียบเทียบขนาดอายุการใช้งานทั้งสองตัวและวิเคราะห์ว่า r มีไลฟ์ไทม์ระดับ 'a แต่ดันไปชี้อ้างอิงตำแหน่งหน่วยความจำที่มีไลฟ์ไทม์ระดับ 'b ตัวโปรแกรมจึงถูกปฏิเสธไม่ให้ผ่านเนื่องจากอายุใช้งาน 'b มีความสั้นกว่าอายุใช้งานของตัวแปรอ้างอิง 'a ซึ่งก็คือตัวข้อมูลอ้างอิงต้นทางมีอายุสั้นกว่าข้อมูลอ้างอิงหลัก
ตัวอย่างที่ 10-18 แสดงวิธีการแก้ไขโค้ดเพื่อไม่ให้เกิดปัญหาข้อมูลอ้างอิงห้อยเคว้ง และช่วยให้คอมไพล์ผ่านโดยไม่มีข้อผิดพลาด
fn main() {
let x = 5; // ----------+-- 'b
// |
let r = &x; // --+-- 'a |
// | |
println!("r: {r}"); // | |
// --+ |
} // ----------+
ในกรณีนี้ x มีอายุใช้งานระดับ 'b ซึ่งกว้างใหญ่ครอบคลุมกว่า 'a ส่งผลให้ r สามารถชี้อ้างอิงไปยัง x ได้อย่างไร้อุปสรรค เนื่องจาก Rust มั่นใจได้ว่าข้อมูลอ้างอิงใน r จะยังถูกต้องใช้งานได้ตลอดเวลาตราบเท่าที่ x ยังคงมีสถานะใช้งานอยู่
เมื่อคุณเข้าใจตำแหน่งอายุใช้งานของข้อมูลอ้างอิง และวิธีการที่ Rust ตรวจวิเคราะห์ไลฟ์ไทม์เพื่อรับประกันความถูกต้องแล้ว ถัดไปเราจะมาเรียนรู้เรื่องพารามิเตอร์ไลฟ์ไทม์เจเนอริก (generic lifetimes) ในพารามิเตอร์และผลลัพธ์ข้อมูลส่งคืนของฟังก์ชันกันครับ
พารามิเตอร์ไลฟ์ไทม์เจเนอริกในฟังก์ชัน (Generic Lifetimes in Functions)
เราจะลองเขียนฟังก์ชันหนึ่งที่มีหน้าที่เปรียบเทียบและส่งคืนสตริงย่อย (string slice) ที่มีความยาวมากกว่าระหว่างสองตัวแปร ฟังก์ชันนี้จะรับพารามิเตอร์เป็น string slices สองตัวและส่งผลลัพธ์กลับมาเป็นหนึ่ง string slice หลังจากเราเขียนฟังก์ชัน longest เสร็จสมบูรณ์แล้ว โค้ดในตัวอย่างที่ 10-19 ควรจะแสดงผลลัพธ์เป็น The longest string is abcd
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
ข้อสังเกตคือ เราต้องการให้ฟังก์ชันรับพารามิเตอร์ในรูปของ string slices ซึ่งทำหน้าที่อ้างอิงข้อมูล แทนที่จะส่งค่าออบเจกต์สตริงตรง ๆ เนื่องจากเราไม่ประสงค์ให้ฟังก์ชัน longest มาแย่งสิทธิ์ความเป็นเจ้าของ (ownership) ของข้อมูลพารามิเตอร์เหล่านั้นไป โปรดดูข้อมูลในหัวข้อ “สตริงย่อยเป็นตัวแปรพารามิเตอร์ (String Slices as Parameters)” ในบทที่ 4 สำหรับเหตุผลที่อธิบายรายละเอียดว่าทำไมตัวแปรพารามิเตอร์ในตัวอย่างที่ 10-19 จึงเป็นรูปแบบที่เราต้องการใช้งาน
หากเราทดลองเขียนฟังก์ชัน longest ที่ไม่ถูกต้องและคอมไพล์ไม่ผ่านบรรทัดนี้ในตัวอย่างที่ 10-20 โค้ดชุดนี้จะยังไม่สามารถคอมไพล์ผ่านได้
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() { x } else { y }
}
แต่ทว่าเราจะได้พบข้อความแจ้งเตือนความผิดพลาดเกี่ยวกับไลฟ์ไทม์ดังนี้:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0106]: missing lifetime specifier
--> src/main.rs:9:33
|
9 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from `x` or `y`
help: consider introducing a named lifetime parameter
|
9 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
| ++++ ++ ++ ++
For more information about this error, try `rustc --explain E0106`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
ข้อความแนะนำแจ้งรายละเอียดให้ทราบว่า ข้อมูลชนิดส่งคืนจำเป็นต้องได้รับการระบุพารามิเตอร์ไลฟ์ไทม์เจเนอริกเพิ่มเติม เนื่องจากตัวภาษา Rust ไม่สามารถประเมินได้ว่าข้อมูลอ้างอิงที่จะถูกส่งคืนกลับออกไปนั้นแท้จริงแล้วชี้ไปยังตัวแปร x หรือ y กันแน่ ซึ่งในความจริงแล้วตัวเราเองก็บอกไม่ได้เหมือนกัน เพราะโค้ดกิ่ง if ในฟังก์ชันจะส่งข้อมูลอ้างอิงไปยัง x และกิ่ง else จะส่งข้อมูลอ้างอิงไปยัง y!
เมื่อตอนที่เรากำลังนิยามโครงสร้างการทำงานของฟังก์ชันนี้ เราย่อมไม่รู้ว่าค่าข้อมูลรูปธรรมที่จะถูกส่งเข้ามาจริงคืออะไร ส่งผลให้ตอบไม่ได้ว่าตรรกะในบล็อก if หรือบล็อก else จะทำงาน และเราก็ไม่รู้ขอบเขตไลฟ์ไทม์รูปธรรมของข้อมูลอ้างอิงที่จะป้อนเข้ามาด้วย เราจึงไม่สามารถตรวจสอบวิเคราะห์ขอบเขตแบบวิธีดั้งเดิมในตัวอย่างที่ 10-17 และ 10-18 เพื่อการันตีว่าค่าอ้างอิงที่ส่งคืนกลับออกไปจะถูกต้องปลอดภัยเสมอ และระบบ borrow checker ก็ประเมินไม่ได้ด้วยเช่นกันเนื่องจากไม่ทราบความเชื่อมโยงเกี่ยวเนื่องกันระหว่างไลฟ์ไทม์ของ x และ y กับไลฟ์ไทม์ของผลลัพธ์ข้อมูลส่งคืน ในการแก้ไขปัญหานี้ เราจะเพิ่มพารามิเตอร์ไลฟ์ไทม์เจเนอริกเพื่อเขียนนิยามความสัมพันธ์ของข้อมูลอ้างอิงเหล่านั้นให้ชัดเจน เพื่อเปิดโอกาสให้ borrow checker ทำการตรวจวิเคราะห์ได้อย่างถูกต้อง
ไวยากรณ์ของการบันทึกระบุไลฟ์ไทม์ (Lifetime Annotation Syntax)
การเขียนบันทึกระบุไลฟ์ไทม์ไม่ได้ไปเปลี่ยนแปลงความยาวอายุการใช้งานจริงของข้อมูลอ้างอิงตัวใดเลย แต่พฤติกรรมคือการอธิบายความสัมพันธ์เกี่ยวโยงของอายุการใช้งานระหว่างข้อมูลอ้างอิงหลาย ๆ ตัวที่มีต่อกันโดยไม่ไปมีผลข้างเคียงใด ๆ กับอายุใช้งานจริง เช่นเดียวกับการที่ฟังก์ชันยินดีรับประเภทข้อมูลใด ๆ เมื่อส่วนซิกเนเจอร์ระบุพารามิเตอร์ประเภทข้อมูลเจเนอริก ฟังก์ชันต่าง ๆ ก็จะยินดีรับข้อมูลอ้างอิงของไลฟ์ไทม์ช่วงเวลาใด ๆ ก็ได้เช่นกันหากมีการเขียนระบุพารามิเตอร์ไลฟ์ไทม์เจเนอริกไว้ในโค้ด
ไวยากรณ์ของการระบุไลฟ์ไทม์มีหน้าตาที่ค่อนข้างแปลกตาเล็กน้อย: โดยชื่อของพารามิเตอร์ไลฟ์ไทม์จำเป็นต้องเริ่มต้นด้วยเครื่องหมายอัญประกาศเดี่ยว (apostrophe: ') และมักนิยมเลือกใช้ตัวอักษรพิมพ์เล็กที่มีขนาดสั้นคล้ายกับกรณีเจเนอริกทั่วไป นักพัฒนาส่วนใหญ่จะเลือกตั้งชื่อ 'a เป็นตัวระบุไลฟ์ไทม์ตัวแรก เราจะนำตัวแปรพารามิเตอร์ไลฟ์ไทม์ไปเขียนวางไว้ต่อท้ายเครื่องหมาย & ของข้อมูลอ้างอิง และเคาะช่องว่าง (space) หนึ่งเคาะเพื่อคั่นแยกระหว่างตัวไลฟ์ไทม์กับประเภทข้อมูลอ้างอิงนั้น
นี่คือตัวอย่างเปรียบเทียบ—ข้อมูลอ้างอิงของ i32 ที่ไม่ได้ระบุไลฟ์ไทม์, ข้อมูลอ้างอิงของ i32 ที่ระบุไลฟ์ไทม์ชื่อ 'a และข้อมูลอ้างอิงแบบแก้ไขได้ (mutable reference) ของ i32 ที่ระบุไลฟ์ไทม์เป็น 'a เช่นเดียวกัน:
&i32 // ข้อมูลอ้างอิงทั่วไป
&'a i32 // ข้อมูลอ้างอิงที่ระบุไลฟ์ไทม์อย่างชัดแจ้ง
&'a mut i32 // ข้อมูลอ้างอิงแบบแก้ไขได้ที่ระบุไลฟ์ไทม์อย่างชัดแจ้ง
การเขียนบันทึกระบุไลฟ์ไทม์เพียงตัวเดียวโดด ๆ จะไม่ได้มีความหมายใด ๆ เลย เนื่องจากเป้าหมายของการเขียนคือการบอกให้ Rust ทราบว่าพารามิเตอร์ไลฟ์ไทม์เจเนอริกของตัวแปรอ้างอิงหลาย ๆ ตัวมีความสัมพันธ์เกี่ยวเนื่องกันอย่างไร เราลองมาวิเคราะห์กันว่าไลฟ์ไทม์เหล่านี้เกี่ยวโยงกันอย่างไรในบริบทของฟังก์ชัน longest
ในส่วนซิกเนเจอร์ของฟังก์ชัน (In Function Signatures)
ในการประยุกต์ใช้งานไลฟ์ไทม์ในซิกเนเจอร์ของฟังก์ชัน เราจำเป็นต้องประกาศชื่อพารามิเตอร์ไลฟ์ไทม์เจเนอริกไว้ในวงเล็บสามเหลี่ยมคั่นกลางระหว่างชื่อของฟังก์ชันกับรายการตัวแปรพารามิเตอร์ เช่นเดียวกับพฤติกรรมการประกาศพารามิเตอร์ประเภทข้อมูลเจเนอริก
เราต้องการให้ซิกเนเจอร์เขียนอธิบายข้อจำกัดดังนี้: ข้อมูลอ้างอิงที่ส่งคืนกลับออกไปจะมีสถานะความถูกต้องใช้งานได้ยาวนานเท่าที่พารามิเตอร์นำเข้าทั้งสองตัวยังมีอายุใช้งานอยู่จริง นี่คือความสัมพันธ์ของไลฟ์ไทม์ระหว่างตัวแปรพารามิเตอร์นำเข้ากับผลลัพธ์ข้อมูลส่งคืน เราจะตั้งชื่อพารามิเตอร์ไลฟ์ไทม์นี้ว่า 'a แล้วนำไปเขียนต่อท้ายสำหรับข้อมูลอ้างอิงแต่ละตัว ดังแสดงในตัวอย่างที่ 10-21
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
โค้ดนี้จะช่วยให้คอมไพล์ผ่านและแสดงผลลัพธ์ตามที่เราต้องการเมื่อนำไปเรียกประมวลผลคำสั่งร่วมกับฟังก์ชัน main ในตัวอย่างที่ 10-19
คราวนี้ซิกเนเจอร์ของฟังก์ชันจะทำหน้าที่บอก Rust ว่า สำหรับช่วงเวลาไลฟ์ไทม์ 'a ใด ๆ ฟังก์ชันนี้จะรับพารามิเตอร์สองตัวซึ่งต่างก็เป็น string slices ที่มีอายุใช้งานอย่างน้อยที่สุดเท่ากับช่วงไลฟ์ไทม์ 'a และซิกเนเจอร์ของฟังก์ชันยังบอก Rust ต่ออีกว่า สตริงย่อยที่จะส่งคืนออกไปจากฟังก์ชันก็จะมีอายุใช้งานนานอย่างน้อยที่สุดเท่ากับช่วงไลฟ์ไทม์ 'a เช่นเดียวกัน ในทางปฏิบัติ นั่นหมายความว่า อายุการใช้งานของข้อมูลอ้างอิงที่ส่งคืนกลับมาจากฟังก์ชัน longest จะเท่ากับความยาวขอบเขตไลฟ์ไทม์ที่สั้นที่สุดระหว่างค่าอาร์กิวเมนต์ตัวแปรทั้งสองที่ป้อนเข้ามา ความสัมพันธ์เกี่ยวเนื่องเหล่านี้คือสิ่งที่ประสงค์ให้ Rust นำไปใช้ในขั้นตอนวิเคราะห์โค้ด
โปรดจำไว้ว่า เมื่อเราเขียนระบุพารามิเตอร์ไลฟ์ไทม์ในซิกเนเจอร์ฟังก์ชัน เราไม่ได้ไปสั่งแก้ไขเปลี่ยนแปลงไลฟ์ไทม์จริงของค่าข้อมูลที่ส่งเข้ามาหรือส่งกลับไปเลย แต่สิ่งที่เราทำคือการมอบหมายให้ระบบ borrow checker คอยแจ้งเตือนปฏิเสธค่าข้อมูลใดก็ตามที่ไม่สอดคล้องตรงตามข้อจำกัดจำพวกนี้ สังเกตว่าตัวฟังก์ชัน longest เองไม่จำเป็นต้องสืบทราบเลยว่าแท้จริงแล้ว x และ y จะมีชีวิตนานกี่บรรทัด รู้เพียงแต่ว่าต้องหาขอบเขตสเปซหนึ่งมาแทนที่ตำแหน่ง 'a เพื่อตอบสนองความต้องการซิกเนเจอร์นี้ได้ก็พอ
เมื่อจะบันทึกระบุไลฟ์ไทม์ในส่วนฟังก์ชัน การระบุจะจัดทำขึ้นที่บริเวณซิกเนเจอร์ของฟังก์ชัน ไม่ใช่วางไว้ภายในบอดี้ของฟังก์ชัน บันทึกระบุไลฟ์ไทม์จะกลายไปเป็นส่วนหนึ่งของข้อตกลงการทำงาน (contract) ของฟังก์ชัน คล้ายคลึงกับกรณีการระบุประเภทข้อมูลในซิกเนเจอร์ การระบุข้อตกลงไลฟ์ไทม์ไว้ที่ซิกเนเจอร์ช่วยให้ขั้นตอนการทำงานตรวจวิเคราะห์ของคอมไพเลอร์ Rust ดำเนินได้ง่ายรวดเร็วขึ้น หากเกิดปัญหาความไม่ถูกต้องขึ้นในจุดการระบุไลฟ์ไทม์หรือลักษณะการเรียกใช้ฟังก์ชัน ตัวคอมไพเลอร์จะรายงานจุดบกพร่องของโค้ดและระบุข้อจำกัดที่มีปัญหาได้อย่างแม่นยำยิ่งขึ้น แต่หากเปลี่ยนเป็นให้คอมไพเลอร์ Rust ทำการคาดเดาเจตนาความสัมพันธ์ของไลฟ์ไทม์เอาเอง ตัวระบบคอมไพเลอร์อาจจะทำได้เพียงชี้เตือนข้อผิดพลาดตรงจุดเรียกใช้งานโค้ดซึ่งอยู่ห่างไกลออกไปหลายขั้นตอนจากจุดเกิดเหตุที่แท้จริง
เมื่อเราส่งตัวแปรอ้างอิงรูปธรรมจริงเข้าไปในฟังก์ชัน longest ตัวแปรอายุใช้งานรูปธรรมที่จะเข้ามาแทนที่ในตำแหน่ง 'a คือพื้นที่ขอบเขตการทำงานของ x ส่วนที่ซ้อนทับเหลื่อมอยู่กับขอบเขตการทำงานของ y กล่าวอีกนัยหนึ่งคือ พารามิเตอร์ไลฟ์ไทม์เจเนอริก 'a จะมีค่าอายุการใช้งานรูปธรรมยาวนานเทียบเท่ากับไลฟ์ไทม์ที่สั้นที่สุดระหว่าง x และ y และเนื่องจากเราเขียนบันทึกกำกับค่าอ้างอิงส่งคืนด้วยตัวแปรพารามิเตอร์ไลฟ์ไทม์ 'a ตัวเดียวกันนี้ ข้อมูลอ้างอิงที่ส่งกลับออกไปจึงใช้งานได้อย่างถูกต้องยาวนานเท่ากับความยาวขอบเขตไลฟ์ไทม์ที่สั้นกว่าของ x และ y ด้วยเช่นกัน
ลองมาศึกษาวิธีการที่บันทึกระบุไลฟ์ไทม์เข้ามาจัดสรรจำกัดการทำงานของฟังก์ชัน longest เมื่อเราป้อนข้อมูลอ้างอิงที่มีอายุการใช้งานต่างประเภทกัน ตัวอย่างที่ 10-22 แสดงโค้ดอย่างง่ายเพื่อแสดงให้เห็นภาพ
fn main() {
let string1 = String::from("long string is long");
{
let string2 = String::from("xyz");
let result = longest(string1.as_str(), string2.as_str());
println!("The longest string is {result}");
}
}
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
ในตัวอย่างนี้ ตัวแปร string1 มีอายุใช้งานครอบคลุมยาวนานไปจนสิ้นสุดขอบเขตด้านนอก ตัวแปร string2 มีอายุใช้งานไปจนสิ้นสุดเพียงแค่ขอบเขตด้านใน และผลลัพธ์ใน result อ้างอิงชี้ไปยังค่าที่มีสิทธิ์ใช้ได้ยาวนานจนสิ้นสุดขอบเขตด้านใน เมื่อคุณสั่งรันโค้ดชุดนี้จะพบว่าระบบ borrow checker ยินดีปล่อยให้ผ่าน โค้ดคอมไพล์สำเร็จและพิมพ์ข้อความ The longest string is long string is long
ถัดไป ลองมาดูตัวอย่างที่แสดงให้เห็นเด่นชัดว่าอายุใช้งานของข้อมูลอ้างอิงใน result จำเป็นต้องยึดตามค่าอายุการใช้งานที่สั้นที่สุดของอาร์กิวเมนต์ทั้งสองตัว เราจะทำการย้ายบรรทัดประกาศตัวแปร result ออกไปอยู่นอกขอบเขตด้านใน แต่ยังคงเขียนขั้นตอนกำหนดค่าให้แก่ตัวแปร result ซ้อนอยู่ภายในขอบเขตด้านในเดียวกับ string2 จากนั้น เราย้ายคำสั่งเรียก println! ที่ใช้ตัวแปร result ออกไปอยู่นอกขอบเขตด้านใน หลังจากที่ขอบเขตด้านในปิดการทำงานลงเรียบร้อยแล้ว ซึ่งโค้ดชุดนี้ในตัวอย่างที่ 10-23 จะไม่สามารถคอมไพล์ผ่านได้
fn main() {
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
}
println!("The longest string is {result}");
}
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
เมื่อเราสั่งคอมไพล์โค้ดชุดนี้ เราจะพบข้อความแจ้งข้อผิดพลาดดังนี้:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0597]: `string2` does not live long enough
--> src/main.rs:6:44
|
5 | let string2 = String::from("xyz");
| ------- binding `string2` declared here
6 | result = longest(string1.as_str(), string2.as_str());
| ^^^^^^^ borrowed value does not live long enough
7 | }
| - `string2` dropped here while still borrowed
8 | println!("The longest string is {result}");
| ------ borrow later used here
For more information about this error, try `rustc --explain E0597`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
ข้อผิดพลาดชี้วัดว่า เพื่อทำให้ตัวแปร result สามารถดึงมาประมวลผลคำสั่งในบรรทัด println! ได้อย่างถูกต้องนั้น ตัวแปร string2 จำเป็นต้องคงอายุใช้งานได้ยาวนานไปจนจบขอบเขตการทำงานด้านนอก ซึ่ง Rust ทราบกฎเกณฑ์ข้อนี้ได้เนื่องจากเราบันทึกจำกัดไลฟ์ไทม์ของพารามิเตอร์นำเข้าและข้อมูลส่งคืนของฟังก์ชันไว้โดยใช้ตัวแปรพารามิเตอร์ไลฟ์ไทม์ 'a ตัวเดียวกัน
ในฐานะมนุษย์ เราสามารถไล่อ่านโค้ดและประเมินได้สบายว่า string1 มีความยาวมากกว่า string2 ดังนั้น ตัวแปร result จะต้องเก็บค่าอ้างอิงชี้ไปยัง string1 แน่นอน และเนื่องจาก string1 ยังไม่หลุดออกนอกขอบเขตการใช้งาน ข้อมูลอ้างอิงที่ชี้ไปยัง string1 จึงควรทำงานได้อย่างสมบูรณ์ดีในบรรทัด println! ทว่าตัวคอมไพเลอร์ไม่ได้มีความสามารถที่จะวิเคราะห์ข้อมูลลึกซึ้งได้ถึงขนาดนั้นในกรณีนี้ เราได้แจ้ง Rust ไปว่าอายุการใช้งานของข้อมูลอ้างอิงที่ส่งคืนกลับมาจากฟังก์ชัน longest จะต้องสอดคล้องเทียบเท่ากับอายุใช้งานที่สั้นที่สุดระหว่างอาร์กิวเมนต์นำเข้าทั้งสองตัว ดังนั้น borrow checker จึงจำต้องสั่งระงับไม่ให้โค้ดในตัวอย่างที่ 10-23 ผ่านไปได้เนื่องจากเล็งเห็นโอกาสเสี่ยงที่จะเกิดข้อมูลอ้างอิงไม่ถูกต้อง
คุณสามารถทดลองเขียนออกแบบกรณีการทดสอบเพิ่มเติมโดยลองปรับเปลี่ยนค่าและอายุการใช้งานของตัวแปรอ้างอิงที่ส่งเข้าไปในฟังก์ชัน longest ร่วมกับการดึงค่าส่งคืนไปใช้งาน ลองเขียนสมมติฐานไว้ล่วงหน้าก่อนสั่งคอมไพล์ว่าโค้ดนั้นจะผ่าน borrow checker ได้หรือไม่ แล้วลองเช็คดูว่าคุณประเมินได้ถูกต้องตรงกันไหมครับ!
ความสัมพันธ์เชิงโครงสร้าง (Relationships)
กระบวนการที่คุณต้องนำมาใช้ในการระบุพารามิเตอร์ไลฟ์ไทม์จะขึ้นอยู่กับขอบเขตคำสั่งประมวลผลที่ฟังก์ชันของคุณดำเนินการอยู่ ตัวอย่างเช่น หากเราปรับเปลี่ยนวิธีการทำงานของฟังก์ชัน longest ให้เลือกส่งกลับคืนค่าพารามิเตอร์ตัวแรกสุดเสมอแทนที่จะส่งคืนตัวที่ยาวที่สุด เราก็จะไม่จำเป็นต้องเขียนระบุไลฟ์ไทม์ให้แก่ตัวแปรพารามิเตอร์ y เลย ซึ่งโค้ดชุดนี้จะสามารถคอมไพล์ผ่านได้ตามปกติ:
fn main() {
let string1 = String::from("abcd");
let string2 = "efghijklmnopqrstuvwxyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
fn longest<'a>(x: &'a str, y: &str) -> &'a str {
x
}
เรากำหนดระบุพารามิเตอร์ไลฟ์ไทม์ 'a ไว้เฉพาะเจาะจงให้แก่พารามิเตอร์ x และประเภทข้อมูลผลลัพธ์ส่งคืน แต่ทิ้งตัวแปรพารามิเตอร์ y ไว้โดยไม่ได้ระบุ เนื่องจากช่วงอายุการใช้งานของ y ไม่ได้มีความสัมพันธ์เกี่ยวพันกันกับอายุการใช้งานของ x หรือของค่าส่งคืนเลย
เมื่อใดก็ตามที่ต้องส่งคืนข้อมูลอ้างอิงออกจากฟังก์ชัน พารามิเตอร์ไลฟ์ไทม์ของชนิดข้อมูลส่งคืนจำเป็นต้องไปสอดรับตรงกันกับพารามิเตอร์ไลฟ์ไทม์ของตัวแปรพารามิเตอร์นำเข้าตัวใดตัวหนึ่งเสมอ หากข้อมูลอ้างอิงที่ส่งคืนกลับไปนั้นไม่ได้ชี้ไปยังพารามิเตอร์นำเข้าตัวใดเลย ก็ย่อมหมายความว่ามันอ้างอิงชี้ไปยังออบเจกต์ค่าข้อมูลที่ถูกเขียนสร้างขึ้นมาภายในโครงสร้างบอดี้ของฟังก์ชันนั้นเอง แต่ทว่านั่นจะส่งผลให้เกิดปัญหาข้อมูลอ้างอิงห้อยเคว้งทันที เพราะออบเจกต์ค่าข้อมูลนั้นจะหลุดออกนอกขอบเขตและสลายตัวไปเมื่อฟังก์ชันสิ้นสุดคำสั่งลง ลองพิจารณาความพยายามเขียนฟังก์ชัน longest ที่ไม่ถูกต้องและคอมไพล์ไม่ผ่านบรรทัดนี้:
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest(string1.as_str(), string2);
println!("The longest string is {result}");
}
fn longest<'a>(x: &str, y: &str) -> &'a str {
let result = String::from("really long string");
result.as_str()
}
ในจุดนี้ แม้ว่าเราจะระบุตัวแปรพารามิเตอร์ไลฟ์ไทม์ 'a ให้แก่ประเภทข้อมูลส่งคืนเรียบร้อยแล้ว แต่โค้ดนำเสนอนี้จะล้มเหลวในการคอมไพล์เนื่องจากไลฟ์ไทม์ของข้อมูลส่งคืนไม่มีจุดเกี่ยวเนื่องเชื่อมโยงใด ๆ กับไลฟ์ไทม์ของพารามิเตอร์นำเข้าเลย ข้อความแจ้งเตือนข้อผิดพลาดที่เราจะเจอก็คือ:
$ cargo run
Compiling chapter10 v0.1.0 (file:///projects/chapter10)
error[E0515]: cannot return value referencing local variable `result`
--> src/main.rs:11:5
|
11 | result.as_str()
| ------^^^^^^^^^
| |
| returns a value referencing data owned by the current function
| `result` is borrowed here
For more information about this error, try `rustc --explain E0515`.
error: could not compile `chapter10` (bin "chapter10") due to 1 previous error
ปัญหาของโครงสร้างนี้คือ ตัวแปร result จะหลุดออกนอกขอบเขตการใช้งานและถูกเคลียร์คืนหน่วยความจำทันทีเมื่อฟังก์ชัน longest สิ้นสุดคำสั่งลง ในขณะเดียวกันเราก็พยายามจะส่งข้อมูลอ้างอิงชี้ไปยัง result นั้นกลับออกไปนอกฟังก์ชัน ไม่มีทางหนทางไวยากรณ์ใด ๆ ที่เราจะเขียนระบุพารามิเตอร์ไลฟ์ไทม์เพื่อเข้าไปเยียวยาแก้ไขปัญหาข้อมูลอ้างอิงห้อยเคว้งตรงจุดนี้ได้ และ Rust ปฏิเสธการสร้างโค้ดที่มีความเสี่ยงลักษณะนี้โดยเด็ดขาด แนวทางแก้ไขที่ดีที่สุดสำหรับสถานการณ์นี้คือการเปลี่ยนมาส่งคืนประเภทข้อมูลที่เป็นเจ้าของตัวเอง (owned data type) แทนที่จะส่งค่าข้อมูลอ้างอิง เพื่อโยนความรับผิดชอบในการกวาดล้างดูแลหน่วยความจำข้อมูลไปให้แก่ฟังก์ชันผู้เรียกใช้งานส่วนปลายทางรับหน้าที่ไปจัดการต่อแทน
สรุปสาระสำคัญคือ ไวยากรณ์ไลฟ์ไทม์มีจุดประสงค์หลักเพื่อใช้ถักทอเชื่อมโยงขอบเขตอายุการใช้งานของพารามิเตอร์นำเข้าต่าง ๆ กับข้อมูลผลลัพธ์ส่งคืนของฟังก์ชันเข้าไว้ด้วยกัน เมื่อความสัมพันธ์ดังกล่าวถูกเชื่อมต่อเรียบร้อยแล้ว Rust ก็จะมีรายละเอียดเพียงพอที่จะตรวจสอบและรับประกันว่าโปรแกรมจะดำเนินคำสั่งที่ปลอดภัยต่อหน่วยความจำ (memory-safe) เสมอ และสั่งระงับขัดขวางคำสั่งใด ๆ ที่อาจก่อเกิดตัวชี้ห้อยเคว้ง (dangling pointers) หรือจุดฝ่าฝืนข้อกำหนดความปลอดภัยของหน่วยความจำ
ในส่วนนิยามของ Struct (In Struct Definitions)
ที่ผ่านมา struct ต่าง ๆ ที่เรากำหนดนิยามขึ้นมาล้วนมีพฤติกรรมจัดเก็บเฉพาะประเภทข้อมูลที่เป็นเจ้าของตัวเอง (owned types) ความจริงแล้วเราสามารถเขียนกำหนดนิยาม struct ให้เก็บข้อมูลอ้างอิงได้เช่นเดียวกัน แต่อย่างไรก็ดีในสถานการณ์เช่นนั้น เราจำเป็นต้องใส่ระบุไลฟ์ไทม์ให้แก่ข้อมูลอ้างอิงทุกตัวที่มีการนิยามไว้ใน struct ตัวอย่างที่ 10-24 แสดง struct ชื่อว่า ImportantExcerpt ที่ทำหน้าที่จัดเก็บสตริงย่อย
struct ImportantExcerpt<'a> {
part: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let i = ImportantExcerpt {
part: first_sentence,
};
}
struct นี้มีฟิลด์ข้อมูลตัวเดียวคือ part ซึ่งเก็บข้อมูลสตริงย่อยที่มีฐานะเป็นข้อมูลอ้างอิง เช่นเดียวกับการเขียนเจเนอริกของประเภทข้อมูลทั่วไป เราประกาศชื่อพารามิเตอร์ไลฟ์ไทม์เจเนอริกไว้ในวงเล็บสามเหลี่ยมคั่นต่อท้ายชื่อของ struct เพื่อที่เราจะดึงไลฟ์ไทม์นี้ไประบุพารามิเตอร์ภายในนิยามฟิลด์ของ struct บันทึกกำกับไลฟ์ไทม์ในจุดนี้มีความหมายว่า อินสแตนซ์ของ ImportantExcerpt จะไม่มีสิทธิ์อยู่ยงคงกระพันยาวนานเกินกว่าขอบเขตอายุใช้งานของข้อมูลอ้างอิงที่มันจัดเก็บไว้ในฟิลด์ part
ฟังก์ชัน main ในตัวอย่างนี้ทำหน้าที่สร้างอินสแตนซ์ของ struct ImportantExcerpt ซึ่งมีพฤติกรรมจัดเก็บข้อมูลอ้างอิงชี้ไปยังประโยคแรกสุดของออบเจกต์ String ที่ครอบครองสิทธิ์โดยตัวแปร novel สังเกตว่า ข้อมูลในตัวแปร novel ได้รับการสร้างไว้และมีตัวตนอยู่ก่อนแล้วที่อินสแตนซ์ ImportantExcerpt จะถือกำเนิดขึ้นมา นอกจากนี้ ตัวแปร novel จะยังไม่หลุดออกนอกขอบเขตจนกว่าตัวแปร ImportantExcerpt จะทำงานสิ้นสุดและหลุดนอกขอบเขตไปก่อน ส่งผลให้ข้อมูลอ้างอิงภายในอินสแตนซ์ ImportantExcerpt ยังคงถูกต้องปลอดภัยดี
กฎการละเว้นการระบุไลฟ์ไทม์ (Lifetime Elision)
คุณได้เรียนรู้ไปแล้วว่า ข้อมูลอ้างอิงทุกตัวใน Rust ต่างก็มีขอบเขตอายุการใช้งาน และคุณจำเป็นต้องเขียนระบุพารามิเตอร์ไลฟ์ไทม์ให้แก่ฟังก์ชันหรือ struct ที่หยิบยืมข้อมูลอ้างอิงไปประมวลผลคำสั่ง อย่างไรก็ตาม เราเคยมีฟังก์ชันหนึ่งในตัวอย่างที่ 4-9 ซึ่งนำมาแสดงให้เห็นอีกครั้งในตัวอย่างที่ 10-25 ที่สามารถผ่านกระบวนการคอมไพล์ได้อย่างราบรื่นโดยที่เราไม่จำเป็นต้องระบุพารามิเตอร์ไลฟ์ไทม์ใด ๆ เลย
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 my_string = String::from("hello world");
// first_word works on slices of `String`s
let word = first_word(&my_string[..]);
let my_string_literal = "hello world";
// first_word works on slices of string literals
let word = first_word(&my_string_literal[..]);
// Because string literals *are* string slices already,
// this works too, without the slice syntax!
let word = first_word(my_string_literal);
}
เหตุผลที่ฟังก์ชันนี้สามารถผ่านขั้นตอนคอมไพล์ได้ฉลุยโดยไม่ต้องบันทึกระบุไลฟ์ไทม์มีประเด็นประวัติศาสตร์แฝงอยู่: ในตัวโปรแกรม Rust เวอร์ชันแรก ๆ (ยุคก่อน 1.0) โค้ดชุดนี้จะไม่มีวันคอมไพล์ผ่านเด็ดขาด เนื่องจากข้อกำหนดดั้งเดิมระบุว่า ข้อมูลอ้างอิงทุกตัวจำเป็นต้องระบุไลฟ์ไทม์อย่างชัดเจนเสมอ ในช่วงเวลานั้น ตัวซิกเนเจอร์ของฟังก์ชันจะจำต้องเขียนระบุไวยากรณ์โครงสร้างแบบนี้:
fn first_word<'a>(s: &'a str) -> &'a str {
หลังจากที่ทีมนักพัฒนา Rust ได้มีโอกาสเขียนโค้ดจำนวนมหาศาล พวกเขาตรวจพบพฤติกรรมที่ว่า นักพัฒนาจำต้องคอยพิมพ์บันทึกระบุไลฟ์ไทม์เดิมซ้ำไปซ้ำมาในสถานการณ์แบบเดิมบ่อยครั้ง ซึ่งรูปแบบความสัมพันธ์เหล่านั้นมีความแน่นอน คาดเดาได้ง่าย และมีแพทเทิร์นที่เป็นระบบ ทางทีมพัฒนาจึงได้เขียนชุดเงื่อนไขบรรจุแพทเทิร์นเหล่านี้ฝังลงไปในระบบของตัวคอมไพเลอร์โดยตรง เพื่อช่วยให้ borrow checker คาดเดาสรุปไลฟ์ไทม์ในสถานการณ์ดังกล่าวแทนเราได้โดยอัตโนมัติ ส่งผลให้ผู้ใช้ไม่ต้องเขียนพิมพ์อธิบายไลฟ์ไทม์ในโค้ดอีกต่อไป
รายละเอียดประวัติศาสตร์ Rust ส่วนนี้มีความเกี่ยวโยงสำคัญกับเรา เนื่องจากมีความเป็นไปได้ว่า ในอนาคตจะมีข้อจำกัดที่แน่นอนใหม่ ๆ ได้รับการประเมินและถูกบรรจุฝังเพิ่มเข้าไปในระบบคอมไพเลอร์อีก ซึ่งอาจทำให้จำนวนจุดที่เราจำต้องเขียนระบุไลฟ์ไทม์ลดจำนวนลงไปอีกก็เป็นได้
กลุ่มเงื่อนไขรูปแบบที่ถูกบรรจุฝังไว้ในตัววิเคราะห์ข้อมูลอ้างอิงของ Rust เรียกว่า กฎการละเว้นการระบุไลฟ์ไทม์ (lifetime elision rules) ซึ่งกฎจำพวกนี้ไม่ใช่กฎข้อบังคับที่กำหนดให้นักพัฒนาโปรแกรมต้องคอยประพฤติปฏิบัติตามแต่อย่างใด แต่คือกลุ่มเงื่อนไขกรณีตัวอย่างที่คอมไพเลอร์จะหยิบยกไปใช้ตรวจสอบ หากโครงสร้างโค้ดของคุณตรงสอดคล้องตามเงื่อนไขเหล่านี้ คุณก็จะไม่ต้องพิมพ์อธิบายไลฟ์ไทม์ลงไปในโค้ดเลย
กฎการละเว้นไม่ได้ทำงานครอบคลุมช่วยคาดเดาความสัมพันธ์ได้หมดทุกกรณี หากตรวจพบจุดก้ำกึ่งกำกวมว่าไลฟ์ไทม์ของข้อมูลอ้างอิงแต่ละตัวเกี่ยวโยงกันอย่างไรหลังจากที่คอมไพเลอร์ได้ลองนำกฎไปใช้งานแล้ว ตัวระบบคอมไพเลอร์จะไม่พยายามสุ่มคาดเดาให้คุณเด็ดขาดเพื่อป้องกันช่องโหว่ความเสี่ยง ในการแก้ไขปัญหานี้ ระบบจะแสดงข้อความเตือนความผิดพลาดในขั้นตอนคอมไพล์เพื่อให้คุณเป็นผู้เข้ามาแก้ไขจัดสรรโดยการพิมพ์ระบุไลฟ์ไทม์เพิ่มเข้าไป
อายุการใช้งานของข้อมูลอ้างอิงที่ระบุกับพารามิเตอร์ของฟังก์ชันหรือเมธอดเรียกว่า ไลฟ์ไทม์นำเข้า (input lifetimes) และอายุใช้งานของผลลัพธ์ข้อมูลส่งคืนเรียกว่า ไลฟ์ไทม์ส่งออก (output lifetimes)
ตัวคอมไพเลอร์จะนำเอากฎเกณฑ์ 3 ข้อมาพิจารณาเพื่อตามหาสรุปไลฟ์ไทม์ของข้อมูลอ้างอิงในจุดที่ผู้เขียนโค้ดไม่ได้ระบุไว้ กฎข้อแรกจะใช้จัดสรรประเมินกับกลุ่มไลฟ์ไทม์นำเข้า ส่วนกฎข้อที่สองและสามจะใช้ประเมินกับฝั่งไลฟ์ไทม์ส่งออก หากคอมไพเลอร์ลองไล่ตรวจดูจนครบทั้งสามกฎเกณฑ์แล้วยังพบตัวแปรอ้างอิงที่ยังสรุปไลฟ์ไทม์ไม่ได้ คอมไพเลอร์จะยุติกระบวนการและแสดงข้อความผิดพลาดทันที ซึ่งกฎเหล่านี้จะถูกนำไปใช้ประเมินกับนิยามโครงสร้าง fn รวมถึงในบล็อกคำสั่ง impl ด้วยเช่นกัน
กฎข้อแรกระบุว่า คอมไพเลอร์จะทำหน้าที่แจกจ่ายตัวแปรพารามิเตอร์ไลฟ์ไทม์เฉพาะให้แก่พารามิเตอร์นำเข้าทุกตัวที่เป็นข้อมูลอ้างอิง กล่าวคือ ฟังก์ชันที่มีพารามิเตอร์ตัวเดียวจะได้รับพารามิเตอร์ไลฟ์ไทม์หนึ่งตัว: fn foo<'a>(x: &'a i32) ส่วนฟังก์ชันที่มีพารามิเตอร์นำเข้าสองตัวจะได้รับพารามิเตอร์ไลฟ์ไทม์แยกกันสองตัว: fn foo<'a, 'b>(x: &'a i32, y: &'b i32) และดำเนินพฤติกรรมนี้ต่อไปเรื่อย ๆ ตามจำนวนตัวแปร
กฎข้อที่สองระบุว่า หากในกลุ่มพารามิเตอร์นำเข้ามีตัวแปรไลฟ์ไทม์เพียงตัวเดียวถ้วน ไลฟ์ไทม์ดังกล่าวจะถูกนำไปผูกกำหนดให้กับตัวแปรไลฟ์ไทม์ส่งออกทั้งหมดโดยอัตโนมัติ: fn foo<'a>(x: &'a i32) -> &'a i32
กฎข้อที่สามระบุว่า หากในกลุ่มพารามิเตอร์นำเข้ามีพารามิเตอร์ไลฟ์ไทม์หลายตัว แต่ทว่าหนึ่งในพารามิเตอร์เหล่านั้นคือ &self หรือ &mut self เนื่องจากฟังก์ชันดังกล่าวมีฐานะเป็นเมธอด ไลฟ์ไทม์ของ self จะถูกนำไปผูกกำหนดให้กับตัวแปรไลฟ์ไทม์ส่งออกทั้งหมดทันที กฎข้อที่สามนี้ช่วยให้เมธอดต่าง ๆ อ่านและเขียนได้สบายตาขึ้นอย่างมาก เนื่องจากไม่จำเป็นต้องใส่เครื่องหมายกำกับพารามิเตอร์ยิบย่อย
เราลองมาสวมบทบาทสมมติเป็นตัวคอมไพเลอร์และลองนำกฎเกณฑ์เหล่านี้มาตรวจสอบหาคำตอบไลฟ์ไทม์ของข้อมูลอ้างอิงในซิกเนเจอร์ของฟังก์ชัน first_word ในตัวอย่างที่ 10-25 กันครับ โดยเริ่มแรก ตัวซิกเนเจอร์จะไม่มีข้อมูลไลฟ์ไทม์ระบุอยู่กับค่าอ้างอิงเลย:
fn first_word(s: &str) -> &str {
จากนั้น คอมไพเลอร์จะนำเอากฎข้อแรกมาพิจารณา ซึ่งกำหนดให้ตัวแปรพารามิเตอร์แต่ละตัวได้รับสิทธิ์ไลฟ์ไทม์เฉพาะของตัวเอง เราจะตั้งชื่อขอบเขตมันว่า 'a เช่นเดิม ส่งผลให้ได้ซิกเนเจอร์หน้าตาแบบนี้:
fn first_word<'a>(s: &'a str) -> &str {
ขั้นต่อไป ระบบตรวจวิเคราะห์ว่าสามารถนำกฎข้อที่สองมาประยุกต์ใช้ได้ เนื่องจากในกลุ่มพารามิเตอร์นำเข้ามีจำนวนไลฟ์ไทม์เพียงหนึ่งตัวถ้วน ซึ่งกฎข้อที่สองระบุว่าไลฟ์ไทม์พารามิเตอร์นำเข้าเดี่ยวนั้นจะถูกนำไปกำหนดให้กับชนิดข้อมูลส่งคืนทันที ส่งผลให้ได้ซิกเนเจอร์เวอร์ชันสุดท้ายดังนี้:
fn first_word<'a>(s: &'a str) -> &'a str {
ในจุดนี้ ข้อมูลอ้างอิงทั้งหมดในซิกเนเจอร์ของฟังก์ชันต่างก็มีขอบเขตไลฟ์ไทม์กำกับไว้อย่างครบถ้วนสมบูรณ์แล้ว คอมไพเลอร์จึงพร้อมไปดำเนินขั้นตอนวิเคราะห์ด้านอื่น ๆ ต่อไปได้โดยที่นักพัฒนาไม่จำเป็นต้องมาพิมพ์ระบุไลฟ์ไทม์ในซิกเนเจอร์นี้เลย
ลองมาดูอีกหนึ่งตัวอย่าง ครั้งนี้เราหยิบฟังก์ชัน longest ที่ไม่มีข้อมูลพารามิเตอร์ไลฟ์ไทม์ติดตัวมาตั้งแต่แรกในตัวอย่างที่ 10-20 มาประเมินกันครับ:
fn longest(x: &str, y: &str) -> &str {
ลองเริ่มจากกฎข้อแรก: กำหนดให้พารามิเตอร์นำเข้าแต่ละตัวมีอายุการใช้งานเฉพาะของตัวเอง ครั้งนี้เรามีตัวแปรอ้างอิงนำเข้าสองตัวแทนที่จะเป็นตัวเดียว ส่งผลให้ได้ขอบเขตไลฟ์ไทม์สองตัวแยกกันดังนี้:
fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str {
คุณจะพบได้ทันทีว่ากฎข้อที่สองไม่สามารถนำมาประยุกต์ใช้งานได้ เนื่องจากตรวจพบพารามิเตอร์ไลฟ์ไทม์นำเข้ามากกว่าหนึ่งตัว และกฎข้อที่สามก็ใช้ไม่ได้เช่นเดียวกัน เนื่องจากฟังก์ชัน longest เป็นฟังก์ชันทั่วไป ไม่ใช่เมธอด จึงไม่มีตัวแปรพารามิเตอร์ใดที่เป็น self เลย หลังจากไล่ประเมินกฎเกณฑ์จนสิ้นสุดครบทั้ง 3 ข้อ เราก็ยังไม่สามารถสรุปได้ว่าประเภทข้อมูลผลลัพธ์ส่งคืนของฟังก์ชันนี้จะมีขอบเขตไลฟ์ไทม์อย่างไร และนี่คือเหตุผลเบื้องหลังที่ทำไมคอมไพเลอร์จึงแสดงข้อผิดพลาดเมื่อพยายามคอมไพล์โค้ดในตัวอย่างที่ 10-20: เพราะคอมไพเลอร์ได้พยายามไล่กฎการละเว้นระบุไลฟ์ไทม์จนครบแล้วแต่ก็ยังไม่สามารถประเมินหาคำตอบไลฟ์ไทม์ของข้อมูลอ้างอิงในซิกเนเจอร์ให้เสร็จสิ้นได้
เนื่องจากกฎข้อที่สามมีบทบาทใช้งานเฉพาะกับส่วนซิกเนเจอร์ของเมธอด ถัดไปเราจะมาเรียนรู้เรื่องไลฟ์ไทม์ในบริบทของเมธอดเพื่อศึกษาว่าทำไมกฎข้อที่สามจึงช่วยลดภาระให้เราไม่ต้องพิมพ์กำกับระบุไลฟ์ไทม์ในเมธอดบ่อยครั้ง
ในส่วนนิยามของ เมธอด (In Method Definitions)
เมื่อเราเขียนคำสั่งนำเสนอเมธอดต่าง ๆ ให้แก่ struct ที่มีพารามิเตอร์ไลฟ์ไทม์ระบุอยู่ เราจะใช้งานไวยากรณ์ในลักษณะเดียวกับกรณีของพารามิเตอร์ประเภทข้อมูลเจเนอริกดังแสดงในตัวอย่างที่ 10-11 ส่วนรายละเอียดจุดประกาศตัวแปรและการนำไลฟ์ไทม์ไปใช้งานจะขึ้นตรงกับว่าพวกมันมีความเกี่ยวข้องผูกพันกับฟิลด์ภายใน struct หรือพารามิเตอร์และผลลัพธ์ข้อมูลส่งคืนของเมธอดเป็นหลัก
การประกาศระบุชื่อไลฟ์ไทม์สำหรับฟิลด์ภายในของ struct จำเป็นต้องเขียนประกาศต่อท้ายคำสำคัญ impl และนำไปกำกับต่อท้ายชื่อของ struct เสมอ เนื่องจากไลฟ์ไทม์เหล่านั้นถือเป็นองค์ประกอบหลักของชนิดประเภทข้อมูลของ struct
ภายในซิกเนเจอร์ของเมธอดที่อยู่ในบล็อก impl ข้อมูลอ้างอิงต่าง ๆ อาจได้รับสิทธิ์ให้เชื่อมต่อผูกพันกับอายุการใช้งานของฟิลด์ภายใน struct หรืออาจเป็นอิสระต่อกันไม่เกี่ยวกันเลยก็ได้ นอกจากนี้ กฎการละเว้นระบุไลฟ์ไทม์มักช่วยจัดการเคลียร์ภาระให้เราไม่ต้องเขียนบันทึกกำกับไลฟ์ไทม์ในซิกเนเจอร์เมธอดอยู่บ่อยครั้ง ลองมาวิเคราะห์ตัวอย่างโดยอิงจาก struct ImportantExcerpt ที่เราได้นิยามไปในตัวอย่างที่ 10-24 กันครับ
เริ่มแรก เราเขียนเมธอดชื่อว่า level ซึ่งรับพารามิเตอร์เพียงตัวเดียวคือข้อมูลอ้างอิงชี้ไปยัง self และส่งค่ากลับคืนมาเป็นชนิด i32 ซึ่งไม่ใช่ข้อมูลอ้างอิงชี้ไปยังสิ่งใดเลย:
struct ImportantExcerpt<'a> {
part: &'a str,
}
impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {announcement}");
self.part
}
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let i = ImportantExcerpt {
part: first_sentence,
};
}
การเขียนประกาศตัวแปรพารามิเตอร์ไลฟ์ไทม์ต่อท้าย impl และกำกับใช้งานหลังชื่อ struct ยังคงมีความจำเป็นต้องเขียนระบุ แต่เนื่องจากอานิสงส์ของกฎการละเว้นข้อแรก ส่งผลให้เราไม่ต้องเขียนบันทึกกำกับไลฟ์ไทม์ใด ๆ ให้แก่ตัวแปรอ้างอิง self เลย
นี่คืออีกหนึ่งตัวอย่างประยุกต์ใช้งานซึ่งตรงตามกฎการละเว้นข้อที่สาม:
struct ImportantExcerpt<'a> {
part: &'a str,
}
impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}
impl<'a> ImportantExcerpt<'a> {
fn announce_and_return_part(&self, announcement: &str) -> &str {
println!("Attention please: {announcement}");
self.part
}
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().unwrap();
let i = ImportantExcerpt {
part: first_sentence,
};
}
ในจุดนี้ตรวจพบตัวแปรอ้างอิงนำเข้าสองตัว ดังนั้น Rust จะใช้กฎการละเว้นข้อแรกและแจกจ่ายตัวแปรพารามิเตอร์ไลฟ์ไทม์เฉพาะให้แก่ &self และ announcement แยกกัน จากนั้น เนื่องจากตรวจพบว่าหนึ่งในพารามิเตอร์นำเข้ามีฐานะเป็น &self ข้อมูลชนิดส่งคืนของฟังก์ชันจึงได้รับสิทธิ์ผูกกำหนดไลฟ์ไทม์ให้สอดรับตรงกันกับไลฟ์ไทม์ของ &self ส่งผลให้ข้อมูลความสัมพันธ์ของไลฟ์ไทม์ทุกตัวได้รับการจัดสรรสรุปจนเสร็จสิ้นเรียบร้อย
อายุการใช้งานแบบสแตติก (The Static Lifetime)
ไลฟ์ไทม์พิเศษเฉพาะประการหนึ่งที่เราต้องนำมาอภิปรายคือ 'static ซึ่งบ่งบอกพฤติกรรมว่าข้อมูลอ้างอิงดังกล่าวสามารถมีอายุใช้งานยาวนานคงอยู่ได้ตลอดช่วงระยะเวลาการทำงานของโปรแกรมทั้งหมด ข้อความสตริงตายตัว (string literals) ทุกตัวต่างก็มีอายุใช้งานระดับ 'static ทั้งสิ้น ซึ่งเราเขียนบันทึกกำกับระบุได้ดังนี้:
#![allow(unused)]
fn main() {
let s: &'static str = "I have a static lifetime.";
}
ข้อมูลเนื้อความของสตริงนี้จะถูกจัดเก็บบันทึกฝังไว้ในตัวไฟล์ไบนารีของโปรแกรมโดยตรงซึ่งพร้อมให้เรียกใช้งานได้ตลอดเวลา ดังนั้น ช่วงอายุการใช้งานของข้อความสตริงตายตัวทั้งหมดจึงเป็นชนิด 'static
คุณอาจจะพบข้อความแนะนำจากคอมไพเลอร์แจ้งเตือนแนะนำให้ลองหยิบใช้งานไลฟ์ไทม์ 'static ขึ้นมาบ้าง ทว่าก่อนที่คุณจะตัดสินใจระบุ 'static ให้แก่ข้อมูลอ้างอิงใด ๆ ขอให้ตรึกตรองและฉุกคิดก่อนว่าข้อมูลอ้างอิงดังกล่าวมีอายุการใช้งานยาวนานคงอยู่ได้ตลอดอายุการรันโปรแกรมจริงหรือไม่ และคุณมีเจตนาต้องการเช่นนั้นจริงหรือเปล่า บ่อยครั้งที่ข้อความแจ้งเตือนที่แนะนำให้สลับมาใช้ไลฟ์ไทม์ 'static มักมีต้นตอมาจากความพยายามสร้างข้อมูลอ้างอิงห้อยเคว้ง หรือเกิดการไม่ตรงกันของอายุการใช้งานข้อมูลอ้างอิงที่มี ในกรณีเช่นนั้น แนวทางแก้ไขปัญหาที่ถูกต้องคือการเข้าไปสะสางแก้ไขโครงสร้างและขอบเขตของโค้ดให้ถูกต้อง ไม่ใช่แก้ปัญหาด้วยการฝืนไประบุไลฟ์ไทม์แบบ 'static
พารามิเตอร์ประเภทข้อมูลเจเนอริก, ขอบเขตทริต, และไลฟ์ไทม์ร่วมกัน (Generic Type Parameters, Trait Bounds, and Lifetimes)
เราลองมาศึกษาภาพรวมไวยากรณ์ในการเขียนระบุทั้งพารามิเตอร์ประเภทข้อมูลเจเนอริก, ขอบเขตของทริต, และไลฟ์ไทม์รวมกันอยู่ภายใต้ฟังก์ชันเดียวกันแบบกระชับกันครับ!
fn main() {
let string1 = String::from("abcd");
let string2 = "xyz";
let result = longest_with_an_announcement(
string1.as_str(),
string2,
"Today is someone's birthday!",
);
println!("The longest string is {result}");
}
use std::fmt::Display;
fn longest_with_an_announcement<'a, T>(
x: &'a str,
y: &'a str,
ann: T,
) -> &'a str
where
T: Display,
{
println!("Announcement! {ann}");
if x.len() > y.len() { x } else { y }
}
นี่คือฟังก์ชัน longest เดิมจากตัวอย่างที่ 10-21 ซึ่งส่งคืนข้อมูลสตริงย่อยที่ยาวกว่าระหว่างสองตัวแปร แต่ทว่ารอบนี้มีการเพิ่มเติมพารามิเตอร์ตัวใหม่เข้ามาชื่อว่า ann ประเภทเจเนอริก T ซึ่งยินดีต้อนรับประเภทข้อมูลใด ๆ ก็ปานที่สืบทอดทริต Display ตามเงื่อนไขที่ระบุไว้ในบล็อกคำสั่ง where พารามิเตอร์เสริมตัวนี้จะถูกเรียกมาพิมพ์ประมวลผลผ่านสัญลักษณ์ {} ซึ่งเป็นเหตุผลว่าทำไมการผูกขอบเขตทริต Display จึงมีความสำคัญ เนื่องจากไลฟ์ไทม์มีฐานะเป็นเจเนอริกรูปแบบหนึ่ง บรรทัดประกาศพารามิเตอร์ไลฟ์ไทม์ 'a และพารามิเตอร์ประเภทเจเนอริก T จึงจะถูกจัดหมวดหมู่เขียนรวมกันอยู่ภายใต้เครื่องหมายวงเล็บสามเหลี่ยมต่อท้ายชื่อฟังก์ชัน
สรุป (Summary)
เราได้เรียนรู้เนื้อหาไปเยอะมากในบทนี้! คราวนี้เมื่อคุณมีความเข้าใจเกี่ยวกับพารามิเตอร์ประเภทข้อมูลเจเนอริก, ทริตและข้อจำกัดขอบเขตของทริต รวมถึงพารามิเตอร์ไลฟ์ไทม์เจเนอริกแล้ว คุณก็พร้อมที่จะเขียนโค้ดเพื่อแก้ปัญหาโดยไม่ต้องเขียนโค้ดซ้ำซากและพร้อมรองรับสถานการณ์ที่หลากหลายได้แล้ว พารามิเตอร์เจเนอริกช่วยให้คุณนำโค้ดไปรันประมวลผลกับประเภทข้อมูลที่ต่างกันได้ ส่วนทริตและขอบเขตทริตจะคอยช่วยควบคุมให้มั่นใจว่าประเภทเจเนอริกเหล่านั้นจะมีพฤติกรรมรองรับตามความต้องการของโค้ด และคุณได้เรียนรู้วิธีการใช้พารามิเตอร์ไลฟ์ไทม์เพื่อปิดประตูความเสี่ยงไม่ให้โค้ดที่มีความยืดหยุ่นสูงเหล่านี้เกิดตัวชี้ห้อยเคว้ง ซึ่งขั้นตอนการตรวจวิเคราะห์ทั้งหมดนี้ถูกดำเนินงานขึ้นอย่างสมบูรณ์แบบตั้งแต่ตอนคอมไพล์ ทำให้ไม่ไปแย่งประสิทธิภาพความเร็วในการประมวลผลของโปรแกรมในตอนรันไทม์เลย!
เชื่อหรือไม่ว่า ยังมีเนื้อหาที่น่าสนใจและลึกซึ้งรอให้ไปเรียนรู้ต่อยอดอีกมากมายจากหัวข้อที่เราอภิปรายกันไปในบทนี้: โดยในบทที่ 18 จะนำเสนอหัวข้อทริตอ็อบเจกต์ (trait objects) ซึ่งเป็นอีกหนึ่งแง่มุมของการใช้ประโยชน์จากทริต และยังมีกรณีการใช้งานไลฟ์ไทม์ที่มีความซับซ้อนขึ้นไปอีกขั้นซึ่งมีความจำเป็นเฉพาะในสถานการณ์ขั้นสูงมาก ๆ ซึ่งคุณสามารถหาอ่านศึกษารายละเอียดต่อยอดได้ที่ Rust Reference แต่ทว่าในบทถัดไป คุณจะได้เรียนรู้วิธีการเขียนชุดทดสอบ (tests) ในภาษา Rust เพื่อช่วยตรวจสอบรับประกันความถูกต้องให้มั่นใจได้เต็มเปี่ยมว่าโค้ดโปรแกรมของคุณทำงานได้ตรงตามเป้าหมาย