後續步驟

程式設計和 C++ 簡介

這個線上教學課程將保留更多進階概念,請參閱第 3 部分。本單元的重點在於使用指標及開始使用物件。

參考範例 #2

本單元的重點在於提供更多練習與分解、瞭解指標以及開始使用物件和類別的經驗。瀏覽下列範例。在提出要求時自行撰寫程式,或是開始實驗。我們沒辦法強調成為優秀程式設計師的關鍵,就是練習、練習、再練習!

範例 #1:更多分解練習

請參考以下簡易遊戲的輸出內容:

Welcome to Artillery.
You are in the middle of a war and being charged by thousands of enemies.
You have one cannon, which you can shoot at any angle.
You only have 10 cannonballs for this target..
Let's begin...

The enemy is 507 feet away!!!
What angle? 25<
You over shot by 445
What angle? 15
You over shot by 114
What angle? 10
You under shot by 82
What angle? 12
You under shot by 2
What angle? 12.01
You hit him!!!
It took you 4 shots.
You have killed 1 enemy.
I see another one, are you ready? (Y/N) n

You killed 1 of the enemy.

第一項觀察是簡介文字,每次執行程式都會顯示一次。我們需要隨機號碼產生器來定義每回合的敵人距離。我們需要從玩家取得角度輸入的機制,而這點顯然地在迴圈結構中,因為這個機制會重複直到擊中敵人。我們也需要一個函式,才能計算距離和角度。最後,我們必須追蹤成功擊敗敵人所需的畫面數量,以及程式執行期間成功擊到的敵人數量。以下是主要程式的大致情況。

StartUp(); // This displays the introductory script.
killed = 0;
do {
  killed = Fire(); // Fire() contains the main loop of each round.
  cout << "I see another one, care to shoot again? (Y/N) " << endl;
  cin >> done;
} while (done != 'n');
cout << "You killed " << killed << " of the enemy." << endl;

火災程序會處理遊戲過程。在這個函式中,我們會呼叫隨機號碼產生器以取得敵方距離,然後設定迴圈來取得玩家輸入內容,並計算玩家是否擊中敵人。鐵圈上的守衛條件是我們前進敵人的程度。

In case you are a little rusty on physics, here are the calculations:

Velocity = 200.0; // initial velocity of 200 ft/sec Gravity = 32.2; // gravity for distance calculation // in_angle is the angle the player has entered, converted to radians. time_in_air = (2.0 * Velocity * sin(in_angle)) / Gravity; distance = round((Velocity * cos(in_angle)) * time_in_air);

由於呼叫 cos() 和 sin(),您需要加入 math.h。請試試編寫這個程式,這是問題分解的絕佳做法,同時也對基礎 C++ 有良好的複習。請記得在每個函式中僅執行一項工作。這是截至目前為止編寫的最複雜程式,因此可能需要一點時間執行。請參閱這裡的解決方案。

範例 2:使用指標練習

使用指標時,請留意四件事:
  1. 指標是保留記憶體位址的變數。當程式執行時,所有變數都會儲存在記憶體中,而且每個變數都有各自的專屬位址或位置。指標是一種特殊類型的變數,內含記憶體位址而非資料值。就像使用一般變數時修改資料一樣,系統會將儲存在指標中的地址值修改為操控指標變數。範例如下:
    int *intptr; // Declare a pointer that holds the address
                 // of a memory location that can store an integer.
                 // Note the use of * to indicate this is a pointer variable.
    
    intptr = new int; // Allocate memory for the integer.
    *intptr = 5; // Store 5 in the memory address stored in intptr.
          
  2. 我們通常會說一個指標「控點」指向儲存的位置 (「點」)。因此,在上述範例中,Intptr 指向第 5 點。

    請注意,使用「new」運算子為整數點分配記憶體。我們得先做過這個步驟,才能嘗試進入焦點。

    int *ptr; // Declare integer pointer.
    ptr = new int; // Allocate some memory for the integer.
    *ptr = 5; // Dereference to initialize the pointee.
    *ptr = *ptr + 1; // We are dereferencing ptr in order
                     // to add one to the value stored
                     // at the ptr address.
          

    * 運算子用於在 C 中解除參照。C/C++ 程式設計師在使用指標時最常出現的錯誤之一,就是忘記初始化指標。這有時會造成執行階段當機,因為我們存取記憶體中含有不明資料的位置。如果嘗試修改這項資料,可能會導致記憶體毀損,因此很難追蹤。

  3. 兩個指標之間的指標指派作業會讓它們指向同一對等點。因此,y = x;讓 y 指向與 x 相同的 Point。指標指派作業不會觸及定點。只是變更一個指標,使其位置與其他指標相同。指標指派後,兩個指標會用來「共用」資料點。 
  4. void main() {
     int* x; // Allocate the pointers x and y
     int* y; // (but not the pointees).
    
     x = new int; // Allocate an int pointee and set x to point to it.
    
     *x = 42; // Dereference x and store 42 in its pointee
    
     *y = 13; // CRASH -- y does not have a pointee yet
    
     y = x; // Pointer assignment sets y to point to x's pointee
    
     *y = 13; // Dereference y to store 13 in its (shared) pointee
    }
      

以下是此程式碼的追蹤記錄:

1. 分配兩個指標 x 和 y。分配指標不會分配任何資料點。
2. 分配 Point 並將 x 設為指向。
3. 解除參照 x,以便在 Pointee 中儲存 42。這是解除參照作業的基本範例。從 x 開始,跟隨箭頭移動以進入終點。
4. 嘗試解除參照 y,在焦點位置儲存 13。這會異常終止,因為 y 沒有焦點,而不是指派對象。
5. 指派 y = x,讓 y 指向 x 的 Point。現在 x 和 y 指向同一焦點,同時也是「分享」。
6. 嘗試解除參照 y,在焦點位置儲存 13。這次是有效,因為先前的作業給了重點。

如您所見,圖片對於瞭解指標使用情形非常有幫助。以下再舉一例說明。

int my_int = 46; // Declare a normal integer variable.
                 // Set it to equal 46.

// Declare a pointer and make it point to the variable my_int
// by using the address-of operator.
int *my_pointer = &my_int;

cout << my_int << endl; // Displays 46.

*my_pointer = 107; // Derefence and modify the variable.

cout << my_int << endl; // Displays 107.
cout << *my_pointer << endl; // Also 107.

請注意,在此範例中,我們從未使用「new」運算子分配記憶體。我們宣告了一般整數變數,並透過指標加以操控。

在這個範例中,我們將說明如何使用可取消分配堆積記憶體的刪除運算子,以及如何分配較複雜的結構。另一節會說明記憶體配置 (堆積和執行階段堆疊)。目前,請將堆積視為可供執行程式的免費記憶體儲存空間。

int *ptr1; // Declare a pointer to int.
ptr1 = new int; // Reserve storage and point to it.

float *ptr2 = new float; // Do it all in one statement.

delete ptr1; // Free the storage.
delete ptr2;

在最後一個範例中,我們會說明如何使用指標透過參照函式來傳送值。這就是修改函式內變數值的方式。

// Passing parameters by reference.
#include <iostream>
using namespace std;

void Duplicate(int& a, int& b, int& c) {
  a *= 2;
  b *= 2;
  c *= 2;
}

int main() {
  int x = 1, y = 3, z = 7;
  Duplicate(x, y, z);
  // The following outputs: x=2, y=6, z=14.
  cout << "x="<< x << ", y="<< y << ", z="<< z;
  return 0;
}

如果我們在重複函式定義中將引數保持停用 (&s),則會「依值」傳遞變數,也就是依據變數值建立副本。對函式中的變數所做的任何變更都會修改複製內容。 不會修改原始變數。

透過參照傳遞變數時,如果系統未傳送該變數的值副本,我們會將變數的位址傳遞至函式。我們對本機變數所做的任何修改,實際上都會修改傳入的原始變數。

如果您是 C 程式設計師,這個新方法可以派上用場。我們可以在 C 中執行相同操作,具體做法是將 Duplicate() 宣告為 Duplicate(int *x),此時 x 是指向 int 的指標,再用引數為 &x (x 的地址) 呼叫 Duplicate(),並在 Duplicate() 中使用 x 取消參照 (請見下方說明)。但 C++ 可讓您更輕鬆地透過參照將值傳送至函式,即使舊的「C」方法仍可正常運作。

void Duplicate(int *a, int *b, int *c) {
  *a *= 2;
  *b *= 2;
  *c *= 2;
}

int main() {
  int x = 1, y = 3, z = 7;
  Duplicate(&x, &y, &z);
  // The following outputs: x=2, y=6, z=14.
  cout << "x=" << x << ", y=" << y << ", z=" << z;
  return 0;
}

請注意,使用 C++ 參照時,我們不需要傳遞變數的地址,也不必對已呼叫函式中的變數解除參照。

以下程式輸出的內容為何?繪製記憶體圖片即可找出答案。

void DoIt(int &foo, int goo);

int main() {
  int *foo, *goo;
  foo = new int;
  *foo = 1;
  goo = new int;
  *goo = 3;
  *foo = *goo + 3;
  foo = goo;
  *goo = 5;
  *foo = *goo + *foo;
  DoIt(*foo, *goo);
  cout << (*foo) << endl;
}

void DoIt(int &foo, int goo) {
  foo = goo + 3;
  goo = foo + 4;
  foo = goo + 3;
  goo = foo;
} 

請執行程式,看看是否能答對。

範例 #3:透過參照傳遞值

請編寫名為 Accelerated() 的函式,該函式會採用車輛速度和金額。這個函式會將金額加到速度中,讓車輛加速。Speed 參數應以參照方式和數值所傳遞。請參考這裡的解決方案。

