ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

C++过滤器模式实战:从if堆积到可组合的Criteria设计

2026/9/8 6:57:03 拓冰建站 浏览量
C++过滤器模式实战:从if堆积到可组合的Criteria设计 1. 过滤器模式是什么从一段让我头皮发麻的筛选代码说起先聊个真实场景。去年我在做一个商品管理系统的重构有个接口要根据前端传过来的条件组合筛选商品价格区间、类目、是否有货、是否上架、商家评分偶尔还要加个关键词模糊搜索。第一版我图省事直接在服务层写了一大串 if 判断大概长这样std::vectorProduct result; for (const auto p : products) { if (!minPrice.empty() p.price minPrice) continue; if (!maxPrice.empty() p.price maxPrice) continue; if (!category.empty() p.category ! category) continue; if (onlyInStock p.stock 0) continue; if (onlyOnSale !p.onSale) continue; // 后面还追了评分、关键词、地区、上架时间... result.push_back(p); }光看这个缩进和 continue 堆叠你可能已经感受到我的痛苦了。这还只是“能用”的状态真正的问题是每加一个筛选条件我就要往这个函数里塞一段 if每调整一次组合规则我就得小心翼翼改这坨代码生怕动了哪一行影响别的逻辑。等条件组合膨胀到六七种之后这个函数已经超过两百行测试用例写起来都想骂人。后来我翻设计模式资料看到过滤器模式Filter Pattern也叫标准模式Criteria Pattern突然就觉得之前那套做法蠢得离谱。这个名字其实挺直白——它把“筛选某类对象”的逻辑从业务代码里抽出来封装成独立的过滤器对象每个过滤器只负责一个判断标准多个过滤器之间可以像搭积木一样用逻辑运算连接起来形成任意复杂的筛选链。说白了过滤器模式的核心就两件事第一把 if 判断变成独立的“标准类”第二把这些标准类用 AND、OR、NOT 组合起来动态生成筛选条件而不需要修改原有对象或者原有筛选方法。它把“筛选动作”从“业务代码”里解耦出来让筛选规则的扩展不再动到主逻辑。这篇文章我不会只讲概念——过滤器模式在 C 里的实现细节、和 lambda 的区别、什么时候该用它什么时候该用标准库、面试里怎么答这些我都会用自己的实测经验讲清楚。无论你是刚学 C 想搞懂设计模式还是在真实项目里被筛选逻辑折磨过这篇都值得看完。2. 接口先行先定义一份能无限扩展的过滤契约过滤器模式的第一步不是写过滤器而是定义一个统一的过滤接口。这个接口是整个模式的基石它规定了“什么是能参与的过滤逻辑”后续所有的单一过滤器和组合过滤器都实现这套契约。2.1 最小但完整的 Criteria 接口先看代码template typename T class ICriteria { public: virtual ~ICriteria() default; virtual bool matches(const T item) const 0; };就这么短。一个虚析构、一个纯虚函数。matches接收一个待筛选对象返回该对象是否符合当前过滤条件。为什么用抽象基类而不是直接用std::functionbool(const T)我后面专门有一节讲你先记住这个接口设计。模板参数 T 让过滤器可以作用于任何类型——商品、日志、像素、游戏实体、网络包都行。有些实现会把这个接口里的对象类型写死比如就叫ProductFilter我的建议是尽量用模板代价几乎为零收益是过滤器可以在多个业务域复用。注意虚析构函数一定不能漏。只要这个类会被作为基类使用缺少虚析构就是未定义行为尤其是当shared_ptrICriteriaT去释放派生类对象的时候后果极其隐蔽程序可能在你完全想不到的地方崩溃。2.2 从最简单的单一过滤器开始实现接口有了接下来写具体的过滤器。我们延续商品场景设计几个单一职责的过滤器struct Product { int id; std::string name; double price; std::string category; int stock; bool onSale; double rating; }; class PriceRangeCriteria : public ICriteriaProduct { double minPrice_; double maxPrice_; public: PriceRangeCriteria(double minPrice, double maxPrice) : minPrice_(minPrice), maxPrice_(maxPrice) {} bool matches(const Product p) const override { return p.price minPrice_ p.price maxPrice_; } }; class CategoryCriteria : public ICriteriaProduct { std::string category_; public: explicit CategoryCriteria(std::string category) : category_(std::move(category)) {} bool matches(const Product p) const override { return p.category category_; } }; class InStockCriteria : public ICriteriaProduct { public: bool matches(const Product p) const override { return p.stock 0; } }; class OnSaleCriteria : public ICriteriaProduct { public: bool matches(const Product p) const override { return p.onSale; } };每个过滤器只回答一个问题这个商品在不在某个价格区间是不是某个类目有没有货每一个都是独立类可以单独测试也可以单独复用。这就是过滤器模式最本质的改进——筛选逻辑不再散落在业务函数里而是以类的形式拥有了身份、边界和可测试性。2.3 接入一个通用的过滤入口有了过滤器需要一个统一的入口来执行过滤template typename T std::vectorT filterItems(const std::vectorT items, const ICriteriaT criteria) { std::vectorT result; result.reserve(items.size()); for (const auto item : items) { if (criteria.matches(item)) { result.push_back(item); } } return result; }这个函数体里的逻辑就是把原来散落在业务代码里的 for 循环集中到了一处。注意我做了reserve(items.size())预分配避免过滤结果数量较大时反复扩容如果你明确知道筛选率很高比如大部分都会被过滤掉可以反过来不 reserve甚至用shrink_to_fit收一下这是性能上的细节取舍。回到开头的场景现在再来看调用方式auto cheapAndInStock filterItems(products, AndCriteriaProduct{} // 这个类下一节写 .add(std::make_sharedPriceRangeCriteria(0, 100)) .add(std::make_sharedInStockCriteria()));新增一个筛选维度你只需要新写一个 Criteria 子类主逻辑和已有过滤器一行都不用动。这就是开闭原则——对扩展开放对修改封闭——在代码层面的直观体现。3. 逻辑编排AND、OR、NOT 组合过滤器的完整实现单一过滤器解决的是“一个条件”的问题但真实业务里几乎没有只按一个条件筛选的。返回上一节那个商品筛选需求价格区间、类目、有货、上架、评分……它们是“同时满足”还是“任一满足”这些组合逻辑本身就是过滤器模式最精华的部分。3.1 把组合条件也当成过滤器组合模式的自然融合这里用到的思路其实和组合模式Composite Pattern是同一套——组合过滤器本身也实现ICriteria接口。所以对于调用方来说一个组合过滤器和一个单一过滤器没有区别你不需要关心传进来的到底是“一个类目条件”还是“一整套复杂的筛选规则”。先看最常用的 AND 组合过滤器template typename T class AndCriteria : public ICriteriaT { std::vectorstd::shared_ptrICriteriaT criteriaList_; public: AndCriteria() default; AndCriteria add(std::shared_ptrICriteriaT criteria) { criteriaList_.push_back(std::move(criteria)); return *this; } bool matches(const T item) const override { for (const auto c : criteriaList_) { if (!c-matches(item)) { return false; } } return true; } };matches的实现是短路求值只要有一个条件不满足直接返回 false不需要跑完所有条件。这里有个顺序优化的讲究——把最容易失败、计算成本最低的条件放在前面能大幅减少不必要的计算。比如商品过滤里onSale判断比字符串匹配便宜得多那就把它放在组合列表靠前的位置。OR 组合过滤器同理只要有一个条件满足就返回 truetemplate typename T class OrCriteria : public ICriteriaT { std::vectorstd::shared_ptrICriteriaT criteriaList_; public: OrCriteria() default; OrCriteria add(std::shared_ptrICriteriaT criteria) { criteriaList_.push_back(std::move(criteria)); return *this; } bool matches(const T item) const override { for (const auto c : criteriaList_) { if (c-matches(item)) { return true; } } return false; } };NOT 逻辑通常用一个取反过滤器实现包装一个底层过滤器template typename T class NotCriteria : public ICriteriaT { std::shared_ptrICriteriaT wrapped_; public: explicit NotCriteria(std::shared_ptrICriteriaT wrapped) : wrapped_(std::move(wrapped)) {} bool matches(const T item) const override { return !wrapped_-matches(item); } };3.2 链式调用的设计取舍我用的是add()返回引用实现链式调用让构造组合过滤器的代码读起来接近自然语言。你把它换成构造函数直接接收std::vector也可以但链式写法有两个好处调用处一眼能看懂条件的组合顺序不需要为了构建临时 vector 多写几行代码。这里有一个细节要提醒criteriaList_里面存的是什么。我用了std::shared_ptrICriteriaT而不是直接存对象。原因是多态的派生类对象大小不同不能直接放进标准容器存对象会有对象切片问题用裸指针也可以但要自己管理生命周期心智负担重。shared_ptr是这里的稳妥选择——因为一个过滤条件可能被多个组合过滤器复用不是独占所有权unique_ptr反而满足不了这个场景。举一个复用场景你有一个“优惠专区专用过滤器”它内部包含“价格低于 200”和“在促销活动中”两个条件。同时你还有一个“综合推荐”过滤器它希望复用“在促销活动中”这个条件。如果使用shared_ptr这个条件可以被两个组合过滤器安全地共同持有不需要复制、不需要担心谁先销毁谁后销毁。3.3 嵌套组合用递归结构表达任意复杂规则组合过滤器最大的威力在于AND、OR、NOT 本身也是ICriteria所以可以无限嵌套。比如现在需求是“价格在 0-100 之间或者类目是 图书并且有货并且评分不低于 4.5”代码是这样的auto complexRule AndCriteriaProduct{} .add(std::make_sharedOrCriteriaProduct() .add(std::make_sharedPriceRangeCriteria(0, 100)) .add(std::make_sharedCategoryCriteria(图书))) .add(std::make_sharedInStockCriteria()) .add(std::make_sharedRatingCriteria(4.5));这个表达式的结构就是一棵以逻辑运算符为节点、以具体过滤器为叶子的树。树的求值过程天然是递归的——AndCriteria::matches内部调用OrCriteria::matches再往下调用叶子过滤器的matches。所以无论规则有多复杂对最外层调用者来说看到的仍然只是一个ICriteria对象。我在实际项目中体会到这个递归结构最省心的地方是不要去硬编码“最多支持多少层嵌套”也别想着用位运算压缩条件直接让结构递归下去自然语言里能表达的逻辑它都能映射出来。不过嵌套层级太深时调试会比较痛苦我会在调试那一节分享一个打印过滤器树的小技巧。3.4 边界条件空组合过滤器怎么处理这块容易被人忽略但面试和代码评审里经常被问到如果AndCriteria的criteriaList_是空的matches应该返回什么从数学角度AND 的幺元是 true——空 AND 应该返回 true所以上面的实现里循环不执行返回 true这是对的。OR 的幺元是 false——空 OR 应该返回 false实现里循环不执行返回 false也对。你可以认为AndCriteria内部有一个“隐含条件的全集”条件一个不满足就拒绝OrCriteria内部有一个“隐含条件的空集”条件一个不满足就直接通过。这个语义保持一致性组合逻辑才不会出现让人困惑的边界 bug。4. 过滤器模式与 lambda 的正面交锋什么时候选择谁讲到这里有些读者应该已经憋不住了C 里明明有 lambda有std::copy_if有 ranges 库我凭什么要写这些类这个问题问得很对答案也没有那么绝对。我自己的经验是过滤器模式不是在和 lambda 打架而是在两个完全不同的层面解决问题。4.1 用 lambda 完成同样筛选有多简单先承认 lambda 的简洁性。同样实现“价格 0-100 且有货”lambda 版本的确更短std::vectorProduct result; std::copy_if(products.begin(), products.end(), std::back_inserter(result), [](const Product p) { return p.price 0 p.price 100 p.stock 0; });如果只需要做一次性筛选条件是临时的之后再也不复用那我绝对推荐 lambda std::copy_if。这是 C 的惯用法简洁、直观、性能还极高。4.2 那为什么还需要过滤器模式lambda 的问题不在“方不方便”而在“能不能被组合、复用、组合之后还能不能被管理”。我用一个实际的迭代过程来说明。第一阶段你有一个 lambdaauto discountFilter [](const Product p) { return p.discount 0; };第二阶段你想复用这个 lambda和另一个条件组合auto discountAndInStock [](const Product p) { return discountFilter(p) p.stock 0; // 捕获问题开始出现了 };你需要在这个 lambda 里捕获那个 lambda可读性已经下降。到了第三阶段需求要支持运行时动态拼接条件——用户在前端勾选“打折”“有货”“评分 4.5”后端要根据勾选状态动态决定筛选逻辑。lambda 这个时候就非常尴尬了你不能把一组 lambda 放进容器里像对象一样管理它们的类型可能不同你只能std::function包装一下而std::function本身又有类型擦除带来的内存分配和间接调用开销。第四阶段业务要求把一套筛选规则序列化进数据库、配置中心或者保存到文件里。lambda 没法序列化但过滤器对象可以——因为它的构造参数和数据是分离的你完全可以根据配置动态创建对应的 Criteria 子类。所以结论很清晰维度过滤器模式lambda 标准库一次性临时筛选太重不值得首选简洁高效条件需要被复用天然支持需要捕获复杂运行时动态组合组合过滤器直接拼很难灵活组合规则序列化/配置化可以根据配置创建过滤器无法序列化代码可读性类名即文档职责清晰短小精悍但复杂条件可读性差性能虚函数调用有一定开销通常可以内联性能更优日志和可视化可以遍历过滤器树很难在运行期查看 lambda 内容4.3 实际项目中两者怎么共存我在项目里的做法是“双轨制”一次性的、固定写死的筛选条件用 lambda std::copy_if简单粗暴且高效需要被多路复用、需要在运行时动态组装、需要被配置化驱动的筛选规则就走过滤器模式。还有一个折中做法让过滤器模式的matches内部可以封装 lambda。比如LambdaCriteriatemplate typename T class LambdaCriteria : public ICriteriaT { std::functionbool(const T) predicate_; public: explicit LambdaCriteria(std::functionbool(const T) predicate) : predicate_(std::move(predicate)) {} bool matches(const T item) const override { return predicate_(item); } };这样你既可以用顶层接口的统一性做组合和编排又能在具体节点上享受 lambda 的简洁。这个折中方案在我的项目里使用频率很高属于“取两者之长”的实用技巧。还有一个性能细节ICriteria::matches是虚函数每个过滤器一次虚调用组合层级深时会有多次间接跳转但现代 CPU 的分支预测和现代编译器的去虚化devirtualization通常能把这个开销压得很低。我在实际项目中测过几十万条数据跑十几层过滤器组合耗时差距和手写 if 相比在个位数百分比量级完全可接受。但如果你的场景是类似图像处理的逐像素过滤每个像素都要跑一遍过滤器树那虚函数开销就会被放大这种情况最好把条件内联掉不要直接套这个模式。5. 实际项目落地从日志系统到图像处理三种实战场景拆解过滤器模式不是挂在嘴边的概念真正用起来能解决很多实际麻烦。我在不同项目里至少见到过这三种典型落地场景全部是 C 开发中经常遇到的问题。5.1 场景一日志系统的多级动态过滤日志系统的过滤需求非常多按日志级别DEBUG/INFO/WARN/ERROR、按模块网络/存储/业务、按关键字、按线程 ID、按时间范围。传统写法是每个输出点写if (level WARN module storage ...)等日志模块一多到处散落这种判断改一个过滤策略要全局搜索替换。用过滤器模式我可以把日志过滤器定义成一组可组合的 Criteriaclass LogLevelCriteria : public ICriteriaLogEntry { LogLevel minLevel_; public: explicit LogLevelCriteria(LogLevel minLevel) : minLevel_(minLevel) {} bool matches(const LogEntry log) const override { return log.level minLevel_; } }; class ModuleCriteria : public ICriteriaLogEntry { std::string module_; public: explicit ModuleCriteria(std::string module) : module_(std::move(module)) {} bool matches(const LogEntry log) const override { return log.module module_; } };然后根据运行配置动态组装auto debugFilter AndCriteriaLogEntry{} .add(std::make_sharedLogLevelCriteria(LogLevel::DEBUG)) .add(std::make_sharedNotCriteriaLogEntry( std::make_sharedModuleCriteria(network)));这段配置的意思是“收集除 network 模块之外的所有 DEBUG 及以上级别日志”。每次排查线上问题时我只需要改这组装配代码底层的日志采集循环一行都不用动。5.2 场景二游戏开发中的实体筛选C 开发游戏时经常需要从一大堆实体角色、NPC、子弹、掉落物里筛选出符合某种条件的集合。比如AI 决策时要找“半径 50 米内、生命值低于 30%、没有被眩晕”的敌人碰撞检测时要找“当前帧激活、且有碰撞体积”的物体。这个场景用过滤器模式有个额外的好处过滤器可以单独写单元测试。你可以为“低血量敌人过滤器”直接构造一个 Enemy 对象并测试matches的返回值不需要真正搭建复杂的游戏场景。这在游戏逻辑越来越复杂、回归测试越来越难写的情况下价值非常大。class LowHealthCriteria : public ICriteriaEntity { double threshold_; public: explicit LowHealthCriteria(double threshold) : threshold_(threshold) {} bool matches(const Entity entity) const override { return entity.health threshold_; } }; class WithinRadiusCriteria : public ICriteriaEntity { const Entity center_; double radius_; public: WithinRadiusCriteria(const Entity center, double radius) : center_(center), radius_(radius) {} bool matches(const Entity entity) const override { double dx entity.x - center_.x; double dy entity.y - center_.y; return dx * dx dy * dy radius_ * radius_; } };AI 系统每帧调用一次组合过滤把结果喂给决策树。由于过滤器之间完全解耦加一个新的敌人类型或者新的筛选条件只是新增一个 Criteria 子类的事不会污染 AI 主循环。5.3 场景三图像处理与像素级筛选图像处理中也有过滤器模式的用武之地。比如基于 OpenCV 做颜色分割时要筛选出符合特定 RGB/HSV 范围的像素做棋盘格标定时要筛选出符合条件的角点候选集合。很多初学者会把这些筛选逻辑直接写在遍历像素的循环里导致循环体内塞满了各种判断。把判断条件抽成过滤器循环就变得异常干净class ColorRangeCriteria : public ICriteriaPixel { cv::Vec3b lower_; cv::Vec3b upper_; public: ColorRangeCriteria(const cv::Vec3b lower, const cv::Vec3b upper) : lower_(lower), upper_(upper) {} bool matches(const Pixel pixel) const override { return pixel.b lower_[0] pixel.b upper_[0] pixel.g lower_[1] pixel.g upper_[1] pixel.r lower_[2] pixel.r upper_[2]; } };然后std::vectorPixel targetPixels filterItems(allPixels, colorFilter);注意我在 5.3 开始前强调过的性能问题在这里再次出现图像动辄几十上百万像素如果过滤器的虚函数无法内联性能损失会被放大很多倍。在我实测中解决方案是让matches实现足够简单并开启链接时代码生成LTO让编译器有机会跨编译单元去虚化或者干脆在热循环外先把过滤器“扁平化”成一个 std::function 或直接内联 lambda。过滤器模式的价值在图像处理里主要体现在“代码组织的清晰度”性能优化上要从编译器和数据层面想办法。5.4 从以上场景提炼的设计原则我在多个项目里反复使用后总结出三条判断标准你套用的时候可以对照筛选条件是否会被多处复用如果同一个条件要在三四个地方重复判断就该给它一个类。组合关系是否可能在运行时变化如果筛选规则要由配置、前端参数、用户输入决定用组合过滤器是正道。筛选逻辑是否复杂到影响主流程阅读当一个循环内部的 if 超过 3 个每次读代码都要花时间还原业务逻辑就到了该重构的时候。6. 容易踩的坑与面试考点实战中的血泪经验最后这部分是干货中的干货。过滤器模式讲起来容易实际写代码的时候有几个坑我是踩过的面试也常被问到一起总结出来。6.1 坑一忘记虚析构的后果前面提过一次我这里再展开说。如果你写基类时漏了virtual ~ICriteria() default;当shared_ptrICriteriaT被析构时它调用的是基类的非虚析构函数派生类部分的资源完全不会被释放。如果你的派生过滤器里持有unique_ptr、std::vector等资源类还好因为派生类成员自己会析构这是成员函数而非虚函数调用的逻辑但如果你在派生类里手动new了东西或者以后有人往里面加了需要特定清理逻辑的资源内存泄漏就出现了。这种 bug 的可怕之处在于单测通常不会爆因为单个测试进程很快就结束了操作系统会回收全部内存一旦在正式环境跑起来泄漏累积几天内存占用量肉眼可见地上涨排查却非常困难。我在 code review 里看到新人写这种基类第一句就问“析构函数呢”。6.2 坑二把 shared_ptr 循环引用带进来组合过滤器持有子过滤器的shared_ptr如果某个子过滤器又反向持有它的shared_ptr就会形成循环引用导致内存泄漏。过滤器模式里常见的形式是一个过滤器内部保存了它所属的组合过滤器指针想用于“反向通知条件变更”之类的功能。这种反向关联我建议一律用weak_ptr或者裸指针观察者不要用shared_ptr。6.3 坑三忽略 const 限定和非多态返回matches必须声明为const因为它不应该修改被筛选对象也不应该修改过滤器自身的状态。如果某个过滤器内部确实有缓存或者需要计数把可变状态标记为mutable同时想清楚并发访问的问题。另外组合过滤器add()如果写成按值返回AndCriteria会返回一个新对象而不是引用链式调用就断了返回值要写成引用或指针类型这也是我前面add()返回AndCriteria的原因。6.4 坑四性能优化时不可盲目内联想用inline把虚函数强制内联是做不到的虚函数的动态分派本质上是运行期行为。要优化过滤器性能可以考虑四个方向开启编译器优化-O2 / -O3配合 LTO 让编译器跨翻译单元做去虚化热路径上把多个简单过滤器的判断手动合并即退化回手写 if这是最后手段把过滤器树预编译成某种更扁平的结构比如把条件装进std::vectorstd::functionbool(const T)按短路规则求值在AndCriteria::matches里对子条件按“最可能提前失败”的顺序排序利用短路求值减少无效计算。我实测过第 4 种方案和手写 if 的性能几乎无差别。前提是过滤器实现本身不重、不涉及堆分配。6.5 面试考点过滤器模式怎么答才加分C 面试里关于过滤器模式的问题经常以“手写一个商品筛选”的面貌出现考察点其实是三件事你是否理解多态、是否能用好组合、以及是否知道现代 C 的替代方案。我的建议是这样回答先给出接口和两三个具体过滤器然后实现AndCriteria/OrCriteria最后主动提一句“如果筛选条件不复杂直接用 lambda std::copy_if更简洁”再加一句“过滤器模式和组合模式天然搭配组合过滤器本身也是过滤器”。这几句话会直接把你和死记硬背设计模式的候选人区分开。还要能够说清楚过滤器模式和策略模式的区别策略模式侧重于“算法可以在运行时替换”过滤器模式侧重于“多个条件可以组合成复合条件”。两者都基于接口和多态但意图不同面试官经常把这两个放在一起问。6.6 调试过滤器树的一个实用小技巧组合过滤器嵌套层数多了之后调试时很难一眼看出“这个条件到底为什么没通过”。我写了一个简单的调试辅助函数给ICriteria加一个虚拟的describe()方法返回人类可读的条件描述字符串比如price in [0,100]、AND{category图书, stock0}。组合过滤器的describe()就递归调用子过滤器的describe()拼接成树状结构。排查问题时直接把整个过滤树打出来比对着代码猜快得多。6.7 什么时候不该用过滤器模式最后说点反共识的。过滤器模式不是银弹以下几种情况别硬套条件只有一两个而且固定不变直接用 if 或者 lambda别为了用模式而用模式筛选条件高度特化、只在一个地方出现一次写成一个类纯属多余性能极其敏感的逐元素热循环比如逐像素、逐顶点虚函数调用开销相对明显优先考虑数据导向写法筛选逻辑主要靠 SQL 完成的场景C 侧只需要把查询条件传给数据库不用在内存里筛。硬要在这里套过滤器模式只会增加理解和维护成本。我在第五个项目上曾经特别激进什么筛选都想用过滤器类包装结果代码文件数量暴增一个新同事接手时面对几十个小类完全懵了。后来我收敛了策略只在条件组合复杂、需要复用、需要动态配置的地方使用这个模式。平衡永远是工程里最重要的事。