Ownership ใน Rust | muitsfriday.dev

web-logo-doge muitsfriday.dev

Ownership ใน Rust

อถิบายแนวคิด ownership ของภาษา Rust

Wed Jun 22 2022

ทำไมต้องมี Ownership ❔

ภาษา Rust มีความพิเศษกว่าภาษาปกติทั่วไปตรงหยิบแนวคิดเรื่อง Ownership (ความเป็นเจ้าของ) มาเพื่อจัดการเรื่อง memory เพื่อให้ตัวภาษาสามารถรู้ได้ว่าเมื่อไหร่ควรจะเคลียร์ตัวแปรทิ้งออกไปจากแรม แทนที่จะไปพึ่งตัวสแกน memory garbage collector หรือให้ programmer เป็นคน drop ทิ้งเองด้วยมือ

หัวใจหลักของ Ownership

ตัวแปรที่มี ownership จะเป็นตัวแปรที่จำเป็นต้องเก็บค่าจริงลงใน heap เนื่องด้วยความใหญ่ หรือความ dynamic ของตัวแปร ตามปกติแล้ว ค่าใน heap จะสามารถมีตัวแปรที่เข้าไป reference ถึงได้มากกว่า 1 ตัวในช่วงเวลานึง

v1 = "hello world"
v2 = &v1

Rust ownership 01

ในเรื่องของการจัดการ memory ค่าใน stack จะถูกลบออกเองหลังจากที่ตัวแปรได้หลุดออกจาก scope ของมันโดยอัตโนมัติ แต่ไม่ใช่กับค่าที่อยู่ใน heap เพราะถ้าหากค่าที่ถูกใน heap ถูกลบขณะที่มีคนใช้เข้าถึงได้อยู่จะทำให้เกิดปัญหา reference ไปยังค่าที่ไม่มีอยู่จริงโดยไม่ได้ตั้งใจได้

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

ภาษาสมัยใหม่ๆ เลยออกแบบให้มีสิ่งที่เรียกว่า garbage collector ทำหน้าที่สแกน memory เพื่อหาว่าค่าใดบ้างที่ไม่มีการ reference มาถึงแล้ว จะทำการเคลียร์ออกให้อัตโนมัติเองโดยที่ตัว programmer ไม่ต้องจัดการ ป้องกันความผิดพลาดในการลบ หรือกันลืม

แต่ garbage collector ไม่ใช่เครื่องมือวิเศษ มันคือโปรแกรมๆนึง ที่มีมูลค่าในการรัน ทุกครั้งที่มันทำงานมันจำเป็นต้องใช้แรงในการรัน scan memory เหล่านั้นไม่ได้ทำได้โดยฟรี

ในกรณีทั่วๆ ไปแล้ว program ส่วนมากสามารถยอมรับได้กับ performance เล็กๆ ที่จะต้องเสียไปตรงนี้ แต่ถ้าหาก performance เป็นสิ่งที่เราอยากได้ อยากจะรีดมันออกมา การมี garbage collector อาจจะเป็นจุดสิ้นสุดของการจูนประสิทธิภาพก็ได้

Ownership 🌠

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

เป็นที่มาของแนวคิด ownership ในภาษา Rust ด้วยการเพิ่มกฎหรือข้อจำกัดบางอย่างที่เหมาะสม แล้วตัวภาษาจะรู้เองว่าเมื่อไหร่ควรจะ drop ข้อมูลทิ้ง

ปัญหาแรกที่ทำให้ภาษาไม่สามารถตัดสินใจเคลียร์ค่าออกจาก heap เองได้คือ multiple reference การมี reference ไปยังค่าใน heap ที่มากกว่า 1 ในช่วงเวลาเดียวกัน

Rust มองว่าการที่ตัวแปร reference ไปยัง heap เป็นเหมือนดั่งการเป็น “เจ้าของ” (owner) ของค่านั้นๆ

Rust ownership 02

ค่าใดๆ ใน heap โดยทั่วไปแล้ว Rust จะไม่ยอมให้มี owner มากกว่า 1

ในกรณีภาษาอื่นๆ ถ้าเรามีการ assign ค่าหน้าตาประมาณนี้่

let v1 = "hello_world"
let v2 = v1

จะเป็นการทำ multiple reference ก็คือค่า “hello world” นั้นจะถูก reference ด้วย v1 และ v2

Rust ownership 01

ภาษา Rust จะไม่ยอมให้มี owner สองคนบนค่านั้น การ assign reference ในภาษา Rust จะไม่เป็นการสร้าง owner คนใหม่มาใช้ค่าร่วมกัน แต่เป็นการที่ owner คนเก่า มอบสิทธิ์ ความเป็นเจ้าของให้คนใหม่แทน (ดูรูปข้างล่าง)

Rust ownership 01

เราจะเรียกว่า ownership จาก v1 จะถูก move ไปยัง v2 แทน