範例 #4:類別與物件

請考慮以下類別:

// time.cpp, Maggie Johnson
// Description: A simple time class.

#include <iostream>
using namespace std;

class Time {
 private:
  int hours_;
  int minutes_;
  int seconds_;
 public:
  void set(int h, int m, int s) {hours_ = h; minutes_ = m; seconds_ = s; return;}
  void increment();
  void display();
};

void Time::increment() {
  seconds_++;
  minutes_ += seconds_/60;
  hours_ += minutes_/60;
  seconds_ %= 60;
  minutes_ %= 60;
  hours_ %= 24;
  return;
}

void Time::display() {
  cout << (hours_ % 12 ? hours_ % 12:12) << ':'
       << (minutes_ < 10 ? "0" :"") << minutes_ << ':'
       << (seconds_ < 10 ? "0" :"") << seconds_
       << (hours_ < 12 ? " AM" : " PM") << endl;
}

int main() {
  Time timer;
  timer.set(23,59,58);
  for (int i = 0; i < 5; i++) {
    timer.increment();
    timer.display();
    cout << endl;
  }
}

請注意,類別成員變數結尾會加上底線。這麼做是為了區別本機變數和類別變數。

為這個類別新增減量方法。請參考這裡的解決方案。

科學奇觀:電腦科學

演習

如同本課程第一個單元,我們並未為運動和專案提供解決方案。

記住這個計畫...

...在邏輯上分解為函式,其中任一函式只會執行一項工作。

...具有類似程式功用的主要程式。

...包含描述性函式、常數和變數名稱。

... 使用常數避免程式中的任何「魔術」數字。

... 具有友善的使用者介面。

暖身運動

  • 運動 1

    整數 36 具有複數屬性:這是完美的平方,同時也是 1 到 8 之間整數的總和。下一個這類數字是 1225,也就是 352,以及 1 到 49 之間整數的總和。請找出下一個是正平方,同時也是數列 1...n 的總和。下一個數字可能大於 32767。您可以使用熟悉的程式庫函式或數學公式,加快程式的執行速度。您也可以使用 forloops 來編寫這個程式,藉此判斷數字是完美的正方形還是數列的總和。(注意:根據您的機器和程式而定,系統可能需要一段時間才能找到該號碼)。

  • 運動 2

    大學的書店需要您協助估算明年的業務成效。經驗證明,銷售量在提升銷售量的關鍵因素,取決於特定書籍是否需要課程或單選,以及先前是否曾在課堂中使用。新教科書的潛在註冊人數則會銷售到 90% 的潛在註冊機會,但如果先前曾在課堂中使用,只有 65% 會購買。同樣地, 40% 的應試者會購買新教科書 (選填),但必須在 20% 之前於課堂中使用,才會購買。(請注意,這裡的「used」不代表二手書)。

  • 編寫程式接受輸入一連串書籍 (直到使用者進入 Sentinel 為止)。每本書都必須提供:代碼、書籍單一副本費用、目前手上的書籍數量、可報名的課程,以及指出書籍是否為必填/選擇性、新/二手的資料。做為輸出,請以正確格式化的畫面顯示所有輸入資訊,以及必須排序的書籍數量 (請注意,只有新書會排序),每筆訂單的總費用。

    所有輸入資料都完成後,請顯示所有書籍訂單的總費用。如果商店支付的 80% 是定價,則預期利潤。由於我們尚未討論如何處理計劃中的大量資料 (敬請持續關注!),因此只要一次處理一本書,並顯示這本書的輸出畫面。 接著,當使用者輸入完所有資料後,您的程式應輸出總金額和利潤值。

    開始編寫程式碼前,請花點時間思考這個程式的設計。分解為一組函式,然後建立 main() 函式,以便讀取問題概略說明的解決方案。確保每個函式都會執行一項工作。

    以下是輸出範例:

    Please enter the book code: 1221
     single copy price: 69.95
     number on hand: 30
     prospective enrollment: 150
     1 for reqd/0 for optional: 1
     1 for new/0 for used: 0
    ***************************************************
    Book: 1221
    Price: $69.95
    Inventory: 30
    Enrollment: 150
    
    This book is required and used.
    ***************************************************
    Need to order: 67
    Total Cost: $4686.65
    ***************************************************
    
    Enter 1 to do another book, 0 to stop. 0
    ***************************************************
    Total for all orders: $4686.65
    Profit: $937.33
    ***************************************************

資料庫專案

在這個專案中,我們會建立功能齊全的 C++ 程式,實作簡單的資料庫應用程式。

我們的計畫將讓我們管理作曲家資料庫及其相關資訊。這項計畫的功能包括:

  • 能夠新增作曲家
  • 為作曲家決定排名的功能 (例如表示我們喜歡或不喜歡該作曲家的音樂)
  • 能夠查看資料庫中的所有作曲家
  • 可依排名查看所有作曲家

