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

การใช้ Box<T> เพื่อชี้ไปยังข้อมูลบนฮีป (Heap)

พอยน์เตอร์อัจฉริยะที่ตรงไปตรงมาที่สุดคือ box ซึ่งชนิดของมันเขียนว่า Box<T> Boxes ช่วยให้คุณเก็บข้อมูลไว้บนฮีป (heap) แทนที่จะเป็นสแต็ก (stack) สิ่งที่ยังคงอยู่บนสแต็กคือพอยน์เตอร์ที่ชี้ไปยังข้อมูลบนฮีป อ้างอิงถึงบทที่ 4 เพื่อทบทวนความแตกต่างระหว่างสแต็กและฮีป

Box ไม่มีภาระส่วนเกิน (overhead) ด้านประสิทธิภาพ นอกจากการเก็บข้อมูลไว้บนฮีปแทนที่จะเป็นบนสแต็ก แต่พวกมันก็ไม่ได้มีความสามารถพิเศษเพิ่มเติมมากมายเช่นกัน คุณจะได้ใช้พวกมันบ่อยที่สุดในสถานการณ์เหล่านี้:

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

เราจะสาธิตสถานการณ์แรกในหัวข้อ “การรองรับชนิดข้อมูลแบบวนซ้ำ (Recursive Types) ด้วย Box” ในกรณีที่สอง การโอนย้ายความเป็นเจ้าของของข้อมูลจำนวนมากอาจใช้เวลานานเพราะข้อมูลจะถูกคัดลอกไปมาบนสแต็ก เพื่อปรับปรุงประสิทธิภาพในสถานการณ์นี้ เราสามารถเก็บข้อมูลจำนวนมากไว้บนฮีปใน box ได้ จากนั้นจะมีการคัดลอกเฉพาะข้อมูลพอยน์เตอร์ที่มีขนาดเล็กไปมาบนสแต็ก ในขณะที่ข้อมูลที่มันอ้างอิงถึงจะยังคงอยู่ในตำแหน่งเดิมบนฮีป กรณีที่สามรู้จักกันในชื่อ วัตถุเทรต (trait object) และหัวข้อ “การใช้วัตถุเทรตเพื่อใช้นามธรรมครอบพฤติกรรมร่วมกัน” ในบทที่ 18 จะอธิบายถึงหัวข้อนั้นโดยเฉพาะ ดังนั้นสิ่งที่คุณเรียนรู้ที่นี่จะถูกนำไปใช้อีกครั้งในส่วนนั้น!

การเก็บข้อมูลไว้บนฮีป

ก่อนที่เราจะพูดถึงกรณีการใช้งานการจัดเก็บข้อมูลบนฮีปสำหรับ Box<T> เราจะครอบคลุมไวยากรณ์และวิธีโต้ตอบกับค่าที่ถูกเก็บไว้ภายใน Box<T>

โค้ดตัวอย่างที่ 15-1 แสดงวิธีใช้ box เพื่อเก็บค่า i32 ไว้บนฮีป

fn main() {
    let b = Box::new(5);
    println!("b = {b}");
}

เรานิยามตัวแปร b ให้มีค่าเป็น Box ที่ชี้ไปยังค่า 5 ซึ่งถูกจัดสรรไว้บนฮีป โปรแกรมนี้จะพิมพ์ b = 5 ในกรณีนี้ เราสามารถเข้าถึงข้อมูลใน box ได้คล้ายกับวิธีที่เราทำหากข้อมูลนี้อยู่บนสแต็ก เช่นเดียวกับค่าที่มีเจ้าของใด ๆ เมื่อ box หลุดออกจากขอบเขต (scope) เช่นเดียวกับที่ b ทำตอนสิ้นสุด main มันจะถูกคืนหน่วยความจำ (deallocated) การคืนหน่วยความจำจะเกิดขึ้นทั้งกับตัว box (ที่เก็บไว้บนสแต็ก) และข้อมูลที่มันชี้ไป (ที่เก็บไว้บนฮีป)

