三、模塊化

Link

?



優(yōu)秀的程序組織方法是: 把程序當(dāng)作一組明確定義過依賴關(guān)系的模塊,利用語(yǔ)言特性表達(dá)這種模塊化的邏輯關(guān)系,再把這種關(guān)系通過文件以實(shí)體形式暴露出來(lái),以實(shí)現(xiàn)高效的分離編譯。

除了函數(shù)、類以及枚舉,C++還提供一個(gè)叫命名空間(namespace)的機(jī)制, 用來(lái)表示某些聲明相互依托,以便這些名稱不會(huì)跟其它名稱發(fā)生沖突。 比如說(shuō),我想弄個(gè)復(fù)數(shù)類型:

namespace My_code {
    class complex {
        // ...
    };
    complex sqrt(complex);
    // ...
    int main();
}

int My_code::main() {
    complex z {1,2};
    auto z2 = sqrt(z);
    std::cout << '{' << z2.real() << ',' << z2.imag() << "}\n";
    // ...
}

int main() {
    return My_code::main();
}
void my_code(vector<int>& x, vector<int>& y) {
    using std::swap;    // 使用標(biāo)準(zhǔn)庫(kù)中的 swap
    // ...
    swap(x,y);          // std::swap()
    other::swap(x,y);   // 別的 swap()
    // ...
}

?
假設(shè)我們想從越界訪問的錯(cuò)誤中恢復(fù)運(yùn)行,解決方案是:實(shí)現(xiàn)Vector的人檢測(cè)越界訪問的企圖,然后把它告知用戶。然后用戶就可以采取適當(dāng)?shù)拇胧?。舉例來(lái)說(shuō):Vector::operator能檢測(cè)越界訪問的企圖,并拋出out_of_range異常:

double& Vector::operator[](int i) {
    if (i<0 || size()<=i)
        throw out_of_range{"Vector::operator[]"};
    return elem[i];
}

throw把控制權(quán)轉(zhuǎn)給處理out_of_range異常的代碼,這個(gè)代碼位于某些函數(shù)里,而這些函數(shù)直接或間接地調(diào)用了Vector::operator[]。 要做到這些,編譯器就得展開函數(shù)調(diào)用堆棧,以便回退到調(diào)用者的代碼環(huán)境。就是說(shuō),異常處理機(jī)制將根據(jù)需要退出作用域和函數(shù),以便回退到有意處理該類異常的調(diào)用者,必要時(shí)沿途調(diào)用析構(gòu)函數(shù)。例如:

void f(Vector& v) {
    // ...
    try {   // 此處的異常將被下面定義的代碼處理
        v[v.size()] = 7;// 試圖訪問v到末尾之后
    } catch (out_of_range& err) { // 壞菜了:out_of_range錯(cuò)誤
        // ... 處理越界錯(cuò)誤 ...
        cerr << err.what() << '\n';
    }
    // ...
}

我們把有意異常處理的代碼放到try-代碼塊里。 給v[v.size()]賦值的企圖不會(huì)得逞。 因此,會(huì)進(jìn)入catch-子句,里面包含處理out_of_range異常的代碼。 out_of_range異常定義在標(biāo)準(zhǔn)庫(kù)中(<stdexcept>里), 并且實(shí)際上已經(jīng)被標(biāo)準(zhǔn)庫(kù)里某些容器的訪問函數(shù)用到了。

我以引用方式捕捉此異常以避免復(fù)制,并使用what()函數(shù)打印錯(cuò)誤信息, 這個(gè)信息是在throw-位置放進(jìn)異常里的。

使用異常處理機(jī)制可以讓錯(cuò)誤處理更簡(jiǎn)潔、更系統(tǒng)化,也更具可讀性。 要確保這一點(diǎn),就別濫用try-語(yǔ)句。 以簡(jiǎn)潔和系統(tǒng)化方式實(shí)現(xiàn)錯(cuò)誤處理的主要技術(shù)(被稱為 資源請(qǐng)求即初始化(Resource Acquisition Is Initialization; RAII)。RAII的大體思路是:讓類的構(gòu)造函數(shù)獲取正常運(yùn)作所需的全部資源,然后讓析構(gòu)函數(shù)釋放全部資源,這樣資源的釋放就可以有保障地隱式執(zhí)行。

如果一個(gè)函數(shù)絕對(duì)不應(yīng)該拋出異常,可以用noexcept聲明它。例如:

void user(int sz) noexcept {
    Vector v(sz);
    iota(&v[0],&v[sz],1); // 用1,2,3,4...填充v
    // ...
}

萬(wàn)一user()還是拋出了異常,就會(huì)立即調(diào)用std::terminate()以終止程序。


既然operator運(yùn)算符要操作Vector類型的對(duì)象,那么,如果Vector的成員不具備“合理的”值,這個(gè)運(yùn)算就毫無(wú)意義。確切的說(shuō),我們指出了“elem指向承載sz個(gè)元素的數(shù)組”,但僅僅止步于注釋中。這種為類聲稱某個(gè)假設(shè)為真的語(yǔ)句被稱為 類的不變式,簡(jiǎn)稱不變式。 為類制定不變式(以確保成員函數(shù)有的放矢)的職責(zé)歸構(gòu)造函數(shù),而成員函數(shù)運(yùn)行完成之后,要確保不變式依然成立。不巧的是,我們Vector的構(gòu)造函數(shù)有點(diǎn)虎頭蛇尾了。它出色地為Vector的成員變量完成了初始化,卻沒留意傳入的參數(shù)是否合理??紤]一下這個(gè):

Vector v(-27);

基本上,這就要出事了。
更靠譜的定義是這樣的:

Vector::Vector(int s) {
    if (s<0)
        throw length_error{"Vector constructor: negative size"};
    elem = new double[s];
    sz = s;
}

我使用標(biāo)準(zhǔn)庫(kù)里的length_error異常報(bào)告“元素?cái)?shù)量不是正整數(shù)”的問題, 因?yàn)闃?biāo)準(zhǔn)庫(kù)也用這個(gè)異常報(bào)告這類問題。 如果new運(yùn)算符沒找到可分配的內(nèi)存,將拋出std::bad_alloc。 我們可以這么寫:

void test() {
    try {
        Vector v(-27);
    } catch (std::length_error& err) {
        // 處理容量為負(fù)數(shù)的情況
    } catch (std::bad_alloc& err) {
        // 處理內(nèi)存耗盡的問題
    }
}

一般來(lái)說(shuō),函數(shù)在捕獲異常之后就已經(jīng)沒法搞定待處理的任務(wù)了。 然后,異?!疤幚怼本鸵馕吨拖薅鹊木植抠Y源清理,然后重新拋出該異常。例如:

void test() {
    try {
        Vector v(-27);
    } catch (std::length_error&) {// 處理一下,然后重新拋出
        cerr << "test failed: length error\n";
        throw;// 重新拋出
    } catch (std::bad_alloc&) {// 糟!這程序沒法處理內(nèi)存耗盡的問題
        std::terminate();// 終止程序
    }
}

目前只能依賴權(quán)宜之計(jì),例如用命令行里的宏控制運(yùn)行時(shí)檢查:

double& Vector::operator[](int i) {
    if (RANGE_CHECK && (i<0 || size()<=i))
        throw out_of_range{"Vector::operator[]"};
    return elem[i];
}

標(biāo)準(zhǔn)庫(kù)提供了調(diào)試用的宏assert(),以確保某個(gè)條件在運(yùn)行時(shí)成立。例如:

void f(const char* p) {
    assert(p!=nullptr); // p絕不能是nullptr
    // ...
}

如果這個(gè)assert()條件在“調(diào)試模式”不成立,程序?qū)⒔K止。 如果不在調(diào)試模式,assert()就不做檢查。 這個(gè)方法忒糙還不靈活,不過通常也湊合夠用了。

?
異常給運(yùn)行時(shí)發(fā)現(xiàn)的問題報(bào)錯(cuò)。如果能在編譯時(shí)發(fā)現(xiàn)錯(cuò)誤就該大力推廣。 對(duì)于絕大多數(shù)類型系統(tǒng)、區(qū)分用戶定義類型的接口那些語(yǔ)言特性而言,這就是意義所在。 最起碼,我們可以對(duì)編譯期已知的大多數(shù)屬性進(jìn)行基本檢查, 以編譯器錯(cuò)誤信息的形式匯報(bào)不滿足需求的情況。例如:

static_assert(4<=sizeof(int), "integers are too small");// 檢查整數(shù)容量

在不滿足4<=sizeof(int)時(shí),這段代碼輸出integers are too small; 就是說(shuō)在系統(tǒng)里的int不足4個(gè)字節(jié)時(shí)。 我們把這種陳述預(yù)期的語(yǔ)句稱為斷言(assertion)。

static_assert機(jī)制可用于任意——可以通過常量表達(dá)式表示的——情形。例如:

constexpr double C = 299792.458;                    // km/s

void f(double speed) {
    constexpr double local_max = 160.0/(60*60);         // 160 km/h == 160.0/(60*60) km/s
    static_assert(speed<C,"can't go that fast");        // 錯(cuò)誤:speed必須是常量
    static_assert(local_max<C,"can't go that fast");    // OK
    // ...
}

如果A不為true,那么static_assert(A,S)就會(huì)把S作為編譯器錯(cuò)誤信息輸出。 如果不想輸出特定信息就把S留空,編譯器會(huì)采用默認(rèn)信息:

static_assert(4<=sizeof(int));    // 采用默認(rèn)信息

默認(rèn)信息的內(nèi)容通常是static_assert所在的源碼位置,外加一個(gè)表示斷言謂詞的字母。

靜態(tài)斷言static_assert最重要的用途體現(xiàn)在泛型編程中對(duì)用作參數(shù)的類型有特定要求時(shí)。

?
把信息從程序的一個(gè)位置向另一個(gè)位置傳遞,最主要且推薦的方法是通過函數(shù)調(diào)用。 執(zhí)行功能所需的信息作為參數(shù)傳入函數(shù),生成的結(jié)果以返回值形式傳出。例如:

int sum(const vector<int>& v) {
    int s = 0;
    for (const int i : v)
        s += i;
    return s;
}

vector fib = {1,2,3,5,8,13,21};

int x = sum(fib);       // x變成53

既然函數(shù)信息的傳入和傳出如此重要,就不難想見它們有多種方式。主要涉及:

  • 該對(duì)象是被復(fù)制還是被共享?
  • 如果該對(duì)象被共享,是否可變?
  • 如果對(duì)象轉(zhuǎn)移了,是否要留下一個(gè)“空對(duì)象”?

在sum()例子中,作為結(jié)果的int是以復(fù)制方式傳出sum()的,但對(duì)于可能容量巨大的vector,讓它以復(fù)制方式進(jìn)入sum()將會(huì)低效且毫無(wú)意義, 所以參數(shù)以引用方式傳入。sum()無(wú)需修改其參數(shù),這種不可變更性通過將vector聲明為const來(lái)標(biāo)示,因此vector通過const-引用傳遞。

完成計(jì)算之后,需要把結(jié)果弄出函數(shù)并交回給調(diào)用者。 跟參數(shù)一樣,值返回也默認(rèn)采用復(fù)制方式,并且這對(duì)于較小的對(duì)象很完美。 只有在把不屬于函數(shù)局部作用域的東西轉(zhuǎn)給調(diào)用者時(shí),才通過“傳引用”返回。例如:

class Vector {
public:
    // ...
    double& operator[](int i) { return elem[i]; }   // 返回對(duì)第i個(gè)元素的引用
private:
    double* elem;       // elem指向一個(gè)數(shù)組,該數(shù)組承載sz個(gè)double
    // ...
};

Vector的第i個(gè)元素的存在不依賴于取下標(biāo)運(yùn)算符,因此可以返回對(duì)它的引用。

另一方面,在函數(shù)的返回操作結(jié)束后,局部變量就消失了,所以不能返回指向它的指針或引用:

int& bad() {
    int x;
    // ...
    return x;   // 糟糕:返回了指向局部變量x的引用
}

返回引用或者較“小”類型的值很高效,但是要把大量信息傳出函數(shù)該怎么辦呢? 考慮這個(gè)示例:

Matrix operator+(const Matrix& x, const Matrix& y) {
    Matrix res;
    // ... 對(duì)所有 res[i,j], res[i,j] = x[i,j]+y[i,j] ...
    return res;
}

Matrix m1, m2;
// ...
Matrix m3 = m1+m2;  // 沒有復(fù)制

即便對(duì)時(shí)下的硬件而言,Matrix可能都非常大,而且復(fù)制的代價(jià)高昂。 因此我們不進(jìn)行復(fù)制,而是為Matrix定義一個(gè)轉(zhuǎn)移構(gòu)造函數(shù)(move constructor),從而以低廉的代價(jià)把Matrix從operator+()傳出。 此處不需要抱殘守缺地使用手動(dòng)內(nèi)存管理:

Matrix* add(const Matrix& x, const Matrix& y)  { // 復(fù)雜且易錯(cuò)的20世紀(jì)風(fēng)格
    Matrix* p = new Matrix;
    // ... 對(duì)所有的 *p[i,j], *p[i,j] = x[i,j]+y[i,j] ...
    return p;
}

Matrix m1, m2;
// ...
Matrix* m3 = add(m1,m2);    // 僅復(fù)制一個(gè)指針
// ...
delete m3;                  // 這個(gè)操作太容易忘記

很遺憾,通過指針返回大型對(duì)象在老式代碼里很常見,而且是個(gè)難以捕獲錯(cuò)誤的主要根源。 別寫這樣的代碼。 注意,operator+()跟add()同樣高效,但是定義簡(jiǎn)單、易于使用還不易出錯(cuò)。

函數(shù)的返回類型可以從返回值本身推斷出來(lái)。例如:

auto mul(int i, double d) { return i*d; } // 此處的“auto”意思是“推斷返回類型”

這很方便,尤其對(duì)于泛型函數(shù)(函數(shù)模板(function template))以及 lambda表達(dá)式來(lái)說(shuō),但是請(qǐng)謹(jǐn)慎采用,因?yàn)橥茖?dǎo)出來(lái)的類型會(huì)讓接口不穩(wěn)定:對(duì)函數(shù)(或lambda表達(dá)式)內(nèi)容的修改,會(huì)改變返回類型。

?
函數(shù)只能返回單獨(dú)的一個(gè)值,但這個(gè)值可以是包含多個(gè)成員的類對(duì)象。 這使得我們得以高效地返回多個(gè)值。例如:

struct Entry {
    string name;
    int value;
};

Entry read_entry(istream& is) {  // 很菜的讀取函數(shù)
    string s;
    int i;
    is >> s >> i;
    return {s,i};
}

auto e = read_entry(cin);

cout << "{ " << e.name << " , " << e.value << " }\n";

此處的{s,i}被用于構(gòu)建Entry類型的返回值。 與之類似,可以把Entry的成員“拆包”到本地變量里:

auto [n,v] = read_entry(is);
cout << "{ " << n << " , " << v << " }\n";

auto [n,v]這句聲明了兩個(gè)局部變量n和v, 它們的類型從read_entry()的返回類型推導(dǎo)出來(lái)。 這個(gè)給類對(duì)象成員命名的機(jī)制叫結(jié)構(gòu)化綁定(structured binding)。

考慮以下示例:

map<string,int> m;
// ... 填充 m ...
for (const auto [key,value] : m)
    cout << "{" << key "," << value << "}\n";

按慣例,可以用const和&限定auto,例如:

void incr(map<string,int>& m) { // 為m的每個(gè)元素自增1
    for (auto& [key,value] : m)
        ++value;
}

在結(jié)構(gòu)化綁定用于不包含私有數(shù)據(jù)的類時(shí),綁定方式顯而易見: 綁定行為定義的名稱數(shù)量必須跟類里面的非靜態(tài)數(shù)據(jù)成員數(shù)量相同, 綁定行為中的引入名稱按次序?qū)?yīng)成員變量。 與顯式使用復(fù)合對(duì)象相比,這種代碼的質(zhì)量并無(wú)差異; 使用結(jié)構(gòu)化綁定的主旨在于恰如其分地表達(dá)意圖。

通過成員函數(shù)訪問類對(duì)象的操作同樣可行。例如:

complex<double> z = {1,2};
auto [re,im] = z+2;// re=3, im=2

忠告

[1] 注意區(qū)分聲明(用做接口)和定義(用做實(shí)現(xiàn));
[2] 使用頭文件代表接口,以強(qiáng)調(diào)邏輯結(jié)構(gòu);
[3] 在實(shí)現(xiàn)函數(shù)的源文件里#include它的頭文件;
[4] 不要在頭文件里定義非內(nèi)聯(lián)函數(shù);
[5] (在支持module的地方)以module替代頭文件;
[6] 使用命名空間表達(dá)邏輯結(jié)構(gòu);
[7] 為代碼遷移、基本類庫(kù)(比如std),或在局部作用域內(nèi)使用using-指令;
[8] 別把using-指令放在頭文件里;
[9] 在無(wú)法搞定待處理的任務(wù)時(shí),拋出異常指出這種情況;
[10] 僅在錯(cuò)誤處理的情形下使用異常;
[11] 在直接調(diào)用者該處理某個(gè)錯(cuò)誤的時(shí)候,采用錯(cuò)誤碼;
[12] 在錯(cuò)誤將要穿過大量函數(shù)調(diào)用層級(jí)的時(shí)候,拋出異常;
[13] 拿不定該用異常還是錯(cuò)誤碼的時(shí)候,用異常;
[14] 在設(shè)計(jì)早期就確定錯(cuò)誤處理的策略;
[15] 使用能夠反映設(shè)計(jì)意圖的用戶定義類型作為異常(而非內(nèi)置類型);
[16] 不要在每個(gè)函數(shù)中都捕捉所有異常;
[17] 用 RAII 代替try-代碼塊;
[18] 如果函數(shù)不該拋出異常,用noexcept聲明它;
[19] 讓構(gòu)造函數(shù)建立不變式,如果做不到就拋出異常;
[20] 圍繞不變式設(shè)計(jì)錯(cuò)誤處理策略;
[21] 能在編譯期檢查的就在編譯期檢查;
[22] “小”值傳值,“大”值傳引用;
[23] 盡可能用常(const)引用而非普通引用;
[24] 使用函數(shù)返回值進(jìn)行返回(而不要用傳出參數(shù));
[25] 別濫用返回類型推斷;
[26] 別濫用結(jié)構(gòu)化綁定;使用具名返回類型可以讓文檔更清晰。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容