初嘗 C++ 類設計


原文發布在我個人小站:here

最近在准備筆試,於是在各種網站上刷題嘛。期間做了百度某年的一道編程題

小B最近對電子表格產生了濃厚的興趣,她覺得電子表格很神奇,功能遠比她想象的強大。她正在研究的是單元格的坐標編號,她發現表格單元一般是按列編號的,第1列編號為A,第2列為B,以此類推,第26列為Z。之后是兩位字符編號的,第27列編號為AA,第28列為AB,第52列編號為AZ。之后則是三位、四位、五位……字母編號的,規則類似。

表格單元所在的行則是按數值從1開始編號的,表格單元名稱則是其列編號和行編號的組合,如單元格BB22代表的單元格為54列中第22行的單元格。

小B感興趣的是,編號系統有時也可以采用RxCy的規則,其中x和y為數值,表示單元格位於第x行的有第y列。上述例子中的單元格采用這種編碼體系時的名稱為R22C54。

小B希望快速實現兩種表示之間的轉換,請你幫忙設計程序將一種方式表示的坐標轉換為另一種方式。

輸入 輸出
輸入的第一行為一個正整數T,表示有T組測試數據(1<=T<=10^5)。隨后的T行中,每行為一組測試數據,為一種形式表示的單元格坐標。保證所有的坐標都是正確的,且所有行列坐標值均不超過10^6 對每組測試數據,單獨輸出一行,為單元格坐標的另一種表示形式。
2
R23C55
BC23
BC23
R23C55

寫這道題目的時候,正好復習了C++的類,於是居然鬼使神差的想設計一個類來實現它。C++的核心思想是面向對象,而此處的單元格正好有顯著的對象特征。一個單元格,應該有座標,以及其中的內容。這是很自然的,當初看C++ Primer的時候,書中也是以書本銷量作為引入,介紹並闡述類的設計。

Definitions

閑話少說,現在就來看看如何實現這個類。

// Unit/Unit.h

class Unit {
private:
    using pos = unsigned int;
    pos rowIdx;
    pos colIdx;

public:
    // constructors
    Unit(pos _r, pos _c) : rowIdx(_r), colIdx(_c) { }
    // constructor2
    Unit(string);
};

先看這么多,我希望每個單元個有兩個屬性,一個行索引rowIdx一個列索引colIdx. 而且我定義了兩個構造函數,一個是直接將行列索引傳入,另一個則是通過讀取一個字符串,來解析出其中的行列索引的值。這里有一個問題,因為不同類型的字符串解析的方式不一樣,所以需要一個指示變量來指明傳入的字符串到底是哪個類型:BC23還是R23C55?除此之外,我還需要一個可以轉換不同類型表示的函數,給了我表示類型1,我可以直接調用一個函數,輸出表示類型2. 需求先大致到這里,來改進一下之前的類。

// Unit/Unit.h

#ifndef _UNIT_H_
#define _UNIT_H_

#include<string>

class Unit {
private:
    using pos = unsigned int; // type alias
    pos rowIdx;
    pos colIdx;
    bool isRC; // is the type `R23C55`?
    void getIdx_RC(std::string); // build index for type 'R23C55'
    void getIdx_NRC(std::string); // for type 'BC23'

public:
    // constructor1
    Unit(pos _r, pos _c) : rowIdx(_r), colIdx(_c) { }
    // constructor2
    Unit(std::string);

    // selectors
    pos getRow() { return rowIdx; }
    pos getCol() { return colIdx; }

    // modifiers
    void setRow(pos _r) { rowIdx = _r; }
    void setCol(pos _c) { colIdx = _c; }

    // utilties
    void printer(void);
    void convertor(void);
};
#endif

這里先不要糾結定義了很多不知道要干嘛的函數,往下看類的實現,你會慢慢明白為什么需要它們。

Implementions

類的頭文件中只定義了類,除少數內聯函數外,大多數函數仍未實現。按照模塊化的哲學,新開一個文件寫類的實現。

構造函數的實現

// Unit/Unit.hpp

#include "Unit.h"

// implement constructor2
Unit::Unit(string _s) {
    // wishful thinking 
    isRC = typeInfer(_s);
    if (isRC) {
    /*
     * wishful thinking: suppose i have the function
     * that helps me do this job
     */
        getIdx_RC(_s);
    } else {
    // again wishful thinking
        getIdx_NRC(_s);
    }
}

好了,現在我們實現了第二個構造函數,它接受一個string對象,從中解析出行列索引的值,然后初始化rowIdxcolIdx.

