從 DSA 作業看 C++ 課程不一定會教你的那些事

之所以要學高階程式語言,其中一個原因是我們不希望它只要「能動就好」。

修 DSA (Data Structures & Algorithms,資料結構與演算法) 已經是去年的事情了。
因為課表相對去年空很多,有一些閒暇時間梳理所學,也能藉機把想寫的文章多推進一點。看今年 Roger 班 DSA 作業一,和去年的進度相比,覺得是個十分「有感」的革新:今年的作業一是個基本但重要的基本功練習,而題材就是經典的矩陣相乘。

這份作業要求使用 C++11 標準撰寫,除了題材上能讓學生稍微練習到用 C++ 撰寫數值計算 (最簡單的那種),可以注意到作業的底稿是一個 Matrix 類別,標頭檔 (header file) 是不可變動的,要學生練習在介面已經確定的情況下將實作補完。


因為課程網 (作業說明) 好像常常連不上,以下標頭檔原文轉載。

matrix.h

觀察這個標頭檔可以發現這個類別:

  1. 矩陣數值的型態均為 double,所需的儲存空間為建立物件時動態配置。
  2. 實作建構子 (constructor):以行列為參數建構物件,並須實作複製與指定等操作。
  3. 實作解構子 (destructor):物件被消滅時正確地回收動態配置的記憶體。
  4. 實作運算子重載 (overload):一元運算(正、負)、二元運算 (加減乘除)、 [] 運算子。
  5. 實作 I/O 方法和一個 inverse() 來取得方陣的反矩陣,spec 上說,呼叫前保證反矩陣一定存在。

筆者自從去年 DSA 修完後就很少寫 C++ 程式了,看到這份作業實在是躍躍欲試,於是試著寫了一個版本,在排除一些基本問題之後很快就通過了,並發現今年的 judge 可以在 AC 後看其他人的程式碼,甚是感動。然而看了之後發現了一些有趣 (或匪夷所思)的寫法。

以下程式碼區塊若有提到參考實作 ( /* ref. impl. */),皆是在不更動語意的情況下改寫自這裡,程式碼需要相應權限才看得到。

Constructor

先來看看建構子 (constructor) 的寫法吧:
(請原諒 Medium 沒有 code highlighting,合先敘明。)

Matrix::Matrix(const int& r, const int& c) : row(r), col(c) {
array = new double*[r];
for (int i = 0; i < r; i++) {
array[i] = new double[c](); // initialized to 0
}
}

透過建構子的「成員初始化列表 (member initializer list)」,可以用簡潔的語法初始化類別實體的成員,先初始化然後才執行建構子函式內部的程式碼。

再來是複製建構子 (copy constructor)。你可能會想直接這樣寫:

/* ref. impl. */
Matrix::Matrix(const Matrix& rhs) : row(rhs.row), col(rhs.col) {
array = new double*[r];
for (int i = 0; i < row; i++) {
array[i] = new double[c]();
}
// ... wait a minute!
}

的確,複製就是先重新做一次初始化,然後把新值填進去。但這麼做是個不好的習慣,因為你剛才才寫過一模一樣的東西,就直接複製貼上過來,若建構子再複雜一點,直接複製貼上會使你的程式碼難以修改、難以維護。為了不要溼掉 (WET, write everything twice),已經在建構子的內容應該要能被重用。

你或許會辯解說這只是個作業云云,或是這只是個簡單的類別,複製貼上無傷大雅,而且我並不需要維護一份只為了交作業的玩具 code。屋內充滿了快活的空氣。但更嚴重的是,C++ 都已經出到 C++17 了,你在寫的是 C++,卻還沒與時俱進

C++11 有個新的語法是「委派建構子 (delegating constructor)」,能夠在時大幅簡化建構子的重複部份:

[…] If the name of the class itself appears as class-or-identifier in the member initializer list, then the list must consist of that one member initializer only; such constructor is known as the delegating constructor, and the constructor selected by the only member of the initializer list is the target constructor.
(
cppreference.com)

大意是說你可以在建構子的開頭把這個建構的工作「委派 (delegate)」給其它建構子 (只能有一個!) 於是你就可以寫成這樣:

Matrix::Matrix(const Matrix& rhs) : Matrix(rhs.row, rhs.col) {
for (int i = 0; i < r; i++)
for (int j = 0; j < c; j++)
array[i][j] = rhs.array[i][j];
}

乾淨許多,語意也較清晰,方便日後維護及擴充 (如果有這個可能的話)。更重要的是,降低了犯錯的可能性。

Assignment Operator

好吧,這裡是「賦值」而不是建構子了,於是你只好再複製貼上一次:

/* ref. impl. */
Matrix Matrix::operator =(const Matrix& rhs) {
row = rhs.row;
col = rhs.col;
array = new double*[row];
/* ... tedious job here ... */
return *this;
}

… 但你其實可以這麼做 (copy-and-swap idiom):

Matrix Matrix::operator =(const Matrix& rhs) {
Matrix ret(rhs); // copy
std::swap(this->row, ret.row);
std::swap(this->col, ret.col);
std::swap(this->array, ret.array);
return *this;
}
The copy-and-swap idiom is a way to do just that: It first calls a class’ copy constructor to create a temporary, then swaps its data with the temporary’s, and then lets the temporary’s destructor destroy the old state. (StackOverflow)

這麼寫非常優雅,而且至此所有邏輯都只寫過一次,沒有複製貼上,確保實作需要修改時,只需要修改極少的程式碼。至於移動語義 (move semantics) 因為標頭檔是無法修改的,這裡也不深究,暫時就只能維持這樣囉。

觀念澄清:C 早已不是 C++ 的子集

C++ 最早的確是基於 C 語言的語法誕生的,但至少在 C89 (筆者出生之前) 以後就不是了,就算不考慮語法層次,語義和實作規範上也大大不同。網路上可以查到很多資料像是這篇。考量到進入資工系的時候第一個學習的語言是 C,或在這之前寫了很多 C 和 C++ 隨意混用的程式碼片段,如果時間許可,想精進自己撰寫 C++ 程式的技巧,我會建議寫之前多查查文件,區分兩者在語言風格上的差異。畢竟,寫程式通常只需要寫一遍,但它將會被無數的人閱讀好幾遍,如果你寫的 C++ 只是包著 STL 和各種 template 的 C,那讀程式碼會是一件非常痛苦的事 …

… 你還不如當個慣 C 就好 (逃跑)