天天看點

C++:類和對象(下)1 再談構造函數2 static成員3 友元4 内部類5 匿名對象6 拷貝對象時的一些編譯器優化7 再次了解類和對象

文章目錄

  • 1 再談構造函數
    • 1.1 構造函數體指派
    • 1.2 初始化清單
    • 1.3 explicit關鍵字
  • 2 static成員
    • 2.1 概念
    • 2.2 特性
  • 3 友元
    • 3.1 友元函數(流插入(<<)及流提取(>>)運算符重載)
    • 3.2 友元類
  • 4 内部類
  • 5 匿名對象
  • 6 拷貝對象時的一些編譯器優化
  • 7 再次了解類和對象

1 再談構造函數

1.1 構造函數體指派

在建立對象時,編譯器通過調用構造函數,給對象中的各個成員變量一個合适的初始值。如下:

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

雖然上述構造函數調用後,對象中已經有了一個初始值,但是不能将其稱之為對對象中成員變量的初始化,構造函數體中的語句隻能将其稱為賦初值,而不能稱作初始化。因為初始化隻能初始化一次,而構造函數體内可以多次指派。

1.2 初始化清單

前言:

我們知道,當我們編寫一個類時,隻是對這個類整體進行了一個聲明,隻有當我們通過類的執行個體化建立出該類類型對象,才算是完成一個對象的定義,但實際上,這隻能算是對對象整體的定義,那其中的每個成員變量又是在什麼時候定義的呢?如下以一個類為例,這裡類中沒有主動編寫的構造函數,在定義對象時調用編譯器預設生成的構造函數。問題來了,當我們在類中增加const成員變量後,再運作程式會發現編譯錯誤,原因是作為内置類型成員變量,編譯器預設生成的構造函數不會對其進行處理,而const修飾的變量必須在定義的時候初始化(注意,在C++98時還不能在成員變量聲明時給預設值),此時直接定義對象,const成員變量沒有實作初始化,是以會發生編譯報錯。

class A {
private:
	int _a1;
	int _a2;
	//const int _x; //const變量必須在定義的時候初始化
	//const int _x = 0; //C++98不支援預設值
};

int main() {
	A aa; //對象整體的定義,每個成員變量什麼時候定義?
	return 0;
}
           

為了解決成員變量初始化的問題,C++引入了初始化清單的概念。

初始化清單:以一個

冒号:

開始,接着是一個以逗号分割的資料成員清單,每個成員變量後面跟一個放在

括号()

中的初始值或表達式。

我們可以了解為初始化清單是調用該構造函數的對象的每個成員變量定義的地方。

示例:

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

初始化清單的相關注意事項:

  • 拷貝構造函數也有初始化清單。
  • 每個成員變量在初始化清單中隻能出現一次(換句話說,初始化隻能初始一次)。如果某個成員變量沒有在初始化清單中,則再使用聲明時的預設值進行初始化,如果預設值也沒有,則以随機值初始化。
  • 類中如果包含以下成員,必須放在初始化清單位置進行初始化:
    • 引用成員變量(必須在定義時進行初始化)
    • 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 
};
           
  • 盡量使用初始化清單初始化,因為不管你是否使用初始化清單,類中每個成員變量都會先使用初始化清單進行初始化。對于内置類型成員變量,如果沒有顯示使用初始化清單,則以預設值進行初始化,如果預設值也沒有,則以随機值初始化;對于自定義類型成員,如果顯示使用了初始化清單,則調用其對應構造函數,如果沒有顯示使用初始化清單,則調用其預設構造函數,如果預設構造函數也沒有,則不能完成初始化,也就不能通過編譯 。

    示例:

class Time{
public:
	Time(int hour = 0)
		:_hour(hour)
	{
		cout << "Time()" << endl;
	}

private:
	int _hour;
};

class Date{
public:
	Date(int day)
	{}

private:
	int _day;
	Time _t;
};

