C++职责链模式实战:从if-else到优雅解耦
写C的职责链模式之前我先说说自己是怎么掉进这个坑里的。当时在某跨平台消息中间件项目里处理收包逻辑数据包要根据类型走不同分支日志类的要落盘业务类的要转发异常类的要触发告警……用if-else硬写第一版还能凑合等接入第四种、第五种消息类型的时候那个分发函数已经膨胀到我不想打开。后来我把它重构成了职责链模式才真正体会到这个模式把判断拆开、把流程串起来的设计意图。这篇博文就围绕那次重构经验展开把C里实现职责链的完整思路、代码骨架、实战案例和踩过的坑都过一遍。适合想在实际项目里落地设计模式的C开发者不管你是刚接触设计模式的新手还是已经写过一段时间业务代码的工程师这篇内容应该都能给你一些参考。1. 职责链模式解决的问题与适用场景1.1 从一堆if-else说起几乎所有职责链相关的讨论都会从一个长函数开始。假设当前有个订单消息进入系统后要依次做合法性校验、风控检查、库存锁定、发货通知。最直观的写法是这样void handleOrder(const OrderMessage msg) { if (validateOrder(msg)) { if (riskCheck(msg)) { if (lockStock(msg)) { sendNotify(msg); } } } }这段代码的问题在嵌套的if里流程顺序被写死在函数内部。一旦要在风控和库存之间加一个优惠券核销对不起你需要打开这个函数在if的中间插入一行改完还得担心改错了某一层括号。更麻烦的是如果不同渠道进来的订单走的校验逻辑不一样这个函数很快就得长出一堆分支参数。这种写法的本质问题是调用方和流程绑得太死新增需求和调整顺序都要改动核心代码违背了开闭原则。1.2 职责链的解耦思路和核心组成职责链模式就是为解决这类耦合而存在的。它的做法很直白把每一个处理步骤封装成独立对象再用一串指针把这些对象串联成链。请求进入链之后从头到尾依次经过每个处理者每个处理者拿到请求可以自行决策要么自己处理掉要么选择不处理转给下一个。调用方不需要知道链里到底有几个处理者、各自负责什么它只需要把请求丢给链头剩下的由链内部解决。打个比方。这就像客服处理工单一线客服能答的问题当场答掉答不了就升级给二线二线再搞不定就提交给技术专家。提工单的用户根本不需要知道后面有多少人在处理他只管提交后面谁处理、怎么处理跟用户无关。从组成上看职责链有四个核心元素抽象处理者定义处理接口持有指向下一个处理者的引用。具体处理者实现抽象处理者内部包含自己的处理逻辑和判定条件。请求对象封装调用方传入的数据和上下文是链上流转的对象。客户端把具体处理者串成链并把请求交给链首。1.3 什么情况适合用什么情况别硬套那是不是所有流程都适合改成职责链我自己的判断标准有三条业务流程里存在多个候选处理者且处理优先级可以通过链的顺序来控制。各处理逻辑彼此独立、边界清楚塞进同一个类会让类膨胀。流程大概率会变加步骤、调整顺序、增删环节是家常便饭。这三条里占了两条职责链基本是合适的选择。反过来如果只有一个固定入口、一条固定流程、几乎不会有变化那直接用顺序调用的函数列表反而更简单——不要为了模式而模式。我在实际中见过不少反面案例本应该用状态机管理的复杂状态流转硬套职责链后每个处理者都要判断当前状态能不能处理处理完又要手动改状态结果代码比if-else还要绕。职责链的核心是多个处理者竞争处理同一个请求如果你的场景是多个步骤必须全部执行那职责链不一定是最优解除非你写的是每个步骤都有条件决定自己是否参与的那种动态流程。2. 用现代C搭出职责链骨架2.1 抽象处理者怎么写先看最常用的抽象基类写法#include memory #include iostream class Message { public: std::string content; int priority 0; bool validated false; }; class Handler { public: virtual ~Handler() default; void setNext(std::shared_ptrHandler next) { m_next std::move(next); } void handle(const Message msg) { if (canHandle(msg)) { process(msg); } else if (m_next) { m_next-handle(msg); } else { std::cerr [Chain] no handler found std::endl; } } protected: virtual bool canHandle(const Message msg) const 0; virtual void process(const Message msg) 0; std::shared_ptrHandler m_next; };几个关键点说明一下。canHandle和process我用纯虚函数拆开这是比较推荐的做法。判断逻辑归判断逻辑处理逻辑归处理逻辑子类只需要实现这两个方法。在handle里先调用canHandle判断当前处理者有没有资格处理这个请求有就处理没有就传到下一个。这样做的好处是处理者的能不能处理和怎么处理被解耦了后续要调整判定条件或者处理逻辑各改各的。setNext返回值我设计成void这是为了简单。如果你想要链式调用也可以在setNext里返回Handler或者std::shared_ptrHandler方便写出一串.setNext(...).setNext(...)这个看个人偏好。2.2 具体处理者与链式组装具体处理者的实现就非常直观了。比如我们需要一个校验处理者、一个格式化处理者、一个持久化处理者class ValidatorHandler : public Handler { protected: bool canHandle(const Message msg) const override { return msg.content.empty(); } void process(const Message msg) override { std::cout [Validator] message is empty, invalid std::endl; } }; class FormatterHandler : public Handler { protected: bool canHandle(const Message msg) const override { return !msg.content.empty(); } void process(const Message msg) override { std::cout [Formatter] format message: msg.content std::endl; } };组装则放在客户端代码里int main() { auto validator std::make_sharedValidatorHandler(); auto formatter std::make_sharedFormatterHandler(); validator-setNext(formatter); Message msg; msg.content hello chain; validator-handle(msg); return 0; }这里有个容易犯迷糊的地方当msg.content不为空时ValidatorHandler的canHandle返回false于是链往下走到Formatter打印格式化消息如果为空Validator直接处理掉链就断在这里不会往下走。这正是职责链抢着处理的语义。2.3 关于生命周期管理的选用建议很多初学者纠结该用裸指针、unique_ptr还是shared_ptr。我的建议分场景项目里链的所有者明确且链的销毁顺序由同一个对象控制可以用裸指针。简单直接性能最好但要求外部严格保证链上处理者的生命周期不被提前释放。链的组装比较零散处理者可能被多个地方持有或者会被动态替换用shared_ptr最省心。Handler基类里用std::shared_ptrHandler m_next子类通过std::make_shared创建生命周期由引用计数管理。链是局部临时搭建不会传出作用域用unique_ptr也合适m_next改用std::unique_ptrHandler语义上明确链独占处理者销毁时自动释放整条链。要特别注意一个问题如果处理者反过来持有链的shared_ptr就会造成循环引用链永远释放不掉。在实际业务里处理者内部往往需要回调某个业务对象这个业务对象又可能保存了指向链首的引用。遇到这种双向持有一边必须用weak_ptr打断。我经历过的两次内存泄漏根源都是这里。3. 两个实战案例完整复现3.1 案例一多级日志系统日志系统是职责链最经典的落地场景之一。按级别从低到高一条日志可能被多个地方消费Debug信息输出到控制台Info信息写入日志文件Warn信息发到监控系统Error信息告警邮件通知。用职责链处理时顺序很重要因为低级别处理者往往会对日志做预处理高级别处理者再拿到是不是已经写盘的结果。先定义一个简单的日志请求对象enum class LogLevel { Debug 1, Info 2, Warn 3, Error 4 }; class LogMessage { public: LogLevel level LogLevel::Debug; std::string text; };然后定义抽象日志处理者。和前面通用版相比这里可以往process里加一个是否已经处理的标记方便后续环节知道上一步做了什么class LogHandler { public: virtual ~LogHandler() default; void setNext(std::shared_ptrLogHandler next) { m_next std::move(next); } void handle(LogMessage msg) { if (canHandle(msg)) { process(msg); } else if (m_next) { m_next-handle(msg); } else { std::cerr [Log] nobody handled this log std::endl; } } protected: virtual bool canHandle(const LogMessage msg) const 0; virtual void process(LogMessage msg) 0; std::shared_ptrLogHandler m_next; };具体处理者class ConsoleLogHandler : public LogHandler { protected: bool canHandle(const LogMessage msg) const override { return msg.level LogLevel::Debug; } void process(LogMessage msg) override { std::cout [Console] msg.text std::endl; } }; class FileLogHandler : public LogHandler { protected: bool canHandle(const LogMessage msg) const override { return msg.level LogLevel::Info; } void process(LogMessage msg) override { std::cout [File] msg.text std::endl; } }; class AlertLogHandler : public LogHandler { protected: bool canHandle(const LogMessage msg) const override { return msg.level LogLevel::Warn; } void process(LogMessage msg) override { std::cout [Alert] msg.text std::endl; } }; class EmailLogHandler : public LogHandler { protected: bool canHandle(const LogMessage msg) const override { return msg.level LogLevel::Error; } void process(LogMessage msg) override { std::cout [Email] msg.text std::endl; } };组装时特别注意顺序Debug处理者放最前面Email处理者放最后面。因为canHandle里的判断用一条Error级别的日志走到Debug时canHandle返回false往下走到File也false走到Alert还是false直到Email才返回true被处理。如果顺序放反了Error级别的日志在第一站就被处理掉后面全都没机会。这个例子告诉我们一个重要的实战经验职责链里处理者的顺序就是优先级顺序。你把谁放在前面谁就有优先处理权。在设计接口时一定要给调用方一个清晰明确的组装脚本否则后面接手的人很容易调反顺序导致请求被低优先级处理者截胡。3.2 案例二订单审批流程日志系统是同一请求类型按级别分流审批流则是同一个业务对象按条件逐级上报。我在某电商后台项目里做过一个下单审批订单金额不超过1000元主管审批就能通过不超过10000元需要经理审批超过10000元还得总监审批。这个场景下每个处理者持有的不是日志级别的阈值而是自己的审批权限上限。class ApprovalRequest { public: double amount 0.0; std::string orderId; }; class Approver { public: Approver(std::string name, double limit) : m_name(std::move(name)), m_limit(limit) {} virtual ~Approver() default; void setNext(std::shared_ptrApprover next) { m_next std::move(next); } void handle(const ApprovalRequest req) { if (req.amount m_limit) { approve(req); } else if (m_next) { m_next-handle(req); } else { std::cerr [Approval] no approver can handle amount: req.amount std::endl; } } protected: virtual void approve(const ApprovalRequest req) { std::cout [Approval] m_name approved order req.orderId , amount: req.amount std::endl; } std::string m_name; double m_limit 0.0; std::shared_ptrApprover m_next; };这种实现比前面的还要简单不用单独设计canHandle判定条件就一条——金额超过我的限额就往上传。组装auto supervisor std::make_sharedApprover(Supervisor, 1000); auto manager std::make_sharedApprover(Manager, 10000); auto director std::make_sharedApprover(Director, 100000); supervisor-setNext(manager); manager-setNext(director); ApprovalRequest req; req.amount 8500; req.orderId A10001; supervisor-handle(req); // 输出Manager approved order A10001这个案例里有一个值得注意的通用原则每个处理者持有的不应该是另一个处理者而是我处理不了时的下一步去向。这个m_next对象可以是一个具体审批人也可以是一个代理节点。只要是遵循接口的对象链上谁都可以。这就是开闭原则的体现新加一个CTO审批人不需要修改主管、经理、总监任何代码只在组装端多new一个节点再串上去即可。4. 实践中最容易踩的坑4.1 链断裂与无人处理职责链最经典的一个坑是某处理者忘了调用setNext或者组装时把节点落下了导致请求走到链尾直接掉出去。调用方拿不到任何结果排查半天找不到问题。我推荐的做法是在抽象处理者的handle方法里加上一个兜底分支void handle(const Request req) { if (canHandle(req)) { process(req); } else if (m_next) { m_next-handle(req); } else { // 这里至少留一条日志别让请求悄无声息地消失 std::cerr [Chain] no handler matched, request dropped std::endl; } }实战中还遇到过更隐蔽的版本setNext调用顺序写反了比如本意是把B接到A后面结果写成A-setNext(B); B-setNext(A);直接成环。4.2 意外成环与无限递归当链上有两个节点互相setNext时请求会在这两个节点间反复横跳直到栈溢出。我遇到过最离谱的一次是在一个中间件刷新的逻辑里处理者内部因为某些条件触发了重新初始化导致本来指向B的m_next又被重置成了A自己请求卡死在A和B之间。排查无限递归最直接的手段是在handle里加一个深度计数器void handle(const Request req, int depth 0) { if (depth 32) { std::cerr [Chain] max depth reached, possible loop std::endl; return; } // 正常逻辑传入 depth 1 }32这个值是我自己定的可以根据链的最长长度调整。正常业务链一般不超过十层一旦超过32基本可以断定是有环了。4.3 生命周期与线程安全前文提过生命周期再展开一次。很多人在处理者内部持有业务对象的裸指针比如日志处理者持有一个Logger*而Logger对象的生命周期比处理者短一旦链还在用、业务对象先析构了就会出现野指针。我的建议是处理者持有的依赖要么用shared_ptr要么确保依赖在链销毁前一定不被释放。在C这种手动管理内存的语境下shared_ptr能解决绝大多数场景的问题代价是引用计数的开销。对于请求处理这种低频操作这点开销完全可接受。线程安全是另一个容易漏掉的点。如果同一个链实例被多个工作线程同时调用两个线程同时走到同一个处理者内部处理者内部若有mutable状态比如统计计数器、缓存最近请求之类的就会数据竞争。我现在的习惯是处理者尽量无状态所有需要变动的数据都放进请求对象里请求对象如果会被多个处理者写那它本身就要加锁或者在链外部保证单线程访问。4.4 异常处理与请求对象设计process里抛出异常时怎么办职责链的经典实现不负责捕获异常异常会一路向外传播最终中断整条链。这有时是好事因为你可以统一拦截有时是坏事因为中间某个处理者抛了异常后面的处理者连机会都没有。我个人的做法是在handle内部不捕获异常在链的入口统一捕获并且区分是业务性失败还是系统性故障。业务性失败可以转给下一个处理者系统性故障直接终止并告警。请求对象也有讲究。不要把一堆零散参数传给handle那样每个处理者的方法签名都不一样抽象基类的统一接口就废了。把需要传递的数据封装成一个Request对象哪怕它只是一个简单的结构体后续加字段也会方便得多。如果请求对象需要经过链的多个环节被逐步修改建议用引用传递如果请求是不可变快照用const传递。5. 一个更轻量的替代方案与我的建议5.1 vector加find_if的弱化版职责链不是所有请求处理场景都需要完整的职责链对象。如果你只是想把一堆处理策略按优先级排好用std::vector加std::find_if会更简洁#include vector #include algorithm #include functional class Request { public: int type 0; }; int main() { std::vectorstd::pairint, std::functionvoid(const Request) handlers; handlers.emplace_back(1, [](const Request req) { std::cout handler 1 std::endl; }); handlers.emplace_back(2, [](const Request req) { std::cout handler 2 std::endl; }); Request req; req.type 2; auto it std::find_if(handlers.begin(), handlers.end(), [req](const auto p) { return p.first req.type; }); if (it ! handlers.end()) { it-second(req); } return 0; }这个版本的好处是代码量少、调试直观适合比较简单的策略分发。但它少了职责链沿路传递、逐级决策的能力结构上更像注册表加查找。如果你的处理逻辑彼此独立不存在上一个处理完会影响下一个是否处理的情况用这个轻量方案完全够用。5.2 结合项目规模做取舍我现在的习惯是先用最简单的方式把功能跑通当发现if-else分支控制已经明显拖累维护效率时再引入职责链。引入时我也会考虑链上的处理节点大概有几个。三五个以内直接用vector实现处理节点经常动态增删或者每个节点自身还有复杂依赖那才需要完整版的抽象基类加shared_ptr版本。对于C项目设计模式落地最大的障碍往往不是模式本身而是生命周期的管理。如果你已经选择了shared_ptr方案记得给链的组装单独写一个工厂函数不要让组装逻辑散落在各个业务模块里。我在实际项目中封装过一个buildChain()函数内部按配置表的顺序创建节点业务代码只需要一行调用。这样链的顺序调整不需要动任何调用方完全集中在工厂里维护起来轻松很多。最后再分享一个小技巧。如果链上的处理者需要根据运行时配置动态决定谁先谁后不要硬编码组装顺序。把处理者的工厂函数放进一个有序注册表每次构建链时遍历注册表生成节点。这样做的好处是后续加新处理者只需注册新节点既有的组装逻辑一分都不用改。设计模式的价值从来不是让你把所有代码都变得复杂而是当复杂度真正出现时你有一个清晰成熟的应对方案。

相关新闻

SpringBoot+Vue党员教育管理系统设计与实现:从需求分析到部署排坑

SpringBoot+Vue党员教育管理系统设计与实现:从需求分析到部署排坑

最近刚把一个计算机毕业设计项目完整跑通,用的是 SpringBoot Vue 的组合,题目是“党员教育和管理系统”,也可以理解成一套党校线上指导平台。如果你正好在选毕业设计题目,或者公司内部需要做一套线上学习培训系统,这个…

2026/10/15 22:27:31 阅读更多 →
CC Switch 多环境切换实战:把 Claude Code 的 endpoint 改到 TaoToken

CC Switch 多环境切换实战:把 Claude Code 的 endpoint 改到 TaoToken

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 22:27:31 阅读更多 →
基于SSM+Vue的景区容灾管理系统设计与实现

基于SSM+Vue的景区容灾管理系统设计与实现

又到一年毕业设计的季节,后台私信里很多同学都在问差不多的一个问题:SSM Vue 到底做什么题目比较稳?既要工作量够,又要技术栈主流,还得好写论文、好答辩。今天想拆解的,正是这类题目里很有代表性的一类——…

2026/10/15 22:27:31 阅读更多 →

最新新闻

NAT与动态路由深度联动:智能制造网络的自适应通信架构

NAT与动态路由深度联动:智能制造网络的自适应通信架构

1. 项目概述:这不是教科书里的概念堆砌,而是真实网络边界上每天都在发生的“交通调度”你有没有遇到过这样的场景:某实验室新部署了一套工业视觉检测系统,后端服务器在内网192.168.100.0/24网段,前端质检终端却分散在车…

2026/10/15 23:19:39 阅读更多 →
TensorFlow 生产级实践:从选型、训练到部署的完整链路

TensorFlow 生产级实践:从选型、训练到部署的完整链路

很多人一听到 TensorFlow 这个名字,第一反应就是"深度学习框架""神经网络""GPU 训练"这些大词。但真到自己上手做项目,从环境搭建到模型跑到线上,中间隔着的距离远比想象中要长。这篇内容我不打算做基础概念扫…

2026/10/15 23:19:39 阅读更多 →
PyTorch深度学习实战:动态图、张量与自动求导核心解析

PyTorch深度学习实战:动态图、张量与自动求导核心解析

1. 为什么“PyTorch”这三个字值得单独拎出来聊如果你在深度学习圈子里待过一阵子,大概率会发现一个现象:大家聊模型结构、聊训练技巧、聊部署方案的时候,绕来绕去最后总会落回到一个词上——PyTorch。它不是一个新东西,也不是什么…

2026/10/15 23:19:39 阅读更多 →
微信公众号抓取研究:用Xposed Hook从App内部获取数据

微信公众号抓取研究:用Xposed Hook从App内部获取数据

1. 从“想抓数据”到“动手Hook”:公众号抓取这条路怎么走干了几年移动端逆向和数据采集,坦白说,公众号内容抓取是一个一直有人问、一直有方案、但一直没被彻底讲透的话题。很多人一上来想的就是用Fiddler或Charles抓包,运气好能抓…

2026/10/15 23:19:39 阅读更多 →
AnyPS5折腾全记录:从局域网到公网的PS5串流方案详解

AnyPS5折腾全记录:从局域网到公网的PS5串流方案详解

我第一次听到“AnyPS5”这个名字,是在一个游戏玩家聚集的技术交流群里。当时有人发问:“有没有办法让PS5游戏不在客厅电视上也能玩?我想躺在床上用平板打。”紧接着另一个人甩了个链接,说这个方案基本能做到“任何设备&#xff0c…

2026/10/15 23:19:39 阅读更多 →
Python3.8官网文档之类的基础语法阅读

Python3.8官网文档之类的基础语法阅读

前言 标题里的「之类」表述比较含糊,本文把它读作:怎么读 Python 官方文档里的基础语法部分。这是一项比背语法更值的技能——语言在演进,教程会过期,但官方文档永远与解释器同步,而且它给出的是定义,不是某…

2026/10/15 23:18:39 阅读更多 →

日新闻

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

Docker Compose 容器化部署 ChirpStack LoRaWAN 网络服务器实战

1. 为什么选择容器化方式部署 LoRa 网络服务LoRa 物联网项目落地时,网络服务器是整个链路的核心枢纽。它负责接收网关上报的数据包、完成去重与解密、再把有效载荷分发给上层应用。早期大家习惯把网络服务器直接装在物理机上,依赖一堆系统库和特定版本的…

2026/10/15 0:00:46 阅读更多 →
共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

共享LLM服务下的跨模型自动扩缩容:Kubernetes实战与避坑指南

1. 共享推理服务里,扩缩容到底难在哪做过大模型推理服务部署的人,大概都经历过这样的场景:一个团队共用一套推理集群,A 业务跑的是 7B 的小模型,B 业务跑的是 72B 的大模型,C 业务白天流量高、晚上几乎归零…

2026/10/15 0:00:46 阅读更多 →
Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

Systemd 启动阶段根文件系统只读之谜:fstab 与 remount-fs 服务深度解析

1. 引子:一次内核恐慌引发的追查先从一个真实场景说起。某次我给一台老旧的嵌入式设备折腾系统裁减,为了把启动时间压进两秒以内,我手贱地把一串自认为“没用”的 systemd 服务给 disable 了,其中就包括systemd-remount-fs.servic…

