پیادهسازی یک الگوی طراحی شیگرا
الگوی وضعیت یک الگوی طراحی شیگرا است. هسته این الگو این است که مجموعهای از وضعیتها را که یک مقدار میتواند بهطور داخلی داشته باشد، تعریف کنیم. این وضعیتها با مجموعهای از اشیای وضعیت نمایش داده میشوند و رفتار مقدار بر اساس وضعیت آن تغییر میکند. قصد داریم مثالی از یک ساختار blog post (پست وبلاگ) را بررسی کنیم که یک فیلد برای نگهداشتن وضعیت دارد. این وضعیت یک شیء از مجموعه “پیشنویس” (draft)، “در حال بررسی” (review)، یا “منتشرشده” (published) خواهد بود.
اشیای وضعیت قابلیتهایی را به اشتراک میگذارند: در Rust، البته، ما از ساختارها (structs) و صفتها (traits) به جای اشیا و ارثبری استفاده میکنیم. هر شیء وضعیت مسئول رفتار خود و مدیریت زمانی است که باید به وضعیت دیگری تغییر کند. مقداری که یک شیء وضعیت را نگه میدارد، هیچ اطلاعی از رفتارهای مختلف وضعیتها یا زمان تغییر وضعیت ندارد.
مزیت استفاده از الگوی وضعیت این است که وقتی نیازهای تجاری برنامه تغییر میکنند، نیازی به تغییر کد مقداری که وضعیت را نگه میدارد یا کدی که از آن مقدار استفاده میکند، نداریم. تنها لازم است کد داخل یکی از اشیای وضعیت را برای تغییر قوانین آن یا شاید اضافه کردن اشیای وضعیت جدید بهروزرسانی کنیم.
ابتدا الگوی وضعیت را به روش سنتی شیگرایی پیادهسازی میکنیم، سپس از رویکردی که در Rust طبیعیتر است استفاده خواهیم کرد. بیایید بهصورت مرحلهبهمرحله پیادهسازی یک فرآیند کاری پست وبلاگ با استفاده از الگوی وضعیت را بررسی کنیم.
قابلیت نهایی به این شکل خواهد بود:
- یک پست وبلاگ بهصورت یک پیشنویس خالی شروع میشود.
- وقتی پیشنویس تمام شد، بررسی پست درخواست میشود.
- وقتی پست تأیید شد، منتشر میشود.
- تنها پستهای وبلاگی که منتشر شدهاند متن را برای چاپ بازمیگردانند، بنابراین پستهای تأییدنشده نمیتوانند بهطور تصادفی منتشر شوند.
هر تغییر دیگری که روی یک پست تلاش شود نباید تأثیری داشته باشد. برای مثال، اگر بخواهیم یک پست وبلاگ پیشنویس را قبل از درخواست بررسی تأیید کنیم، پست باید بهعنوان پیشنویس منتشرنشده باقی بماند.
یک تلاش سنتی شیءگرایانه
راههای بیشماری برای ساختاردهی کد بهمنظور حل یک مسئله وجود دارد که هرکدام با معایب و مزایای متفاوتی همراهاند. پیادهسازی این بخش بیشتر به سبک سنتی شیءگرایانه نزدیک است، که در Rust قابل نوشتن است، اما از برخی نقاط قوت Rust بهره نمیبرد. در ادامه، راهحل متفاوتی را نشان خواهیم داد که هنوز از الگوی طراحی شیءگرایانه استفاده میکند اما بهگونهای ساختار یافته که ممکن است برای برنامهنویسان با تجربهی شیءگرایی کمتر آشنا باشد. این دو راهحل را با هم مقایسه خواهیم کرد تا تفاوتها و معایب و مزایای طراحی کد Rust به شکلی متفاوت نسبت به زبانهای دیگر را تجربه کنیم.
لیستینگ 18-11 این روند کاری را بهصورت کد نشان میدهد: این یک نمونه استفاده از API است که در کتابخانهای به نام 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());
}
blog را نشان میدهدما میخواهیم به کاربر اجازه دهیم یک پست وبلاگ پیشنویس جدید با Post::new ایجاد کند. میخواهیم امکان اضافه کردن
متن به پست وبلاگ را فراهم کنیم. اگر فوراً بخواهیم محتوای پست را دریافت کنیم، قبل از تأیید، نباید هیچ متنی دریافت
کنیم، زیرا پست هنوز یک پیشنویس است. ما از assert_eq! در کد برای اهداف نمایشی استفاده کردهایم. یک تست واحد عالی
برای این مورد این است که تأیید کنیم یک پست وبلاگ پیشنویس یک رشته خالی از متد content بازمیگرداند، اما قصد نداریم
برای این مثال تست بنویسیم.
سپس میخواهیم امکان درخواست بررسی برای پست فراهم شود و میخواهیم content در حین انتظار برای بررسی یک رشته خالی
بازگرداند. وقتی پست تأیید شود، باید منتشر شود، به این معنی که متن پست هنگام فراخوانی content بازگردانده خواهد شد.
توجه کنید که تنها نوعی که از crate با آن تعامل داریم، نوع Post است. این نوع از الگوی حالت (state pattern) استفاده خواهد کرد و یک مقدار نگه میدارد که یکی از سه شیء حالت مختلف را نمایندگی میکند—حالتهای پیشنویس (draft)، بازبینی (review) یا منتشر شده (published). تغییر از یک حالت به حالت دیگر بهصورت داخلی و درون نوع Post مدیریت میشود. این حالتها در پاسخ به متدهایی که کاربران کتابخانه روی نمونهی Post فراخوانی میکنند تغییر میکنند، اما کاربران نیازی به مدیریت مستقیم تغییرات حالت ندارند. همچنین، کاربران نمیتوانند در مدیریت حالتها اشتباه کنند، مثلاً ارسال پستی قبل از بازبینی آن.
تعریف Post و ایجاد یک نمونهی جدید در حالت پیشنویس (Draft)
بیایید پیادهسازی کتابخانه را شروع کنیم! میدانیم که به یک ساختار Post عمومی نیاز داریم که مقداری محتوا را نگه
میدارد، بنابراین با تعریف این ساختار و یک تابع مرتبط new عمومی برای ایجاد یک نمونه از Post شروع میکنیم. این
تعاریف در لیستینگ 18-12 آمدهاند. همچنین، یک صفت خصوصی State ایجاد خواهیم کرد که رفتاری را که تمام اشیای وضعیت
برای Post باید داشته باشند تعریف میکند.
سپس، Post یک شیء صفت Box<dyn State> را درون یک Option<T> در یک فیلد خصوصی به نام state نگه خواهد داشت تا
شیء وضعیت را مدیریت کند. در ادامه خواهید دید که چرا 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 {}
Post و یک تابع new که یک نمونه جدید از Post ایجاد میکند، یک صفت State، و یک ساختار Draftصفت State رفتاری را که وضعیتهای مختلف پستها به اشتراک میگذارند تعریف میکند. اشیای وضعیت شامل Draft,
PendingReview و Published هستند و همه آنها صفت State را پیادهسازی خواهند کرد. فعلاً صفت هیچ متدی ندارد و
ما با تعریف تنها وضعیت Draft شروع میکنیم، زیرا این وضعیت است که میخواهیم پست در آن شروع شود.
وقتی یک Post جدید ایجاد میکنیم، فیلد state آن را به یک مقدار Some تنظیم میکنیم که یک Box را نگه میدارد.
این Box به یک نمونه جدید از ساختار Draft اشاره میکند. این کار تضمین میکند که هرگاه یک نمونه جدید از Post
ایجاد شود، بهعنوان یک پیشنویس شروع شود. از آنجا که فیلد state در Post خصوصی است، هیچ راهی برای ایجاد یک
Post در وضعیت دیگری وجود ندارد! در تابع Post::new، فیلد content را به یک String جدید و خالی تنظیم
میکنیم.
ذخیره متن محتوای پست
در لیستینگ 18-11 دیدیم که میخواهیم بتوانیم یک متد به نام add_text فراخوانی کنیم و یک &str به آن بدهیم که به
عنوان محتوای متنی پست وبلاگ اضافه شود. این کار را بهصورت یک متد پیادهسازی میکنیم تا فیلد content را بهجای
تعریف آن بهصورت pub کنترل کنیم و بتوانیم در آینده متدی برای کنترل چگونگی خواندن داده فیلد content
پیادهسازی کنیم. متد add_text نسبتاً ساده است، بنابراین بیایید پیادهسازی آن را به بلوک impl Post در لیستینگ
18-13 اضافه کنیم:
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 برای افزودن متن به content یک پستمتد add_text یک رفرنس قابلتغییر به self میگیرد، زیرا ما در حال تغییر نمونهی Post هستیم که روی آن این متد را فراخوانی میکنیم. سپس متد push_str را روی رشتهی درون content فراخوانی میکنیم و آرگومان text را به آن میدهیم تا به محتوای ذخیرهشده اضافه شود. این رفتار به وضعیت فعلی پست بستگی ندارد، بنابراین بخشی از الگوی حالت نیست. متد add_text اصلاً با فیلد state تعامل ندارد، اما بخشی از رفتار کلی است که میخواهیم پشتیبانی کنیم.
اطمینان از اینکه محتوای یک پست پیشنویس خالی است
حتی پس از آنکه add_text را فراخوانی کردیم و مقداری محتوا به پست اضافه نمودیم، همچنان میخواهیم متد content یک رشتهی خالی برگرداند، زیرا پست هنوز در حالت پیشنویس (draft) قرار دارد، همانطور که در خط ۷ لیستینگ 18-11 نشان داده شده است. فعلاً، متد content را به سادهترین شکل ممکن پیادهسازی میکنیم که این نیاز را برآورده کند: همیشه یک برش رشتهی خالی بازگرداند. بعداً، زمانی که توانایی تغییر حالت پست برای منتشر شدن را پیادهسازی کنیم، این متد را تغییر خواهیم داد. تا اینجا، پستها تنها میتوانند در حالت پیشنویس باشند، پس محتوای پست همیشه باید خالی باشد. لیستینگ 18-14 این پیادهسازی جایگزین را نشان میدهد.
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 در Post که همیشه یک برش رشته خالی بازمیگرداندبا افزودن این متد content، تمام موارد تا خط 7 لیستینگ 18-11 به درستی کار میکنند.
درخواست بازبینی، وضعیت پست را تغییر میدهد
در مرحلهی بعد، باید قابلیت درخواست بازبینی برای یک پست را اضافه کنیم، که باید وضعیت آن را از 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 و صفت Stateما یک متد عمومی به نام request_review به Post اضافه میکنیم که یک ارجاع متغیر به self میگیرد. سپس یک متد
داخلی request_review را روی وضعیت فعلی Post فراخوانی میکنیم، و این متد دوم وضعیت فعلی را مصرف کرده و یک وضعیت
جدید بازمیگرداند.
ما متد request_review را به صفت State اضافه میکنیم؛ تمام انواعی که این صفت را پیادهسازی میکنند اکنون باید
متد request_review را پیادهسازی کنند. توجه داشته باشید که به جای self، &self یا &mut self بهعنوان اولین
پارامتر متد، از self: Box<Self> استفاده کردهایم. این نحو به این معنی است که متد فقط زمانی معتبر است که روی
یک Box نگهدارنده نوع فراخوانی شود. این نحو مالکیت Box<Self> را میگیرد و وضعیت قدیمی را باطل میکند تا مقدار
وضعیت Post بتواند به یک وضعیت جدید تبدیل شود.
برای مصرف وضعیت قدیمی، متد request_review باید مالکیت مقدار وضعیت (state) را بگیرد. اینجا است که استفاده از Option در فیلد state ساختار Post اهمیت پیدا میکند: ما متد take را فراخوانی میکنیم تا مقدار Some را از فیلد state بیرون بکشیم و در عوض آن None قرار دهیم، زیرا Rust اجازه نمیدهد فیلدهای بدون مقدار (unpopulated) در ساختارها وجود داشته باشد. این کار به ما اجازه میدهد مقدار state را بهجای قرضگرفتن، به بیرون منتقل کنیم (move). سپس مقدار state پست را به نتیجهی این عملیات اختصاص میدهیم.
باید بهطور موقت state را به None تنظیم کنیم، نه اینکه مستقیماً آن را با کدی مانند
self.state = self.state.request_review(); تنظیم کنیم، تا مالکیت مقدار state را بدست آوریم. این کار اطمینان
میدهد که Post نمیتواند از مقدار قدیمی state پس از تبدیل آن به یک وضعیت جدید استفاده کند.
The request_review method on Draft returns a new, boxed instance of a new
PendingReview struct, which represents the state when a post is waiting for a
review. The PendingReview struct also implements the request_review method
but doesn’t do any transformations. Rather, it returns itself because when we
request a review on a post already in the PendingReview state, it should stay
in the PendingReview state.
اکنون میتوانیم مزایای الگوی وضعیت را مشاهده کنیم: متد request_review در Post بدون توجه به مقدار state آن
یکسان است. هر وضعیت مسئول قوانین خاص خود است.
ما متد content در Post را به همان صورت باقی میگذاریم که یک برش رشته خالی بازمیگرداند. اکنون میتوانیم
یک Post در وضعیت PendingReview و همچنین در وضعیت Draft داشته باشیم، اما میخواهیم همان رفتار در وضعیت
PendingReview نیز باشد. لیستینگ 18-11 اکنون تا خط 10 کار میکند!
افزودن متد approve برای تغییر رفتار content
متد approve شبیه به متد request_review خواهد بود: این متد فیلد state را به مقداری تغییر میدهد که وضعیت فعلی (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 در Post و صفت Stateما متد approve را به صفت State اضافه میکنیم و یک ساختار جدید که صفت State را پیادهسازی میکند، یعنی وضعیت
Published، اضافه میکنیم.
مشابه روشی که متد request_review در حالت PendingReview کار میکند، اگر متد approve را روی حالت Draft فراخوانی کنیم، هیچ تأثیری نخواهد داشت زیرا approve در این حالت خودِ self را باز میگرداند. زمانی که متد approve را روی حالت PendingReview فراخوانی کنیم، یک نمونهی جدید از ساختار Published را بهصورت جعبهشده (boxed) باز میگرداند. ساختار Published نیز trait مربوط به State را پیادهسازی میکند، و برای هر دو متد request_review و approve، خودِ self را برمیگرداند، زیرا در این موارد پست باید در وضعیت Published باقی بماند.
اکنون باید متد content را در ساختار Post بهروز کنیم. ما میخواهیم مقداری که از content بازگردانده میشود، به وضعیت فعلیِ (state) پست بستگی داشته باشد. بنابراین از متد 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
}
}
content در Post برای ارجاع به متد content در Stateچون هدف این است که تمام این قوانین در داخل ساختارهایی که صفت 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 دارد وقتی این متدها کارشان را تمام میکنند. این یکی از مواردی است
که در بخش “مواردی که شما اطلاعات بیشتری نسبت به کامپایلر دارید” در فصل 9
در مورد آن صحبت کردیم، زمانی که میدانیم یک مقدار None هرگز ممکن نیست، حتی اگر کامپایلر نتواند این موضوع را
درک کند.
در این مرحله، وقتی content را روی &Box<dyn State> فراخوانی میکنیم، تبدیل خودکار به نوع ارجاع (deref coercion)
روی & و Box اعمال میشود تا متد content در نهایت روی نوعی که صفت State را پیادهسازی میکند، فراخوانی شود.
این بدان معناست که باید content را به تعریف صفت State اضافه کنیم، و اینجا جایی است که منطق مربوط به بازگرداندن
محتوا بر اساس وضعیت فعلی قرار خواهد گرفت، همانطور که در لیستینگ 18-18 نشان داده شده است:
چون هدف این است که تمام این قوانین را درون ساختارهایی که 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 باشد. این مورد یکی از همان حالتهایی است که در بخش «حالتی که شما اطلاعات بیشتری نسبت به کامپایلر دارید» در فصل ۹ اشاره کردیم، یعنی زمانی که میدانیم مقدار 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 به trait مربوط به Stateما یک پیادهسازی پیشفرض (default implementation) برای متد content اضافه میکنیم که یک برش رشتهی خالی ("") برمیگرداند. این یعنی دیگر نیازی نداریم متد content را برای ساختارهای Draft و PendingReview پیادهسازی کنیم. ساختار Published متد content را override میکند و مقداری که در post.content وجود دارد را برمیگرداند. گرچه این روش راحت است، اما باعث میشود مرز بین مسئولیتهای State و مسئولیتهای Post کمی مبهم شود.
توجه داشته باشید که برای این متد نیاز به حاشیهنویسی طول عمر داریم، همانطور که در فصل 10 توضیح داده شد. چون یک
ارجاع به یک post را بهعنوان آرگومان میگیریم و یک ارجاع به بخشی از آن post را بازمیگردانیم، طول عمر ارجاع
بازگشتی به طول عمر آرگومان post مرتبط است.
و تمام—اکنون تمام لیستینگ 18-11 کار میکند! ما الگوی وضعیت را با قوانین مربوط به فرآیند کاری پست وبلاگ پیادهسازی
کردهایم. منطق مربوط به قوانین در اشیای وضعیت قرار دارد، نه اینکه در سراسر Post پراکنده باشد.
چرا از enum استفاده نکردیم؟
شاید برایتان این سؤال پیش آمده باشد که چرا از یک enum با حالتهای ممکن مختلف پست بهعنوان واریانتها استفاده نکردیم.
قطعاً این راهحل نیز قابل استفاده است؛ آن را امتحان کنید و نتایج نهایی را مقایسه کنید تا ببینید کدام روش را ترجیح میدهید! یکی از معایب استفاده از enum این است که در هر جایی که نیاز دارید مقدار آن enum را بررسی کنید، مجبورید از یک عبارت match (یا چیزی مشابه آن) استفاده کنید تا تمام حالتهای ممکن را مدیریت کنید. این موضوع میتواند در مقایسه با راهحل trait object که اینجا استفاده کردیم، منجر به تکرار کد بیشتری شود.
معایب و مزایای الگوی حالت (State Pattern)
ما نشان دادهایم که Rust قادر است الگوی وضعیت شیگرا را برای کپسوله کردن رفتارهای مختلف یک پست در هر حالت
پیادهسازی کند. متدهای Post هیچ اطلاعی از رفتارهای مختلف ندارند. با روشی که کد را سازماندهی کردهایم، تنها
باید در یک مکان بهدنبال راههای مختلف رفتار یک پست منتشرشده بگردیم: پیادهسازی صفت State روی ساختار
Published.
اگر میخواستیم یک پیادهسازی جایگزین ایجاد کنیم که از الگوی حالت (state pattern) استفاده نکند، احتمالاً مجبور بودیم در متدهای ساختار Post و یا حتی در کد تابع main، از عبارات match استفاده کنیم تا وضعیت فعلی پست را بررسی کرده و رفتار مناسب را در آنجا انتخاب کنیم. در این حالت، برای درک تمام اثرات ناشی از قرار گرفتن پست در وضعیت منتشرشده (published)، باید چندین نقطهی مختلف از کد را بررسی میکردیم.
اما با استفاده از الگوی حالت، متدهای ساختار Post و مکانهایی که از Post استفاده میکنیم، نیازی به عبارات match ندارند. همچنین، برای افزودن یک حالت جدید تنها کافی است یک ساختار جدید ایجاد کرده و متدهای trait را فقط برای همان ساختار جدید و در یک محل واحد پیادهسازی کنیم.
پیادهسازی با استفاده از الگوی وضعیت بهراحتی قابلیت گسترش برای اضافه کردن عملکردهای بیشتر را دارد. برای دیدن سادگی نگهداری کدی که از الگوی وضعیت استفاده میکند، چند پیشنهاد زیر را امتحان کنید:
- یک متد
rejectاضافه کنید که وضعیت پست را ازPendingReviewبهDraftتغییر دهد. - دو فراخوانی به
approveنیاز داشته باشید تا وضعیت بهPublishedتغییر کند. - اجازه دهید کاربران فقط زمانی که یک پست در حالت
Draftاست متن محتوا اضافه کنند. نکته: بگذارید شیء وضعیت مسئول تغییراتی باشد که ممکن است در محتوا ایجاد شود، اما مسئول اصلاح مستقیمPostنباشد.
یکی از معایب الگوی وضعیت این است که به دلیل اینکه وضعیتها انتقال بین حالتها را پیادهسازی میکنند، برخی از
وضعیتها به یکدیگر وابسته هستند. اگر یک حالت دیگر بین PendingReview و Published اضافه کنیم، مانند
Scheduled، باید کد در PendingReview را تغییر دهیم تا به Scheduled منتقل شود. اگر نیازی نبود که
PendingReview با اضافه شدن یک حالت جدید تغییر کند، کار کمتری میداشتیم، اما این به معنای تغییر به یک الگوی
طراحی دیگر خواهد بود.
نکتهی منفی دیگر این است که ما در اینجا منطقهایی را تکرار کردهایم. برای حذف بخشی از این تکرارها، ممکن است تلاش کنیم پیادهسازی پیشفرضی برای متدهای request_review و approve در trait مربوط به State ایجاد کنیم که مقدار self را بازگردانند. اما این رویکرد جواب نمیدهد: وقتی از State به عنوان یک trait object استفاده میکنیم، آن trait دقیقاً نمیداند که نوع مشخص self چیست، بنابراین نوع بازگشتی در زمان کامپایل قابل تشخیص نیست. (این یکی از همان قواعد مربوط به سازگاری با dyn است که پیشتر به آن اشاره شد.)
سایر موارد تکرار شامل پیادهسازیهای مشابه متدهای request_review و approve در Post است. هر دو متد از Option::take با فیلد state از ساختار Post استفاده میکنند و اگر مقدار state برابر با Some باشد، عملیات به پیادهسازی همان متد در مقدار درون آن منتقل میشود و نتیجهی آن را به فیلد state اختصاص میدهد. اگر متدهای زیادی در Post داشته باشیم که از این الگو تبعیت میکنند، ممکن است بخواهیم برای حذف تکرار، یک ماکرو تعریف کنیم (به بخش «ماکروها» در فصل ۲۰ مراجعه کنید).
با پیادهسازی الگوی حالت دقیقاً همانطور که برای زبانهای شیءگرای سنتی تعریف شده است، ما بهطور کامل از نقاط قوت زبان Rust استفاده نمیکنیم. بیایید تغییراتی را بررسی کنیم که میتوانیم در crate مربوط به blog ایجاد کنیم تا حالتها و انتقالهای نادرست را به خطاهای زمان کامپایل تبدیل کنیم.
کدگذاری حالتها و رفتارها به عنوان نوعها (Encoding States and Behavior as Types)
به شما نشان خواهیم داد که چگونه الگوی وضعیت را دوباره طراحی کنید تا مجموعهای متفاوت از مزایا و معایب به دست آورید. بهجای اینکه وضعیتها و انتقالات را کاملاً کپسوله کنیم تا کد خارجی از آنها اطلاعی نداشته باشد، وضعیتها را به انواع مختلف کدگذاری میکنیم. در نتیجه، سیستم بررسی نوع 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());
}
ما همچنان اجازه میدهیم که پستهای جدید در حالت پیشنویس (draft) توسط متد Post::new ایجاد شوند و امکان اضافه کردن متن به محتوای پست را نیز خواهیم داشت. اما به جای اینکه یک متد content روی پست پیشنویس تعریف کنیم که یک رشتهی خالی برمیگرداند، کاری میکنیم که پستهای پیشنویس اصلاً متد content نداشته باشند. به این ترتیب، اگر تلاش کنیم محتوای یک پست پیشنویس را بخوانیم، کامپایلر به ما خطا میدهد و اعلام میکند که چنین متدی وجود ندارد. در نتیجه، غیرممکن خواهد شد که بهصورت تصادفی محتوای یک پست پیشنویس را در محیط نهایی (production) نمایش دهیم، زیرا اساساً چنین کدی کامپایل نمیشود.
لیستینگ 18-19 تعریف ساختارهای Post و 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);
}
}
Post با یک متد content و یک DraftPost بدون متد contentهر دو ساختار Post و DraftPost دارای یک فیلد خصوصی به نام content هستند که متن پست وبلاگ را ذخیره میکند. این
ساختارها دیگر فیلد state ندارند زیرا کدگذاری وضعیت را به انواع ساختارها منتقل کردهایم. ساختار Post نماینده
یک پست منتشرشده است و دارای متد content است که مقدار content را بازمیگرداند.
ما همچنان یک تابع Post::new داریم، اما بهجای بازگرداندن نمونهای از Post، یک نمونه از DraftPost بازمیگرداند.
از آنجا که content خصوصی است و هیچ تابعی وجود ندارد که Post را بازگرداند، در حال حاضر امکان ایجاد نمونهای از
Post وجود ندارد.
ساختار DraftPost یک متد add_text دارد، بنابراین میتوانیم همانند قبل متن را به content اضافه کنیم، اما
توجه کنید که DraftPost متد content تعریفشده ندارد! بنابراین اکنون برنامه تضمین میکند که تمام پستها بهصورت
پستهای پیشنویس شروع میشوند و پستهای پیشنویس محتوای خود را برای نمایش در دسترس ندارند. هر تلاشی برای دور زدن
این محدودیتها منجر به خطای کامپایلر خواهد شد.
حال چگونه یک پست منتشرشده خواهیم داشت؟ میخواهیم این قانون را اجباری کنیم که یک پستِ پیشنویس حتماً باید پیش از انتشار، بازبینی و تأیید شود. همچنین پستی که در وضعیت انتظار بازبینی (PendingReview) است، همچنان نباید هیچ محتوایی را نمایش دهد. بیایید این محدودیتها را با اضافه کردن یک ساختار دیگر به نام 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,
}
}
}
PendingReviewPost که با فراخوانی request_review روی DraftPost ایجاد میشود و یک متد approve که یک PendingReviewPost را به یک Post منتشرشده تبدیل میکندمتدهای request_review و approve مالکیت self را میگیرند، بنابراین نمونههای DraftPost و PendingReviewPost
را مصرف کرده و آنها را بهترتیب به یک PendingReviewPost و یک Post منتشرشده تبدیل میکنند. به این ترتیب،
پس از فراخوانی request_review روی یک DraftPost و به همین ترتیب، هیچ نمونهای از DraftPost باقی نمیماند.
ساختار PendingReviewPost متد content تعریفشدهای ندارد، بنابراین تلاش برای خواندن محتوای آن منجر به خطای
کامپایلر میشود، همانطور که در مورد DraftPost اتفاق میافتد. چون تنها راه برای گرفتن یک نمونه از Post منتشرشده
که متد content تعریفشدهای دارد، فراخوانی متد approve روی یک PendingReviewPost است، و تنها راه برای گرفتن
یک PendingReviewPost فراخوانی متد request_review روی یک DraftPost است، ما اکنون فرآیند کاری پست وبلاگ را به
سیستم نوع کدگذاری کردهایم.
اما همچنین باید تغییرات کوچکی در main ایجاد کنیم. متدهای request_review و approve نمونههای جدیدی بازمیگردانند
بهجای اینکه ساختاری که روی آن فراخوانی شدهاند را تغییر دهند، بنابراین باید تخصیصهای مجدد با 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 برای استفاده از پیادهسازی جدید فرآیند کاری پست وبلاگتغییراتی که باید در main برای تخصیص مجدد post انجام میدادیم، به این معناست که این پیادهسازی دیگر کاملاً از
الگوی وضعیت شیگرا پیروی نمیکند: انتقالات بین حالتها دیگر بهطور کامل در پیادهسازی Post کپسوله نشدهاند.
اما، مزیت ما این است که اکنون وضعیتهای نامعتبر به دلیل سیستم نوع و بررسی نوعی که در زمان کامپایل انجام میشود،
غیرممکن هستند! این تضمین میکند که برخی از باگها، مانند نمایش محتوای یک پست منتشرنشده، قبل از رسیدن به تولید
کشف شوند.
تکالیف پیشنهادی در ابتدای این بخش را روی crate blog همانطور که پس از لیستینگ 18-21 است امتحان کنید تا ببینید
درباره طراحی این نسخه از کد چه نظری دارید. توجه داشته باشید که برخی از تکالیف ممکن است در این طراحی از پیش
انجام شده باشند.
دیدیم که حتی با وجود اینکه Rust قادر به پیادهسازی الگوهای طراحی شیگرا است، الگوهای دیگر، مانند کدگذاری حالت در سیستم نوع، نیز در Rust در دسترس هستند. این الگوها مزایا و معایب متفاوتی دارند. اگرچه ممکن است با الگوهای شیگرا بسیار آشنا باشید، بازاندیشی مسئله برای بهرهبردن از ویژگیهای Rust میتواند مزایایی مانند جلوگیری از برخی باگها در زمان کامپایل را فراهم کند. الگوهای شیگرا همیشه بهترین راهحل در Rust نخواهند بود، به دلیل ویژگیهایی مانند مالکیت که زبانهای شیگرا ندارند.
خلاصه
فارغ از اینکه پس از خواندن این فصل فکر میکنید Rust یک زبان شیگرا است یا نه، اکنون میدانید که میتوانید از اشیای صفت برای دریافت برخی ویژگیهای شیگرایی در Rust استفاده کنید. تخصیص پویا (Dynamic Dispatch) میتواند انعطافپذیری به کد شما بدهد، اما در ازای آن کمی از عملکرد زمان اجرا را قربانی میکند. میتوانید از این انعطافپذیری برای پیادهسازی الگوهای شیگرا که میتوانند به نگهداری کد شما کمک کنند، استفاده کنید. Rust همچنین دارای ویژگیهای دیگری مانند مالکیت است که زبانهای شیگرا ندارند. یک الگوی شیگرا همیشه بهترین راه برای بهرهبردن از نقاط قوت Rust نخواهد بود، اما بهعنوان یک گزینه در دسترس است.
در ادامه، به بررسی الگوها خواهیم پرداخت که یکی دیگر از ویژگیهای Rust است که انعطافپذیری زیادی را فراهم میکنند. در طول کتاب بهطور مختصر به آنها اشاره کردهایم، اما هنوز بهطور کامل توانایی آنها را ندیدهایم. برویم!