:解決對象切片與所有權陷阱)
裝飾器模式在C里被聊得很多但真正能從上手到落地、把設計意圖講透的文章其實并不多。剛工作那陣子我看《Head First 設計模式》Java寫得很爽等回到C項目里自己上手才發(fā)現(xiàn)有一堆坑等著踩對象切片、虛析構(gòu)、所有權轉(zhuǎn)移、和STL的配合還有性能上那些看似不起眼的開銷。折騰過幾輪之后才慢慢摸出一套適合自己的打法。這篇文章我不打算講教科書式的定義也不會貼一堆只有類名沒有業(yè)務場景的抽象代碼。我會從實際項目中最常見的需求切入比如給已有的接口加日志、加計時、加權限校驗把裝飾器模式從基礎實現(xiàn)到C特色改進講一遍附帶踩過的坑和排查思路。適合那些設計模式看過但沒真用明白的C程序員也適合準備重構(gòu)手頭模塊、想在不改老代碼的前提下擴展功能的人。1. 裝飾器模式要解決的真實問題1.1 先從“子類爆炸”說起想象你有一條消息發(fā)送鏈路基礎發(fā)送可能只是把文本寫進日志文件后來產(chǎn)品說需要加密運維說要壓縮測試說要統(tǒng)計耗時老板說敏感信息要脫敏……如果每加一個功能就繼承一次類數(shù)量直接起飛。我見過最夸張的一個項目一個FileSender派生出了十幾個子類EncryptedFileSender、CompressedEncryptedFileSender、TimedCompressedEncryptedFileSender……光看名字就知道維護有多酸爽更別提組合順序一變又要新增一個類。子類爆炸的真正根源是功能擴展點被硬編碼成了類型層級而實際需求往往是一組正交的橫切關注點日志、鑒權、統(tǒng)計、緩存疊加在同一個核心邏輯上。裝飾器模式給出的答案是把這些橫切關注點做成一層一層的“外包裝”每層實現(xiàn)同一個接口內(nèi)部再調(diào)用下一個對象。核心業(yè)務類不用改新功能就是新增一個裝飾器類組合順序由運行時構(gòu)造決定。1.2 C 語境下的特殊考量Java里裝飾器模式幾乎是純面向?qū)ο蟮臉藴首鳂I(yè)接口 多個實現(xiàn)類 組合。但C有自己的一套脾氣直接搬到C會出問題對象切片如果接口用基類傳參而裝飾器持有基類對象賦錯一次值就把派生部分切沒了。所有權語義裝飾器包著被裝飾對象到底誰刪誰裸指針時代這是懸垂指針和多刪的溫床。虛析構(gòu)缺失接口基類沒有虛析構(gòu)delete的時候只析構(gòu)了基類部分資源泄漏沒跑。性能敏感虛函數(shù)調(diào)用有間接跳轉(zhuǎn)一次兩次無所謂在熱路徑里疊五六個裝飾器就能看到性能差異。所以C里實現(xiàn)裝飾器不只是套用UML類圖關鍵是把值語義、移動語義、智能指針、甚至模板元編程揉進去才能寫出既符合模式思想又不別扭的代碼。2. 經(jīng)典Gof風格實現(xiàn)先把基礎打牢2.1 角色拆分與接口設計經(jīng)典實現(xiàn)里有四個角色Component抽象組件定義業(yè)務接口裝飾器和被裝飾對象都實現(xiàn)它。ConcreteComponent具體組件真正干活的類。Decorator裝飾器基類持有Component的引用/指針轉(zhuǎn)發(fā)所有接口調(diào)用。ConcreteDecorator具體裝飾器在轉(zhuǎn)發(fā)前后加自己的邏輯。在實際項目里接口要設計得“恰好夠用”。如果一個接口塞了幾十個方法裝飾器基類就得轉(zhuǎn)發(fā)幾十次寫起來很煩。我曾經(jīng)封裝過一個Stream接口里面讀寫定位刷新全都有每個裝飾器都有一大堆透傳代碼后來學聰明了接口拆分把讀寫核心抽成單獨的Readable和Writable裝飾器只轉(zhuǎn)發(fā)自己關心的部分。2.2 一個能跑起來的示例下面我用一個文本處理鏈來演示TextProcessor基類核心實現(xiàn)是PlainTextProcessor裝飾器分別是去空白、轉(zhuǎn)大寫。實際項目里這個思路完全可以套到網(wǎng)絡包處理、圖像濾波、價格計算等場景。#include iostream #include string #include memory #include algorithm // 抽象組件 class TextProcessor { public: virtual ~TextProcessor() default; virtual std::string process(const std::string input) 0; }; // 具體組件 class PlainTextProcessor : public TextProcessor { public: std::string process(const std::string input) override { return input; } }; // 裝飾器基類 class TextDecorator : public TextProcessor { protected: std::unique_ptrTextProcessor inner_; public: explicit TextDecorator(std::unique_ptrTextProcessor inner) : inner_(std::move(inner)) {} }; // 具體裝飾器A轉(zhuǎn)大寫 class UpperCaseDecorator : public TextDecorator { public: using TextDecorator::TextDecorator; std::string process(const std::string input) override { std::string result inner_-process(input); std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { return std::toupper(c); }); return result; } }; // 具體裝飾器B去除連續(xù)空格 class CompactWhitespaceDecorator : public TextDecorator { public: using TextDecorator::TextDecorator; std::string process(const std::string input) override { std::string result inner_-process(input); std::string output; bool inWhitespace false; for (char c : result) { if (std::isspace(static_castunsigned char(c))) { if (!inWhitespace) output.push_back( ); inWhitespace true; } else { output.push_back(c); inWhitespace false; } } return output; } };使用方式就是在構(gòu)造時層層包裹int main() { auto processor std::make_uniqueUpperCaseDecorator( std::make_uniqueCompactWhitespaceDecorator( std::make_uniquePlainTextProcessor())); std::string text hello world from decorator ; std::cout processor-process(text) std::endl; return 0; }注意這里TextDecorator構(gòu)造函數(shù)接收的是unique_ptrUpperCaseDecorator用using TextDecorator::TextDecorator繼承構(gòu)造函數(shù)省掉了自己寫一遍轉(zhuǎn)發(fā)的代碼。2.3 構(gòu)造函數(shù)簽名設計的教訓有一個細節(jié)值得單獨說裝飾器的構(gòu)造參數(shù)我強烈建議用unique_ptr而不要用裸指針。用裸指針的話調(diào)用方很容易寫出這樣的代碼auto inner new PlainTextProcessor(); auto deco new UpperCaseDecorator(inner); delete deco; // inner 誰來刪業(yè)務代碼里一旦漏了delete inner就是內(nèi)存泄漏刪兩次就是未定義行為。用unique_ptr把所有權在構(gòu)造時直接轉(zhuǎn)移干凈裝飾器析構(gòu)時自然會把內(nèi)部對象析構(gòu)掉生命周期清晰。傳引用也試過但會有個問題被裝飾對象必須活得比裝飾器久在工廠函數(shù)里組合對象時臨時對象生命周期很容錯。最后實用下來的方案就是所有權跟著裝飾器走全部用unique_ptr串成一條鏈。提示如果你需要多個裝飾器共享同一個被裝飾對象比如做觀察者模式配合那unique_ptr不合適改成shared_ptr并注意循環(huán)引用。我在實現(xiàn)一個簡單的發(fā)布訂閱裝飾器時裝飾器要同時包裝多個訂閱者就用shared_ptr容器來持有它們。3. C 特色改進棧上裝飾器與函數(shù)式包裝3.1 棧上裝飾器避免堆分配經(jīng)典方式里每個裝飾器都是一次堆分配。對于延遲不敏感的中后臺代碼沒問題但游戲里的技能Buff系統(tǒng)、實時渲染管線里的后處理鏈堆分配太頻繁會帶來性能抖動。C特有的優(yōu)雅解法是模板加變參構(gòu)造在棧上完成裝飾器嵌套template typename Component, typename... Decorators class DecoratedProcessor; // 遞歸模板展開逐層包裹 template typename Component class DecoratedProcessorComponent { public: std::string process(const std::string input) { return component_.process(input); } private: Component component_; }; template typename Component, typename Decorator, typename... Rest class DecoratedProcessorComponent, Decorator, Rest... : public Decorator { public: template typename... Args explicit DecoratedProcessor(Args... args) : Decorator(std::forwardArgs(args)...) {} };這個寫法有點燒腦但核心思想是用編譯期遞歸把裝飾器展開成繼承鏈所有對象都分配在棧上沒有虛函數(shù)調(diào)用、沒有堆空分配、沒有運行時多態(tài)開銷。缺點也明顯裝飾器類型必須編譯期確定運行時動態(tài)組合做不到。什么時候用棧上版本裝飾器集合固定的場景比如一個產(chǎn)品線的固定消息處理鏈什么時候用經(jīng)典版本裝飾器需要配置驅(qū)動、運行時決定的場景比如根據(jù)配置中心動態(tài)啟停某個中間件。我通常把兩者配合核心鏈用模板固定可熱插拔的擴展用虛函數(shù)版本。3.2 用 std::function 簡化裝飾器如果裝飾器邏輯非常簡單加個日志、加個耗時統(tǒng)計不值得定義一整套類。利用C的std::function可以把裝飾器退化成一個函數(shù)包裝鏈。#include functional #include string #include chrono #include iostream using ProcessFunc std::functionstd::string(const std::string); ProcessFunc withLogging(ProcessFunc next) { return [next](const std::string input) { std::cout [log] before process, input size input.size() std::endl; std::string result next(input); std::cout [log] after process, result size result.size() std::endl; return result; }; } ProcessFunc withTiming(ProcessFunc next) { return [next](const std::string input) { auto start std::chrono::steady_clock::now(); std::string result next(input); auto end std::chrono::steady_clock::now(); std::cout [timer] std::chrono::durationdouble, std::milli(end - start).count() ms std::endl; return result; }; } int main() { ProcessFunc core [](const std::string input) { return input; }; ProcessFunc pipeline withLogging(withTiming(core)); pipeline(hello); return 0; }輸出會看到先打日志、再計時實際上執(zhí)行順序是withLogging 包著 withTiming所以流程是進入withLogging lambda打印before日志調(diào)用withTiming進入withTiming lambda計時開始調(diào)用core計時結(jié)束返回給withLogging打印after日志。用std::function最大的好處是靈活性構(gòu)建管道時可以按配置拼接任意一個環(huán)節(jié)都可以替換成函數(shù)或lamba。缺點也很現(xiàn)實std::function本身的調(diào)用開銷比直接虛函數(shù)還要高一點我壓測過簡單的轉(zhuǎn)發(fā)鏈每層std::function大概多幾十納秒。非極端熱路徑完全可接受但如果你每次請求要走幾十層裝飾器、每秒百萬請求就得回到模板方案或者手寫虛函數(shù)。3.3 C17 之后把裝飾器做成可變參數(shù)模板鏈還有一種介于兩者之間的思路利用C17的折疊表達式把裝飾器直接拼成一條鏈代碼寫起來挺像函數(shù)式編程里的middleware。#include iostream #include string template typename Fn auto decorate(Fn fn) { return fn; } template typename Dec, typename... Rest auto decorate(Dec dec, Rest... rest) { return dec(decorate(rest...)); } int main() { auto core [](const std::string s) { return s; }; auto withPrefix [](auto next) { return [next](const std::string s) { return [prefix] next(s); }; }; auto withSuffix [](auto next) { return [next](const std::string s) { return next(s) [suffix]; }; }; auto pipeline decorate(withPrefix, withSuffix, core); std::cout pipeline(hello) std::endl; return 0; }這種方式比std::function更輕量類型可以在編譯期完整推導但調(diào)試信息非常反人類——一旦IDE里報錯模板層數(shù)深到你懷疑人生。我個人的建議裝飾器數(shù)量不超過三個、邏輯簡單的時候模板玩一玩沒問題大型工程里還是看清楚點好可維護性優(yōu)先。4. 實戰(zhàn)案例從需求到組合完整走一遍4.1 場景設定一個可擴展的HTTP請求處理模塊假設我們有一個老項目中央處理函數(shù)是個幾百行的spaghettivoid handleRequest(HttpRequest req, HttpResponse resp);每次上線新需求就在這個函數(shù)里加一個if語句加一個邏輯分支改了半年之后誰也動不了?,F(xiàn)在回頭用裝飾器重構(gòu)第一步定義處理接口struct HttpHandler { virtual ~HttpHandler() default; virtual void handle(HttpRequest req, HttpResponse resp) 0; };然后核心處理器實現(xiàn)class CoreHttpHandler : public HttpHandler { public: void handle(HttpRequest req, HttpResponse resp) override { // 真正的業(yè)務邏輯 resp.setBody(Hello from core handler); } };4.2 日志裝飾器日志裝飾器的關注點是記錄請求路徑、狀態(tài)碼、耗時。不打進業(yè)務代碼里單獨一層class LoggingHandler : public HttpHandler { std::unique_ptrHttpHandler inner_; public: explicit LoggingHandler(std::unique_ptrHttpHandler inner) : inner_(std::move(inner)) {} void handle(HttpRequest req, HttpResponse resp) override { auto start std::chrono::steady_clock::now(); inner_-handle(req, resp); auto end std::chrono::steady_clock::now(); std::cout [log] path req.path() status resp.status() cost std::chrono::duration_caststd::chrono::milliseconds(end - start).count() ms std::endl; } };4.3 權限校驗裝飾器權限校驗的核心邏輯是沒權限直接短路不再調(diào)內(nèi)部處理器。這種“短路”行為正好體現(xiàn)了裝飾器的職責分離優(yōu)勢——CoreHttpHandler完全不知道權限什么東西class AuthHandler : public HttpHandler { std::unique_ptrHttpHandler inner_; public: explicit AuthHandler(std::unique_ptrHttpHandler inner) : inner_(std::move(inner)) {} void handle(HttpRequest req, HttpResponse resp) override { if (!req.hasValidToken()) { resp.setStatus(401); resp.setBody(Unauthorized); return; // 短路不再調(diào)用內(nèi)部處理器 } inner_-handle(req, resp); } };4.4 緩存裝飾器緩存裝飾器的思路是如果命中直接返回不穿透到內(nèi)部。這里要特別注意“短路緩存”兩個裝飾器同時存在時的順序。class CachedHandler : public HttpHandler { std::unique_ptrHttpHandler inner_; std::unordered_mapstd::string, std::string cache_; public: explicit CachedHandler(std::unique_ptrHttpHandler inner) : inner_(std::move(inner)) {} void handle(HttpRequest req, HttpResponse resp) override { auto key req.path() req.queryString(); auto it cache_.find(key); if (it ! cache_.end()) { resp.setBody(it-second); return; } inner_-handle(req, resp); if (resp.status() 200) { cache_[key] resp.body(); } } };4.5 組合順序決定行為組合順序的一個經(jīng)典案例auto handler std::make_uniqueLoggingHandler( std::make_uniqueAuthHandler( std::make_uniqueCachedHandler( std::make_uniqueCoreHttpHandler())));這個是外層日志、中層鑒權、內(nèi)層緩存。行為是請求進來先記日志然后鑒權鑒權通過后查緩存緩存沒有再進核心。但如果你把Auth和Cache換位置auto handler2 std::make_uniqueLoggingHandler( std::make_uniqueCachedHandler( std::make_uniqueAuthHandler( std::make_uniqueCoreHttpHandler())));行為就變成請求進來先記日志直接查緩存如果緩存命中就完全不鑒權這在安全敏感系統(tǒng)里是事故。我說這個例子是想強調(diào)裝飾器的順序不是隨便定的必須由業(yè)務語義決定最好在文檔里畫清楚調(diào)用順序圖別只留一份代碼。4.6 從配置工廠動態(tài)組裝裝飾器在線系統(tǒng)里裝飾器鏈經(jīng)常要按配置調(diào)整??梢杂煤唵喂Sstd::unique_ptrHttpHandler buildHandler(const Config cfg) { std::unique_ptrHttpHandler handler std::make_uniqueCoreHttpHandler(); if (cfg.enableCache) { handler std::make_uniqueCachedHandler(std::move(handler)); } if (cfg.enableAuth) { handler std::make_uniqueAuthHandler(std::move(handler)); } if (cfg.enableLog) { handler std::make_uniqueLoggingHandler(std::move(handler)); } return handler; }這里要小心賦值給新的unique_ptr時舊的handler對象所有權被轉(zhuǎn)移進了新裝飾器再訪問舊handler就是懸空。我在這個工廠里實際踩過一次在cfg.enableCache分支里取handler.get()存了個全局指針后面繼續(xù)用結(jié)果對象已經(jīng)沒了排查了好幾個小時。關鍵原則在組合鏈構(gòu)造期間不要保存任何對中間對象的裸指針等整個鏈構(gòu)造完再取get()。5. 與相關模式的對比與選型5.1 裝飾器 vs 繼承擴展能力完全不同對比一下兩種方式在增加“加解密”功能時的差異。繼承方式定義EncryptedFileHandler繼承FileHandler重寫寫方法。如果要“加密壓縮”呢定義EncryptedCompressedFileHandler繼承誰如果還要“加密限流”類數(shù)量繼續(xù)膨脹。裝飾器方式每個能力一個類自由組合需要什么包什么不需要時直接不包。核心邏輯和橫切關注點徹底解耦。5.2 裝飾器 vs 代理模式控制權歸屬不同代理模式Proxy和裝飾器結(jié)構(gòu)很像經(jīng)常有人混淆。我的理解代理強調(diào)的是“控制訪問”它創(chuàng)建和持有真實對象的引用在調(diào)用前后加入訪問控制、延遲加載、遠程通信這些管理性邏輯調(diào)用方感知到的是一個代理甚至可能未被創(chuàng)建。裝飾器強調(diào)的是“增強功能”它在已有對象上疊加新行為而且可以遞歸疊加重點在于擴展。舉例一個ImageProxy用于延遲加載大圖這是代理一個WatermarkDecorator在圖片上加水印這是裝飾器。代理控制你什么時候能看到圖裝飾器改變你看到的圖本身。5.3 裝飾器 vs 策略模式業(yè)務邏輯的變換維度策略模式解決的是“同一動作的不同算法”比如排序算法的選擇、壓縮格式的選擇在構(gòu)造時注入其中一個策略。裝飾器解決的是“