「軟體設計的建構方式有兩種:讓它變得明顯沒有效率提升的方法,另一個方法是讓它變得複雜,不明顯有效率。第一種方法並不容易。」- C.A.R. Hoare

許多人也學到如何以「程序」的方式設計及編寫程式碼。我們一開始的核心問題是「程式必須做些什麼?」。我們會把解決方案分解為工作,每個工作都會解決某個問題。這些工作對應到我們程式中的函式,並會從 main() 或其他函式依序呼叫。這個逐步引導方式適用於我們需要解決的一些問題。不過,我們的程式往往不僅是工作或事件的線性序列,

選擇物件導向 (OO) 方法時,我們會先從以下問題開始:「我要模擬哪些實際物件?」我們不會將程式分割為上述所述的任務,而是將程式分割為實體物件的模型。這些實體物件具有由一組屬性定義的狀態,以及一組可執行的行為或動作。這些動作可能會變更物件的狀態,或是叫用其他物件的動作。基本的注意事項是,物件「知道」如何自行處理事情。

在 OO 設計中,我們依據類別和物件、屬性和行為來定義實體物件。OO 程式中通常會有大量物件。不過,這些物件中許多但基本上是相同的。請考量下列要點。

類別是物件的一般屬性和行為組合,這些屬性和行為可能存在於實際世界中。在上圖中,我們有一個 Apple 類別。 所有蘋果 (無論類型為何) 都有顏色和品味屬性。我們也定義了 Apple 顯示屬性的行為。

在這張圖表中,我們定義了兩個 Apple 類別的物件。每個物件的屬性和動作都與類別相同,但物件會定義特定蘋果類型的屬性。此外,「多媒體」動作會顯示該特定物件的屬性,例如「Green」和「Sour」。

OO 設計包含一組類別、與這些類別相關聯的資料,以及類別可執行的動作組合。我們也需要找出不同類別的互動方式。這類互動可透過叫用其他類別物件動作的類別物件執行。舉例來說,我們可以建立 AppleOutputer 類別,藉由呼叫每個 Apple 物件的 Display() 方法,輸出 Apple 物件陣列的顏色和品味。

以下是 OO 設計中執行步驟:

  1. 識別類別,並在一般情況下定義每個類別的物件會儲存為資料,以及物件可執行的操作。
  2. 定義每個類別的資料元素
  3. 定義每個類別的動作,以及一個類別透過其他相關類別的動作可如何實作。

針對大型系統,這些步驟會以不同程度反覆執行。

針對 Composer 資料庫系統,我們需要 Composer 類別,能夠封裝我們要儲存在個別作曲家中的所有資料。此類別的物件可以提升或降低其排名 (變更其排名),並顯示其屬性。

此外,我們也需要一組 Composer 物件。為此,我們定義了管理個別記錄的 Database 類別。這個類別的物件可以新增或擷取 Composer 物件,並叫用 Composer 物件的顯示動作來顯示個別物件。

最後,我們需要特定的使用者介面,才能在資料庫上提供互動式作業。這是預留位置類別,亦即我們真的不知道使用者介面的呈現方式,但確實知道需要一個。也許是圖形化,也許是文字形式。目前我們定義了預留位置,稍後可以填入。

目前我們已識別 Composer 資料庫應用程式的類別,下一步就是定義類別的屬性和動作。在較複雜的應用程式中,我們會使用鉛筆和紙張、UMLCRC 資訊卡OOD 來規劃類別階層以及物件互動的方式。

針對我們的 Composer 資料庫,我們會定義 Composer 類別,其中包含我們想儲存在各作曲家上的相關資料。也包含操控排名及顯示資料的方法。

資料庫類別需要某種結構來保留 Composer 物件。我們必須能在結構中新增 Composer 物件,以及擷取特定 Composer 物件。我們還想依照項目順序或排名顯示所有物件。

使用者介面類別會導入選單型介面,以及呼叫資料庫類別中的動作的處理常式。

如果類別可輕鬆理解、屬性和動作也清晰易懂,就如同在 Composer 應用程式中一樣,設計類別時就相對簡單。不過,如果您對於類別之間的關聯和互動有任何疑問,最好先畫出問題,然後逐步完成相關細節,再開始編寫程式碼。

清楚掌握設計流程並評估設計之後 (稍後會詳細說明),我們就會定義每個類別的介面。目前不必擔心實作詳細資料 - 只有屬性和動作,以及類別狀態與動作可供其他類別使用的的部分。

在 C++ 中,我們通常會為每個類別定義標頭檔案。Composer 類別擁有私人資料成員,方便存放我們想在作曲家中儲存的所有資料。 我們需要存取子 (「get」方法) 和變動器 (「set」方法),以及類別的主要動作。

// composer.h, Maggie Johnson
// Description: The class for a Composer record.
// The default ranking is 10 which is the lowest possible.
// Notice we use const in C++ instead of #define.
const int kDefaultRanking = 10;

