适配器模式详解:第三部分的学习体验
一、什么是Adapter模式
我们先举个例子:如果想让额定工作电压是直流12V的笔记本电脑在交流220V的电源下工作,应该怎么做呢?通常,我们会使用适配器,将家庭用的交流220V电压转换成我们所需要的直流12V电压。这就是适配器的工作,它位于实际情况与需求之间,填补两者之间的差异。
在程序世界中,经常会存在现有的程序无法直接使用,需要做适当的变换之后才能使用的情况。这种用于填补“现有的程序”和“所需的程序”之间差异的设计模式就是Adapter模式。
Adapter模式也被称为Wrapper模式。Wrapper有“包装器”的意思,就像用精美的包装纸将普通商品包装成礼物那样,替我们把某样东西包起来,使其能够用于其他用途的东西就被称为“包装器”或是“适配器”。
用一句话来概括:Adapter模式就是为程序加一个“适配器”以便于复用。
Adapter模式有以下两种:
- 类适配器模式(使用继承的适配器)
- 对象适配器模式(使用委托的适配器)
本文将依次介绍这两种Adapter模式。
二、使用继承的Adapter模式示例代码
2.1 各个类之间的关系
先看一下类图:
这里的示例程序是一段会将输入的字符串显示为括号包围或者星号包围的简单程序。例如,输入字符串Hello,显示为(Hello)或是*Hello*。
目前在Banner类( Banner有广告横幅的意思)中,有将字符串用括号括起来的showWithParen方法,和将字符串用*号括起来的showWithAster方法。我们假设这个Banner类是类似前文中的“交流220伏特电压”的“实际情况”。
假设Print接口中声明了两种方法,即弱化字符串显示(加括号)的printweak方法,和强调字符串显示(加*号)的printstrong方法。我们假设这个接口是类似于前文中的“直流12伏特电压”的“需求”。
现在要做的事情是使用Banner类编写一个实现了Print接口的类,也就是说要做一个将“交流220伏特电压”转换成“直流12伏特电压”的适配器。
扮演适配器角色的是 PrintBanner类。该类继承了Banner类并实现了“需求”——Print接口。PrintBanner类使用showWithParen方法实现了printWeak,使用showwithAster方法实现了printstrong。这样,PrintBanner类就具有适配器的功能了。
2.2 Banner类
Banner类是我们现有的功能。
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 + "*"); } }
2.3 Print接口
Print接口就是我们新的“需求”。
public interface Print { public abstract void printWeak(); public abstract void printStrong(); }
2.4 PrintBanner类
PrintBanner类扮演适配器的角色。
public class PrintBanner extends Banner implements Print{ public PrintBanner(String string) { super(string); } @Override public void printWeak() { showWithParen(); } @Override public void printStrong() { showWithAster(); } }
2.5 用于测试的Main类
public class Main { public static void main(String[] args) { Print p = new PrintBanner("Hello"); p.printStrong(); p.printWeak(); } }
2.6 运行结果
需要注意的是,我们是使用Print接口(即调用printweak方法和printstrong方法)来进行编程的。对Main类的代码而言,Banner类中的showWithParen'方法和showWithAster方法被完全隐藏起来了。这就好像需要12V的笔记本电脑插在220V的插座上能正常工作,但它并不知道这12伏特的电压是由适配器将220伏特交流电压转换而成的。
Main类并不知道PrintBanner类是如何实现的,这样就可以在不用对Main类进行修改的情况下改变 PrintBanner类的具体实现。
三、使用委托的Adapter模式示例代码
3.1 各个类之间的关系
先看一下类图:
Main类和 Banner类与示例程序中的内容完全相同,不过这里我们假设Print不是接口而是类。
也就是说,我们打算利用Banner类实现一个类,该类的方法和Print类的方法相同。由于在Java中无法同时继承两个类(只能是单一继承),因此我们无法将PrintBanner类分别定义为Print类和 Banner类的子类。
3.2 Banner类
同上面的Banner
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 + "*"); } }
3.3 Print类
public abstract class Print { public abstract void printWeak(); public abstract void printStrong(); }
3.4 PrintBanner类
PrintBanner类的banner字段中保存了Banner类的实例。该实例是在PrintBanner类的构造函数中生成的。然后,printWeak方法和printStrong方法会通过banner字段调用Banner类的showWithParen和 showWithAster方法。
与之前的示例代码中调用了从父类中继承的showWwithParen方法和showwithAster方法不同,这次我们通过字段来调用这两个方法。
这样就形成了一种委托关系。当PrintBanner类的printWeak被调用的时候,并不是PrintBanner类自己进行处理,而是将处理交给了其他实例(Banner类的实例)的showWithParen方法。
public class PrintBanner extends Print{ private Banner banner; public PrintBanner(String string) { this.banner = new Banner(string); } @Override public void printWeak() { banner.showWithParen(); } @Override public void printStrong() { banner.showWithAster(); } }
3.5 用于测试的Main类
同上面的Main
public class Main { public static void main(String[] args) { Print p = new PrintBanner("Hello"); p.printStrong(); p.printWeak(); } }
3.6 运行结果
四、拓展思路的要点
4.1 什么时候使用Adapter模式
一定会有读者认为“如果某个方法就是我们所需要的方法,那么直接在程序中使用不就可以了吗?为什么还要考虑使用Adapter模式呢?”那么,究竟应当在什么时候使用Adapter模式呢?
很多时候,我们并非从零开始编程,经常会用到现有的类。特别是当现有的类已经被充分测试过了,Bug很少,而且已经被用于其他软件之中时,我们更愿意将这些类作为组件重复利用。
Adapter模式会对现有的类进行适配,生成新的类。通过该模式可以很方便地创建我们需要的方法群。当出现 Bug时,由于我们很明确地知道Bug不在现有的类( Adaptee角色)中,所以只需调查扮演Adapter角色的类即可。这样一来,代码问题的排查就会变得非常简单。
4.2 如果没有现成的代码
让现有的类适配新的接口(API)时,使用Adapter模式似乎是理所当然的。不过实际上,我们在让现有的类适配新的接口时,常常会有“只要将这里稍微修改下就可以了”的想法,一不留神就会修改现有的代码。但是需要注意的是,如果要对已经测试完毕的现有代码进行修改,就必须在修改后重新进行测试。
使用Adapter模式可以在完全不改变现有代码的前提下使现有代码适配于新的接口(API)。此外,在Adapter模式中,并非一定需要现成的代码。只要知道现有类的功能,就可以编写出新的类。
4.3 版本升级与兼容性
软件的生命周期总是伴随着版本的升级,而在版本升级的时候经常会出现“与旧版本的兼容性”问题。如果能够完全抛弃旧版本,那么软件的维护工作将会轻松得多,但是现实中往往无法这样做。这时,可以使用Adapter模式使新旧版本兼容,帮助我们轻松地同时维护新版本和旧版本。
例如,假设我们今后只想维护新版本。这时可以让新版本扮演Adaptee角色,旧版本扮演Target角色。接着编写一个扮演Adapter角色的类,让它使用新版本的类来实现旧版本的类中的方法。
4.4 功能完全不同的类
当然,当Adaptee角色和Target角色的功能完全不同时,Adapter模式是无法使用的。就如同我们无法用交流220伏特电压让自来水管出水一样。
五、相关的设计模式
5.1 Bridge桥接模式
Adapter模式用于连接接口(API )不同的类,而 Bridge模式则用于连接类的功能层次结构与实现层次结构。
设计模式学习(一):Bridge桥接模式
5.2 Decorator装饰器模式
Adapter模式用于填补不同接口(API)之间的缝隙,而Decorator模式则是在不改变接口(API)的前提下增加功能。
设计模式学习(十二):Decorator装饰器模式
六、思考题
6.1
题目:
在示例程序中生成PrintBanner类的实例时,我们采用了如下方法,即使用Print类型的变量来保存PrintBanner实例。
Print p = new PrintBanner ( "Hello");
请问我们为什么不像下面这样使用PrintBanner类型的变量来保存PrintBanner的实例呢?
PrintBanner p = new PrintBanner ( "Hello");
答案:
明确地表明程序的意图,即“并不是使用PrintBanner类中的方法,而是使用Print接口中的方法”。
上一篇: 速成Web安全:一句话轻松实现木马攻击
推荐阅读
-
NeurIPS 2022 | 最强斗地主AI!网易互娱AI Lab提出基于完美信息蒸馏的方法-完美信息蒸馏(PTIE) 在斗地主游戏中,非完美信息的引入主要是由于三位玩家均不能看到别人的手牌,对于任意一位玩家而言,仅可知道其余两位玩家当前手牌的并集,而难于精准判断每位玩家当前手牌。完美信息蒸馏的思路是针对这种非完美问题,构建一个第三方角色,该角色可以看到三位玩家的手牌,该角色在不告知每位玩家完美信息的情况下通过信息蒸馏的方式引导玩家打出当前情况下合理的出牌。 以强化学习常用的 Actor-Critic 算法为例,PTIE 在 Actor-Critic 算法的应用中可以利用 Critic 的 Value 输出作为蒸馏手段来提升 Actor 的表现。具体而言即在训练中 Critic 的输入为完美信息(包含所有玩家的手牌信息),Actor 的输入为非完美信息(仅包含自己手牌信息),此种情况下 Critic 给予的 Value 值包含了完美信息,可以更好地帮助 Actor 学习到更好的策略。 从更新公式上来看,正常的 Actor-Critic 算法 Actor 更新的方式如下: 在 PTIE 模式下,对于每个非完美信息状态 h,我们可以在 Critic 中构建对应的完美信息状态 D(h),并用 Critic 的输出来更新 Actor 的策略梯度,从而达到完美信息蒸馏的效果。 PTIE 框架的整体结构如下图所示: 无论是训练还是执行过程中智能体都不会直接使用完美信息,在训练中通过蒸馏将完美信息用于提升策略,从而帮助智能体达到一个更高的强度。 PTIE 的另一种蒸馏方式是将完美信息奖励引入到奖励值函数的训练中,PerfectDou 提出了基于阵营设计的完美信息奖励 node reward,以引导智能体学习到斗地主游戏中的合作策略,其定义如下: 如上所示,完美信息部分 代表 t 时刻地主手牌最少几步可以出完,在斗地主游戏中可以近似理解为是距游戏获胜的距离, 代表 t 时刻地主阵营和农民阵营距游戏获胜的距离之差, 为调节系数。通过此种奖励设计,在训练时既可以一定程度地引入各玩家的手牌信息(出完的步数需要知道具体手牌才能计算),同时也鼓励农民以阵营的角度做出决策,提升农民的合作性。 特征构建: PerfectDou 针对牌类游戏的特点主要构建了两部分特征:牌局状态特征和动作特征。其中牌局状态特征主要包括当前玩家手牌牌型特征、当前玩家打出的卡牌牌型特征、玩家角色、玩家手牌数目等常用特征,动作特征主要用于刻画当前状态下玩家的所有可能出牌,包括了每种出牌动作的牌型特征、动作的卡牌数目、是否为最大动作等特征。 牌型特征为 12 * 15 的矩阵,如下图所示: 该矩阵前 4 行代表对应每种卡牌的张数,5-12 行代表该种卡牌的种类和对应位置。 网络结构和动作空间设计 针对斗地主游戏出牌组合数较多的问题,PerfectDou 基于 RLCard 的工作上对动作空间进行了简化,对占比最大的两个出牌牌型:飞机带翅膀和四带二进行了动作压缩,将整体动作空间由 27472 种缩减到 621 种。 PerfectDou 策略网络结构如下图所示: 策略网络结构同样分为两部分:状态特征部分和动作特征部分。 在状态特征部分,LSTM 网络用于提取玩家的历史行为特征,当前牌局状态特征和提取后的行为特征会再通过多层的 MLP 网络输出当前的状态信息 embedding。 在动作特征部分,每个可行动作同样会经过多层 MLP 网络进行编码,编码后的动作特征会与其对应的状态信息 embedding 经过一层 MLP 网络计算两者间的相似度,并经由 softmax 函数输出对应的动作概率。 实验结果
-
适配器模式详解:第三部分的学习体验