C++ 類的記憶體分配是怎麼樣的?

ivanlee717發表於2024-03-24

dynamic_memory

首先透過一段程式碼來引入動態記憶體分配的主題。一個名為StringBad的類以及一個功能更強大的String類。

#include<iostream>
#ifndef STRNGBAD_H_
#define STRNGBAD_H_

class StringBad {
private:
	char* str;
	int len;
	static int num_strings;
public:
	StringBad(const char* s);
	StringBad();
	~StringBad();
	friend std::ostream& operator<<(std::ostream& os,
		const StringBad& st);
};

介紹一下這些定義,第一個char指標來表示一段字串,這就意味著類宣告沒有為字串本身分配儲存空間。而是要在建構函式里透過new來為字串分配空間,這就避免了在類宣告裡面預先定義字串長度。

num_strings這個東西被宣告為了靜態儲存類,靜態成員有一個特點:無論建立了多少物件,程式都只建立一個靜態類變數副本。也就是說,類的所有物件共享一個靜態成員,具體解釋可以看到下圖的程式碼執行結果。

image-20240320221019713image-20240320221204441

這對於所有類物件都具有相同值的類私有資料都是非常方便的,比如num_strings可以記錄所有建立的物件數目。

image-20240321133016710

#include<iostream>
#include<cstring>
/*#include <cstring>:
這是 C 語言標準庫中的標頭檔案,提供了一系列操作 C 字串(字元陣列)的函式。
在 C++ 中,<cstring> 標頭檔案中的函式都被放在 std 名稱空間中,並且可以使用 C 風格的字串處理函式,比如 strcpy, strcat, strlen 等。
示例用法:#include <cstring> 可以用來進行基於字元陣列的字串操作,如複製、連線、比較等。
#include <string>:
這是 C++ 標準庫中的標頭檔案,提供了 std::string 類及相關操作,是 C++ 中用來處理字串的首選方式。
<string> 標頭檔案中定義了字串類 std::string,提供了豐富的字串操作方法,比如字串拼接、查詢、替換等。
示例用法:#include <string> 可以用來定義和操作 C++ 標準庫中的字串物件,避免了使用 C 風格的字元陣列所帶來的問題。*/
#include "strngbad.h"
using std::cout;
int StringBad::num_strings = 0;

StringBad::StringBad(const char* s) {
	len = std::strlen(s);
	str = new char[len + 1];
	std::strcpy(str, s);//使用strcpy函式時,它會從源地址開始複製字元,
	//直到遇到字串結尾的null字元\0為止,所以可以透過指向字串的指標來操作字串
	num_strings++;
	cout << num_strings << ": \"" << str
		<< "\" object created\n";
}
StringBad::StringBad() {
	len = 4;
	str = new char[4];
	std::strcpy(str, "c++");
     num_strings++;
	cout << num_strings << ": \"" << str
		<< "\" object created\n";
}
StringBad::~StringBad() {
	cout << "\"" << str << "\" object deleted";
	cout << num_strings << " left \n";
	delete[]str;
}
std::ostream& operator<<(std::ostream& os, const StringBad& st) {
	os << st.str;
	return os;
}

這段程式碼就是對模板檔案的方法進行了定義,首先不能在類宣告中初始化靜態成員變數,因為宣告描述瞭如何分配記憶體,但並不分配記憶體。對於靜態類成員,可以在類外單獨初始化,因為靜態類成員是單獨儲存的,而不是物件的組成部分。

第一個建構函式里,類成員是指標,所以建構函式必須提供記憶體來儲存字串。然後將字串複製到記憶體裡。要理解這種方法,必須知道字串並不儲存在物件裡,字串單獨儲存在堆記憶體裡面,物件僅僅儲存了指出到哪裡去查詢字串的資訊。比如str = s這種語句只儲存了地址,而沒有建立副本。

在解構函式里面,str指向的是new分配的記憶體,當stringbad物件過期時,str指標也會過期。但是指向的記憶體仍然被分配,刪除物件可以釋放物件本身佔用的記憶體,但並不能自動釋放屬於物件成員的指標指向的記憶體。因此必須使用解構函式。

最後一個過載<<函式就是把 StringBad 物件中的 str 成員變數輸出到給定的輸出流 os 中,然後返回輸出流物件本身。這樣做的目的是為了支援鏈式輸出。

#define  _CRT_SECURE_NO_WARNINGS

#include<iostream>
#include "strngbad.h"
using std::cout;
void callme1(StringBad&);
void callme2(StringBad);

