一、C語言基礎知識入門
C語言一經出現就以其功能豐富、表達能力強、靈活方便、應用面廣等特點迅速在全世界普及和推廣。C語言不但執行效率高而且可移植性好,可以用來開發應用軟件、驅動、操作系統等。C語言也是其它眾多高級語言的鼻祖語言,所以說學習C語言是進入編程世界的必修課!
二、C語言的具體結構
簡單來說,一個C程序就是由若干頭文件
和函數
組成。
-
#include <stdio.h>
就是一條預處理命令, 它的作用是通知C語言編譯系統在對C程序進行正式編譯之前需做一些預處理工作。 -
函數
就是實現代碼邏輯的一個小的單元。
三、主函數
一個C程序有且只有一個主函數,即main函數。
- C程序就是執行主函數里的代碼,也可以說這個主函數就是C語言中的唯一入口。
- 而main前面的int就是主函數的類型.
- printf()是格式輸出函數,這里就記住它的功能就是在屏幕上輸出指定的信息
- return是函數的返回值,根據函數類型的不同,返回的值也是不同的。
- \n是轉義字符中的換行符。(注意:C程序一定是從主函數開始執行的)
四、規范
- 一個說明或一個語句占一行,例如:包含頭文件、一個可執行語句結束都需要換行。
- 函數體內的語句要有明顯縮進,通常以按一下Tab鍵為一個縮進。
- 括號要成對寫,如果需要刪除的話也要成對刪除。
- 當一句可執行語句結束的時候末尾需要有分號。
- 代碼中所有符號均為英文半角符號。
五、程序解釋——注釋
注釋是寫給程序員看的,不是寫給電腦看的。
C語言注釋方法有兩種:
多行注釋:
/* 注釋內容 */
單行注釋:
//注釋一行
六、C的標識符
C語言規定,標識符可以是字母(A~Z,a~z)、數字(0~9)、下划線_組成的字符串,並且第一個字符必須是字母或下划線。在使用標識符時還有注意以下幾點:
- 標識符的長度最好不要超過8位,因為在某些版本的C中規定標識符前8位有效,當兩個標識符前8位相同時,則被認為是同一個標識符。
- 標識符是嚴格區分大小寫的。例如Imooc和imooc 是兩個不同的標識符。
- 標識符最好選擇有意義的英文單詞組成做到"見名知意",不要使用中文。
- 標識符不能是C語言的關鍵字。想了解更多C語言關鍵字的知識。
七、變量及賦值
變量就是可以變化的量,而每個變量都會有一個名字(標識符)。變量占據內存中一定的存儲單元。使用變量之前必須先定義變量,要區分變量名和變量值是兩個不同的概念。
變量定義的一般形式為:數據類型 變量名;
多個類型相同的變量:數據類型 變量名, 變量名, 變量名…;
注意:在定義中不允許連續賦值,如int a=b=c=5;是不合法的。
變量的賦值分為兩種方式:
- 先聲明再賦值
- 聲明的同時賦值
八、基本數據類型
C語言中,數據類型可分為:
- 基本數據類型
- 構造數據類型
- 指針類型
- 空類型四大類
最常用的整型, 實型與字符型(char,int,float,double):
整型數據是指不帶小數的數字(int,short int,long int, unsigned int, unsigned short int,unsigned long int):
注:
- int short int long int是根據編譯環境的不同,所取范圍不同。
- 而其中short int和long int至少是表中所寫范圍, 但是int在表中是以16位編譯環境寫的取值范圍。
- 另外 c語言int的取值范圍在於他占用的字節數 ,不同的編譯器,規定是不一樣。
- ANSI標准定義int是占2個字節,TC是按ANSI標准的,它的int是占2個字節的。但是在VC里,一個int是占4個字節的。
浮點數據是指帶小數的數字。
生活中有很多信息適合使用浮點型數據來表示,比如:人的體重(單位:公斤)、商品價格、圓周率等等。
因為精度的不同又分為3種(float,double,long double):
九、格式化輸出語句
格式化輸出語句,也可以說是占位輸出,是將各種類型的數據按照格式化后的類型及指定的位置從計算機上顯示。
其格式為:printf("輸出格式符",輸出項)
;
當輸出語句中包含普通字符時,可以采用以下格式:
注意:格式符的個數要與變量、常量或者表達式的個數一一對應
十、常量
在程序執行過程中,值不發生改變的量稱為常量。
mtianyan: C語言的常量可以分為直接常量和符號常量。
直接常量也稱為字面量,是可以直接拿來使用,無需說明的量,比如:
- 整型常量:13、0、-13;
- 實型常量:13.33、-24.4;
- 字符常量:‘a’、‘M’
- 字符串常量:”I love imooc!”
在C語言中,可以用一個標識符來表示一個常量,稱之為符號常量。符號常量在使用之前必須先定義,其一般形式為
符號常量不可以被改變。
十一、自動類型轉換
數據類型存在自動轉換的情況.
自動轉換發生在不同數據類型運算時,在編譯的時候自動完成。
char
類型數據轉換為int
類型數據遵循ASCII
碼中的對應值.
注:
字節小的可以向字節大的自動轉換,但字節大的不能向字節小的自動轉換
char可以轉換為int,int可以轉換為double,char可以轉換為double。但是不可以反向。
十二、強制類型轉換
強制類型轉換是通過定義類型轉換運算來實現的。其一般形式為:
其作用是把表達式的運算結果強制轉換成類型說明符所表示的類型
在使用強制轉換時應注意以下問題:
- 數據類型和表達式都必須加括號, 如把(int)(x/2+y)寫成(int)x/2+y則成了把x轉換成int型之后再除2再與y相加了。
- 轉換后不會改變原數據的類型及變量值,只在本次運算中臨時性轉換。
- 強制轉換后的運算結果不遵循四舍五入原則。
十三、運算符號
C語言中運算符:
1.算術運算符
c語言基本運算符:
2.自增與自減運算符
- 自增運算符為
++
,其功能是使變量的值自增1 - 自減運算符為
--
,其功能是使變量值自減1。
它們經常使用在循環中。自增自減運算符有以下幾種形式:
3.賦值運算符
C語言中賦值運算符分為簡單賦值運算符和復合賦值運算符
簡單賦值運算符=
號了,下面講一下復合賦值運算符:
復合賦值運算符就是在簡單賦值符=
之前加上其它運算符構成.
注意:復合運算符中運算符和等號之間是不存在空格的。
4.關系運算符
C語言中的關系運算符:
關系表達式的值是真
和假
,在C程序用整數1
和0
表示。
注意:>=, <=, ==, !=
這種符號之間不能存在空格。
5.邏輯運算符
C語言中的邏輯運算符:
6.三目運算符
C語言中的三目運算符:?:
,其格式為:
表達式1 ? 表達式2 : 表達式3;
執行過程是:
先判斷表達式1的值是否為真,如果是真的話執行表達式2;如果是假的話執行表達式3。
7.運算符大比拼之優先級比較
各種運算符號的順序:
優先級別為1的優先級最高,優先級別為10的優先級別最低。
十四、分支結構
1.簡單if語句
C語言中的分支結構語句中的if條件
語句。
簡單if語句的基本結構如下:
其語義是:如果表達式的值為真,則執行其后的語句,否則不執行該語句。
注意:if()
后面沒有分號,直接寫{}
2.if-else語句
簡單的if-else
語句的基本結構:
語義是: 如果表達式的值為真,則執行代碼塊1,否則執行代碼塊2。
3.多重if-else語句
C語言中多重if-else
語句,其結構如下:
語義是:依次判斷表達式的值,當出現某個值為真時,則執行對應代碼塊,否則執行代碼塊n。
注意:當某一條件為真的時候,則不會向下執行該分支結構的其他語句。
4.嵌套if-else語句
C語言中嵌套if-else
語句。嵌套if-else
語句的意思,就是在if-else
語句中,再寫if-else
語句。其一般形式為:
十五、循環結構
1.while循環
反復不停的執行某個動作就是江湖人稱的循環 。
C語言中有三種循環結構,先看一下C語言while循環的結構
其中表達式表示循環條件,執行代碼塊為循環體。
while語句的語義是:計算表達式的值,當值為真(非0)時, 執行循環體代碼塊。
- while語句中的表達式一般是關系表達或邏輯表達式,當表達式的值為假時不執行循環體,反之則循環體一直執行。
- 一定要記着在循環體中改變循環變量的值,否則會出現死循環(無休止的執行)。
- 循環體如果包括有一個以上的語句,則必須用{}括起來,組成復合語句。
2.do-while循環
C語言中的do-while
循環,一般形式如下:
do-while循環語句的語義是:
它先執行循環中的執行代碼塊,然后再判斷while中表達式是否為真,如果為真則繼續循環;如果為假,則終止循環。因此,do-while循環至少要執行一次循環語句。
注意:mtianyan: 使用do-while結構語句時,while括號后必須有分號。
3.for循環
c語言中for循環一般形式:
它的執行過程如下:
- 執行表達式1,對循環變量做初始化;
- 判斷表達式2,若其值為真(非0),則執行for循環體中執行代碼塊,然后向下執行;若其值為假(0),則結束循環;
- 執行表達式3,(i++)等對於循環變量進行操作的語句;
- 執行for循環中執行代碼塊后執行第二步;第一步初始化只會執行一次。
- 循環結束,程序繼續向下執行。
注意:for循環中的兩個分號一定要寫
在for循環中:
- 表達式1是一個或多個賦值語句,它用來控制變量的初始值;
- 表達式2是一個關系表達式,它決定什么時候退出循環;
- 表達式3是循環變量的步進值,定義控制循環變量每循環一次后按什么方式變化。
- 這三部分之間用分號 ; 分開。
使用for語句應該注意:
- for循環中的“表達式1、2、3”均可不寫為空,但兩個分號(;;)不能缺省。
- 省略“表達式1(循環變量賦初值)”,表示不對循環變量賦初始值。
- 省略“表達式2(循環條件)”,不做其它處理,循環一直執行(死循環)。
- 省略“表達式3(循環變量增減量)”,不做其他處理,循環一直執行(死循環)。
- 表達式1可以是設置循環變量的初值的賦值表達式,也可以是其他表達式。
- 表達式1和表達式3可以是一個簡單表達式也可以是多個表達式以逗號分割。
- 表達式2一般是關系表達式或邏輯表達式,但也可是數值表達式或字符表達式,只要其值非零,就執行循環體。
- 各表達式中的變量一定要在for循環之前定義。
3.三種循環比較
while, do-while和for三種循環在具體的使用場合上是有區別的,如下:
- 在知道循環次數的情況下更適合使用for循環;
- 在不知道循環次數的情況下適合使用while或者do-while循環:
- 如果有可能一次都不循環應考慮使用while循環
- 如果至少循環一次應考慮使用do-while循環。
- 但是從本質上講,while,do-while和for循環之間是可以相互轉換的。
4.多重循環
多重循環就是在循環結構的循環體中又出現循環結構。
在實際開發中一般最多用到三層重循環。
因為循環層數越多,運行時間越長,程序越復雜,所以一般用2-3層多重循環就可以了。另外不同循環之間也是可以嵌套的。
多重循環在執行的過程中,外層循環為父循環,內層循環為子循環,
**父循環一次,子循環需要全部執行完,直到跳出循環。**父循環再進入下一次,子循環繼續執行…
十六、結束語句
1.break語句
那么循環5次的時候,需要中斷不繼續訓練。在C語言中,可以使用break
語句進行該操作.
使用break
語句時注意以下幾點:
- 在沒有循環結構的情況下,break不能用在單獨的if-else語句中。
- 在多層循環中,一個break語句只跳出當前循環。
2.continue語句
那么循環5次的時候,需要中斷后繼續訓練。在C語言中,可以使用continue語句進行該操作
continue語句的作用是結束本次循環開始執行下一次循環。
break語句與continue語句的區別是:
break是跳出當前整個循環,continue是結束本次循環開始下一次循環。
十七、局部與全局
C語言中的變量,按作用域范圍可分為兩種,即局部變量和全局變量。局部變量也稱為內部變量。局部變量是在函數內作定義說明的。其作用域僅限於函數內, 離開該函數后再使用這種變量是非法的。在復合語句中也可定義變量,其作用域只在復合語句范圍內。
全局變量也稱為外部變量,它是在函數外部定義的變量。它不屬於哪一個函數,它屬於一個源程序文件。其作用域是整個源程序。
十八、變量存儲類別
mtianyan: C語言根據變量的生存周期來划分,可以分為靜態存儲方式和動態存儲方式。
靜態存儲方式:是指在程序運行期間分配固定的存儲空間的方式。靜態存儲區中存放了在整個程序執行過程中都存在的變量,如全局變量。
動態存儲方式:是指在程序運行期間根據需要進行動態的分配存儲空間的方式。動態存儲區中存放的變量是根據程序運行的需要而建立和釋放的,通常包括:函數形式參數;自動變量;函數調用時的現場保護和返回地址等。
C語言中存儲類別又分為四類:
- 自動(auto)、
- 靜態(static)、
- 寄存器的(register)
- 外部的(extern)。
十九、內部函數與外部函數
在C語言中不能被其他源文件調用的函數稱謂內部函數 ,內部函數由static關鍵字來定義,因此又被稱謂靜態函數,形式為:
static [數據類型] 函數名([參數])
這里的static是對函數的作用范圍的一個限定,限定該函數只能在其所處的源文件中使用,因此在不同文件中出現相同的函數名稱的內部函數是沒有問題的。
在C語言中能被其他源文件調用的函數稱謂外部函數 ,外部函數由extern關鍵字來定義,形式為:
extern [數據類型] 函數名([參數])
C語言規定,在沒有指定函數的作用范圍時,系統會默認認為是外部函數,因此當需要定義外部函數時extern也可以省略。
靜態變量只賦值一次
二十、數組初體驗
程序中也需要容器,只不過該容器有點特殊,它在程序中是一塊連續的,大小固定並且里面的數據類型一致的內存空間,它還有個好聽的名字叫數組。可以將數組理解為大小固定,所放物品為同類的一個購物袋,在該購
物袋中的物品是按一定順序放置的。
1.我們來看一下如何聲明一個數組:
數據類型 數組名稱[長度];
數組只聲明也不行啊,看一下數組是如何初始化的。說到初始化,C語言中的數組初始化是有三種形式的,分別是:
- 數據類型 數組名稱[長度n] = {元素1,元素2…元素n};
- 數據類型 數組名稱[] = {元素1,元素2…元素n};
- 數據類型 數組名稱[長度n]; 數組名稱[0] = 元素1; 數組名稱[1] = 元素2; 數組名稱[n-1] = 元素n;
我們將數據放到數組中之后又如何獲取數組中的元素呢?
獲取數組元素時: 數組名稱[元素所對應下標];
如:初始化一個數組 int arr[3] = {1,2,3}; 那么arr[0]就是元素1。
注意:
數組的下標均以0開始;
數組在初始化的時候,數組內元素的個數不能大於聲明的數組長度;
mtianyan: 如果采用第一種初始化方式,元素個數小於數組的長度時,多余的數組元素初始化為0;
在聲明數組后沒有進行初始化的時候,靜態(static)和外部(extern)類型的數組元素初始化元素為0,自動(auto)類型的數組的元素初始化值不確定。
2.數組的遍歷
數組就可以采用循環的方式將每個元素遍歷出來,而不用人為的每次獲取指定某個位置上的元素,例如我們用for循環遍歷一個數組:
注意以下幾點:
- 最好避免出現數組越界訪問,循環變量最好不要超出數組的長度.
- C語言的數組長度一經聲明,長度就是固定,無法改變,並且C語言並不提供計算數組長度的方法。
由於C語言是沒有檢查數組長度改變或者數組越界的這個機制,可能會在編輯器中編譯並通過,但是結果就不能肯定了,因此還是不要越界或者改變數組的長度
3.數組作為函數參數
數組可以由整個數組當作函數的參數,也可以由數組中的某個元素當作函數的參數:
整個數組當作函數參數,即把數組名稱傳入函數中,例如:
數組中的元素當作函數參數,即把數組中的參數傳入函數中,例如:
數組作為函數參數時注意以下事項:
- 數組名作為函數實參傳遞時,函數定義處作為接收參數的數組類型形參既可以指定長度也可以不指定長度。
- 數組元素作為函數實參傳遞時,數組元素類型必須與形參數據類型一致。
4.字符串與數組
C語言中,是沒有辦法直接定義字符串數據類型的,但是我們可以使用數組來定義我們所要的字符串。一般有以下兩種格式:
- char 字符串名稱[長度] = “字符串值”;
- char 字符串名稱[長度] = {‘字符1’,‘字符2’,…,‘字符n’,’\0’};
注意:
- []中的長度是可以省略不寫的;
- 采用第2種方式的時候最后一個元素必須是’\0’,’\0’表示字符串的結束標志;
- 采用第2種方式的時候在數組中不能寫中文。
- 在輸出字符串的時候要使用:printf(“%s”,字符數組名字);或者puts(字符數組名字);。
5.mtianyan:字符串函數
常用的字符串函數如下(strlen,strcmp,strcpy,strcat,atoi):
使用字符串函數注意以下事項:
- strlen()獲取字符串的長度,在字符串長度中是不包括‘\0’而且漢字和字母的長度是不一樣的
- strcmp()在比較的時候會把字符串先轉換成ASCII碼再進行比較,返回的結果為0表示s1和s2的ASCII碼相等,返回結果為1表示s1比s2的ASCII碼大,返回結果為-1表示s1比s2的ASCII碼小
- strcpy()拷貝之后會覆蓋原來字符串且不能對字符串常量進行拷貝
- strcat在使用時s1與s2指的內存空間不能重疊,且s1要有足夠的空間來容納要復制的字符串
6.多維數組
多維數組的定義格式是:
數據類型 數組名稱[常量表達式1][常量表達式2]…[常量表達式n];
定義了一個名稱為num,數據類型為int的二維數組。其中第一個[3]表示第一維下標的長度,就像購物時分類存放的購物;第二個[3]表示第二維下標的長度,就像每個購物袋中的元素。
多維數組的初始化與一維數組的初始化類似也是分兩種:
- 數據類型 數組名稱[常量表達式1][常量表達式2]…[常量表達式n] = {{值1,…,值n},{值1,…,值n},…,{值1,…,值n}};
- 數據類型 數組名稱[常量表達式1][常量表達式2]…[常量表達式n]; 數組名稱[下標1][下標2]…[下標n] = 值;
多維數組初始化要注意以下事項:
- 采用第一種始化時數組聲明必須指定列的維數。mtianyan: 因為系統會根據數組中元素的總個數來分配空間,當知道元素總個數以及列的維數后,會直接計算出行的維數;
- 采用第二種初始化時數組聲明必須同時指定行和列的維數。
二維數組定義的時候,可以不指定行的數量,但是必須指定列的數量
二十一、C語言最核心的指針
說到指針,就不可能脫離開內存,學會指針的人分為兩種,一種是不了解內存模型,另外一種則是了解。
不了解的對指針的理解就停留在“指針就是變量的地址”這句話,會比較害怕使用指針,特別是各種高級操作。
而了解內存模型的則可以把指針用得爐火純青!
想學好C語言,很關鍵就是搞懂內存、指針、還有各種編譯鏈接,
1、內存本質
編程的本質其實就是操控數據,數據存放在內存中。
因此,如果能更好地理解內存的模型,以及 C 如何管理內存,就能對程序的工作原理洞若觀火,從而使編程能力更上一層樓。
大家真的別認為這是空話,我大一整年都不敢用 C 寫上千行的程序也很抗拒寫 C。
因為一旦上千行,經常出現各種莫名其妙的內存錯誤,一不小心就發生了 coredump...... 而且還無從排查,分析不出原因。
相比之下,那時候最喜歡 Java,在 Java 里隨便怎么寫都不會發生類似的異常,頂多偶爾來個 NullPointerException,也是比較好排查的。
直到后來對內存和指針有了更加深刻的認識,才慢慢會用 C 寫上千行的項目,也很少會再有內存問題了。(過於自信
「指針存儲的是變量的內存地址」這句話應該任何講 C 語言的書都會提到吧。
所以,要想徹底理解指針,首先要理解 C 語言中變量的存儲本質,也就是內存。
(1)內存編址
計算機的內存是一塊用於存儲數據的空間,由一系列連續的存儲單元組成,就像下面這樣,
每一個單元格都表示 1 個 Bit,一個 bit 在 EE 專業的同學看來就是高低電位,而在 CS 同學看來就是 0、1 兩種狀態。
由於 1 個 bit 只能表示兩個狀態,所以大佬們規定 8個 bit 為一組,命名為 byte。
並且將 byte 作為內存尋址的最小單元,也就是給每個 byte 一個編號,這個編號就叫內存的地址。
這就相當於,我們給小區里的每個單元、每個住戶都分配一個門牌號,在生活中,我們需要保證門牌號唯一,這樣就能通過門牌號很精准的定位到一家人。
同樣,在計算機中,我們也要保證給每一個 byte 的編號都是唯一的,這樣才能夠保證每個編號都能訪問到唯一確定的 byte。
(2)內存地址空間
上面我們說給內存中每個 byte 唯一的編號,那么這個編號的范圍就決定了計算機可尋址內存的范圍。
所有編號連起來就叫做內存的地址空間,這和大家平時常說的電腦是 32 位還是 64 位有關。
早期 Intel 8086、8088 的 CPU 就是只支持 16 位地址空間,寄存器和地址總線都是 16 位,這意味着最多對 2^16 = 64 Kb
的內存編號尋址。
這點內存空間顯然不夠用,后來,80286 在 8086 的基礎上將地址總線和地址寄存器擴展到了20 位,也被叫做 A20 地址總線。
當時在寫 mini os 的時候,還需要通過 BIOS 中斷去啟動 A20 地址總線的開關。
但是,現在的計算機一般都是 32 位起步了,32 位意味着可尋址的內存范圍是 2^32 byte = 4GB
。
所以,如果你的電腦是 32 位的,那么你裝超過 4G 的內存條也是無法充分利用起來的。
好了,這就是內存和內存編址。
(3)變量的本質
有了內存,接下來我們需要考慮,int、double 這些變量是如何存儲在 0、1 單元格的。
在 C 語言中我們會這樣定義變量:
當你寫下一個變量定義的時候,實際上是向內存申請了一塊空間來存放你的變量。
我們都知道 int 類型占 4 個字節,並且在計算機中數字都是用補碼(不了解補碼的記得去百度)表示的。
999 換算成補碼就是:0000 0011 1110 0111
這里有 4 個byte,所以需要四個單元格來存儲:
有沒有注意到,我們把高位的字節放在了低地址的地方,那能不能反過來呢?
當然,這就引出了大端和小端。
像上面這種將高位字節放在內存低地址的方式叫做大端,反之,將低位字節放在內存低地址的方式就叫做小端。
上面只說明了 int 型的變量如何存儲在內存,而 float、char 等類型實際上也是一樣的,都需要先轉換為補碼。
對於多字節的變量類型,還需要按照大端或者小端的格式,依次將字節寫入到內存單元。
記住上面這兩張圖,這就是編程語言中所有變量的在內存中的樣子,不管是 int、char、指針、數組、結構體、對象... 都是這樣放在內存的。
2、指針是什么啥?
變量放在哪?上面我說,定義一個變量實際就是向計算機申請了一塊內存來存放。
那如果我們要想知道變量到底放在哪了呢?可以通過運算符&
來取得變量實際的地址,這個值就是變量所占內存塊的起始地址。
PS: 實際上這個地址是虛擬地址,並不是真正物理內存上的地址
我們可以把這個地址打印出來
大概會是像這樣的一串數字:0x7ffcad3b8f3c
上面說,我們可以通過&
符號獲取變量的內存地址,那獲取之后如何來表示這是一個地址,而不是一個普通的值呢?
也就是在 C 語言中如何表示地址這個概念呢?
對,就是指針,你可以這樣
pa 中存儲的就是變量 a 的地址,也叫做指向 a 的指針。
在這里我想談幾個看起來有點無聊的話題:
為什么我們需要指針?直接用變量名不行嗎?
當然可以,但是變量名是有局限的。
變量名的本質是什么?
是變量地址的符號化,變量是為了讓我們編程時更加方便,對人友好,可計算機可不認識什么變量 a,它只知道地址和指令。
所以當你去查看 C 語言編譯后的匯編代碼,就會發現變量名消失了,取而代之的是一串串抽象的地址。
你可以認為,編譯器會自動維護一個映射,將我們程序中的變量名轉換為變量所對應的地址,然后再對這個地址去進行讀寫。
也就是有這樣一個映射表存在,將變量名自動轉化為地址:
說的好!
可是我還是不知道指針存在的必要性,那么問題來了,看下面代碼:
假設我有一個需求:
要求在
func
函數里要能夠修改main
函數里的變量a
,這下咋整,在main
函數里可以直接通過變量名去讀寫a
所在內存。但是在
func
函數里是看不見a
的呀。
你說可以通過&
取地址符號,將 a
的地址傳遞進去:
這樣在func
里就能獲取到 a
的地址,進行讀寫了。
理論上這是完全沒有問題的,但是問題在於:
編譯器該如何區分一個 int 里你存的到底是 int 類型的值,還是另外一個變量的地址(即指針)。
這如果完全靠我們編程人員去人腦記憶了,會引入復雜性,並且無法通過編譯器檢測一些語法錯誤。
而通過int *
去定義一個指針變量,會非常明確:這就是另外一個 int 型變量的地址。
編譯器也可以通過類型檢查來排除一些編譯錯誤。
這就是指針存在的必要性。
實際上任何語言都有這個需求,只不過很多語言為了安全性,給指針戴上了一層枷鎖,將指針包裝成了引用。
可能大家學習的時候都是自然而然的接受指針這個東西,但是還是希望這段啰嗦的解釋對你有一定啟發。
同時,在這里提點小問題:
既然指針的本質都是變量的內存首地址,即一個 int 類型的整數。
那為什么還要有各種類型呢?
比如 int 指針,float 指針,這個類型影響了指針本身存儲的信息嗎?
這個類型會在什么時候發揮作用?
解引用
上面的問題,就是為了引出指針解引用的。
pa
中存儲的是a
變量的內存地址,那如何通過地址去獲取a
的值呢?
這個操作就叫做解引用,在 C 語言中通過運算符 *
就可以拿到一個指針所指地址的內容了。
比如*pa
就能獲得a
的值。
我們說指針存儲的是變量內存的首地址,那編譯器怎么知道該從首地址開始取多少個字節呢?
這就是指針類型發揮作用的時候,編譯器會根據指針的所指元素的類型去判斷應該取多少個字節。
如果是 int 型的指針,那么編譯器就會產生提取四個字節的指令,char 則只提取一個字節,以此類推。
下面是指針內存示意圖:
pa
指針首先是一個變量,它本身也占據一塊內存,這塊內存里存放的就是 a
變量的首地址。
當解引用的時候,就會從這個首地址連續划出 4 個 byte,然后按照 int 類型的編碼方式解釋。
別看這個地方很簡單,但卻是深刻理解指針的關鍵。
舉兩個例子來詳細說明:
比如:
你能解釋清楚上面過程,對於 f
變量,在內存層面發生了什么變化嗎?或者 c
的值是多少?1 ?
實際上,從內存層面來說,f
什么都沒變。
如圖:
假設這是f
在內存中的位模式,這個過程實際上就是把 f
的前兩個 byte 取出來然后按照 short 的方式解釋,然后賦值給 c
。
詳細過程如下:
上面第二步什么都沒做,這個表達式只是說 :
“噢,我認為f
這個地址放的是一個 short 類型的變量”
最后當去解引用的時候*(short*)&f
時,編譯器會取出前面兩個字節,並且按照 short 的編碼方式去解釋,並將解釋出的值賦給 c
變量。
這個過程 f
的位模式沒有發生任何改變,變的只是解釋這些位的方式。
當然,這里最后的值肯定不是 1,至於是什么,大家可以去真正算一下。
那反過來,這樣呢?
如圖:
具體過程和上述一樣,但上面肯定不會報錯,這里卻不一定。
為什么?
(float*)&c
會讓我們從c
的首地址開始取四個字節,然后按照 float 的編碼方式去解釋。
但是c
是 short 類型只占兩個字節,那肯定會訪問到相鄰后面兩個字節,這時候就發生了內存訪問越界。
當然,如果只是讀,大概率是沒問題的。
但是,有時候需要向這個區域寫入新的值,比如:
那么就可能發生 coredump,也就是訪存失敗。
另外,就算是不會 coredump,這種也會破壞這塊內存原有的值,因為很可能這是是其它變量的內存空間,而我們去覆蓋了人家的內容,肯定會導致隱藏的 bug。
如果你理解了上面這些內容,那么使用指針一定會更加的自如。
3、結構體和指針
結構體內包含多個成員,這些成員之間在內存中是如何存放的呢?
比如:
這是一個定點小數結構體,它在內存占 8 個字節(這里不考慮內存對齊),兩個成員域是這樣存儲的:
我們把 10 放在了結構體中基地址偏移為 0 的域,2 放在了偏移為 4 的域。
接下來我們做一個這樣的操作:
上面這個究竟會輸出多少呢?自己先思考下噢~
接下來我分析下這個過程發生了什么:
首先,&fp.denom
表示取結構體 fp 中 denom 域的首地址,然后以這個地址為起始地址取 8 個字節,並且將它們看做一個 fraction 結構體。
在這個新結構體中,最上面四個字節變成了 denom 域,而 fp 的 denom 域相當於新結構體的 num 域。
因此:
((fraction*)(&fp.denom))->num = 5
實際上改變的是 fp.denom
,而
((fraction*)(&fp.denom))->denom = 12
則是將最上面四個字節賦值為 12。
當然,往那四字節內存寫入值,結果是無法預測的,可能會造成程序崩潰,因為也許那里恰好存儲着函數調用棧幀的關鍵信息,也可能那里沒有寫入權限。
大家初學 C 語言的很多 coredump 錯誤都是類似原因造成的。
所以最后輸出的是 5。
為什么要講這種看起來莫名其妙的代碼?
就是為了說明結構體的本質其實就是一堆的變量打包放在一起,而訪問結構體中的域,就是通過結構體的起始地址,也叫基地址,然后加上域的偏移。
其實,C++、Java 中的對象也是這樣存儲的,無非是他們為了實現某些面向對象的特性,會在數據成員以外,添加一些 Head 信息,比如C++ 的虛函數表。
實際上,我們是完全可以用 C 語言去模仿的。
這就是為什么一直說 C 語言是基礎,你真正懂了 C 指針和內存,對於其它語言你也會很快的理解其對象模型以及內存布局。
4、多級指針
說起多級指針這個東西,我以前上學的時候最多理解到 2 級,再多真的會把我繞暈,經常也會寫錯代碼。
你要是給我寫個這個:int ******p
能把我搞崩潰,我估計很多同學現在就是這種情況🤣
其實,多級指針也沒那么復雜,就是指針的指針的指針的指針......非常簡單。
今天就帶大家認識一下多級指針的本質。
首先,我要說一句話,沒有多級指針這種東西,指針就是指針,多級指針只是為了我們方便表達而取的邏輯概念。
首先看下生活中的快遞櫃:
這種大家都用過吧,每個格子都有一個編號,我們只需要拿到編號,然后就能找到對應的格子,取出里面的東西。
這里的格子就是內存單元,編號就是地址,格子里放的東西就對應存儲在內存中的內容。
假設我把一本書,放在了 03 號格子,然后把 03 這個編號告訴你,你就可以根據 03 去取到里面的書。
那如果我把書放在 05 號格子,然后在 03 號格子只放一個小紙條,上面寫着:「書放在 05 號」。
你會怎么做?
當然是打開 03 號格子,然后取出了紙條,根據上面內容去打開 05 號格子得到書。
這里的 03 號格子就叫指針,因為它里面放的是指向其它格子的小紙條(地址)而不是具體的書。
明白了嗎?
那我如果把書放在 07 號格子,然后在 05 號格子 放一個紙條:「書放在 07號」,同時在03號格子放一個紙條「書放在 05號」
這里的 03 號格子就叫二級指針,05 號格子就叫指針,而 07 號就是我們平常用的變量。
依次,可類推出 N 級指針。
所以你明白了嗎?同樣的一塊內存,如果存放的是別的變量的地址,那么就叫指針,存放的是實際內容,就叫變量。
上面這段代碼,pa
就叫一級指針,也就是平時常說的指針,ppa
就是二級指針。
內存示意圖如下:
不管幾級指針有兩個最核心的東西:
-
指針本身也是一個變量,需要內存去存儲,指針也有自己的地址
-
指針內存存儲的是它所指向變量的地址
這就是我為什么多級指針是邏輯上的概念,實際上一塊內存要么放實際內容,要么放其它變量地址,就這么簡單。
怎么去解讀int **a
這種表達呢?
int ** a
可以把它分為兩部分看,即int*
和 *a
,后面 *a
中的*
表示 a
是一個指針變量,前面的 int*
表示指針變量a
只能存放 int*
型變量的地址。
對於二級指針甚至多級指針,我們都可以把它拆成兩部分。
首先不管是多少級的指針變量,它首先是一個指針變量,指針變量就是一個*
,其余的*
表示的是這個指針變量只能存放什么類型變量的地址。
比如int****a
表示指針變量 a
只能存放int***
型變量的地址。
5、指針與數組
(1)一維數組
數組是 C 自帶的基本數據結構,徹底理解數組及其用法是開發高效應用程序的基礎。
數組和指針表示法緊密關聯,在合適的上下文中可以互換。
如下:
在內存中,數組是一塊連續的內存空間:
第 0 個元素的地址稱為數組的首地址,數組名實際就是指向數組首地址,當我們通過array[1]
或者*(array + 1)
去訪問數組元素的時候。
實際上可以看做 address[offset]
,address
為起始地址,offset
為偏移量,但是注意這里的偏移量offset
不是直接和 address
相加,而是要乘以數組類型所占字節數,也就是: address + sizeof(int) * offset
。
學過匯編的同學,一定對這種方式不陌生,這是匯編中尋址方式的一種:基址變址尋址。
看完上面的代碼,很多同學可能會認為指針和數組完全一致,可以互換,這是完全錯誤的。
盡管數組名字有時候可以當做指針來用,但數組的名字不是指針。
最典型的地方就是在 sizeof:
第一個將會輸出 40,因為 array
包含有 10 個int類型的元素,而第二個在 32 位機器上將會輸出 4,也就是指針的長度。
為什么會這樣呢?
站在編譯器的角度講,變量名、數組名都是一種符號,它們都是有類型的,它們最終都要和數據綁定起來。
變量名用來指代一份數據,數組名用來指代一組數據(數據集合),它們都是有類型的,以便推斷出所指代的數據的長度。
對,數組也有類型,我們可以將 int、float、char 等理解為基本類型,將數組理解為由基本類型派生得到的稍微復雜一些的類型,
數組的類型由元素的類型和數組的長度共同構成。而 sizeof
就是根據變量的類型來計算長度的,並且計算的過程是在編譯期,而不會在程序運行時。
編譯器在編譯過程中會創建一張專門的表格用來保存變量名及其對應的數據類型、地址、作用域等信息。
sizeof
是一個操作符,不是函數,使用 sizeof
時可以從這張表格中查詢到符號的長度。
所以,這里對數組名使用sizeof
可以查詢到數組實際的長度。
pa
僅僅是一個指向 int 類型的指針,編譯器根本不知道它指向的是一個整數,還是一堆整數。
雖然在這里它指向的是一個數組,但數組也只是一塊連續的內存,沒有開始和結束標志,也沒有額外的信息來記錄數組到底多長。
所以對 pa
使用 sizeof
只能求得的是指針變量本身的長度。
也就是說,編譯器並沒有把 pa
和數組關聯起來,pa
僅僅是一個指針變量,不管它指向哪里,sizeof
求得的永遠是它本身所占用的字節數。
(2)二維數組
大家不要認為二維數組在內存中就是按行、列這樣二維存儲的,實際上,不管二維、三維數組... 都是編譯器的語法糖。
存儲上和一維數組沒有本質區別,舉個例子:
或許你以為在內存中 array
數組會像一個二維矩陣:
可實際上它是這樣的:
和一維數組沒有什么區別,都是一維線性排列。
當我們像 array[1][1]
這樣去訪問的時候,編譯器會怎么去計算我們真正所訪問元素的地址呢?
為了更加通用化,假設數組定義是這樣的:
int array[n][m]
訪問: array[a][b]
那么被訪問元素地址的計算方式就是: array + (m * a + b)
這個就是二維數組在內存中的本質,其實和一維數組是一樣的,只是語法糖包裝成一個二維的樣子。
6、 void 指針
想必大家一定看到過 void 的這些用法:
在這些情況下,void 表達的意思就是沒有返回值或者參數為空。
但是對於 void 型指針卻表示通用指針,可以用來存放任何數據類型的引用。
下面的例子就 是一個 void 指針:
void 指針最大的用處就是在 C 語言中實現泛型編程,因為任何指針都可以被賦給 void 指針,void 指針也可以被轉換回原來的指針類型, 並且這個過程指針實際所指向的地址並不會發生變化。
比如:
這兩次輸出的值都會是一樣:
平常可能很少會這樣去轉換,但是當你用 C 寫大型軟件或者寫一些通用庫的時候,一定離不開 void 指針,這是 C 泛型的基石,比如 std 庫里的 sort 函數申明是這樣的:
所有關於具體元素類型的地方全部用 void 代替。
void 還可以用來實現 C 語言中的多態,這是一個挺好玩的東西。
不過也有需要注意的,不能對 void 指針解引用
比如:
為什么?
因為解引用的本質就是編譯器根據指針所指的類型,然后從指針所指向的內存連續取 N 個字節,然后將這 N 個字節按照指針的類型去解釋。
比如 int *型指針,那么這里 N 就是 4,然后按照 int 的編碼方式去解釋數字。
但是 void,編譯器是不知道它到底指向的是 int、double、或者是一個結構體,所以編譯器沒法對 void 型指針解引用。
關於指針想寫的內容還有很多,這其實也只算是開了個頭,限於篇幅,以后有機會補齊以下內容:
-
二維數組和二維指針
-
數組指針和指針數組
-
指針運算
-
函數指針
-
動態內存分配: malloc 和 free
-
堆、棧
-
函數參數傳遞方式
-
內存泄露
-
數組退化成指針
-
const 修飾指針
-
...
到此基本上涵蓋了 C 語言的全部基本知識。