CodeGym /課程 /C++ SELF /編譯錯誤 vs 連結錯誤

編譯錯誤 vs 連結錯誤

C++ SELF
等級 30, 課堂 1
開放

1. 要分清楚 compile error 與 link error

當你剛開始寫程式時,IDE 裡的「Build/Run」按鈕看起來像一個「把程式變出來的魔法按鈕」。這很正常:大腦在節省精力。但這種節省有代價:你看到紅色輸出就開始到處修——彷彿車子壞了,你先去換雨刷,因為「它們比較容易拿到」。

在 C++ 中,編譯錯誤和連結錯誤是兩種不同階段的「故障」。如果把它們搞混,你幾乎肯定會往錯的地方修修改。好消息是:可以很快區分它們——根據典型詞彙和錯誤訊息實際在抱怨「什麼」。

流程圖:編譯器與連結器

簡單說,編譯器像一位嚴格的語法老師:它檢查每個檔案中語句是否語法正確、單詞是否存在、形態是否一致。連結器則像婚禮籌辦者:它把不同檔案中的「儀式片段」拼湊成一個完整的活動,確保「新郎確實遇到了新娘」,也就是每個函式呼叫都能找到自己的實現。

關鍵點:編譯器按 單一 個 .cpp(單一翻譯單元,translation unit)工作。連結器則根據所有 .cpp 的編譯結果合併它們為一個可執行檔。因此有些問題會在「編譯時」立刻顯現,而有些問題只有在「連結時」才會浮現。

下面是一張簡短表格,作為本講座的錨點。

階段 檢查內容 常見訊息用語 通常修在哪裡
編譯 (compile) 語法、型別、名稱、標頭可見性、宣告的「可見性」 error: expected ..., error: ... was not declared, no such file or directory, no matching function 在具體的 .cpp 或被包含的 .hpp
連結 (link) 是否能為所有使用到的函式/變數找到定義,是否存在重複定義 undefined reference, unresolved external, multiple definition, LNK... 檢查哪些 .cpp 參與構建,以及定義放在哪裡

2. 編譯錯誤:「我看不懂這個檔案」

編譯錯誤是「某個 .cpp 無法被轉換為物件檔」的情況。通常(幾乎總是)編譯器會在訊息中給出具體位置:檔案 → 行 → 欄,指出編譯器「絆到」的地方。這有點像寫測驗時被劃紅線,指出哪個字寫錯了。

為了讓例子不那麼抽象,我們繼續用教學範例主控台程式 TaskBox——一個最簡單的任務管理器。我們刻意把小函式拆到不同檔案,以便直觀地捕捉不同錯誤類型。

語法錯誤:「我讀不懂這段」

語法錯誤是經典場景。比如少了 ;、缺少閉合的 },或寫出了不符合 C++ 語法的東西。

#include <iostream>

int main() {
    std::cout << "TaskBox started\n"   // <- 忘記了 ';'
    return 0;
}

這裡編譯器通常會說類似 expected ‘;’ 或 expected ...。重要的是:在語法沒修好的情況下,後續訊息可能只是第一個錯誤的「回聲」,所以先修語法最有效。

名稱未找到:「我不知道這是什麼」

第二常見類別是你使用了一個編譯器在該處看不到的函式/變數/類型。這在多檔案專案中尤其常見——比如忘了包含所需的標頭。

#include <iostream>

int main() {
    std::cout << addTask("buy milk") << '\n';
    return 0;
}

如果 addTask 沒有在可見範圍內宣告(例如你忘了 #include "task.hpp"),編譯器會報 was not declared in this scope 或 identifier not found。這是 編譯錯誤,因為編譯器甚至無法描述該呼叫——它不知道什麼是 addTask。

標頭未找到:「你沒給我需要的檔案」

有時問題並非 C++ 語言本身,而是編譯器找不到檔案。

#include "task.hpp"
#include "missing.hpp" // 檔案不存在

int main() {}

訊息會像 No such file or directory。這也是 compile error,因為編譯器無法組裝翻譯單元:它缺少你要求包含的內容。

4. 連結錯誤:「我把部分組裝起來,但無法連結」

連結錯誤的特徵很明顯:編譯看似通過,但在最後一步構建時程式無法產生。這會讓新手特別困惑,因為「編譯器已經檢查過了」。事實上,編譯器只檢查了它在每個 .cpp 範圍內能看到的東西。

如果把編譯器比作語法檢查,那連結器就是檢查劇本中每個「角色提及」是否真的有演員出場。

Link error:undefined reference / unresolved external

這是最常見的連結錯誤:某處你已經 宣告 函式(或包含了擁有宣告的標頭),但 定義(實作)沒有被包含到構建中。

我們來把 TaskBox 的結構做得「真正正確」。

task.hpp(宣告):

#pragma once
#include <string>

int addTask(const std::string& title);

task.cpp(實作):

#include "task.hpp"

int addTask(const std::string& title) {
    (void)title;
    return 1;
}

main.cpp:

#include <iostream>
#include "task.hpp"

int main() {
    std::cout << addTask("buy milk") << '\n'; // 1
}

注意一種典型情況:task.cpp 檔案存在於專案資料夾,但 沒有參與構建(例如你忘了把檔案加入到專案/目標)。此時 main.cpp 可以成功編譯:它從標頭看到了 addTask(...) 的宣告,並「相信」某處有實作。

但連結器會報:找不到定義(undefined reference,或 unresolved external symbol)——這正是它嘗試把呼叫和實作「縫合」時失敗的場景。

Link error:multiple definition

另一個常見情況是:同一個定義意外地出現在構建中兩次。這通常發生在把 全域變數 定義在標頭中,而該標頭被包含到多個 .cpp。

錯誤示例:

// config.hpp
#pragma once

int g_nextId = 1; // <- 在標頭中定義(有風險!)

task.cpp:

#include "config.hpp"

int getNextIdForTask() {
    return g_nextId++;
}

main.cpp:

#include "config.hpp"
#include <iostream>

int main() {
    std::cout << g_nextId << '\n'; // 1
}

兩個 .cpp 都包含了 config.hpp,每個單位翻譯單元都獲得了自己的 g_nextId 定義。在連結時這會變成 multiple definition。這直接關涉到「單一定義原則」(ODR)。

5. 編譯「相信」什麼,連結「檢查」什麼

在此停下來把關鍵思想說清楚很重要,以免後續覺得像魔法。當編譯器看到呼叫 addTask("buy milk"),它想弄清兩件事:這個函式在形式上是否存在,以及傳遞的參數是否合適。

為此它只需要一個 宣告:

int addTask(const std::string& title);

這就像樓裡掛著一個牌子寫著「這裡有公證人」:你可以走進去表示需要服務,即便公證人還沒出現。但連結器則像付錢簽名的時刻:需要確認公證人真實存在並能蓋章。

因此「宣告了但沒定義」會導致 link error 而非 compile error。編譯器不必猜測實作是否存在——它假設你是成年人(劇透:我們常常會不小心犯錯)。

實用偵測器:5 個信號幫你判斷錯誤類型

下面是很實用的一段,將幫助你在未來看到紅色日誌時在 10 秒內判斷「哦,這是連結錯誤」。我會用連貫的敘述,而不是枯燥的列點,因為把「如何讀訊息」和「要去哪裡修」連在一起更有幫助。

第一個信號——訊息中有具體的原始碼位置:main.cpp:12:5: error: ...。這幾乎總是編譯錯誤。連結器有時會提到檔案,但通常是物件檔或庫名,而不是你原始碼中的精確行號。

第二個信號——出現「預期」之類的詞(expected)、「無法解析」和任何語法相關的抱怨。連結器看不到語法:它處理的是編譯結果。因此 expected ';' 幾乎百分之百是 compile error。

第三個信號——在 #include 附近出現「找不到檔案」(No such file or directory)。這是 compile error:編譯器組裝翻譯單元時缺少文字內容。

第四個信號——詞組如 undefined reference、unresolved external、LNK2019(MSVC)或 ld: ...。這些都是連結錯誤:在尋找符號定義時失敗。

第五個信號——multiple definition(或 MSVC 的同類訊息)。這是連結錯誤:定義已經到達構建系統,但連結器發現有兩個相同身分的人在場。

6. 在 TaskBox 上練習:同一問題,不同階段的表現

現在來做一個重要的腦力練習:觀察相似語意的問題如何在編譯階段與連結階段不同地表現。這能幫助你分辨「看不到」和「找不到」之間的差別。

編譯器看不到宣告

main.cpp:

#include <iostream>

int main() {
    std::cout << addTask("buy milk") << '\n';
}

這裡沒有 #include "task.hpp",因此編譯器根本不知道什麼是 addTask。這是 compile error。

編譯器看到宣告,但連結器找不到定義

main.cpp:

#include <iostream>
#include "task.hpp"

int main() {
    std::cout << addTask("buy milk") << '\n';
}

task.hpp 有、宣告有、編譯會通過。但如果 task.cpp 沒有參與構建或裡面沒有 addTask 的定義,就會在連結階段遇到 undefined reference。

結論很直接:錯誤不是「神祕地移動了」,它只是在哪個階段變得可檢查而已。

常見的 link error 原因:簽名不一致

這是一個常見陷阱:你以為有定義,但連結器仍報 undefined reference。

實際上常見原因很平凡:你宣告了一個函式,但定義了一個「看起來相似但不一樣」的函式。連結器要求名稱與參數精確匹配。

task.hpp:

#pragma once
#include <string>

int addTask(const std::string& title);

task.cpp(錯誤):

#include "task.hpp"

int addTask(std::string title) { // <- 沒有 const&,參數不同
    (void)title;
    return 1;
}

對編譯器來說(在某些情況下)這段程式碼可能透過編譯,尤其是當你沒有在 task.cpp 中包含自己的 task.hpp 時(這本身就是糟糕的做法)。但連結器會尋找 addTask(const std::string&),卻只找到 addTask(std::string),對連結器來說這是兩個不同的函式。

一個實用習慣能大幅減少此類錯誤:每個 .cpp 都應包含對應的 .hpp。這樣編譯器會在編譯階段就抓到簽名不一致。

7. 讀懂構建日誌的迷你演算法

我想給你一套可執行的方法,而不是哲學。假設你點了 Build 並得到 200 行日誌。別把它當小說看(雖然有時內容足夠戲劇化)。你需要每次採取一致的步驟。

先判斷這是編譯還是連結階段的錯誤,根據關鍵詞和訊息風格。然後找到 第一個實質錯誤,因為後面的錯誤通常是連鎖反應。最後決定去哪些地方找原因:具體某個 .cpp 的程式碼、標頭包含,還是檔案是否被加入構建以及定義放在哪裡。

下面是一張簡單的心智流程圖,方便記在腦中:

flowchart TD
    A["構建失敗"] --> B{"訊息中是否有 undefined reference / multiple definition / LNK... ?"}
    B -- 是 --> L["這是 link error。找:定義在哪裡?有沒有重複?所有 .cpp 是否參與構建?"]
    B -- 否 --> C{"訊息中是否有 file:line:col 並包含 'error:' ?"}
    C -- 是 --> D["這是 compile error。找:語法/型別/名稱/#include/宣告可見性"]
    C -- 否 --> E["往上查看日誌:有時 IDE 隱藏了實際階段"]

8. 常見錯誤:為何會反覆出現

錯誤 #1:修錯的階段搞錯。
最常見的情況是:你看到 undefined reference,卻開始改寫 if/else 在 main 裡,因為「也許邏輯有問題」。連結器不在乎你的邏輯:它找不到定義。習慣先分類錯誤類型再動手修,能避免這種情況。

錯誤 #2:檔案存在就等於參與構建。
心理上可以理解:如果 task.cpp 就在旁邊,好像它「理應」被編譯。但構建是有明確清單的:哪些檔案會被編譯與連結。如果 task.cpp 沒有加到專案/目標,它就不存在於構建中。結果是 undefined reference,讓人有被現實「gaslight」的感覺。

錯誤 #3:在標頭裡定義變數。
新手會想:「我想讓大家都看到 g_nextId,那我就放在 .hpp 裡」。這會導致 multiple definition,因為標頭被包含到多個翻譯單元,每個都會得到一份定義。這就像在兩個辦公室各印了一本同樣的護照,然後嘗試通過驗證。這類衝突直接觸及單一定義規則(ODR)。

錯誤 #4:宣告與定義不同步,且 .cpp 沒有包含自己的 .hpp。
這是一顆靜悄悄的地雷。你在 .hpp 改了簽名、忘了更新 .cpp,然後驚訝地看到 undefined reference。若每個 .cpp 都包含自己的 .hpp,編譯器會在編譯階段抓到不一致,避免掉在連結時才暴雷的情況。因此「包含自己的標頭」不是風格問題,而是實用的構建保護。

錯誤 #5:試圖用增加 #include 來「擊敗」連結錯誤。
有時人看到 undefined reference 就以為「可能沒包含正確的標頭」。但 #include 影響的是編譯階段,而不是定義是否存在於構建中。標頭提供宣告,但連結需要實作。就算你把標頭 include 十次,定義也不會因此出現(反而可能造成更多問題)。

1
任務
C++ SELF, 等級 30, 課堂 1
上鎖
失敗檢測器
失敗檢測器
1
任務
C++ SELF, 等級 30, 課堂 1
上鎖
批次分類器
批次分類器
1
任務
C++ SELF, 等級 30, 課堂 1
上鎖
第一個線索
第一個線索
1
任務
C++ SELF, 等級 30, 課堂 1
上鎖
組建模擬器
組建模擬器
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION