玩转iOS“宏定义”
宏定义在c类语言中非常重要,因为宏是一种预编译时的功能,因此其可以比运行时更高层面的对程序流程进行控制。在初学宏定义的时候,大家可能都会有这样一种感觉:就是完全替换么,太简单了。但如果你真这么想,那你就太天真了,不说自己编写宏,在foundation框架中内置定义的许多宏要看明白也要费一番脑筋。本篇博客,总结了前辈的经验,同时收集了一些编写非常巧妙的宏进行分析,希望可以帮助大家对宏定义有更加深刻的理解,并且可以将心得应用于实际开发中。
一、准备
宏的本质是预编译时的替换,在开始正文之前,我们需要先介绍一种观察宏替换后结果的方法,这样帮助我们更方便的对宏最终的结果进行验证与测试。xcode开发工具自带查看预编译结果的功能,首先需要对工程编译一遍,之后选择工具栏中的assistant选项,打开助手窗口,如下图所示:
之后选择窗口的preprocess选项,即可打开预编译结果窗口,可以看到,宏被替换后的最终结果,如下图所示:
后面,我们将使用这种方式来对编写的宏进行验证。
二、关于“宏定义”
宏使用#define来进行定义,宏定义分为两种,一种是对象式宏,一种是函数式宏。对象式宏通常对来定义量值,在预编译时,直接将宏名替换成对应的量值,函数式宏在定义时可以设置参数,其作用与函数很类似。
例如,我们可以将π的值定义成一个对象式宏,在使用的时候,用有意义的宏名要比直接使用π的字面值方便很多,例如:
#import <foundation/foundation.h> #define pi 3.1415926 int main(int argc, const char * argv[]) { @autoreleasepool { // insert code here... cgfloat res = pi * 3; nslog(@"%f", res); } return 0; }
函数式宏要更加灵活一些,例如对圆面积计算的方法,我们就可以将其定义成一个宏:
#define pi 3.1415926 #define circlearea(r) pi * r * r int main(int argc, const char * argv[]) { @autoreleasepool { // insert code here... cgfloat res = circlearea(1); nslog(@"%f", res); } return 0; }
现在,有了这个面积计算宏我们可以更加方便的计算圆的面积了,看上去很完美,后面我们就使用这个函数式宏为例,来深入理解宏的原理。
作为一个开发者,有一个学习的氛围跟一个交流圈子特别重要,这是一个我的点击加入群聊ios交流群:789143298 ,不管你是小白还是大牛欢迎入驻 ,分享bat,阿里面试题、面试经验,讨论技术,大家一起交流学习成长!
三、从一个简单的函数式宏说起
再来看下上面我们编写的计算面积的宏,正常情况下好像没什么问题,但是需要注意,归根结底宏并不是函数,如果完全把其作为函数使用,我们就可能会陷入一系列的陷阱中,比如这样使用:
#define pi 3.1415926 #define circlearea(r) pi * r * r int main(int argc, const char * argv[]) { @autoreleasepool { // insert code here... cgfloat res = circlearea(1 + 1); nslog(@"%f", res); } return 0; }
运行代码,运算的结果并不是半径为2个圆的面积,哪里出了问题呢,我们还是先看下宏预编译后的结果:
cgfloat res = 3.1415926 * 1 + 1 * 1 + 1;
一目了然了,由于运算符的优先级问题导致了运算顺序错误,在编程中,所有运算符优先级产生的问题都可以使用一种方式解决:用小括号。对circlearea宏进行一下改造,如下:
#define circlearea(r) (pi * (r) * (r))
对执行顺序进行了强制的控制,代码执行又恢复了正常,看上去好像是没有问题了,现在就满意了还为时过早,例如下面这样使用这个宏:
#import <foundation/foundation.h> #define pi 3.1415926 #define circlearea(r) pi * (r) * (r) int main(int argc, const char * argv[]) { @autoreleasepool { // insert code here... int r = 1; cgfloat res = circlearea(r++); nslog(@"%f, %d", res, r); } return 0; }
运行,发现结果又错了,不仅计算结果与我们的预期不符,变量自加的的结果也不对了,我们检查其展开的结果:
cgfloat res = 3.1415926 * (r++) * (r++);
原来问题出在这里,宏在展开的时候,将参数替换了两次,由于参数本身是一个自加表达式,所以被自加了两次,产生了问题,那么这个问题怎么解决呢,c语言中有一种很有用的语法,即使用大括号定义代码块,代码块会将最后一条语句的执行结果返回,修改上面宏定义如下:
#import <foundation/foundation.h> #define pi 3.1415926 #define circlearea(r) \ ({ \ typeof(r) _r = r; \ (pi * (_r) * (_r)); \ }) int main(int argc, const char * argv[]) { @autoreleasepool { int r = 1; cgfloat res = circlearea(r++); nslog(@"%f, %d", res, r); } return 0; }
这次程序又恢复的了正常。但是,如果如果在调用宏是变量的名字与宏内的临时变量产生了重名,灾难就又发生了,例如:
#import <foundation/foundation.h> #define pi 3.1415926 #define circlearea(r) \ ({ \ typeof(r) _r = r; \ (pi * (_r) * (_r)); \ }) int main(int argc, const char * argv[]) { @autoreleasepool { int _r = 1; cgfloat res = circlearea(_r); nslog(@"%f, %d", res, _r); } return 0; }
运行上面代码,会发现宏内的临时变量没有被初始化成功。这确实难受,我们在进一步,比如对临时变量的名字做一些手脚,将其命名为极其不容易重复的名字,其实系统内置的一个宏就是专门用来构造唯一性变量名的:counter,这个宏是一个计数器,在编译的时候会自动进行累加,再次对我们编写的宏进行改造,如下:
#import <foundation/foundation.h> #define pi 3.1415926 #define past(a, b) a##b #define circlearea(r) __circlearea(r, __counter__) #define __circlearea(r, v) \ ({ \ typeof(r) past(_r, v) = r; \ (pi * past(_r, v) * past(_r, v)); \ }) int main(int argc, const char * argv[]) { @autoreleasepool { int _r = 1; cgfloat res = circlearea(_r); cgfloat res2 = circlearea(_r); nslog(@"%f, %f", res, res2); } return 0; }
这里改造后,我们的宏就没有那么容易理解了,首先counter在每次宏替换时都会进行自增,##是一种宏中专用的特殊符号,用来将参数拼接到一起,但是需要注意,使用##符号拼接的如果是另外一个宏,则其会阻止宏的展开,因此我们定义了一个转换宏past(a, b)来处理拼接。如果你一下子不能理解为什么这样就可以解决宏展开的问题,你只需要记住这样一条宏展开的原则:如果形参有使用#或##这种处理符号,则不会进行宏参数的展开,否则先展开宏参数,在展开当前宏。上面代码最终预编译的结果如下:
int main(int argc, const char * argv[]) { @autoreleasepool { int _r = 1; cgfloat res = ({ typeof(_r) _r0 = _r; (3.1415926 * _r0 * _r0); }); cgfloat res2 = ({ typeof(_r) _r1 = _r; (3.1415926 * _r1 * _r1); }); nslog(@"%f, %f", res, res2); } return 0; }
一个简单的计算圆面积的宏,为了安全,我们就进行了这么多的处理,看来要用好宏,的确不容易。
四、编写宏时的好习惯
通过前面的介绍,我们知道,如果随随意意的编写一个宏是非常不负责任的,看上去好像没问题与在任何场景下使用都没有问题是完全不同的。在编写宏时,我们可以刻意的去培养这样几个编码习惯:
-
参数与计算结果要加小括号
这条原则应该不必多说了,前面的示例中就有演示,完整的添加小括号可以避免很多由于运算符优先级造成的异常问题。
-
多语句功能性宏,要使用do-while包裹
这条原则看上去有些莫名其妙,但是其非常重要,例如,我们需要编写一个自定义的log宏,在进行打印时添加一些自定义的信息,你或许会这样写:
#define log(string) \ nslog(@"自定义的信息"); \ nslog(string); int main(int argc, const char * argv[]) { @autoreleasepool { log(@"info") } return 0; }
运行代码,目前貌似没有问题,但是如果其和if语句进行结合,可能问题就来了:
int main(int argc, const char * argv[]) { @autoreleasepool { if (no) log(@"info") } return 0; }
运行代码,还是有一行log信息被输出了,看下其预编译后的结果如下:
int main(int argc, const char * argv[]) { @autoreleasepool { if (__objc_no) nslog(@"自定义的信息"); nslog(@"info"); } return 0; }
找到问题了,由于if结构如果不加大括号进行规范,其默认作用域只有一句代码,多写大括号是不会出问题,因此编写多语句宏时,加上大括号是一个好习惯,如下:
#define log(string) \ {nslog(@"自定义的信息"); \ nslog(string);}
这样解决了问题,但是并不完美,假设在使用时这样写:
int main(int argc, const char * argv[]) { @autoreleasepool { if (no) log(@"no"); else log(@"yes"); } return 0; }
结果发现还是会报错,是由于分号捣的鬼,预编译结果如下:
int main(int argc, const char * argv[]) { @autoreleasepool { if (__objc_no) {nslog(@"自定义的信息"); nslog(@"no");}; else {nslog(@"自定义的信息"); nslog(@"yes");}; } return 0; }
我们知道,像if,while,for这种语法结构块的大括号后是不需要分号的,我们为了兼容单行if语句由于宏的原因被展开成多行的问题强行加了一个大括号上去,就产生这样的问题了,解决它的一个好方法是真的将多行的宏转化成单语句,do-whlie结构就可以实现这种效果,修改宏如下:
#define log(string) \ do {nslog(@"自定义的信息"); \ nslog(string);} while(0); int main(int argc, const char * argv[]) { @autoreleasepool { if (no) log(@"no") else log(@"yes"); } return 0; }
预编译后:
int main(int argc, const char * argv[]) { @autoreleasepool { if (__objc_no) do {nslog(@"自定义的信息"); nslog(@"no");} while(0); else do {nslog(@"自定义的信息"); nslog(@"yes");} while(0);; } return 0; }
现在,无论外面怎么使用,这个宏都可以正常工作了。
-
对于不定参数的宏,借助##符号来拼接参数
在定义函数时,我们可以定义函数的参数为不定个数参数,定义函数式宏时也类似,使用符号"..."可以指定不定个数参数,例如对log宏进行调整,如下:
#define log(format, ...) \ do {nslog(@"自定义的信息"); \ nslog(format, __va_args__);} while(0); int main(int argc, const char * argv[]) { @autoreleasepool { if (no) log(@"%d", no) else log(@"%d", yes); } return 0; }
va_args也是一个内置的宏符号,则作用是代表宏定义中的可变参数“...”,需要注意,如果按照上面的写法,如果我们传入的可变参数为0个,会产生问题,其原因也是由于多了一个逗号,例如:
int main(int argc, const char * argv[]) { @autoreleasepool { if (no) log(@"%d") // 这里会被预编译成nslog(@"%d", ) else log(@"%d", yes); } return 0; }
解决方案是对可变参数进行一次##拼接,宏在使用##符号进行参数拼接时,如果后面的参数为空,其会自动将前面的逗号去掉,如下:
#define log(format, ...) \ do {nslog(@"自定义的信息"); \ nslog(format, ##__va_args__);} while(0);
五、特殊的宏符号与常用内置宏
有几个特殊的符号可以让宏定义变得非常灵活,常用的特殊符号和特殊宏列举如下:
-
井号的作用是将参数字符串化,例如:
#define test(p) #p int main(int argc, const char * argv[]) { @autoreleasepool { test(abc); // 预编译后成为 "abc"; } return 0; }
-
双井号我们前面有使用过,其作用是对参数进行拼接,例如:
#define test(a,b) a##b int main(int argc, const char * argv[]) { @autoreleasepool { test(1,2); // 预编译后成为 12; } return 0; }
-
__time __
可变参数宏中专用,表示所有传入的可变参数。
-
__counter __
一个累加计数宏,常用来构造唯一变量名。
-
__line __
记录log信息时,常用的一个内置宏,预编译时会将其替换为当前的行号。
-
__file __
记录log信息时,常用的一个内置宏,预编译时会将其替换为当前文件的全路径。
-
__file_name __
记录log信息时,常用的一个内置宏,预编译时会将其替换为当前的文件名。
-
__date __
记录log信息时,常用的一个内置宏,预编译时会将其替换为当前日期。
-
__time __
记录log信息时,常用的一个内置宏,预编译时会将其替换为当前时间。
六、宏的展开规则
通过前面的介绍,对于应用宏我们已经没有太大的问题,并且也了解了很多宏的使用技巧。这一小节将更深入的对宏的替换规则进行讨论。宏本身是支持嵌套的,例如:
#define m1(a) m2(a) #define m2(a) a int main(int argc, const char * argv[]) { @autoreleasepool { m1(1); } return 0; }
上面代码中定义的两个宏基本上是没有意义的,m1宏替换后的结果是m2宏,m2宏最终被替换为参数本身,从这个例子可以看出,宏是可以嵌套递归展开的,但是递归展开是有原则,不会出现无限递归,例如:
#define m1(a) m2(a) #define m2(a) m1(a) int main(int argc, const char * argv[]) { @autoreleasepool { m1(1); // 最终展开为 m1(1) } return 0; }
宏的展开需要符合下面原则:
- 在展开宏的过程中会先将参数进行展开,如果使用##对参数进行了拼接或使用#进行了处理,则此参数不会被展开。
- 在宏的展开过程中,如果替换列表中出现了要被展开的宏,则此宏不会被展开。
上面的展开原则提到了替换列表,宏在展开过程中会维护一个替换列表,展开的过程中需要从参数到宏本身,从外层宏到内层宏一层一层的替换,每次替换的时候都会将被替换的宏名放入维护的替换列表中,再下一轮替换中,如果再次出现替换列表中出现过的宏名,则不会被再次替换。以我们上面的代码为例进行分析:
- 首先m1宏在第一轮替换后,被替换成了m2,此时替换列表中放入宏名m1。
- m2依然是一个宏名,第二轮对m2进行替换,将其替换为m1,再次将m2放入替换列表,此时替换列表中有宏名m1和m2。
- m1依然是宏名,但是替换列表中已经存在m1,此宏名不再展开。
七、宏的妙用
这一小节,我们要转身成为鉴赏家,来对很多实用的宏的巧妙案例进行分析与鉴赏。从这些优秀的使用案例中,可以扩宽我们对宏使用的思路。
-
min与max
foundataion内置了一些常用的运算宏,如获取两个数的最大值、最小值、绝对值等等。以max宏为例,这个宏的编写基本涵盖了函数式宏所有要注意的点,如下:
#define __nsx_paste__(a,b) a##b #if !defined(max) #define __nsmax_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__(__b,l) : __nsx_paste__(__a,l); }) #define max(a,b) __nsmax_impl__(a,b,__counter__) #endif
其中nsmax_impl宏借助计数counter和拼接nsx_paste宏来构造唯一的内部变量名,我们前面提供的示例宏的写法也基本是参照这个系统宏来的。后面大家在编写函数式宏的时候,都可以参照下这个宏的实现。
-
nsassert等
nsassert是断言宏,在开发调试中经常会使用断言来进行安全保障,这个宏的定义如下:
#define nsassert(condition, desc, ...) \ do { \ __pragma_push_no_extra_arg_warnings \ if (__builtin_expect(!(condition), 0)) { \ nsstring *__assert_file__ = [nsstring stringwithutf8string:__file__]; \ __assert_file__ = __assert_file__ ? __assert_file__ : @"<unknown file>"; \ [[nsassertionhandler currenthandler] handlefailureinmethod:_cmd \ object:self file:__assert_file__ \ linenumber:__line__ description:(desc), ##__va_args__]; \ } \ __pragma_pop_no_extra_arg_warnings \ } while(0)
nsassert宏定义中使用到了不定参数拼接消除逗号的技巧,并且是多行宏语句使用do-while进行优化的一个实践。
- @weakify与@strongify
weakify与strongify是reactcocoa中常用的两个宏,用来处理循环引用问题。这两个宏的定义非常巧妙,以weakify宏为例,要看懂这个宏并不是十分简单,首先与这个宏相关的宏定义列举如下:
#if debug #define rac_keywordify autoreleasepool {} #else #define rac_keywordify try {} @catch (...) {} #endif #define rac_weakify_(index, context, var) \ context __typeof__(var) metamacro_concat(var, _weak_) = (var); #define weakify(...) \ rac_keywordify \ metamacro_foreach_cxt(rac_weakify_,, __weak, __va_args__) #define metamacro_foreach_cxt(macro, sep, context, ...) \ metamacro_concat(metamacro_foreach_cxt, metamacro_argcount(__va_args__))(macro, sep, context, __va_args__) #define metamacro_argcount(...) \ metamacro_at(20, __va_args__, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1) #define metamacro_at20(_0, _1, _2, _3, _4, _5, _6, _7, _8, _9, _10, _11, _12, _13, _14, _15, _16, _17, _18, _19, ...) metamacro_head(__va_args__) #define metamacro_at(n, ...) \ metamacro_concat(metamacro_at, n)(__va_args__) #define metamacro_concat(a, b) \ metamacro_concat_(a, b) #define metamacro_concat_(a, b) a ## b #define metamacro_head(...) \ metamacro_head_(__va_args__, 0) #define metamacro_foreach_cxt1(macro, sep, context, _0) macro(0, context, _0) #define metamacro_head_(first, ...) first
其中rac_keywordify区分debug和release环境,在debug环境下,其实际上是创建了一个无用的autoreleasepool,消除前面的@符号,在release环境下,其会创建一个try-catch结构,用来消除参数警告。metamacro_foreach_cxt宏比较复杂,其展开过程如下:
// 第一步: 原始宏 metamacro_foreach_cxt(rac_weakify_,, __weak, obj) // 第二步: 展开metamacro_foreach_cxt metamacro_concat(metamacro_foreach_cxt, metamacro_argcount(obj))(rac_weakify_,, __weak, obj) // 第三步: 展开metamacro_argcount metamacro_concat(metamacro_foreach_cxt, metamacro_at(20, obj, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1))(rac_weakify_,, __weak, obj) // 第四步: 展开metamacro_at metamacro_concat(metamacro_foreach_cxt,metamacro_concat(metamacro_at, 20)(obj, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1))(rac_weakify_,, __weak, obj) // 第五步:展开metamacro_concat metamacro_concat(metamacro_foreach_cxt,metamacro_at20(obj, 20, 19, 18, 17, 16, 15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1))(rac_weakify_,, __weak, obj) // 第六步:展开metamacro_at20 metamacro_concat(metamacro_foreach_cxt,metamacro_head(1))(rac_weakify_,, __weak, obj) // 第七步:展开metamacro_head metamacro_concat(metamacro_foreach_cxt,metamacro_head_(1, 0))(rac_weakify_,, __weak, obj) // 第八步:展开metamacro_head_ metamacro_concat(metamacro_foreach_cxt,1)(rac_weakify_,, __weak, obj) // 第九步:展开metamacro_concat metamacro_foreach_cxt1(rac_weakify_,, __weak, obj) // 第十步:展开metamacro_foreach_cxt1 rac_weakify_(0, __weak, obj) // 第十一步:展开rac_weakify_ __weak __typeof__(obj) metamacro_concat(obj, _weak_) = (obj); // 第十二步:展开metamacro_concat __weak __typeof__(obj) obj_weak_ = (obj);
strongify宏的展开与之类似。
-
paragraphstyleset宏
paragraphstyleset宏是yylabel中提供的一个设置属性字符串paragraphstyle相关属性的快捷方法,其中使用到的一个技巧是直接使用宏的形参作为属性名进行使用,使得各种属性的设置都使用同一个宏即可完成,其定义如下:
#define paragraphstyleset(_attr_) \ [self enumerateattribute:nsparagraphstyleattributename \ inrange:range \ options:kniloptions \ usingblock: ^(nsparagraphstyle *value, nsrange subrange, bool *stop) { \ nsmutableparagraphstyle *style = nil; \ if (value) { \ if (cfgettypeid((__bridge cftyperef)(value)) == ctparagraphstylegettypeid()) { \ value = [nsparagraphstyle yy_stylewithctstyle:(__bridge ctparagraphstyleref)(value)]; \ } \ if (value. _attr_ == _attr_) return; \ if ([value iskindofclass:[nsmutableparagraphstyle class]]) { \ style = (id)value; \ } else { \ style = value.mutablecopy; \ } \ } else { \ if ([nsparagraphstyle defaultparagraphstyle]. _attr_ == _attr_) return; \ style = [nsparagraphstyle defaultparagraphstyle].mutablecopy; \ } \ style. _attr_ = _attr_; \ [self yy_setparagraphstyle:style range:subrange]; \ }];
八、结语
宏看上去简单,但是真的用好用巧却并不容易,我想,最好的学习方式就是在实际应用中不断的使用,不断的琢磨与优化。如果能将宏的使用驾轻就熟,一定会为你的代码能力带来质的提升。
更多文章:
2020年ios大厂面试题总结(一)
ios学习栈(将持续更新)上
阿里、字节:一套高效的ios面试题
推荐
下一篇: 分布式ID生成之雪花算法小白专场