پیادهسازی یک الگوی طراحی شیگرا
الگوی وضعیت یک الگوی طراحی شیگرا است. هسته این الگو این است که مجموعهای از وضعیتها را که یک مقدار میتواند بهطور داخلی داشته باشد، تعریف کنیم. این وضعیتها با مجموعهای از اشیای وضعیت نمایش داده میشوند و رفتار مقدار بر اساس وضعیت آن تغییر میکند. قصد داریم مثالی از یک ساختار 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 است که انعطافپذیری زیادی را فراهم میکنند. در طول کتاب بهطور مختصر به آنها اشاره کردهایم، اما هنوز بهطور کامل توانایی آنها را ندیدهایم. برویم!