设计模式之美笔记14 水深无声 2022-11-30 15:51 146阅读 0赞 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 ### 文章目录 ### * * 状态模式 * * 背景 * * 什么是有限状态机 * 状态机实现方式1:分支逻辑法 * 状态机实现方式2:查表法 * 状态机实现方式3:状态模式 * 迭代器模式 * * 迭代器模式的优势 * 讨论 * 实现支持快照功能的迭代器模式 * * 背景 * 解决方案1 * 解决方案2 * 备忘录模式 * * 原理和实现 * 如何优化内存和时间消耗 ## 状态模式 ## ### 背景 ### 状态模式一般用来实现状态机,状态机常用在游戏、工作流引擎等系统开发中。状态机的实现方式有多种,除了状态机,常见的还有分支逻辑法和查表法。 #### 什么是有限状态机 #### 有限状态机,finite state machine,FSM,简称状态机。有三个组成部分:状态state、事件event、动作action,事件也称为转移条件transition condition。事件的触发状态的转移和动作的执行,不过动作不是必须的,也可能是只转移状态,不执行任何动作。 举例,超级马里奥游戏,马里奥可变身多种形态,如小马里奥small Mario、超级马里奥super Mario、火焰马里奥fire Mario、斗篷马里奥cape Mario等。不同游戏情节下,各个形态相互转化,并相应的增减积分,如初始形态为小马里奥,吃蘑菇后变成超级马里奥,增加100积分。 马里奥的形态的转化就是个状态机。不同形态就是状态机的“状态”,游戏情节(如吃蘑菇)就是状态机的“事件”,加减积分就是状态机的“动作”,如吃蘑菇事件触发状态的转移:从小马里奥转移到超级马里奥,以及触发动作的执行(增加100积分)。 简化后的状态转移图如下: ![在这里插入图片描述][watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dqbDMxODAy_size_16_color_FFFFFF_t_70_pic_center] 如何将上图翻译为代码呢? 如下所示,其中obtainMushRoom()、obtainCape()、obtainFireFlower()、meetMonster()这几个方法,根据当前的状态和事件,更新状态和增减积分。 public enum State { SMALL(0), SUPER(1), FIRE(2), CAPE(3); private int value; private State(int value){ this.value = value; } public int getValue() { return this.value; } } public class MarioStateMachine { private int score; private State currentState; public MarioStateMachine(){ this.score = 0; this.currentState = State.SMALL; } public void obtainMushRoom(){ //todo } public void obatainCape(){ //todo } public void obtainFireFlower(){ //todo } public void meetMonster(){ //todo } public int getScore(){ return this.score; } public State getCurrentState(){ return this.currentState; } } public class ApplicationDemo { public static void main(String[] args) { MarioStateMachine mario = new MarioStateMachine(); mario.obtainMushRoom(); int score = mario.getScore(); State state = mario.getCurrentState(); System.out.println("mario score: "+score+"; state: "+state); } } ### 状态机实现方式1:分支逻辑法 ### 有三种方式实现状态机,最简单直接的实现方式是,参照状态转移图,将每个状态转移,原样翻译为代码,会包含大量的if-else,暂时称为分支逻辑法 public class MarioStateMachine { private int score; private State currentState; public MarioStateMachine(){ this.score = 0; this.currentState = State.SMALL; } public void obtainMushRoom(){ if (currentState.equals(State.SMALL)){ this.currentState = State.SUPER; this.score += 100; } } public void obatainCape(){ if (currentState.equals(State.SMALL) || currentState.equals(State.SUPER)){ this.currentState = State.CAPE; this.score += 200; } } public void obtainFireFlower(){ if (currentState.equals(State.SMALL) || currentState.equals(State.SUPER)){ this.currentState = State.FIRE; this.score += 300; } } public void meetMonster(){ if (currentState.equals(State.SUPER)){ this.currentState = State.SMALL; this.score -= 100; return; } if (currentState.equals(State.CAPE)){ this.currentState = State.SMALL; this.score -= 200; return; } if (currentState.equals(State.FIRE)){ this.currentState = State.SMALL; this.score -= 300; return; } } public int getScore(){ return this.score; } public State getCurrentState(){ return this.currentState; } } 对简单的状态机,分支逻辑这种实现方式可以接受,但复杂的状态机,这种方式极易漏写或错写某个状态转移。此外,代码充斥大量的if-else,可读性和可维护性都很差。如果哪天改了状态机的某个状态转移,要在冗长的分支逻辑找到对应的代码进行修改,很容易改错,引入bug。 ### 状态机实现方式2:查表法 ### 状态机除了用状态转移图表示,还可用二维表表示。如下,第一维表示当前状态,第二维表示事件,值表示当前状态经过事件后,转移到的新状态和执行的动作。 <table> <thead> <tr> <th></th> <th>E1(Got MushRoom)</th> <th>E2(Got Cape)</th> <th>E3(Got Fire Flower)</th> <th>E4(Meet Monster)</th> </tr> </thead> <tbody> <tr> <td>Small</td> <td>super/+100</td> <td>cape/+200</td> <td>fire/+300</td> <td>/</td> </tr> <tr> <td>Super</td> <td>/</td> <td>cape/+200</td> <td>fire/+300</td> <td>small/-100</td> </tr> <tr> <td>Cape</td> <td>/</td> <td>/</td> <td>/</td> <td>small/-200</td> </tr> <tr> <td>Fire</td> <td>/</td> <td>/</td> <td>/</td> <td>small/-300</td> </tr> </tbody> </table> 表中的斜杠表示不存在这种状态转移。 相较于分支逻辑的实现方式,查表法的代码实现更加清晰,可读性和可维护性都更好。当修改状态机时,只需修改transitionTable和actionTable两个二维数组。实际上,如果把这两个二维数组存储在配置文件汇总,当腰修改状态机时,甚至不用改代码,只要修改配置文件即可。 public enum Event { GOT_MUSHROOM(0), GOT_CAPE(1), GOT_FIRE(2), MET_MONSTER(3); private int value; private Event(int value){ this.value = value; } public int getValue() { return this.value; } } public class MarioStateMachine { private int score; private State currentState; private static final State[][] transitionTable = { { SUPER,CAPE,FIRE,SMALL}, { SUPER,CAPE,FIRE,SMALL}, { CAPE,CAPE,CAPE,SMALL}, { FIRE,FIRE,FIRE,SMALL}, }; private static final int[][] actionTable = { { +100,+200,+300,+0}, { +0,+200,+300,-100}, { +0,+0,+0,-200}, { +0,+0,+0,-300}, }; public MarioStateMachine(){ this.score = 0; this.currentState = SMALL; } public void obtainMushRoom(){ executeEvent(Event.GOT_MUSHROOM); } public void obatainCape(){ executeEvent(Event.GOT_CAPE); } public void obtainFireFlower(){ executeEvent(Event.GOT_FIRE); } public void meetMonster(){ executeEvent(Event.MET_MONSTER); } private void executeEvent(Event event){ int stateValue = currentState.getValue(); int eventValue = event.getValue(); this.currentState = transitionTable[stateValue][eventValue]; this.score = actionTable[stateValue][eventValue]; } public int getScore(){ return this.score; } public State getCurrentState(){ return this.currentState; } } ### 状态机实现方式3:状态模式 ### 查表法的代码实现中,事件触发的动作只是简单的积分加减,用一个int类型的二维数组actionTable就能表示,二维数组的值表示积分的加减值。但如果要执行的动作较为复杂,是一系列复杂的逻辑操作(如加减积分,写数据,还可能发送消息通知等),没法使用简单的二维数组,也即是查表法的实现方式有一定的局限性。 虽然分支逻辑的实现方式不存在该问题,但又存在之前说的分支判断逻辑较多,难维护的问题,实际上,针对分支逻辑法存在的问题,可用状态模式解决。 状态模式通过将事件触发的状态转移和动作执行,拆分到不同的状态机,避免分支判断逻辑。 利用状态模式,补全MarioStateMachine类,其中IMario是状态的接口,定义所有的事件。SmallMario、SuperMario、CapeMario、FireMario是IMario接口的实现类,分别对应状态机的4个状态,原来所有的状态转移和动作执行的代码逻辑,都集中在MarioStateMachine类,现在这些代码逻辑分散到4个状态类中。 public interface IMario { State getName(); //以下是定义的事件 void obtainMushRoom(); void obtainCape(); void obtainFireFlower(); void meetMonster(); } public class SmallMario implements IMario { private MarioStateMachine stateMachine; public SmallMario(MarioStateMachine stateMachine){ this.stateMachine = stateMachine; } @Override public State getName() { return State.SMALL; } @Override public void obtainMushRoom() { stateMachine.setCurrentState(new SuperMario(stateMachine)); stateMachine.setScore(stateMachine.getScore()+100); } @Override public void obtainCape() { stateMachine.setCurrentState(new CapeMario(stateMachine)); stateMachine.setScore(stateMachine.getScore()+200); } @Override public void obtainFireFlower() { stateMachine.setCurrentState(new FireMario(stateMachine)); stateMachine.setScore(stateMachine.getScore()+300); } @Override public void meetMonster() { //...do nothing... } } public class SuperMario implements IMario { private MarioStateMachine stateMachine; public SuperMario(MarioStateMachine stateMachine){ this.stateMachine = stateMachine; } @Override public State getName() { return State.SUPER; } @Override public void obtainMushRoom() { //...do nothing... } @Override public void obtainCape() { stateMachine.setCurrentState(new CapeMario(stateMachine)); stateMachine.setScore(stateMachine.getScore()+200); } @Override public void obtainFireFlower() { stateMachine.setCurrentState(new FireMario(stateMachine)); stateMachine.setScore(stateMachine.getScore()+300); } @Override public void meetMonster() { stateMachine.setCurrentState(new SmallMario(stateMachine)); stateMachine.setScore(stateMachine.getScore()-100); } } //...省略CapeMario FireMario类... public class MarioStateMachine { private int score; private IMario currentState;//不再使用枚举类表示状态 public MarioStateMachine(){ this.score = 0; this.currentState = new SmallMario(this); } public void obtainMushRoom(){ this.currentState.obtainMushRoom(); } public void obtainCape(){ this.currentState.obtainCape(); } public void obtainFireFlower(){ this.currentState.obtainFireFlower(); } public void meetMonster(){ this.currentState.meetMonster(); } public int getScore(){ return this.score; } public State getCurrentState(){ return this.currentState.getName(); } public void setScore(int score){ this.score = score; } public void setCurrentState(IMario currentState){ this.currentState = currentState; } } 代码里,MarioStateMachine和各个状态类之间双向依赖,各个状态类需要更新MarioStateMachine的两个变量,score和currentState。 综上,像游戏这种比较复杂的状态机,包含的状态比较多,优先推荐使用查表法,而像电商下单、外卖下单等类型的状态机,状态并不多,状态转移较为简单,但事件触发执行的动作包含的业务逻辑可能较为复杂,更推荐使用状态模式。 ## 迭代器模式 ## iterator design pattern,也叫游标模式cursor design pattern。用来遍历集合对象,这里的集合对象也可叫“容器”“聚合对象”,实际就是包含一组对象的对象,如数组、链表、树、图、跳表。迭代器模式将集合对象的遍历操作从集合类中才分出来,放到迭代器类中,让两者的职责更单一。 迭代器用来遍历容器的,所以,一个完整的迭代器模式会涉及到容器和容器迭代器两部分内容。为达到基于接口而非实现编程,容器又包含容器接口、容器实现类,迭代器又包含迭代器接口、迭代器实现类。 举例,假设某个新的编程语言的基础类库中,还没提供线性容器对应的迭代器,需要从零开发。 线性数据结构包括数据和链表,在大部分编程语言中都有对应的类封装这两种数据结构,在开发中直接用即可。假设新编程语言中,这两个数据结构对应ArrayList和LinkedList类。此外,从两个类中抽象出公共接口,定义为List接口,以方便开发者基于接口而非实现编程,编写的代码能在两种数据存储结构之间灵活切换。 针对ArrayList和LinkedList这两个线性容器,设计实现对应的迭代器。按之前给的迭代器模式的类图,定义一个迭代器接口Iterator,以及针对两种容器的具体的迭代器实现类ArrayIterator和ListIterator.。先看Iterator接口的定义。 //接口定义方式1 public interface Iterator<E>{ boolean hasNext(); void next(); E currentItem(); } //接口定义方式2 public interface Iterator<E>{ boolean hasNext(); E next(); } Iterator接口有两种定义方式。 第一种定义中,next()方法用来将游标后移一位元素,currentItem()方法用来返回当前游标指向的元素。第二种定义,返回当前元素与后移一位这两个操作,要放到同一个方法next()中完成。 第一种定义方式更灵活,如可以多次调用currentItem()查询当前元素,而非移动游标。接下来的实现中,选择第一种接口定义方式。 再看ArrayIterator的代码实现,具体如下。 public class ArrayIterator<E> implements Iterator<E> { private int cursor; private ArrayList<E> arrayList; public ArrayIterator(ArrayList<E> arrayList){ this.cursor = 0; this.arrayList = arrayList; } @Override public boolean hasNext() { return cursor != arrayList.size();//cursor在指向最后一个元素时,hashNext } @Override public void next() { cursor++; } @Override public E currentItem() { if (cursor >= arrayList.size()){ throw new NoSuchElementException(); } return arrayList.get(cursor); } } public class Demo { public static void main(String[] args) { ArrayList<String> names = new ArrayList<>(); names.add("xzg"); names.add("wang"); names.add("zheng"); Iterator<String> iterator = new ArrayIterator<>(names); while(iterator.hasNext()){ System.out.println(iterator.currentItem()); iterator.next(); } } } 上面代码实现中,需要将待遍历的容器对象,通过构造函数传递给迭代器类。实际上,为了封装迭代器的创建细节,可在容器中定义一个iterator()方法,创建对应的迭代器。为能实现基于接口而非实现编程,还要将这个方法定义在List接口中。 public interface List<E> { Iterator iterator(); void add(E xzg); //..省略其他接口函数... } public class ArrayList<E> implements List<E> { @Override public Iterator iterator() { return new ArrayIterator(this); } @Override public void add(E xzg) { //... } } public class Demo { public static void main(String[] args) { List<String> names = new ArrayList<>(); names.add("xzg"); names.add("b"); names.add("c"); Iterator<String> iterator = names.iterator(); while (iterator.hasNext()){ System.out.println(iterator.currentItem()); iterator.next(); } } } LinkedIterator的代码结构和ArrayIterator完全相同。 结合刚才的例子,总结迭代器的设计思路。总结就是: 迭代器汇总需要定义hasNext() currentItem() next()三个最基本的方法。待遍历的容器对应通过依赖注入传递到迭代器类中,容器通过iterator()方法创建迭代器。 ### 迭代器模式的优势 ### 一般来说,遍历集合数据有三种方法:for循环、foreach循环、iterator迭代器。实际上,foreach只是一个语法糖,底层是基于迭代器实现。也即是,这两种都可看做迭代器遍历方式。 为什么要给容器设置个迭代器呢? 首先,对于类似数组和链表的数据结构,遍历方式较为简单,直接用for循环就够了,但对于复杂的数据结构(如树、图)来说,有各种复杂的遍历方式。如,树有前中后序、按层遍历,图有深度优先、广度优先遍历等。如果客户端代码实现遍历算法,势必会增加开发成本,且容易写错,而将遍历的逻辑写到容器类中,也会导致容器类代码的复杂性。 应对复杂性的方法就是拆分。可将遍历操作拆分到迭代器类中。如针对图的遍历,就可以定义DFSIterator、BFSIterator两个迭代器类,让他们分别实现深度优先遍历和广度优先遍历。 其次,将游标指向的当前位置等信息,存储在迭代器类中,每个迭代器独享游标信息。这样,就可以创建多个不同的迭代器,同时对同一个容器进行遍历而互不影响。 最后,容器和迭代器都提供了抽象的接口,方便开发时,基于接口而非实现编程。需要切换新的遍历算法时,比如,从前往后遍历链表切换为从后往前遍历链表,客户端代码只需将迭代器类从LinkedIterator切换为ReversedLinkedIterator即可。此外,添加新的遍历算法,只需扩展新的迭代器类,也更符合开闭原则。 ### 讨论 ### 1. 除了编程语言外,数据库如mysql ResultSet类提供的first() last() previous()等方法也可看做迭代器,ResultSet内部通过维护一个类型为ResultSetRows的rowData变量实现迭代,而rowData的迭代方法本质就是实现了标准库的Iterator接口。 2. 在java中,如果迭代器使用的同时删除容器中的元素,会导致迭代器报错,为什么,如何解决?容易导致next()检测大的modCount不等于expectedModCount从而引发ConcurrentModificationException,最好fail-fast,直接报错。 ### 实现支持快照功能的迭代器模式 ### #### 背景 #### 快照,指的是我们为容器创建迭代器时,相当于为容器拍了一张快照snapshot,之后即便增删容器的元素,快照中的元素并不会做相应的改动。而迭代器遍历的对象是快照而非容器,避免使用迭代器遍历的过程中,增删容器的元素,导致的不可预期的结果或报错。 如何实现上述的功能?骨架代码需要包含ArrayList SnapshotArrayIterator两个类。 public class ArrayList<E> implements List<E> { @Override public Iterator<E> iterator() { return new SnapshotArrayIterator(this); } @Override public void add(E xzg) { //... } } public class SnapshotArrayIterator<E> implements Iterator<E> { @Override public boolean hasNext() { return false; } @Override public E next() { //返回当前元素,且游标后移一位 //... } } #### 解决方案1 #### 先看最简单的解决方案,在迭代器中定义一个成员变量snapshot来存储快照。每当创建迭代器时,都拷贝一份容器中的元素到快照,后续的遍历操作都基于这哥迭代器自己持有的快照进行。 public class SnapshotArrayIterator<E> implements Iterator<E> { private int cursor; private ArrayList<E> snapshot; public SnapshotArrayIterator(ArrayList<E> arrayList){ this.cursor = 0; this.snapshot = new ArrayList<>(); this.snapshot.addAll(arrayList); } @Override public boolean hasNext() { return cursor < snapshot.size(); } @Override public E next() { E currentItem = snapshot.get(cursor); cursor++; return currentItem; } } 该方案虽然简单,但代价较高,每次创建迭代器时,都要拷贝一份数据到快照,增加内存的消耗。如果一个容器同时有多个迭代器在遍历元素,会导致数据在内存中重复存储多份。不过,java的拷贝属于浅拷贝,只是拷贝了对象的引用而已。 有没有方法,既支持快照,又不需要拷贝容器呢? #### 解决方案2 #### 在容器中,为每个元素保存两个时间戳,一个是添加时间戳addTimestamp,另一个是删除时间戳delTimestamp。当元素被加入到集合中,我们将addTimestamp作为当前时间,将delTimestamp设置为最大长整型值,当元素被删除,将delTimestamp更新为当前时间,表示已经被删除,不过只是标记删除。 同时,每个迭代器也保存一个迭代器创建时间戳snapshotTimestamp,也就是迭代器对应的快照的创建时间戳。当使用迭代器遍历容器时,只有满足addTimestamp<snapshotTimestamp<delTimestamp的元素,才是属于这个迭代器的快照。 如果元素的addTimestamp>snapshotTimestamp,说明元素在创建迭代器之后才加入的,不属于这个迭代器的快照。如果delTimestamp<snapshotTimestamp,说明元素在创建迭代器之前就被删除了,也不属于迭代器的快照。 这样就在不拷贝容器的情况下,在容器本身上借助时间戳实现快照功能。具体代码如下,暂时没考虑ArrayList的扩容。 public class ArrayList<E> implements List<E> { private static final int DEFAULT_CAPACITY = 10; private int actualSize;//不包含标记删除元素 private int totalSize;//包含标记删除元素 private Object[] elements; private long[] addTimestamps; private long[] delTimestamps; public ArrayList(){ this.elements = new Object[DEFAULT_CAPACITY]; this.addTimestamps = new long[DEFAULT_CAPACITY]; this.delTimestamps = new long[DEFAULT_CAPACITY]; this.totalSize = 0; this.actualSize = 0; } @Override public void add(E obj) { elements[totalSize] = obj; addTimestamps[totalSize] = System.currentTimeMillis(); delTimestamps[totalSize] = Long.MAX_VALUE; totalSize++; actualSize++; } @Override public void remove(E obj){ for (int i=0; i<totalSize; i++){ if(elements[i].equals(obj)){ delTimestamps[i] = System.currentTimeMillis(); actualSize--; } } } public int actualSize(){ return this.actualSize; } public int totalSize(){ return this.totalSize; } public E get(int i){ if (i >= totalSize){ throw new IndexOutOfBoundsException(); } return (E)elements[i]; } public long getAddTimestamp(int i){ if (i >= totalSize){ throw new IndexOutOfBoundsException(); } return addTimestamps[i]; } public long getDelTimestamp(int i){ if (i >= totalSize){ throw new IndexOutOfBoundsException(); } return delTimestamps[i]; } } public class SnapshotArrayIterator<E> implements Iterator<E> { private long snapshotTimestamp; private int cursorInAll;//在整个容器中的下标,而非快照中的下标 private int leftCount;//快照中还有几个元素未被遍历 private ArrayList<E> arrayList; public SnapshotArrayIterator(ArrayList<E> arrayList){ this.snapshotTimestamp = System.currentTimeMillis(); this.cursorInAll = 0; this.leftCount = arrayList.actualSize(); this.arrayList = arrayList; justNext();//先跳到这个迭代器快照的第一个元素 } @Override public boolean hasNext() { return this.leftCount>=0;//注意是>=,而非> } @Override public E next() { E currentItem = arrayList.get(cursorInAll); justNext(); return currentItem; } private void justNext(){ while(cursorInAll < arrayList.totalSize()){ long addTimestamp = arrayList.getAddTimestamp(cursorInAll); long delTimestamp = arrayList.getDelTimestamp(cursorInAll); if (snapshotTimestamp>addTimestamp && snapshotTimestamp<delTimestamp){ leftCount--; break; } cursorInAll++; } } } 实际上,上述解决方案相当于解决一个问题,又引入另一个问题。ArrayList底层依赖数组,原本可支持快速的随机访问,但现在删除数据并非真正删除,而是通过时间戳标记删除,导致无法支持按照下标快速随机访问了。 这个问题怎么解决?可以在ArrayList中存储两个数组,一个支持标记删除,用于实现快照遍历功能;一个不支持标记删除,用于支持随机访问。 另外,解决方案2中,删除元素只是被标记删除,被删除的元素即便没有迭代器使用情况下,也不会从数组中真正删除,会导致不必要的内存占用,如何优化?类似数组动态扩容和缩容,删除元素时可比较被删除元素的总数,在被删除元素总数<总数的一半时,进行resize数组,清空被删除的元素,回收空间。 ## 备忘录模式 ## 应用场景主要是用来防止丢失、撤销、恢复等。 ### 原理和实现 ### 备忘录模式,也叫快照(snapshot)模式,memento design pattern,定义:Captures and externalizes an object’s internal state so that it can be restored later, all without violating encapsulation. 在不违背封装原则的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,以便之后恢复对象之前的状态。 该模式的定义主要表达两部分内容。一部分是,存储副本以便后期恢复,另一部分是,要在不违背封装原则的前提下,进行对象的备份和恢复。 需搞清楚两个问题: * 为什么存储和恢复副本会违背封装原则? * 备忘录模式如何做到不违背封装原则? 假设这样一道面试题,希望你编写一个小程序,可接收命令行的输入,用户输入文本时,程序将其追加存储到内存文本中,用户输入":list",程序在命令行中输出内存文本的内容;用户输入":undo",程序会撤销上一次输入的文本,也就是从内存文本中将上次输入的文本删除。 例子如下: >hello >:list hello >world >:list helloworld >:undo >:list hello 如何编程实现?一种思路: public class InputText { private StringBuilder text = new StringBuilder(); public String getText(){ return text.toString(); } public void append(String input){ text.append(input); } public void setText(String text){ this.text.replace(0,this.text.length(),text); } } public class SnapshotHolder { private Stack<InputText> snapshots = new Stack<>(); public InputText popSnapshot(){ return snapshots.pop(); } public void pushSnapshot(InputText inputText){ InputText deepClonedInputText = new InputText(); deepClonedInputText.setText(inputText.getText()); snapshots.push(deepClonedInputText); } } public class ApplicationMain { public static void main(String[] args) { InputText inputText = new InputText(); SnapshotHolder snapshotHolder = new SnapshotHolder(); Scanner scanner = new Scanner(System.in); while (scanner.hasNext()){ String input = scanner.next(); if (input.equals(":list")){ System.out.println(inputText.toString()); }else if (input.equals(":undo")){ InputText snapshot = snapshotHolder.popSnapshot(); inputText.setText(snapshot.getText()); }else{ snapshotHolder.pushSnapshot(inputText); inputText.append(input); } } } } 实际上,备忘录模式的实现很灵活,也没有固定的实现方式,不同的业务需求、不同的编程语言,代码实现可能都不大一样。上面的代码基本实现最基本的备忘录的功能。深究下,还有些问题要解决,就是不违背封装原则。而上面代码并不满足这点,原因: 1. 为了能用快照恢复InputText对象,在InputText类中定义了setText()方法,但该方法可能被其他业务使用,所以,暴露了不该暴露的方法违背封装原则; 2. 快照本身是不可变的,理论上,不应该包含任何set()方法,但上面代码实现中,快照这个业务模型复用InputText类的定义,而InputText类本身有一系列修改内部状态的方法,用InputText类表示快照违背封装原则 针对上面的问题,做些修改,一是定义一个独立的类Snapshot表示快照,而非复用InputText类,该类只暴露get()方法,没有set()方法;二是在InputText类中,把setText()方法重命名为restoreSnapshot()方法,用意更明确,只用来恢复对象。重构后: public class InputText { private StringBuilder text = new StringBuilder(); public String getText(){ return text.toString(); } public void append(String input){ text.append(input); } public Snapshot createSnapshot(){ return new Snapshot(text.toString()); } public void restoreSnapshot(Snapshot snapshot){ this.text.replace(0,this.text.length(),snapshot.getText()); } } public class Snapshot { private String text; public Snapshot(String text){ this.text = text; } public String getText(){ return this.text; } } public class SnapshotHolder { private Stack<Snapshot> snapshots = new Stack<>(); public Snapshot popSnapshot(){ return snapshots.pop(); } public void pushSnapshot(Snapshot snapshot){ snapshots.push(snapshot); } } public class ApplicationMain { public static void main(String[] args) { InputText inputText = new InputText(); SnapshotHolder snapshotHolder = new SnapshotHolder(); Scanner scanner = new Scanner(System.in); while (scanner.hasNext()){ String input = scanner.next(); if (input.equals(":list")){ System.out.println(inputText.toString()); }else if (input.equals(":undo")){ Snapshot snapshot = snapshotHolder.popSnapshot(); inputText.restoreSnapshot(snapshot); }else{ snapshotHolder.pushSnapshot(inputText.createSnapshot()); inputText.append(input); } } } } 实际上,上述代码就是典型的备忘录模式的代码实现,很多书籍中也是这种实现方法。 除了备忘录模式,还有一个和它很类似的概念,“备份”,更常提到。备忘录模式跟备份有什么区别和联系呢?实际上,应用场景很类似,在防丢失、恢复、撤销等场景。区别是,备忘录模式更侧重代码的设计和实现,备份更侧重架构设计或产品设计。 ### 如何优化内存和时间消耗 ### 如果备份的对象数据比较大,备份频率又比较高,那快照占用的内存会比较大,备份和恢复的耗时比较长。这个问题如何解决? 不同应用场景有不同的解决方案。如前面的例子,应用场景是利用备忘录实现撤销操作,而且仅仅支持顺序撤销,也就是说每次操作只能撤销上一次的输入,不能跳过上次输入撤销之前的输入。这种情况下,为节省内存,不需要在快照中存储完整的文本,只需记录少许信息,如在获取快照当下的文本长度,用这个值结合InputText类对象存储的文本做撤销操作。 再举例,假设每当有数据改动,都要生成一个备份,以备之后恢复。如果备份的数据很大,这样高频的备份,不管对存储(内存或硬盘)的消耗,还是对时间的消耗,都无法接受。这种情况,一般会采用“低频率全量备份”和“高频率增量备份”相结合的方法。 全量备份就是上面例子的把所有数据“拍个快照”保存下来,而“增量备份”指的是记录每次操作或数据变动。当需要恢复到某一时间点的备份的时候,如果这一时间点有做全量备份,直接拿来恢复即可。如果没有对应的全量备份,先找到最近的一次全量备份,然后用它来恢复之后执行此次全量备份跟这一时间点之间的所有增量备份,也就是对应的操作或数据变动。这样减少全量备份的数量和频率,减少对时间、内存的消耗。 > 其实mysql也有,低频全量备份,binlog增量备份,来恢复数据。游戏存档也是。 [watermark_type_ZmFuZ3poZW5naGVpdGk_shadow_10_text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3dqbDMxODAy_size_16_color_FFFFFF_t_70_pic_center]: /images/20221123/32d01389cbdb43a3a3e4211aee68d006.png
相关 设计模式之美笔记16 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 解释器模式 解释器模式的原理和实现 深藏阁楼爱情的钟/ 2022年12月01日 11:53/ 0 赞/ 136 阅读
相关 设计模式之美笔记15 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 访问者模式 访问者模式的诞生 我就是我/ 2022年12月01日 05:16/ 0 赞/ 144 阅读
相关 设计模式之美笔记14 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 状态模式 背景 什么 水深无声/ 2022年11月30日 15:51/ 0 赞/ 147 阅读
相关 设计模式之美笔记13 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 策略模式 策略模式的原理和实现 忘是亡心i/ 2022年11月30日 12:27/ 0 赞/ 145 阅读
相关 设计模式之美笔记12 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 观察者模式 原理及应用场景剖析 深碍√TFBOYSˉ_/ 2022年11月30日 04:18/ 0 赞/ 174 阅读
相关 设计模式之美笔记11 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 门面模式 门面模式的原理和实现 ゞ 浴缸里的玫瑰/ 2022年11月28日 13:41/ 0 赞/ 156 阅读
相关 设计模式之美笔记10 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 序言 代理模式 桥接模式 柔情只为你懂/ 2022年11月28日 10:36/ 0 赞/ 150 阅读
相关 设计模式之美笔记9 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 工厂模式 1. 简单工厂 待我称王封你为后i/ 2022年11月28日 00:41/ 0 赞/ 148 阅读
相关 设计模式之美笔记8 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 单例模式 1. 为什么要使用单例 柔光的暖阳◎/ 2022年11月26日 07:52/ 0 赞/ 154 阅读
相关 设计模式之美笔记7 > 记录学习王争的设计模式之美 课程 笔记和练习代码,以便回顾复习,共同进步 文章目录 实战1:id生成器的重构 1. 需求背景 女爷i/ 2022年11月25日 13:19/ 0 赞/ 187 阅读
还没有评论,来说两句吧...