CodeGym /課程 /C++ SELF /=default — 將操作作為 API 的一部分進行生成

=default — 將操作作為 API 的一部分進行生成

C++ SELF
等級 45, 課堂 0
開放

1. 為什麼需要 =default,如果「本來就能工作」?

當你剛開始撰寫自己的 struct 時,感覺語言好像已經照顧好一切:欄位會被複製、字串不會洩漏、vector 自行釋放記憶體,生活美好。但之後你加入一個「小改進」——例如一個帶參數的構造器——忽然發現 T t; 再也無法編譯。或者你希望任何閱讀程式碼的人都能看到:「是的,這個型別可以被複製,而且複製是標準的。」這時候 =default 就成了你用來和編譯器及同事用同一語言溝通的方式。

=default 並不是「寫一個空函式」。它是: 「請生成該函式的標準實作,就像編譯器自動做的那樣」。 換句話說,你不是自己去發明邏輯——你在確認標準的邏輯適合你,並將其固定在型別的 API 中。

實務上 =default 最常用於特殊成員函式:

函式 說明 何時被呼叫
T() = default;
預設構造器 T x;, T x{}; (視不同語境)
~T() = default;
解構函式 物件生命週期結束時
T(const T&) = default;
複製構造器 T b = a;
T& operator=(const T&) = default;
拷貝賦值 b = a;

(有時會把「移動操作」也算進來,但我們今天有意不觸及,以免跳到下一堂課的內容。)

2. =default 的語法與「契約」概念

在程式設計中,有一種便利的「簡寫」用來表達:「請正確且以標準方式處理」。在 C++ 中這正是 =default。心理上它像是你不自己實作泡泡排序,而是信任標準的 std::sort。在這裡扮演 std::sort 角色的是編譯器。

語法對所有特殊函式都是一樣簡單:


struct T {
    T() = default;
    ~T() = default;
    T(const T&) = default;
    T& operator=(const T&) = default;
};

這裡要注意不要混淆兩件事。

首先,=default 不是「寫一對空的大括號」。 空的主體({})意味著:「我自己寫了函式,內容就是這樣」。 而 =default 的意思是:「請生成標準的實作」。有時差異只體現在型別的細微屬性,但對界面設計來說這是關鍵: =default 是一個意圖標記。

其次,=default 並不會「違反物理規則」。 如果由於欄位組成(例如你有一個 non-copyable 的欄位)導致標準版本無法存在, =default 並不會變成魔法棒。 最多它會在程式碼中給出一個更直接且誠實的契約:「我們期望標準行為,但它不可用」。

3. 如何恢復預設構造器

最常讓人從實務中認識 =default 的情況不是書本,而是生活經驗——「我加入了一個構造器,一切就壞了」。 C++ 有一條規則:如果你宣告了任意使用者自訂的構造器, 編譯器就不再有義務自動提供預設構造器。

讓我們把這個情境織入教學用的迷你應用。假設我們在寫一個簡單的命令列任務管理器 TaskBox,並且有應用設定。

先看「天真的」版本:

#include <string>

struct AppConfig {
    std::string storagePath = "tasks.txt";
};

這個版本可以正常建立,例如 AppConfig cfg{};,一切平靜。

現在我們想允許顯式指定路徑:

#include <string>

struct AppConfig {
    std::string storagePath = "tasks.txt";

    explicit AppConfig(const std::string& path) : storagePath(path) {}
};

這時初學者常會驚訝:「為什麼 AppConfig cfg; 突然變成問題?」 答案是因為我們改變了物件的建立模型。 如果我們仍然需要一個「預設」的物件,就必須明確告訴編譯器——最誠實的方式就是:

#include <string>

struct AppConfig {
    std::string storagePath = "tasks.txt";

    AppConfig() = default; // 恢復「標準建立」
    explicit AppConfig(const std::string& path) : storagePath(path) {}
};

現在兩種情境又都是合法的:既可以「以預設設定啟動」,也可以「使用指定設定啟動」。

我們在 main 稍微展示一下,讓它看起來像應用程式的一部分:

#include <iostream>
#include <string>