class Composer {
 public:
  // Constructor
  Composer();
  // Here is the destructor which has the same name as the class
  // and is preceded by ~. It is called when an object is destroyed
  // either by deletion, or when the object is on the stack and
  // the method ends.
  ~Composer();

  // Accessors and Mutators
  void set_first_name(string in_first_name);
  string first_name();
  void set_last_name(string in_last_name);
  string last_name();
  void set_composer_yob(int in_composer_yob);
  int composer_yob();
  void set_composer_genre(string in_composer_genre);
  string composer_genre();
  void set_ranking(int in_ranking);
  int ranking();
  void set_fact(string in_fact);
  string fact();

  // Methods
  // This method increases a composer's rank by increment.
  void Promote(int increment);
  // This method decreases a composer's rank by decrement.
  void Demote(int decrement);
  // This method displays all the attributes of a composer.
  void Display();

 private:
  string first_name_;
  string last_name_;
  int composer_yob_; // year of birth
  string composer_genre_; // baroque, classical, romantic, etc.
  string fact_;
  int ranking_;
};

Database 類別也相當簡單。

// database.h, Maggie Johnson
// Description: Class for a database of Composer records.
#include  <iostream>
#include "Composer.h"

// Our database holds 100 composers, and no more.
const int kMaxComposers = 100;

class Database {
 public:
  Database();
  ~Database();

  // Add a new composer using operations in the Composer class.
  // For convenience, we return a reference (pointer) to the new record.
  Composer& AddComposer(string in_first_name, string in_last_name,
                        string in_genre, int in_yob, string in_fact);
  // Search for a composer based on last name. Return a reference to the
  // found record.
  Composer& GetComposer(string in_last_name);
  // Display all composers in the database.
  void DisplayAll();
  // Sort database records by rank and then display all.
  void DisplayByRank();

 private:
  // Store the individual records in an array.
  Composer composers_[kMaxComposers];
  // Track the next slot in the array to place a new record.
  int next_slot_;
};

請留意我們如何在個別類別中仔細封裝 Composer 專屬資料。我們可以將結構或類別加入 Database 類別來代表 Composer 記錄,並直接在該記錄存取。但這屬於「物件推理範圍」,也就是說,我們不像盡可能運用物件建模。

在開始實作 Composer 和 Database 類別時,您會發現要建立個別 Composer 類別會更加簡潔。特別是,在 Composer 物件上單獨執行不可部分完成的作業,大幅簡化 Database 類別中 Display() 方法的實作。

當然,我們也會嘗試將所有類別都製作成一個類別,或者類別數量超過需求。要取得適當平衡是熟練的事,您也會發現每個程式設計人員都有不同的意見。

您可以藉由仔細繪製類別圖表來找出物件過度或物件不足的情形。如前文所述,在開始寫程式前,請務必先測試類別設計,這有助於您分析方法。UML (統合式模型語言) 可用於此用途的常見標記法是:我們為 Composer 和 Database 物件定義類別,因此需要一個介面,讓使用者能與資料庫互動。只要選單簡單,就能派上用場:

Composer Database
---------------------------------------------
1) Add a new composer
2) Retrieve a composer's data
3) Promote/demote a composer's rank
4) List all composers
5) List all composers by rank
0) Quit

我們可以將使用者介面實作為類別或程序程式。C++ 程式中的所有內容不必是類別。事實上,如果處理程序是以順序或工作為導向,如這個選單程式所示,則可以逐步實作。實作方式必須保持為「預留位置」,也就是說,如果日後想要建立圖形使用者介面,我們應該不必變更系統中的任何內容,而是使用者介面。

最後,我們還需要完成類別的程式測試類別。針對 Composer 類別,我們希望 main() 程式可接收輸入內容、填入 Composer 物件,然後顯示該物件,確保類別正常運作。我們也希望呼叫 Composer 類別的所有方法。

// test_composer.cpp, Maggie Johnson
//
// This program tests the Composer class.

#include <iostream>
#include "Composer.h"
using namespace std;

int main()
{
  cout << endl << "Testing the Composer class." << endl << endl;

  Composer composer;

  composer.set_first_name("Ludwig van");
  composer.set_last_name("Beethoven");
  composer.set_composer_yob(1770);
  composer.set_composer_genre("Romantic");
  composer.set_fact("Beethoven was completely deaf during the latter part of "
    "his life - he never heard a performance of his 9th symphony.");
  composer.Promote(2);
  composer.Demote(1);
  composer.Display();
}

資料庫類別需要進行類似的測試計畫。

// test_database.cpp, Maggie Johnson
//
// Description: Test driver for a database of Composer records.
#include <iostream>
#include "Database.h"
using namespace std;

