2020年6月15日

rust17_rust 的物件導向特性

以下討論 rust 如何提供物件導向的特性,並討論這樣做的優缺點

物件導向程式語言的特性

物件、封裝、繼承

物件包含資料及行為

rust 的 struct, enum 是儲存資料,而 impl 區塊提供 methods,雖然帶有 methods 的 struct, enum 不被稱為物件,但跟物件有相同的定義

封裝隱藏實作細節

唯一能跟物件溝通的方法,是透過 public API,封裝可讓我們在不修改 API 的狀況下,修改或重構物件的內部實作。

chap7 提到,可使用 pub 決定 module, 類別, function, method 是公有的,預設則是私有的。

例如 AveragedCollection 定義為 pub,其他 code 可使用這個 struct,但內部的 list, average 還是私有的

pub struct AveragedCollection {
    list: Vec<i32>,
    average: f64,
}

透過 impl 區塊,實作 add, remove, average

impl AveragedCollection {
    pub fn add(&mut self, value: i32) {
        self.list.push(value);
        self.update_average();
    }

    pub fn remove(&mut self) -> Option<i32> {
        let result = self.list.pop();
        match result {
            Some(value) => {
                self.update_average();
                Some(value)
            },
            None => None,
        }
    }

    pub fn average(&self) -> f64 {
        self.average
    }

    fn update_average(&mut self) {
        let total: i32 = self.list.iter().sum();
        self.average = total as f64 / self.list.len() as f64;
    }
}

公有方法 addremoveaverage 是修改 AveragedCollection 的唯一方式。當使用 add方法把一個元素加入到 list 或者使用 remove 方法來刪除時,這些方法的實現同時會呼叫私有的 update_average 方法來更新 average

listaverage 是私有的,所以沒有其他方式來使得外部的代碼直接向 list 增加或者刪除元素,否則 list 改變時可能會導致 average 不同步。average 方法回傳 average 的值,這使得外部的代碼只能讀取 average 而不能修改它。

因為我們已經封裝了 AveragedCollection 的細節,將來可以輕鬆改變類似資料結構這些方面的內容。例如,可以使用 HashSet 代替 Vec 作為 list 的類別。只要 addremoveaverage 公有函數的簽名保持不變,使用 AveragedCollection 的code就無需改變。相反如果使得 list 為公有,就未必都會如此了: HashSetVec 使用不同的方法增加或移除項,所以如果要想直接修改 list 的話,外部的code不得不做出修改。

如果封裝是一個語言被認為是面向OO語言所必要的方面的話,那麼 Rust 滿足這個要求。在code中不同的部分使用 pub 可以封裝其實作細節。

以 Type System 進行繼承,並提供共用程式

繼承Inheritance)是一個很多程式語言都提供的機制,一個物件可以定義為繼承另一個物件的定義,這使其可以獲得父物件的資料和行為,而無需重新定義。

如果一個語言必須有繼承才能被稱為面向對象語言的話,那麼 Rust 就不是物件導向。

rust 無法定義一個 struct 繼承父 struct 的成員和方法。然而,Rust 也提供了其他的解決方案。

選擇繼承有兩個主要的原因。第一個是為了重用代碼:一旦為一個類別實作了特定 methods,繼承可以對一個不同的類別重用這個實作。

Rust 可以使用默認 trait 方法實作來進行共享,這類似於父類別有一個方法的實現,而通過繼承子類別也擁有這個方法的實作。當實作 trait 時也可以選擇覆蓋的預設實作,這類似於子類別覆蓋從父類別繼承的方法。

第二個使用繼承的原因與類別系統有關:子類別可以用於父類別被使用的地方。這也被稱為 多態polymorphism),這意味著如果多種對象共享特定的屬性,則可以相互替代使用。

很多人將多態(Polymorphism)視為繼承的同義詞。不過它是一個可以用於多種類別的code的更一般化的概念。對於繼承來說,這些類別通常是子類別。 Rust 利用泛型來支援多個不同類別的抽象,並通過 trait bounds 加強對這些類別所必須提供的內容的限制。這有時被稱為 bounded parametric polymorphism

