宏,簡單來說就是按預定義的規則來替換相應的文本內容,被替換的文本內容可以是對象也可以是函數。既然是替換,那就需要遵循一定的規則來執行,這里的規則就是本文要討論的主要內容,希望通過深入淺出和逐層剖析的方式可以讓大家對宏定義有更加透徹的理解,繼而能夠在實際項目中運用并發揮宏定義的magic.
使用宏定義不僅可以讓代碼看起來更加簡潔易讀,更重要的是可以進行編譯檢查。由于宏定義是在預處理的時候被執行的,因此可以在編譯之前就檢查出包括參數類型,參數完整性等相關的錯誤。比如ReactiveCocoa里面的keypath(...)宏,可以接受可變參數:
keypath(self.observerPath)
或keypath(self, observerPath)
如果self沒有observerPath這個成員變量,那么編譯器會直接給出錯誤提示,避免等到運行時才發現路徑無效而導致程序異常。
宏定義分為對象宏和函數宏,對象宏通常是一些簡單的對象替換,比如
#define M_PI 3.1415
, 函數宏(宏名字后加上括號)可以接受參數如函數調用一樣在代碼中使用,比如#define ADD(x,y) x + y
. 函數宏有兩點需要注意: a). 括號與宏名之間不能有空格,否則就成對象宏了 b). 函數式宏定義的參數沒有類型,預處理器只負責做形式上的替換,而不做參數類型檢查,所以傳參時要格外小心。
Okay, 介紹完宏的基本概念之后,讓我們正式進入宏定義的魔法世界吧!
宏定義符號
在宏定義中,有四個特殊的符號:1)\
2)#
3)##
4) ...
,它們分別表示換行,字符串化,連接運算和可變多參數。
1) 換行符
我們知道在字符串里面可以使用\n
來實現換行,同樣在宏定義里面也可以插入換行符而不影響其含義,只不過在宏里面是用反斜杠\
來標識換行。
#define APP_VERSION() \
[[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleVersion"]
等價于:
#define APP_VERSION() [[NSBundle mainBundle] objectForInfoDictionaryKey:@"CFBundleVersion"]
如果不加\
換行標識符而直接轉行,則第二行的內容就不屬于宏定義了, APP_VERSION()
會被定義成空,也就是調用APP_VERSION
會不起任何作用。
#define SWAP_VALUE(a, b) { \
typeof(a) _t = a; \
a = b; \
b = _t; \
}
一般聲明帶有參數列表宏定義的時候,如果函數體字符串太長,通常都會使用換行符來增強函數的可讀性。
在宏聲明中可以使用
typeof
來引用宏參數的類型。這個示例中typeof會獲取參數a的類型,并用這個類型定義中間變量_t來交換a和b的值。因此,該宏可以交換所有基本數據類型的變量(整數,字符,結構等)
2) 字符串化
單個#
號的作用是字符串化,簡單來說就是在輸入值上加上""
雙引號,使其轉換為C字符串。如果是在ObjC環境下,則可在頭部再加上@
符號,出來后即是一個NSString
類型。
#define STRINGIZE_(x) #x
#define STRINGIZE2(x) STRINGIZE_(x)
#define OCNSSTRING(x) @STRINGIZE2(x)
我們帶入實際值,比如OCNSSTRING(3)
,一步步展開看一下:
OCNSSTRING(3) => @STRINGIZE2(3) => @STRINGIZE_(3) => @"3" // NSString @"3"
STRINGIZE2(3) => STRINGIZE_(3) // 這里多加一層是為了處理宏展開的問題,見后文介紹
STRINGIZE_(3) => #3 => "3" // C字符串"3"
字符串化對空格的處理有兩種情況: a). 忽略傳入參數名前面和后面的空格 e.g.
STRINGIZE_( abc ) => "abc"
b)當傳入參數之間存在空格時,忽略其中多于一個的空格 e.g.STRINGIZE_(abc /*多個空格*/ def) => "abc def"
3) 連接運算
連接符號##
用來將前后兩項合并成一個整體。它的執行分為兩步,先是分隔,然后它會將合并項之間的空格去除后完成連接操作。
#define metamacro_concat(A, B) A ## B
NSString *str = @"This is Ryan!";
NSLog(@"%@", metamacro_concat(st, r)); // This is Ryan!
metamacro_concat
的作用是將st
和r
這兩項合并成整體str
,而str
是字符串@"This is Ryan!"
的對象,所以最終會打印出來該字符串內容。剛剛這個示例僅僅是演示了連接這個動作,那上面所說的分隔是什么意思或者說是什么場景呢?我們直接來看下面這個示例:
#define A1(name, type) type name_##type##_type
#define A2(name, type) type name##_##type##_type
帶入實參A1(a1, int)
和A2(a1, int)
,相信你看到這兩個宏定義后,會覺得沒什么特別的,理解了##
的含義,再逐個替換之后(type
替換成int
,name
替換成a1
,##
前后的項連成整體),很快就能得出答案:
A1(a1, int) => int a1_int_int; // 然而并不對!!!
A2(a1, int) => int a1_int_int; // 然而并不對!!!
是的,參照后面注釋,然而并不對!現在該是討論##
分隔操作的時候了,我們先來公布下正確答案,當然我建議你可以找個編譯器實際試一下,看看結果到底是什么。
A1(a1, int) => int name_int_type; // bingo!
A2(a1, int) => int a1_int_type; // bingo!
看到答案是不是很驚訝,為什么有的替換了,有的沒替換,name
不應該都替換成a1
,type
不應該都替換成int
嗎?好了,我們來揭開謎底吧:
預處理器在解析宏的時候會先做分隔操作,就是把
##
的前后項分隔開。
- 宏
A1
的name_##type##_type
會被分隔成name_
,type
和_type
這3段,顯然name
!=name_
;type
!=_type
,所以第一段name_
和第三段_type
不會被宏替換,中間段type
則被替換成int
,按這個規則帶入后就可以得到最終的結果為int name_int_type
- 宏
A2
的name##_##type##_type
會被分隔成name
,_
,type
和_type
這4段,現在就一目了然了,_type
不會被替換,因此帶入參數后得到最終結果為int a1_int_type
分隔的作用類似于空格。在宏定義中,預處理器一般把空格解釋成分段標志,對于每一段和前面比較,相同的就被替換。而##
則會把前后項之間的空格都去除,然后再做連接操作。所以A1
和A2
的定義也可如下:
#define A1(name, type) type name_ ## type ## _t1ype
#define A2(name, type) type name ## _ ## type ## _t1ype
常見的運算符比如
+
,-
,*
,/
,++
以及宏定義操作符#
和...
也是分隔標志,e.g.define add(a, b) a+b
和define add(a, b) a + b
結果是一致的,+
會把a
和b
之間的空格去掉后再去做相應運算
4) 可變多參數
標識符號...
用來標識該宏可以接收可變的參數數量(零個或多個符號)。在宏體中,使用__VA_ARGS__
來表示那些輸入的實際參數,也就是__VA_ARGS__
在預處理中將為實際的參數集所替換。需要注意的是...
只能放在末尾,代替最后面的宏參數。
同
...
與__VA_ARGS__
配對類似,也可以使用NAME...
與NAME
來配對使用表示可變多參數。不同于前者,這里的NAME
是你任意的參數名,并不是系統保留名: e.g.format...
和format
我們來看一個實現對ObjC的NSLog打印信息補充和限制只在DEBUG環境下輸出的可變參數宏定義示例:
#ifdef DEBUG
#define NSLog(format, ...) NSLog((@"%s [Line %d] " format), __func__, __LINE__, ##__VA_ARGS__);
#else
#define NSLog(format, ...)
#endif
在這個宏定義中,如果是非DEBUG環境,那么直接替換為空,也就是NSLog將不起任何作用。我們重點討論DEBUG環境下的定義,第一個參數format
將被單獨處理,接下來輸入的參數則作為一個整體被視為可變參數。比如NSLog(@"name = %@, age = %d", @"Ryan", 18)
, 這里的@"name = %@, age = %d"
即對應宏里的format
, 后面的@"Ryan"
和18
則映射為...
指代為統一的可變參數。 因為我們不確定用戶格式化字符串時會輸入多少個參數,所以我們指定為可變參數,允許用戶輸入任意數量的參數。帶入具體的實參后替換后的結果為:
NSLog((@"%s [Line %d] " "name = %@, age = %d"), __func__, __LINE__, ##@"Ryan", 18);
不知道你有沒有注意到__VA_ARGS__
前面的##
標識符,有了上文的介紹,我們知道它是用來做連接操作的,也就是將name = %@, age = %d
和前面的參數連接后打印出來。但是__VA_ARGS__
本來就是順著__LINE__
后面寫的,應該不需要加##
吧?YES! 確實不需要加##
來做"連接"的作用,那為什么還要加呢?
既然是可變多參數,那它是包括一個case的: 參數數量為0,如果我們把##
去掉,替換后宏就變成如下結果:
NSLog((@"%s [Line %d] "), __func__, __LINE__, ); // 注意最后一個逗號
有沒有發現,當可變參數的個數為0時,最后面會多一個逗號,顯然這個情況下編譯器會報錯的,那怎么才能支持0參數的情況呢?答案就是##
. 當可變參數的個數為0的時候,##
會把前面多余的逗號去掉,所以定義可變參數宏需要記得加上##
來處理這個情況。
宏定義展開
當宏定義有多層嵌套的情況,即宏定義里面又包含另外的宏定義,這時宏展開(替換)需要遵循一定的規則,總體原則是每次只解開當前層的宏,我們直接來看下面這個示例:
#define _ANONYMOUS1(type, var, line) type var##line
#define _ANONYMOUS0(type, line) _ANONYMOUS1(type, _anonymous, line)
#define ANONYMOUSS(type) _ANONYMOUS0(type, __LINE__)
帶入實參ANONYMOUSS(static int);
即: static int _anonymous70;
70表示該行行號。這個宏包含三層,逐一解析:
第一層:ANONYMOUSS(static int) –> _ANONYMOUS0(static int, __LINE__)
第二層: –> _ANONYMOUS1(static int, _anonymous, 70);
第三層: –> static int _anonymous70;
由于每次只能解開當前層的宏,__LINE__
需要等到第二層才能被解開。所以如果我們把中間層_ANONYMOUS0
去掉,直接由_ANONYMOUS1
來定義ANONYMOUSS
:
#define _ANONYMOUS1(type, var, line) type var##line
#define ANONYMOUSS(type) _ANONYMOUS1(type, _anonymous, __LINE__)
再次帶入實參ANONYMOUSS(static int);
這個情況下,最終的結果會是static int _anonymous__LINE__
,預定義宏__LINE__
并不會被解開!所以當你看一些有嵌套宏定義的時候(包括系統的宏定義),你會發現它們往往會加多一層中間轉換宏,加這層宏的用意是把所有宏的參數在這層里全部展開,這個我們在自己實際項目中定義復雜宏的時候也需要特別留意。
這里用到了預定義宏
__LINE__
,預定義宏的行為是由編譯器指定的。__LINE__
返回展開該宏時在文件中的行數,其他類似的有__FILE__
返回當前文件的絕對路徑;__func__
是該宏所在scope的函數名稱;__COUNTER__
在編譯過程中將從0開始計數,每次被調用時加1。因為唯一性,所以很多時候被用來構造獨立的變量名稱。
宏展開的另外一個規則是,在展開當前宏函數時,如果形參有#
或##
則不進行宏參數的展開,否則先展開宏參數,再展開當前宏。我們來看一道經典的C語言題目:
#include <stdio.h>
#define f(a,b) a##b
#define g(a) #a
#define h(a) g(a)
int main() {
printf("%s\n", h(f(1,2))); // => 12
printf("%s\n", g(f(1,2))); // => f(1,2)
return 0;
}
這道題的正確答案是分別是12
和f(1,2)
,后者宏g
里面的參數f(1,2)
不會被展開。我們對照上面宏展開的規則來分析下:
第一行h(f(1,2))
由于h(a)
非#
或者##
所以先展開參數f(1,2)
即12
再展開當前宏h(12)
=> g(12)
=> 12
第二行g(f(1,2))
由于g(a)
形參帶有#
所以里面的f(1,2)
不會被展開,最終結果就是f(1,2)
相信你應該發現了,其實h(a)
在這里充當的就是中間轉換宏的角色,目的就是為了讓f(1,2)
先在h(a)
里面被展開,避免放到g(a)
里面遇到#
而無法被替換。好了,了解了宏定義的展開規則,我們再留個小作業給大家:
#define VALUE 2
#define STRINGIZES_(s) #s
#define COMBINATION(a,b) int(a##e##b)
printf("int max: %s\n", STRINGIZES_(INT_MAX)); // => ?
printf("%s\n", COMBINATION(VALUE, VALUE)); // => ?
應該怎樣添加轉換宏才能分別打印出int max: 0x7fffffff
和200
? P.S. INT_MAX
的十六進制為0x7fffffff
; 200
則等于2e2
, e
為指數表達式,表示2
乘以10
的2
次方。
宏實例分析
有了上面的介紹,我們可以選一些相對復雜的宏定義來分析了,這邊我們還是選取ReactiveCocoa里面的兩個宏。大家如果有興趣,還是強烈推薦去GitHub下載這個庫查看下,里面有很多讓人嘆為觀止的宏定義。
1) 計算參數個數
下面這個宏metamacro_argcount(...)
用來計算在可變參數的情況下,傳入的實參數量。e.g. int num = metamacro_argcount(a, b, c);
等價于int num = 3;
作者提到靈感是來自于P99. 這里為了方便分析,我們把支持最多參數數量的計算改成10個且做了稍許簡化。
#define metamacro_argcount(...) metamacro_at(10, __VA_ARGS__,10, 9, 8, 7, 6, 5, 4, 3, 2, 1)
#define metamacro_at(N,...) metamacro_concat_at##N(__VA_ARGS__)
#define metamacro_concat_at10(_0,_1,_2,_3,_4,_5,_6,_7,_8,_9,...) metamacro_head(__VA_ARGS__)
#define metamacro_head(...) metamacro_head_first(__VA_ARGS__,0)
#define metamacro_head_first(first,...) first
看起來是不是感覺很復雜?沒關系,我們一步步來,逐層帶入參數來分析。假設我們傳入5個參數metamacro_argcount(a,b,c,d,e)
STEP 1: 帶入metamacro_argcount
metamacro_argcount(a,b,c,d,e) => metamacro_at(10, a,b,c,d,e,10,9,8,7,6,5,4,3,2,1)
這里的__VA_ARGS__
替換成前面傳入的可變實參a,b,c,d,e
STEP 2: 帶入metamacro_at
metamacro_at(10, a,b,c,d,e,10,9,8,7,6,5,4,3,2,1) => metamacro_concat_at10 (a,b,c,d,e,10,9,8,7,6,5,4,3,2,1)
第一個參數為N
, 之后都定義為可變參數。故而N
為10
, __VA_ARGS__
為a, b, c, d, e, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1
, 這一步既修改了參數又修改了方法名。
STEP 3: 帶入metamacro_concat_at10
metamacro_concat_at10 (a,b,c,d,e,10,9,8,7,6,5,4,3,2,1) => metamacro_head(5,4,3,2,1)
這里把前面十個參數替換成_0,_1,_2,_3,_4,_5,_6,_7,_8,_9
, 然后之后的參數,也就是5,4,3,2,1
定義為可變參數,并作為實參傳給宏metamacro_head
. 前10個參數就被drop掉了,所以你不用關心它是被替換成了_0
還是___0
, 總之它們不需要繼續被使用了。
STEP 4: 帶入metamacro_head
metamacro_head(5,4,3,2,1) => metamacro_head_first(5,4,3,2,1,0)
為什么在后面加個0
呢?還記得前面說過的,可變參數的數量可以為零的吧,在這個場景下就變成metamacro_head_first()
了,后面再用metamacro_head_first
取第一個值就出錯了,所以需要額外加個0
, 這樣可變參數為空的時候就變成metamacro_head_first(0)
, 再取第一個值就可以得到參數數量為0了。
STEP 5: 帶入metamacro_head_first
metamacro_head_first(5,4,3,2,1,0) => 5 // 直接獲取第一個值,其他的省略
是不是很cool很magic? 通過幾個宏定義的轉換,我們就能輕易的得出傳入的實參個數,而且這些結果在預處理階段就獲得了,不必等到運行階段再去計算。
2) 參數格式檢查
ReactiveCocoa里面還有個非常精妙的宏keypath(...)
, 可以判斷輸入的路徑參數是否合法,并且給出代碼提示。比如輸入keypath(self.path)
, 宏會作出判斷path
是否為self
的property
, 如果該path
不存在,則給出警告,避免誤寫。而且這個宏支持可變參數,還可以輸入格式為keypath(self, path)
, 同樣會對path
做參數檢查。是不是很神奇?讓我們來揭開外衣看看它的魔法來源。
#define keypath(...) \
metamacro_if_eq(1, metamacro_argcount(__VA_ARGS__))(keypath1(__VA_ARGS__))(keypath2(__VA_ARGS__))
#define keypath1(PATH) \
(((void)(NO && ((void)PATH, NO)), strchr(# PATH, '.') + 1))
#define keypath2(OBJ, PATH) \
(((void)(NO && ((void)OBJ.PATH, NO)), # PATH))
STEP 1: 帶入metamacro_if_eq
metamacro_if_eq(1, metamacro_argcount(self,path))(keypath1(__VA_ARGS__))(keypath2(self,path))
這邊的metamacro_argcount
上面討論過,是計算可變參數個數,所以metamacro_if_eq
的作用就是判斷參數個數,如果個數是1
就執行后面的keypath1
, 若不是1就執行keypath2
, 我們來看看metamacro_if_eq
的定義:
/**
* If A is equal to B, the next argument list is expanded; otherwise, the
* argument list after that is expanded. A and B must be numbers between zero
* and twenty, inclusive. Additionally, B must be greater than or equal to A.
*
* @code
// expands to true
metamacro_if_eq(0, 0)(true)(false)
// expands to false
metamacro_if_eq(0, 1)(true)(false)
* @endcode
*
* This is primarily useful when dealing with indexes and counts in
* metaprogramming.
*/
#define metamacro_if_eq(A, B) \
metamacro_concat(metamacro_if_eq, A)(B)
限于篇幅,這邊就不展開太多了,只重點分析下keypath(...)
宏的實現,至于metamacro_if_eq
我們知道它的作用就可以了,不過我還是建議大家去ReactiveCocoa查看下這個宏的完整定義,并嘗試分析下metamacro_if_eq
的實現原理。我相信,通過本文的介紹再加上一步步的帶入替換,應該不難理解它的實現。
STEP 2: 帶入keypath2
keypath2(self,path) (((void)(NO && ((void)self.path, NO)), # path))
這個宏整體是一個C語言的逗號表達式,我們來回憶下逗號表達式的格式: e.g. int a = (b, c);
逗號表達式取后面的值,故而a
將被賦值成c
, 此時b
在賦值運算中就被忽略了,沒有被使用,所以編譯器會給出警告,為了消除這個warning我們需要在b
前面加上(void)
做個類型強轉操作。
逗號表達式的前項和NO進行了與操作,這個主要是為了讓編譯器忽略第一個值,因為我們真正賦值的是表達式后面的值。預編譯的時候看見了NO, 就會很快的跳過判斷條件。我猜你看到這兒肯定會奇怪了,既然要忽略,那為啥還要用個逗號表達式呢,直接賦值不就好了?
這里主要是對傳入的第一個參數OBJ
和第二個正要輸入的PATH
做了.
操作,這也正是為什么輸入第二個參數時編輯器會給出正確的代碼提示(只要是作為表達式的一部分, Xcode自動會提示)。如果傳入的path不是self的屬性,那么self.path就不是一個合法的表達式,所以自然編譯就不會通過了。
STEP 3: 帶入keypath1
keypath1(self.path) (((void)(NO && ((void)self.path, NO)), strchr(# self.path, '.') + 1))
宏keypath1
接受1個參數,所以我們直接帶入self.path
. 宏的前半段和上面是一樣的,不同的是逗號表達式的后一段strchr(# self.path, '.') + 1
, 函數strchar
是C語言中的函數,用來查找某字符在字符串中首次出現的位置,這里用來在self.path
(注意前面加了#
字符串化)中查找.
出現的位置,再加上1
就是返回.
后面path
的地址了。也就是strchr('self.path', '.')
返回的是一個C字符串,這個字符串從找到'self.path'
中為'.'
的字符開始往后,即'path'
.
按照上面的分析,我們知道keypath(...)
是返回一個經過檢查的合法路徑。如果在ObjC環境下,我們需要的是一個NSString
, 所以我們在調用這個宏的時候,再加上@
符號就OK了, e.g. @keypath(self.path) => @"self.path"
.
有時定義宏我們會故意加上
@
符號,但不是為了轉換NSString
類型,也不是為了某種特別的作用,只是讓調用看起來更原生一些。
我們來看下面這個例子:
#define weakObj(obj) __weak typeof(obj) obj##Weak = obj;
在ObjC里面的block為了防止循環引用,我們會使用__weak
關鍵字,這個宏就是用來實現obj的weak化,調用的時候則是weakObj(self)
, 但是iOS都是習慣加@
符號,比如字符串是@""
, 數組是@[]
, 就連定義協議都是@protocol
, 那怎么讓我們的weakObj
也能在前面加上@
符號呢?
iOS開發的同學應該都記得系統的自動釋放池@autoreleasepool{}
, 這里面就有個@
符號,所以我們可以在weakObj
的宏定義里面放一個空的autoreleasepool{}
, 并且不加@
符號,讓這個@
符號有外面調用的時候加上,也就是這樣的:
#define weakObj(obj) autoreleasepool{} __weak typeof(obj) obj##Weak = obj;
調用的時候@weakObj
里的@
符號就被加到autoreleasepool{}
上了,其實這個autoreleasepool{}
是空的,并不起任何實際作用:
@weakObj(obj) => @autoreleasepool{} __weak typeof(obj) obj##Weak = obj;
宏知識補充
由于宏定義的實質只是文本替換,所以這個并不智能的替換會在一些環境下發生不可預知的錯誤。幸運的是,我們的前輩們發現了這些問題,并且提供了很好的解決方案,這也是我們下面要討論的許多宏定義約定俗成的格式寫法。
1) 使用do{}while(0)語句
對于函數宏,我們一般都是推薦使用do{}while(0)
語句,把函數體包到do
后面的{}
內,為什么要這樣呢?我們看一個實例:
#difne FOO(a,b) a+b; \
a++;
正常調用是沒有問題的,但是如果我們是在if
條件語句里面調用,并且if
語句沒有{}
, 像下面這樣:
if (...)
FOO(a,b) // 滿足了if條件后FOO會被執行
展開之后就會變成(顯然就不對了):
if (...)
a+b; // a+b在滿足了if條件后會被執行
a++; // a++不管if條件是否滿足都會被執行
如果加上do{}while(0)
語句展開后就是:
if (...)
do {
a+b;
a++;
} while (0);
這樣就沒有問題了,但你肯定會疑惑,這個和直接包一個{}
不是一樣的嗎,只要把函數體包成一個整體就可以了。是的,在這個情況下是一樣的,但是do{}while(0)
還有一個功能,會去除多余的分號,我們還是看實例:
#difne FOO(a,b) { a+b; \
a++; }
if (...)
FOO;
else
...
使用{}
情況下我們展開來看:
if (...) {
a+b;
a++;
}; else // 注意這邊多出來的分號,編譯直接報錯!
...
如果是do{}while(0)
的話會直接吸收掉這個分號:
if (...)
do {
a+b;
a++;
} while(0); // 分號被do{}while(0)吸收了
else {
...
}
這個吸收分號的方法現在已經幾乎成了標準寫法。而且因為絕大多數的編譯器都能夠識別do{}while(0)
這種無用的循環并進行優化,所以不會因為這種方法導致運行效率上的差異。
2) 使用({...})語句
GNU C里面有個({...})
形式的賦值擴展。這種形式的語句在順次執行之后,會將最后一次的表達式的賦值作為返回。
#define MIN(A,B) ({ __typeof__(A) __a = (A); __typeof__(B) __b = (B); __a < __b ? __a : __b; })
這個宏用來取輸入參數中較小的值,并將該值作為返回值返回。這里就是用到了({...})
語句來實現,函數體中可以做任意的邏輯處理和運算,但最終的返回值則是最后的表達式。所以在定義宏的時候,我們可以用({...})
語句來定義有返回值的函數宏,這個也是函數宏很常見的寫法,大家在實際項目中也可以注意參照使用。
最后簡單提下宏和const
怎么區別使用,一般來說定義常量字符串就用const,定義代碼就用宏(可以參見iOS的API相關定義)。如果有任何不清楚的,歡迎留言討論。PS. 本文參考了很多前輩們的精彩文章,在文中以超鏈接的方式做了引用,感謝他們的分享,也希望本文能給大家帶來一點幫助。