C++物件間通訊元件,讓C++物件「無障礙交流」

2022-06-20 06:03:41

介紹

這是很久之前的一個專案了,最近剛好有些時間,就來總結一下吧!
推薦初步熟悉專案後閱讀本文: https://gitee.com/smalldyy/easy-msg-cpp

從何而來

這要從我從事Qt開發的那些日子說起了,專案說大不大,說小也不小,人倒是一茬又一茬,需求也換了又換,後來的事情大家都懂了,專案變成了一坨濃Shit,且不說其中的設計、構架、以及需求問題,單說說我對這個專案的直觀感受,在我看來,整個程式彷彿一顆大樹,從某點作為根然後一直向上延伸,在沒有足夠時間重構的情況下,它的層級越來越深,這時候問題來了,如果想讓樹木的兩個不同分支的葉子節點發生關係,事情就馬上會變得十分痛苦!

這兩個想要聯絡的物件根本不再一個地方,我可能要將其中一個物件的指標在這顆大樹的節點上倒退3層然後再前進2層才能讓他們見面,然後暗戳戳的寫下一個connect。

這時候我就想,如果有一個專門的通訊元件負責傳遞各種訊息,讓兩個物件中間產生一個媒介作為他們通訊的橋樑,獲取這件事情就會變得更加輕鬆了,我不用再費盡心思的將兩個物件參照到同一個作用域,甚至還要考慮哪個作用域更加合理。

誠然,如果在前期就對專案的各個元件進行全盤規劃,我想這種困境可能不會或者很少出現,但是並非所有事情都會按照美好的方向前進,就如曾經堆在我面前的那坨濃Shit,儘管我也為它的存在出過不少力…………

設計目標

  • 提供C++物件程序內通訊功能 可進行訊息傳遞;
  • 將已經存在的結構體定義為訊息時,不能破壞已經存在的結構體本身的結構;
  • 處理訊息的類無需繼承任何基礎類別;
  • 足夠簡單的訂閱方法;
  • RAII形式的取消訂閱,但也支援手動取消訂閱。

你可能注意到了,我特意強調了不破壞原有結構。目的很簡單,就是為了保證專案不會因為引入這個元件而發生太大的變化。眾所周知,大部分程式設計師都是懶癌晚期,如果引入一個元件會導致工作量激增,程式設計師就會開始衡量shit的臭味和工作量之間的關係了。

總之,核心特徵只有兩個:易用,改動小。

原理分析

我首先將這個元件設計為一個基於訂閱分發方式的通訊元件,它有三個主要角色,訂閱者,釋出者,和訊息。

首先考慮最簡單的釋出者,釋出者的功能非常直觀——傳送訊息,也就是說使用者只要在需要的位置呼叫一個sendMsg之類的函數即可,這個函數的功能就是將使用者給定的訊息傳送出去。

然後便是訂閱者,我們要求訂閱的宿主型別不可以繼承任何基礎類別,這個要求決定了我們訂閱的方式,我們需要提供一個函數,它接受一個物件的指標(我稱之為宿主)和它的成員函數,將兩者包裝成一個std::function,將這個包裝好的回撥函數與一個定義好的訊息關聯並記錄下來,這就形成了訂閱關係。

當釋出者傳送訊息時,我們的元件需要查詢訂閱關係,找到訊息對應的回撥函數,將訊息作為引數呼叫它!此時,物件間就完成了一次通訊。我們的元件就是信使,這樣就無需發信人四處奔波了。

我們還要求不破壞原本的結構體的結構,這也就意味著我們不能改動已經存在的結構體,比如果讓它繼承一個訊息基礎類別然後就能作為訊息傳遞之類的操作——雖然很好,但是我們得對這個設計說拜拜了。但是,上樹訂閱分發的流程必然要求訊息擁有一個統一的基礎類別型別,否則我們無法統一回撥函數的函數簽名,儲存訂閱關係也就無從談起了!因為引數型別不同的函數,是很難儲存到一個容器中以供查詢的!

為了解決這個鬧人的問題,我們不妨反向思考一下,既然我們不能讓一個已經存在的訊息結構繼承我們的基礎類別,那麼就建立一個新的型別同時繼承兩者吧!

 class NewExistMsg : public ExistMsg, public em::EasyMsg

使用者可以使用 NewExistMsg 來建立訊息體,就像使用 ExistMsg一樣,回撥函數可以使用EasyMsg*作為引數,來達到型別的統一,並可以安全的進行多型設計。

至此,訊息的問題也解決了。

你可能會感興趣的技術細節

以下是EasyMsg的標頭檔案:

class EASYMSG_API EasyMsg {
public:
  EasyMsg();
  virtual ~EasyMsg() = default;
  virtual std::string id() const = 0;

  template <typename T> struct is_easymsg {
    template <typename U> static char test(typename U::MsgType *x);
    template <typename U> static long test(U *x);
    static const bool value = sizeof(test<T>(0)) == 1;
  };

  // c++17 support constexpr if
#if ((defined(_MSVC_LANG) && _MSVC_LANG >= 201703L) || __cplusplus >= 201703L)
  template <typename EASY_MSG_ID> bool match() {
    is_easymsg<EASY_MSG_ID> test_easymsg;
    if constexpr (test_easymsg.value) { // c++17
      return id() == EASY_MSG_ID::value;
    } else {
      std::cerr << "匹配訊息ID時發生錯誤,檢查是否使用了未定義的訊息? 檢查:"
                << typeid(EASY_MSG_ID).name() << std::endl;
      return false;
    }
  }
#else
  template <class MSGID>
  typename std::enable_if<!is_easymsg<MSGID>::value, bool>::type match() {
    std::cerr
        << "匹配訊息ID時發生錯誤,檢查是否使用了未定義的訊息? typeinfo : "
        << typeid(MSGID).name() << std::endl;
    return false;
  }

  template <class MSGID>
  typename std::enable_if<is_easymsg<MSGID>::value, bool>::type match() {
    return id() == MSGID::value;
  }

#endif
};

這裡邊有一些有意思的東西可以學習一下,首先映入眼簾的就像是經典的虛解構函式,這是作為多型基礎類別的必要手續。接下來就是SFINAE的經典用法,我是用這個技巧實現了match函數,這個函數的主要作用就是判斷給定的EASY_MSG_ID是否和傳入的訊息指標是同一種訊息型別。

match根據c++標準分成了兩個實現,C++17版本藉助了 constexpr if特性。以前的版本則用了經典的std::enable_if。

SFINAE不甚瞭解的人應該很難理解這些程式碼,SFINAE中文含義為「匹配失敗不是錯誤」,這對模板變成來說非常重要,不過這已經超出了本文範圍,我僅僅是拋磚引玉,之後我可能會更新文章對此段程式碼進行詳解,從而讓大家瞭解這些慣用法。

其他的便沒有什麼技術細節了,都是些常規的東西,無非是用map記錄下訂閱關係,然後send時執行回撥之類的東西,不值細說。

結論

本文向大家介紹了一個侵入性較低的C++物件間通訊元件,或許可以幫助你解決一些頭疼的通訊問題,並展示了一些你可能感興趣的技術細節,如果能引發更多的思考那就更好不過了!