1. 程式人生 > 其它 >《深度探索C++物件模型》讀書筆記-第六章

《深度探索C++物件模型》讀書筆記-第六章

技術標籤:深度探索C++物件模型c++

第六章 執行期語意學

一、物件的構造與解構

1.全域性物件

Matrix identity;
main()
{
    //identity必須在此處被初始化
    Matrix m1=identity;
    ...
    return 0;
}

C++保證,一定會在main()函式中第一次用到identity之前,把identity構造出來,而在main()函式結束之前把identity摧毀掉。像identity這樣的所謂全域性物件,如果有建構函式和解構函式的話,就說它需要靜態的初始化操作和記憶體釋放操作。

C++程式中所有的全域性物件都被放置在程式的data segment中,如果明確指定給它一個值,object將以該值為初值。否則object所配置到的記憶體內容為0。

2.區域性靜態物件

const Matrix&
identity(){
    static Matrix mat_identity;
    //...
    return mat_identity;
}

以上程式碼中,無論函式identity()被呼叫多少次,區域性靜態變數mat_identity的建構函式和解構函式都只施行一次。

1)靜態區域性變數存放在記憶體的全域性資料區函式結束時,靜態區域性變數不會消失,每次該函式呼叫時,也不會為其重新分配空間。它始終駐留在全域性資料區,直到程式執行結束。

2)靜態區域性變數的初始化與全域性變數類似.如果不為其顯式初始化,則C++自動為其 初始化為0。

4)靜態區域性變數與全域性變數共享全域性資料區但靜態區域性變數只在定義它的函式中可見

5)靜態區域性變數與區域性變數在儲存位置上不同,使得其存在的時限也不同導致對這兩者操作的執行結果也不同

3.物件陣列

陣列其實也可以容納更復雜的資料型別,比如程式設計師定義的結構或物件。這一切所需的條件就是,每個元素都擁有相同型別的結構或同一類的物件。

比如陣列

Point knot[10];

關於物件陣列的7個要點:

1)陣列的元素可以是物件。

2)如果在建立物件陣列時未使用初始化列表,則會為陣列中的每個物件呼叫預設建構函式。

3)沒有必要讓陣列中的所有物件都使用相同的建構函式。

4)如果在建立物件陣列時使用初始化列表,則將根據所使用引數的數量和型別為每個物件呼叫正確的建構函式。

5)如果建構函式需要多個引數,則初始化項必須釆用建構函式呼叫的形式。

6)如果列表中的初始化項呼叫少於陣列中的物件,則將為所有剩餘的物件呼叫預設建構函式。

7)最好總是提供一個預設的建構函式。如果沒有,則必須確保為陣列中的每個物件提供一個初始化項。

二、new和delete

此部分參考https://blog.csdn.net/passion_wu128/article/details/38966581

new

new操作針對資料型別的處理,分為兩種情況:

int *p=new int;
int *p=new int(4);//指定初值

1.簡單資料型別(包括基本資料型別和不需要建構函式的型別)

簡單型別直接呼叫operator new分配記憶體;

可以通過new_handler來處理new失敗的情況;

new分類失敗的時候不像malloc那樣返回NULL,它直接丟擲異常。要判斷是否分配成功應該用異常捕獲的機制

2.複雜資料型別(需要由建構函式初始化物件)

class Object
{
  public:
     Object()
     {
          _val=1;
      }
      ~Object()
      {
      }
private:
    int _val;
}
void main()
{
    Object *p= new Object();
}

new複雜資料型別的時候先呼叫operator new,然後在分配的記憶體上呼叫建構函式。

3.new陣列

new[]也分為兩種情況

簡單資料型別(包括基本資料型別和不需要解構函式的型別)

new[]呼叫的是operator new[],計算出陣列總大小之後呼叫operator new.

可以通過()初始化陣列為零值,例如

char *p = new char[32];
等同於
char *p = new char[32];
memset(p,32,0);

針對簡單型別,new[]計算好大小後呼叫operator new.

複雜資料型別(需要由解構函式銷燬物件)


class Object
{
public:
	Object()
	{
		_val = 1;
	}
 
	~Object()
	{
		cout << "destroy object" << endl;
	}
private:
	int _val;
};
 
void main()
{
	Object* p = new Object[3];
}

ew[]先呼叫operator new[]分配記憶體,然後在p的前四個位元組寫入陣列大小,最後呼叫三次建構函式。

實際分配的記憶體塊如下:

這裡為什麼要寫入陣列大小呢?因為物件析構時不得不用這個值,舉個例子:

當我們在main()函式最後中加上

delete[] p;

釋放記憶體之前會呼叫每個物件的解構函式。但是編譯器並不知道p實際所指物件的大小。如果沒有儲存陣列大小,編譯器如何知道該把p所指的記憶體分為幾次來呼叫解構函式呢?

總結:

針對複雜型別,new[]會額外儲存陣列大小。

delete

delete也分為兩種情況:

簡單資料型別包括基本資料型別和不需要解構函式的型別)。

int *p = new int(1);
delete p;

delete簡單資料型別預設只是呼叫free函式。

複雜資料型別

class Object
{
public:
	Object()
	{
		_val = 1;
	}
 
	~Object()
	{
		cout << "destroy object" << endl;
	}
private:
	int _val;
};
 
void main()
{
	Object* p = new Object;
	delete p;
}

delete複雜資料型別先呼叫解構函式再呼叫operator delete

delete[]也分為兩種情況

簡單資料型別包括基本資料型別和不需要解構函式的型別)。

delete和delete[]效果一樣

比如下面的程式碼:

int* pint = new int[32];
delete pint;
 
char* pch = new char[32];
delete pch;
執行後不會有什麼問題,記憶體也能完成的被釋放。看下彙編碼就知道operator delete[]就是簡單的呼叫operator delete。

總結:

針對簡單型別,delete和delete[]等同。

複雜資料型別(需要由解構函式銷燬物件)

釋放記憶體之前會先呼叫每個物件的解構函式。

new[]分配的記憶體只能由delete[]釋放。如果由delete釋放會崩潰,為什麼會崩潰呢?

假設指標p指向new[]分配的記憶體。因為要4位元組儲存陣列大小,實際分配的記憶體地址為[p-4],系統記錄的也是這個地址。delete[]實際釋放的就是p-4指向的記憶體。而delete會直接釋放p指向的記憶體,這個記憶體根本沒有被系統記錄,所以會崩潰。

總結:

針對複雜型別,new[]出來的記憶體只能由delete[]釋放。

三、臨時性物件

有三種常見的臨時物件建立的情況

  • 以值的方式給函式傳參
  • 型別轉換
  • 函式需要返回物件時

以下內容來自:http://blog.leanote.com/post/gaunthan/C-%E5%B0%BD%E9%87%8F%E9%81%BF%E5%85%8D%E4%B8%B4%E6%97%B6%E5%AF%B9%E8%B1%A1

從一個例子出發

下面的程式碼你能找出幾個不必要的臨時物件?

string FindAddr(list<Employee> emps, string name)
{
for(list<Employee>::iteraotr i = emps.begin();
i != emps.end(); i++) {
if(*i == name)
return i->addr;
}
return "";
}

無論你是否相信,在上面這個短短的函式中存在著三個明顯的,以及兩個不太明顯的不必要的臨時物件,還有兩處可能會迷惑你的地方。

以 const 引用傳遞物件引數

在函式的宣告語句中有兩個明顯的臨時物件:

string FindAddr(list<Employee> emps, string name)

這些引數應該通過 const & 的方式來傳遞,而不應該通過傳值方式。傳值方式將會使編譯器建立這兩個引數物件的完全副本,而這種做法非常昂貴,而且完全沒有必要。

在傳遞物件引數時,選擇 const & 方式而不是傳值方式。

快取不變數而不是重新構造

第三個臨時物件是在 for 迴圈的條件判斷語句中,這個臨時物件比前面兩個更明顯,而且同樣是可以避免的:

for(/*..*/; i != emps.end(); /*..*/)

對於大部分的容器(包括連結串列)而言,呼叫容器的end()函式將返回一個臨時物件,這個物件需要被構造和析構。由於這個臨時物件的值在迴圈中是不會改變的,因此如果在每次迴圈迭代中都重新進行計算(包括重新構造和重新析構),都將會導致不必要的低效,而且程式碼也不夠乾淨利落。實際上,這個臨時物件的值只需計算一次,將其儲存在一個區域性物件中,之後重複使用即可。

對於程式執行中不會改變的值,應該預先計算並儲存起來備用,而不是重複地建立物件,這是沒有必要的。

優先選擇字首遞增

接下來再考慮一下在for迴圈中i的遞增方式:

for(/*...*/; i++)

這個臨時物件並不是很明顯。通常,後置遞增的運算效率要低於前置遞增,因為字尾遞增必須記錄和返回運算元的初始值。通常為了保持一致性,使用前置遞增來實現後置遞增,看起來像下面這樣:

const T T::operator++(int)
{
T old(*this); // 記錄初始值
++*this; // 使用前置遞增
return old; // 返回記錄的初始值
}

現在,就很容易理解為什麼後置遞增的運算效率要低於前置遞增了。在後置遞增運算中除了必須完成與前置遞增相同的所有工作外,還必須構造和返回一個包含初始值的臨時物件。

通常,為了保持一致性,應該使用前置遞增來實現後置遞增。
優先選擇使用前置遞增。只有在需要初始值時,才使用後置遞增。

在上述問題的程式碼中,初始值永遠都用不到,因此也就沒有必要使用後置遞增,而應該使用前置遞增。

編譯器何時優化後置遞增

也許你會認為編譯器會對上面那種沒有使用初始值的後置遞增進行優化。然而編譯器通常都不會這樣做。只有當運算元的型別是內建型別或者標準型別,比如 int 和 complex,編譯器才會將後置遞增改寫為前置遞增以進行優化,因為編譯器知道這些標準型別的語義。

而對於我們自己建立的型別,編譯器不可能知道前置遞增和後置遞增的實際語義——實際上,這兩個運算所執行的操作可能確實不同。不過,如果這兩個運算的語義不同,那將是一件非常可怕的事情。

有一種方法可以讓編譯器知道在一個類中前置遞增和後置遞增之間的關係:用標準的形式來實現後置遞增,即在後置遞增函式中呼叫前置遞增,並使用inline來聲明後置遞增函式,這樣編譯器就能跨越函式邊界來檢測未被使用的臨時物件(這要求編譯器支援inline指令)。然而inline並不是萬能的,它有可能會被編譯器忽略,並在其他一些情況下帶來更為緊密的耦合。

更好的解決方案就是養成一種習慣:如果不需要初始值,那麼就使用前置遞增,這樣就不需要使用上面的優化措施了。

注意隱式轉換中的臨時物件

再看看 if 條件語句:

if(*i == name)
...

雖然我們沒有給出 Employee 類的定義,但還是可以推斷出這個類的一些資訊。為了使上面的程式碼能夠執行,在 Employee 類中很可能有一個將 Employee 轉換為 string 的函式,或者有一個帶有 string 引數的型別轉換建構函式。即要麼轉換*i為 string 臨時物件,要麼以name為實參構造一個 Employee 臨時物件。

在這兩種情況中都會建立一個臨時物件,並在這個臨時物件上呼叫 string 的operator==(),或者呼叫 Employee 的operator==()。只有當存在一個同時帶有 Employee 引數和 string 引數的operator==,或者 Employee 能夠被轉換為引用型別,即 string & 時,才不會生成臨時物件。

在進行隱式轉換時,要注意在轉換過程中建立的臨時物件。要避免這個問題,一個好辦法就是儘可能地通過顯式的方式來構造物件,並避免編寫型別轉換運算子。

單入/單出更好嗎

程式碼中有兩處返回語句:

return i->addr;
...
return "";

這是第一個可能迷糊你的地方。這兩條語句確實都建立了臨時的 string 物件,但是這些臨時物件是無法避免的。你也許會想使用單入/單出(single-entry/single-exit)的程式設計方式,即在函式中宣告一個區域性的 string 物件來儲存返回值,這樣只需一句 return 語句:

string ret;
...
ret = i->addr;
break;
...
return ret;

這種單入/單出的方式通常可以提高程式碼的可讀性(而且有時也能使程式執行得更快),但這種做法的表現很大程度上取決於實際的程式碼和編譯器。因為還附加了 string 的賦值運算子函式的呼叫開銷。具體的表現得在你使用的編譯器上做測試。但是一般來說,“兩條 return 語句”的函式表現得更加良好。

絕不返回區域性物件的控制代碼

確實有一個方法能夠避免返回語句使用的臨時物件,那就是宣告一個靜態區域性物件,並返回這個物件的控制代碼(引用或指標)。但是,將區域性物件定義為靜態的,將導致函式不可重入,這意味著在多執行緒環境中它幾乎肯定是一個大問題。此外,返回區域性物件的引用,不用多說,呼叫者總是會在該物件已經銷燬後還使用它(通過這個函式返回的控制代碼),而這一般都會引起記憶體故障,更壞的情況是程式“正常”執行下去······

記住物件的生存期。永遠都不要返回指向區域性物件的指標或引用,它們沒有任何用處,因為主調程式碼無法跟蹤它們的有效性,但卻可能會試圖這麼做。