Effective c++(筆記) 之 類與函式的設計宣告中常遇到的問題

jsjliuyun發表於2014-05-28

1.當我們開始去敲程式碼的時候,想過這個問題麼?怎麼去設計一個類?

或者對於程式設計師來說,寫程式碼真的就如同搬磚一樣,每天都乾的事情,但是我們是否曾想過,在c++的程式碼中怎麼樣去設計一個類?我覺得這個問題可比我們“搬磚”重要的多,大家說不是麼?

這個答案在本部落格中會細細道來,當我們設計一個類時,其實會出現很多問題,例如:我們是否應該在類中編寫copy constructor 和assignment運算子(這個上篇部落格中已說明),另外,我們是讓編寫的函式成為類的成員函式還是友元還是非成員函式,函式的引數使用傳引用的方式還是傳值的方式,這個函式該不該宣告為const,函式的返回值是該設計成const麼?等一系列的問題,我會在下文分成各個小問題來解釋。

首先,我覺得應該考慮的一個重要問題,我們設計的類該有多大,每當有新的需求時,我們是否應該隨意的新增。

請在設計類的時候遵循下面的原則:

讓你的class類介面即完美又最小化

原因------設計的類就相當於定義了一個新的型別,如果不能滿足我們的需求,那麼就不用談其他的了,所以首先我們應該讓設計的類滿足我們的需求,在滿足我們的需求時,儘可能的使類最小化,類的最小化是指,當類有新的需求時,我們看這個需求是否跟已經編寫的函式衝突,是否可以和以前的整合,也就是說看這個成員函式是否是必要寫到類裡面的,因為大型class介面的缺點可維護性差。


2.類中的資料成員是設計成public、protected還是private?

答案:儘量使自己的data members設計成私有,不讓外部訪問,使其封裝性更好,如果類的資料成員設計成public的話,外界隨便訪問,這對於c++的封裝性而言就不是很好。

我們通常設計為pirvate,如果需要得到或者改變這些值,我們會編寫專門的成員函式來操作,如下所示

int GetX() const { return x;}
void SetX(int value) { x = value;}

如果所設計的類為基類,同時希望基類的資料成員被派生類繼承,那麼一個很好的方法常常將資料成員設計為保護型別protected

這樣,當繼承方式為公有繼承時,基類的公有成員和保護成員均以原有的狀態繼承下來,派生類中的成員函式和友元可以訪問到基類的資料成員。


3.類class與結構體struct 的區別在哪裡?

答:定義類等於定義了一種新的型別,結構體其實也可以達到這樣的結果,有兩個非常明顯的不同點

類中如果不註明資料成員的訪問級別預設的資料成員是私有的private,當繼承的時候,如果不註明繼承方式,則是私有private繼承

結構體正好相反,它定義是預設的資料成員是公有的public,同時它的預設繼承方式也是公有public的


4.設計類的函式時,應該將其設計為成員函式、非成員函式還是友元函式?

答:首先簡單說一下它們之間的區別

成員函式與非成員函式最大的區別是---------------成員函式可以為虛擬函式,而其他的函式不可以為虛擬函式(最大最明顯的區別)。

友元函式相當於該類的一個朋友友元,它可以訪問該類的所有資料成員,但有時候朋友多並不是什麼好事,所以,在設計類的時候,如果這個函式不能為成員函式但同時它又必須訪問到該類的資料成員(輸入>> 輸出<< 操作符過載)此時再設計成友元,如果可能不設計為友元那就儘量這樣。

用一個例子來教我們怎麼判斷這個函式是設計成成員函式還是非成員函式!

下面是一個分數的類,其中有個實現分數乘法的函式,我們暫且先將它設計為類的成員函式來討論

class Rational{
public:
	Rational( int numerator = 0 , int denominator = 1);
	int numerator() const;
	int denominator() const;
	//分數的乘法,開始設計成類的成員函式
	const Rational operator*(const Rational &rhs) const;
private:
	int n , d;//分別表示分子和分母
};
int Rational::numerator() const
{
	return n;
}
int Rational::denominator() const
{
	return d;
}
const Rational Rational::operator *(const Rational &rhs) const
{
	Rational result;
	result.n = n * rhs.n;
	result.d = d * rhs.d;
	return result;
}

看上述程式碼,將Rational類中的分數乘法設計為類的成員函式看似沒什麼錯誤,看下面的例項就知道了

Rational oneHalf(1,2);
Rational oneEight(1,8);
Rational res;
res = oneHalf * oneEight;
res = oneHalf *2;
res = 2 * oneHalf;//報錯

如果我們寫成上述的成員函式,那麼乘號*左邊一定得是Rational物件,因為成員函式的形式決定了這樣,

在此你可能會說不對,兩邊都必須是Rational物件,乘號*右邊可以為int物件,原因如下:

當我們在類中的建構函式設計為

Rational( int numerator = 0 , int denominator = 1);

而不是

explicit Rational( int numerator = 0 , int denominator = 1);

這二者的區別就是,當函式的引數是類型別的時候,當你傳入函式的引數並不是類型別,恰該類型別的建構函式沒有申明explicit,那麼便會產生隱式轉換,使int ------->   Rational,其內部的轉換過程大致如下:

//運用建構函式的隱式轉換產生一個臨時的類型別物件
const Rational temp(2);
//用該臨時物件替換int的值
res = oneHalf * temp;

再次說明:

只有當類型別的建構函式沒有宣告explicit時才會產生型別轉換

這裡的型別轉換隻針對於出現在參數列上的類型別形參有效,而對於(*this)是無效的,所以最後一個例項會報錯,因為左邊不會進行轉換。

所以這樣看來將分數乘法的這個函式設計為成員函式顯然不合理,因為沒辦法乘號*左邊是int型別的資料,這跟現實不符合。

有人說了再寫一個類似能實現左邊是int值的函式就可以了,別忘了我們設計類最初要遵循的:儘量介面最小化,我們完全可以通過一個函式實現所有的型別的分數相乘,為什麼要再寫一個函式呢,如果再寫一個函式就違背了介面最小化的原則。

我們可以這樣改變operator*函式 , 將其設計為非成員函式,如下所示

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

這樣引數為兩個均為Rational類的引用,都為引數,在建構函式不為explicit時,均可以進行隱式轉換,實現了左右兩邊都可以是int型資料的可能。同時類中的成員設計為私有,通過numerator() 和denominator()來訪問,提高了類的封裝性,引數的形式採用了引用的方式,當呼叫該函式時,不用複製實參,提高了效率,返回值採用了const的形式,避免了分數乘積的結果被寫的危險,即有效率又有安全性的寫法。

很多情況下,都需要過載輸入輸出操作符,常常為了我們的程式設計習慣,把輸入輸出操作符過載的函式設計成了類的非成員友元函式。

istream& operator>>(istream &in , T &object)
{//因為輸入改變了物件的狀態,引數不能為const
	//輸出類的成員操作
	return in;
}

ostream& operator<<(ostream &out , const T &object)
{//輸出沒有改變物件的狀態,設定為const引用的方式

	return out;
}

結論

虛擬函式必須為類的成員函式

類的加減乘除運算子過載常常設計為類的非成員函式

輸入輸出操作符過載的函式一定不能為類的成員函式,常常為類的友元函式

只有非成員函式才能在其左端物件(形參)身上使用型別轉換


5.函式的引數儘量使用傳址方式,而少使用傳值方式

這條几乎是c++領域中公認的規則,在編寫函式中儘量使用引用(傳址)方式,而少用c語言中的傳值方式

原因有下面兩點

傳址方式效率比較高,不用在呼叫函式的時候複製實參呼叫拷貝建構函式,通常將物件以傳值的方式進行時,將實參複製給形參需要呼叫copy constructor 當返回的時候將返回值傳回物件又呼叫copy constructor 完了之後對區域性物件會析構掉,還要呼叫解構函式,這樣的效率可知。

另外,當使用傳址的方式時,可以避免派生類物件傳入引數為基類物件的函式時發生的切割現象,當用傳址的方式,該基類物件的引用繫結的是派生類物件,所以在執行這個函式裡中如果該物件呼叫了虛擬函式,那麼就會根據其繫結的動態物件來決定執行基類還是派生類的物件,這樣很容易達到我們的目的。


6. 當返回值是物件時,儘量不要採用傳回引用的方式

其實這點我感覺Effective c++沒有說清楚,我認為,當函式是類的成員函式,通常返回的應該是該類的引用,為什麼呢?因為Effective c++上說這條的原因是,如果返回的是物件,採用引用的方式,通常該引用指向不存在的物件,但是在類的成員函式中,常以T&作為返回值,是因為呼叫該成員函式的物件肯定存在我們常常返回時*this,所以對於成員函式而言返回*this不可能指向不存在的物件。

所以我感覺書上這點應該指的是類的非成員函式,當類的非成員函式返回物件時,我們的確不要返回該物件的引用。

