过滤器模式这名字听起来像是个高大上的Golang中间件专属概念但实际上它最早被总结成一种通用设计模式时场景非常朴素给你一堆对象让你按照各种条件筛出一部分来。C里最典型的表现就是业务代码里四处都是if判断、循环里套条件、条件之间还要组合改一个筛选规则就要动好几个地方。这篇文章就是把C里的过滤器模式从原理到落地一次讲清楚适合正在学习设计模式的人也适合写业务逻辑写到想重构的老手。我会用完整的可运行代码、组合过滤器的设计思路、以及现代C下的演进写法让你看完就能直接在自己项目里用起来。1. 过滤器模式到底在解决什么问题1.1 业务场景里真实的“筛选地狱”先抛一个我实际维护过的代码片段。那时候做一个教务管理系统的查询功能需求很简单按性别筛选、按年级筛选、按成绩区间筛选、按班级筛选。第一版实现时我图省事直接在函数里写std::vectorStudent queryMaleGrade10(const std::vectorStudent students) { std::vectorStudent result; for (const auto stu : students) { if (stu.getGender() M stu.getGrade() 10) { result.push_back(stu); } } return result; }看起来没毛病对吧问题出在需求第二天就变了要“男同学或者成绩大于80的同学”。我又写了一个queryMaleOrHighScore。第三天要“高二年级里成绩大于80的女同学”第四天要“不是高一的同学”第五天要……两周后这个文件里堆了二十多个长这样而复制的函数每个函数里都是一模一样的循环骨架只是if条件换了换。更要命的是查询条件开始组合A且B、A或B、非A。你很快就会发现最朴素的“写死”思路根本扛不住条件组合的爆炸。这不是代码风格问题是设计问题。筛选逻辑散落在各个业务函数里条件与条件之间的关系隐式藏在if语句中复用和组合都没有依托。这时候就需要把“怎么筛”这件独立的事从“筛谁”“筛完干嘛”里彻底抽出来。1.2 过滤器模式的核心思想过滤器模式Filter Pattern也叫标准模式Criteria Pattern它的核心做法特别简单把每一个筛选条件封装成一个独立对象这些对象统一实现同一个判定接口然后通过逻辑组合与、或、非构建出任意复杂的条件树最后用一个统一的容器遍历函数去批量判定。你可以把每个过滤器当成一个筛子。单条件过滤器就是一个网眼的筛子组合过滤器就是好几个筛子串在一起或者并排放。业务方只需要告诉他“我要什么”组合逻辑自己去拼遍历和筛选的骨架只写一遍。这个模式解决的关键痛点就三个筛选条件独立变化。加一个条件只需要新增一个过滤器类不需要改动已有逻辑。条件可以任意组合。AND、OR、NOT都是对象嵌套使用就能构造任意复杂规则。筛选骨架与筛选规则解耦。遍历集合的代码只写一次后续只换过滤器。它特别适合那些判定规则多、需要反复组合、规则本身要复用的场景。反过来如果项目里就那么两三个固定条件这辈子都不变那真没必要硬套模式老老实实写if反而更清晰。这一点后面我会在最后一部分专门展开。2. 从零搭建过滤器模式一份能直接跑的示例2.1 领域对象与基础接口设计先说领域对象。我用学生信息来举例因为足够直观#include algorithm #include iostream #include memory #include string #include vector class Student { public: Student(std::string name, char gender, int grade, double score, std::string className) : name_(std::move(name)), gender_(gender), grade_(grade), score_(score), className_(std::move(className)) {} const std::string getName() const { return name_; } char getGender() const { return gender_; } int getGrade() const { return grade_; } double getScore() const { return score_; } const std::string getClassName() const { return className_; } std::string toString() const { std::string genderStr (gender_ M) ? 男 : 女; return name_ genderStr 年级 std::to_string(grade_) 班: className_ 成绩 std::to_string(score_); } private: std::string name_; char gender_; int grade_; double score_; std::string className_; };领域对象尽量做成不可变风格所有数据通过构造函数一次性设置提供const访问器。这是过滤器模式能顺畅跑起来的一个隐性前提因为筛选过程只读对象、不修改对象所以永远不会出现“筛选着筛选着对象状态变了”这种灵异事件。然后是过滤器接口。每个过滤器都是一个可判定的规则对象class Criteria { public: virtual ~Criteria() default; virtual bool matches(const Student stu) const 0; };这里有两件事必须提醒刚入门的读者。第一基类析构函数必须是虚函数否则你通过基类指针删除派生类对象时行为是未定义的。第二matches必须是const成员函数因为筛选动作不应该改变过滤器自身的状态这也是逻辑正确性的保障。2.2 单条件过滤器的实现单条件过滤器就是把一个具体的判定规则写成一个类。比如男生、某个年级、成绩不低于某个阈值class MaleCriteria : public Criteria { public: bool matches(const Student stu) const override { return stu.getGender() M; } }; class GradeCriteria : public Criteria { public: explicit GradeCriteria(int grade) : grade_(grade) {} bool matches(const Student stu) const override { return stu.getGrade() grade_; } private: int grade_; }; class ScoreAboveCriteria : public Criteria { public: explicit ScoreAboveCriteria(double threshold) : threshold_(threshold) {} bool matches(const Student stu) const override { return stu.getScore() threshold_; } private: double threshold_; };注意GradeCriteria和ScoreAboveCriteria的构造函数都带了参数这个参数就是筛选阈值。这么做的好处在于同一个过滤器类可以配置不同参数复用例如一个ScoreAboveCriteria(80)和一个ScoreAboveCriteria(90)是两个不同的过滤器实例但它们的类型和逻辑只有一份。然后写一个统一的筛选函数这个函数就是那个只写一遍的骨架std::vectorStudent filterStudents(const std::vectorStudent students, const Criteria criteria) { std::vectorStudent result; result.reserve(students.size()); for (const auto stu : students) { if (criteria.matches(stu)) { result.push_back(stu); } } return result; }这里我用result.reserve(students.size())做了一次预分配避免大量push_back触发频繁扩容。严格来说如果筛选结果很小这种预分配有点浪费但在大多数业务场景下用一点内存换取vector扩容的性能稳定性是划算的。2.3 组合过滤器AND、OR、NOT的配合单条件过滤器很简单真正的威力来自组合。组合过滤器本身也是一个过滤器它内部持有两个子过滤器判定时把两个子过滤器的结果做逻辑运算class AndCriteria : public Criteria { public: AndCriteria(std::shared_ptrCriteria left, std::shared_ptrCriteria right) : left_(std::move(left)), right_(std::move(right)) {} bool matches(const Student stu) const override { return left_-matches(stu) right_-matches(stu); } private: std::shared_ptrCriteria left_; std::shared_ptrCriteria right_; }; class OrCriteria : public Criteria { public: OrCriteria(std::shared_ptrCriteria left, std::shared_ptrCriteria right) : left_(std::move(left)), right_(std::move(right)) {} bool matches(const Student stu) const override { return left_-matches(stu) || right_-matches(stu); } private: std::shared_ptrCriteria left_; std::shared_ptrCriteria right_; };对于NOT可以实现成单参数过滤器class NotCriteria : public Criteria { public: explicit NotCriteria(std::shared_ptrCriteria inner) : inner_(std::move(inner)) {} bool matches(const Student stu) const override { return !inner_-matches(stu); } private: std::shared_ptrCriteria inner_; };为什么用std::shared_ptr而不是std::unique_ptr原因很实际组合过滤器经常出现同一个子过滤器同时被多个父过滤器引用的场景比如一个“男生”过滤器既被用在“男生且成绩好”里又被用在“男生或高二”里如果用unique_ptr就得把那个子过滤器移动走移动完它就消失了别的组合就引用不了。shared_ptr的引用计数语义天然符合“规则复用”的需求。当然如果你确保每个子过滤器只被引用一次用unique_ptr也无妨只是维护阶段容易踩坑。有了这三个组合过滤器任意复杂条件都能构建。例如“高二年级中成绩不低于80的学生”就是auto and_ std::make_sharedAndCriteria( std::make_sharedGradeCriteria(11), std::make_sharedScoreAboveCriteria(80.0) );嵌套下去“男生或高二且不是高一”这样的条件也能用组合过滤器一层层套出来。整个条件树的结构其实就对应着你在需求文档里写的那句话。3. 实操过程从需求到落地完整走一遍3.1 一个完整的需求场景光讲类结构没什么意思我拿一个会出现在真实需求文档里的查询要求完整过一遍。假设学生数据如下小明男高三年级12艺术班成绩82小红女高三艺术班成绩91小刚男高二年级11理科班成绩75小芳女高二理科班成绩88小强男高一年级10文科班成绩95小雪女高一文科班成绩60需求一找出“高二年级中成绩不低于80”的同学。 需求二找出“男生或者高一且成绩大于90”的同学。 需求三找出“不是高三且成绩在及格线以上”的同学。三个需求里需求二就包含了嵌套逻辑外层是OR右侧的AND里面还套了一个成绩条件。这种场景正是过滤器模式的用武之地。3.2 完整代码与运行结果把前面所有类合在一起写一个完整可编译的mainint main() { std::vectorStudent students { Student(小明, M, 12, 82, ART1), Student(小红, F, 12, 91, ART2), Student(小刚, M, 11, 75, SCI1), Student(小芳, F, 11, 88, SCI2), Student(小强, M, 10, 95, LIB1), Student(小雪, F, 10, 60, LIB2) }; auto male std::make_sharedMaleCriteria(); auto grade11 std::make_sharedGradeCriteria(11); auto grade12 std::make_sharedGradeCriteria(12); auto grade10 std::make_sharedGradeCriteria(10); auto score80 std::make_sharedScoreAboveCriteria(80.0); auto score90 std::make_sharedScoreAboveCriteria(90.0); auto score60 std::make_sharedScoreAboveCriteria(60.0); // 需求一高二且成绩不低于80 auto condition1 std::make_sharedAndCriteria(grade11, score80); std::cout 高二且成绩80 std::endl; for (const auto stu : filterStudents(students, *condition1)) { std::cout stu.toString() std::endl; } // 需求二男生 或 (高一且成绩90) auto condA std::make_sharedAndCriteria(grade10, score90); auto condition2 std::make_sharedOrCriteria(male, condA); std::cout 男生 或 (高一且成绩90) std::endl; for (const auto stu : filterStudents(students, *condition2)) { std::cout stu.toString() std::endl; } // 需求三不是高三 且 成绩60 auto notG12 std::make_sharedNotCriteria(grade12); auto condition3 std::make_sharedAndCriteria(notG12, score60); std::cout 非高三 且 成绩60 std::endl; for (const auto stu : filterStudents(students, *condition3)) { std::cout stu.toString() std::endl; } return 0; }运行结果如下 高二且成绩80 小芳 女 年级11 班:SCI2 成绩88.000000 男生 或 (高一且成绩90) 小明 男 年级12 班:ART1 成绩82.000000 小刚 男 年级11 班:SCI1 成绩75.000000 小强 男 年级10 班:LIB1 成绩95.000000 非高三 且 成绩60 小刚 男 年级11 班:SCI1 成绩75.000000 小芳 女 年级11 班:SCI2 成绩88.000000 小强 男 年级10 班:LIB1 成绩95.000000 小雪 女 年级10 班:LIB2 成绩60.000000你可以看到对于这三个差异很大的查询逻辑完全可以像搭积木一样拼装出来。新增查询条件不需要新增查询函数只需要新增过滤器类或调整组合方式。3.3 模式落地的三个设计决策在实际项目中有几个设计细节当时纠结了我一段时间也建议你先想清楚再动手。第一个是筛选函数的返回值设计。上面我按值返回std::vectorStudent对现代C来说按值返回配合移动语义和NRVO具名返回值优化实际拷贝开销通常比想象中小得多。如果你还要进一步减少拷贝可以改成模板函数接收一个输出迭代器template typename OutputIt void filterStudents(const std::vectorStudent students, const Criteria criteria, OutputIt out) { for (const auto stu : students) { if (criteria.matches(stu)) { *out stu; } } }这种方式性能最好但可读性会差一些。我个人的取舍标准是数据量在十万以下、过滤次数不频繁的直接按值返回高频调用的大集合过滤才考虑输出迭代器或者惰性求值。第二个是接口粒度。单条件过滤器最好遵循“一个类只封装一个独立判定维度”。ScoreAboveCriteria只负责“成绩是否高于某个阈值”不要顺手在里面加“并且年级是10”。一旦你开始把多个条件写进同一个过滤器组合过滤器的灵活性就被破坏了。这跟单一职责原则其实是同一件事在不同场景下的体现。第三个是所有权模型。前面说了组合过滤器之间会共享子过滤器因此我用shared_ptr。但要注意shared_ptr的循环引用问题在这里不会出现因为子过滤器不会反向指向父过滤器引用关系是严格单向有向无环的不存在循环引用导致的内存泄漏。4. 现代C下过滤器模式的演进4.1 用std::function和lambda重构经典的过滤器模式用继承和多态实现可读性强但每个条件都要定义一个类写起来啰嗦。C11之后有一种轻量得多的玩法把过滤器定义成std::functionbool(const Student)用lambda直接表达条件。using Predicate std::functionbool(const Student); Predicate male [](const Student s) { return s.getGender() M; }; Predicate highScore [](const Student s) { return s.getScore() 80.0; }; // 组合 Predicate maleAndHigh [](const Student s) { return male(s) highScore(s); };这样写当然简洁但组合逻辑还是手工塞进lambda里的不够优雅。可以重载运算符把逻辑组合变成一种更直白的写法Predicate operator(const Predicate a, const Predicate b) { return [a, b](const Student s) { return a(s) b(s); }; } Predicate operator||(const Predicate a, const Predicate b) { return [a, b](const Student s) { return a(s) || b(s); }; } Predicate operator!(const Predicate a) { return [a](const Student s) { return !a(s); }; }用了运算符重载之后业务表达就非常接近自然语言了Predicate condition male (grade10 || highScore);这个方案最大的好处是灵活、开发速度快坏处是调试的时候你看不到“过滤器对象”的类名看到的只是std::function和lambda闭包。一旦组合层级特别深排查条件哪里多了一个非、少了一个括号会比较痛苦。我的建议是中大型业务系统经典类式设计和函数式方案可以混用简单条件用lambda复杂、需要复用的规则用类。4.2 标准库算法的高阶抽象用std::function定义过滤器之后筛选骨架也能更进一步直接使用标准库的算法。std::vectorStudent filtered; std::copy_if(students.begin(), students.end(), std::back_inserter(filtered), [](const Student s) { return s.getScore() 80.0; });std::copy_if的第四个参数是一个谓词这本质上就是一个过滤器。配合std::back_inserter输出迭代器整个筛选过程一行搞定。如果不仅要筛选还要在源容器上做“移除不符合项”操作可以考虑std::erase_ifC20收编了std::erase_if配合vector等容器使用std::erase_if(students, [](const Student s) { return s.getScore() 60.0; });这会直接移除成绩低于60的学生原地操作不需要额外分配新容器。4.3 C20 ranges的极致简化C20的ranges库把这个模式推向了另一个境界。std::views::filter是一个范围适配器它可以像流水线一样串联多个过滤步骤并且是惰性求值的——只有在真正遍历结果时才逐个计算。#include ranges namespace views std::views; auto result students | views::filter([](const Student s) { return s.getGender() M; }) | views::filter([](const Student s) { return s.getScore() 80.0; }); for (const auto stu : result) { std::cout stu.toString() std::endl; }每个views::filter都像一个过滤器节点多个节点用管道符串联起来。这正是过滤器模式“独立条件、逐层组合”思想的现代形态。由于ranges是惰性的如果结果集只取前几个值后续的过滤不会被计算大规模数据下性能优势明显。不过ranges的filter在组合复杂逻辑时并不适合把AND条件拆成两个节点因为拆成两个节点会产生两次独立的过滤循环虽然最终结果一样但语义上“且”的条件放在同一个谓词里更准确// 推荐 auto result students | views::filter([](const Student s) { return s.getGender() M s.getScore() 80.0; });所以我的经验是现代C下如果只是简单筛选直接用ranges或copy_if如果条件要复用、要组合、要在多个场景共享还是回到过滤器模式用类或者std::function管理它。Criteria接口本身并不一定非要用继承实现不可重要的是“把规则做成独立可组合的表达式”这一思想。5. 常见问题与排查心得5.1 常见陷阱速查表过滤器模式核心代码看着简单实际用起来有个隐藏风险点是所有人都躲不开的使用多态时稍不留神就触发对象切片。用vectorCriteria装派生类而不是用vectorshared_ptrCriteria或引用派生类对象的特有成员会被切掉matches会调用到基类版本甚至直接报错。我把这些年实际踩过的坑整理成了一张表方便你出问题时快速对照问题原因解决方案筛选结果莫名空阈值边界用错例如条件写80但数据恰好是80统一约定开闭区间建议条件都写成或并在单元测试里覆盖边界值删除元素时迭代器失效崩溃在遍历vector时直接erase先通过过滤器收集需要保留的元素生成新容器再用std::erase_if原地清理通过vectorCriteria存储派生对象对象切片多态失效用vectorshared_ptrCriteria或vectorunique_ptrCriteria存储删除过滤器对象时崩溃基类析构函数不是虚函数给Criteria基类加virtual ~Criteria() default;组合条件逻辑颠倒对AND/OR的嵌套结构理解偏差先在纸面上写出布尔表达式再对照构建组合过滤器必要时给组合类加打印lambda捕获了悬空引用用[]捕获局部临时变量结束生命周期后还调用使用按值捕获[]或显式捕获需要的变量过滤逻辑无法调试看不到哪层条件失败给过滤器加描述字段失败时打印完整条件链这些坑里对象切片和虚析构缺失是新手最容易踩的而且一旦触发就是未定义行为表现很随机——有的版本能跑换一个优化级别就崩。排查手段也很直接只要代码里出现了把派生类对象按基类值拷贝的写法立刻改成指针或引用。5.2 可调试性设计让过滤条件“看得见”多态过滤器最大的缺点之一是不容易调试。当你面对一个五层嵌套的组合筛选结果是空的你能看到的是matches返回了false但不知道是哪一层条件让它返回了false。这个问题的常规解法是给过滤器增加描述信息。class Criteria { public: virtual ~Criteria() default; virtual bool matches(const Student stu) const 0; virtual std::string describe() const 0; };单条件过滤器实现描述很简单组合过滤器可以把两个子条件的描述拼起来std::string AndCriteria::describe() const override { return ( left_-describe() AND right_-describe() ); }然后提供一个检查函数如果结果是空就把条件树打印出来void debugFilter(const std::vectorStudent students, const Criteria criteria) { std::cout 条件: criteria.describe() std::endl; size_t count 0; for (const auto stu : students) { if (!criteria.matches(stu)) { std::cout 未通过: stu.toString() std::endl; } else { count; } } std::cout 通过数: count std::endl; }这个能力我在生产环境救过不止一次。条件组合多了以后人脑很难直接推算布尔表达式的真值表把条件树显式打印出来问题一眼就看到了。5.3 性能取舍内存拷贝与移动语义过滤器模式落地后一次筛选通常涉及两处性能开销遍历时对集合元素的访问以及结果容器的构建。对于元素访问const Student是最安全的不要为了炫技用const auto stu产生无谓拷贝。对于结果容器按值返回时依赖RVO和移动语义现代编译器的优化效果已经很好。但有一种高频调用场景需要注意如果一次筛选要在同样的条件树里反复使用不要把过滤器对象放到函数内部创建的局部变量里应该把它提升到外层比如类成员或函数的参数避免每次调用都创建一个新的过滤器对象。另一个更深的取舍是基于继承的shared_ptr过滤器对象在matches调用时会有一次虚函数间接跳转。在千万级数据的循环里这个开销会被放大。真遇到这种极致性能需求可以用C20 ranges的惰性求值方案它可以在编译期内联谓词逻辑没有虚函数跳转还支持链式组合。6. 过滤器模式与相关模式的对比选型6.1 过滤器模式 vs 职责链模式职责链模式Chain of Responsibility也是把一个一个的处理对象串起来但它和过滤器模式有本质区别。职责链关心的是“谁来处理”和“处理到哪一步为止”每个节点都有机会中断链条过滤器模式关心的是“满足哪些条件才放行”所有条件最终汇总成一个布尔结果。举个容易混淆的例子请求先经过登录校验再经过权限校验再经过参数校验一旦某个校验失败就返回错误——这更像职责链。而“给一个请求集合筛选出满足登录且权限达标且参数合法的请求”——这才是过滤器模式。如果你在代码里发现用过滤器模式去实现“任一条件为真就停止”那大概率是职责链更适合。从实现上看职责链节点需要维护一个“下一个节点”的指针而过滤器组合没有链表概念只有布尔运算树。6.2 过滤器模式 vs 策略模式策略模式Strategy Pattern侧重的是“算法的可替换性”同一个操作有多种实现运行时根据上下文选择其中一种。比如排序算法有快排、归并、堆排它们在功能上是等价的只是性能特征不同。过滤器模式的各个过滤器之间没有“等价替换”的关系它们是规则片段需要通过AND/OR/NOT组合出新的规则。一个通俗的区别是策略是一道菜的不同做法选一种就能吃过滤器是一道菜的不同原料得靠组合才能端上桌。如果需求是“成绩筛选逻辑有A与B两种实现可以切换”用策略模式如果需求是“成绩、班级、性别三个条件随意组合”用过滤器模式。6.3 过滤器模式 vs 装饰器模式装饰器模式Decorator Pattern动态地为对象添加额外职责比如给文件流增加缓冲功能、增加加密功能。它和过滤器模式的共同点是都使用组合结构但意图完全不同装饰器改变的是对象的功能行为过滤器改变的是对集合的判定结果。有些系统的中间件架构比如Web框架里的拦截器看起来很像装饰器与过滤器的混合体每个中间件既能够决定请求是否继续向下传递又能贡献一些职责。这其实是过滤器模式的思想在中间件场景的变形核心还是“按条件分层筛选、逐层放行”。选择建议比较明确条件简单且不会变直接if别套模式。条件会变化、需要组合过滤器模式或std::function组合。对象行为需要动态扩展装饰器模式。请求处理需要多级放行/拦截职责链模式。7. 写在最后我实际使用中的一点体会过滤器模式是我在C项目里用得最多的设计模式之一但也是被误解最多的一个。很多人一听到设计模式就觉得过度设计可在筛选场景里它带来的收益非常直接。我个人的经验是不要拘泥于GOF书里那一套类名和继承结构。C发展到现在过滤器模式的思想可以用std::function、lambda、ranges等多种形式表达核心始终是把规则做成可独立声明、可自由组合、可复用的表达式。另一个体会是引入模式之前先算一笔账。如果这个筛选条件只出现在一个函数里、永远不需要组合、不需要复用写普通if是最高效的。一旦你发现代码里复制粘贴了三四个“循环判断”的筛选函数或者需求文档里频繁出现“且、或、非”的表达那就该引入过滤器模式了。它是用来对抗条件组合膨胀的武器而不是为了模式而模式的道具。最后分享一个小技巧在实际工程里我会把过滤器对象统一放在一个命名空间里用工厂函数返回shared_ptrCriteria这样业务侧只需要调用makeCondition::maleAndHigh(80)这样的函数不需要关心组合细节。后续维护者看到的是接近需求文档的语义化api而不是纠缠在底层组合逻辑里。模式的价值不在于它被叫了什么名字而在于它能不能让你的代码在需求变化面前保持安静。