近來繼承在很多語言中失寵了,因為其時常帶有共享多於所需的代碼的風險。子類別不應總是共享其父類別的所有特徵,但是繼承卻始終如此。如此會使程序設計更為不靈活,並引入無意義的類別方法,或由於方法實際並不適用於子類別而造成錯誤的可能性。某些語言還只允許子類別繼承一個父類別,進一步限制了程序設計的靈活性。

因為這些原因,Rust 選擇使用 trait 替代繼承。讓我們看一下 Rust 中的 trait 是如何實現多態的。

利用 trait objects 實作不同類別的值

chap 8 提到了 vector 只能儲存相同類別元素的限制。替代方案是定義一個 enum 來儲存 integer, float, string。這表示可在每個單元儲存不同類別的資料。

有時候會想擴充有效的類別集合,以 GUI 為例,他會呼叫 list 的每一個 draw method,把資料畫在 UI 上。如果要實作 gui crate,會有 Button, TextField,但 gui libray 的 user 會希望產生自訂可以繪製在畫面上的類別,例如 Image, SelectBox

實作 library 時,不會知道所有 user 需要用到的類別,只知道 gui 要記錄不同類別的值,且要對每一個值呼叫 draw。

定義一般行為的 trait

為了實作 gui,要定義一個 Draw trait,其中包含 draw method,另外定義一個存放 trait object 的 vector,trait object 會指向實作了指定 trait 的類別 instance

可使用 trait object 替代泛型或其他類別。rust 就會在編譯時,確保該值會實作 trait object 的 trait

rust 刻意不將 struct, enum 稱為物件,在 struct, enum 中,資料跟 impl 行為是分開的。

trait object 會將資料跟行為結合,更接近物件的定義,但 trait object 不同於傳統的 object,因為不能向 trait object 增加資料,trait object 的作用,是將一般通用行為抽象化。

泛型類別參數一次只能替代一個具體類型,而 trait 對象則允許在執行時替代為多種具體類別。

使用 trait 對象的方法,一個 Screen 實例可以存放一個既能包含 Box<Button>,也能包含 Box<TextField>Vec

// 定義一個帶有 draw 方法的 trait Draw
pub trait Draw {
    fn draw(&self);
}

// 一個 Screen struct的定義,它帶有 components,儲存實作了 Draw trait 的 trait 對象的 vector
// 這個 vector 的類別是 Box<Draw>
pub struct Screen {
    pub components: Vec<Box<dyn Draw>>,
}

// 在 Screen 上實作一個 run 方法,該方法在每個 component 上呼叫 draw
impl Screen {
    pub fn run(&self) {
        for component in self.components.iter() {
            component.draw();
        }
    }
}

//////////

// 實作 Draw trait 的 Button
pub struct Button {
    pub width: u32,
    pub height: u32,
    pub label: String,
}

impl Draw for Button {
    fn draw(&self) {
        // 實際繪製按鈕的代碼
    }
}
// 一個使用 gui 的 crate 中,在 SelectBox 上實作 Draw trait
use gui::Draw;

struct SelectBox {
    width: u32,
    height: u32,
    options: Vec<String>,
}

impl Draw for SelectBox {
    fn draw(&self) {
        // code to actually draw a select box
    }
}

use gui::{Screen, Button};

fn main() {
    let screen = Screen {
        components: vec![
            Box::new(SelectBox {
                width: 75,
                height: 10,
                options: vec![
                    String::from("Yes"),
                    String::from("Maybe"),
                    String::from("No")
                ],
            }),
            Box::new(Button {
                width: 50,
                height: 10,
                label: String::from("OK"),
            }),
        ],
    };

    screen.run();
}

只關心值所反映的資訊而不是其具體類別 —— 類似於動態語言中稱為 duck typing 的概念:如果它走起來像一隻鴨子,叫起來像一隻鴨子,那麼它就是一隻鴨子

