C語言探索之旅 | 第二部分第八課:動態分配

ciscopuke發表於2021-09-09

圖片描述

作者 謝恩銘,慕課網精英講師 Oscar老師。

內容簡介


  1. 前言
  2. 變數的大小
  3. 記憶體的動態分配
  4. 動態分配一個陣列
  5. 總結
  6. 第二部分第九課預告

1. 前言


上一課是 。

經歷了第二部分的一些難點課程,我們終於來到了這一課,一個聽起來有點酷酷的名字:動態分配

“萬水千山總是情,分配也由系統定”。

到目前為止,我們建立的變數都是系統的編譯器為我們自動構建的,這是簡單的方式。

其實還有一種更偏手動的建立變數的方式,我們稱為“動態分配”(Dynamic Allocation)。dynamic 表示“動態的”,allocation 表示“分配”。

動態分配的一個主要好處就是可以在記憶體中“預置”一定空間大小,在編譯時還不知道到底會用多少。

使用這個技術,我們可以建立大小可變的陣列。到目前為止我們所建立的陣列都是大小固定不可變的。而學完這一課後我們就會建立所謂“動態陣列”了。

我們知道當我們建立一個變數時,在記憶體中要為其分配一定大小的空間。例如:

int number = 2;

當程式執行到這一行程式碼時,會發生幾件事情:

  1. 應用程式詢問作業系統(Operating System,簡稱 OS。例如Windows,Linux,macOS,Android,iOS,等)是否可以使用一小塊記憶體空間。

  2. 作業系統回覆我們的程式,告訴它可以將這個變數儲存在記憶體中哪個地方(給出分配的記憶體地址)。

  3. 當函式結束後,你的變數會自動從記憶體中被刪除。你的程式對作業系統說:“我已經不需要記憶體中的這塊地址了,謝謝!” (當然,實際上你的程式不可能對作業系統說一聲“謝謝”,但是確實是作業系統在掌管一切,包括記憶體,所以對它還是客氣一點比較好…)。

可以看到,以上的過程都是自動的。當我們建立一個變數,作業系統就會自動被程式這樣呼叫。

那麼什麼是手動的方式呢?說實在的,沒人喜歡把事情複雜化,如果自動方式可行,何必要大費周章來使用什麼手動方式呢?但是要知道,很多時候我們是不得不使用手動方式。

這一課中,我們將會:

  1. 探究記憶體的機制(是的,雖然以前的課研究過,但是還是要繼續深入),瞭解不同變數型別所佔用的記憶體大小。

  2. 接著,探究這一課的主題,來學習如何向作業系統動態請求記憶體。也就是所謂的“動態記憶體分配”。

  3. 最後,透過學習如何建立一個在編譯時還不知道其大小(只有在程式執行時才知道)的陣列來了解動態記憶體分配的好處。

準備好了嗎?Let’s Go !

2. 變數的大小


根據我們所要建立的變數的型別(char,int,double,等等),其所佔的記憶體空間大小是不一樣的。

事實上,為了儲存一個大小在 -128 至 127 之間的數(char 型別),只需要佔用一個位元組(8 個二進位制位)的記憶體空間,是很小的。

然而,一個 int 型別的變數就要佔據 4 個位元組了;一個 double 型別要佔據 8 個位元組。

問題是:並不總是這樣。

什麼意思呢?

因為型別所佔記憶體的大小還與作業系統有關係。不同的作業系統可能就不一樣,32 位和 64 位的作業系統的型別大小一般會有區別。

這一節中我們的目的是學習如何獲知變數所佔用的記憶體大小。

有一個很簡單的方法:使用 sizeof()

雖然看著有點像函式,但其實 sizeof 不是一個函式,而是一個 C語言的關鍵字,也算是一個運算子吧。

我們只需要在 sizeof 的括號裡填入想要檢測的變數型別,sizeof 就會返回所佔用的位元組數了。

例如,我們要檢測 int 型別的大小,就可以這樣寫:

sizeof(int)

在編譯時,sizeof(int) 就會被替換為 int 型別所佔用的位元組數了。

在我的電腦上,sizeof(int) 是 4,也就是說 int 型別在我的電腦的記憶體中佔據 4 個位元組。在你的電腦上,也許是 4,但也可能是其他的值。

我們用一個例子來測試一下吧:

// octet 是英語“位元組”的意思,和 byte 類似
printf("char : %d octetsn", sizeof(char));
printf("int : %d octetsn", sizeof(int));
printf("long : %d octetsn", sizeof(long));
printf("double : %d octetsn", sizeof(double));

在我的電腦(64 位)執行,輸出:

char : 1 octets
int : 4 octets
long : 8 octets
double : 8 octets

我們並沒有測試所有已知的變數型別,你也可以課後自己去測試一下其他的型別,例如:short,float。

曾幾何時,當電腦的記憶體很小的年代,有這麼多不同大小的變數型別可供選擇是一件很好的事,因為我們可以選“夠用的最小的”那種變數型別,以節約記憶體。

現在,電腦的記憶體一般都很大,“有錢任性”麼。所以我們在程式設計時也沒必要太“拘謹”。不過在嵌入式領域,記憶體大小一般是有限的,我們就得斟酌著使用變數型別了。

既然 sizeof 這麼好用,我們可不可以用它來顯示我們自定義的變數型別的大小呢?例如 struct,enum,union。

是可以的。寫一個程式測試一下:

#include <stdio.h>

typedef struct Coordinate
{
    int x;
    int y;
} Coordinate;

int main(int argc, char *argv[])
{
    printf("Coordinate 結構體的大小是 : %d 個位元組n", sizeof(Coordinate));

    return 0;
}

執行輸出:

Coordinate 結構體的大小是 : 8 個位元組

對於記憶體的全新視角


之前,我們在繪製記憶體圖示時,還是比較不精準的。現在,我們知道了每個變數所佔用的大小,我們的記憶體圖示就可以變得更加精準了。

假如我定義一個 int 型別的變數:

int age = 17;

我們用 sizeof 測試後得知 int 的大小為 4。假設我們的變數 age 被分配到的記憶體地址起始是 1700,那麼我們的記憶體圖示就如下所示:

圖片描述

我們看到,我們的 int 型變數 age 在記憶體中佔用 4 個位元組,起始地址是 1700(它的記憶體地址),一直到 1703。

如果我們對一個 char 型變數(大小是一個位元組)同樣賦值:

char number = 17;

那麼,其記憶體圖示是這樣的:

圖片描述

假如是一個 int 型的陣列:

int age[100];

用 sizeof() 測試一下,就可以知道在記憶體中 age 陣列佔用 400 個位元組。4 * 100 = 400。

即使這個陣列沒有賦初值,但是在記憶體中仍然佔據 400 個位元組的空間。變數一宣告,在記憶體中就為它分配一定大小的記憶體了。

那麼,如果我們建立一個型別是 Coordinate 的陣列呢?

Coordinate coordinate[100];

其大小就是 8 * 100 = 800 個位元組了。

3. 記憶體的動態分配


好了,現在我們就進入這一課的關鍵部分了,重提一次這一課的目的:學會如何手動申請記憶體空間。

我們需要引入 stdlib.h 這個標準庫標頭檔案,因為接下來要使用的函式是定義在這個庫裡面。

這兩個函式是什麼呢?就是:

  • malloc:是 Memory Allocation 的縮寫,表示“記憶體分配”。詢問作業系統能否預支一塊記憶體空間來使用。

  • free:表示“解放,釋放,自由的”。意味著“釋放那塊記憶體空間”。告訴作業系統我們不再需要這塊已經分配的空間了,這塊記憶體空間會被釋放,另一個程式就可以使用這塊空間了。

當我們手動分配記憶體時,須要按照以下三步順序來:

  1. 呼叫 malloc 函式來申請記憶體空間。

  2. 檢測 malloc 函式的返回值,以得知作業系統是否成功為我們的程式分配了這塊記憶體空間。

  3. 一旦使用完這塊記憶體,不再需要時,必須用 free 函式來釋放佔用的記憶體,不然可能會造成記憶體洩漏。

以上三個步驟是不是讓我們回憶起關於上一課“檔案讀寫”的內容了?

這三個步驟和檔案指標的操作有點類似,也是先申請記憶體,檢測是否成功,用完釋放。

malloc 函式:申請記憶體


malloc 分配的記憶體是在堆上,一般的區域性變數(自動分配的)大多是在棧上。

關於堆和棧的區別,還有記憶體的其他區域,如靜態區等,大家可以自己延伸閱讀。

之前“字串”那一課裡已經給出過一張圖表了。再來回顧一下吧:

名稱 內容
程式碼段 可執行程式碼、字串常量
資料段 已初始化全域性變數、已初始化全域性靜態變數、區域性靜態變數、常量資料
BSS段 未初始化全域性變數,未初始化全域性靜態變數
區域性變數、函式引數
動態記憶體分配

給出 malloc 函式的原型,你會發現有點滑稽:

void* malloc(size_t numOctetsToAllocate);

可以看到,malloc 函式有一個引數 numOctetsToAllocate,就是需要申請的記憶體空間大小(用位元組數表示),這裡的 size_t(之前的課程有提到過)其實和 int 是類似的,就是一個 define 宏定義,實際上很多時候就是 int。

對於我們目前的演示程式,可以將 sizeof(int) 置於 malloc 的括號中,表示要申請 int 型別的大小的空間。

真正引起我們興趣的是 malloc 函式的返回值:

void*

如果你還記得我們在函式那章所說的,void 表示“空”,我們用 void 來表示函式沒有返回值。

所以說,這裡我們的函式 malloc 會返回一個指向 void 的指標,一個指向“空”(void 表示“虛無,空”)的指標,有什麼意義呢?malloc 函式的作者不會搞錯了吧?

不要擔心,這麼做肯定是有理由的。

難道有人敢質疑老爺子 Dennis Ritchie(C語言的作者)的智商?
來人吶,拖出去… 罰寫 100 個 C語言小遊戲。

事實上,這個函式返回一個指標,指向作業系統分配的記憶體的首地址。

如果作業系統在 1700 這個地址為你開闢了一塊記憶體的話,那麼函式就會返回一個包含 1700 這個值的指標。

但是,問題是:malloc 函式並不知道你要建立的變數是什麼型別的。

實際上,你只給它傳遞了一個引數: 在記憶體中你需要申請的位元組數。

如果你申請 4 個位元組,那麼有可能是 int 型別,也有可能是 long 型別。

正因為 malloc 不知道自己應該返回什麼變數型別(它也無所謂,只要分配了一塊記憶體就可以了),所以它會返回 void* 這個型別。這是一個可以表示任意指標型別的指標。

void* 與其他型別的指標之間可以透過強制轉換來相互轉換。例如:

int *i = (int *)p;  // p 是一個 void* 型別的指標

void *v = (void *)c;  // c 是一個 char* 型別的指標

實踐


如果我實際來用 malloc 函式分配一個 int 型指標:

int *memoryAllocated = NULL;  // 建立一個 int 型指標

memoryAllocated = malloc(sizeof(int));  // malloc 函式將分配的地址賦值給我們的指標 memoryAllocated

經過上面的兩行程式碼,我們的 int 型指標 memoryAllocated 就包含了作業系統分配的那塊記憶體地址的首地址值。

假如我們用之前我們的圖示來舉例,這個值就是 1700。

檢測指標


既然上面我們用兩行程式碼使得 memoryAllocated 這個指標包含了分配到的地址的首地址值,那麼我們就可以透過檢測 memoryAllocated 的值來判斷申請記憶體是否成功了:

  1. 如果為 NULL,則說明 malloc 呼叫沒有成功。

  2. 否則,就說明成功了。

一般來說記憶體分配不會失敗,但是也有極端情況:

  1. 你的記憶體(堆記憶體)已經不夠了。

  2. 你申請的記憶體值大得離譜(比如你申請 64 GB 的記憶體空間,那我想大多數電腦都是不可能分配成功的)。

希望大家每次用 malloc 函式時都要做指標的檢測,萬一真的出現返回值為 NULL 的情況,那我們需要立即停止程式,因為沒有足夠的記憶體,也不可能進行下面的操作了。

為了中斷程式的執行,我們來使用一個新的函式:

exit()

exit 函式定義在 stdlib.h 中,呼叫此函式會使程式立即停止。

這個函式也只有一個引數,就是返回值,這和 return 函式的引數是一樣原理的。例項:

int main(int argc, char *argv[])
{
    int *memoryAllocated = NULL;

    memoryAllocated = malloc(sizeof(int));

    if (memoryAllocated == NULL)  // 如果分配記憶體失敗
    {
        exit(0);  // 立即停止程式
    }

    // 如果指標不為 NULL,那麼可以繼續進行接下來的操作

    return 0;
}

另外一個問題:用 malloc 函式申請 0 位元組記憶體會返回 NULL 指標嗎?


可以測試一下,也可以去查詢關於 malloc 函式的說明文件。

申請 0 位元組記憶體,函式並不返回 NULL,而是返回一個正常的記憶體地址。
但是你卻無法使用這塊大小為 0 的記憶體!

這就好比尺子上的某個刻度,刻度本身並沒有長度,只有某兩個刻度一起才能量出長度。

對於這一點一定要小心,因為這時候 if(NULL != p) 語句校驗將不起作用。

free函式:釋放記憶體


記得上一課我們使用 fclose 函式來關閉一個檔案指標,也就是釋放佔用的記憶體。

free 函式的原理和 fclose 是類似的,我們用它來釋放一塊我們不再需要的記憶體。原型:

void free(void* pointer);

free 函式只有一個目的:釋放 pointer 指標所指向的那塊記憶體。

例項程式:

int main(int argc, char *argv[])
{
    int* memoryAllocated = NULL;

    memoryAllocated = malloc(sizeof(int));

    if (memoryAllocated == NULL)  // 如果分配記憶體失敗
    {
        exit(0);  // 立即停止程式
    }

    // 此處新增使用這塊記憶體的程式碼

    free(memoryAllocated);  // 我們不再需要這塊記憶體了,釋放之

    return 0;
}

綜合上面的三個步驟,我們來寫一個完整的例子:

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
    int* memoryAllocated = NULL;

    memoryAllocated = malloc(sizeof(int));  // 分配記憶體

    if (memoryAllocated == NULL)  // 檢測是否分配成功
    {
        exit(0);  // 不成功,結束程式
    }

    // 使用這塊記憶體
    printf("您幾歲了 ? ");

    scanf("%d", memoryAllocated);

    printf("您已經 %d 歲了n", *memoryAllocated);

    free(memoryAllocated);  // 釋放這塊記憶體

    return 0;
}

執行輸出:

您幾歲了 ? 32
您已經 32 歲了

以上就是我們用動態分配的方式來建立了一個 int 型變數,使用它,釋放它所佔用的記憶體。

但是,我們也完全可以用以前的方式來實現,如下:

int main(int argc, char *argv[])
{
    int myAge = 0; // 分配記憶體 (自動)

    // 使用這塊記憶體
    printf("您幾歲了 ? ");

    scanf("%d", &myAge);

    printf("你已經 %d 歲了n", myAge);

    return 0;
}  // 釋放記憶體 (在函式結束後自動釋放)

在這個簡單使用場景下,兩種方式(手動和自動)都是能完成任務的。

總結說來,建立一個變數(說到底也就是分配一塊記憶體空間)有兩種方式:自動和手動。

  • 自動:我們熟知並且一直使用到現在的方式。

  • 手動(動態):這一課我們學習的內容。

你可能會說:“我發現動態分配記憶體的方式既複雜又沒什麼用嘛!”

複雜麼?還行吧,確實相對自動的方式要考慮比較多的因素。

沒有用麼?絕不!

因為很多時候我們不得不使用手動的方式來分配記憶體。

接下來我們就來看一下手動方式的必要性。

4. 動態分配一個陣列


暫時我們只是用手動方式來建立了一個簡單的變數。

然而,一般說來,我們的動態分配可不是這樣“大材小用”的。

如果只是建立一個簡單的變數,我們用自動的方式就夠了。

那你會問:“啥時候須要用動態分配啊?”

問得好。動態分配最常被用來建立在執行時才知道大小的變數,例如動態陣列。

假設我們要儲存一個使用者的朋友的年齡列表,按照我們以前的方式(自動方式),我們可以建立一個 int 型的陣列:

int ageFriends[18];

很簡單對嗎?那問題不就解決了?

但是以上方式有兩個缺陷:

  1. 你怎麼知道這個使用者只有 18 個朋友呢?可能他有更多朋友呢。

  2. 你說:“那好,我就建立一個陣列:

int ageFriends[10000];

足夠儲存 1 萬個朋友的年齡。”

但是問題是:可能我們使用到的只是這個大陣列的很小一部分,豈不是浪費記憶體嘛。

最恰當的方式是詢問使用者他有多少朋友,然後建立對應大小的陣列。

而這樣,我們的陣列大小就只有在執行時才能知道了。

Voila,這就是動態分配的優勢了:

  1. 可以在執行時才確定申請的記憶體空間大小。

  2. 不多不少剛剛好,要多少就申請多少,不怕不夠或過多。

所以藉著動態分配,我們就可以在執行時詢問使用者他到底有多少朋友。

如果他說有 20 個,那我們就申請 20 個 int 型的空間;如果他說有 50 個,那就申請 50 個。經濟又環保。

我們之前說過,C語言中禁止用變數名來作為陣列大小,例如不能這樣:

int ageFriends[numFriends];  // numFriends 是一個變數

儘管有的 C編譯器可能允許這樣的宣告,但是我們不推薦。

我們來看看用動態分配的方式如何實現這個程式:

#include <stdio.h>
#include <stdlib.h>

int main(int argc, char *argv[])
{
    int numFriends = 0, i = 0;

    int *ageFriends= NULL;  // 這個指標用來指示朋友年齡的陣列

    // 詢問使用者有多少個朋友
    printf("請問您有多少朋友 ? ");

    scanf("%d", &numFriends);

    if (numFriends > 0)  // 至少得有一個朋友吧,不然也太慘了 :P
    {
        ageFriends = malloc(numFriends * sizeof(int));  // 為陣列分配記憶體
        if (ageFriends== NULL)  // 檢測分配是否成功
        {
            exit(0); // 分配不成功,退出程式
        }

        // 逐個詢問朋友年齡
        for (i = 0 ; i < numFriends; i++)  {
            printf("第%d位朋友的年齡是 ? ", i + 1);
            scanf("%d", &ageFriends[i]);
        }

        // 逐個輸出朋友的年齡
        printf("nn您的朋友的年齡如下 :n");
        for (i = 0 ; i < numFriends; i++) {
            printf("%d 歲n", ageFriends[i]);
        }

        // 釋放 malloc 分配的記憶體空間,因為我們不再需要了
        free(ageFriends);
    }

    return 0;
}

執行輸出:

請問您有多少朋友 ? 7
第1位朋友的年齡是 ? 25
第2位朋友的年齡是 ? 21
第3位朋友的年齡是 ? 27
第4位朋友的年齡是 ? 18
第5位朋友的年齡是 ? 14
第6位朋友的年齡是 ? 32
第7位朋友的年齡是 ? 30

您的朋友的年齡如下 :
25歲
21歲
27歲
18歲
14歲
32歲
30歲

當然了,這個程式比較簡單,但我向你保證以後的課程會使用動態分配來做更有趣的事。

5. 總結


  1. 不同型別的變數在記憶體中所佔的大小不盡相同。

  2. 藉助 sizeof 這個關鍵字(也是運算子)可以知道一個型別所佔的位元組數。

  3. 動態分配就是在記憶體中手動地預留一塊空間給一個變數或者陣列。

  4. 動態分配的常用函式是 malloc(當然,還有 calloc,realloc,可以查閱使用方法,和 malloc 是類似的),但是在不需要這塊記憶體之後,千萬不要忘了使用 free 函式來釋放。而且,malloc 和 free 要一一對應,不能一個 malloc 對應兩個 free,會出錯;或者兩個 malloc 對應一個 free,會記憶體洩露!

  5. 動態分配使得我們可以建立動態陣列,就是它的大小在執行時才能確定。

6. 第二部分第九課預告


今天的課就到這裡,一起加油吧!


我是 ,慕課網精英講師 ,終生學習者。
熱愛生活,喜歡游泳,略懂烹飪。
人生格言:「向著標杆直跑」

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/4369/viewspace-2825656/,如需轉載,請註明出處,否則將追究法律責任。

相關文章