EffectiveJava读书笔记01

本文为Effective Java中文版的读书笔记,可能部分术语听起来比较枯燥,但术语其实就是最精炼的总结,可以反复琢磨。由于时间仓促,看的囫囵吞枣,之后有空再刷。

1.创建和销毁对象

0. 预备知识

API相关概念

Application Programming Interface,应用编程接口,不要同java的interface混淆。使用API编写程序的程序员被称为该API的用户user,在类的实现中使用该API的类被称为该API的客户端client。

类、接口、构造器、成员以及序列化形式被统称为API元素(API element)。API由所有可在定义该API的包之外访问的API元素组成。任何客户端都可使用这些API元素,而API的创建者则负责支持这些API元素。Javadoc工具类在它的默认操作模式下也正是为这些元素生成文档。一个包的API是由该包的每个public类或者接口中所有public或protected成员和构造器组成。

java9新增了模块系统(module system),如果类库使用模块系统,其API就是类库的模块声明导出的所有包的导出API组合。

1. 静态工厂方法替代构造器

1. 概念

为了让客户端获取类的一个实例,最传统方法是提供一个公有的构造器。还有个方法,就是类提供一个公有的静态工厂方法(static factory method),只是一个返回类的实例的静态方法。如Boolean(boolean的装箱类)的范例,将基本类型转为Boolean对象引用。

public static Boolean valueOf(boolean b){
  return b ? Boolean.TRUE : Boolean.FALSE;
}

注意:和设计模式中的工厂方法不同

2. 和构造器的区别

优势

  1. 有名称,易于阅读,突出静态工厂方法之间的区别
  2. 不必每次调用创建新对象,能为重复的调用返回相同对象,实例受控(instance-controlled),可确保是Singleton或不可实例化,还可使得不可变的值类可以确保不会存在两个相等的实例(枚举保证)
  3. 可以返回原返回类型的任何子类型的对象,选择返回对象的类时更灵活
  4. 所返回的对象的类可以随着每次调用而发生变化,取决于静态工厂方法的参数值
  5. 方法返回的对象所属的类,在编写包含该静态工厂方法的类时可以不存在

第三点灵活性的一种应用是,API可以返回对象,同时又不会使对象的类变成公有的。这使得API变得非常简洁。适用于基于接口的框架(interface-based framework)。接口为静态工厂方法提供了自然返回类型。

第五点使得静态工厂构成了服务提供者框架(Service Provider Framework)的基础。如JDBC的API。

服务提供者

服务提供者框架是指这样一个系统:多个服务提供者实现一个服务,系统为服务提供者的客户端提供多个实现,并把他们从多个实现中解耦出来。

扫描二维码关注公众号,回复: 6709803 查看本文章

服务提供者框架中有三个重要的组件:服务接口(Service Interface),这是提供者实现的;提供者注册API(Provider Registration API),这是提供者用来注册实现的;服务访问API(Service Access API),这是客户端用来获取服务的实例。服务访问API是“灵活的静态工厂”,构成服务提供者框架的基础。

服务提供者框架的第四个组件:服务提供者接口(Service Provider Interface)是可选的,表示产生服务接口的实例的工厂对象。如果没有,则通过反射进行实例化。对于JDBC,Connection就是其服务接口的一部分。DriverManager.registerDriver是提供者注册API,DriverManager.getConnection是服务访问API,Driver是服务提供者接口。

服务提供者框架模式有多种变形。如,服务访问API可以返回比提供者需要的更丰富的服务接口,也就是桥接模式(Bridge);依赖注入框架可以被看做一个强大的服务提供者。Java6开始,java平台提供了一个通用的服务提供者框架java.util.ServiceLoader ,不需要自己再编写。

缺点

  1. 类如果没有public或protected修饰的构造器,不能被子类化。
  2. 程序员很难发现它们,最好在类或接口注释中展示,并遵循标准命名习惯

静态工厂常用命名习惯

  • from 类型转换方法,只有单个参数,返回该类型的一个相对应的实例
Date d = Date.from(instant);
  • of 聚合方法,带有多个参数,返回该类型的一个实例,把他们合并起来
Set<Rank> faceCards = EnumSet.of(JACK,PONY,ROSE);
  • valueOf 比from和of更繁琐的一种替代方法。
BigInteger prime = BigInteger.valueOf(Integer.MAX_VALUE);
  • instance或者getInstance 返回的实例是通过方法的参数来描述的
StackWalker luke = StackWalker.getInstance(options);//java9的新特性打印堆栈信息,luke是lucene的东西
  • create或者newInstance 确保每次调用都返回一个新的实例
Object newArray = Array.newInstance(classObject,arrayLen);
  • getType 像getInstance一样,但是在工厂方法处于不同的类中的时候使用,Type表示工厂方法返回的对象类型
FileStore fs = Files.getFileStore(path);//FileStore是java7中nio包的API
  • newType 像newInstance一样,但是在工厂方法处于不同的类的时候使用,Type表示工厂方法返回的对象类型
BufferedReader br = Files.newBufferedReader(path);
  • type getType和newType的简版
List<Complaint> litany = Collections.list(legacyLitany);

2. 遇到多个构造器参数时考虑使用构建器

1. 背景

静态工厂和构造器的局限性:不能很好的扩展到大量的可选参数。如用一个类表示包装食品外显示的营养成分标签。有几个域是必须的:每份的含量、每罐的含量、每份的热量。还有20多个可选域:总脂肪量、碳水化合物、钠等。

对于这样的类,如何编写?大部分程序员习惯使用重叠构造器(telescoping constructor)模式

重叠构造器

提供的第一个构造器只有必要的参数,第二个构造器有一个可选参数,第三个构造器有两个可选参数…,最后一个构造器包含所有可选参数。

public class NutritionFacts {
    private final int servingSize; // 每份的含量 必须
    private final int servings;// 每罐的含量 必须
    private final int calories; // 热量 可选
    private final int fat; // 脂肪 可选
    private final int sodium; // 钠 可选
    private final int carbohydrate; // 碳水化合物 可选
    
    public NutritionFacts(int servingSize,int servings){
        this(servingSize,servings,0);//不太懂为什么这么写
    }
    public NutritionFacts(int servingSize,int servings,int calories){
        this(servingSize,servings,calories,0);
    }
    public NutritionFacts(int servingSize,int servings,int calories,int fat){
        this(servingSize,servings,calories,fat,0);
    }
    public NutritionFacts(int servingSize,int servings,int calories,int fat,int sodium){
        this(servingSize,servings,calories,fat,sodium,0);
    }
    public NutritionFacts(int servingSize,int servings,int calories,int fat,int sodium,int carbohydrate){
        this.servingSize=servingSize;
        this.servings=servings;
        this.calories = calories;
        this.fat=fat;
        this.sodium=sodium;
        this.carbohydrate=carbohydrate;
    }

    public static void main(String[] args) {
        NutritionFacts cocaCola = new NutritionFacts(240,8,100,0,35,27);
    }
}

随着参数的增加,客户端代码会很难写,且难以阅读,毕竟一长串相同的参数,如果客户端颠倒了某两个参数的顺序,编译期不出错,但运行期可能会报错。

javabeans模式

遇到许多可选的构造器参数的时候,第二种代替办法,是javabeans模式。先用无参构造器创建对象,再setter方法设置每个必要的参数,以及可选的参数

public class NutritionFacts {
    // 初始化为默认值
    private int servingSize = -1;//必要,没有默认值
    private int servings=-1;//同上
    private int calories = 0;
    private int fat=0;
    private int sodium=0;
    private int carbohydrate=0;
    
    public NutritionFacts(){}
    
    // setters

    public void setServingSize(int servingSize) {
        this.servingSize = servingSize;
    }

   // ...

    public static void main(String[] args) {
        NutritionFacts cocaCola = new NutritionFacts();
        cocaCola.setServingSize(240);
        cocaCola.setServings(8);
        cocaCola.setCalories(100);
        cocaCola.setSodium(35);
        cocaCola.setCarbohydrate(27);
    }
}

这种代码可读性强,缺点是构造过程被分到几个调用中,在构造过程中javabean可能出在不一致的状态。无法保证一致性,调试困难。此外,javabean模式使得把类做成不可变的可能性不复存在。需要额外的努力确保线程安全。

2. 构建者模式

第三种方法就是构建者模式,既可保证像重叠构造器模式的安全性,又有javabeans的可读性。不直接生成想要的对象,而是让客户端利用所有必要的参数调用构造器(或者静态工厂),得到一个builder对象。然后在builder对象上调用类似setter的方法,设置每个相关可选参数。最后,客户端调用无参的build方法生成通常不可变的对象。这个builder通常是它构建的类的静态成员类。

public class NutritionFacts {
    private final int servingSize; // 每份的含量 必须
    private final int servings;// 每罐的含量 必须
    private final int calories; // 热量 可选
    /*private final int fat; // 脂肪 可选
    private final int sodium; // 钠 可选
    private final int carbohydrate; // 碳水化合物 可选*/

    public static class Builder{
        private final int servingSize;
        private final int servings;

        private int calories=0;
        private int fat=0;
        private int sodium=0;
        private int carbohydrate=0;

        public Builder(int servingSize,int servings){
            this.servingSize=servingSize;
            this.servings = servings;
        }
        public Builder calories(int val){
            calories = val;
            return this;
        }
        // ...
        public NutritionFacts build(){
            return new NutritionFacts(this);
        }
    }

    private NutritionFacts(Builder builder){
        servingSize = builder.servingSize;
        servings = builder.servings;
        calories = builder.calories;
    }

    public static void main(String[] args) {
        NutritionFacts cocaCola = new NutritionFacts.Builder(240,8).calories(20).build();
        System.out.println(cocaCola.calories);//20
    }
}

这样的代码,既容易编写,也便于阅读。Builder模式模拟了具名的可选参数。

该代码省略了有效性检查,要想尽快侦测到无效的参数,可在builder构造器和方法中检查参数的有效性。


Builder模式也适合于类层次结构。

使用平行层次结构的builder时,各自嵌套在相应的类中,抽象类有抽象类的builder,具体类有具体类的builder。假设用类层次根部的一个抽象类表示各式披萨:

public abstract class Pizza {
    public enum Topping{HAM, MUSHROOM, ONION,PEPPER,SAUSAGE} // 配料
    final Set<Topping> toppings;

    abstract static class Builder<T extends Builder<T>>{
        EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
        public T addTopping(Topping topping){
            toppings.add(Objects.requireNonNull(topping));
            return self();
        }
        abstract Pizza build();
        
        // 子类必须override该方法来返回this
        protected abstract T self();
    }
    Pizza(Builder<?> builder){
        toppings = builder.toppings.clone();
    }
}

注意:Pizza.Builder的类型是泛型,带有一个递归类型参数(recursive type parameter)。和抽象的self方法一样,允许在子类中适当的进行方法链接,不需要转换类型。这个针对java缺乏self类型的解决方案,被称作模拟的self类型(simulated self-type)。(感觉很像python的self)

有两个具体的Pizza子类,其中一个表示经典纽约风味,一个表示馅料内置的半月型(calzone)披萨。前者需要一个尺寸参数,后者则要你指定酱汁内置还是外置:

public class MyPizza extends Pizza {

    public enum Size {SMALL, MEDIUM, LARGE}

    private final Size size;

    public static class Builder extends Pizza.Builder<Builder> {
        private final Size size;

        public Builder(Size size) {
            this.size = Objects.requireNonNull(size);
        }

        @Override
        MyPizza build() {
            return new MyPizza(this);
        }

        @Override
        protected Builder self() {
            return this;
        }
    }

    private MyPizza(Builder builder) {
        super(builder);
        size = builder.size;
    }
}
public class Calzone extends Pizza {
    private final boolean sauceInside;

    public static class Builder extends Pizza.Builder<Builder> {
        private boolean sauceInside = false;//Default

        public Builder sauceInside() {
            sauceInside = true;
            return this;
        }

        @Override
        Calzone build() {
            return new Calzone(this);
        }

        @Override
        protected Builder self() {
            return this;
        }
    }

    private Calzone(Builder builder) {
        super(builder);
        sauceInside = builder.sauceInside;
    }
}

注意,每个子类的构建器中的build方法,都声明返回正确的子类:MyPizza.Builder的build方法返回MyPizza,而Calzone.Builder中的则返回Calzone。子类方法声明返回超类中声明的返回类型的子类型,被称作协变返回类型(covariant return type)。允许客户端无需转换类型就能使用这些构建器。

MyPizza pizza = new MyPizza.Builder(SMALL).addTopping(SAUSAGE).addTopping(ONION).build();
        Calzone calzone = new Calzone.Builder().addTopping(HAM).sauceInside().build();

和构造器相比,builder可有多个可变参数(varargs)。builder利用单独的方法设置每个参数。此外,构造器可以多次调用某个方法传入的参数集中到一个域,如addTopping方法

缺点

相比重叠构造器,更加冗长,只有在很多参数的时候才使用。如4个或更多参数。

3. 使用私有构造器或枚举类型强化Singleton属性

单例,仅仅被实例化一次的类。

构造器私有,保证全局唯一性。一旦实例化,只能存在一个实例。但是如果反射攻击,借助AccessibleObject.setAccessible方法,调用私有构造器,如果想抵御攻击,可以修改构造器,在被要求创建第二个实例时抛异常。

枚举的优点:更加简洁,无偿提供序列化机制,绝对防止多次实例化,即使面对复杂的序列化或反射攻击的时候。

注意:如果单例必须扩展一个超类,而不是扩展Enum的时候,不宜使用该方法。

4. 通过私有构造器强化不可实例化的能力

有些工具类,如java.util.Math java.util.Arrays java.util.Collections 不希望被实例化,然而如果没有显式构造器,编译器会提供默认构造器。

通过私有构造器,让类不能被实例化。

public class UtilityClass {
  // 为了不可实例化抑制默认构造器
  // Suppresses default constructor, ensuring non-instantiability
    private UtilityClass(){
        throw new AssertionError();
    }
    // 其余代码
}

最好在代码中加上不可实例化的注释

副作用:使得一个类不能被子类化。

5. 优先考虑依赖注入来引入资源

静态工具类和Singleton不适合需要引入底层资源的类。

例如,拼写检查器需要依赖词典,需要能够支持类的多个实例(本例指SpellChecker),每个实例都使用客户端指定的资源(本例指词典)。满足该需求的最简单模式是,当创建一个新的实例时,就将该资源传到构造器。这是依赖注入的一种形式。

public class SpellChecker {
    private final Lexicon dictionary;
    
    public SpellChecker(Lexicon dictionary){
        this.dictionary = Objects.requireNonNull(dictionary);
    }
    
    public boolean isValid(String word){ ... }
    public List<String> suggestions(String typo){ ... }
}

依赖注入适合任意数量的资源,以及任意的依赖形式。DI的对象具有不可变性,多个客户端可共享依赖对象。依赖注入也同样适用于构造器、静态工厂和构建器。

该模式有个变体是,将资源工厂factory传给构造器。工厂可以被重复调用用来创建类型实例的一个对象。具体表现为工厂方法模式。java8增加的接口Supplier<T> ,最适合用来表示工厂。

例如,生产马赛克瓷片的方法,利用客户端提供的工厂来生产每一片马赛克瓷片

Mosaic create(Supplier<? extends Tile> tileFactory){...}

常见的依赖注入框架Spring,依赖注入的好处:极大的提升了类的灵活性、可重用性和可测试性。

6. 避免创建不必要的对象

如String,不用new String,直接写即可,这样对象还会被重用。

如果对象不是不可变的,考虑适配器(adapter),也叫视图(view)。适配器是指这样一个对象:把功能委托一个后备对象(backing object),从而为后备对象提供一个可替代的接口。

例如,map接口的keySet方法返回该Map对象的Set视图,其中包含该Map的所有key,对于一个给定的Map对象,实际上每次调用keySet都返回同样的Set实例

另一种创建多余对象的方法,就是自动装箱(autoboxing),允许基本类型和装箱基本类型混用,按需自动装箱和拆箱。当然二者有差别,优先使用基本数据类型,当心无意识的自动装箱(如long写成了Long)。

当然,小对象的创建和回收动作非常廉价,通过创建附加的对象,提升程序的清晰性、简洁性和功能性,通常是好的。

7. 消除过期的对象引用

栈里面可能会发生内存泄露,需要清空对象引用。当然这是例外行为,而非规范行为。

stack类自己管理内存。

内存泄露的另一个常见来源是缓存。对象引用放到缓存中,很容易忘掉。解决方案:用WeakHashMap代表缓存。过期会自动被删除。记住只有当缓存项的生命周期由其外部引用而非决定时,WeakHashMap才有用。

更常见的情况是,“缓存项的生命周期是否有意义”不容易确定,根据LRU决定。可用后台线程(ScheduledThreadPoolExecutor)完成,或者给缓存添加新条目时顺便清理,利用LinkedHashMap的removeEldestEntry方法实现。

内存泄露的第三个常见来源是监视器和其他回调。如果你实现一个API,客户端在该API种注册回调,但没有显式的取消注册,就会不断堆积。确保回调立即被当做垃圾回收的最佳方法是只保存弱引用,如保存成WeakHashMap的key

内存泄露可以再一个系统很多年。可借助Heap剖析工具发现。

8. 避免使用终结方法和清除方法

finalize不可预测,一般也不必要,java9用清除方法cleaner替代,仍不可预测、运行缓慢,也不必要;一般用try…finally保证资源的回收

合理用途:一种是当资源的所有者忘记调用close方法,终结方法或清除方法可以作为“安全网”,毕竟客户端无法正常结束操作。另一种用途是跟对象的native peer有关,是个本地对象,普通对象通过本地方法委托给一个本地对象。垃圾回收器并不会知道它,兼容。

9. try-with-resources优先于try-finally

这样代码更加简洁易懂,也更容易诊断。

static String firstLineOfFile(String path,String defaultVal){
  try(BufferedReader br = new BufferedReader(
    new FileReader(path)
  )){
    return br.readLine();
  }catch(IOException e){
    return defaultVal;
  }
}

2. 对所有对象都通用的方法

尽管Object是个具体类,但设计它主要是为了扩展。所有的非final方法(equals、hashCode、toString、clone、finalize)都有明确的通用约定(general contract),因为它们被设计为要被override的。

10. 覆盖equals时请遵守通用约定

equals方法实现了等价关系(equivalence relation),属性包括:

  • 自反性(reflexive) 对象必须等于自身
  • 对称性(symmetric) 任何两个对象对于“它们是否相等 ”的问题必须保持一致
  • 传递性(transitive)
  • 一致性(consistent) 如果两个对象相等,他们必须始终保持相等,除非至少一个被修改
  • 对于任何非null的引用值x,x.equals(null)必须返回false

对称性

对于第二条对称性,反面例子如下面的类,实现了一个区分大小写的字符串,字符串由toString保存,但在equals操作中被忽略。

 public final class CaseInsensitiveString {
    private final String s;

    public CaseInsensitiveString(String s){
        this.s = Objects.requireNonNull(s);
    }

    @Override
    public boolean equals(Object o) {
        if(o instanceof CaseInsensitiveString)
            return s.equalsIgnoreCase(((CaseInsensitiveString)o).s);
        if(o instanceof String)
            return s.equalsIgnoreCase((String)o);
        return false;
    }
}

这个类的问题在于,虽然CaseInsensitiveString类中的equals方法知道普通的字符串对象,但是String类中的equals方法并不知道不区分大小写的字符串,因此违反对称性。你完全不知道这些对象的行为会怎么样。

如何解决?

只需要将企图与String互相操作的这段代码从equals方法中去掉,然后重构该方法

public boolean equals(Object o) {
        return o instanceof CaseInsensitiveString && ((CaseInsensitiveString)o).s.equalsIgnoreCase(s);
    }

TODO 好难啃,暂时跳过这部分,之后回溯。


一致性

无论类是否是不可变的,都不要使equals方法依赖于不可靠的资源。反面案例:java.net.URL的equals方法依赖于对URL中主机IP地址的比较。将一个主机名转变为IP地址可能需要访问网络,随着时间的推移,IP地址可能发生改变。导致URL equals方法违反equals约定,在实践中可能会引发一些问题。遗憾的是,由于兼容性的要求,无法改变,为避免该问题,equals方法应该对驻留在内存中的对象进行确定性的计算。

非空性

没有正式的名称,暂且称为非空性。所有对象都不能等于null。许多类的equals方法通过一个显式的null测试来防止该情况。

public boolean equals(Object o) {
  if(o == null)
    return false;
  // ...
}

其实没有必要,为了测试其参数的等同性,equals方法必须先将参数转换为适当的类型,以便调用其访问方法,或者访问它的域。转换之前,必须用instanceof操作符,检查参数的类型是否正确:

public boolean equals(Object o) {
  if(!(o instanceof MyType))
    return false;
  MyType mt = (MyType) o;
  // ...
}

如果漏掉这一步类型检查,并且传给equals方法的参数是错误的类型,equals方法会抛异常ClassCastException,违反equals约定。如果instanceof的第一个操作数是null,不管第二个操作数是哪种类型,都会返回false。因此,不需要显式的null检查。

高质量equals方法

  1. 使用== 操作符检查“参数是否为这个对象的引用”。如果是,返回true。如果比较操作可能比较昂贵,值得这么做
  2. 使用instanceof操作符检查“参数是否为正确的类型”。如果不是,返回false。正确类型一般指equals方法所在的类。某些情况,指该类实现的接口。如果类实现的接口改进了equals约定,允许在实现该接口的类之间进行比较,使用接口。集合接口如Set、List、Map和Map.Entry具有这样的特性。
  3. 把参数转换为正确的类型 转换之前进行过instanceof,所以确保会成功
  4. 对于该类中的每个“关键”(significant)域,检查参数中的域是否与对象中对应的域匹配。

对于不是float或double类型的基本类型域,使用== 比较;对于对象引用域,可递归的调用equals方法;对于float域,可以使用静态Float.compare(float,float)方法;对于double域,使用Double.compare(double,double)

有些对象引用域包含null可能是合法的,避免空指针异常,使用静态方法Objects.equals(Object,Object)来检查这类域的等同性。

域的比较顺序可能会影响equals方法的性能,为了获取最佳性能,应最先比较最可能不一致的域,或开销最小的域。

编写完equals方法后,最好单元测试,是否满足对称性、传递性和一致性

注意

  1. 覆盖equals时总要覆盖hashCode
  2. 不要企图让equals太过智能
  3. 不要将equals声明中的Object对象替换为其他的类型
public boolean equals(MyClass o){
  // ...
} 

上面这种就是典型反面案例,该方法并未override Object.equals方法,相反,overload了Object.equals方法。可用注解@Override 防止这种错误。

Google开源的AutoValue框架自动生成编写和测试equals方法,IDE也可生成,但得到的源码比AutoValue的更冗长,可读性更差,也无法追踪类中的变化,需要进行测试。

11. 覆盖equals总要覆盖hashCode

没有覆盖hashCode违反约定的第二条:相等的对象必须具有相等的散列码(hash code)。

一个好的散列函数倾向于“为不相等的对象产生不相等的散列码”。

不要对hashCode方法的返回值做出具体的规定,因此客户端无法理所当然的依赖它;这样可以为修改提供灵活性。

当然AutoValue和IDE也都提供替代方法。

12. 始终要覆盖toString

toString的通用约定指出:被返回的字符串应该是一个“简洁的但信息丰富,并且易于阅读的表达形式”。并进一步指出:建议所有的子类都覆盖这个方法。

提供好的toString实现可使类用起来更舒服,系统更易于调试。实际应用中,toString方法应该返回对象中包含的值得关注的信息

在实现toString的时候,必须决定:是否在文档中指定返回值的格式。对于值类(value class),如电话号码类、矩阵类,建议这么做。好处是:可被当做一种标准的、明确的、适合人阅读的对象表示法。这种表示法可用于输入、输出以及CSV文档等适合阅读的数据对象中。如果指定格式,最好配一个匹配的静态工厂或者构造器,以便程序员较容易在对象及其字符串表示法之间切换。java平台的类库的很多值类都采用该做法,包括BigInteger、BigDecimal和绝大多数的基本类型包装类。

不足之处:不够灵活

无论是否决定指定格式,都应该在文档中明确表明意图。

无论是否指定格式,都为toString返回值中包含的所有信息提供一种可通过编程访问的途径。

13. 谨慎的覆盖clone

对象拷贝的更好方法是提供一个拷贝工厂(copy factory)或拷贝构造器(copy constructor)

public Yum(Yum yum){...} // copy constructor
public static Yum newInstance(Yum yum){...}; // copy factory

14. 考虑实现Comparable接口

与其他方法不同,compareTo方法并没有在Object类中声明。它是Comparable接口中唯一的方法。compareTo方法不但允许简单的等同性比较,而且允许执行顺序比较,此外,与Object的equals方法具有相似的特征,还是个泛型。类实现Comparable接口,就表明它的实例具有内在的排序关系(natural ordering)。为实现Comparable接口的对象数组排序就非常简单:

Arrays.sort(a);

对存储在集合中的Comparable对象进行搜索、计算极限值以及自动维护也非常简单。如,下面的程序依赖于实现了Comparable接口的String类,去掉了命令行参数列表的重复参数,并按字母顺序打印出来:

public class WordList {
    public static void main(String[] args) {
        Set<String> s = new TreeSet<>();
        Collections.addAll(s,args);
        System.out.println(s);
    }
}

一旦类实现Comparable接口,就可以跟很多泛型算法(generic algorithm)以及依赖该接口的集合实现(collection implementation)进行协作。java平台的所有值类以及所有枚举类型都实现了Comparable接口。如果你正在编写一个值类,具有明显的内在排序关系,如按字母顺序、按数值顺序或者按年代顺序,都应坚决考虑实现Comparable接口。

依赖于比较关系的类包括有序集合类TreeSet和TreeMap,以及工具类Collections和Arrays,内部包含有搜索和排序算法。

java7中,已在所有装箱基本类型的类中添加了静态的compare方法。

在compareTo方法的实现中比较域值时,避免使用<和> 操作符,而应该在装箱基本类型的类中使用静态的compare方法,或者在Comparator接口中使用比较器构造方法。

3. 类和接口

类和接口是java编程语言的核心,也是java语言的基本抽象单元。java语言提供许多强大的基本元素,供程序员用来设计类和接口

15. 使类和成员的可访问性最小化

区分一个组件设计的好不好,唯一重要的因素在于,它对外部的其他组件而言,是否隐藏了其内部数据和其他实现细节。组件之间只通过API通信,一个模块不需要知道其他模块的内部工作情况。这被称为封装(encapsulation),是软件设计的基本原则之一。

封装的重要性大多是因为解耦(decouple),使得组件可以并行开发,加快系统开发的速度,也减轻维护的负担。也提高了软件的可重用性。

尽可能使每个类或成员不被外界访问。

对成员(域、方法、嵌套类和嵌套接口)有四种可能的访问级别:

  • private
  • package-private,也叫default
  • protected
  • public

公有类的实例域决不能是公有的。包含公有可变域的类通常不是线程安全的。让类具有公有的静态final数组域,或者返回这种域的访问方法,是错误的。这是安全漏洞的常见根源。

16. 要在公有类而非公有域中使用访问方法

也就是getter/setter

对外提供访问方法。

17. 使可变性最小化

不可变类是指其实例不能被修改的类。java平台提供的不可变类有String、基本类型的包装类、BigInteger和BigDecimal。

为了使类称为不可变类,要遵循以下五条规则:

  1. 不要提供任何会修改对象状态的方法(也叫设值方法)
  2. 保证类不会被扩展 为了防止子类化,一般声明final
  3. 声明所有的域都是final
  4. 声明所有的域都是私有的
  5. 确保对任何可变组件的互斥访问

函数方式带来不可变性,不可变对象比较简单。

不可变对象本质是线程安全的,不要求同步,可被自由共享;不仅可共享不可变对象,还可以共享他们的内部信息;不可变对象为其他对象提供大量的构件;不可变对象无偿的提供了失败的原子性。

不可变对象唯一的缺点是:对每个不同的值都需要一个单独的对象。而创建对象的代价可能很高。最好的解决方法是提供一个公有的可变配套类,如String类的StringBuilder。

为了确保不可变性,除了final类,还有另外一种方式。让类的所有构造器都变成私有,并添加公有的静态工厂来代替公有的构造器。这种方法虽然不常用,但通常是最好的替代方案。最灵活,允许使用多个包级私有的实现类;还使得可能通过改善静态工厂的对象缓存能力,在后续的发行版本改进性能。

BigInteger和BigDecimal是典型的反面案例,为保持向后兼容,问题无法得到修正。

有关序列化功能,如果选择让自己的不可变类实现Serializable接口,并且包含一个或多个指向可变对象的域,就必须提供一个显式的readObject或readResolve方法,或者使用ObjectOutputStream.writeUnshared和ObjectInputStream.readUnshared方法。即便默认的序列化形式可接受,也是如此。否则,攻击者可能从不可变的类创建可变的实例。