trait object 執行 dynamic dispatch

回憶一下 chap 10 討論過的,當對泛型使用 trait bound 時編譯器所進行單態化處理:編譯器為每一個被泛型類型參數代替的具體類別產生了非泛型的函數和方法實作。單態化所產生的代碼進行 靜態分發static dispatch)。靜態分發發生於編譯器在編譯時就知道呼叫了什麼方法的時候。這與 動態分發dynamic dispatch)相對,這時編譯器在編譯時無法知道呼叫了什麼方法。在動態分發的情況下,編譯器會產生可以在執行時判斷呼叫了什麼方法的code。

當使用 trait object 時,Rust 必須使用動態分發。編譯器無法知道所有可能用於 trait object 代碼的類別,所以它也不知道應該呼叫哪個類別的哪個方法實作。為此,Rust 在執行時使用 trait object 中的pointer 來判斷要呼叫哪個方法。動態分發不讓編譯器有選擇的內聯方法代碼,這會相應的禁用一些優化。儘管在編寫示例 17-5和可以支持示例 17-9 中的代碼的過程中確實獲得了額外的靈活性,但仍然需要權衡取捨。

trait object 要求 object safety

只有 對象安全object safe)的 trait 才可以組成 trait object。圍繞所有使得 trait object safe 的屬性存在一些複雜的規則,不過在實現中,只涉及到兩條規則。如果一個 trait 中所有的方法有如下屬性時,則該 trait 是 object safe 的:

  • 回傳值類別不是 Self
  • 方法沒有任何泛型類別參數

Self 關鍵字是我們要實現 trait 或方法的類別的別名。object safe 對於 trait 對象是必須的,因為一旦有了 trait object,就不再知道實現該 trait 的具體類別是什麼了。如果 trait 方法回傳具體的 Self 類別,但是 trait object 沒有了其真正的類型,那麼該方法不可能使用已經不知道的原始具體類別。同理對於泛型類別參數來說,當使用 trait 時其會放入具體的類別參數:此具體類別變成了實現該 trait 的類別的一部分。當使用 trait object 時其具體類別被抹去了,故無從得知放入泛型參數類別的類別是什麼。

一個 trait 的方法不是 object safe 的例子是標準庫中的 Clone trait。Clone trait 的 clone 方法的參數簽名看起來像這樣:

pub trait Clone {
    fn clone(&self) -> Self;
}

String 實作了 Clone trait,當在 String 呼叫 clone 方法時會得到一個 String instance。類似的,當呼叫 Vec 實例的 clone 方法會得到一個 Vec instance。clone 的簽名需要知道什麼類型會代替 Self,因為這是它的回傳值。

如果嘗試做一些違反有關 trait object 的object safe 規則的事情,編譯器會提示你。例如,如果嘗試實現示例 17-4 中的 Screen struct 來存放實作了 Clone trait 而不是 Draw trait 的類型,像這樣:

pub struct Screen {
    pub components: Vec<Box<dyn Clone>>,
}

將會得到如下錯誤:

error[E0038]: the trait `std::clone::Clone` cannot be made into an object
 --> src/lib.rs:2:5
  |
2 |     pub components: Vec<Box<dyn Clone>>,
  |     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `std::clone::Clone` cannot be
made into an object
  |
  = note: the trait cannot require that `Self : Sized`

這表示不能以這種方式使用此 trait 作為 trait object。

實作物件導向設計模式

state pattern 是有一個值有某些內部狀態,就是 state object,該值的行為隨著內部狀態而改變。state object 有共享的機制,在 rust 是用 struct, trait 實作,而不是 object 與繼承。每一個 state object代表負責自己的行為與需要改變為另一個狀態的規則。擁有這個 state object 不需要知道,不同狀態的行為及怎麼轉換狀態。

使用 state pattern 表示當程式業務邏輯需求改變時,不需要改變使用者的狀態或值。只需要修改 state object 裡面的 code 來改變規則,或增加更多 state object。

