ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

设计模式入门:Iterator与Adapter如何优雅解决接口不匹配

设计模式入门:Iterator与Adapter如何优雅解决接口不匹配

设计模式这个词,很多人第一次接触是在面试八股文里,背了一堆“工厂模式解决对象创建问题”“观察者模式实现一对多依赖”,真到写代码的时候还是该写啥写啥。我当初翻《图解设计模式》这本书也是这个心态,觉得又是本讲二十三种模式的老生常谈。但读到第一章“适应设计模式”的时候,感觉不太一样——它没有急着甩模式定义,而是先讲了一个特别朴素的问题:当现有的东西和你要用的东西对不上时,你该怎么办。这个切入点很接地气,因为日常开发里真正让人头疼的往往不是“从零设计一个系统”,而是“手里有个能用的旧东西,新代码又必须按另一套接口来调”,中间这层胶水怎么糊才不脏、不脆、不埋雷,才是实打实的手艺。

这篇笔记就围绕“适应设计模式”这个主题展开,核心是两种模式:Iterator(迭代器)和Adapter(适配器)。前者解决的是“遍历集合时不想暴露内部结构”的问题,后者解决的是“接口对不上时怎么搭桥”的问题。别看名字简单,真要把它们用对、用出价值,里面有不少值得掰扯的细节。适合刚学设计模式、看定义能懂但不知道怎么落地的人,也适合写了几年代码、想回头把基础打扎实的同行。我会结合书里的思路,加上我自己在实际项目里踩过的坑,把这两个模式从“为什么需要”到“怎么实现”再到“什么时候别用”讲透。

1. 为什么“适应”这件事值得单独拎出来讲

1.1 设计模式的起点不是“设计”,是“变化”

很多人对设计模式有个误解,觉得它是教你怎么把代码写得更“高级”。其实不是。设计模式真正处理的是变化——需求会变、数据结构会变、外部依赖会变,而你的代码得在这种变化里活下来。书里把 Iterator 和 Adapter 放在第一章,恰恰是因为这两个模式面对的是最基础的一类变化:接口不匹配。

Iterator 面对的是“集合的内部结构可能变,但遍历方式要统一”。今天你用数组存数据,明天可能换成链表、树、哈希表,如果调用方直接依赖数组下标去遍历,那存储结构一换,调用方全得改。Iterator 就是在这中间插一层,把“怎么遍历”和“遍历谁”解耦开。

Adapter 面对的是“现有的东西接口不对,但你又不能改它”。比如一个第三方库、一个遗留系统、一个硬件驱动,它的方法名、参数顺序、返回值类型跟你现在的代码约定完全对不上。你不能改它(改了下次升级就冲突),也不想改自己的调用方(改一处牵动全身),那就得有个中间层来翻译。

这两个模式的共同点,就是在“已有的”和“想要的”之间加一层适应层。这一层加得好,两边各自独立演化;加得不好,就是多了一层没用的壳,还增加了理解成本。所以“适应”这个主题值得单独讲,因为它考验的是你对“边界”的判断力——什么时候该加这层,什么时候不该加。

1.2 一个反直觉的结论:适应层不是越多越好

我见过不少项目,一上来就抽象,什么都想加个接口、加个适配层,结果代码里全是转发调用,看一个功能要跳五六个文件。这就是把“适应”用过头了。设计模式的书容易给人一种暗示:加了模式就是好代码。实际上,每加一层适应层,你都付出了可读性和调试成本的代价。

判断标准其实很朴素:如果两边的接口未来大概率会独立变化,那加适应层是值得的;如果两边就是一一对应、永远同步改,那加层纯属自找麻烦。Iterator 之所以值得加,是因为“集合的存储结构”和“遍历逻辑”确实是两个独立变化的维度;Adapter 之所以值得加,是因为“外部依赖”和“自己的业务代码”本来就不该耦合。反过来,如果你只是给一个自己的类包一层一模一样的接口,那不叫适配,那叫脱裤子放屁。

提示:判断要不要加适应层,问自己一个问题——这两边的东西,未来会不会因为不同的原因而修改?如果答案是“会”,加;如果答案是“永远一起改”,别加。

2. Iterator 模式:把“遍历”从“存储”里拆出来

2.1 书里那个“书架”例子的真正用意

