位元詩人 [Objective-C] 程式設計教學:記憶體管理 (Memory Management)

Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

前言

在先前的文章中,我們談到如何建立和使用 Objective-C 物件。限於文章篇幅,當時沒有談到如何管理記憶體。本文延續 Objective-C 物件的主題,說明如何在 Objective-C 程式中管理記憶體。除了沿用原本 C 語言的記憶體管理模式外,Objective-C 發展出數個新的策略,我們會用範例分別展示其寫法。

C 語言的記憶體管理模式

在 C 語言中,記憶體管理有三個模式:

  • 靜態記憶體配置 (static memory allocation)
  • 自動記憶體配置 (automatic memory allocation)
  • 手動記憶體配置 (manual memory allocation)

靜態記憶體用於儲存程式本身、全域變數 (global variable)、靜態變數 (static variable) 等。靜態記憶體不需人為介入,但只能用來初始化固定數值的變數,也無法在執行期動態決定記憶體大小;此外,靜態記體的大小是有限的,故無法把所有的變數都寫到靜態記憶體裡面。濫用全域變數往往造成程式難以維護,故我們會謹慎地使用全域變數。

自動管理的記憶體儲存在堆疊 (stack) 中,會隨著函式的生命週期而消失;除了主函式外,寫在函式中的局部變數 (local variable) 都要注意生命週期的議題,以免得到垃圾值。自動管理的記憶體不需人為介入,但堆疊的容量大小有限,而且自動管理的值有生命週期的問題,所以也不會所有的資料都使用自動記憶體配置。

手動管理的記憶體儲存在堆積 (heap) 中,這些記憶體可以跨越函式傳遞。如同其名,手動管理的記憶體需要人為配置和釋放。由於堆積可使用的記憶體上限約略等於系統所有的記憶體,會是 C 語言中主要的記憶體使用模式。

註:此處的堆疊 (stack) 和堆積 (heap) 不是指資料結構 (data structures),而是記憶體的層級 (layout)。

我們在這篇文章介紹 C 的指標和記憶體管理,有興趣的讀者可以參考一下。

Objective-C 的記憶體管理策略

Objective-C 大部分的物件都是配置在堆積中,故需要人為介入。Objective-C 在發展過程中,發展出以下數種策略:

  • 手動記憶體管理
  • 垃圾回收 (garbage collection)
  • 記憶體池 (memory pool)
  • ARC (automatic reference collecting)

圾圾回收是即將棄置的 (deprecated) 特性,故本文不討論。

手動記憶體管理需程式設計師自行配置和釋放記憶體,但 Objective-C 有引入和管理記憶體相關的訊息 (message),而不是用原本在 stdlib.h 中的函式。我們在後文中會有實際的範例來展示如何手動管理記憶體。

記憶體池是半自動的記憶體管理方式。程式設計者需手動建立和釋放記憶體池,物件也要明確地傳入 autorelease 訊息。在 Objective-C 2.0 之前,記憶體池是主要的記憶體管理方式。

ARC 則是 Objective-C 2.0 引入的新特性,可以省下手動管理記憶體的工夫。由於 ARC 僅 Clang 有支援,如果讀者的 Objective-C 程式碼要相容於 GCC 的話,就無法使用這項特性,只能繼續使用記憶體池。我們在後文中也會有範例來展示如何使用 ARC。

手動配置和釋放 Objective-C 物件的記憶體

在 Objective-C 中,同樣也可以手動管理記憶體,但不是用 stdlib.h 中的函式,而是用訊息傳遞的方式。以下是一個假想的例子:

#import <Foundation/Foundation.h>                     /*  1 */
#include <stdio.h>                                    /*  2 */

int main(void)                                        /*  3 */
{                                                     /*  4 */
    /* Allocate a NSMutableDictionary object. */      /*  5 */
    NSMutableDictionary *dict = \
        [[NSMutableDictionary alloc] init];           /*  6 */
    if (!dict) {                                      /*  7 */
        perror("Failed to allocate a dictionary\n");  /*  8 */
        goto ERROR;                                   /*  9 */
    }                                                 /* 10 */

    /* Do something on `dict`. */                     /* 11 */

    /* Release the object. */                         /* 12 */
    [dict release];                                   /* 13 */

    return 0;                                         /* 14 */

ERROR:                                                /* 15 */
    /* Release the object only if it exists. */       /* 16 */
    if (dict)                                         /* 17 */
        [dict release];                               /* 18 */

    return 1;                                         /* 19 */
}                                                     /* 20 */

我們在第 6 行時為 dict 物件配置記憶體及初始化,最後在第 13 行釋放掉 dict 物件所占的記憶體。這和 C 風格的記憶體管理很像,allocrelease 的訊息成對出現。

注意 Objective-C 的 allocrelease 訊息不能和純 C 的 malloc()free() 函式混用。使用什麼方式配置記憶體,就要用相對應的訊息或函式來釋放記憶體。

但以下的物件則不應手動釋放記憶體:

NSNumber *n = [NSNumber numberWithInteger: 12345];

為什麼會有兩種相異的管理方式呢?這由程式是否對物件有所有權 (ownership) 來決定。

當我們對物件用 allocnewcopymutableCopy 等訊息來產生時,我們對該物件就有所有權,這時候我們就要用 release 訊息歸還所有權。反之,我們使用其他訊息來產生物件時,我們對該物件就沒有所有權,這時候就不需要使用 release 來歸還所有權 (參考 Objective-C 的記憶體管理策略)。

如果我們要確保物件的所有權,可以對物件傳遞 retain 訊息。前述連結有說明使用 retain 的時機,現在暫時用不到,只要知道 Objective-C 的物件有這個訊息即可。

使用記憶體池 (Memory Pool) 半自動管理記憶體

在 Objective-C 2.0 之前,我們要自行在程式中建立記憶池 (memory pool),再搭配 autorelease 訊息來使用。參考下例:

#import <Foundation/Foundation.h>                                     /*  1 */
#include <stdio.h>                                                    /*  2 */

#define PRINT(FORMAT, ...) \
fprintf(stderr, "%s\n", \
    [[NSString stringWithFormat:FORMAT, ##__VA_ARGS__] UTF8String]);  /*  3 */

int main(void)                                                        /*  4 */
{                                                                     /*  5 */
    /* Create an autorelease pool object. */                          /*  6 */
    NSAutoreleasePool* pool = \
        [[NSAutoreleasePool alloc] init];                             /*  7 */
    if (!pool) {                                                      /*  8 */
        perror("Failed to allocate a memory pool\n");                 /*  9 */
        return 1;                                                     /* 10 */
    }                                                                 /* 11 */

    /* Create a NSDate object with `autorelease` message. */          /* 12 */
    NSDate *now = [[[NSDate alloc] init] autorelease];                /* 13 */
    if (!now) {                                                       /* 14 */
        perror("Failed to allocate a date object\n");                 /* 15 */
        goto ERROR;                                                   /* 16 */
    }                                                                 /* 17 */

    PRINT(@"%@", [now description]);                                  /* 18 */

    /* Release all objects with `autorelease` message. */             /* 19 */
    [pool drain];                                                     /* 20 */

    return 0;                                                         /* 21 */

ERROR:                                                                /* 22 */
    [pool drain];                                                     /* 23 */

    return 1;                                                         /* 24 */
}                                                                     /* 25 */

我們在第 7 行時建立 pool 物件,在第 20 行時釋放 pool 物件。這算是固定的使用方式。

在建立 autorelease pool 後,要對新建立的物件傳遞 autorelease 訊息,該物件就會在 pool 釋放時一併釋放掉。以本例來說,我們在第 13 行中建立 NSDate 物件時,就一併傳遞了 autorelease 訊息。

使用 ARC (Automatic Reference Counting) 自動管理記憶體 (Clang 限定)

在 Objective-C 2.0 後,引入更方便的 @autorelease 區塊,就不該繼續使用前一節所提到的舊式語法,除非是為了繼續相容 GCC。

我們將先前的例子利用 ARC 改寫如下:

#import <Foundation/Foundation.h>

#define PRINT(FORMAT, ...) \
fprintf(stderr, "%s\n", \
    [[NSString stringWithFormat:FORMAT, ##__VA_ARGS__] UTF8String]);

int main(void)
{
    @autoreleasepool
    {
        NSDate *now = [[NSDate alloc] init];

        PRINT(@"%@", [now description]);
    }

    return 0;
}

由於整個程式碼包在 @autoreleasepool 區塊內,我們就不需要再對 now 物件傳遞 autorelease 訊息,轉由系統自動處理記憶體配置和釋放。

Apple 公司在其官方手冊中提到使用 @autoreleasepool 區塊的效能會比使用記憶體池來得好,如果不需考慮編譯器相容性的話,應該優先使用這個手法管理記憶體。

對 Objective-C 程式檢查記憶體

原本用在 C 語言或 C++ 的知名記憶體檢查軟體 Valgrind 對 GNUstep 程式來說並不太合用,因為 GNUstep 現階段的實作會洩露少量記憶體,且目前沒有要改掉這項臭蟲 (參考這裡)。

替代的方式可以用 Clang 所附帶的 scan-build 對程式碼進行靜態檢查;由於 Clang 支援多個平台,所以這項工具不限於 Mac 平台。像是在 Ubuntu 或 Debian 的 clang-tools 套件中,就包括了該工具,而 MSYS2 則將該工具包在 clang-analyzer 套件中。

在 MSYS2 中開啟 MSYS 終端機環境,輸入安裝指令如下:

$ pacman -S mingw-w64-x86_64-clang-analyzer

使用時,將原本的編譯指令當成 scan-build 的參數即可:

$ scan-build clang -o prog prog.m -lobjc

也可以搭配 Make 等編譯自動化軟體:

$ scan-build make

scan-build 檢查程式碼時,若無法完全消除該軟體發出的警告訊息,至少不要出現記憶體洩露的訊息。透過這個程式,應該就可以確保 Objective-C 程式沒有記憶體使用的問題。

結語

在本文中,我們介紹了 Objective-C 的記憶體管理方式。主要的使用考量是使用的編譯器。如果只用 Apple 平台的 Clang 編譯程式,使用 ARC 是最方便的。如果要相容 GCC 的話,則要手動建立記憶體池。

關於作者

身為資訊領域碩士,位元詩人 (ByteBard) 認為開發應用程式的目的是為社會帶來價值。如果在這個過程中該軟體能成為永續經營的項目,那就是開發者和使用者雙贏的局面。

位元詩人喜歡用開源技術來解決各式各樣的問題,但必要時對專有技術也不排斥。閒暇之餘,位元詩人將所學寫成文章,放在這個網站上和大家分享。