HOWTO · C++

#pragma once in C++:包含保護和可移植性

在 C++ 中,使用 #pragma once 或包含保護措施來防止重複包含頭文件,並了解可移植性方面的權衡。

使用 #pragma once 防止重複包含標頭

#pragma once 當 C++ 頭檔在單一翻譯單元中只能處理一次時,應將其放在頭檔的頂部。否則,重複包含可能會導致類別、函數、變數和模板的重定義錯誤。

#pragma once

class Config {
public:
    int port() const { return 8080; }
};

當另一個標頭包含 Config.hpp如果原始檔案同時包含這兩個頭文件,則預處理器會處理這些頭文件。 Config.hpp 只需一次。這樣可以在 C++ 編譯開始之前保護宣告。

#pragma once 它並非 ISO C++ 標準的一部分。儘管如此,主流 C++ 編譯器,包括​​ GCC、Clang 和 Microsoft Visual C++,都支援它。對於必須支援未知編譯器的可移植公共庫或程式碼庫,請改用包含保護符或遵循專案現有的約定。

為什麼標題會被多次包含

常見的例子是頭部依賴關係圖。 Server.hpp 包括 Config.hpp原始檔包含兩者 Server.hppConfig.hpp

// Config.hpp
class Config {};

// Server.hpp
#include "Config.hpp"
class Server {
    Config config_;
};

// main.cpp
#include "Server.hpp"
#include "Config.hpp"

如果沒有保護措施,預處理器會插入以下內容: Config.hpp 兩次進入 main.cpp編譯器隨後看到了兩個定義 Config. 頭部護罩或 #pragma once 使得第二個包涵體無害。

使用包含保護符作為標準預處理器解決方案

包含保護符使用一個宏,該宏在首次包含頭檔時定義。巨集名稱在整個專案中必須是唯一的,並且不能以下劃線開頭,後面跟著大寫字母或另一個下劃線,因為這些標識符是保留的。

#ifndef PROJECT_CONFIG_HPP
#define PROJECT_CONFIG_HPP

class Config {
public:
    int port() const { return 8080; }
};

#endif  // PROJECT_CONFIG_HPP

首次納入時, PROJECT_CONFIG_HPP 由於未定義,因此會處理該聲明並定義該巨集。在後續的包含操作中,預處理器會跳過所有內容,直到該巨集被定義。 #endif

包含保護符是標準的預處理器指令,即使在下列情況下也能正常運作: #pragma once 不可用。它們也會將僅使用一次的條件顯示在原始程式碼中。它們的主要成本是選擇和維護一個唯一的巨集名稱。

#pragma once 對比包括衛兵

每個頭部都使用同一種機制。這兩種方法都能解決常見的重複包含問題。

  • #pragma once 更簡潔,避免巨集名稱衝突。

  • 包含保護程式碼是可移植的 C++ 預處理器程式碼。

  • 預設情況下,不要在每個頭檔中都包含這兩種保護代碼。這樣做會增加冗餘,而不會提高正常的保護效果。

  • 不要使用通用的包含保護程式碼,例如 CONFIG_H另一個依賴項可能使用相同的宏,並意外地隱藏了標頭。

編譯器通常能夠有效率地識別傳統的頭檔保護符,因此選擇 #pragma once 僅僅為了提升建置速度而進行更改很少是有意義的。在進行專案範圍的變更之前,應先測量建置效能。

文件身分注意事項 #pragma once

#pragma once 這取決於編譯器能否辨識出兩條路徑指向的是同一個檔案。現代工具鏈能夠很好地處理常見的專案佈局。但對於不常見的檔案系統別名、產生的頭檔、網路檔案系統或指向同一個頭檔的多條路徑,可能會出現一些特殊情況。包含保護機制是基於巨集展開,因此,在可攜式要求較高的建置中,精心挑選的唯一包含保護機制可能是更安全的選擇。

編譯範例

將受保護的標頭保存為 Config.hpp 並編譯一個原始文件,該文件透過依賴關係圖中的多個路徑包含它:

#include "Config.hpp"

int main() {
    Config config;
    return config.port() == 8080 ? 0 : 1;
}
g++ -std=c++17 -Wall -Wextra main.cpp -o app
./app

程式以狀態退出 0移除防護罩或 #pragma once 並新增第二個有效包含項來觀察重定義錯誤。

概括

使用 #pragma once 當您的工具鏈策略允許使用非標準指令時,可以使用簡潔且廣泛支援的頭檔保護機制。當嚴格的可移植性或明確的標準預處理器行為至關重要時,請使用唯一命名的包含保護。這兩種方法都能防止因重複包含頭檔而導致的重複聲明;但它們都不能取代正確的依賴關係設計,也不能完全避免循環依賴問題。

設計標題以減少包含問題

頭檔保護可以解決重複文字包含的問題,但並不能解決所有依賴問題。頭文件應該隻公開其使用者所需的聲明。當類別儲存或接受指向其他類型的指標或引用時,應優先使用前向聲明,並在實作檔案中包含完整的定義。這可以減少重新編譯的工作量,並防止不必要的依賴迴圈。

例如,一個類別可以聲明 class Logger; 在其頭部存儲 Logger* 或者 Logger&當它儲存一個物件時,它不能使用前向聲明。 Logger 按值對象,繼承自 Logger或需要成員 Logger 在內聯定義中。在這種情況下,請包含定義頭並保留保護符或 #pragma once

