程式碼的擴展 - macro 與 inline function 的比較

前言

macro 與 inline function 皆是擴展或是重複利用的方法之一。與一般我們重複利用程式碼的作法 —— function —— 不同之處在於,透過 macro 或是 inline function,程式在運行時可以減少主程式 subroutine 間 push / pop 的步驟,近一步加快執行速度。

兩者間最主要的不同處在於擴展的時機。Macro 會於前處理階段由 preprocessor 進行文字替代,至於 inline function 則在編譯階段由 Compiler 進行擴展。但無論是 macro 或者是 inline function ,皆會因為程式碼的複製導致最終大小,相較使用 function 方式來重複利用程式碼大。 在這篇文章中,我們將會解釋 macro 與 inline function 各自的原理,並透過實際的例子比較兩者之間的差異。

Macro

Macro 是一種透過 preprocessor 將程式碼展開的方式。我們在 #:前處理器的語言 中提過幾個前置處理的指令:File Inclusion、Macro Definition 與 Conditional Compilation。其中,透過 macro definition 的指令就能幫助我們定義一個像函式般的的巨集1。 我們來簡單的複習一下巨集的定義方式。

1#define identifier(parameters, ...) replacement-list

透過上述的程式碼片段,我們將 identfier 定義為一個 macro,所有程式碼中出現的 identifier 都會依序替代成 replacement-list 的內容。一個簡單的巨集如下,我們定義了一個 MAX 的巨集,來比較兩個數的大小,並將大者進行回傳。

1#define MAX(x, y) (x) > (y) ? (x) : (y)

定義 MAX(x, y) 的巨集後,所有程式碼中出現的 MAX(x, y) 皆會在前置處理階段被替換為 (x) > (y) ? (x) : (y)

注意事項

  1. 常見的錯誤定義方式 a. 誤加等號 = b. 誤加分號 ;

    1#define MAX(x, y) (x) > (y) ? (x) : (y)   // correct
    2#define MAX(x, y) = (x) > (y) ? (x) : (y) // error 1(a)
    3#define MAX(x, y) (x) > (y) ? (x) : (y);  // error 1(b)
    
  2. 以括號保護變數

    1#define TWICE(x) 2 * x
    2
    3int M = TWICE(3 + 5)
    

    在上述程式碼中,TWICE(3 + 5) 會以下面所示的方式展開

    1M = 2 * 3 + 5
    

    導致 M 的值為 11,而非預期的 16,需要特別的注意。

Inline Function

Inline function 直譯的話中文是內嵌函式,顧名思義就是把函式的內容直接鑲嵌在程式碼中。我們有兩種方式可以使用這類型的函式,加速程式運行的速度。其一,透過 inline 修飾字實現;其二,交由編譯器決定。

對於第一個方法,在函示的前方加入修飾字 inline,則編譯階段編譯器會將該函式內嵌至程式碼內。以下面的例子來說,max(x, y) 就會在編譯時期展開。

1inline int max(x, y) {
2    if (x >= y) return x;
3    else return y;
4}

至於第二個方法編譯器的優化中,Compiler 會自動將較短的函式於編譯時期自行展開來提升程式執行的效能。

透過 GCC 觀察 inline 與 macro

macro 的觀察

我們可以透過 -E 的參數來觀察經過 preprossor 處理後的程式。首先,我們先來看原先的程式碼:

1/* test_macro_01.c */
2#define MAX(x, y) x > y? x: y
3
4int main () {
5    MAX(2, 3);
6}

經由 gcc -E -o test_macro.i test_macro.c 後,我們得到如是結果:

1/* test_macro_01.i */
2...
3
4int main()
5{
6    2 > 3 ? 2 : 3;
7}

若我們嘗試在上述注意事項 2. 所提及的項目的話,將程式碼改成以下形式:

1/* test_macro_02.c */
2#define MAX(x, y) x > y? x: y
3
4int main () {
5    int i=4, j=5;
6    MAX(i++, j);
7}

再經由 -E 參數轉換成 .i 檔後,可以得到如下結果。我們會發現,由於 macro 是將文字取代,因此 i 這個變數在判斷 MAX 的過程中,被遞增了兩次。導致最後 i 的值為 6

1/* test_macro_02.i */
2...
3
4int main()
5{
6    int i=4, j=5;
7    i++ > j ? i++ : j;
8}

inline

我們可以透過 gcc -S 的參數來觀察編譯完後程式的組合語言。以下兩段程式碼分別為使用 inline 與不使用 inline 的範例。

1/* test_inline.c */
2inline int twice (int x) {
3    return 2 * x;
4}
5int main () {
6    twice(3);
7}
1/* test_no_inline.c */
2int twice (int x) {
3    return 2 * x;
4}
5int main () {
6    twice (3);
7}

我們將這兩段程式碼編譯成組合語言,並對兩者進行觀察。第一段為使用 inline 修飾字的程式片段,第二段則為不使用的片段。

使用 inline 關鍵字

 1	.section	__TEXT,__text,regular,pure_instructions
 2	.build_version macos, 12, 0	sdk_version 12, 1
 3	.globl	_main                           ## -- Begin function main
 4	.p2align	4, 0x90
 5_main:                                  ## @main
 6	.cfi_startproc
 7## %bb.0:
 8	pushq	%rbp
 9	.cfi_def_cfa_offset 16
10	.cfi_offset %rbp, -16
11	movq	%rsp, %rbp
12	.cfi_def_cfa_register %rbp
13	subq	$16, %rsp
14	movl	$0, -4(%rbp)
15	movl	$4, -8(%rbp)
16	movl	$3, -12(%rbp)
17	movl	-8(%rbp), %eax
18	movl	%eax, %ecx
19	addl	$1, %ecx
20	movl	%ecx, -8(%rbp)
21	cmpl	-12(%rbp), %eax
22	jle	LBB0_2
23## %bb.1:
24	movl	-8(%rbp), %eax
25	movl	%eax, %ecx
26	addl	$1, %ecx
27	movl	%ecx, -8(%rbp)
28	movl	%eax, -16(%rbp)                 ## 4-byte Spill
29	jmp	LBB0_3
30LBB0_2:
31	movl	-12(%rbp), %eax
32	movl	%eax, -16(%rbp)                 ## 4-byte Spill
33LBB0_3:
34	movl	-16(%rbp), %esi                 ## 4-byte Reload
35	leaq	L_.str(%rip), %rdi
36	movb	$0, %al
37	callq	_printf
38	movl	-8(%rbp), %esi
39	leaq	L_.str(%rip), %rdi
40	movb	$0, %al
41	callq	_printf
42	movl	-4(%rbp), %eax
43	addq	$16, %rsp
44	popq	%rbp
45	retq
46	.cfi_endproc
47                                        ## -- End function
48	.section	__TEXT,__cstring,cstring_literals
49L_.str:                                 ## @.str
50	.asciz	"%d\n"
51
52.subsections_via_symbols		  

未使用 inline 關鍵字

 1	.section	__TEXT,__text,regular,pure_instructions
 2	.build_version macos, 12, 0	sdk_version 12, 1
 3	.globl	_twice                          ## -- Begin function twice
 4	.p2align	4, 0x90
 5_twice:                                 ## @twice
 6	.cfi_startproc
 7## %bb.0:
 8	pushq	%rbp
 9	.cfi_def_cfa_offset 16
10	.cfi_offset %rbp, -16
11	movq	%rsp, %rbp
12	.cfi_def_cfa_register %rbp
13	movl	%edi, -4(%rbp)
14	movl	-4(%rbp), %eax
15	shll	$1, %eax
16	popq	%rbp
17	retq
18	.cfi_endproc
19                                        ## -- End function
20	.globl	_main                           ## -- Begin function main
21	.p2align	4, 0x90
22_main:                                  ## @main
23	.cfi_startproc
24## %bb.0:
25	pushq	%rbp
26	.cfi_def_cfa_offset 16
27	.cfi_offset %rbp, -16
28	movq	%rsp, %rbp
29	.cfi_def_cfa_register %rbp
30	movl	$3, %edi
31	callq	_twice
32	xorl	%eax, %eax
33	popq	%rbp
34	retq
35	.cfi_endproc
36                                        ## -- End function
37.subsections_via_symbols

從上面兩個例子我們可以觀察到,使用 inline 修飾字,在編譯階段 Compiler 便將其攤開至 _main 的 subroutine 之中。然而,若未使用 inline 修飾字的一般函式,我們可以看到名為 _twice 的 subroutine,每當我們要使用該函式時,就產生額外的 push / pop 動作,使得程式的執行較 inline function 緩慢。

Macro v.s. Inline Function

以下是兩者與一般函式的比較。三者執行的時機不同,一般 function 是在執行時期以 subroutine 的方式被呼叫,而 macro 與 inline 則分別是在 preprocessing 與 compiling 時期被展開取代。從宣告或是定義的位置來看,macro 會在程式的開頭進行定義,而 inline function 與一般函式相同,在程式的任何位置皆可宣告。對於一般函示或是內嵌函式來說,終止的符號是右大括號,然而 macro 則無特定終止符號,是以換行作為終止。當今天我們為了可讀性要跨行書寫 macro 時,可以透過在該行底端加入 \ 符號繼續書寫。

相較於 macro 需要明確定義,有的時候 compiler 會自動將較短小的函式轉換成 inline function。儘管兩者都因為擴展、取代的關係,使得程式的大小增加,但無論是 macro 或是 inline function 都也因此提升了程式的執行速度。 我們可以以下表來一覽兩者之間的差別:

表一:

FunctionMacroInline
執行時期RuntimePreprocessingCompiling
使用方式type function (arguments)#defineinline
宣告位置任何須在程式的開頭宣告可在 class 中,或外面
終止時機大括號 }無,以 newline 作為終止大括號 }
作法push / poptext substituefunction substitue
Debugging容易困難容易
AutomationNo須明確定義class 中較短的 function 會被 Compiler
自動 inline
ExpansionNoAlways可下參數停止 function 展開
速度較慢較快較快
空間使用較小較多較多

小結

在這篇文章中,我們介紹了除了一般函示外,兩種可重複利用程式碼的方式 —— 巨集 macro 與 內嵌函式 inline function。我們各舉了幾個例子來說明兩者的使用方式與使用時應該注意的事項,以及常見的錯誤好比說巨集的定義方式2。之後,我們又透過 GCC3 工具的協助觀察 macro 與 inline function 在不同階段的變化。並透過 表一: 比對兩者與一般函示之間的異同。

若對 preprocessor 有興趣或是尚有疑問的的話,可以參考前面的文章 #:前置處理器的語言.md

References