[Rust] lifetime annotation มีไว้ช่วย compiler เช็คความถูกต้อง reference ตอน compile time

Rust พยายามไม่ใช้เกิดการใช้งาน reference ที่ผิดๆ เช่นอ้างอิงถึงตัวแปรที่ถูกคืนหน่วยความจำไปแล้ว (Dangling Reference)

ทีนี้มีรูปแบบการใช้งานบางรูปแบบที่ compiler ไม่สามารถตรวจสอบได้โดยตรงว่ามีการใช้งาน reference ของตัวแปรที่คืนหน่วยความจำไปแล้วหรือไม่ ทำให้ต้องเราต้องใช้ lifetime annotation ช่วยเพิ่มข้อมูลให้กับ compiler รู้ได้ว่า reference ที่เราใช้งานนั้นมีช่วงชีวิต lifetime อยู่แค่ไหน

โพสต์นี้จะอธิบายสองรูปแบบคือ

  • การส่งค่า reference เข้าไปเป็น parameter ของฟังก์ชัน และ return ค่า reference กลับมา
  • การเก็บค่า reference เป็น field หนึ่งของ struct

ตัวอย่างเคสของการส่ง reference ไปให้กับฟังก์ชัน แล้ว ฟังก์ชันตอบกับ reference กลับคืนมา

fn ref_of_x(x: &str) -> &str {
        return x;
}

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

    let result = ref_of_x(&string1);
    println!("{}", result);
}

โค้ดนี้ compile ผ่านเพราะเพราะเรามีแค่ 1 parameter คือ x ที่เป็น reference ของ str แล้วก็ตอบกับ x กลับไป

ใน main เราก็เรียก ref_of_x แล้วเก็บค่าที่ตอบกลับมาใน result

ซึ่งจะเห็นว่าตัวแปร string1 และ result อยู่ใน scope เดียวกันจะถูกคืนค่าหน่วยความจำหลังจากจบการทำงาน block เดียวกันไม่มีโอกาสที่จะถูกเอาไปใช้ผิดๆหลังจากนั้น จึงทำให้ compiler compile ผ่าน เพราะวิเคราะห์แล้วไม่มีทางที่จะเกิด Dangling reference เกิดขึ้น

ทีนี้ถ้าโค้ดเราแบบนี้แทน

fn ref_of_x(x: &str) -> &str {
        return x;
}

fn main() {
    let result;
    {
        let string1 = String::from("abcd");
        result = ref_of_x(&string1);
    }
    println!("{}", result);
}

compiler จะ compile error แบบนี้

error[E0597]: `string1` does not live long enough
  --> src/main.rs:9:27
   |
9  |         result = ref_of_x(&string1);
   |                           ^^^^^^^^ borrowed value does not live long enough
10 |     }
   |     - `string1` dropped here while still borrowed
11 |     println!("{}", result);
   |                    ------ borrow later used here

For more information about this error, try `rustc --explain E0597`.
error: could not compile `playground` due to previous error

เพราะว่า string1 ถูกคืนหน่วยความจำไปก่อน (drop) เนื่องจากว่าถูกประกาศตัวแปรไว้ใน block {} อีกอันที่มีช่วงชีวิตที่สั้นกว่า result ที่เป็น reference หา string1

จากโค้ดตัวอย่างเมื่อกี้ไม่เห็นมี lifetime annotation เลย เพราะ Rust มีกด lifetime elission คือถ้าไม่ใส่จะเติมให้เองได้ถ้าเข้าเงื่อนไขของ lifetime elission เช่นตัวอย่างเมื่อกี้ Rust จะมองว่าเหมือนมี lifetime annotation ให้แบบนี้

fn ref_of_x<'a>(x: &'a str) -> &'a str {
        return x;
}

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

    let result = ref_of_x(&string1);
    println!("{}", result);
}

ซึ่ง lifetime annotation เราต้องลิสต์เป็น type parameter แบบเดียวกับ generic type parameter อื่นๆ แต่ว่าชื่อ parameter ต้องขึ้นต้นด้วย ' แล้วตาม convention จะใช้อักษรตัวเลขไล่จาก a เป็นต้นไป

ตอนเอามากำหนดให้ตัวแปร reference ก็ใส่หลัง & ก่อนแล้วค่อยเว้นวรรค ตามด้วยชื่อ type

ทีนี้มาดูเคสที่ไม่ตรงเงื่อนไข lifetime elission แล้วต้องใส่เองกันเช่น

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

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);
    }
}

