本文同步發在: http://cpper.info/2016/01/05/Two-Points-Of-Oriented-Object.html。
總覽
在工作初期,我們可能會經常會有這樣的感覺,自己的代碼接口設計混亂、代碼耦合較為嚴重、一個類的代碼過多等等,自己回頭看的時候都覺得汗顔。再看那些知名的開源庫,它們大多有着整潔的代碼、清晰簡單的接口、職責單一的類,這個時候我們通常會捶胸頓足而感歎:什麼時候老夫才能寫出這樣的代碼!
作為新手,我們寫的東西不規範或者說不夠清晰的原因是缺乏一些指導原則。我們手中揮舞着面向對象的大旗,寫出來的東西卻充斥着面向過程的氣味。也許是我們不知道有這些原則,也許是我們知道但是不能很好運用到實際代碼中,亦或是我們沒有在實戰項目中體會到這些原則能夠帶來的優點,以至于我們對這些原則并沒有足夠的重視。
在這裡,我們将一塊學習一些面向對象設計的要點、原則及模式。
在此之前,有一點需要大家知道,熟悉這些原則不會讓你寫出優秀的代碼,隻是為你的優秀代碼之路鋪上了一層栅欄,在這些原則的指導下你才能避免陷入一些常見的代碼泥沼,進而讓你專心寫出優秀的東西。
本文将逐漸介紹一下内容:
- 面向對象之2大要點
- 面向對象之6大原則
- 面向對象之23種設計模式
本節先介紹面向對象之2大要點。
面向對象之兩大要點
面向接口程式設計而不是面向實作程式設計
簡述
面向接口和面向實作都基于面向對象的模式,也就是說面向接口并不能稱為比面向對象的更高的一種程式設計模式,而是在面向對象中大的背景下的一種更加合理的軟體設計模式,這裡的接口并不是具體語言中的實作機制(比如java中的interface),而是軟體設計層面的一種模式。面向接口程式設計它增強了類與類之間,子產品與子產品的之間的低耦合性,使軟體系統更容易維護、擴充。
舉個簡單的例子,我們經常用的操作資料庫的方法,在jdk中定義的都是接口,由不同的資料庫廠商實作,比如mysql的驅動,oracle的驅動,都是實作了jdk中定義接口标準。jdk中資料庫驅動的設計就是面向接口的,而不同的資料庫廠商就是面向實作的。面向接口的好處就是,定義好接口标準,不管是誰隻要按定義好的标準來實作,都可以無縫的切換,是以不管是用mysql也好,還是用oracle也都,從使用者層面來說都是在使用jdk的api。
面向實作程式設計主要缺點是高耦合,不支援擴充,而面向接口程式設計的主要優點是低耦合,便于擴充。
優點
- 用戶端不知道他們所使用對象的具體類型
- 一個對象可以被另一個對象輕易地替換
- 對象不需要硬連接配接到一個特殊類的對象,是以增加了靈活性
- 松耦合
- 增加了重用的機會
- 增加了組合的機會,因為被包含的對象可以被實作了特定接口的其他對象替換
缺點
- 某種程度上增加了設計的複雜性
例子
假設我們要封裝一個IO複用的類,通常有select、poll、epoll模型,且允許使用者自行選擇某一模型, 是以可以通過繼承體系來實作。
/// I/O MultiPlexing 抽象接口
class Poller
{
public:
typedef std::vector<Channel *> ChannelList;
typedef std::map<ZL_SOCKET, Channel *> ChannelMap;
public:
explicit Poller(EventLoop *loop);
virtual ~Poller();
/// 根據各種宏定義及作業系統區分建立可用的backends
static Poller *createPoller(EventLoop *loop);
public:
/// 添加/更新Channel所綁定socket的I/O events, 必須在主循環中調用
virtual bool updateChannel(Channel *channel) = 0;
/// 删除Channel所綁定socket的I/O events, 必須在主循環中調用
virtual bool removeChannel(Channel *channel) = 0;
/// 得到可響應讀寫事件的所有連接配接, 必須在主循環中調用
virtual Timestamp poll_once(int timeoutMs, ChannelList &activeChannels) = 0;
/// 獲得目前所使用的IO複用backends的描述
virtual const char* ioMultiplexerName() const = 0;
protected:
ChannelMap channelMap_;
EventLoop *loop_;
};
/*static*/ Poller* Poller::createPoller(EventLoop *loop)
{
#if defined(USE_POLLER_EPOLL)
return new EpollPoller(loop);
#elif defined(USE_POLLER_SELECT)
return new SelectPoller(loop);
#elif defined(USE_POLLER_POLL)
return new PollPoller(loop);
#else
return NULL;
#endif
}
class SelectPoller : public Poller
{
public:
explicit SelectPoller(EventLoop *loop);
~SelectPoller();
virtual bool updateChannel(Channel *channel);
virtual bool removeChannel(Channel *channel);
virtual Timestamp poll_once(int timeoutMs, ChannelList& activeChannels);
virtual const char* ioMultiplexerName() const { return "select"; }
private:
fd_set readfds_; /// select傳回的所有可讀事件
fd_set writefds_; /// select傳回的所有可寫事件
fd_set exceptfds_; /// select傳回的所有錯誤事件
fd_set select_readfds_; /// 加入到select中的感興趣的所有可讀事件
fd_set select_writefds_; /// 加入到select中的感興趣的所有可寫事件
fd_set select_exceptfds_; /// 加入到select中的感興趣的所有錯誤事件
std::set< int, std::greater<int> > fdlist_;
};
class EpollPoller : public Poller
{
public:
explicit EpollPoller(EventLoop *loop, bool enableET = false);
~EpollPoller();
virtual bool updateChannel(Channel *channel);
virtual bool removeChannel(Channel *channel);
virtual Timestamp poll_once(int timeoutMs, ChannelList& activeChannels);
virtual const char* ioMultiplexerName() const { return "linux_epoll"; }
private:
typedef std::vector<struct epoll_event> EpollEventList;
int epollfd_;
bool enableET_;
EpollEventList events_;
};
以上通過多态實作了一個IO複用的繼承體系。在純虛基類(Poller)中定義了所有要實作IO複用的接口,而由其他子類(SelectPoller、EpollPoller)針對接口分别實作相應功能。對于使用者來說,并不需要考慮其内容實作細節,隻需根據Poller類的接口即可使用。
優先使用組合而不是繼承(CARP)
面向對象系統中功能複用的兩種最常用技術是類繼承和對象組合(object composition)。
類繼承允許你根據其他類的實作來定義一個類的實作。這種通過生成子類的複用通常被稱為白箱複用(white-box reuse)。術語“白箱”是相對可視性而言:在繼承方式中,父類的内部細節對子類可見,可以說是“破壞了封裝性”,父類實作中的任何變化必然會導緻子類發生變化,彼此間的依賴程度高。
對象組合是類繼承之外的另一種複用選擇。新的更複雜的功能可以通過組裝或組合對象來獲得。對象組合要求被組合的對象具有良好定義的接口。這種複用風格被稱為黑箱複用(black-box reuse),因為對象的内部細節是不可見的。對象隻以“黑箱”的形式出現,隻需要關心提供的接口,彼此間的依賴低。
優先使用組合而不是繼承要求我們在複用對象的時候,要優先考慮使用組合,而不是繼承,這是因為在使用繼承時,父類的任何改變都可能影響子類的行為,而在使用組合時,是通過獲得對其他對象的組合而獲得更強功能,且有助于保持每個類的單一職責原則。
繼承群組合各有優缺點。類繼承是在編譯時刻靜态定義的,且可直接使用,因為程式設計語言直接支援類繼承。類繼承可以較友善地改變被複用的實作。當一個子類重定義一些而不是全部操作時,它也能影響它所繼承的操作,隻要在這些操作中調用了被重定義的操作。
優先使用對象組合有助于你保持每個類被封裝,并被集中在單個任務上。這樣類和類繼承層次會保持較小規模,并且不太可能增長為不可控制的龐然大物。另一方面,基于對象組合的設計會有更多的對象 (而有較少的類),且系統的行為将依賴于對象間的關系而不是被定義在某個類中。
Echo協定是指服務端收到用戶端的任何資料後都原封不動的發送回去。如果要實作一個EchoServer,按照傳統的做法,一般是先實作一個TcpServer抽象類,設定幾個虛函數,比如連接配接到來、資料到來,連接配接關閉等等,這也是面向對象中多态機制的常用實作方式。而這裡我們示範一個基于函數回調的實作。
假設我們已經有了一個封裝了IO複用的EventLoop類和一個可以直接使用但不做任何資料處理的TcpServer類。
TcpServer的接口如下:
class TcpServer : zl::NonCopy
{
public:
TcpServer(EventLoop *loop, const InetAddress& listenAddr);
void start();
public:
EventLoop* getLoop() const
{ return loop_; }
void setConnectionCallback(const ConnectionCallback& cb)
{ connectionCallback_ = cb; }
void setMessageCallback(const MessageCallback& cb)
{ messageCallback_ = cb; }
void setWriteCompleteCallback(const WriteCompleteCallback& cb)
{ writeCompleteCallback_ = cb; }
};
那麼一個EchoServer實作起來就很簡單了:
class EchoServer
{
public:
EchoServer(EventLoop *loop, const InetAddress& listenAddr);
void start();
private:
/// 用戶端連接配接建立或關閉時的回調
void onConnection(const TcpConnectionPtr& conn);
/// 用戶端有資料發送過來時的回調
void onMessage(const TcpConnectionPtr& conn, ByteBuffer *buf, const Timestamp& time);
private:
EventLoop *loop_;
TcpServer *server_;
};
使用方式也很簡單:
EventLoop loop;
InetAddress listenAddr(port);
EchoServer server(&loop, listenAddr);
server.start();
loop.loop();
這樣就完成了一個EchoServer。
與此類似,還有簡單的daytime協定,用戶端連接配接服務端後,服務端傳回給用戶端一個目前時間。一個DaytimeServer完全可以按照上面的思路來實作。
這也是面向對象和基于對象實作的差異,關于以函數回調方式替代面向對象中純虛函數方式的更多介紹請參考:以boost::function和boost:bind取代虛函數。