[Objective-C] 程式設計教學:在 GNU/Linux 上測試 Objective-C 程式碼的相容性

【分享本文】
Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email

    前言

    在 Objective-C 的發展中,Clang 和 GCC 的腳步並不一致,造成兩者在編譯 Objective-C 程式碼時不完全相容。如果很在意程式碼的編譯器相容性的話,最好針對兩個編譯器都各自編譯一次。

    由於編譯和執行程式是很機械性的動作,最好能夠把這個過程自動化。因此,筆者自製了一個 shell 命令稿,用來自動化這個檢查的過程。本文一大部分是在講解這個命令稿,如果對此命令稿的細節沒興趣的讀者,可以略過說明的部分,把這個命令稿當成立即可用的腳本即可。

    系統需求

    本命令稿本身以 POSIX shell 寫成。除此之外,相依於以下三個軟體:

    • GCC
    • Clang
    • GNUstep

    基本上就是在非 Apple 平台上寫 Objective-C 的開發軟體。有在寫 Objective-C 的讀者應該都已經裝好了。

    本命令稿在 Ubuntu 18.04 LTS 上測試,但對其他的類 Unix 系統應該也可以使用。

    使用 objcheck

    這個命令稿叫做 objcheck,本身以 POSIX shell 寫成,在類 Unix 系統上可直接使用。

    使用 Git 即可下載:

    $ git clone https://github.com/cwchentw/objcheck.git
    

    使用前要先把命令稿加上可執行的權限:

    $ chmod +x objcheck/objcheck
    

    建議把 objcheck 放在合法的 $PATH 路徑,像是 $HOME/bin/usr/local/bin 等。

    如果讀者的 Objective-C 程式碼是單一檔案的話,直接把該檔案當成參數即可:

    $ objcheck path/to/file.m
    

    如果讀者的 Objective-C 程式碼分散在多個檔案上,則可參考以下指令:

    $ objcheck path/to/*.m
    

    如果想測試靜態函式庫,可參考以下指令:

    $ objcheck static path/to/*.m
    

    如果想測試動態函式庫,可參考以下指令:

    $ objcheck dynamic path/to/*.m
    

    如果目標程式有帶參數的話,使用 -- 隔開:

    $ objcheck path/to/*.m -- --opt param_a param_b param_c
    

    我們假定標頭檔和原始碼皆位於同一目錄中。如果讀者的標頭檔位在其他目錄,則要設置環境變數 CFLAGS 。設置的方式同 GCC 的 -I 參數。

    輸入 help 子命令可看本命令稿的使用說明:

    $ objcheck help
    

    本命令稿可用數個環境變數客製化命令稿的行為,請讀者自行閱讀命令稿的說明。接下來,我們會逐一講解此命令稿所執行的動作。

    解說此腳本

    本節的內容除了可以了解 objcheck 內部實作外,也可用來觀看 shell scripting 的寫法。如果對 shell scripting 沒興趣的讀者,可以略過本節,只要會使用此腳本即可。

    此命令稿的第一個參數是解析選擇性的子命令 (subcommand)。以下的 shell 程式碼會解析子命令:

    if [ "$action" = "version" ]; then
        version;
        exit 0;
    fi
    
    # Show license info and exit.
    
    # Show help info and exit.
    
    if [ "$action" = "application" ] || [ "$action" = "static" ] || [ "$action" = "dynamic" ]; then
        shift;
    fi
    
    if [ "$action" != "application" ] && [ "$action" != "static" ] && [ "$action" != "dynamic" ]; then
        action="application";
    fi
    

    version 是我們自己寫的 shell 函式,只是負責簡單地印出資料。當子命令是 version 時,印出相關資訊後即離開程式。而 licensehelp 的邏輯大抵上雷同,此處不再秀出程式碼。

    此範例程式可編譯的項目有 applicationstaticdynamic 三種,分別代表應用程式、靜態函式庫、動態函式庫。當第一個參數是子命令時,將該參數吃入。除此之外,表示該參數代表別的意義,這時候我們會保留該參數。

    由於子命令是選擇性的參數,當命令稿使用者未輸入子命令,我們將其設為預設值 application

    由於 GNUstep 在類 Unix 系統的位置不統一,我們使用環境變數來客製其標頭檔的位置:

    GNUSTEP_INCLUDE=$GNUSTEP_INCLUDE
    if [ -z "$GNUSTEP_INCLUDE" ]; then
        GNUSTEP_INCLUDE=/usr/include/GNUstep
    fi
    

    除此之外,我們還有數個接收環境變數的程式碼,讀者可自行追蹤 objcheck 的原始碼即可知。

    為求慎重,我們會在編譯程式前檢查 GCC 是否存在:

    $GCC --version > /dev/null
    if [ "$?" -ne 0 ]; then
        echo "Please install gcc and gobjc";
        exit 1;
    fi
    

    當 GCC 未安裝時,我們會提示使用者安裝 GCC 並中止此程式。我們也用同樣的邏輯來檢查 Clang 是否已安裝。

    我們為了檢查 GNUstep 是否存在,我們寫了一個 Hello World 程式,將程式存到暫存檔後編譯及執行:

    helloworld=$(cat << EOF
    #import <Foundation/Foundation.h>
    int main(void)
    {
        NSAutoreleasePool* pool = [[NSAutoreleasePool alloc] init];
        if (!pool)
            return 1;
        NSLog(@"Hello World");
        
        [pool drain];
        return 0;
    }
    EOF
    )
    
    # Check whether GNUstep exists on the host.
    temp="$(mktemp --suffix .m)"
    echo "$helloworld" > $temp
    $GCC -o $dest $temp -lobjc -lgnustep-base -I $GNUSTEP_INCLUDE -L $GNUSTEP_LIB \
        -fconstant-string-class=NSConstantString -fobjc-exceptions
    ./$dest > /dev/null 2>&1
    
    if [ "$?" -ne 0 ]; then
        echo "Please install GNUstep";
        rm -f $dest
        exit 1;
    fi
    
    rm -f $dest
    

    我們用 mktemp 建立一個暫存檔,將 Hello World 程式寫入該暫存檔。然後編譯此程式。若未安裝 GNUstep,即無法編譯此程式,這時會提示使用者安裝 GNUstep 並結束此腳本。

    為求慎重,我們用同樣的邏輯寫了一個 Objective-C++ 版本的 Hello World 程式。由於兩段程式碼的邏輯雷同,此處不再列出腳本程式碼。

    接著,我們檢查使用者是否有輸入參數:

    src="$@"
    
    if [ -z "$1" ]; then
        echo "No input file";
        exit 1;
    fi
    

    當使用者未輸入參數時,後續也無法進行編譯的動作,這時候我們會中止腳本。

    最後,實際編譯程式。我們會根據程式的類型使用不同的指令來編譯:

    if [ "$action" = "application" ]; then
        IS_CPP=0
        OBJS=
        IS_WRONG=0
        for file in "$@"; do
            if [ "$file" = "--" ]; then
                break;
            fi
    
            FILE_EXT=$(check_ext $file | cat)
    
            if [ $FILE_EXT = $unsupported_file_extension ]; then
                IS_WRONG=1;
                continue;
            fi
    
            filename=$(basename $file)
            objname="${filename%$FILE_EXT}.o"
            OBJS="$objname $OBJS"
    
            FILE_TYPE=$(check_file_type $FILE_EXT | cat)
    
            if [ $IS_WRONG -ne 0 ]; then
                continue;
            fi
    
            if [ "$FILE_TYPE" = "objective-c" ]; then
                $GCC -c -o $objname $file -I $GNUSTEP_INCLUDE $CFLAGS \
                    -fconstant-string-class=NSConstantString -fobjc-exceptions
            elif [ "$FILE_TYPE" = "objective-c++" ]; then
                IS_CPP=1
                $GPP -c -o $objname $file -I $GNUSTEP_INCLUDE $CXXFLAGS \
                    -fconstant-string-class=NSConstantString -fobjc-exceptions
            elif [ "$FILE_TYPE" = "c" ]; then
                $GCC -c -o $objname $file $CFLAGS
            elif [ "$FILE_TYPE" = "c++" ]; then
                IS_CPP=1
                $GPP -c -o $objname $file $CXXFLAGS
            fi
        done
    
        if [ $IS_WRONG -ne 0 ]; then
            exit 1;
        fi
    
        if [ $IS_CPP -eq 1 ]; then
            LIBS_INTERNAL="-lstdc++"
        fi
        $GCC -o $dest $OBJS $LIBS_INTERNAL -lobjc -lgnustep-base $LIBS \
            -I $GNUSTEP_INCLUDE -L $GNUSTEP_LIB $CFLAGS $LDFLAGS -fconstant-string-class=NSConstantString
    
        args=
        is_arg=0
        for arg in "$@"; do
            if [ "$arg" = "--" ]; then
                is_arg=1;
                continue;
            fi
    
            if [ $is_arg -eq 1 ]; then
                args="$args $arg"
            fi
        done
        LD_LIBRARY_PATH=$GNUSTEP_LIB:$LD_LIBRARY_PATH ./$dest $args
    elif [ "$action" = "dynamic" ]; then
        # Compile a dynamic library.
    elif [ "$action" = "static" ]; then
        # Compile a static library.
    fi
    
    if [ "$?" -ne 0 ]; then
        echo "Wrong program compiled by GCC";
        clean;
        exit 1;
    fi
    
    clean;
    

    由於這段程式碼比較長,我們省掉類似的部分。只取其中一段來說明。

    Objective-C 專案中實際上可能混合四種程式語言的原始碼,這四種語言分別是 C、C++、Objective-C、Objective-C++。所以,我們要逐一偵測每個檔案,根據不同的檔案類型使用不同的指令把原始碼編譯成目的檔,最後才一口氣把所有的目的檔編譯成執行檔或二進位檔。

    偵測檔案類型的動作分為兩步,先偵測檔案結尾再偵測檔案類型。要拆成兩步的原因是 C++ 原始碼可使用多種檔案結尾。

    偵測檔案結尾的函式如下:

    check_ext () {
        case $1 in
        *.mm)
            echo ".mm"
            ;;
        *.m)
            echo ".m"
            ;;
        *.cpp)
            echo ".cpp"
            ;;
        *.cxx)
            echo ".cxx"
            ;;
        *.cc)
            echo ".cc"
            ;;
        *.c)
            echo ".c"
            ;;
        *)
            echo "Unsupported file: $1" >&2;
            echo $unsupported_file_extension;
            ;;
        esac
    }
    

    由於 shell 沒有什麼字串函式可用,對於檢查字串來說,case 敘述是窮人版的字串解析工具。$unsupported_file_extension 是我們自訂的字串,用來表示不支援的副檔名。

    接著,我們就可以偵測檔案類型:

    check_file_type () {
        case $1 in
        .m)
            echo "objective-c";
            ;;
        .mm)
            echo "objective-c++";
            ;;
        .c)
            echo "c";
            ;;
        .cpp|.cxx|.cc)
            echo "c++";
            ;;
        *)
            echo "Unknown file extension: $FILE_EXT" >&2;
            ;;
        esac
    }
    

    除了偵測檔案類型外,我們還有別的任務要做。包括偵測程式碼是純 C 還是有加入 C++ 的部分、檢查是否有不支援的程式、列出目的檔清單等。請讀者回顧稍早的程式碼片段即可理解。

    當檔案有誤時,我們不會編譯最後的執行檔或二進位檔,而會列出所有不支援的檔案。

    我們一邊走訪檔案清單,一邊建立目的檔清單。其實我們會把所有的目的碼移到工作目錄,之後要清除時比較容易。所以我們用 basename 去除相對目錄的部分,只保留檔名,並將檔尾去除,改為目地檔的檔尾 .o

    由於 shell script 沒有字串相接的功能,我們用窮人版的方式來處理:

    FILE_EXT=$(check_ext $file | cat)
    # Chech $FILE_EXT error.
    
    filename=$(basename $file)
    objname="${filename%$FILE_EXT}.o"
    OBJS="$objname $OBJS"
    

    這段程式實際在做的事情是變數代換,效果等同於字串相接。

    我們的程式會偵測程式碼是否有混入 C++ 原始碼:

    if [ $IS_CPP -eq 1 ]; then
        LIBS_INTERNAL="-lstdc++"
    fi
    $GCC -o $dest $OBJS $LIBS_INTERNAL -lobjc -lgnustep-base $LIBS \
        -I $GNUSTEP_INCLUDE -L $GNUSTEP_LIB $CFLAGS $LDFLAGS -fconstant-string-class=NSConstantString
    

    若有,會在編譯時加上 linking 參數 -lstdc++,這樣就可以用 GCC 編譯 C++ 目的檔。

    由於執行程式時,有可能會帶參數,所以我們需要把 objcheck 的參數和目標程式的參數分開。以下是假想的指令:

    $ objcheck application path/to/*.m -- --help
    

    -- 之前的參數是 objcheck 的參數,而在 -- 之後的參數則是目標程式的參數。-- 本身用來區隔兩者,不視為參數。

    所以我們要再對參數解析一次,然後執行目標程式:

    args=
    is_arg=0
    for arg in "$@"; do
        if [ "$arg" = "--" ]; then
            is_arg=1;
            continue;
        fi
    
        if [ $is_arg -eq 1 ]; then
            args="$args $arg"
        fi
    done
    LD_LIBRARY_PATH=$GNUSTEP_LIB:$LD_LIBRARY_PATH ./$dest $args
    

    當參數出現 -- 時,代表出現分隔線。之後的參數都要收集起來。執行目標程式時,要將這些參數帶入。

    如果編譯、執行目標程式的過程出錯,我們會提示使用者並中止腳本:

    if [ "$?" -ne 0 ]; then
        echo "Wrong program compiled by GCC";
        clean;
        exit 1;
    fi
    
    clean;
    

    clean 也是我們自己寫的函式,基本上就是清除所有的檔案:

    clean () {
        rm -f $dest "lib${dest}.a" "lib${dest}.so" *.o;
    }
    

    編譯函式的過程大同小異,差別在不需執行函式庫。此外,使用 Clang 編譯的程式碼也和使用 GCC 的大同小異,此處不重覆列出,請讀者自行追蹤腳本程式碼。

    結語

    Clang 和 GCC 之間的不相容,並不是我們所樂見的情形。但這項議題短時間內也不會消失。現階段最好的做法,就是自行檢查 Objective-C 程式碼的編譯器相容性。

    檢查編譯器相容器最簡單的做法,就是用兩個編譯器重覆編譯同一原始碼。但編譯和執行程式是相當機械化的動作,所以我們用 shell 寫了 objcheck 腳本程式來自動化這個過程。

    由於 Mac 平台上已有 Clang 和 Cocoa 可用,編譯器相容性不是我們關注的議題,所以我們目前不在 Mac 平台上使用 objcheck

    【分享本文】
    Facebook Twitter LinkedIn LINE Skype EverNote GMail Yahoo Email
    【追蹤新文章】
    Facebook Twitter Plurk