การจัดเก็บข้อมูลเป็นรายการด้วยเวกเตอร์ (Storing Lists of Values with Vectors)
คอลเลกชันประเภทแรกที่เราจะมาดูกันคือ Vec<T> หรือที่รู้จักกันในชื่อเวกเตอร์ (vector)
เวกเตอร์ช่วยให้คุณสามารถจัดเก็บข้อมูลได้มากกว่าหนึ่งค่าไว้ในโครงสร้างข้อมูลเดียว โดยระบบจะจัดเก็บค่าทั้งหมดให้อยู่ตำแหน่งติดกันในหน่วยความจำ เวกเตอร์สามารถจัดเก็บได้เฉพาะค่าที่เป็นประเภทข้อมูล (type) เดียวกันเท่านั้น เวกเตอร์จะมีประโยชน์มากเมื่อคุณมีรายการสิ่งของต่าง ๆ เช่น บรรทัดข้อความในไฟล์ หรือราคาสินค้าในรถเข็นช้อปปิ้ง
การสร้างเวกเตอร์ใหม่ (Creating a New Vector)
ในการสร้างเวกเตอร์ใหม่ที่ยังไม่มีข้อมูล เราจะเรียกใช้ฟังก์ชัน Vec::new ดังที่แสดงในตัวอย่างที่ 8-1 (Listing 8-1)
fn main() {
let v: Vec<i32> = Vec::new();
}
สังเกตว่าเราได้เพิ่มการระบุประเภทข้อมูล (type annotation) ไว้ที่นี่ เนื่องจากเรายังไม่ได้ใส่ค่าใด ๆ ลงไปในเวกเตอร์นี้ Rust จึงไม่รู้ว่าเราต้องการจัดเก็บข้อมูลประเภทใด นี่คือจุดสำคัญ เวกเตอร์ถูกเขียนขึ้นโดยใช้เจเนอริก (generics) ซึ่งเราจะมาเรียนรูวิธีกำหนดเจเนอริกกับประเภทข้อมูลของเราเองในบทที่ 10 สำหรับตอนนี้ ขอให้ทราบว่าประเภทข้อมูล Vec<T> ที่จัดเตรียมไว้โดยไลบรารีมาตรฐาน (standard library) สามารถเก็บข้อมูลประเภทใดก็ได้ เมื่อเราสร้างเวกเตอร์เพื่อจัดเก็บประเภทข้อมูลเฉพาะเจาะจง เราสามารถระบุประเภทข้อมูลนั้นไว้ในวงเล็บสามเหลี่ยม (angle brackets) ได้ ในตัวอย่างที่ 8-1 เราบอก Rust ว่า Vec<T> ใน v จะเก็บข้อมูลประเภท i32
แต่ส่วนใหญ่แล้ว คุณมักจะสร้าง Vec<T> พร้อมกับค่าเริ่มต้น และ Rust จะสามารถคาดเดา (infer) ประเภทข้อมูลที่คุณต้องการจัดเก็บได้เอง ดังนั้นคุณจึงไม่จำเป็นต้องระบุประเภทข้อมูลนี้บ่อยนัก Rust ได้จัดเตรียมมาโคร vec! เพื่อความสะดวก ซึ่งจะช่วยสร้างเวกเตอร์ใหม่พร้อมเก็บค่าที่คุณกำหนดให้ ตัวอย่างที่ 8-2 จะสร้าง Vec<i32> ใหม่ที่เก็บค่า 1, 2 และ 3 ประเภทของจำนวนเต็มคือ i32 เนื่องจากเป็นประเภทจำนวนเต็มเริ่มต้น ดังที่เราได้พูดคุยกันในส่วน “ประเภทข้อมูล (Data Types)” ของบทที่ 3
fn main() {
let v = vec![1, 2, 3];
}
เนื่องจากเราได้ให้ค่าเริ่มต้นที่เป็น i32 แล้ว Rust จึงสามารถคาดเดาได้ว่าประเภทข้อมูลของ v คือ Vec<i32> และไม่จำเป็นต้องระบุประเภทข้อมูลอีก ถัดไปเราจะมาดูวิธีการแก้ไขข้อมูลในเวกเตอร์
การอัปเดตเวกเตอร์ (Updating a Vector)
ในการสร้างเวกเตอร์แล้วเพิ่มข้อมูลลงไป เราสามารถใช้เมธอด push ได้ ดังที่แสดงในตัวอย่างที่ 8-3
fn main() {
let mut v = Vec::new();
v.push(5);
v.push(6);
v.push(7);
v.push(8);
}
เช่นเดียวกับตัวแปรอื่น ๆ หากเราต้องการแก้ไขค่าของมันได้ เราจำเป็นต้องทำให้มันสามารถเปลี่ยนแปลงค่าได้ (mutable) โดยใช้คีย์เวิร์ด mut ดังที่กล่าวไปในบทที่ 3 ตัวเลขที่เราใส่ไว้ข้างในล้วนเป็นประเภท i32 ซึ่ง Rust คาดเดาได้จากข้อมูล ดังนั้นเราจึงไม่จำเป็นต้องใส่การระบุประเภท Vec<i32>
การอ่านข้อมูลจากเวกเตอร์ (Reading Elements of Vectors)
มีสองวิธีในการอ้างอิงถึงค่าที่จัดเก็บอยู่ในเวกเตอร์ ได้แก่ การใช้ดัชนี (indexing) หรือการใช้เมธอด get ในตัวอย่างต่อไปนี้ เราได้ระบุประเภทข้อมูลของค่าที่ส่งกลับมาจากฟังก์ชันเหล่านี้เพื่อความชัดเจนยิ่งขึ้น
ตัวอย่างที่ 8-4 แสดงทั้งสองวิธีในการเข้าถึงค่าในเวกเตอร์ โดยใช้ไวยากรณ์ดัชนี (indexing) และเมธอด get
fn main() {
let v = vec![1, 2, 3, 4, 5];
let third: &i32 = &v[2];
println!("The third element is {third}");
let third: Option<&i32> = v.get(2);
match third {
Some(third) => println!("The third element is {third}"),
None => println!("There is no third element."),
}
}
สังเกตรายละเอียดเล็กน้อยตรงนี้ เราใช้ดัชนีค่า 2 เพื่อดึงข้อมูลตัวที่สาม เนื่องจากเวกเตอร์อ้างอิงด้วยดัชนีตัวเลขที่เริ่มต้นด้วยศูนย์ การใช้ & และ [] จะให้ข้อมูลอ้างอิง (reference) ไปยังข้อมูลตำแหน่งดัชนีนั้น เมื่อเราใช้เมธอด get โดยส่งดัชนีเป็นพารามิเตอร์ เราจะได้ผลลัพธ์เป็น Option<&T> ซึ่งเราสามารถนำไปใช้กับ match ได้
Rust ได้จัดเตรียมสองวิธีนี้ในการอ้างอิงข้อมูลเพื่อให้คุณสามารถเลือกได้ว่า จะให้โปรแกรมทำงานอย่างไรเมื่อพยายามใช้ค่าดัชนีที่อยู่นอกช่วงข้อมูลที่มีอยู่ ตัวอย่างเช่น มาดูกันว่าจะเกิดอะไรขึ้นเมื่อเรามีเวกเตอร์ที่มีสมาชิก 5 ตัว แล้วเราพยายามเข้าถึงข้อมูลที่ดัชนี 100 ด้วยแต่ละวิธี ดังที่แสดงในตัวอย่างที่ 8-5
fn main() {
let v = vec![1, 2, 3, 4, 5];
let does_not_exist = &v[100];
let does_not_exist = v.get(100);
}
เมื่อเราสั่งรันโค้ดนี้ วิธีแรกที่ใช้ [] จะทำให้โปรแกรมหยุดทำงานกะทันหัน (panic) เนื่องจากมีการอ้างอิงถึงข้อมูลที่ไม่มีอยู่จริง วิธีนี้เหมาะอย่างยิ่งเมื่อคุณต้องการให้โปรแกรมของคุณทำงานล้มเหลว (crash) หากมีการพยายามเข้าถึงข้อมูลที่เกินขอบเขตของเวกเตอร์
แต่เมื่อเราส่งดัชนีที่อยู่นอกขอบเขตของเวกเตอร์ให้กับเมธอด get มันจะส่งกลับค่า None โดยไม่เกิด panic คุณควรเลือกใช้วิธีนี้หากการเข้าถึงข้อมูลนอกช่วงของเวกเตอร์อาจเกิดขึ้นได้ในสถานการณ์ปกติทั่วไป จากนั้นโค้ดของคุณก็จะมีตรรกะสำหรับจัดการกับกรณีที่เป็น Some(&element) หรือ None ตามที่คุยกันในบทที่ 6 ตัวอย่างเช่น ดัชนีดังกล่าวอาจมาจากผู้ใช้ป้อนตัวเลขเข้ามา หากพวกเขาเผลอป้อนตัวเลขที่มากเกินไปและโปรแกรมได้รับค่า None คุณก็สามารถแจ้งผู้ใช้ได้ว่าในเวกเตอร์มีจำนวนสมาชิกทั้งหมดเท่าใด และเปิดโอกาสให้พวกเขากรอกค่าใหม่อีกครั้ง ซึ่งวิธีนี้จะดูเป็นมิตรกับผู้ใช้มากกว่าการปล่อยให้โปรแกรมพังไปเพียงเพราะผู้ใช้พิมพ์ผิด!
เมื่อโปรแกรมมีข้อมูลอ้างอิงที่ถูกต้อง ระบบตรวจสอบการยืม (borrow checker) จะบังคับใช้กฎความเป็นเจ้าของและการยืม (ซึ่งได้อธิบายไว้ในบทที่ 4) เพื่อให้มั่นใจได้ว่าข้อมูลอ้างอิงนี้และข้อมูลอ้างอิงอื่น ๆ ที่ชี้ไปยังข้อมูลในเวกเตอร์ยังคงถูกต้องสมบูรณ์ ย้อนกลับไปดูกฎที่ว่า คุณไม่สามารถมีข้อมูลอ้างอิงแบบเปลี่ยนค่าได้ (mutable reference) และอ้างอิงแบบเปลี่ยนค่าไม่ได้ (immutable reference) อยู่ในขอบเขต (scope) เดียวกันได้ กฎนี้ถูกนำมาใช้ในตัวอย่างที่ 8-6 ที่เราถือครองข้อมูลอ้างอิงแบบเปลี่ยนค่าไม่ได้ของสมาชิกตัวแรกของเวกเตอร์ แล้วพยายามเพิ่มข้อมูลตัวใหม่เข้าไปที่ท้ายเวกเตอร์ โปรแกรมนี้จะทำงานไม่ได้ หากเราพยายามอ้างอิงถึงสมาชิกตัวแรกนั้นอีกครั้งในส่วนหลังจากนั้นของฟังก์ชัน
fn main() {
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];
v.push(6);
println!("The first element is: {first}");
}
การคอมไพล์โค้ดนี้จะส่งผลให้เกิดข้อผิดพลาดต่อไปนี้:
$ cargo run
Compiling collections v0.1.0 (file:///projects/collections)
error[E0502]: cannot borrow `v` as mutable because it is also borrowed as immutable
--> src/main.rs:6:5
|
4 | let first = &v[0];
| - immutable borrow occurs here
5 |
6 | v.push(6);
| ^^^^^^^^^ mutable borrow occurs here
7 |
8 | println!("The first element is: {first}");
| ----- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `collections` (bin "collections") due to 1 previous error
โค้ดในตัวอย่างที่ 8-6 อาจดูเหมือนควรจะทำงานได้: ทำไมข้อมูลอ้างอิงถึงสมาชิกตัวแรกถึงต้องมาสนใจกับการเปลี่ยนแปลงที่ท้ายเวกเตอร์ด้วยล่ะ? ข้อผิดพลาดนี้เกิดจากหลักการทำงานของเวกเตอร์: เนื่องจากเวกเตอร์จะวางข้อมูลเรียงต่อกันในหน่วยความจำ การเพิ่มสมาชิกใหม่เข้าไปที่ท้ายเวกเตอร์อาจทำให้ต้องจัดสรรพื้นที่หน่วยความจำใหม่ (allocating new memory) และคัดลอกสมาชิกตัวเก่าไปยังพื้นที่ใหม่ หากพื้นที่เดิมที่เวกเตอร์จัดเก็บอยู่ไม่มีที่ว่างเพียงพอที่จะวางสมาชิกทั้งหมดเรียงต่อกันได้ ในกรณีเช่นนั้น ข้อมูลอ้างอิงถึงสมาชิกตัวแรกก็จะไปชี้ตำแหน่งหน่วยความจำที่ถูกคืนสิทธิ์ไปแล้ว (deallocated memory) กฎการยืมจึงเข้ามาเพื่อป้องกันไม่ให้โปรแกรมต้องไปตกอยู่ในสถานการณ์เช่นนั้น
หมายเหตุ: หากต้องการดูรายละเอียดเพิ่มเติมเกี่ยวกับการทำงานเบื้องหลังของประเภทข้อมูล
Vec<T>โปรดดูที่ “The Rustonomicon”
การวนลูปผ่านค่าต่าง ๆ ในเวกเตอร์ (Iterating Over the Values in a Vector)
เพื่อเข้าถึงแต่ละสมาชิกในเวกเตอร์ตามลำดับ เราจะวนลูปผ่านสมาชิกทั้งหมดแทนที่จะใช้ดัชนีเข้าถึงทีละตัว ตัวอย่างที่ 8-7 แสดงวิธีการใช้ลูป for เพื่อรับข้อมูลอ้างอิงแบบแก้ไขไม่ได้ไปยังแต่ละสมาชิกของเวกเตอร์ประเภท i32 แล้วนำมาแสดงผล
fn main() {
let v = vec![100, 32, 57];
for i in &v {
println!("{i}");
}
}
นอกจากนี้ เรายังสามารถวนลูปผ่านข้อมูลอ้างอิงแบบแก้ไขได้ (mutable reference) ของแต่ละสมาชิกในเวกเตอร์ที่สามารถแก้ไขได้ เพื่อเปลี่ยนแปลงข้อมูลของสมาชิกทุกตัวได้ด้วย ลูป for ในตัวอย่างที่ 8-8 จะบวกค่าเพิ่มเข้าไป 50 ในสมาชิกแต่ละตัว
fn main() {
let mut v = vec![100, 32, 57];
for i in &mut v {
*i += 50;
}
}
ในการเปลี่ยนค่าที่ข้อมูลอ้างอิงแบบแก้ไขได้ชี้อยู่ เราจำเป็นต้องใช้ตัวดำเนินการหาค่าจากตำแหน่งอ้างอิง (dereference operator) * เพื่อเข้าถึงค่าจริงในตัวแปร i ก่อน จากนั้นเราจึงจะสามารถใช้ตัวดำเนินการ += ได้ เราจะอธิบายรายละเอียดเกี่ยวกับตัวดำเนินการ dereference เพิ่มเติมในส่วน “การตามรอยข้อมูลอ้างอิงไปยังค่าจริง (Following the Reference to the Value)” ของบทที่ 15
การวนลูปเข้าถึงข้อมูลในเวกเตอร์ ไม่ว่าจะเป็นแบบอ่านอย่างเดียวหรือแบบแก้ไขได้ จะมีความปลอดภัยสูงเนื่องจากกฎของ borrow checker หากเราพยายามใส่ข้อมูลเพิ่มหรือนำข้อมูลออกภายในโครงสร้างของลูป for ในตัวอย่างที่ 8-7 และตัวอย่างที่ 8-8 เราจะเจอกับข้อผิดพลาดของคอมไพเลอร์ที่คล้ายกับข้อผิดพลาดที่พบจากโค้ดในตัวอย่างที่ 8-6 ข้อมูลอ้างอิงของเวกเตอร์ที่ลูป for ถือครองไว้นั้นจะช่วยป้องกันไม่ให้เกิดการแก้ไขตัวเวกเตอร์ทั้งหมดไปพร้อม ๆ กัน
การใช้ Enum เพื่อเก็บข้อมูลหลายประเภท (Using an Enum to Store Multiple Types)
เวกเตอร์สามารถจัดเก็บได้เฉพาะข้อมูลที่เป็นประเภทเดียวกันเท่านั้น ซึ่งอาจจะไม่สะดวกในบางกรณี เพราะมีสถานการณ์ที่เราต้องการจัดเก็บรายการของข้อมูลที่มีหลายประเภทผสมกันอย่างแน่นอน โชคดีที่ค่าตัวแปรย่อย (variants) ของ enum นั้นถูกกำหนดไว้ภายใต้ประเภท enum เดียวกัน ดังนั้นเมื่อเราต้องการประเภทข้อมูลเดียวเพื่อเป็นตัวแทนของข้อมูลหลายประเภท เราจึงสามารถกำหนดและใช้งาน enum ได้!
ตัวอย่างเช่น สมมติว่าเราต้องการดึงข้อมูลจากแถวหนึ่งในสเปรดชีต (spreadsheet) ซึ่งบางคอลัมน์ในแถวนั้นอาจเก็บจำนวนเต็ม บางคอลัมน์เก็บตัวเลขทศนิยม และบางคอลัมน์เก็บข้อความ เราสามารถกำหนด enum ที่มีตัวแปรย่อยต่าง ๆ เพื่อจัดเก็บข้อมูลประเภทที่แตกต่างกันเหล่านี้ได้ และตัวแปรย่อยทั้งหมดของ enum นั้นจะถูกมองว่าเป็นประเภทข้อมูลเดียวกัน นั่นคือประเภทข้อมูลของ enum นั้นเอง จากนั้น เราจะสามารถสร้างเวกเตอร์สำหรับจัดเก็บ enum ดังกล่าวได้ ซึ่งท้ายที่สุดแล้วก็จะช่วยให้เก็บข้อมูลหลากประเภทได้นั่นเอง ดังที่แสดงไว้ในตัวอย่างที่ 8-9
fn main() {
enum SpreadsheetCell {
Int(i32),
Float(f64),
Text(String),
}
let row = vec![
SpreadsheetCell::Int(3),
SpreadsheetCell::Text(String::from("blue")),
SpreadsheetCell::Float(10.12),
];
}
Rust จำเป็นต้องรู้ว่าจะมีประเภทข้อมูลใดบ้างอยู่ในเวกเตอร์ในขั้นตอนการคอมไพล์ (compile time) เพื่อที่มันจะได้รู้ขนาดหน่วยความจำบนฮีป (heap) ที่ต้องจัดสรรได้อย่างแม่นยำสำหรับใช้จัดเก็บสมาชิกแต่ละตัว นอกจากนี้ เราต้องระบุอย่างชัดเจนว่าประเภทข้อมูลใดบ้างที่ได้รับอนุญาตให้เก็บอยู่ในเวกเตอร์นี้ หาก Rust ยอมให้เวกเตอร์เก็บข้อมูลประเภทใดก็ได้ ก็มีความเสี่ยงที่ประเภทข้อมูลบางตัวอาจก่อให้เกิดข้อผิดพลาดเมื่อทำตามคำสั่งกับสมาชิกต่าง ๆ ของเวกเตอร์นั้น การใช้งาน enum ร่วมกับนิพจน์ match จะช่วยให้ Rust มั่นใจได้ตั้งแต่ตอนคอมไพล์ว่าทุกกรณีที่เป็นไปได้จะถูกจัดการอย่างถูกต้อง ดังที่เราพูดคุยกันในบทที่ 6
หากคุณไม่ทราบประเภทข้อมูลทั้งหมดล่วงหน้าในระหว่างการพัฒนาเพื่อนำมาเก็บในเวกเตอร์ตอนรันโปรแกรม (runtime) เทคนิคการใช้ enum จะไม่สามารถตอบโจทย์ได้ ในกรณีนั้น คุณสามารถเลือกใช้งานทริตอ็อบเจกต์ (trait object) แทนได้ ซึ่งเราจะมาศึกษาในบทที่ 18
เมื่อเราได้พูดคุยเกี่ยวกับวิธีการยอดนิยมในการใช้งานเวกเตอร์กันไปแล้ว อย่าลืมเข้าไปศึกษา เอกสารอ้างอิง API (API documentation) เพิ่มเติมเกี่ยวกับเมธอดที่มีประโยชน์อื่น ๆ อีกมากมายสำหรับ Vec<T> ที่ไลบรารีมาตรฐานกำหนดไว้ ตัวอย่างเช่น นอกเหนือจากเมธอด push แล้ว ยังมีเมธอด pop ที่จะนำสมาชิกตัวสุดท้ายออกจากเวกเตอร์และส่งค่ากลับคืนมาให้ด้วย
การทำลายเวกเตอร์จะเป็นการทำลายสมาชิกในเวกเตอร์ไปด้วย (Dropping a Vector Drops Its Elements)
เช่นเดียวกับโครงสร้างข้อมูล (struct) อื่น ๆ เวกเตอร์จะถูกคืนพื้นที่หน่วยความจำเมื่อมันอยู่นอกขอบเขตการใช้งาน (out of scope) ดังที่ระบุไว้ในตัวอย่างที่ 8-10
fn main() {
{
let v = vec![1, 2, 3, 4];
// do stuff with v
} // <- v goes out of scope and is freed here
}
เมื่อเวกเตอร์ถูกทำลาย ข้อมูลทั้งหมดภายในเวกเตอร์จะถูกทำลายไปด้วย นั่นหมายความว่าค่าจำนวนเต็มต่าง ๆ ที่ถูกเก็บไว้จะถูกกวาดล้างและคืนหน่วยความจำ ระบบ borrow checker จะเป็นผู้รับประกันว่า ข้อมูลอ้างอิงใด ๆ ที่ชี้ไปยังข้อมูลของเวกเตอร์นั้นจะถูกใช้งานเฉพาะตอนที่ตัวเวกเตอร์เองยังมีสถานะใช้งานได้อยู่เท่านั้น
มาต่อกันที่คอลเลกชันประเภทถัดไปกันเลย นั่นก็คือ String!