int main() {
	using std::endl;
	{
		cout << "Starting an inner block.\n" << endl;
		StringBad headline1("Regina in home");
		StringBad headline2("having sex");
		StringBad sport("Dance");
		cout << "headline1:" << headline1 << endl;
		cout << "headline2:" << headline2 << endl;
		cout << "sport:" << sport << endl;
		callme1(headline1);
		cout << "headline1:" << headline1 << endl;
		callme2(headline2);
		cout << "headline2:" << headline2 << endl;
		cout << "把一個物件初始化給另一個物件:\n";
		StringBad ss = sport;
		cout << "ss:" << ss << endl;
		cout << "用=號進行賦值:\n";
		StringBad regina;
		regina = headline1;
		cout << "regina:" << regina << endl;
	}
	cout << "Exit the main\n";
	return 0;
}
void callme1(StringBad & rsb) {
	cout << "透過引用傳遞的字串\n";
	cout << "     \"" << rsb << " \"\n";
}
void callme2(StringBad sb) {
	cout << "透過值傳遞的字串\n";
	cout << "     \"" << sb << " \"\n";
}
#define  _CRT_SECURE_NO_WARNINGS
#pragma warning(disable:4996)

首先說第一段程式碼是有殘缺的,這些缺陷使得輸出不確定,就像下面的輸出紅框一樣。就是因為strcpy在 StringBad::StringBad(const char* s)StringBad::StringBad() 建構函式中,使用 strcpy 函式來將源字串複製到 str 緩衝區中,如果源字串長度超過了 str 緩衝區的大小(len + 1),就會導致緩衝區溢位,可能引起程式崩潰或資料損壞

image-20240323110419545

上述第一個紅框裡的亂碼字元來自透過值傳遞的字串的函式callme2,因此在函式執行完畢返回時,會銷燬 callme2 函式內部的 headline2 物件,從而觸發該物件的解構函式的呼叫。

透過引用傳遞引數給函式時,不會觸發解構函式的原因在於引用本身並不擁有被引用物件的所有權,它只是對物件的一個別名或者引用。因此,當引用超出其作用域時,不會觸發被引用物件的解構函式。

解構函式的呼叫主要與物件的生命週期和所有權有關。當一個物件的所有權轉移或該物件的生命週期結束時,其解構函式會被呼叫以執行必要的清理操作。然而,透過引用傳遞並不改變物件的所有權,只是提供了對物件的訪問方式,因此不會觸發解構函式。

其實程式在執行時已經報錯image-20240323111107130

這條訊息通常意味著在使用堆記憶體(透過 newdelete 進行記憶體分配和釋放)時出現了問題,可能是由於記憶體洩漏、重複釋放已釋放的記憶體或者其他與堆記憶體操作相關的錯誤導致的。

實際上最後的num_string的值是-2,在《C++ primer Plus》裡面其實用Borland C++執行會成這樣,我沒有執行出來。

image-20240323131833684其實是因為StringBad ss = sports這句沒有呼叫預設的建構函式,也沒有呼叫有引數的建構函式,而是StringBad(const StringBad &)的一種複製建構函式,當用一個物件來初始化另一個物件時,編譯器將自動生成上述建構函式,這個函式我們沒有宣告過,不知道要更新靜態變數,所以實際有三次的已知建構函式+2次預設系統建構函式和5次解構函式。

新建一個物件並將其初始化為同類現有物件,複製建構函式都將被呼叫。最常見的將新物件顯式地初始化為現有物件。有下列四種情況:

  1. StringBad regina(ivan);
  2. StringBad regina = ivan;
  3. StringBad regina = StringBad(ivan);
  4. StringBad * regina = new StringBad(ivan);

每當程式生成了物件副本時,編譯器都將使用複製建構函式。當函式按值傳遞物件或函式返回物件時,都將使用複製建構函式。

所以如果按照剛剛的說法,我們顯式的定義一下複製建構函式

StringBad::StringBad(const StringBad& st) {
	num_strings++;  // 增加物件計數
}

image-20240323132932473

會發現隱式複製建構函式是按值進行復制,所以原函式里的功能相當於ss.str = sport.str;這裡複製的並不是字串,而是一個指向該字串的指標。我們過載了運算子<<,並且在解構函式中釋放時,ss.str將會被釋放,sport所對應的字串的記憶體將會消失。但是因為兩個物件指向了同一個位置的記憶體,第二次再呼叫delete的時候該記憶體已經沒有了,就會導致不確定性,可能會釋放掉其他位置。

所以我們將全面的顯式定義一下。

StringBad::StringBad(const StringBad& st) {
	len = st.len;  // 複製長度
	str = new char[len + 1];  // 分配新的記憶體
	std::strcpy(str, st.str);  // 執行復制
	num_strings++;  // 增加物件計數
	std::cout << num_strings << ": \"" << str
		<< "\" object created by copy\n";  // 輸出資訊
}

