原文地址:www.objc.io/issues/6-bu…
原文作者:twitter.com/chriseidhof
发布时间:2013年11月
在这篇文章中,我们将看看编译器是做什么的,以及我们如何利用它来为我们带来优势。
大致来说,编译器有两个任务:将我们的Objective-C代码转换为低级代码,以及分析我们的代码以确保我们没有犯任何明显的错误。
这几天,Xcode出厂时就用clang作为编译器。无论我们在哪里写编译器,你都可以把它理解为clang。clang是一个工具,它把Objective-C代码,分析它,并把它转换为更低级的表示,类似于汇编代码。LLVM中间表示法 LLVM IR是低级的,而且与操作系统无关。LLVM接收指令并将其编译成目标平台的本地字节码。这可以是及时完成的,也可以在编译的同时完成。
拥有这些LLVM指令的好处是,你可以在LLVM支持的任何平台上生成和运行它们。例如,如果你写了你的iOS应用,它就会自动运行在两种截然不同的架构上(英特尔和ARM),是LLVM负责将IR代码翻译成这些平台的原生字节码。
LLVM的优势在于它有一个三层架构,这意味着它在第一层支持很多输入语言(如C、Objective-C和C++,也包括Haskell),然后在第二层有一个共享优化器(对LLVM IR进行优化),第三层有不同的目标(如Intel、ARM和PowerPC)。如果你想增加一种语言,你可以专注于第一层,如果你想增加另一个编译目标,你不必太担心输入语言。在《The Architecture of Open Source Applications》一书中,LLVM的创建者Chris Lattner有一章关于LLVM架构的精彩内容。
当编译一个源文件时,编译器会经过几个阶段。为了了解不同的阶段,我们可以询问clang在编译hello.m文件时会做什么。
% clang -ccc-print-phases hello.m 0: input, "hello.m", objective-c 1: preprocessor, {0}, objective-c-cpp-output 2: compiler, {1}, assembler 3: assembler, {2}, object 4: linker, {3}, image 5: bind-arch, "x86_64", {4}, image 复制代码
在本文中,我们将重点介绍第一阶段和第二阶段。在Mach-O可执行文件中,Daniel将解释第三和第四阶段。
当你在编译一个源文件时,首先发生的是预处理。预处理器处理的是一种宏处理语言,这意味着它将用宏的定义来代替你文本中的宏。例如,如果你写了以下内容。
#import <Foundation/Foundation.h> 复制代码
预处理器将采用这一行,并用该文件的内容替换。如果该头文件包含任何其他宏定义,它们也会被替换。
这就是为什么人们告诉你要尽量不导入头文件的原因,因为只要你导入了一些东西,编译器就必须做更多的工作。例如,在你的头文件中,不要写以下内容
#import "MyClass.h" 复制代码
你可以写
@class MyClass; 复制代码
通过这样做,你向编译器承诺将有一个类,MyClass。在实现文件(.m文件)中,你可以导入MyClass.h并使用它。
现在假设我们有一个非常简单的纯C程序,名为hello.c。
#include <stdio.h> int main() { printf("hello world/n")。 return 0; } 复制代码
我们可以对其运行预处理程序,看看效果如何。
clang -E hello.c | less 复制代码
现在,看看那段代码。有401行。如果我们还在上面加上以下一行。
#import <Foundation/Foundation.h>。 复制代码
我们可以再次运行该命令,看到我们的文件已经扩展到了惊人的89,839行。有的整个操作系统的代码行数更少。
幸运的是,这种情况最近有了一些改善。现在有一个叫做模块的功能,让这个过程变得更高级一些。
另一个例子是当你定义或使用自定义宏时,就像这样。
#define MY_CONSTANT 4 复制代码
现在,只要你在这行之后写下MY_CONSTANT
,它就会在剩下的编译开始之前被4
取代。你还可以定义更多有趣的带参数的宏。
#define MY_MACRO(x) x 复制代码
本文篇幅太短,无法讨论使用预处理器的全部范围,但它是一个非常强大的工具。通常,预处理器被用来内联代码。我们强烈不鼓励这样做。例如,假设你有以下看似无害的程序。
#define MAX(a,b) a > b ? a : b int main() { printf("largest: %d\n", MAX(10,100)); return 0; } 复制代码
这样就可以了。但是,下面的程序呢。
#define MAX(a,b) a > b ? a : b int main() { int i = 200; printf("largest: %d\n", MAX(i++,100)); printf("i: %d\n", i); return 0; } 复制代码
如果我们用clang max.c
来编译,我们会得到以下结果。
largest: 201 i: 202 复制代码
当我们运行预处理程序并通过发出clang -E max.c
来扩展所有宏时,这一点非常明显。
int main() { int i = 200; printf("largest: %d\n", i++ > 100 ? i++ : 100); printf("i: %d\n", i); return 0; } 复制代码
在这种情况下,这是一个明显的例子,说明宏可能会出问题,但事情也可能以更意外和难以调试的方式出错。与其使用宏,不如使用静态内联
函数。
#include <stdio.h> static const int MyConstant = 200; static inline int max(int l, int r) { return l > r ? l : r; } int main() { int i = MyConstant; printf("largest: %d\n", max(i++,100)); printf("i: %d\n", i); return 0; } 复制代码
这将打印正确的结果(i:201
)。因为代码是内联的,所以它的性能与宏变体相同,但它的错误发生率要低很多。此外,你还可以设置断点,有类型检查,并避免意外行为。
宏是一个合理的解决方案的唯一时机是用于日志记录,因为你可以使用__FILE__
和__LINE__
以及断言宏。
预处理完成后,现在每个源.m
文件都有一堆定义。这些文本从一个字符串转换为一个标记流。例如,以一个简单的Objective-C hello world程序为例。
int main() { NSLog(@"hello, %@", @"world"); return 0; } 复制代码
我们可以要求clang转储这个程序的tokens,具体方法是:clang -Xclang -dump-tokens hello.m
:
int 'int' [StartOfLine] Loc=<hello.m:4:1> identifier 'main' [LeadingSpace] Loc=<hello.m:4:5> l_paren '(' Loc=<hello.m:4:9> r_paren ')' Loc=<hello.m:4:10> l_brace '{' [LeadingSpace] Loc=<hello.m:4:12> identifier 'NSLog' [StartOfLine] [LeadingSpace] Loc=<hello.m:5:3> l_paren '(' Loc=<hello.m:5:8> at '@' Loc=<hello.m:5:9> string_literal '"hello, %@"' Loc=<hello.m:5:10> comma ',' Loc=<hello.m:5:21> at '@' [LeadingSpace] Loc=<hello.m:5:23> string_literal '"world"' Loc=<hello.m:5:24> r_paren ')' Loc=<hello.m:5:31> semi ';' Loc=<hello.m:5:32> return 'return' [StartOfLine] [LeadingSpace] Loc=<hello.m:6:3> numeric_constant '0' [LeadingSpace] Loc=<hello.m:6:10> semi ';' Loc=<hello.m:6:11> r_brace '}' [StartOfLine] Loc=<hello.m:7:1> eof '' Loc=<hello.m:7:2> 复制代码
我们可以看到,每个 token 都由一段文本和一个源位置组成。源位置是在宏扩展之前的,所以如果出了问题,clang可以给你指出正确的位置。
现在有趣的部分开始了:我们的标记流被解析成一棵抽象的语法树。因为Objective-C是一种相当复杂的语言,所以解析并不总是那么容易。在解析之后,一个程序现在可以作为一个抽象语法树:一个代表原始程序的树。假设我们有一个程序hello.m
。
#import <Foundation/Foundation.h> @interface World - (void)hello; @end @implementation World - (void)hello { NSLog(@"hello, world"); } @end int main() { World* world = [World new]; [world hello]; } 复制代码
当我们发出clang -Xclang -ast-dump -fsyntax-only hello.m
的命令时,我们得到以下结果。
@interface World- (void) hello; @end @implementation World - (void) hello (CompoundStmt 0x10372ded0 <hello.m:8:15, line:10:1> (CallExpr 0x10372dea0 <line:9:3, col:24> 'void' (ImplicitCastExpr 0x10372de88 <col:3> 'void (*)(NSString *, ...)' <FunctionToPointerDecay> (DeclRefExpr 0x10372ddd8 <col:3> 'void (NSString *, ...)' Function 0x1023510d0 'NSLog' 'void (NSString *, ...)')) (ObjCStringLiteral 0x10372de38 <col:9, col:10> 'NSString *' (StringLiteral 0x10372de00 <col:10> 'char [13]' lvalue "hello, world")))) @end int main() (CompoundStmt 0x10372e118 <hello.m:13:12, line:16:1> (DeclStmt 0x10372e090 <line:14:4, col:30> 0x10372dfe0 "World *world = (ImplicitCastExpr 0x10372e078 <col:19, col:29> 'World *' <BitCast> (ObjCMessageExpr 0x10372e048 <col:19, col:29> 'id':'id' selector=new class='World'))") (ObjCMessageExpr 0x10372e0e8 <line:15:4, col:16> 'void' selector=hello (ImplicitCastExpr 0x10372e0d0 <col:5> 'World *' <LValueToRValue> (DeclRefExpr 0x10372e0a8 <col:5> 'World *' lvalue Var 0x10372dfe0 'world' 'World *')))) 复制代码
抽象语法树中的每一个节点都会被标注上原始的源头位置,这样以后如果有什么问题,clang就可以对你的程序发出警告,并给出正确的位置。
一旦编译器有了抽象语法树,它就可以对这棵树进行分析,帮助你发现错误,比如在类型检查中,它检查你的程序是否类型正确。例如,当你向一个对象发送一个消息时,它就会检查这个对象是否真的实现了这个消息。此外,clang 还会进行更高级的分析,它将检查你的程序,以确保你没有做任何奇怪的事情。
任何时候你写代码的时候,clang都会帮助你检查你是否没有犯任何错误。其中很明显的一件事就是你的程序是否向正确的对象发送了正确的消息,并在正确的值上调用了正确的函数。如果你有一个普通的NSObject*
,你不能只给它发送hello
消息,因为clang会报错。另外,如果你创建一个子类为NSObject
的Test
类,像这样。
@interface Test : NSObject @end 复制代码
而你再尝试给该对象分配一个不同类型的对象,编译器会帮助你,并警告你,你所做的可能是不正确的。
有两种类型的类型:动态类型和静态类型。动态排版意味着在运行时检查类型,而静态排版意味着在编译时检查类型。过去,你可以随时向任何对象发送任何消息,在运行时,会确定对象是否响应该消息。当这种类型只在运行时检查时,这就是所谓的动态类型。
对于静态类型,这是在编译时检查的。当你使用ARC时,编译器在编译时检查更多的类型,因为它需要知道它与哪些对象一起工作。例如,你不能再写下面的代码了。
[myObject hello] 复制代码
如果你的程序中没有定义hello
方法。
还有很多其他的分析,clang都会帮你做。如果你克隆 clang 仓库,然后去 lib/StaticAnalyzer/Checkers
,你会看到所有的静态检查器。例如,有ObjCUnusedIVarsChecker.cpp
,它检查ivars是否未使用。或者有ObjCSelfInitChecker.cpp
,它检查你在初始化器里面开始使用self
之前是否调用了[self initWith...]
或者[super init]
。其他一些检查发生在编译器的其他部分。例如,在lib/Sema/SemaExprObjC.cpp
的第2,534行,你可以看到下面这一行。
Diag(SelLoc, diag::warning_arc_perform_selector_leaks); 复制代码
这将产生可怕的 "执行选择器可能会导致泄漏,因为它的选择器是未知的 "警告。
现在,一旦你的代码被完全标记化,解析,并被clang分析,它就可以为你生成LLVM代码。为了看看会发生什么,我们可以再看一下程序hello.c
。
#include <stdio.h> int main() { printf("hello world\n"); return 0; } 复制代码
要将其编译到LLVM IR中,我们可以发出以下命令。
clang -O3 -S -emit-llvm hello.c -o hello.ll 复制代码
这将生成一个hello.ll
文件,它给了我们以下的输出。
; ModuleID = 'hello.c' target datalayout = "e-m:o-i64:64-f80:128-n8:16:32:64-S128" target triple = "x86_64-apple-macosx10.9.0" @str = private unnamed_addr constant [12 x i8] c"hello world\00" ; Function Attrs: nounwind ssp uwtable define i32 @main() #0 { %puts = tail call i32 @puts(i8* getelementptr inbounds ([12 x i8]* @str, i64 0, i64 0)) ret i32 0 } ; Function Attrs: nounwind declare i32 @puts(i8* nocapture readonly) #1 attributes #0 = { nounwind ssp uwtable "less-precise-fpmad"="false" "no-frame-pointer-elim"="true" "no-frame-pointer-elim-non-leaf" "no-infs-fp-math"="false" "no-nans-fp-math"="false" "stack-protector-buffer-size"="8" "unsafe-fp-math"="false" "use-soft-float"="false" } attributes #1 = { nounwind } !llvm.ident = !{!0} !0 = metadata !{metadata !"Apple LLVM version 6.0 (clang-600.0.41.2) (based on LLVM 3.5svn)"} 复制代码
你可以看到,main
函数只有两行:一行用来打印字符串,一行用来返回0
。
对一个非常简单的Objective-C程序five.m
做同样的事情也很有趣,我们编译后使用LLVM-dis < five.bc | less
。
#include <stdio.h> #import <Foundation/Foundation.h> int main() { NSLog(@"%@", [@5 description]); return 0; } 复制代码
还有很多东西,但这里是main
函数。
define i32 @main() #0 { %1 = load %struct._class_t** @"\01L_OBJC_CLASSLIST_REFERENCES_$_", align 8 %2 = load i8** @"\01L_OBJC_SELECTOR_REFERENCES_", align 8, !invariant.load !5 %3 = bitcast %struct._class_t* %1 to i8* %4 = tail call %0* bitcast (i8* (i8*, i8*, ...)* @objc_msgSend to %0* (i8*, i8*, i32)*)(i8* %3, i8* %2, i32 5) %5 = load i8** @"\01L_OBJC_SELECTOR_REFERENCES_2", align 8, !invariant.load !5 %6 = bitcast %0* %4 to i8* %7 = tail call %1* bitcast (i8* (i8*, i8*, ...)* @objc_msgSend to %1* (i8*, i8*)*)(i8* %6, i8* %5) tail call void (i8*, ...)* @NSLog(i8* bitcast (%struct.NSConstantString* @_unnamed_cfstring_ to i8*), %1* %7) ret i32 0 } 复制代码
最重要的行是第4行,创建NSNumber对象,第7行,向数字对象发送描述消息,第8行,记录描述消息返回的字符串。
要想知道LLVM和clang能做哪些优化,不妨看看一个稍微复杂一点的C语言例子,递归定义的factorial
函数。
递归定义的阶乘函数: SELECT ALL
#include <stdio.h> int factorial(int x) { if (x > 1) return x * factorial(x-1); else return 1; } int main() { printf("factorial 10: %d/\n", factorial(10)); } 复制代码
要在不进行优化的情况下编译,请运行以下命令。
clang -O0 -S -emit-llvm factorial.c -o factorial.ll 复制代码
有趣的部分是看一下因子函数的生成代码。
define i32 @factorial(i32 %x) #0 { %1 = alloca i32, align 4 %2 = alloca i32, align 4 store i32 %x, i32* %2, align 4 %3 = load i32* %2, align 4 %4 = icmp sgt i32 %3, 1 br i1 %4, label %5, label %11 ; <label>:5 ; preds = %0 %6 = load i32* %2, align 4 %7 = load i32* %2, align 4 %8 = sub nsw i32 %7, 1 %9 = call i32 @factorial(i32 %8) %10 = mul nsw i32 %6, %9 store i32 %10, i32* %1 br label %12 ; <label>:11 ; preds = %0 store i32 1, i32* %1 br label %12 ; <label>:12 ; preds = %11, %5 %13 = load i32* %1 ret i32 %13 } 复制代码
你可以看到,在标有%9
的那一行,它递归地调用自己。这是相当低效的,因为每次递归调用都会增加堆栈。为了开启优化,我们可以将标志-O3
传递给clang。
clang -O3 -S -emit-llvm factorial.c -o factorial.ll 复制代码
现在,factorial
函数的代码看起来像这样。
define i32 @factorial(i32 %x) #0 { %1 = icmp sgt i32 %x, 1 br i1 %1, label %tailrecurse, label %tailrecurse._crit_edge tailrecurse: ; preds = %tailrecurse, %0 %x.tr2 = phi i32 [ %2, %tailrecurse ], [ %x, %0 ] %accumulator.tr1 = phi i32 [ %3, %tailrecurse ], [ 1, %0 ] %2 = add nsw i32 %x.tr2, -1 %3 = mul nsw i32 %x.tr2, %accumulator.tr1 %4 = icmp sgt i32 %2, 1 br i1 %4, label %tailrecurse, label %tailrecurse._crit_edge tailrecurse._crit_edge: ; preds = %tailrecurse, %0 %accumulator.tr.lcssa = phi i32 [ 1, %0 ], [ %3, %tailrecurse ] ret i32 %accumulator.tr.lcssa } 复制代码
尽管我们的函数不是以尾部递归的方式写的,但clang仍然可以对它进行优化,现在它只是一个带循环的函数。还有很多优化,clang会对你的代码进行优化。gcc能做的很好的例子在 ridiculousfish.com。
现在我们已经看到了一个完整的编译,从标记化到解析,从抽象的语法树到分析和编译,我们可以想:为什么我们要关心?
clang 的酷之处在于它是开源的,而且是一个非常完善的项目:几乎所有的东西都是一个库,这意味着它可以创建你自己的 clang 版本,并且只修改你需要的部分。这意味着你可以创建自己的clang版本,并且只修改你需要的部分。例如,你可以改变 clang 生成代码的方式,添加更好的类型检查,或者进行分析。有很多方法可以做到这一点,最简单的方法是使用一个叫libclang的C库。libclang为你提供了一个简单的C API到clang,你可以用它来分析你所有的源代码。但是,根据我的经验,只要你想做一些更高级的事情,libclang就太局限了。另外,还有ClangKit,它是围绕clang提供的一些功能的Objective-C包装器。
另一种方法是直接使用LibTooling来使用clang提供的C++库。这要多做很多工作,而且涉及到C++,但可以充分发挥clang的功能。你可以做任何形式的分析,甚至可以重写程序。如果你想给clang添加自定义分析,想写自己的重构器,需要重写大量的代码,或者想从你的项目中生成图表和文档,LibTooling就是你的朋友。
按照Tutorial上的说明使用LibTooling构建工具,构建LLVM、clang和clang-tools-extra。一定要留出一些时间来编译,虽然我的机器速度非常快,但在LLVM编译的时间里,我还是可以洗碗。
接下来,到你的LLVM目录下,做一个cd ~/llvm/tools/clang/tools/
。在这个目录下,你可以创建自己独立的clang工具。作为一个例子,我们创建了一个小工具来帮助我们检测库的正确使用。将示例库克隆到这个目录中,然后输入 make
。这将为你提供一个名为 example
的二进制文件。
我们的用例如下:假设我们有一个Observer类,它看起来像这样。
@interface Observer + (instancetype)observerWithTarget:(id)target action:(SEL)selector; @end 复制代码
现在,我们希望每当使用这个类时,都能检查action
是否是target
对象上存在的方法。我们可以写一个快速的C++函数来实现这个功能(请注意,这是我写的第一个C++函数,所以肯定不是很习惯)。
virtual bool VisitObjCMessageExpr(ObjCMessageExpr *E) { if (E->getReceiverKind() == ObjCMessageExpr::Class) { QualType ReceiverType = E->getClassReceiver(); Selector Sel = E->getSelector(); string TypeName = ReceiverType.getAsString(); string SelName = Sel.getAsString(); if (TypeName == "Observer" && SelName == "observerWithTarget:action:") { Expr *Receiver = E->getArg(0)->IgnoreParenCasts(); ObjCSelectorExpr* SelExpr = cast<ObjCSelectorExpr>(E->getArg(1)->IgnoreParenCasts()); Selector Sel = SelExpr->getSelector(); if (const ObjCObjectPointerType *OT = Receiver->getType()->getAs<ObjCObjectPointerType>()) { ObjCInterfaceDecl *decl = OT->getInterfaceDecl(); if (! decl->lookupInstanceMethod(Sel)) { errs() << "Warning: class " << TypeName << " does not implement selector " << Sel.getAsString() << "\n"; SourceLocation Loc = E->getExprLoc(); PresumedLoc PLoc = astContext->getSourceManager().getPresumedLoc(Loc); errs() << "in " << PLoc.getFilename() << " <" << PLoc.getLine() << ":" << PLoc.getColumn() << ">\n"; } } } } return true; } 复制代码
这个方法首先寻找以Observer
为接收者,以observerWithTarget:action:
为选择器的消息表达式,然后查看目标并检查该方法是否存在。当然,这只是一个有点造作的例子,但如果你想在你的代码库中使用AST机械地验证一些东西,这就是你要做的事情。
我们还有很多方法可以利用clang。例如,可以编写编译器插件(例如,使用与上面相同的检查器),并将它们动态地加载到你的编译器中。我还没有测试过,但它应该可以和Xcode一起工作。例如,如果你想得到一个关于代码风格的警告,你可以为此写一个clang插件。(关于简单的检查,请参见Build过程文章。)
另外,如果你需要对你的代码库进行大规模的重构,而Xcode或AppCode中内置的普通重构工具是不够的,你可以使用clang写一个简单的重构工具。这听起来可能让人望而生畏,但在下面链接的教程中,你会发现这并不难。
最后,如果你真的需要,你可以编译你自己的clang,并指示Xcode使用它。同样,这并不像听起来那么难,而且绝对是好玩的。
通过www.DeepL.com/Translator(免费版)翻译