Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Ownership คืออะไร?

Ownership คือชุดของกฎที่กำกับวิธีที่โปรแกรม Rust จัดการหน่วยความจำ ทุกโปรแกรมต้องจัดการวิธีที่ใช้หน่วยความจำของคอมพิวเตอร์ระหว่างรัน บางภาษามี garbage collection ที่คอยดูหน่วยความจำที่ไม่ใช้แล้วเป็นระยะ ระหว่างที่ โปรแกรมรัน ในภาษาอื่น โปรแกรมเมอร์ต้อง allocate และ free หน่วยความจำเอง แบบ explicit Rust ใช้แนวทางที่สาม — หน่วยความจำถูกจัดการผ่านระบบ ownership ที่มีชุดกฎที่ compiler ตรวจสอบ ถ้ามีกฎใดถูกฝ่าฝืน โปรแกรมจะ compile ไม่ ผ่าน ฟีเจอร์ของ ownership ทั้งหมดจะไม่ทำให้โปรแกรมช้าลงระหว่างรัน

เพราะ ownership เป็นแนวคิดใหม่สำหรับโปรแกรมเมอร์หลายคน มันใช้เวลาทำความ คุ้นเคยบ้าง ข่าวดีคือยิ่งคุณมีประสบการณ์กับ Rust และกฎของระบบ ownership มาก ขึ้น คุณจะพบว่ามันง่ายขึ้นที่จะพัฒนาโค้ดที่ปลอดภัยและมีประสิทธิภาพอย่างเป็น ธรรมชาติ สู้ ๆ ครับ!

เมื่อคุณเข้าใจ ownership คุณจะมีรากฐานที่แข็งแรงในการเข้าใจฟีเจอร์ที่ทำให้ Rust มีเอกลักษณ์ ในบทนี้ คุณจะเรียน ownership ผ่านการทำตัวอย่างที่เน้น โครงสร้างข้อมูลที่ใช้บ่อยมาก — string

Stack และ Heap

ภาษาโปรแกรมหลายภาษาไม่ได้บังคับให้คุณคิดเรื่อง stack และ heap บ่อยนัก แต่ ในภาษา systems programming อย่าง Rust การที่ค่าอยู่บน stack หรือ heap ส่งผลต่อพฤติกรรมของภาษา และว่าทำไมคุณต้องตัดสินใจบางอย่าง บางส่วนของ ownership จะถูกอธิบายในความสัมพันธ์กับ stack และ heap ในบทนี้ ดังนั้นนี่ คือคำอธิบายสั้น ๆ เพื่อเตรียมตัว

ทั้ง stack และ heap เป็นส่วนของหน่วยความจำที่โค้ดของคุณใช้ได้ตอน runtime แต่พวกมันมีโครงสร้างต่างกัน Stack เก็บค่าตามลำดับที่ได้รับมา และเอาค่าออก ในลำดับตรงข้าม นี่เรียกว่า last in, first out (LIFO) คิดถึงกองจาน — เมื่อคุณเพิ่มจาน คุณวางไว้บนสุดของกอง และเมื่อต้องการจาน คุณเอาออกจากบนสุด การเพิ่มหรือลบจานจากกลางหรือล่างไม่ work เท่าไหร่! การเพิ่มข้อมูลเรียกว่า push บน stack และการลบข้อมูลเรียกว่า pop ออกจาก stack ข้อมูลทั้งหมดที่ เก็บบน stack ต้องมีขนาดที่รู้และคงที่ ข้อมูลที่ขนาดไม่รู้ตอน compile time หรือขนาดอาจเปลี่ยน ต้องเก็บบน heap แทน

Heap มีระเบียบน้อยกว่า — เมื่อคุณใส่ข้อมูลบน heap คุณขอพื้นที่จำนวนหนึ่ง memory allocator หาที่ว่างใน heap ที่ใหญ่พอ, mark ว่ากำลังใช้, และ return pointer ซึ่งคือ address ของตำแหน่งนั้น กระบวนการนี้เรียกว่า allocate บน heap และบางครั้งย่อเป็นแค่ allocate (การ push ค่าบน stack ไม่ถือว่า เป็น allocate) เพราะ pointer ไปยัง heap มีขนาดรู้และคงที่ คุณจึงเก็บ pointer บน stack ได้ แต่เมื่อคุณต้องการข้อมูลจริง คุณต้องตาม pointer ไป คิดถึงการได้ที่นั่งในร้านอาหาร — เมื่อคุณเข้าไป คุณบอกจำนวนคนในกลุ่ม และ host หาโต๊ะว่างที่จุคนทั้งหมดได้ แล้วพาคุณไป ถ้ามีคนในกลุ่มมาสาย เขาถามได้ ว่าคุณนั่งอยู่ที่ไหน เพื่อหาคุณ