书里讲 Iterator 用了一个很经典的例子:一个书架(BookShelf),里面放书(Book),然后提供一个迭代器来遍历。初看觉得这例子太简单了,不就是个数组遍历吗,至于搞个模式?但例子的用意不在复杂度,在于展示职责的分离。

书架负责“存书”,迭代器负责“按顺序取书”。书架不需要知道别人怎么遍历它,迭代器也不需要知道书架内部是用数组还是用列表存的。这个分离带来的直接好处是:书架可以换内部实现,而遍历代码一行不用改。今天书架内部用数组,明天改成用链表,只要它还能返回一个正确的迭代器,所有调用hasNext()和next()的地方都不受影响。

这就是面向接口编程的一个具体体现。注意,书里迭代器是一个接口(Iterator),书架返回的是这个接口,而不是具体的迭代器实现。调用方只认接口,不认实现。这个细节很关键,很多人写代码的时候习惯直接new一个具体类返回,那就把实现细节泄露给调用方了,后面想换实现就得改调用方。

2.2 手写一个迭代器:从接口定义到落地

光看书容易飘,我习惯把例子敲一遍,敲的过程中会冒出很多书里没写的问题。下面是我按书里思路实现的一个版本,用 Java 写,因为书里就是 Java,但思路换成任何语言都成立。

先定义两个接口:

public interface Iterator<T> { boolean hasNext(); T next(); } public interface Aggregate<T> { Iterator<T> iterator(); }

Iterator负责遍历,Aggregate负责“能产出迭代器”。注意Aggregate这个接口的存在感很低,很多人会省略它,直接让书架类自己提供iterator()方法。省略也能跑,但保留它有个好处:调用方可以面向Aggregate编程,不关心具体是书架还是别的什么集合。这就是书里强调的“面向接口”。

然后是书和书架:

public class Book { private String name; public Book(String name) { this.name = name; } public String getName() { return name; } } public class BookShelf implements Aggregate<Book> { private Book[] books; private int last = 0; public BookShelf(int maxSize) { this.books = new Book[maxSize]; } public Book getBookAt(int index) { return books[index]; } public void appendBook(Book book) { books[last++] = book; } public int getLength() { return last; } @Override public Iterator<Book> iterator() { return new BookShelfIterator(this); } }

书架内部用数组存,last记录当前存到哪了。关键在iterator()方法,它返回一个BookShelfIterator,把this传进去。这里有个细节:迭代器需要访问书架的getBookAt和getLength,所以要么把书架的方法设为 public,要么把迭代器写成书架的内部类。书里用的是前者,我实际写的时候更倾向后者,因为内部类能访问私有成员,不用为了迭代器把书架的内部方法暴露出去。这是个设计取舍,书里没展开,但实际写的时候值得想一想。

迭代器实现:

public class BookShelfIterator implements Iterator<Book> { private BookShelf bookShelf; private int index; public BookShelfIterator(BookShelf bookShelf) { this.bookShelf = bookShelf; this.index = 0; } @Override public boolean hasNext() { return index < bookShelf.getLength(); } @Override public Book next() { Book book = bookShelf.getBookAt(index); index++; return book; } }

hasNext()判断还有没有下一个,next()返回当前并后移。这里有个容易踩的坑:next()没有做越界检查。如果调用方不先调hasNext()就直接next(),数组越界异常就来了。书里的例子假设调用方守规矩,但实际项目里你不能假设这个。我的做法是在next()里加一个检查,越界就抛一个明确的异常,比让数组越界异常冒出来更容易排查。

调用方这样用:

BookShelf shelf = new BookShelf(4); shelf.appendBook(new Book("设计模式")); shelf.appendBook(new Book("重构")); Iterator<Book> it = shelf.iterator(); while (it.hasNext()) { System.out.println(it.next().getName()); }

调用方只认Iterator接口,完全不知道书架内部是数组。这就是模式的价值所在。

2.3 迭代器模式在真实项目里的变体

书里的例子是教学版,真实项目里迭代器的形态会复杂一些,但核心思想不变。我梳理几个常见的变体,方便你在实际场景里对号入座。