ex: 增量式發布 blog 的 work flow

  1. 產生一個空白的 blog draft
  2. 完成 draft,就要求審核
  3. draft 通過審核,就會被發布
  4. 被發佈的 blog 內容會被列印出來

無法對 blog 進行任何修改,在審核通過前,blog 要保持未發佈的狀態。

這是使用 blog crate 的實作

use blog::Post;

fn main() {
    // 用 new 產生新的 blog draft
    let mut post = Post::new();
    // 編輯 draft 內容
    post.add_text("I ate a salad for lunch today");
    assert_eq!("", post.content());
    // 要求審核
    post.request_review();
    assert_eq!("", post.content());
    // 審核通過,取得 content 時才會有完整的內容
    post.approve();
    assert_eq!("I ate a salad for lunch today", post.content());
}

定義 Post 並產生 draft state 的 blog instance

公有的 Post,與私有的 State trait

lib.rs

pub struct Post {
    state: Option<Box<dyn State>>,
    content: String,
}

impl Post {
    pub fn new() -> Post {
        Post {
            // 在 state 記錄 Option 類別的 trait object:  Box<State>
            state: Some(Box::new(Draft {})),
            content: String::new(),
        }
    }
}

trait State {}

struct Draft {}

impl State for Draft {}

State trait 定義了所有不同狀態的 blog 所共享的行為,接下來 DraftPendingReviewPublished 狀態都會實作 State 。現在這個 trait 並沒有任何方法,一開始只定義 Draft狀態因為這是我們希望 blog 的初始狀態。

當產生新的 Post 時,我們將其 state 設定為一個存放了 BoxSome 值。這個 Box 指向一個 Draft struct 新實例。這確保了無論何時新建一個 Post 實例,它都會從草案開始。因為 Poststate 是私有的,也就無法產生任何其他狀態的 Post 了!。Post::new 函數中將 content 設定為新的空 String

存放 blog content

希望有一個 method 可以產生 blog content

post.add_text("I ate a salad for lunch today");

因此就先在 impl Post 增加這個 fn,因為需要改變 add_textPost,故獲取一個 self 的可變引用,接下來用content 中的 Stringpush_str 並傳送 text 參數,然後存到 content

impl Post {
    // --snip--
    pub fn add_text(&mut self, text: &str) {
        self.content.push_str(text);
    }
}

確保 draft post 的 content 是空的

blog draft 必須要在 content method 回傳空白字串,暫時先固定回傳 ""

impl Post {
    // --snip--
    pub fn content(&self) -> &str {
        ""
    }
}

審核 blog 並改變狀態

將其狀態由 Draft 改為 PendingReview

impl Post {
    // --snip--
    pub fn request_review(&mut self) {
        if let Some(s) = self.state.take() {
            // 將 state 透過 request_review 改為 PendingReview
            self.state = Some(s.request_review())
        }
    }
}

trait State {
    // 不同於使用 self、 &self 或者 &mut self 作為方法的第一個參數,這裡使用了 self: Box<Self>
    // 這個表示這個方法,只對這個類型的 Box 有效。這個語法獲取了 Box<Self> 的所有權,使老狀態無效化以便 Post 的狀態值可以將自身轉換為新狀態。
    fn request_review(self: Box<Self>) -> Box<dyn State>;
}

struct Draft {}

impl State for Draft {
    // 產生新的 PendingReview state
    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
    }
}

改變 content 行為的 approve fn

approve 方法將與 request_review 方法類似:它會將 state 設定為審核通過時應處於的狀態

impl Post {
    // --snip--
    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--
    // 沒有作用,回傳 self
    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

struct PendingReview {}

impl State for PendingReview {
    // --snip--
    // 回傳 Published state
    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
    }

    // 沒有作用,回傳 self
    fn approve(self: Box<Self>) -> Box<dyn State> {
        self
    }
}

為 State trait 增加 content fn

trait State {
    // --snip--
    fn content<'a>(&self, post: &'a Post) -> &'a str {
        ""
    }
}