ถ้าอย่างงั้นแล้วจะเกิดอะไรขึ้นกับ v1 ล่ะ Rust จะแจ้ง compile error ถ้ามีการเข้าถึงค่า v1 ดังตัวอย่างข้างล่าง โค้ดที่ผมจะเอามาเป็นตัวอย่างคือโค้ดตามรูปภาพบนที่เขียนด้วยภาษา Rust

fn main() {
    let v1 = String::from("hello world");
    let v2 = v1;

    print!("{} {}", v1, v2);
}

Rust compiler จะแจ้งเตือนขึ้นมาว่า

Rust ownership 01

ความหมายคือเราพยายามใช้ v1 ทั้งๆ ที่ความเป็นเจ้าของของมันได้ถูกย้ายไปให้ v2 แทนแล้ว ตัว v1 ไม่มีสิทธิ์ใดๆ อีก เรื่องพวกนี้ Rust จะเป็นคนแจ้งเตือนเราตั้งแต่ช่วย compile โค้ดทำให้เราไม่ทำพลาดใดๆ ก่อนจะเอาโปรแกรมขึ้นไปรันจริงๆ

ความลำบากของ ownership 💭

ทุกครั้งที่มีการ assign ค่าในแบบตามตัวอย่างข้างต้น ownership จะมีการ move (เฉพาะค่าที่เป็น reference to data in heap นะ) เกิดขึ้น

นั่นหมายถึงทุกครั้งที่เราส่งผ่านค่าเหล่านั้นผ่าน function ให้ตัว function เอาไปใช้งาน ownership ก็จะถูก move ออกไปด้วย ดังตัวอย่าง

struct Data{
    title: String,
    description: String,
}

fn main() {
    let data = Data {
        title: String::from("hello world"),
        description: String::from("ownership example"),
    };

    print_data(data);

    println!("{}", data.title);
}

fn print_data(x: Data) {
    println!("{}", x.title);
}

จะเกิด compile error ขึ้นเพราะเราพยายามเข้าถึงค่า data ที่ถูก move ออกไปแล้วจากการ assign เข้า function print_data ไป

Rust ownership 01

Rust ownership 01

การ assign เกิดขึ้นเมื่อมีการ pass ค่าเข้า function ทำให้ ownership ของ data ในฟังก์ชัน main ถูก move ออกไปหาตัวแปร x ใน function print_data

การเข้าถึงค่า data ในฟังก์ชัน main จึงไม่สามารถทำได้อีก

Borrowing 🤏🏽

ภาษานี้จะไม่น่าใช้ขึ้นมาทันทีถ้าไม่มีวิธีดีๆ มาแก้ปัญหาข้างต้น

Rust ทำไอเดียของการ “ขอยืม” (Borrow) ค่าตัวแปรแทนที่การดึงเอาความเป็นเจ้าของมาที่ตัวเอง

การยืมเพื่อใช้ในการอ่านเพียงอย่างเดียว 👏🏽

การขอยืมค่าทำได้ด้วยการเติม & ที่ตัวแปรดังตัวอย่าง

fn main () {
    let a = String::from("hello world");
    let b = &a;

    println!("{}, {}", a, b);
}

การทำแบบนี้ตัวแปร b จะไม่ได้ดึงความเป็น ownership มาจาก a เจ้าของค่า String::from("hello world") ยังคงเป็น a เหมือนเดิม เพียงแค่ b เป็นผู้ยืมค่ามาใช้ชั่วคราวเท่านั้น

Rust ownership 01

เราสามารถยืมค่าเพื่อการอ่าน (immutable borrow) ได้หลายๆครั้งในเวลาเดียวกัน เช่น

fn main () {
    let a = String::from("hello world");
    let b = &a;
    let c = &a;

    println!("{}, {}, {}", a, b, c);
}

การยืมเพื่อการเขียน ✍🏽

ข้างต้นเรายืมค่ามาเพื่ออ่านอย่างเดียว นอกจากยืมมาอ่านแล้วเราสามารถยืมมาแบบเขียนได้ด้วย

fn main () {
    let mut a = String::from("hello world");
    let b = &mut a;
    b.push_str("!!");

    println!("{}", b);
}

&mut เป็นการขอยืมเพื่อการเขียน (mutable borrow) โดยการขอยืมครั้งนี้ตัวแปรที่ขอยืมมาจะต้องอนุญาตให้เขียนด้วยเช่นกัน

เงื่อนไขของการขอยืม 🙅🏽‍♂️

ไม่ใช่ว่าเราจะขอยืมอะไรก็ได้ตลอดเวลา การขอยืมตัวแปรมีกฎดังนี้

  1. ในเวลานึง บนหนึ่งตัวแปรเราสามารถขอยืมแบบอ่านกี่ครั้งก็ได้
  2. ในเวลานึง บนหนึ่งตัวแปรเราสามารถขอยืมแบบเขียนได้เพียงครั้งเดียวเท่านั้น
  3. ถ้ามีการขอยืมแบบเขียนบนตัวแปรใดแล้ว ไม่สามารถขอยืมแบบอ่านมาได้อีก

