C++20通過了一項非常重要的特性:提供import機制。使用import來引用某個導出的庫,而不是基於源代碼展開的#include來引用頭文件,有助於寫出更直觀的代碼,減少編譯時間。都9102年了,要知道在很多年前,C#,Java就支持了這個特性,如C#,以Assembly(程序集)的形式來管理模塊,而C語言,C++,從誕生開始到現在仍然在用最原始的#include,真是讓人頭大。
那么,我就來稍微總結一下(我自認為的)良好的C/C++的工程代碼結構應該是什么樣子。在新建一個新工程的時候,我們可以以這個為模板,快速構建一個工程。
一、工程的目錄結構
一個良好結構的代碼,一般會有以下幾個部分:
● 構建系統:如CMakeLists.txt或者makefile腳本
● 自述文件:里面有說明和協議(License)
● 源代碼:如果這個工程是開源的,那么它會有它的源代碼。很多開源工程源代碼放在了src文件夾
● 靜態鏈接庫:作者可能已經為你編好了靜態庫。很多工程靜態鏈接庫放在了lib文件夾中。
● 二進制文件:作者可能為你生成好了二進制文件,比如Windows下它里面包含dll和lib。很多工程二進制文件放在了bin文件夾。
● 頭文件:基本上所有的開源庫都會提供頭文件。C/C++畢竟不像C#那樣方便,你要知道類和函數的聲明才可以正確使用它。
以freetype某個版本為例:

freetype可以說是一個很典型的結構了。builds里面為各種平台提供了構建環境,比如它生成了vs2010,vs2008等IDE的sln。它也提供Makefile和CMakeLists.txt,能夠方便構建出它的編譯環境。src里面是freetype的源代碼,include里面是src所引用的頭文件。根目錄上的README則是自述文件,告訴使用者如何使用庫,以及要注意的方面。
freetype需要使用者自己編譯,因此它沒有提供lib或者dll,或者是.so這樣的文件。不過這並不影響什么,因為它已經考慮到了足夠多,使用者應該可以輕輕松松編出包來。
二、解決方案的結構
開源庫的作者應該充分考慮到使用者環境的多樣性,比如,使用者可能不是在Windows平台,可能用的是GCC等。所以我們需要為各種環境來生成工程的解決方案。所以現在很多工程都會使用CMake,就是因為CMake是跨平台的,寫好一份CMake腳本,就可以在各個平台上生成解決方案。
開發者應該給使用者至少提供動態鏈接庫的版本和靜態鏈接的版本。例如,在我自己寫的渲染引擎GameMachine中,為每一個庫工程都生成了靜態和動態版本,它們在Windows下分別生成lib文件和dll+lib文件。順便貼上這個庫的地址:

靜態庫和動態庫的區別在於,靜態庫是不需要導出函數的,它直接被編譯進生成的二進制文件中,而動態庫需要導出函數,以便自己的工程能在dll中找到某函數的偏移地址。從編譯選項的角度來說,動態庫往往比靜態庫多一個宏,表明自己是個動態庫。例如上面的GameMachine,如果是個動態庫,我就給它增加了個GM_DLL預處理宏。具體的原因我稍后再說。
三、工程的代碼結構
1. 區分公開部分和非公開部分
我個人認為,一個良好的工程應該區分公開部分和非公開部分。公開和非公開是針對文件,而不是C++層面的public和private限定符。簡單來說就是,我應該把別人要用的頭文件放到include文件夾,並且如果依賴了其它庫,且這個庫位於自己的代碼倉庫,那么自己的源代碼路徑的頭文件盡量不要用#include <>形式引用其它庫(個人建議)。
舉個例子,假設我有個工程A,它依賴了工程B,並且把工程B的include文件夾設置為了自己的include directory。
A --
|--src
|--A.h
|--A.cpp
B --
|--include
|--B.h
並且A.h里面直接引用了B.h:
#pragma once #include <B.h>
上面的代碼在結構上有一些瑕疵。假如使用者C想要使用工程A,那么他首先需要include A.h。但是A工程里面並沒有include文件夾,C迫不得已只能將src設置為自己的include文件夾。如果src文件夾里面有很多文件和C的頭文件重名,那么代碼會造成混亂,所以首先應該在A工程加個include文件夾,並且新建個A.h:
A --
|--src
|--A.h
|--A.cpp
|--include
|--A.h
B --
|--include
|--B.h
include中A.h的代碼非常簡單,轉而引用真正的A.h:
#include "../src/A.h"
這樣,使用者C只需要把A的include文件夾設置為include directory,便可以使用A庫所提供的功能了,避免了頭文件的混亂。
下面來說一下另外一個問題。由於之前的假設是A和B在一個倉庫,而A是通過設置B的include directory來引用B的頭文件:
#pragma once #include <B.h>
這意味着使用者C也必須將B的include也設置成自己工程的include directory,否則當編譯器遇到#include <B.h>時,它只會嘗試查找A/include/B.h,然后發現並不存在這個文件,拋出一個抱怨。
這種情況下,A中的src的A.h,應該以相對路徑的方式來引用B.h,將它改為:
#pragma once #include "../../B/include/B.h"
當然,這么改可能對於庫作者來說是難看了點,但是減少了使用者出錯的概率。
當然,還有其它很多種方法可以來解決這樣的開發者工程屬性和使用者工程屬性不一致而導致的問題,比如開發者可以寫一個CMake宏,來自動幫使用者生成一個解決方案,並且設置正確的include directory。
2. 頭文件的依賴一定要寫清楚
什么意思呢?還是假設有一個工程A:
A --
|--src
|--A.h
|--A.cpp
|--B.h
|--include
|--A.h
|--B.h
其中include中的A.h和B.h分別引入了src里面的A.h和B.h。其中B.h依賴了A.h,但是它又沒有#include <A.h>:
// B.h #progma once inline void B_doSomething() { A a; a.doSomething(); }
然后庫作者寫出了這樣的代碼:
#pragma once #include <A.h> #include <B.h> inline void foo() { B_doSomething(); }
這段代碼對於庫的開發者來說不會報錯,因為雖然B.h沒有include A.h,但是在include B.h之前,A.h已經被引入。但是對於使用者來說,就可能會很有些問題。他可能並不知道B.h是要依賴A.h的,假如他寫了下面這樣的代碼:
#pragma once #include <B.h> inline void foo() { B_doSomething(); }
直接報錯!因為B_doSomething里面用到了A,但是B.h並沒有include A.h。並且編譯器的錯誤會非常含糊,會說找不到A這個類型,然后使用者一臉蒙蔽,他並不知道還需要哪個頭文件!修正的方法就是修改B.h的頭文件,讓它include A.h:
// B.h #progma once #include <A.h> inline void B_doSomething() { A a; a.doSomething(); }
PS: 有些頭文件,比如freetype或者windows的一些頭文件,會檢查某個頭文件是否被include,否則會通過#error拋出個錯誤提醒用戶引入。
3. 區分導出和非導出函數
當我們寫一個動態庫時,我們要設置函數的可見性,把需要公開的函數表明為可見函數,也就是導出函數。我們編寫靜態庫時,則不能設置它可見性,因為它是二進制輸出文件的一部分。
由於我們用到的是同一份頭文件,因此最好的方法就是用宏來區分這個頭文件是給誰用的。一般來說有2個群體,每個群體2種輸出類型:
- 這個頭文件給庫開發者用,靜態庫
- 這個頭文件給庫開發者用,動態庫
- 這個頭文件給庫使用者用,靜態庫
- 這個頭文件給庫使用者用,動態庫
我們分別用2個宏,第一個宏用來區分是開發者還是使用者,第二個宏來區分是靜態庫還是動態庫。
以GameMachine為例:

gamemachine和gamemachine_static分別是dll和lib庫(開發者),gamemachinedemo和gamemachinedemo_dll分別是用了gamemachine和gamemachine_static的exe程序(使用者)。
gamemachine工程定義了GM_DLL宏,表示它是一個dll工程。gamemachinedemo定義了GM_USE_DLL宏,表示它用到的是gamemachine的動態庫工程。
#ifndef GM_DECL_EXPORT # ifdef GM_WINDOWS # define GM_DECL_EXPORT __declspec(dllexport) # elif GM_GCC # define GM_DECL_EXPORT __attribute__((visibility("default"))) # endif # ifndef GM_DECL_EXPORT # define GM_DECL_EXPORT # endif #endif #ifndef GM_DECL_IMPORT # if GM_WINDOWS # define GM_DECL_IMPORT __declspec(dllimport) # else # define GM_DECL_IMPORT # endif #endif #ifdef GM_DLL # ifndef GM_EXPORT # define GM_EXPORT GM_DECL_EXPORT # endif #else # if GM_USE_DLL # ifndef GM_EXPORT # define GM_EXPORT GM_DECL_IMPORT # endif # else # ifndef GM_EXPORT # define GM_EXPORT # endif # endif #endif
以上是GameMachine用來區分導入、導出函數的宏。以VS為例,GM_DECL_EXPORT表示__declspec(dllexport),GM_DECL_IMPORT表示__declspec(dllimport)。
那么:
1. gamemachine的GM_EXPORT將展開為__declspec(dllexport)
2. gamemachine的GM_EXPORT將展開為空
3. gamemachinedemo的GM_EXPORT將展開為空
4. gamemachinedemo_dll的GM_EXPORT將展開為__declspec(dllimport)
就這樣,通過GM_EXPORT宏,巧妙將類和函數導出了。
class GM_EXPORT GameMachine { };
這是一種非常常見的手法,遍布各種庫,它可以作為一個模板,應用到各個C/C++工程中去。
以上便是我對於良好的工程結構的一些理解,歡迎大家補充和討論。
里面說的很多手法,主要還是要結合實際,實踐才是檢驗真理的唯一標准。