struct AppConfig {
    std::string storagePath = "tasks.txt";

    AppConfig() = default;
    explicit AppConfig(const std::string& path) : storagePath(path) {}
};

int main() {
    AppConfig a{};
    AppConfig b{"backup_tasks.txt"};

    std::cout << a.storagePath << '\n'; // tasks.txt
    std::cout << b.storagePath << '\n'; // backup_tasks.txt
}

在這裡 =default 的意義非常實用: 我們不手動撰寫「預設初始化」的邏輯, 而是請編譯器以標準方式為我們執行(考慮欄位的初始值、欄位順序等)。

4. 複製與與空函式主體的差別

複製:把標準行為作為介面的一部分

另一個使用 =default 的原因不是為了讓程式能編譯,而是為了讓程式碼更清楚。有時你希望型別的使用者能直接看出:「複製是允許的,而且是按欄位做標準複製」。

在我們的 TaskBox 中,假設有任務模型:

#include <string>

struct Task {
    int id{};
    std::string title;

    Task() = default;
    Task(const Task&) = default;
    Task& operator=(const Task&) = default;
};

純技術上對於這樣的 Task 可以完全不寫任何東西:編譯器會自動生成。但這裡重點在於:=default 是讓行為變得明確的方式。

看一個複製的實例:

#include <iostream>
#include <string>

struct Task {
    int id{};
    std::string title;

    Task() = default;
    Task(const Task&) = default;
    Task& operator=(const Task&) = default;
};

int main() {
    Task a{1, "Read C++ book"};
    Task b = a;            // 複製構造器
    b.title = "Write C++ code";

    std::cout << a.title << '\n'; // Read C++ book
    std::cout << b.title << '\n'; // Write C++ code
}

為什麼這在教學上重要?因為這會讓你開始把特殊成員函式視為「編譯器的魔法」以外的介面契約。當你之後遇到一個不可複製的型別時,你會以正確的視角閱讀程式碼:「啊,複製是模型禁止的」。

為什麼 T() {} 不是等同於 T() = default;

這個時候通常有人會問:「好吧好吧,如果我寫一個空的構造器,這不就幾乎等同於 =default 嗎?」

當你寫:

struct X {
    X() {}
};

你是在創建一個使用者定義的構造器。 編譯器不會再把它視為「自然的」標準行為。

而當你寫:

struct X {
    X() = default;
};

你是在說:請生成標準的實作。也就是說,一種情況你在聲明:「我的邏輯是空的」, 另一種情況你在聲明:「邏輯是標準的」。

對初學者來說,實際效果常常是:空的構造器往往成為日後偷偷加入「一點點邏輯」的地方,過了一段時間就會發現物件可能處於奇怪的狀態。 =default 在心理上像是給門加了一把鎖:如果你想要「標準建立」,你就把它鎖定住。

還有一個細節(此處不深入,只當作一個事實):標準生成的操作有時會擁有額外的「對最佳化友好」或語言層面更嚴格的性質。而使用者自訂的函式,即使是空的,也可能失去這些性質。如果你不想無意中改變型別的「特性」,選擇 =default 通常是最安全的。

5. 在哪裡寫 =default:放在型別內還是放在 .cpp

當專案變得稍微大一點時,會想把宣告和定義分散到不同檔案:在 .hpp 中宣告,在 .cpp 中定義。 這是一個正常的直覺——大約在你開始接觸連結器的一週後就會出現。

但關於 =default 有一個微妙之處: 寫它的位置有時會改變函式的狀態。 用最直觀的說法,規則是:通常最好在宣告函式的同處寫上 =default——也就是直接在 struct 的定義中。

例如,對於 AppConfig,一般建議這樣寫:

// config.hpp
#pragma once
#include <string>

struct AppConfig {
    std::string storagePath = "tasks.txt";
    AppConfig() = default;
};

而下面這種做法是可接受的,但需小心:

// config.hpp
#pragma once
#include <string>

struct AppConfig {
    std::string storagePath = "tasks.txt";
    AppConfig(); // 只有宣告
};
// config.cpp
#include "config.hpp"
AppConfig::AppConfig() = default;

