c++ 類之間的依賴問題:impl、代理模式
參考
Effective_C++第三版.pdf
Effective_Modern_C__.pdf
描述
類似於託管的方式來解決幾個問題:
- 減少編譯時間
- 解決迴圈引用
- 遮蔽內部實現
減少編譯時間,本質降低依賴
因為c++是靜態編譯語言,他看的就是檔案和檔案之間的依賴,如果是例項 type a,那麼就一定需要include type相關標頭檔案,這樣導致一件事情:當多重依賴的時候,很可能基層類的小改動,導致所有包括這個類的大類都需要重新編譯,注意:(Run和Date是隨意寫的兩個類)
例子1:
#include "Run.h"
#include "Date.h"
class Test
{
public:
Test(Run& run, Date& date);
std::string g
Run run;
Date date;
};
對於這種帶來的就是 當Test這個類依賴的標頭檔案改變,或者這些標頭檔案依賴的其他標頭檔案改變的時候,每一個含有Test的檔案都要重新編譯,使用Test的檔案也要重新編譯,這將帶來的是連串編譯依存關係
解決辦法 : 前置宣告
首先對於標準庫無法使用類似 class string的方式,因為 string不是class,是一個typedef, 涉及到 template,但是你應該不對標準庫進行前置宣告,因為他們幾乎不會改變
其次,c++這種語言編譯的時候需要知道物件的大小,也就是 sizeof你得出來準確的值,那麼要求你類中的變數型別都是確定的
解決辦法就是:
使用引用/指標,因為引用/指標的大小是固定的 指標大小,並且對於java這些他的成員變數其實也是指向地址的型別
class Run;
class Date;
class Test
{
public:
Test(Run& run, Date& date);
Run& run;
Date& date;
};
- 當然你使用者肯定要包含Run和Date類,但是還是推薦智慧指標,想不出來不用的理由
成員變數的IMPL
上面的用引用保留變數其實不常見,一般都是用指標,但是指標又有釋放的問題,那麼就使用智慧指標,類中定義Impl的結構體,包含所有必要的成員變數,但是這裡不去體現,標頭檔案中僅僅進行必要的class 宣告
weight.h
#ifndef WEIGHT_H
#define WEIGHT_H
#include <memory>
class Weight
{
public:
Weight();
private:
struct Impl;
std::unique_ptr<Impl> m_impl;
};
#endif // WEIGHT_H
weight.cpp
#include "Weight.h"
#include <vector>
#include <string>
struct Weight::Impl {
std::string name;
std::vector<double> data;
};
Weight::Weight()
: m_impl(new Impl())
{
}
將所有需要例項化的成員變數建立一個結構體,結構體指標使用unique_ptr管理!!!
但是這種方式在例項化weight的時候會出問題,因為unique_ptr內部預設析構器會對指標型別進行判斷如果是不完全的型別會進行報錯,為啥會不完全呢,因為編譯器預設的解構函式是在標頭檔案隱式內聯的,在標頭檔案中當然看不到具體型別
解決辦法是:
讓析構的時候看到完整型別唄,也就是析構實現的時候看到結構體是完成的,所以將weight的解構函式移到.cpp中
#include "Weight.h"
#include <vector>
#include <string>
struct Weight::Impl {
std::string name;
std::vector<double> data;
};
Weight::Weight()
: m_impl(new Impl())
{
}
Weight::~Weight() {
}
也可以使用 ~Weight() = default; 相當於實現使用預設的編譯器生成程式碼
#include "Weight.h"
#include <vector>
#include <string>
struct Weight::Impl {
std::string name;
std::vector<double> data;
};
Weight::Weight()
: m_impl(new Impl())
{
}
Weight::~Weight() = default;
那麼析構有影響,拷貝構造和賦值操作符呢?
我們都知道,當聲明瞭解構函式,編譯器就不會給我們預設生成移動操作符函式,需要我們顯示宣告
那麼對於下面的
#ifndef WEIGHT_H
#define WEIGHT_H
#include <memory>
class Weight
{
public:
Weight();
~Weight();
Weight(Weight&& rhs) = default;
Weight& operator=(Weight&& rhs) = default;
private:
struct Impl;
std::unique_ptr<Impl> m_impl;
};
#endif // WEIGHT_H
因為unique_ptr的原因,我們只能使用預設的移動操作符
然而在
#include <iostream> // std::streambuf, std::cout
#include "Weight.h"
int main () {
Weight w;
Weight c;
w = std::move(c);
return 0;
}
報錯了,原因是在 移動操作符的預設實現中 會對原有的進行delete處理,這就和解構函式相同了,不完整型別
解決辦法就是換個地方,在.h中統一宣告
#ifndef WEIGHT_H
#define WEIGHT_H
#include <memory>
class Weight
{
public:
Weight();
~Weight();
Weight(Weight&& rhs);
Weight& operator=(Weight&& rhs);
private:
struct Impl;
std::unique_ptr<Impl> m_impl;
};
#endif // WEIGHT_H
#include "Weight.h"
#include <vector>
#include <string>
struct Weight::Impl {
std::string name;
std::vector<double> data;
};
Weight::Weight()
: m_impl(new Impl())
{
}
Weight::~Weight() = default;
Weight::Weight(Weight&& rhs) = default;
Weight& Weight::operator=(Weight&& rhs) = default;
為了保證賦值操作符可以正常使用,我們必須手工自己進行實現
Weight& Weight::operator=(const Weight& rhs) {
if (this != &rhs) {
*m_impl = *rhs.m_impl;
}
return *this;
}
我們使用這種賦值方式,讓結構體內部進行賦值,注意的是 記憶體是兩塊記憶體,只不過現在內容是一樣的了
值得一提的shared_ptr和unique_ptr
上面例子2中的unique_ptr的種種,換成shared_ptr後都不需要了
#ifndef WEIGHT_H
#define WEIGHT_H
#include <memory>
class Weight
{
public:
Weight();
private:
struct Impl;
std::shared_ptr<Impl> m_impl;
};
#endif // WEIGHT_H
#include "Weight.h"
#include <vector>
#include <string>
struct Weight::Impl {
std::string name;
std::vector<double> data;
};
Weight::Weight()
: m_impl(new Impl())
{
}
但是呢,是有代價的。對於unique_ptr他的析構器是智慧指標的一部分,因為一開始就可以確定下來,這讓編譯器可以快速執行程式碼,這就要求編譯時候看到的指標型別是完全的;對於shared_ptr,他的內部析構器不是智慧指標的一部分,屬於control Block的一部分,所以這也帶來的編譯器無法優化、減少程式碼大小
注意一點,我們使用類變數託管的方式解決依賴,但是客戶端有的時候就是要Impl裡面變數的具體實現,比如
迴圈引用
這裡的迴圈引用說的不是shared_ptr的那個,說的是標頭檔案的互相包含,這時候可以選擇的是 A include B,B 標頭檔案中 class A,進行宣告
從類之間的角度進行IMPL
上面我們說了 單個成員變數使用指標減少依賴、類中定義結構體較少所有成員變數的具體依賴,那麼類之間的IMPL呢?
其實這個作用在設計模式上還有 叫 代理模式,還有的叫不和陌生人說話,本質就是在 兩者之間 加入第三個類來解決兩個類的互相依賴
將類分成兩部分,一個負責提供介面,一個負責提供實現,注意了我們這裡說的可沒有帶繼承這種類關係
那麼例子:還是上面的Weight,但是使用WeightProxy進行IMPL
WeightProxy標頭檔案
#ifndef WEIGHTPROXY_H
#define WEIGHTPROXY_H
#include <string>
#include <memory>
class Weight;
class WeightProxy
{
public:
WeightProxy();
~WeightProxy();
std::string GetInfo();
std::unique_ptr<Weight> m_proxy;
};
#endif // WEIGHTPROXY_H
WeightProxy實現檔案
#include "Weightproxy.h"
#include "Weight.h"
WeightProxy::WeightProxy()
: m_proxy(new Weight())
{
}
WeightProxy::~WeightProxy() = default;
std::string WeightProxy::GetInfo() {
return m_proxy->GetInfo();
}
Main函式
#include <iostream> // std::streambuf, std::cout
#include "Weightproxy.h"
int main () {
WeightProxy w;
std::cout << w.GetInfo() << std::endl;
return 0;
}
對於客戶端完全看不到類的具體實現,這也就 面對介面程式設計
基本原則是:
-
如果使用指標或者引用可以實現,就不要用Object,因為定義某型別的Object需要型別的定義式,而前者只需要宣告
-
儘量用宣告替換定義,當你宣告一個函式,並且他用到某個class時,不需要該class的定義,即使函式是 Object傳遞引數或者返回值
class Test; Test getTest(); void setTest(Test obj);
注意了,變數只有是指標我們才能使用宣告,但是函式卻沒有這個限制,即使是物件也可使用宣告,本質是函式編譯不依賴於實現,但是呼叫函式之前,Test定義式一定要存在,重要的目的是把這種include形式傳遞到客戶呼叫函式的那個檔案中,將型別定義和客戶端依賴去除,說白了庫的提供者一個類中會提供很多函式,因為庫的提供者選擇 class形式,那麼對於客戶端只有需要 知道Test的具體定義的才去包含Test標頭檔案,減少不必要的依賴
-
為宣告式和定義式提供不同標頭檔案
因為定義式裡面包含的標頭檔案的真實實現,客戶端不應該自己手工class宣告,而是庫實現側自己提供兩種標頭檔案一個是宣告、一個是定義宣告檔案就是給客戶端像include的形式使用宣告,也就是宣告檔案的內容就僅僅是 class Test
所以 介面實現的檔案中的函式要和 介面標頭檔案相同,什麼以來具體實現都是 介面實現檔案Proxy考慮的事情
建構函式中依然傳遞 Weight需要的引數(使用class宣告),但是這個依賴就丟給了客戶端,這就表示事情還是會做,只不過方式改變了,這是一種Handle class的方式
4. 另一種方式:
純虛類
在java 和 .NET中,就有專門的interface定義,裡面不能有成員變數,實現的成員函式等
因為這樣,使用這個純虛類的客戶,必須用指標/引用使用應用程式,因為無法定義例項,那麼這樣的話除非介面修改否則客戶也不需要重新編譯
並且,一般考慮使用工廠模式來建立這種型別的物件,這種工廠函式一般在介面類中定義為靜態,通過引數不同生成不同的函式
class Test
{
public:
static Test* getInstanse(std::string type);
virtual std::string getRunString() const = 0;
};
還是推薦返回智慧指標,這樣返回不同的派生類例項
最後總結
- 使用IMPL方式來較少類之間的依賴,減少編譯時間
- 變數可以使用指標,一大推變數使用結構體,類可以使用一個託管類,大致這三型別來實現減少依賴
- 其實本質上來說,標頭檔案之間就不應該有定義的依賴,所以java中統一使用了指標,實現cpp中才是真正包含所有具體定義,標頭檔案是用來宣告這個類長什麼樣子,實現cpp中用來實現這個類內部怎麼實現的