// --snip--
struct Published {}

impl State for Published {
    // --snip--
    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}

完整程式:

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 {
    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 {
        ""
    }
}

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
    }

    fn content<'a>(&self, post: &'a Post) -> &'a str {
        &post.content
    }
}

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());
}

Trade-offs of the State Pattern

如果不用 state pattern 實作,會需要在 Post, main 裡使用很多 match 語法,檢查 blog state 並改變行為。

對於 state pattern 來說,Post fn 與使用 Post 的 code 都不需要 match 語法,增加新狀態也只需要增加新的 struct,並實作 trait methods

可再擴充以下功能

  • 只允許 blog 在 Draft state 修改 content
  • 增加 reject 將 state 由 PendingReview 改回 Draft
  • 將狀態改為 Published 以前,要呼叫兩次 approve

state pattern 的一個缺點是,因為實作了狀態轉換,讓狀態之間有相關性,例如想在 PendingReviewPublished 之間增加 Scheduled 狀態,就要修改 PendingReview

另一個缺點是重複邏輯的 code。可嘗試在 State trait 回傳 selfrequest_reviewapprove 增加預設實作,但會違反 object safe 特性,因為 trait 不知道 self 的具體資料型別。

另一個重複是 Post 裡面的 request_reviewapprove 很類似,都是設定 state 為新的值。這部分可用 macro 解決

以物件導向方法實作,並沒有用到 rust 的優勢,以下修改程式,將無效狀態與狀態改變,轉換為編譯錯誤。

將 States, Behaviors 編碼為類別

不同於完全封裝狀態及狀態轉移,將狀態編碼為類別,rust 的類別檢查就能處理為編譯期的錯誤

Post 初始化 Post::new 時為 DraftPost,但 DraftPost 沒有 content method,如果嘗試呼叫 content 就會發生編譯錯誤

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);
    }
}

PostDraftPost 都有一個私有的 content 來儲存 blog content。這些 struct 不再有 state,因為我們將類別編碼為 struct 的類別。Post 將代表發佈的 blog,它有一個返回 contentcontent 方法。

仍然有一個 Post::new 函數,不過不同於返回 Post 實例,它回傳 DraftPost 。不可能產生一個 Post instance,因為 content 是私有的同時沒有任何函數返回 Post

DraftPost 上定義了一個 add_text 方法,這樣就可以像之前那樣向 content 增加文本,不過注意 DraftPost 並沒有定義 content 方法!如此現在程序確保了所有 blog 都從草案開始,同時DraftPost沒有任何可供展示的內容。任何繞過這些限制的嘗試都會產生編譯錯誤。

以類別轉換實作狀態轉移

增加另一個struct PendingReviewPost 來實現這個限制,在 DraftPost 上定義 request_review 方法來返回 PendingReviewPost,並在 PendingReviewPost 上定義 approve 方法來返回 Post

impl DraftPost {
    // --snip--

    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_reviewapprove 方法獲取 self 的所有權,因此會消費 DraftPostPendingReviewPost instance,並分別轉換為 PendingReviewPost 和發佈的 Post。在呼叫 request_review 之後就不會遺留任何 DraftPost 實例,後者同理。

PendingReviewPost 並沒有定義 content 方法,所以嘗試讀取其內容會導致編譯錯誤,DraftPost 同理。因為唯一得到定義了 content 方法的 Post 實例的途徑是呼叫 PendingReviewPostapprove 方法,而得到 PendingReviewPost 的唯一辦法是呼叫 DraftPostrequest_review 方法,現在我們就將發博文的工作流編碼進了類別系統。

因為 request_reviewapprove 返回新 instance 而不是修改被呼叫的 struct,所以我們需要增加更多的 let post = 覆蓋賦值來保存返回的實例。也不再能 assert 草案和等待審核的 blog content 為空字符串了

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());
}

References

The Rust Programming Language

中文版

中文版 2

沒有留言:

張貼留言