坚决不要为每个get方法编写一个相应的set方法。除非有很好的理由让类称为可变的类,否则它应该是不可变的。

除非有令人信服的理由要使域变成非final的,否则要使每个域都是private final的

构造器应该创建完全初始化的对象,并建立所有的约束关系。

通过CountDownLatch类的例子可以说明这些原则。它是可变的,但它的状态空间有意被设计非常小。比如创建一个实例,只用一次,任务就完成了:一旦定时器计数为零,就不能重用了。域也是private final。

18. 复合优先于继承

对于普通的具体类(concrete class)进行跨越包边界的继承,非常危险。与方法调用不同,继承打破了封装性。子类依赖超类中特定功能的实现细节,超类的实现可能随着版本的不同而变化,子类可能代码没变,但遭到破坏。因而,子类必须随着超类的更新而演变。

导致子类脆弱的一个相关原因是:其超类在后续的版本中可以获得新的方法。一旦超类增加了新方法,很可能仅仅由于调用了这个未被子类覆盖的新方法,而将“非法的”元素添加到子类的实例中。在把HashTable和Vector加入到Collections Framework中的时候,就修正了几个这类性质的安全漏洞。

如何避免上述问题?

即不扩展现有的类,而是在新的类中增加一个私有域,引用现在类的一个实例。这种设计被称为“复合”(composition)。因为现有的类成了新类的一个组件。新类中的每个实例方法都可以调用被包含的现有类实例中对应的方法,并返回它的结果。这被称为转发(forwarding),新类中的方法被称为转发方法(forwarding method)。这样得到的类非常稳固,不需要依赖现有类的实现细节。如下面的案例

public class ForwardingSet<E> implements Set<E> {
    private final Set<E> s;
    public ForwardingSet(Set<E> s){
        this.s=s;
    }
    public void clear(){
        s.clear();
    }

    @Override
    public boolean contains(Object o) {
        return s.contains(o);
    }

    @Override
    public boolean isEmpty() {
        return s.isEmpty();
    }

    @Override
    public int size() {
        return s.size();
    }

    @Override
    public Iterator<E> iterator() {
        return s.iterator();
    }

    @Override
    public boolean add(E e) {
        return s.add(e);
    }

    @Override
    public boolean remove(Object o) {
        return s.remove(o);
    }

    @Override
    public boolean containsAll(Collection<?> c) {
        return s.containsAll(c);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        return s.addAll(c);
    }

    @Override
    public boolean removeAll(Collection<?> c) {
        return s.removeAll(c);
    }

    @Override
    public boolean retainAll(Collection<?> c) {
        return s.retainAll(c);
    }

    @Override
    public Object[] toArray() {
        return s.toArray();
    }

    @Override
    public <T> T[] toArray(T[] a) {
        return s.toArray(a);
    }

    @Override
    public boolean equals(Object obj) {
        return s.equals(obj);
    }

    @Override
    public int hashCode() {
        return s.hashCode();
    }

