中山市网站建设_网站建设公司_模板建站_seo优化
2026/1/21 23:37:32 网站建设 项目流程

二面技术官问了你一道看似简单的问题:“用C++实现一个观察者模式,说说关键点。”

你噼里啪啦说了一通:接口设计、注册注销、通知机制……自我感觉良好。结果他皱了皱眉说:“这些是基础,我想听的是C++特有的实现难点。”

那一刻你才意识到,观察者模式用Java、Python实现和用C++实现,根本不是一回事——C++没有GC,指针满天飞,稍不注意就是野指针崩溃,所以面试官想听的是你对C++内存管理、线程安全、异常处理这些底层问题的理解,而不是设计模式本身的概念。今天我总结出了这7个C++观察者模式的实现关键点,今天分享给你。


一、接口设计:虚函数还是std::function?

传统教科书告诉我们观察者模式要定义抽象基类:

classIObserver{public:virtual~IObserver()=default;virtualvoidonNotify(constEvent&event)=0;};

这种设计没毛病,但有个实际问题:每个想监听事件的类都得继承这个接口,如果一个类想同时监听多种事件怎么办?多重继承会让接口迅速膨胀,代码也变得难以维护。

现代C++的做法是用std::function替代继承:

classSubject{public:usingCallback=std::function<void(constEvent&)>;intsubscribe(Callback cb){intid=nextId_++;callbacks_[id]=std::move(cb);returnid;}voidunsubscribe(intid){callbacks_.erase(id);}voidnotify(constEvent&event){for(auto&[id,cb]:callbacks_){cb(event);}}private:intnextId_=0;std::unordered_map<int,Callback>callbacks_;};

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;Subscription&operator=(constSubscription&)=delete;Subscription(Subscription&&other)noexcept;Subscription&operator=(Subscription&&other)noexcept;private:Subject*subject_=nullptr;intid_=-1;};

这个设计的精妙之处在于用RAII管理订阅生命周期:对象销毁时自动取消订阅,调用者只需要保存这个Subscription对象就行,不用手动调unsubscribe,也不会因为忘记取消订阅导致野指针崩溃。

关键点:用RAII封装订阅关系让编译器帮你管理生命周期,比依赖程序员记得手动调用靠谱得多。


三、生命周期管理:这是C++的命门

Java程序员可能不理解为什么生命周期管理这么重要,因为他们有GC——只要有引用对象就不会被回收。但C++没有这个待遇,你必须自己管理每个对象的生死。

考虑这种情况:

classObserver:publicIObserver{voidonNotify(constEvent&e)override{// 处理事件}};voidfoo(){Observer obs;subject.subscribe(&obs);}// obs析构了,但subject还持有它的指针!subject.notify(event);// 崩溃:访问已销毁的对象

这就是典型的悬垂指针问题,生产环境中这种bug排查起来极其痛苦,因为崩溃点和问题根源往往相隔甚远。解决方案有两种主流做法:

方案一:weak_ptr + shared_ptr

classSubject{public:voidsubscribe(std::weak_ptr<IObserver>observer){observers_.push_back(observer);}voidnotify(constEvent&event){observers_.erase(std::remove_if(observers_.begin(),observers_.end(),[&event](auto&weak){if(autostrong=weak.lock()){strong->onNotify(event);returnfalse;}returntrue;// 已失效,移除}),observers_.end());}private:std::vector<std::weak_ptr<IObserver>>observers_;};

这种方式通过weak_ptr检测观察者是否还活着:如果已经销毁就自动从列表中移除,非常安全。缺点是要求观察者必须用shared_ptr管理,有时候这个约束太强了——特别是当观察者是栈上对象或者由其他生命周期管理机制控制的时候。

方案二:RAII Subscription(前面讲过的那个)

这是我更推荐的方式,因为它不强制观察者的内存管理方式,只要保证Subscription的生命周期不超过观察者就行,灵活性更高。

关键点:C++的观察者模式必须显式处理生命周期问题,要么用智能指针约束,要么用RAII自动管理——千万不能寄希望于程序员记得手动取消订阅。


四、通知过程中的增删问题:迭代器失效陷阱

想象一个场景:Subject正在遍历观察者列表发通知,某个观察者收到通知后决定取消自己的订阅,或者注册一个新的观察者。

voidSubject::notify(constEvent&event){for(auto&observer:observers_){// 正在遍历observer->onNotify(event);// 回调里可能调用unsubscribe!}}

如果onNotify里面调用了unsubscribe,那observers_容器就被修改了,当前的迭代器立刻失效,程序直接崩溃。这个问题在复杂系统中特别常见,因为回调函数的行为往往不可预测。

解决办法有两种:

方案一:遍历副本

voidSubject::notify(constEvent&event){autocopy=observers_;// 先复制一份for(auto&observer:copy){observer->onNotify(event);}}

简单粗暴:先复制一份列表再遍历,原列表怎么改都不影响当前遍历。缺点是如果观察者列表很大,每次notify都要复制一遍,开销不小。

方案二:延迟删除

classSubject{public:voidnotify(constEvent&event){notifying_=true;for(auto&observer:observers_){if(!observer.removed){observer.callback(event);}}notifying_=false;// 遍历结束后再真正执行删除observers_.erase(std::remove_if(observers_.begin(),observers_.end(),[](auto&o){returno.removed;}),observers_.end());}voidunsubscribe(intid){autoit=findById(id);if(notifying_){it->removed=true;// 只标记,不删除}else{observers_.erase(it);// 立即删除}}private:boolnotifying_=false;// ...};

延迟删除稍微复杂一点,但避免了复制开销,适合观察者列表较大或者notify调用非常频繁的场景。

关键点:通知过程中的增删操作会导致迭代器失效,必须特殊处理。列表小就用副本遍历简单可靠,列表大就用延迟删除节省开销。


五、线程安全:锁的粒度是门艺术

如果Subject和Observer可能在不同线程中操作,问题就更复杂了。最直接的做法是加锁:

classSubject{public:voidsubscribe(Callback cb){std::lock_guard<std::mutex>lock(mutex_);callbacks_.push_back(std::move(cb));}voidnotify(constEvent&event){std::lock_guard<std::mutex>lock(mutex_);for(auto&cb:callbacks_){cb(event);// 问题:持锁调用回调!}}private:std::mutex mutex_;std::vector<Callback>callbacks_;};

看起来线程安全了对吧?其实藏着死锁风险:如果回调函数里又调用了subscribe或unsubscribe,就会发生递归加锁,普通的std::mutex直接死锁——整个系统卡死。

改进方案:缩小锁的粒度

voidSubject::notify(constEvent&event){std::vector<Callback>snapshot;{std::lock_guard<std::mutex>lock(mutex_);snapshot=callbacks_;// 持锁复制}// 释放锁之后再调用回调for(auto&cb:snapshot){cb(event);}}

先在锁保护下复制一份回调列表,然后释放锁再遍历调用,这样回调函数里可以自由地subscribe和unsubscribe而不会死锁。如果追求更高性能可以考虑无锁数据结构或者用读写锁(std::shared_mutex)来区分读多写少的场景,但大多数情况下上面的方案已经够用了。

关键点:多线程场景下绝对不要在持锁状态调用用户回调函数,否则极易死锁。先复制再调用是最稳妥的做法。


六、异常安全:通知链条别断掉

如果某个观察者的回调函数抛出异常会怎样?

voidSubject::notify(constEvent&event){for(auto&cb:callbacks_){cb(event);// 这里抛异常的话,后面的观察者就收不到通知了}}

默认情况下异常会中断循环,导致后续的观察者收不到通知。这在很多场景下是不可接受的——一个观察者的bug不应该影响整个系统的通知机制。

解决方案:捕获并记录

voidSubject::notify(constEvent&event){for(auto&cb:callbacks_){try{cb(event);}catch(conststd::exception&e){// 记录日志,继续通知下一个std::cerr<<"Observer threw: "<<e.what()<<std::endl;}catch(...){std::cerr<<"Observer threw unknown exception"<<std::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_ptr<const Event>传递,避免每次notify都复制一遍Event对象,这个开销在高频调用场景下会非常可观。

3. 小对象优化

如果用std::function,尽量让你的回调是小对象(能放进small buffer optimization的缓冲区),避免堆分配。Lambda捕获太多变量就会触发堆分配从而影响性能,实测差距可达10倍以上。

关键点:先保证正确性再考虑性能。大多数场景下观察者模式不会成为瓶颈,但如果真的遇到性能问题,从容器选择和复制开销入手优化效果最明显。


总结

如果让你重新回答,你会这样子说了:

C++实现观察者模式比其他语言多了不少坑,核心难点在于没有GC的情况下如何安全地管理观察者的生命周期和处理各种边界情况。这7个关键点可以分成三类:

设计层面:

  1. 接口设计:虚函数 vs std::function,根据灵活性需求选择
  2. 注册机制:返回RAII封装的Subscription,自动管理订阅生命周期

安全层面:
3. 生命周期:用weak_ptr或RAII避免悬垂指针
4. 迭代器失效:通知时增删观察者要用副本或延迟删除
5. 线程安全:不要持锁调用回调,避免死锁
6. 异常安全:try-catch保护,一个观察者异常不能影响其他

性能层面:
7. 容器选择、避免复制、小对象优化

这7点答全了,面试官应该挑不出毛病来了吧?

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

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

立即咨询