標頭

標頭檔 在 C++

2 個練習

關於 標頭檔

在 C++ 中,宣告與定義通常是分開的。 宣告會集中放在所謂的標頭檔裡,而各自的實作則放在原始碼檔案中。 你可以把標頭檔想成一份 API。 標頭檔會告訴你一個程式碼庫提供了_什麼_,而不會深入說明_如何_做到。

標頭檔與原始碼

標頭檔最常見的副檔名是.h。 有些專案會使用.hpp,或是完全不寫副檔名。

定義則放在另一個.cpp檔案裡。 為了把這兩個部分重新結合起來,原始碼檔案會在一開始_引入_對應的標頭檔。

假設你想寫一個叫做「quick_math」的函式庫,它提供一個你經常想用的函式「super_root」,那麼這些檔案會長得像這樣:

// A file named quick_math.h
#pragma once

namespace quick_math {

double super_root(double x, int n);

}
// A file named quick_math.cpp
#include <cmath>

#include "quick_math.h"

double quick_math::super_root(double x, int n) {
    while (n) {
        x = std::sqrt(x), --n;
    }
    return x;
}

如果你需要引入的標頭檔只有實作會用到,那麼對應的#include那一行只需要寫在原始碼檔案裡。 標頭檔裡引入的所有東西,在.cpp檔案中也同樣可以使用,例如下面例子裡的string函式庫。 注意:標頭檔的宣告後面需要;,但原始碼檔案的定義後面則不需要。

Note

Exercism 上許多 C++ 練習都是從兩個幾乎空白的檔案開始:標頭檔與原始碼檔。 你必須查看*_test.cpp檔案,找出預期函式的名稱與命名空間,才能解開這道練習。

類別與標頭檔

類別可以變得非常複雜,它和標頭檔、原始碼之間的分工關係也可能令人困惑。 一種可能的做法,是把所有實作細節留在原始碼檔案,而把所有宣告和成員變數放在標頭檔:

// A file named robot_flower.h
#if !defined(ROBOT_FLOWER_H)
#define ROBOT_FLOWER_H

#include <string>

namespace robots {

class Flower {
   private:
    bool needs_water{};
    int size{};
    std::string name{};

   public:
    Flower(std::string name, int size = 0);
    void give_water();
    std::string get_name();
    int get_size();
    void start_next_day();
};
}  // namespace robots
#endif
// A file named robot_flower.cpp
#include "robot_flower.h"

robots::Flower::Flower(std::string name, int size) {
    this->name = "Robotica " + name;
    this->size = size;
}

void robots::Flower::start_next_day() {
    if (!needs_water) ++size;
    needs_water = true;
}

std::string robots::Flower::get_name() { return name; }

int robots::Flower::get_size() { return size; }

當標頭檔被當成 API 總覽來使用時,大家就會在這裡尋找預設值之類的資訊。 因此,建構子的size參數預設值會寫在標頭檔,而不是寫在實作裡。 原始碼檔案中的定義,會加上命名空間robots和類別型別Flower的前綴。

另一種做法是_僅有標頭檔_的函式庫,完全沒有.cpp檔案:

// A file named robot_flower.h
#pragma once

#include <string>

namespace robots {

class Flower {
   private:
    bool needs_water{};
    int size{};
    std::string name{};

   public:
    Flower(std::string name, int size = 0) {
        this->name = "Robotica " + name;
        this->size = size;
    }
    void give_water() { needs_water = false; }
    std::string get_name() { return name; }
    int get_size() { return size; }
    void start_next_day() {
        if (!needs_water) ++size;
        needs_water = true;
    }
};
}  // namespace robots

專案可能會混用這些做法,而哪一種做法最適合哪一種使用情境,也一直有許多討論。

引入防護

你可能已經注意到上面範例標頭檔裡的#pragma once那一行。 這就叫做引入防護,它能確保檔案內容在編譯過程中只被引入一次,以免發生錯誤。 引入防護還有一種更複雜的變化形,以#ifndef開頭、以#endif結尾,詳細內容如下。

Advanced

前置宣告

C++ 程式碼是循序求值的。 如果你想使用某個函式,編譯器在使用它的當下就必須認識它。 有時候,很難用線性的順序在原始碼裡安排這些定義。 來看看下面的例子:

int myFunction(int n) {
    if (n < 10) {
        return n;
    } else {
        return myOtherFunction(n / 10);
    }
}

int myOtherFunction(int m) { return myFunction(m / 2); }

定義myFunction的時候,編譯器還不認識myOtherFunction。 可惜的是,這種互相引用的問題沒辦法靠交換順序來解決。

C++ 提供了前置宣告,讓編譯器在myFunction和myOtherFunction被定義之前就先知道它們。 編譯器會假設宣告之後的某個時間點會出現定義。 下一個例子示範函式的前置宣告怎麼使用。

int myFunction(int n);       // Forward declaration of myFunction
int myOtherFunction(int m);  // Forward declaration of myOtherFunction

// Definition of myFunction
int myFunction(int n) {
    if (n < 0) {
        return 0;
    } else {
        return myOtherFunction(n - 2);
    }
}

// Definition of myOtherFunction
int myOtherFunction(int m) { return myFunction(m / 2); }

用 ifndef 實作的引入防護

同一個檔案在一個專案裡被引入多次,本身並沒有問題。 標頭檔不應該包含定義。 整個專案裡,同一個定義不能出現超過一次。 這就叫做「單一定義規則」。 編譯器會強制執行這個規則。

有了引入防護,就能輕鬆避免同一個定義被意外重複引入。 它們是在編譯的各個階段,透過一個特殊的程序產生。 目的在於:只有在某個變數尚未被設定時才引入這個檔案,並在檔案引入之後把該變數設定起來。 這個變數的名稱通常會取檔案名稱的變化形。 另一種做法是產生一個 UUID,以降低不小心重複使用同一個名稱的風險。 下面的語法以MY_HEADER_FILE_H作為這個變數:

#ifndef MY_HEADER_FILE_H /* any name uniquely mapped to file name */
#define MY_HEADER_FILE_H

// file content

#endif

#pragma once的問題在於,pragma 並不是 C++ 語言的正式規範,而且各家編譯器的實作方式都不一樣。 許多大型專案已經改用這個比較簡單的 pragma 做法,但仍有少數專案保持謹慎。

透過 GitHub 編輯 連結會在新視窗或分頁中開啟

學習 標頭檔