Push บน stack เร็วกว่า allocate บน heap เพราะ allocator ไม่ต้องค้นหาที่ เก็บข้อมูลใหม่ — ตำแหน่งนั้นอยู่บนสุดของ stack เสมอ เมื่อเทียบกัน การ allocate พื้นที่บน heap ต้องการงานมากกว่า เพราะ allocator ต้องหาพื้นที่ที่ ใหญ่พอจะเก็บข้อมูลก่อน แล้วทำ bookkeeping เพื่อเตรียมพร้อมสำหรับการ allocate ครั้งถัดไป

การเข้าถึงข้อมูลใน heap โดยทั่วไปช้ากว่าการเข้าถึงข้อมูลบน stack เพราะ คุณต้องตาม pointer ไป processor สมัยใหม่เร็วกว่าถ้ามันกระโดดในหน่วยความจำ น้อยลง ต่อจากการอุปมา ลองนึกถึง server ในร้านอาหารที่รับ order จากหลาย โต๊ะ มีประสิทธิภาพที่สุดที่จะรับ order ทุกอย่างที่โต๊ะหนึ่งก่อนย้ายไปอีก โต๊ะ การรับ order จากโต๊ะ A แล้ว order จากโต๊ะ B แล้วจาก A อีก แล้วจาก B อีก จะเป็นกระบวนการที่ช้ากว่ามาก ในทำนองเดียวกัน processor ทำงานได้ดีกว่า ถ้าทำงานบนข้อมูลที่อยู่ใกล้ข้อมูลอื่น (อย่างที่อยู่บน stack) มากกว่าไกล ออกไป (อย่างที่อาจอยู่บน heap)

เมื่อโค้ดของคุณเรียกฟังก์ชัน ค่าที่ส่งเข้าฟังก์ชัน (รวมถึง pointer ไป ข้อมูลบน heap ที่อาจมี) และตัวแปร local ของฟังก์ชัน ถูก push บน stack เมื่อฟังก์ชันจบ ค่าเหล่านั้นถูก pop ออกจาก stack

การติดตามว่าส่วนใดของโค้ดใช้ข้อมูลใดบน heap, การลดข้อมูลซ้ำใน heap, และ การ cleanup ข้อมูลที่ไม่ใช้บน heap เพื่อไม่ให้หมดพื้นที่ ทั้งหมดเป็นปัญหาที่ ownership จัดการ เมื่อคุณเข้าใจ ownership คุณจะไม่ต้องคิดเรื่อง stack และ heap บ่อยนัก แต่การรู้ว่าจุดประสงค์หลักของ ownership คือการจัดการข้อมูลบน heap ช่วยอธิบายว่าทำไมมันทำงานในแบบที่ทำ

กฎของ Ownership

ขั้นแรก มาดูกฎของ ownership กัน จำกฎเหล่านี้ไว้ตอนเราทำตัวอย่างที่แสดง ให้เห็น:

  • ทุกค่าใน Rust มี owner
  • ในเวลาเดียวกันมี owner ได้แค่หนึ่งคน
  • เมื่อ owner ออกจาก scope ค่าจะถูก drop

Scope ของตัวแปร