int main()
{
	Date d(1);
}
//輸出:Time()
           
  • 成員變量在類中的聲明次序就是其在初始化清單中的初始化順序,與其在初始化清單中的先後次序無關。
class A{
public:
	A(int a)
		:_a1(a) //先定義_a1
		, _a2(_a1)
	{}

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

private:
	int _a2; //先聲明_a2
	int _a1;
};

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

//輸出:1 -858993460
//即按聲明順序進行初始化,在_a1在_a2之後初始化,而_a2又以_a1的值進行初始化,此時_a1還是随機值,是以_a2為随機值
           

1.3 explicit關鍵字

構造函數不僅可以構造與初始化對象,對于單個參數或者除第一個參數無預設值而其餘參數均有預設值的構造函數,還可以進行隐式類型轉換。而如果不想使其能進行隐式類型轉換,可以用關鍵字

explicit

修飾構造函數。

示例:

class A {
public:
	//單參構造函數 - 支援隐式類型轉換
	A(int a)
		:_a1(a)
	{
		cout << "A(int a)" << endl;
	}

	不支援隐式類型轉換
	//explicit A(int a)
	//	:_a1(a)
	//{
	//	cout << "A(int a)" << endl;
	//}

	//多參構造函數 - C++98不支援隐式類型轉換,C++11可以以{參數……}的方式支援
	A(int a1, int a2)
		:_a1(a1)
		,_a2(a2)
	{}

	不支援隐式類型轉換
	//explicit A(int a1, int a2)
	//	:_a1(a1)
	//	, _a2(a2)
	//{}

	A(const A& aa) 
		:_a1(aa._a1)
	{
		cout << "A(const A& aa)" << endl;
	}

private:
	int _a2;
	int _a1;
};

int main() {
	A aa1(1); //單參構造函數
	
	//下面語句發生了隐式類型轉換:通過建立一個臨時變量将1轉換成A類類型對象,再用臨時的對象拷貝建立aa2對象
	//按理來說,運作程式,下面語句會調用一次構造函數加一次拷貝構造函數,
	//但實際輸出表示隻調用了一次構造函數,這是因為編譯器進行了優化,直接用1構造了aa2對象
	A aa2 = 1; 
	
	//下面語句如果不加const,則無法通過編譯,而加了const則編譯通過,
	//正是因為隐式類型轉換産生了臨時對象,而臨時對象具有常屬性,
	//是以需要用const修飾才能進行引用,這也側面說明了隐式類型轉換的過程中産生了臨時變量
	const A& ref = 2;

	A aa3(1, 2);//多參構造函數
	A aa4 = { 1, 2 };//隐式類型轉換 - C++11支援,C++98不支援
	return 0;
}
           

2 static成員

2.1 概念

聲明為 static 的類成員稱為類的靜态成員,用 static 修飾的成員變量,稱之為靜态成員變量;用 static 修飾的成員函數,稱之為靜态成員函數。靜态成員變量一定要在類外進行初始化。

示例:要求實作一個類,計算程式中建立出了多少個類對象。

① 方法一:使用全局變量計數

錯誤示例:

#include <iostream>

using namespace std;

int count = 0;

class A {
public:
	//建立對象要麼使用構造函數,要麼使用拷貝構造函數
	A() { ++count; } //隻要調用構造函數就讓計數值加一

	A(const A& t) { ++count; } //調用拷貝構造函數也讓計數值加一

	~A() { --count; } //如果對象被析構,則計數值減一
};

int main() {
	cout << count << endl;
	A a1, a2;
	A a3(a1);
	cout << count << endl;
	return 0;
}
           

上述代碼運作後報錯,指出count是不明确的符号,這是因為在C++的

xutility(5263,45)

檔案中有個與 count 的同名函數

std::count(const _InIt,const _InIt,const _Ty &)

,而我們選擇将整個 std 命名空間展開,這就造成了命名沖突。基于此,可以做出以下修改:隻将用到 cout 及 endl 展開。

正确寫法:

#include <iostream>
using std::cout;
using std::endl;

int count = 0;

class A {
public:
	//建立對象要麼使用構造函數,要麼使用拷貝構造函數
	A() { ++count; } //隻要調用構造函數就讓計數值加一

	A(const A& t) { ++count; } //調用拷貝構造函數也讓計數值加一

	~A() { --count; } //如果對象被析構,則計數值減一
};

int main() {
	cout << count << endl;
	A a1, a2;
	A a3(a1);
	cout << count << endl;
	return 0;
}
           

可以看到,使用這種方法統計建立的對象要稍微複雜一些,且因為全局變量可以在任意位置被修改,是以存在安全隐患。

② 方法二:使用靜态成員變量計數

#include <iostream>

using namespace std;

class A{
public:
	//建立對象要麼使用構造函數,要麼使用拷貝構造函數
	A() { ++_scount; } //隻要調用構造函數就讓計數值加一

	A(const A & t) { ++_scount; } //調用拷貝構造函數也讓計數值加一

	~A() { --_scount; } //如果對象被析構,則計數值減一

	//受通路限定符限制,為了保證封裝性,提供GetACount()函數來擷取靜态成員變量值
	//靜态成員函數
	static int GetACount1() { return _scount; }

	//非靜态成員函數也可以調用靜态成員函數
	int GetACount2() { return A::GetACount1(); }

	//靜态成員函數隻有在包含類類型對象參數時,才能用對象調用非靜态成員函數
	static int GetACount3(A& a) { return a.GetACount2(); }

private:
	static int _scount;//靜态成員變量
};

int A::_scount = 0;//受類域限制,需以 類名:: 的方式通路

int main(){
	cout << A::GetACount1() << endl; // 類名::靜态成員方式調用
	A a1, a2;
	A a3(a1);
	cout << a3.GetACount1() << endl; // 對象.靜态成員方式調用
	A a4[10];//對象數組

	cout << a3.GetACount2() << endl;//非靜态成員函數調用靜态成員函數
	cout << A::GetACount3(a3) << endl;//靜态成員函數調用非靜态成員函數
	return 0;
}

//輸出:0 3 13 13
           

2.2 特性

  • 靜态成員為所有類對象所共享,不屬于某個具體的對象,存放在靜态區。
  • 靜态成員變量必須在類外定義,定義時不添加 static 關鍵字,類中隻是聲明。
  • 類靜态成員即可用

    類名::靜态成員

    或者

    對象.靜态成員

    的方式通路。
  • 靜态成員函數沒有隐藏的 this指針,不能通路任何非靜态成員。
  • 靜态成員也是類的成員,受public、protected、private通路限定符的限制。
  • 非靜态成員函數可以調用靜态成員函數,靜态成員函數隻有在包含類類型對象參數時采用通過對象調用非靜态成員函數。

3 友元

友元提供了一種突破封裝的方式,有時提供了便利。但是友元會增加耦合度,破壞了封裝,是以友元不建議過多使用。

友元分為:友元函數和友元類

3.1 友元函數(流插入(<<)及流提取(>>)運算符重載)

以下以實作對

流插入運算符>>

流提取運算符<<

重載為例說明友元函數使用:

以日期類為例,可以看到,當我們想輸出日期時,通常時會先編寫一個成員函數,然後通過對象去調用成員函數來輸出相應的年、月、日。我們知道,在C++中通常使用

cin >>

cout <<

進行内置類型的輸入輸出,那能不能也使用這種方式來對自定義類型對象進行輸入輸出呢?答案是當然可以。

通過 cplusplus 網站檢視,可以發現,實際上

cout

cin

分别是

ostream

istream

類型的對象,而之是以在C++中

cin >>

cout <<

輸入輸出可以自動識别類型,是因為在

istream

ostream

類中分别實作了的對

流提取運算符 >>

流插入運算符 <<

的重載。也就是說,如果我們也能實作對這兩個運算符的重載,那就可以采用

cin >>

cout <<

的方式進行自定義類型對象的輸入輸出了。

C++:類和對象(下)1 再談構造函數2 static成員3 友元4 内部類5 匿名對象6 拷貝對象時的一些編譯器優化7 再次了解類和對象
C++:類和對象(下)1 再談構造函數2 static成員3 友元4 内部類5 匿名對象6 拷貝對象時的一些編譯器優化7 再次了解類和對象

但這裡需要注意的是,該運算符重載是實作在

istream

ostream

這兩個類中的,而這兩個類是無法被修改的,是以我們隻能選擇在全局實作這兩個運算符的重載,或是在需要用到的該運算符的自己編寫的類中進行運算符重載。

考慮到為了能通路對象中的私有成員,我們通常會将運算符重載實作在對應的類中,如下:

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

	void operator<<(ostream& out) {
		out << _year << "-" << _month << "-" << _day << endl;
	}

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

int main() {
	Date d1(2023, 2, 1);
	//cout << d1; //編譯報錯
	d1 << cout; //輸出2023-2-1
	d1.operator<<(cout); //輸出2023-2-1
	return 0;
}
           

但運作程式發現:我們采用

cout << d1;

的方式輸出日期時會發生編譯報錯,而采用

d1 << cout;

d1.operator<<(cout);

的方式卻可以正常輸出。這是因為當我們在日期類中實作運算符重載時,預設第一個參數即為隐含參數

*this

,而對于雙目操作符來說,第一個參數即為左操作數。但是這樣的輸出方式與我們平時的輸出寫法有所差異,那怎麼能采用

cout << d1;

的方式進行輸出呢?

于是我們考慮将運算符重載實作在類外,這樣我們就可以主動使

ostream

類對象作為第一個參數,而日期類對象為第二個參數。但這樣還面臨一個問題,我們無法在類外通路私有成員,當然,我們也可以将成員變為公有,但這會失去封裝性,一般不建議這樣處理;此外,也可以通過設定對應的

Get_year()

等公有成員函數來在類外擷取私有成員;而還有一種方法則是使用

friend

修飾函數(即友元函數),可以了解為經過修飾後該函數成為了對應類的朋友,是以可以通路類中私有成員。此外,還有一點需要注意,我們平常使用的流插入運算符是可以連續使用的,也就是說,還運算符重載應該要有傳回值,傳回

ostream

類型對象的引用。如下所示:

class Date{
	//友元函數聲明
	friend ostream& operator<<(ostream& out, const Date& date);
	
public:
	Date(int year = 2023, int month = 1, int day = 1){
		_year = year;
		_month = month;
		_day = day;
	}

	//void operator<<(ostream& out) {
	//	out << _year << "-" << _month << "-" << _day << endl;
	//}

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

ostream& operator<<(ostream& out, const Date& date) {
	out << date._year << "-" << date._month << "-" << date._day << endl;
	return out;
}

int main() {
	Date d1(2023, 2, 1);
	Date d2(2023, 2, 7);
	cout << d1 << d2; 
	//輸出:
	//2023-2-1
	//2023-2-7
	return 0;
}
           

同樣的,接下來我們也可以實作流提取運算符重載如下:

class{
	friend istream& operator>>(istream& in, Date& date);
public:
	//其它方法
private:
	int _year;
	int _month;
	int _day;
};

istream& operator>>(istream& in, Date& date) {
	in >> date._year >> date._month >> date._day;
	return in;
}

int main() {
	Date d1;
	cin >> d1;
	cout << d1;
	return 0;
}
           

說明:

  • 友元函數可以直接通路類的私有和保護成員,但它不是類的成員函數,它是定義在類外部的普通函數,不屬于任何類,但需要在類的内部聲明,聲明時需要加

    friend

    關鍵字。
  • 友元函數不能用

    const

