การนำ Object-Oriented Design Pattern ไปใช้งาน
state pattern เป็นรูปแบบการออกแบบเชิงวัตถุ (object-oriented design pattern) หัวใจสำคัญของรูปแบบนี้คือเรานิยามชุดของสถานะ (states) ที่ค่าหนึ่งๆ สามารถมีได้ภายใน ตัวสถานะต่างๆ ถูกแทนด้วยชุดของ state objects และพฤติกรรมของค่านั้นจะเปลี่ยนแปลงไปตามสถานะของมัน เรากำลังจะทำงานผ่านตัวอย่างของ struct โพสต์บล็อก (blog post) ที่มีฟิลด์สำหรับเก็บสถานะของมัน ซึ่งจะเป็น state object จากชุดสถานะ “ร่าง” (draft), “รอตรวจทาน” (review), หรือ “เผยแพร่แล้ว” (published)
State objects เหล่านี้แชร์ฟังก์ชันการทำงานร่วมกัน: ใน Rust แน่นอนว่าเราใช้ structs และ traits แทนที่จะเป็น objects และ inheritance ตัว state object แต่ละตัวรับผิดชอบพฤติกรรมของตัวเองและกำกับการสลับเข้าสู่สถานะอื่น ค่าที่เก็บ state object ไม่จำเป็นต้องรู้พฤติกรรมที่แตกต่างกันของสถานะต่างๆ หรือรู้ว่าเมื่อใดควรสลับเปลี่ยนระหว่างสถานะ
ข้อดีของการใช้ state pattern คือ เมื่อความต้องการทางธุรกิจของโปรแกรมเปลี่ยนแปลงไป เราไม่จำเป็นต้องแก้ไขโค้ดของค่าที่เก็บสถานะหรือโค้ดที่ใช้นั้น เราเพียงแค่อัปเดตโค้ดภายใน state object ตัวใดตัวหนึ่งเพื่อเปลี่ยนแปลงกฎระเบียบของมัน หรืออาจเพิ่ม state objects เข้าไปอีก
ก่อนอื่น เราจะนำ state pattern ไปใช้งานในสไตล์เชิงวัตถุดั้งเดิมก่อน จากนั้นเราจะใช้วิธีการที่เป็นธรรมชาติมากกว่าใน Rust เรามาเจาะลึกเพื่อสร้างเวิร์กโฟลว์ของโพสต์บล็อกทีละขั้นตอนโดยใช้ state pattern กัน
ฟังก์ชันการทำงานสุดท้ายจะมีลักษณะดังนี้:
- โพสต์บล็อกเริ่มต้นเป็นร่าง (draft) ที่ว่างเปล่า
- เมื่อร่างเสร็จสิ้น มีการส่งคำขอตรวจทาน (review) โพสต์
- เมื่อโพสต์ได้รับการอนุมัติ (approved) โพสต์จะถูกเผยแพร่ (published)
- เฉพาะโพสต์บล็อกที่เผยแพร่แล้วเท่านั้นที่จะคืนค่าเนื้อหาออกมาพิมพ์ได้ เพื่อไม่ให้โพสต์ที่ยังไม่อนุมัติถูกเผยแพร่โดยบังเอิญ
การเปลี่ยนแปลงอื่นใดที่พยายามทำกับโพสต์ไม่ควรมีผลกระทบ ตัวอย่างเช่น หากเราพยายามอนุมัติโพสต์บล็อกที่เป็นร่างก่อนที่จะส่งคำขอตรวจทาน โพสต์นั้นควรยังคงเป็นร่างที่ยังไม่เผยแพร่
การทดลองใช้สไตล์เชิงวัตถุแบบดั้งเดิม
มีวิธีนับไม่ถ้วนในการจัดโครงสร้างโค้ดเพื่อแก้ปัญหาเดียวกัน แต่ละวิธีมีข้อแลกเปลี่ยนที่แตกต่างกัน การนำไปใช้งานในหัวข้อนี้เป็นสไตล์เชิงวัตถุแบบดั้งเดิม ซึ่งสามารถเขียนใน Rust ได้ แต่ไม่ได้ดึงเอาประโยชน์จากจุดแข็งบางอย่างของ Rust มาใช้ ในภายหลัง เราจะสาธิตแนวทางแก้ไขอื่นที่ยังคงใช้รูปแบบการออกแบบเชิงวัตถุ แต่จัดโครงสร้างในวิธีที่อาจดูคุ้นเคยน้อยกว่าสำหรับโปรแกรมเมอร์ที่มีประสบการณ์ด้านเชิงวัตถุ เราจะเปรียบเทียบแนวทางแก้ไขทั้งสองวิธีเพื่อสัมผัสกับข้อแลกเปลี่ยนของการออกแบบโค้ด Rust ในลักษณะที่แตกต่างจากโค้ดในภาษาอื่นๆ
โค้ดตัวอย่างที่ 18-11 แสดงเวิร์กโฟลว์นี้ในรูปแบบโค้ด: นี่คือตัวอย่างการใช้งาน API ที่เราจะนำไปใช้งานใน crate ไลบรารีชื่อ blog โค้ดนี้จะยังคอมไพล์ไม่ผ่านเนื่องจากเรายังไม่ได้นำ crate blog ไปใช้งาน
use blog::Post;
fn main() {
let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
assert_eq!("", post.content());
post.request_review();
assert_eq!("", post.content());
post.approve();
assert_eq!("I ate a salad for lunch today", post.content());
}
เราต้องการให้ผู้ใช้สามารถสร้างโพสต์บล็อกร่างใหม่ด้วย Post::new เราต้องการอนุญาตให้เพิ่มข้อความลงในโพสต์บล็อก หากเราพยายามเอาเนื้อหาของโพสต์ทันที ก่อนการอนุมัติ เราไม่ควรได้ข้อความใดๆ ออกมาเนื่องจากโพสต์ยังคงเป็นร่าง เราได้เพิ่ม assert_eq! ในโค้ดเพื่อวัตถุประสงค์ในการสาธิต ยูนิตเทสต์ที่ดีเยี่ยมสำหรับสิ่งนี้คือการยืนยันว่าโพสต์บล็อกร่างจะคืนค่าสตริงว่างจากเมธอด content แต่เราจะไม่เขียนเทสต์สำหรับตัวอย่างนี้
ถัดไป เราต้องการเปิดใช้งานการส่งคำขอตรวจทานโพสต์ และเราต้องการให้ content คืนค่าสตริงว่างในขณะที่รอการตรวจทาน เมื่อโพสต์ได้รับการอนุมัติ มันควรจะถูกเผยแพร่ ซึ่งหมายความว่าข้อความของโพสต์จะถูกคืนค่าออกมาเมื่อเรียกใช้ content
โปรดสังเกตว่าชนิดข้อมูลเดียวที่เราปฏิสัมพันธ์ด้วยจาก crate คือชนิดข้อมูล Post ชนิดข้อมูลนี้จะใช้ state pattern และจะเก็บค่าที่จะเป็นหนึ่งในสาม state objects ซึ่งแทนสถานะต่างๆ ที่โพสต์สามารถเป็นได้—draft, review, หรือ published การเปลี่ยนจากสถานะหนึ่งไปยังอีกสถานะหนึ่งจะถูกจัดการภายในชนิดข้อมูล Post สถานะต่างๆ จะเปลี่ยนแปลงเพื่อตอบสนองต่อเมธอดที่เรียกใช้โดยผู้ใช้ไลบรารีของเราบนอินสแตนซ์ของ Post โดยที่พวกเขาไม่ต้องจัดการการเปลี่ยนแปลงสถานะโดยตรง นอกจากนี้ ผู้ใช้ยังไม่สามารถทำข้อผิดพลาดเกี่ยวกับสถานะได้ เช่น การเผยแพร่โพสต์ก่อนที่จะได้รับการตรวจทาน
การนิยาม Post และการสร้างอินสแตนซ์ใหม่
เรามาเริ่มต้นการนำไลบรารีไปใช้งานกัน! เรารู้ว่าเราต้องการ struct Post สาธารณะที่เก็บเนื้อหาบางอย่าง ดังนั้นเราจะเริ่มด้วยคำนิยามของ struct และฟังก์ชันสาธารณะที่เกี่ยวเนื่อง new เพื่อสร้างอินสแตนซ์ของ Post ดังแสดงในโค้ดตัวอย่างที่ 18-12 เรายังจะสร้าง trait ส่วนตัว State ซึ่งจะนิยามพฤติกรรมที่ state objects ทั้งหมดสำหรับ Post ต้องมี
จากนั้น Post จะเก็บ trait object ของ Box<dyn State> ไว้ภายใน Option<T> ในฟิลด์ส่วนตัวชื่อ state เพื่อเก็บ state object คุณจะได้เห็นว่าเหตุใด Option<T> จึงจำเป็นในอีกสักครู่
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
}
trait State {}
struct Draft {}
impl State for Draft {}
trait State นิยามพฤติกรรมที่แชร์ร่วมกันโดยสถานะต่างๆ ของโพสต์ โดย state objects คือ Draft, PendingReview, และ Published ซึ่งทุกตัวจะนำ trait State ไปใช้งาน สำหรับตอนนี้ trait ยังไม่มีเมธอดใดๆ และเราจะเริ่มด้วยการนิยามเฉพาะสถานะ Draft เพราะนั่นคือสถานะที่เราต้องการให้โพสต์เริ่มต้น
เมื่อเราสร้าง Post ใหม่ เราตั้งค่าฟิลด์ state ของมันเป็นค่า Some ที่เก็บ Box โดย Box นี้ชี้ไปยังอินสแตนซ์ใหม่ของ struct Draft สิ่งนี้เน้นย้ำให้แน่ใจว่าเมื่อใดก็ตามที่เราสร้างอินสแตนซ์ใหม่ของ Post มันจะเริ่มต้นด้วยการเป็นร่าง เนื่องจากฟิลด์ state ของ Post เป็นส่วนตัว จึงไม่มีวิธีสร้าง Post ในสถานะอื่นได้เลย! ในฟังก์ชัน Post::new เราตั้งค่าฟิลด์ content เป็น String ใหม่ที่ว่างเปล่า
การจัดเก็บข้อความเนื้อหาของโพสต์
เราเห็นในโค้ดตัวอย่างที่ 18-11 ว่าเราต้องการเรียกเมธอดชื่อ add_text และส่ง &str ให้มัน ซึ่งจะถูกเพิ่มเข้าไปเป็นเนื้อหาข้อความของโพสต์บล็อก เรานำสิ่งนี้ไปใช้งานในรูปแบบเมธอด แทนที่จะเปิดฟิลด์ content เป็น pub เพื่อที่ในภายหลังเราจะได้สามารถนำเมธอดมาใช้วิธีควบคุมการอ่านข้อมูลของฟิลด์ content เมธอด add_text ค่อนข้างตรงไปตรงมา ดังนั้นเรามาเพิ่มการทำงานในโค้ดตัวอย่างที่ 18-13 ลงในบล็อก impl Post กัน
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
// --snip--
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
}
trait State {}
struct Draft {}
impl State for Draft {}
เมธอด add_text รับการอ้างอิงแบบเปลี่ยนค่าได้ (mutable reference) ไปยัง self เพราะเรากำลังเปลี่ยนแปลงอินสแตนซ์ของ Post ที่เราเรียกใช้ add_text จากนั้นเราเรียก push_str บน String ใน content และส่งอาร์กิวเมนต์ text เพื่อเพิ่มเข้าไปใน content ที่บันทึกไว้ พฤติกรรมนี้ไม่ได้ขึ้นอยู่กับสถานะที่โพสต์อยู่ ดังนั้นจึงไม่ได้เป็นส่วนหนึ่งของ state pattern เมธอด add_text ไม่ได้ปฏิสัมพันธ์กับฟิลด์ state เลย แต่มันเป็นส่วนหนึ่งของพฤติกรรมที่เราต้องการรองรับ
การทำให้แน่ใจว่าเนื้อหาของโพสต์ร่างว่างเปล่า
แม้ว่าหลังจากที่เราเรียก add_text และเพิ่มเนื้อหาบางอย่างลงในโพสต์ของเราแล้ว เรายังคงต้องการให้เมธอด content คืนค่าสไลซ์สตริงว่างเปล่า (empty string slice) ออกมาอยู่ดี เนื่องจากโพสต์ยังคงอยู่ในสถานะร่าง ดังแสดงโดย assert_eq! ตัวแรกในโค้ดตัวอย่างที่ 18-11 สำหรับตอนนี้ เรามานำเมธอด content ไปใช้งานด้วยสิ่งที่เรียบง่ายที่สุดที่จะตอบสนองความต้องการนี้ นั่นคือการคืนค่าสไลซ์สตริงว่างเปล่าเสมอ เราจะเปลี่ยนแปลงสิ่งนี้ในภายหลังเมื่อเรานำความสามารถในการเปลี่ยนสถานะของโพสต์ไปใช้งานเพื่อให้มันถูกเผยแพร่ได้ จนถึงตอนนี้ โพสต์สามารถอยู่ในสถานะร่างได้เท่านั้น ดังนั้นเนื้อหาของโพสต์จึงควรว่างเปล่าเสมอ โค้ดตัวอย่างที่ 18-14 แสดงการนำไปใช้งานชั่วคราว (placeholder) นี้
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
// --snip--
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn content(&self) -> &str {
""
}
}
trait State {}
struct Draft {}
impl State for Draft {}
เมื่อเพิ่มเมธอด content นี้เข้าไป ทุกอย่างในโค้ดตัวอย่างที่ 18-11 ไปจนถึง assert_eq! ตัวแรกก็ทำงานตามที่ตั้งใจไว้
การส่งคำขอตรวจทาน ซึ่งเปลี่ยนสถานะของโพสต์
ถัดไป เราต้องเพิ่มฟังก์ชันการทำงานเพื่อส่งคำขอตรวจทานโพสต์ ซึ่งควรเปลี่ยนสถานะจาก Draft เป็น PendingReview โค้ดตัวอย่างที่ 18-15 แสดงโค้ดนี้
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
// --snip--
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn content(&self) -> &str {
""
}
pub fn request_review(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.request_review())
}
}
}
trait State {
fn request_review(self: Box<Self>) -> Box<dyn State>;
}
struct Draft {}
impl State for Draft {
fn request_review(self: Box<Self>) -> Box<dyn State> {
Box::new(PendingReview {})
}
}
struct PendingReview {}
impl State for PendingReview {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
}
เราให้เมธอดสาธารณะชื่อ request_review แก่ Post ซึ่งจะรับการอ้างอิงแบบเปลี่ยนค่าได้ไปที่ self จากนั้น เราเรียกเมธอด request_review ภายในบนสถานะปัจจุบันของ Post และเมธอด request_review ตัวที่สองนี้จะใช้สถานะปัจจุบันไปและคืนค่าสถานะใหม่กลับมา
เราเพิ่มเมธอด request_review ลงใน trait State ชนิดข้อมูลทั้งหมดที่นำ trait นี้ไปใช้งานจำเป็นต้องนำเมธอด request_review ไปใช้งานด้วย โปรดสังเกตว่าแทนที่จะมี self, &self, หรือ &mut self เป็นพารามิเตอร์แรกของเมธอด เรากลับมี self: Box<Self> ไวยากรณ์นี้หมายความว่าเมธอดจะใช้งานได้ก็ต่อเมื่อถูกเรียกบน Box ที่เก็บชนิดข้อมูลนั้นไว้ ไวยากรณ์นี้รับความเป็นเจ้าของของ Box<Self> ทำให้สถานะเดิมใช้การไม่ได้ เพื่อให้ค่าสถานะของ Post แปลงสภาพเป็นสถานะใหม่ได้
ในการนำสถานะเดิมไปใช้ เมธอด request_review จำเป็นต้องรับความเป็นเจ้าของของค่าสถานะ นั่นคือเหตุผลที่ Option ในฟิลด์ state ของ Post เข้ามามีบทบาท: เราเรียกใช้เมธอด take เพื่อดึงค่า Some ออกมาจากฟิลด์ state และทิ้ง None ไว้แทนที่ เพราะ Rust ไม่ยินยอมให้เรามีฟิลด์ที่ไม่มีค่าอยู่ใน struct สิ่งนี้ช่วยให้เราเคลื่อนย้ายค่า state ออกจาก Post แทนที่จะเป็นการยืมมัน จากนั้น เราจะตั้งค่า state ของโพสต์เป็นผลลัพธ์ของการดำเนินการนี้
เราจำเป็นต้องตั้งค่า state เป็น None ชั่วคราว แทนที่จะตั้งค่ามันโดยตรงด้วยโค้ดอย่าง self.state = self.state.request_review(); เพื่อรับความเป็นเจ้าของของค่า state สิ่งนี้เน้นย้ำให้แน่ใจว่า Post ไม่สามารถใช้ค่า state เก่าหลังจากที่เราแปลงสภาพมันเป็นสถานะใหม่ไปแล้ว
เมธอด request_review บน Draft จะคืนค่าอินสแตนซ์ใหม่ที่อยู่ใน box ของ struct PendingReview ตัวใหม่ ซึ่งแทนสถานะเมื่อโพสต์กำลังรอการตรวจทาน Struct PendingReview ก็นำเมธอด request_review ไปใช้งานเช่นกันแต่ไม่ได้ทำการแปลงสภาพใดๆ แต่จะคืนค่าตัวมันเองกลับมา เพราะเมื่อเราส่งคำขอตรวจทานบนโพสต์ที่อยู่ในสถานะ PendingReview อยู่แล้ว มันควรจะยังคงอยู่ในสถานะ PendingReview ต่อไป
ตอนนี้เราเริ่มเห็นข้อดีของ state pattern แล้ว: เมธอด request_review บน Post จะเหมือนเดิมเสมอไม่ว่าค่า state ของมันจะเป็นอะไรก็ตาม แต่ละสถานะจะรับผิดชอบกฎระเบียบของตนเอง
เราจะละเมิดเมธอด content บน Post ไว้ตามเดิม ให้คืนค่าสไลซ์สตริงว่างเปล่า ตอนนี้เราสามารถมี Post ในสถานะ PendingReview เช่นเดียวกับในสถานะ Draft แต่เราต้องการพฤติกรรมเดียวกันในสถานะ PendingReview โค้ดตัวอย่างที่ 18-11 สามารถทำงานได้ถึงการเรียก assert_eq! ตัวที่สองแล้ว!
การเพิ่ม approve เพื่อเปลี่ยนพฤติกรรมของ content
เมธอด approve จะคล้ายคลึงกับเมธอด request_review: มันจะตั้งค่า state เป็นค่าที่สถานะปัจจุบันบอกว่าควรเป็นอย่างไรเมื่อสถานะนั้นได้รับการอนุมัติ ดังแสดงในโค้ดตัวอย่างที่ 18-16
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
// --snip--
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn content(&self) -> &str {
""
}
pub fn request_review(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.request_review())
}
}
pub fn approve(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.approve())
}
}
}
trait State {
fn request_review(self: Box<Self>) -> Box<dyn State>;
fn approve(self: Box<Self>) -> Box<dyn State>;
}
struct Draft {}
impl State for Draft {
// --snip--
fn request_review(self: Box<Self>) -> Box<dyn State> {
Box::new(PendingReview {})
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
struct PendingReview {}
impl State for PendingReview {
// --snip--
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
Box::new(Published {})
}
}
struct Published {}
impl State for Published {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
เราเพิ่มเมธอด approve ลงใน trait State และเพิ่ม struct ใหม่ที่นำ State ไปใช้งาน นั่นคือสถานะ Published
คล้ายกับวิธีที่ request_review บน PendingReview ทำงาน หากเราเรียกเมธอด approve บน Draft มันจะไม่มีผลใดๆ เพราะ approve จะคืนค่า self เมื่อเราเรียก approve บน PendingReview มันจะคืนค่าอินสแตนซ์ใหม่ใน box ของ struct Published ตัว struct Published นำ trait State ไปใช้งาน และทั้งเมธอด request_review และเมธอด approve จะคืนค่าตัวมันเองกลับมา เนื่องจากโพสต์ควรคงอยู่ในสถานะ Published ในกรณีเหล่านั้น
ตอนนี้เราต้องอัปเดตเมธอด content บน Post เราต้องการให้ค่าที่คืนจาก content ขึ้นอยู่กับสถานะปัจจุบันของ Post ดังนั้นเราจึงจะให้ Post มอบหมายงานไปยังเมธอด content ที่นิยามไว้บน state ของมัน ดังแสดงในโค้ดตัวอย่างที่ 18-17
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
// --snip--
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn content(&self) -> &str {
self.state.as_ref().unwrap().content(self)
}
// --snip--
pub fn request_review(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.request_review())
}
}
pub fn approve(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.approve())
}
}
}
trait State {
fn request_review(self: Box<Self>) -> Box<dyn State>;
fn approve(self: Box<Self>) -> Box<dyn State>;
}
struct Draft {}
impl State for Draft {
fn request_review(self: Box<Self>) -> Box<dyn State> {
Box::new(PendingReview {})
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
struct PendingReview {}
impl State for PendingReview {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
Box::new(Published {})
}
}
struct Published {}
impl State for Published {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
เพราะเป้าหมายคือการเก็บกฎระเบียบทั้งหมดนี้ไว้ภายใน structs ที่นำ State ไปใช้งาน เราจึงเรียกเมธอด content บนค่าใน state และส่งอินสแตนซ์ของโพสต์ (นั่นคือ self) เป็นอาร์กิวเมนต์ จากนั้น เราคืนค่าที่ได้กลับมาจากการใช้เมธอด content บนค่า state
เราเรียกเมธอด as_ref บน Option เพราะเราต้องการการอ้างอิงไปยังค่าภายใน Option แทนที่จะเป็นความเป็นเจ้าของค่านั้น เนื่องจาก state เป็น Option<Box<dyn State>> เมื่อเราเรียก as_ref จะได้ Option<&Box<dyn State>> คืนกลับมา หากเราไม่เรียก as_ref เราจะพบกับข้อผิดพลาดเพราะเราไม่สามารถเคลื่อนย้าย state ออกจากการอ้างอิง &self ของพารามิเตอร์ฟังก์ชันได้
จากนั้นเราเรียกเมธอด unwrap ซึ่งเรารู้ว่าจะไม่มีทางเกิด panic เนื่องจากเรารู้ว่าเมธอดต่างๆ บน Post รับประกันว่า state จะบรรจุค่า Some เสมอเมื่อเมธอดเหล่านั้นทำงานเสร็จ นี่เป็นหนึ่งในกรณีที่เราอภิปรายในส่วน “When You Have More Information Than the Compiler” ในบทที่ 9 เมื่อเรารู้ว่าค่า None ไม่มีทางเกิดขึ้นได้ แม้ว่าคอมไพเลอร์จะไม่สามารถเข้าใจสิ่งนั้นได้ก็ตาม
ณ จุดนี้ เมื่อเราเรียก content บน &Box<dyn State> กระบวนการ deref coercion จะส่งผลกับ & และ Box เพื่อให้เมธอด content ถูกเรียกใช้งานลงไปถึงชนิดข้อมูลที่นำ trait State ไปใช้งานในท้ายที่สุด นั่นหมายความว่าเราจำเป็นต้องเพิ่ม content ลงในการนิยาม trait State และนั่นคือจุดที่เราจะใส่ตรรกะว่าเนื้อหาใดควรถูกคืนค่าออกมาตามสถานะที่เรามี ดังแสดงในโค้ดตัวอย่างที่ 18-18
pub struct Post {
state: Option<Box<dyn State>>,
content: String,
}
impl Post {
pub fn new() -> Post {
Post {
state: Some(Box::new(Draft {})),
content: String::new(),
}
}
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn content(&self) -> &str {
self.state.as_ref().unwrap().content(self)
}
pub fn request_review(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.request_review())
}
}
pub fn approve(&mut self) {
if let Some(s) = self.state.take() {
self.state = Some(s.approve())
}
}
}
trait State {
// --snip--
fn request_review(self: Box<Self>) -> Box<dyn State>;
fn approve(self: Box<Self>) -> Box<dyn State>;
fn content<'a>(&self, post: &'a Post) -> &'a str {
""
}
}
// --snip--
struct Draft {}
impl State for Draft {
fn request_review(self: Box<Self>) -> Box<dyn State> {
Box::new(PendingReview {})
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
}
struct PendingReview {}
impl State for PendingReview {
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
Box::new(Published {})
}
}
struct Published {}
impl State for Published {
// --snip--
fn request_review(self: Box<Self>) -> Box<dyn State> {
self
}
fn approve(self: Box<Self>) -> Box<dyn State> {
self
}
fn content<'a>(&self, post: &'a Post) -> &'a str {
&post.content
}
}
เราเพิ่มการนำไปใช้งานแบบเริ่มต้นสำหรับเมธอด content ที่จะคืนค่าสไลซ์สตริงว่างเปล่า นั่นหมายความว่าเราไม่จำเป็นต้องนำ content ไปใช้งานบน structs Draft และ PendingReview ตัว struct Published จะทำการ override เมธอด content และคืนค่าที่อยู่ใน post.content แม้จะสะดวกสบาย แต่การให้เมธอด content บน State เป็นตัวกำหนดเนื้อหาของ Post ก็ทำให้เส้นแบ่งระหว่างหน้าที่ความรับผิดชอบของ State กับของ Post เริ่มเลือนรางไป
โปรดสังเกตว่าเราจำเป็นต้องระบุชื่ออายุการใช้งาน (lifetime annotations) บนเมธอดนี้ ดังที่เราอภิปรายในบทที่ 10 เรากำลังรับการอ้างอิงไปยัง post เป็นอาร์กิวเมนต์และคืนการอ้างอิงไปยังส่วนหนึ่งของ post นั้น ดังนั้นอายุการใช้งานของการอ้างอิงที่คืนกลับมาจึงสัมพันธ์กับอายุการใช้งานของอาร์กิวเมนต์ post
และเราก็ทำเสร็จแล้ว—โค้ดทั้งหมดในตัวอย่างที่ 18-11 ทำงานได้แล้วตอนนี้! เราได้นำ state pattern ไปใช้งานตามกฎของเวิร์กโฟลว์โพสต์บล็อก ตรรกะที่เกี่ยวข้องกับกฎระเบียบต่างๆ อาศัยอยู่ใน state objects แทนที่จะกระจัดกระจายอยู่ทั่ว Post
ทำไมไม่ใช้ Enum?
คุณอาจจะสงสัยว่าทำไมเราจึงไม่ใช้ enum ที่มีแวเรียนต์เป็นสถานะต่างๆ ของโพสต์ที่เป็นไปได้ นั่นเป็นแนวทางแก้ไขที่เป็นไปได้แน่นอน! ลองทำและเปรียบเทียบผลลัพธ์สุดท้ายดูว่าคุณชอบแบบไหนมากกว่า! ข้อเสียอย่างหนึ่งของการใช้ enum คือทุกแห่งที่ตรวจสอบค่าของ enum จะต้องใช้พจน์
matchหรือที่คล้ายกันเพื่อจัดการกับทุกแวเรียนต์ที่เป็นไปได้ สิ่งนี้อาจกลายเป็นการเขียนซ้ำซ้ำซากมากกว่าแนวทางแก้ไขด้วย trait object นี้
การประเมินผล State Pattern
เราได้แสดงให้เห็นว่า Rust มีความสามารถในการนำ state pattern เชิงวัตถุไปใช้งานเพื่อซ่อนแคปซูลพฤติกรรมประเภทต่างๆ ที่โพสต์ควรมีในแต่ละสถานะ เมธอดบน Post ไม่จำเป็นต้องรู้เกี่ยวกับพฤติกรรมเหล่านั้นเลย เนื่องจากวิธีที่เราจัดระเบียบโค้ด เราจึงต้องดูเพียงแห่งเดียวเพื่อรับรู้พฤติกรรมในแง่มุมต่างๆ ที่โพสต์ที่เผยแพร่แล้วสามารถทำได้: นั่นคือการนำ trait State ไปใช้งานบน struct Published
หากเราสร้างทางเลือกการนำไปใช้งานที่ไม่ได้ใช้ state pattern เราอาจต้องใช้พจน์ match ในเมธอดต่างๆ บน Post หรือแม้แต่ในโค้ด main ที่ตรวจสอบสถานะของโพสต์และเปลี่ยนพฤติกรรม ณ จุดเหล่านั้น นั่นจะหมายความว่าเราต้องดูในหลายแห่งเพื่อทำความเข้าใจผลกระทบทั้งหมดของการที่โพสต์อยู่ในสถานะเผยแพร่แล้ว
ด้วย state pattern เมธอดของ Post และตำแหน่งที่เราใช้ Post ไม่จำเป็นต้องมีพจน์ match และในการเพิ่มสถานะใหม่ เราจะต้องเพิ่ม struct ใหม่และนำเมธอดของ trait ไปใช้งานบน struct นั้นเพียงจุดเดียวเท่านั้น
การนำไปใช้งานโดยใช้ state pattern ขยายเพิ่มเติมเพื่อเพิ่มฟังก์ชันการทำงานได้อย่างง่ายดาย ในการสัมผัสความเรียบง่ายของการดูแลรักษาโค้ดที่ใช้ state pattern ให้ลองทำตามคำแนะนำเหล่านี้:
- เพิ่มเมธอด
rejectที่เปลี่ยนสถานะของโพสต์จากPendingReviewกลับเป็นDraft - กำหนดให้ต้องเรียกใช้
approveสองครั้งก่อนที่สถานะจะถูกเปลี่ยนเป็นPublishedได้ - อนุญาตให้ผู้ใช้เพิ่มเนื้อหาข้อความได้เฉพาะเมื่อโพสต์อยู่ในสถานะ
Draftเท่านั้น คำแนะนำ: ให้ state object รับผิดชอบสิ่งที่อาจเปลี่ยนแปลงเกี่ยวกับเนื้อหา แต่ไม่ต้องรับผิดชอบการแก้ไขPost
ข้อเสียอย่างหนึ่งของ state pattern คือ เนื่องจากสถานะต่างๆ เป็นผู้นำการสลับเปลี่ยนระหว่างสถานะไปใช้งาน สถานะบางตัวจึงถูกผูกมัด (coupled) เข้าด้วยกัน หากเราเพิ่มอีกสถานะหนึ่งระหว่าง PendingReview กับ Published เช่น Scheduled เราจะต้องเปลี่ยนโค้ดใน PendingReview เพื่อให้เปลี่ยนไปสู่ Scheduled แทน งานจะน้อยลงหาก PendingReview ไม่จำเป็นต้องเปลี่ยนแปลงเมื่อมีการเพิ่มสถานะใหม่ แต่นั่นจะหมายถึงการสลับไปใช้ดีไซน์แพตเทิร์นอื่น
ข้อเสียอีกประการหนึ่งคือเราได้เขียนตรรกะซ้ำซากบางส่วน ในการขจัดความซ้ำซากบางส่วน เราอาจพยายามสร้างการนำไปใช้งานแบบเริ่มต้นสำหรับเมธอด request_review และ approve บน trait State ที่จะคืนค่า self ทว่าสิ่งนี้จะไม่ทำงาน: เมื่อใช้ State เป็น trait object ตัว trait ไม่ทราบว่า self รูปธรรมจะเป็นอะไรอย่างแน่ชัด ดังนั้นชนิดข้อมูลของค่าคืนกลับจึงไม่ทราบในขณะคอมไพล์ (นี่เป็นหนึ่งในกฎ dyn compatibility ที่กล่าวถึงก่อนหน้านี้)
ส่วนซ้ำซากอื่นๆ รวมไปถึงการนำเมธอด request_review และ approve ไปใช้งานที่คล้ายกันบน Post ทั้งสองเมธอดใช้ Option::take กับฟิลด์ state ของ Post และหาก state เป็น Some พวกมันจะมอบหมายไปยังการนำเมธอดเดียวกันไปใช้งานของค่าที่ห่อหุ้มอยู่ และตั้งค่าใหม่ของฟิลด์ state เป็นผลลัพธ์ หากเรามีเมธอดจำนวนมากบน Post ที่ทำตามรูปแบบนี้ เราอาจพิจารณานิยามแมโครเพื่อขจัดความซ้ำซาก (ดูส่วน “Macros” ในบทที่ 20)
โดยการนำ state pattern ไปใช้งานตามนิยามสำหรับภาษาเชิงวัตถุอย่างเป๊ะๆ เราไม่ได้ดึงเอาประโยชน์จากจุดแข็งของ Rust มาใช้อย่างเต็มที่เท่าที่เราจะทำได้ เรามาดูการเปลี่ยนแปลงบางอย่างที่เราทำได้กับ crate blog ซึ่งสามารถทำให้สถานะและการสลับเปลี่ยนที่ไม่ถูกต้อง กลายเป็นข้อผิดพลาดในขณะคอมไพล์ได้
การเข้ารหัสสถานะและพฤติกรรมให้เป็นชนิดข้อมูล
เราจะแสดงวิธีคิดทบทวนเกี่ยวกับ state pattern เพื่อให้ได้ชุดข้อแลกเปลี่ยนที่แตกต่างออกไป แทนที่จะซ่อนแคปซูลสถานะและการสลับเปลี่ยนอย่างสมบูรณ์จนโค้ดภายนอกไม่มีความรู้เกี่ยวกับพวกมัน เราจะเข้ารหัสสถานะให้กลายเป็นชนิดข้อมูลที่แตกต่างกัน ผลก็คือ ระบบตรวจสอบชนิดข้อมูลของ Rust จะป้องกันความพยายามใช้โพสต์ร่างในตำแหน่งที่อนุญาตเฉพาะโพสต์ที่เผยแพร่แล้ว โดยการออกข้อผิดพลาดจากคอมไพเลอร์
ให้พิจารณาส่วนแรกของ main ในโค้ดตัวอย่างที่ 18-11:
use blog::Post;
fn main() {
let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
assert_eq!("", post.content());
post.request_review();
assert_eq!("", post.content());
post.approve();
assert_eq!("I ate a salad for lunch today", post.content());
}
เรายังคงเปิดใช้งานการสร้างโพสต์ใหม่ในสถานะร่างโดยใช้ Post::new และความสามารถในการเพิ่มข้อความลงในเนื้อหาของโพสต์ แต่แทนที่จะมีเมธอด content บนโพสต์ร่างที่คืนค่าสตริงว่าง เราจะทำให้โพสต์ร่างไม่มีเมธอด content เลย ด้วยวิธีนี้ หากเราพยายามเอาเนื้อหาของโพสต์ร่าง เราจะได้ข้อผิดพลาดจากคอมไพเลอร์ที่บอกว่าไม่มีเมธอดนี้อยู่ ส่งผลให้เป็นไปไม่ได้เลยที่เราจะแสดงเนื้อหาของโพสต์ร่างในโปรดักชันโดยบังเอิญ เพราะโค้ดนั้นจะไม่สามารถคอมไพล์ผ่านตั้งแต่แรก โค้ดตัวอย่างที่ 18-19 แสดงนิยามของ struct Post และ struct DraftPost รวมถึงเมธอดบนแต่ละตัว
pub struct Post {
content: String,
}
pub struct DraftPost {
content: String,
}
impl Post {
pub fn new() -> DraftPost {
DraftPost {
content: String::new(),
}
}
pub fn content(&self) -> &str {
&self.content
}
}
impl DraftPost {
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
}
ทั้ง struct Post และ DraftPost ต่างมีฟิลด์ส่วนตัว content ที่เก็บข้อความโพสต์บล็อก ตัว structs ไม่มีฟิลด์ state อีกต่อไป เพราะเราได้ย้ายการเข้ารหัสของสถานะไปไว้ที่ชนิดข้อมูลของ structs โดย struct Post จะแทนโพสต์ที่เผยแพร่แล้ว และมีเมธอด content ที่คืนค่า content
เรายังมีฟังก์ชัน Post::new อยู่ แต่แทนที่จะคืนค่าอินสแตนซ์ของ Post มันจะคืนค่าอินสแตนซ์ของ DraftPost เนื่องจาก content เป็นส่วนตัวและไม่มีฟังก์ชันใดที่คืนค่า Post จึงเป็นไปไม่ได้ที่จะสร้างอินสแตนซ์ของ Post ได้ในตอนนี้
struct DraftPost มีเมธอด add_text เราจึงสามารถเพิ่มข้อความลงใน content ได้เหมือนเดิม แต่สังเกตว่า DraftPost ไม่ได้นิยามเมธอด content ไว้! ดังนั้นตอนนี้โปรแกรมจะรับประกันว่าโพสต์ทั้งหมดเริ่มต้นจากการเป็นโพสต์ร่าง และโพสต์ร่างไม่มีเนื้อหาพร้อมให้แสดง การพยายามหลีกเลี่ยงข้อจำกัดเหล่านี้จะส่งผลให้เกิดข้อผิดพลาดจากคอมไพเลอร์
แล้วเราจะรับโพสต์ที่เผยแพร่แล้วได้อย่างไร? เราต้องการบังคับใช้กฎว่าโพสต์ร่างจะต้องได้รับการตรวจทานและอนุมัติก่อนจึงจะเผยแพร่ได้ โพสต์ในสถานะรอการตรวจทานไม่ควรแสดงเนื้อหาใดๆ เช่นกัน เรามานำข้อจำกัดเหล่านี้ไปใช้งานโดยการเพิ่มอีก struct หนึ่งคือ PendingReviewPost โดยนิยามเมธอด request_review บน DraftPost ให้คืนค่า PendingReviewPost และนิยามเมธอด approve บน PendingReviewPost ให้คืนค่า Post ดังแสดงในโค้ดตัวอย่างที่ 18-20
pub struct Post {
content: String,
}
pub struct DraftPost {
content: String,
}
impl Post {
pub fn new() -> DraftPost {
DraftPost {
content: String::new(),
}
}
pub fn content(&self) -> &str {
&self.content
}
}
impl DraftPost {
// --snip--
pub fn add_text(&mut self, text: &str) {
self.content.push_str(text);
}
pub fn request_review(self) -> PendingReviewPost {
PendingReviewPost {
content: self.content,
}
}
}
pub struct PendingReviewPost {
content: String,
}
impl PendingReviewPost {
pub fn approve(self) -> Post {
Post {
content: self.content,
}
}
}
เมธอด request_review และ approve รับความเป็นเจ้าของของ self จึงเป็นการกินอินสแตนซ์ DraftPost และ PendingReviewPost ไป และแปลงสภาพพวกมันเป็น PendingReviewPost และ Post ที่เผยแพร่แล้วตามลำดับ ด้วยวิธีนี้ เราจะไม่มีอินสแตนซ์ DraftPost หลงเหลืออยู่หลังจากที่เราเรียกใช้ request_review บนพวกมัน และอื่นๆ ตัว struct PendingReviewPost ไม่ได้นิยามเมธอด content ไว้ ดังนั้นการพยายามอ่านเนื้อหาจะส่งผลให้เกิดข้อผิดพลาดจากคอมไพเลอร์เช่นเดียวกับ DraftPost เนื่องจากวิธีเดียวที่จะได้อินสแตนซ์ Post ที่เผยแพร่แล้วซึ่งมีเมธอด content นิยามไว้ คือการเรียกใช้เมธอด approve บน PendingReviewPost และวิธีเดียวที่จะได้ PendingReviewPost คือการเรียกเมธอด request_review บน DraftPost เราจึงได้เข้ารหัสเวิร์กโฟลว์ของโพสต์บล็อกลงในระบบชนิดข้อมูลเรียบร้อยแล้ว
แต่เราจำเป็นต้องปรับเปลี่ยนเล็กน้อยใน main ด้วย เมธอด request_review และ approve คืนค่าอินสแตนซ์ใหม่แทนที่จะแก้ไข struct ที่พวกมันถูกเรียกใช้ ดังนั้นเราจึงต้องเพิ่มการกำหนดค่า shadowing ด้วย let post = เพื่อบันทึกอินสแตนซ์ที่คืนกลับมา นอกจากนี้ เราไม่สามารถมีการยืนยันเกี่ยวกับเนื้อหาของโพสต์ร่างและโพสต์ที่รอการตรวจทานว่าเป็นสตริงว่างเปล่าได้อีก และเราก็ไม่จำเป็นต้องมีพวกมันอีกต่อไป: เราไม่สามารถคอมไพล์โค้ดที่พยายามใช้เนื้อหาของโพสต์ในสถานะเหล่านั้นได้อีกแล้ว โค้ดที่อัปเดตใน main แสดงไว้ในโค้ดตัวอย่างที่ 18-21
use blog::Post;
fn main() {
let mut post = Post::new();
post.add_text("I ate a salad for lunch today");
let post = post.request_review();
let post = post.approve();
assert_eq!("I ate a salad for lunch today", post.content());
}
การเปลี่ยนแปลงที่เราต้องทำกับ main เพื่อกำหนดค่า post ใหม่ หมายความว่าการนำไปใช้งานนี้ไม่ได้ดำเนินตาม state pattern เชิงวัตถุอย่างแท้จริงอีกต่อไป: การแปลงสภาพระหว่างสถานะไม่ได้ถูกซ่อนแคปซูลอยู่ภายในการสร้าง Post ทั้งหมดอีกแล้ว อย่างไรก็ตาม สิ่งที่เราได้รับกลับมาคือสถานะที่ไม่ถูกต้องกลายเป็นเรื่องที่เป็นไปไม่ได้ เนื่องจากมาจากระบบชนิดข้อมูลและการตรวจสอบชนิดข้อมูลที่เกิดขึ้นในขณะคอมไพล์! สิ่งนี้รับประกันว่าบั๊กบางประเภท เช่น การแสดงเนื้อหาของโพสต์ที่ยังไม่ได้เผยแพร่ จะถูกค้นพบก่อนที่จะหลุดไปถึงโปรดักชัน
ลองทำโจทย์งานที่แนะนำในตอนต้นของหัวข้อนี้กับ crate blog ในสภาพหลังโค้ดตัวอย่างที่ 18-21 เพื่อดูว่าคุณคิดอย่างไรเกี่ยวกับการออกแบบของโค้ดเวอร์ชันนี้ สังเกตว่าบางงานอาจเสร็จสิ้นสมบูรณ์อยู่แล้วในดีไซน์นี้
เราได้เห็นว่าแม้ว่า Rust จะมีความสามารถในการนำ design patterns เชิงวัตถุไปใช้งาน แต่รูปแบบอื่นๆ เช่น การเข้ารหัสสถานะลงในระบบชนิดข้อมูล ก็เป็นทางเลือกที่มีอยู่ใน Rust เช่นกัน รูปแบบเหล่านี้มีข้อแลกเปลี่ยนที่แตกต่างกัน แม้ว่าคุณอาจคุ้นเคยกับรูปแบบเชิงวัตถุอย่างมาก แต่การคิดทบทวนปัญหาใหม่เพื่อใช้ประโยชน์จากคุณลักษณะของ Rust สามารถมอบประโยชน์ได้ เช่น การป้องกันบั๊กบางประการตั้งแต่ขณะคอมไพล์ รูปแบบเชิงวัตถุจะไม่ใช่แนวทางแก้ไขที่ดีที่สุดใน Rustเสมอไป เนื่องจากคุณลักษณะบางอย่าง เช่น ความเป็นเจ้าของ (ownership) ที่ภาษาเชิงวัตถุไม่มี
สรุป
ไม่ว่าคุณจะคิดว่า Rust เป็นภาษาเชิงวัตถุหรือไม่หลังจากอ่านบทนี้ ตอนนี้คุณรู้อยู่แล้วว่าคุณสามารถใช้ trait objects เพื่อรับคุณลักษณะเชิงวัตถุบางอย่างใน Rust ได้ การ dispatch แบบไดนามิก (dynamic dispatch) สามารถมอบความยืดหยุ่นให้กับโค้ดของคุณเพื่อแลกกับประสิทธิภาพขณะรันเล็กน้อย คุณสามารถใช้ความยืดหยุ่นนี้เพื่อนับเอาดีไซน์แพตเทิร์นเชิงวัตถุมาใช้อนุเคราะห์การดูแลรักษาโค้ดของคุณ Rust ยังมีคุณลักษณะอื่นๆ เช่น ความเป็นเจ้าของ ที่ภาษาเชิงวัตถุไม่มี รูปแบบเชิงวัตถุจะไม่ใช่ทางที่ดีที่สุดในการดึงเอาจุดแข็งของ Rust มาใช้เสมอไป แต่ก็เป็นตัวเลือกที่มีพร้อมให้ใช้งาน
ถัดไป เราจะดูเรื่องรูปแบบ (patterns) ซึ่งเป็นอีกหนึ่งคุณลักษณะของ Rust ที่ช่วยให้เกิดความยืดหยุ่นอย่างมาก เราได้ดูพวกมันแบบคร่าวๆ มาแล้วตลอดทั้งหนังสือเล่มนี้ แต่ยังไม่ได้เห็นความสามารถอย่างเต็มที่ของพวกมัน มาลุยกันเลย!