CPP遊戲攻略02

前言

上一篇攻略中,我們已經充分理解了不帶指針的類的設計原則,並且還從標準庫設計大師的作品裏收穫了不少功力。而這一篇攻略,將繼續完成基於對象的類的關卡,解決這一關的最後一個問題,那就是帶指針的類。在這途中,我們將會遇到知名的 BIG THREE ,剖析 BIG THREE 的設計原則!

帶指針的類

一個帶指針的類,我們可以想到很多,但是 String 類一定是一個最經典的例子。在這裏,我們自己設計一個 String 類,看看帶指針的類在設計過程中到底需要注意什麼情況。source code

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;
};

一些人可能會問,這個類的實現也可以不使用指針,直接聲明一個數組來存儲字符不就行了麼?使用數組當然可以,不過這樣的設計很low,比較明顯的一個理由就是我們在一開始並不知道字符串的長度多長,太少則浪費空間,太長則導致溢出。在這種情況下,使用指針是非常好而且非常普遍的,在32位的操作系統中,一個指針的大小是4個字節,因此一個 String 對象的大小十分小,對於效率的提升非常大。

BIG THREE

我們常說的 BIG THREE 就是如下三個:

  String(const String &str);
  String& operator = (const String &str);
  ~String();

分別是拷貝構造函數、拷貝賦值函數和析構函數。那麼問題來了,在上一篇攻略中,我們的 complex 類爲什麼沒有 BIG THREE 呢?其實也有的,只是我們沒有顯式地寫出來,而是使用的編譯器提供的 BIG THREE。以下我們逐一分析爲什麼要使用它們,爲什麼不能使用默認的 BIG THREE。

接下來我們考慮使用者會怎麼使用我們的 String 類。首先能夠想到的就是構造函數,這部分同上一篇攻略類似,考慮下參數、默認參數以及初始化列表的問題:

String::String(const char *cstr)
{
  if(cstr) {
    m_data = new char[strlen(cstr) + 1];
    strcpy(m_data, cstr);
  } else {
    m_data = new char[1];
    *m_data = '\0';
  }
}

對於一個帶指針的類,最爲麻煩,而且也是最需要關注的點就是如何進行賦值,賦值的方法有如下幾種:

String s1("hello");
String s2(s1);
String s3 = s1;
s3 = s1;

對於

String s2(s1);

s2是第一次出現,因此會調用構造函數,而參數是 String 對象,因此會調用 BIG THREE 的第一個函數,拷貝構造函數。

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

拷貝構造函數也是構造函數,它的特徵非常明顯,就是參數const String &str爲同類對象,這個函數比較簡單,我們就不多嘴了。一些朋友可能會對下面這個等式產生疑問,

String s3 = s1;

其實這個用法和String s3(s1);完全相同,同樣也是調用拷貝構造函數。那麼我們爲什麼需要手寫,而不能使用默認的拷貝構造函數呢?這是由指針導致的。一個默認的拷貝構造函數大概長這樣:

String::String(const String &str)
{
  m_data = str.m_data;
}

這樣的構造函數會造成大麻煩,這涉及到深淺拷貝的問題。默認的拷貝函數是淺拷貝:
cpp01
s1和s2都指向同一份字符串,當這份字符串被釋放了,就會導致野指針問題,但是這並不是我們想要的,我們需要的是深拷貝:
cpp02

接下來是

s3 = s1;

這種情況的賦值是操作在兩個已存在的對象上,這就不關構造函數任何事了。我們同樣需要s3和s1指向兩份不同的字符串,而默認的拷貝賦值函數會是這樣的:

String& String::operator=(const String& str)
{
  this.m_data = str.m_data;
  return *this;
}

這樣造成的後果就是,兩個對象指向的還是同一份字符串,這是很不好的,因此我們必須手寫拷貝賦值函數,也就是操作符重載函數:

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;
}

我們的函數首先判斷是不是自我賦值s1 = s1;,如果是自我賦值,那麼直接返回自己。一般情況下,我們必須首先殺死自己,然後再重新分配一片內存,再將字符填進去。這樣就完成了我們的賦值函數。

最後是我們的析構函數函數,同樣地,我們不使用默認的析構函數,而是自己寫一個析構函數,那是因爲默認的析構函數只會回首指針佔用的4個字節的空間,而指針指向的空間卻沒有回收,導致內存泄漏:
cpp03
因此我們必須要手動回收字符串佔有的空間,寫出我們的析構函數:

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

總結

這一篇攻略我們討論了拷貝構造函數、拷貝賦值函數和析構函數,以及默認的 BIG THREE 。在帶指針的類中,我們必須要手寫 BIG THREE ,這樣做的原因在於編譯器默認給我們的拷貝賦值的方法是淺拷貝的方法,而我們需要深拷貝。在對象結束生命時,我們需要手動回收分配的空間,以免造成內存泄漏。

這一篇攻略就到此爲止,接下來我們將深度分析分配空間的種種問題,包括 new delete 操作以及內存空間管理的淺層問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章