轉載請註明文章出處:https://tlanyan.me/cpp-forwar...
前置聲明是C/C++
開發中比較常用的技巧,主要用在三種情形:
- 變量/常量,例如
extern int var1;
; - 函數,例如
void foo();
,注意類的成員函數無法單獨做前置聲明
; - 類,例如
class Foo;
,也可以前置聲明模板類:template class<typename T1, int SIZE>Foo;
。如果類包含在名字空間中,需在名字空間內做前置聲明:namespace tlanyan {class Foo;};
,而不能這樣:class tlanyan::Foo;
。
前置聲明作用
根據其用途,前置聲明的主要作用爲:
- 避免重複定義變量;
- 避免引入函數定義/聲明文件,從而函數文件發生更改時不會重新編譯依賴文件;
- 解決循環依賴問題。
前兩種用途好理解,第三種稍微複雜點,但卻是前置聲明最重要的用途。其解決類A包含類B,同時類B包含類A的依賴問題。循環依賴一般是設計層面的問題,可通過接口、引入輔助類等手段化解。前置聲明也能解決,只是架構上稍微彆扭。
不管A和B是否定義在同一個文件中,c++永遠無法解決如下形式的循環依賴
(後文解釋原因):
<pre>// file: A.hpp
include "B.hpp"
class A {
int id;
B b;
};
// file: B.hpp
include "A.hpp"
class B {
...
A a;
};
</pre>
前置聲明解決該問題需要與指針配合
,轉換成另一種形式。要點如下:
- 至少將某類的變量類型轉換成指針,例如A中將B轉成
B*
; - 類A中對B使用前置聲明;
- 類A的定義文件中移除對類B文件的包含(做了包含保護則可忽略)。
使用前置聲明後,以下是一種可行的解決形式(兩個類均使用了前置聲明):
<pre>// file: A.hpp
//3. 移除對B的包含(使用了#pragma once或者#ifndef B_HPP等保護措施則無必要)
// 2. 前置聲明類B
class B;
class A {
int id;
// 1. 成員變量轉換成指針
B* b;
};
// file: B.hpp
// 3. 移除對A的包含(有包含保護則非必要)
// 2. 前置聲明類A
class B {
...
// 1. 成員變量轉換成指針
A* a;
};
</pre>
深入前置聲明
如果你有其他編程語言的經驗,會發現c++有點怪異:Java/C#/Python/PHP等語言可以輕鬆做到循環引用,無需使用類似的前置聲明技巧。這不禁讓人思考:C++爲何必須要用前置聲明才能化解?
原因在於C++定義對象有兩種方式:一種是A a
形式,a
即對象,調用成員變量或函數用.
,對象在棧中分配;另一種是A* a
,a
是指針,調用成員變量或函數用->
,其指向地址存儲實際對象,對象在堆中分配。
分配對象需要知道具體的內存大小,但以下形式我們不能確定類A和類B對象的大小:
<pre>class A {
B b;
};
class B {
A a;
};
</pre>
對於這個簡單例子,你可以直觀認爲A和B佔用同樣的內存,例如1字節,但也可以是2字節,3字節等;根據內存對齊要求,一般是4字節,8字節等。無論哪種情況,編譯器無法確定其對象佔用內存,便會報錯停止編譯。所以你應該知道爲什麼C++永遠不應該(不能)這樣做了吧?
那爲何前置聲明加指針的組合能解決循環引用問題的呢?因爲正常情況下,數據類型指針在同一機器的編譯器裏佔同樣的內存
。指針一般是4或者8個字節,對應32和64位指針。用了指針,即使有循環引用,類的大小也能輕易的確定下來。這也是Java/C#/Python/PHP等可以輕鬆循環引用的原因:這些語言中,對象變量其實都是指針
,也意味着對象變量都是引用傳遞
。
如果不移除文件的相互包含,能否省去前置聲明呢?答案是不能,原因如下:
- C++按照一個個編譯單元(translation unit)進行編譯,如果兩個文件互相包含且沒有
#pragma once
等包含保護措施,則會出現遞歸包含,編譯器報錯; - 如果兩個頭文件都有文件包含保護,編譯A時會把B包含進來,但因爲B包含了A,A中的包含保護生效,導致B文件內的內容實際未引入A,於是報B爲未知符號的錯誤。
總的來說,不管是否移除對方的頭文件,前置聲明都是必須的。實踐中爲了避免文件變動時重新編譯的耗費,移除不必要的頭文件是一個好習慣。