> 在前一篇,我們提供了一個方向性的指南,但是學什麼,怎麼學卻沒有詳細展開。本篇將在前文的基礎上,著重介紹下怎樣學習C++的類型系統。 ### 寫在前面 在進入類型系統之前,我們應該先達成一項共識——儘可能使用C++的現代語法。眾所周知,出於相容性的考慮,C++中很多語法都是合法的。但是隨著新版本的 ...
在前一篇,我們提供了一個方向性的指南,但是學什麼,怎麼學卻沒有詳細展開。本篇將在前文的基礎上,著重介紹下怎樣學習C++的類型系統。
寫在前面
在進入類型系統之前,我們應該先達成一項共識——儘可能使用C++的現代語法。眾所周知,出於相容性的考慮,C++中很多語法都是合法的。但是隨著新版本的推出,有些語法可能是不推薦或者是需要避免使用的。所以本篇也儘可能採用推薦的語法形式(基於C++11或以上版本),這也是現代C++標題的含義。
採用現代語法有兩點好處。其一,現代語法可以編譯出更快更健壯的代碼。編譯器也是隨著語言的發展而發展的,現代語法可以在一定程度上幫助編譯器做更好的優化。其二,現代語法通常更簡潔,更直觀,也更統一,有助於增強可讀性和可維護性。
明確了這點後,讓我們一起踏入現代C++的大門吧。
類型系統
程式是一種計算工具,根據輸入,和預定義的計算方法,產生計算結果。當程式運行起來後,這三者都需要在記憶體中表示成合適的值才能讓程式正常工作,負責解釋的這套工具就是類型系統。數字,字元串,鍵盤滑鼠事件等都是數據,而且在記憶體中實際存在的形式也是一樣的,但是按我們人類的眼光來看的話,對它們的處理是不一樣的。數字能進行加減乘除等算術運算,但是對字元串進行算術運算就沒有意義,而鍵盤滑鼠的值通常只是讀取,不進行計算的。正是由於這些差異,編程語言的第一個任務就是需要定義一套類型系統,告訴電腦怎樣處理記憶體中的數據。
為了讓編程語言儘可能簡單,編程語言一般把類型系統分為兩步實現,一部分是編譯器,另一部分是類型。編譯器那部分負責將開發者的代碼解釋成合適的形式,以便可以高效,準確在記憶體中表示。類型部分則定義一些編譯器能處理的類型,以便開發者可以找到合適的數據來完成輸入輸出的表示和計算方法的描述。這兩者相輔相成,相互成就。
類型作為類型系統的重要表現形式,在編程語言中的重要性也就不言而喻了。如果把寫程式看成是搭積木的話,那麼程式的積木就是類型系統。類型系統是開發者能操作的最小單位,它限制了開發者的操作規則,但是提供了無限的可能。C++有著比積木更靈活的類型系統。
類型
類型是編程語言的最小單位,任何一句代碼都是一種記憶體使用形式。
而談到C++的類型也就不得不談到它的三種類型表現形式——普通類型,指針,引用。它們是三種不同的記憶體使用和解釋形式,也是C++的最基礎的形式。和大部分編程語言不同,C++對內置類型沒有做特權處理,只要開發者願意,所有的類型都可以有一致的語法形式(通過運算符重載),所以下麵關於類型的舉例適合所有的類型。
普通類型就是沒有修飾的類型,如int
,long
,double
等。它們是按值傳遞的,也就是賦值和函數傳參是拷貝一份值,對拷貝後的值進行操作,不會再影響到老值。
int a=1; //老值,存在地址1
int b=a; //新值,存在地址2
b=2; //改變新值,改變地址2
//此時a還是1,b變成了2
那假如我們需要修改老值呢,有兩種途徑,一種是指針,另一種則是引用。
指針是C/C++裡面的魔法,一切皆可指針。指針包含兩個方面,一方面它是指一塊記憶體,另一方面它可以指允許對這塊記憶體進行的操作。指針的值是一塊記憶體地址,操作指針,操作的是它指向的那塊地址。
int a=1; //老值,存在地址1
int* b=&a; //&代表取地址,從右往左讀,取a的地址——地址1,存在地址2
*b=2; //*是解引用,意思是把存在地址2(b)的值取出來,並把那個地址(地址1)的值改成2
//此時a,*b變成了2
引用則是指針的改進版,引用能避免無效引用,不過引用不能重設,比指針缺少一定的靈活性。
int a=1; //老值,存在地址1
int& b=a; //&出現在變數聲明的位置,代表該變數是引用變數,引用變數必須在聲明時初始化
b=2; //可以像普通變數一樣操作引用變數,同時,對它的操作也會反應到原始對象上
//此時a,b變成了2
變數定義
類型僅僅是一種語法定義,而要真正使用這種定義,我們需要用類型來定義變數,即變數定義。
C++變數定義是以下形式:
type name[{initial_value}]
這裡的關鍵在於type
。type
是類型和限定符的組合。看下麵的例子:
int a; //普通整型
int* b; //類型是int和*的組合,組成了整型指針
const int* c; //從右往左讀,*是指針,const int是常量整型,組成了指向常量整型的指針類型
int *const d; //也是從右往左讀,const是常量,後面是指針,說明這個指針是常量指針,指向最左邊的int,組成常量指針指向整型
int& e=a; //類型是int和&的組合,組成了整型引用
constexpr int f=a+e; //constexpr代表這個變數需要在編譯期求值,並且不再可變。
以上,基本就是變數定義的所有形式了,類型確定了變數的基本屬性,而限定符限定了變數的使用範圍。
定義變數也是按照這個步驟進行,首先確定我們需要什麼類型的變數,其次再進一步確定是否需要對這個變數添加限定,很多時候是需要的。可以按以下步驟來確定添加什麼樣的限定符:
- 是個大對象,可以考慮把變數聲明成引用類型。通常引用類型是比指針類型更優的選擇。
- 大對象可能需要被重置,可以考慮聲明為指針。
- 只想要個常量,添加
constexpr
。 - 只想讀這個變數,添加
const
。
變數初始化
變數定義往往伴隨著初始化,這對於局部變數來說很重要,因為局部變數的初值是不確定的,在沒有對變數進行有效初始化前就使用變數,會導致不可控的問題。所以嚴格來說,前面的變數定義是不完全正確的。
C++11推出了全新的,統一的初始化方式,即在變數名後面跟著大括弧,大括弧里包著初始化的值。這種方式可以用在任何變數上,稱之為統一初始化,如:
int a{9527}; //普通類型
string b={"abc"}; //另一種寫法,等價但是不推薦
Student c{"張三","20220226",18}; //大括弧中是構造函數參數
當然,除了用類型名來定義變數外,還可以將定義和初始化合二為一,變成下麵這種最簡潔的形式:
auto a={1}; //推導為整型
auto b=string{"abc"};
auto c=Student{"張三","20220226",18}
這裡auto
是讓編譯器自己確定類型的意思。上面這種寫法是完全利用了C++的類型推導,這也是好多現代語言推薦的形式。不過需要註意的是,使用類型推導後,=
就不能省略了。
有了初始化的變數後,我們就可以用它們完成各種計算任務了。C++為開發者實現了很多內置的計算支持。如數字的加減乘除運算,數組的索引,指針的操作等。還提供了分支if
,switch
,迴圈while
,for
等語句,為我們提供了更靈活的操作。
函數
變數是編程語言中的最小單位,隨著業務的複雜度增加,有些時候中間計算會分散業務的邏輯,增加複雜度。為了更好地組織代碼,類型系統增加了 函數來解決這個問題。
函數也是類型,是一種複合類型。它的類型由參數列表,返回值組合而成,也就是說兩個函數,假如參數列表和返回值一樣,那麼它們從編譯器的角度來看是等價的。當然光有它們還不夠,不然怎麼能出現兩個參數列表和返回值一樣的函數呢。一個完整的函數還需要有個函數體和函數名。所以函數一般是下麵這種形式:
//常規函數形式
[constexpr] 返回值 函數名(參數列表)[noexcept]{
函數體
}
//返回值後置形式
auto 函數名(參數列表)->返回值
當一個函數沒有函數體的時候,我們通常稱之為函數聲明。加上函數體就是一個函數定義。
void f(int); //函數聲明
void fun(int value){ //函數定義,因為有大括弧代表的函數體
}
以上就是函數的基本框架,接下來我們分別來看一看組成它的各部分。
先說最簡單的函數名,它其實是函數這種類型的一個變數,這個變數的值表示從記憶體地址的某個位置開始的一段代碼塊。前面也說過之所以能出現兩個參數列表和返回值都相同的函數,但是編譯器能識別,其主要功勞就在函數名上,所以函數名也和變數名一樣,是一種標識符。那假如反過來,函數名相同,但是參數列表或者返回值不同呢,這種情況有個專有名詞——函數重載。基於函數是複合類型的認識,它們中只要其中一種不同就算重載。另外,在C++11,還有一種沒有名字的函數,稱為lambda表達式。lambda表達式是一種類似於直接量的函數值,就像13,'c'這種,是一種不提前定義函數,直接在調用處定義並使用的函數形式。
參數列表是前面類型定義的升級款。所有前面說的關於變數定義的都適用於它,三種形式的變數定義,多個變數,變數初始化等。不過,它們都有了新名詞。參數列表的變數稱為形式參數,初始化稱為預設參數。同樣形參在實際使用的時候需要初始化,不過初始化來自調用方。形式參數沒有預設值就需要在調用的時候提供參數,有預設值的可以省略。
int plus(int a,int b=1){ //b是一個預設參數
return a+b;
}
int main(void){
int c=plus(1); //沒有提供b的值,所以b初始化為1,結果是2
int d=plus(2,2); //a,b都初始化為2,結果是4
//int f=plus(1,2,3); //plus只有兩個形參,也就是兩個變數,沒法保存三個值,所以編譯錯誤
return 0;
}
和參數列表一樣,返回值也是一個變數,這個變數會通過return
語句返回給調用者,所以從記憶體操作來看,它是一個賦值操作。
std::string msg(){
std::string input;
std::cin>>input;
return input;
}
int main(void){
auto a=msg();
std::string b=msg();//msg返回的input複製到了b中
return 0;
}
遺憾的是C++只支持單返回值,也就是一個函數調用最多只能返回一個值,假如有多個值就只能以形參形式返回了,這種方式對於函數調用就不是很友好,所以C++提出了新的解決思路。
類
隨著業務的複雜度再次增加,函數形參個數可能會增加,或者可能需要返回多個值,然後在多個不同的函數間傳遞。這樣會導致數據容易錯亂,並且增加使用者的學習成本。
為瞭解決這些問題,工程師們提出了面向對象——多個數據打包的技術。表現在語言層面上,就是用類把一組操作和完成這組操作需要的數據打包在一起。數據作為類的屬性,操作作為類的方法,使用者通過方法操作內部數據,數據不再需要使用者自己傳遞,管理。這對於開發者無疑是大大簡化了操作。我們稱之為面向對象編程,而在函數間傳遞數據的方式稱為面向過程編程。這兩種方式底層邏輯其實是一致的,該傳遞的參數和函數調用一樣都不少,但是面向對象的區別是這些繁瑣、容易出錯的工作交給編譯器來做,開發者只需要按照面向對象的規則做好設計工作就好了,剩下的交給編譯器。至此,我們的類型系統又向上提升了一級。類不僅是多個類型的聚合體,還是多個函數的聚合體,是比函數更高級的抽象。
可以看下麵面向過程編程和麵向對象編程的代碼對比
struct Computer{
bool booted;
friend std::ostream& operator<<(std::ostream& os,const Computer & c){
os<<"Computing";
return os;
}
};
void boot(Computer& c){
c.booted=true;
std::cout<<"Booting...";
}
void compute(const Computer& c){
if(c.booted){
std::cout<<"Compute with "<<c;
}
}
void shutdown(Computer& c){
c.booted=false;
std::cout<<"Shutdown...";
}
int main(void){
auto c=Computer();
boot(c);
compute(c);
shutdown(c);
return 0;
}
面向過程最主要的表現就是,開發者需要在函數間傳遞數據,並維護數據狀態,上面例子中的數據是c
。
struct Computer{
bool booted;
friend std::ostream& operator<<(std::ostream& os,const Computer & c){
os<<"Computing";
return os;
}
void boot(){
booted=true;
std::cout<<"Booting...";
}
void compute(){
if(booted){
std::cout<<"Compute with "<<this;
}
}
void shutdown(){
booted=false;
std::cout<<"Shutdown...";
}
};
int main(void){
auto c=Computer();
c.boot();
c.compute();
c.shutdown();
return 0;
}
可以看出面向對象的代碼最主要的變化是,方法的參數變少了,但是可以在方法裡面直接訪問到類定義的數據。另一個變化發生在調用端。調用端是用數據調用方法,而不是往方法裡面傳遞數據。這也是面向對象的本質——以數據為中心。
當然,類的封裝功能只是類功能的一小部分,後面我們會涉及到更多的類知識。作為初學者,我們瞭解到這一步就能讀懂大部分代碼了。
總結
類型系統是一門語言的基本構成部分,它支撐著整個系統的高級功能,很多高級特性都是在類型系統的基礎上演化而來的。所以學習語言的類型系統有個從低到高,又從高到低的過程,從最基礎的類型開始,學習如何從低級類型構築出高級類型,然後站在高級類型的高度上,審視高級類型是怎樣由低級類型構築的。這一上一下,一高一低基本上就能把語言的大部分特性瞭解清楚了。
低級類型更偏向於讓編譯器更好地工作,高級類型偏向於讓開發者更好地工作,C++從普通類型,函數,類提供了各個層級的支持,讓開發者有更多自由的選擇,當然也就增加了開發者的學習難度。但是開發者並不是都需要所有選擇的,所以我覺得正確的學習應該是以項目規模為指導的。一些項目,完全用不到面向對象,就可以把精力放在打造好用的函數集上。而有的項目,面向對象是很好的選擇,就需要在類上花費時間。回到開頭的積木例子,選用什麼積木完全看我們想搭什麼模型,要是沒有合適的積木,我們可以自己創造。這就是C++的迷人之處。