ตอนนี้เราผ่าน syntax พื้นฐานของ Rust แล้ว เราจะไม่รวม fn main() { ทั้งหมด ในตัวอย่าง ดังนั้นถ้าคุณตามไปด้วย อย่าลืมเอาตัวอย่างต่อไปนี้ใส่ในฟังก์ชัน main เอง ผลลัพธ์คือ ตัวอย่างของเราจะกระชับขึ้นนิดหน่อย ให้เราโฟกัสที่ราย ละเอียดจริง ๆ แทนโค้ด boilerplate

ตัวอย่างแรกของ ownership เราจะดู scope ของตัวแปร scope คือช่วงในโปรแกรม ที่ item valid ดูตัวแปรต่อไปนี้:

#![allow(unused)]
fn main() {
let s = "hello";
}

ตัวแปร s อ้างถึง string literal ที่ค่าของ string ถูก hardcode ลงในข้อความ ของโปรแกรม ตัวแปรนี้ valid ตั้งแต่จุดที่ประกาศจนถึงท้าย scope ปัจจุบัน Listing 4-1 แสดงโปรแกรมพร้อม comment annotate ตำแหน่งที่ตัวแปร s จะ valid

fn main() {
    {                      // s is not valid here, since it's not yet declared
        let s = "hello";   // s is valid from this point forward

        // do stuff with s
    }                      // this scope is now over, and s is no longer valid
}
Listing 4-1: ตัวแปรและ scope ที่มัน valid

พูดอีกอย่าง มีจุดสำคัญสองจุดในเวลาที่นี่:

  • เมื่อ s เข้ามา ใน scope มัน valid
  • มันยัง valid อยู่จนกว่าจะออก นอก scope

ณ จุดนี้ ความสัมพันธ์ระหว่าง scope และตอนที่ตัวแปร valid คล้ายกับในภาษา โปรแกรมอื่น ทีนี้เราจะต่อยอดความเข้าใจนี้ ด้วยการแนะนำ type String

Type String

เพื่อแสดงกฎของ ownership เราต้องมีชนิดข้อมูลที่ซับซ้อนกว่าที่เราครอบคลุมใน ส่วน “ชนิดข้อมูล” ของบทที่ 3 type ที่ครอบคลุม ก่อนหน้านี้มีขนาดรู้ เก็บบน stack และ pop ออกจาก stack ได้เมื่อ scope จบ และคัดลอกให้สร้าง instance ใหม่ที่อิสระได้เร็วและง่าย ถ้าโค้ดส่วนอื่นต้อง ใช้ค่าเดียวกันใน scope ต่าง แต่เราอยากดูข้อมูลที่เก็บบน heap และสำรวจว่า Rust รู้เมื่อไหร่ว่าควร cleanup ข้อมูลนั้น และ type String เป็นตัวอย่าง ที่ดี

เราจะโฟกัสที่ส่วนของ String ที่เกี่ยวกับ ownership แง่มุมเหล่านี้ยังใช้ กับชนิดข้อมูลซับซ้อนอื่น ๆ ด้วย ไม่ว่าจะมาจาก standard library หรือสร้าง เอง เราจะพูดถึงแง่มุมที่ไม่เกี่ยว ownership ของ String ใน บทที่ 8

เราเห็น string literal มาแล้ว ที่ค่า string ถูก hardcode ลงในโปรแกรม String literal สะดวก แต่ไม่เหมาะกับทุกสถานการณ์ที่เราอยากใช้ข้อความ เหตุผลหนึ่งคือมัน immutable อีกเหตุผลคือไม่ใช่ทุกค่า string จะรู้เมื่อ เราเขียนโค้ด เช่น ถ้าเราอยากรับ input จากผู้ใช้แล้วเก็บ? สำหรับสถานการณ์ เหล่านี้ Rust จึงมี type String type นี้จัดการข้อมูลที่ allocate บน heap และสามารถเก็บข้อความปริมาณที่เราไม่รู้ตอน compile time คุณสร้าง String จาก string literal ได้โดยใช้ฟังก์ชัน from แบบนี้:

#![allow(unused)]
fn main() {
let s = String::from("hello");
}

operator double colon :: ให้เรา namespace ฟังก์ชัน from นี้ภายใต้ type String แทนการใช้ชื่อแบบ string_from เราจะพูดถึง syntax นี้มาก ขึ้นในส่วน “เมธอด” ของบทที่ 5 และตอนเราพูดถึง namespace กับ module ใน “Path สำหรับอ้างถึง item ใน module tree” ในบทที่ 7

string ชนิดนี้ สามารถ ถูก mutate ได้:

fn main() {
    let mut s = String::from("hello");

    s.push_str(", world!"); // push_str() appends a literal to a String

    println!("{s}"); // this will print `hello, world!`
}

แล้วอะไรคือความแตกต่างที่นี่? ทำไม String ถึง mutate ได้ แต่ literal ไม่ได้? ความแตกต่างอยู่ที่วิธีที่ type ทั้งสองนี้จัดการหน่วยความจำ

หน่วยความจำและการ Allocate

ในกรณีของ string literal เรารู้เนื้อหาตอน compile time ดังนั้นข้อความถูก hardcode ลงใน executable สุดท้ายโดยตรง นี่คือเหตุผลที่ string literal เร็ว และมีประสิทธิภาพ แต่คุณสมบัติเหล่านี้มาจากการที่ string literal เป็น immutable เท่านั้น น่าเสียดายที่เราไม่สามารถใส่ blob ของหน่วยความจำลงใน binary สำหรับข้อความแต่ละชิ้นที่ขนาดไม่รู้ตอน compile time และขนาดอาจ เปลี่ยนระหว่างรันโปรแกรม

กับ type String เพื่อรองรับชิ้นข้อความที่ mutable และเติบโตได้ เราต้อง allocate หน่วยความจำจำนวนหนึ่งบน heap, ไม่รู้ตอน compile time, เพื่อเก็บ เนื้อหา นี่หมายความว่า:

  • หน่วยความจำต้องถูกขอจาก memory allocator ตอน runtime
  • เราต้องมีวิธี return หน่วยความจำนี้กลับให้ allocator เมื่อเราใช้ String เสร็จ

ส่วนแรกนั้นเราทำ — เมื่อเราเรียก String::from การ implement ของมันขอ หน่วยความจำที่ต้องการ นี่ค่อนข้างเป็นสากลในภาษาโปรแกรม

อย่างไรก็ตาม ส่วนที่สองต่างกัน ในภาษาที่มี garbage collector (GC) GC ติดตามและ cleanup หน่วยความจำที่ไม่ได้ใช้แล้ว และเราไม่ต้องคิดเรื่องนั้น ในภาษาส่วนใหญ่ที่ไม่มี GC เป็นหน้าที่ของเราที่จะระบุเมื่อหน่วยความจำไม่ ถูกใช้แล้ว และเรียกโค้ดให้ free มันแบบ explicit เหมือนที่เราทำเพื่อขอมัน การทำสิ่งนี้ให้ถูก เป็นปัญหา programming ที่ยากมาตลอด ถ้าเราลืม เราเสีย หน่วยความจำ ถ้าเราทำเร็วเกินไป เรามีตัวแปร invalid ถ้าเราทำสองครั้ง นั่น ก็เป็น bug ด้วย เราต้องจับคู่ allocate หนึ่งครั้งกับ free หนึ่งครั้ง เป๊ะ ๆ

Rust เลือกเส้นทางต่าง — หน่วยความจำถูก return อัตโนมัติเมื่อตัวแปรที่ owner มันออกจาก scope นี่คือ version ของตัวอย่าง scope ของเราจาก Listing 4-1 ที่ใช้ String แทน string literal:

fn main() {
    {
        let s = String::from("hello"); // s is valid from this point forward

        // do stuff with s
    }                                  // this scope is now over, and s is no
                                       // longer valid
}

มีจุดธรรมชาติที่เรา return หน่วยความจำที่ String ของเราต้องการกลับให้ allocator ได้ — เมื่อ s ออกจาก scope เมื่อตัวแปรออกจาก scope Rust เรียก ฟังก์ชันพิเศษให้เรา ฟังก์ชันนี้เรียกว่า drop และเป็นที่ที่ผู้เขียน String ใส่โค้ดที่ return หน่วยความจำได้ Rust เรียก drop อัตโนมัติที่ curly bracket ปิด

หมายเหตุ: ใน C++ pattern การ deallocate resource ที่ท้าย lifetime ของ item บางครั้งเรียกว่า Resource Acquisition Is Initialization (RAII) ฟังก์ชัน drop ใน Rust จะคุ้นเคยกับคุณถ้าคุณใช้ RAII pattern มาก่อน

pattern นี้มีผลกระทบลึกซึ้งต่อวิธีที่เขียนโค้ด Rust ตอนนี้อาจดูง่าย แต่ พฤติกรรมของโค้ดอาจคาดไม่ถึงในสถานการณ์ที่ซับซ้อนกว่า เมื่อเราอยากให้ตัวแปร หลายตัวใช้ข้อมูลที่เรา allocate บน heap มาสำรวจบางสถานการณ์เหล่านั้นกัน

ตัวแปรและข้อมูลโต้ตอบกันด้วย Move

ตัวแปรหลายตัวโต้ตอบกับข้อมูลเดียวกันได้หลายวิธีใน Rust Listing 4-2 แสดง ตัวอย่างที่ใช้ integer

fn main() {
    let x = 5;
    let y = x;
}
Listing 4-2: Assign ค่า integer ของตัวแปร x ให้ y

เราอาจเดาได้ว่ามันทำอะไร — “Bind ค่า 5 กับ x แล้วทำสำเนาของค่าใน x แล้ว bind กับ y” ตอนนี้เรามีสองตัวแปร x และ y และทั้งคู่เท่ากับ 5 นี่คือสิ่งที่เกิดขึ้นจริง เพราะ integer เป็นค่าง่าย ๆ ที่มีขนาดรู้และคงที่ และค่า 5 สองตัวนี้ถูก push บน stack

ทีนี้มาดู version String:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
}

นี่ดูคล้ายกันมาก เราจึงอาจสมมติว่าวิธีที่มันทำงานจะเหมือนกัน — นั่นคือ บรรทัดที่สองจะทำสำเนาของค่าใน s1 แล้ว bind กับ s2 แต่นี่ไม่ใช่สิ่งที่ เกิดขึ้นทีเดียว

ดู Figure 4-1 เพื่อเห็นว่าเกิดอะไรขึ้นกับ String เบื้องลึก String ประกอบด้วยสามส่วน แสดงทางซ้าย — pointer ไปยังหน่วยความจำที่เก็บเนื้อหา ของ string, ความยาว และความจุ กลุ่มข้อมูลนี้เก็บบน stack ทางขวาคือหน่วย ความจำบน heap ที่เก็บเนื้อหา

สองตาราง: ตารางแรกมีตัวแทนของ s1 บน stack ประกอบด้วยความยาว (5),
ความจุ (5), และ pointer ไปยังค่าแรกในตารางที่สอง ตารางที่สองมีตัวแทนของ
ข้อมูล string บน heap ทีละ byte

Figure 4-1: ตัวแทนในหน่วยความจำของ String ที่เก็บ ค่า "hello" bind กับ s1

ความยาวคือจำนวนหน่วยความจำใน byte ที่เนื้อหาของ String ใช้อยู่ปัจจุบัน ความจุคือจำนวนหน่วยความจำทั้งหมดใน byte ที่ String ได้รับจาก allocator ความแตกต่างระหว่างความยาวและความจุสำคัญ แต่ไม่ใช่ในบริบทนี้ ดังนั้นตอนนี้ ละเว้นความจุได้

เมื่อเรา assign s1 ให้ s2 ข้อมูล String ถูกคัดลอก ซึ่งหมายความว่าเรา คัดลอก pointer, ความยาว และความจุที่อยู่บน stack เราไม่คัดลอกข้อมูลบน heap ที่ pointer อ้างถึง พูดอีกอย่าง ตัวแทนข้อมูลในหน่วยความจำดูเหมือน Figure 4-2

สามตาราง: ตาราง s1 และ s2 แทน string เหล่านั้นบน stack ตามลำดับ
และทั้งคู่ชี้ไปยังข้อมูล string เดียวกันบน heap

Figure 4-2: ตัวแทนในหน่วยความจำของตัวแปร s2 ที่มี สำเนาของ pointer, ความยาว และความจุของ s1

ตัวแทน ไม่ ดูเหมือน Figure 4-3 ซึ่งเป็นสิ่งที่หน่วยความจำจะดูเหมือนถ้า Rust คัดลอกข้อมูล heap ด้วย ถ้า Rust ทำสิ่งนี้ operation s2 = s1 อาจ แพงมากในแง่ของ runtime performance ถ้าข้อมูลบน heap ใหญ่

สี่ตาราง: สองตารางแทนข้อมูล stack ของ s1 และ s2 และแต่ละตัวชี้
ไปยังสำเนาของข้อมูล string ของตนเองบน heap

Figure 4-3: ความเป็นไปได้อีกอย่างของสิ่งที่ s2 = s1 อาจทำ ถ้า Rust คัดลอกข้อมูล heap ด้วย

ก่อนหน้านี้ เราบอกว่าเมื่อตัวแปรออกจาก scope Rust เรียกฟังก์ชัน drop อัตโนมัติและ cleanup หน่วยความจำ heap สำหรับตัวแปรนั้น แต่ Figure 4-2 แสดงทั้ง data pointer ชี้ไปตำแหน่งเดียวกัน นี่เป็นปัญหา — เมื่อ s2 และ s1 ออกจาก scope ทั้งคู่จะพยายาม free หน่วยความจำเดียวกัน นี่รู้จักใน ชื่อ double free error และเป็นหนึ่งใน memory safety bug ที่เราพูดถึง ก่อนหน้านี้ การ free หน่วยความจำสองครั้งอาจนำไปสู่ memory corruption ซึ่ง อาจนำไปสู่ security vulnerability

เพื่อรับประกัน memory safety หลังบรรทัด let s2 = s1; Rust ถือว่า s1 ไม่ valid อีกต่อไป ดังนั้น Rust ไม่ต้อง free อะไรเมื่อ s1 ออกจาก scope ดูสิ่งที่เกิดขึ้นเมื่อคุณพยายามใช้ s1 หลังจากสร้าง s2 — มันจะไม่ ทำงาน:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;

    println!("{s1}, world!");
}

คุณจะได้ error แบบนี้ เพราะ Rust ป้องกันคุณจากการใช้ reference ที่ถูก invalidate:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:16
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |
5 |     println!("{s1}, world!");
  |                ^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

ถ้าคุณเคยได้ยินคำว่า shallow copy และ deep copy ขณะทำงานกับภาษาอื่น แนวคิดของการคัดลอก pointer, ความยาว และความจุ โดยไม่คัดลอกข้อมูล น่าจะ ฟังดูเหมือน shallow copy แต่เพราะ Rust ยัง invalidate ตัวแปรแรกด้วย แทน ที่จะเรียกว่า shallow copy มันถูกเรียกว่า move ในตัวอย่างนี้ เราจะบอก ว่า s1 ถูก move ไปเป็น s2 ดังนั้นสิ่งที่เกิดขึ้นจริงแสดงใน Figure 4-4

สามตาราง: ตาราง s1 และ s2 แทน string เหล่านั้นบน stack ตามลำดับ
และทั้งคู่ชี้ไปยังข้อมูล string เดียวกันบน heap ตาราง s1 ถูกทำให้เป็นสี
เทาเพราะ s1 ไม่ valid อีกต่อไป — มีเพียง s2 ที่ใช้เข้าถึงข้อมูล heap ได้

Figure 4-4: ตัวแทนในหน่วยความจำหลังจาก s1 ถูก invalidate

แก้ปัญหาเราแล้ว! ด้วยแค่ s2 ที่ valid เมื่อมันออกจาก scope มันเพียง ตัวเดียวจะ free หน่วยความจำ และเราเสร็จแล้ว

นอกจากนี้ มีตัวเลือกการออกแบบที่นัยจากนี้ — Rust จะไม่สร้างสำเนาแบบ “deep” ของข้อมูลของคุณอัตโนมัติ ดังนั้น การคัดลอก อัตโนมัติ ใด ๆ สามารถสมมติได้ ว่าไม่แพงในแง่ของ runtime performance

Scope และ Assignment

ตรงข้ามของสิ่งนี้เป็นจริงสำหรับความสัมพันธ์ระหว่าง scope, ownership และ หน่วยความจำที่ถูก free ผ่านฟังก์ชัน drop ด้วย เมื่อคุณ assign ค่าใหม่ สมบูรณ์ให้ตัวแปรที่มีอยู่ Rust จะเรียก drop และ free หน่วยความจำของค่า เดิมทันที พิจารณาโค้ดนี้ตัวอย่าง:

fn main() {
    let mut s = String::from("hello");
    s = String::from("ahoy");

    println!("{s}, world!");
}

เราประกาศตัวแปร s ในตอนแรกและ bind กับ String ที่มีค่า "hello" จาก นั้นเราสร้าง String ใหม่ทันทีที่มีค่า "ahoy" แล้ว assign ให้ s ณ จุดนี้ ไม่มีอะไรอ้างถึงค่าเดิมบน heap แล้ว Figure 4-5 แสดงข้อมูล stack และ heap ตอนนี้:

หนึ่งตารางแทนค่า string บน stack ชี้ไปยังข้อมูล string ชิ้นที่
สอง (ahoy) บน heap โดยข้อมูล string เดิม (hello) ถูกทำเป็นสีเทาเพราะเข้า
ถึงไม่ได้แล้ว

Figure 4-5: ตัวแทนในหน่วยความจำหลังจากค่าเริ่มต้นถูก แทนที่ทั้งหมด

string เดิมจึงออกจาก scope ทันที Rust จะรันฟังก์ชัน drop กับมัน และหน่วย ความจำของมันจะถูก free ทันที เมื่อเราพิมพ์ค่าตอนท้าย มันจะเป็น "ahoy, world!"

ตัวแปรและข้อมูลโต้ตอบกันด้วย Clone

ถ้าเรา อยาก คัดลอกข้อมูล heap ของ String แบบ deep ไม่ใช่แค่ข้อมูล stack เราใช้เมธอดที่ใช้บ่อยชื่อ clone ได้ เราจะพูดถึง syntax ของเมธอด ในบทที่ 5 แต่เพราะเมธอดเป็นฟีเจอร์ที่ใช้บ่อยในภาษาโปรแกรมหลายภาษา คุณคง เคยเห็นมาก่อน

นี่คือตัวอย่างเมธอด clone ในการใช้งาน:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();

    println!("s1 = {s1}, s2 = {s2}");
}

นี่ทำงานได้ปกติและ produce พฤติกรรมที่แสดงใน Figure 4-3 แบบ explicit ที่ข้อมูล heap ถูก คัดลอกด้วย

เมื่อคุณเห็นการเรียก clone คุณรู้ว่าโค้ดบางอย่างกำลัง execute และโค้ดนั้น อาจแพง มันเป็นสัญญาณเชิง visual ว่ามีบางอย่างต่างกำลังเกิดขึ้น

ข้อมูลที่อยู่บน Stack เท่านั้น: Copy

มีอีกประเด็นที่เรายังไม่ได้พูด โค้ดนี้ที่ใช้ integer — ส่วนหนึ่งของมัน แสดงใน Listing 4-2 — ทำงานและ valid:

fn main() {
    let x = 5;
    let y = x;

    println!("x = {x}, y = {y}");
}

แต่โค้ดนี้ดูเหมือนขัดแย้งกับสิ่งที่เราเพิ่งเรียน — เราไม่ได้เรียก clone แต่ x ยัง valid และไม่ถูก move เข้า y

เหตุผลคือ type อย่าง integer ที่มีขนาดรู้ตอน compile time ถูกเก็บทั้งหมด บน stack ดังนั้นสำเนาของค่าจริงทำได้เร็ว นั่นหมายความว่าไม่มีเหตุผลที่เรา จะอยากป้องกัน x จากการ valid หลังจากเราสร้างตัวแปร y พูดอีกอย่าง ไม่ มีความแตกต่างระหว่าง deep และ shallow copy ที่นี่ ดังนั้นการเรียก clone จะไม่ทำอะไรต่างจาก shallow copy ปกติ และเราละเว้นมันได้

Rust มี annotation พิเศษเรียกว่า trait Copy ที่เราวางบน type ที่เก็บบน stack ได้ อย่างที่ integer เป็น (เราจะพูดถึง trait มากขึ้นใน บทที่ 10) ถ้า type implement trait Copy ตัวแปร ที่ใช้มันจะไม่ move แต่ถูกคัดลอกอย่างง่าย ทำให้มันยัง valid หลัง assign ให้ตัวแปรอื่น

Rust ไม่ให้เรา annotate type ด้วย Copy ถ้า type หรือส่วนใดของมัน implement trait Drop ถ้า type ต้องการอะไรพิเศษที่จะเกิดเมื่อค่าออกจาก scope และ เราเพิ่ม annotation Copy ให้ type นั้น เราจะได้ compile-time error เพื่อ เรียนรู้เกี่ยวกับวิธีเพิ่ม annotation Copy ให้ type ของคุณเพื่อ implement trait ดู “Derivable Trait” ใน ภาคผนวก C

แล้ว type อะไรที่ implement trait Copy? คุณเช็ค documentation ของ type นั้น ๆ ได้เพื่อให้แน่ใจ แต่กฎทั่วไป กลุ่มของค่า scalar ง่าย ๆ ใด ๆ implement Copy ได้ และไม่มีอะไรที่ต้องการ allocation หรือเป็น resource รูปแบบใด ๆ ที่ implement Copy ได้ นี่คือ type บางตัวที่ implement Copy:

  • type integer ทั้งหมด เช่น u32
  • type Boolean bool ที่มีค่า true และ false
  • type floating-point ทั้งหมด เช่น f64
  • type character char
  • tuple ถ้ามีแค่ type ที่ implement Copy ด้วย เช่น (i32, i32) implement Copy แต่ (i32, String) ไม่

