almost 4 years ago

先前和剛開始寫iOS的同事pair programming,同事對於我習慣將private member data宣告成read-only property感到有點疑惑,老實說,這習慣是只有寫Objective C時才有的,而且不是全部的private member data都是如此,主因是Objective C對於存取限制的設計不像C++ / Java / C#那樣顯著,但這確實讓我時常思考:存取限制的目的是什麼?像是JavaScript或最近新推出的Swift語言(在XCode 6 Beta 4的release note中有提到publicprivateinternal三種存取層級了),存取限制的設計也不明顯,最後還是回到OO的基本特性之一:封裝。封裝有時會被誤以為是information hidding,我認為封裝最大的用意是讓使用者以既定規範使用被封裝的元件,避免元件被破壞。因此,在不違反這原則下,我能接受部分資訊以唯讀的方式暴露出來,有時這些唯讀資訊還能讓測試變容易。

另外還有一個要思考的是,除了語言本身提供對存取限制的支援外,沒有其他方法能達到同樣的效果嗎?事實上我見過很多Java工程師在宣告完private member data後就立即撰寫對應的getter和setter,甚至還因為覺得寫getter/setter很麻煩有了像lombok這樣的third-party library,如果是這樣,一開就把該member data設計成public不是更簡單?可是有時候確實會面臨一個兩難,某個member data確實是可以改的,但又不是任何人都可以改,所以若不開放setter會變成所有人都無法修改,但開放後又變成所有人都能改,因此像Java提供了protected和package層級的限制,前者讓繼承的物件能夠修改,後者讓屬於同個package的物件能夠修改,但有時候A物件和B物件既非繼承關係也非同package關係,此時這些限制都無法滿足一個需求:讓物件暫時變成immutable (Java官方有建議的方式設計永久性immutable object,但不是我要的)。

一個簡單的例子,在一個行動裝置的App上,可能會使用一個User類別記錄使用者的暱稱、ID以及照片,除了ID可能是一創建後無法修改外,其他像暱稱或是照片都是可以修改的,因此,通常會替類別加上對應的setter,但這會有個問題,修改暱稱或是照片可能需要和server同步及儲存,但持有User物件的對象(例如:UI),可能誤以為呼叫對應的setter即可完成任務,事實上確實可以在setter做完這些事情,但這會讓User的responsibility變繁重,或是透過observer的方式,當任何屬性有變更時,通知observer做對應的動作(和server同步及儲存),但若observer發生錯誤,處理錯誤變得很不乾脆,所以這兩種方式我都沒使用過。

為了不讓不合適的物件持有者修改物件狀態,可以將getter和setter分離,將getter全部集中到介面上,一個只有getter的interface,稱之為Immutable interface (原本我不知道有沒有相似的pattern,所以給了一個Getter-only interface的名字,後來真的找到了),setter則保留在實作的類別上,如Figure 1,UserInfo僅宣告getter,而實作的User依舊提供setter。當UI需要呈現使用者的暱稱和照片時,可以只傳遞UserInfo給UI,雖然UI取得的是只有getter的物件,但這些getter以提供充足的資訊完成顯示的任務,但如果要修改暱稱,由於沒有setter,是無法擅自修改暱稱,只能透過UserManager進行修改。

Figure 1 - UserInfo, User, and UserManager

這只是program to interface的一個小把戲,事實上,傳遞給UI和UserManager所持有的物件可以是同一個物件,只是彼此看到的介面不同罷了。例如Code List 1中的DefaultUserManager實際上所持有的是User實體。

Code List 1 - The actual instances kept in DefaultUserManager
public class DefaultUserManager implements UserManager {

    private UserDao _userDao;
    private UserWebService _userWebService;
    private Map<String, User> _users;

    /* implement the methos in UserManager */
}

當UI需要取得UserInfo時,呼叫UserManagergetUser(String),Code List 2的實作,先從記憶體當中先找有沒有符合的實體,若沒有則呼叫web service嘗試向server取得指定的使用者資訊,若沒有發生任何錯誤,web service一樣會回傳immutable的UserInfo實體,接著呼叫DAO將該使用者的資訊儲存到資料庫中,若沒有錯誤,用該資訊建立一個User實體放入_users中,待下次使用,接著回傳User實體,但對UI而言取得的是immutable object。若擔心強制轉型,可以複製一份再回傳。

Code List 2 - The implementation of getUser(String)
public UserInfo getUser(String userId) {
    if (_users.containsKey(userId)) {
        return _users.get(userId);
    }
    User user = null;
    try {
        UserInfo userInfo = _userWebService.getUser(userId);
        _userDao.save(userInfo);
        user = new User(userInfo);
        _users.put(userId, user);
    } catch(WebServiceException we) {
        we.printStackTrace();
    } catch(DaoException de) {
        de.printStackTrace();
    }
    return user;
}

由於資料物件常常有多個屬性要初始化,以User為例有三個屬性要初始化,若都透過constructor建立,constructor的參數會變得相當多,這變成long parameter list的壞味道,此時,UserInfo正好扮演Parameter Object的規範,而且還是immutable的parameter object (雖然Java有final關鍵字可以用,但只限制該變數無法改變所指向的物件,無法阻止呼叫setter改變物件內的狀態)。這樣也可以避免常常寫「用預設建構子建立物件,然後呼叫很多的setter完成初始化」這類重複的程式碼。

Code List 3 - The constructor that constructs object from the immutable object
public User(UserInfo info) {
    _photo = info.getPhoto();
    _userId = info.getUserId();
    _nickname = info.getNickname();
}

