1. 程式人生 > 其它 >1.C++入門基礎(上)

1.C++入門基礎(上)

2022-04-29-

摘要

C++關鍵字

名稱空間

預設引數

函式過載

extern“C”

引用

總結

目錄

目錄

C++關鍵字

C++關鍵字全集(參考 C++ Primer ):

asm auto
bad _cast bad _typeid
bool break case catch
char class const const _cast
continue default delete do
double dynamic _cast else enum
except explicit extern false
finally float for friend
goto if inline int
long mutable namespace new
operator private protected public
register
reinterpret _cast return short
signed sizeof static static _cast
struct switch template this
throw true try type _info
typedef typeid typename union
unsigned using virtual void
volatile wchar_t while

名稱空間

在C/C++中,變數、函式和後面要學到的類都是大量存在的,這些變數、函式和類的名稱將都存在於全域性作用域中,可能會導致很多衝突。使用名稱空間的目的是對識別符號的名稱進行本地化,以避免命名衝突或名字汙染,namespace關鍵字的出現就是針對這種問題的,std:c++標準庫的名稱空間。

在C語言中我們如果使用了同一個識別符號定義了不同的函式或者是變數,會導致它們之間產生衝突,而C++為了解決這個問題,引入了名稱空間的概念,不同名稱空間的成員佔有不同的記憶體空間,即使名稱相同,但相互之間並不會受到影響。因此在C++中,庫函式也是被定義在名稱空間中的。

例如:C語言的標頭檔案包含通常是 #include<xxx.h>,包含後我們便可以直接使用庫函式,而在C++中我們的標頭檔案通常是: #include<xxx>,並且無法直接使用庫函式,必須要指定名稱空間std才能使用。

不過C++相容C幾乎所以語法的,因此我們可以在C++中穿插C的程式碼,不過有一些混用是很容易出錯的,要小心並且正確的使用。

名稱空間定義


定義名稱空間需要使用namespace關鍵字,後面跟上要定義的名稱空間的名字,將名稱空間成員定義在後面的{}內即可,類似於結構體和類的定義方式。

名稱空間內可以定義變數,函式,型別,使用名稱空間的型別定義出的變數不屬於名稱空間。

一般的名稱空間定義方式

namespace sx
{
	int a;
	void swap(int& a, int& b)
	{
		cout << " namespace:sx " << endl;
		int tmp = a;
		a = b;
		b = tmp;
	}
	struct Stu
	{
		char name[10];
		int age;
	};
}

名稱空間的巢狀定義

namespace sx
{
	int a;
	void swap(int& a, int& b)
	{
		cout << " namespace:sx " << endl;
		int tmp = a;
		a = b;
		b = tmp;
	}
	struct Stu
	{
		char name[10];
		int age;
	};
	namespace psm
	{
		int b;
		void print()
		{
			cout << "hello psm!" << endl;
		}
	}
}

同一個工程中允許存在多個相同名稱的名稱空間,編譯器最後會合併成同一個名稱空間中

namespace n1
{
	int a;
	void swap(int& a, int& b)
	{
		cout << "n1:swap" << endl;
	}
}
namespace n2
{
	int b;
}
namespace n1
{
	int c;
	void swap(int& a, int& b)
	{
		cout << "n1:swap" << endl;
	}
}

像這樣子去定義編譯時會報錯:

函式“void n1::swap(int &,int &)”已有主體

刪除其中一個即可正常編譯,由此可見編譯時兩個名字相同的名稱空間會合並,如果有重複的定義則會報錯。

注意:一個名稱空間就定義了一個新的作用域,名稱空間中的所有內容都侷限於該名稱空間中。

名稱空間的使用


定義在名稱空間中的變數、型別以及函式我們是無法直接使用的,由於名稱空間就定義了一個新的作用域,而程式中預設是隻使用兩個作用域的內容的:

  1. 全域性作用域
  2. 區域性作用域

並且根據區域性優先原則會程式會先檢索當前作用域的內容,如果沒有找到我們需要的,再到全域性域去檢索,所以預設情況下我們所定義的名稱空間的作用域的內容我們是無法直接訪問的。

比如:

namespace sx
{
	int a;
	void swap(int& a, int& b)
	{
		cout << " namespace:sx " << endl;
		int tmp = a;
		a = b;
		b = tmp;
	}
	struct Stu
	{
		char name[10];
		int age;
	};
}
int main()
{
	cout << a << endl;//該語句編譯出錯,無法識別a
	return 0;
}

