สรุป The Rust Programming Language ฉบับมือเก่า ตอนที่ 10.3
บทความนี้สรุปจากหนังสือ The Rust Programming Language chapter 10.3 ด้วยความคิดเห็นของผมเอง อาจมีการตีความที่มีโอกาสผิดพลาดได้ ทางที่ดีถ้าอยากได้ reference จริงๆแนะนำต้นทางครับ
10.3 Validating References with Lifetimes
เรื่อง lifetimes นี้เป็นเรื่องใหม่พอสมควร เอาจริงๆผมเองยังไม่เคยเจอ concept นี้ในภาษาอื่นมาก่อน แต่ก็อาจจะเพราะ safe memory management เป็น key feature นึงของ Rust ละมั้ง ซึ่งเรื่อง Lifetimes เองก็เกี่ยวกับเรื่องนี้นั่นแหล่ะ
ให้อธิบายสั้นๆ lifetimes ก็เป็น type รูปแบบนึง แต่ type นี้แทนที่จะใช้บอก data structure หรือ behavioral structure มันกลับเป็นตัวบอก property นึงของ reference ว่า lifetime scope ของมันคืออะไร (พูดอย่างง่ายก็คือ value ที่ไปยืมเค้ามา จะโดน pop ออกจาก stack จนทำให้ reference ไม่ valid เมื่อไหร่)
และด้วยความที่มันเป็น type มันก็เลยไม่ได้เป็นตัวกำหนด lifetime จริงๆ ของ reference นั้นใน runtime แต่เป็น annotation ที่ช่วยบอก compiler มากกว่าว่า lifetime คืออะไร (แบบเดียวกับ type อื่นๆ คือมันไม่ได้เปลี่ยนแปลงอะไรที่ runtime แต่เป็นตัวช่วยใน compile time มากกว่า)
เรื่องนึงที่น่าจำก็คือ `ทุก reference จะต้องมี lifetime กำกับ` (แต่จะ implicitly หรือ explicitly นั้นอีกเรื่อง)
เพราะว่า Borrow Checker จะต้องรู้ว่า lifetime ของ reference เรามันอายุไม่เกินอายุของตัวต้นฉบับที่เราไปยืมมา
ยกตัวอย่างเช่น code ด้านล่างนี้ (copy มาจาก code ในหนังสือเลย 555)
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {r}"); // |
} // ---------+
อย่างที่เห็นว่าค่าต้นฉบับมัน let x = 5
มันมี lifetime อยู่แค่ 'b
scope แต่คนที่ยืม r = &x;
กลับมี lifetime อยู่ที่ 'a
scope ที่อายุนานกว่า 'b
scope (ซึ่ง code ด้านบนก็จะได้ compile error มา)
ในขณะที่โค้ดนี้
fn main() {
let x = 5; // ----------+-- 'b
// |
let r = &x; // --+-- 'a |
// | |
println!("r: {r}"); // | |
// --+ |
} // ----------+
คราวนี้ let r = &x
อยู่ที่ 'a
scope ที่จะถูก pop ของจาก stack เร็วกว่าตัว let x = 5;
ก็เลยใช้ได้
แต่จะเห็นว่าตัวอย่างข้างบน เราไม่จำเป็นต้องมี lifetime annotation กำกับเพราะ compiler สามารถ infer เอาได้ไม่ยาก แต่บางกรณี compiler เองก็ยังไม่ฉลาดพอที่จะอนุมาน lifetime ของ references ทั้งหมด เราเลยจะต้องกำกับด้วย annotation
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
เช่น code ด้านบนนี้ compiler รู้ lifetime ของ x: &str
และ y: &str
นะ แต่เค้าไม่รู้ว่า lifetime ของ return type -> &str
คืออะไร (เอาจริงๆผมแอบรู้สึกว่าเคสแบบนี้ compiler ควรจะสามารถ infer ได้ เพราะมีการ return ทั้ง x และ y ไม่น่าจะต้องให้เรากำหนด annotation เอง ในหนังสือเค้าบอกว่า compiler จะค่อยๆฉลาดขึ้น แล้วจะสามารถ infer ได้เยอะขึ้นเรื่อยๆไปเอง)
โอเคงั้น case นี้เราก็ต้องใส่ lifetime annotation ไปด้วยแบบนี้
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
สิ่งที่เพิ่มขึ้นมาก็คือ generic lifetime type param <’a>
นั่นเอง คราวนี้ compiler รู้แล้วว่าจะต้องเอา lifetime ที่สั้นที่สุดระหว่าง x
และ y
มาเป็น lifetime ของ output
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}");
}
code ด้านบนนี้จะไม่ compiled เพราะ lifetime ของ result
ที่ได้จาก longest()
จะเป็น lifetime ของ string2
เพราะเป็น lifetime ที่สั้นที่สุดระหว่าง string1 และ string2
ถึงแม้ว่าเราจะเห็นอยู่ทนโท่ว่า reference ที่ return มามันเป็นของ string1
เพราะมันเป็น hardcoded string literal ที่ใน runtime มันควรจะรันผ่าน แต่อย่างที่บอกว่าในเมื่อ lifetime มันก็คือ type แบบนึงมันก็เลยจะถูก verify ที่ compile time มันก็เลบไม่สนว่าตอน rumtime จะเป็นยังไง
และแน่นอนในเมื่อ lifetime เป็น type มันเลยสามารถไปอยู่ใน generic type param ของ struct ได้ด้วยแบบนี้
struct ImportantExcerpt<'a> {
part: &'a str,
}
Lifetime Elision
เรื่องนี้คือ rules ที่ compiler จะ follow เพื่อ analyze lifetime relationship ทั้งหมดของเรา พูดแบบ overly simplified ก็คือมันเป็นกฎที่ถ้า code ของเราเข้ากันได้กับกฎพวกนี้เป๊ะๆ compiler มันก็จะแอบเติม lifetime ให้เราเองโดยที่เราไม่ต้องไปใส่ annotation ให้วุ่นวาย
กฎ lifetime elision ตอนนี้มี 3 ข้อ
- ทุกๆ function parameter จะถูก compiler assign lifetime ให้อัตโนมัติ
เช่นสมมติ function ของเราคือ fn foo(x: &i32, y: &i32)
แบบนี้ compiler จะแอบเติมให้เป็นfn foo<'a, 'b>(x: &'a i32, y: &'b i32)
2. ถ้า parameter มีแค่ตัวเดียว lifetime ของ paramter นั้นจะถูก assigned ให้เป็น lifetime ของ output ทั้งหมด
เช่น fn foo(x: &i32) -> &i32
ด้านกฎข้อแรก compiler จะเติม lifetime ของ x: &i32
ให้ และด้วยกฎข้อนี้จะทำให้ output ถูกเติม lifetime ไปด้วยเป็น fn foo<'a>(x: &'a i32) -> &'a i32
3. ถ้า parameter มีหลายตัว (มากกว่า 1) และ หนึ่งในนั้นคือ &self
(หรือ &mut self
) compiler จะ assign lifetime ของมันให้กับ output ทั้งหมด
ข้อนี้คนอาจจะสงสัยว่า อ่าวแล้วถ้ามีแค่ parameter เดียวอย่าง func(&self) -> i32
แบบนี้ compiler จะไม่เติมให้หรอ
คำตอบคือเค้าแอบเติมให้ครับ เพราะไปตกข้อ 1 กับ 2
ทีนี้ถ้า compile run through กฎ 3 ข้อด้านบนแล้ว ยังมี references ที่ยังไม่สามารถกำหนด lifetime ที่ชัดเจนได้ เค้าก็จะพ่น error ออกมาให้เราไปใส่ annotation ให้เอง
The Static Lifetime
อันนี้ง่าย มันคือการกำหนดให้ reference นั้นๆมี lifetime จนถึง program exit 😅
let s: &'static str = "I have a static lifetime.";
ซึ่งไม่ควรเอามาใช้ยกเว้นจำเป็นจริงๆ (string literals ทุกตัวจะมี lifetime เป็น 'static
โดยที่เราไม่ต้องไปใส่ให้มัน)
บทนี้เป็นบทสุดท้ายของ chapter 10 แล้ว ทีนี้ถ้าเราเอาของใน chapter นี้มายำรวมกันก็จะได้ code ประมาณนี้
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
}
}
function longest_with_an_announcement
รับ parameters x, y, ann
โดยที่ lifetime ของ output จะเป็น shortest lifetime ระหว่าง string slicex
และ y
ส่วน ann
คืออะไรก็ได้ที่ implement Display
trait
จบแล้วครับบทที่ 10
ขอแอบปาดเหงื่อหน่อยครับ ในที่สุดก็ผ่านไปแล้วครึ่งทาง ผมรู้สึกว่าอ่านถึงตรงนี้ เราน่าจะพออ่านและเขียน code Rust ได้พอควรแล้ว เพราะน่าจะ cover basic concepts เกือบทั้งหมด
เท่าที่ผมแอบ peek ดูหัวข้อครึ่งหลัง ดูเหมือนจะเป็นพวก nice to know ซะมากกว่า แต่ผมอาจจะเดาผิดก็ได้
เจอกันใหม่บทหน้าครับ