NSPredicate 的简单使用

0x00 前言

最近在重新学习 Objective-C,因为觉得基础非常重要,而且对 Objective-C 的使用都基本上是工作中用到的,了解得还不够多,就算当初学习 Objective-C 的时候知道了,但是到现在也忘了不少东西。内容主要来自《Objective-C 基础教程(第2版)》,这本书的代码可以到这里下载。

0x01 NSPredicate

其实当初第一次接触 NSPredicate 的时候,觉得不是很懂,也觉得有点难,也没去多了解一下,直到最近重新学基础时才发觉,其实 NSPredicate 挺好用的,也很好理解,以前用的最多的应该是正则吧,各种语言里面都是可以用的,就是需要记住的符号什么的好多,如果长时间不用的话,就会忘记,虽然这很正常。

基本上每个 App 都会涉及到数据的筛选过滤,也就是搜索,很少见到哪个 App 没有搜索功能。Cocoa 用 NSPredicate 描述查询的方式,原理类似于数据库查询,其实感觉语法上也有点相似呢。

0x02 创建谓词

我们这里还是使用书上的例子,不过不用担心,就算没有看过书上的例子代码也是 OK 的,不难理解。
下面是一个快速创建一辆汽车的 C 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Car *makeCar(NSString *name, NSString *make, NSString *model, int modelYear, int numberOfDoors, float mileage, int horsepower) {
Car *car = [[[Car alloc] init] autorelease];
car.name = name;
car.make = make;
car.model = model;
car.modelYear = modelYear;
car.numberOfDoors = numberOfDoors;
car.mileage = mileage;

Slant6 *engine = [[[Slant6 alloc] init] autorelease];
[engine setValue:[NSNumber numberWithInt:horsepower] forKey:@"horsepower"];
car.engine = engine;

// Make some tires.
// int i;
for (int i = 0; i < 4; i++) {
Tire * tire= [[[Tire alloc] init] autorelease];
[car setTire:tire atIndex:i];
}

return (car);
} // makeCar

可以使用这个 C 函数来创建一辆汽车:

1
2
Car *car = makeCar(@"Herbie", @"Honda", @"CRX", 1984, 2, 34000, 58);
[garage addCar:car];

上面代码创建一辆汽车,具体的汽车信息为:Herbie 品牌,型号为双门1984 Honda CRX,马力引擎为58,已行驶34000英里。

现在来创建谓词:

1
NSPredicate *predicate = [NSPredicate predicateWithFormat:@"name == 'Herbie'"];

我们使用 NSPredicate 的类方法 +predicateWithFormat: 创建了一个 NSPredicate 的对象,并传入了一个字符串参数,这个类方法使用该字符串在后台构建对象,来计算谓词的值,简单来说,就是以这个字符串来当筛选过滤的条件。

0x03 计算谓词

通过以上步骤,就创建好了一个谓词,现在我们可以使用它了:

1
2
BOOL match = [predicate evaluateWithObject:car];
NSLog(@"%s", (match) ? "YES" : "NO");

方法 -evaluateWithObject: 通知接收对象(即谓词)根据指定的对象计算自身的值。这里是以 name 作为键路径,使用 -valueForKeyPath: 方法来获取名称,然后将自身的这个名称(即 name)与 Herbie 进行比较,相同返回 YES,否则返回 NO。

以下是另外一个谓词:

1
2
3
predicate = [NSPredicate predicateWithFormat:@"engine.horsepower > 150"];
match = [predicate evaluateWithObject:car];
NSLog(@"%s", (match) ? "YES" : "NO");

这个谓词字符串的左侧是一个键路径,该键路径链接到 car 内部,查找 engine,然后再查找 engine 的 horsepower,然后将其与150进行比较,看它是否大于150。

以上都是通过特定的谓词条件检查单个对象,都很简单,但是在实际工作当中,我们通常都是从一堆的数据里面进行筛选的,比如,有一个车库,我们需要筛选出哪些汽车的马力大于150,普通的做法可能如下:

1
2
3
4
5
for (Car *car in [garage cars]) {
if ([predicate evaluateWithObject:car]) {
NSLog(@"%@", car.name);
}
}

使用 for 循环遍历每一辆汽车,然后检查其马力是否大于150,大于150的话,就输出汽车的名字。

0x04 数据过滤器

懒惰一直是编程人员的缺点,但是在某种意义上也是优点。如果不用编写这样的 for 循环和 if 语句,没什么不好的。而且 Cocoa 也提供了这样的方法:-filteredArrayUsingPredicate:,这个方法是 NSArray 数组中的一种类别方法,它会循环遍历数组中的对象,根据谓词来计算每个对象的值,如果是 YES,那么就将这个对象添加到将被返回的新数组中,感觉这个和上面的那个 for 循环类似吧?

1
2
NSArray *results = [cars filteredArrayUsingPredicate:predicate];
NSLog(@"%@", results);

这样的话,只需要一行代码就可以获取筛选之后的结果了,不过这里输出的是汽车的全部信息,而不是上面那样输出汽车的名字,不过我们可以使用 KVC(键值编码)来提取其中的名称:

1
2
NSArray *names = [results valueForKey:@"name"];
NSLog(@"%@", names);

上面的 cars 是不可变数组,如果我们的数据是存放在可变数组里面,而且我们需要剔除不满足条件的对象的话,可以使用 NSMutableArray 的 -filterUsingPredicate: 方法:

1
2
3
NSMutableArray *carsCopy = [cars mutableCopy];
[carsCopy filterUsingPredicate:predicate];
NSLog(@"%@", carsCopy);

因为 NSMutableArray 是 NSArray 的子类,所以也是可以用 -filteredArrayUsingPredicate: 方法来构建新的不可变数组的。使用谓词的确很便捷,但是它的运行速度并不会比自己编写全部代码要快,因为它无法避免要使用循环来遍历。

0x05 格式说明符

从方法 -predicateWithFormat: 可以看出来,传入的字符串是可以使用格式说明符的,比如:%d%f 等。而且一般筛选条件都不是硬编码到项目里面的,都是根据用户的输入来进行筛选的。

除了可以使用 printf 说明符,还可以使用 %@ 来插入字符串,而 %@ 会被当做带引号的字符串:

1
predicate = [NSPredicate predicateWithFormat:@"name == %@", @"Herbie"];

要注意,这里的格式字符串 %@ 并没有打单引号,如果打上了单引号,例如:"name == '%@'",字符 %@ 会被当做普通字符而失去了格式说明符的作用。

NSPredicate 字符串中也可以使用 %K 来指定键路径:

1
predicate = [NSPredicate predicateWithFormat:@"%K == %@", @"name", @"Herbie"];

为了构造灵活的谓词,一种方式是使用格式说明符,另一种方式是将变量名放入字符串中,类似于环境变量:

1
NSPredicate *predicateTemplate = [NSPredicate predicateWithFormat:@"name == $NAME"];

现在,我们有一个含有变量的谓词,可以使用 -predicateWithSubstitutionVariables: 来构造新的专用谓词,创建一个键/值对字典,其中键是变量名(不包含美元符号$),值是想要插入谓词的内容:

1
NSDictionary *varDict = [NSDictionary dictionaryWithObjectsAndKeys:@"Herbie", @"NAME", nil];

这里使用字符串 “Herbie” 作为键 “NAME” 的值,构造以下新谓词:

1
predicate = [predicateTemplate predicateWithSubstitutionVariables:varDict];

这个谓词跟之前的那些没什么不一样。

也可以用其他对象作为变量的值,例如 NSNumber,以下谓词用于过滤引擎的马力:

1
2
3
predicateTemplate = [NSPredicate predicateWithFormat:@:"engine.horsepower > $POWER"];
varDict = [NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithInt:150], @"POWER", nil];
predicate = [predicateTemplate predicateWithSubstitutionVariables:varDict];

