C++提供了很多泛型算法,可以对各个容器使用,如sort对迭代器范围内的容器元素排序、unique把不重复的元素排列到容器前列去、copy复制范围内的容器元素、find寻找符合条件的容器元素等等。
在最基本的使用方法下,会调用默认的相关操作,比如sort会对容器内使用默认的排序方法,比如如果容器内是int型的话,就会比较大小,是string型的话,就会比较字符串内容字符的顺序等等。
但有时候我们希望自己来决定如何比较大小,或者更直观的,对于find_if算法,我们当然会想要自己决定寻找的条件是什么。
C++允许我们自己决定算法的操作方式,这就叫做定制操作。但是定制操作有一个限制。
通常我们提供给算法的自己定制的操作叫做“谓词”,该操作一般返回一个能作为条件的值,供算法使用。这个谓词最直观的表现形式就是你写的函数了。但是谓词对于其参数数量是有限制的,这取决于具体使用它的算法,但允许的参数数量只能使一个或者两个,相应的谓词也就叫“一元谓词”或“二元谓词”。
为什么一般只允许传递一到两个参数呢?这是因为算法就是对容器内元素做操作的,我们只用提供容器内要操作的范围,以及操作函数,至于如何调用,算法会自动帮我们完成,这就要求操作函数必须是正好按照算法的含义接受容器内的元素作为操作对象,比如sort算法,必定是比较容器内某两个元素,所以操作函数一定是个二元谓词,不能多不能少,而find_if算法,用来判断一个元素是否满足操作函数设定的条件,那操作函数一定是个一元谓词,一次只处理判断一个元素,因此这都必须限制好,否则无法正常工作。
明白了谓词的参数量限制后,举一个简单的例子,假设我们要将sort算法按照string的长度来排序,那么可以自己编写一个函数来改变sort算法的默认方式:
bool longer(std::string s1, std::string s2) {
return s1.size() >= s2.size();
}
vector<string> vec = {……};
sort(vec.begin(), vec.end(), longer);
如代码所示,sort算法只需要确定排序的范围,然后提供一个二元谓词即可,这里的二元谓词是longer函数。
再看一个一元谓词的例子:
bool longThan(std::string s) {
return s.size() >= 6;
}
vector<string> vec = {……};
find_if(vec.begin(), vec.end(), longThan);
该代码的目的是寻找容器内第一个字符串长度大于6的元素,由于find_if算法会对元素一个个判断,所以只能接受一元谓词,因此,这里的长度条件6是写死在函数中的。
但是,如果我们不想写死呢?毕竟这种操作很不“优雅”,应用起来很不灵活,但一元谓词的限制又摆在那里,只能传递一个参数,怎么办呢?
此时可以使用lambda表达式。lambda表达式可以看做一种特殊的函数,不像一般的函数一样需要单独写函数体,lambda表达式可以直接在函数体内声明,其内包括捕获列表、参数列表、返回类型、函数体,形式如下:
[捕获列表] (参数列表) -> 返回类型 { 函数体; }
如果是初识lambda表达式,除了对形式不太习惯外,可能最不好理解的就是捕获列表是个什么东西。其实捕获列表就是另一种形式的参数,总觉得这是在犯规,既然参数列表有限制,那就用捕获列表来获取想要的东西吧,这种做法不就是变着形式绕过限制么。。
总的来说,捕获列表内可以填写lambda表达式所在的上下文中的变量值,然后就可以在其函数体中使用了,和参数的区别不大,如下:
int sz = 6;
vector<string> vec = {……};
find_if(vec.begin(), vec.end(), [sz](const string &s)->bool { return s.size() >= sz; });
可以看到,代码中把原本放一元谓词的地方改成了lambda表达式,同时成功地传递了两个“参数”,一个是字符串,另一个就是自己定义的一个长度变量,然后通过捕获列表传递到函数体中使用。
另外,这种函数体只有一个return语句的,可以省略返回类型的说明(“->bool”部分),编译器可以自己推断出来,但如果有多条语句,那就必须写清楚返回类型了,否则编译器会设为void。如果没有捕获变量和参数,中括号和小括号内可以不写内容,但括号本身都不能省略。
要说捕获变量和参数有什么不同,就是对变量的操作方面了。首先,捕获变量会在声明lambda表达式(不是使用时,就是声明的时候)时复制捕获变量的值进去,此后你可以改变外在的捕获变量本身的值,都不影响lambda表达式函数体内的变量值,如果要传递的东西无法复制(比如cout),也可以使用引用的方式,但是要确保lambda表达式调用过程中该变量始终存在。
关于lambda表达式还有一些内容,比如隐式捕获、可变lambda等,不细讲了,本文主要是说明借用lambda表达式来突破算法中对谓词参数量的限制。
使用lambda虽然可以突破限制,不过对于需要频繁调用的操作,如果每次都要写一遍lambda表达式,既写起来麻烦,需要修改的时候也难保能全部改到,这时候函数的优势就体现出来了,一次编写,随时调用,且有修改需要的时候只需要改一个地方即可,而lambda可能更适合少量使用且操作简单的情况了。那有办法使用函数并且突破参数限制吗?有的,可以使用“参数绑定”,也就是bind函数。
说起来C++的开发者真的是有点缝缝补补的感觉,为了一些限制不得不创造出一些解决方法给大家使用。
bind函数其实原理就是在原本我们的操作函数之上再覆盖一层,包装成一个新的函数,然后在该包装过程中,可以把一些需要的额外的参数防止进去,同时留出空位给算法使用中要填充的容器元素,这样就可以减少参数数量了。说起来绕,直接看代码:
bool longer(std::string s, std::vector<std::string>::size_type sz) {
return s.size() >= sz;
}
int sz = 6;
vector<string> vec = {……};
auto count = count_if(vec.begin(), vec.end(), bind(longer, std::placeholders::_1, sz));
这里用到了count_if算法,该算法是计算容器指定范围内有多少符合特定条件的元素,我们的目的是计算有多少长度大于sz(6)的字符串。count_if一次只判断一个元素,因此只能给其配置一元谓词,但是我们的longer函数有两个参数,怎么办呢?
使用bind函数,将其包装成一个新函数,bind的第一个参数为要包装的函数名,后续可以接很多个参数,其中可以有很多上下文包含的变量,这些参数类似lambda表达式中的捕获变量,不会占谓词的参数数量,同时留出空位(placeholders)给容器元素,这些空位数量才是真正的占参数数量的。把代码写得更清楚的话如下:
auto bindLogner = bind(longer, std::placeholders::_1, sz);
auto count = count_if(vec.begin(), vec.end(), bindLogner);
这样就可以清楚的看到我们包装了一个新的函数bindLonger,该函数只接受一个参数,也就是预留的std::placeholders::_1,符合一元谓词的要求。
这里的_1(前缀表示所在的命名空间)其实有讲究,除了_1,当然还有_2、_3、_4等等,这里的1/2/3/4等表示使用bind时的第几个参数会映射到创建bind时对应的原始函数的参数位置,在上面的例子中会映射到第一个参数,来看个更复杂中的例子:
auto someCallable= bind(callable, a, _2, b, _1, c);
someCallable(_1, _2);
// 等价于
callable(a, _2, b, _1, c);
// 也就是说
someCallable(X, Y);
// 等价于
callable(a, Y, b, X, c);
使用bind后的新函数是someCallable,调用时需要两个参数,分别处于第一个和第二个的位置(X和Y)。实际上会映射到callable函数,其中X和Y分别对应定义bind时,其所约定的位置上,看代码应该可以理解的比较清楚。
需要注意的是bind如果想要使用参数的引用,而不是复制的话,不能简单的用&,而应该使用ref:
auto someCallable= bind(callable, ref(a), _2, b, _1, c);
这里的第一个参数a就是使用的引用。
以上就是两种突破泛型算法定制操作的方法,不知道为什么,总有点投机取巧的感觉,其实实质上是一样的,只是换了一种形式来传递“参数”,让函数体可以使用其值。