    修飾。因為 const 隻能修飾成員函數,更直接的說 const 是用來修飾 this指針 的,而友元函數沒有 this指針。
  • 友元函數可以在類定義的任何地方聲明,不受類通路限定符限制。
  • 一個函數可以是多個類的友元函數。
  • 友元函數的調用與普通函數的調用原理相同。

3.2 友元類

  • 友元類的所有成員函數都可以是另一個類的友元函數,都可以通路另一個類中的非公有成員。
  • 友元關系是單向的,不具有交換性。

    如下,在 Time 類中聲明 Date 類為其友元類,那麼可以在 Date 類中直接通路 Time 類中的私有成員變量,但想在 Time 類中通路 Date 類中的私有成員變量則不行。

class Time{
	friend class Date; //聲明日期類為時間類的友元類,則在日期類中就可直接通路Time類中的私有成員變量
public:
	Time(int hour = 0, int minute = 0, int second = 0)
		: _hour(hour)
		, _minute(minute)
		, _second(second)
	{}

private:
	int _hour;
	int _minute;
	int _second;
};

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

	void SetTimeOfDate(int hour, int minute, int second){
		//直接通路時間類私有的成員變量
		_t._hour = hour;
		_t._minute = minute;
		_t._second = second;
	}

private:
	int _year;
	int _month;
	int _day;
	Time _t;
};
           
  • 友元關系不能傳遞。 (如果 C 是 B 的友元,B 是 A 的友元,也不能說明 C 是 A 的友元。)
  • 友元關系不能繼承。

4 内部類

概念:如果一個類定義在另一個類的内部,這個類就叫做内部類。 内部類是一個獨立的類,它不屬于外部類,更不能通過外部類的對象去通路内部類的成員。外部類對内部類沒有任何優越通路權限。

注意:内部類就是外部類的友元類。 内部類可以通過外部類的對象參數來通路外部類中的所有成員。但外部類不是内部類的友元。

特性:

  • 内部類可以定義在外部類的public、protected、private任意位置。
  • 内部類可以直接通路外部類中的 static 成員,不需要外部類的對象/類名。
  • sizeof(外部類) = 外部類

    ,和内部類沒有任何關系。

示例:

class A {
private:
	static int k;
	int h;

public:
	class B { // B天生就是A的友元
	private:
		int _b = 2;

	public:
		void foo(const A& a) {
			cout << k << endl;//OK
			cout << a.h << endl;//OK
		}
	};
};

int A::k = 1;

int main() {
	A::B b;
	b.foo(A());
	cout << sizeof(A) << endl; //輸出:4,其中靜态成員變量存儲在靜态區
	return 0;
}
           

5 匿名對象

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

	~A(){
		cout << "~A()" << endl;
	}
private:
	int _a;
};

class Solution {
public:
	int Sum_Solution(int n) {
		//...
		return n;
	}
};

int main(){
	A aa1;

	// 不能這麼定義對象,因為編譯器無法識别下面是一個函數聲明,還是對象定義
	//A aa1();
	// 
	// 但是我們可以這麼定義匿名對象,匿名對象的特點不用取名字,
	// 但是他的生命周期隻有這一行,我們可以看到下一行他就會自動調用析構函數
	A();

	A aa2(2);
	// 匿名對象在這樣場景下就很好用
	Solution().Sum_Solution(10);
	return 0;
}
           

輸出結果:

C++:類和對象(下)1 再談構造函數2 static成員3 友元4 内部類5 匿名對象6 拷貝對象時的一些編譯器優化7 再次了解類和對象

6 拷貝對象時的一些編譯器優化

在傳參和傳傳回值的過程中,一般編譯器會做一些優化,減少對象的拷貝,在一些場景下還是非常有用的。

示例:

class A
{
public:
	//構造函數
	A(int a = 0)
		:_a(a)
	{
		cout << "A(int a)" << endl;
	}
	//拷貝構造函數
	A(const A& aa)
		:_a(aa._a)
	{
		cout << "A(const A& aa)" << endl;
	}
	//指派運算符重載
	A& operator=(const A& aa){
		cout << "A& operator=(const A& aa)" << endl;
		if (this != &aa)
		{
			_a = aa._a;
		}
		return *this;
	}

	~A(){
		cout << "~A()" << endl;
	}

private:
	int _a;
};

void f1(A aa)
{}

A f2(){
	A aa;
	return aa;
}

int main(){
	// 傳值傳參
	A aa1;
	f1(aa1);
	cout << endl;

	// 傳值傳回
	f2();
	cout << endl;

	// 隐式類型,連續構造+拷貝構造->優化為直接構造
	f1(1);
	// 一個表達式中,連續構造+拷貝構造->優化為一個構造
	f1(A(2));
	cout << endl;

	// 一個表達式中,連續拷貝構造+拷貝構造->優化為一個拷貝構造
	A aa2 = f2();
	cout << endl;

	// 一個表達式中,連續拷貝構造+指派重載->無法優化
	aa1 = f2();
	cout << endl;
	return 0;
}
           

結果輸出:

C++:類和對象(下)1 再談構造函數2 static成員3 友元4 内部類5 匿名對象6 拷貝對象時的一些編譯器優化7 再次了解類和對象

說明: 不同的編譯器的優化程度不同,從上述結果也可以看出,本文中所使用的VS2022的編譯器的優化程度比較大。對于函數 f2 來說,不優化的情況下應該是調用一個構造函數和一個拷貝構造函數,而這裡直接優化成了一次構造,顯然優化方式稍有些激進,因為函數 f2 中的對象構造和傳回是分開的,為了避免中間可能還有使用對象的地方,保守些的編譯器在此是不做優化的。

總結:

  • 關于對象傳回的總結:
    • 接收傳回值對象時,盡量使用拷貝構造方式接收,不要指派接收;
    • 函數中傳回對象時,盡量傳回匿名對象。
  • 關于函數傳參的總結:
    • 盡量使用

      const 類型&

      的方式傳參。

7 再次了解類和對象

現實生活中的實體,計算機并不認識,計算機隻認識二進制格式的資料。如果想要讓計算機認識現實生活中的實體,使用者必須通過某種面向對象的語言,對實體進行描述,然後通過編寫程式,建立對象後計算機才可以認識。比如想要讓計算機認識洗衣機,就需要:

  • 使用者先要對現實中洗衣機實體進行抽象 – 即在人為思想層面對洗衣機進行認識,洗衣機有什麼屬性,有哪些功能,即對洗衣機進行抽象認知的一個過程。
  • 經過上述步驟後,在人的頭腦中已經對洗衣機有了一個清晰的認識,隻不過此時計算機還不清楚,想要讓計算機識别人想象中的洗衣機,就需要人通過某種面向對象的語言(比如:C++、Java、Python等)将洗衣機用類來進行描述,并輸入到計算機中。
  • 經過上述步驟後,計算機中就有了一個洗衣機類,但洗衣機類隻是站在計算機的角度對洗衣機對象進行描述的,通過洗衣機類,可以執行個體化出一個個具體的洗衣機對象,此時計算機才能清楚洗衣機是什麼東西。
  • 接着使用者就可以借助計算機中的洗衣機對象,來模拟現實中的洗衣機實體了。

注意:類是對某一實體(對象)來進行描述的,描述該對象具有哪些屬性,哪些方法,描述完之後就形成了一種新的自定義類型,使用該自定義類型就可以執行個體化出具體的對象。

C++:類和對象(下)1 再談構造函數2 static成員3 友元4 内部類5 匿名對象6 拷貝對象時的一些編譯器優化7 再次了解類和對象

以上是我對C++中類和對象相關知識的一些學習記錄總結,如有錯誤,希望大家幫忙指正,也歡迎大家給予建議和讨論,謝謝!

繼續閱讀