Type ขั้นสูง
ระบบ type ของ Rust มีฟีเจอร์บางอย่างที่เราเคยกล่าวถึงแต่ยังไม่ได้พูดถึง
เราจะเริ่มโดยพูดถึง newtype โดยทั่วไปเมื่อเราตรวจสอบว่าทำไมพวกมันมี
ประโยชน์เป็น type แล้ว เราจะไปที่ type alias ฟีเจอร์ที่คล้ายกับ newtype
แต่มี semantic แตกต่างเล็กน้อย เราจะพูดถึง type ! และ dynamically
sized type ด้วย
Type Safety และ Abstraction ด้วย Newtype Pattern
ส่วนนี้สมมุติว่าคุณอ่านส่วนก่อนหน้า “Implement External Trait ด้วย
Newtype Pattern” แล้ว newtype pattern มี
ประโยชน์สำหรับงานเกินจากที่เราพูดถึงตอนนี้ด้วย รวมการบังคับ statically
ว่าค่าไม่เคย confuse และบ่งบอกหน่วยของค่า คุณเห็นตัวอย่างของการใช้
newtype เพื่อบ่งบอกหน่วยใน Listing 20-16 — จำว่า struct Millimeters
และ Meters ห่อค่า u32 ใน newtype ถ้าเราเขียนฟังก์ชันกับ parameter
ของ type Millimeters เราจะไม่สามารถ compile โปรแกรมที่บังเอิญพยายาม
เรียกฟังก์ชันนั้นกับค่าของ type Meters หรือ u32 ธรรมดา
เราใช้ newtype pattern เพื่อ abstract รายละเอียด implementation บาง อย่างของ type ได้ด้วย — type ใหม่สามารถเปิดเผย public API ที่ต่างจาก API ของ inner type private
Newtype สามารถซ่อน implementation ภายในได้ด้วย ตัวอย่างเช่น เราให้
type People เพื่อห่อ HashMap<i32, String> ที่เก็บ ID ของคนที่
associate กับชื่อของพวกเขา โค้ดที่ใช้ People จะ interact เพียงกับ
public API ที่เราให้ เช่นเมธอดเพื่อเพิ่ม string ชื่อให้ collection
People — โค้ดนั้นไม่ต้องรู้ว่าเรา assign ID i32 ให้ชื่อภายใน
Newtype pattern คือวิธีเบาเพื่อบรรลุ encapsulation เพื่อซ่อนรายละเอียด
implementation ซึ่งเราพูดถึงในส่วน “Encapsulation ที่ซ่อนรายละเอียด
Implementation”
ในบทที่ 18
Type Synonym และ Type Alias
Rust ให้ความสามารถในการประกาศ type alias เพื่อให้ type ที่มีอยู่ชื่อ
อื่น สำหรับนี้เราใช้คีย์เวิร์ด type ตัวอย่างเช่น เราสร้าง alias
Kilometers ให้ i32 แบบนี้ได้:
fn main() {
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
}
ตอนนี้ alias Kilometers คือ synonym สำหรับ i32 ต่างจาก type
Millimeters และ Meters ที่เราสร้างใน Listing 20-16, Kilometers
ไม่ใช่ type แยกใหม่ ค่าที่มี type Kilometers จะถูก treat เหมือนค่า
ของ type i32:
fn main() {
type Kilometers = i32;
let x: i32 = 5;
let y: Kilometers = 5;
println!("x + y = {}", x + y);
}
เพราะ Kilometers และ i32 เป็น type เดียวกัน เราเพิ่มค่าของทั้งสอง
type และส่งค่า Kilometers ให้ฟังก์ชันที่รับ parameter i32 ได้
อย่างไรก็ตาม ใช้เมธอดนี้ เราไม่ได้ประโยชน์ type-checking ที่เราได้
จาก newtype pattern ที่พูดถึงก่อนหน้า ในคำพูดอื่น ถ้าเราผสมค่า
Kilometers และ i32 ที่ไหนสักแห่ง compiler จะไม่ให้ error เรา
Use case หลักสำหรับ type synonym คือลดการซ้ำซ้อน ตัวอย่างเช่น เราอาจมี type ยาวแบบนี้:
Box<dyn Fn() + Send + 'static>
เขียน type ยาวนี้ใน signature ฟังก์ชันและเป็น type annotation ทั่วโค้ด ทำให้เหนื่อยและเสี่ยง error จินตนาการมี project เต็มไปด้วยโค้ดแบบใน Listing 20-25
fn main() {
let f: Box<dyn Fn() + Send + 'static> = Box::new(|| println!("hi"));
fn takes_long_type(f: Box<dyn Fn() + Send + 'static>) {
// --snip--
}
fn returns_long_type() -> Box<dyn Fn() + Send + 'static> {
// --snip--
Box::new(|| ())
}
}
Type alias ทำให้โค้ดนี้จัดการง่ายมากขึ้นโดยลดการซ้ำซ้อน ใน Listing
20-26 เราแนะนำ alias ชื่อ Thunk สำหรับ type verbose และแทนการใช้
type ทั้งหมดด้วย alias ที่สั้นกว่า Thunk ได้
fn main() {
type Thunk = Box<dyn Fn() + Send + 'static>;
let f: Thunk = Box::new(|| println!("hi"));
fn takes_long_type(f: Thunk) {
// --snip--
}
fn returns_long_type() -> Thunk {
// --snip--
Box::new(|| ())
}
}
Thunk เพื่อลดการซ้ำซ้อนโค้ดนี้ง่ายในการอ่านและเขียนมาก! เลือกชื่อที่มีความหมายสำหรับ type alias ช่วยสื่อสารเจตนาของคุณได้ด้วย (thunk คือคำสำหรับโค้ดที่จะถูก evaluate ในเวลาภายหลัง ดังนั้นมันคือชื่อที่เหมาะสำหรับ closure ที่ถูก เก็บ)
Type alias ก็ถูกใช้ปกติกับ type Result<T, E> สำหรับการลดการซ้ำซ้อน
พิจารณาโมดูล std::io ใน standard library Operation I/O มัก return
Result<T, E> เพื่อจัดการสถานการณ์เมื่อ operation fail ในการทำงาน
Library นี้มี struct std::io::Error ที่ represent error I/O ที่
เป็นไปได้ทั้งหมด ฟังก์ชันหลายตัวใน std::io จะ return Result<T, E>
ที่ E คือ std::io::Error เช่นฟังก์ชันเหล่านี้ใน trait Write:
use std::fmt;
use std::io::Error;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize, Error>;
fn flush(&mut self) -> Result<(), Error>;
fn write_all(&mut self, buf: &[u8]) -> Result<(), Error>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<(), Error>;
}
Result<..., Error> ถูกซ้ำเยอะ ดังนั้น std::io มีการประกาศ type
alias นี้:
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
เพราะการประกาศนี้อยู่ในโมดูล std::io เราใช้ fully qualified alias
std::io::Result<T> ได้ — นั่นคือ Result<T, E> ที่ E ถูกเติมเป็น
std::io::Error Signature ฟังก์ชัน trait Write จบลงดูแบบนี้:
use std::fmt;
type Result<T> = std::result::Result<T, std::io::Error>;
pub trait Write {
fn write(&mut self, buf: &[u8]) -> Result<usize>;
fn flush(&mut self) -> Result<()>;
fn write_all(&mut self, buf: &[u8]) -> Result<()>;
fn write_fmt(&mut self, fmt: fmt::Arguments) -> Result<()>;
}
Type alias ช่วยในสองวิธี — มันทำให้โค้ดง่ายในการเขียน และ มันให้เรา
interface consistent ทั่ว std::io ทั้งหมด เพราะมันเป็น alias มัน
เป็นเพียง Result<T, E> อื่น ซึ่งหมายความว่าเราใช้เมธอดใดที่ทำงานบน
Result<T, E> กับมันได้ รวมทั้ง syntax พิเศษเช่น operator ?
Never Type ที่ไม่เคย Return
Rust มี type พิเศษชื่อ ! ที่รู้จักในศัพท์ theory type เป็น empty
type เพราะมันไม่มีค่า เราชอบเรียกมัน never type เพราะมันยืนใน
ที่ของ return type เมื่อฟังก์ชันจะไม่เคย return นี่คือตัวอย่าง:
fn bar() -> ! {
// --snip--
panic!();
}
โค้ดนี้อ่านเป็น “ฟังก์ชัน bar return never” ฟังก์ชันที่ return never
ถูกเรียก diverging function เราไม่สามารถสร้างค่าของ type ! ดังนั้น
bar ไม่เคย return ได้
แต่มีประโยชน์อะไรของ type ที่คุณไม่เคยสร้างค่าให้ได้? จำโค้ดจาก Listing 2-5 ส่วนของเกมเดาตัวเลข — เราสร้างใหม่บางส่วนที่นี่ใน Listing 20-27
use std::cmp::Ordering;
use std::io;
use rand::Rng;
fn main() {
println!("Guess the number!");
let secret_number = rand::thread_rng().gen_range(1..=100);
println!("The secret number is: {secret_number}");
loop {
println!("Please input your guess.");
let mut guess = String::new();
// --snip--
io::stdin()
.read_line(&mut guess)
.expect("Failed to read line");
let guess: u32 = match guess.trim().parse() {
Ok(num) => num,
Err(_) => continue,
};
println!("You guessed: {guess}");
// --snip--
match guess.cmp(&secret_number) {
Ordering::Less => println!("Too small!"),
Ordering::Greater => println!("Too big!"),
Ordering::Equal => {
println!("You win!");
break;
}
}
}
}
match ที่มี arm ที่จบใน continueในเวลานั้น เราข้ามรายละเอียดบางอย่างในโค้ดนี้ ในส่วน “Control Flow
Construct match”
ในบทที่ 6 เราพูดถึงว่า arm match ต้องทั้งหมด return type เดียวกัน
ดังนั้น ตัวอย่างเช่น โค้ดต่อไปนี้ไม่ทำงาน:
fn main() {
let guess = "3";
let guess = match guess.trim().parse() {
Ok(_) => 5,
Err(_) => "hello",
};
}
Type ของ guess ในโค้ดนี้จะต้องเป็น integer และ string และ Rust
ต้องการให้ guess มีเพียงหนึ่ง type แล้ว continue return อะไร?
เราได้รับอนุญาตให้ return u32 จาก arm หนึ่งและมี arm อีกอันที่จบ
ด้วย continue ใน Listing 20-27 ได้ยังไง?
อย่างที่คุณเดา continue มีค่า ! นั่นคือ เมื่อ Rust คำนวณ type ของ
guess มันดูทั้งสอง match arm อันแรกกับค่า u32 และอันหลังกับค่า !
เพราะ ! ไม่เคยมีค่าได้ Rust ตัดสินว่า type ของ guess คือ u32
วิธีเป็นทางการของการอธิบายพฤติกรรมนี้คือ expression ของ type ! ถูก
coerce เป็น type อื่นใดได้ เราได้รับอนุญาตให้จบ match arm นี้ด้วย
continue เพราะ continue ไม่ return ค่า — แทน มันย้าย control กลับ
ไปยังบนของ loop ดังนั้นในกรณี Err เราไม่เคย assign ค่าให้ guess
Never type มีประโยชน์กับ macro panic! ด้วย จำฟังก์ชัน unwrap ที่
เราเรียกบนค่า Option<T> เพื่อผลิตค่าหรือ panic กับนิยามนี้:
enum Option<T> {
Some(T),
None,
}
use crate::Option::*;
impl<T> Option<T> {
pub fn unwrap(self) -> T {
match self {
Some(val) => val,
None => panic!("called `Option::unwrap()` on a `None` value"),
}
}
}
ในโค้ดนี้ สิ่งเดียวกันเกิดขึ้นเหมือนใน match ใน Listing 20-27 — Rust
เห็นว่า val มี type T และ panic! มี type ! ดังนั้นผลของ
expression match โดยรวมคือ T โค้ดนี้ทำงานเพราะ panic! ไม่ผลิตค่า
— มันจบโปรแกรม ในกรณี None เราจะไม่ return ค่าจาก unwrap ดังนั้น
โค้ดนี้ valid
Expression สุดท้ายอันหนึ่งที่มี type ! คือ loop:
fn main() {
print!("forever ");
loop {
print!("and ever ");
}
}
ที่นี่ loop ไม่เคยจบ ดังนั้น ! คือค่าของ expression อย่างไรก็ตาม นี่
จะไม่จริงถ้าเรารวม break เพราะ loop จะสิ้นสุดเมื่อมันได้ไปยัง break
Dynamically Sized Type และ Trait Sized
Rust ต้องการรู้รายละเอียดบางอย่างเกี่ยวกับ type ของมัน เช่นพื้นที่ เท่าไหร่ที่จะ allocate สำหรับค่าของ type เฉพาะ นี่ทิ้งหนึ่งมุมของระบบ type ของมัน confusing เล็กน้อยที่แรก — แนวคิดของ dynamically sized type บางครั้งเรียก DST หรือ unsized type type เหล่านี้ให้เราเขียน โค้ดโดยใช้ค่าที่ขนาดของเราสามารถรู้เพียงที่ runtime
มาเจาะรายละเอียดของ dynamically sized type เรียก str ซึ่งเราใช้ผ่าน
หนังสือ ใช่แล้ว ไม่ใช่ &str แต่ str เองคือ DST ในหลายกรณี เช่น
เมื่อเก็บ text ที่ป้อนโดย user เราไม่สามารถรู้ว่า string ยาวเท่าไหร่
จนกระทั่ง runtime นั่นหมายความว่าเราไม่สามารถสร้างตัวแปรของ type str
และเราไม่สามารถรับ argument ของ type str พิจารณาโค้ดต่อไปนี้ ซึ่งไม่
ทำงาน:
fn main() {
let s1: str = "Hello there!";
let s2: str = "How's it going?";
}
Rust ต้องการรู้ memory เท่าไหร่ที่จะ allocate สำหรับค่าใดของ type
เฉพาะ และค่าทั้งหมดของ type ต้องใช้ memory ปริมาณเดียวกัน ถ้า Rust
อนุญาตให้เราเขียนโค้ดนี้ สองค่า str เหล่านี้จะต้องใช้พื้นที่ปริมาณ
เดียวกัน แต่พวกมันมีความยาวต่างกัน — s1 ต้องการ 12 byte ของ storage
และ s2 ต้องการ 15 นี่คือเหตุผลที่ไม่เป็นไปได้ที่จะสร้างตัวแปรที่บรรจุ
dynamically sized type
แล้วเราทำอะไร? ในกรณีนี้ คุณรู้คำตอบแล้ว — เราทำ type ของ s1 และ s2
เป็น string slice (&str) แทน str จำจากส่วน “String
Slice” ในบทที่ 4 ว่าโครงสร้างข้อมูล
slice เก็บเพียงตำแหน่งเริ่มต้นและความยาวของ slice ดังนั้น แม้ &T
คือค่าเดียวที่เก็บ memory address ของที่ที่ T ตั้งอยู่ string slice
คือ สอง ค่า — address ของ str และความยาวของมัน ดังนั้น เรารู้ขนาด
ของค่า string slice ที่ compile time ได้ — มันคือสองเท่าของความยาวของ
usize นั่นคือ เรารู้ขนาดของ string slice เสมอ ไม่ว่า string ที่มัน
อ้างถึงยาวเท่าไหร่ โดยทั่วไป นี่คือวิธีที่ dynamically sized type ถูก
ใช้ใน Rust — พวกมันมี metadata เพิ่มเล็กน้อยที่เก็บขนาดของข้อมูล
dynamic กฎทองของ dynamically sized type คือเราต้องวางค่าของ
dynamically sized type ไว้หลัง pointer ของบางชนิดเสมอ
เรารวม str กับ pointer ทุกชนิดได้ — ตัวอย่างเช่น Box<str> หรือ
Rc<str> จริง ๆ คุณเห็นนี่ก่อนแต่กับ dynamically sized type ต่างกัน —
trait ทุก trait คือ dynamically sized type ที่เราอ้างถึงได้โดยใช้
ชื่อของ trait ในส่วน “ใช้ Trait Object เพื่อ Abstract เหนือพฤติกรรมที่
แชร์”
ในบทที่ 18 เรากล่าวว่าเพื่อใช้ trait เป็น trait object เราต้องวางพวกมัน
ไว้หลัง pointer เช่น &dyn Trait หรือ Box<dyn Trait> (Rc<dyn Trait> จะทำงานด้วย)
เพื่อทำงานกับ DST, Rust ให้ trait Sized เพื่อตัดสินว่าขนาดของ type
รู้ที่ compile time หรือไม่ trait นี้ถูก implement อัตโนมัติสำหรับ
ทุกอย่างที่ขนาดรู้ที่ compile time นอกจากนี้ Rust เพิ่ม bound บน
Sized ทุก generic function โดยปริยาย นั่นคือ นิยาม generic function
แบบนี้:
fn generic<T>(t: T) {
// --snip--
}
จริง ๆ ถูก treat ราวกับเราเขียนนี้:
fn generic<T: Sized>(t: T) {
// --snip--
}
โดยค่าเริ่มต้น generic function จะทำงานเพียงบน type ที่มีขนาดที่รู้ที่ compile time อย่างไรก็ตาม คุณใช้ syntax พิเศษต่อไปนี้เพื่อ relax restriction นี้ได้:
fn generic<T: ?Sized>(t: &T) {
// --snip--
}
Trait bound บน ?Sized หมายถึง “T อาจหรืออาจไม่เป็น Sized” และ
notation นี้ override default ที่ generic type ต้องมีขนาดรู้ที่ compile
time Syntax ?Trait กับความหมายนี้ใช้ได้เพียงสำหรับ Sized ไม่ใช่
trait อื่นใด
สังเกตด้วยว่าเรา switch type ของ parameter t จาก T เป็น &T เพราะ
type อาจไม่เป็น Sized เราต้องใช้มันหลัง pointer ของบางชนิด ในกรณีนี้
เราเลือก reference
ถัดไป เราจะพูดเกี่ยวกับฟังก์ชันและ closure!