*************************************************
** 0) 引言 **
*************************************************
本文旨在解決新手在理解 #include、標頭檔案和原始檔互動時遇到的常見問題。文章概述並解釋了多項良好實踐,以展示如何避免一些棘手的陷阱。後面的章節將深入探討更高階的主題(內聯和模板),因此即使是經驗豐富的 C++ 程式設計師也可能從中受益!
如果您已經熟悉基礎知識,請隨意跳至第 4 部分。那裡將討論實踐和設計策略。
*************************************************
** 1) 為什麼我們需要標頭檔案。**
*************************************************
如果您剛開始接觸 C++,您可能會想,為什麼需要 #include 檔案,以及為什麼要把一個程式分成多個 .cpp 檔案。原因很簡單:
(1) 它加快了編譯時間。隨著程式的增長,程式碼量也會隨之增加,如果所有程式碼都放在一個檔案中,那麼每次進行微小的改動都需要重新編譯整個檔案。對於小型程式來說,這可能看起來無關緊要(確實如此),但在處理中等規模的專案時,編譯整個程式可能需要“幾分鐘”的時間。您能想象每次微小的改動都要等這麼久嗎?
編譯/等待 8 分鐘/“糟糕,忘了分號”/編譯/等待 8 分鐘/除錯/編譯/等待 8 分鐘/等等
(2) 它使您的程式碼更有條理。如果您將不同的概念分離到特定的檔案中,當您想進行修改(或只是檢視程式碼以記住如何使用它或它如何工作)時,會更容易找到您要找的程式碼。
(3) 它允許您將“介面”與“實現”分離。如果您不理解這是什麼意思,請不用擔心,在本文中我們將貫穿始終地看到它的實際應用。
這些是優點,但顯而易見的缺點是,如果您不理解它是如何工作的,它會使事情變得有點複雜(但實際上,隨著專案規模的增長,它的複雜度比其他替代方案要低)。
C++ 程式是分兩階段構建的。首先,每個原始檔都“獨立編譯”。編譯器為每個已編譯的原始檔生成中間檔案。這些中間檔案通常被稱為“目標檔案”——但不要將其與您程式碼中的物件混淆。一旦所有檔案都已單獨編譯,然後“連結”所有目標檔案,生成最終的可執行檔案(程式)。
這意味著“每個原始檔都是與其他原始檔“分開編譯”的”。因此,在編譯方面,“a.cpp”並不知道“b.cpp”內部發生了什麼。這是一個簡單的例子來說明:
1 2 3 4 5 6 7 8 9 10 11 12 13
|
// in myclass.cpp
class MyClass
{
public:
void foo();
int bar;
};
void MyClass::foo()
{
// do stuff
}
|
1 2 3 4 5 6 7
|
// in main.cpp
int main()
{
MyClass a; // Compiler error: 'MyClass' is unidentified
return 0;
}
|
即使 MyClass 在“您的程式”中已宣告,但在 main.cpp 中並未宣告,因此當您編譯 main.cpp 時會收到該錯誤。
這就是標頭檔案的用武之地。標頭檔案允許您將“介面”(在本例中是 MyClass 類)對其他 .cpp 檔案可見,同時將“實現”(在本例中是 MyClass 的成員函式體)保留在其自己的 .cpp 檔案中。再看同一個例子,但稍作調整:
1 2 3 4 5 6 7 8
|
// in myclass.h
class MyClass
{
public:
void foo();
int bar;
};
|
1 2 3 4 5 6
|
// in myclass.cpp
#include "myclass.h"
void MyClass::foo()
{
}
|
1 2 3 4 5 6 7 8
|
//in main.cpp
#include "myclass.h" // defines MyClass
int main()
{
MyClass a; // no longer produces an error, because MyClass is defined
return 0;
}
|
#include 語句基本上就像一個複製貼上操作。當編譯器編譯您包含的檔案時,“替換” #include 行及其包含的檔案的實際內容。
***************************************************************
** 2) .h/.cpp/.hpp/.cc/etc 之間的區別 **
***************************************************************
所有檔案在本質上都是相同的,因為它們都是文字檔案,但是不同型別的檔案應該有不同的副檔名。
-
標頭檔案 應使用 .h__ 副檔名(.h / .hpp / .hxx)。使用哪個並不重要。
-
C++ 原始檔 應使用 .c__ 副檔名(.cpp / .cxx / .cc)。使用哪個並不重要。
-
C 原始檔 應使用 .c(僅 .c)。
這裡 C 和 C++ 原始檔分開的原因是,它會影響某些編譯器。極有可能(既然您在 C++ 網站上閱讀此文),您將使用 C++ 程式碼,因此請避免使用 .c 副檔名。此外,如果您嘗試編譯標頭檔案副檔名的檔案,編譯器可能會忽略它們。
那麼標頭檔案和原始檔有什麼區別?基本上,標頭檔案是 #included 而不是被編譯,而原始檔是被編譯而不是 #included。您可以嘗試繞過這些約定,讓具有原始檔副檔名的檔案像標頭檔案一樣工作,反之亦然,但您不應該這樣做。我不會列出許多不應該這樣做的原因(除了我已列出的一些原因)——總之,不要這樣做。
唯一的例外是,有時(儘管“非常罕見”)包含原始檔很有用。這種情況與模板例項化有關,不屬於本文的範圍。目前……把它記在腦子裡:“不要 #include 原始檔”。
*****************************************************
** 3) 包含保護 **
*****************************************************
C++ 編譯器本身沒有智慧,所以它們會完全按照您的指示去做。如果您告訴它們多次包含同一個檔案,那麼它們就會這樣做。如果您處理不當,將會導致一些奇怪的錯誤。
1 2 3 4 5 6
|
// myclass.h
class MyClass
{
void DoSomething() { }
};
|
1 2 3
|
// main.cpp
#include "myclass.h" // define MyClass
#include "myclass.h" // Compiler error - MyClass already defined
|
您可能會想:“這太愚蠢了,我為什麼要兩次包含同一個檔案?”您信不信,這種情況會經常發生。不過,不像上面展示的那樣。通常情況下,這是因為您包含了兩個檔案,而這兩個檔案又都包含了同一個檔案。例如:
1 2 3 4
|
// a.h
#include "x.h"
class A { X x; };
|
1 2 3 4
|
// b.h
#include "x.h"
class B { X x; };
|
1 2 3 4
|
// main.cpp
#include "a.h" // also includes "x.h"
#include "b.h" // includes x.h again! ERROR
|
正是因為這種情況,許多人被告知不要在標頭檔案中放置 #include。然而,
這是錯誤的建議,您不應該聽信它。不幸的是,有些人甚至在他們“付費”的 C++ 課程中被這樣“教導”。如果您的 C++ 老師告訴您不要在標頭檔案中 #include,那麼就(不情願地)按照他的指示去做,以便透過課程,但在您離開他的課程後,請戒掉這個習慣。
事實是,在標頭檔案中 #include 並沒有什麼問題——事實上,它非常有益。
前提是您要採取兩項預防措施。
1) 只 #include 您“需要”包含的內容(下一節介紹)。
2) 使用包含保護來防止意外的多次包含。
包含保護是一種技術,它使用一個唯一的識別符號,您在檔案頂部 #define 它。例如:
1 2 3 4 5 6 7 8
|
//x.h
#ifndef __X_H_INCLUDED__ // if x.h hasn't been included yet...
#define __X_H_INCLUDED__ // #define this so the compiler knows it has been included
class X { };
#endif
|
如果標頭檔案第一次被包含,`__X_H_INCLUDED__` 就會被 #define,並且在第二次包含 x.h 時,編譯器會跳過該標頭檔案,因為 `#ifndef` 檢查將失敗。
始終 保護您的標頭檔案。永遠,永遠,永遠。這樣做沒有任何壞處,而且可以避免一些麻煩。在本文的其餘部分,假設所有標頭檔案都已包含保護(即使我在示例中沒有明確寫出)。
您不需要保護您的 .cpp 檔案,因為它們不會被 #included(或者至少不應該被 #included……對吧?
對吧?)
*****************************************************
** 4) “正確”的包含方式 **
*****************************************************
您建立的類通常會依賴於其他類。例如,派生類總是依賴於其基類,因為要從基類派生,它必須在編譯時就瞭解它的基類。
您需要注意兩種基本型別的依賴關係:
1) 可以前向宣告的內容
2) 需要 #include 的內容
例如,如果類 A 使用類 B,那麼類 B 是類 A 的依賴項之一。它是否可以被前向宣告或需要被包含,取決於 B 在 A 中如何被使用。
- 如果:A 完全不引用 B,則不執行任何操作。
- 如果:對 B 的唯一引用是在 `friend` 宣告中,則不執行任何操作。
- 如果:A 包含 B 的指標或引用,則前向宣告 B:`B* myb;`
- 如果:一個或多個函式具有 B 物件/指標/引用,則前向宣告 B。
作為引數或返回型別:`B MyFunction(B myb);`
- 如果:B 是 A 的基類,則 #include "b.h"。
- 如果:A 包含 B 的物件,則 #include "b.h":`B myb;`
您應該選擇儘可能不那麼劇烈的選項。如果可以,就什麼都不做,但如果不能,就選擇前向宣告。但如果確實有必要,那麼就 #include 其他標頭檔案。
理想情況下,類的依賴關係應該在標頭檔案中列出。以下是“正確”標頭檔案通常的結構:
myclass.h
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
|
//=================================
// include guard
#ifndef __MYCLASS_H_INCLUDED__
#define __MYCLASS_H_INCLUDED__
//=================================
// forward declared dependencies
class Foo;
class Bar;
//=================================
// included dependencies
#include <vector>
#include "parent.h"
//=================================
// the actual class
class MyClass : public Parent // Parent object, so #include "parent.h"
{
public:
std::vector<int> avector; // vector object, so #include <vector>
Foo* foo; // Foo pointer, so forward declare Foo
void Func(Bar& bar); // Bar reference, so forward declare Bar
friend class MyFriend; // friend declaration is not a dependency
// don't do anything about MyFriend
};
#endif // __MYCLASS_H_INCLUDED__
|
這顯示了兩種不同型別的依賴關係以及如何處理它們。由於 MyClass 只使用 Foo 的指標而不是完整的 Foo 物件,因此我們可以前向宣告 Foo,而無需 #include "foo.h"。
您應該始終儘可能前向宣告,除非必要,否則不要 #include。不必要的 #include 可能會導致問題。
如果您遵循這個系統,您將能避免很多問題,並將 #include 相關的風險降到最低。
*****************************************************
** 5) 為什麼那是“正確”的包含方式 **
*****************************************************
注意:在本節中,我將上面概述的“正確方式”稱為“我的方式”。雖然我確實是在掙扎了一段時間後自己想出來的——但我不能說我是第一個想到它的人,所以它並不是真正“我的”。但為了本文的目的,我簡單地稱之為“我的”。
您:“某某人說在標頭檔案中 #include 很危險,但您說不是!為什麼您的方法比某某人說的好得多?”
某某人說得部分正確,但解釋錯了。不必要的、粗心的 #include 確實
可能導致問題。避免這些問題的一種方法是
永遠不要在標頭檔案中 #include。所以,某某人的出發點是好的。但最終,採用某某人的方法會給您帶來大量的額外工作和頭痛。
我正在說明的概念非常面向物件(OO),並且增強了封裝。總體的想法是,它使“myclass.h”完全自成一體,並且除了 MyClass 的實現/原始檔外,不需要程式中的任何其他區域知道 MyClass 的內部工作原理。如果其他類需要使用 MyClass,它只需 #include "myclass.h" 即可!
替代方法(某某人的方法)將要求您在 #include "myclass.h"
之前 #include MyClass 的所有依賴項,因為 myclass.h 本身無法包含其依賴項。這會帶來很多麻煩,因為使用一個類不再那麼直接。
這是我方法好的一個例子
1 2 3 4 5 6 7
|
//example.cpp
// I want to use MyClass
#include "myclass.h" // will always work, no matter what MyClass looks like.
// You're done
// (provided myclass.h follows my outline above and does
// not make unnecessary #includes)
|
這是某某人方法糟糕的一個例子
1 2 3 4 5
|
//example.cpp
// I want to use MyClass
#include "myclass.h"
// ERROR 'Parent' undefined
|
某某人:“嗯……好吧……”
1 2 3
|
#include "parent.h"
#include "myclass.h"
// ERROR 'std::vector' undefined
|
1 2 3 4
|
#include "parent.h"
#include <vector>
#include "myclass.h"
// ERROR 'Support' undefined
|
某某人:“搞什麼鬼?MyClass 根本
不使用 Support!好吧……”
1 2 3 4 5
|
#include "parent.h"
#include <vector>
#include "support.h"
#include "myclass.h"
// ERROR 'Support' undefined
|
某某人:“饒了我吧!我正在包含它!你還想要什麼!”
您信不信,上面
確實會發生。可憐的某某人不知道,“parent.h”使用了 Support,因此您必須在“parent.h”
之前 #include "support.h"。
如果 support.h 需要其他東西怎麼辦?如果
那個其他東西需要其他東西怎麼辦?僅使用一個類,我們已經需要 4 個 #include 了!使用某某人的方法,您不僅要記住每個類需要哪些 include,還要記住
包含它們的順序。這會
非常快地變成一個
巨大的噩夢。
如果您想調整 MyClass 怎麼辦?比如說,您決定使用 std::list 而不是 std::vector 會更好。使用某某人的方法,您現在必須回溯並更改
每個 #include "myclass.h" 的檔案,並將其更改為包含 <list> 而不是 <vector>(根據專案的大小和 MyClass 的使用頻率,這可能是幾十個檔案),而使用我的方法,您只需要更改 "myclass.h" 和可能 "myclass.cpp"。
我上面說明的“正確方式”完全是為了封裝。想要使用 MyClass 的檔案無需瞭解 MyClass 使用了什麼就能正常工作,並且無需 #include 任何 MyClass 的依賴項。要讓 MyClass 工作,您只需要 #include "myclass.h"。僅此而已!標頭檔案被設定為完全自包含。它對 OO 非常友好,非常易於使用,並且非常易於維護。
*****************************************************
** 6) 迴圈依賴 **
*****************************************************
迴圈依賴是指兩個(或多個)類相互依賴。例如,類 A 依賴類 B,類 B 依賴類 A。
如果您遵循“正確方式”,並在可以前向宣告時前向宣告,而不是不必要地 #include,這通常不是問題。只要迴圈在某個點被前向宣告打破,您就沒問題了。
以下是為什麼您應該只 #include 必要內容的完美示例:
1 2 3 4
|
// a.h -- assume it's guarded
#include "b.h"
class A { B* b; };
|
1 2 3 4
|
// b.h -- assume it's guarded
#include "a.h"
class B { A* a };
|
乍一看可能沒問題。B 是 A 的依賴項,所以您包含它;A 是 B 的依賴項,所以您包含它。那麼有什麼問題呢?
這是迴圈包含(也稱為無限包含),它是一個或多個不應該存在的 include 的結果。例如,假設您編譯 "a.cpp":
1 2
|
// a.cpp
#include "a.h"
|
編譯器將執行以下操作:
1 2 3 4 5 6 7 8 9 10 11 12
|
#include "a.h"
// start compiling a.h
#include "b.h"
// start compiling b.h
#include "a.h"
// compilation of a.h skipped because it's guarded
// resume compiling b.h
class B { A* a }; // <--- ERROR, A is undeclared
|
即使您 #included "a.h",編譯器在 B 類編譯完成之前也看不到 A 類。這是因為存在迴圈包含問題。這就是為什麼您應該
始終在前向宣告時只使用指標或引用。這裡,“a.h”不應該 #include b.h,而應該只是前向宣告 B。同樣,b.h 應該前向宣告 A。如果您進行了這些更改,問題就解決了。
如果兩個依賴項都是 #include 依賴項(即它們不能被前向宣告),迴圈包含問題可能會持續存在。例如:
1 2 3 4 5 6 7 8
|
// a.h (guarded)
#include "b.h"
class A
{
B b; // B is an object, can't be forward declared
};
|
1 2 3 4 5 6 7 8
|
// b.h (guarded)
#include "a.h"
class B
{
A a; // A is an object, can't be forward declared
};
|
然而,您可能會注意到,這種情況“概念上是不可能的”。存在根本性的設計缺陷。如果 A 有一個 B 物件,而 B 有一個 A 物件,那麼 A 包含一個 B,B 包含另一個 A,A 包含另一個 B,B 包含另一個 A,依此類推。您遇到了無限遞迴問題,而其中一個類根本不可能例項化。解決方案是讓一個或兩個類包含另一個類的
指標或引用,然後您可以前向宣告,從而繞過迴圈包含問題。
*****************************************************
** 7) 函式內聯 **
*****************************************************
行內函數的問題在於,它們的函式體必須存在於呼叫它們的每個 cpp 檔案中,否則您將遇到連結器錯誤(因為它們不能在連結器過程中進行連結——它們需要在編譯器過程中編譯到程式碼中)。
這可能會開啟迴圈引用或其他可能使“正確方式”概述變得複雜的情況。
1 2 3 4 5 6 7 8 9 10
|
class B
{
public:
void Func(const A& a) // parameter, so forward declare is okay
{
a.DoSomething(); // but now that we've dereferenced it, it
// becomes an #include dependency
// = we now have a potential circular inclusion
}
};
|
關鍵在於,雖然行內函數需要存在於標頭檔案中,但它們
不需要存在於類定義本身中。這允許我們利用一個漏洞:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
|
// b.h (assume its guarded)
//------------------
class A; // forward declared dependency
//------------------
class B
{
public:
void Func(const A& a); // okay, A is forward declared
};
//------------------
#include "a.h" // A is now an include dependency
inline void B::Func(const A& a)
{
a.DoSomething(); // okay! a.h has been included
}
|
雖然您乍一看可能不這麼認為……這是“完全安全”的。即使 a.h 包含 b.h,迴圈包含問題也會被完全避免。這是因為額外的 #include 要到類 B 完全定義
之後才會出現,因此它們是無害的。
您:“但把函式體放在標頭檔案末尾很醜陋。有沒有辦法避免這種情況?”
我:“當然!只需將函式體移到另一個頭檔案即可。”
1 2 3 4 5 6 7
|
// b.h
// blah blah
class B { /* blah blah */ };
#include "b_inline.h" // or I sometimes use "b.hpp"
|
1 2 3 4 5 6 7 8 9 10 11
|
// b_inline.h (or b.hpp -- whatever)
#include "a.h"
#include "b.h" // not necessary, but harmless
// you can do this to make this "feel" like a source
// file, even though it isn't
inline void B::Func(const A& a)
{
a.DoSomething();
}
|
這將在分離介面和實現的同時,仍然允許實現內聯。您也可以有一個普通的“b.cpp”檔案用於未內聯的實現。
*****************************************************
** 8) 前向宣告模板 **
*****************************************************
在處理模板類時,對於簡單類而言,前向宣告相當直接,但情況並非如此簡單。考慮以下場景:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
// a.h
// included dependencies
#include "b.h"
// the class template
template <typename T>
class Tem
{
/*...*/
B b;
};
// class most commonly used with 'int'
typedef Tem<int> A; // typedef'd as 'A'
|
1 2 3 4 5 6 7 8 9 10 11
|
// b.h
// forward declared dependencies
class A; // error!
// the class
class B
{
/* ... */
A* ptr;
};
|
雖然這看起來完全合乎邏輯,但它不起作用!(儘管邏輯上您確實認為它應該起作用。這是語言的一個令人不快的特性)。因為 'A' 實際上不是一個類,而是 typedef,編譯器會報錯。另外請注意,由於存在迴圈依賴問題,我們不能只 #include "a.h"。
為了前向宣告 'A',我們需要 typedef 它。這意味著我們需要前向宣告它的 typedef。前向宣告它的方式如下:
1 2
|
template <typename T> class Tem; // forward declare our template
typedef Tem<int> A; // then typedef 'A'
|
這比 `class A;` 醜陋得多,但仍然是必要的邪惡。然而,這使得模板類的封裝變得困難,因為它要求每個前向宣告它的類都確切地知道模板的佈局。如果將來發生更改,您將面臨巨大的清理工作。
這個問題的一個實際解決方案是建立一個備用標頭檔案,其中包含您的模板類及其 typedef 的前向宣告。以下是處理上述示例的一種更優雅的方法:
1 2 3 4 5 6 7 8 9 10
|
//a.h
#include "b.h"
template <typename T>
class Tem
{
/*...*/
B b;
};
|
1 2 3 4
|
//a_fwd.h
template <typename T> class Tem;
typedef Tem<int> A;
|
1 2 3 4 5 6 7 8 9
|
//b.h
#include "a_fwd.h"
class B
{
/*...*/
A* ptr;
};
|
這允許 B 包含一個前向宣告 A 的標頭檔案,而無需包含整個類定義。