# C++ 開發環境與專案設定指南
在軟體開發的過程中,建置專案是一個必須的步驟。然而,當專案的規模逐漸擴大、原始碼的檔案越來越多時,如何有效地管理每一個檔案的編譯順序、相依關係與目標設定,就成為一個工程上需要嚴謹思考的問題。
當專案規模較小時,開發者可以會手動使用編譯器(如 g++)羅列檔案,輸入冗長的指令來建構整個程式。這種方式不僅繁瑣,且容易出錯,更不利於團隊協作與跨平台部署。為了解決這個問題,各種建構系統(Build System)來自動化與管理編譯流程。而 CMake,正是其中最廣泛使用的一種。
>[!Note] 引言
> 本文章開發環境基於 VS Build、Visual Studio Code 與 CMake,並使用 LLVM 相關的專案來設定、編譯專案,相較於平常學校撰寫的幾個小檔案的作業,我們將要設定一些稍微大型的專案。除此之外,還會補充分離式編譯、搜尋路徑等概念,因為這些知識須等到大三同學們修習編譯器時才會有更深入體會,這裡我會盡可能地用簡單的方式來說明。
>[!Important] 誰適合閱讀此文章
> 本文章撰寫的時候,預設會使用 Windows 進行說明,因為原意是寫給大一修習「物件導向程式設計」的學生。如果你是**非**Windows用戶,其實更加單純,安裝的時候可以不用安裝VS BuildTools,然後其他的全部下載`.tar*`格式,解壓縮後丟到 `/usr/local`。macOS 作法接近,但需注意 macOS 為非 GNU/Linux 系統,可能在動態連結庫格式、CPU 架構與執行簽章限制上與 Linux 有所不同,請確認 binary 相容後再安裝。
>
> 本文章用意是讓學生學習跨平台的C++環境架設,並學習專案架構與CMake的簡單用法。
## 生態系比較
| 套件 | 風格 | 優缺點 |
| ------------------------------- | ------------------ | ------------------------------------------ |
| 🔵 Visual Studio 全家桶 | 微軟生態 | 一切都有,但很稍顯肥大 |
| 🟢 WSL2 + VSCode | 類 Linux | 環境相對簡單、可跨平台 |
| 🔴 Windows SDK + LLVM + CMake | LLVM 生態 | 靈活但複雜 |
| 🟡 MSYS2 / MinGW | 類 UNIX in Windows | 可用 GCC/libstdc++,編譯快但和 MSVC 不兼容 |
>[!Tip]
其實以筆者個人經驗,如果你學會配置 Linux 環境,並且沒有需求要撰寫 Windows 專用的程式( 如 *WinForm* , *.NET* ) 之類的最簡單的環境可能是直接安裝 WSL2 然後設定一下 CMake 跟 Ninja,然後安裝一下編譯器就好。
「Visual Studio 稍顯肥大」的意思是:
Visual Studio 全家桶 ≒ 新安裝的 Linux 作業系統 + CMake + 編譯器 + VSCode
但也不否認 Visual Studio 整個環境配置完善是真的很強,該有的套件應有盡有,也有好好整合CMake的工具鏈以及 vcpkg 等輔助工具。
## 安裝套件
### Visual Studio Buildtools
>[!Warning]
如果已經安裝了 VS2019 或是 VS2022,可以跳過該章節
[Visual Studio Buildtools](https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022) 是微軟提供的基於命令列的VS工具包,是一個精簡版的 Visual Studio,只包含編譯器工具鏈與建構系統,沒有完整的圖形化 IDE、編輯器、設計工具、範本等工具。對於 C++ 專案的基本開發,只需要安裝以下套件:

它包含了 MSVC 標頭與 lib(C++ STL, runtime, Windows headers)與 msbuild 等工具。
### Visual Studio Code
[Official Download](https://code.visualstudio.com/)
VSCode 是由微軟開發的免費開源跨平台程式碼編輯器。它支援多種編程語言,並提供許多強大的功能,如語法高亮、自動補全、調試支持以及豐富的擴展插件系統。用戶可以安裝各種插件來增強 VSCode 的功能,比如 Git 支援、Python 語言支援等。
### CMake 3.28 以上
[Official Download](https://cmake.org/download/)
CMake 是一個跨平台的開源建構系統工具,主要用來自動化編譯過程。它將源碼和編譯選項轉換為平台特定的編譯命令(如 Makefile 或 Visual Studio 工程),從而幫助開發者簡化不同平台上編譯過程的管理。請確保安裝的版本在 3.28 以上最好

### LLVM Projects v19 以上
[Github Release Page](https://github.com/llvm/llvm-project/releases)
[Debian Packages](https://apt.llvm.org/)
LLVM 是一個開源的編譯器基礎設施,設計上旨在為各種編程語言提供後端支持。LLVM 其實是一組工具和庫,幫助開發者編寫編譯器、靜態分析工具等。它支持生成高效的機器碼,並提供了優化工具、分析工具等功能。

### Ninja
[Github Release Page](https://github.com/ninja-build/ninja/releases)
Ninja 是一個輕量級的建構系統,設計目的是用來進行快速、高效的編譯和構建過程。它通常與其他工具(如 CMake)搭配使用,生成用於構建的指令檔案(如 build.ninja),並依此執行構建過程。

安裝完成後,可以先打開終端機,Ex. 安裝 Git 附帶的 `Bash`,並嘗試執行幾個指令:
```sh
cmake --version
ninja --version
clang -v
```
如果每個指令都有正確輸出版本資訊,就是安裝成功了。若沒有正確輸出,確保所有的執行檔在環境變數中:

以筆者的例子來說
- D:\LLVM
- D:\CMake
分別是安裝 LLVM 與 CMake 的路徑,那就把 `D:\LLVM\bin` 以及 `D:\CMake\bin` 放到環境變數即可。如果你使用的是 Linux,套件管理工具應該會幫你設定好。
>[!Tip]
>在 Linux 的開發者,不少人都喜歡自己管理工具的位置,或是你有需要管理不同的工具版本,建議的做法是下載編譯好的 archive 檔案,比方說 .zip 或是 .tar 檔案,然後直接解壓縮到 `$HOME/.local` 底下即可。
>若希望這些工具可以讓所有該主機的用戶使用,可以選擇解壓縮到 `/usr/local` 底下
Ninja 下載後應該是單獨一個執行檔,可以丟到 CMake 安裝路徑底下的 bin 資料夾下即可
## Visual Studio Code 整合
### 相關擴充
以下幾個插件請同學們進行安裝,它們分別是針對語法提示、程式碼風格化、偵錯器進行設定的工具
接下的專案,將會先請先在你的 Visual Studio Code 安裝以下的延伸模組:

另外也推薦你安裝 `Editorconfig`,用來整合整個團隊的編輯器設定:

接下來,請隨便開啟一個 C++ 檔案,他可能會跳出提示,跟你說找不到 clangd。省麻煩的可以直接按 install,Visual Studio 會嘗試幫你安裝

另一種做法是,在 Visual Studio 的頁籤中:找到設定,然後搜尋 `clangd` 的設定,手動把 clangd 的執行檔位置填入(此範例是 `/usr/local/bin/clangd`)


把滑鼠移動到系統的標頭上

如果有出現提示,就是 clangd 設定完成。
也可以打開終端機,切換到「輸出」面板,右側選擇 clangd 輸出

有看到 clangd 的運作輸出,就是正常執行中。其中 LSP 的意義是 `Language Server Protocol`
> 語言伺服器協定(Language Server Protocol,LSP)是一個開放的、基於JSON-RPC的網路傳輸協定,原始碼編輯器或整合式開發環境(IDE)與提供特定程式語言特性的伺服器之間互動時會用到這個協定。該協定的目標是讓編輯器或整合式開發環境能支援更多的程式語言。
### 建議設定

在設定右邊,有一個「*開啟設定(JSON)*」,會使用JSON格式來顯示目前系統的設定,舉例來說:


以上兩張圖都是關於`Editor`的相關設定,一個使用 GUI 呈現;你也可以選擇使用 JSON 格式來編寫你的系統設定。
這裡推薦幾個設定:
- 使用 Clang-format 進行格式化
在 C++ 檔案點選右鍵 -> 文件格式化方式 -> 選擇預設格式器 -> Clang-Format


對應的 JSON 格式設定:
```json
"[c]": {
"editor.defaultFormatter": "xaver.clang-format"
},
"[cpp]": {
"editor.defaultFormatter": "xaver.clang-format"
},
```
- Clangd 的相關參數
Clangd 找到 Arguments,填入對應的資訊:

對應的 JSON 格式設定:
```json
"clangd.arguments": [
"--all-scopes-completion",
"--background-index",
"--clang-tidy",
"--compile-commands-dir=${workspaceFolder}/build",
"--completion-parse=always",
"--completion-style=detailed",
"--fallback-style=google",
"--function-arg-placeholders=0",
"--header-insertion=never",
"--pch-storage=memory",
"--import-insertions",
"--enable-config"
]
```
- 內嵌提示 inlayHints

這個可以根據自己的喜好來設定,有分成開、關、「按下 Ctrl + Alt 時關閉」、「按下 Ctrl + Alt 時開啟」

開啟的話會顯示參數的名稱、自動推導的型別等,像我個人覺得很礙眼,所以設定是「按下 Ctrl + Alt 時開啟」
### 個人化設定
Visual Studio Code 除了可以設定整個系統的設定,也可以設定工作區的個人化設定。
工作區就是目前開啟的資料夾位置,可以在 `.vscode` 中設定個人化的設定,這些設定通常不會同步到雲端上,細節可以查看 VSCode 的[官方說明](https://code.visualstudio.com/docs/configure/settings)。
接下來我們就要正式進入專案設定的說明,但是在進入專案設定之前,會簡單補充一下**分離式編譯**的概念,讀者可以選擇性跳過,直接到最後的專案設定教學。
## 分離式編譯
在正式說明如何設定專案之前,首先簡單的講一下分離式編譯與查找路徑。分離式編譯會在 OOP 的課程上提到,但是更正式一點必須得要到編譯器的課程才會提及。通常學生只會被 OOP 課程的一句話快速帶過:
> 分離式編譯可以讓我們在撰寫程式的時候,把聲明與實作給分開
畢竟 OOP 是給大一學生的課程,諸如符號表、靜態鏈結、動態鏈結等觀念自然不會深入說明,所以同學們會一直抱著一個疑問:「為什麼我一定得要把`.hpp` 檔案跟 `.cpp` 檔案分開來?」鑒於各位到大三編譯器應該也不會認真上課,這裡筆者就簡單解釋一下。

[該網站](https://hackingcpp.com/cpp/lang/separate_compilation.html)的寫的其實很清楚,但同學們現階段應該無法獨立理解整個過程,因此助教簡單的導讀一下
### 編譯執行檔
假設一開始我們只有一個簡單的程式:
```cpp=
// main.cpp
#include <iostream>
int main() {
std::cout << "Hello, world!" << std::endl;
}
```
通常會用一個簡單的指令進行編譯:
```sh
clang++ ./main.cpp -o hello
#會輸出 "Hello, world!"
./hello
```
在這個情境下,很單純的就是生成一個可執行檔 `hello`
### 加入標頭檔
現在情境稍微複雜一點,比方說有以下三個檔案
```cpp=
// main.cc
#include <iostream>
#include "sum.hh"
int main() {
std::cout << sum(100, 200) << '\n';
}
```
```cpp=
// sum.hh
#ifndef _SUM_H_
#define _SUM_H_
int sum(int a, int b);
#endif
```
```cpp=
// sum.cc
#include "sum.hh"
int sum(int a, int b) {
return a + b;
}
```
那我們可以使用幾種方法進行編譯:
第一種是把所有的**實作原始碼**當作輸入,直接進行編譯
```
clang++ main.cc sum.cc -o sum
```
第二種是先把原始碼編成**目標檔**,然後再產生**可執行檔**
```
clang++ -c sum.cc -o sum.o
clang++ main.cc sum.o -o sum
```
第三種是先把原始碼編成**函式庫**,然後再產生**可執行檔**
```
clang++ -fPIC -shared sum.cc -o libsum.so
clang++ main.cc -L. -lsum -o sum
```
以下分別是他們的檔案大小與描述


而第二種與第三種作法,是本段的主旨:分離式編譯
- `.o` 檔的意義是 `Object File`,是編譯的中介產物
- `.so` 檔的意義是 `Shared Object`,是允許共享的函示庫
`.so` 檔案與 Windows 的 `.dll`(Dynamic Link Library) 意義相同,就是先編好的一組函式庫。
### 現實中的比喻
這裡用熟悉的生活經驗來理解抽象概念,分離式編譯到底在幹嘛:
把編譯的過程想像成製作一道菜,比方說番茄肉醬義大利麵
- 第一種方法就是一次全煮:
- 自己切番茄、洋蔥
- 炒絞肉
- 煮醬料
- 煮麵
- 擺盤
- 第三種方法就是先去買調好的肉醬(`.so檔案`):
- 煮麵
- 倒醬料
- 擺盤
那 `.o` 檔案這個東西,因為他是編譯的中間產物,約等於以下物品
- 切好的番茄
- 炒好的絞肉
那你可以把多個 `.o` 檔案先編成 `.so` 檔案,那就是你先做一罐醬料,等以後使用;
也可以把多個 `.o` 檔案直接編成執行檔,該情境就是直接下鍋炒出成品出來。
- `.cc` 檔案是生鮮食材
- `.o` 檔案是你備好的料,可以直接下鍋後出盤,也可以煮成醬料用
- `.so` 檔案是預先做好的調味粉or醬料包,需要就加進去

這裡感謝 chatgpt 把筆者的比喻繪製成圖片
其實還有一種叫做 `.a` 的檔案,意義是 *Archive File*,其實也是一堆 `.o` 檔案的集合
他跟 `.so` 檔案主要的差異就是動態鏈結與靜態鏈結,這個主題就跳過不提
### 搜尋路徑 -I
在上個情境中,程式檔案目錄如下:
```
example1
├── main.cc
├── sum.cc
└── sum.hh
```
並且所有的指令都在 `example1` 資料夾底下執行,情境二的檔案目錄如下:
```
example2
├── include
│ └── sum.hh
├── main.cc
└── sum.cc
```
我們用同樣的編譯指令執行,會出現錯誤:

原因是因為 `sum.hh` 不在編譯器搜尋標頭檔的目錄,有兩種解法:
1. 分別修改 `sum.cc` 跟 `main.cc`
- `main.cc` 把 `#include "sum.hh"` 改成 `#include "include/sum.hh"`
- `sum.cc` 把 `#include "sum.hh"` 改成 `#include "include/sum.hh"`
2. 在編譯器中,使用 `-I` 參數
```sh
clang++ -Iinclude main.cc sum.cc -o sum
```
>[!Important]
> 上面範例並沒有錯誤,是 `-Iinclude` 而不是 `-I include`,不需要空白隔開
> 直接使用 `-I<Path>` 就好
方案一方法顯然不是很聰明,如果你的專案很大,你會需要修改非常多的原始碼,且還要考慮他們的檔案結構。
所以建置大型專案都會採用方案二,直接在編譯器的參數加上搜尋路徑。如果是 Linux 系統,有更加簡單的作法,後面會提到。
### 鏈結路徑 -L
同樣的,如果是採用先編譯出函式庫的做法,就需要使用`-L`指定鏈結路徑:

如果你沒有加上 `-L` 參數:

會顯示「不知道 `-l` 是要鏈結哪個函式庫」
>[!Important]
> 共享函式庫必須得要是 lib 開頭的檔案,比方說 `libsum.so` 或是 `libsum.a`
> 如果沒有 `lib` 開頭,一樣會跟你說不知道要鏈結誰
> `-L`用來指定要鏈結的搜尋路徑,`-l` 指定要鏈結哪些函式庫

這樣同學們應該知道為什麼 Linux 上,函式庫幾乎都是以 `lib` 開頭 `.so` 結尾
在 Linux 上,有個環境變數叫做 `LD_LIBRARY_PATH`,編譯器也會根據其內容去嘗試鏈結函式庫,比方說 Nvidia 的 CUDA,安裝後會出現以下提示:

開發者通常會在如 `.bashrc` 或是 `.zshrc` 等設定檔案,加入以下資訊:

讓套件的函式庫可以被編譯器與鏈結器搜尋到。還有一種做法是 `rpath`,但這裡也跳過不提
### Linux 更好做為開發平台嗎
根據筆者認識的同學與業界前輩,大部分人都同意這個觀點。
除非你是撰寫一些平台特定的程式,比方說寫 WinForm 當然就用 Windows,寫 Windows 上可以玩的遊戲也是用 Windows 等;或者要寫 Swift / Objective-C 會使用 macOS,否則 Linux 或者 Unix-Like 平台確實有其優點:
Linux 除了有不少對開發者來說很好用的妙妙工具,再來就是檔案系統規劃的比較清楚。
在 Linux 的根目錄,存在一個 `/usr` 資料夾,`usr` 一種說法是 **U**nix **S**ystem **R**esources,筆者也覺得挺合理的。

其實我們只要關注 `include` 跟 `lib` 開頭的資料夾就好,在 Linux 中,只需要把標頭檔丟進去 `/usr/include`,編譯器就能搜尋到;同時只要把 `.so` 檔案丟到 `/usr/lib`,就可以被鏈結到。
`g++` 或是 `clang++` 都可以列出搜尋路徑,編譯器會告訴你以下資訊:

以下兩個是比較重要的路徑:
- `/usr/local/include`
- `/usr/include`
已經明確跟你說 `#include <...> search starts here:` ,使用角括號來 include 的時候,會嘗試尋找那些路徑。
同時也注意到,`#include "..." search starts here:`,預設是沒有搜尋路徑的,所以通常會使用`#include<...>`引用 std library;使用`#include "..."` 引用自己的標頭檔。
而 Linux 的套件管理工具,比方說`apt`,會直接幫你把下載好的套件解壓縮到 `/usr`,我們看看第一節提到的 LLVM 跟 CMake,裝在你的 Windows 上長怎樣


太苦了,Windows 的系統,卻用著 Linux 的檔案結構風格。對於 Linux 來說,只要把這些資料夾丟到 `/usr` 或是 `/usr/local` 底下就可以直接使用大多數的軟體,而 Windows 還要設定一堆玩意,所以單純論開發環境,Linux 是比 Windows 有不少優勢的。微軟這幾年可能也注意到這個趨勢,所以整合了不少 Linux 開發相關的功能,比方說 WSL。而套件管理工具,之前有 `chocolatey` 跟 `scoop`,近幾年還有內建的 `winget`,但老實說還是滿難用的。
比方說我需要使用 `SDL`、`OpenGL` 跟 `curl`

只需要使用: `lib<套件名稱>-dev`,就可以直接裝好對應的開發包。對Linux開發人員,使用原始碼建置也是常見的作法,比方說同學們用來做UI的 `qt`:

也是使用 `CMake` 跟 `Ninja` 作為專案建置的工具。那通常在使用 `cmake --build .` 之後,會接續使用 `ninja install` 或是 `make install` 直接安裝套件到 `/usr/local`
### 比較一下 Windows 與 Linux
#### Linux 開發相關的路徑
| 層級 | include 路徑 | lib 路徑 | 意義與用途
| - | - | - | -
| 系統層級 | `/usr/include` | `/usr/lib` | 由發行版安裝的核心開發檔(如 glibc、OpenSSL)
| 本機全域 | `/usr/local/include` | `/usr/local/lib` | 手動安裝的第三方函式庫(如自行編譯的 OpenGL)
| 使用者私有 | `$HOME/.local/include` | `$HOME/.local/lib` | 單一使用者安裝的函式庫(無需 root 權限)
>[!Tip]
> `/usr` 通常由套件管理工具處理,檔案來源是系統的套件庫(例如 apt / dnf / pacman 等)
> `/usr/local` 這裡存放的是你自己手動安裝、自行編譯或非套件管理安裝的軟體。
> 以上面的例子來說,就會把 CMake 跟 LLVM 套件丟到 `/usr/local`。如果你沒有系統權限,就丟到 `$HOME/.local`,代表安裝的這些東西僅限你本人使用
> 其實 Windows 在安裝的時候,也會問你應該 `Add PATH to all User` 還是 `Add PATH to current User`,就是這個區別
> CMake 編譯出來的檔案,預設安裝路徑也是 `/usr/local`
| 動機 | 行為
| - | -
| 你需要新版的 CMake,但套件庫太舊 | 自己編譯 CMake 安裝到 /usr/local
| 你測試一個開源函式庫但不想影響系統環境 | 安裝到 /usr/local/lib + /usr/local/include
| 開發一個新函式庫,但還未正式發佈 | 裝在 /usr/local,不干擾原本的套件
#### Windows 開發相關的路徑
> 沒有😅
與其說沒有,不如說沒有統一的標準:
在 Windows 中:
> - 編譯器(如 MSVC、MinGW)並不會主動搜尋標準 include/lib 路徑,開發者必須在建構系統中手動指定。
> - 安裝的開發工具包通常會透過 環境變數 或 CMake 的 find_package 機制來設定 include/lib 路徑。
Windows 開發環境中常見的做法:
> - 透過 Visual Studio 安裝的 SDK 路徑 自動管理(例如 Windows SDK、MSVC 工具集)
> - 第三方函式庫則多透過 vcpkg、conan、NuGet 等套件管理器安裝與管理
這裡講那麼多其實也是跟你說學一下怎麼用 Linux,如果你是軟體開發相關人員就更該了解一下。
### 為什麼需要分離式編譯
分離式編譯(Separate Compilation) 是一種將程式碼分散到多個原始碼檔案中,並各自獨立編譯,最後再 **統一連結(link)** 成一個完整可執行檔的做法。這涉及到[增量編譯](https://en.wikipedia.org/wiki/Incremental_compiler)的概念,當整個軟體的一小部分進行修改時,不需要把整個專案重新編譯一次,只需要把修改過的部分重新編譯成函式庫,再透過靜態鏈結或是動態鏈結生成執行檔即可。
一個實際的例子就是 Windows 或 macOS,某天系統跳出提示:「發現更新,請重新啟動以套用更新」。請問更新內容是什麼?
大多數情況下,不會下載新的原始碼,並重新編譯整個作業系統,而是僅下載某些動態函式庫(例如 `.dll` 或 `.dylib`,Linux上可能是 `.so` 檔),也就是:
:::success
**只替換有更新的模組,而不是整個程式。**
分離式編譯讓你局部處理變化、有效控管複雜度、加速開發、優化維護與部署。
是這個時代的必要技術。
:::
另外一個例子就是 Steam 或是一些遊戲的更新,只更新了幾個模組(`.dll` / `.so`),而不是重新下載整個遊戲的主程式。
## 專案規劃
當我們在學習程式語言時,經常是以一支單獨的檔案來撰寫、編譯與執行。這樣的方式適合學習語法、觀念與演算法,但 當程式碼逐漸變得龐大或需要多人協作時,良好的專案規劃就變得不可或缺。本小節會很簡易的提及一些專案設計的基本概念。
### 軟體設計原則
在討論怎麼寫程式之前,先了解一下軟體設計的原則。在軟體設計中,有一些非常重要的基本原則
- 內聚性:模組內部功能的關聯度
- 耦合度:模組之間的相依程度
- 抽象化:隱藏實作的細節,僅提供基本的功能
- 關注點:每個部份應該解決的問題是什麼
這裡引用[維基百科](https://en.wikipedia.org/wiki/Cohesion_(computer_science))的說明與圖片

其中**內聚性**與**耦合度**是一個重要的指標
> 一般會希望程式的模組有高內聚性,因為高內聚性一般和許多理想的軟體特性有關,包括強健性、可靠度、可復用性及易懂性(understandability)等特性,而低內聚性一般也代表不易維護、不易測試、不易復用以及難以理解。
通俗的來說,就是模組裡的所有成員是不是都在做同一件事?
內聚性關注的是「模組內」,舉例來說,如果我們需要讀取與解析XML檔案,可能會撰寫以下程式碼:
```cpp=
class XmlReader {
public:
bool loadFile(const std::string& filename);
std::string getAttribute(const std::string& path, const std::string& attr) const;
std::string getText(const std::string& path) const;
std::vector<std::string> children(const std::string& path) const;
private:
std::string rawXml;
struct XmlNode;
std::unique_ptr<XmlNode> root;
void parse();
const XmlNode* findNode(const std::string& path) const;
};
```
該類別中,所有的功能都是為了**讀取與解析XML**,另一個反例是:
如果我們需要控制學生的行為,比方說規劃以下的程式:
```cpp=
class Student {
public:
void addScore(int);
void printSchedule();
void playMusic();
void sendEmail();
void submitMoodle();
};
```
比方說寄送 Email、列出課表、交作業到Moodle,可能都是學生會進行的操作,但是其實每件事情的關聯度很低,這個類別就是低內聚性的。
耦合度則是討論「模組之間」,耦合度的例子要說明比較困難,因為同學們對於介面隔離並沒有太深入的理解。但是以現實的例子來說,其實就是 Mac筆電跟自己裝桌機的區別:
- MacBook:高耦合系統
- 所有零件焊在主機板上(RAM、SSD、電池都不能換)
- 整體設計精緻一致,效率高、整合佳
- 但是如果你有以下需求:
- 換記憶體 - 找原廠 or 不能換
- 換顯卡 - 可能不能加裝
- 壞一個零件 - 使用者很難自行更換
這就是高耦合的系統:
> 各模組彼此依賴緊密,整合性強,但彈性與維護性差
- 自己組桌機:低耦合系統
- 每個零件獨立(主機板、CPU、RAM、硬碟、顯卡)
- 用 **標準化介面(如 PCIe、SATA、ATX)** 彼此連接
- 你如果有以下需求:
- 換記憶體 - 買了裝上,記得買對代數就好
- 換顯卡 - 買了裝上,甚至有內顯可以不用裝
- 壞一個零件 - 使用者自行更換相對簡單
這就是低耦合的系統:
> 各模組責任分明、彼此獨立,容易維護與擴充
| 硬體概念 | 軟體概念
| - | -
| 每個零件可替換 | 每個模組只依賴抽象介面
| 標準化插槽 | 明確定義 API / interface
| 可選組件(你想裝什麼都行) | 多型(polymorphism)、依賴反轉(DI)
| 單一元件壞了不會影響整台 | 單元測試、可重構
介面是一個很好的概念,其意義就是大家都遵守一樣的規格,例如現在的手機幾乎都使用 TypeC 的充電口,你買哪個廠商的線材都可以直接插上去充電。
>[!Tip]
對應到 C++,就是 `<algorithm>` 的內部實作,比方說 `std::all_of` 的參數不是直接傳入 `vector`, `list`, `array`,他要求的是傳入 `container.begin()`,所有的算法都是基於迭代器(`iterator`)這個介面去設計的。
### 檔案結構規劃
在我們談論架構規劃之前,會先決定怎麼如何存放原始碼與資源,這就是專案的檔案結構,或者說是 Code Structure,就是討論「該怎麼組織我的專案檔案與資料夾?」
不少網誌都有討論到這些,這裡主要以 [Redux](https://redux.js.org/faq/code-structure) 的例子中說明
#### 按檔案類型分類(By Type)
```
StudentManager/
├── CMakeLists.txt # 主建構腳本
├── include/ # 所有對外公開的標頭檔
│ ├── Student.hpp
│ ├── StudentDatabase.hpp
│ └── Utility.hpp
├── impl/ # 所有實作原始碼
│ ├── Student.cpp
│ ├── StudentDatabase.cpp
│ └── Utility.cpp
└── data/ # 測試用資料、輸出資料
└── sample_students.txt
```
以該例子來說, `include` 都是存放 .h 標頭檔, `impl` 都是存放 .cpp 實作檔案
#### 按照功能分類(By Feature)
```
StudentManager/
├── CMakeLists.txt # 主建構腳本
├── student/
│ ├── Student.hpp
│ └── Student.cpp
├── database/
│ ├── StudentDatabase.hpp
│ └── StudentDatabase.cpp
├── common/
│ ├── Utility.hpp
│ └── Utility.cpp
└── data/
└── sample_students.txt
```
以該例子來說,不同的資料夾區分不同的模組,也僅存放對應模組的相關檔案
| 比較項目 | 按功能模組分類 | 按檔案類型分類
| - | - | -
| 資料夾組織 | 每個功能是一個資料夾,內含其 UI、邏輯、測試等 | 每種型別(如 models、views、controllers)一個資料夾
| 設計理念 | 高內聚:功能模組自成單元 | 分工清楚:每種職責集中管理
| 模組邊界 | 明確:每個功能集中在一起 | 分散:功能實作橫跨多個資料夾
| 擴充新功能 | 建一個資料夾即可(例:features/chat/) | 需在多個資料夾新增檔案(例:model/chat, view/chat)
| 團隊協作 | 每個人可以負責一個完整模組 | 每個人處理一種檔案型別(前端/後端/DB)
| 模組耦合 | 低:各模組獨立,依賴明確 | 易耦合:模組之間交錯依賴
| 學習曲線 | 初期稍複雜,但結構清晰、擴充性強 | 初學者容易理解,但隨規模成長會變雜亂
| 適用專案規模 | 中大型專案、多人協作、強調模組化 | 小型專案、原型快速開發、單人開發
| 代表架構例子 | Redux Toolkit、Vue SFC、C++ feature-based CMake | 傳統 MVC、C++ 類別集中於 include/, src/
在專案的規模小一點時,依照 Type 去分類是一個不錯的選擇,但是這裡會建議你都依照功能分類,這會讓你的專案結構更加清晰。不過這並不是一種強制性的規範,無論何種選擇都沒有正確與錯誤的區別,比方說以下兩個例子:
- 例子一:在每個資料夾底下新增 `test.cpp`,專門用來測試該模組的程式碼
```
StudentManager/
├── CMakeLists.txt
├── student/
│ ├── test.cpp
│ ├── Student.hpp
│ └── Student.cpp
├── database/
│ ├── test.cpp
│ ├── StudentDatabase.hpp
│ └── StudentDatabase.cpp
├── common/
│ ├── test.cpp
│ ├── Utility.hpp
│ └── Utility.cpp
└── data/
└── sample_students.txt
```
- 例子二:資料夾的深度追加一層,並把所有測試代碼集中
```
StudentManager/
├── CMakeLists.txt
├── features
│ ├── student/
│ │ ├── Student.hpp
│ │ └── Student.cpp
│ ├── database/
│ │ ├── StudentDatabase.hpp
│ │ └── StudentDatabase.cpp
│ └── common/
│ ├── Utility.hpp
│ └── Utility.cpp
├── tests
│ ├── student.test.cpp
│ ├── database.test.hpp
│ └── common.test.cpp
└── data/
└── sample_students.txt
```
>[!Note]
> 實務上兩種做法都很常見,只要團隊統一作法即可,但我的個人經驗是:
> **大方向先根據類別區分**
> - assets 存放圖片、影片等素材
> - src 存放原始碼
> - config 放置一些專案的設定檔案
> > 如果你是遊戲開發人員,你正在撰寫 `Archer` 這個職業的代碼,你會希望弓箭的素材(Ex.弓的圖片)直接放在
`src/roles/archer.png`,還是置放在 `assets/archer.png`?
> 兩種做法好像都可行,但實務上會放在 `assets` 底下,因為還有其他因素,如安裝遊戲時,只需要複製`assets`到安裝目錄底下就好。
影響你怎麼規劃檔案結構的因素其實很多,無法一概而論。這裡只是提出一些常見的觀點。
## CMake 基礎介紹
因為 CMake 其實是一個非常知名的專案,網路上有很多介紹的資源,比方說:[cmake-example](https://github.com/ttroy50/cmake-examples?tab=readme-ov-file)或者 CMake 的官網,但對於初學者來說,筆者稍微減少範例,只講最簡單的幾個用法
>[!Tip]
> 以下說明的範例程式碼,將會放在:https://github.com/silent4v/ntust-cmake-example
1. 如何設定 CMake 專案
2. 如何編譯執行檔跟函式庫
3. 如何新增標頭檔的搜尋位置
4. 如何新增函式庫的搜尋位置
5. 和 clangd 整合
6. CMake 擴充使用教學
那我們先從經典案例 `Hello, World` 開始

- 建立一個`main.cc`,然後寫一個小範例
- 建立一個`CMakeLists.txt`,然後寫入以下內容
```cmake
# CMakeLists.txt
cmake_minimum_required(VERSION 3.28)
project(hello)
add_executable(main main.cc)
```
這就是一個 CMake 的最小範例了,先來解釋用到的三個語法
- `cmake_minimum_required` 定義該專案用到的 CMake 最小版本
- `project` 該專案的名稱
- `add_executable` 新增一個叫做 `main` 的執行檔案,使用 `main.cc` 進行編譯

對於 CMake 來說,最基本的可以拆解成兩個階段:
1. Configure
- 解析 CMakeLists.txt
- 決定編譯器與工具鏈(例如 gcc、clang、MSVC)
- 根據平台與選項,產生適合的 原生建置系統檔案(如 Makefile、Visual Studio .sln、Ninja 檔案等)
2. Build
- 執行編譯器
- 連結程式碼
以上方的例子來說,Configure 階段使用 `cmake -S . -B build`,編譯階段則使用 `cmake --build build`。
而 `-S` 跟 `-B` 是非常重要的兩個參數,它會讓原始碼還有建置過程分離:
參數 | 意義 | 預設行為 | 實務建議
|-|-|-|-
-S | Source directory 原始碼目錄 | 預設是當前目錄 | 包含 CMakeLists.txt 的資料夾
-B | Binary directory 編譯產物目錄 | 預設也是當前目錄 | 建議指定 build 或 out
這個例子中,會生成以下的資料夾:

如果使用 `cmake -S . -B .`,或是直接使用 `cmake`,資料夾目錄會變成這樣:
- 使用 Visual Studio 工具鏈

- 使用 LLVM + Ninja

>[!Important] 讓原始碼與建置分離
把原始碼跟編譯目錄區分是很重要的,可以避免把編譯產物塞進原始碼資料夾,保持乾淨。也允許同時建立 Debug 和 Release 等多版本。遇到問題時,只需刪除 build 資料夾就能重建。是現代 CMake 專案的標準做法。
同時也會注意到:**使用 Visual Studio 工具鏈**的案例,他是生成一個 `hello.sln` 專案。是的,CMake的作法不是直接編譯程式,而是幫助你「負責產生 Makefile、Visual Studio 專案等原生建置腳本」,這也是為什麼還需要安裝 Visual Studio Buildtools。
當然,如果你不熟悉使用終端機的話,也可以用剛剛請你安裝的 CMake 延伸模組,點選「設定」後,會請你選擇套件。請先開啟Visual Studio Code 的設定,然後找到`cmake.buildDirectory` 填入 `${workspaceFolder}/build`,這樣預設編譯的目錄就會在當前專案的 `build` 資料夾下。


然後在上方「專案狀態」的位置,會有一個選項是「刪除快取並重新設定」(齒輪旁邊)

此時會看到專案大綱出現`main`,這個就是會幫你建置的執行檔案。在「專案大綱」Debug符號的右邊,就是建置所有專案的按鈕。
### 加入搜尋路徑
接下來,稍微把例子變得複雜一點:
- 建立一個 `project` 資料夾,放置原始碼
- `project/include` 放置所有的標頭檔案
- `project/implement` 放置所有的實作檔案

```cpp=
/**
* @file project/implement/add.cc
*/
#include "add.hh"
int add(int a, int b) {
return a + b;
}
```
現在 `main.cc` 會跳錯誤提示:「add.hh not found」,我們回到 `CMakeLists.txt`,修改成以下內容:
```cmake
cmake_minimum_required(VERSION 3.28)
project(hello)
message(STATUS "PROJECT_SOURCE_DIR = ${PROJECT_SOURCE_DIR}")
message(STATUS "CMAKE_CURRENT_SOURCE_DIR = ${CMAKE_CURRENT_SOURCE_DIR}")
message(STATUS "CMAKE_CURRENT_BINARY_DIR = ${CMAKE_CURRENT_SOURCE_DIR}")
# 加入搜尋路徑的檔案
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/project/include)
add_executable(main
${CMAKE_CURRENT_SOURCE_DIR}/project/main.cc
${CMAKE_CURRENT_SOURCE_DIR}/project/implement/add.cc
)
```
`include_directories`,就如同其字義:新增 `include` 路徑的資料夾。
而 `message` 不是必要的指令,他只是輸出 `configure` 階段的資訊,初學者只要知道 3 個變數就好:
* `PROJECT_SOURCE_DIR` - 整個專案的原始碼根目錄
* `CMAKE_CURRENT_SOURCE_DIR` - **目前這個** CMakeLists.txt 所在的資料夾
* `CMAKE_CURRENT_BINARY_DIR` - 編譯時使用的路徑
因為在 `CMakeLists.txt` 中,`project` 是必要的指令,你也可以把 `PROJECT_SOURCE_DIR` 想成 `project()` 指令所在的檔案的資料夾。而 `CMAKE_CURRENT_SOURCE_DIR` 為什麼要強調「**目前這個**」,我們會在後面說明。
- 加入前:

- 加入後:

### 修理 intellisense
你可能會注意到一個很詭異的事情:雖然編譯可以通過,但是語法提示還是會顯示找不到檔案。這是因為語法提示不是由 CMake 處理的,而是clangd提供的,如果你在上一節 VSCode 設定沒有弄好,就會出現該問題。你有幾個簡單的補救方式是:
- 建立一個 `.clangd` 檔案,寫入以下內容:
```cpp
CompileFlags:
CompilationDatabase: build
```
比較建議的做法,意義和上一節設定的 clangd 參數是一樣的:
```
--compile-commands-dir=${workspaceFolder}/build
```
也就是嘗試搜尋專案資料夾的`build`資料夾,看看有沒有 `compile_commands.json` 檔案。`compile_commands.json`是實際上編譯器會調用的參數,其內容可能為:

clangd 的做法是解析編譯器的指令,然後再回推給語法提示。如果使用 clangd 當作語法提示引擎,CMake的設定檔一定要是 **clang-cl**。
- 或者,在 `.vscode/settings` 新增你的編譯路徑:
```json
"clangd.fallbackFlags": [
"-I${workspaceFolder}/project/include2",
"-I${workspaceFolder}/project/include"
]
```
其原理是:`clangd.fallbackFlags` 的意義為「當 `clangd` 檢測 `compile_commands.json` 失敗時,會嘗試加入這些 Flag 來進行檢查」。那以該例子來說,就是把當前專案資料夾的`project/include2`跟`project/include1` 加入搜尋目錄。
### 模組化
假定專案採用「以功能性區分檔案結構」,並設計了加減乘除四種模組:

當我們的專案越來越大,`include_directories` 和 `add_executable` 會越來越大。
我們必須得要有幾個處理方式
#### 蒐集所有檔案
```cmake
cmake_minimum_required(VERSION 3.28)
project(hello)
message(STATUS "PROJECT_SOURCE_DIR = ${PROJECT_SOURCE_DIR}")
message(STATUS "CMAKE_CURRENT_SOURCE_DIR = ${CMAKE_CURRENT_SOURCE_DIR}")
message(STATUS "CMAKE_CURRENT_BINARY_DIR = ${CMAKE_CURRENT_SOURCE_DIR}")
# 加入搜尋路徑的檔案
include_directories(
${CMAKE_CURRENT_SOURCE_DIR}/project/sum
${CMAKE_CURRENT_SOURCE_DIR}/project/sub
${CMAKE_CURRENT_SOURCE_DIR}/project/mul
${CMAKE_CURRENT_SOURCE_DIR}/project/div
)
# 加入這行
file(GLOB_RECURSE SOURCES "${CMAKE_CURRENT_SOURCE_DIR}/project/*.cc")
foreach(file IN LISTS SOURCES)
message(STATUS "Find ${file}")
endforeach()
add_executable(main
${SOURCES}
)
```
可以使用 `file(GLOB_RECURSE <變數名稱> <搜尋模式>)` 來匹配多個檔案,以該例子來說,就是匹配所有在 `project` 底下,以 `*.cc` 結尾的檔案。`foreach` 語法也只是列印出找到了那些檔案,並非必要的。

#### 模組化建置與target-based
接下來要講的最重要的一個章節:模組化建置
在分離式編譯中,我們大概可以猜測到,當專案越大型,就會切割成多個模組進行編譯,此時合理作法是:
```cmake
cmake_minimum_required(VERSION 3.28)
project(hello)
add_library(div
${CMAKE_CURRENT_SOURCE_DIR}/project/div/div.cc
)
add_library(mul
${CMAKE_CURRENT_SOURCE_DIR}/project/mul/mul.cc
)
add_library(sub
${CMAKE_CURRENT_SOURCE_DIR}/project/sub/sub.cc
)
add_library(sum
${CMAKE_CURRENT_SOURCE_DIR}/project/sum/sum.cc
)
add_executable(main ${CMAKE_CURRENT_SOURCE_DIR}/project/main.cc)
target_link_libraries(
main
div
mul
sub
sum
)
target_include_directories(
main PUBLIC
${CMAKE_CURRENT_SOURCE_DIR}/project/div
${CMAKE_CURRENT_SOURCE_DIR}/project/mul
${CMAKE_CURRENT_SOURCE_DIR}/project/sub
${CMAKE_CURRENT_SOURCE_DIR}/project/sum
)
```
注意到我們使用了 `add_library`, `target_link_libraries`, `target_include_directories` 三個新參數
- `add_library`:建立一個函式庫
- `target_link_libraries`:當編譯該目標時,應該鏈結哪些函式庫
- `target_include_directories`:當編譯這個 target 時,要加上哪些 -I 目錄」,而且會根據 PRIVATE / PUBLIC / INTERFACE 控制傳遞性
這裡要先提及 cmake 中,絕大多數的方法,首個參數都是**target**。以該例子,會分別建立 5 個 **target**
- main
- div
- mul
- sub
- sum
這些也同時會在你延伸模組的「專案大綱」顯示:

這就是**目標導向**的編譯方式,這個範例很粗糙,因為 main 處理太多東西了,我們修改一下
### 優化後的模組化
最後一個章節,將會把現代大型專案如何建構與模組化拆分作個範例:
首先把四個子模組加入到 `math` 資料夾

此外,分別建立
- `project/CMakeLists.txt`:
```txt
cmake_minimum_required(VERSION 3.28)
project(hello)
add_subdirectory(math)
add_executable(main ${CMAKE_CURRENT_SOURCE_DIR}/main.cc)
target_link_libraries(
main math
)
```
- `project/math/CMakeLists.txt`:
```cmake
add_library(div
${CMAKE_CURRENT_SOURCE_DIR}/div/div.cc
)
target_include_directories(div PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/div)
add_library(mul
${CMAKE_CURRENT_SOURCE_DIR}/mul/mul.cc
)
target_include_directories(mul PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/mul)
add_library(sub
${CMAKE_CURRENT_SOURCE_DIR}/sub/sub.cc
)
target_include_directories(sub PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/sub)
add_library(sum
${CMAKE_CURRENT_SOURCE_DIR}/sum/sum.cc
)
target_include_directories(sum PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/sum)
add_library(math INTERFACE)
target_link_libraries(math INTERFACE div mul sub sum)
```
#### `project/math/CMakeLists.txt` 的用途說明
1. 個別建立四個函式庫(div, mul, sub, sum)
每個子模組各自建立為 add_library() target,
並設定對應的 include 目錄讓外部可以使用 header(如 div.hh)。
2. 建立虛擬的整合函式庫 math
math 本身不含任何程式碼,僅是個「轉接器」,
幫助 main 一次引入所有功能模組,也會自動繼承 include 路徑。
#### `project/CMakeLists.txt` 做了什麼
1. **加入子模組(數學函式庫)**
```cmake
add_subdirectory(math)
```
> 將 math 子目錄下的 `CMakeLists.txt` 加入,載入 `div`, `mul`, `sub`, `sum` 等 library。
2. **設定主程式(可執行檔)**
```cmake
add_executable(main ${CMAKE_CURRENT_SOURCE_DIR}/main.cc)
```
> 建立主程式 `main` 的執行檔。
3. **將主程式與 math 函式庫連結**
```cmake
target_link_libraries(main math)
```
> 將 `main` 與整合後的 `math` 函式庫連結,包含所有 `*.hh` 的 include path 也會自動傳入。
### 如何共用
在 CMake 中,我們可以用 `target_include_directories()` 加上 `PUBLIC`、`PRIVATE`、`INTERFACE`,來控制 **header 搜尋路徑** 要給誰用。
#### `target_link_libraries 的 PRIVATE 是:自己用就好`
```cmake
target_include_directories(mylib PRIVATE include/)
```
表示:
> `mylib` 自己在編譯的時候會用到 `include/`,
> 但別人 `target_link_libraries` 到它的時候不會自動得到這個路徑。
---
#### `target_link_libraries 的 PUBLIC 是:自己用,也給別人用`
```cmake
target_include_directories(mylib PUBLIC include/)
```
這表示:
> `mylib` 編譯時會用到 `include/`,
> 而且任何 `target_link_libraries` 到 `mylib` 的人,也會自動得到這個 include 路徑。
---
#### `target_link_libraries 的 INTERFACE 是:自己不用,只給別人用`
```cmake
target_include_directories(mylib INTERFACE include/)
```
這表示:
> `mylib` 本身沒有用到 `include/`,但它會「幫忙轉交」這個路徑給使用者。
## 總結
本篇教學從零開始,帶領學生建立一個跨平台的 C++ 開發環境,並逐步引導至模組化的專案架構設計。主要涵蓋以下幾個核心主題:
### 1. 開發環境的建置與工具鏈整合
- **工具選擇與安裝**:介紹了多種開發環境,包括 Visual Studio、WSL2 + VSCode、LLVM + CMake 等,並比較其優缺點。
- **必要套件安裝**:說明了 Visual Studio BuildTools、CMake、LLVM、Ninja 等工具的安裝與配置。
### 2. 分離式編譯與模組化概念
- **分離式編譯的介紹**:透過簡單的程式範例,說明了分離式編譯的概念,並比較了不同編譯方式(如直接編譯、使用目標檔、建立函式庫)的差異。
- **模組化設計的實踐**:以數學函式庫為例,展示了如何將程式碼分割為多個模組,並透過 CMake 的 `add_library`、`target_include_directories` 等指令進行管理。
### 3. CMake 的進階使用與實踐
- **CMake 指令的應用**:探討了 CMake 的指令使用,包括 `add_subdirectory`、`target_link_libraries` 等,並說明了 `PUBLIC`、`PRIVATE`、`INTERFACE` 的差異與使用時機。
- **專案結構的規劃**:強調了良好的專案結構對於維護與擴充的重要性,並提供了範例專案的目錄結構,作為學生參考。
在本篇教學中,我們不僅學習了如何建立一個 C++ 開發環境,更重要的是理解了專案規劃與模組化設計的核心理念。這些知識將為同學們未來的軟體開發之路打下堅實的基礎。
學習程式設計不僅是掌握語法,更是培養解決問題的能力。希望您能夠將所學應用於實際專案中,逐步成為一位具備專業素養的軟體工程師。