如何理解《Effective C++》第31條將文件間的編譯依賴關係降低的方法?
Rule 31:Minimize compilation dependencies between files
規則 31:將文件間的編譯依存關係降至最低
一、文件間的編譯依存性
1.現象: 假設你對C++程序的某個class實現文件做了些輕微的修改。(而且,這裡修改的並不是class介面,而是實現,而且只改private成分。
然後重新建置這個程序,你會發現所有的東西都需要重新編譯和連接。
2.原因:問題出在C++並沒有把"將介面從實現中分離"做的很好。
Class的定義式不只詳細敘述了class介面,還包括十足的實現細目。
例如:
- class Person {
- public:
- Person(const std::string name,const Date birthday,const Address addr);
- std::string name() const;
- std::string birthDate() const;
- std::string address() const;
- ...
- private:
- std::string theName;
- Date theBirthDate;
- Address theAddress;
- };
這裡的class Person無法通過編譯——如果編譯器沒有取得其實現代碼所用到的classes string,Date 和 Address的定義式。這樣的定義式通常有#include指示符提供,所以Person類的定義文件最上方,會有一些頭文件的包含,比如:
- #include &
- #include "date.h"
- #include "address.h"
不幸的是,這樣一來就在Person定義文件和其含入文件之間形成了一種編譯依存關係。
這將會 導致 如果這些頭文件中任何一個被改變,那麼每一個含入Person class的文件都需要重新編譯,任何使用Person class的文件也要重新編譯。
3.疑問:為什麼C++堅持將class的實現細目置於class定義式中?
如果像下面這樣做,如何?
- namespace std {
- class string;
- }
- class Date;
- class Address;
- class Person {
- public:
- Person(const std::string name,const Date birthday,const Address addr);
- std::string name() const;
- std::string birthDate() const;
- std::string address() const;
- ...
- };
如果可以這樣做,Person的客戶就只需要在Person介面被修改過時才重新編譯。
4.導致的問題
這個想法存在兩個問題:
(1) string不是個class,它是個typedef(定義為basic_string&)。因此上述針對string而做的前置聲明並不正確(正確的前置聲明因為涉及額外的templates所以比較複雜)
(2) 編譯器必須在編譯期間知道對象的大小,看看這個:
- int main()
- {
- int x; // 定義一個int
- Person p(params); // 定義一個Person
- ...
- }
當編譯器看到x的定義式,它知道必須分配多少內存(通常位於stack內)才夠持有一個int。
每個編譯器都知道一個int有多大,當編譯器看到p的定義式,它也知道必須分配足夠的空間以放置一個Person,但它如何知道一個Person對象有多大呢?
——編譯器獲得這項信息的唯一方法就是詢問class定義式。
然而如果class定義式可以合法地不列出實現細目,編譯器如何知道該分配多少空間?
這個其實書裡面已經說的很清楚了。
雖然各個編譯器有自己的 trick, C++ 的文件依賴實現基本來說非常的簡單。 如果你寫一個
#include "person.h"
預編譯器真的就是把這個文件拼接在了 #include 那裡..
我們假設 person.h 裡面包含
class Person {
public:
std::string name() const;
...
private:
std::string mName;
}
而 main.cpp 依賴於 person.h。 那麼基本上,不管你是怎麼改 person.h, 甚至就算是 touch 了一下, 大部分依賴管理器也會讓編譯器重新編譯一次,區別可能僅僅是好一點的編譯器很快發現其實 person.h 根本沒有變化, 編譯時間稍微短一些而已。
那麼問題來了, 我們現在看到 main.cpp -&> person.h, 意味著任何時候 person.h 的改變都會導致 main.cpp 重新被編譯。在現實情況中, 甚至會有上百的個文件都會依賴於 person.h, 我們並不想因為 person.h 被修改就導致所有依賴於它的文件被編譯,那麼怎麼做呢。 書裡面說了一個小 trick, 把類和類的具體實現分開 - pimpl idom (PIMPL, Rule of Zero and Scott Meyers).
基本上是把這個 Person 類拆開
class Person {
public:
std::string name() const;
...
private:
class PersonImpl * pImpl;
}
in Person.cpp
#include "personimpl.h"
std::string Person::name() {
return pImpl-&>name();
}
PersonImpl 作為一個具體的實現類
class PersonImpl {
public:
std::string name() const {
return mName;
}
...
private:
std::string mName;
}
這裡你可以看到, person.h 裡面只是包含了介面信息,具體的實現挪到了PersonImpl 這個類。 由於 Person 對 PersonImpl 的引用是一個指針, 而指針大小在同一平台是固定的。 所以 person.h 根本就不需要包含 personimp.h ( 注意 person.cpp 需要包含 personimpl.h, 因為需要具體使用到 PersonImpl 的函數)。
於是文件關係依賴改變為:
main.cpp -&> person.h
person.cpp -&> person.h, personimpl.h
personimpl.cpp -&> personimpl.h
所以你看到, main.cpp 和 personimpl.h 徹底解除了依賴關係。如果還有一百個文件依賴於 person.h, 而你又想改 Person 的實現。 由於 Person 的實現在 personimpl.cpp/h 裡面, 不管你怎麼去搞, 由於你壓根就不會去碰 person.h , 所有的依賴 person.h 文件都不會被重新編。
說了怎麼多好處, 那麼這裡給題主提幾個問題思考下:
- 你真的想把你的實現藏在另外一個文件嘛, 你確定看代碼的人看到這種一層套一層的實現到處找你的代碼的時候不會想砍死你。。
- 這個依賴關係又會把編譯時間降低多少呢?如果僅僅是幾個文件依賴這個類定義,多搞一個類出來是否值得?
- 說到把介面和實現分開, 你肯定在想, 尼瑪, 我直接把 Person 搞成一個介面不就行了嘛。 為啥還要這麼麻煩。
=========
如果你思考過這些問題,說明你已經擺脫了教科書裡面這些條條款款, 進入真正的工程實踐領域了。
這種降低文件依賴關係的做法一般並不會在開始寫代碼的時候做, 這個叫 Premature optimization, 因為你可能在優化一個根本就不存在的問題。
在大工程裡面, 把代碼寫得清晰,容易懂, 比什麼優化都重要, 而如果不是大工程, 編譯時間本來也就不長。
而你代碼寫好了以後,如果發現很多文件依賴一個類實現,再把這個類改成 pimpl idom 也不遲。
甚至關於優化編譯時間, 也有非常多的技巧, 比如, 另外一個極端是,就算你不想拆代碼, 構造一個新的 cpp, 把其他所有的 cpp 全部包含進去。
all.cpp
#include "person.cpp"
#include "personimpl.cpp"
#include "main.cpp"
依賴關係不是複雜難搞嘛, 我全部包含在一起,雖然不管改個啥都要重新編,但是只用編一個 cpp 啊,不管編譯還是鏈接都要快幾個數量級。 ( opera 用了這個 trick 編譯時間從 半個小時降到了 5分鐘。。)
對了,對於最後一個問題,介面 + 實現的最大問題是介面本身不能被直接構建出來, 所以
- 沒法在棧上面用
- 需要一個輔助的工廠類
是在說pimpl idiom么(pointer to implementation,https://en.m.wikipedia.org/wiki/Opaque_pointer)?
把類成員把裝在一個struct裡面。類中只放一個指針(用unique_ptr)指向這個struct,然後在類前面forward declare一下,在.cpp中定義這個struct。
優點:
- 多了一層封裝(encapsulation)。- 這樣在.h中就不用include每個成員所需的頭文件。減少編譯時間。- 某個成員的實現被修改後,不必重新編譯依賴它的類,因為只是forward declare,沒有include。減少編譯時間。缺點:
- 運行效率下降:原來可以直接放在stack上的成員變成內存動態分配了。- API難懂,對protected成員不是很適用。看一下Herb Sutter這兩篇文章,所有該知道的都在裡面。包括有哪些坑,怎麼用unique_ptr包裝pimpl,做一個可重複利用的通用庫代碼:1. https://herbsutter.com/gotw/_100/2. https://herbsutter.com/gotw/_101/
最近敲代碼有感。pimpl除了可以隱藏實現,修改後只需重新鏈接這些作用。
還可以防止其他庫中的全局變數等等的污染。。。
拿我最近遇到的例子來說,可能整個類的介面都很清晰,無依賴。但是private中用到了windows的某些東西。這時候如果不用pimpl,以後你只要include了這個類的頭文件,就都附帶上了windows帶來的一大堆全局變數,宏什麼的。。。煩得很。
用了pimpl就可以限制住了
幫樓主把問題補全了。
下面我就這個問題回答一下
c++的類的出現是極大的增加了封裝的難度,原因在於class暴露了過多實現細節,比如
class A
{
public:
int func();
B b;
};
那麼A的編譯必須依賴於B。
其中一種實踐是弱化對B的引用,比如講包含關係改為指針
class B;
class A
{
public:
int func();
B *b;
};
這樣只需要聲明class B就可以完成編譯了。然而這樣還是暴露了A和B之間的關係,實際上對於使用者而言,根本就不需要看到B。
一個更好的優化是使用純虛類將其介面化:
介面頭文件interface.h中聲明一個
class base
{
public:
virtual func() = 0;
}
然後在a.cpp裡面實現它
class A:public base
{
public:
int func();
B b;
};
這樣對於調用者只需要包含interface.h即可,這裡還需要的一個額外需要提供的功能是:必須提供一個工廠來實例化一個介面:
base * p = createA();
相比之下使用c語言做封裝,不要太爽
int open();/*return a handle*/
int func(int handle);/*interface*/
void close(int handle)
手頭沒有書,憑印象告訴你。1.定義對象需要知道對象的size。
2.指針的size固定。
所以handle的方法可以降低依賴,缺點是介面要寫兩遍。頻繁分配釋放內存也有不必要的開銷。還有interface的方法,就是搞個抽象基類當介面,然後用工廠模式。PIMPL的另外一個用途是保持二進位兼容性,結構體加欄位不需要重新編譯用到它的文件,對採用動態編譯的工程來說優勢很大
使用前置聲明(Date和Address)是有條件的,這些類型只能出現在函數參數中,或者以Date*作為class的成員,此時是不需要用到Date的定義的,也不需要知道它佔多少內存。在Person的cpp文件中,要用到Date的具體定義,此時要include它的頭文件。
這裡帶來的好處是使用Person無需include Date,當Date介面有變時,只需重新編譯Person.o,然後重新鏈接,而不用所有代碼重新編譯。你可就把所有內容都上來啊,給你回答問題我還得去翻書啊
推薦閱讀:
※為什麼bs虛函數表的地址(int*)(&bs)與虛函數地址(int*)*(int*)(&bs) 不是同一個?
※C++ 鏈接時間過長,如何找到原因?
※C 語言比 C++ 更強大嗎?
TAG:C | CC | EffectiveC書籍 |