ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

结构型设计模式全解析:适配器、装饰器、代理等七大模式实战指南

结构型设计模式全解析:适配器、装饰器、代理等七大模式实战指南 结构型设计模式这六个字凡是学编程的应该都不陌生。它和创建型、行为型并称设计模式三大类而结构型这七个模式——适配器、桥接、组合、装饰器、外观、享元、代理——恰恰是日常开发里出场率最高、也最容易让人犯迷糊的一组。我见过太多人临到期末或者做大作业时才来突击结果把装饰器和代理搞混把适配器和桥接分不清代码写出来丑得没法看答辩时被老师一句你为什么不用xxx模式问住直接卡壳。这篇东西我也不打算写成教科书式的罗列就按我实际做项目和带新人时总结的思路来拆每个模式解决什么问题、在什么场景下真的该用、代码该怎么落以及期末作业和大作业里最容易踩的坑。目标是你看完能直接把结构型模式用起来而不是背完就忘。1. 结构型设计模式的整体认知它到底在解决什么1.1 为什么叫结构型从类的拼装到系统的骨架想理解结构型模式先忘掉那些拗口的定义回到一个最朴素的问题写代码时类和数据都有了怎么把它们搭起来才最合理创建型模式管的是对象怎么new出来行为型模式管的是对象之间怎么通信。而结构型模式卡在中间管的是类和对象的组合方式。你可以把系统想象成一栋房子创建型是烧砖、和水泥行为型是房子里的人怎么活动结构型就是墙体、承重柱、门窗这些骨架——它决定了砖和水泥怎么咬合才让房子既结实又灵活。结构型模式里有一部分用继承来实现比如适配器继承目标接口有一部分用组合来实现比如装饰器层层包裹。继承是白盒复用组合是黑盒复用后者的耦合度更低这也是为什么现代开发更推崇组合优先于继承。你在做设计模式大作业时如果发现某个结构型模式硬要用继承去做多半是思路偏了。1.2 七个模式一张表先建立整体地图我每次讲结构型模式都先让学生记住下面这张表。它不是用来背的是用来在拿到需求时快速定位选型的模式一句话本质核心解决典型场景适配器翻译接口老接口和新接口对不上兼容旧系统、对接第三方SDK桥接分离抽象与实现两个维度独立变化跨平台UI控件、多种数据库驱动组合树形结构统一处理部分和整体无法分清文件系统、组织架构、菜单装饰器动态叠加功能继承爆炸需要运行时增强IO流、日志增强、权限校验外观统一门面子系统太复杂调用方不需要知道细节接口聚合、SDK封装享元共享内部状态大量细粒度对象浪费内存字符串常量池、连接池、棋类游戏代理控制访问不想直接操作真实对象懒加载、远程调用、AOP记这张表有个窍门别按名字记按它让你偷了什么懒记。适配器让你不用改旧代码桥接让你不用写爆炸性继承装饰器让你不用为每个功能组合建一个类……想清楚痛点模式自然就记住了。1.3 期末和大作业场景选型比实现更重要结合搜索词里频繁出现的设计模式期末设计模式大作业我得说句实话作业被扣分多半不是模式实现错了而是选型选错了。比如需求是给不同的报表添加导出Excel、导出PDF的能力这明显是装饰器模式你非要上继承每个报表类配一个导出子类三张报表两种格式就要六个类老师看一眼就知道你没学透。选型的正确姿势是先看需求里有没有维度的概念再看有没有整体部分的概念然后看有没有接口不匹配的概念。需求提到统一访问入口就考虑外观提到对象创建开销大就考虑享元提到不能直接访问目标对象就考虑代理。这套判断流程我在第三、四、五节会具体演示。2. 适配器模式与桥接模式接口的翻译官和维度的拆解师2.1 适配器模式别让新旧代码互相嫌弃适配器模式的目标很直白——让接口不兼容的类能一起工作。它分两种类适配器用多继承实现对象适配器用组合实现。Java不支持多继承所以Java里绝大多数是对象适配器C可以多继承但实际工程里也推荐对象适配器因为更灵活。我举个实际的工作场景。之前接一个老项目的需求数据库要从MySQL迁移到Oracle老代码里全是mysql_query($conn, $sql)这类直连写法。我不可能把几百处调用全改了正确做法是定义一个DatabaseInterface暴露出query、execute、connect三个方法再写一个MysqlAdapter和一个OracleAdapter去适配不同类型的底层连接。这样上层业务代码一行不用动新数据库的驱动直接被适配器接进来。光说概念太虚给一段能跑的核心示例Java版对象适配器// 目标接口业务代码真正依赖的抽象 public interface DatabaseInterface { void connect(String url); ResultSet query(String sql); void execute(String sql); } // 被适配者第三方MySQL驱动有自己的一套API public class MysqlDriver { public void open(String url) { /* 建立连接 */ } public Object run(String sql) { return new ResultSet(); } public void update(String sql) { /* 更新 */ } } // 适配器把MysqlDriver翻译成DatabaseInterface public class MysqlAdapter implements DatabaseInterface { private MysqlDriver driver new MysqlDriver(); Override public void connect(String url) { driver.open(url); // 内部仍然是调用老接口 } Override public ResultSet query(String sql) { return (ResultSet) driver.run(sql); } Override public void execute(String sql) { driver.update(sql); } }看到没有适配器做的就是一个翻译的动作外部只知道DatabaseInterface内部把请求转成MysqlDriver能理解的调用。这就是典型的对象适配器它持有被适配者的引用而不是继承它。实操时有一个细节必须注意适配器不要塞入业务逻辑。我见过有人嫌麻烦把SQL拼接、结果集处理也写进适配器导致适配器越来越臃肿。适配器只做接口转换其他一概不管这是它的本分。2.2 桥接模式两个维度别用继承硬凑桥接模式是我在作业里见少用对频率最高的模式也是面试里最容易和适配器混淆的模式。区分它们的口诀很简单适配器是让你已有的代码继续能用桥接是让未来的变化不会互相牵制。桥接模式的核心是把抽象部分和实现部分分离让它们可以独立变化。英语里有句描述最形象——Decouple an abstraction from its implementation。举例消息系统有普通消息和加急消息发送方式有邮件和短信。如果全部用继承就是普通邮件、普通短信、加急邮件、加急短信四个类。再加一个延迟消息维度呢六个类再加微信发送呢九个类。继承爆炸就是从这里来的。正确解法是用桥接抽象的消息类型和发送方式各出一条继承链中间通过组合搭一座桥C代码如下// 实现部分发送方式 class MessageSender { public: virtual void send(const std::string content) 0; virtual ~MessageSender() default; }; class EmailSender : public MessageSender { public: void send(const std::string content) override { std::cout [Email] content std::endl; } }; class SmsSender : public MessageSender { public: void send(const std::string content) override { std::cout [SMS] content std::endl; } }; // 抽象部分消息类型 class Message { protected: MessageSender* sender; public: Message(MessageSender* s) : sender(s) {} virtual void sendMessage(const std::string content) 0; }; class UrgentMessage : public Message { public: UrgentMessage(MessageSender* s) : Message(s) {} void sendMessage(const std::string content) override { sender-send([加急] content); } }; // 使用两个维度任意组合 // EmailSender email; // UrgentMessage msg(email); // msg.sendMessage(系统宕机了);桥接模式的精髓在构造函数上——Message持有一个MessageSender引用消息类型和发送方式各自扩展互不干扰。以后加一个WechatSender直接实现MessageSender就好完全不用碰Message这条链。我调试桥接代码时常遇到一个问题有人为了让代码显得高级把桥接用到根本不需要的地方。比如两个维度里一个维度永远不变那桥接就是过度设计。桥接的价值前提是两个维度至少有一方变化频繁否则纯属浪费。作业里选型时先问自己这个系统真的有两个独立的扩展方向吗没有就别硬上。2.3 适配器和桥接的区分指南含面试版回答作业答辩或者面试时被问到适配器和桥接有什么区别是大概率事件。我给一个能直接套用的回答框架先说共同点它们都在解决类之间关系复杂的问题都倾向于用组合取代继承。然后说本质区别适配器是为了兼容它的目标是把不匹配的接口修好让双方能对接通常是事后补救桥接是为了设计它在编码之前就规划好两个维度各自独立扩展是事前预防。再补一个判断技巧如果拿到需求时接口不兼容已经是个既定事实这是适配器如果需求里存在两个变化方向且你不希望它们互相绑架这是桥接。有了这个判断技巧期末卷子上的场景题基本不会丢分。3. 组合模式与装饰器模式树形结构的统一与功能的动态叠加3.1 组合模式让叶子节点和枝干节点用同一种方式对待组合模式解决的是部分与整体的递归关系。文件系统里一个文件夹下面有文件也有子文件夹管理后台里一个部门下面有员工也有子部门GUI里一个面板里面有按钮也有子面板——这些都是天然的树形结构。组合模式最核心的设计决策是让叶子节点和容器节点实现同一个抽象接口。这样调用方就不用关心自己处理的是单个对象还是整个树一个display()方法既能显示文件也能显示整个目录树。Java版的核心结构是这样// 抽象构件统一叶子与容器的接口 public abstract class FileSystemNode { protected String name; protected String path; public FileSystemNode(String name, String path) { this.name name; this.path path; } public abstract void display(int depth); } // 叶子构件文件没有子节点 public class FileNode extends FileSystemNode { private long size; public FileNode(String name, String path, long size) { super(name, path); this.size size; } Override public void display(int depth) { System.out.println( .repeat(depth) name ( size KB)); } } // 容器构件文件夹内部可以装文件也可以装子文件夹 public class DirectoryNode extends FileSystemNode { private ListFileSystemNode children new ArrayList(); public DirectoryNode(String name, String path) { super(name, path); } public void add(FileSystemNode node) { children.add(node); } public void remove(FileSystemNode node) { children.remove(node); } Override public void display(int depth) { System.out.println( .repeat(depth) name); for (FileSystemNode child : children) { child.display(depth 1); // 递归调用 } } }写组合模式时有一个极其容易翻车的点递归的终止条件没想清楚。display方法在DirectoryNode里递归遍历子节点但FileNode的display不会再递归——这就是天然终止条件叶子节点就是递归树的边界。如果你把递归条件写在调用方而不是让每个节点自己负责自己的行为组合模式就白写了。另一个实操经验是容器节点的add和remove方法是否要定义在抽象接口里业界有透明式和安全式两种流派。透明式把add、remove也放进抽象类调用方操作方便但叶子节点调用add会抛异常安全式只在容器里定义add、remove接口更安全但调用方需要instanceof判断类型。我的建议是学习作业用透明式展示组合的优势更直观工程代码用安全式避免接口污染。3.2 装饰器模式叠加功能像套娃但每个娃都能单独用装饰器模式是结构型模式里最生动的一个——它就是把功能像套娃一样一层层套起来。Java里的IO流是最经典的教科书案例new BufferedReader(new FileReader(test.txt))就是先用FileReader读字节再套上BufferedReader加缓冲。装饰器模式解决的核心痛点是继承爆炸。以前面加急消息的例子继续如果既要加急又要延迟又要密文用继承得写多少排列组合类装饰器则完全不同——每个功能做一个装饰器类然后按需组合new 加密(new 延迟(new 加急(msg)))三个装饰器任意组合。C版装饰器模式的骨架// 抽象组件 class Message { public: virtual std::string getContent() 0; virtual ~Message() default; }; // 具体组件原始消息 class BaseMessage : public Message { private: std::string content; public: BaseMessage(const std::string c) : content(c) {} std::string getContent() override { return content; } }; // 装饰器基类持有Message引用这也是装饰器的关键 class MessageDecorator : public Message { protected: Message* wrapper; public: MessageDecorator(Message* m) : wrapper(m) {} std::string getContent() override { return wrapper-getContent(); } }; // 具体装饰器加密 class EncryptDecorator : public MessageDecorator { public: EncryptDecorator(Message* m) : MessageDecorator(m) {} std::string getContent() override { std::string s wrapper-getContent(); // 模拟加密实际用真正的加密算法 std::string result ; for (char c : s) result (char)(c 1); return [加密] result; } }; // 具体装饰器附加日志 class LogDecorator : public MessageDecorator { public: LogDecorator(Message* m) : MessageDecorator(m) {} std::string getContent() override { std::string s wrapper-getContent(); std::cout [日志] s std::endl; return s; } }; // 使用套娃式组合 // Message* msg new LogDecorator(new EncryptDecorator(new BaseMessage(Hello))); // std::cout msg-getContent() std::endl;关于装饰器我有一条反复强调的纪律装饰器和原始组件必须实现同一个接口。这是装饰器能套娃的前提。如果某天你发现装饰器里要加一堆新方法而原始组件没有这些方法那就说明你的层次结构设计错了应该回去重新建模。另一个常见问题是和代理模式混淆。我后面会专门讲代理这里先给一个最简单的分辨方法装饰器是为调用方添加功能代理是限制访问。同样是包装一层装饰器允许你调用新加的方法代理则往往什么都不加只是控制原方法的入口。3.3 组合模式与装饰器模式的异同辨析两种模式在形式上都涉及树形结构和递归很多人学完就乱我就直接做个对比。它们最大的区别在意图上组合模式想让你忽略单个对象和组合对象的差别统一对待装饰器模式想让你无中生有地叠加能力动态扩展。组合模式中父节点持有子节点的引用所以天然是树装饰器模式中装饰器只持有一层引用套多层时看起来像链表但和树没关系。还有一个场景判断法需求里出现部分-整体语义比如菜单里嵌套菜单就用组合需求里出现增强附加临时加一个功能比如给汉堡加芝士加牛肉就用装饰器。4. 外观模式与享元模式系统的门面担当和内存的抠门专家4.1 外观模式给调用方一个傻瓜式入口外观模式可能是七个模式里最容易理解的一句话你不需要知道子系统内部怎么运作只需要一个统一入口。就像你去餐厅吃饭不需要知道后厨有几个厨师、用了什么灶只需要对服务员点单就行。工程里最常见的外观模式应用是SDK封装。一个视频处理SDK内部可能要初始化播放器、加载解码器、管理字幕、处理音轨如果让外部调用全部内部模块调用方得写一大堆初始化代码。正确的做法是提供一个VideoFacade把init()、play()、pause()、stop()这些粗粒度方法暴露出去内部再去协调各个子系统。写外观模式时最需要克制的是不要在外观类里写太多业务逻辑。外观只是把复杂调用串起来业务规则应该留在子系统内部。很多人写着写着把外观类写成了一个上帝类所有逻辑全塞进去结果子系统之间的耦合转移到了外观类里这跟没做封装没有区别。外观模式和中介者模式也容易混但它们在本质上是不同的外观模式是单向的子系统并不知道外观的存在中介者模式是双向的多个对象通过中介者通信。如果你发现外观类需要反向调用子系统的细节那这个方案就要重新考虑。4.2 享元模式把重复创建的代价降到最低享元模式的核心思想是共享只适合处理大量相似的细粒度对象。最经典的例子是String常量池同样的字符串字面量在内存中只有一份所有引用都指向它。游戏里的子弹、树、粒子效果如果用new创建几千个对象内存直接爆掉用享元模式相同状态的物体共享同一份数据只有状态不同的部分才单独存储。享元模式把对象的状态分成两类这个划分是整个模式能否成功的关键内部状态Intrinsic State存在享元对象内部永不变化可以被共享外部状态Extrinsic State由客户端持有使用对象时再传入。我用一个俄罗斯方块的例子来说明。如果一个方块算一类那I、L、J、T、S、Z、O七种方块是固定不变的这些是内部状态但每个方块的位置、旋转角度、颜色方案是变化的这些是外部状态。// 享元工厂负责创建和管理享元对象 public class TetrisBlockFactory { private static final MapString, TetrisBlock blockMap new HashMap(); public static TetrisBlock getBlock(String type) { TetrisBlock block blockMap.get(type); if (block null) { block new TetrisBlock(type, new int[][]{/* 初始化形状矩阵 */}); blockMap.put(type, block); System.out.println(创建新的方块类型 type); } return block; } } // 享元对象内部状态是type和形状外部状态是位置x,y public class TetrisBlock { private final String type; private final int[][] shape; public TetrisBlock(String type, int[][] shape) { this.type type; this.shape shape; } public void display(int x, int y) { // x, y是外部状态 System.out.println(type 方块绘制在( x , y )); } } // 使用每个方块对象可以反复使用 // TetrisBlock lBlock TetrisBlockFactory.getBlock(L); // lBlock.display(10, 20); // lBlock.display(11, 20); // 同一个对象不同位置我在C课程设计里见过一个反面案例有学生要渲染一千个雪花给每个雪花单独new了一个对象每个对象里都存了形状、颜色、大小这些完全相同的属性。用享元模式重写之后所有雪花共享同一个形状数据只存各自的位移和透明度内存占用从十几MB直接降到几百KB这个优化效果在期末答辩时是非常有说服力的。享元模式的陷阱是线程安全。共享意味着并发访问如果享元对象的内部状态被外部修改整个系统的数据就乱了。所以享元的内部状态必须是只读或不可变的需要修改的状态一律作为外部状态传入。4.3 外观享元的经典组合缓冲池设计设计模式从来不是孤立使用的。外观和享元经常合作——数据库连接池就是典型。连接本身被共享享元池的对外接口是一个简单的getConnection()外观。外部调用方完全不知道池子里有多少连接、连接如何创建销毁只需要拿连接、用、归还。这个组合思路在你做大作业时特别值得展示既能体现对单一模式的理解又能体现模式组合的能力这是高分作业和普通作业的分水岭。5. 代理模式不直接动手找个中间人5.1 代理模式的四种经典应用场景代理模式的结构和装饰器几乎一模一样都是持有一个真实对象的引用然后包装一层。区别在于目的。代理模式的目的不是增加新功能而是控制对真实对象的访问。根据控制的方式不同我归纳为四类远程代理隐藏对象存在于不同地址空间的事实。RPC框架的客户端就是一个代理调用本地方法其实是发网络请求到远端执行。虚拟代理延迟对象的创建。大图加载时可以先用一个小图片代理真正的图片在后台慢慢加载。保护代理控制访问权限。某些页面只有登录用户才能访问代理里做权限校验。智能引用在访问对象时附加额外操作比如引用计数、日志记录这其实就是AOP的雏形。期末大作业里保护代理是最容易展示的场景。比如一个文档管理器普通用户只能read()不能write()管理员两个都能操作。用代理模式真实文档对象完全不知道权限的存在代理在调用前拦截判断。5.2 Java动态代理作业里最亮眼的加分项静态代理是每个类手写一个代理类一个真实类配一个代理类很死板。Java的InvocationHandler可以实现在运行时动态生成代理类代码量小可维护性强而且答辩时导师看了就知道你是真的理解代理不是背概念。核心代码如下import java.lang.reflect.InvocationHandler; import java.lang.reflect.Method; import java.lang.reflect.Proxy; // 真实主题接口 interface DocumentService { void read(String user); void write(String user, String content); } // 真实主题实现 class DocumentServiceImpl implements DocumentService { Override public void read(String user) { System.out.println(user 读取了文档); } Override public void write(String user, String content) { System.out.println(user 写入了 content); } } // 动态代理处理器 class AccessInvocationHandler implements InvocationHandler { private Object target; public AccessInvocationHandler(Object target) { this.target target; } Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { String user (String) args[0]; // 保护代理write操作需要admin权限 if (method.getName().equals(write) !admin.equals(user)) { throw new SecurityException(用户 user 没有写入权限); } // 原样转发给真实对象 return method.invoke(target, args); } } // 客户端获取代理对象 public class ProxyDemo { public static void main(String[] args) { DocumentService realService new DocumentServiceImpl(); DocumentService proxy (DocumentService) Proxy.newProxyInstance( realService.getClass().getClassLoader(), realService.getClass().getInterfaces(), new AccessInvocationHandler(realService) ); proxy.read(zhangsan); // proxy.write(zhangsan, hello); // 运行时会抛出SecurityException proxy.write(admin, hello); } }这个示例把保护代理和动态代理结合了。有一个必须记牢的坑Proxy.newProxyInstance的第二个参数是接口数组它要求真实对象实现了接口而且代理对象只能强转成接口类型。如果你忘了传接口数组或者真实对象没实现接口运行时会直接抛异常。5.3 代理 vs 装饰器 vs 适配器三兄弟一次性分清这三个模式结构都像包一层容易混我直接给一个终极分辨方法这也是面试的高频题。看接口适配器的接口和目标接口讨论的是不同的事物比如USB接口和Type-C接口它们天生不匹配装饰器和代理的接口与被包装对象完全一致讨论的是同一件事。看目的适配器的目的是兼容让原本不匹配的两方协作装饰器的目的是增强给原本的对象加新能力代理的目的是控制限制对原本对象的访问。看生命周期适配器往往在系统集成时一次性定义好装饰器可以动态地在运行时任意组合套娃代理通常绑定一个具体的真实对象职责单一。做个表更直观对比维度适配器装饰器代理接口转换接口与目标接口一致与目标接口一致核心目的兼容增强控制创建时机集成期运行期动态组合运行期或编译期双方关系原本不认识认识且同一接口认识且同一接口去面试或答辩时能把这个表流利地讲出来面试官对你的理解深度评估会上一个台阶。6. 常见问题与排查技巧实录作业答辩里最容易翻的车6.1 选型错误把七个模式当万能药乱套我审过不少设计模式课程作业最大的问题不是代码bug而是用错了地方。有学生为了凑模式数量把外观模式用到只有两个类的系统上把享元模式用到总共才创建十个对象的地方还把代理和装饰器叠着上——结果代码长度翻倍可读性崩了。给一个简洁的选型checklist需求里有没有接口不兼容有 → 适配器需求里有没有两个维度独立变化有 → 桥接需求里有没有部分-整体的树形关系有 → 组合需求里有没有动态添加功能或功能排列组合有 → 装饰器需求里有没有子系统很复杂我只想给一个简单入口有 → 外观需求里有没有大量相似对象导致内存压力有 → 享元需求里有没有不能直接访问需要中间控制有 → 代理这七个问题按顺序过一遍选型基本不会错。记住设计模式的目的是让代码更简单、更清晰而不是让代码看起来更复杂。如果引入模式后代码更难懂了那一定是你的用法出了问题。6.2 实现细节里的四个高频bug代码层面的问题也有规律可循我列四个最常见的都是我真实踩过或者帮别人调过的装饰器构造函数忘记调用父类初始化。子类装饰器继承了MessageDecorator但构造函数里忘了传入Message引用导致空指针。C里直接段错误Java里是NullPointerException。排查方式很简单所有装饰器的第一行代码应该是构造函数的参数传递。组合模式忘记递归终止条件。display里只写了遍历逻辑忘了判断是叶子节点还是容器节点结果无限递归栈溢出。这个bug的特征是运行时报StackOverflowError而且报错栈里能看到自己调用自己。适配器方法的返回类型对不上。被适配者的方法返回void但目标接口要求返回ResultSet适配器里憋不出来只能到处找替代方案。做适配器前先画一张接口映射表把每个方法的源返回类型和目标返回类型都写清楚再动手。代理对象忘记实现接口。Java动态代理第二参必须传接口列表很多人从网上抄代码第三个参数抄成了真实对象的实例而不是InvocationHandler。结果编译没问题运行就抛ClassCastException。6.3 答辩时被问到为什么用这个模式怎么答期末答辩或面试被问为什么用xxx模式几乎是必考题。我给一个万能回答模板但不是让你去背而是帮助你建立表达框架先说场景我们的需求是……再说痛点如果不用这个模式会遇到……问题最后说这个模式带来的收益用了之后可以独立扩展/动态调整/复用已有代码……。如果还能补一句这个模式的代价但它引入了更多的类增加了系统复杂度那就更稳了——这说明你真的理解了设计模式是Trade-off不是银弹。6.4 一份期末考试速查口诀最后送一份我整理的速查口诀适合复习到最后一晚的同学适配器管兼容桥接管分离组合管树形装饰器管增强外观管聚合享元管共享代理管控制。七个模式的意图各一句考试时看到场景题先对号入座再写UML图最后补关键代码保证拿分。我个人在实际教学中发现结构型模式学得好不好不看会不会背定义要看拿到一个新需求时能不能快速锁定模式。这也是为什么我一直强调要理解每个模式背后的痛点。等你真正用结构型模式做完一次大作业你会发现它们不是七个孤立的概念而是一套组合拳——适配器接旧系统外观封装门面装饰器动态加能力代理控制访问各司其职整个系统的架构才立得起来。遇到拿不准的场景按第三节的checklist多练几次慢慢就有感觉了。
返回列表