漫談C++:良好的程式設計習慣與程式設計要點

melonstreet發表於2016-05-23

以良好的方式編寫C++ class

假設現在我們要實現一個複數類complex,在類的實現過程中探索良好的程式設計習慣。

① Header(標頭檔案)中的防衛式宣告

complex.h:

# ifndef  __COMPLEX__
# define __COMPLEX__
class complex
{

}
# endif

防止標頭檔案的內容被多次包含。

② 把資料放在private宣告下,提供介面訪問資料

# ifndef  __COMPLEX__
# define __COMPLEX__
class complex
{
    public:
        double real() const {return re;}
        double imag() const {return im;}
    private:
        doubel re,im;
}
# endif

③ 不會改變類屬性(資料成員)的成員函式,全部加上const宣告

例如上面的成員函式:

double real () `const` {return re;}
double imag() `const` {return im;}

既然函式不會改變物件,那麼就如實說明,編譯器能幫你確保函式的const屬性,閱讀程式碼的人也明確你的意圖。

而且,const物件才可以呼叫這些函式——const物件不能夠呼叫非const成員函式。

④ 使用建構函式初始值列表

class complex
{
    public:
        complex(double r = 0, double i =0)
            : re(r), im(i)  { }
    private:
        doubel re,im;
}

在初始值列表中,才是初始化。在建構函式體內的,叫做賦值。

⑤如果可以,引數儘量使用reference to const

為complex 類新增一個+=操作符:

class complex
{
    public:
        complex& operator += (const complex &)
}

使用引用避免類物件構造與析構的開銷,使用const確保引數不會被改變。內建型別的值傳遞與引用傳遞效率沒有多大差別,甚至值傳遞效率會更高。例如,傳遞char型別時,值傳遞只需傳遞一個位元組;引用實際上是指標實現,需要四個位元組(32位機)的傳遞開銷。但是為了一致,不妨統一使用引用。

⑥ 如果可以,函式返回值也儘量使用引用

以引用方式返回函式區域性變數會引發程式未定義行為,離開函式作用域區域性變數被銷燬,引用該變數沒有意義。但是我要說的是,如果可以,函式應該返回引用。當然,要放回的變數要有一定限制:該變數的在進入函式前,已經被分配了記憶體。以此條件來考量,很容易決定是否要放回引用。而在函式被呼叫時才建立出來的物件,一定不能返回引用。

說回operator +=,其返回值就是引用,原因在於,執行a+=b時,a已經在記憶體上存在了。

operator + ,其返回值不能是引用,因為a+b的值,在呼叫operator +的時候才產生。

下面是operator+= 與’operator +’ 的實現:

inline complex & complex :: operator += (const complex & r)
{
        this -> re+= r->re;
        this -> im+= r->im;
        return * this;
}
inline complex operator + (const complex & x , const complex & y)
{
        return complex ( real (x)+ real (y),                        //新建立的物件,不能返回引用
                                 imag(x)+ imag(y));
}

operator +=中返回引用還是必要的,這樣可以使用連續的操作:

c3 += c2 += c1;

⑦ 如果過載了操作符,就考慮是否需要多個過載

就我們的複數類來說,+可以有多種使用方式:

complex c1(2,1);
complex c2;
c2 = c1+ c2;
c2 = c1 + 5;
c2 = 7 + c1;

為了應付怎麼多種加法,+需要有如下三種過載:

inline complex operator+ (const complex & x ,const complex & y)
{
    return complex (real(x)+real(y),
                    imag(x+imag(y););
}
inline complex operator + (const complex & x, double y)
{
    return complex (real(x)+y,imag(x));

inline complex operator + (double x,const complex &y)
{
    return complex (x+real(y),imag(y));
}

⑧ 提供給外界使用的介面,放在類宣告的最前面

這是某次面試中,面試官大哥告訴我的。想想確實是有道理,類的使用者用起來也舒服,一眼就能看見介面。

Class with pointer member(s):記得寫Big Three

C++的類可以分為帶指標資料成員與不帶指標資料成員兩類,complex就屬於不帶指標成員的類。而這裡要說的字串類String,一般的實現會帶有一個char *指標。帶指標資料成員的類,需要自己實現class三大件:拷貝建構函式、拷貝賦值函式、解構函式。

class String
{
    public:
        String (const char * cstr = 0);
        String (const String & str);
        String & operator = (const String & str);
        ~String();
        char * get_c_str() const {return m_data};
    private:
        char * m_data;
}

如果沒有寫拷貝建構函式、賦值建構函式、解構函式,編譯器預設會給我們寫一套。然而帶指標的類不能依賴編譯器的預設實現——這涉及到資源的釋放、深拷貝與淺拷貝的問題。在實現String類的過程中我們來闡述這些問題。

①解構函式釋放動態分配的記憶體資源

如果class裡有指標,多半是需要進行記憶體動態分配(例如String),解構函式必須負責在物件生命結束時釋放掉動態申請來的記憶體,否則就造成了記憶體洩露。區域性物件在離開函式作用域時,物件解構函式被自動呼叫,而使用new動態分配的物件,也需要顯式的使用delete來刪除物件。而delete實際上會呼叫物件的解構函式,我們必須在解構函式中完成釋放指標m_data所申請的記憶體。下面是一個建構函式,體現了m_data的動態記憶體申請:

/*String的建構函式*/
inline 
String ::String (const char *cstr = 0)
{
    if(cstr)
    {
        m_data = new char[strlen(cstr)+1];   // 這裡,m_data申請了記憶體
        strcpy(m_data,cstr);
    }
    else
    {
        m_data= new char[1];
        *m_data = '\0';
    }
}

這個建構函式以C風格字串為引數,當執行

String *p = new String ("hello");

m_data向系統申請了一塊記憶體存放字串hello

解構函式必須負責把這段動態申請來的記憶體釋放掉:

inline 
String ::~String()
{
    delete[]m_data;
}

②賦值建構函式與複製建構函式負責進行深拷貝

來看看如果使用編譯器為String預設生成的拷貝建構函式與賦值操作符會發生什麼事情。預設的複製建構函式或賦值操作符所做的事情是對類的記憶體進行按位的拷貝,也稱為淺拷貝,它們只是把物件記憶體上的每一個bit複製到另一個物件上去,在String中就只是複製了指標,而不復制指標所指內容。現在有兩個String物件:

String a("Hello");
String b("World");

a、b在記憶體上如圖所示:

如果此時執行

b = a;

淺拷貝體現為:

儲存World\0的記憶體塊沒有指標所指向,已經成了一塊無法利用記憶體,從而發生了記憶體洩露。不止如此,如果此時物件a被刪除,使用我們上面所寫的解構函式,儲存Hello\0的記憶體塊就被釋放呼叫,此時b.m_data成了一個野指標。來看看我們自己實現的建構函式是如何解決這個問題的,它複製的是指標所指的記憶體內容,這稱為深拷貝

/*拷貝賦值函式*/
inline String &String ::operator= (const String & str)
{
    if(this == &str)           //①
        return *this;
    delete[] m_data;        //②
    m_data = new char[strlen(str.m_data)+1];        //③
    strcpy(m_data,str.m_data);            //④
    return *this
}

這是拷貝賦值函式的經典實現,要點在於:

① 處理自我賦值,如果不存在自我賦值問題,繼續下列步驟:
② 釋放自身已經申請的記憶體
③ 申請一塊大小與目標字串一樣大的記憶體
④ 進行字串的拷貝

對於a = b,②③④過程如下:

同樣的,複製建構函式也是一個深拷貝的過程:

inline String ::String(const String & str )
{
    m_data = new char[ strlen (str) +1];
    strcpy(m_data,str.m_data);
}

另外,一定要在operator = 中檢查是否self assignment 假設這時候確實執行了物件的自我賦值,左右pointers指向同一個記憶體塊,前面的步驟②delete掉該記憶體塊造成下面的結果。當企圖對rhs的記憶體進行訪問是,結果是未定義的。

static與類

① 不和物件直接相關的資料,宣告為static

想象有一個銀行賬戶的類,每個人都可以開銀行賬戶。存在銀行利率這個成員變數,它不應該屬於物件,而應該屬於銀行這個類,由所有的使用者來共享。static修飾成員變數時,該成員變數放在程式的全域性區中,整個程式執行過程中只有該成員變數的一份副本。而普通的成員變數存在每個物件的記憶體中,若把銀行利率放在每個物件中,是浪費了記憶體。

② static成員函式沒有this指標

static成員函式與普通函式一樣,都是隻有一份函式的副本,儲存在程式的程式碼段上。不一樣的是,static成員函式沒有this指標,所以它不能夠呼叫普通的成員變數,只能呼叫static成員變數。普通成員函式的呼叫需要通過物件來呼叫,編譯器會把物件取地址,作為this指標的實參傳遞給成員函式:

obj.func() ---> Class :: fun(&obj);

而static成員函式即可以通過物件來呼叫,也可以通過類名稱來呼叫。

③在類的外部定義static成員變數

另一個問題是static成員變數的定義。static成員變數必須在類外部進行定義:

class A
{
    private:
        static int a; //①
}
int A::a = 10;  //②

注意①是宣告,②才是定義,定義為變數分配了記憶體。

④static與類的一些小應用

這些可以用來應付一下面試,在實現單例模式的時候,static成員函式與static成員變數得到了使用,下面是一種稱為”餓漢式“的單例模式的實現:

class A
{
        public:
            static A& getInstance();
            setup(){...};
        private:
            A();
            A(const A & rhs);
            static A a;
}

這裡把class A的建構函式都設定為私有,不允許使用者程式碼建立物件。要獲取物件例項需要通過介面getInstance。”餓漢式“缺點在於無論有沒有程式碼需要aa都被建立出來。下面是改進的單例模式,稱為”懶漢式“:

class A
{
    public: 
        static  A& getInstance();
        setup(){....};
    private:
        A();
        A(const A& rsh);
        ...
};
A& A::getInstance()
{
        static A a;
        return a;
}

“懶漢式”只有在真正需要a時,呼叫getInstance才建立出唯一例項。這可以看成一個具有拖延症的單例模式,不到最後關頭不幹活。很多設計都體現了這種拖延的思想,比如string的寫時複製,真正需要的時候才分配記憶體給string物件管理的字串。

相關文章