@TOC


📝再談構造函數

🌠 構造函數體賦值

在創建對象時,編譯器通過調用構造函數,給對象中各個變量一個合適的初始值

class Date
{
public:
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;
	}
private:
	int _year;
	int _month;
	int _day;
};

雖然上面構造函數調用之後,對象中已經有了一個初始值,但是不能將其稱為對對象中成員變量的初始化,構造函數體中的語句只能將其稱為賦初值,這和我們之間常常説的給缺省值其實就是賦初值,而不能稱作初始化。因為初始化只能初始化一次,而構造函數體內可以多次賦值。

🌉初始化列表

初始化列表:以一個冒號開始,接着是一個逗號分隔的數據成員列表,每個“成員變量”後面跟一個放在括號的初始化或表達式

class Date
{
public:
	Date(int year,int month,int day)
		:_year(year)
		,_month(month)
		,_day(day)
	{
		//
	}

private:
	int _year;
	int _month;
	int _day;
};

為什麼要有初始化列表來賦初值,不能直接給缺省值,或者傳參嗎?

class Date
{
public:
	Date(int year, int month, int day)
	{
		_year = year;
		_month = month;
		_day = day;

		_x = 1;
	}

private:
	int _year;
	int _month;
	int _day;

	//必須在定義初始化
	const int _x;
};

由於const必須在定義時就要進行初始化,而這個在構造函數中_x=1的行為是賦值行為,不是初始化,因此const 修飾_x無法再賦值。引用&也是如此,需要在定義的時候並且進行初始化,不能分開。

初始化列表與explicit_初始化

因此對於普通的內置類型,普通成員變量都可以在函數體或者在初始化列表進行初始化,

int _year;
int _month;
int _day;

因為在這裏只是聲明,沒有定義,定義時實例化的時候完成的,而有些特殊的成員變量需要再定義的時候就初始化,而不是再通過賦值。

class Date
{
public:
    Date(int year, int month, int day, int& refDay)
        : ref(refDay)
        , _x(1)
    {
        _year = year;
        _month = month;
        _day = day;
    }

    void printDate()
    {
        std::cout << "Date: " << _year << "-" << _month << "-" << _day << std::endl;
        std::cout << "Reference day: " << ref << std::endl;
        std::cout << "Constant value: " << _x << std::endl;
    }

private:
    int _year;
    int _month;
    int _day;

    int& ref;
    const int _x;
};

int main()
{
    int someDay = 22;
    Date date1(2024, 4, 22, someDay);
    date1.printDate();
    return 0;
}

初始化列表與explicit_初始化_02

小知識:初始化和賦值之間的本質區別 初始化對象就是在對象創建的同時使用初值直接填充對象的內存單元,因此不會有數據類型轉換等中間過程,也就不會產生臨時對象;而賦值則是在對象創建好後任何時候都可以調用的而且可以多次調用的函數,由於它調用的是“=”運算符,因此可能需要進行類型轉換,即會產生臨時對象

但是類中包含以下成員,必須放在初始化列表位置進行初始化:

  • 引用&成員變量
  • const成員變量
  • 自定義類型成員(且該類沒有默認構造函數時)
class A
{
public:
	A(int a)
		:_a(a)
	{}
private:
	int _a;
};

class B
{
public:
	B(int a, int ref)
		:_aobj(a)
		, _ref(ref)
		, _n(10)
	{};

private:
	A _aobj;     // 沒有默認構造函數
	int& _ref;   //引用
	const int _n;//const
};

int main()
{
	int x = 10;
	B bb(20, x);
	return 0;
}
自定義類型成員(且該類沒有默認構造函數時)會發生錯誤

初始化列表與explicit_構造函數_03

這是按F11一步一步運行的順序:

初始化列表與explicit_初始化_04

這裏我們知道,對於 int、double、float 等內置類型的成員變量,如果沒有在初始化列表中顯式初始化,它們將被默認初始化,這個初始化編譯器可能會初始化為0,但是默認初始化他其實是未定義的,有可能為0,也有可能為隨機值。

對於自定義類類型的成員變量,如果沒有在初始化列表中顯式初始化,它們將使用該類的默認構造函數進行初始化。如果該類沒有提供默認構造函數,則會出現編譯錯誤。

我們知道_n和引用ref是通過初始化列表進行賦值的,因為是const和引用,只能在初始化列表初始化,但是而這些內置類型_year可以不使用初始化列表顯示賦值,他們先進行默認初始化,然後再在構造函數體內進行_year = year; _month = month; _day = day;等賦值操作,那在賦值之前,他們的值是未定義的--》