int main() {
  Database myDB;

  // Remember that AddComposer returns a reference to the new record.
  Composer& comp1 = myDB.AddComposer("Ludwig van", "Beethoven", "Romantic", 1770,
    "Beethoven was completely deaf during the latter part of his life - he never "
    "heard a performance of his 9th symphony.");
  comp1.Promote(7);

  Composer& comp2 = myDB.AddComposer("Johann Sebastian", "Bach", "Baroque", 1685,
    "Bach had 20 children, several of whom became famous musicians as well.");
  comp2.Promote(5);

  Composer& comp3 = myDB.AddComposer("Wolfgang Amadeus", "Mozart", "Classical", 1756,
    "Mozart feared for his life during his last year - there is some evidence "
    "that he was poisoned.");
  comp3.Promote(2);

  cout << endl << "all Composers: " << endl << endl;
  myDB.DisplayAll();
}

請注意,這些簡單的測試程式是好的第一步,但需要我們手動檢查輸出內容,確認程式是否正常運作。隨著系統規模變大,手動檢查輸出就變得不切實際。在後續課程中,我們將以單元測試的形式介紹自我檢查測試計畫。

我們應用程式的設計現已完成。下一步是為類別和使用者介面實作 .cpp 檔案。首先,複製/貼上上方的 .h 程式碼,然後將上方的驅動程式程式碼複製到檔案中,再編譯這些檔案。請使用測試驅動程式測試類別。然後實作下列介面:

Composer Database
---------------------------------------------
1) Add a new composer
2) Retrieve a composer's data
3) Promote/demote a composer's rank
4) List all composers
5) List all composers by rank
0) Quit

使用您在資料庫類別中定義的方法實作使用者介面。確保方法能避免錯誤。舉例來說,排名應一律在 1 到 10 之間。除非您打算變更資料庫類別中的資料結構,否則請勿讓任何人新增 101 個 Composer。

請注意,所有程式碼都必須遵循我們為方便而重複進行的程式設計慣例:

  • 我們撰寫的每個程式都會以標頭註解開頭,提供作者姓名、聯絡資訊、簡短說明和用途 (如有)。每個函式/方法都會從作業和使用情形註解開始。
  • 每當程式碼未記錄程式碼 (例如處理過程困難、不明顯、有趣或重要的時),我們就會以完整句子加入說明文字。
  • 一律使用描述性名稱:變數是小寫的字詞,並以 _ 分隔,如 my_variable 中所示。函式/方法名稱會使用大寫字母來標示字詞,例如 MyExcitingFunction() 中。常數以「k」開頭,並使用大寫英文字母標示字詞,例如 kDaysInWeek。
  • 縮排為二的倍數。第一層是兩個空格;如果需要進一步縮排,我們會使用四個空格、六個空格,以此類推。

歡迎來到現實世界!

在本單元中,我們將介紹大多數軟體工程機構使用的兩項非常重要的工具。第一項是建構工具,第二種是設定管理系統。這兩項工具在工業軟體工程中扮演關鍵角色,許多工程師通常都會使用同一個大型系統。這些工具有助於協調及控製程式碼集的變更,並提供從許多程式和標頭檔案編譯及連結系統的高效方法。

製作檔案

建構程式的程序通常是透過建構工具管理,這項工具會按照正確順序編譯及連結必要檔案。C++ 檔案通常具有依附元件,例如,在某個程式中呼叫的函式位於另一個程式中。或是需要多個不同的 .cpp 檔案必須使用標頭檔案。建構工具會從這些依附元件判斷正確的編譯順序。這個做法也只會編譯上次建構後變更的檔案。這麼一來,內含數百個或數千個檔案的系統可以省下大量時間。

經常使用名為 Make 的開放原始碼建構工具。如要瞭解相關資訊,請參閱這篇文章。請確認您是否能建立 Composer 資料庫應用程式的依附元件圖表,然後將該圖表轉譯為 makefile。請參閱這裡的解決方案。

設定管理系統

工業軟體工程中使用的第二種工具是設定管理 (CM)。用於管理變更。假設 Bob 和 Susan 都是技術文件撰寫專員,兩位都正在努力更新技術手冊。在會議期間,主管會分別指派在同一份文件中要更新的部分。

技術手冊會儲存在 Bob 和 Susan 都能存取的電腦中。如果沒有設定任何 CM 工具或程序,可能會導致一些問題。其中一個可能的情況是,儲存文件的電腦可能會經過設定,讓 Bob 和 Susan 無法同時手動操作。進而大幅降低速度。

如果儲存體的電腦允許 Bob 和 Susan 同時開啟文件, 會發生更危險的情況。以下是可能的情況:

  1. 白先生在電腦上開啟文件,然後開始做作業。
  2. 蘇珊在電腦上開啟文件,然後完成她的工作。
  3. 志明完成變更,並將文件儲存在儲存電腦上。
  4. 小蘇完成變更後,將文件儲存在儲存電腦上。

這張插圖顯示如果在技術手冊的單一副本上沒有控制項,可能會發生的問題。小珊儲存變更時,她會覆寫了 Bob 所做的變更。

這正是 CM 系統可以控制的情況類型。有了 CM 系統, Bob 和 Susan 都會「查看」自己的技術手冊副本並進行相關作業。當 Bob 再次檢查變更時,系統會知道阿蘇已查看自己的副本。當 Susan 檢查自己的副本時,系統會分析 Bob 和 Susan 所做的變更,然後建立新版本,將兩組變更合併在一起。

除了管理並行變更之外,CM 系統還有許多功能,如上所述。許多系統會儲存文件所有版本的封存 (從建立文件開始時)。就技術手冊而言,如果使用者持有舊版手冊,並向技術撰寫人員提問,這項功能就非常實用。CM 系統可讓技術寫入者存取舊版本,並查看使用者看到的內容。

CM 系統在控制軟體變更時特別實用。這類系統稱為軟體設定管理 (SCM) 系統。如果您考慮到大型軟體工程組織中的大量個別原始碼檔案,以及需要變更設定檔的工程師人數,就能知道 SCM 系統至關重要。

軟體設定管理

SCM 系統秉持一個簡單的理念:將檔案的確切副本保存在中央存放區。使用者會查看存放區中的檔案副本,處理這些副本,並在作業完成後再次回來查看。SCM 系統會針對單一主要集合,管理及追蹤多位使用者的修訂版本。

所有 SCM 系統都提供下列基本功能:

  • 並行管理
  • 版本管理
  • 同步處理

接下來,我們將詳細說明這兩項功能。

並行管理

並行是指多人同時編輯檔案。對於大型存放區,我們希望使用者可以這麼做,但也可能會造成一些問題。

考慮使用工程領域的簡易範例:假設 Google 允許工程師在原始碼的中央存放區中同時修改相同的檔案,Client1 和 Client2 都要同時變更檔案:

  1. Client1 開啟 bar.cpp 開啟。
  2. Client2 開啟 bar.cpp。
  3. Client1 變更並儲存檔案,
  4. Client2 變更檔案並儲存該檔案,覆寫了 Client1 的變更。

很遺憾,我們不希望發生這種狀況。即使我們控制這個情況,會讓兩位工程師使用單獨的副本,而非直接使用主集合 (如下圖所示),但副本也必須以某種方式協調。大多數的 SCM 系統處理這個問題時,多半會允許多位工程師簽出檔案 (「同步處理」或「更新」),然後視需要進行變更。接著,SCM 系統會在檔案再次簽入 (「提交」或「修訂」) 至存放區時執行演算法,將變更合併。

這些演算法可能相當簡單 (要求工程師解決衝突的變更) 或不簡單 (決定如何以智慧方式合併衝突的變更,且只在系統確實卡住時詢問工程師)。

版本管理

版本管理是指追蹤檔案修訂版本,方便您重新建立 (或復原至) 舊版檔案。方法是在存放區檢查您的所有檔案時,為其建立封存檔案副本,或是儲存檔案的所有變更。我們可隨時使用封存檔或變更資訊來建立先前的版本。版本管理系統還可以建立記錄報告,說明檢查變更的使用者、檢查時間以及變更的內容。

同步處理

使用某些 SCM 系統時,個別檔案會移入或移出存放區。系統功能更強大,可讓您一次查看多個檔案。工程師會自行查看完整的存放區副本 (或其中一部分),並視需要處理檔案。接著,他們會定期將變更修訂至主要存放區,並更新自己的個人副本,隨時掌握他人所做變更。這項程序稱為同步處理或更新。

子版本

Subversion (SVN) 是開放原始碼版本管控系統。具備上述所有功能。

如果發生衝突,SVN 會採用簡單的方法。如果兩名以上的工程師對程式碼集的同一區域做出不同變更,然後兩人都提交變更,就會發生衝突。但 SVN 只會通知工程師是否有衝突,交由工程師解決。

本課程將使用 SVN,協助您熟悉設定管理作業。這類系統在業界很常見。

第一步是在系統上安裝 SVN。如需操作說明,請按這裡。找出您的作業系統,然後下載合適的二進位檔。

部分 SVN 術語

  • 修訂版本:單一檔案或一組檔案的變更。修訂版本是指在不斷變動的專案中,一個「快照」。
  • 存放區:SVN 用於儲存專案完整修訂版本記錄的主要副本。每項專案都有一個存放區。
  • 使用中副本:工程師對專案進行變更的副本。特定專案的許多工作副本可由個別工程師擁有。
  • 結帳:要求存放區中的工作副本。有效副本等同於簽出專案的狀態。
  • 修訂:將工作副本中的變更傳送至中央存放區。也稱為「簽到或提交」。
  • 更新:將存放區中其他使用者所做的變更帶到工作副本,或是標示工作副本是否有未提交的變更。這與上述的同步處理作業相同。因此,update/sync 服務就會使用存放區副本,即時更新工作副本。
  • 衝突:當兩位工程師嘗試對檔案同一區域做出變更時,就會發生此情況。SVN 表示衝突,但工程師必須解決。
  • 記錄訊息:您修訂修訂版本時附加到修訂版本的註解,說明您的變更。記錄檔會提供專案處理情況的摘要。