การวางค่าเดี่ยว ๆ ไว้บนฮีปไม่ได้มีประโยชน์มากนัก ดังนั้นคุณจะไม่ค่อยได้ใช้ box เพียงอย่างเดียวในลักษณะนี้บ่อยนัก การมีค่าเช่น i32 ตัวเดียวบนสแต็ก ซึ่งเป็นตำแหน่งจัดเก็บเริ่มต้น มีความเหมาะสมมากกว่าในสถานการณ์ส่วนใหญ่ มาดูกรณีที่ box ช่วยให้เราสามารถนิยามชนิดข้อมูลที่เราจะไม่ได้รับอนุญาตให้นิยามได้หากเราไม่มี box

การรองรับชนิดข้อมูลแบบวนซ้ำ (Recursive Types) ด้วย Box

ค่าของ ชนิดข้อมูลแบบวนซ้ำ (recursive type) สามารถมีค่าอื่นที่เป็นชนิดข้อมูลเดียวกันเป็นส่วนหนึ่งของตัวมันเองได้ ชนิดข้อมูลแบบวนซ้ำทำให้เกิดปัญหาเนื่องจาก Rust จำเป็นต้องรู้ในขณะคอมไพล์ว่าชนิดข้อมูลนั้นใช้พื้นที่เท่าใด อย่างไรก็ตาม การซ้อนกันของค่าของชนิดข้อมูลแบบวนซ้ำในทางทฤษฎีสามารถดำเนินต่อไปได้อย่างอนันต์ ดังนั้น Rust จึงไม่สามารถรู้ได้ว่าค่านั้นต้องการพื้นที่เท่าใด เนื่องจาก box มีขนาดที่แน่นอน เราจึงสามารถรองรับชนิดข้อมูลแบบวนซ้ำได้โดยการแทรก box ลงในการนิยามชนิดข้อมูลแบบวนซ้ำ

ตัวอย่างของชนิดข้อมูลแบบวนซ้ำ มาสำรวจ cons list กัน นี่คือชนิดข้อมูลที่มักพบในภาษาโปรแกรมเชิงฟังก์ชัน ชนิดข้อมูล cons list ที่เราจะนิยามนั้นตรงไปตรงมาเว้นแต่เรื่องการวนซ้ำ (recursion) ดังนั้น แนวคิดในตัวอย่างที่เราจะทำงานด้วยจะมีประโยชน์ทุกครั้งที่คุณเข้าไปในสถานการณ์ที่ซับซ้อนขึ้นซึ่งเกี่ยวข้องกับชนิดข้อมูลแบบวนซ้ำ

ทำความเข้าใจ Cons List

cons list คือโครงสร้างข้อมูลที่มาจากภาษาโปรแกรม Lisp และภาษาในตระกูลเดียวกัน ประกอบด้วยคู่ซ้อนกัน และเป็นเวอร์ชันภาษา Lisp ของลิงก์ลิสต์ (linked list) ชื่อของมันมาจากฟังก์ชัน cons (ย่อมาจาก construct function) ใน Lisp ที่สร้างคู่ใหม่ขึ้นมาจากอาร์กิวเมนต์สองตัวของมัน โดยการเรียก cons กับคู่ที่ประกอบด้วยค่าหนึ่งค่าและอีกคู่หนึ่ง เราสามารถสร้าง cons list ที่ประกอบด้วยคู่แบบวนซ้ำได้

ตัวอย่างเช่น นี่คือการแสดงรหัสเทียม (pseudocode) ของ cons list ที่มีลิสต์ 1, 2, 3 โดยแต่ละคู่อยู่ในวงเล็บ:

(1, (2, (3, Nil)))

สมาชิกแต่ละตัวใน cons list บรรจุสององค์ประกอบ: ค่าของสมาชิกปัจจุบัน และสมาชิกถัดไป สมาชิกสุดท้ายในลิสต์บรรจุเพียงค่าที่เรียกว่า Nil โดยไม่มีสมาชิกถัดไป cons list ถูกสร้างขึ้นโดยการเรียกฟังก์ชัน cons ซ้ำ ๆ ชื่อตามหลักการที่ใช้แทนกรณีฐาน (base case) ของการวนซ้ำคือ Nil สังเกตว่าสิ่งนี้ไม่เหมือนกับแนวคิด “null” หรือ “nil” ที่พูดถึงในบทที่ 6 ซึ่งเป็นค่าที่ไม่ถูกต้องหรือไม่ปรากฏอยู่

cons list ไม่ใช่โครงสร้างข้อมูลที่ใช้บ่อยใน Rust ส่วนใหญ่เมื่อคุณมีรายการสมาชิกใน Rust Vec<T> จะเป็นตัวเลือกที่ดีกว่าในการใช้งาน ชนิดข้อมูลแบบวนซ้ำอื่น ๆ ที่ซับซ้อนกว่า มี ประโยชน์ในสถานการณ์ต่าง ๆ แต่การเริ่มต้นด้วย cons list ในบทนี้ เราสามารถสำรวจวิธีที่ box ช่วยให้เรานิยามชนิดข้อมูลแบบวนซ้ำได้โดยไม่มีสิ่งรบกวนมากนัก

โค้ดตัวอย่างที่ 15-2 บรรจุนิยาม enum สำหรับ cons list โปรดทราบว่าโค้ดนี้ยังไม่สามารถคอมไพล์ได้เนื่องจากชนิดข้อมูล List ไม่มีขนาดที่แน่นอน ซึ่งเราจะสาธิตให้เห็น

enum List {
    Cons(i32, List),
    Nil,
}

fn main() {}

หมายเหตุ: เรากำลังประยุกต์ใช้ cons list ที่บรรจุเฉพาะค่า i32 เพื่อวัตถุประสงค์ของตัวอย่างนี้ เราอาจประยุกต์ใช้งานโดยใช้ generics ตามที่เราพูดถึงในบทที่ 10 เพื่อนิยามชนิดข้อมูล cons list ที่สามารถเก็บค่าของชนิดข้อมูลใดก็ได้

การใช้ชนิดข้อมูล List เพื่อเก็บลิสต์ 1, 2, 3 จะดูเหมือนโค้ดในโค้ดตัวอย่างที่ 15-3

enum List {
    Cons(i32, List),
    Nil,
}

// --snip--

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Cons(2, Cons(3, Nil)));
}

ค่า Cons แรกถือครอง 1 และค่า List อีกค่าหนึ่ง ค่า List นี้เป็นค่า Cons อีกค่าหนึ่งที่ถือครอง 2 และค่า List อีกค่าหนึ่ง ค่า List นี้เป็นค่า Cons อีกหนึ่งค่าที่ถือครอง 3 และค่า List ซึ่งสุดท้ายคือ Nil ตัวแปรย่อยแบบไม่วนซ้ำที่ส่งสัญญาณสิ้นสุดลิสต์

หากเราพยายามคอมไพล์โค้ดในโค้ดตัวอย่างที่ 15-3 เราจะได้ข้อผิดพลาดดังแสดงในโค้ดตัวอย่างที่ 15-4