從「能否編譯」的角度來看——兩者通常都沒問題。 但從「我希望得到盡可能標準的行為且沒有副作用」的角度看——第二種做法有時會讓該函式變得比你預期的更「使用者自訂」。 因此型別可能會意外地變得「不那麼簡單」。

可以這樣記:=default 關乎意圖。 而意圖最好寫在能立刻被看到的地方,而不是寫在那只有在問題出現時才會被查看的遠端 .cpp。

6. 實作範例:TaskBox 與 =default

為了把所有內容串成一個完整的畫面,做一個 TaskBox 的迷你版本: 有設定、有任務、有一個記憶體中的任務儲存庫。我們不做檔案 I/O 或複雜指令——重點是看 =default 如何形成清晰的契約。

#include <iostream>
#include <string>
#include <vector>

struct AppConfig {
    std::string storagePath = "tasks.txt";
    AppConfig() = default;
    explicit AppConfig(const std::string& path) : storagePath(path) {}
};

struct Task {
    int id{};
    std::string title;

    Task() = default;
    Task(const Task&) = default;
    Task& operator=(const Task&) = default;
};

struct TaskStore {
    std::vector<Task> tasks;

    TaskStore() = default;
    TaskStore(const TaskStore&) = default;
    TaskStore& operator=(const TaskStore&) = default;
};

int main() {
    AppConfig cfg{};
    TaskStore store{};

    store.tasks.push_back(Task{1, "Buy milk"});
    TaskStore backup = store;

    std::cout << cfg.storagePath << '\n';        // tasks.txt
    std::cout << backup.tasks[0].title << '\n';  // Buy milk
}

注意這段程式給人的感覺:它似乎「沒有試圖比必要的更聰明」。 我們沒有手動撰寫複製,也沒有憑直覺做所謂的「優化」,也沒有在解構函式中加入可疑的行為。 相反地,我們把契約寫得很明確:這些型別以標準方式建立、以標準方式複製,而資源由欄位自行管理 (例如 std::string, std::vector)。

7. 使用 =default 時的常見錯誤

錯誤 1:把空主體 {} 寫成替代 =default「因為看起來一樣」。
在「程式可以編譯」的層面,確實可能看起來 T() {} 與 T() =default; 很相似。 但意義不同:空主體表示這是你的實作,而 =default 是向編譯器請求標準生成。 由於這個差別,型別可能在語言的形式屬性上行為不同,且你失去對意圖的清晰表達。

錯誤 2:期待 =default 能「恢復」因欄位而被禁止的操作。
如果型別內有一個不可複製的欄位(例如 std::unique_ptr),則該包含型別的複製在所有權模型下就是不可用的。 在這種情況下,寫下 T(const T&) =default; 不會讓複製變得可能,只是誠實地在程式碼中表達你的期望——編譯器仍然無法生成正確的複製實作。

錯誤 3:加入帶參數構造器後忘記預設構造器可能已消失。
這是個經典陷阱:你為 API 加了改進(例如 Config(path)),然後驚訝於為什麼 Config cfg; 無法編譯。 如果語意上仍需要「預設物件」,就必須明確恢復它:Config() =default;。 這是 =default 最有用且實際的情境之一。

錯誤 4:在標頭中宣告特殊函式,卻在 .cpp 中用 =default,未意識到這會改變函式的狀態。
這麼做是可以的,有時也合理,但你必須清楚接受其後果:該函式可能不再具有你原本期望的「最大程度的標準性」。 如果你不打算改變型別的性質,通常更簡單且更誠實的做法是在 struct 的定義中直接寫 =default。

1
任務
C++ SELF, 等級 45, 課堂 0
上鎖
伺服器設定
伺服器設定
1
任務
C++ SELF, 等級 45, 課堂 0
上鎖
任務標籤
任務標籤
1
任務
C++ SELF, 等級 45, 課堂 0
上鎖
購物清單
購物清單
1
任務
C++ SELF, 等級 45, 課堂 0
上鎖
玩家個人檔案
玩家個人檔案
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION