网页制作和网站开发门户网站构建
2026/2/28 0:34:10 网站建设 项目流程
网页制作和网站开发,门户网站构建,seo查询网站,沈阳微信网站开发二面技术官问了你一道看似简单的问题#xff1a;“用C实现一个观察者模式#xff0c;说说关键点。” 你噼里啪啦说了一通#xff1a;接口设计、注册注销、通知机制……自我感觉良好。结果他皱了皱眉说#xff1a;“这些是基础#xff0c;我想听的是C特有的实现难点。” …二面技术官问了你一道看似简单的问题“用C实现一个观察者模式说说关键点。”你噼里啪啦说了一通接口设计、注册注销、通知机制……自我感觉良好。结果他皱了皱眉说“这些是基础我想听的是C特有的实现难点。”那一刻你才意识到观察者模式用Java、Python实现和用C实现根本不是一回事——C没有GC指针满天飞稍不注意就是野指针崩溃所以面试官想听的是你对C内存管理、线程安全、异常处理这些底层问题的理解而不是设计模式本身的概念。今天我总结出了这7个C观察者模式的实现关键点今天分享给你。一、接口设计虚函数还是std::function传统教科书告诉我们观察者模式要定义抽象基类classIObserver{public:virtual~IObserver()default;virtualvoidonNotify(constEventevent)0;};这种设计没毛病但有个实际问题每个想监听事件的类都得继承这个接口如果一个类想同时监听多种事件怎么办多重继承会让接口迅速膨胀代码也变得难以维护。现代C的做法是用std::function替代继承classSubject{public:usingCallbackstd::functionvoid(constEvent);intsubscribe(Callback cb){intidnextId_;callbacks_[id]std::move(cb);returnid;}voidunsubscribe(intid){callbacks_.erase(id);}voidnotify(constEventevent){for(auto[id,cb]:callbacks_){cb(event);}}private:intnextId_0;std::unordered_mapint,Callbackcallbacks_;};std::function的好处是灵活可以传成员函数、lambda、甚至另一个函数对象观察者不需要继承任何东西。代价是有一定的性能开销类型擦除加上可能的堆分配不过对大多数业务场景来说这点开销完全可以忽略。关键点接口设计的选择取决于场景——如果观察者类型固定且追求极致性能就用虚函数如果需要灵活性就用std::function。二、注册/注销机制返回值设计很重要很多人实现观察者模式的时候注册函数写成这样voidsubscribe(IObserver*observer);voidunsubscribe(IObserver*observer);这样设计有个隐患如果同一个observer注册了两次unsubscribe的时候是把两个都删掉还是只删一个行为不明确调用方很容易踩坑。更好的做法是返回一个唯一ID或者token我更推荐用RAII封装成Subscription类classSubscription{public:Subscription()default;Subscription(Subject*subject,intid):subject_(subject),id_(id){}~Subscription(){if(subject_){subject_-unsubscribe(id_);}}// 禁止拷贝允许移动Subscription(constSubscription)delete;Subscriptionoperator(constSubscription)delete;Subscription(Subscriptionother)noexcept;Subscriptionoperator(Subscriptionother)noexcept;private:Subject*subject_nullptr;intid_-1;};这个设计的精妙之处在于用RAII管理订阅生命周期对象销毁时自动取消订阅调用者只需要保存这个Subscription对象就行不用手动调unsubscribe也不会因为忘记取消订阅导致野指针崩溃。关键点用RAII封装订阅关系让编译器帮你管理生命周期比依赖程序员记得手动调用靠谱得多。三、生命周期管理这是C的命门Java程序员可能不理解为什么生命周期管理这么重要因为他们有GC——只要有引用对象就不会被回收。但C没有这个待遇你必须自己管理每个对象的生死。考虑这种情况classObserver:publicIObserver{voidonNotify(constEvente)override{// 处理事件}};voidfoo(){Observer obs;subject.subscribe(obs);}// obs析构了但subject还持有它的指针subject.notify(event);// 崩溃访问已销毁的对象这就是典型的悬垂指针问题生产环境中这种bug排查起来极其痛苦因为崩溃点和问题根源往往相隔甚远。解决方案有两种主流做法方案一weak_ptr shared_ptrclassSubject{public:voidsubscribe(std::weak_ptrIObserverobserver){observers_.push_back(observer);}voidnotify(constEventevent){observers_.erase(std::remove_if(observers_.begin(),observers_.end(),[event](autoweak){if(autostrongweak.lock()){strong-onNotify(event);returnfalse;}returntrue;// 已失效移除}),observers_.end());}private:std::vectorstd::weak_ptrIObserverobservers_;};这种方式通过weak_ptr检测观察者是否还活着如果已经销毁就自动从列表中移除非常安全。缺点是要求观察者必须用shared_ptr管理有时候这个约束太强了——特别是当观察者是栈上对象或者由其他生命周期管理机制控制的时候。方案二RAII Subscription前面讲过的那个这是我更推荐的方式因为它不强制观察者的内存管理方式只要保证Subscription的生命周期不超过观察者就行灵活性更高。关键点C的观察者模式必须显式处理生命周期问题要么用智能指针约束要么用RAII自动管理——千万不能寄希望于程序员记得手动取消订阅。四、通知过程中的增删问题迭代器失效陷阱想象一个场景Subject正在遍历观察者列表发通知某个观察者收到通知后决定取消自己的订阅或者注册一个新的观察者。voidSubject::notify(constEventevent){for(autoobserver:observers_){// 正在遍历observer-onNotify(event);// 回调里可能调用unsubscribe}}如果onNotify里面调用了unsubscribe那observers_容器就被修改了当前的迭代器立刻失效程序直接崩溃。这个问题在复杂系统中特别常见因为回调函数的行为往往不可预测。解决办法有两种方案一遍历副本voidSubject::notify(constEventevent){autocopyobservers_;// 先复制一份for(autoobserver:copy){observer-onNotify(event);}}简单粗暴先复制一份列表再遍历原列表怎么改都不影响当前遍历。缺点是如果观察者列表很大每次notify都要复制一遍开销不小。方案二延迟删除classSubject{public:voidnotify(constEventevent){notifying_true;for(autoobserver:observers_){if(!observer.removed){observer.callback(event);}}notifying_false;// 遍历结束后再真正执行删除observers_.erase(std::remove_if(observers_.begin(),observers_.end(),[](autoo){returno.removed;}),observers_.end());}voidunsubscribe(intid){autoitfindById(id);if(notifying_){it-removedtrue;// 只标记不删除}else{observers_.erase(it);// 立即删除}}private:boolnotifying_false;// ...};延迟删除稍微复杂一点但避免了复制开销适合观察者列表较大或者notify调用非常频繁的场景。关键点通知过程中的增删操作会导致迭代器失效必须特殊处理。列表小就用副本遍历简单可靠列表大就用延迟删除节省开销。五、线程安全锁的粒度是门艺术如果Subject和Observer可能在不同线程中操作问题就更复杂了。最直接的做法是加锁classSubject{public:voidsubscribe(Callback cb){std::lock_guardstd::mutexlock(mutex_);callbacks_.push_back(std::move(cb));}voidnotify(constEventevent){std::lock_guardstd::mutexlock(mutex_);for(autocb:callbacks_){cb(event);// 问题持锁调用回调}}private:std::mutex mutex_;std::vectorCallbackcallbacks_;};看起来线程安全了对吧其实藏着死锁风险如果回调函数里又调用了subscribe或unsubscribe就会发生递归加锁普通的std::mutex直接死锁——整个系统卡死。改进方案缩小锁的粒度voidSubject::notify(constEventevent){std::vectorCallbacksnapshot;{std::lock_guardstd::mutexlock(mutex_);snapshotcallbacks_;// 持锁复制}// 释放锁之后再调用回调for(autocb:snapshot){cb(event);}}先在锁保护下复制一份回调列表然后释放锁再遍历调用这样回调函数里可以自由地subscribe和unsubscribe而不会死锁。如果追求更高性能可以考虑无锁数据结构或者用读写锁std::shared_mutex来区分读多写少的场景但大多数情况下上面的方案已经够用了。关键点多线程场景下绝对不要在持锁状态调用用户回调函数否则极易死锁。先复制再调用是最稳妥的做法。六、异常安全通知链条别断掉如果某个观察者的回调函数抛出异常会怎样voidSubject::notify(constEventevent){for(autocb:callbacks_){cb(event);// 这里抛异常的话后面的观察者就收不到通知了}}默认情况下异常会中断循环导致后续的观察者收不到通知。这在很多场景下是不可接受的——一个观察者的bug不应该影响整个系统的通知机制。解决方案捕获并记录voidSubject::notify(constEventevent){for(autocb:callbacks_){try{cb(event);}catch(conststd::exceptione){// 记录日志继续通知下一个std::cerrObserver threw: e.what()std::endl;}catch(...){std::cerrObserver threw unknown exceptionstd::endl;}}}这样即使某个观察者抛异常也不会影响其他观察者收到通知。至于异常怎么处理是记日志、重试、还是移除该观察者取决于你的业务需求和异常严重程度。关键点在notify循环中加try-catch保护保证一个观察者的异常不会影响其他观察者的正常通知。七、性能优化别让观察者模式成为瓶颈说了这么多安全性问题最后聊聊性能毕竟有些场景下观察者模式的调用频率非常高。1. 容器选择不同的容器特性差异很大std::vector遍历最快适合频繁notify但较少subscribe/unsubscribe的场景std::list插入删除是O(1)但遍历有额外的指针追踪开销std::unordered_map用ID做key删除是O(1)遍历稍慢但支持随机删除大多数情况下用vector配合ID查找O(n)删除就够用了除非你的观察者列表有成百上千个。2. 避免不必要的复制如果Event对象很大用const Event或者std::shared_ptrconst Event传递避免每次notify都复制一遍Event对象这个开销在高频调用场景下会非常可观。3. 小对象优化如果用std::function尽量让你的回调是小对象能放进small buffer optimization的缓冲区避免堆分配。Lambda捕获太多变量就会触发堆分配从而影响性能实测差距可达10倍以上。关键点先保证正确性再考虑性能。大多数场景下观察者模式不会成为瓶颈但如果真的遇到性能问题从容器选择和复制开销入手优化效果最明显。总结如果让你重新回答你会这样子说了C实现观察者模式比其他语言多了不少坑核心难点在于没有GC的情况下如何安全地管理观察者的生命周期和处理各种边界情况。这7个关键点可以分成三类设计层面接口设计虚函数 vs std::function根据灵活性需求选择注册机制返回RAII封装的Subscription自动管理订阅生命周期安全层面3. 生命周期用weak_ptr或RAII避免悬垂指针4. 迭代器失效通知时增删观察者要用副本或延迟删除5. 线程安全不要持锁调用回调避免死锁6. 异常安全try-catch保护一个观察者异常不能影响其他性能层面7. 容器选择、避免复制、小对象优化这7点答全了面试官应该挑不出毛病来了吧

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询