    @Override
    public String toString() {
        return s.toString();
    }
}
public class InstrumentedSet<E> extends ForwardingSet<E> {
    private int addCount = 0;
    public InstrumentedSet(Set<E> s){
        super(s);
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

Set接口的存在使得InstrumentedSet类的设计成为可能。Set接口保存了HashSet类的功能特性。除了健壮性,这种设计也带来更多的灵活性。InstrumentedSet类实现了Set接口,并且拥有单个构造器,参数也是Set类型。本质上,这个类将一个Set转变为另一个Set,同时增加了计数的功能。这个包装类可以被用来包装任何Set实现,并且可以结合任何先前存在的构造器一起工作。这也是装饰者模式(Decorator)。

包装类的缺点:不适合用于回调框架(callback framework)。在回调框架中,对象把自身的引用传递给其他的对象,用于后续的调用。而被包装的对象并不知道它外面的包装对象,所以它传递一个指向它自身的引用(this),回调避开了外面的包装对象。

如果打算让类B扩展类A时,应该问问自己:每个B确实也是A吗?如果否定,通常B应该包含A的一个私有实例,并且暴露一个较小、较简单的API。A本质不是B的一部分,只是它的实现细节而已。

在java平台类库中,有些明显违反这条原则的反面案例。如Properties不应该扩展Hashtable;Stack并不是vector。这两种情况,复合模式才是恰当的。

此外,在决定使用继承而非复合的时候,还要问自己最后一组问题:对于你正试图扩展的类,它的API是否有缺陷?如果有,是否愿意将这些缺陷传播到类的API中?继承会传播超类所有缺陷,而复合则允许设计新的API隐藏这些缺陷。

19. 要么设计继承并提供文档说明,要么禁止继承

首先,该类必须精确的描述覆盖每个方法所带来的的影响。也就是该类必须有文档说明它override的方法的自用性(self-use)。

20. 接口优于抽象类

接口是定义mixin(混合类型)的理想选择。mixin类型是指:类除了实现它的“基本类型”之外,还可以实现这个mixin类型,以表明它提供了某些可供选择的行为。

一般来说,无法更新现有的类来扩展新的抽象类。如果希望两个类扩展同一个抽象类,就必须把抽象类放到类型层次(type hierarchy)的高处,变成这两个类的一个祖先,但,这样会间接的伤害到类层次。迫使这个公共祖先的所有后代都扩展这个新的抽象类,无论是否合适。

接口允许构造非层次结构的类型框架。如果用抽象类的话,类层次臃肿(bloated),如果整个类层次系统有n个属性,必须支持2ⁿ种组合,称为组合爆炸(combinatorial explosion)。此外,类也臃肿。

接口使得安全的增强类的功能成为可能。

通过对接口提供一个抽象的骨架实现(skeletal implementation)类,可以把接口和抽象类的优点结合起来。接口负责定义类型,或许还提供一些默认方法,而骨架实现类负责实现除了基本类型接口方法外,剩下的非基本类型接口方法。扩展骨架实现占了实现接口之外的大部分工作。这就是模板方法模式(Template Method)。

骨架实现类被称为AbstractInterface,这里的Interface指所实现的接口的名字,如Collections Framework的AbstractCollection、AbstractSet、AbstractList和AbstractMap。如果设计得好,骨架实现(无论是单个抽象类,还是接口中唯一包含的默认方法)可以使程序员非常容易的提供自己的接口实现。如下面的静态工厂方法,除了AbstractList外,还包含一个完整的、功能全面的List实现。

static List<Integer> intArrayAsList(int[] a){
  Objects.requireNonNull(a);
  // 菱形操作符,jdk9才有
  return new AbstractList<Integer>() {
    @Override
    public Integer get(int i) {
      return a[i]; // 自动装箱
    }

    @Override
    public Integer set(int i, Integer val) {
      int oldVal = a[i];
      a[i]=val; // 自动拆箱
      return oldVal; // 自动装箱
    }

    @Override
    public int size() {
      return a.length;
    }
  };
}

当然,这个例子也是适配器Adapter,允许将int数组看做Integer实例的列表。因为要在int和Integer切换,性能不太好。

骨架实现类的好处:为抽象类提供了实现上的帮助,但又不强加“抽象类被用作类型定义时”所特有的严格限制。此外,骨架实现类有助于接口的实现。实现该接口的类可以把对于接口方法的调用转发到一个内部私有类的实例上。这个内部私有类扩展了骨架实现类。这种方法被称作模拟多重继承(simulated multiple inheritance),这个技术具有多重继承的绝大多数优点,同时避免了相应的缺陷。

骨架实现类的编写相对简单。首先,必须认真研究接口,确定哪些方法最为基本,其他方法可以根据他们实现。这些基本方法成为骨架实现类中的抽象方法。接下来,在接口中为所有可以在基本方法之上直接实现的方法提供默认方法,但记住,不能为Object方法(equals和hashCode)提供默认方法。如果基本方法和默认方法覆盖接口,就不需要骨架实现类;否则,编写一个类,声明实现接口,并实现所有剩下的接口方法。这个类中可以包含任何非公有的域,以及适合该任务的任何方法。

以Map.Entry接口为例,明显的基本方法是getKey、getValue和(可选的)setValue。接口定义了equals和hashCode的行为,并且有一个明显的toString实现。由于不允许给Object方法提供默认实现,所有实现都放在骨架实现类中。

public abstract class AbstractMapEntry<K,V> implements Map.Entry<K,V>{
  	// 可修改的map的Entries必须覆盖这个方法
    @Override
    public V setValue(V value) {
        throw new UnsupportedOperationException();
    }
	// 实现equals方法
    @Override
    public boolean equals(Object o) {
        if(o==this){
            return true;
        }
        if(!(o instanceof Map.Entry)){
            return false;
        }
        Map.Entry<?,?> e = (Map.Entry) o;
        return Objects.equals(e.getKey(),getKey())
                && Objects.equals(e.getValue(),getValue());
    }
	// 实现hashCode方法
    @Override
    public int hashCode() {
        return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
    }

    @Override
    public String toString() {
        return getKey() + "=" + getValue();
    }
}

注意,这个骨架实现不能在Map.Entry接口实现,也不能作为子接口,因为不允许默认方法覆盖Object方法。

骨架实现类,应该遵循关于设计和文档的指导原则,好的文档绝对非常必要。

总之,接口通常是定义允许多个实现的类型的最佳途径。如果导出了一个重要的接口,就应该坚决考虑同时提供骨架实现类。并且尽可能通过默认方法在接口中提供骨架实现,以便接口的所有实现类都能使用。

21. 为后代设计接口

jdk8在核心集合接口中增加许多新的默认方法,主要是为了便于使用lambda。但,并非每一个可能的实现的所有变体,始终都可以编写出一个默认方法。

尽量避免使用默认方法在现有接口上添加新方法,慎重考虑是否会破坏现有的接口实现。

在发布程序之前,测试每一个新的接口尤为重要。应该以最少三种方法实现每一个接口,及时发现缺陷,如果接口有严重的缺陷,可能摧毁包含它的API。

22. 接口只用于定义类型

当类实现接口时,接口就充当可以引用这个类的实例的类型(type)。因此,类实现接口,就表明客户端可以对这个类的实例实施某些动作。为了任何其他目的而定义接口是不恰当的。

反面:常量接口(constant interface)。类在内部使用某些常量,这纯粹是实现细节。常量接口会将其泄露到该类的导出API中,而这对用户并没有什么价值,反而更糊涂。更糟糕的是,它代表一种承诺:如果将来的发行版本中,该类被修改,不再需要这些常量,依然要实现这个接口,确保二进制兼容性。如果非final类实现了常量接口,其所有子类的命名空间也会被接口中的常量“污染”。

反面如java平台类库的java.io.ObjectStreamConstants

如果要导出常量,有几种合理选择方案。如果这些常量与某个现有的类或者接口紧密相关,应该把这些常量添加到这个类或者接口中。如,java平台类库中所有的数值包装类,如Integer和Double,都导出了MIN_VALUE和MAX_VALUE常量。如果这些常量最好被看做枚举类型的成员,就应该用枚举类型来导出;否则,应该使用不可实例化的工具类来导出。

例子:

public class PhysicalConstants {
    private PhysicalConstants(){ }//防止实例化
    public static final double AVOGADROS_NUMBER = 6.022_140_857e23;//阿伏伽德罗常量,C12的原子质量
    public static final double BOLTZMANN_CONST = 1.380_648_52e-23;
    public static final double ELECTRON_MASS = 9.109_383_56e-31;
}
import static cn.itcast.day02.PhysicalConstants.*;
public class Test {
    double atoms(double mols){
        return AVOGADROS_NUMBER * mols;
    }
}

注意:jdk7以后就可以用下划线,对数字字面量的值没有影响。可提高可读性,建议基数为10的字面量每三位一组。

工具类通常要求客户端用类名修饰这些常量名。如果大量利用工具类导出的常量,可通过利用静态导入(static import)机制,避免用类名修饰常量名。

23. 类层次优于标签类

什么是标签类,看下面的代码示范

class Figure {
    enum Shape { RECTANGLE, CIRCLE};
    // 标签域,图像的形状
    final Shape shape;
    
    // 正方形的域
    double length;
    double width;
    // 圆形的域
    double radius;
    // 圆形的构造器
    Figure(double radius){
        shape = Shape.CIRCLE;
        this.radius = radius;
    }
    // 正方形的构造器
    Figure(double length,double width){
        shape = Shape.RECTANGLE;
        this.length = length;
        this.width = width;
    }
    
    double area(){
        switch (shape){
            case RECTANGLE:
                return length*width;
            case CIRCLE:
                return Math.PI * (radius * radius);
            default:
                throw new AssertionError(shape);
        }
    }
}

这种标签类有许多缺点。充斥了样板代码,包括枚举声明、标签域以及条件语句。多个实现挤在单个类中,破坏可读性。实例承担属于其他风格的不相关的域,增加了内存占用。域不能做成final的,除非构造器初始化了不相关的域,产生更多的样板代码。构造器必须不借助编译器设置标签域,并初始化正确的数据域,如果初始化错误的域,运行时失败。无法给标签类添加风格,除非修改源文件。总之一句话:标签类过于冗长、容易出错,且效率低下

面向对象的语言如java提供了更好的方法定义能表示多种风格对象的单个数据类型:子类型化(subtyping)。标签类正是对类层次的一种简单仿效。

如何转变为类层次?首先为标签类中的每个方法都定义一个包含抽象方法的抽象类,标签类的行为依赖于标签值。在Figure类中只有一个这样的方法:area。同样的,如果所有的方法都用到了某些数据域,就应该把他们放到根类中。接着,为每种原始标签类定义根类的具体子类,每个子类有特定于该类的数据域。

abstract class Figure {
    abstract double area();
}
class Cirle extends Figure{
    final double radius;
    Cirle(double radius){
        this.radius = radius;
    }

    @Override
    double area() {
        return Math.PI * (radius * radius);
    }
}
class Rectangle extends Figure{
    final double length;
    final double width;
    Rectangle(double length,double width){
        this.length = length;
        this.width =width;
    }

    @Override
    double area() {
        return length*width;
    }
}

简而言之,标签类很少被使用。当你想要编写标签类的时候,应该考虑是否可以用类层次来代替。如果有标签类,就要考虑重构到一个层次结构中。

24. 静态成员类优于非静态成员类

嵌套类(nested class)是指定义在另一个类的内部的类。其目的应该是只为它的外部类(enclosing class)提供服务。有四种:静态成员类(static member class)、非静态成员类(nonstatic member class)、匿名类(anonymous class)和局部类(local class)。除了第一种,其余都叫内部类(inner class)。

静态成员类最简单,最好把它看做普通的类,只是恰好被声明在另一个类的内部而已。可以访问外部类的所有成员,包括private的成员。与其他静态成员一样,遵守同样的可访问性规则。如果声明为private,只能在外部类的内部被访问。

非静态成员类的每个实例都隐含的和外部类的一个外围实例(enclosing instance)相关联。非静态成员类的实例被创建时,它和外围实例的关联关系也随之建立,且不能被修改。

非静态成员类的一种常见用法是定义一个Adapter,允许外部类的实例被看做是另一个不相关的类的实例。如Map接口的实现往往使用非静态成员类实现他们的集合视图(collection view),这些集合视图是由Map的keySet、entrySet和values方法返回的。同样,如Set和List这种集合接口的实现往往也是用非静态成员类来实现他们的迭代器(iterator)

public class MySet<E> extends AbstractSet<E> {
    @Override
    public Iterator<E> iterator() {
        return new MyIterator();
    }

    @Override
    public int size() {
        return 0;
    }

    private class MyIterator implements Iterator<E>{
        @Override
        public boolean hasNext() {
            return false;
        }

        @Override
        public E next() {
            return null;
        }
    }
}

如果声明成员类不要求访问外部实例,就要始终把static放在它的声明中,使其成为静态成员类。如果省略了static,每个实例都将包含一个额外的指向外围对象的引用。保存这份引用消耗时间和空间,并且会导致外围实例符合垃圾回收但仍然得到保留,造成内存泄露不被察觉。

私有静态成员类的一种常见用法是代表外部类所代表的对象的组件。以Map为例,许多Map的实现的内部都有个Entry对象,对应于Map中的key-value对。虽然每个entry都与一个Map关联,但entry的方法(getKey、getValue和setValue)并不需要访问该Map。因此,使用非静态成员类来表示entry很浪费;private的静态成员类是最佳选择。

匿名内部类没有名字,不是外部类的一个成员。不与其他成员一起被声明,而是在使用的同时被声明和实例化。匿名类可以出现在代码中任何允许存在表达式的地方。当且仅当匿名类出现在非静态的环境中时,才有外围实例。但即使出现在静态环境,也不拥有任何静态成员,而是拥有常数变量(constant variable),final修饰。

在lambda出现之前,匿名内部类是动态的创建小型函数对象(function object)和过程对象(process object)的最佳方式。另一个常见用法是在静态工厂方法的内部,参见20条的intArrayAsList方法。

局部类使用最少。

总之,如果一个嵌套类需要在单个方法之外仍然可见,或者太长了,不适合放在方法内部,就应该使用成员类。如果成员类的每个实例都需要一个指向其外围实例的引用,将其做成非静态的;否则静态的。假设这个嵌套类属于一个方法的内部,只需要在一个地方创建实例,且有了一个预置的类型可以说明这个类的特征,做成匿名类;否则局部类。

25. 限制源文件为单个顶级类

确保编译时一个类不会有多个定义。

猜你喜欢

转载自blog.csdn.net/wjl31802/article/details/94360956