但是,我們想當然的調用了一些我們還沒有實現的函數。這里要特別注意一點:在寫程序的時候,這種想法很有用!以下思想來自我學習SICP一段時間以后的自我體會:程序往往會包含很多的函數,為什么?因為有時候一個稍微復雜的問題,並不是一個函數就能解決的,所以需要多個函數相互協調,相互調用才能共同完成或是解決一項工作。如果你費盡心思把它們都寫在一個函數里,可能你覺得很好,但是一旦程序報錯,你將無從下手,很難定位到錯誤發生在哪里。這也是程序設計講究模塊化的理由。將復雜的功能抽象成一個一個的互不干涉的模塊(子程序),在每一個小模塊里盡可能的將代碼寫好,使得它只完成並且高效准確地完成這一項任務。那么當所有模塊相互協同起來,將會使難以想象的高效,且不容易出錯,即便是出錯了,也能很快定位到錯誤發生的地點,便於調試。

這樣做的好處還有一個,就是你在寫程序的時候變得更加輕松。為什么?因為我不用考慮所有的細節,只是調用了一些函數,而實現這些函數很可能不是我們要干的活。Oh, that's cool! George will do for me. 你甚至可以去喝杯咖啡。但是現在,讓我們短暫的充當以下 George 的角色。就拿Unit類的設計來說,我現在想要實現第二個構造函數,根據題目的意思,我可能接受兩個代表不同表示方法的string,我要將它們解析成行列索引。讓我們回頭看看這個函數的實現,它先判斷輸入的是那個類型,然后分情況做不同的事(調用不同的函數)。這里我用到了3個wishful thinking:

  1. 我希望有一個叫typeInfer()的函數,我給它一個字符串,它告訴我這屬於哪個類型的表示方法。
  2. 如果是RxCy型,我希望有一個函數getIdx_RC()能夠處理這種類型的輸入,解析出行列索引。
  3. 如果是BC23型,我希望有一個函數getIdx_NRC()能夠處理這種類型的輸入,解析出行列索引。

這樣一來,我們不容易犯錯。為什么?因為這個構造函數的邏輯足夠簡單,僅僅是分兩個情況做不同的事。做的事也很簡單:僅僅是調用一個函數!如果你能保證所調用的函數不犯錯,那么整個過程也不會出錯。既簡單,又robust!

還有我個人認為的好處就是,寫程序變得簡單了。我到每一步的時候,需要什么,想象一下,假設它已經有了,我該怎么寫,怎么去調用它。這樣你就對為什么要有這個函數,以及這個函數要干什么,心知肚明了。然后上層建設好之后,我再去考慮如何實現那些想象!現在,我們來看看,之前想當然的幾個函數如何實現。

// Unit/Unit.hpp

#include "Unit.h"
#include <string>

/*
 * Predefined things...
 */

bool typeInfer(std::string _s) { // infer the representation type
    if (_s[0] == 'R' && std::isdigit(_s[1]))
        return true;
    else
        return false;
}

void Unit::getIdx_RC(std::string _s) {
    /*
     * build the row/col index for
     * type 'R23C55'
     */
    _s.erase(0, 1); // remove first 'R'
    
    // find where 'C' is
    std::string::size_type c_pos = 0;
    for (; c_pos != _s.size(); ++c_pos) {
        if (_s[c_pos] == 'C') break;
    }
    auto s1 = _s.substr(0, 0 + c_pos); // s1 = "23"
    auto s2 = _s.substr(c_pos + 1, _s.size()); // s2 = "55"
    // set index
    rowIdx = std::stoi(s1);
    colIdx = std::stoi(s2);
}

void Unit::getIdx_NRC(std::string _s) {
    /*
     * build the row/col index for
     * type 'BC23'
     */

    // find the first num
    std::string::size_type n_pos = 0;
    for (; n_pos != _s.size(); ++n_pos) {
        if (std::isdigit(_s[n_pos]))
            break;
    }

    auto s1 = _s.substr(0, n_pos); // s1 should be "BC"
    auto s2 = _s.substr(n_pos, _s.size()); // s2 should be "23"
    rowIdx = std::stoi(s2);
    colIdx = letter2pos(s1);
}

Note: 注意到getIdx_RC()getIdx_NRC()需要訪問類內私有變量,所以應該聲明成類的成員函數。

