尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

C++ STL函数对象与算法精解:从仿函数到高效编程实践

C++ STL函数对象与算法精解:从仿函数到高效编程实践 1. 从“可调用”到“可定制”理解STL中的函数对象在C的日常开发里尤其是和STLStandard Template Library打交道时我们经常听到“函数对象”或者“仿函数”这个词。很多初学者包括当年的我一开始都会有点懵明明有函数指针有C11之后好用的lambda表达式为什么还要搞出这么一个概念它到底解决了什么问题简单来说函数对象就是一个行为像函数的对象。更具体点它是一个类或结构体的实例这个类重载了函数调用运算符operator()。这听起来可能有点抽象我们直接看个最经典的例子std::less。当你写std::sort(vec.begin(), vec.end(), std::lessint())时你传给sort算法的第三个参数就是一个函数对象。std::lessint()创建了一个临时对象当sort内部需要比较两个元素时它会通过这个对象的operator()来执行比较比如less_obj(a, b)这看起来和调用一个普通函数less_func(a, b)一模一样。那么为什么不用普通函数或者函数指针呢函数对象的核心优势在于“状态”。一个函数对象可以拥有自己的数据成员这意味着它可以在多次调用之间保持和修改内部状态。比如你可以写一个计数器记录自己被调用了多少次或者写一个累加器在遍历过程中不断累加值。这是静态的普通函数做不到的。此外函数对象作为类可以拥有构造函数方便进行初始化配置编译器也更容易对函数对象的调用进行内联优化性能上可能比通过函数指针调用更有优势。在STL中函数对象主要分为两大类算术类、关系类和逻辑类的仿函数它们通常是无状态的用于执行基本操作以及谓词这是一个更重要的概念。谓词是返回bool类型的函数或函数对象它用于判断。根据接受参数的个数又分为一元谓词接受一个参数如std::find_if中的条件和二元谓词接受两个参数如std::sort中的比较规则。理解谓词是灵活运用STL算法的关键。2. 内建函数对象STL为你准备好的“瑞士军刀”STL在functional头文件中提供了一套现成的、模板化的函数对象我们称之为内建函数对象。它们都是类模板使用时需要指定模板参数类型。这些工具就像一套标准扳手在大多数情况下你不需要自己造轮子直接拿来用就行既安全又高效。2.1 算术仿函数封装基本运算除了加减乘除最容易被忽略但极其有用的是std::plus,std::minus,std::multiplies,std::divides,std::modulus取模以及取反操作std::negate。它们的价值在泛型编程和算法中尤为突出。一个经典的场景是使用std::transform算法对容器中所有元素进行统一运算。假设我们有一个vectorint想把所有元素都加10。用传统循环当然可以但用std::transform配合std::plus会更清晰且意图更明确。#include iostream #include vector #include algorithm #include functional int main() { std::vectorint vec {1, 2, 3, 4, 5}; std::vectorint result(vec.size()); // 使用 bind2ndC11前或 lambdaC11后来绑定第二个参数 // C11 之前的方式已弃用但需了解 // std::transform(vec.begin(), vec.end(), result.begin(), std::bind2nd(std::plusint(), 10)); // C11 及以后更优雅的方式lambda int add_value 10; std::transform(vec.begin(), vec.end(), result.begin(), [add_value](int x) { return x add_value; }); // 或者为了演示仿函数我们可以手动创建一个带状态的仿函数虽然这里杀鸡用牛刀 // 但更体现内建仿函数价值的是与 std::bind 配合见下文 for (auto num : result) { std::cout num ; // 输出11 12 13 14 15 } std::cout std::endl; return 0; }注意std::bind1st和std::bind2nd在 C11 中已被弃用在 C17 中移除。现代 C 应优先使用std::bind或 lambda 表达式。这里提到它们是为了理解历史背景和仿函数的应用场景。2.2 关系仿函数与逻辑仿函数构建判断逻辑关系仿函数如std::greater,std::less,std::greater_equal,std::less_equal,std::equal_to,std::not_equal_to它们定义了元素间的序关系。逻辑仿函数如std::logical_and,std::logical_or,std::logical_not用于组合布尔条件。关系仿函数最直接的应用是改变排序规则。默认情况下std::sort使用std::less即升序排序。若要降序排序直接传入std::greaterint()即可。std::vectorint vec {5, 3, 1, 4, 2}; // 升序排序 std::sort(vec.begin(), vec.end()); // 等价于 std::sort(vec.begin(), vec.end(), std::lessint()); // 降序排序 std::sort(vec.begin(), vec.end(), std::greaterint());逻辑仿函数的一个巧妙用法是在算法中组合多个谓词。例如我们想找到容器中所有大于2且小于5的元素。在C11之前没有lambda你需要自己写一个函数对象或者使用std::bind2nd和std::bind1st进行繁琐的组合。现在虽然用lambda更简单但了解其原理有助于理解functional的设计思想。理论上你可以用std::bind将std::logical_and与两个关系仿函数绑定在一起创建一个复杂的谓词但这通常不如一个lambda表达式直观。我个人的一个深刻体会是虽然lambda表达式如今几乎取代了手写简单函数对象的需求但内建函数对象的价值在于其“标准性”和“无状态性”。在编写模板库或高度泛化的代码时使用std::lessT作为默认比较器比要求用户提供一个自定义的lambda或函数指针更具通用性和可预测性。它保证了比较操作是严格弱序的这是很多STL算法如std::map的键正确工作的基础。所以它们更像是STL体系内部的“基石”而lambda是给用户使用的“快捷工具”。3. 谓词、内建函数对象与适配器构建复杂操作逻辑掌握了基本的函数对象后我们可以开始组合它们以应对更复杂的场景。这里的关键是“适配器”。函数对象适配器可以用来修改或组合已有的函数对象生成新的可调用对象。虽然std::bind1st/bind2nd已过时但 C11 引入的std::bind和std::function是更强大、更通用的工具它们与函数对象协同工作构成了现代C可调用对象生态的核心。3.1 使用std::bind进行参数绑定std::bind可以将一个可调用对象函数、函数指针、成员函数指针、函数对象与其部分或全部参数进行“绑定”生成一个新的可调用对象。这对于将多元函数适配成一元谓词特别有用。假设我们有一个检查数字是否在某个区间内的函数bool isInRange(int value, int low, int high)。我们想用std::find_if在容器中寻找第一个在 [10, 20] 区间内的数。如果不绑定参数isInRange需要三个参数不符合find_if要求的一元谓词。#include iostream #include vector #include algorithm #include functional bool isInRange(int value, int low, int high) { return value low value high; } int main() { std::vectorint vec {5, 15, 25, 12, 8}; using namespace std::placeholders; // 用于 _1, _2 等占位符 // 使用 std::bind 绑定 low 和 high 参数value 由算法传入占位符 _1 auto isIn10To20 std::bind(isInRange, _1, 10, 20); auto it std::find_if(vec.begin(), vec.end(), isIn10To20); if (it ! vec.end()) { std::cout Found first number in range: *it std::endl; // 输出 15 } // 同样可以绑定函数对象 auto isGreaterThan10 std::bind(std::greaterint(), _1, 10); it std::find_if(vec.begin(), vec.end(), isGreaterThan10); if (it ! vec.end()) { std::cout Found first number 10: *it std::endl; // 输出 15 } return 0; }_1是占位符表示新生成的可调用对象的第一个参数它将在find_if内部被容器元素替换。std::bind非常灵活可以绑定任意参数调整参数顺序甚至绑定成员函数。3.2 使用std::function实现类型擦除std::function是一个通用的、多态的函数包装器。它可以存储、复制和调用任何可调用对象——只要其签名符合要求。这解决了不同类型可调用对象函数指针、lambda、函数对象的统一存储和传递问题。例如你想实现一个回调机制允许用户注册不同的处理函数可能是自由函数、lambda或成员函数。使用std::function可以轻松实现。#include iostream #include vector #include functional class Button { public: using Callback std::functionvoid(); void onClick(Callback cb) { callback_ std::move(cb); } void click() { if (callback_) { callback_(); } } private: Callback callback_; }; void globalHandler() { std::cout Global function called.\n; } int main() { Button btn; // 绑定自由函数 btn.onClick(globalHandler); btn.click(); // 绑定lambda表达式 btn.onClick([]() { std::cout Lambda called.\n; }); btn.click(); // 绑定函数对象 struct Functor { void operator()() const { std::cout Functor called.\n; } }; btn.onClick(Functor{}); btn.click(); return 0; }这里有一个非常重要的实操心得当按值捕获大的对象或需要转移所有权时lambda表达式可能会产生不必要的拷贝。而std::function在构造时也会存储其目标的可调用对象。如果这个可调用对象很大比如捕获了一个大容器的lambda可能会影响性能。一个常见的优化是对于无状态的函数对象如没有捕获的lambda或普通函数指针std::function可能会使用小缓冲区优化SBO避免堆内存分配。但对于有状态的复杂对象则可能会在堆上分配内存。在性能敏感的代码中需要对此有所意识。4. STL标准算法精讲从“会用”到“懂为什么这么用”STL算法库主要位于algorithm和numeric是C标准库的瑰宝它提供了一套泛型的、作用于迭代器上的操作。理解算法的核心是理解“迭代器范畴”和“算法对谓词的要求”。4.1 非修改序列操作只读遍历与查找这类算法不改变容器内容包括find,find_if,count,count_if,all_of,any_of,none_of,for_each等。它们的共同特点是接受输入迭代器。std::findvsstd::find_iffind查找等于某个值的元素而find_if查找满足某个谓词条件的元素。选择哪一个取决于你的查找条件是否是简单的相等比较。std::for_each的现代意义在C11范围for循环出现后for_each的简单遍历功能被部分取代。但它仍然有价值尤其是在你需要将遍历操作本身作为一个可调用对象传递时或者当你需要显式指定迭代器范围而非整个容器时。此外for_each的返回值C11起是传入的函数对象本身这可以用来收集遍历过程中的状态。#include iostream #include vector #include algorithm int main() { std::vectorint vec {1, 2, 3, 4, 5}; int sum 0; // 使用 for_each 求和仅作示例更推荐 std::accumulate std::for_each(vec.begin(), vec.end(), [sum](int n) { sum n; }); std::cout Sum is: sum std::endl; // 一个更体现 for_each 价值的例子修改容器内对象的状态 struct Item { int id; bool processed false; }; std::vectorItem items {{1}, {2}, {3}}; std::for_each(items.begin(), items.end(), [](Item item) { item.processed true; }); // 此时所有 items 的 processed 字段都变为 true return 0; }std::all_of/any_of/none_of的妙用这组算法用于快速检查容器中元素是否全部、存在或不存在满足某条件的情况。它们比手写循环更清晰且具有短路求值特性一旦结果确定就停止遍历。例如检查一个vectorstring是否所有字符串都不为空。4.2 修改序列操作变换、复制与替换这类算法会修改源序列或目标序列的内容包括transform,copy,copy_if,replace,replace_if,fill,generate等。它们通常要求输出迭代器或前向迭代器。std::transform的双重形态这是最强大的修改算法之一。它有两种重载形式一元操作和二元操作。一元操作将一个输入范围变换到输出范围二元操作将两个输入范围的元素逐对结合输出到目标范围。它常与函数对象或lambda结合实现数据转换。#include iostream #include vector #include algorithm #include iterator // 用于 back_inserter int main() { std::vectorint src1 {1, 2, 3}; std::vectorint src2 {10, 20, 30}; std::vectorint dst; // 一元 transform: 将 src1 中每个元素加1 std::transform(src1.begin(), src1.end(), std::back_inserter(dst), [](int x) { return x 1; }); // dst: {2, 3, 4} dst.clear(); // 二元 transform: 将 src1 和 src2 对应元素相加 std::transform(src1.begin(), src1.end(), src2.begin(), std::back_inserter(dst), std::plusint()); // 使用内建函数对象 // dst: {11, 22, 33} for (int n : dst) std::cout n ; std::cout std::endl; return 0; }注意使用std::back_inserter可以避免目标容器dst初始大小不足的问题它会自动调用push_back。但这也意味着它只适用于有push_back方法的容器如vector,deque,list。std::copy与std::copy_ifcopy是简单的范围复制而copy_if则是带条件的复制它需要一个一元谓词来决定哪些元素需要被复制。这是从序列中“过滤”出所需元素的利器。一个常见的坑是迭代器失效问题。对于std::remove和std::remove_if算法务必理解它们并不真正删除元素它们只是把不满足“移除”条件的元素移动到范围的前部并返回一个指向新的逻辑结尾的迭代器。真正的删除需要结合容器的erase方法这就是著名的“erase-remove”惯用法。std::vectorint vec {1, 2, 3, 4, 5, 4, 3}; // 移除所有值为3的元素 auto new_end std::remove(vec.begin(), vec.end(), 3); // 此时 vec 内容可能变为 {1, 2, 4, 5, 4, ?, ?}new_end 指向第二个4之后的位置 vec.erase(new_end, vec.end()); // 这才是真正的删除 // vec: {1, 2, 4, 5, 4}4.3 排序、分区与二分查找有序世界的操作这类算法要求随机访问迭代器因为它们涉及到元素的比较和交换。核心算法包括sort,stable_sort,partial_sort,nth_element,partition,lower_bound,upper_bound,binary_search等。std::sort的不稳定性与std::stable_sort默认的sort是不稳定排序即相等元素的相对顺序在排序后可能会改变。如果需要保持相等元素的原始顺序应使用stable_sort但它的时间复杂度可能略高。std::partial_sort与std::nth_element当你只需要序列中前N个最小或最大的元素而不需要完全排序时partial_sort效率更高。nth_element则更特别它保证第n个位置的元素是正确的即其左边都不大于它右边都不小于它但两边的子序列是无序的。这在找中位数或第k大/小的元素时非常高效。二分查找家族lower_bound返回第一个不小于给定值的元素位置、upper_bound返回第一个大于给定值的元素位置、equal_range返回一个pair即[lower_bound, upper_bound)的范围、binary_search只返回是否存在。使用它们有一个绝对前提范围必须已经按照相同的比较准则排序好了否则行为未定义。这是新手最容易犯的错误之一。std::vectorint vec {10, 20, 20, 20, 30, 40}; // 必须先排序这里已排序 auto low std::lower_bound(vec.begin(), vec.end(), 20); // 指向第一个20 auto up std::upper_bound(vec.begin(), vec.end(), 20); // 指向30 std::cout Number of 20s: std::distance(low, up) std::endl; // 输出 3 // binary_search 只问存在与否 bool found std::binary_search(vec.begin(), vec.end(), 25); // false我个人的排序算法选择经验对普通vector或deque进行全排序默认用std::sort。如果需要保持相等元素的顺序用std::stable_sort。如果容器是list或forward_list使用其成员函数sort()因为标准算法std::sort要求随机访问迭代器。找Top K问题用std::partial_sort或std::nth_element后者通常更快。任何二分查找前心里默念三遍“范围有序了吗”。4.4 数值算法与杂项容易被忽略的实用工具numeric头文件提供了一些数值相关的算法如accumulate,inner_product,partial_sum,adjacent_difference。std::accumulate的通用性它不仅仅是求和。它的第三个参数是初始值第四个参数可选是一个二元操作函数对象。通过自定义这个操作你可以实现累乘、字符串连接、甚至是更复杂的归约操作。#include iostream #include vector #include numeric #include string int main() { std::vectorint vec {1, 2, 3, 4, 5}; // 求和 int sum std::accumulate(vec.begin(), vec.end(), 0); // 求积 int product std::accumulate(vec.begin(), vec.end(), 1, std::multipliesint()); // 字符串连接 std::vectorstd::string strs {Hello, , World}; std::string concat std::accumulate(strs.begin(), strs.end(), std::string()); std::cout Sum: sum , Product: product , Concat: concat std::endl; return 0; }std::inner_product计算两个序列的内积点积同样可以自定义“加法”和“乘法”操作使其用途远超数学上的内积。**杂项算法如std::max_element,std::min_element找最大最小元素位置std::lexicographical_compare字典序比较std::next_permutation生成下一个排列等在特定场景下非常有用。例如next_permutation可以用于解决全排列类的问题并且它非常高效。5. 算法组合与性能考量写出地道的STL代码单独使用算法只是第一步将多个算法和函数对象、迭代器适配器如back_inserter,front_inserter组合起来才能发挥STL真正的威力。这种风格被称为“STL风格”或“泛型编程风格”。5.1 管道式编程将算法串联起来想象一个需求从一个vectorint中找出所有偶数将它们加1然后复制到另一个list中最后逆序输出。用传统循环当然可以但用STL算法组合代码意图更清晰更像是在声明要做什么而不是怎么做。#include iostream #include vector #include list #include algorithm #include iterator int main() { std::vectorint src {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}; std::listint dst; // 1. 使用 copy_if 过滤出偶数 // 2. 使用 transform 对每个元素加1 // 3. 使用 back_inserter 插入到 list但list有push_front我们也可以选择 front_inserter 来直接逆序 // 更清晰的写法是分步但为了演示组合我们使用两步 std::vectorint temp; std::copy_if(src.begin(), src.end(), std::back_inserter(temp), [](int x) { return x % 2 0; }); // 过滤偶数 std::transform(temp.begin(), temp.end(), std::front_inserter(dst), [](int x) { return x 1; }); // 加1并前插实现逆序 // 输出 dst: 11 9 7 5 3 (因为 front_inserter 导致顺序是反的) for (int n : dst) std::cout n ; std::cout std::endl; // 另一种更“管道”的思路使用 C20 的 ranges 库会非常优雅但C17及之前需要分步或嵌套。 return 0; }虽然C20之前的版本无法实现真正的管道语法但通过将中间结果存储在临时容器中再传递给下一个算法逻辑依然是清晰的。C20的Ranges库引入了|操作符使得这种组合更加直观。5.2 理解算法复杂度与迭代器类别选择算法时必须对其时间复杂度有基本了解。例如std::find是 O(n) 线性查找。std::binary_search是 O(log n)但前提是范围已排序。std::sort平均复杂度为 O(n log n)。std::stable_sort复杂度也是 O(n log n)但在内存不足时可能退化为 O(n log^2 n)。迭代器类别决定了哪些算法可用。例如std::list提供双向迭代器所以不能用std::sort需要随机访问迭代器但可以用list::sort成员函数。std::find只需要输入迭代器因此它几乎可以用于所有容器甚至输入流通过istream_iterator。一个性能陷阱std::remove对std::list的低效性。std::remove算法通过移动元素来工作这对于vector或deque是高效的。但对于list移动元素可能涉及指针操作并不一定比直接使用list::remove成员函数高效。list::remove是专门为链表数据结构优化的它直接操作内部指针复杂度是 O(n)但常数项可能更优。同理list也有自己的sort,merge,unique等成员函数版本在操作链表时应优先考虑使用成员函数版本。5.3 自定义类型的算法应用STL算法不仅适用于内置类型更适用于自定义类型。关键在于为你的类型提供正确的比较或操作语义。这通常通过两种方式实现重载运算符例如为你的Person类重载运算符那么std::sort就可以直接对vectorPerson进行排序。提供自定义函数对象或lambda在调用算法时显式传入比较或操作函数。这种方式更灵活可以为同一类型定义多种不同的排序规则。#include algorithm #include vector #include string struct Person { std::string name; int age; }; // 方法1重载 运算符用于默认排序 bool operator(const Person a, const Person b) { return a.age b.age; // 按年龄排序 } int main() { std::vectorPerson people {{Alice, 30}, {Bob, 25}, {Charlie, 35}}; // 使用重载的运算符排序 std::sort(people.begin(), people.end()); // 方法2使用lambda按姓名排序 std::sort(people.begin(), people.end(), [](const Person a, const Person b) { return a.name b.name; }); // 使用 find_if 查找特定条件的人 auto it std::find_if(people.begin(), people.end(), [](const Person p) { return p.name Bob; }); if (it ! people.end()) { // 找到了 Bob } return 0; }对于包含动态内存或复杂资源管理的自定义类型要特别注意算法可能进行的“复制”或“移动”操作。确保你的类型满足“值语义”即拷贝构造函数和拷贝赋值运算符行为正确遵循三五法则或零法则。否则在std::sort这类涉及元素交换的算法中可能会导致资源泄漏或未定义行为。在现代C中优先考虑使用移动语义来提升这类操作在算法中的性能。
返回列表