变体一:带删除操作的迭代器。很多集合在遍历过程中需要删除元素,直接用 for 循环删会出问题(索引错乱或者并发修改异常)。迭代器提供一个remove()方法,由迭代器自己维护删除逻辑,就安全得多。Java 的Iterator接口就有remove(),默认实现是抛异常,具体集合类覆盖它。

变体二:反向迭代器。有时候需要从后往前遍历,可以再实现一个ReverseIterator,hasNext()判断index >= 0,next()返回后index--。书架只需要再加一个reverseIterator()方法。这就是面向接口的好处——加新遍历方式不用动老代码。

变体三:过滤迭代器。遍历的时候只想拿满足条件的元素,可以包一层过滤迭代器,内部持有原迭代器,next()里循环跳过不满足条件的。这个在函数式编程里很常见,Java 8 的 Stream 底层就是类似思路。

变体四:懒加载迭代器。数据量大的时候,不能一次性把所有元素加载到内存,迭代器可以做成“取一个加载一个”。数据库查询结果集就是典型,ResultSet本质上就是个迭代器,next()每次从游标读一行。

这些变体的共同点是:迭代器把“遍历策略”变成了可替换的东西。你可以在不改动集合本身的前提下,给它配不同的迭代器,实现不同的遍历行为。这是 Iterator 模式最核心的扩展点。

2.4 什么时候不该用迭代器

迭代器虽好,但不是所有遍历都值得包一层。我总结了几种不适合的场景:

  • 遍历逻辑极其简单且只在一处使用。比如一个方法内部对一个临时列表做一次 for 循环,包个迭代器纯属增加代码量。
  • 性能极度敏感的热点路径。迭代器多了一层方法调用,虽然 JIT 优化后开销很小,但在极端场景下(比如每秒百万次的遍历)还是直接下标访问更快。这种场景要先测量再决定,别凭感觉优化。
  • 语言本身已经内置了优雅的遍历语法。比如 Python 的for x in collection、JavaScript 的for...of,底层已经是迭代器协议了,你不需要自己再实现一套。这时候“用迭代器”是语言层面的事,不是你要手写的事。

注意:设计模式是解决问题的工具,不是必须完成的仪式。判断标准永远是“这层抽象带来的灵活性,是否大于它带来的复杂度”。

3. Adapter 模式:接口对不上时,桥怎么搭

3.1 两种适配思路:继承和委托

Adapter 模式书里讲了两种实现方式:类适配器(用继承)和对象适配器(用委托)。这个区分很重要,因为两者的适用场景和副作用完全不同。

类适配器是让适配器类同时继承“被适配者”和实现“目标接口”。Java 不支持多继承,所以类适配器只能用“继承被适配者 + 实现目标接口”的方式。它的好处是适配器直接拥有被适配者的所有方法,调用开销小;坏处是继承了被适配者的所有东西,包括你不需要的,而且被适配者如果是 final 类就没法继承。

对象适配器是让适配器类持有被适配者的一个实例,通过委托来调用。好处是灵活,被适配者可以是任何实现,不受继承限制;坏处是多一层转发调用。实际项目里对象适配器用得更多,因为它耦合更松,符合“组合优于继承”的原则。

书里两个都讲了,我建议新手先把对象适配器吃透,因为它是更通用的解法。类适配器在特定场景下(比如被适配者是个抽象类,你需要复用它的部分实现)才有优势。

3.2 一个“电压适配”的例子,把原理讲透

书里用的是“交流电适配器”的例子,把 100V 的交流电适配成 12V 的直流电。这个例子很形象,我用代码复现一下,顺便把两种实现都写出来对比。

先定义目标接口和被适配者:

// 目标接口:现在代码里统一用这个 public interface Print { void printWeak(); void printStrong(); } // 被适配者:现有的东西,接口对不上 public class Banner { private String string; public Banner(String string) { this.string = string; } public void showWithParen() { System.out.println("(" + string + ")"); } public void showWithAster() { System.out.println("*" + string + "*"); } }

Print是调用方期望的接口,Banner是现有的、接口对不上的东西。现在要写适配器,让Banner能当Print用。

对象适配器版本:

public class PrintBannerAdapter implements Print { private Banner banner; public PrintBannerAdapter(String string) { this.banner = new Banner(string); } @Override public void printWeak() { banner.showWithParen(); } @Override public void printStrong() { banner.showWithAster(); } }

适配器持有Banner实例,printWeak()委托给showWithParen(),printStrong()委托给showWithAster()。调用方只认Print接口,完全不知道背后是Banner在干活。

类适配器版本(Java 里只能继承一个类,所以继承Banner实现Print):

public class PrintBannerClassAdapter extends Banner implements Print { public PrintBannerClassAdapter(String string) { super(string); } @Override public void printWeak() { showWithParen(); } @Override public void printStrong() { showWithAster(); } }

类适配器直接继承了Banner,所以showWithParen()可以直接调。看起来更简洁,但它把Banner的所有 public 方法都暴露给了调用方(因为继承),这其实是一种接口污染。对象适配器只暴露Print接口定义的方法,封装性更好。

3.3 适配器在工程里的真实形态

书里的例子是教学版,真实项目里适配器的形态会更“脏”一些,因为要处理的差异更多。我列几个我实际遇到过的场景,你看看有没有眼熟的。

场景一:第三方 SDK 的接口封装。项目里接了一个外部服务,它的 SDK 方法名、参数顺序、错误码体系跟项目内部的约定完全不一样。直接在每个调用点写 SDK 的调用代码,会导致 SDK 一升级,所有调用点都得改。正确做法是写一个适配器,把 SDK 的接口翻译成项目内部的统一接口,调用方只认内部接口。这样 SDK 升级只需要改适配器一处。

场景二:新旧系统对接。老系统的方法签名是getUserInfo(String userId, boolean needDetail),新系统统一用UserQuery对象传参。两边都不能大改,就在中间加一个适配器,把UserQuery拆成老系统要的参数。这种适配器往往还要处理参数校验、默认值填充、异常转换,比书里的例子复杂得多。

场景三:日志框架的抽象。项目早期直接用某个日志库,后来想换,但代码里到处是那个库的 API 调用。这时候可以写一个适配器,把项目内部的日志接口适配到新库上,老代码逐步迁移。适配器在这里起的是“过渡桥梁”的作用。

场景四:硬件或协议适配。比如一个数据采集程序,底层可能对接多种设备,每种设备的通信协议不同,但上层的数据处理逻辑是一样的。为每种设备写一个适配器,统一成内部的数据接口,上层就不用关心底层是哪种设备。

这些场景的共同点是:两边都是“既成事实”,改动成本高,所以需要中间层来翻译。适配器不是用来“设计”新系统的,是用来“缝合”已有系统的。

3.4 适配器和外观模式、装饰器模式的区别

学到适配器的时候,很容易跟另外两个模式搞混:外观模式(Facade)和装饰器模式(Decorator)。这三个模式都是“包一层”,但目的完全不同,用错了就南辕北辙。

模式目的接口变化典型场景
适配器 Adapter让接口对不上东西能一起工作接口被转换成目标接口接第三方库、新旧系统对接
外观 Facade简化复杂子系统的调用提供一个新的简化接口一个操作要调多个子系统时
装饰器 Decorator动态给对象加功能接口保持不变加日志、加缓存、加权限校验

关键区别在接口是否变化。适配器是“接口变了”(从被适配者的接口变成目标接口),装饰器是“接口不变”(还是原来那个接口,只是行为增强了),外观是“新造一个接口”(把多个接口简化成一个)。

我见过有人把装饰器写成了适配器,结果调用方拿到的对象接口变了,原来的代码全编译不过。也见过把适配器写成外观,把一堆不相关的功能硬塞进一个类里。搞清楚这三个的边界,比记住它们的定义重要得多。

4. 把两个模式放在一起看:适应层的设计原则

4.1 适应层的“薄”与“厚”

Iterator 和 Adapter 都是适应层,但它们的“厚度”不一样。Iterator 是薄适应层,它只做一件事:把遍历逻辑从存储结构里抽出来,接口很窄(就hasNext和next两个方法)。Adapter 是厚适应层,它可能要处理参数转换、异常映射、默认值填充、类型转换,逻辑可以很复杂。

这个区别决定了它们的实现策略不同。薄适应层要尽量保持简单,不要在里面塞业务逻辑,否则就失去了“薄”的优势;厚适应层要把转换逻辑集中管理,做好测试,因为它是两边系统的交界处,出问题最难排查。

我在实际项目里的经验是:薄适应层可以多写几个,厚适应层要慎写。因为薄适应层成本低、职责单一,多几个不影响可读性;厚适应层每多一个,就多一个需要重点测试和维护的模块。所以遇到需要厚适应层的场景,先想想能不能把差异消化在某一侧,实在消化不了再上适配器。

4.2 接口设计的一个实用技巧:面向“最小契约”

写 Iterator 和 Adapter 的时候,都会涉及接口设计。书里反复强调“面向接口编程”,但接口怎么设计才合理,书里没细说。我分享一个自己常用的原则:接口只暴露调用方真正需要的东西,不多不少。

比如 Iterator 接口,只暴露hasNext()和next(),不暴露“当前索引是多少”“总共多少个元素”这些信息。因为调用方遍历的时候不需要知道这些,暴露了反而增加耦合。再比如 Adapter 的目标接口,只定义调用方实际会用到的方法,不要为了“完整性”把被适配者的所有方法都翻译一遍。

这个原则说起来简单,做起来难,因为人天生有“多提供一点总没坏处”的倾向。但接口一旦暴露出去,就是承诺,后面想删就难了。所以设计接口的时候要克制,宁可少暴露,后面需要了再加,也不要一开始就暴露一堆用不上的方法。

4.3 测试适应层的几个要点

适应层是两边系统的交界处,测试要格外上心。我总结几个实际踩过坑的测试要点:

  • 边界值测试。Iterator 的空集合遍历、单元素遍历、遍历到最后一个元素后再调next(),这些边界情况最容易出问题。Adapter 的参数为 null、空字符串、超长字符串,也要覆盖到。
  • 异常转换测试。Adapter 经常要把被适配者的异常转换成目标接口约定的异常。要测试被适配者抛异常时,适配器是否正确转换,而不是把原始异常直接漏出去。
  • 并发场景测试。如果 Iterator 或 Adapter 会被多线程访问,要测试并发下的行为。Iterator 通常不是线程安全的,要在文档里写清楚,或者加同步。
  • 性能基线测试。适应层多了一层调用,如果它在热点路径上,要测一下开销是否可接受。特别是 Adapter 如果做了复杂的参数转换,性能影响不能忽视。

提示:适应层的测试重点不是“功能对不对”,而是“边界和异常处理对不对”。因为正常路径往往很简单,出问题的都是边界情况。

5. 我踩过的几个坑和对应的解法

5.1 迭代器在遍历中被修改集合

这个坑太经典了。用迭代器遍历一个列表,遍历过程中调用了列表的add或remove,结果抛ConcurrentModificationException。原因是迭代器内部维护了一个“修改计数”,集合被修改后计数对不上,迭代器就报错。

解法有两个:一是用迭代器自己的remove()方法删除元素,它会同步更新计数;二是先收集要删除的元素,遍历完再统一删。第一种更优雅,但不是所有集合的迭代器都支持remove(),用之前要确认。第二种更通用,但要注意收集过程本身的内存开销。

5.2 适配器里做了太多业务逻辑

早期写适配器,我习惯把参数校验、默认值填充、业务规则判断都塞进去,结果适配器变成了一个“什么都干”的类,测试难写,改一处影响一片。后来我定了个规矩:适配器只做“翻译”,不做“决策”。参数格式转换、异常类型映射可以做,但“这个参数该不该用默认值”“这个业务规则该不该触发”这类决策,要放到调用方或者被适配者那边。

这个规矩的本质是职责单一。适配器的职责是“让接口对上”,不是“处理业务”。业务逻辑混进适配器,就违背了适应层“薄”的原则。

5.3 过度适配导致调用链过长

有个项目里,一个功能调用要经过三层适配器:业务层调内部接口,内部接口适配到旧系统接口,旧系统接口又适配到第三方 SDK。结果一个简单的查询,调用栈深得吓人,出问题排查要跳好几个文件。

后来复盘,发现其中两层适配是多余的——内部接口和旧系统接口其实可以合并,没必要分两层。这就是“过度适配”的典型症状:每遇到一点差异就加一层,最后层数失控。解法是定期审视适配层,问自己“这两层能不能合并”“这层适配是不是可以消化到某一侧”。适配层不是越多越好,能少一层就少一层。

