2.C++入門基礎(下)
行內函數
C++中函式的使用我們已經比較清楚了,與C語言中函式的使用大多相同,主要是增加了過載的特性,對C語言的函式的一些缺陷做了一些補充。
那麼對於一些比較簡單卻又經常使用的功能,我們在C語言中常常使用巨集來替換,巨集呢與函式相比沒有棧幀的開闢,型別的檢查,沒有傳參,僅僅是做一個替換,非常適合功能簡單卻使用頻繁的應用場景,但是巨集正因為如此,也就具有了不安全、無法除錯的缺陷,那麼C++中如何處理這樣地缺陷呢?
行內函數應運而生它既繼承了巨集的優點也繼承了函式的優點,即既沒有開闢棧幀的開銷,又可以去除錯,並且有型別的檢查。
行內函數和巨集很類似,而區別在於,巨集是由前處理器對巨集進行替代,而行內函數是通過編譯器控制來實現的。而且行內函數是真正的函式,只是在需要用到的時候,行內函數像巨集一樣的展開,所以取消了函式的引數壓棧,減少了呼叫的開銷。你可以像呼叫函式一樣來呼叫行內函數,而不必擔心會產生 處理巨集的一些問題。
概念
以inline修飾的函式叫做行內函數,編譯時C++編譯器會在呼叫行內函數的地方展開,沒有函式壓棧的開銷,行內函數提升程式執行的效率。
函式是一個可以重複使用的程式碼塊,CPU 會一條一條地挨著執行其中的程式碼。CPU 在執行主調函式程式碼時如果遇到了被調函式,主調函式就會暫停,CPU 轉而執行被調函式的程式碼;被調函式執行完畢後再返回到主調函式,主調函式根據剛才的狀態繼續往下執行。
我們得明白,函式呼叫是有時間和空間開銷的。程式在執行一個函式之前需要做一些準備工作,要將實參、區域性變數、返回地址以及若干暫存器都壓入棧中,然後才能執行函式體中的程式碼;函式體中的程式碼執行完畢後還要清理現場,將之前壓入棧中的資料都出棧,才能接著執行函式呼叫位置以後的程式碼。
如果函式體程式碼比較多,需要較長的執行時間,那麼函式呼叫機制佔用的時間可以忽略;如果函式只有一兩條語句,那麼大部分的時間都會花費在函式呼叫機制上,這種時間開銷就就不容忽視,要儘可能處理函式呼叫機制所用時間佔比大的這種情況。
為了消除函式呼叫的時空開銷,C++ 提供一種提高效率的方法,即在編譯時將函式呼叫處用函式體替換,類似於C語言中的巨集展開。這種在函式呼叫處直接嵌入函式體的函式稱為行內函數(Inline Function),又稱內嵌函式或者內建函式。
先來看看普通函式的呼叫過程:
呼叫函式時是使用call指令,去呼叫某地址上的函式。(注意:普通函式都是有地址的,可以用以區分行內函數)
如果在上述函式的前面加上inline關鍵字
不過我們通常在Debug模式下預設函式不會被當做內聯,即使你加上了inline,都會被編譯器忽略,只有在release模式下,inline才有可能會被採納,至於為什麼是有可能,編譯器只會把你的inline關鍵字當做一個建議,至於編譯器是否按照你所要求的去做,這就不一定了,因為這僅僅是一個建議,編譯器會結合具體情況比如函式體指令的多少來判斷到底是否當做行內函數。
所以我們如何去觀察一個函式是否被當做行內函數呢?
在release模式下
-
檢視編譯生成的彙編程式碼中是否存在call Add
-
監視器視窗檢視Add函式是否有地址;
在debug模式下需要對編譯器進行設定,否則不會展開,因為在debug模式下,編譯器預設不會對程式碼進行優化,行內函數其實算一種優化方式。
- 在專案—>屬性中找到 C/C++選項—>常規
將除錯資訊格式改為程式資料庫(/Zi)
-
在C/C++選項中找到優化
將行內函數擴充套件選擇—>只適用於__inline(Ob1)
- 重新生成可執行檔案即可
完成後,我們便可以在debug模式下檢視到行內函數的展開
這裡並沒有call Add函式,而是函式體的展開(當然不僅僅是簡單的展開,還會涉及一些其他指令,不做深入討論)。
特性
- inline是一種空間換時間的做法 ,節省了開闢棧幀的時間開銷;
與呼叫普通函式相比不需要去開闢棧幀空間,節省了時間,相當於inline函式體所有指令都在當前棧內被執行;
- inline對於編譯器僅僅是一個建議,編譯器會自動優化,如果定義為inline的函式體內有迴圈/遞迴等,編譯器優化會自動忽略內聯。
不僅是以上兩種情況,函式體內的指令一旦較多,編譯器就會自動忽略,如下:
函式體指令較複雜:
函式體指令較簡單:
- inline函式不建議宣告和定義分離,分離會導致連結錯誤。因為inlinn函式被展開,也就不會有函式地址,自然不用提去連結了。
//func.h檔案
#pragma once
#include<iostream>
using namespace std;
inline void f(int i);
//func.cpp檔案
#include"test.h"
void f(int i)
{
cout << "func" << endl;
}
//main.cpp檔案
#include"test.h"
int main()
{
f(1);
return 0;
}
報錯:error LNK2019: 無法解析的外部符號 "void __cdecl f(int)" (?f@@YAXH@Z),函式 main 中引用了該符號。
如果想要一個函式成為內聯,但是類的定義和類例項化的地方在不同的原始檔(宣告定義分離),那麼最好是將此函式定義在類中。
對於行內函數,其工作原理是:
對於任何行內函數,編譯器在符號表裡放入函式的宣告(包括名字、引數型別、返回值型別)。如果編譯器沒有發現行內函數存在錯誤,那麼該函式的程式碼也被放入符號表裡。在呼叫一個行內函數時,編譯器首先檢查呼叫是否正確(進行型別安全檢查,或者進行自動型別轉換,當然對所有的函式都一樣)。如果正確,行內函數的程式碼就會直接替換函式呼叫,於是省去了函式呼叫的開銷。
各個檔案是分離編譯的,在func.c中由於聲明瞭f函式是內聯的,並且函式體也很簡短,因此編譯器遵循了我們的建議,使其成為一個行內函數,由於沒有函式地址,自然無法被除本原始檔以外的地方呼叫;也可以說行內函數在符號表不會有合併這一步操作,僅僅存在於本原始檔中。
行內函數的缺點
難道行內函數就沒有缺點嗎,當然有!不然還要函式做什麼?行內函數隨著一次次的呼叫展開,會造成程式碼膨脹的問題,通俗講就是生成的可執行檔案會變大,這是我們不願意看到的(有誰願意看著自己的電腦硬碟被榨乾呢?)
可以大致從幾個方面看:
- 編譯後的程式會存在多份相同的函式指令拷貝,這些函式拷貝編譯後都是二進位制的指令,如果被宣告為行內函數的函式體非常大,那麼編譯後的程式體積也將會變得很大。
- 可執行程式在執行前要先載入記憶體,程式的執行就是一步步去記憶體取指令然後交給CPU執行的過程,可以試想可執行檔案大,那麼其指令也就越多,載入記憶體後消耗的空間也越大。
很好理解,普通的函式都有一個地址,每當我們需要使用這個函式時,直接通過函式名訪問地址,然後就是建立棧幀的過程,在新棧幀中執行相應函式指令。
行內函數沒有它的地址,我們需要呼叫這個函式時,只能臨時拷貝一份,再執行相應指令。
舉一個例子就是,普通函式就是一個堅信好 “記性不如爛筆頭的乖學生”,老師講一個重要的、多次使用的知識點時,他就記在筆記本上,需要了就拿出來看看就會了。行內函數也是一個愛記筆記的學生,不過它丟三落四的,剛記下筆記筆記本就丟了,每次需要時,就只能又去問老師再記下來,慢慢的他寫過的筆記本就很多了,不過他自己還渾然不知。
他們兩個同學的筆記本都是一個作用,就是記錄下這個知識,但是隨著使用次數的增加,這位有收拾的同學只需要一個筆記本就能終生受用,而這位丟三落四的同學則會隨著記了又丟,丟了又記的過程產生很多個筆記本,行內函數也是同樣的道理。
那麼它們的過程實際區別如下:
在程式載入過程中,兩個函式體內容相同的普通函式和行內函數,普通函式的函式指令只向記憶體中載入了一次,之後每次呼叫此函式都只需要一條指令,直接訪問其函式地址並取指令。
行內函數不會載入記憶體,沒有函式地址。在編譯後這些呼叫行內函數的語句都會被展開為行內函數的指令(做了一些特殊處理,並不是完全複製的函式體指令),由於編譯後行內函數展開部分就只是一條條的指令,這些指令都會被載入記憶體,可以看到這裡呼叫了多少次,內聯就展開了多少次,展開的指令都會被載入記憶體,可以等效於呼叫了幾次就將行內函數的指令載入了幾次到記憶體。
那麼也就得出差異,普通函式只需要載入記憶體一次,而內斂函式是呼叫了幾次就會載入記憶體幾次。
- 如果行內函數呼叫次數很多,呼叫結束後由於呼叫所產生的記憶體消耗並不會被釋放(普通函式呼叫結束後棧幀會銷燬)
如圖:
總體來說,如果除去開闢棧幀的花銷,行內函數和普通函式的所執行的指令數、時間幾乎是相同的,重點在於如何把控執行一個函式時,它開闢的棧幀的消耗佔整個函式呼叫的比重,如何把控這個比重,決定了我們是否建議一個函式為內聯。
我們來寫個程式驗證一下,並從指令的角度來看:
inline void func()
{
cout << "func" << endl;
}
int main()
{
func();
func();
func();
func();
return 0;
}
來看看行內函數的彙編指令:
func();
00007FF6AA4D1522 lea rdx,[string "func" (07FF6AA4DAE64h)]
00007FF6AA4D1529 mov rcx,qword ptr [__imp_std::cout (07FF6AA4E0198h)]
00007FF6AA4D1530 call std::operator<<<std::char_traits<char> > (07FF6AA4D1046h)
00007FF6AA4D1535 lea rdx,[std::endl<char,std::char_traits<char> > (07FF6AA4D1014h)]
00007FF6AA4D153C mov rcx,rax
00007FF6AA4D153F call qword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (07FF6AA4E01B0h)]
func();
00007FF6AA4D1545 lea rdx,[string "func" (07FF6AA4DAE64h)]
00007FF6AA4D154C mov rcx,qword ptr [__imp_std::cout (07FF6AA4E0198h)]
00007FF6AA4D1553 call std::operator<<<std::char_traits<char> > (07FF6AA4D1046h)
00007FF6AA4D1558 lea rdx,[std::endl<char,std::char_traits<char> > (07FF6AA4D1014h)]
00007FF6AA4D155F mov rcx,rax
00007FF6AA4D1562 call qword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (07FF6AA4E01B0h)]
func();
00007FF6AA4D1568 lea rdx,[string "func" (07FF6AA4DAE64h)]
00007FF6AA4D156F mov rcx,qword ptr [__imp_std::cout (07FF6AA4E0198h)]
00007FF6AA4D1576 call std::operator<<<std::char_traits<char> > (07FF6AA4D1046h)
00007FF6AA4D157B lea rdx,[std::endl<char,std::char_traits<char> > (07FF6AA4D1014h)]
00007FF6AA4D1582 mov rcx,rax
00007FF6AA4D1585 call qword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (07FF6AA4E01B0h)]
func();
00007FF6AA4D158B lea rdx,[string "func" (07FF6AA4DAE64h)]
00007FF6AA4D1592 mov rcx,qword ptr [__imp_std::cout (07FF6AA4E0198h)]
00007FF6AA4D1599 call std::operator<<<std::char_traits<char> > (07FF6AA4D1046h)
00007FF6AA4D159E lea rdx,[std::endl<char,std::char_traits<char> > (07FF6AA4D1014h)]
00007FF6AA4D15A5 mov rcx,rax
00007FF6AA4D15A8 call qword ptr [__imp_std::basic_ostream<char,std::char_traits<char> >::operator<< (07FF6AA4E01B0h)]
接著我們取消內聯再來看看:
func();
00007FF679261572 call func (07FF679261285h)
func();
00007FF679261577 call func (07FF679261285h)
func();
00007FF67926157C call func (07FF679261285h)
func();
00007FF679261581 call func (07FF679261285h)
差別還是蠻大的,這些指令都會在執行時載入記憶體,造成程式碼膨脹。
一些其他不足
- 通常,編譯器比程式設計者更清楚對於一個特定的函式是否合適進行內聯擴充套件;一些情況下,對於程式設計師指定的某些行內函數,編譯器可能更傾向於不使用內聯甚至根本無法完成內聯。
- 對於一些開發中的函式,它們可能從原來的不適合內聯擴充套件變得適合或者倒過來。儘管行內函數或者非行內函數的轉換易於巨集的轉換,但增加的維護開支還是使得它的優點顯得更不突出了。
- 對於基於C的編譯系統,行內函數的使用可能大大增加編譯時間,因為每個呼叫該函式的地方都需要替換成函式體,程式碼量的增加也同時帶來了潛在的編譯時間的增加。
判斷是否設定為內聯:一般只將那些短小的、頻繁呼叫的函式宣告為行內函數。
最後需要說明的是,對函式作 inline 宣告只是程式設計師對編譯器提出的一個建議,而不是強制性的,並非一經指定為 inline 編譯器就必須這樣做。編譯器有自己的判斷能力,它會根據具體情況決定是否這樣做。
auto關鍵字
auto為自動的意思,C語言中貌似也有提到過(自動變數什麼的,記不清了幾乎沒使用過),那麼在C++中auto有什麼作用、應用於哪些場景呢?
auto簡介
在早期C/C++中auto的含義是:使用auto修飾的變數,是具有自動儲存器的區域性變數,但遺憾的是一直沒有人去使用它,大家可思考下為什麼?
C++11中,標準委員會賦予了auto全新的含義即:auto不再是一個儲存型別指示符,而是作為一個新的型別指示符來指示編譯器,auto宣告的變數必須由編譯器在編譯時期推導而得。
int main()
{
auto a = 1;
auto b = 2.0;
auto c = 2.0f;
auto d = 'w';
cout << typeid(a).name() << endl;
cout << typeid(b).name() << endl;
cout << typeid(c).name() << endl;
cout << typeid(d).name() << endl;
return 0;
}
//auto e;無法通過編譯。
既然是編譯期間自動推導型別,那麼就說明一定得初始化咯。
使用auto定義變數時必須對其進行初始化,在編譯階段編譯器需要根據初始化表示式來推導auto的實際型別。因此auto並非是一種“型別”的宣告,而是一個型別宣告時的“佔位符”,編譯器在編譯期會將auto替換為變數實際的型別。
詳細使用規則
既然是佔位符則說明auto可以是任何型別,而不一定是對其進行初始化資料的型別。
- auto和指標結合起來使用
用auto宣告指標型別時,用auto和auto*沒有任何區別,但是auto對於引用的宣告必須加上&;
int main()
{
int a = 10;
auto p1 = &a;//auto在編譯時會被替換為int*
auto* p2 = &a;//auto在編譯時會被替換為int
int& ref = a;
auto ref1 = ref;//ref是a的別名,因此推匯出是int型別
ref1 = 12;//a並不會改變
cout << a << endl;
cout << typeid(a).name() << endl;
cout << typeid(ref).name() << endl;
cout << typeid(p1).name() << endl;
cout << typeid(p2).name() << endl;
cout << typeid(ref1).name() << endl;
return 0;
}
- 同一行定義多個變數
在同一行定義多個變數時,這些變數的型別必須相同,否則編譯器會報錯,因為編譯器實際只對第一個型別進行推導,然後替換為auto,並用該型別定義其他變數。
int main()
{
auto a = 1, b = 1.0;
return 0;
}
在宣告符列表中,“auto”必須始終推導為同一型別
這樣看auto好像還挺萬能的,那麼auto能適用於所以場景嗎?
auto不能推導型別的場景
- 不能作為函式的形參
// 此處程式碼編譯失敗,auto不能作為形參型別,因為編譯器無法對a和b的實際型別進行推導
int Add(auto a, auto b)
{
return a + b;
}
error C3533: 引數不能為包含“auto”的型別
函式在編譯時是需要形參型別來確定修飾後函式名的,所以形參型別要先確定。
這種顯然是不可行的,如果可以直接使用auto推導,那麼就沒後面的模板什麼事了。
注:返回值是可以用auto的。
- 不能用來直接宣告陣列
int main()
{
auto arr[] = { 1,2,3,4,5 };
return 0;
}
error C3318: “auto []”: 陣列不能具有其中包含“auto”的元素型別
error C3535: 無法推導“auto []”的型別(依據“initializer list”)
- 為了避免與C++98中的auto發生混淆,C++11只保留了auto作為型別指示符的用法
- auto為了在實際中最常見的優勢用法就是跟以後會講到的C++11提供的新式for迴圈,還有lambda表示式等
進行配合使用
基於範圍的for迴圈
for迴圈是我們非常熟悉的,用法也比較單一,通常在知道迴圈次數的情況下使用,那麼什麼是範圍for呢?
範圍for的語法
C++98中如果我們要遍歷一個數組通常是下面這種方式:
void TestFor()
{
int array[] = { 1, 2, 3, 4, 5 };
for (int i = 0; i < sizeof(array) / sizeof(array[0]); ++i)
array[i] *= 2;
for (int* p = array; p < array + sizeof(array) / sizeof(array[0]); ++p)
cout << *p << endl;
}
對於一個範圍已知的集合而言,由我們來說明迴圈的範圍顯然是多餘的,有時候不注意還會出錯。因此C++11中引入了基於範圍的for迴圈。for迴圈後的括號由冒號“:”分為兩部分:第一部分是範圍內用於迭代的變數,第二部分則表示被迭代的範圍。
void TestFor()
{
int array[] = { 1, 2, 3, 4, 5 };
for (auto& e : array)
e *= 2;
for (auto e : array)
cout << e << " ";
}
迴圈體內就和普通的迴圈一樣了,我們可以使用break跳出迴圈,也可以使用continue結束本次迴圈,只不過是編譯器幫助我們確定迭代的範圍而已。
範圍for的使用條件
- for迴圈迭代的範圍是確定的
對於陣列而言,迭代的範圍就是從第一個元素到最後一個元素;
對於類而言,應該提供begin以及end的方法,迴圈的範圍就是從begin到end;
也就是說我麼給for的應該是一組資料集合,例如陣列,連結串列等等…
看看一下程式碼是否有問題:
void TestFor(int array[])
{
for (auto& e : array)
cout << e << endl;
}
“begin”: 未找到匹配的過載函式
顯然是有問題的,陣列名作為形參,其本質上是一個指標,指標是一組資料的集合嗎??顯然不是。
- 迭代的物件的迭代器要實現++和==的操作。(關於迭代器這個問題,以後會講,現在大家瞭解一下就可以了)
可以先簡單說說迭代器,迭代器就是一個封裝後的指標,通過這個封裝後的指標我們可以通過地址找到資料的儲存位置。為什麼要封裝呢?因為對於原生指標我們執行++操作,它是在相鄰的位置移動,這樣對於連結串列等資料結構是無法正確訪問的,可如果我們對++操作符進行過載,就可以讓它根據他的儲存特性去移動指標了!
新的指標空值nullptr C++11
從學習C語言之初,我們就說要養成一個好習慣——宣告一個變數時給一個合適的初值,否則可能會出現不可預料的錯誤,比如未初始化的指標。如果當前指標沒有明確的指向,那麼可以給其賦值為空;
int main()
{
int* p = NULL;
int* p1 = 0;
return 0;
}
這兩者其實是一樣的,NULL其實是一個巨集,即是0值;
在傳統C標頭檔案裡(stddef.h)中,可以看到如下程式碼:
#ifndef NULL
#ifdef __cplusplus
#define NULL 0
#else
#define NULL ((void *)0)
#endif
#endif
可以看到,NULL可能被定義為字面常量0,或者被定義為無型別指標(void*)的常量。不論採取何種定義,在使用空值的指標時,都不可避免的會遇到一些麻煩,比如:
void func(int)
{
cout << "f(int)" << endl;
}
void func(int*)
{
cout << "f(int*)" << endl;
}
int main()
{
func(0);
func(NULL);
func((int*)NULL);
return 0;
}
我們的本意是讓func(NULL)
去呼叫void func(int*)
的,但卻出現了問題,由於NULL被定義成0,而0預設被當做一個整型,因此與程式的初衷相悖。
在C++98中,字面常量0既可以是一個整形數字,也可以是無型別的指標(void*)常量,但是編譯器預設情況下將其看成是一個整形常量,如果要將其按照指標方式來使用,必須對其進行強轉(void *)0。
因此在C++程式中我們更傾向使用nullptr而不是NULL,來看看nullptr是什麼。
int main()
{
int* p = NULL;
int* p1 = 0;
cout << " " << typeid(NULL).name() << endl;
cout << " " << typeid(nullptr).name() << endl;
return 0;
}
注意:
-
在使用nullptr表示指標空值時,不需要包含標頭檔案,因為nullptr是C++11作為新關鍵字引入的。
-
在C++11中,sizeof(nullptr) 與 sizeof((void*)0)所佔的位元組數相同。
-
為了提高程式碼的健壯性(適用於更多場景),在後續表示指標空值時建議最好使用nullptr。