欢迎访问我的博客首页。
泛型算法
标准库定义了超过 100 个对序列进行操作的泛型算法,具体见《C++ Primer 第 5 版》第 770 页 附录 A.2。序列可以是标准库容器类型中的元素、内置数组和 IO 流。但不建议是关联容器,原因见《C++ Primer 第 5 版》第 383 页《关联容器与算法》。
顺序容器的成员函数可以实现一些基本操作:添加删除、访问首位、查询容量等。为了实现查找、修改、删除、排序等经典操作,标准库定义了一些泛型算法。这些泛型算法支持多种容器类型和多种元素类型且实现了一些诸如排序、搜索之类的经典算法,因此称为泛型算法。
标准库定义的泛型算法操作的是迭代器而不是容器的成员函数。因此泛型算法只会修改或移动容器元素,而不会直接对容器进行添加、删除。泛型算法需要借助插入迭代器向容器添加元素。
1. 标准库泛型算法
标准库实现的算法大多定义在头文件 algorithm 中,numeric 中定义的是数值泛型算法。泛型算法定义的函数有 find、count、accumulate、equal、fill、fill_n、back_inserter、copy、replace、replace_copy、sort、unique、find_if、for_each、transform、partial_sum 等。
2. 定制操作
标准库定义的泛型算法 sort 默认从小到大排序,因为它默认使用元素类型的 < 运算符。如果要按其它方法排序,我们需要定制操作。
2.1 谓词与算法参数
C++ 中有四种可调用对象:函数、函数指针、重载了函数调用运算符的类、lambda 表达式。C++ 中的谓词是返回值为布尔类型的可调用对象。可调用对象有几个参数就称为几元谓词。标准库算法使用的谓词只能是一元谓词和二元谓词。
向泛型算法 sort 传递一个谓词参数即可按指定方式排序。下面我们利用标准库的泛型算法 partition,从一些单词中找出长度大于等于 5 的单词并输出:
#include <iostream>
#include <vector>
#include <algorithm>
bool check_size(const std::string& str) {
return str.length() >= 5;
}
void biggies() {
std::vector<std::string> words{
"the", "quick", "red", "fox", "jumps", "over", "the", "slow", "red", "turtle"};
std::vector<std::string>::iterator p =
std::partition(words.begin(), words.end(), check_size);
std::vector<std::string>::iterator it;
for (it = words.begin(); it != p; it++) {
std::cout << *it << " ";
}
std::cout << std::endl;
}
可以看出,我们传递给泛型算法 std::partition 一个一元谓词。
2.2 lambda 表达式
标准库算法使用的谓词只能是一元谓词和二元谓词,但有时需要向谓词传递更多参数。例如,我们把 5 硬编码到谓词函数 check_size 的函数体中,更好的方法是把 5 当作参数传进去,但泛型算法 std::partition 只接受一元谓词。使用 lambda 表达式可以向谓词传递更多参数。
C++11 引入 lambda 表达式,C++14 的 lambda 表达式支持泛型。一个 lambda 表达式具有如下形式:
lambda 表达式可以被理解为一个未命名的内联函数。与普通函数相似的是,它有一个返回类型、一个参数列表和一个函数体;与普通函数不同的是,它可以定义在函数内部且必须使用尾置返回类型。
int main() {
// [捕获列表] (参数列表) -> 返回值类型 {函数体};
// 1. 仅有必需的捕获列表和函数体。
int (*f1)() = [] { return 42; };
cout << f1() << endl; // 和普通函数调用方法相同。
// 2. 使用参数列表。
int (*f2)(int, int) = [](int a, int b) -> int { return a + b; };
cout << f2(1, 2) << endl;
// 3. 使用捕获列表。
int temp = 17;
auto f3 = [temp]() { return temp + 1; }; // 捕获 main 函数中的局部变量 temp。
cout << f3() << endl;
}
使用 lambda 表达式代替函数 check_size 做泛型算法 std::partition 的谓词:
void biggies(int sz) {
std::vector<std::string> words{
"the", "quick", "red", "fox", "jumps", "over", "the", "slow", "red", "turtle"};
std::vector<std::string>::iterator p =
std::partition(words.begin(), words.end(), [sz](const std::string &str) {
return str.length() >= sz;
});
std::vector<std::string>::iterator it;
for (it = words.begin(); it != p; it++) {
std::cout << *it << " ";
}
std::cout << std::endl;
}
可以看出,该 lambda 表达式的参数列表只有一个参数 str,但我们通过其捕获列表又传入一个参数 sz。
2.3 lambda 是函数对象
编译器会把我们编写的 lambda 表达式翻译成一个未命名类的未命名对象,该未命名类中含有一个重载的函数调用运算符。该未命名类的参数列表和函数体与我们编写的 lambda 表达式完全一样。
默认情况下,lambda 表达式不能改变它捕获的变量,因此,默认情况下由 lambda 翻译成的类当中的函数调用运算符是一个 const 成员函数。
class Check_size {
public:
Check_size(size_t n) {
sz = n;
}
bool operator()(const std::string &str) const {
return str.length() >= sz;
}
private:
size_t sz;
};
void biggies(int sz) {
std::vector<std::string> words{
"the", "quick", "red", "fox", "jumps", "over", "the", "slow", "red", "turtle"};
std::vector<std::string>::iterator p =
std::partition(words.begin(), words.end(), Check_size(sz));
std::vector<std::string>::iterator it;
for (it = words.begin(); it != p; it++) {
std::cout << *it << " ";
}
std::cout << std::endl;
}
通过值捕获的变量会被拷贝到 lambda 中,如上面的 sz。通过引用捕获的变量不会被拷贝到 lambda 中,而是直接使用。
lambda 表达式产生的类不含默认构造函数、赋值运算符及默认析构函数。是否含有默认的拷贝/移动构造函数则通常要视捕获的数据成员类型而定。
2.4 lambda 捕获
lambda 表达式可以定义在函数内部,其捕获列表是其所在函数中定义的局部变量的列表。
lambda 捕获有显式和隐式两种。显式捕获是指在捕获列表中显式地列出所有需要捕获的局部变量名;隐式捕获是指在捕获列表中仅写一个 = 或 & 符号,由编译器根据 lambda 的函数体推断出捕获列表。显式捕获和隐式捕获可以混用,它们在捕获列表中被逗号分隔且隐式捕获符号 = 或 & 必须在前面。混用时,显式捕获和隐式捕获必须一个是值捕获,另一个是引用捕获,不能使用相同的捕获方式。
使用值捕获时,编译器把 lambda 表达式翻译成的未命名类的函数调用运算符是一个 const 成员函数,因此捕获对象不能在 lambda 函数体内被修改。在 lambda 函数体内修改捕获对象需要使用 mutable 关键字。由于捕获对象被拷贝到了未命名类的对象中,因此在 lambda 函数体内修改捕获对象,lambda 所在函数的局部对象不会被修改。
void fun() {
int x = 7;
auto f = [x]() mutable {
x = 17;
};
f();
std::cout << x << std::endl; // 7
}
使用引用捕获时,捕获对象是 lambda 所在函数的局部对象的引用。这种捕获对象的值可以在 lambda 函数体中被修改,lambda 所在函数的局部对象也即被修改。
可以拷贝的变量才能使用值捕获。应该尽量使用值捕获,而避免捕获指针和引用。
2.5 参数绑定
在函数 biggies 中,我们为了向使用一元谓词的泛型算法 std::partition 传递两个参数,使用了 lambda 表达式。下面使用参数绑定实现同样的功能。
#include <algorithm>
#include <functional>
#include <iostream>
#include <vector>
bool check_size(const std::string &str, const size_t sz) {
return str.length() >= sz;
}
void biggies() {
std::vector<std::string> words{
"the", "quick", "red", "fox", "jumps", "over", "the", "slow", "red", "turtle"};
std::vector<std::string>::iterator p =
std::partition(words.begin(), words.end(), bind(check_size, std::placeholders::_1, 5));
std::vector<std::string>::iterator it;
for (it = words.begin(); it != p; it++) {
std::cout << *it << " ";
}
std::cout << std::endl;
}
在上面的程序中,我们使用标准库函数 bind 生成一个可调用对象。该可调用对象使用 std::placeholders::_1 接收泛型算法 std::partition 第三个参数获取到的参数,再和后面的常数 5 一起作为 check_size 的参数。由于 bind 绑定的参数只有一个 std::placeholders,所以 bind 返回的对象是一个一元谓词。
bind 的那些不是占位符的参数会被拷贝到 bind 返回的可调用对象中。有些参数不能被拷贝,或者我们就想绑定引用参数,这时可以使用标准库函数 ref。函数 ref 和 lambda、bind 一样返回一个对象,这个对象包含参数的引用。因为 ref 返回的对象是可拷贝的,所以可作为 bind 的参数。如果想要生成一个 const 引用,可以使用函数 cref。
3. 再探迭代器
除了为每个容器定义的迭代器之外,标准库在头文件 iterator 中还定义了额外几种迭代器:插入迭代器、流迭代器、反向迭代器、移动迭代器。
3.1 插入迭代器
插入迭代器有 front_inserter、back_inserter、inserter 三种。front_inserter 逆序插入,back_inserter 和 inserter 顺序插入。只有像 list 这样支持头部插入的容器才支持 front_inserter。
#include <iostream>
#include <list>
#include <vector>
int main() {
int arr[] = {1, 2, 3, 4, 5};
std::vector<int> vec1, vec2;
copy(std::begin(arr), std::end(arr), back_inserter(vec1)); // 1 2 3 4 5
copy(std::begin(arr), std::end(arr), inserter(vec2, vec2.begin())); // 1 2 3 4 5
std::list<int> list1, list2, list3;
auto it1 = front_inserter(list1);
auto it2 = back_inserter(list2);
auto it3 = inserter(list3, list3.begin());
for (int i = 1; i < 6; i++) {
it1 = i; // 5 4 3 2 1
it2 = i; // 1 2 3 4 5
it3 = i; // 1 2 3 4 5
}
}
3.2 iostream 迭代器
std::istream_iterator 是输入流迭代器,当遇到文件尾或 IO 错误时迭代器的值与尾后迭代器相等。出现 IO 错误的例子:迭代整型数据类型的输入流迭代器遇到字符等类型就会出现 IO 错误。
void f1() {
// 数字以空格或换行分割,输入非数字字符并换行以停止输入。
std::istream_iterator<int> it(std::cin);
std::istream_iterator<int> end;
// 方式 1。
//std::vector<int> vec1;
//while (it != end) {
// vec1.push_back(*it++);
//}
// 方式 2。
std::vector<int> vec2(it, end);
for (auto x : vec2) {
std::cout << x << " ";
}
std::cout << std::endl;
}
void f2() {
// 字符以空格或换行分割,使用 ctrl+c 停止输入。
//std::string str{ std::istream_iterator<char>(std::cin), {} };
std::string str((std::istream_iterator<char>(std::cin)), {});
std::cout << str << std::endl;
}
void f3() {
// 用一个字符串存放文件内容。
const char* path = "D:/1.txt";
std::ifstream is(path);
if (!is) {
std::cerr << "Failed to open " << path << "!" << std::endl;
return;
}
is.unsetf(std::ios::skipws); // 不忽略空白符(空格、换行)。
std::string str((std::istream_iterator<char>(is)), std::istream_iterator<char>());
std::cout << str << std::endl;
}
void f4() {
// 以空白字符为界,把文件内容转为字符串。
const char* path = "D:/1.txt";
std::ifstream is(path);
if (!is) {
std::cerr << "Failed to open " << path << "!" << std::endl;
return;
}
std::vector<std::string> vec{ std::istream_iterator<std::string>(is), std::istream_iterator<std::string>() };
for (auto x : vec) {
std::cout << x << std::endl;
}
}
std::ostream_iterator 是输出流迭代器下面是基本用法。
void f1() {
// 利用 ostream_iterator 输出到控制台。
std::vector<int> vec{ 1,2,3,4,5 };
std::ostream_iterator<int> it(std::cout, " ");
// 方式1。
for (auto x : vec) {
*it++ = x;
}
std::cout << std::endl;
// 方式2:忽略解引用和递增运算。
for (auto x : vec) {
it = x;
}
std::cout << std::endl;
// 方式3:也可以写成 it++。
std::copy(vec.begin(), vec.end(), it);
std::cout << std::endl;
}
void f2() {
// 利用 ostream_iterator 输出到文件。
std::vector<int> vec{ 1,2,3,4,5 };
std::ofstream os("D:/1.txt", std::ios::app);
std::ostream_iterator<int> it(os, " ");
std::copy(vec.begin(), vec.end(), it);
}
std::istream_iterator 处理类类型。假设文件中存放了一些数,我们把这些数看成二维坐标值,把它们存储到 std::vector<int> 中。迭代器从文件中读取整数,以空白字符作为整数的分割。遇到小数点、字母等数字以外的 IO 时停止迭代。
struct Point {
int x, y;
};
std::istream& operator>>(std::istream& is, Point& point) {
is >> point.x >> point.y;
return is;
}
int main() {
const char* path = "D:/1.txt";
std::ifstream is(path);
if (!is) {
std::cerr << "Failed to open " << path << "!" << std::endl;
return 0;
}
std::vector<Point> vec;
std::copy(std::istream_iterator<Point>(is), std::istream_iterator<Point>(), std::back_inserter(vec));
for (auto p : vec) {
std::cout << p.x << " " << p.y << std::endl;
}
}
std::ostream_iterator 处理类类型。逆操作,把 std::vector<Point> 中的坐标写入文件,x、y 坐标以空格分割,每个 Point 占一行。注意,输出流的第二个参数必须是 const 类型,否则出现编译错误。
struct Point {
int x, y;
};
std::ostream& operator<<(std::ostream& os, const Point& point) {
os << point.x << " " << point.y;
return os;
}
int main() {
const char* path = "D:/1.txt";
std::ofstream os(path, std::ios::app);
std::ostream_iterator<Point> it(os, "\n");
if (!os) {
std::cerr << "Failed to open " << path << "!" << std::endl;
return 0;
}
std::vector<Point> vec{ {1,2},{3,4},{5,6} };
std::copy(vec.begin(), vec.end(), it);
}
3.3 反向迭代器
除 forward_list 外的其它标准容器都支持递增运算和递减运算,因此这些容器都支持反向迭代器。流迭代器和 forward_list 一样不支持递减运算,因此流迭代器也不支持反向迭代器。
4. 泛型算法结构
4.1 五类迭代器
泛型算法的最基本特性是,它要求其迭代器提供哪些操作。按操作的不同,迭代器可以分为 5 类。
4.2 特定容器算法
标准库为 list 和 forward_list 定义了特定算法,使用这些特定算法操作 list 和 forward_list 要比通用算法更高效。
5. 参考
- lambda 表达式,牛客网。
6. 重载运算符
class Example {
public:
Example(int x) :a(x) {}
int operator+(Example& e) { return a + e.a; }
int operator+(int x) { return a + x; }
friend int operator+(int x, Example& e) { return x + e.a; }
int operator()(int x) { return a + x; }
friend istream& operator>>(istream& input, Example& e) { input >> e.a; return input; }
istream& operator>>(istream& input) { input >> a; return input; } // 使用成员函数重载双目运算符。使用时形参作为右操作数,不合习惯,如第23行。
// 前缀递增运算符
Example operator++() { a+=1; return *this; } // 没有返回值不能赋值:ex2 = ++ex1。
// 后缀递增运算符
Example operator++(int) { Example origin(*this); a+=1; return origin; } // 如果不需要赋值,++i 比 i++ 性能好,因为后者需要临时变量 origin。
private:
int a;
};
//int operator+(int x, Example& e) { return x + e.a; }
int main() {
Example a(4), b(7);
cout << a + b << endl; // 调用第4行。
cout << a + 1 << endl; // 调用第5行。
cout << 1 + a << endl; // 调用第6行。
cout << a()6 << endl; // 这样调用第7行是错误的。
cin >> a; // 调用第8行。
a >> cin; // 调用第9行。这种用法不符合习惯。
}
只有C++规定的运算符才可以重载。
重载运算符要遵循运算符本身的使用方法。比如重载函数调用运算符(),可以给任意个参数,调用时实参必须放在运算符内而不能像加运算符那样放在运算符右侧,这和函数调用一样。再比如重载加运算符+,成员重载函数必须有且只有1个形参且只能作为右操作数,非成员函数必须有2个形参分别作为左右操作数,因为+是双目运算符。
重载运算符函数是成员函数或非成员函数(包括友元)的区别:
- 成员函数:重载单目运算符如&、*、++时形参为空。用法同这些运算符本身的用法一样,如&a、a++。重载双目运算符时,形参有且只能有一个作为右操作数,调用时运算符左侧必须是对象作为左操作数。因此像1+a、cin>>a、cout<<a这样对象在操作符右侧的双目运算符重载函数,就不能用成员函数实现。
- 非成员函数(包括友元):形参是全部的操作数,使用灵活。
不使用友元,在类外面定义重载加号运算符的函数,如第14行,也可以代替第6行的友元函数。但是这样的形参不能访问非public成员。