Ownership และฟังก์ชัน

กลไกของการส่งค่าให้ฟังก์ชันคล้ายกับการ assign ค่าให้ตัวแปร การส่งตัวแปรให้ ฟังก์ชันจะ move หรือ copy เหมือนที่การ assign ทำ Listing 4-3 มีตัวอย่าง พร้อม annotation บ้างที่แสดงตำแหน่งที่ตัวแปรเข้าและออก scope

Filename: src/main.rs
fn main() {
    let s = String::from("hello");  // s comes into scope

    takes_ownership(s);             // s's value moves into the function...
                                    // ... and so is no longer valid here

    let x = 5;                      // x comes into scope

    makes_copy(x);                  // Because i32 implements the Copy trait,
                                    // x does NOT move into the function,
                                    // so it's okay to use x afterward.

} // Here, x goes out of scope, then s. However, because s's value was moved,
  // nothing special happens.

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{some_string}");
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{some_integer}");
} // Here, some_integer goes out of scope. Nothing special happens.
Listing 4-3: ฟังก์ชันที่มี ownership และ scope annotate ไว้

ถ้าเราพยายามใช้ s หลังการเรียก takes_ownership Rust จะโยน compile-time error การตรวจสอบ static เหล่านี้ปกป้องเราจากความผิดพลาด ลองเพิ่มโค้ดใน main ที่ใช้ s และ x เพื่อดูตำแหน่งที่คุณใช้พวกมันได้ และตำแหน่งที่ กฎ ownership ป้องกันคุณ

Return Value และ Scope

การ return ค่ายังโอน ownership ได้ด้วย Listing 4-4 แสดงตัวอย่างฟังก์ชันที่ return ค่าบางอย่าง พร้อม annotation คล้ายกับใน Listing 4-3

Filename: src/main.rs
fn main() {
    let s1 = gives_ownership();        // gives_ownership moves its return
                                       // value into s1

    let s2 = String::from("hello");    // s2 comes into scope

    let s3 = takes_and_gives_back(s2); // s2 is moved into
                                       // takes_and_gives_back, which also
                                       // moves its return value into s3
} // Here, s3 goes out of scope and is dropped. s2 was moved, so nothing
  // happens. s1 goes out of scope and is dropped.

fn gives_ownership() -> String {       // gives_ownership will move its
                                       // return value into the function
                                       // that calls it

    let some_string = String::from("yours"); // some_string comes into scope

    some_string                        // some_string is returned and
                                       // moves out to the calling
                                       // function
}

// This function takes a String and returns a String.
fn takes_and_gives_back(a_string: String) -> String {
    // a_string comes into
    // scope

    a_string  // a_string is returned and moves out to the calling function
}
Listing 4-4: โอน ownership ของ return value

Ownership ของตัวแปรตาม pattern เดียวกันทุกครั้ง — การ assign ค่าให้ตัวแปร อื่น move มัน เมื่อตัวแปรที่รวมข้อมูลบน heap ออกจาก scope ค่าจะถูก cleanup โดย drop เว้นแต่ ownership ของข้อมูลถูก move ไปยังตัวแปรอื่น

ขณะที่นี่ทำงานได้ การรับ ownership แล้ว return ownership ทุกฟังก์ชันค่อน ข้างน่าเบื่อ ถ้าเราอยากให้ฟังก์ชันใช้ค่าโดยไม่รับ ownership ล่ะ? มันน่ารำคาญ ที่อะไรก็ตามที่เราส่งเข้า ต้องส่งกลับด้วยถ้าเราอยากใช้อีก เพิ่มเติมจาก ข้อมูลใด ๆ ที่ผลจาก body ของฟังก์ชันที่เราอาจอยาก return ด้วย

Rust ให้เรา return หลายค่าโดยใช้ tuple ได้ ดังที่แสดงใน Listing 4-5

Filename: src/main.rs
fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{s2}' is {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() returns the length of a String

    (s, length)
}
Listing 4-5: Return ownership ของ parameter

แต่นี่เป็นพิธีการมากเกินไป และเป็นงานเยอะสำหรับแนวคิดที่ควรใช้บ่อย โชคดี สำหรับเรา Rust มีฟีเจอร์สำหรับการใช้ค่าโดยไม่โอน ownership — reference