Effective Java(3rd)-Item37 使用EnumMap而不是序号索引

  偶尔,你可能会看到代码使用序号方法(item37 )对数组或列表进行索引。例如,考虑这个简单的类代表了一个植物:

image.png

  现在假设你有一个花园拥有一个植物数组,你想要列出这些按生命周期分类的植物(一年生,多年生,或两年一次)。为了达到这个效果,你构造了三个set,一个用于每个生命周期,在花园中迭代,将每个植物放置在合适的set上。有写程序员会通过将集合放入按生命周期序号索引的数据来做到这一点:


image.png

  这种技术有效,但是充满了问题。因为数组与泛型不兼容(item28)。程序需要一个未经检查的强制转换,将不会干净地编译。因为数组不知道它的索引代表了什么,你不得不手动标记输出。但是,这种技术最严重的问题是,当你访问由枚举的序号索引的数组时,使用正确的int值是你的责任;ints不提供枚举的类型安全。如果你使用了错误值,程序将安静地做了错误的事情,或者如果你足够幸运,将抛出ArrayIndexOutOfBoundsException。

  这里有一个更好的方式来实现相同的作用。数组实际上是从枚举到值的 映射,所以最好使用Map。更具体地说,这里有一个使用枚举key而设计的非常快的map,叫作java.util.EnumMap。当程序被重写未使用EnumMap时,如下所示:


image.png

  这个程序更短,更清晰,更安全,与原始速度相当。这里没有不安全的强制转换;不需要手动标记输入因为map的keys是枚举,它们知道如何转换它们,并可打印字符串;在计算数组索引时不可能出现错误。EnumMap的速度与序号索引相当的原因是EnumMap在内部使用了这样的数组,但它对程序员隐藏了实现细节,联合了Map的特性和类型安全以及数组的速度。注意到EnumMap构造器接受key类型的Class对象:这是一个 有界的类型标记,提供了运行时泛型类型信息(item33)

  使用stream( item45 )来管理map,先前的程序会更短。这是基于stream的最简单代码,极大地复制了前面例子的行为:

image.png

  这串代码的问题在于,它选择了它自己map的实现,实际上它不会是EnumMap,所以它与显式的EnumMap不匹配版本的空间和时间性能。为了矫正这个问题,使用Collectors.groupingBy的三个参数的形式,允许调用方确定map的实现,使用mapFacotory参数:


image.png

  这种优化在像这样的玩具程序中是不值得做的,但是对于一个大量使用map的程序来说可能是至关重要的。
  基于stream的版本的行为与EnumMap版本略有不同,EnumMap版本总是为每个植物生命周期生成一个嵌套的map,基于stream的版本值只生成了一个嵌套版本,如果花园包含了一个或更多具有该生命周期的植物。所以,比如,如果花园包含了一年生植物和多年生植物但是没有两年期植物,plantsByLifeCycle的大小在EnumMap版本中将是3,在基于stream的两个版本中都是2.
  你可能会看到一个数组用序数索引(两次!)来表示来自两个枚举值的映射。比如,这个程序使用这样的数组来映射两相向相变(流体向固体是通过冷冻,流体向气体是煮,诸如此类):

  
image.png

  这个程序运行正常甚至显得优雅,但是外表是骗人的。想之前的简单花园例子看到的哪有,编译器没有办法值得序号和数组指数的关系。如果你在转化表中犯错了或忘记修改相或相的枚举,你的程序将在编译时失败。失败可能是ArrayIndexOutOfBoundsException,NullPointerException,或(更糟的是)沉默的错误行为。以及表的大小在阶段数上是二次方的,即使非空项的数目较小。
  同样,使用EnumMap你能做得更好。因为每个状态改变都基于一系列的状态枚举,最好将关系表示为从一个枚举(”从“阶段)到从第二个枚举(”到“阶段)到结果(相变)的映射。与相变相关联的两个阶段最好通过将它们与相变枚举相关联来捕获,然后使用初始化嵌套的EnumMap:


image.png

  初始化相变映射的代码有点复杂。映射的类型是Map>,这意味着”从(源)阶段映射到从(目标)阶段映射到过渡阶段。“此映射使用两个收集器的级联序列初始化的。第一个收集器通过源相分类,第二个从目标相映射创建了一个EnumMap。在第二个收集器()(x,y)->y)种的合并函数并未使用;之所以需要它,只是因为我们需要指定一个映射工厂才能获得EnumMap,收集器提供了可伸缩的工厂。本书的上一版使用显式迭代来初始化相变映射。代码更冗长,但可以说更容易理解。
  现在,假设你想要在这个系统中加上新的相:等离子体,或店里话气体。只有两个与此阶段相关的过渡:电离化,把气体运输到等离子体中;以及去化,将等离子体转化未气体。要更新基于数组的程序,你必须向阶段添加一个新的常量,向Phase.Transition添加两个新的常数,并用一个新的16元素版本替换原来的9元素数组。如果将太多或太少的元素添加到数组中,或者将元素放置得乱七八糟,你就不走运了:程序将会编译,但在运行时失败。基于EnumMap的版本更新,只需要将等离子体添加到相位列表中,将电离(气体,等离子体)和去电离(等离子体,气体)添加到相变列表中:

image.png

  这个程序负责所有其他的一切,几乎没有出错的机会。在内部映射是用数组来实现的,因此为了增加清晰度,安全性和易于维护,你只需在空间或时间上花费很少的费用。
  为了简洁起见,上面的示例用null来表示没有状态更改(其中往返是相同的)。这不是很好的实践,很可能在运行时导致NullPointweException.为这个问题设计一个干净而优雅的解决方案是令人惊讶的棘手的,由此产生的程序足够长以至于它们将本item中的主要材料中减少了。
  总之,几乎没有使用序数索引数组的使用场景:使用EnumMap代替。如果关系代表了多层面,使用EnumMap<...,EnumMap<...>>。使用Enum.ordinal是应用程序员很少应该遵守的一般原则的特例( item35)

本文写于2019.7.8,历时2天

你可能感兴趣的:(Effective Java(3rd)-Item37 使用EnumMap而不是序号索引)