class Date
{
public:
    Date(int year, int month, int day, int& refDay)
        : ref(refDay)
        , _x(1)
    {
        _year = year;
        _month = month;
        _day = day;
    }

private:
    int _year;
    int _month;
    int _day;

    int& ref;
    const int _x;
};

而我們之前也學過一個在聲明時使用的一個操作:給缺省值:

初始化列表與explicit_構造函數_05

這裏是我們熟悉的給缺省值,我們可以看到當進入對象裏面時,我們先去找內置類型,然後給缺省值,當走完缺省值,他還會走一遍初始化列表,因為這上面沒有寫出初始化列表,那麼我們調試看不出來,自定義先去找他的構造函數,如果沒有就會報錯,因此自定義類型的盡頭還是內置類型,所以,這個缺省值是給初始化列表準備的,有缺省值,沒有初始化化列表,就用缺省值來初始化列表,那兩者都有呢,先走缺省值,然後再去按初始化列表,最終還是按照初始化列表來初始化。

總結一下就是:

  1. 初始化列表,不管你寫不寫,每個成員變量都會先走一遍
  2. 自定義類型的成員會調用默認構造(沒有默認構造就編譯錯誤)
  3. 內置類型有缺省值用缺省值,沒有的話,不確定,要看編譯器,有的編譯器會報錯
  4. 先走初始化列表 + 再走函數體實踐中:儘可能使用初始化列表初始化,不方便在使用函數體初始化

以下是調試代碼,可以動手試試哦:

typedef int DataType;
class Stack
{
public:
	Stack(size_t capacity = 4)
	{
		_array = (DataType*)malloc(sizeof(DataType) * capacity);
		if (NULL == _array)
		{
			perror("malloc申請空間失敗!!!");
			return;
		}
		_capacity = capacity;
		_size = 0;
	}

	void Push(DataType data)
	{
		// CheckCapacity();
		_array[_size] = data;
		_size++;
	}

	// 其他方法...
	~Stack()
	{
		if (_array)
		{
			free(_array);
			_array = NULL;
			_capacity = 0;
			_size = 0;
		}
	}

private:
	DataType* _array;
	int _capacity;
	int _size;
};



class MyQueue
{
public:
	// 初始化列表,不管你寫不寫,每個成員變量都會先走一遍
	// 自定義類型的成員會調用默認構造(沒有默認構造就編譯報錯)
	// 內置類型有缺省值用缺省值,沒有的話,不確定,要看編譯器,有的編譯器會處理,有的不會處理
	// 先走初始化列表 + 再走函數體
	// 實踐中:儘可能使用初始化列表初始化,不方便再使用函數體初始化
	MyQueue()
		:_size(1)
		, _ptr((int*)malloc(40))
	{
		memset(_ptr, 0, 40);
	}

private:
	// 聲明
	Stack _pushst;
	Stack _popst;

	// 缺省值  給初始化列表用的
	int _size = 0;
	const int _x = 10;

	int* _ptr;
};

int main()
{
	MyQueue q;

	return 0;
}

儘量使用初始化列表初始化,因為不管你是否使用初始化列表,對於自定義類型成員變量,一定會先使用初始化列表初始化。

初始化列表與explicit_構造函數_06


成員變量在類中聲明次序就是其在初始化列表中的初始化順序,與其在初始化列表中的先後次序無關看看這個代碼會出現什麼情況:

class A
{
public:
	A(int a)
		:_a1(a)
		,_a2(_a1)
	{}

	void Print()
	{
		cout << _a1 << " " << _a2 << endl;
	}
private:
	int _a2;
	int _a1;
};

int main()
{
	A aa(1);
	aa.Print();
}
A. 輸出1 1
B.程序崩潰
C.編譯不通過
D.輸出1 隨機值

正確答案是 D. 輸出1 隨機值。

在這個例子中, _a2_a1 之後聲明, 所以 _a2 會先被初始化。但在初始化 _a2 時, _a1 還沒有被初始化, 所以 _a2 會被初始化為一個隨機值。

Print() 函數被調用時, _a1 被正確初始化為 1, 但 _a2 被初始化為一個隨機值, 因此輸出結果會是 "1 隨機值"。

所以, 這個程序不會崩潰也不會編譯失敗, 只是輸出結果不是我們期望的。要解決這個問題, 可以調換 _a1_a2 在初始化列表中的順序, 或者在構造函數中手動初始化 _a2

修改後的代碼:

class A
{
public:
	A(int a)
		:_a1(a)
		,_a2(_a1)
	{}

	void Print()
	{
		cout << _a1 << " " << _a2 << endl;
	}
private:
	int _a1;
	int _a2;
};

int main()
{
	A aa(1);
	aa.Print();
}

運行截圖:

初始化列表與explicit_初始化列表_07

當使用成員初始化列表來初始化數據成員時,這些成員函數真正的初始化順序並不一定與你在初始化列表中為他們安排的順序一致,編譯器總是按照他們在類中聲明的次序來初始化的,因此,最好是按照他們的聲明順序來書寫成員初始化列表:

  1. 調用基類的構造函數,向他們傳遞參數
  2. 初始化本類的數據成員(包括成員對象的初始化)
  3. 在函數體內完成其他的初始化工作

🌉初始化列表效率

class A
{
	//...
	A(); //默認構造函數
	A(const A& d); //拷貝構造函數
	A& operator=(const A& d); //賦值函數
};

class B
{
public:
	B(const A& a); //B的成員對象
private:
	A m_a;     //成員對象
};

(1)採用初始化列表的方式初始化

B::B(const A& a) :m_a(a)
{
	...
}

(2)採用函數體內賦值的方式初始化

B::B(const A& a)
{
	m_a = a;
	...
}

本例第一種方式,類B的構造函數在其初始化列表裏調用了類A的拷貝構造函數,從而將成員對象 m_a初始化。本例第二種方式,類B的構造函數在函數體內用賦值的方式將成最對象a初始化。我們看到的只是一條賦值語句,但實際上 B 的構造函數幹了兩件事、先暗地裏創建m_a對象(調用了 A 的默認構造函數),再調用類A的賦值函數,才將參囊。賦給 m_a。顯然第一種方式的效率比第二種高。對於內部數據類型的數據成員而言,兩種初始化方式的效率幾乎沒有區別,

🌠隱式類型轉換

看看小類A:

class A
{
public:
	A(int a)
		:_a(a)
	{
		cout << "A(int a)" << endl;
	}

private:
	int _a;
};
int main()
{
	A aa1(1);
	//拷貝構造
	A aa2 = aa1;
	A aa3 = 3;
	return 0;
}

初始化列表與explicit_初始化列表_08

  • [ ] 這個代碼把1作為參數調用構造函數,創建aa1對象。
A aa1(1);
  • [ ] 在代碼中,這個代碼為啥能直接賦值?
A aa3 = 3;

在這個代碼中,A aa3 = 3;能夠直接賦值是因為發生了隱式類型轉換

A 類中,有一個接受 int 類型參數的構造函數 A(int a), 在 main() 函數中,A aa3 = 3; 是一個複製初始化的過程,編譯器在執行復制初始化時,會嘗試將右側的 3 隱式轉換為 A 類型,由於 A 類有一個接受 int 類型參數的構造函數,編譯器會自動調用這個構造函數,將 3 轉換為 A 類型的對象 aa3

初始化列表與explicit_初始化列表_09

🌉複製初始化

複製初始化(copy initialization)是 C++ 中一種常見的初始化方式,它指的是使用等號(=)來初始化一個變量。

複製初始化的過程如下:

  1. 首先,編譯器會嘗試將等號右側的表達式轉換為左側變量的類型。
  2. 如果轉換成功,則使用轉換後的值來初始化左側變量。
  3. 如果轉換失敗,則編譯器會嘗試調用類的拷貝構造函數來初始化左側變量。

例如:

A aa1(1); // 直接初始化
A aa2 = aa1; // 複製初始化,調用拷貝構造函數
A aa3 = 3; // 複製初始化,調用 A(int) 構造函數進行隱式轉換
  • A aa1(1) 是直接初始化,調用的是 A(int) 構造函數。
  • A aa2 = aa1 是複製初始化,調用的是拷貝構造函數。
  • A aa3 = 3 也是複製初始化,但是由於 A 類有一個接受 int 類型參數的構造函數,所以編譯器會自動將 3 轉換為 A 類型,然後調用該構造函數來初始化 aa3

編譯器遇到連續構造+拷貝構造->優化為直接構造,C++ 編譯器的一種常見優化技巧,稱為"構造+拷貝構造優化"。在某些情況下,編譯器可以識別出連續的構造和拷貝構造操作,並將其優化為單次直接構造。這種優化可以提高程序的性能,減少不必要的拷貝操作。

class A
{
public:
	A(int a)
		:_a(a)
	{
		cout << "A(int a)" << endl;
	}
	
	A(const A& aa)
		:_a(aa._a)
	{
		cout << "A(const A& aa)" << endl;
	}

private:
	int _a;
};
int main()
{
	A aa1(1);
	A aa3 = 3;

	return 0;
}

初始化列表與explicit_初始化列表_10

在語句 A aa3 = 3; 中,編譯器會進行優化,將連續的構造和拷貝構造操作優化為單次直接構造。