循環包含是另一個常見的混淆來源。保護機制可以防止預處理器無限擴展,但當兩個頭檔相互依賴對方的完整定義時,它們可能會暴露一個不完整的類型。解決方法是重構所有權關係、將實作移到原始檔案中,或使用前向聲明。切勿嘗試透過移除保護機制來解決循環依賴。

保持聲明內容適合標題

在類別定義內部定義的函數預設是內聯的。如果將非內聯的自由函數或成員函數定義放在頭檔中,當多個來源檔案都包含該頭檔時,可能會導致連結器出現重複定義錯誤。頭檔保護只能防止在單一翻譯單元內重複定義;它並不能保證普通定義在所有翻譯單元中都是安全的。

在頭檔中使用聲明,並在定義中使用 .cpp 預設情況下,檔案會被定義。當必須在頭檔中定義函數時,請標記對應的自由函數或成員函數。 inline或使用模板,或使用 C++17 特有的內聯變數。單一定義規則仍然適用,因此定義在所有翻譯單元中必須等效。

採用項目慣例

程式碼倉庫應該選擇一個穩定的頭檔後綴和保護約定,然後透過程式碼審查或工具強制執行。一致性有助於貢獻者識別公共接口,並防止意外的巨集衝突。如果現有程式碼庫使用了包含保護,則需要將每個頭檔轉換為 #pragma once 對讀者來說價值不大,而且會使歷史記錄變得吵雜。如果它使用 #pragma once 如果目標僅支援工具鏈,請遵循該約定。

在修改一個廣泛使用的頭檔之前,請先建立所有依賴目標。一個很小的包含項變更可能會暴露出其他頭文件意外提供的缺失的直接包含項。每個頭檔都應該包含它所使用的所有依賴項,避免依賴傳遞包含,並且在從最小原始檔包含時能夠編譯通過。這使得保護機制和 #pragma once 可維護的 C++ 介面的可靠組成部分。

了解翻譯單元和頭部保護

C++編譯器不會將整個專案編譯成一個連續的原始檔。它通常會對每個部分進行預處理和編譯。 .cpp 單獨儲存文件。來源文件及其所有導入的文字。 #include 指令構成一個翻譯單元。頭部保護作用於每個翻譯單元內部,而不是在整個應用程式中只保護一次。

假設兩者 main.cppserver.cpp 包括 Config.hpp編譯器必須處理來自以下檔案的聲明: Config.hpp 編譯過程中一次 main.cpp 編譯過程中再次出現這種情況。 server.cpp這是正確的。 #pragma once 它不會阻止兩個原始檔案使用標頭;它只會阻止在單一翻譯單元內進行重複處理。

這種差異解釋了為什麼頭檔保護無法修復所有多重定義連結器錯誤。如果一個頭檔包含一個非內聯函數定義,那麼每個包含該頭檔的原始檔都可能產生一個單獨的定義。每個翻譯單元只包含一次頭文件,因此 #pragma once 雖然成功了,但最終的連結器仍然發現多個外部定義。將實作移至一個定義中。 .cpp 文件或使定義合法地內聯。

避免使用實現保留的守衛名稱

包含保護機制雖然簡單,但它們的巨集名稱與其他巨集共用同一個預處理器命名空間。衝突可能導致頭檔消失,且沒有明顯的診斷資訊:如果另一個檔案已經定義了該保護宏,預處理器就會跳過受保護的頭檔。

使用源自項目、目錄和檔案名稱的描述性前綴。例如: ACME_NETWORK_HTTP_CLIENT_HPPHTTP_CLIENT_H避免使用包含兩個連續底線的標識符,以及以下劃線後面跟著大寫字母的標識符。這些形式保留給 C++ 實作使用。許多團隊透過格式化或程式碼檢查工具產生守衛名稱,以保持命名的一致性。

移動或重新命名頭檔時,請決定是否也要變更其保護機制。保留原有的唯一保護機制在技術上是可行的,但更新它可以提高可發現性。在更改保護機制之前,請先搜尋程式碼庫,因為外部建置配置或產生的程式碼可能引用了該宏,儘管依賴頭檔的私有保護機制是一種糟糕的設計。

不要將頭部保護與模組導入混淆

C++20 模組處理依賴關係的方式與文字頭檔包含的方式不同。 import 聲明並非簡單地將文件內容貼到每個翻譯單元中,因此傳統的包含保護機制並不能保護模組介面。項目仍然可以同時使用受保護的舊版頭文件和模組,並且頭文件單元可以引入特定於工具鏈的細節。

不要僅為了刪除而將頭檔轉換為模組 #pragma once模組採用會影響建置系統支援、編譯器版本、相依性掃描、打包和介面設計。應將其視為架構遷移。對於普通的基於頭檔的項目,一致的 #pragma once 或者,包含保護策略仍然是直接的解決方案。

排查仍然失敗的標頭問題

如果受保護的頭檔仍然報告重定義,首先檢查重複的聲明是否確實來自不同的文件。兩個複製的頭檔可能包含相同的類,但各自正確使用了不同的保護規則。接下來,驗證該指令是否出現在聲明之前,以及每個條件預處理器分支是否已正確關閉。

若要包含守衛,請搜尋巨集的早期定義並確認其拼字是否符合。 #ifndef#define。 為了 #pragma once檢查生成的檔案、符號連結、大小寫差異以及可能包含獨立物理檔案的建置路徑。編譯器選項可以產生預處理輸出或包含樹,從而準確顯示讀取了哪些頭檔以及讀取順序。

最後,將問題簡化為一個來源檔案及其直接包含的檔案。最小複現範例能夠區分預處理問題和單定義規則或連結器問題,並使相應的修復方法更加清晰。