《Effective C++》第三版-4. 設計與宣告(Design and Declarations)

Roanapur發表於2024-05-02

目錄
  • 條款17:讓介面容易被正確使用,不易被誤用(Make interfaces easy to use correctly and hard to use incorrectly)
    • 限制型別和值
    • 規定能做和不能做的事
    • 提供行為一致的介面
  • 條款19:設計class猶如設計type(Treat class design as type design)
  • 條款20:寧以pass-by-reference-to-const替換pass-by-value(Prefer pass-by-reference-to-cons to pass-by-value)
    • 避免構造和析構
    • 避免物件切割
    • 例外
  • 條款21:必須返回物件時,別妄想返回其reference(Don’t try to return a reference when you must return an object)
  • 條款22:成員變數宣告為private(Declare data members private)
  • 條款23:寧以non-member、non-friend替換member函式(Prefer non-member non-friend functions to member functions)
  • 條款24:若所有引數皆需型別轉換,請為此採用non-member函式(Declare non-member functions when type conversions should apply to all parameters)
  • 條款25:考慮寫出一個不拋異常的swap函式(Consider support for a non-throwing swap)
    • 預設的swap
    • 特化的swap
    • 使用swap的總結

條款17:讓介面容易被正確使用,不易被誤用(Make interfaces easy to use correctly and hard to use incorrectly)

限制型別和值

class Date {
public:
	Date(int month, int day, int year);  //可能月日年順序錯,可能傳遞無效的月份或日期
	...
};

可使用型別系統(type system)規避以上錯誤,即引入外覆型別(wrapper type)區別年月日:

struct Day {
explicite Day(int d)
	: val(d) { }
int val;
}
struct Month {
explicite Month(int m)
	: val(m) { }
int val;
}
struct Year{
explicite Year(int y)
	: val(y) { }
int val;
}

class Date {
public:
	Date(const Month& m, const Day& d, const Year& y);  //可能月日年順序錯,可能傳遞無效的月份或日期
	...
};
Date d(Month(3), Day(30), Year(1995));  //可有效防止介面誤用

保證了型別正確之後,需要保證輸入的值有效:

class Month {
public:
	static Month Jan() { return Month(1); }
	static Month Feb() { return Month(2); }
	...
	static Month Dec() { return Month(12); }
	...
private:
	explicit Month(int m);
	...
};
Date d(Month::Mar(), Day(30), Year(1995));

規定能做和不能做的事

if ( a * b = c) ...  //以const修飾運算子*,使其不能被賦值

提供行為一致的介面

為了避免忘記刪除或者重複刪除指標,可令工廠函式直接返回智慧指標:

Investment* createInvestment(); //使用者可能忘記刪除或者重複刪除指標
std::tr1::shared_ptr<Investment> createInvestment();

若期望用自定義的getRidOfInvestment,則需要避免誤用delete,可考慮將getRidOfInvestment繫結為刪除器(deleter):

刪除器在引用次數為0時呼叫,故可建立一個null shared_ptr

std::tr1::shared_ptr<Investment> createInvestment()
{
	std::tr1::shared_ptr<Investment> retVal(static_cast<Investment*>(0), 
																					getRidOfInvestment);  //建立一個null shared_ptr
	retVal = ... ;  //令retVal指向目標物件
	return retVal;
}

若pInv管理的原始指標能在pInv創立之前確定下來,則將原始指標直接傳遞給pInv的建構函式更好

tr1::shared_ptr會自動使用每個指標專屬的刪除器,從而無須擔心cross-DLL problem:

cross-DLL problem:物件在動態連線程式庫(DLL)中被new建立,但在另一個DLL內被delete銷燬

//返回的tr1::shared_ptr可能被傳遞給任何其他DLL
//其會追蹤記錄從而在引用次數為0時呼叫那個DLL的delete
std::tr1:;shared_ptr<Investment> createInvestment()
{
	return std::tr1::shared_ptr<Investment>(new Stock);
}

Boost的tr1::shared_ptr特點:

  • 是原始指標的兩倍大
  • 以動態分配記憶體作為簿記用途和刪除器的專屬資料
  • 以virtual形式呼叫刪除器
  • 在多執行緒程式修改引用次數時有執行緒同步化(thread synchronization)的額外開銷

Tips:

  • 好的介面不易被誤用
  • 促進正確使用的方法包括介面一致性和與內建型別的行為相容
  • 阻止誤用的辦法包括建立新型別、限制型別上的操作、束縛物件值、消除客戶的資源管理責任
  • tr1::shared_ptr支援定製型刪除器(custom deleter),這可以防範DLL問題,可被用來自動解除互斥鎖(mutexes)等

條款19:設計class猶如設計type(Treat class design as type design)

定義一個新class時也就定義了一個新type。設計高效的類需要考慮以下問題:

  • 新type的物件應如何建立和銷燬(第8章))
    • 影響建構函式和解構函式、記憶體分配函式和釋放函式(operator new,operator new [],operator delete,operator delete [])
  • 物件的初始化和賦值應有什麼差別(條款4)
    • 決定建構函式和賦值運算子的行為
  • 新type的物件如果被pass-by-value意味著什麼
    • 由copy建構函式定義pass-by-value如何實現
  • 什麼是新type的合法值
    • 有效的數值集決定了類必須維護的約束條件(invariants),
      • 進而決定了成員函式(特別是建構函式、解構函式、setter函式)的錯誤檢查
    • 還影響函式丟擲的異常和極少使用的函式異常明細列(exception specifications)
  • 新type需要配合某個繼承圖系(inheritance graph)嗎
    • 繼承既有的類,則受那些類束縛,尤其要考慮那些類的函式是否為虛擬函式
    • 被其他類繼承,則影響解構函式等是否為virtual
  • 新type需要什麼樣的轉換
    • 若允許型別T1隱式轉換為型別T2,可可考慮:
      • 在T1類內寫型別轉換函式(operator T2)
      • 在T2類內些non-explicit-one-argument(可被單一實參呼叫)的建構函式
      • 若只允許explicit建構函式存在,就得寫專門執行轉換的函式,且沒有型別轉換運算子(type conversion operators)或non-explicit-one-argument建構函式
  • 什麼樣的運算子和函式對於此新type合理
    • 決定需要宣告哪些函式,其中哪些是成員函式
  • 什麼樣的標準函式應駁回
    • 這些必須宣告為private
  • 誰改取用新type的成員
    • 影響public、private、protected的選擇
    • 影響友元類、友元函式、及其巢狀的設計
  • 什麼是新type的未宣告介面(undeclared interface)
    • 要考慮其對效率、異常安全性、資源運用的保證
  • 新type有多麼一般化
    • 若要定義整個type家族,則應該定義新的class template
  • 是否真的需要新type
    • 若定義新的派生類就足夠,則可能定義non-member函式或templates更好

Tips:

  • Class設計就是type設計,需要考慮以上所有問題

條款20:寧以pass-by-reference-to-const替換pass-by-value(Prefer pass-by-reference-to-cons to pass-by-value)

避免構造和析構

class Person {
public:
	Person();
	virtual ~Person();
	...
private:
	std::string name;
	std::string address;
};
class Student: public Person {
public:
	Student();
	~Student();
	...
private:
	std::string schoolName;
	std::string schoolAddress;
};
bool validateStudent(Student s);  //會呼叫六次建構函式和六次解構函式
bool validateStudent(const Student& s);  //效率提升很多

上述程式碼validateStudent函式中pass-by-value會呼叫六次建構函式和六次解構函式:

  • Student構造+Person構造+Student的2個string+Person的2個string
  • 析構同理

使用pass-by-reference可避免頻繁構造和析構

避免物件切割

物件切割(slicing):派生類以值傳遞並被視為基類物件時,回撥用基類的建構函式,而派生類的成分全無

class Window {
public:
	...
	std::string name() const;  //返回視窗名稱
	virtual void display() const;  //顯示視窗和其內容
};
class WindowWithScrollBars: public Window {
public:
	...
	virtual void display() const;
};

void printNameAndDisply(Window w)
{
	std::cout << w,name();
	w.display();
}
WindowWithScrollBars wwsb;
printNameAndDisply(wwsb);  //物件會被切割,因為引數w時Window物件,故呼叫的Window::display
void printNameAndDisply(const Window& w)  //不會被切割
{
	std::cout << w,name();
	w.display();
}

例外

  • 內建型別和STL的迭代器與函式物件採用pass by value往往效率更高,
  • 小型type不一定適合pass by value
    • 一旦需要複製指標的所指物,則copy建構函式可能成本很高
  • 即使小型物件的copy建構函式不昂貴,其效率也存在爭議
    • 某些編譯器對內建型別和自定義型別的態度截然不同,即使二者底層表示(underlying representation)相同
    • 如可能會把一個double放入快取器,而只包含一個double的物件則不會
    • by reference則肯定把指標放入快取器
  • 使用者自定義型別的大小容易變化,因其內部實現可能改變,故不一定適合pass by value
    • 某些標準程式庫實現版本中的string型別比其他版本大七倍

Tips:

  • 儘量以pass-by-reference-to-const替換pass-by-value。前者通常高效且能避免切割問題
  • 以上規則並不適用內建型別和STL的迭代和與函式物件,它們更適合pass-by-value

條款21:必須返回物件時,別妄想返回其reference(Don’t try to return a reference when you must return an object)

考慮有理數乘積:

class Rational {
public:
	Rational(int numerator = 0, int denominator = 1);
	...
private:
	int n, d;  //分子和分母
	friend const Rational operator* (const Rational& lhs, const Rational& rhs);
};

上述程式碼中運算子以by value的方式返回值,如果要返回reference則運算子必須自己建立新Rational物件,其途徑有二:在stack或heap空間建立(反例)

//返回local物件的引用,但是local物件在離開函式時就銷燬了
const Rational& operator* (const Rational& lhs, const Rational& rhs)
{
	Rational result(lhs.n * rhs.n, lhs.d * rhs.d);
	return result;
}

//難以對new建立的物件delete,尤其以下連乘的例子
const Rational& operator* (const Rational& lhs, const Rational& rhs)
{
	Rational* result = new Rational(lhs.n * rhs.n, lhs.d * rhs.d);
	return *result;
}
//無法取得引用背後的指標
Rational w, x, y, z;
w = x * y * z;  //operator*(operator*(x, y), z)

若使用static Rational避免呼叫建構函式,則會有如下問題:

//返回local物件的引用,但是local物件在離開函式時就銷燬了
const Rational& operator* (const Rational& lhs, const Rational& rhs)
{
	static Rational result;
	result = ... ;
	return result;
}
bool operator==(const Rational& lhs, const Rational& rhs);
Rational a, b, c, d;
...
if ((a * b) == (c * d)) { ... }  //==總是為true
else { ... }  //,因兩側是同一個同一個stetic Rational物件的引用

必須返回新物件的函式的正確寫法為:

inline const Rational& operator* (const Rational& lhs, const Rational& rhs)
{
	return Rational(lhs.n * rhs.n, lhs.d * rhs.d);
}

Tips:

  • 絕不要返回指標或引用指向local stack物件
  • 絕對不要返回引用指向heap-allocated物件
  • 絕對不要在有可能同時需要多個這樣的物件時返回指標或引用指向local static物件

條款22:成員變數宣告為private(Declare data members private)

這一條似乎沒啥內容_

把成員變數宣告為private的原因如下:

  • 介面的一致性:非public的成員函式只能透過函式訪問,並且可以方便的設定讀寫許可權
  • 封裝
    • 把成員變數隱藏在函式介面背後,可以方便地更改實現方式
    • public成員變數修改後所有使用它們的客戶碼都會被破壞
    • protected成員變數修改後所有使用它們的派生類都會被破壞,其並不比public更具有封裝性

Tips:

  • 切記把成員變數宣告為private,這個賦予訪問資料的一致性、可細微劃分訪問控制、保證約束條件、提供充分的實現彈性
  • protected並不比public更具封裝性

條款23:寧以non-member、non-friend替換member函式(Prefer non-member non-friend functions to member functions)

考慮有個類表示網頁瀏覽器:

class WebBrowser {
public:
	...
	void clearCache();
	void clearHistory();
	void removeCookies();
	void clearEverything();  //執行所有清除動作
	...
};

執行所有清除動作由兩個方案:

  • WebBrowser 提供函式
  • 由non-member函式呼叫相應的member函式
    • 封裝性更高,且包裹彈性(packaging flexibility)較大,編譯相依度較低,是更好的方案
//WebBrowser 提供函式
class WebBrowser {
public:
	...
	void clearEverything();  //執行所有清除動作
	...
};
//由non-member函式呼叫相應的member函式
void clearBrowser(WebBrowser& wb)
{
	wb.clearCache();
	wb.clearHistory();
	wb.removeCookies();
}

兩點注意事項:

  • 準確地說,封裝性良好的是non-member non-friend函式,而非non-member函式
  • 一個類的non-member non-friend函式可以是可以是另一個類的member
    • 有些語言的函式必須定義在類內(如Eiffel,Java,C#),可以令clearBrowser成為某個工具類(utility class)的一個static member函式,而非WebBrowser的一部分或friend
    • 在C++中可讓clearWebBrowser成為non-member函式且和WebBrowser位於同一名稱空間
namespace WebBrowserStuff {
	Class WebBrowser { ... };
	void clearBrowser(WebBrowser& wb);
	...
}

名稱空間能跨越多個原始碼檔案而類不能,故可將同一名稱空間下不同功能型別的函式放在不同的標頭檔案:

標準程式庫也不是一個龐大的單一標頭檔案,而是有若干個標頭檔案,每個標頭檔案宣告std的某些功能,這樣可以使得使用者只依賴所使用的一小部分系統

//標頭檔案webbrowser.h,包含WebBrowser自身和核心功能
namespace WebBrowserStuff {
class WebBrowser { ... };
	...  //核心功能,如廣泛使用的non-member函式
}
//標頭檔案webbrowserbookmarks.h,
namespace WebBrowserStuff {
	...  ////與書籤相關的函式
}
//標頭檔案webbrowsercookies.h,
namespace WebBrowserStuff {
	...  //與cookie相關的函式
}

Tips:

  • 寧可拿non-member non-friend函式替換member函式,以增肌封裝性、包裹彈性、功能擴充套件性

條款24:若所有引數皆需型別轉換,請為此採用non-member函式(Declare non-member functions when type conversions should apply to all parameters)

考慮有理數類:

class Rational {
public:
	Rational(int numerator = 0,    //建構函式刻意不為explicit
					 int denominator = 1);  //允許int到Rational的隱式轉換
	int numerator() const;  //分子和分母的訪問函式
	int denominator() const;
private:
	...
};

若運算子*為Rational的成員函式:

class Rational {
public:
	...
	const Rational operator* (const Rational& rhs) const;
};
Rational oneHalf(1, 2);
Rational result = oneHalf * 2;  //正確,發生了隱式型別轉換,根據int建立了Rational
result = oneHalf.operator*(2);  //但如果是explicit建構函式則錯誤

result = 2 * oneHalf;  //錯誤!
result = 2.operator*(oneHalf);  //錯誤!重寫上式,錯誤一目瞭然

result = operator*(2, oneHalf);  //錯誤!本例不存在接受int和Rational的運算子*

只有引數位於引數列內,這個引數才能隱式型別轉換

要支援混合運算,則可讓運算子*成為non-member函式:

const Rational operator*(const Rational& lhs, const Rational& rhs)
{
	return Rational(lhs.numerator() * rhs.numerator(),
									lhs.denominator() * rhs.denominator());

member函式的反面是non-member函式,而非friend函式

從Objected-Oriented C++轉換到Template C++且Rational是一個class template時,本條款需要考慮新的問題

Tips:

  • 若需要為某個函式的所有引數(包括this指標所指的那個隱喻引數)進行型別轉換,那這個函式必須是non-member

條款25:考慮寫出一個不拋異常的swap函式(Consider support for a non-throwing swap)

預設的swap

預設情況下swap動作可由標準程式庫提供的swap演算法完成:

namespace std {
	template<typename T>  //只要T支援copying即可實現swap
	void swap(T& a, T& b)
	{
		T temp(a);
		a = b;
		b = temp;
	}
}

特化的swap

預設的swap涉及三個物件的複製,而pimpl手法(pointer to implementation)可避免這些複製:

置換兩個Widget物件值只需要置換其pImpl指標;而預設的swap會複製三個Widget,並且複製三個WidgetImpl物件

class Widget {
public:
	Widget(const Widget& rhs);
	Widget& operator=(const Widget& rhs)  //複製Widget時,就複製WidgetImpl物件
	{
		...
		*pImpl = *(ths.pImpl);
		...
	}
	...
private:
	WidgetImpl* pImpl;  //所指物件內涵Widget資料
};

將std::swap針對Widget特化可解決上述問題:

令Widget宣告public swap成員函式做真正的置換工作(採用成員函式是為了取用private pImpl,non-member函式則不行),再把std::swap特化

class Widget {
public:
	...
	void swap(Widget& other)
	{
		using std::swap;  //這個宣告有必要,稍後解釋
		swap(pImpl, other.pImpl);  //真正做置換工作,
	}
	...
};
namespace std {
	template<>  //表示其是std::swap的全特化(total template specialization)版本
	void swap<Widget>(Widget& a, Widget& b)
	{
		a.swap(b);  //要置換WIdget就呼叫其swap成員函式
	}
}

上述程式碼與STL容器有一致性,因為所有STL容器也都提供有public swap成員函式和std::特化版本(以呼叫成員函式)

若Widget和WidgetImpl都是class template,可考慮把WidgetImpl內的資料型別引數化:

template<typename T>
class WidgetImpl { ... };
template<typename T>
calss Widget { ... };

此時特化std::swap會遇到問題:

//以下程式碼企圖偏特化(partially specialize)一個function template(std::swap)
//但C++只允許對class template偏特化
//故無法透過編譯(雖然少數編譯器錯誤地透過編譯)
namespace std {
	template<typename T>
	void swap<Widget<T>>(Widget<T>& a, Widget<T>& b)
	{ a.swap(b);}
}

//偏特化function template時,通常會新增過載版本
//但以下程式碼也不合法,因為std不能新增新的templates,這由C++彼岸準委員會決定
namespace std {
	template<typename T>
	void swap(Widget<T>& a, Widget<T>& b)  //注意swap之後沒有<...>
	{ a.swap(b); }
}

解決方案:宣告一個non-memebr swap以呼叫member swap,但不再將non-member swap宣告為std::swap的特化版本或過載版本

任何程式碼如果要置換兩個Widget物件而呼叫swap,則C++的名稱查詢法則(name lookup rules;更具體地說是argument-dependent lookup或Koeing lookup法則)會找到WidgetStuff內的Widget專屬版本

namespace WidgetStuff {  //為簡化,把Widget相關功能都放入WidgetStuff名稱空間內
	...
	template<typename T>
	class Widget { ... };
	...
	template<typename T>
	void swap(Widget<T>& a, Widget<T>& b)  //non-member swap函式,不屬於std名稱空間
	{
		a.swap(b);
	}
}

若想要class專屬版的swap在儘可能多的語境下被呼叫,則需呀在該class所在的名稱空間內寫一個non-member版本和一個std::特化版本,故應該為該class特化std::swap

若希望呼叫T專屬版本,並且在該版本不存在的情況下呼叫std內的一般化版本,可實現如下:

C++的名稱查詢法則確保會找到global作用域或T所在的名稱空間內的任何T專屬的swap;若沒有專屬swap則using宣告使得能夠呼叫std::swap

template<typename T>
void doSomething(T& obj1, T& obj2)
{
	using std::swap;  //令std::swap在此函式內可用
	...
	swap)obj1, obj2);  //為T呼叫最佳swap版本
	...
}

std::swap(obj1, obj2);  //錯誤的方式!強迫編譯器呼叫std::swap

使用swap的總結

swap的使用總結如下:

  1. 若預設的swap的效率可接受,則無需做額外的事
  2. 若預設的swap效率不足,則可考慮:
    1. 提供public swap成員函式,使其置換相應型別的兩個物件值,且絕不丟擲異常
    2. 在class或template所在的名稱空間內提供一個non-member swap,並呼叫上述swap成員函式
    3. 若正在編寫class(而非class template),則特化std::swap並使其呼叫swap成員函式
  3. 若呼叫swap,則需要包含using宣告式,使std::swap在函式內可見,之後不加namespace直接呼叫swap

成員版swap絕不可丟擲異常,其最好的應用是幫助class或class template提供強烈的異常安全性(exception-safety)保障

Tips:

  • 當std::效率不高時,提供一個swap成員函式,並確保其不丟擲異常
  • 如果提供一個member swap,則要提供一個non-member swap呼叫前者。對於class(而非template),也最好特化std::swap
  • 呼叫swap時應宣告 using std:;swap,之後不帶名稱空間修飾地呼叫swap
  • 為自定義型別進行std template全特化可以,但是不要再std內加入新東西`

相關文章