好了,看了上面的實現,我又想當然的引入了幾個函數。但是通過上下文,你可以很明顯的看出來我引入這個函數實干嘛用的。正是因為這個時候我需要有一個函數幫我去干這個事情,而我又不想把這些復雜的工作都寫到一個函數里面(因為容易出錯,且很難調試)。所以我引入了它們:

  1. letter2pos()接受一個字符串,返回解析出來的數值索引。

好吧,居然只引入了一個(╬ Ò ‸ Ó),再來看看它的實現。

// Unit/Unit.hpp

/*
 * Predefined things...
 */

// return the corresponding num for a given string
int letter2pos(const std::string& _s) {
    auto len = _s.size();
    int res = 0;
    for (; len != 0; --len) {
        res = res*26 + alph2num(_s[_s.size() - len]);
        // std::cout << "res: " << res << std::endl;
    }
    return res;
}

// return the corresponding string for a given num
std::string pos2letter(int _p) {
    if (_p <= 26){
        char c[1] = {num2alph(_p)};
        std::string s(c);
        return s;
    }

    std::string res;
    int r = 0;
    while (_p > 26) {
        r = _p%26;
        res += num2alph(r);
        _p /= 26;
    }
    res += num2alph(_p);
    std::reverse(res.begin(), res.end());
    // cout << "res: " << res << endl; 
    return res;
}

哈哈,我又想象了幾個不存在的函數。它們的作用很容易通過上下文得知。letter2pos()pos2letter()是一對相反的函數,它們的作用是完成BC<->55的映射。至於alph2num()num2alph(),其實也是一對相反的函數,用於檢索26個字母對應的數值。

// Unit/Unit.hpp

// global map for quick search
char MAP[26] = {'A','B','C','D','E','F','G',
                'H','I','J','K','L','M','N',
                'O','P','Q','R','S','T',
                'U','V','W','X','Y','Z'};

// return a num for the given char
int alph2num(char _c) {
    int idx = 0;
    for (; idx != 26; ++idx) {
        if (MAP[idx] == _c)
            return idx + 1;
    }
    std::cerr << _c << "Not found!" << std::endl;
    return 0;
}

// return a char for the given num
char num2alph(int _i) { return MAP[_i - 1]; }

轉換函數的實現

基於上面的工作,轉換函數的實現就顯得格外清晰簡單。所謂轉換函數,就是當我輸入的是類型1的字符串,它輸出轉換之后的類型2的字符串,由此達到一個轉換單元格表示方法的效果。

// Unit/Unit.hpp

/*
 * Predefined things...
 */

void Unit::convertor() {
    if (isRC) {
        std::cout << pos2letter(colIdx)
            + std::to_string(rowIdx) << std::endl;
    } else {
        std::cout << "R"
            + std::to_string(rowIdx)
            + "C"
            + std::to_string(colIdx) << std::endl;
    }
}

void Unit::printer() {
    std::cout << "row index: " << rowIdx
        << "\ncol index: " << colIdx << std::endl;
}

最后附加一個printer()方便打印信息。至此,整個類的設計大概就完了。值得注意的是,最后的convertor()之所以能夠如此簡單地寫出來,是因為我們合理將一些工作模塊化,這樣帶來的好處就是可以重復利用,易於維護。

總結

我之前寫代碼,總是不注意模塊化,不注意抽象化。能一個函數完成的事為什么要寫兩個函數?然而,最后只能自食其果。一旦報錯,一步步地定位錯誤,從下往上調試,陷入苦海。也有的時候,因為函數過於復雜,把自己繞糊塗,陷入一個圈子里想不通,弄不懂,出不來。這些結果都以失敗告終!而且會打擊自信心,感覺別人寫代碼是寫代碼,我寫代碼就純粹是寫bug啊!

好在前段時間看了點SICP,B站上有視頻的,我自己也在對着書看,真的是非常好的課程。循着書中傳遞的思想,慢慢就這么寫着,發現有的問題可以寫出來了,得益於代碼結構的改變,調試錯誤也比以前稍微輕松一些。到這次寫這道編程題,要是在考試中這么寫,我肯定來不及的。但是我在課余花了不少時間構思,終於用面向對象的思想將它初步實現。雖然這個類設計的很簡單,也有很多瑕疵:

  1. 異常輸入的處理
  2. 類結構的優化以及完備性檢查
  3. 代碼細節的優化

但是和以前半途而廢相比,起碼完成了類的實現,雖然很粗糙。謹以此文記錄一下!

附贈:SICP的學習資源

  1. B站視頻
  2. 在線電子書
  3. 習題答案
  4. p2pu sicp solutions

感謝SICP視頻翻譯工作者,感謝B站up主的搬運,感謝開源社區!


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM