1. 程式人生 > >iOS中的預編譯指令的初步探究

iOS中的預編譯指令的初步探究

目錄

  其他
    #line

  結語

開篇

我們人類創造東西的時候有個詞叫做仿生學!人類創造什麼東西都會模仿自己來創造,所以上帝沒有長成樹的樣子而和人長得一樣,科幻片裡面外星人也像人一樣有眼睛有鼻子……但是人類自己創造的東西如果太像自己,自己又會嚇尿(恐怖谷效應),人類真是奇葩;奇葩的我們在20世紀創造了改變世界的東西——計算機(電腦),不用懷疑,這貨當然也是仿生學!這貨哪裡長得像人了??別不服,先聽我說完,先把你的磚頭放下。狹義的仿生學是外形上仿生嘛,其實廣義上仿生學還可以原理的仿生,構造的仿生,效能的仿生阿拉巴拉……,計算機(這裡我狹義的使用個人PC來舉例)我們常說的有輸入裝置(鍵盤呀滑鼠呀攝像頭呀……)、處理裝置(CPUGPU……)和輸出裝置(顯示器、音響……);然後你自個兒瞅瞅你自己的眼睛耳朵(輸入),大腦(處理),四肢(輸出) 當初設計電腦必須要這種構造的人難道不是瞅著自己來設計計算機的麼?^_^

所以上計算機組成原理的時候有什麼地方晦澀難以理解的時候,我就立刻解禁我高中的生物知識,然後就迎刃而解了~但是今天我這篇部落格是要講程式的呀,這把犢子扯的那麼遠看客們也難免心有憤懣,你切勿急躁,我馬上就帶你們飛!跟著我用仿生學的角度去理解計算機,那麼計算機程式是神馬呢?教科書上怎麼說?可以被計算機執行,那神馬東西會被人執行的呢?老婆的命令、老爸的呵斥、專案經理的需求變更……我們都會執行,貌似這就是人的程式了,這確實就是人的程式!下面我具體拿老婆的命令來詳解一下人得程式的執行過程;比如老婆說了一句你給我滾出去睡沙發!,首先這句話的處理流程是這樣的:

1

帶你們看計算機程式執行過程之前,我們要嚴肅的瞭解一點程式的編譯,也就是上圖中的,我們把老婆的命令轉換成電訊號的過程。在計算機世界中有些好事者把這個玩意兒稱作編譯器(compiler),什麼gcc呀clang呀阿拉巴拉,說的編譯器這名字逼格好高~其實說白了就是個翻譯的東西,如我們人執行程式過程中,把老婆的話(也是人類的話)翻譯成大腦懂的話(電波),在計算機中就是把各種程式語言(c、c++、oc……)翻譯成0101011……讓計算機懂。編譯器的工作原理基本上都是三段式的,可以分為前端(Frontend)、優化器(Optimizer)、後端(Backend)。前端負責解析原始碼,檢查語法錯誤,並將其翻譯為抽象的語法樹(Abstract Syntax Tree)。優化器對這一中間程式碼進行優化,試圖使程式碼更高效。後端則負責將優化器優化後的中間程式碼轉換為目標機器的程式碼,這一過程後端會最大化的利用目標機器的特殊指令,以提高程式碼的效能。

圖2

為什麼要弄成這三段式的呢?我肯定不會從什麼框架、結構啊優化……角度說起,因為我也不懂呀,哈哈 不過我可以講一個過去的故事給大家,大家試想一下編譯器是怎麼開發出來的呀,好傢伙,上網一搜LLVM編譯器是C++寫的,那c++的編譯器呢?其實不用那麼麻煩,現在把你的手借給我,讓我牽著你回到上個世紀70年代,裡奇正在為他新發明的C語言在寫編譯器呢,他在用匯編語言!組合語言怎麼編譯變成二進位制流呢?答案是使用01011機器碼編寫的編譯器;所以編譯器和計算機語言的進步就像這樣迭代發展的,再之後是用高階語言寫更高階的編譯器,高階的編譯器能編譯更高階的計算機語言……,雖然藍翔的挖掘機技術強,但問題還是來了,世界上計算機那麼多,各種不同的架構,人還好基本架構都一樣,但是計算機有Intel架構的又有ARM架構,怎麼能讓程式語言通過編譯分別產生不同架構的執行碼呢?所以這就是編譯器三段式這種模型的好處了,當我們要支援多種語言時,只需要新增多個前端就可以了。當需要支援多種目標機器時,只需要新增多個後端就可以了。對於中間的優化器,我們可以使用通用的中間程式碼。gcc可以支援c、c++、java……等語言的編譯。

圖3

那麼一個HelloWord的程式的編譯和執行過程大家就按照圖1自行腦補吧

說了這麼多終於正片開始了~ 原來我的囉嗦,因為我就是叫做話癆戴^_^,本人從沒有開發過Mac os的應用所以本文主要示例程式碼和框架都是iOS下的,但是是因為C系語言的預編譯指令,所以基本都能通用。雖然這篇文章有個巨集大的開端,但是本文主要就是想探究一下編譯過程中的預處理部分的部分預處理指令,希望本文能夠做到的就是拋磚引玉,給比我菜的廣大猿友指引一條學習的方向。

在很久很久以前的Xcode不知道什麼版本,Build settings裡面還可以選擇不同的編譯器。

如圖4

不同的編譯器,是否對於預處理指令有差異,我也沒辦法考究了。還有其實、其實人家接觸iOS也只有3個月,我開發iOS使用的第一個IDE就是XCode6,如果坑了大家,那就索瑞~~

現在Xcode6裡面預設使用了Apple LLVM(Low Level Virtual Machine) 6.0的編譯器

圖5

那麼接下來就是正片的高潮啦——預處理指令

高潮之前再加一個預高潮^_^,幹嘛要預處理呢?回去看圖一,老婆說“你給我滾出去睡沙發!” 如果你沒有預處理,你按照順序執行,先滾出去了你可能還不想睡覺,你在沙發上看電視看了幾個小時後才打算睡覺,這時候你發現你竟然忘了從房間拿枕頭和被子出來了,你這時候就去敲老婆的門,又是一頓臭罵,之後你才能睡覺……折騰不? 如果你進行了預處理,當老婆說完指令,其中你獲取到關鍵字“睡沙發”,不管我滾出去之後睡不睡覺,我都先從房間把被子枕頭拿到沙發,這樣是不是效率高了很多?同樣對於C系的語言的開發,預處理可謂舉足輕重,如果你閱讀過優秀的C原始碼,你一定看到了很多 #define #if #error ……  預編譯對程式之後的編譯提供了很多方便以及優化,對於錯誤處理、包引用、跨平臺……有著極大的幫助。而且開發中使用預編譯指令完成一些事情也是很屌的事情,並且你既然走上了一條改變世界的道路那麼當一個有逼格的程式猿的覺悟也需要覺醒呀

檔案包含

#include

這個我真的不想多說,只要你大學C語言課程不是體育老師教得話,他們肯定跟你說過#include “”、#include <>的區別,他們肯定說過#include“xxx”包含和使用#include <xxx>包含的不同之處就是使用<>包含時,前處理器會搜尋C函式庫標頭檔案路徑下的檔案,而使用“”包含時首先搜尋程式所在目錄,其次搜尋系統Path定義目錄,如果還是找不到才會搜尋C函式庫標頭檔案所在目錄。

所以我不想為了彌補你老師犯下的錯,我就不想重複了,有一點需要注意使用#include的時候包含檔案的時候是不能遞迴包含的,例如a.h檔案包含b.h,而b.h就不能再包含a.h了;還有就是重複包含(比如a.h包含了b.h,然後main.c中又包含了a.h和b.h)雖然是允許的但是這會降低編譯效能。那該怎麼辦呢?1、使用#import替代include 2、使用巨集判斷(巨集判斷下面會詳解),xcode很聰明,只要新建一個頭檔案a.h 裡面就自動就生成了 圖6

這個看不懂?你可以等看完#ifndef和#define之後就明白了,大概的原理就是,用巨集定義判斷一個巨集是否定義了,如果沒有定義則會定義這個巨集,這樣以來如果已經包含過則這個巨集定義肯定已經定義過了,即使再包含也不會重新定義了,下面的程式碼也就不會包含進去。

#include_next

這個是非C標準庫裡面的預處理指令,但是Xcode中允許使用,所以也就介紹一下吧。#include_next是GNU(一群牛逼的人瘋狂開源的組織,可以說是Linux的靈魂)的一個擴充套件,並不是標準C中的指令 例如有個搜尋路徑鏈,在#include中,它們的搜尋順序依次是A,B,C,D和E。在B目錄中有個標頭檔案叫a.h,在D目錄中也有個標頭檔案叫a.h,如果在我們的原始碼中這樣寫#include <a.h>,那麼我們就會包含的是B目錄中的a.h標頭檔案,如果我們這樣寫#include_next <a.h>那麼我們就會包含的是D目錄中的a.h標頭檔案。#include_next <a.h>的意思按我們上面的引號包含中的解釋來說就是“在B目錄中的a.h標頭檔案後面的目錄路徑(即C,D和E)中搜索a.h標頭檔案幷包含進來)。#include_next <a.h>的操作會是這樣的,它將在A,B,C,D和E目錄中依次搜尋a.h標頭檔案,那麼首先它會在B目錄中搜索到a.h標頭檔案,那它就會以B目錄作為分割點,搜尋B目錄後面的目錄(C,D和E),然後在這後面的目錄中搜索a.h標頭檔案,並把在這之後搜尋到的a.h標頭檔案包含進來。這樣說的話大家應該清楚了吧。

#import

OC特有的就是一個智慧的#include,解決了#include的重複包含的問題。

巨集定義

#define 

這個使用的就太多了,個人認為是所有預處理指令中最酷的!必須要學習!這裡我厚顏無恥的轉載OneV’s Den的文章,他寫的非常的棒!免得同學們連結跳來跳去我就直接貼上他的文章吧,請叫我快樂的搬運工!

巨集定義的黑魔法 - 巨集菜鳥起飛手冊

巨集定義在C系開發中可以說佔有舉足輕重的作用。底層框架自不必說,為了編譯優化和方便,以及跨平臺能力,巨集被大量使用,可以說底層開發離開define將寸步難行。而在更高層級進行開發時,我們會將更多的重心放在業務邏輯上,似乎對巨集的使用和依賴並不多。但是使用巨集定義的好處是不言自明的,在節省工作量的同時,程式碼可讀性大大增加。如果想成為一個能寫出漂亮優雅程式碼的開發者,巨集定義絕對是必不可少的技能(雖然巨集本身可能並不漂亮優雅XD)。但是因為巨集定義對於很多人來說,並不像業務邏輯那樣是每天會接觸的東西。即使是能偶爾使用到一些巨集,也更多的僅僅只停留在使用的層級,卻並不會去探尋背後發生的事情。有一些開發者確實也有探尋的動力和意願,但卻在點開一個定義之後發現還有巨集定義中還有其他無數定義,再加上滿螢幕都是不同於平時的程式碼,既看不懂又不變色,於是乎心生煩惱,怒而回退。本文希望通過循序漸進的方式,通過幾個例子來表述C系語言巨集定義世界中的一些基本規則和技巧,從0開始,希望最後能讓大家至少能看懂和還原一些相對複雜的巨集。考慮到我自己現在objc使用的比較多,這個站點的讀者應該也大多是使用objc的,所以有部分例子是選自objc,但是本文的大部分內容將是C系語言通用。

入門

如果您完全不知道巨集是什麼的話,可以先來熱個身。很多人在介紹巨集的時候會說,巨集嘛很簡單,就是簡單的查詢替換嘛。嗯,只說對了的一半。C中的巨集分為兩類,物件巨集(object-like macro)和函式巨集(function-like macro)。對於物件巨集來說確實相對簡單,但卻也不是那麼簡單的查詢替換。物件巨集一般用來定義一些常數,舉個例子:


//This defines PI
#define M_PI        3.14159265358979323846264338327950288

#define關鍵字表明即將開始定義一個巨集,緊接著的M_PI是巨集的名字,空格之後的數字是內容。類似這樣的#define X A的巨集是比較簡單的,在編譯時編譯器會在語義分析認定是巨集後,將X替換為A,這個過程稱為巨集的展開。比如對於上面的M_PI


#define M_PI        3.14159265358979323846264338327950288

double r = 10.0;  
double circlePerimeter = 2 * M_PI * r;  
// => double circlePerimeter = 2 * 3.14159265358979323846264338327950288 * r;

printf("Pi is %0.7f",M_PI);  
//Pi is 3.1415927

那麼讓我們開始看看另一類巨集吧。函式巨集顧名思義,就是行為類似函式,可以接受引數的巨集。具體來說,在定義的時候,如果我們在巨集名字後面跟上一對括號的話,這個巨集就變成了函式巨集。從最簡單的例子開始,比如下面這個函式巨集


//A simple function-like macro
#define SELF(x)      x
NSString *name = @"Macro Rookie";  
NSLog(@"Hello %@",SELF(name));  
// => NSLog(@"Hello %@",name);
//   => Hello Macro Rookie

這個巨集做的事情是,在編譯時如果遇到SELF,並且後面帶括號,並且括號中的引數個數與定義的相符,那麼就將括號中的引數換到定義的內容裡去,然後替換掉原來的內容。 具體到這段程式碼中,SELF接受了一個name,然後將整個SELF(name)用name替換掉。嗯..似乎很簡單很沒用,身經百戰閱碼無數的你一定會認為這個巨集是寫出來賣萌的。那麼接受多個引數的巨集肯定也不在話下了,例如這樣的:


#define PLUS(x,y) x + y
printf("%d",PLUS(3,2));  
// => printf("%d",3 + 2);
//  => 5

相比物件巨集來說,函式巨集要複雜一些,但是看起來也相當簡單吧?嗯,那麼現在熱身結束,讓我們正式開啟巨集的大門吧。

巨集的世界,小有乾坤

因為巨集展開其實是編輯器的預處理,因此它可以在更高層級上控制程式原始碼本身和編譯流程。而正是這個特點,賦予了巨集很強大的功能和靈活度。但是凡事都有兩面性,在獲取靈活的背後,是以需要大量時間投入以對各種邊界情況進行考慮來作為代價的。可能這麼說並不是很能讓人理解,但是大部分巨集(特別是函式巨集)背後都有一些自己的故事,挖掘這些故事和設計的思想會是一件很有意思的事情。另外,我一直相信在實踐中學習才是真正掌握知識的唯一途徑,雖然可能正在看這篇博文的您可能最初並不是打算親自動手寫一些巨集,但是這我們不妨開始動手從實際的書寫和犯錯中進行學習和挖掘,因為只有肌肉記憶和大腦記憶協同起來,才能說達到掌握的水準。可以說,寫巨集和用巨集的過程,一定是在在犯錯中學習和深入思考的過程,我們接下來要做的,就是重現這一系列過程從而提高進步。

第一個題目是,讓我們一起來實現一個MIN巨集吧:實現一個函式巨集,給定兩個數字輸入,將其替換為較小的那個數。比如MIN(1,2)出來的值是1。嗯哼,simple enough?定義巨集,寫好名字,兩個輸入,然後換成比較取值。比較取值嘛,任何一本入門級別的C程式設計上都會有講啊,於是我們可以很快寫出我們的第一個版本:


//Version 1.0
#define MIN(A,B) A < B ? A : B

Try一下


int a = MIN(1,2);  
// => int a = 1 < 2 ? 1 : 2;
printf("%d",a);  
// => 1

輸出正確,打包釋出!圖7

瀟灑走一回

但是在實際使用中,我們很快就遇到了這樣的情況


int a = 2 * MIN(3, 4);  
printf("%d",a);  
// => 4

看起來似乎不可思議,但是我們將巨集展開就知道發生什麼了


int a = 2 * MIN(3, 4);  
// => int a = 2 * 3 < 4 ? 3 : 4;
// => int a = 6 < 4 ? 3 : 4;
// => int a = 4;

嘛,寫程式這個東西,bug出來了,原因知道了,事後大家就都是諸葛亮了。因為小於和比較符號的優先順序是較低的,所以乘法先被運算了,修正非常簡單嘛,加括號就好了。


//Version 2.0
#define MIN(A,B) (A < B ? A : B)

這次2 * MIN(3, 4)這樣的式子就輕鬆愉快地拿下了。經過了這次修改,我們對自己的巨集信心大增了...直到,某一天一個怒氣衝衝的同事跑來摔鍵盤,然後給出了一個這樣的例子:


int a = MIN(3, 4 < 5 ? 4 : 5);  
printf("%d",a);  
// => 4

簡單的相比較三個數字並找到最小的一個而已,要怪就怪你沒有提供三個數字比大小的巨集,可憐的同事只好自己實現4和5的比較。在你開始著手解決這個問題的時候,你首先想到的也許是既然都是求最小值,那寫成MIN(3, MIN(4, 5))是不是也可以。於是你就隨手這樣一改,發現結果變成了3,正是你想要的..接下來,開始懷疑之前自己是不是看錯結果了,改回原樣,一個4赫然出現在螢幕上。你終於意識到事情並不是你想像中那樣簡單,於是還是回到最原始直接的手段,展開巨集。


int a = MIN(3, 4 < 5 ? 4 : 5);  
// => int a = (3 < 4 < 5 ? 4 : 5 ? 3 : 4 < 5 ? 4 : 5);  //希望你還記得運算子優先順序
//  => int a = ((3 < (4 < 5 ? 4 : 5) ? 3 : 4) < 5 ? 4 : 5);  //為了您不太糾結,我給這個式子加上了括號
//   => int a = ((3 < 4 ? 3 : 4) < 5 ? 4 : 5)
//    => int a = (3 < 5 ? 4 : 5)
//     => int a = 4

找到問題所在了,由於展開時連線符號和被展開式子中的運算子號優先順序相同,導致了計算順序發生了變化,實質上和我們的1.0版遇到的問題是差不多的,還是考慮不周。那麼就再嚴格一點吧,3.0版!


//Version 3.0
#define MIN(A,B) ((A) < (B) ? (A) : (B))

至於為什麼2.0版本中的MIN(3, MIN(4, 5))沒有出問題,可以正確使用,這裡作為練習,大家可以試著自己展開一下,來看看發生了什麼。

經過兩次悲劇,你現在對這個簡單的巨集充滿了疑惑。於是你跑了無數的測試用例而且它們都通過了,我們似乎徹底解決了括號問題,你也認為從此這個巨集就妥妥兒的哦了。不過如果你真的這麼想,那你就圖樣圖森破了。生活總是殘酷的,該來的bug也一定是會來的。不出意外地,在一個霧霾陰沉的下午,我們又收到了一個出問題的例子。


float a = 1.0f;  
float b = MIN(a++, 1.5f);  
printf("a=%f, b=%f",a,b);  
// => a=3.000000, b=2.000000

拿到這個出問題的例子你的第一反應可能和我一樣,這TM的誰這麼二貨還在比較的時候搞++,這簡直亂套了!但是這樣的人就是會存在,這樣的事就是會發生,你也不能說人家邏輯有錯誤。a是1,a++表示先使用a的值進行計算,然後再加1。那麼其實這個式子想要計算的是取a和b的最小值,然後a等於a加1:所以正確的輸出a為2,b為1才對!嘛,滿眼都是淚,讓我們這些久經摧殘的程式設計師淡定地展開這個式子,來看看這次又發生了些什麼吧:


float a = 1.0f;  
float b = MIN(a++, 1.5f);  
// => float b = ((a++) < (1.5f) ? (a++) : (1.5f))

其實只要展開一步就很明白了,在比較a++和1.5f的時候,先取1和1.5比較,然後a自增1。接下來條件比較得到真以後又觸發了一次a++,此時a已經是2,於是b得到2,最後a再次自增後值為3。出錯的根源就在於我們預想的是a++只執行一次,但是由於巨集展開導致了a++被多執行了,改變了預想的邏輯。解決這個問題並不是一件很簡單的事情,使用的方式也很巧妙。我們需要用到一個GNU C的賦值擴充套件,即使用({...})的形式。這種形式的語句可以類似很多指令碼語言,在順次執行之後,會將最後一次的表示式的賦值作為返回。舉個簡單的例子,下面的程式碼執行完畢後a的值為3,而且b和c只存在於大括號限定的程式碼域中


int a = ({  
    int b = 1;
    int c = 2;
    b + c;
});
// => a is 3

有了這個擴充套件,我們就能做到之前很多做不到的事情了。比如徹底解決MIN巨集定義的問題,而也正是GNU C中MIN的標準寫法


//GNUC MIN
#define MIN(A,B)    ({ __typeof__(A) __a = (A); __typeof__(B) __b = (B); __a < __b ? __a : __b; })

這裡定義了三個語句,分別以輸入的型別申明瞭__a__b,並使用輸入為其賦值,接下來做一個簡單的條件比較,得到__a__b中的較小值,並使用賦值擴充套件將結果作為返回。這樣的實現保證了不改變原來的邏輯,先進行一次賦值,也避免了括號優先順序的問題,可以說是一個比較好的解決方案了。如果編譯環境支援GNU C的這個擴充套件,那麼毫無疑問我們應該採用這種方式來書寫我們的MIN巨集,如果不支援這個環境擴充套件,那我們只有人為地規定引數不帶運算或者函式呼叫,以避免出錯。

關於MIN我們討論已經夠多了,但是其實還存留一個懸疑的地方。如果在同一個scope內已經有__a或者__b的定義的話(雖然一般來說不會出現這種悲劇的命名,不過誰知道呢),這個巨集可能出現問題。在申明後賦值將因為定義重複而無法被初始化,導致巨集的行為不可預知。如果您有興趣,不妨自己動手試試看結果會是什麼。Apple在Clang中徹底解決了這個問題,我們把Xcode開啟隨便建一個新工程,在程式碼中輸入MIN(1,1),然後Cmd+點選即可找到clang中 MIN的寫法。為了方便說明,我直接把相關的部分抄錄如下:


//CLANG MIN
#define __NSX_PASTE__(A,B) A##B

#define MIN(A,B) __NSMIN_IMPL__(A,B,__COUNTER__)

#define __NSMIN_IMPL__(A,B,L) ({ __typeof__(A) __NSX_PASTE__(__a,L) = (A); __typeof__(B) __NSX_PASTE__(__b,L) = (B); (__NSX_PASTE__(__a,L) < __NSX_PASTE__(__b,L)) ? __NSX_PASTE__(__a,L) : __NSX_PASTE__(__b,L); })

似乎有點長,看起來也很吃力。我們先美化一下這巨集,首先是最後那個__NSMIN_IMPL__內容實在是太長了。我們知道程式碼的話是可以插入換行而不影響含義的,巨集是否也可以呢?答案是肯定的,只不過我們不能使用一個單一的回車來完成,而必須在回車前加上一個反斜槓\。改寫一下,為其加上換行好看些:


#define __NSX_PASTE__(A,B) A##B

#define MIN(A,B) __NSMIN_IMPL__(A,B,__COUNTER__)

#define __NSMIN_IMPL__(A,B,L) ({ __typeof__(A) __NSX_PASTE__(__a,L) = (A); \
                                 __typeof__(B) __NSX_PASTE__(__b,L) = (B); \
                                 (__NSX_PASTE__(__a,L) < __NSX_PASTE__(__b,L)) ? __NSX_PASTE__(__a,L) : __NSX_PASTE__(__b,L); \
                              })

但可以看出MIN一共由三個巨集定義組合而成。第一個__NSX_PASTE__裡出現的兩個連著的井號##在巨集中是一個特殊符號,它表示將兩個引數連線起來這種運算。注意函式巨集必須是有意義的運算,因此你不能直接寫AB來連線兩個引數,而需要寫成例子中的A##B。巨集中還有一切其他的自成一脈的運算子號,我們稍後還會介紹幾個。接下來是我們呼叫的兩個引數的MIN,它做的事是呼叫了另一個三個引數的巨集__NSMIN_IMPL__,其中前兩個引數就是我們的輸入,而第三個__COUNTER__我們似乎不認識,也不知道其從何而來。其實__COUNTER__是一個預定義的巨集,這個值在編譯過程中將從0開始計數,每次被呼叫時加1。因為唯一性,所以很多時候被用來構造獨立的變數名稱。有了上面的基礎,再來看最後的實現巨集就很簡單了。整體思路和前面的實現和之前的GNUC MIN是一樣的,區別在於為變數名__a__b添加了一個計數字尾,這樣大大避免了變數名相同而導致問題的可能性(當然如果你執拗地把變數叫做__a9527並且出問題了的話,就只能說不作死就不會死了)。

花了好多功夫,我們終於把一個簡單的MIN巨集徹底搞清楚了。巨集就是這樣一類東西,簡單的表面之下隱藏了很多玄機,可謂小有乾坤。作為練習大家可以自己嘗試一下實現一個SQUARE(A),給一個數字輸入,輸出它的平方的巨集。雖然一般這個計算現在都是用inline來做了,但是通過和MIN類似的思路我們是可以很好地實現它的,動手試一試吧 :)

Log,永恆的主題

Log人人愛,它為我們指明前進方向,它為我們抓蟲提供幫助。在objc中,我們最多使用的log方法就是NSLog輸出資訊到控制檯了,但是NSLog的標準輸出可謂殘廢,有用資訊完全不夠,比如下面這段程式碼:

NSArray *array = @[@"Hello", @"My", @"Macro"];  
NSLog (@"The array is %@", array); 

列印到控制檯裡的結果是類似這樣的

2014-01-20 11:22:11.835 TestProject[23061:70b] The arr

ay is ( Hello, My, Macro )

我們在輸出的時候關心什麼?除了結果以外,很多情況下我們會對這行log的所在的檔案位置方法什麼的會比較關心。在每次NSLog裡都手動加上方法名字和位置資訊什麼的無疑是個笨辦法,而如果一個工程裡已經有很多NSLog的呼叫了,一個一個手動去改的話無疑也是噩夢。我們通過巨集,可以很簡單地完成對NSLog原生行為的改進,優雅,高效。只需要在預編譯的pch檔案中加上

//A better version of NSLog
#define NSLog(format, ...) do {                                                                          \
                             fprintf(stderr, "<%s : %d> %s\n",                                           \
                             [[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String],  \
                             __LINE__, __func__);                                                        \
                             (NSLog)((format), ##__VA_ARGS__);                                           \
                             fprintf(stderr, "-------\n");                                               \
                           } while (0)

嘛,這是我們到現在為止見到的最長的一個巨集了吧...沒關係,一點一點來分析就好。首先是定義部分,第2行的NSLog(format, ...)。我們看到的是一個函式巨集,但是它的引數比較奇怪,第二個引數是...,在巨集定義(其實也包括函式定義)的時候,寫為...的引數被叫做可變引數(variadic)。可變引數的個數不做限定。在這個巨集定義中,除了第一個引數format將被單獨處理外,接下來輸入的引數將作為整體一併看待。回想一下NSLog的用法,我們在使用NSLog時,往往是先給一個format字串作為第一個引數,然後根據定義的格式在後面的引數裡跟上寫要輸出的變數之類的。這裡第一個格式化字串即對應巨集裡的format,後面的變數全部對映為...作為整體處理。

接下來巨集的內容部分。上來就是一個下馬威,我們遇到了一個do while語句...想想看你上次使用do while是什麼時候吧?也許是C程式設計課的大作業?或者是某次早已被遺忘的演算法面試上?總之雖然大家都是明白這個語句的,但是實際中可能用到它的機會少之又少。乍一看似乎這個do while什麼都沒做,因為while是0,所以do肯定只會被執行一次。那麼它存在的意義是什麼呢,我們是不是可以直接簡化一下這個巨集,把它給去掉,變成這個樣子呢?

//A wrong version of NSLog
#define NSLog(format, ...)   fprintf(stderr, "<%s : %d> %s\n",                                           \
                             [[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String],  \
                             __LINE__, __func__);                                                        \
                             (NSLog)((format), ##__VA_ARGS__);                                           \
                             fprintf(stderr, "-------\n"); 

答案當然是否定的,也許簡單的測試裡你沒有遇到問題,但是在生產環境中這個巨集顯然悲劇了。考慮下面的常見情況

if (errorHappend)  
    NSLog(@"Oops, error happened");

展開以後將會變成

if (errorHappend)  
    fprintf((stderr, "<%s : %d> %s\n",[[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String], __LINE__, __func__);
(NSLog)((format), ##__VA_ARGS__); //I wi

ll expand this later fprintf(stderr, "-------\n");

注意..C系語言可不是靠縮排來控制程式碼塊和邏輯關係的。所以說如果使用這個巨集的人沒有在條件判斷後加大括號的話,你的巨集就會一直呼叫真正的NSLog輸出東西,這顯然不是我們想要的邏輯。當然在這裡還是需要重新批評一下認為if後的單條執行語句不加大括號也沒問題的同學,這是陋習,無需理由,請改正。不論是不是一條語句,也不論是if後還是else後,都加上大括號,是對別人和自己的一種尊重。

好了知道我們的巨集是如何失效的,也就知道了修改的方法。作為巨集的開發者,應該力求使用者在最大限度的情況下也不會出錯,於是我們想到直接用一對大括號把巨集內容括起來,大概就萬事大吉了?像這樣:

//Another wrong version of NSLog
#define NSLog(format, ...)   {
                               fprintf(stderr, "<%s : %d> %s\n",                                           \
                               [[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String],  \
                               __LINE__, __func__);                                                        \
                               (NSLog)((format), ##__VA_ARGS__);                                           \
                               fprintf(stderr, "-------\n");                                               \
                             }

展開剛才的那個式子,結果是

//I am sorry if you don't like { in the same like. But I am a fan of this style :P
if (errorHappend) {  
    fprintf((stderr, "<%s : %d> %s\n",[[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String], __LINE__, __func__);
    (NSLog)((format), ##__VA_ARGS__);
    fprintf(stderr, "-------\n");
};

編譯,執行,正確!因為用大括號標識程式碼塊是不會嫌多的,所以這樣一來的話我們的巨集在不論if後面有沒有大括號的情況下都能工作了!這麼看來,前面例子中的do while果然是多餘的?於是我們又可以愉快地釋出了?如果你夠細心的話,可能已經發現問題了,那就是上面最後的一個分號。雖然編譯執行測試沒什麼問題,但是始終稍微有些刺眼有木有?沒錯,因為我們在寫NSLog本身的時候,是將其當作一條語句來處理的,後面跟了一個分號,在巨集展開後,這個分號就如同噩夢一般的多出來了。什麼,你還沒看出哪兒有問題?試試看展開這個例子吧:

if (errorHappend)  
    NSLog(@"Oops, error happened");
else  
    //Yep, no error, I am happy~ :)

No! I am not haapy at all! 因為編譯錯誤了!實際上這個巨集展開以


後變成了這個樣子:
if (errorHappend) {  
    fprintf((stderr, "<%s : %d> %s\n",[[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String], __LINE__, __func__);
    (NSLog)((format), ##__VA_ARGS__);
    fprintf(stderr, "-------\n");
}; else {
    //Yep, no error, I am happy~ :)
}

因為else前面多了一個分號,導致了編譯錯誤,很惱火..要是寫程式碼的人乖乖寫大括號不就啥事兒沒有了麼?但是我們還是有巧妙的解決方法的,那就是上面的do while。把巨集的程式碼塊新增到do中,然後之後while(0),在行為上沒有任何改變,但是可以巧妙地吃掉那個悲劇的分號,使用do while的版本展


開以後是這個樣子的
if (errorHappend)  
    do {
        fprintf((stderr, "<%s : %d> %s\n",[[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String], __LINE__, __func__);
        (NSLog)((format), ##__VA_ARGS__);
        fprintf(stderr, "-------\n");
    } while (0);
else {  
    //Yep, no error, I am really happy~ :)
}

這個吃掉分號的方法被大量運用在程式碼塊巨集中,幾乎已經成為了標準寫法。而且while(0)的好處在於,在編譯的時候,編譯器基本都會為你做好優化,把這部分內容去掉,最終編譯的結果不會因為這個do while而導致執行效率上的差異。在終於弄明白了這個奇怪的do while之後,我們終於可以繼續深入到這個巨集裡面了。巨集本體內容的第一行沒有什麼值得多說的fprintf(stderr, "<%s : %d> %s\n",,簡單的格式化輸出而已。注意我們使用了\將這個巨集分成了好幾行來寫,實際在最後展開時會被合併到同一行內,我們在剛才MIN最後也用到了反斜槓,希望你還能記得。接下來一行我們填寫這個格式輸出中


的三個token,
[[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String], __LINE__, __func__);

這裡用到了三個預定義巨集,和剛才的__COUNTER__類似,預定義巨集的行為是由編譯器指定的。__FILE__返回當前檔案的絕對路徑,__LINE__返回展開該巨集時在檔案中的行數,__func__是改巨集所在scope的函式名稱。我們在做Log輸出時如果帶上這這三個引數,便可以加快解讀Log,迅速定位。關於編譯器預定義的Log以及它們的一些實現機制,感興趣的同學可以移步到gcc文件的PreDefine頁面和clang的Builtin Macro進行檢視。在這裡我們將格式化輸出的三個引數分別設定為檔名的最後一個部分(因為絕對路徑太長很難看),行數,以及方法名稱。

接下來是還原原始的NSLog,(NSLog)((format), ##__VA_ARGS__);中出現了另一個預定義的巨集__VA_ARGS__(我們似乎已經找出規律了,前後雙下槓的一般都是預定義)。__VA_ARGS__表示的是巨集定義中的...中的所有剩餘引數。我們之前說過可變引數將被統一處理,在這裡展開的時候編譯器會將__VA_ARGS__直接替換為輸入中從第二個引數開始的剩餘引數。另外一個懸疑點是在它前面出現了兩個井號##。還記得我們上面在MIN中的兩個井號麼,在那裡兩個井號的意思是將前後兩項合併,在這裡做的事情比較類似,將前面的格式化字串和後面的引數列表合併,這樣我們就得到了一個完整的NSLog方法了。之後的幾行相信大家自己看懂也沒有問題了,最後輸出一下試試看,大概


看起來會是這樣的。
-------
<AppDelegate.m : 46> -[AppDelegate application:didFinishLaunchingWithOptions:]  
2014-01-20 16:44:25.480 TestProject[30466:70b] The array is (  
    Hello,
    My,
    Macro
)
-------

帶有檔案,行號和方法的輸出,並且用橫槓隔開了(請原諒我沒有質感的設計,也許我應該畫一隻牛,比如這樣?),debug的時候也許會輕鬆一些吧 :)圖8

hello cowsay

這個Log有三個懸念點,首先是為什麼我們要把format單獨寫出來,然後吧其他引數作為可變引數傳遞呢?如果我們不要那個format,而直接寫成NSLog(...)會不會有問題?對於我們這裡這個例子來說的話是沒有變化的,但是我們需要記住的是...是可變引數列表,它可以代表一個、兩個,或者是很多個引數,但同時它也能代表零個引數。如果我們在申明這個巨集的時候沒有指定format引數,而直接使用引數列表,那麼在使用中不寫引數的NSLog()也將被匹配到這個巨集中,導致編譯無法通過。如果你手邊有Xcode,也可以看看Cocoa中真正的NSLog方法的實現,可以看到它也是接收一個格式引數和一個引數列表的形式,我們在巨集裡這麼定義,正是為了其傳入正確合適的引數,從而保證使用者可以按照原來的方式正確使用這個巨集。

第二點是既然我們的可變引數可以接受任意個輸入,那麼在只有一個format輸入,而可變引數個數為零的時候會發生什麼呢?不妨展開看一看,記住##的作用是拼接前後,而現在##


後的可變引數是空:
NSLog(@"Hello");  
=> do {
       fprintf((stderr, "<%s : %d> %s\n",[[[NSString stringWithUTF8String:__FILE__] lastPathComponent] UTF8String], __LINE__, __func__);
       (NSLog)((@"Hello"), );
       fprintf(stderr, "-------\n");
   } while (0);

中間的一行(NSLog)(@"Hello", );似乎是存在問題的,你一定會有疑惑,這種方式怎麼可能編譯通過呢?!原來大神們其實早已想到這個問題,並且進行了一點特殊的處理。這裡有個特殊的規則,在逗號__VA_ARGS__之間的雙井號,除了拼接前後文本之外,還有一個功能,那就是如果後方文字為空,那麼它會將前面一個逗號吃掉。這個特性當且僅當上面說的條件成立時才會生效,因此可以說是特例。加上這條規則後,我們就可以將剛才的式子展開為正確的(NSLog)((@"Hello"));了。

最後一個值得討論的地方是(NSLog)((format), ##__VA_ARGS__);的括號使用。把看起來能去掉的括號去掉,寫成NSLog(format, ##__VA_ARGS__);是否可以呢?在這裡的話應該是沒有什麼大問題的,首先format不會被呼叫多次也不太存在誤用的可能性(因為最後編譯器會檢查NSLog的輸入是否正確)。另外你也不用擔心展開以後式子裡的NSLog會再次被自己展開,雖然展開式中NSLog也滿足了我們的巨集定義,但是巨集的展開非常聰明,展開後會自身無限迴圈的情況,就不會再次被展開了。

作為一個您讀到了這裡的小獎勵,附送三個debug輸出rect,size和point的巨集,希望您能用上(嗯..想想曾經有多少次你需要列印這些結構體的某個數字而被折磨致死,讓它們玩兒蛋去吧!當然請


先加油看懂它們吧)
#define NSLogRect(rect) NSLog(@"%s x:%.4f, y:%.4f, w:%.4f, h:%.4f", #rect, rect.origin.x, rect.origin.y, rect.size.width, rect.size.height)
#define NSLogSize(size) NSLog(@"%s w:%.4f, h:%.4f", #size, size.width, size.height)
#define NSLogPoint(point) NSLog(@"%s x:%.4f, y:%.4f", #point, point.x, point.y)

兩個實際應用的例子

當然不是說上面介紹的巨集實際中不能用。它們相對簡單,但是裡面坑不少,所以顯得很有特點,非常適合作為入門用。而實際上在日常中很多我們常用的巨集並沒有那麼多奇怪的問題,很多時候我們按照想法去實現,再稍微注意一下上述介紹的可能存在的共通問題,一個高質量的巨集就可以誕生。如果能寫出一些有意義價值的巨集,小了從對你的程式碼的使用者來說,大了從整個社群整個世界和減少碳排放來說,你都做出了相當的貢獻。我們通過幾個實際的例子來看看,巨集是如何改變我們的生活,和寫程式碼的習慣的吧。

先來看看這兩個巨集


#define XCTAssertTrue(expression, format...) \
    _XCTPrimitiveAssertTrue(expression, ## format)

#define _XCTPrimitiveAssertTrue(expression, format...) \
({ \
    @try { \
        BOOL _evaluatedExpression = !!(expression); \
        if (!_evaluatedExpression) { \
            _XCTRegisterFailure(_XCTFailureDescription(_XCTAssertion_True, 0, @#expression),format); \
        } \
    } \
    @catch (id exception) { \
        _XCTRegisterFailure(_XCTFailureDescription(_XCTAssertion_True, 1, @#expression, [exception reason]),format); \
    }\
})

如果您常年做蘋果開發,卻沒有見過或者完全不知道XCTAssertTrue是什麼的話,強烈建議補習一下測試驅動開發的相關知識,我想應該會對您之後的道路很有幫助。如果你已經很熟悉這個命令了,那我們一起開始來看看幕後發生了什麼。

有了上面的基礎,相信您大體上應該可以自行解讀這個巨集了。({...})的語法和##都很熟悉了,這裡有三個值得注意的地方,在這個巨集的一開始,我們後面的的引數是format...,這其實也是可變引數的一種寫法,和...__VA_ARGS__配對類似,{NAME}...將於{NAME}配對使用。也就是說,在這裡巨集內容的format指代的其實就是定義的先對expression取了兩次反?我不是科班出身,但是我還能依稀記得這在大學程式課上講過,兩次取反的操作可以確保結果是BOOL值,這在objc中還是比較重要的(關於objc中BOOL的討論已經有很多,如果您還沒能分清BOOL, bool和Boolean,可以參看NSHisper的這篇文章)。然後就是@#expression這個式子。我們接觸過雙井號##,而這裡我們看到的操作符是單井號#,注意井號前面的@是objc的編譯符號,不屬於巨集操作的物件。單個井號的作用是字串化,簡單來說就是將替換後在兩頭加上"",轉為一個C字串。這裡使用@然後緊跟#expression,出來後就是一個內容是expression的內容的NSString。然後這個NSString再作為引數傳遞給_XCTRegisterFailure_XCTFailureDescription等,繼續進行展開,這些是後話。簡單一瞥,我們大概就可以想象巨集幫助我們省了多少事兒了,如果各位看官要是寫個斷言還要來個十多行的話,想象都會瘋掉的吧。

另外一個例子,找了人民群眾喜聞樂見的ReactiveCocoa(RAC)中的一個巨集定義。對於RAC不熟悉或者沒聽過的朋友,可以簡單地看看Limboy的一系列相關博文(搜尋ReactiveCocoa),介紹的很棒。如果覺得“哇哦這個好酷我很想學”的話,不妨可以跟隨raywenderlich上這個系列的教程做一些實踐,裡面簡單地用到了RAC,但是都已經包含了RAC的基本用法了。RAC中有幾個很重要的巨集,它們是保證RAC簡潔好用的基本,可以說要是沒有這幾個巨集的話,是不會有人喜歡RAC的。其中RACObserve就是其中一個,它通過KVC來為物件的某個屬性建立一個訊號返回(如果你看不懂這句話,不要擔心,這對你理解這個巨集的寫法和展開沒有任何影響)。對於這個巨集,我決定不再像上面那樣展開和講解,我會在最後把相關的巨集都貼出來,大家不妨拿它練練手,看看能不能將其展開到程式碼的狀態,並且明白其中都發生了些什麼。如果你遇到什麼問題或者在展開過程中有所心得,歡迎在評論裡留言分享和交流 :)

好了,這篇文章已經夠長了。希望在看過以後您在看到巨集的時候不再發怵,而是可以很開心地說這個我會這個我會這個我也會。最終目標當然是寫出漂亮高效簡潔的巨集,這不論對於提高生產力還是~震懾你的同事~提升自己實力都會很有幫助。

另外,在這裡一定要宣傳一下關注了很久的@hangcom 吳航前輩的新書《iOS應用逆向工程》。很榮幸能夠在釋出之前得到前輩的允許拜讀了整本書,可以說看的暢快淋漓。我之前並沒有越獄開發的任何基礎,也對相關領域知之甚少,在這