$ cargo run
   Compiling cons-list v0.1.0 (file:///projects/cons-list)
error[E0072]: recursive type `List` has infinite size
 --> src/main.rs:1:1
  |
1 | enum List {
  | ^^^^^^^^^
2 |     Cons(i32, List),
  |               ---- recursive without indirection
  |
help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

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

ข้อผิดพลาดแสดงว่าชนิดข้อมูลนี้ “มีขนาดเป็นอนันต์” เหตุผลก็คือเราได้นิยาม List ด้วยตัวแปรย่อยที่เป็นแบบวนซ้ำ: มันถือครองอีกค่าหนึ่งของตัวมันเองโดยตรง ผลลัพธ์คือ Rust ไม่สามารถคิดได้ว่าจะต้องใช้พื้นที่เท่าใดในการเก็บค่า List มาวิเคราะห์กันว่าทำไมเราถึงได้ข้อผิดพลาดนี้ อันดับแรก เราจะดูวิธีที่ Rust ตัดสินใจว่าจะต้องใช้พื้นที่เท่าใดในการเก็บค่าของชนิดข้อมูลแบบไม่วนซ้ำ

การคำนวณขนาดของชนิดข้อมูลแบบไม่วนซ้ำ

ทบทวน enum Message ที่เรานิยามไว้ในโค้ดตัวอย่างที่ 6-2 เมื่อเราพูดถึงนิยาม enum ในบทที่ 6:

enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

fn main() {}

ในการพิจารณาว่าจะต้องจัดสรรพื้นที่เท่าใดสำหรับค่า Message Rust จะไล่ดูแต่ละตัวแปรย่อยเพื่อดูว่าตัวแปรย่อยใดต้องการพื้นที่มากที่สุด Rust เห็นว่า Message::Quit ไม่ต้องการพื้นที่เลย Message::Move ต้องการพื้นที่เพียงพอสำหรับเก็บค่า i32 สองค่า และอื่น ๆ เนื่องจากจะมีเพียงตัวแปรย่อยเดียวเท่านั้นที่จะถูกใช้งาน พื้นที่มากที่สุดที่ค่า Message จะต้องการจึงเป็นพื้นที่ที่ใช้สำหรับเก็บตัวแปรย่อยที่ใหญ่ที่สุด

ลองเปรียบเทียบสิ่งนี้กับสิ่งที่เกิดขึ้นเมื่อ Rust พยายามพิจารณาว่าชนิดข้อมูลแบบวนซ้ำอย่าง enum List ในโค้ดตัวอย่างที่ 15-2 ต้องการพื้นที่เท่าใด คอมไพเลอร์เริ่มจากดูที่ตัวแปรย่อย Cons ซึ่งถือครองค่าชนิด i32 และค่าชนิด List ดังนั้น Cons จึงต้องการพื้นที่เท่ากับขนาดของ i32 บวกกับขนาดของ List ในการหาว่าชนิดข้อมูล List ต้องการหน่วยความจำเท่าใด คอมไพเลอร์จะดูที่ตัวแปรย่อย โดยเริ่มจากตัวแปรย่อย Cons ตัวแปรย่อย Cons ถือครองค่าชนิด i32 และค่าชนิด List และกระบวนการนี้จะดำเนินต่อไปอย่างอนันต์ ดังแสดงในรูปภาพที่ 15-1

Cons list อนันต์: สี่เหลี่ยมผืนผ้าป้ายชื่อ 'Cons' แบ่งออกเป็นสี่เหลี่ยมผืนผ้าขนาดเล็กสองอัน อันแรกถือป้ายชื่อ 'i32' และอันที่สองถือป้ายชื่อ 'Cons' และเวอร์ชันเล็กกว่าของสี่เหลี่ยมผืนผ้า 'Cons' ด้านนอก สี่เหลี่ยมผืนผ้า 'Cons' ยังคงถือครองเวอร์ชันที่เล็กกว่าและเล็กกว่าไปเรื่อย ๆ จนกระทั่งสี่เหลี่ยมผืนผ้าที่เล็กที่สุดถือสัญลักษณ์อนันต์ แสดงว่าการทำซ้ำนี้ดำเนินไปตลอดกาล

รูปภาพที่ 15-1: List อนันต์ที่ประกอบด้วยตัวแปรย่อย Cons อนันต์

การได้มาซึ่งชนิดข้อมูลแบบวนซ้ำที่มีขนาดแน่นอน

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

help: insert some indirection (e.g., a `Box`, `Rc`, or `&`) to break the cycle
  |
2 |     Cons(i32, Box<List>),
  |               ++++    +

ในคำแนะนำนี้ การอ้อม (indirection) หมายความว่าแทนที่จะเก็บค่าไว้โดยตรง เราควรเปลี่ยนโครงสร้างข้อมูลเพื่อเก็บค่านั้นทางอ้อมโดยเก็บพอยน์เตอร์ไปยังค่านั้นแทน

เนื่องจาก Box<T> เป็นพอยน์เตอร์ Rust จึงรู้อยู่เสมอว่า Box<T> ต้องการพื้นที่เท่าใด: ขนาดของพอยน์เตอร์จะไม่เปลี่ยนแปลงตามปริมาณข้อมูลที่มันชี้ไป สิ่งนี้หมายความว่าเราสามารถใส่ Box<T> ไว้ภายในตัวแปรย่อย Cons แทนที่จะเป็นอีกค่า List โดยตรง Box<T> จะชี้ไปยังค่า List ถัดไปที่จะอยู่บนฮีปแทนที่จะอยู่ภายในตัวแปรย่อย Cons โดยหลักการแล้ว เรายังคงมีลิสต์ที่สร้างขึ้นด้วยลิสต์ที่ถือครองลิสต์อื่นอยู่ แต่การประยุกต์ใช้นี้ตอนนี้คล้ายกับการวางสมาชิกรอบ ๆ ข้างกันแทนที่จะวางไว้ข้างในกันและกัน

เราสามารถเปลี่ยนนิยามของ enum List ในโค้ดตัวอย่างที่ 15-2 และการใช้งาน List ในโค้ดตัวอย่างที่ 15-3 เป็นโค้ดในโค้ดตัวอย่างที่ 15-5 ซึ่งจะสามารถคอมไพล์ได้

enum List {
    Cons(i32, Box<List>),
    Nil,
}

use crate::List::{Cons, Nil};

fn main() {
    let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}

ตัวแปรย่อย Cons ต้องการขนาดของ i32 บวกกับพื้นที่สำหรับเก็บข้อมูลพอยน์เตอร์ของ box ตัวแปรย่อย Nil ไม่ได้เก็บค่าใด ๆ ดังนั้นจึงต้องการพื้นที่บนสแต็กน้อยกว่าตัวแปรย่อย Cons ตอนนี้เรารู้แล้วว่าค่า List ใด ๆ จะใช้พื้นที่เท่ากับขนาดของ i32 บวกกับขนาดของข้อมูลพอยน์เตอร์ของ box การใช้ box ทำให้เราตัดห่วงโซ่แบบวนซ้ำที่เป็นอนันต์ออกไปได้ คอมไพเลอร์จึงสามารถคิดขนาดที่จำเป็นในการเก็บค่า List ได้ รูปภาพที่ 15-2 แสดงว่าตัวแปรย่อย Cons ดูเป็นอย่างไรตอนนี้

สี่เหลี่ยมผืนผ้าป้ายชื่อ 'Cons' แบ่งออกเป็นสี่เหลี่ยมผืนผ้าขนาดเล็กสองอัน อันแรกถือป้ายชื่อ 'i32' และอันที่สองถือป้ายชื่อ 'Box' พร้อมสี่เหลี่ยมผืนผ้าภายในหนึ่งอันที่บรรจุป้ายชื่อ 'usize' ซึ่งแทนขนาดจำกัดของพอยน์เตอร์ของ box

รูปภาพที่ 15-2: List ที่ไม่ได้มีขนาดอนันต์ เพราะ Cons ถือครอง Box

Box ให้เพียงการอ้อมและการจัดสรรบนฮีปเท่านั้น พวกมันไม่ได้มีความสามารถพิเศษอื่น ๆ เหมือนสิ่งที่เราจะได้เห็นกับชนิดพอยน์เตอร์อัจฉริยะอื่น ๆ พวกมันยังไม่มีภาระส่วนเกินด้านประสิทธิภาพที่ความสามารถพิเศษเหล่านั้นก่อขึ้น ดังนั้นจึงมีประโยชน์ในกรณีอย่าง cons list ที่การอ้อมเป็นเพียงฟีเจอร์เดียวที่เราต้องการ เราจะดูcases การใช้งานอื่น ๆ สำหรับ box ในบทที่ 18

ชนิดข้อมูล Box<T> เป็นพอยน์เตอร์อัจฉริยะเนื่องจากมันประยุกต์ใช้เทรต Deref ซึ่งช่วยให้ค่า Box<T> ถูกปฏิบัติเหมือนการอ้างอิงได้ เมื่อค่า Box<T> หลุดออกจากขอบเขต (scope) ข้อมูลบนฮีปที่ box ชี้ไปก็จะถูกล้างทำความสะอาดด้วยเช่นกันเนื่องจากการประยุกต์ใช้เทรต Drop เทรตทั้งสองนี้จะมีความสำคัญยิ่งขึ้นต่อฟังก์ชันการทำงานที่จัดเตรียมไว้โดยชนิดพอยน์เตอร์อัจฉริยะอื่น ๆ ที่เราจะพูดถึงในส่วนที่เหลือของบทนี้ มาสำรวจเทรตทั้งสองนี้โดยละเอียดกันเถอะ