ถ้าโค้ดแบบนี้ Rust จะงงว่า lifetime ของ return type จะเป็นอะไร เพราะมีทั้ง x และ y แล้วจะ compile error มาแบบนี้

error[E0106]: missing lifetime specifier
 --> src/main.rs:1:33
  |
1 | 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
  |
1 | 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 `playground` due to previous error

ซึ่งก็ guide มาให้เรียบร้อย เราก็เลยต้องแก้โค้ดเป็นแบบนี้

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

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);
    }
}

ทีนี้จุดที่น่าสงสัยคือ ทำไมถึงใช้ 'a กับทั้ง x และ y

จริงๆแล้วเราใช้คนละตัวก็ได้ แต่พอโค้ดเรามีโอกาสตอบกลับได้ทั้ง x และ y ทำให้ Rust compiler ไม่รู้ว่า return type เราจะเป็น x หรือ y กันแน่

Rust เลยให้เราใช้ 'a ได้กับทั้ง x และ y

แม้ว่า syntax จะเป็นแบบนี้ ไม่ได้หมายความว่า 'a จะเป็นข้อมูล lifetime ของทั้ง x และ y ได้ในเวลาเดียวกัน

แต่ Rust compiler จะเลือกแค่ lifetime ที่สั้นที่สุด อายุน้อยที่สุดระหว่าง x และ y

เช่นจากโค้ดการใช้งานใน main เมื่อกี้ เราส่ง ref ของ string1 เข้าไปที่ x, ส่ง ref string2 ไปที y ซึ่งช่วงชีวิตของ string2 สั้นกว่าเพราะอยู่ใน scope ที่จบก่อน string1 ทำให้ 'a ก็จะแทนด้วย lifetime ของ y นั่นเอง ทำให้ lifetime ของค่าที่ตอบกลับมาของ longest ที่เก็บใน result ก็จะมีช่วงชีวิตเดียวกันกับ string2 ไปด้วย

ดังนั้นเราจะเขียนโค้ดแบบ จะ compile ไม่ผ่าน

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

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);
}

เพราะเรามีใช้งาน result หลังจากค่าที่ return นั้นจบชีวิต (drop) ไปแล้วนั่นเอง แม้ว่าฟังก์ชันมีโอกาสตอบกลับค่า x ตอน runtime แต่ว่า compiler ไม่สามารถรู้ได้ตอน compile time จึงทำให้ compile error นั่นเอง

ต่อไปคือเคสที่มี reference ใน data structure อื่นๆเช่น struct เราก็ต้องกำหนด lifetime annotation ให้กับ field ที่เป็น reference ด้วยแบบนี้

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let novel = String::from("Call me Ishmael. Some years ago...");
    let first_sentence = novel.split('.').next().expect("Could not find a '.'");
    let i = ImportantExcerpt {
        part: first_sentence,
    };
    println!("{}", i.part);
}

เพราะว่า Rust ไม่อยากปล่อยให้มีกรณีที่ช่วงชีวิตของตัวแปร struct เองนั้นยาวนานกว่าช่วงชีวิตของ reference ที่ struct เก็บไว้อยู่เพราะมันทำให้เกิด Dangling ได้นั่นเอง

เช่นแบบนี้ที่จะ compile ไม่ผ่าน เพราะ field part ไม่มีช่วงชีวิตที่ยาวพออีกแล้ว

struct ImportantExcerpt<'a> {
    part: &'a str,
}

fn main() {
    let i;
    {
        let novel = String::from("Call me Ishmael. Some years ago...");
        let first_sentence = novel.split('.').next().expect("Could not find a '.'");
        i = ImportantExcerpt {
            part: first_sentence,
        };
    }
    println!("{}", i.part);
}

สรุปก็คือ Rust compiler จะรู้ช่วงชีวิต lifetime ของตัวแปรต่างๆตั้งแต่ตอน compile time ส่วน lifetime annotation มีก็เพื่อช่วยกำหนดความสัมพันธ์ของช่วงชีวิต ว่า reference ที่ตอบกลับไปจาก function จะมีช่วงชีวิตสั้นยาวแค่ไหนโดยดูจาก reference parameter ที่ส่งเข้ามา

ส่วนของ Struct field ก็ดูจากค่า reference ที่ assign ให้กับ field นั้นๆเช่นกัน

Ref: อ่านเพิ่มเติมได้จากที่นี่ https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html ซึ่งโค้ดตัวอย่างในโพสต์นี้ก็เอามาจากลิ้งนี้เช่นกัน