effective java 第三周

2018-03-09  本文已影响28人  bruvir

第6章 枚举和注解

第30条:用 enum 代替 int 常量

在没有 enum 之前表示枚举类型的常用模式时声明一组具名的 int 常量或 String 常量,这种方式有非常多不足,在类型安全性和使用方便性方面没有帮助。

Java 1.5 版本提供了 enum 枚举,其基本想法是:通过公有的静态 final 域为每个枚举常量导出实例的类,不提供可访问的构造器,不能扩展,所以枚举类型是实例受控的。

枚举提供了编译时的类型安全,如果某个参数指定就是枚举类型,那么传递到这里就一定是枚举值之中的一个。

枚举类型还允许添加任意的域和方法(这里可以将它看成就是一个普通的类),数据,行为和常量关联起来。

在枚举中有一种方法可以将不同的行为与每个枚举常量关联起来,而不是去根据类型判断执行不同的行为:在枚举类型中声明一个抽象的 apply 方法,并在特定于常量的类主题中覆盖它。这种方法被称为特定于常量的方法实现。

代码如下:

public enum Operation{
  PLUS (double apply(double x, double y){return x + y;}),
  MINUS {double apply(double x, double y){return x - y;}},
  TIMES {double apply(double x, double y){return x * y;}},
  DIVIDE {double apply(double x, double y){return x / y;}};
  
  abstract double apply(double x, double y);
}

与 int 常量相比,枚举有一个小小的性能缺点,即装载和初始化枚举时会有空间和时间的成本。所以在早期的 Android 版本中并不推荐使用枚举,过于占用内存,后来有所改善,可以适当地使用。

第31条:用实例域代替序数

许多枚举天生就合一个单独的 int 值相关联,而所有枚举都有一个 ordinal 方法,它返回每一个枚举常量在类型中的数字位置,这个就是序数。

不建议使用序数的原因是:不好维护,一旦重新排序了,之前依靠 oridinal 数字的功能就会被破坏;也无法单独给某个 int 值添加常量。

所以,推荐的做法是永远不要根据枚举的序数导出与它相关联的值,而是要将它保存在一个实例域中:

public enum Ensemble {
  SOLO(1), DUET(2),TRIO(3),
  QUARTET(4),QUINTET(5);
  
  private final int numberOfMusicians;
  
  Ensemble(int size) {this.numberOfMusicians = size;}
  
  public int numberOfMusicians(){return numberOfMusicians;}
}

第32条:用 EnumSet 代替位域

如果一个枚举类型的元素主要用在集合中,一般就使用 int 枚举模式,将2的不同倍数赋予每个常量

public class Text{
    public static final int STYLE_BOLD = 1;  // 1
    public static final int STYLE_ITALIC = 1 << 1;  // 2
    public static final int STYLE_UNDERLINE = 1 << 2;   // 4
    public static final int STYLE_STRIKETHROUCH = 1 << 3; // 8
}

这个就是位域,可以用 OR 位运算将几个常量合并到一个集合中,也可以使用位操作高效地执行像联合和交集这样的集合操作。但是位域有着 int 枚举常量的所有缺点,甚至更多,打印的时候难以阅读,遍历位域表示的所有元素也不容易。

EnumSet 类有效地表示从单个枚举类型中提取的多个值的多个集合。在其内部实现上,每个 EnumSet 内容都表示为位矢量,如果底层的枚举类型有64或更少的元素,整个 EnumSet 用单个 long 表示,如果多于64,则用 long[] 表示。因此它的性能比得上位域的性能。

public class Text{
  public enum Style {BOLD, ITALIC, UNDERLINE, STRIKETHROUCH}
  
  // any Set could be passed in, but EnumSet is clearly best
  public void applyStyles(Set<Style> styles) {...}
}

// usage
text.applyStyles(EnumSet.of(Style.BOULD, Style.ITALIC));

EnumSet 有一个缺点,截止到 Java 9,都无法创建不可变的 EnumSet。

第33条:用 EnumMap 代替序数索引

在那条推荐用实例域代替序数时阐明了序数的缺点,所以也不适合用序数来作为数组索引。数组不知道序数索引代表着什么,需要你手工标注这些索引的输出,int 不能提供枚举的类型安全,所以使用正确的 int 值就是你的职责,无法在编译时发现问题。

所以当我们需要一个容易来表示从枚举到值的映射时,有一个非常快速的容器叫 EnumMap,专门用于枚举键。

public class Herb{
  public enum Type {ANNUAL, PERENNIAL, BIENNIAL}
  ...
}

Map<Herb.Type, Set<Herb>> herbsByType = new EnumMap();
for(Herb.Type t: Herb.Type.values())
  herbsByType.put(t, new HashSet<Herb>());

以上的这个使用在运行速度方面可以和使用序数的程序相媲美,也没有不安全的转换,不必手工标注这些索引的输出。

第34条:用接口模拟可伸缩的枚举

枚举不可扩展,但有时我们有需要设计可以让用户进行扩展的的功能。这时候我们可以接口实现,由于枚举类型可以通过给操作码类型和枚举定义接口,来实现任意接口。

比如计算器中的操作类型,在提供基本操作的基础上,可以让用户提供他们自己的操作。

public interface Operation {
  double apply(double x, double y);
}

public enum BasicOperation implement Operation {
  PLUS("+"){
    public double apply(double x, double y) {return x + y;}
  },
  MINUS("-"){...},
  TIMES("*"){...},
  DIVIDE("/"){...};
  
  private final String symbol;
  BasicOperation(String symbol){
    this.symbol = symbol;
  }
  @Override
  public String toString(){
    return symbol;
  }
}
// 枚举类型 BasicOperation 不可扩展,但是可以扩展接口 Operation
public enum ExtendedOperation implements Operation {
  EXP("^"){
    public double apply(double x, double y){return Math.pow(x, y);}
  },
  REMAINDER("%"){...}
  
  private final String symbol;
  ExtendedOperation(String symbol){
    this.symbol = symbol;
  }
  @Override
  public String toString(){
    return symbol;
  }
}

虽然无法编写可扩展的枚举类型,却可以通过编写接口以及实现该接口的基础枚举类型,对它进行模拟。这样允许客户端编写自己的枚举来实现接口。如果 API 是根据接口编写的,那么可以使用基础枚举类型的任何地方,也都可以使用这些枚举。

第35条:注解优先于命名模式

这一条介绍了 Java 1.5 之前,一般使用命名模式来表明有些程序元素需要通过某种工具或者框架进行特殊处理。

命名模式有很多的缺点,而这些注解都解决了,比如编译检查,作用位置,提供参考值。

注解的元注解可以声明注解是运行时保留,还是只有编译时,也可以表明只作用在类,方法,域或其他程序元素。这些都是命名模式无法做到的。

大多数程序员都不必定义注解类型,但是所有的程序员都应该使用 Java 平台所提供的预定义的注解类型。

第36条:坚持使用 Override 注解

Override 注解只能用在方法声明中,表示被注解的方法声明覆盖了超类型的一个声明。当我们想要覆盖超类的某个方法时,如果我们有加上 Override ,那么方法签名写错时,编译器就会帮你发现这个错误。

而现在的 IDE 更是提供了坚持使用 Override 注解的另一种理由。IDE 具有自动检查功能,当有一个方法没有 Override 注解,却覆盖了超类方法,IDE 就会产生一条警告,这可以帮忙在未编译之前就发现问题。

只有一个例外,在具体的类中,不必标注你确信覆盖了抽象方法声明的方法,但是这样做也没有什么坏处。

第37条:用标记接口定义类型

标记接口是没有包含方法声明的接口,只是指明一个类实现了具有某种属性的接口。比如 Serializable 接口,它就没有声明一个方法,但是实现了这个接口的类,它的实例就是可以被序列化的。

标记注解,没有参数,只是用来标注被注解的元素。

标记接口比标记注解好的最重要一点原因:标记接口定义的类型是由标记类的实例实现的;标记注解则没有定义这样的类型。这个类型允许你在编译时就能捕抓到错误,而注解需要到运行时才能发现。

标记接口胜过标记注解的另一个优点是:它们可以被更加精确地进行锁定。因为注解可以利用 @Target(ElementType.Type) 声明运用到任何类或接口上,范围广,而如果标记成接口,就可以用它将唯一的一个接口进行扩展。

而标记注解胜过标记接口的最大优点在于,可以通过默认的方法添加一个或者多个注解类型,给已被使用的注解类型添加更多信息,可以演变。而接口的演变是比较困难的。

标记接口和标记注解各有用处。如果想要定义一个任何新方法都不会与之关联的类型,标记接口就是最好的选择。如果想要标记程序元素而非类和接口,考虑到未来可能要给标记添加更多的信息,或者标记要适合于已经广泛使用了注解类型的框架,那么标记注解就是正确的选择。

插播一些注解的知识点:

可以通过给注解添加元注解的方式,让注解变得更加灵活。常见的元注解有

  1. Target: 表示用在哪, CONSTRUCTOR 构造器,FIELD 域,LOCAL_VARIABLE 局部变量,METHOD 方法, PACKAGE 包
  2. Retention:生命周期,SOURCE 只在源码显示,编译时丢弃,CLASS 编译时记录到 class,运行时忽略,RUNTIME 运行时存在,可以反射读取
  3. Inherited: 允许子类继承
  4. Documented: 生成 javadoc 时会包含注解

在 effective java 第三版中,第7章是 lamdbas and streams,而第二版中并没有相关内容,这里也把 lambdas and streams 的内容插入,但并不调整条目数字。包括前面的内容,有一部分也是插入了第3版中才有的条目,但以第二版的条目数为主。

第3版的第7章,Lambdas and Streams

在 Java 8 中,添加了功能接口,lambdas,方法引用来使得创建一个功能对象变得更容易。也添加了流式 API 来提供对序列数据元素的处理。这些与 RxJava 的一部分特性颇为相似。

条目42:使用 lambdas 代替匿名类

Java 中定义的方法不可能完全独立,也不能将方法作为参数或返回一个方法给实例。

曾经,我们使用待着抽象方法的接口(或抽象类)来作用一个函数类型,他们代表着一个函数或动作,被称为函数对象,以匿名类的形式实现。

以前是这么实现的:

Collections.sort(words, new Comparator<String>() {
  public int compare(String s1, String s2) {
        return Integer.compare(s1.length(), s2.length());
    }
});

在 Java 8,修改了这种语法,允许用户使用 lambda 表达式来创建这些函数接口的实例,Lambda 表达式是对象了:

// Lambda express as function object(replaces anonymous class)
Collections.sort(words,
                (s1,s2) -> Integet.compare(s.length(), s2.length()));

因为老编译器可以可以类型推导,所以可以省略 lambda 参数来让语句看起来更简洁。但无法类型推导时需要补充。

在之前的内容中讲到,标记接口是一种没有方法的接口,相似地,函数式接口是只包含一个抽象方法声明的接口。

lambda 表达式的结构:

除了可以使用箭头语法创建 lambda 表达式,也可以使用双冒号(::)操作符将一个常规方法转化成方法引用:

List<Integer> list = Arrays.asList(1, 2, 3, 4, 5, 6);
list.forEach(n -> System.out.println(n));

list.forEach(System.out::println);

lambda 表示式缺乏名字和文档,所以如果一个计算不是可自我解释的,或者超过一定行数,就不要放在 lambda 表达式中。

lambda 表达式也有一些缺点,它只能用在函数接口,只能有一个方法,this无法引用到自己。

如果想创建一个抽象类的实例,也只能用匿名类,而不能使用 lambda。

条目43:多用方法引用而不是 lambda

Java 提供了一种比 lambda 还要简洁的方式来创建一个函数对象。这部分主要是使用双冒号将方法转化成 方法引用,使之更简洁。

map.merge(key, 1, (count, incr) -> count + incr);
// 使用方法引用,同样是表达两个数相加的函数
map.merge(key, 1, Integer::sum);

有时候也并不是使用方法引用便一定比 lambda 表达式更简洁,酌情。

所以,总结就一句话:当方法引用更简洁时,使用方法引用;否则,使用 lambda 表达式。

条目44:更多使用标准函数接口

由于现在 Java 有了 lambda,所以你可以写更多的以函数对象作为参数的构造器,方法。

但是要注意函数对象并无法拿到类实例中的方法。

java.util.function 包中提供了大量的关于 collection 的标准函数接口供用户使用,优先使用这些标准函数而不是自己去定义。

大部分的标准函数接口都只是支持基本数据类型,注意看好是否会自动装箱与拆箱。

如果标准函数接口不符合你的需求,需要定义新的接口时,请像定义普通接口那样慎重。另外加上注解 @FunctionalInterfact,这个注解可以让阅读者清晰的知道这是个函数接口,也可以让编译器帮忙检查是否只有一个方法。

条目45:使用流式 API

Java 8 中提供了 流式 API 来串行或并行地处理数据操作。

流式 API 更简洁,更清晰,但如果阅读难度也高一些,特别是使用不是那么恰当的时候。

流,代表着一个有限或者无限的数据元素串。

Stream 和其他集合类的区别在于:其他集合类主要关注有限数量的数据的访问和有效管理(增删改),而 Stream 并没有提供访问和管理元素的方式,而是通过声明数据源的方式,利用可计算的操作在数据源上执行。

流中的元素可以来自任何地方,比如集合,数组,文件,正则表达式匹配的,随机器产生的及其其他流的。

比如:Arrays.asList(1,2,3).stream() 就创建了一个 流。

流中的元素可以对象引用,也可以是 int, long, double(目前基本类型就支持这3个)。

Stream 提供串行和并行两种类型的流,保持一致的接口,提供函数式编程方式,以管道方式提供中间操作和终点操作。

流管道可以有零个或多个中间操作,和一个终点操作。中间操作间一个流转变成另一个,而终点操作在流上进行最后的计算。

流的特点:

  1. 不存储数据,只是通过管道将数据源的元素传递给操作。
  2. 函数式编程,流的操作不会修改数据源。
  3. 延迟操作。很多中间操作是延迟执行的,只有到终点操作才会按操作顺序执行。
  4. 可以解绑。对于无限数据的流,有些操作是可以在有限的时间内完成的。
  5. 纯消费,流的元素只能访问一次,没有回头路。

流式 API 也有一些缺点:比如中间处理操作过后,就无法拿到中间值,没有参数类型,所以参数名字的命名要特别精准,方便阅读。

有些任务适合用流式,有些适合用遍历处理,或者两个合起来一起处理,没有一定的规则。

条目46:选择没有副作用的流式方法

副作用是指行为参数在执行的时候有输入输出,Java 并不能保证这些副作用对其他线程可见,也不保证相同流管道上的同样的元素的不同操作运行在同一个线程中。

流式并只是API,他是基于函数式编程的思想。流式范式使得你的计算是转换的组合串行,这些转换的结果会和他的上一个阶段紧密相关。而一个纯的函数式编程方法,它的输入应该只会和他的输入有关,并不依赖于其他阶段,也不更新其他阶段。所以为了实现更加纯正的函数式编程,任何传递给流操作的函数对象,包括中间操作和终结操作,都应该是无副作用的。

比如:

// use the streams API but not the paradigm, don't do this!
Map<String, Long> freq = new HashMap<>();
try (Stream<String> words = new Scanner(file).tokens()) {
    words.forEach(word ->{
        freq.merge(word.toLowerCase(), 1L, Long::sum);
    })
}

上面这个使用就不好,它使用了流,lambda, 函数对象。但其全部的任务都是在 forEach 这个操作中完成的,使用一个 lambda 去更改外部的 freq。

如果是这样呢:

//proper use of streams to initialize a frequency table
Map<String, Long> freq;
try (Stream<String> words = new Scanner(file).tokens()){
    freq = words.
        collect(groupingBy(String::toLowerCase, countting()));
}

这个和上面那段代码完成的功能是一样的,但是 forEach 是所有终结操作中能力最低的,最不流式友好的,一般只用于报告打印一个流式计算的结果,而不是执行这些计算。

而 collect 用于将流中的元素收集成到一个集合中,还有一些 API 也是将元素集成到集合中,比如 toList(), toSet(), toCollection(collectionFactory)。

joining 方法只作用在 CharSequence 实例上,返回一个元素集合。

像 toMap, groupingBy, joining 这样重要的方法也是需要了解一下。

条目47:返回类型选用 Collection 而不是 Stream

在 Stream 还没有被加入到 Java 的年代,当方法需要返回一些元素时,有以下几个类型可以选择:Collection, Set, List, Iterable, 数组。现在多了一种选择,可以返回 Stream。

由于 Stream 无法做遍历,所以如果用户是希望拿到返回之后去做遍历,那么直接返回 Stream 就不合适,而如果通过是由 Stream 来返回 Iterable,则万一用户是处理数据元素就不行。

只有在能很肯定用户只想用这个返回去做流管道处理,才可以将返回类型选为 Stream;同样也只有在很肯定用户只想做遍历的才能将返回类型选为 Iterable。但是如果是提供一个公有 API 返回数据序列,那么应该既能让用户去做流管道操作,也能让用户去做遍历,所以优先选择 Collection。

Collection 是 Iterable 的子类型,又有一个 stream() 方法,所以既能提供遍历,也能提供 stream。

Collection 也有缺点,它有一个返回 int 的 size() 方法,注定了它的元素数量最大就是Integer 的最大值了。

条目48:谨慎地使用并行流

前面说到流可以分为串行和并行的,有的时候我们在完成任何时多使用一些线程可以提供完成速度,当要小心处理并发和同步的问题,同样,并行流也存在许多问题。

除非显式地创建并行流,否则 Java 库中创建的都是串行流。Java 类库并没有在管道并行和启发式错误上提供任何想法,即便是在非常好的环境中,如果数据来源是来自 Stream.iterate, 或者中间操作 limit,那么管道并行也不太可能提高性能。

一般来说,并行流可以在以下这些情况下获得更高的性能:ArrayList, HashMap, HashSet , ConcurrentHashMap,数组, int range, long range。因为这些数据结构可以低成本第,准确地分割成更小的子集(spliterator方法分割),方便并行,也提供了一个很好的访问局部性(在内存中的存储更靠近)。

并行流不仅可能导致低性能,还可能导致错误的错误和不可预测的行为(安全性失败)。比如我们的操作最好是彼此(与上下操作)没有关联的,没有状态的,如果违反了这些,但我们是用并行的,那么它很可能会获得正确的结果,而如果使用并行的,那么失败的可能性就很大。

上一篇下一篇

猜你喜欢

热点阅读