如何使用 Clang Plugin 找到项目中的无用代码(Part 2)

前言

上一篇文章中,我们了解了 AST 树的结构,并简单的实现了一个 RecursiveASTVisitor 子类,成功的访问了语法树上的各个节点。
回头再看看一下前文的代码,结合官方文档,我们可以大致整理出如下调用逻辑:

Clang 对 AST 树的解析是以单个文件为单位的,这点我们从 `ast-dump` 的命令也可以看出来,这也注定了使用 Clang AST 静态分析,是无法完整分析整个项目中的调用关系的。但没有关系,本篇文章中,我们先从单个文件的角度切入,尝试寻找无用的代码。

静态分析 v1.0

思路

在动手之前,明确我们的目标以及大致的实现思路是非常要必要的,我们希望能够搜索到被定义但却未被使用的方法,但要如何实现呢?
一条显而易见的途径就是记录所有定义的方法以及所有被调用的方法,再取差集即可。对于 oc 而言,我们想要唯一确定一个方法,有两个关键点:

  1. 方法所属的对象类型(Interface)
  2. 方法的选择子(Selector)

不论是方法定义,还是方法调用时,知道了 Interface 和 Selector,就可以保证方法的唯一性。

尽管 Clang 提供了 overloadable 的 attribute,但 oc 并不支持这项特性。

我们先将示例工程代码进行如下修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@protocol MyProtocol <NSObject>
- (void)protocolMethod;
@end

@interface HelloAST : NSObject <MyProtocol>
@end

@implementation HelloAST

- (void)execute{
[self instanceMethod];
[self performSelector:@selector(selectorMethod) withObject:nil afterDelay:0];
[NSTimer scheduledTimerWithTimeInterval:2 target:self selector:@selector(selectorMethod) userInfo:nil repeats:NO];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(onNotification:) name:NSUserDefaultsDidChangeNotification object:nil];
}

- (void)instanceMethod{}

- (void)selectorMethod{}

- (void)timerMethod{}

- (void)onNotification:(NSNotification *)notification{}

- (void)protocolMethod{}

@end

接着利用 clang -fmodules -fsyntax-only -Xclang -ast-dump main.m 命令,打印语法树并进行分析。
让我们先看 execute 方法的节点,分析需要考虑的调用情况:

  1. 最常见的 objc 消息发送,如 [obj foo]

    可以看到,对于一个普通的消息发送,我们得到的是一个 `ObjCMessageExpr` 节点,且我们可以看到,所调用的 selector 就是我们定义的 `instanceMethod`,从该类的官方文档上我们可以看到该类有一个 `getReceiverInterface()` 方法,可以获取到接收消息的对象的类型,而 `getSelector()` 方法则可以拿到对应的 selector,所以我们可以通过 `VisitObjCMessageExpr()` 方法,来识别普通的消息发送语句。
  2. 非直接消息发送的方法调用,如 [NSObject performSelector] ,[NSTimer scheduleTimer] ,[NotificationCenter addObserver] 等。这几类方法非常相似,我们就分析分析 performSelector 方法,先来看看这个语句本身:

    1
    [self performSelector:@selector(selectorMethod) withObject:nil afterDelay:0];

    方法本身是对 self 对象调用 performSelector: 方法,同时将我们定义的 selectorMethod@selector 形式作为参数传入,在写下这个语句的时候,我们清楚的知道,@selector(selectorMethod) 所作用对象就是 self,如果类比第一类消息发送表达式,此处几乎就是一个 [self selectorMethod]。乍一看,我们似乎可以从这个语句中找到我们所需要的 interface 和 selector,但实际上并不是如此,这个语句仅仅能确定 selfperformSelector: 的绑定关系,而 selectorMethod 仅仅是一个普通的参数,其最后实际调用时的对象之所以是 self,是因为 NSObject 内部实现如此,而不是语言特性使然。事实上,对于任意以 @selector 作为参数的方法,我们都无法确认其最终执行时所做用的对象。再让我们看看它的树节点:

    可以看到,`performSelector:` 节点是 `ObjCMessageExpr` 类型,而作为参数的 `selectorMethod` 是一个 `ObjCSelectorExpr` 类型,参看 Clang 的官方文档,会发现这个类型中是没有任何方法可以反向查找 interface 相关信息的,当然,这也符合我们对于 oc 的认知,`selector` 的存在是与类无关的,仅当其成为一个语句(声明/调用)时,他才会和某个具体的类的对象进行关联绑定。 同理,`[NSTimer schedulTimer]` 与 `[NSNotificationCenter addObserver]` 方法中的 `@selector` 参数,也无法在静态分析的阶段确定起所作用的对象,更宽泛的说,一切将 `@selector` 作为参数的方法,都无法确定作用类。所以非常遗憾,面对 `@selector`,我们只能假设它们都是有用的代码。
  3. 协议方法与普通方法的差异性。
    现在让我们想一想,日常开发中引入协议的初衷:为了不让类与类之间有强耦合关系,我们将对方法的依赖抽离出来,放在一个与具体类型无关的声明中,而在 oc 中,这种抽象的表现便是 Protocol。那么脱离了具体类型之后,我们是如何处理依赖的呢?我想最最典型的就是下面这种形式了:

    1
    2
    3
    @property (nonatomic ,weak) id <SomeProtocol> protocolImplementor;
    ...
    [self.protocolImplementor someProtocolMethod];

    苹果许多原生组件都是以类似的方式,暴露接口给开发者进行一些自定义操作,但这样的解耦同样也意味着在编译阶段,协议方法作用的具体类对象同样是无法确定的,同样,我们也只能假设所有协议方法都是有用的。在 HelloAST 的类定义树节点上,我们可以看到如下信息:

    在 `ObjCInterfaceDecl` 的文档中,我们可以找到 `all_referenced_protocols()` 方法,可以让我们拿到当前类遵循的所有协议,而其中的 `ObjCProtocolDecl` 类则有 `lookUpMethod()` 方法,可以用于检索协议定义中是否有某个方法。也就是说,当我们遇到一个方法定义时,我们需要多做一步判断:若该方法是协议方法,则忽略,否则记录下来,用于后续判断是否被使用。

最后,我们再看看方法定义,语法树中的几个方法的定义都大同小异,我们只需要在 VisitObjCMethodDecl() 回调中存储所需的方法即可。ObjCMethodDecl 类中包含 getClassInterface() 以及 getSelector() 方法,这两个方法可以让我们拿到方法的唯一标识,同时,我们需要过滤这其中的协议方法,因为我们无法对其调用情况做准确判断。

实现

根据上述分析,我们需要记录所有定义的方法以及所有被调用过的方法,并在扫描完整个 AST 之后对它们进行比较,我所采用的方式是以类名作为 key,以 ObjCMethodDecl 数组作为 value,构造一个 Map 来存储这些信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
typedef std::vector<ObjCMethodDecl *> MethodVector;
typedef std::map<StringRef ,MethodVector> InterfaceMethodsMap;
typedef std::vector<Selector> SelectorVector;

class DemoVisitor : public RecursiveASTVisitor<DemoVisitor>{
private:
CompilerInstance &Instance;
ASTContext *Context;
InterfaceMethodsMap definedMethodsMap;
InterfaceMethodsMap usedMethodsMap;
SelectorVector usedSelectors;
...
}

我们需要记录所有非协议方法的方法定义、所有的方法调用、以及所有 @selector

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//statement
bool VisitObjCMessageExpr(ObjCMessageExpr *expr){
ObjCInterfaceDecl *interfaceDecl = expr -> getReceiverInterface();

StringRef clsName = interfaceDecl->getName();
MethodVector methodVec;
if (usedMethodsMap.find(clsName) != usedMethodsMap.end()) {
methodVec = usedMethodsMap.at(clsName);
}else{
methodVec = MethodVector();
usedMethodsMap.insert(std::make_pair(clsName, methodVec));
}
methodVec.push_back(expr->getMethodDecl());

InterfaceMethodsMap::iterator it = usedMethodsMap.find(clsName);
it->second = methodVec;

return true;
}

bool VisitObjCSelectorExpr(ObjCSelectorExpr *expr){
usedSelectors.push_back(expr->getSelector());
return true;
}

//declaration
bool VisitObjCMethodDecl(ObjCMethodDecl *methDecl){ // 包括了 protocol 方法的定义
if (!isUserSourceCode(methDecl)){
return true;
}
ObjCInterfaceDecl *interfaceDecl = methDecl->getClassInterface();
if (!interfaceDecl || interfaceHasProtocolMethod(interfaceDecl, methDecl)){
return true;
}
StringRef clsName = interfaceDecl->getName();
MethodVector methodVec;
if (definedMethodsMap.find(clsName) != definedMethodsMap.end()) {
methodVec = definedMethodsMap.at(clsName);
}else{
methodVec = MethodVector();
definedMethodsMap.insert(std::make_pair(clsName, methodVec));
}
methodVec.push_back(methDecl);

InterfaceMethodsMap::iterator it = definedMethodsMap.find(clsName);
it->second = methodVec;

return true;
}

bool interfaceHasProtocolMethod(ObjCInterfaceDecl *interfaceDecl ,ObjCMethodDecl *methDecl){
for (auto *protocolDecl : interfaceDecl->all_referenced_protocols()){
if (protocolDecl->lookupMethod(methDecl->getSelector(), methDecl->isInstanceMethod())) {
return true;
}
}
return false;
}

上面的代码,就是我们之前分析出来的思路的具体实现。不过有这么一小段是需要注意的:

1
2
3
4
ObjCInterfaceDecl *interfaceDecl = methDecl->getClassInterface();
if (!interfaceDecl || interfaceHasProtocolMethod(interfaceDecl, methDecl)){
return true;
}

在这里,我们还判断了 interfaceDecl 是否为空指针,这是因为协议方法在 @protocol 中定义时,语法树同样将其解析为一个 ObjCMethodDecl 节点,但此时它和任何具体类型都没有关联,我们是无法取到它的类信息的。最后,当整个语法树扫描完毕后,我们要将所有信息进行对比,并最终过滤出没有被调用的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class MyPluginConsumer : public ASTConsumer
{
...
void HandleTranslationUnit(ASTContext &context) override{
this->visitor.setASTContext(context);
this->visitor.TraverseDecl(context.getTranslationUnitDecl());
this->visitor.logResult();
}
...
};

class DemoVisitor : public RecursiveASTVisitor<DemoVisitor>{
...
InterfaceMethodsMap definedMethodsMap;
InterfaceMethodsMap usedMethodsMap;
SelectorVector usedSelectors;
...
public:
void logResult(){
DiagnosticsEngine &D = Instance.getDiagnostics();
for (InterfaceMethodsMap::iterator definedIt = definedMethodsMap.begin(); definedIt != definedMethodsMap.end(); ++definedIt){
StringRef clsName = definedIt->first;
MethodVector definedMethods = definedIt->second;
if (usedMethodsMap.find(clsName) == usedMethodsMap.end()) {
// the class could not be found ,all of its method is unused.
for (auto *methDecl : definedMethods){
int diagID = D.getCustomDiagID(DiagnosticsEngine::Warning, "Method Defined ,but never used. SEL : %0 ");
D.Report(methDecl->getLocStart(), diagID) << methDecl->getSelector().getAsString();
}
continue;
}

MethodVector usedMethods = usedMethodsMap.at(clsName);
for (auto *defined : definedMethods){
bool found = false;
for (auto *used : usedMethods){
if (defined->getSelector() == used->getSelector()){ // find used method
found = true;
break;
}
}

if (!found) {
for (auto sel : usedSelectors){
if (defined->getSelector() == sel){ // or find @selector
found = true;
break;
}
}
}

if (!found){
int diagID = D.getCustomDiagID(DiagnosticsEngine::Warning, "Method Defined ,but never used. SEL : %0 ");
D.Report(defined->getLocStart(), diagID) << defined->getSelector().getAsString();
}
}
}
}
}

现在,重新编译插件程序,Clean 示例程序,然后再重新编译示例程序,我们会看到对应的 Warning:

喔!看起来我不小心漏调用了 timerMethod:

1
2
//[NSTimer scheduledTimerWithTimeInterval:2 target:self selector:@selector(selectorMethod) userInfo:nil repeats:NO];
[NSTimer scheduledTimerWithTimeInterval:2 target:self selector:@selector(timerMethod) userInfo:nil repeats:NO];

:) 现在,一个基本的无用方法扫描插件就开发完成了。

总结

本篇文章中,我们进一步分析了 Clang 所解析出来的语法树,通过利用树节点提供的信息,我们成功的编写出了一个简单的扫描程序。到这一步,整个依赖 Clang AST 实现无用方法扫描的流程就基本走完了,我们知道了在静态分析的流程中我们能做些什么,又有哪些事情是做不到的。接下来就可以根据具体的项目,再添加一些自定义过滤逻辑了。

当然,想要在一个真正的项目工程中运行该插件,还有许多工作要做,如前文所说的,AST 树分析的基本单位是文件,而实际开发中,我们的类会有许许多多的 public 方法暴露在头文件中,供项目其他文件使用。同时,本文的例子也未考虑继承情况,以及协议方法在继承链中的表现。在下一篇文章中,我将会讨论更复杂的实际应用情况,以及在将插件从 Demo 迁移至项目工程中时,所遇到的各种问题。