學習軌道
/
C++
C++
/
練習
/
資料醫生
資料醫生

資料醫生

學習練習

簡介

在 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 "quick_math.h"
#include <cmath>
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();
    };
}
#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;}
    };
}

專案可能會混合使用這幾種安排方式,而哪一種最適合各種使用情境,則有許多討論。

引入防護

你可能已經注意到上面範例標頭檔中的#pragma once那一行。 這叫做引入防護,它能確保檔案內容在編譯過程中只被引入一次,以避免錯誤。 還有一種更複雜的引入防護寫法,以#ifndef開頭,並以#endif結尾。 它的用途相同,用法也展示在上面的Flower類別範例中。

說明

你在一家名為「Doctor Data」的澳洲公司開始你的第一天,該公司專門從事資訊復原。 你憑藉對 C++ 和馮·諾伊曼探測器的知識,順利通過了工作面試。 由於你見過許多測試檔案,你的新老闆希望你能從公司最近恢復的一些測試程式碼中,重建各自的原始碼與標頭檔案。

Note

這個概念練習的工作流與 Exercism 練習題的結構非常相似。 練習的介紹文字只是規格的一部分。 測試檔案是解決給定問題的權威指南。 由於 C++ 編譯的運作方式,在你實作測試所要求的每個類別、函式與列舉的最小版本之前,測試結果可能不會顯示在網頁介面上。 如果你收到編譯錯誤,在你處理完所有任務與測試後,它們可能會消失。

你有四項任務,全都與從標頭檔與原始碼檔案中復原遺失的程式碼有關。

1. 試著提取一些初步線索

你查看從測試檔案中恢復的兩行程式碼:

heaven::Vessel bob{"Robert Johansson", 1};
heaven::Vessel will{"Riker", 2, star_map::System::BetaHydri};

你銳利的眼睛立刻認出一個命名空間heaven。 你也看到一定有一個叫做Vessel的類別。 建構子顯然可以用兩個或三個引數來呼叫。 第一個引數的型別似乎是string,第二個是數字。 可以使用第三個引數來初始化Vessel類別。 第三個引數來自star_map命名空間。 它是型別為System的列舉值。 你甚至得到了其中一個列舉值:BetaHydri。

準備原始碼與標頭檔案,並寫入你發現的資訊。 你需要兩個命名空間:heaven和star_map。 heaven命名空間有一個類別Vessel,它可以用兩個或三個引數來呼叫。 System列舉應放在star_map命名空間中,並有一個BetaHydri列舉值。 留意其他列舉值,以便你能不斷更新System列舉。

2. 找出更多細節

你發現更多來自測試的程式碼行:

heaven::Vessel bob{"Robert Johansson", 1};
REQUIRE(bob.current_system == star_map::System::Sol);
REQUIRE(bob.generation == 1);
heaven::Vessel bob5 = bob.replicate("Mario");
REQUIRE(bob5.current_system == star_map::System::Sol);
REQUIRE(bob5.generation == 2);

新發現的測試程式碼行揭露了Vessel類別的另一個成員函式:replicate。 這個函式接收一個字串並回傳另一個Vessel實例。 你從前一項任務中得知建構子第三個引數的預設值。 你也看到Vessel類別的兩個公開成員變數,以及replicate成員函式的規格。

將replicate函式以及公開成員變數current_system和generation加入標頭檔與原始碼檔案中。

3. 探究內部運作

你在恢復的測試檔案中發現一些更有趣的程式碼行:

heaven::Vessel bob6{"Homer", 3, star_map::System::EpsilonEridani};
REQUIRE(bob6.busters == 0);
bob6.make_buster();
REQUIRE(bob6.busters == 1);
bool success = bob6.shoot_buster();
REQUIRE(success);
REQUIRE(bob6.busters == 0);
success = bob6.shoot_buster();
REQUIRE_FALSE(success);

顯然,Vessel類別有一個成員變數busters,可以透過兩個類別成員函式make_buster和shoot_buster來改變。 在其他資訊出現之前,你猜測make_buster函式回傳void。 由於有針對shoot_buster回傳值的測試,你假設該函式回傳bool。

將這兩個函式與成員變數加入Vessel類別。 繼續尋找其他System列舉值。

4. 完成全貌

在你掃描測試檔案時,你只發現兩個尚未涵蓋的程式碼區段:

heaven::Vessel bob1{"Bob", 1, star_map::System::AlphaCentauri};
heaven::Vessel marv{"Marvin", 2, star_map::System::DeltaEridani};
heaven::Vessel milo{"Milo", 3, star_map::System::DeltaEridani};
heaven::Vessel howie{"Howard", 4, star_map::System::Omicron2Eridani};

REQUIRE("Bob" == heaven::get_older_bob(bob1, marv));
REQUIRE(heaven::in_the_same_system(marv, milo));
REQUIRE_FALSE(heaven::in_the_same_system(marv, howie));

你看到兩個函式,它們不是Vessel類別的成員,因為它們不是用實例來呼叫的。 get_older_bob比較兩個Vessel實例並回傳一個string。 in_the_same_system比較兩個Vessel實例並回傳一個bool。

實作上述恢復的程式碼行中最後缺少的函式。

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

準備好開始 資料醫生 了嗎?

註冊 Exercism,透過 19 個概念100 個練習 和真人引導來學習並精通 C++,全部免費。