除了使用 NSNumber 和 NSString 之外,也可以使用 [NSNull null] 来设置 nil 值,甚至可以使用数组。要注意的是,不能使用 "$变量名" 作为键路径,它只能表示值。使用谓词格式字符串时,如果想在程序中通过代码改变键路径,需要使用 %K 格式说明符。谓词机制不进行静态类型检查。

0x06 运算符

NSPredicate 的格式字符串包含大量不同的运算符。

比较和逻辑运算符

谓词字符串语法支持 C 语言中的一些常用运算符,不等号运算符如下表所示:

运算符 比较作用
> 大于某数
>= 和 => 大于或等于某数
< 小于某数
<= 和 =< 小于或等于某数
!= 和 <> 不等于某数

此外,谓词字符串语法还支持括号表达式、AND、OR 和 NOT 逻辑运算符,以及用 C 语言样式表示具有相同功能的 “&&”、”||” 和 “!” 符号。

我们可以筛选出马力在某个范围内的汽车:

1
2
3
predicate = [NSPredicate predicateWithFormat:@"(engine.horsepower > 50) AND (engine.horsepower < 200)"];
results = [cars filteredArrayUsingPredicate:predicate];
NSLog(@"oop %@", results);

上面是筛选出马力在50和200之间的汽车。

谓词字符串中的运算符不区分大小写,比如上面的 AND,可以随便写,比如:And、aNd等。

不等号既适用于数字值也适用于字符串值,比如按照字母表顺序查看所有名字排在 “Newton” 之前的汽车:

1
2
3
predicate = [NSPredicate predicateWithFormat:@"name < 'Newton'"];
results = [cars filteredArrayUsingPredicate:predicate];
NSLog(@"%@", [results valueForKey:@"name"]);

数组运算符

上面筛选马力在50到200之间的汽车使用的谓词字符串为 "(engine.horsepower > 50) AND (engine.horsepower < 200)",我们还可以写得更加简洁:

1
predicate = [NSPredicate predicateWithFormat:@"engine.horsepower BETWEEN { 50, 200 }"];

花括号表示数组,BETWEEN 将数组中的第一个元素看成数组的下限,第二个元素看成数组的上限。

可以使用 %@ 格式说明符向 NSArray 数组中插入对象:

1
2
NSArray *betweens = [NSArray arrayWithObjects:[NSNumber numberWithInt:50], [NSNumber numberWithInt:200], nil];
predicate = [NSPredicate predicateWithFormat:@"engine.horsepower BETWEEN %@", betweens];

也可以使用变量:

1
2
3
predicateTemplate = [NSPredicate predicateWithFormat:@"engine.horsepower BETWEEN $POWERS"];
varDict = [NSDictionary dictionaryWithObjectsAndKeys:betweens, @"POWERS", nil];
predicate = [predicateTemplate predicateWithSubstitutionVariables:varDict];

数组不仅可以用来指定某个区间的端点值,还可以配合 IN 运算符来查找数组中是否含有某个特定值,跟 SQL 很像哈:

1
2
3
predicate = [NSPredicate predicateWithFormat:@"name IN { 'Herbie', 'Snugs', 'Badger', 'Flap' }"];
results = [cars filteredArrayUsingPredicate:predicate];
NSLog(@"%@", [results valueForKey:@"name"]);

0x07 有 SELF 就足够了

有时候,我们可能需要将谓词应用于简单的值(例如纯文本的字符串),而不是那些通过键路径进行操作的复杂对象,比如我们需要从一汽车名称的数组中查询 name 时,就不能像之前那样子用了,这时候就轮到 SELF 出场了!

SELF 表示的是响应谓词计算的对象,事实上我们可以将谓词中所有的键路径表示成对应的 SELF 形式:

1
predicate = [NSPredicate predicateWithFormat:@"SELF.name IN { 'Herbie', 'Snugs', 'Badger', 'Flap' }"];

我们先获取所有的汽车名称,然后构造一个谓词,并计算该谓词的值:

1
2
3
4
names = [cars valueForKey:@"name"];
predicate = [NSPredicate predicateWithFormat:@"SELF IN { 'Herbie', 'Snugs', 'Badger', 'Flap' }"];
results = [names filteredArrayUsingPredicate:predicate];
NSLog(@"%@", results);

结果和前面的示例是一样的:Herbie 和 Badger。

这里提一个问题,以下代码会输出什么呢?

1
2
3
4
5
NSArray *names1 = [NSArray arrayWithObjects:@"Herbie", @"Badger", @"Judge", @"Elvis", nil];
NSArray *names2 = [NSArray arrayWithObjects:@"Judge", @"Paper Car", @"Badger", @"Phoenix", nil];
predicate = [NSPredicate predicateWithFormat:@"SELF IN %@", names1];
results = [names2 filteredArrayUsingPredicate:predicate];
NSLog(@"%@", results);

答案如下:

1
2
3
4
(
Judge,
Badger
)

对于取两个数组交集的运算来说,这种方式很巧妙。谓词包含了第一个数组的内容,因此和下面的形式类似:

1
SELF IN {"Herbie", "Badger", "Judge", "Elvis"}

现在,使用该谓词筛选第二个名称数组,在 name2 中如果有同时存在两个数组中的字符串,那么 SELF IN 会确定它是符合条件的,因此它就会保留在结果数组中,如果对象只存在于第二个数组中,那么它不会与谓词中的任何字符串匹配,所以该对象将被过滤掉,而只存在于第一个数组中的字符串因为要用来进行比较,所以将一直保留在原来的位置,不会出现在结果数组中。

0x08 字符串运算符

之前使用字符串时说到过关系运算符,此外,还有一些针对字符串的关系运算符:

运算符 意义
BEGINSWITH 检查某个字符串是否以另一个字符串开头
ENDSWITH 检查某个字符串是否以另一个字符串结尾
CONTAINS 检查某个字符串是否在另一个字符串内部

使用关系运算符可以执行一些有用的操作,例如使用 "name BEGINSWITH 'Bad'" 匹配 Badger,使用 "name ENDSWITH 'vis'" 匹配 Elvis,以及使用 "name CONTAINS udg" 匹配 Judge。

如果是 "name BEGINSWITH 'HERB'" 这样的呢?它不会与 Herbie 匹配,因为这些匹配是区分大小写的,同样,"name BEGINSWITH 'Hérb'" 也不会与之匹配,因为其中的 e 是含有重音符的。为了避免这些情况,可以为运算符添加 [c]、[d] 或 [cd] 修饰符,其中 c 表示 “不区分大小写”,d 表示 “不区分发音符号(diacritic insensitive,即忽略重音符)”,cd 表示 “即不区分大小写,也不区分发音符号”。通常情况下,都会使用这两个修饰符,除非有特殊需求需要区分大小写或者重音符号。

所以 Herbie 和 “name BEGINSWITH[cd] ‘HERB’” 相匹配。

0x09 LIKE 运算符

了解 SQL 的同学看到 LIKE 应该能猜到了,没错,有时候匹配开头或者结尾,又或者是否包含还不够,所以谓词格式字符串还提供了 LIKE 运算符,问号表示与一个字符匹配,型号表示与任意个字符匹配,也可以称为 “通配符”。

谓词字符串 “name LIKE ‘*er*‘“ 将会与任何含有 er 的名称相匹配,等效于 CONTAINS。

谓词字符串 “name LIKE ‘???er*’” 将会与 Pager Car 相匹配,因为其中的 er 前面有3个字符,后面也有一些字符。但是它与 Badger 不匹配,因为 Badger 的 er 前面有4个字符。

另外,LIKE 也接收 [cd] 修饰符,用户忽略大小写和发音符号。

如果你喜欢用正则表达式,可以使用 MATCHES 运算符,赋给它一个正则表达式,谓词将会计算出它的值。