以剛剛的Code List 2為例,如果是一個回傳JSON的web service,只需替JSON parser分析出來的結果寫一個wrapper (Code List 4),就能當做是建立User實體所需要的parameter object了,雖然很多JSON parser都有data binding的功能,能直接將JSON轉成對應的POJO物件,可是如果JSON內的屬性名稱和POJO的屬性名稱不相同時,在model object中加入與domain無關的annotation (例如@SerializedName)就讓我有點討厭。

Code List 4 - The wrapper of JSON object
public class UserGsonWrapper implements UserInfo {

    private JsonObject _json;

    public UserGsonWrapper(JsonObject json) {
        _json = json;
    }

    @Override
    public String getUserId() {
        return _json.get("user_id").getAsString();
    }

    @Override
    public String getNickname() {
        return _json.get("user_nickname").getAsString();
    }

    @Override
    public String getPhoto() {
        return _json.get("user_photo").getAsString();
    }
}

此外,Java不像C++在語言上就支援copy constructor,雖然提供一個clone()函式用來複製既有的物件,但使用上,必須在想支援複製的類別上加上Cloneable的實作,否則呼叫clone()會拋出CloneNotSupportedException例外,而且clone()的回傳值是Object型別,每次都要轉型確實有點討厭。但有類似Code List 3的建構子後(其實有點像copy constructor),複製一個物件,只需將要被複製當成建構子的參數即可。使用的方式就像Code List 5一樣,由於Dao的save(UserInfo)函式需要一個帶有最新狀態的物件好將狀態寫入資料庫,但在沒有成功之前,又不想改變既有的物件狀態,此時只需複製一份新的物件,將新的暱稱設定到複製出來的物件內,當成save(UserInfo)的參數,若儲存成功,在將新的暱稱設回原先的物件,如此一來,若儲存失敗,既有的物件也不會受影響。

Code List 5 - Easy clone an object
public UserInfo updateNickname(String userId, String nickname) {
    User user = null;
    if (!_users.containsKey(userId)) {
        return user;
    }
    try {
        user = _users.get(userId);
        _userWebService.updateNickname(userId, nickname);
        User tmp = new User(user);
        tmp.setNickname(nickname);
        _userDao.save(tmp);
        user.setNickname(nickname);
    } catch (WebServiceException we) {
        we.printStackTrace();
    } catch (DaoException de) {
        de.printStackTrace();
    }
    return user;
}

由於Java的interface是可以繼承另一個interface,所以Immutable interface也可以用在原有的繼承架構上,只是會變成像Figure 2那樣是個平行的繼承架構。最近IM (Instant Messaging)很熱門,有各式各樣的IM App,而且傳遞的訊息也不再僅僅是文字訊息,以程式來說,要表現出不同的訊息類型,可能會有Figure 2右半邊的繼承架構,一個抽象類別Message代表一則訊息,實際的訊息種類則用TextMessageImageMessage分別代表文字訊息或是圖片訊息。在導入Immutable interface時,為了符合右邊的繼承架構,可以看到Figure 2左半邊有一個相似的繼承關係,在這繼承關係中都有一個對應的Immutable interface,MessageInfo對應MessageTextMessageInfo對應TextMessage,而ImageMessageInfo對應ImageMessage

Figure 2 - Inheritance of Immutable interfaces

在這樣的平行繼承關係中,先前提到的建構子一樣能適用,如Code List 6中,TextMessage的建構子直接把參數傳給Message的建構子,實際上,一則文字訊息一旦送出,內容不會在修改(好吧,最近有些IM主打閱過即焚,就好像Mission: Impossible中的任務錄影帶一樣),所以TextMessage除了透過建構子將訊息內容傳入外,並沒有setter能夠修改訊息內容,但因為是繼承Message,所以保留訊息ID,可以在實際送達server取得訊息ID後再更新。

Code List 6 - The implementation of Figure 2
public interface MessageInfo {

    public long getMessageId();

    public String getSenderId();
}

public abstract class Message implements MessageInfo {

    private long _messageId;
    private String _senderId;

    protected Message() {
        _senderId = null;
        _messageId = -1;
    }

    protected Message(MessageInfo info) {
        _senderId = info.getSenderId();
        _messageId = info.getMessageId();
    }

    @Override
    public String getSenderId() {
        return _senderId;
    }

    @Override
    public long getMessageId() {
        return _messageId;
    }

    public void setSenderId(String senderId) {
        _senderId = senderId;
    }

    public void setMessageId(long messageId) {
        _messageId = messageId;
    }
}

public interface TextMessageInfo extends MessageInfo {

    public String getContent();
}

public class TextMessage extends Message implements TextMessageInfo {

    private String _content;

    public TextMessage(String content) {
        _content = content;
    }

    public TextMessage(TextMessageInfo info) {
        super(info);
        _content = info.getContent();
    }

    @Override
    public String getContent() {
        return _content;
    }
}

從上面的例子,可以看出Immutable interface讓封裝更有彈性,不用擔心setter的過度開放。當不希望物件被不允許的對象修改時,只需讓對方取得getter的介面即可,反之,讓能夠允許修改的對象取得有setter的物件即可。雖然沒有提到functional programming,但immutable object是functional programming中很重要的一環,也是常用的一種thread-safe技巧,Immutable interface某種程度上(若不用強制轉型),不需複製物件就能達成immutable object的目標了。

後記:雖然C++已經有相當久的歷史,但若善用const關鍵字(使用const point或const reference搭配const member functions),確實不需要Immutable interface就能輕鬆達成immutable object的目的。

← Bonjour iOS and Android User Story Refinement →