傳引用無非目標就是避免呼叫建構函式使效率提高,但是因為傳回的引用必然要繫結物件(因為引用其實就是別名,為某個物件某個變數起了另外一個名字,改變這個別名也就等於改變了本身,操作這個引用也就等於操作了本身) ,所以我們必然要產生一個物件,這樣返回時,引用才能指向一個物件,棧空間和堆空間中產生

凡是區域性變數都是在棧空間中產生的,

凡是通過new出來的都是在堆空間中產生的

還是拿上面的那個分數乘法的例子繼續討論

//傳回非引用的物件
const Rational operator *(const Rational &lhs , const Rational &rhs) 
{
	return Rational(lhs.numerator() *rhs.numerator() , lhs.denominator() * rhs.denominator());
}
//傳回引用的物件
const Rational& operator*(const Rational &lhs , const Rational &rhs)
{
	//在棧空間產生result
	Rational result(lhs.numerator() *rhs.numerator() , lhs.denominator() * rhs.denominator());
	return result;
}

const Rational& operator*(const Rational &lhs , const Rational &rhs)
{
	//在堆空間中產生result
	Rational *result = new Rational(lhs.numerator() *rhs.numerator() , lhs.denominator() * rhs.denominator());
	return *result;
}

首先,在棧空間中產生的result,仍然呼叫了建構函式,效率完全沒有提高,另外一個最大的bug是你傳回了區域性物件,這樣當函式執行完後區域性物件就析構不存在了,這是非常大的錯誤。

如果在堆空間中產生result,new產生的還是需要呼叫建構函式,同時也存在一個bug,那就是你new了什麼時候delete哈!new和delete必須要配對產生哈!

所以,在非成員函式中,如果返回的是物件時,儘量返回值儘量為物件而不應該為物件的引用

7.什麼時候應該使用const?

其實,在初學c++的時候感覺最鬱悶的就是這個const關鍵字了,它無時不刻存在所有的c++程式碼中,讓我看得頭暈眼花。

const表示常量,在定義全域性變數時我們常用到,如下所示

const int M = 1000000007;
const double ASPECT_RATIO = 1.653;

這是在全域性作用域定義一些常量,別告訴我你還在用#define,可以改改舊的c語言習慣了哈!

另外在類的成員函式的末尾也經常見到這個關鍵字const,如下所示

int numerator() const;
int denominator() const;

這表示呼叫該成員函式的物件的資料成員不可更改,說的簡單點就是這個函式中隱藏的this指標所指向的物件時const物件(注意,指標並不是const,而是指向的物件時const物件)

在函式的返回值時有時也能見到const

這裡表示返回的物件時const,不能被寫,只能讀,函式傳回的是一個常量值。如下所示

const Rational operator *(const Rational &lhs , const Rational &rhs) 
{
	return Rational(lhs.numerator() *rhs.numerator() , lhs.denominator() * rhs.denominator());
}
Rational a , b , c;
(a*b) = c //報錯,這樣是錯誤的,返回的是const常量,不能賦值

常常函式的引數我們也使用const 引用的方式

此處,如果在函式中不打算改變引數的資料成員,就儘量設定成const,這樣當你不注意在函式體內試圖改變引數時編譯器便會給提醒。

怎麼去區別const關鍵字是限制指標還是變數的,下面是一種最簡單的方法

const char *p = "Hello"; //非const指標指向const變數
char * const p = "Hello";//const指標指向非const變數
const char * const p = "Hello";//const指標指向const變數

以*號為分界線,const在左邊就是限制變數,即指向的是const的變數,const在*號右邊指的是指標不能修改,const指標。


8.如果不想用編譯器產生的預設函式,儘量顯示的拒絕這個函式

怎麼去拒絕編譯器產生的預設函式,將它定義為類的私有方式即可,這樣例項化的物件也可以是客戶就沒法呼叫這個函式,或者嘗試去呼叫這個函式時,編譯器會提示錯誤。

為什麼要去拒絕編譯器為我們產生的預設函式,比如預設的賦值操作符

當我們定義資料類的時候,陣列是不能賦值的,需要迴圈才可以,所以在設計類的時候,便不想允許這個函式存在,雖然自己沒有編寫,但是編譯器依然還會給我們合成一個,所以此時我們就必須顯示指出這個函式不能呼叫,如下所示

template<class T>
class Array{
private:
	//顯示的指出不定義這個函式
	Array& operator=(const Array &rhs);
};


相關文章