C++高級編程:構建高效穩(wěn)定接口與深入對象設計技巧
一、建立穩(wěn)定接口
類是C++中的主要抽象單位。你應該將抽象原則應用于你的類,盡可能將接口與實現(xiàn)分離。具體來說,你應該使所有數(shù)據(jù)成員私有,并可選擇性地提供getter和setter方法。這就是SpreadsheetCell類的實現(xiàn)方式:m_value是私有的,而公共的set()方法設置值,getValue()和getString()方法檢索值。
1.使用接口和實現(xiàn)類
即便采取了上述措施和最佳設計原則,C++語言本質上對抽象原則不友好。其語法要求你將公共接口和私有(或受保護的)數(shù)據(jù)成員及方法組合在一個類定義中,從而將類的一些內部實現(xiàn)細節(jié)暴露給其客戶端。這樣做的缺點是,如果你需要在類中添加新的非公開方法或數(shù)據(jù)成員,所有使用該類的客戶端都必須重新編譯。這在大型項目中可能成為負擔。
好消息是你可以讓你的接口更加干凈,并隱藏所有實現(xiàn)細節(jié),從而實現(xiàn)穩(wěn)定的接口。壞消息是這需要一些編碼工作。基本原則是為你想編寫的每個類定義兩個類:接口類和實現(xiàn)類。實現(xiàn)類與你在不采取此方法時編寫的類相同。接口類提供與實現(xiàn)類相同的公共方法,但它只有一個數(shù)據(jù)成員:指向實現(xiàn)類對象的指針。這被稱為pimp習語,私有實現(xiàn)習語,或橋接模式。接口類的方法實現(xiàn)簡單地調用實現(xiàn)類對象上的等效方法。
這樣的結果是,無論實現(xiàn)如何改變,都不會影響公共接口類。這減少了重新編譯的需要。如果實現(xiàn)(僅實現(xiàn))發(fā)生變化,使用接口類的客戶端無需重新編譯。請注意,這種習語僅在單一數(shù)據(jù)成員是指向實現(xiàn)類的指針時才有效。如果它是按值數(shù)據(jù)成員,則在實現(xiàn)類定義發(fā)生變化時,客戶端必須重新編譯。
要在Spreadsheet類中使用此方法,請定義以下公共接口類,稱為Spreadsheet。
module;
#include <cstddef>
export module spreadsheet;
export import spreadsheet_cell;
import <memory>;
export class SpreadsheetApplication { };
export class Spreadsheet {
public:
Spreadsheet(const SpreadsheetApplication& theApp, size_t width = MaxWidth, size_t height = MaxHeight);
Spreadsheet(const Spreadsheet& src);
Spreadsheet(Spreadsheet&&) noexcept;
~Spreadsheet();
Spreadsheet& operator=(const Spreadsheet& rhs);
Spreadsheet& operator=(Spreadsheet&&) noexcept;
void setCellAt(size_t x, size_t y, const SpreadsheetCell& cell);
SpreadsheetCell& getCellAt(size_t x, size_t y);
size_t getId() const;
static const size_t MaxHeight { 100 };
static const size_t MaxWidth { 100 };
void swap(Spreadsheet& other) noexcept;
private:
class Impl;
std::unique_ptr<Impl> m_impl;
};
export void swap(Spreadsheet& first, Spreadsheet& second) noexcept;
實現(xiàn)類Impl是一個私有嵌套類,因為除了Spreadsheet類之外,沒有人需要了解這個實現(xiàn)類?,F(xiàn)在,Spreadsheet類只包含一個數(shù)據(jù)成員:指向Impl實例的指針。公共方法與舊的Spreadsheet類相同。
2.掌握類和對象
嵌套的Spreadsheet::Impl類在spreadsheet模塊的實現(xiàn)文件中定義。它應該對客戶端隱藏,因此不導出Impl類。Spreadsheet.cpp模塊實現(xiàn)文件如下開始:
module;
#include <cstddef
>
module spreadsheet;
import <utility>;
import <stdexcept>;
import <format>;
import <algorithm>;
using namespace std;
// Spreadsheet::Impl類定義。
class Spreadsheet::Impl {
/* 為簡潔起見省略 */
};
// Spreadsheet::Impl方法定義。
Spreadsheet::Impl::Impl(const SpreadsheetApplication& theApp, size_t width, size_t height)
: m_id { ms_counter++ }
, m_width { min(width, Spreadsheet::MaxWidth) }
, m_height { min(height, Spreadsheet::MaxHeight) }
, m_theApp { theApp }
{
m_cells = new SpreadsheetCell*[m_width];
for (size_t i{ 0 }; i < m_width; i++) {
m_cells[i] = new SpreadsheetCell[m_height];
}
}
// 其他方法定義省略以簡潔。
Impl類幾乎具有與原始Spreadsheet類相同的接口。對于方法實現(xiàn),需要記住Impl是一個嵌套類;因此,你需要指定作用域為Spreadsheet::Impl。所以,對于構造函數(shù),它變成了Spreadsheet::Impl::Impl(...)。
由于Spreadsheet類具有指向實現(xiàn)類的unique_ptr,因此Spreadsheet類需要有用戶聲明的析構函數(shù)。由于我們不需要在此析構函數(shù)中執(zhí)行任何操作,因此可以在實現(xiàn)文件中將其默認為:
Spreadsheet::~Spreadsheet() = default;
事實上,它必須在實現(xiàn)文件中默認,而不是直接在類定義中。原因是Impl類僅在Spreadsheet類定義中前向聲明;也就是說,編譯器知道將會有一個Spreadsheet::Impl類出現(xiàn)在某處,但此時它還不知道定義。因此,你不能在類定義中默認析構函數(shù),因為編譯器會嘗試使用尚未定義的Impl類的析構函數(shù)。在這種情況下,對其他方法進行默認操作時也是如此,例如移動構造函數(shù)和移動賦值運算符。
二、實現(xiàn)Spreadsheet方法
Spreadsheet類的方法實現(xiàn),如setCellAt()和getCellAt(),只是將請求傳遞給底層的Impl對象:
void Spreadsheet::setCellAt(size_t x, size_t y, const SpreadsheetCell& cell) {
m_impl->setCellAt(x, y, cell);
}
SpreadsheetCell& Spreadsheet::getCellAt(size_t x, size_t y) {
return m_impl->getCellAt(x, y);
}
Spreadsheet的構造函數(shù)必須構造一個新的Impl以執(zhí)行其工作:
Spreadsheet::Spreadsheet(const SpreadsheetApplication& theApp, size_t width, size_t height) {
m_impl = make_unique<Impl>(theApp, width, height);
}
Spreadsheet::Spreadsheet(const Spreadsheet& src) {
m_impl = make_unique<Impl>(*src.m_impl);
}
拷貝構造函數(shù)看起來有些奇怪,因為它需要從源Spreadsheet復制底層的Impl。拷貝構造函數(shù)接受一個Impl的引用,而不是指針,所以你必須解引用m_impl指針來獲取對象本身。
Spreadsheet賦值運算符必須同樣將賦值傳遞給底層的Impl:
Spreadsheet& Spreadsheet::operator=(const Spreadsheet& rhs) {
*m_impl = *rhs.m_impl;
return *this;
}
賦值運算符中的第一行看起來有些奇怪。Spreadsheet賦值運算符需要將調用轉發(fā)給Impl賦值運算符,這只在你復制直接對象時運行。通過解引用m_impl指針,你強制執(zhí)行直接對象賦值,這導致調用Impl的賦值運算符。
swap()方法簡單地交換單一數(shù)據(jù)成員:
void Spreadsheet::swap(Spreadsheet& other) noexcept {
std::swap(m_impl, other.m_impl);
}
這種技術將接口與實現(xiàn)真正分離,是非常強大的。雖然一開始有些笨拙,但一旦習慣了,你會發(fā)現(xiàn)它很自然易用。然而,在大多數(shù)工作環(huán)境中,這不是常見做法,所以你可能會遇到同事的一些抵觸。支持這種做法的最有力論據(jù)不是分離接口的美學,而是如果類的實現(xiàn)發(fā)生變化,構建時間的加速。
三、注意
使用穩(wěn)定的接口類,可以減少構建時間。將實現(xiàn)與接口分離的另一種方法是使用抽象接口,即只有純虛方法的接口,然后有一個實現(xiàn)該接口的實現(xiàn)類。這是下個主題。