程式碼裡還有一個地方是StringBad regina; regina = headline1;這裡也呼叫了複製建構函式,賦值運算子的隱式實現也對成員進行逐個複製,如果成員本身就是物件,則程式將使用為這個類定義的賦值運算子來複制該成員。但是這樣做還有問題!也是和上面的問題一樣,regina和headline1指向同一個記憶體位置的str,不能無故刪除兩次。我們還需要對=符號進行一個過載。

StringBad& StringBad::operator=(const StringBad& st) {
	if (this == &st) {
		return *this;
		/*在賦值運算子過載函式中,
		返回*this意味著返回當前物件的引用。
		這樣做的目的是為了支援連續賦值操作,
		比如a = b = c。透過返回物件自身的引用,
		可以實現多重賦值操作的鏈式呼叫。*/
	}
	delete[] str;
	len = st.len;
	str = new char[len + 1];  // 分配新的記憶體
	std::strcpy(str, st.str);  // 執行復制
	return *this;
}

這段程式碼裡面的語法首先是檢查自我複製,這個對比的是地址,如果相同就返回本身的引用。如果不相同就和之前的複製操作一樣了。

image-20240323140210209

改進後的類

class NewString {
private:
	char* str;
	int len;
	static int num_strings;
	static const int CINLIM = 80;
public:
	NewString(const char* s);
	NewString();
	NewString(const NewString&);
	~NewString();
	int length() const { return len; }

	NewString& operator=(const NewString&);
	NewString& operator=(const char *);
	char& operator[](int i);
	const char& operator[](int i) const;
	friend bool operator<(const NewString& s1, const NewString& s2);
	friend bool operator>(const NewString& s1, const NewString& s2);
	friend bool operator==(const NewString& s1, const NewString& s2);
	friend ostream& operator<< (ostream & os, const NewString & ns);
	friend ostream& operator>> (ostream& is, NewString& ns);
};

這就是我們修改之後的新的類定義。我們可以看到新增了很多的方法以及符號過載,下列我會依次介紹每一種用法。

首先是預設的無引數建構函式

NewString::NewString() {
	len = 0;
	str = new char[1];
	str[0] = '\0';
    num_strings++;
}

為什麼不再是之前的寫法了,而是要開闢一個陣列空間。其實不加[1]兩種形式分配的記憶體量是相同的,區別在於前者和類解構函式相容,而後者不相容。解構函式為

NewString::~NewString() {
	--num_strings;
	delete[] str;
}

delete[]和 new[]初始化的指標和空指標都相容,直接將str寫成0 也代表了設定為空指標,和前面的程式碼功能相同,因為空指標通常用整數0來表示,因為0是一個特殊的地址值,代表著無效的記憶體地址。在C++11裡面,str=nullptr;的寫法也同樣表示空指標。


friend bool operator<(const NewString& s1, const NewString& s2);
friend bool operator>(const NewString& s1, const NewString& s2);
friend bool operator==(const NewString& s1, const NewString& s2);

bool operator<(const NewString& s1, const NewString& s2) {
	return (std::strcmp(s1.str, s2.str) < 0);
}

bool operator>(const NewString& s1, const NewString& s2) {
	return (std::strcmp(s1.str, s2.str) > 0);
}

bool operator==(const NewString& s1, const NewString& s2) {
	return (std::strcmp(s1.str, s2.str) == 0);
}

把運算子過載寫成友元函式,有助於將類物件和普通字串進行比較。不光是可以訪問類的私有靜態成員,還可以進行比較,假設regina是一個物件,則

"love" == regina & operator==("love",regina) & operator==(NewString("love"),regina)

都是成立的。


char& operator[](int i);
const char& operator[](int i) const;

實際的需求就是希望獲取字串的某一位時簡單一些,我們可以對[]進行過載。

char& NewString::operator[](int i) {
	return str[i];
}

這裡使用的是char&,主要目的是允許透過該函式返回的引用來修改呼叫物件內部儲存的字元陣列中的元素。當函式返回一個 char& 型別時,它實際上返回的是一個指向字元陣列中特定位置的引用。透過返回引用而不是值,可以直接在呼叫物件的字元陣列中進行讀寫操作,具體區別如下圖所示。

image-20240324135347878

image-20240324135421956

const char& operator[](int i) const;但是為什麼還有這句話呢,因為在示例裡的程式碼會用到const關鍵字使const A a("regina");裡面的a變成了常量物件,這種物件是無法被修改的,這和我們過載運算子的目的相違背,所以需要特意的寫一個常量的過載方法。

const char& NewString::operator[](int i) const{
	return str[i];
}
//第一個 const 關鍵字放在函式宣告或定義的最後表示該函式是一個常量成員函式,即在該函式內部不能修改物件的成員變數。
//第二個 const 關鍵字放在函式返回型別 char& 前面表示該函式返回一個常量引用,即返回值不能被修改。

可以將成員函式也宣告為靜態的(函式宣告必須包含static關鍵字,但如果函式定義是獨立的,則其中不能包含static)。因為首先不能透過物件呼叫靜態函式,實際上靜態成員函式甚至都不能呼叫this指標。如果靜態成員函式在公有部分宣告,則可以用類名和作用域解析符呼叫。(完整程式碼看最後)

其次由於靜態成員函式不與特定的物件相關聯,因此只能使用靜態資料成員。所以程式碼裡的Howmany函式只能使用num_strings。其他的私有成員都不能訪問。

靜態成員函式屬於類本身而不是類的例項,因此它們在不依賴於特定物件狀態的情況下執行。由於靜態成員函式不會自動獲取任何類例項的指標或引用,所以它們無法直接訪問非靜態資料成員或呼叫非靜態成員函式,這些成員和函式都是特定於類的物件的。


image-20240324213152875

這段程式碼裡面有一個delete操作,這個可以不加,但是由於目標物件可能引用了以前分配的資料,一般情況我們需要先釋放掉這個引用物件的str指向的記憶體,來為新字串分配足夠的記憶體。

程式碼

newString.h
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
using namespace std;
#ifndef NEWSTRING_H_
#define NEWSTRING_H_

class NewString {
private:
	char* str;
	int len;
	static int num_strings;
	static const int CINLIM = 80;
public:
	NewString(const char* s);
	NewString();
	NewString(const NewString&);
	~NewString();
	int length() const { return len; }

	NewString& operator=(const NewString&);
	NewString& operator=(const char *);
	char& operator[](int i);
	const char& operator[](int i) const;
	friend bool operator<(const NewString& s1, const NewString& s2);
	friend bool operator>(const NewString& s1, const NewString& s2);
	friend bool operator==(const NewString& s1, const NewString& s2);
	friend ostream& operator<< (ostream & os, const NewString & ns);
	friend istream& operator>> (istream& is, NewString& ns);

	static int HowMany();
};
#endif // !
#Newstring.cpp
#include "newString.h"
#include<cstring>

int NewString::num_strings = 0;
NewString::NewString() {
	len = 0;
	str = new char[1];
	str[0] = '\0';
	num_strings++;
}
NewString::~NewString() {
	--num_strings;
	delete[] str;
}

bool operator<(const NewString& s1, const NewString& s2) {
	return (std::strcmp(s1.str, s2.str) < 0);
}

bool operator>(const NewString& s1, const NewString& s2) {
	return (std::strcmp(s1.str, s2.str) > 0);
}

bool operator==(const NewString& s1, const NewString& s2) {
	return (std::strcmp(s1.str, s2.str) == 0);
}

char& NewString::operator[](int i) {
	return str[i];
}

const char& NewString::operator[](int i) const{
	return str[i];
}

int NewString::HowMany() {
	return num_strings;
}

NewString::NewString(const char* s) {
	len = std::strlen(s);
	str = new char[len + 1];
	std::strcpy(str, s);//使用strcpy函式時,它會從源地址開始複製字元,
	//直到遇到字串結尾的null字元\0為止,所以可以透過指向字串的指標來操作字串
	num_strings++;
}

NewString::NewString(const NewString& ns) {
	/*區分用=賦值的兩個例項指向同一個記憶體地址*/
	len = ns.len;
	str = new char[len + 1];
	std::strcpy(str, ns.str);
	num_strings++;
}

NewString& NewString::operator=(const NewString& ns) {
	if (this == &ns) {
		return *this;
	}
	delete[] str;
	len = ns.len;
	str = new char[len + 1];
	std::strcpy(str, ns.str);
}

NewString& NewString::operator=(const char * s) {
	/*NewString name;
	char tmp[40];
	cin.getline(tmp,40);
	name = tmp;這個過程會一直建立一個臨時物件,
	然後再呼叫解構函式刪除該物件,很低效*/
	delete[] str;
	len = std::strlen(s);
	str = new char[len + 1];
	std::strcpy(str, s);
    return *this;
}

ostream& operator<<(ostream& os, const NewString& ns) {
	os << ns.str;
	return os;
}

istream& operator>>(istream& is, NewString& ns) {
	char tmp[NewString::CINLIM];
	is.get(tmp, NewString::CINLIM);
	if (is) {
		ns = tmp;
	}
	while (is && is.get() != '\n') {
		continue;
	}
    /*NewString name; cin >> name; 直接可以實現輸入為str*/
}

這樣程式碼就完成了對於基本賦值和一些基礎運算子的最佳化,具體實現可以參照《C++ primer plus》裡面445頁的程式碼進行實現。

相關文章