Advanced Traits
เราได้พูดถึงเทรต (traits) ครั้งแรกในหัวข้อ “การนิยามพฤติกรรมร่วมด้วย Traits” ในบทที่ 10 แต่เรายังไม่ได้พูดถึงรายละเอียดระดับสูงขึ้น ตอนนี้ที่คุณรู้เกี่ยวกับ Rust มากขึ้นแล้ว เรามาเจาะลึกรายละเอียดเชิงลึกกันครับ
Defining Traits with Associated Types
ประเภทแบบเชื่อมโยง (Associated types) ทำหน้าที่เชื่อมต่อตัวแทนประเภท (type placeholder) เข้ากับเทรต เพื่อให้การนิยามเมธอดของเทรตสามารถใช้ประเภทตัวแทนเหล่านี้ในลายเซ็น (signatures) ของมันได้ ผู้ที่นำเทรตไปใช้งาน (implementor) จะเป็นผู้ระบุประเภทคอนกรีต (concrete type) ที่จะนำมาใช้แทนตัวแทนประเภทสำหรับการอิมพลีเมนต์นั้นๆ ด้วยวิธีนี้ เราสามารถนิยามเทรตที่ใช้ประเภทบางอย่างได้ โดยไม่จำเป็นต้องรู้ว่าประเภทเหล่านั้นคืออะไรแน่ชัดจนกว่าจะมีการอิมพลีเมนต์เทรต
เราได้อธิบายคุณสมบัติขั้นสูงส่วนใหญ่ในบทนี้ว่าเป็นสิ่งที่ไม่ค่อยได้ใช้บ่อยนัก สำหรับ Associated types นั้นจัดอยู่ในระดับปานกลาง: พวกมันถูกใช้งานน้อยกว่าคุณสมบัติอื่นๆ ที่อธิบายไว้ในส่วนที่เหลือของหนังสือเล่มนี้ แต่ก็ใช้งานบ่อยกว่าคุณสมบัติขั้นสูงหลายๆ อย่างที่พูดถึงในบทนี้
ตัวอย่างหนึ่งของเทรตที่มีประเภทแบบเชื่อมโยงคือ Iterator trait ที่ไลบรารีมาตรฐาน (standard library) มีให้ ประเภทแบบเชื่อมโยงมีชื่อว่า Item ซึ่งทำหน้าที่เป็นตัวแทนสำหรับประเภทของค่าที่ประเภทซึ่งอิมพลีเมนต์ Iterator trait กำลังวนซ้ำอยู่ นิยามของ Iterator trait แสดงในโค้ดตัวอย่างที่ 20-13
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
ประเภท Item คือตัวแทนประเภท และการนิยามเมธอด next แสดงให้เห็นว่าจะคืนค่าประเภท Option<Self::Item> ผู้ที่อิมพลีเมนต์ Iterator trait จะระบุประเภทคอนกรีตสำหรับ Item และเมธอด next จะคืนค่า Option ที่บรรจุค่าของประเภทคอนกรีตนั้น
ประเภทแบบเชื่อมโยงอาจดูคล้ายกับแนวคิดของเจเนอริก (generics) ในแง่ที่ว่าเจเนอริกอนุญาตให้เรานิยามฟังก์ชันโดยไม่ต้องระบุประเภทที่สามารถรับได้ เพื่อศึกษาความแตกต่างระหว่างสองแนวคิดนี้ เราจะดูการอิมพลีเมนต์ Iterator trait บนประเภทชื่อ Counter ซึ่งระบุว่าประเภท Item คือ u32:
struct Counter {
count: u32,
}
impl Counter {
fn new() -> Counter {
Counter { count: 0 }
}
}
impl Iterator for Counter {
type Item = u32;
fn next(&mut self) -> Option<Self::Item> {
// --snip--
if self.count < 5 {
self.count += 1;
Some(self.count)
} else {
None
}
}
}
ไวยากรณ์นี้ดูเทียบเคียงได้กับไวยากรณ์ของเจเนอริก แล้วทำไมเราไม่เพียงแค่นิยาม Iterator trait ด้วยเจเนอริก เหมือนที่แสดงในโค้ดตัวอย่างที่ 20-14 ล่ะ?
pub trait Iterator<T> {
fn next(&mut self) -> Option<T>;
}
ความแตกต่างคือเมื่อใช้เจเนอริก เหมือนในโค้ดตัวอย่างที่ 20-14 เราต้องระบุประเภท (type annotation) ในการอิมพลีเมนต์แต่ละครั้ง เนื่องจากเราสามารถอิมพลีเมนต์ Iterator<String> for Counter หรือประเภทอื่นใดก็ได้ เราจึงสามารถมีการอิมพลีเมนต์ Iterator สำหรับ Counter ได้หลายรายการ กล่าวอีกนัยหนึ่ง เมื่อเทรตมีพารามิเตอร์เจเนอริก มันสามารถถูกอิมพลีเมนต์ให้กับประเภทหนึ่งๆ ได้หลายครั้ง โดยเปลี่ยนประเภทคอนกรีตของพารามิเตอร์ประเภทเจเนอริกในแต่ละครั้ง เมื่อเราใช้เมธอด next บน Counter เราจะต้องระบุการคำอธิบายประเภทเพื่อระบุว่าการอิมพลีเมนต์ Iterator ใดที่เราต้องการใช้
เมื่อใช้ประเภทแบบเชื่อมโยง เราไม่จำเป็นต้องระบุประเภท เพราะเราไม่สามารถอิมพลีเมนต์เทรตให้กับประเภทหนึ่งๆ ได้หลายครั้ง ในโค้ดตัวอย่างที่ 20-13 ที่ใช้นิยามประเภทแบบเชื่อมโยง เราสามารถเลือกประเภทที่จะเป็น Item ได้เพียงครั้งเดียวเท่านั้น เนื่องจากจะมี impl Iterator for Counter ได้เพียงอันเดียว เราจึงไม่ต้องระบุว่าต้องการตัววนซ้ำของค่า u32 ทุกครั้งที่เราเรียกใช้ next บน Counter
ประเภทแบบเชื่อมโยงยังกลายเป็นส่วนหนึ่งของสัญญา (contract) ของเทรต: ผู้ที่อิมพลีเมนต์เทรตจะต้องระบุประเภทมาแทนที่ตัวแทนประเภทแบบเชื่อมโยงนั้น โดยประเภทแบบเชื่อมโยงมักจะมีชื่อที่อธิบายถึงวิธีการที่จะนำประเภทนั้นไปใช้งาน และการเขียนเอกสารกำกับประเภทแบบเชื่อมโยงไว้ในเอกสารประกอบ API ถือเป็นแนวทางปฏิบัติที่ดี
Using Default Generic Parameters and Operator Overloading
เมื่อเราใช้พารามิเตอร์ประเภทเจเนอริก เราสามารถระบุประเภทคอนกรีตเริ่มต้น (default concrete type) สำหรับประเภทเจเนอริกได้ สิ่งนี้ทำให้ขจัดความจำเป็นที่ผู้พัฒนาซึ่งนำเทรตไปใช้ต้องระบุประเภทคอนกรีตหากประเภทเริ่มต้นนั้นทำงานได้ตามต้องการ คุณจะระบุประเภทเริ่มต้นเมื่อประกาศประเภทเจเนอริกด้วยไวยากรณ์ <PlaceholderType=ConcreteType>
ตัวอย่างที่ยอดเยี่ยมของสถานการณ์ที่เทคนิคนี้มีประโยชน์คือเรื่อง การโอเวอร์โหลดตัวดำเนินการ (operator overloading) ซึ่งคุณกำหนดพฤติกรรมของตัวดำเนินการ (เช่น +) สำหรับสถานการณ์เฉพาะเองได้
Rust ไม่อนุญาตให้คุณสร้างตัวดำเนินการของคุณเองหรือโอเวอร์โหลดตัวดำเนินการใดๆ ได้ตามใจชอบ แต่คุณสามารถโอเวอร์โหลดปฏิบัติการและเทรตที่เกี่ยวข้องซึ่งรายการอยู่ใน std::ops ได้ โดยการอิมพลีเมนต์เทรตที่เชื่อมโยงกับตัวดำเนินการนั้นๆ ตัวอย่างเช่น ในโค้ดตัวอย่างที่ 20-15 เราโอเวอร์โหลดตัวดำเนินการ + เพื่อบวกอินสแตนซ์ Point สองอันเข้าด้วยกัน โดยเราทำสิ่งนี้ด้วยการอิมพลีเมนต์ Add trait บน Point struct
use std::ops::Add;
#[derive(Debug, Copy, Clone, PartialEq)]
struct Point {
x: i32,
y: i32,
}
impl Add for Point {
type Output = Point;
fn add(self, other: Point) -> Point {
Point {
x: self.x + other.x,
y: self.y + other.y,
}
}
}
fn main() {
assert_eq!(
Point { x: 1, y: 0 } + Point { x: 2, y: 3 },
Point { x: 3, y: 3 }
);
}
เมธอด add จะบวกค่า x ของอินสแตนซ์ Point สองอัน และบวกค่า y ของอินสแตนซ์ Point สองอันเพื่อสร้าง Point ใหม่ขึ้นมา Add trait มีประเภทแบบเชื่อมโยงชื่อ Output ซึ่งกำหนดประเภทที่ส่งคืนจากเมธอด add
ประเภทเจเนอริกเริ่มต้นในโค้ดนี้อยู่ภายใน Add trait นี่คือนิยามของมัน:
#![allow(unused)]
fn main() {
trait Add<Rhs=Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}
}
โค้ดนี้น่าจะดูคุ้นเคยโดยทั่วไป: เทรตที่มีหนึ่งเมธอดและประเภทแบบเชื่อมโยง ส่วนใหม่คือ Rhs=Self: ไวยากรณ์นี้เรียกว่า พารามิเตอร์ประเภทเริ่มต้น (default type parameters) พารามิเตอร์ประเภทเจเนอริก Rhs (ย่อมาจาก “right-hand side” หรือฝั่งขวา) จะกำหนดประเภทของพารามิเตอร์ rhs ในเมธอด add หากเราไม่ได้ระบุประเภทคอนกรีตสำหรับ Rhs ตอนเราอิมพลีเมนต์ Add trait ประเภทของ Rhs จะถูกตั้งเป็นค่าเริ่มต้นคือ Self ซึ่งก็คือประเภทที่เรากำลังอิมพลีเมนต์ Add ให้ในขณะนั้น
เมื่อเราอิมพลีเมนต์ Add สำหรับ Point เราใช้ค่าเริ่มต้นสำหรับ Rhs เพราะเราต้องการบวกอินสแตนซ์ Point สองอันเข้าด้วยกัน ตอนนี้เรามาดูตัวอย่างการอิมพลีเมนต์ Add trait ที่เราต้องการปรับแต่งประเภท Rhs แทนที่จะใช้ค่าเริ่มต้นกันบ้าง
เรามีสตรักต์สองอันคือ Millimeters และ Meters ซึ่งถือครองค่าในหน่วยที่ต่างกัน การหุ้มประเภทที่มีอยู่บางๆ ไว้ในอีกสตรักต์หนึ่งเรียกว่า รูปแบบ newtype (newtype pattern) ซึ่งเราจะอธิบายรายละเอียดเพิ่มเติมในหัวข้อ “การอิมพลีเมนต์ External Traits ด้วยรูปแบบ Newtype” เราต้องการบวกค่าในหน่วยมิลลิเมตรเข้ากับค่าในหน่วยเมตร และให้การอิมพลีเมนต์ Add ทำการแปลงหน่วยอย่างถูกต้อง เราสามารถอิมพลีเมนต์ Add สำหรับ Millimeters โดยให้ Meters เป็น Rhs ได้ ดังแสดงในโค้ดตัวอย่างที่ 20-16
use std::ops::Add;
struct Millimeters(u32);
struct Meters(u32);
impl Add<Meters> for Millimeters {
type Output = Millimeters;
fn add(self, other: Meters) -> Millimeters {
Millimeters(self.0 + (other.0 * 1000))
}
}
ในการบวก Millimeters และ Meters เราจะระบุ impl Add<Meters> เพื่อตั้งค่าของพารามิเตอร์ประเภท Rhs แทนที่จะใช้ค่าเริ่มต้นซึ่งก็คือ Self
คุณจะใช้พารามิเตอร์ประเภทเริ่มต้นในสองกรณีหลักๆ:
- เพื่อขยายความสามารถของประเภทโดยไม่ทำให้โค้ดเดิมเสียหาย (breaking existing code)
- เพื่อเปิดให้สามารถปรับแต่งได้ในกรณีเฉพาะเจาะจงที่ผู้ใช้ส่วนใหญ่ไม่จำเป็นต้องใช้
Add trait ในไลบรารีมาตรฐานเป็นตัวอย่างของวัตถุประสงค์ประการที่สอง: โดยปกติ คุณมักจะบวกสองประเภทที่เหมือนกันเข้าด้วยกัน แต่ Add trait ให้ความสามารถในการปรับแต่งเพิ่มเติมมากกว่านั้น การใช้พารามิเตอร์ประเภทเริ่มต้นในนิยามของ Add trait หมายความว่าคุณไม่ต้องระบุพารามิเตอร์พิเศษเกือบตลอดเวลา กล่าวอีกนัยหนึ่ง ช่วยลดโค้ดส่วนเกินในการอิมพลีเมนต์ (boilerplate) ทำให้ใช้งานเทรตได้ง่ายขึ้น
วัตถุประสงค์แรกจะคล้ายกับวัตถุประสงค์ที่สองแต่เป็นในทางกลับกัน: หากคุณต้องการเพิ่มพารามิเตอร์ประเภทให้กับเทรตที่มีอยู่ คุณสามารถกำหนดค่าเริ่มต้นให้มันได้เพื่ออนุญาตให้ขยายฟังก์ชันการทำงานของเทรตโดยไม่ทำให้โค้ดที่อิมพลีเมนต์ไว้อยู่แล้วพัง
Disambiguating Between Identically Named Methods
ไม่มีสิ่งใดใน Rust ที่ป้องกันไม่ให้เทรตมีเมธอดที่มีชื่อเดียวกับเมธอดของอีกเทรตหนึ่ง และ Rust ก็ไม่ได้ห้ามไม่ให้คุณอิมพลีเมนต์ทั้งสองเทรตลงบนประเภทเดียว นอกจากนี้ยังสามารถอิมพลีเมนต์เมธอดบนประเภทโดยตรงให้มีชื่อเดียวกับเมธอดจากเทรตได้อีกด้วย
เมื่อเรียกใช้เมธอดที่มีชื่อเหมือนกัน คุณต้องบอก Rust ว่าต้องการใช้เมธอดใด พิจารณาโค้ดในโค้ดตัวอย่างที่ 20-17 ซึ่งเรานิยามเทรตสองอันคือ Pilot และ Wizard โดยทั้งคู่มีเมธอดชื่อ fly จากนั้นเราอิมพลีเมนต์ทั้งสองเทรตบนประเภท Human ซึ่งมีเมธอดชื่อ fly อิมพลีเมนต์อยู่บนตัวมันเองอยู่แล้ว โดยเมธอด fly แต่ละอันทำหน้าที่แตกต่างกัน
trait Pilot {
fn fly(&self);
}
trait Wizard {
fn fly(&self);
}
struct Human;
impl Pilot for Human {
fn fly(&self) {
println!("This is your captain speaking.");
}
}
impl Wizard for Human {
fn fly(&self) {
println!("Up!");
}
}
impl Human {
fn fly(&self) {
println!("*waving arms furiously*");
}
}
fn main() {}
เมื่อเราเรียกใช้ fly บนอินสแตนซ์ของ Human คอมไพเลอร์จะเรียกใช้เมธอดที่อิมพลีเมนต์อยู่บนประเภทโดยตรงเป็นค่าเริ่มต้น ดังแสดงในโค้ดตัวอย่างที่ 20-18
trait Pilot {
fn fly(&self);
}
trait Wizard {
fn fly(&self);
}
struct Human;
impl Pilot for Human {
fn fly(&self) {
println!("This is your captain speaking.");
}
}
impl Wizard for Human {
fn fly(&self) {
println!("Up!");
}
}
impl Human {
fn fly(&self) {
println!("*waving arms furiously*");
}
}
fn main() {
let person = Human;
person.fly();
}
การรันโค้ดนี้จะพิมพ์ *waving arms furiously* ซึ่งแสดงว่า Rust เรียกใช้เมธอด fly ที่อิมพลีเมนต์บน Human โดยตรง
ในการเรียกใช้เมธอด fly จาก Pilot trait หรือ Wizard trait เราต้องใช้ไวยากรณ์ที่ชัดเจนยิ่งขึ้นเพื่อระบุว่าเราหมายถึงเมธอด fly อันไหน โค้ดตัวอย่างที่ 20-19 สาธิตไวยากรณ์นี้
trait Pilot {
fn fly(&self);
}
trait Wizard {
fn fly(&self);
}
struct Human;
impl Pilot for Human {
fn fly(&self) {
println!("This is your captain speaking.");
}
}
impl Wizard for Human {
fn fly(&self) {
println!("Up!");
}
}
impl Human {
fn fly(&self) {
println!("*waving arms furiously*");
}
}
fn main() {
let person = Human;
Pilot::fly(&person);
Wizard::fly(&person);
person.fly();
}
การระบุชื่อเทรตไว้ข้างหน้าชื่อเมธอดจะช่วยให้ Rust เข้าใจชัดเจนว่าเราต้องการเรียกการอิมพลีเมนต์ fly ใด นอกจากนี้เรายังสามารถเขียน Human::fly(&person) ซึ่งมีผลเทียบเท่ากับ person.fly() ที่เราใช้ในโค้ดตัวอย่างที่ 20-19 แต่การเขียนแบบนี้จะยาวกว่าเล็กน้อยหากเราไม่จำเป็นต้องแยกความกำกวม
การรันโค้ดนี้จะพิมพ์ผลลัพธ์ต่อไปนี้:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.46s
Running `target/debug/traits-example`
This is your captain speaking.
Up!
*waving arms furiously*
เนื่องจากเมธอด fly รับพารามิเตอร์ self หากเรามีสอง_ประเภท_ที่อิมพลีเมนต์หนึ่ง_เทรต_เหมือนกัน Rust จะสามารถพิจารณาได้ว่าควรใช้การอิมพลีเมนต์เทรตอันไหนโดยอิงตามประเภทของ self
อย่างไรก็ตาม ฟังก์ชันที่เชื่อมโยง (associated functions) ที่ไม่ได้เป็นเมธอดจะไม่มีพารามิเตอร์ self เมื่อมีหลายประเภทหรือหลายเทรตที่นิยามฟังก์ชันที่ไม่ใช่เมธอดด้วยชื่อฟังก์ชันเดียวกัน Rust จะไม่ทราบเสมอไปว่าคุณหมายถึงประเภทใด เว้นแต่คุณจะใช้ไวยากรณ์แบบระบุชื่อเต็มสมบูรณ์ (fully qualified syntax) ตัวอย่างเช่น ในโค้ดตัวอย่างที่ 20-20 เราสร้างเทรตสำหรับสถานรับเลี้ยงสัตว์ที่ต้องการตั้งชื่อลูกสุนัขทุกตัวว่า Spot เราสร้าง Animal trait พร้อมฟังก์ชันที่เชื่อมโยงที่ไม่ใช่เมธอดชื่อ baby_name โดย Animal trait ถูกอิมพลีเมนต์ให้กับสตรักต์ Dog ซึ่งบน Dog เองก็มีฟังก์ชันที่เชื่อมโยงที่ไม่ใช่เมธอดชื่อ baby_name โดยตรงเช่นกัน
trait Animal {
fn baby_name() -> String;
}
struct Dog;
impl Dog {
fn baby_name() -> String {
String::from("Spot")
}
}
impl Animal for Dog {
fn baby_name() -> String {
String::from("puppy")
}
}
fn main() {
println!("A baby dog is called a {}", Dog::baby_name());
}
เราอิมพลีเมนต์โค้ดสำหรับการตั้งชื่อลูกสุนัขทั้งหมดว่า Spot ในฟังก์ชันที่เชื่อมโยง baby_name ซึ่งนิยามบน Dog ประเภท Dog ยังอิมพลีเมนต์ Animal trait ซึ่งอธิบายลักษณะเด่นที่สัตว์ทุกตัวมี ลูกของสุนัขเรียกว่าลูกสุนัข (puppy) และสิ่งนั้นถูกแสดงออกในการอิมพลีเมนต์ Animal trait บน Dog ในฟังก์ชัน baby_name ที่เชื่อมโยงกับ Animal trait
ใน main เราเรียกฟังก์ชัน Dog::baby_name ซึ่งจะเรียกใช้ฟังก์ชันที่เชื่อมโยงที่นิยามไว้บน Dog โดยตรง โค้ดนี้จะพิมพ์ผลลัพธ์ดังนี้:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.54s
Running `target/debug/traits-example`
A baby dog is called a Spot
ผลลัพธ์นี้ไม่ใช่สิ่งที่เราต้องการ เราต้องการเรียกใช้ฟังก์ชัน baby_name ที่เป็นส่วนหนึ่งของ Animal trait ซึ่งเราอิมพลีเมนต์ให้กับ Dog เพื่อให้โค้ดพิมพ์ A baby dog is called a puppy เทคนิคการระบุชื่อเทรตที่เราใช้ในโค้ดตัวอย่างที่ 20-19 ไม่สามารถช่วยได้ที่นี่ หากเราเปลี่ยน main เป็นโค้ดในโค้ดตัวอย่างที่ 20-21 เราจะเจอข้อผิดพลาดในการคอมไพล์
trait Animal {
fn baby_name() -> String;
}
struct Dog;
impl Dog {
fn baby_name() -> String {
String::from("Spot")
}
}
impl Animal for Dog {
fn baby_name() -> String {
String::from("puppy")
}
}
fn main() {
println!("A baby dog is called a {}", Animal::baby_name());
}
เนื่องจาก Animal::baby_name ไม่มีพารามิเตอร์ self และอาจมีประเภทอื่นที่อิมพลีเมนต์ Animal trait อยู่ด้วย Rust จึงไม่สามารถคาดเดาได้ว่าเราต้องการการอิมพลีเมนต์ Animal::baby_name ของอันไหน เราจะได้รับข้อผิดพลาดคอมไพเลอร์ดังนี้:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0790]: cannot call associated function on trait without specifying the corresponding `impl` type
--> src/main.rs:20:43
|
2 | fn baby_name() -> String;
| ------------------------- `Animal::baby_name` defined here
...
20 | println!("A baby dog is called a {}", Animal::baby_name());
| ^^^^^^^^^^^^^^^^^^^ cannot call associated function of trait
|
help: use the fully-qualified path to the only available implementation
|
20 | println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
| +++++++ +
For more information about this error, try `rustc --explain E0790`.
error: could not compile `traits-example` (bin "traits-example") due to 1 previous error
เพื่อขจัดความกำกวมและบอก Rust ว่าเราต้องการใช้การอิมพลีเมนต์ Animal สำหรับ Dog ไม่ใช่การอิมพลีเมนต์ Animal สำหรับประเภทอื่น เราจำเป็นต้องใช้ไวยากรณ์แบบระบุชื่อเต็มสมบูรณ์ (fully qualified syntax) โค้ดตัวอย่างที่ 20-22 แสดงวิธีใช้ไวยากรณ์แบบระบุชื่อเต็มสมบูรณ์
trait Animal {
fn baby_name() -> String;
}
struct Dog;
impl Dog {
fn baby_name() -> String {
String::from("Spot")
}
}
impl Animal for Dog {
fn baby_name() -> String {
String::from("puppy")
}
}
fn main() {
println!("A baby dog is called a {}", <Dog as Animal>::baby_name());
}
เราได้ระบุการอธิบายประเภทให้แก่ Rust ไว้ภายในวงเล็บสามเหลี่ยม (angle brackets) ซึ่งบ่งบอกว่าเราต้องการเรียกเมธอด baby_name จาก Animal trait ตามที่อิมพลีเมนต์ไว้บน Dog โดยการบอกว่าเราต้องการปฏิบัติกับประเภท Dog เสมือนเป็น Animal สำหรับการเรียกฟังก์ชันนี้ บัดนี้โค้ดนี้จะพิมพ์ผลลัพธ์ตามที่เราต้องการแล้ว:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.48s
Running `target/debug/traits-example`
A baby dog is called a puppy
โดยทั่วไป ไวยากรณ์แบบระบุชื่อเต็มสมบูรณ์จะถูกนิยามไว้ดังนี้:
<Type as Trait>::function(receiver_if_method, next_arg, ...);
สำหรับฟังก์ชันที่เชื่อมโยงซึ่งไม่ได้เป็นเมธอด จะไม่มีตัวรับ (receiver): จะมีเพียงรายการพารามิเตอร์อื่นๆ คุณสามารถใช้ไวยากรณ์แบบระบุชื่อเต็มสมบูรณ์ได้ทุกที่ที่คุณเรียกใช้ฟังก์ชันหรือเมธอด อย่างไรก็ตาม คุณได้รับอนุญาตให้ละเว้นส่วนใดก็ตามของไวยากรณ์นี้ที่ Rust สามารถพิจารณาได้จากข้อมูลอื่นในโปรแกรม คุณจำเป็นต้องใช้ไวยากรณ์ที่ยาวขึ้นนี้เฉพาะในกรณีที่มีหลายการอิมพลีเมนต์ใช้ชื่อเดียวกัน และ Rust ต้องการความช่วยเหลือในการระบุว่าคุณต้องการเรียกใช้การอิมพลีเมนต์ใด
Using Supertraits
บางครั้งคุณอาจเขียนนิยามเทรตที่ขึ้นอยู่กับอีกเทรตหนึ่ง: เพื่อให้ประเภทหนึ่งๆ สามารถอิมพลีเมนต์เทรตแรกได้ คุณจำเป็นต้องกำหนดให้ประเภทนั้นอิมพลีเมนต์เทรตที่สองด้วย คุณจะทำเช่นนี้เพื่อให้นิยามเทรตของคุณสามารถใช้ประโยชน์จากไอเทมที่เชื่อมโยงอยู่ (associated items) ของเทรตที่สองได้ เทรตที่นิยามเทรตของคุณพึ่งพาอยู่นั้นจะเรียกว่า ซูเปอร์เทรต (supertrait) ของเทรตคุณ
ตัวอย่างเช่น สมมติว่าเราต้องการสร้าง OutlinePrint trait ที่มีเมธอด outline_print ซึ่งจะพิมพ์ค่าที่กำหนดโดยจัดรูปแบบให้ล้อมรอบด้วยเครื่องหมายดอกจัน นั่นคือ หากมีสตรักต์ Point ที่อิมพลีเมนต์เทรตในไลบรารีมาตรฐาน Display ซึ่งแสดงผลเป็น (x, y) เมื่อเราเรียกใช้ outline_print บนอินสแตนซ์ของ Point ที่มี 1 เป็น x และ 3 เป็น y มันควรจะพิมพ์ผลลัพธ์ดังนี้:
**********
* *
* (1, 3) *
* *
**********
ในการอิมพลีเมนต์เมธอด outline_print เราต้องการใช้ฟังก์ชันการทำงานของ Display trait ดังนั้น เราจึงต้องระบุว่า OutlinePrint trait จะทำงานเฉพาะกับประเภทที่อิมพลีเมนต์ Display ด้วย และมอบฟังก์ชันการทำงานที่ OutlinePrint ต้องการเท่านั้น เราสามารถทำได้ในนิยามเทรตโดยระบุ OutlinePrint: Display เทคนิคนี้คล้ายกับการเพิ่มข้อจำกัดเทรต (trait bound) ให้กับเทรต โค้ดตัวอย่างที่ 20-23 แสดงการอิมพลีเมนต์ OutlinePrint trait
use std::fmt;
trait OutlinePrint: fmt::Display {
fn outline_print(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {output} *");
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
fn main() {}
เนื่องจากเราระบุว่า OutlinePrint ต้องการ Display trait เราจึงสามารถใช้ฟังก์ชัน to_string ซึ่งถูกอิมพลีเมนต์ให้อัตโนมัติสำหรับทุกประเภทที่อิมพลีเมนต์ Display หากเราพยายามใช้ to_string โดยไม่ได้เพิ่มเครื่องหมายทวิภาค (:) และไม่ระบุ Display trait ต่อท้ายชื่อเทรต เราจะพบข้อผิดพลาดที่ระบุว่าไม่พบเมธอดชื่อ to_string สำหรับประเภท &Self ในขอบเขตปัจจุบัน
เรามาดูสิ่งที่จะเกิดขึ้นเมื่อเราพยายามอิมพลีเมนต์ OutlinePrint บนประเภทที่ไม่ได้อิมพลีเมนต์ Display เช่น สตรักต์ Point:
use std::fmt;
trait OutlinePrint: fmt::Display {
fn outline_print(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {output} *");
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
struct Point {
x: i32,
y: i32,
}
impl OutlinePrint for Point {}
fn main() {
let p = Point { x: 1, y: 3 };
p.outline_print();
}
เราจะได้รับข้อผิดพลาดระบุว่าต้องการ Display แต่อังไม่ได้ถูกอิมพลีเมนต์:
$ cargo run
Compiling traits-example v0.1.0 (file:///projects/traits-example)
error[E0277]: `Point` doesn't implement `std::fmt::Display`
--> src/main.rs:20:23
|
20 | impl OutlinePrint for Point {}
| ^^^^^ unsatisfied trait bound
|
help: the trait `std::fmt::Display` is not implemented for `Point`
--> src/main.rs:15:1
|
15 | struct Point {
| ^^^^^^^^^^^^
note: required by a bound in `OutlinePrint`
--> src/main.rs:3:21
|
3 | trait OutlinePrint: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `OutlinePrint`
error[E0277]: `Point` doesn't implement `std::fmt::Display`
--> src/main.rs:24:7
|
24 | p.outline_print();
| ^^^^^^^^^^^^^ unsatisfied trait bound
|
help: the trait `std::fmt::Display` is not implemented for `Point`
--> src/main.rs:15:1
|
15 | struct Point {
| ^^^^^^^^^^^^
note: required by a bound in `OutlinePrint::outline_print`
--> src/main.rs:3:21
|
3 | trait OutlinePrint: fmt::Display {
| ^^^^^^^^^^^^ required by this bound in `OutlinePrint::outline_print`
4 | fn outline_print(&self) {
| ------------- required by a bound in this associated function
For more information about this error, try `rustc --explain E0277`.
error: could not compile `traits-example` (bin "traits-example") due to 2 previous errors
เพื่อแก้ไขปัญหานี้ เราจึงอิมพลีเมนต์ Display บน Point เพื่อให้เป็นไปตามเงื่อนไขข้อจำกัดที่ OutlinePrint ต้องการ ดังนี้:
trait OutlinePrint: fmt::Display {
fn outline_print(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {output} *");
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
struct Point {
x: i32,
y: i32,
}
impl OutlinePrint for Point {}
use std::fmt;
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
fn main() {
let p = Point { x: 1, y: 3 };
p.outline_print();
}
จากนั้น การอิมพลีเมนต์ OutlinePrint trait บน Point จะคอมไพล์ผ่านได้สำเร็จ และเราสามารถเรียกใช้ outline_print บนอินสแตนซ์ของ Point เพื่อแสดงผลโดยมีกรอบดอกจันล้อมรอบได้
Implementing External Traits with the Newtype Pattern
ในหัวข้อ “การอิมพลีเมนต์ Trait บนประเภท” ในบทที่ 10 เราได้กล่าวถึงกฎกำพร้า (orphan rule) ซึ่งระบุว่าเราได้รับอนุญาตให้อิมพลีเมนต์เทรตบนประเภทหนึ่งๆ ได้ก็ต่อเมื่อเทรตหรือประเภท หรือทั้งคู่ เป็นสิ่งท้องถิ่น (local) ในเครตของเราเท่านั้น เป็นไปได้ที่จะก้าวข้ามข้อจำกัดนี้โดยใช้รูปแบบ newtype (newtype pattern) ซึ่งเกี่ยวข้องกับการสร้างประเภทใหม่ในทูเพิลสตรักต์ (tuple struct) (เราได้ครอบคลุมเรื่องทูเพิลสตรักต์ในหัวข้อ “การสร้างประเภทที่แตกต่างด้วย Tuple Structs” ในบทที่ 5) โดยทูเพิลสตรักต์จะมีหนึ่งฟิลด์และทำหน้าที่เป็นตัวหุ้มอย่างบาง (thin wrapper) รอบประเภทที่เราต้องการอิมพลีเมนต์เทรตให้ จากนั้น ประเภทตัวหุ้มดังกล่าวจะกลายเป็นสิ่งท้องถิ่นในเครตของเรา และเราสามารถอิมพลีเมนต์เทรตบนตัวหุ้มนั้นได้คำว่า Newtype เป็นคำที่มีจุดเริ่มต้นมาจากภาษาโปรแกรม Haskell การใช้รูปแบบนี้ไม่มีบทลงโทษทางประสิทธิภาพในการทำงานขณะรันไทม์ (runtime performance penalty) และประเภทตัวหุ้มจะถูกตัดออกไปตอนคอมไพล์ (elided at compile time)
เพื่อเป็นตัวอย่าง สมมติว่าเราต้องการอิมพลีเมนต์ Display บน Vec<T> ซึ่งกฎกำพร้าป้องกันไม่ให้เราทำโดยตรงเนื่องจาก Display trait และประเภท Vec<T> ถูกนิยามไว้นอกเครตของเรา เราสามารถสร้างสตรักต์ Wrapper ที่บรรจุอินสแตนซ์ของ Vec<T> เอาไว้ จากนั้นเราสามารถอิมพลีเมนต์ Display บน Wrapper และใช้ค่า Vec<T> ได้ ดังแสดงในโค้ดตัวอย่างที่ 20-24
use std::fmt;
struct Wrapper(Vec<String>);
impl fmt::Display for Wrapper {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(f, "[{}]", self.0.join(", "))
}
}
fn main() {
let w = Wrapper(vec![String::from("hello"), String::from("world")]);
println!("w = {w}");
}
การอิมพลีเมนต์ Display ใช้ self.0 เพื่อเข้าถึง Vec<T> ภายใน เนื่องจาก Wrapper เป็นทูเพิลสตรักต์ และ Vec<T> เป็นไอเทมที่ดัชนี 0 ในทูเพิล จากนั้นเราจะสามารถใช้ฟังก์ชันการทำงานของ Display trait บน Wrapper ได้
ข้อเสียของการใช้เทคนิคนี้คือ Wrapper เป็นประเภทใหม่ ดังนั้นจึงไม่มีเมธอดของค่าที่มันถือครองอยู่ เราจะต้องอิมพลีเมนต์เมธอดทั้งหมดของ Vec<T> บน Wrapper โดยตรงเพื่อให้เมธอดเหล่านั้นมอบหมายงาน (delegate) ต่อไปยัง self.0 ซึ่งจะทำให้เราสามารถปฏิบัติต่อ Wrapper ได้เหมือนกับ Vec<T> ทุกประการ หากเราต้องการให้ประเภทใหม่นี้มีทุกเมธอดที่ประเภทภายในมี การอิมพลีเมนต์ Deref trait บน Wrapper เพื่อส่งคืนประเภทภายในก็จะเป็นแนวทางแก้ไขปัญหา (เราได้พูดคุยเกี่ยวกับการอิมพลีเมนต์ Deref trait ในหัวข้อ “การปฏิบัติต่อ Smart Pointers เสมือนการอ้างอิงปกติ” ในบทที่ 15) หากเราไม่ต้องการให้ประเภท Wrapper มีเมธอดทั้งหมดของประเภทภายใน—เช่น เพื่อจำกัดพฤติกรรมของประเภท Wrapper—เราจะต้องอิมพลีเมนต์เฉพาะเมธอดที่เราต้องการด้วยตนเอง
รูปแบบ newtype นี้ยังมีประโยชน์แม้ว่าจะไม่ได้เกี่ยวข้องกับเทรตก็ตาม ตอนนี้เรามาเปลี่ยนจุดสนใจไปดูวิธีขั้นสูงบางวิธีในการโต้ตอบกับระบบประเภท (type system) ของ Rust กันครับ