編譯器首先會調用 A(int a) 構造函數,使用字面量 3 創建一個臨時 A 對象,通常情況下,這個臨時對象應該被拷貝到 aa3 變量中。但是,聰明的編譯器可以識別出這種模式,並將其優化為直接在 aa3 變量的位置上構造一個 A 對象。因此,編譯器會直接調用 A(int a) 構造函數,在 aa3 變量的位置上構造一個 A 對象,省略了中間的拷貝步驟。所以,在這個例子中,輸出結果應該是:

A(int a)

只會輸出一次 A(int a),而不會輸出 A(const A& aa) 表示拷貝構造函數的調用。

這種優化技巧可以提高程序的性能,因為它減少了不必要的拷貝操作。編譯器會自動進行這種優化,開發者無需手動進行。這是 C++ 編譯器常見的一種性能優化手段。

因此編譯器遇到連續構造+拷貝構造->優化為直接構造

🌠單多參數構造函數

class A
{
public:
	A(int a)
		:_a(a)
	{
		cout << "A(int a)" << endl;
	}
	
private:
	int _a;
};
int main()
{
	A& raa = 3;

	return 0;
}

初始化列表與explicit_構造函數_11

在代碼 A& raa = 3; 中,編譯器無法進行隱式轉換,因為不能從 int 類型直接轉換為 A& 類型的引用。

這裏發生的問題是:

  1. 編譯器試圖將字面量 3 綁定到 A& 類型的引用 raa 上,因為3是常量,具有常性,相當於有了const,而我們知道從常性到正常無常性的轉換,不就等於權限的放大,權限的放大將不會發生轉換。

正確的做法應該是:

A aa(3);
A& raa = aa;

或者:

A aa = 3;
A& raa = aa;

在這兩種情況下,編譯器都能找到合適的構造函數來創建 A 對象,然後再將引用綁定到該對象上。

這樣寫的是對的,但是不方便,我們可以直接加const

const A& raa = 3;
//或者
const A& raa = aa(3);

初始化列表與explicit_初始化_12

此時此刻,兩行可以寫成一行,這下就方便了

class Stack
{
public:
	void Push(const A& aa)
	{
		//...
	}

	//...
};
int main()
{
	Stack st;

	A a1(1);
	st.Push(a1);

	A a2(2);
	st.Push(a2);
	//可以直接寫
	st.Push(2);
	st.Push(4);
	return 0;
}

或者聲明瞭一個名為lt的list,向列表中添加元素:

#include<vector>
#include<list>

int main()
{

	list<string> lt;
	// 第一種寫法:
	string s1("111");
	lt.push_back(s1);
	
	//第二種寫法
	lt.push_back("1111");

	return 0;
}

這是單參數構造函數,以下是多參數構造函數

//多參數構造函數
A(int a1, int a2)
	:_a(0)
	,_a1(a1)
	,_a2(a2)
{}

A aaa1(1, 2);
A aaa2 = { 1, 2 };
const A& aaa3 = { 1, 2 };

🌉explicit關鍵字

構造函數不僅可以構造與初始化對象,對於接收單個參數的構造函數,還具有類型轉換的作用。接收單個參數的構造函數具體表現:

  1. 構造函數只有一個參數
  2. 構造函數有多個參數,除第一個參數沒有默認值外,其餘參數都有默認值
  3. 全缺省構造函數
class Date
{
public:
	// 1. 單參構造函數,沒有使用explicit修飾,具有類型轉換作用
	// explicit修飾構造函數,禁止類型轉換---explicit去掉之後,代碼可以通過編譯
	explicit Date(int year)
		:_year(year)
	{}
	/*
	// 2. 雖然有多個參數,但是創建對象時後兩個參數可以不傳遞,沒有使用explicit修飾,具有類型轉
   換作用
	// explicit修飾構造函數,禁止類型轉換
	explicit Date(int year, int month = 1, int day = 1)
	: _year(year)
	, _month(month)
	, _day(day)
	{}
	*/
	Date& operator=(const Date& d)
	{
		if (this != &d)
		{
			_year = d._year;
			_month = d._month;
			_day = d._day;
		}
		return *this;
	}
private:
	int _year;
	int _month;
	int _day;
};
void Test()
{
	Date d1(2022);
	// 用一個整形變量給日期類型對象賦值
	// 實際編譯器背後會用2023構造一個無名對象,最後用無名對象給d1對象進行賦值
	d1 = 2023;
	// 將1屏蔽掉,2放開時則編譯失敗,因為explicit修飾構造函數,禁止了單參構造函數類型轉換的作
	用
}

上述代碼可讀性不是很好,用explicit修飾構造函數,將會禁止構造函數的隱式轉換。