กฎเหล่านี้ถูกตั้งมาเพื่อป้องกัน race condition บนตัวแปรเวลาเราอ่านหรือเขียนข้อมูลพร้อมๆกันอาจจะมีปัญหาได้

ตัวอย่างเช่นถ้าเรามี mutable borrow แล้ว เราจะไม่สามารถสร้าง borrow อื่นๆ ได้อีก

fn main () {
    let mut a = String::from("hello world");
    let b = &mut a;
    let c = &mut a;
    b.push_str("!!");
    c.push_str("!!");

    println!("{} {}", b, c);
}

Rust ownership 01

compiler ก็จะเตือนออกมาแบบนี้ cannot borrow 'a' as mutable more than once at a time หมายถึงเราไม่สามารถ

ให้ฟังก์ชันยืมตัวแปร แทนที่จะดึงความเป็นเจ้าของมา 🪢

การสร้างฟังก์ชันมาถ้าตัวฟังก์ชันเราไม่ได้อยากดึงความเป็นเจ้าของตัวแปรเข้ามาในฟังก์ชัน เราสามารถใช้วิธีขอยืมแทนได้

จาก

fn display_data(x: Data)

เป็น

fn display_data(x: &Data)

เพื่อให้ฟังก์ชันที่สร้างขึ้นมายืมตัวแปรที่เป็น parameter มาใช้ ไม่ได้มีการแย่ง ownership เข้ามา

struct Data{
    title: String,
    description: String,
}

fn main() {
    let data = Data {
        title: String::from("hello world"),
        description: String::from("ownership example"),
    };

    print_data(&data);

    println!("{}", data.title);
}

fn print_data(x: &Data) {
    println!("{}", x.title);
}

Function print_data รับ struct Data มาแบบ ขอยืม ดังนั้นเวลาเรียกใช้ฟังก์ชันเราจะส่ง print_data(&data); เป็นการบอกว่า อะนี่ ให้ยืมนะตามภาพนี้

Rust ownership 01

หรือจะเป็นการยืมเพื่อการเขียนก็ทำได้เช่นเดียวกัน

fn main() {
    let mut text = String::from("hello world");
    set_title(&mut text);

    println!("{}", text);
}

fn set_title(text: &mut String) {
    text.push_str(" append.")
}

fn set_title(text: &mut String) { เป็นการบอกว่าฟังก์ชัน set_title นั้นต้องการ String มาเขียนในแบบ__ขอยืมมา__

set_title(&mut text); เป็นการเรียกใช้ฟังก์ชัน set_title โดยส่งตัวแปร text ไปแบบ ให้ยืมและยอมให้แก้ไขค่าได้

ปัญหาการยืนยันการมีอยู่ของของที่ยืมมา 😋

แนวคิดการยืมค่ามาใช้เป็นไอเดียที่สะดวกมาก แต่มีบางกรณีก็สร้างปัญหาได้เช่นกัน เช่น ถ้าสมมติเจ้าของที่ยืมมาได้หมดอายุขัยไปแล้ว ค่าที่เจ้าของนั้นถือจะต้องถูกเคลียร์ทิ้ง แล้วคนที่ขอยืมของนั้นมา(ไม่ใช่นาฬิกานะ) จะเกิดปัญหาขึ้น

ภาษา Rust มีการใช้ lifetime ช่วยในการตรวจสอบว่า ของที่ใครสักคนจะยืมมานั้นจะมี ช่วงชีวิต (lifetime) ที่ยาวนานมากเพียงพอที่คนขอยืมจะใช้ได้ตลอดเวลาที่เขาต้องการ

ตัวอย่าง

    {
        let r;

        {
            let x = 5;
            r = &x;
        }

        println!("r: {}", r);
    }

โค้ดชุดข้างบนนี้จะ error เพราะ r พยายามจะขอยืม x มาใช้ แต่ compiler ตรวจสอบได้ว่า x มีอายุอยู่ไม่ยาวเพียงพอที่จะให้ r ใช้งาน ถ้าเราเอาโค้ดชุดนี้ไปคอมไฟล์ เราจะได้ข้อความ error หน้าตาประมาณว่า

borrowed value does not live long enough

เรื่องการตรวจสอบ lifetime มีความซับซัอนกว่าที่เห็นจะขอยกไว้พูดในบล็อกอื่นแทนนะครับ

สรุป ✔️

อาจจะเห็นว่า Rust มีความยุ่งยากเพิ่มเติมจากภาษาอื่นๆ ก็จริงแต่ error ทั้งหมดเราจะเห็นได้ทันทีตอนที่เรา compile โค้ดเลยดังนั้นเราจะสามารถแก้ได้ก่อนที่โค้ดจะไปทำงานจริง

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

ถ้าเราเริ่มชินกับแนวคิดพวกนี้แล้ว เราจะใช้งานมันได้คล่องเหมือนการเขียนภาษาปกติทั่วไปเลยล่ะครับ