《Effective C++》第三版-1. 讓自己習慣C++(Accustoming Yourself to C++)

Roanapur發表於2024-04-28

目錄
  • 條款01:視C++為一個語言聯邦(View C++ as a federation of languages)
  • 條款02:儘量以const、enum、inline替換#define(Prefer consts, enums, and inlines to #define)
    • 替換原因
    • 兩種特殊常量
    • 形似函式的宏
  • 條款03:儘可能使用const(Use const whenever possible)
    • const和指標
    • const成員函式
    • 在const和non-const成員函式中避免重複
  • 條款04:確定物件被使用前已先被初始化(Make sure that objects are initialized before they’re used)
    • 成員初值列
    • 成員初始化的次序

條款01:視C++為一個語言聯邦(View C++ as a federation of languages)

C++有4個主要的次語言(sublanguage):

  • C。包含區塊(blocks)、語句(statements)、前處理器(preprocessor)、內建資料型別(built-in data)、陣列(arrays)、指標(pointers)等;沒有模板(templates)、異常(exceptions)、繼承(inheritance)。
  • Object-Oriented C++。這是C with classes部分,包含classes(包括建構函式和解構函式)、封裝(encapsulation)、繼承(inheritance)、多型(polymorphism)、virtual函式等。
  • Template C++。這是C++泛型程式設計(generic programming)部分。
  • STL。涉及容器(containers)、迭代器(iterators)、演算法(algorithms)、函式物件(function objects)。

Tips:

  • C++高效程式設計守則視情況而變化,和使用的次語言種類有關

條款02:儘量以const、enum、inline替換#define(Prefer consts, enums, and inlines to #define)

該條款可表達為:寧可以編譯器替換前處理器

替換原因

#define ASPECT_RATIO 1.653 
const double AspectRatio = 1.653;  //以常量替換宏

替換原因:

  • 記號名稱ASPECT_RATIO可能在編譯器開始處理原始碼之前被前處理器移走,而未被編譯器看到,沒有進入記號表(symbol table),故在編譯錯誤涉及該常量時難以確定1.653的來源。使用語言常量AspectRation則不會有這個問題。
  • 對浮點常量(floating point constant,如本例),使用常量可能比使用#define導致更少量的碼,因為前處理器盲目的將宏名稱替換為1.653可能導致目標碼(object code)出現多份1.653,改用常量AspectRatio則不會有此問題

兩種特殊常量

常量指標(constant pointers):常量定義式常位於標頭檔案,故有必要將指標宣告為const

const char* const authorName = "Scott Meyers";
const std::string authorName("Scott Meyers");  //使用string更合適

class專屬常量:為了將常量作用於(scope)限制在class內,需要讓其成為class的一個成員(member);為了確保此常量至多隻有一份實體,需要讓其成為static成員

class GamePlayer {
private:
	static const int NumTurns = 5;  //常量宣告式
	int scores[Numturns];  //使用該常量
	...
};

當某個東西是class專屬常量+static+整數型別(integral type,如ints、chars、bools),只要不取地址,則可是有宣告式而無定義式,否則需要提供定義式。

//應放入實現檔案而非標頭檔案
const int Gameplayer::NumTurns;  //NumTurns的定義,宣告時設定了初值故此處可不設定值

舊編譯器可能不允許static成員在宣告式上獲得初值,此時可將初值放在定義式。

class CostEstimate {
private:
	static const double FudgeFactor;  //static class常量宣告,位於標頭檔案
};
const double CostEstimate::FudgeFactor = 1.35;  //static class常量定義,位於實現檔案

若譯器不允許static成員在宣告式上獲得初值,且class編譯期間需要一個class常量值(如存在陣列宣告式),則可用“the enum hack”補償,利用列舉型別(enumerated type)的數值可充當ints使用的特點。

enum hack有以下特點:

  • 取enum地址不合法,可避免存在指向其的pointer或reference,進而不會導致非必要的記憶體分配
  • “enum hack”是template programming(模板超程式設計)的基礎技術
class GamePlayer {
private:
	enum { NumTurns = 5 };  //令NumTurns成為5的一個記號名稱
	int scores[NumTurns];
	...
};

形似函式的宏

類似函式的宏(macros)沒有函式呼叫(function call)帶來的額外開銷,但其缺點顯著,最好替換為inline函式

//帶宏實參的宏,每個實參都需要加上小括號,然而還是可能出現難以預料的問題
#define CALL_WITH_MAX(a, b) f((a) > (b) ? (a) : (b))

//使用template inline實現宏的高效以及函式的可預料性和型別安全性(type safety)
template<typename T>
inline void callWithMax(const T& a, const T& b)
{
	f(a > b ? a : b);
}

Tips:

  • 對於單純常量,最好用const或enums替換#define
  • 對於形似函式的宏,最好用inline函式替換#defines

條款03:儘可能使用const(Use const whenever possible)

const和指標

  • 常量指標:const在星號*左邊,則被指物是常量
  • 指標常量:const在星號*右邊,則指標自身是常量
void f1(const Widget* pw);  //被指物是常量
void f2(Widget const * pw);  //同上

STL迭代器的作用類似T*指標,其同樣有指標常量和常量指標的用法

std::vector<int> vec;
...
const std::vector<int>::iterator iter = vec.begin();  //指標常量
*iter = 10;  //正確,改變iter所指物
++iter;  //錯誤!iter本身是const
std::vector<int>::const_iterator cIter = vec.begin();  //常量指標
*cIter = 10;  //錯誤!*cIter是const
++cIter;  //正確,改變cIter本身

const成員函式

const成員函式有兩個作用:

  • 使class介面容易被理解
  • 使操作const物件成為可能
//兩個成員函式如果只是常量性(constness)不同,可以被過載
class TextBlock {
public:
	...
	const char& operator[](std::size_t position) const  //對於const物件的運算子[]
	{ return text[position]; }
	char& operator[](std::size_t position)  //對於non-const物件的運算子[]
	{ return text[position]; }
private:
	std::string text;
};

//operator[]使用方式如下
TextBlock tb("Hello");
std::cout << tb{0];  //呼叫non-const TextBlock::operator[]
tb[0] = 'x';  //正確,寫一個non-const TextBlock,operator[]返回reference to char
const TextBlock ctb("World");
std::cout << ctb[0];  //呼叫const TextBlock::operator[]
ctb[0] = 'x';  //錯誤!寫一個const TextBlock,operator[]呼叫合法,但對其返回的const賦值非法

//更真實的例子
void print(const TextBlock& ctb)  //此函式中ctb是const
{
	std::cout << ctb[0];  //呼叫const TextBlock::operator[]
	...
}

const成員函式有兩個流行概念:

  • bitwise const(或physical const):const成員函式不能更改物件的任何成員變數(static除外)
  • logical const:const成員函式可以修改其所處理的物件內的某些const

當只有指標(而非其所指物)隸屬於物件,此時更改了指標所指物的成員函式不具備十足的const性質但編譯器認為其滿足bitwise const

class CtextBlock {
public: 
	...
	char& operator[](std::size_t position) const  //bitwise const宣告,但不適當
	{ return pText[position]; }  //operator[]實現程式碼並不更改pText本身
private:
	char* pText;  //只有指標(而非其所指物)隸屬於物件
}

const CTextBlock cctb("Hello");  //宣告一個常量物件
char* pc = &cctb[0];  //呼叫const operator[]獲得一個指標,指向cctb的資料
*pc = 'J';  //cctb變為"Jello"

當有些量需要修改而違反編譯器的bitwise const,則可利用C++的一個與const相關的擺動場:mutable,釋放掉non-static成員變數的bitwise constness約束

class CTextBlock {
public:
 ...
 std::size_t length() const;
private:
	char* pText;
	mutable std::size_t textLength;  //mutable使其可在const成員函式內更改
	mutable bool lengthIsValid;  //否則不能更改,編譯器會堅持bitwise const
};
std::size_t CTextBlock::length() const
{
	if (!lengthIsValid) {
		textLength = std::strlen(pText);  //正確,宣告時有mutable,否則錯誤
		lengthIsValid = true;
	}
	return textLength;
}

在const和non-const成員函式中避免重複

如果non-const和const operator[]相同,則程式碼會過長。可讓non-const operator[]呼叫const operator[]避免程式碼重複。這需要將常量性轉除(casting away constness)。

class TextBlock {
public:
	...
	const char& operator[](std::size_t position) const
	{
		...  //邊界檢驗(bounds checking)
		...  //志記資料訪問(log access data)
		...  //檢驗資料完整性(verify data integrity)
		return text[position];
	}
	char& operator[](std::size_t position)
	{
		return 
			const_cast<char&>(  //將op[]返回值的const轉除
				static_cast<const TextBlock&>(*this)  //為*this加上const
					[position]  //呼叫const op[]
			);
	}
private:
	std::string text;
}

上述程式碼包含兩個轉型動作:

  1. 將*this從其原始型別TextBlock&轉型為const TextBlock&,則之後operator[]會呼叫const版本而非non-const版本。直接在non-const operator[]內部呼叫operator[]會遞迴呼叫自己。
  2. 從const operator[]的返回值中移除const。

在const成員函式中呼叫non-const成員函式會有風險,因為物件有可能因此被改動。

Tips:

  • 將某些東西宣告為const可幫助編譯器偵測出錯誤用法。const可被施加於在任何作用域內的物件、函式引數、函式返回型別、成員函式本體
  • 編譯器強制實施bitwise constness,但編寫程式時應該使用概念上的常量性(conceptual constness)
  • 當const和non-const成員函式有著實質等價實現時,令non-const版本呼叫const版本可避免程式碼重複

條款04:確定物件被使用前已先被初始化(Make sure that objects are initialized before they’re used)

  • 使用C part of C++且初始化可能導致執行成本,則C++不保證初始化這些物件
  • non-C parts of C++的規則有變化
int x;  //x在某些語境中會被初始化(為0),但是其他語境中不保證
class Point {
	int x, y;
};
...
Point p;  //p的成員變數有時候被初始化(為0),有時候不會

由於是否初始化難以確定,故最好永遠在使用物件之前先將他初始化,對於內建型別以外的任何東西,確保每一個建構函式(constructors)都將物件的每一個成員初始化

成員初值列

class PhoneNumber {...};
class ABEntry {  // Addrress Book Entry
public:
	ABEntry(const std::string& name, const std::string& address, 
					const std::list<PhoneNumer>& phones);
private:
	std::string theName;
	std::string theAddress;
	std::list<PhoneNmuber> thePhones;
	int numTimesConsulted;
};
ABEntry::ABEntry(const std::string& name, const std::string& address, 
									const std::list<PhoneNumer>& phones);
{
	theName = name;  //這都是賦值(assignments)而非初始化(initializations)
	theAddress = address;
	thePhones = phones;
	numTimesConsulted = 0;
}

上述程式碼中,theName、theAddress、thePhones在進入ABEntry建構函式之前已經被初始化,而numTimesConsulted則不確定是否已被初始化。

ABEntry建構函式最好使用成員初值列(member initialization list)替換賦值動作

ABEntry(const std::string& name, const std::string& address, 
				const std::list<PhoneNumer>& phones);
	:theName(name),   //這些都是初始化
	 theAddress(address),
	 thePhones(phones), 
	 numTimesConsulted(0)
{}  //建構函式本體沒有動作

ABEntry();
	:theName(), //也可指定無物(nothing)呼叫default建構函式
	 theAddress(), 
	 thePhones(), 
	 numTimesConsulted(0)
{}  //建構函式本體沒有動作

初值列的效能消耗:

  • 一般而言,使用成員初值列只呼叫一次copy建構函式比先呼叫default建構函式再呼叫copy assignment運算子更高效
  • 對於內建型物件,初始化和賦值的成本相同,但在初值列中初始化可提升一致性

成員初值列的使用建議:

  • 總是在初值列中列出所有成員變數,以免還得記住哪些成員變數無需初值
  • 如果成員變數是const或reference,則一定要在初值列中初始化,其不能被賦值
  • 對於擁有多個建構函式且存在許多成員變數或base classes的classes,可在初值列中省略賦值和初始化的效能消耗相當的成員變數,將它們的賦值移往某個函式(通常是private),供建構函式呼叫
    • 在成員變數的初值有檔案或資料庫讀入時很有效

成員初始化的次序

C++的初始化次序固定為:

  • base classes先於derived classes初始化
  • class的成員變數按其宣告次序初始化

需要額外關注不同編譯單元內定義的non-local static物件,C++對這類物件的初始化次序無明確定義,故如果這類物件存在依賴關係可能會出問題。決定這類物件的初始化次序非常困難,最常見的形式是經由模板隱式具現化(implicit template instantiations)形成

  • static物件:其壽命從被構造出來直到程式結束為止,其解構函式在main()結束時被呼叫,包括global物件、定義餘namespace作用域內的物件、在classes內、在函式內、以及在file作用域內被宣告為static的物件
    • local static物件:函式內的static物件
    • non-local static物件:其他static物件
  • 編譯單元(translation unit):產出單一目標檔案(single object file)的原始碼,基本上是單一原始碼檔案加上其所含入的標頭檔案(#include files)
//假設自己有一個FielSystem Class
class FileSystem {
public:
	...
	std::size_t numDisks() const;
	...
};
extern FileSystem tfs;
//假設客戶在其他位置建立一個class以處理檔案系統內的目錄
class Directory {
public:
	Directory( *params* );
	...
};
Directory::Directory( *params* )
{
	...
	std::size_t disks = tfs.numDisks();  //使用tfs物件
	...
}
Directory tempDir( *params* );  //放臨時檔案的目錄

上述程式碼無法保證tfs在tempDir之前被初始化

解決方案:將每個non-local static物件搬到自己的專屬函式內並宣告為static,這些函式返回一個reference指向它所含的物件。換句話說,non-local static物件被替換為local static物件,這是單例(Singleton)模式的一個常見實現手法。

class FileSystem {...};  //同前
FileSystem& tfs()  //初始化一個local static物件並返回指向其的reference
{
	static FileSystem fs;
	return fs;
}
class Directory {...};  //同前
Directory::Directory( *params* )
**{
	...
	std::size_t disks = tfs().numDisks();  //呼叫tfs函式,而非直接用reference to tfs
}
Directory& tempDir()
{
	static Directoy td;
	return td;
}

reference-returning函式=定義並初始化一個local static物件+返回它

由於在多執行緒環境下任何non-const static物件都會有麻煩,則可在程式的單執行緒啟動階段(singl-threaded startup portion)手工呼叫所偶reference-returning函式,以消除與初始化有關的競速形式(race conditions)

Tips:

  • 為內建型物件進行手工初始化,因為C++不保證初始化它們
  • 建構函式最好使用成員初值列,而不要在建構函式本體內使用賦值操作。初值列列出的成員變數次序應和class中的宣告次序相同
  • 為避免跨編譯單元的初始化次序問題,請以local static物件替換non-local static物件

相關文章