安裝 SVN 後,我們將完成一些基本指令。首先,請在指定目錄中設定存放區。下列指令如下:

$ svnadmin create /usr/local/svn/newrepos
$ svn import mytree file:///usr/local/svn/newrepos/project -m "Initial import"
Adding         mytree/foo.c
Adding         mytree/bar.c
Adding         mytree/subdir
Adding         mytree/subdir/foobar.h

Committed revision 1.

import 指令會將 mytree 目錄內容複製到存放區中的目錄專案,我們可以使用 list 指令查看存放區中的目錄

$ svn list file:///usr/local/svn/newrepos/project
bar.c
foo.c
subdir/

匯入作業無法建立有效副本。方法是使用 svn checkout 指令。這項操作會建立目錄樹狀結構的工作副本。現在開始吧:

$ svn checkout file:///usr/local/svn/newrepos/project
A    foo.c
A    bar.c
A    subdir
A    subdir/foobar.h
…
Checked out revision 215.

建立作業副本後,您就可以變更該處的檔案和目錄。工作副本就像任何其他檔案和目錄的集合,您可以新增或編輯檔案和目錄,也可以移動這些項目,甚至刪除整個工作副本。請注意,如果要複製及移動工作副本中的檔案,請務必使用 svn 副本svn Move,而不是作業系統指令。如要新增檔案,請使用 svn add 功能並刪除檔案。如要刪除檔案,請使用 svn delete。如果想進行編輯,只要使用編輯器開啟檔案,就能立即編輯!

某些標準目錄名稱經常與 Subversion 搭配使用。「中繼」目錄包含專案的主要開發線,「分支版本」目錄會保留您可能在處理的任何分支版本版本。

$ svn list file:///usr/local/svn/repos
/trunk
/branches

因此,假設您已對工作副本進行所有必要的變更,且想要與存放區同步處理。如果有許多工程師在存放區的這個區域中工作,請務必隨時更新現有版本的副本。您可以使用 svn status 指令查看您所做的變更。

A       subdir/new.h      # file is scheduled for addition
D       subdir/old.c        # file is scheduled for deletion
M       bar.c                  # the content in bar.c has local modifications

請注意,狀態指令有許多標記可以控制這項輸出。如要查看修改後檔案的特定變更,請使用 svn diff

$ svn diff bar.c
Index: bar.c
===================================================================
--- bar.c	(revision 5)
+++ bar.c	(working copy)
## -1,18 +1,19 ##
+#include
+#include

 int main(void) {
-  int temp_var;
+ int new_var;
...

最後,如要從存放區更新工作副本,請使用 svn update 指令。

$ svn update
U  foo.c
U  bar.c
G  subdir/foobar.h
C  subdir/new.h
Updated to revision 2.

這是可能發生衝突的位置。在上方的輸出內容中,「U」表示未變更這些檔案的存放區版本,且更新已完成。「G」表示發生合併。存放區版本已變更,但這些變更並未與您的存放區版本相衝突。「C」表示衝突。這表示存放區中的變更與您的機構重疊,您現在必須選擇這些變更。

針對每個有衝突的檔案,Subversion 會在工作副本中存放三個檔案:

  • file.mine:這是在你更新工作副本前,就存在於工作副本中的檔案。
  • file.rOLDREV:這是您變更前從存放區登出的檔案。
  • file.rNEWREV:這個檔案是存放區中的現行版本。

您可以採取下列其中一種做法來解決衝突:

  • 逐一查看檔案,手動進行合併。
  • 將 SVN 建立的其中一個暫存檔案複製到運作中的副本版本。
  • 執行 svn 還原 可捨棄所有變更。

衝突解決後,請執行 svn 解決並通知 SVN。 這樣會移除三個暫存檔案,SVN 就不會再查看處於衝突狀態的檔案。

最後一步是將最終版本提交至存放區。如要這麼做,請使用 svnCommit 指令。提交變更時,您必須提供記錄訊息來說明變更。這則記錄訊息會附加至您建立的修訂版本。

svn commit -m "Update files to include new headers."  

SVN 可進一步掌握許多資訊,以及這項技術如何支援大型軟體工程專案。網路上有許多豐富的資源,只要對「Subversion」進行 Google 搜尋即可。

以練習來說,請為 Composer 資料庫系統建立存放區,並匯入所有檔案。接著檢查可正常運作的複本,並遵循上述指令。

參考資料

線上子版本書籍

維基百科的 SVN 文章

子版本網站

應用程式:解剖學研究

查看德州大學奧斯汀分校的 eSkeletons