報錯:“a”: 未宣告的識別符號

名稱空間的使用方式有三種:

  • 加名稱空間名稱及作用域限定符
int main()
{
	cout << sx::a << endl;//指定使用在sx這個名稱空間中的a
	return 0;
}

作用域限定符是臨時的,因此每次使用時都需要加名稱空間和作用域限定符(限定符限定的是成員的名稱,因此限定符應緊挨著在成員名稱的前面,例如:sx::Stu s

  • 使用using宣告名稱空間中的成員,宣告我們可以不加限定符使用此成員
using sx::a;//指定地將sx中的a引入

int main()
{
	cout << a << endl;	//可以使用a
    Stu s;				//無法使用Stu型別
	return 0;
}

這樣只能使用指定使用我們需要的成員,並且宣告時指定的應是成員的名稱(變數名嗎,函式名,型別名)。

  • 使用using namespace 將名稱空間中的成員引入至全域性域
using namespace sx;

int main()
{
	cout << a << endl;
	Stu s;
	return 0;
}

這種方法會將名稱空間的所有成員一次性引入,可以直接訪問其中所有的成員,平時我們可以這樣使用,但是著違背了名稱空間誕生的初衷,容易產生命名衝突的問題,因此在工程中通常是使用第一種或者第二種方法。

如何證明名稱空間是被引入至全域性域的呢?

int a = 1;
using namespace sx;

int main()
{
	cout << a << endl;//“a”: 不明確的符號
	return 0;
}

using namespace sx;

int main()
{
	int a = 1;
	cout << a << endl;
	return 0;
}

第一個程式提示錯誤,而第二個程式正常執行。

引入至全域性域後我們的程式即可在全域性域中找到定義在名稱空間sx中的變數 a ,而我們本身又在全域性域中定義了一個變數 a ,那麼自然如果不指定是哪個域中的也就產生了歧義,使得a變數名指代不明確。

可如果我們再次定義的a變數是區域性的,即使名稱空間中的a被引入至全域性域,但並不會產生歧義,因為區域性優先的原則,我們並不會去全域性域中檢索變數a,也就不存在命名衝突。

注意:即使是引入至全域性域,名稱空間sx中的a和本身定義在全域性的a是擁有各自的記憶體空間的,"引入"僅僅是讓其在全域性域中可被檢索,它仍然是屬於名稱空間sx的,有點像環境變數,引入就像是將某個命令所在的路徑新增至環境變數,環境變數路徑中的命令是可以在計算機任何路徑下使用的,然而其被使用的命令可能並不在當前路徑,它們之間是相互獨立的。

指定使用全域性域中的內容


namespace sx
{
	int a;
	void swap(int& a, int& b)
	{
		int tmp = a;
		a = b;
		b = tmp;
	}
}
using namespace sx;

int main()
{
    int a = 1;
	cout << ::a << endl;//這裡的a訪問的是全域性的
	return 0;
}

:全域性變數 a 表達為 ::a,用於當有同名的區域性變數時來區別兩者。

名稱空間是有一些比較坑的地方的,例如:

//程式碼1
int a = 1;
namespace sx
{
	int a;
	void swap(int& a, int& b)
	{
		int tmp = a;
		a = b;
		b = tmp;
	}
}

using namespace sx;
int main()
{
	cout << ::a << endl;
	return 0;
}

//程式碼2
int a = 1;
namespace sx
{
	int a;
	void swap(int& a, int& b)
	{
		int tmp = a;
		a = b;
		b = tmp;
	}
}
using sx::a;
int main()
{
	cout << ::a << endl;
	return 0;
}

程式碼1可以正常執行,而程式碼2卻顯示a多次定義,個人覺得還是有些奇怪的,不過專案中我們並不會將名稱空間展開,更不會有這樣的寫法。使名稱空間變數具有與全域性變數相同的名稱是錯誤的(參考微軟官方文件https://docs.microsoft.com/zh-cn/cpp/cpp/namespaces-cpp?view=msvc-170)。因此不用過於糾結這裡的差異。

總之,使用using將名稱空間展開或者是宣告成員,即代表著後續的程式碼可以使用此名稱空間的成員。

C++輸入輸出

向世界打個招呼!

#include<iostream>
using std::cout;

int main()
{
	cout << "hello world!" << endl;
	return 0;
}
  1. 使用cout標準輸出(控制檯)和cin標準輸入(鍵盤)時,必須包含< iostream >標頭檔案以及std標準名稱空間。

    注意:早期標準庫將所有功能在全域性域中實現,宣告在.h字尾的標頭檔案中,使用時只需包含對應標頭檔案即可,後來將其實現在std名稱空間下,為了和C標頭檔案區分,也為了正確使用名稱空間,規定C++標頭檔案不帶.h;舊編譯器(vc 6.0)中還支援<iostream.h>格式,後續編譯器已不支援,因此推薦使用<iostream> +std的方式。

  2. 使用C++輸入輸出更方便,它會自動識別型別(函式過載實現)而不需增加資料格式控制,比如:整形--%d,字元--%c

例如:

#include<iostream>
//using namespace std;
using std::cout;
using std::endl;
using std::cin;

int main()
{
	int i = 1;
	double d = 1.1;
	cout << "i =" << i << ",d =" << d << endl;
	return 0;
}

但是C++的這樣的輸入輸出方式在有些場景下使用會非常麻煩,而C語言就會很方便,例如左對齊右對齊或者是保留幾位小數這樣的場景,推薦使用C語言的輸出方式printf函式。

預設引數

預設引數即可有可無的引數,就像汽車備胎,帶上備胎也能上路不帶也不影響,除非運氣實在太差。

預設引數是宣告或定義函式時為函式的引數指定一個預設值,在呼叫該函式時,如果沒有指定實參則採用該預設值,否則使用指定的實參。

void testfunc(int t = 10)
{
	cout << t << endl;
}

int main()
{
	testfunc(100);//傳入100,就使用指定的實參
	testfunc();//沒有實參,就使用預設的形參10
	return 0;
}

預設引數的分類


  • 全預設引數

​ 即所有引數都有自己的預設值,傳參時可以全部省略。

void FAll(int x = 1, int y = 2, int z = 3)
{
	cout << x << y << z << endl;
}

int main()
{
	FAll();//全預設
	return 0;
}
  • 半預設引數

​ 即只有部分引數都有自己的預設值,傳參時一定需要傳參。

void FHalf(int x, int y = 10, int z = 30)
{
	cout << x << y << z << endl;
}
int main()
{
	FHalf(5);//半預設
	return 0;p
}

注意:

  1. 半預設引數只能依次從右到左且連續,因為形參是從左往右依次傳給實參,所以必須保證沒有預設值的實參一定能有形參傳值給它。
  2. 預設引數不能在定義和宣告中同時出現,以免給的預設值不同產生歧義。
void Test(int a = 10);

void Test(int a = 20)//報錯
{
	cout << a << endl;
}
  1. 預設值必須是常量或者是全域性變數
  2. C語言不支援

注意:如果定義和宣告分離,那麼只能預設在宣告

如果預設引數在定義中,而宣告沒有,那麼宣告的標頭檔案展開後,由於宣告和定義在不同的原始檔中,它們會先分別編譯,那麼包含定義的那個原始檔在編譯時編譯器認為該函式是沒有預設引數的,但是該原始檔函式的呼叫卻沒有傳入引數,就發生了編譯錯誤。

函式過載

在我們的中文中常常會有一詞多義的情況,但是我們可以通過上下文來幫助我們判斷並確定它所表達意義而不是讓我們無法識別。

講個笑話:

我國有兩個體育專案大家根本不用看,也不用擔心。一個是乒乓球,一個是男足。前者是“誰也贏不了!”,後者是“誰也贏不了!

那麼一個相同的函式名我們想讓它不只是有一種功能或者是不止能處理一種特定情況呢,函式過載可以幫助我們解決這個問題。

函式過載的概念


函式過載:是函式的一種特殊情況,C++允許在同一作用域中宣告幾個功能類似的同名函式,這些同名函式的形參列表(引數個數或 型別或順序)必須不同,常用來處理實現功能類似但資料型別不同的問題。

例如:

int Add(int a, int b)
{
	cout << "int Add(int a, int b)" << endl;
	return a + b;
}
double Add(double a, double b)
{
	cout << "double Add(double a, double b)" << endl;
	return a + b;
}
float Add(float a, float b)
{
	cout << "float Add(float a, float b)" << endl;
	return a + b;
}

int main()
{
	int ret1 = Add(1, 2);
	int ret2 = Add(1.1, 2.2);
	int ret3 = Add((float)1.1, (float)2.2);
	return 0;
}

輸出:

相同的函式名傳入不同型別的引數呼叫的函式實體不同。

注意:無法區分僅按返回型別區分的函式

例如:

short Add(short left, short right)
{
	return left + right;
}
int Add(short left, short right)
{
	return left + right;
}

因為函式呼叫時只能根據實參的型別去找相匹配的函式,而無法識別返回型別。

函式過載的底層實現


C語言是不支援函式過載的,但是C++卻引入了這個特性,那麼一定是因為底層實現有區別,於是我們從程式的編譯和執行來探索一下,一個程式要執行起來,那麼必須要經過預處理、編譯、彙編、連結最終成可執行檔案,在Windows中是字尾為 exe的檔案,但由於VS是整合環境不方便檢視,我們可以在Linux環境下嘗試。

程式的編譯過程:

符號表的合併

  1. 實際我們的專案通常是由多個頭檔案和多個原始檔構成,而通過我們C語言階段學習的編譯連結,我們可以知道,【當前a.cpp中呼叫了b.cpp中定義的Add函式時】,編譯後連結前,a.o的目標檔案中沒有Add的函式地址,因為Add是在b.cpp中定義的,所以Add的地址在b.o中。那麼怎麼辦呢?

  2. 所以連結階段就是專門處理這種問題,連結器看到a.o呼叫Add,但是沒有Add的地址,就會到b.o的符號表中找Add的地址,然後連結到一起。

  3. 那麼連結時,面對Add函式,聯結器會使用哪個名字去找呢?這裡每個編譯器都有自己的函式名修飾規則。

【程式的編譯具體參見】:

【C語言進階】程式的編譯 – Sabrina

函式名修飾

  1. 由於Windows下vs的修飾規則過於複雜,而Linux下gcc的修飾規則簡單易懂,下面我們使用了gcc演示了這個修飾後的名字。
  2. 通過下面我們可以看出gcc的函式修飾後名字不變。而g++的函式修飾後變成【_Z+函式長度+函式名+引數型別首字母】。

分別使用C的編譯器和C++的編譯器去編譯並獲得一個可執行檔案

  • 使用C語言(gcc)編譯器編譯後結果

使用objdump -S 命令檢視gcc生成的可執行檔案:

  • 使用C++編譯器(g++)編譯後結果

使用objdump -S 命令檢視g++生成的可執行檔案:

linux下:修飾後的函式名= _Z + 函式名長度 + 形參型別首字母

通過這裡就理解了C語言沒辦法支援過載,因為同名函式沒辦法區分。而C++是通過函式修飾規則來區分,只要引數不同,修飾出來的名字就不一樣,就支援了過載,另外我們也從底層理解了,為什麼函式過載要求引數不同!而跟返回值沒關係。

C++的編譯和連結方式

採用g++編譯完成後,函式的名字將會被修飾,編譯器將函式的引數型別資訊新增到修改後的名字中,因此當相同函式名的函式擁有不用型別的引數時,在g++編譯器看來是不同的函式,而我們另一個模組中想要呼叫這些函式也就必須使用相對應的C++的規則去連結函式(找修飾後的函式名)才能找到函式的地址。

C的編譯和連結方式

對於C程式,由於不支援過載,編譯時函式是未加任何修飾的,而且連結時也是去尋找未經修飾的函式名。

C和C++直接混合編譯時的連結錯誤

在C++程式中,函式名是會被引數型別資訊修飾的,這就造成了它們之間無法直接相互呼叫。

例如:

print(int)函式,使用g++編譯時函式名會被修飾為 _Z5printi,而使用gcc編譯時函式名則仍然是print,如果直接在C++中呼叫使用C編譯規則的函式,會連結錯誤,因為它會去尋找 _Z5printi而不是 print。

結論:在Linux環境下,採用g++編譯完成後,函式的名字將會被修飾,編譯器將函式的引數型別資訊新增到修改後的名字中,因此當相同函式名的函式擁有不用型別的引數時,在g++編譯器看來是不同的函式。

對過載函式的呼叫不明確


難道說有了過載函式那麼函式在呼叫時即使函式名相同就一定能區分了嗎?

來看看下面這種情況:

void test(int a = 1, int b = 2)
{
	cout << "testab" << endl;
}

void test()
{
	cout << "test" << endl;
}
int main()
{
	test();
	return 0;
}

那麼在12行呼叫test函式,按照C++的連結規則,我們應該找的是_Z4test,這樣的被修飾過的函式名。

第1行的test函式經過修飾是_Z4testii

第6行的test函式經過修飾是_Z4test

那是否意味著我們不傳參呼叫時就一定去找的_Z4test呢?但是明明第1行的函式帶有預設引數即使不傳參也可以呼叫啊。

事實上這個程式是可以編譯通過的因為被修飾後的函式名並不會產生衝突,只會在呼叫函式時會存在歧義,連結過程中,這兩個過載的函式都會成為被呼叫的候選人,並且都符合呼叫的條件,多個匹配函式找到,呼叫將被拒絕,因此我們連結過程中不僅僅是尋找函式名那麼簡單,還有很多複雜的規範。

【拓展閱讀】:C++的函式過載 - 吳秦 - 部落格園

extern “C”


我們在寫C++程式碼時,由於其相容C語言,因此我們通常會使用一些C標準庫裡的函式,那如果它們的函式名修飾規則不同,那麼C++編譯器又是怎麼去呼叫C的庫的呢?

在C++出現以前,很多程式碼都是C語言寫的,而且很底層的庫也是C語言寫的,為了更好的支援原來的C程式碼和已經寫好的C語言庫,需要在C++中儘可能的支援C,而extern "C"就是其中的一個策略.

在C++工程中需要將某些函式按照C的風格來編譯,在函式前加extern "C",意思是告訴編譯器,該函式是按照C語言規則來編譯和連結的。

比如:tcmalloc是google用C++實現的一個專案,他提供tcmallc()和tcfree,兩個介面來使用,但如果是C專案就沒辦法使用,那麼他就使用extern “C”來解決。

原始檔A(cpp):

int Add(int num1, int num2)
{
	return num1 + num2;
}

原始檔B(cpp):

extern "C" int Add(int num1, int num2);
int main()
{
	Add(1, 2);//在模組B中呼叫A中的函式
	return 0;
}

error LNK2019: 無法解析的外部符號_Add,該符號在函式 _main 中被引用

注意:

這裡的模組A的 Add函式仍然是按照C++規則去編譯的,函式名仍會被修飾為_Z3Addii,不過在模組B 使用extern ”C“會讓編譯器讓Add函式按照C的方式連結,所以在呼叫時用C的方式去尋找Add,所以會報錯。

總結:

extern "C" 只是 C++ 的關鍵字,不是 C

所以,如果在 C 程式中引入了 extern "C" 會導致編譯錯誤。

被 extern "C" 修飾的目標一般是對一個全域性C或者 C++ 函式的宣告

從原始碼上看 extern "C" 一般對標頭檔案中函式宣告進行修飾。 Ccpp 中標頭檔案函式宣告的形式都是一樣的(因為兩者語法基本一樣),對應宣告的實現卻可能由於語言特性而不同了( C 庫和 C++ 庫裡面當然會不同)。

extern "C" 這個關鍵字宣告的真實目的,就是實現 C++ 與C及其它語言的混合程式設計

一旦被 extern "C" 修飾之後,它便以 C 的方式工作(編譯階段:以C的方式編譯,連結階段:尋找C方式編譯生成的符號), C 中引用 C++ 庫的函式,或 C++ 中引用 C 庫的函式,都可以通過這個方式(即在C++檔案中用extern "C" 宣告,實現C與C++的相容。

【關於extern “C”的具體使用】:

C++和C的混合編譯(extern“C”) – Sabrina

引用

引用的概念


引用不是定義了一個新的變數,而是是一個別名,也就是說,它是某個已存在變數的另一個名字,它和被引用的物件共用同一塊記憶體空間。一旦把引用初始化為某個變數,就可以使用該引用名稱或變數名稱來指向變數。

別名字面意思就是另一個名字,例如孫悟空,他的別名是孫行者,孫悟空也是他,齊天大聖也是它,一切可以指代他的名稱都可以稱作他的別名。

初始化引用格式

引用實體型別 & 引用變數名 = 引用實體

#include<iostream>
using namespace std;
int main()
{
	int a = 1;
	int& quote = a;//初始化quote為a的別名
	cout << a << quote << endl;
	quote = 2;
	cout << a << quote << endl;
	return 0;
}

我們進入除錯視窗:

通過除錯可以看到,a和quote的地址是一樣的,並且quote的型別就為int&,所以quote的改變一定會影響a。

引用特性


  1. 引用在定義時必須初始化;
  2. 一個變數可以有多個引用;
  3. 引用一旦引用一個實體,引用指向的物件就不能再改變;
  4. 引用的實體可以是另一個引用;

例如:

#include <iostream>

using namespace std;
//定義引用時未初始化
int main()
{
	int a = 1;
	int& quote;//未初始化引用,error: ‘rodents’ declared as reference but not initialized
	quote = a;
}
//修改引用實體
int main()
{
	int a = 1;
	int b = 2;
	int& quote = a;
	int& quote = b;//只能引用一個實體,編譯出錯
	return 0;
}

//引用另一個引用
int main()
{
    int a = 1;
    int& quote1 = a;
    int& quote2 = quote1;//它們的地址仍然相同,指向同一塊空間
    return 0;
}

常引用


在C++中,與C語言不同,被const修飾的變數會被當做是一個常量(只對該變數記憶體空間有讀許可權,沒有寫許可權),而不是常變數,因此引用的型別一定要和被引用的實體相匹配,可以有許可權的縮小,但不能有許可權的擴大。

例如:

//許可權的縮小
int main()
{
	int a = 1;
	const int& quote = a;//從可讀可寫-》只可讀
	return 0;
}
//許可權的放大會報錯
int main()
{
	const int a = 1;
	int& quote = a;//從只可讀-》可讀可寫
	return 0;
}
//常引用
int main()
{
	const int a = 1;
	const int& quote = a;
	quote = 2;//不可賦值
	return 0;
}

使用場景


  • 做引數

​ 對於需要在函式內部修改函式外部實參的函式,讓形參為實參的引用,就可以在函式內部修改外部變數,並且 還可以減少形參拷貝實參的開銷。

void swap(int& num1, int& num2)
{
	int tmp = num1;
	num1 = num2;
	num2 = tmp;
}

int main()
{
	int n1 = 3;
	int n2 = 5;
	swap(n1, n2);
	cout << "n1=" << n1 << endl << "n2=" << n2 << endl;
	return 0;
}
  • 做返回值

如果返回的變數在函式呼叫結束後不會被自動銷燬,則可以返回該變數的引用,減少返回值拷貝的開銷

int& count()
{
	static int n = 1;
	++n;
	cout << "int& count()" << endl;
	return n;
}
  • 返回值不能是函式內建立的區域性變數的引用

​ 否則會非法訪問記憶體(訪問不屬於程式的記憶體)

int& Add(int a, int b)
{
	int c = a + b;
	return c;
}

int main()
{
	int& ret = Add(2, 8);//Add(2, 8)的型別是c的引用,當賦值給ret時,c變數已經銷燬
	cout << ret << endl;
	return 0;
}

總結:如果函式呼叫結束後棧幀銷燬但是返回物件仍未銷燬,則可以使用引用返回,否則只能傳值返回。

傳值和傳引用的區別


以值作為引數或者返回值型別,在傳參和返回期間,函式不會直接傳遞實參或者將變數本身直接返回,而是傳遞實參或者返回變數的一份臨時的拷貝,因此用值作為引數或者返回值型別,效率是非常低下的,尤其是當引數或者返回值型別非常大時,效率就更低。

函式的傳參如果是傳值呼叫的話,形參實際上是實參的一份拷貝,也就是說每一次呼叫函式,都要將實參拷貝給形參,這也帶來了資源的消耗,如果多次呼叫此函式,那麼必定會導致效率的低下。

可以使用如下程式碼測試多次呼叫函式時傳值呼叫和傳引用呼叫的時間差異

#include<iostream>
#include<time.h>
using namespace std;

struct A
{
	A()
	{
		memset(arr, 0, sizeof(arr));
	}
	int arr[1000];
};

void TestFunc1(A p)
{}

void TestFunc2(A& p)
{}

void TestEfficiencyByCall()
{
	A p;
	size_t start1 = clock();
	for (int i = 0; i < 1000000; i++)
	{
		TestFunc1(p);
	}
	size_t end1 = clock();
	
	size_t start2 = clock();
	for (int i = 0; i < 10000; i++)
	{
		TestFunc2(p);
	}
	size_t end2 = clock();

	cout << "傳值呼叫 void TestFunc1(A p):" << end1 - start1 << endl;
	cout << "傳引用呼叫 void TestFunc1(A& p):" << end2 - start2 << endl;
}

int main()
{
	TestEfficiencyByCall();
	return 0;
}

執行結果如下:

值和引用的作為返回值型別的效能比較

struct A
{
	A()
	{
		memset(arr, 0, sizeof(arr));
	}
	int arr[1000];
};
A a;
A TestFunc1()
{
	return a;
}

A& TestFunc2()
{
	return a;
}

void TestEfficiency()
{
	size_t start1 = clock();
	for (int i = 0; i < 1000000; i++)
	{
		TestFunc1();
	}
	size_t end1 = clock();
	
	size_t start2 = clock();
	for (int i = 0; i < 10000; i++)
	{
		TestFunc2();
	}
	size_t end2 = clock();

	cout << "值返回 void TestFunc1(A p):" << end1 - start1 << endl;
	cout << "引用返回 void TestFunc1(A& p):" << end2 - start2 << endl;
}

int main()
{
	TestEfficiency();
	return 0;
}

執行結果:

可以看到無論是作為引數還是作為返回值,傳遞引用和值的時間的開銷差異都是比較大的。

我們可以看一看函式返回值是如何傳遞的:

函式返回值從被呼叫的函式的棧幀到呼叫方棧幀的傳遞過程大致如上。

通常我們會建立一個變數接收函式得返回值,在這裡就是這個在main函式中預先開好空間的用於儲存函式返回值的物件。

接下來看過程:

如果是傳值返回,則產生的臨時變數會是返回物件的一份臨時拷貝,然後再拷貝給main函式中預先開好空間的用於儲存函式返回值的物件,而如果是傳引用返回,則臨時變數會是a的引用,臨時物件再賦值給main函式中預先開好空間的用於儲存函式返回值的物件,那這裡也會是一個引用,因此我們在main函式中就可以訪問到a物件了。

不過不是每次都需要建立一個臨時變數,對於一些比較小的變數,會直接用暫存器來傳遞值。

注:臨時變數的型別就是定義的返回型別,此臨時變數通常也是也是具有常性的,不過也有例外,那就是傳引用返回的情況。

  1. 傳值返回:那麼該臨時變數是有常性的。
  2. 傳引用返回:無常性,只和返回的型別是否被const修飾有關。

臨時變數儲存於呼叫方函式的棧幀**。

引用和指標的區別


引用很容易與指標混淆,它們之間有三個主要的不同:

  • 不存在空引用(引用的物件必須存在)。引用必須連線到一塊合法的記憶體。
  • 一旦引用被初始化為一個物件,就不能被指向到另一個物件。指標可以在任何時候指向到另一個物件。
  • 引用必須在建立時被初始化。指標可以在任何時間被初始化。

引用在語法層面上就是一個別名,別名是不單獨享有記憶體空間的,它和被引用的實體共用同一塊記憶體空間。

int main()
{
	int a = 9;
	int& ra = a;
	cout << "&a = " << &a << endl;
	cout << "&ra = " << &ra << endl;
	return 0;
}

這樣的語法解釋實在有些難以理解它在底層是如何做到的。

實際上在底層實現上引用還是有空間的,因為引用本質還是指標的方式來實現的。

int main()
{
	int a = 9;

	int& ra = a;
	ra = 99;

	int* pa = &a;
	*pa = 99;
	return 0;
}

我們來看看彙編:

彙編指令大致都是相同的,也就是說它和指標實際上是同根同源的。

指標和引用差異彙總:

  1. 引用在定義時必須初始化,而指標不需要;
  2. 引用在初始化引用一個實體後就不能再引用其他實體了,而指標指向的物件可以隨意修改;
  3. 沒有NULL引用,但是又NULL指標;
  4. 在sizeof中的含義不同,引用結果為被引用實體的型別大小,而指標的大小是地址空間所佔的位元組數;
  5. 引用在初始化後,一切對引用的操作都是對實體物件操作的,而指標可以操作指標變數本身,也可以操作被指向的物件;
  6. 多級指標但沒有多級引用;
  7. 訪問實體方式不同,指標需要我們顯式的去解引用方能對指向的物件進行操作,而引用是編譯器替我們處理;
  8. 引用相對於指標更加的安全,不存在野指標等潛在的風險;

一些引用的注意事項


型別轉換實現方法以及臨時變數的特性

看如下程式碼:

int main()
{
	double d = 9.9;
	int& a = d;
   	cout << a << endl;
	return 0;
}

報錯:

“初始化”: 無法從“double”轉換為“int &”

改動如下即可正常編譯:

int main()
{
	double d = 9.9;
	const int& a = d;
   	cout << a << endl;
	return 0;
}

這是什麼原因??const修飾過後為什麼就能正常編譯了呢???(warning)

這裡不得不提到型別轉換時發生的小動作;

型別轉換是如何實現的呢?不論是顯式的還是隱式的發生的型別轉換,它這個型別轉換的效果都是“臨時”的,僅僅在當前行生效,也就是說本身發生轉換的那個變數或者說是物件它的型別並沒有改變。

既然如此,那麼中間一定會有另一個臨時變數的產生,而是這個我們看不到的臨時變數在發揮讓我們看起來像“型別轉換”的作用。

那麼再來看第4行程式碼,int&只能初始化為int型別的引用,因此這裡會發生隱式型別轉換,即產生一個int型別的變數,並且讓這個臨時變數在這一行中代替d來產生作用,a就被初始化為了這個臨時變數的引用;

為什麼不用const修飾就無法通過編譯呢?

答案是:臨時變數具有常性,也就是說臨時變數具有只可讀不可寫的性質,那麼如果不使用const對引用加以限制,就造成了許可權的放大,而這是不被C++所允許的,因此必須加上const修飾a;

嘿,那麼新問題來了,既然臨時變數只在當前行生效,也就是程式走完這一行臨時變數就銷燬了,而a作為此臨時變數的引用,卻在第5行正常訪問了a,那麼這裡我們還可以得出一個結論:

const修飾的引用的實體是臨時變數時,臨時變數的宣告週期就會延長,直到引用的生命週期結束。

概括一下:

  • 型別轉換伴隨著臨時變數的產生;
  • 臨時變數具有常性;
  • const修飾的引用的實體是臨時變數時,臨時變數的宣告週期就會延長,知道引用的生命週期結束;
  • 不會被修改的變數儘量用const修飾;

關於臨時物件的型別的注意事項

如下兩段程式碼有何差異?

//程式碼1
int main()
{
	double d = 9.9;
	const int& a = (int&)d;
    cout << "d = " << d << endl;
	cout << "a = " << a << endl;
	return 0;
}

輸出:

d = 9.9
a = -858993459

//程式碼2
int main()
{
	double d = 9.9;
	const int& a = d;
	cout << "d = " << d << endl;
	cout << "a = " << a << endl;
	return 0;
}

輸出:

d = 9.9
a = 9

出錯了,奇怪,這兩段程式碼的執行結果應該相同才對啊???不急我們耐心分析一下這兩段程式碼的差異。

差異就只有第四行,我們來單獨看看

程式碼1:

const int& a = (int&)d;

這行程式碼的意思應該是將 d (double型別)強制型別轉換為 int&,我們都知道強轉型別時會生成一個臨時變數(int&),再初始化a為為這個臨時變數的引用;

關係如圖:

這裡的關係文字描述為d是double型別, tmp是d的引用(int&),而a又是tmp的引用(int&),可以直接認為a是d的引用,只不過引用的型別為 const int;

程式碼2:

const int& a = d;

這行程式碼的意思是初始化a為d的引用,不過型別並不匹配,int&需要一個引用一個int的實體或者一個int&的引用,因此,d會發生隱式型別轉換,產生一個int型別的臨時變數,即a會是這個臨時變數的引用。

關係如圖:

這裡的關係文字描述為d是double型別,而tmp是一個臨時的int型別,a是tmp的引用;

這樣就解釋的通了,但是是否真是如此,我們需要通過地址來驗證;

程式碼1:

可以看到他d和a的地址是一樣的,說明a是d的引用(指向d的地址),但只是引用的型別和d的型別不同。

程式碼2:

a的地址和d不同,這是因為a是隱式型別轉換所產生的臨時變數的引用,而此臨時變數是一個int型別,而非引用,具有自己獨立的記憶體空間,而a指向這塊臨時變數的空間,因此地址不同。

這兩段程式碼的唯一差異就是型別轉換時生成的臨時變數的型別不同,一個是int型別,一個是int&型別,即一個有自己的單獨記憶體空間,而另一個與發生型別轉換的物件共享一塊空間(其實引用是有單獨的記憶體空間,不過經過編譯器處理,我們對引用操作時都是實際上操作的是被引用的實體,因此可以視作沒有分配記憶體),而a都是臨時變數的引用,就導致了最終結果的不同。

因此在使用引用時,一定要注意這些可能會遇到的問題,一不留神就可能掉坑了,要規範正確的使用引用。