2026/10/15 0:00:46 阅读更多 →

周新闻

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

流感时间序列预测实战:ARIMA/LSTM全流程拆解与避坑指南

简介:基于 ARIMA、LSTM、Transformer 等模型的流感时间序列预测 Python 源码,面向计算机相关专业课程设计与期末大作业学生,以及项目实战学习者。内容覆盖预处理、平稳性检验、定阶、残差分析、多模型对比预测的完整时序建模流程,…

2026/10/15 16:18:54 阅读更多 →
影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别

影刀RPA新手教程:键盘模拟输入实战——输入文本与模拟按键的区别 做影刀RPA自动化,十个新手有八个栽在"往输入框里填东西"这件事上:要么填不进去,要么填了一半,要么直接把原来内容追加在后面。这背后的根因&…

2026/10/15 19:41:51 阅读更多 →
影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容

影刀RPA新手教程:阅文起点小说数据采集实战——书籍信息与章节内容 1. 认识影刀:什么场景该用RPA采小说数据 起点中文网的页面结构相对稳定——分类榜单、书籍详情、章节内容三块独立页面,跳转链路清晰。这种场景非常适合影刀自动化&#x…

2026/10/15 13:48:15 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 0:14:36 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/14 22:45:47 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/15 9:49:14 阅读更多 →