5.4 接口命名带来的理解成本

Iterator 的next()方法名看起来没问题,但在某些语言里next有“下一个”和“下一个并返回当前”两种语义,容易混淆。Adapter 的方法命名也容易出问题,比如把showWithParen翻译成printWeak,新人看到printWeak根本猜不到背后是加括号。

命名这件事没有标准答案,但有个原则:适应层的方法名要体现“目标语义”,而不是“实现细节”。printWeak比showWithParen好,因为调用方关心的是“弱化打印”,不关心“用括号包起来”。但如果目标语义本身就不清晰,那说明接口设计有问题,要回头改接口,而不是靠命名硬凑。

6. 这两个模式在现代语言里的影子

6.1 迭代器已经成了语言基础设施

现在主流语言基本都把迭代器做进了语法层。Python 的for...in、JavaScript 的for...of、Java 的增强 for 循环,底层都是迭代器协议。你写for (Book b : shelf)的时候,编译器自动帮你调iterator()、hasNext()、next()。这意味着你很少需要手写迭代器了,但理解它的原理依然重要,因为:

  • 自定义集合类的时候,还是要实现迭代器协议,才能用上语言的遍历语法。
  • 排查遍历相关的问题(比如并发修改异常)时,要知道底层发生了什么。
  • 设计 API 的时候,返回一个可迭代对象比返回一个数组更灵活。

所以 Iterator 模式的价值不在于“手写”,而在于“理解语言在帮你做什么”。理解了这一层,你用语言特性的时候心里有底,不会把它当黑盒。

6.2 适配器在框架和中间件里无处不在

Adapter 模式在现代工程里更常见了,只是很多时候不叫这个名字。比如:

  • ORM 框架:把数据库的行映射成对象,本质是适配器,把关系模型的接口适配成对象模型的接口。
  • 消息队列的客户端:不同 MQ 的客户端 API 不同,很多项目会包一层统一的消息接口,这层就是适配器。
  • 前端的状态管理:把后端返回的数据结构适配成前端组件需要的格式,也是适配器思路。
  • 跨平台框架:同一套业务代码跑在不同平台上,底层用适配器对接各平台的 API。

这些场景里,适配器往往被封装在框架内部,业务开发者感知不到。但当你需要自己接一个“不听话”的外部依赖时,Adapter 模式就是你的工具箱里该有的东西。

6.3 函数式编程对这两个模式的影响

函数式编程流行之后,Iterator 和 Adapter 的写法都有变化。Iterator 在很多场景被map、filter、reduce这类高阶函数替代了——你不需要显式写迭代器,传一个函数进去就行。Adapter 则经常用函数组合来实现,把转换逻辑写成纯函数,组合起来用。

但模式的思想没变。map底层还是遍历,只是把“怎么遍历”隐藏了;函数组合本质还是“把 A 接口转成 B 接口”,只是用函数代替了类。所以学模式的时候,重点学的是“为什么这样分层”,而不是“类该怎么写”。语言在变,分层的思想相对稳定。

7. 给正在学设计模式的同行几句实在话

学设计模式最容易走的弯路,是把模式当成“标准答案”去套。看到一个场景,先想“这该用哪个模式”,然后硬套上去,结果代码比不用模式还复杂。我自己的体会是,先有痛点,再有模式。你在实际写代码的时候,真的遇到了“接口对不上”的难受,再去想 Adapter;真的遇到了“遍历逻辑和存储结构纠缠”的难受,再去想 Iterator。这时候模式是解药,不是负担。

另外,书里的例子要敲一遍,但别只敲一遍。敲完之后问自己几个问题:如果集合换成链表,迭代器要改哪里?如果被适配者的方法名变了,适配器要改哪里?如果要在适配器里加缓存,该怎么加?这些问题书里不会问,但实际项目里一定会遇到。把这些问题想清楚,模式才算真正学会,而不是背下来。

最后说个我自己的习惯:每学一个模式,我会在现有项目里找一处“可能用得上”的地方,试着重构一下,然后对比重构前后的代码量和可读性。有时候重构完发现还不如原来,那就说明这个场景不该用这个模式,这个“不该用”的判断,比“该用”的判断更有价值。设计模式的功力,一半在会用,一半在知道什么时候不用。

返回列表