美思 [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/opensourcedoc/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

關於作者

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

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