Compile-time Key Paths Verification

在使用 CoreAnimation 或 KVO 等API 的时候要需要指定一个 NSString类型的 keyPath 参数,通常的做法是直接传入一个字符串常量:

@interface Foo : NSObject
@property(nonatomic, strong) NSNumber *bar;
@end

Foo *foo = [Foo new];

[foo addObserver:self 
      forKeyPath:@"bar" 
         options:NSKeyValueObservingOptionNew 
         context:nil];

但这种做法实际上会带来一些问题:

由于使用了字符串,所以编译器不去检查该 keyPath 对于 Foo 是否合法,如果某天 bar 属性的名称发生变动而该 keyPath 参数没有做对应的修改,就会导致运行时的异常;而且,由于 keyPath 参数是字符串类型,所以使用重构工具进行属性的重命名时这些参数也无法一并修改。

为了解决这些问题 libextobjc 里面定义了一个宏 keypath,达到在产生 keyPath 字符串的同时提供编译期检查的目的:

#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))

这个宏可以接受两种形式的参数,一种为完整的路径,即:@keypath(foo.bar);还有一种是对象加上路径,即:@keypath(foo, bar),使用方法如下:

[foo addObserver:self 
      forKeyPath:@keypath(foo, bar)
         options:NSKeyValueObservingOptionNew 
         context:nil];

[foo addObserver:self 
      forKeyPath:@keypath(foo.bar)
         options:NSKeyValueObservingOptionNew 
         context:nil];

下面让我们来看看这个宏的实现原理:

首先,由于这个宏可以接受两种形式的参数,所以需要根据参数的数量展开为不同的代码,keypath 宏里面用到了另外的两个宏 metamacro_argcountmetamacro_if_eq。其中 metamacro_argcount 会返回参数的数量,metamacro_if_eq 则根据参数数量决定应该展开为 keypath1 或者 keypath2。这两个宏的实现使用了一些宏元编程的技巧,这里不会具体解释其实现原理,有兴趣可以查看 libextobjc 的代码。

知道了 keypath 可以展开为 keypath1keypath2 后,我们来看看这两个宏的实现。要理解这两个宏需要先了解宏里 # 的用法,这个操作符可以在预处理阶段将后面的宏参数张开为一个 C 的字符串常量,比如定义 #define str(s) #s, 那么 str(foo) 展开的结果就是 "foo"。接下来假设传入的是foo.bar,那么第一次展开得到的便是 keypath1(foo.bar),然后再次展开 keypath1(foo.bar) 得到这样的代码:

(
    (
        (void)(NO && ((void)foo.bar, NO)), 
        strchr("foo.bar", '.') + 1
    )
)

可以看到第二层下面是一个由逗号操作符 组成的表达式,分别由 (void)(NO && ((void)foo.bar, NO))strchr("foo.bar", '.') + 1 两个子表达式组成,根据逗号操作符的用法可以得出,这个表达式的值是就是 strchr("foo.bar", '.') + 1。整个宏展开后在运行时的效果相当于:((strchr("foo.bar", '.') + 1)),其中 strchar("foo.bar") 返回的是指向字符串中第一个 . 的指针,根据指针运算的规则加1后就是指向 b 的指针,即 C 字符串 "bar"。此时你可能会注意到宏展开后最外的一层括号似乎是多余的,但其实不是,keyPath 所需要的是一个 NSString 对象,而上面表达式的值却是一个 C 字符串,通过最外层的括号和 keypath 宏使用时的 @ 前缀,就组成了一个 Boxed Expressions @(strchr("foo.bar", '.') + 1),等价于 [NSString stringWithUTF8String:(strchr("foo.bar", '.') + 1)

既然 strchr("foo.bar", '.') + 1 提供了逗号表达式的值,那么 (void)(NO && ((void)foo.bar, NO)) 所做的就是 keyPath 合法性检查了,由于展开的代码中通过点操作符访问的部分 foo.bar,所以会有编译时检查。但是这段属性访问的代码需要在运行时进行屏蔽,防止一些 getter 产生副作用,这里屏蔽的方法是通过一个 && 操作符,由于左操作数是 NO所以后面的表达式会被短路。至于里面的两个 void 类型转换则是为了消除编译器 -Wunused-value 产生的警告,告知编译器表达式的值不会被使用。

知道了 keypath1 的实现原理后 keypath2 的自然也清晰,所以这里略去不表。但是这个实现存在着一个问题,通过 Boxed Expression 产生的字符串对象并不是一个编译期常量(Compile-time Constant),所以不能赋值给一个静态变量,按照这种实现static NSString *keypath = @keypath(foo, bar); 会报如下错误: objc initializer element is not a compile-time constant 针对这个问题,Github 的 @rob_rix 提出了另外一种实现:

#define keypath2(OBJ, PATH) \
    "" ? @ # PATH : (__typeof__(((__typeof__(OBJ))nil).PATH, @"")) nil

这个实现利用了三元操作符(?:)的值可以作为编译期常量的特点解决了不能赋值给静态变量的问题。同时由于使用了 __typeof__ 在编译时获取 OBJ 的类型,使得可以支持 @keypath(Foo *, bar) 这样的写法,相比之前 @keypath(Foo.new, bar) 优雅了一些。

总结:

像这类 "stringly typed" 的代码不止存在于 keyPath 的使用中,像对一些图片资源引用或控件的 identifier 也会有这样的问题,比如:

UIImage *buttonImage = [UIImage imageNamed:@"GreenButton"];

只要图片的名称改了就会导致资源无法正常加载。对于这类问题,Square 开源了一套工具 objc-codegenutils 用来根据资源文件(图片,颜色,storyboard等)自动生成对应的常量供你在代码里面使用,避免直接使用其名称。