有没有觉得OC的数组和字典使用起来特别繁琐?我们在使用字典或数组时,总是需要判断数组越界,判断数据是否为空,判断数据类型。
有没有觉得定义多参数函数的时候特别杂乱无章?
有没有觉得Swift的元组使用起来特别顺心?
这些问题其实都源于OC是一门比较古老的语言,早期设计时缺少一个足够灵活,足够好用的数据封装工具。
试想一下,假如OC也有一个元组类,这个类可以封装任意数据,包装时也无须关注数据的类型。在解构数据时可以一次性的将数据展开,无需关注越界的问题。那势必能够提高不少开发的效率,让OC这门古老的语言也swift一下。
元组(tuple)是一种数据结构,它本来是一种关系型数据库的概念,用来描述一张表中的一条数据。例如:
那么一行数据就是一个元组,即: tuple1 = (阿强,18,战士);tuple2 = (阿珍,28,法师)。这个概念后来也被使用在代码中,成为一种数据封装的工具。元组可以包含任意个数和任意类型的数据,具体元组中包含什么,取决于使用时的定义。同时,元组内的元素既可以有顺序也可以有键值,但一旦定义后不可增删元素,类似于不可变的数组+字典。那么我们就尝试根据元组的定义,为OC也增加一个元组的实现。
首先我们先来看看成品的效果,以及功能。
JDTuple通过jd_tuple(...)方法来构造一个元组,如下代码所示我们构造了一个包含三个元素的元组:
NSString *name = @"阿珍";
NSInteger age = 28;
NSArray *arr = @[@"a", @"b", @"c"];
JDTuple *someTuple = jd_tuple(name, age, arr);
这一个方法其实包含了三个步骤:
当然也可以定义匿名元素的元组,直接将值传给构造方法,将默认为匿名的元素。如:
JDTuple *someTuple = jd_tuple(@"阿强", 18, (CGRectMake(100, 200, 50.5, 7)), [NSObject new]);
1.顺序解构
JDTuple通过jd_tuple(tuple)^(...){...}方法来进行顺序元素解构,顺序解构是根据构造元组时的元素顺序来取值,根据构造时元素的顺序和类型,定义相应的变量就能获取到元素的值,如:
JDTuple *someTuple = jd_tuple(@"阿强", 18, (CGSizeMake(5, 18)), [NSObject new]);
jd_unpack(someTuple)^(NSString *name, int age, CGSize size, NSObject *objc) {
NSLog(@"name: %@", name);
NSLog(@"age: %d", age);
NSLog(@"width:%f, height:%f", size.width, size.height);
NSLog(@"objc: %@", objc);
//NSLog output
//name: 阿强
//age: 18
//width:5.000000, height:18.000000
//objc: <NSObject: 0x6000014279d0>
};
上述代码解构了someTuple元组,将元素在一个作用域内赋值给了name、age、size、objc变量。
当我们只需要部分元素时,我们只需要在相应的位置上定义变量就可以了,需要忽略的元素可以用arg_ph类型来占位,如:
JDTuple *someTuple = jd_tuple(@"阿强", 18, (CGSizeMake(5, 18)), [NSObject new]);
jd_unpack(someTuple)^(NSString *name, int age) {
NSLog(@"name: %@", name);
NSLog(@"age: %d", age);
//NSLog output
//name: 阿强
//age: 18
};
jd_unpack(someTuple)^(NSString *name, arg_ph one, arg_ph two, NSObject *objc) {
NSLog(@"name: %@", name);
NSLog(@"objc: %@", objc);
//NSLog output
//name: 阿强
//objc: <NSObject: 0x6000014279d0>
};
当相应位置的元素类型不匹配时,元素将不会被赋值,并在控制台输出提示, 如:
JDTuple *someTuple = jd_tuple(@"阿强", 18, (CGSizeMake(5, 18)), [NSObject new]);
jd_unpack(someTuple)^(NSInteger name, id age, CGSize size, NSObject *objc) {
NSLog(@"name: %ld", name);
NSLog(@"age: %@", age);
NSLog(@"width:%f, height:%f", size.size.width, size.size.height);
NSLog(@"objc: %@", objc);
//NSLog output
//JDTuple unpack params _ 0 _ type is not match. @ != q
//JDTuple unpack params _ 1 _ type is not match. i != @
//name: 0
//age: (null)
//width:5.000000, height:18.000000
//objc: <NSObject: 0x6000002439e0>
};
2.键值解构
如果元组在构造时包含键值,则可以使用jd_unpackWithkey(...)方法,通过对应的键值解构元组,而无需关心元素顺序或个数,如:
- (void)testFunctionOne {
NSString *name = @"阿珍";
NSInteger age = 28;
NSArray *arr = @[@"a", @"b", @"c"];
JDTuple *someTuple = jd_tuple(name, age, arr);
[self testFunctionTwo:someTuple];
}
- (void)testFunctionTwo:(JDTuple *)tuple {
jd_unpackWithkey(NSInteger age, NSArray *arr, NSString *name) = tuple;
NSLog(@"name: %@", name);
NSLog(@"age: %ld", age);
NSLog(@"array: %@", arr);
//NSLog output
//name: 阿珍
//age: 28
//array: (a,b,c)
}
上述代码在testFunctionTwo方法中解构了入参tuple, 在函数作用域内将元素直接赋值给了同名的参数name、age、arr。
如果尝试解构元组中不存在的键值,或者键值类型不匹配,则不会给予赋值,并在控制台输出提示,如:
- (void)testFunctionOne {
NSString *name = @"阿珍";
NSInteger age = 28;
NSArray *arr = @[@"a", @"b", @"c"];
JDTuple *someTuple = jd_tuple(name, age, arr);
[self testFunctionTwo:someTuple];
}
- (void)testFunctionTwo:(JDTuple *)tuple {
jd_unpackWithkey(NSString *name, id test1, CGFloat age) = tuple;
NSLog(@"name: %@", name);
NSLog(@"age: %f", age);
NSLog(@"test1: %@", test1);
//NSLog output
//JDTuple unpack params _ ” id test1 ” _ non-existent
//JDTuple unpack params _ “ CGFloat age ” _ type is not match. q != d
//name: 阿珍
//age: 0.000000
//test1: (null)
}
可以从元组中根据下标或者键值来获取单个元素,类似数组或者字典的操作,得到的元素是被NSValue封装起来的,使用时要根据实际的类型进行值的获取, 如果下标或键值不正确,则会返回nil,如:
NSString *name = @"阿珍";
NSInteger age = 28;
NSArray *arr = @[@"a", @"b", @"c"];
JDTuple *someTuple = jd_tuple(name, age, arr);
NSString *nameWithIndex = [someTuple[0] nonretainedObjectValue]; //阿珍
NSInteger ageWithKey = [someTuple[@"age"] integerValue]; //28
NSValue *nonexistentWithIndex = someTuple[99999]; //(null)
NSValue *nonexistentWithKey = someTuple[@"existent"]; //(null)
为了让开发者清楚的知道元组中有什么元素,我们可以使用JD_TUPLE(...)来定义一个元组,避免遇到一个元组时不清楚里面会包含什么元素的窘境,在使用顺序解构时,我们可以使用严格模式 jd_unpack_strict(...)来完全匹配元组的定义。
//定义元组内包含:NSString *name, NSInteger age
JD_TUPLE(NSString *name, NSInteger age) tuple = jd_tuple(@"阿强", 18);
- (void)testFunction:( JD_TUPLE(NSString *name, NSInteger age) )tuple {
jd_unpack_strict(tuple)^(NSString *name, NSUInteger age) { //解构正确可以通过编译
...
};
jd_unpack_strict(tuple)^(UIView *name) { //解构错误编译不通过
...
};
jd_unpack(tuple)^(CGFloat f) { //解构正确可以通过编译,非严格模式
...
};
}
现在我们知道了JDTuple是什么,且要怎么使用JDTuple。接下来我们就一步步分析一下JDTuple是如何实现的。
1.一次性构造
首先我们要解决数据如何方便的放到元组里,根据jd_tuple(...)方法的定义,我们可以将任意数据作为入参,并将入参保存在元组中。首先,任意入参这个需求,我们很自然的可以想到使用不定参数函数,它使用一块连续的内存来保存入参信息,并使用va_start来找到内存的首地址来获取入参。如:
void test(id one, ...) {
va_list list;
va_start(list, one);
id arg = nil;
do {
arg = va_arg(list, id);
//.../
} while (arg);
va_end(list);
};
test([NSObject new],[NSObject new],[NSObject new],[NSObject new], nil);
如上述例子,不定参数函数的标准用法中会要求使用一个nil来作为参数的结尾,原因就是va_list并不知道入参的个数和类型,需要被调用者预先跟调用者约定如何传参,所以规定一个nil值来规范双方,所以nil结尾只是一个约定,并不是一定要遵守的。实际上NSLog(...)就没有使用nil结尾,而是采用了特殊字符来让被调用者知道调用者的入参个数和类型。由于构造元组时,存在入参是nil的可能,所以我们也不能使用nil来作为结尾。我们需要使用另一种约定,来实现参数的解析。所以我们采用OC的类型编码来作为入参的类型解析,类型和值需成对传入,根据类型获取后面的值,最后使用一个全局字符串常量的指针地址作为结束符号。由此设计了方法- (JDTuple *(^)(const char *OCType, ...))addArg
使用如下方法:
//use
JDTuple *tuple = [JDTuple new];
id objc = [NSObject new];
NSInteger iNumber = 100;
CGFloat fNumber = 50.55;
CGReact frame = CGReactMake(10,20,300,400);
tuple.addArg("@", objc,
"q", iNumber,
"d", fNumber,
"{CGRect={CGPoint=dd}{CGSize=dd}}", frame,
jd_tuple_end);
//implementation
static const char *jd_tuple_end = "JD_TUPLE_END"; //结束标识符
@interface JDTuple : NSObject
- (JDTuple *(^)(const char *OCType, ...))addArg;
@end
@implementation JDTuple
- (JDTuple *(^)(const char *OCType, ...))addArg {
return ^(const char *OCType, ...) {
NSInteger iIndex = 1;
va_list valist;
va_start(valist, OCType);
while (iIndex != -1) {
if (iIndex % 2 == 0) { //双数位置上的入参为类型编码
OCType = va_arg(valist, const char*);
if (OCType == NULL || &OCType[0] == &jd_tuple_end[0]) {//识别到结束符,停止循环
iIndex = -1;
}else {
iIndex ++;
}
continue;
}else { //单数位置上的入参为值
iIndex ++;
}
//根据类型编码从valist中取出相应的值,保存在NSValue中
NSValue *newValue = nil;
if (strcmp(OCType, "@") == 0) {
__unsafe_unretained id<NSObject> p = va_arg(valist, id);
newValue = [[NSValue alloc]initWithBytes:&p objCType:OCType];
}else if (strcmp(OCType, "q") == 0) {
NSInteger p = va_arg(valist, NSInteger);
newValue = [[NSValue alloc]initWithBytes:&p objCType:OCType];
}else if ...
}
...
}
}
@end
关于OC类型编码的知识可以去官网查一下:https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/ObjCRuntimeGuide/Articles/ocrtTypeEncodings.html
2.类型自动识别
有了addArg方法,我们就已经实现了对元素的封装,但是这个方法使用起来还非常不便捷,我们既要输入值还要输入值的类型编码,依然很影响开发体验,这时我们需要借助@encode()和typeof()两个关键字就可以实现自动识别变量的类型编码。typeof(): 可以转译出变量的类型。@code(): 可以根据类型转译出类型编码。如:
id objc = [NSObject new];
NSInteger i = 66;
@encode(typeof(objc)) //@
@encode(typeof(i)) //q
这样我们就不用自己手动去输入类型的编码了,可以由变量自动转换:
JDTuple *tuple = [JDTuple new];
id objc = [NSObject new];
NSInteger iNumber = 100;
CGFloat fNumber = 50.55;
CGReact frame = CGReactMake(10,20,300,400);
tuple.addArg(@encode(typeof(objc)), objc,
@encode(typeof(iNumber)), iNumber,
@encode(typeof(fNumber)), fNumber,
@encode(typeof(frame)), frame,
jd_tuple_end);
3.宏定义封装
最后我们再利用一个宏定义,将入参操作简化为一个参数,利用宏的预编译能力来自动填写类型编码。宏定义实现较为冗长,这里只展示解析2个参数的部分代码,如:
//use
JDTuple *tuple = [JDTuple new];
id objc = [NSObject new];
NSInteger iNumber = 100;
tuple.addArg(___tuple_add(objc, iNumber));
//implementation
//填写入参
static const char *jd_tuple_end = "JD_TUPLE_END";
//计算参数个数
_0, _1, _2, N, ...) N
//添加变量
解决入参个数和类型的问题,再利用宏的特性,给入参元素一个键值。首先定义一个键值输入方法,入参为一个键值字符串集合,以逗号作为分隔符:
- (void)tupleInputKeys:(const char *)keys;
[ ];
再利用宏定义的#号(使用#可以把宏参数变为一个字符串),将入参转化为字符串传入tupleInputKeys方法,生成入参的键值。最终得到构造宏定义方法jd_tuple(...), 如:
#__VA_ARGS__]; tuple.addArg(___tuple_add(__VA_ARGS__)); tuple;}) define jd_tuple(...) ({JDTuple *tuple = [JDTuple new]; [tuple tupleInputKeys:
以上就完成了入参数据封装的工作。
解决了入参的问题,将入参保存下来的工作就容易多了。这里我们定义两个属性,分别保存元素和键值:
//数组用于顺序保存元素
@property (nonatomic, strong) NSMutableArray<__kindof NSValue *> *arrParams;
//字典用于映射键值和数组的下标
@property (nonatomic, strong) NSMutableDictionary<NSString *, NSNumber *> *dicKeys;
因NSArray只支持存储对象,顾所有元素都会使用NSValue来进行封装,保存在数组中,使用NSValue的好处是,他不仅能够存储数据本身,同时还会存储数据的类型编码,类型编码对于解构数据时的校验起到至关重要的作用,同时也更适用于ARC的编译环境。
解决了存数据的问题,接下来我们就要考虑如何把元素从元组中取出来,也就是解构元组。
1.单个数据解构
JDTuple是用数组和字典来存储数据的,那么它就天然具备数组和字典的特性,可以使用下标和键值取值。想要具有中括号取值的能力其实也很简单,只要实现以下两个方法,就能让编译器识别。
- (objectType)objectAtIndexedSubscript:(NSUInteger)idx;
- (objectType)objectForKeyedSubscript:(NSString *)key;
实现objectForKeyedSubscript方法,可以让编译器认为你具有键值取值的能力。
NSValue *value1 = someTuple[1];
NSValue *value2 = someTuple[@"name"];
2.批量键值解构
有了单个数据的获取方式,我们当然希望有批量的操作来提高我们的生产效率,这也是元组的精髓所在,下面我们来看看如何实现通过键值批量结构元组。通过键值从元组中取出的数据是封装在NSValue中的,我们使用时还需要从NSValue中把真正的元素取出,那我们能不能通过一个方法来获取NSValue中的值,并转化为目标值的类型呢?这时,我们需要借助C语言函数重载来实现。如:
__attribute__((overloadable)) int add(int num){
NSLog(@"Add Int %i",num);
return num;
}
__attribute__((overloadable)) NSString add(NSString * num){
NSLog(@"Add NSString %@",num);
return num;
}
__attribute__((overloadable)) NSNumber add(NSNumber * num){
NSLog(@"Add NSNumber %@",num);
return num;
}
int i = add(123); // Add Int 123
NSString *s = add(@"asd"); // Add NSString asd
NSNumber *n = add(@(444)); // Add NSNumber 444
如上例子,通过__attribute__((overloadable))修饰的add函数可以使用同一个函数名来接收不同类型的入参,且定义不同的返回值。我们可以借助一组重载的函数来直接获取NSValue中的值。如:
// implementation
__attribute__((overloadable)) id tmp_tuple_unpack_c(id tuple, const char *key, id v){...};
__attribute__((overloadable)) NSInteger tmp_tuple_unpack_c(id tuple, const char *key, NSInteger v){...};
__attribute__((overloadable)) CGFloat tmp_tuple_unpack_c(id tuple, const char *key, CGFloat v){...};
//use
id valueObjc = tmp_tuple_unpack_c(tuple, "name", valueObjc);
NSInteger valueInt = tmp_tuple_unpack_c(tuple, "age", valueInt);
CGFloat valueFloat = tmp_tuple_unpack_c(tuple, "height", valueFloat);
接下来我们只需要借助一个宏,来将tmp_tuple_unpack_c方法的调用压缩成一个宏调用就能实现键值的批量结构了,为了让宏能够正确调用不同返回值类型的tmp_tuple_unpack_c函数,我需要将tmp_tuple_unpack_c函数的最后一个入参改造一下,如:
// implementation
__attribute__((overloadable)) id tmp_tuple_unpack_c(id tuple, const char *key, void(^v)(id)){...};
__attribute__((overloadable)) NSInteger tmp_tuple_unpack_c(id tuple, const char *key,void(^v)(NSInteger)){...};
__attribute__((overloadable)) CGFloat tmp_tuple_unpack_c(id tuple, const char *key, void(^v)(CGFloat)){...};
//use
help_tuple_unpack_set(tuple, id valueObjc);
// id valueObjc = tmp_tuple_unpack_c(tuple, "id valueObjc", ^(id valueObjc){});
help_tuple_unpack_set(tuple, NSInteger valueInt);
// NSInteger valueInt = tmp_tuple_unpack_c(tuple, "NSInteger valueInt", ^(NSInteger valueInt){});
help_tuple_unpack_set(tuple, CGFloat valueFloat);
// CGFloat valueFloat = tmp_tuple_unpack_c(tuple, "CGFloat valueFloat", ^(CGFloat valueFloat){});
最后再加工一下,用宏封装成赋值形式的等号写法,就有了unpackWithkey(...)方法,其中参考了ReactCocoa中RACTuple的解构写法,这个宏的封装描述起来比较复杂,主要原理是利用宏遍历宏的参数,再用宏参数调用help_tuple_unpack_set宏,感兴趣的话可以去看看源码。("Don’t try to understand it. Feel it." ——Tenet)
...
id tmp__tupleUnpack_
JDTTupleUnpack_after_
jd_tuple_arg_count_key(__VA_ARGS__)(tmp__tupleUnpack_
if (tmp__tupleUnpack_
while (![ tmp__tupleUnpack_
if (tmp__tupleUnpack_
goto JDTTupleUnpack_after_
3.批量顺序解构
顺序解构,我们采用Block回调的方式来实现,在JDTuple中定义一个unpack属性,属性类型为id,利用这个属性的set方法来接受一个任意类型的block变量,在set方法中利用NSINvocation动态调用这个block,将元组中的元素作为block的入参顺序传入,就实现了元组的解构。其中利用Block的signature和NSValue的objCType做比较,可以很好的比对类型是否一致,避免崩溃。下面是部分关键代码:
@interface JDTuple<__contravariant objectType, deconstructType> : NSObject
@property (nonatomic, copy) id unpack;
@property (nonatomic, copy) deconstructType unpackStrict;
...
- (void)setUnpack:(id)block {
...
NSInvocation *blockInvocation = [NSInvocation invocationWithMethodSignature:methodSignature];
[blockInvocation retainArguments];
for (NSInteger idx = 0; idx < methodSignature.numberOfArguments - 1; idx++) {
NSValue *param = self.arrParams[idx];
__unsafe_unretained id p;
[param getValue:&p];
[blockInvocation setArgument:&p atIndex:idx + 1];
...
}
[blockInvocation setTarget:block];
[blockInvocation invoke];
}
// use
jd_unpack(tuple)^(NSString *name, NSInteger age, CGFloat height) {
....
};
分享一些坑点
OC中的BOOL类型是一个char型的数据,_Bool才是0或1。
在解构数据类型时,发现CGFloat类型在不同的CPU架构下使用的类型是不一样的,在ARM64架构下的CGFloat实际是double型的,而在ARM32架构下才是float型的。同理,NSInteger也会在不同架构下表现为long或int。如果没注意这些细节,可能在平时的开发中会出现精度丢失的问题。
元组的最直接使用好处就是方便的存取,无须在意元组中元素的个数和类型,很适合多参数传递的场景,实际使用中可以提升不少开发的效率。
- (void)test {
NSObject *name = [NSObject new];
NSInteger age = 28;
NSArray *arr = @[@"a", @"b", @"c"];
CGRect frame = CGRectMake(1, 2, 3, 4);
CGFloat f = 55.334;
JDTuple *someTuple = jd_tuple(name, age, arr, frame, f);
[self testFunction:someTuple];
}
- (void)testFunction:(JD_TUPLE(NSString *, NSInteger, NSArray *, CGRect, CGFloat))tuple {
jd_unpack_strict(tuple)^(NSString *a, NSInteger b, NSArray *c, CGRect d, CGFloat e) {
};
}
我们可以利用Tuple的灵活性来做一些很有趣的事情。
1.返回多个返回值
- (JD_TUPLE(NSString*test, NSInteger i, CGFloat f))test{
NSString *test = @"test";
NSInteger i = 667;
CGFloat f = 334.5;
return jd_tuple(test, i, f);
}
- (void)viewDidLoad {
[super viewDidLoad];
jd_unpackWithkey(NSString *test, NSInteger i, CGFloat f) = [self test];
}
2.一个更万能的performSelector方法
- (JDTuple *)performSelector_tuple:(JDTuple *)tuple {
jd_unpackWithkey(id __self, NSString *selector) = tuple;
NSInvocation *invocation = [NSInvocation invocationWithMethodSignature:signature];
...
[invocation invoke];
...
id returnValue;
[invocation getReturnValue:&returnValue];
return jd_tuple(returnValue);
}
JDTuple *returnTuple = [self performSelector_tuple:jd_tuple(self, @"someFunc", @"args1", 234, [NSObject new])];
JDTuple *returnTuple2 = [self performSelector_tuple:jd_tuple(self, @"someFunc2", 1,2,3,4,5,6,7,8,9)];
可以利用JDTuple的能力,实现一个json字符串转元组的方法,然后直接使用tuple来获取数据,省去了一遍又一遍写model的繁琐工作。
JDTuple *json_to_tuple(NSString *jsonString) {
...
return tuple;
}
JDTuple *tuple = json_to_tuple(@"{ \
a : \"abc\"\
b : [\"hello\", \"world\", \"!!\"]\
c : {\
cc : \"ccc\"\
}\
}");
*a, NSArray *b, JDTuple *c) = tuple;
NSString *cc) = c;
利用JDTuple对Swift使用了元组的方法进行简单封装,使得OC也可以调用使用了元组的Swift 方法。更多的使用场景就留给大家去发散了。
JDTuple还处于持续开发阶段,有很多细节的工作还没有完善,例如类型的解析,目前还不支持自定义结构体的存取。源码已开源在gitee上供大家使用, 有兴趣的朋友可以到上面提提意见。
Gitee: https://gitee.com/jd-platform-opensource/jdtuple
Type Encodings (https://nshipster.cn/type-encodings/)
ReactCocoa (https://reactivecocoa.io/)
iOS中的attribute和宏 (http://fighting300.com/2016/06/12/iOS-attribute/)
计算可变参数宏 VA_ARGS 的参数个数(https://blog.csdn.net/10km/article/details/80760533)