一、前言
“白马非马,可乎?”曰:“可。”
曰:“何哉?”曰:“马者,所以命形也。白者,所以命色也。命色者,非命形也,故曰白马非马。”
曰:“有白马,不可谓无马也。不可谓无马者,非马也?有白马为有马,白之非马,何也?”
曰:“求马,黄、黑马皆可致。求白马,黄、黑马不可致。使白马乃马也,是所求一也,所求一者,白者不异马也。所求不异,如黄、黑马有可有不可,何也?可与不可其相非明。故黄、黑马一也,而可以应有马,而不可以应有白马,是白马之非马审矣。” ……
上面这段是战国时期平原君门客公孙龙的名言,也就是让诸多先哲吐血的“白马非马”辩题。据说孔子的六世孙孔穿(这名字我也是服了……)当时年轻气盛,上门挑战公孙龙,结果被他以自己老祖宗孔丘的一段话作为论据针锋相对驳得体无完肤,恐怕孔老夫子也没想到自己这不成器的孙子居然是因此而青史留名……
其实说到底,“白马非马”其实是逻辑辩证中很普通的论断,也就是集合“马”的真子集“白马”,肯定不等于“马”这个集合本身罢了,如果“是”代表完全等同,那么自然就得出了结论——白马非马;但如果我们定义“是”表示从属归属这意思,那么白马又是马了。那么多大牛被公孙龙的诡辩给忽悠了,无非是没有意识到给绕进去罢了。
当然,计算机语言世界里的OO思想其实也有相似的论断:类A是父类,类B是继承于A的子类,那么A、B并不等同,但如果存在一个可以判定继承关系的方法 isSubclassOf,那么必然有如下(伪代码)表达式
B:isSubclassOf(A) == true
的条件判断成立,相信大家对此都无异议。
但不幸的是,在使用XCTest构造某个项目的单元测试case时,我却发现了匪夷所思与上述论断相悖的情况……
二、追根溯源
曲径通幽
其实那部分单元测试代码逻辑很简单,无非是利用TBJsonModel(厂内JsonModel实现)将反序列化后的NSArray/NSDictionary容器对象通过ORM转换成自定义的Model对象(定义如下)。
@protocol CpmDataModel <NSObject>
@end
@interface CpmDataModel : TBJSONModel
....
@property (nonatomic, strong) NSString *synth;
....
@end
@interface CpmDataListModel : TBJSONModel
@property (nonatomic, strong) NSArray<CpmDataModel> *model;
@end
然后继续处理 CpmDataListModel对象中model属性容纳的CpmDataModel实例,但在如下语句执行时报错并crash,LLDB提示的原因iOS同学再熟悉不过了:“-[__NSDictionary0 synth]: unrecognized selector sent to instance 0x61000001e600”……
if (listModel.model.firstObject.synth.integerValue == 1) {
......
}
很明显,反序列化后的NSDictionary对象并没有如愿转换成 CpmDataModel类实例,可这是为什么呢?
穷追不舍
既然看起来是TBJsonModel的锅,那么思路也自然而然转到研判其内部实现上来 —— 下载好源码后,在podfile指定TBJsonModel使用本地源码而非远程仓库地址作为依赖然后一起编译,然后跑起来即可。
结果就碰到了让我头大的“白马非马”问题,下面的这个条件判断表达式,在modelContainerElementClass为CpmDataModel.class时(参照定义代码,CpmDataModel继承于TBJSONModel),居然结果是 NO……
if (modelContainerElementClass && [modelContainerElementClass isSubclassOfClass:[TBJSONModel class]]) {
......
}
调试到这的时候,我的心情是崩溃的,这……难道千百年来公孙龙的冤魂仍然在作祟?
作为不信邪的社会主义新青年,我毅然决然做了新的尝试:
NSString *className1 = NSStringFromClass(modelContainerElementClass.superclass);
NSString *className2 = NSStringFromClass([TBJSONModel class]);
BOOL flag = [className1 isEqualToString:className2];
结果居然是 flag 为YES!!😱😱😱
再试:
(lldb) po [modelContainerElementClass isSubclassOfClass:[TBJSONModel class]]
YES
(lldb) po ((objc_class *)modelContainerElementClass.superclass)->isa
TBJSONModel
哎??在console中执行判断指令,居然结果反而是YES??难道是LLDB 运行时库精神分裂了吗??🙄🙄🙄
破釜沉舟
整整一天都在纠结这个诡异的问题,但却百思不得其解,甚至把 TBJsonModel的作者卢克都给打扰了一通,也是毫无收获。
正在走投无路,忽然间console里的一条日志引起了我的注意
objc[97073]: Class XXXX is implemented in both /Users/..../munionDemo.app/munionDemo (0x1027316f8) and /Users/..../munionDemo.app/PlugIns/munionDemoTests.xctest/munionDemoTests (0x117fb5618). One of the two will be used. Which one is undefined.
难道是因为类实现的重复引入导致的该问题?循着这个思路继续查下去,发现objective-c引以为傲的runtime库特殊的设定:
Because the Objective-C runtime uses a flat name space, you must be careful when developing a plug-in not to choose a name that conflicts with the application code or another plug-in loaded by the application. This section describes how to name your classes and other symbols to avoid this kind of conflict.
The Objective-C runtime provides only a single flat, global name space per process for all exported symbols. This includes all global variables, nonstatic functions, class names, and categories declared for individual classes; protocols have a separate global name space of their own.
也就是说,对每一个iOS沙盒app来说,Objective-C runtime分别维护了一个公用的命名空间(flat namespace),在其中存在着所有导入的符号、非静态方法、类名以及类的category。
大概的运行机制如下:
1.加载App/Bundle的二进制映像,并检查其各种依赖关系
2.每一个二进制映像被加载的同时,其中包含的各个objc类被注册到runtime命名空间中
3.如果具有相同名称的类被再次加载,objc runtime的行为则不可预知。
也就是说,如果相同名称的类被加载多次,可能的情况可能是其中某一份实现会被真正加载(应该是runtime库的默认动作);如果类名相同但其真实类型发生冲突(比如一个继承于UIViewController,另一个则是继承于UIView),那么很有可能由于实现不一致导致App crash(unrecognized selector...)
也就是说,如果上述场景中,TBJsonModel类被runtime加载了两次,其内存地址分别是0x00001111 和 0x00002222,假设那么可能的结果就是测试用例跑起来的时候CpmDataModel类随便选择了一个类继承(假设为0x00001111),那么在执行到如下逻辑时
if (modelContainerElementClass && [modelContainerElementClass isSubclassOfClass:[TBJSONModel class]]) {
......
}
实际执行的比较很可能为
if (modelContainerElementClass && [继承TBJsonModel(0x00001111)类的CpmDataModel类 isSubclassOfClass:[TBJSONModel(0x00002222)class]]) {
......
}
自然是不会通过的。通过NSLog打印 CpmDataModel.superclass 地址以及 TBJsonModel地址,也确实是不一致的 。
至于在console中执行的指令又满足,则可能是LLDB的特殊处理,内存中不存在重复导入类实现导致的冲突。
疾在腠理
咳咳,上面症状说了一大堆,还是得瞧瞧病根出在哪里。
由于使用了XCTest框架,实际上App可执行文件 和Unit Test Bundle是分两个二进制文件存在的,在执行Test的时候才会动态加载(因而不会在链接时触发duplicate symbols导致编译失败),进而产生了类冲突的问题。
TBJsonModel是二方组件形式引入的依赖,所以还要去描述依赖关系的podfile里探究一番:
source "git@gitlab.alibaba-inc.com:alipods/specs.git"
source "git@gitlab.alibaba-inc.com:alipods/specs-mirror.git"
platform :ios, '8.0'
def all_pods
platform:ios, '8.0'
....
pod 'TBJSONModel', :path=>'../TBJSONModel'
....
end
target 'munionDemo' do
all_pods
end
target 'munionDemoTests' do
all_pods
end
唉,当初为了解决单元测试Target munionDemoTests编译不过的问题,分别为App的正常target munionDemo 和 munionDemoTests 各指定了一遍组件依赖,结果编译倒是没问题了,反而导致为这两个target在编译时分别都link上了 all_pods指定的所有组件——也就是说,在单元测试case执行时,这些组件中的类都将被重复导入,真的是一失足即成千古恨……
三、闲话再叙
既然是同名类冲突,其实还有别的可能:
一是业务代码被加载了多次,此情况往往是在不同target配置项 Build Phases中 Complie Sources中指定了相同的源文件导致;解决办法也简单,如果target有依赖关系,那么确保只在某一个target中编译该文件即可
二是纯粹的命名冲突,自己的业务逻辑中的类定义,和二方/三方组件库中的类定义发生重复导致;这也是为什么苹果建议文件/类定义一定要带上自定义的前缀的缘故咯
言归正传~ 利用XCTest框架编写的单元测试Case独立为target,在执行 Product->Test时,会先把App可执行文件加载并run起来之后,才会动态加载执行起来的,故重复引入类的问题被延后到运行时,且加载的类其定义实现完全一致,故若非特殊场景下的检测,其实很难暴露出来。碰巧是在TBJsonModel的实现中存在继承关系的检查,才让整个冰山漏出了险峻的一角;看起来,“锅”不仅不能让 TBJsonModel 来背,原来还有功劳啊哈哈……