Effective java笔记(三),类与接口

时间:2022-07-01 06:37:19

类与接口是Java语言的核心,设计出更加有用、健壮和灵活的类与接口很重要。

13、使类和成员的可访问性最小化

设计良好的模块会隐藏起所有的实现细节,仅使用API与其他模块进行通信。这个概念称为信息隐藏或封装,是软件设计的基本原则之一。信息隐藏可以是实现系统各模块的解耦,以使这些模块可以独立的开发、测试、优化。信息隐藏还提高了软件的可重用性,降低了构建大型系统的风险。

java中实体的可访问性由实体声明的位置以及访问修饰符(private、不写、protected、public)共同决定。尽可能的降低每个类及成员的访问级别。由于共有类是包的导出API的一部分,可以被客户端程序直接调用,对这种类的修改可能会影响程序向前的兼容性。而包级私有的类(默认访问级别)是包实现的一部分,不会对外提供接口,在以后的发现版本中可以对其放心修改。同样对于公有类,其public或protected成员是类的导出API的一部分,必须永远得到支持

  • 若子类覆盖了超类中的方法,则子类中的访问级别不能比超类低。否则使用多态时会报错。

  • 若类实现了一个接口,则接口中的所有方法在子类中都必须是公有的。接口中所有方法隐藏含有public的访问级别

final域指向可变对象:需要指出的是final的是引用不是对象本身。虽然引用本身不能被修改,但它指向的对象可以被修改。对于私有的final域,其对象不能被外部类访问和修改从而保证了这个对象不可变的能力。而public final域没有这个能力。所以包含公有域的类不是线程安全的。对于不可变对象,其是线程安全的,无需担心线程间对象不一致。

长度非零的数组总是可变的(例如,将元素null),所以,若类具有公有的静态final数组域,或返回这种域的方法,则客户端将能够修改其中的内容。这是一个安全漏洞。

public static final Thing[] VALUES = { ... };

修改方法:


//公有数组变私有
private static final Thing[] PRIVATE_VALUES = { ... }; //方法1,增加一个公有的不可变列表
public static final List<Thing> VALUES =
Collection.unmodifiableList(Arrays.asList(PRIVATE_VALUES)); //方法2,返回私有数组的拷贝
public static final Thing[] values() {
return PRIVATE_VALUES.clone; //注意要完全拷贝
}

确保公有静态final域所引用的对象都是不可变的

14、公有类中使用访问方法而非公有域

如果类可以在包外对其进行访问,应该提供访问方法(getter或setter方法),以保留将来改变该类内部实现的灵活性。若共有类暴露了它的数据域,由于程序的向前兼容,将来要改变它是不可能的。

对于私有嵌套类或包级私有的类,允许直接暴露它的数据域。因为它们的方位都被限制在了包内部。

公有类永远都不应该暴露可变的域,暴露不可变的域危害较小

15、使可变性最小化

不可变类是实例创建后不能被修改的类。java类库中的不可变了包括String、基本数据类型的包装类、BigInteger和BigDecimal。不可变类易于设计和实现且更加安全。

使类不可变,要遵循的规则:

  • 不要提供任何会修改对象状态的方法(setter方法)
  • 保证类不会被扩展,使类成为final的
  • 使所有的域都是private final的
  • 确保对于任何可变组件的互斥访问

例如:


public final class Complex { private final double re;//实部
private final double im; public Complex(double re, double im) {
this.re = re;
this.im = im;
} public double realPart() { return re; }
public double imaginaryPart() { return im; } //两个复数相加
public Complex add(Complex c) {
return new Complex(re + c.re, im + c.im); //新建实例
}
}

在这个类中,两个复数相加返回一个新的Complex实例,而不是修改这个实例。大多数不可变类都使用这种模式,它被称为函数的做法

不可变对象的优点:

  • 不可变对象是线程安全的,函数的做法这种模式使得不可变类对象只有一种状态,即被创建时的状态。
  • 不可变对象可以被*的共享。不需要为不可变对象提供clone方法或拷贝构造器,因为不可变对象是不变的,不存在被其他引用修改的可能。基于这个原因客户端应该尽可能的重用不可变对象(可将常用对象声明为公有的静态final常量或为不可变类提供静态工厂方法)。

不可变对象的缺点:对于每个不同的值都需要一个单独的对象,创建这些对象的代价可能很高,特别是对于大型对象。

若使用不可变对象执行一个多步骤的操作,每个步骤都将产生一个新对象,除了最后的结果外的其它对象都将被丢弃,此时程序的性能会比较低。处理这个问题的办法是为这个不可变类提供一个可变配套类。例如,不可变类String的公有配套类StringBuilder是可变的。

为了确保不可变性,不可变类绝对不允许自身被子类化。实现这个限制的方法有①、使类成为final的;②、让类所有的构造器变为私有的或包级私有的并添加公有的静态工厂方法来代替构造器。

例如:


public class Complex { private final double re;//实部
private final double im; private Complex(double re, double im) {
this.re = re;
this.im = im;
} public double realPart() { return re; }
public double imaginaryPart() { return im; } public static Complex valueOf(double re, double im) {
return new Complex(re, im);
}
}

对于处在包外的客户端而言,上面这个类实际上是final的,因为缺少public或protected构造器的类不能被继承。(子类的创建会先调用父类的构造器)。另外使用静态工厂方法的优点见第一条1、考虑用静态工厂方法代替构造器

忠告:

  • 不要为每个get方法编写一个相应的set方法,除非有很好的理由让类成为可变的类

  • 如果类不能被做成不可变的,应该尽可能的限制它的可变性,降低对象可以存在的状态数,这样可以更容易的分析该对象的行为,同时降低出错的可能性。

16、复合优先于继承

继承是实现代码重用的有力手段,但它违背了封装原则,使用不当将会导致软件变得很脆弱。在包内使用继承是非常安全的,因为这些代码一般由一个程序员来编写。使用专门为继承设计的类也是安全的,然而对于普通的具体类进行跨包的继承是非常危险的(不包括接口的继承和实现),这是因为子类依赖于其超类的具体实现,当超类的实现随版本变化时,子类可能会遭到破坏。

例如:为了程序调优需要查询HashSet类自从被建立以来一共添加过多少个元素


public class MyHashSet<E> extends HashSet<E>{
private static final long serialVersionUID = 1L; private int addCount = 0; @Override
public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return super.addAll(c);
} @Override
public boolean add(E e) {
addCount++;
return super.add(e);
} public int getAddCount() {
return addCount;
} public static void main(String[] args) {
MyHashSet<String> mHashSet = new MyHashSet<>();
mHashSet.addAll(Arrays.asList("aaa","bbb","ccc"));
System.out.println(mHashSet.getAddCount()); //输出为6
}
}

程序的输出为6,而正确的结果应该为3。哪里出错了呢?在HashSet的内部,addAll方法是基于它的add方法来实现的,所以在MyHashSet中调用addAll方法首先将addCount增加3,然后调用父类HashSet的addAll方法,根据多态其将会调用MyHashSet的add方法。因此每个元素被增加了两次。虽然这个错误可以通过重写addAll方法来遍历集合消除,但是我们不能保证下个版本中HashSet的实现保持不变,MyHashSet这个类是非常脆弱的。

使用复合(composition)可以有效的解决这个问题,在新的类中增加一个私有域来引用现有类的一个实例,在这个类中的实例方法通过调用被包含类的实例方法返回结果。这样的类不依赖于现有类具体的实现细节,即使现有的类添加了新的方法,也不会影响新的类。

利用复合实现MyHashSet:


public class MyHashSet<E>{ private int addCount = 0;
private Set<E> mSet = null; public MyHashSet(Set<E> set) {
this.mSet = set;
} public boolean addAll(Collection<? extends E> c) {
addCount += c.size();
return mSet.addAll(c);
} public boolean add(E e) {
addCount++;
return mSet.add(e);
} public int getAddCount() {
return addCount;
} public static void main(String[] args) {
HashSet<String> hashSet = new HashSet<>();
MyHashSet<String> mHashSet = new MyHashSet<>(hashSet);
mHashSet.addAll(Arrays.asList("aaa","bbb","ccc"));
System.out.println(mHashSet.getAddCount()); //输出为3
}
}

利用复合能够很好的实现现有类与新建类之间的解耦,增加了程序的灵活性与健壮性

注意:

  • 只有当子类真正是超类的子类型时,才适用于继承。即只有当两者之间确实存在“is-a”的关系时,才用继承。

  • 继承机制会把超类API中的所有缺陷传播到子类中去,而复合则允许设计新的API来隐藏这些缺陷。

17、要么为继承而设计,并提供文档说明,要么就禁止继承

对于专门为了继承而设计并且具有良好文档说明的类,继承它是安全的。该类的文档必须精确的描述覆盖每个方法所带来的影响,更一般的类必须在文档中说明,在哪些情况下它会调用可覆盖方法。如果方法调用到了可覆盖方法,在它的文档注释的末尾应该包含关于这些调用的描述信息,通常这样开头“This implementation... Note that...”

好的API文档应该描述一个给定的方法做了什么工作,而不是描述它是如何做到的。

上面的做法违背了这个原则,这正是继承破坏了封装性的后果。

注意:能被继承的类中构造器决不能调用可被覆盖的方法,无论是直接调用还是间接调用。

超类的构造器在子类的构造器之前运行,子类中覆盖的方法将会在子类的构造器运行之前被调用(多态),这可能会导致运行失败。

例如:


public final class Sub extends Super {
private final Date date; Sub() {
date = new Date();
} @Override
public void overrideMe() {
System.out.println(date.getTime());
} public static void main(String[] args) {
Sub sub = new Sub();
sub.overrideMe();
}
} class Super {
public Super() {
overrideMe();
} public void overrideMe(){ }
}

这段程序将抛出NullPointerException异常,因为overrideMe方法被Super构造器调用时,Sub构造器还没有初始化date,此时date为null。

在一个为了继承而设计的类中实现Cloneable接口或Serializable接口时,也应该遵循这样的规则,即无论clone还是readObject中都不可以调用可覆盖的方法,不管直接还是间接调用。因为clone和readObject的行为类似于构造器。

对于那些并非为了安全的进行子类化而设计和编写文档的类,应该禁止子类化。一个好的替代方案是使用包装类来对现有类进行扩展(要实现接口)。若具体的类没有实现接口而这个类必须允许被继承,一个合理的方法是确保这个类永远也不会调用它的任何可覆盖的方法,即消除这个类中可覆盖方法的自用性。

消除可覆盖类自用性的方法:

  • 将每个可覆盖方法的代码体移到一个私有的辅助方法中
  • 让每个可覆盖方法调用它的私有辅助方法
  • 在类中使用辅助方法代替可覆盖方法完成自我调用

例如:

class Super {
public Super() {
helperMethod();//使用辅助方法代替可覆盖方法
}
public void overrideMe(){
helperMethod();
} //辅助方法
private void helperMethod() {
//具体实现
....
}
}

18、接口优于抽象类

接口和抽象类的区别:①、抽象类允许包含某些方法的实现,接口不允许;②、为实现由抽象类定义的类型,类必须成为抽象类的一个子类,而java中只允许单继承,抽象类作为类型定义受到极大限制。对于接口任何类都能实现。

接口优于抽象类:

  • 接口比抽象类更容易被使用。java是单继承的。

  • 接口是定义mixin(混合类型)的理想选择。mixin类型:类除了实现它的“基本类型”之外,还可以实现这个mixin类型,以表明它提供了某些可供选择的(额外的)行为。例如:Comparable是个mixin接口。抽象类不能被用于定义mixin。

  • 接口允许我们构造非层次结构的类型框架。一个类可实现多个接口,也可以定义一个接口来继承多个接口。

  • 接口可以使用包装类模式为现有类增加功能,而抽象类只能用继承来增加功能

虽然接口不允许包含方法的实现,但接口可以为类的实现提供帮助。通过为每个重要接口提供一个抽象的骨架实现类,可以把接口和抽象类的优点结合起来。接口定义类型,骨架类定义接口基本的实现。例如,Collections框架的每个重要接口都有一个骨架实现类,包括AbstractCollection、AbstractSet、AbstractList、AbstractMap。骨架实现类有助于接口的实现,实现了这个接口的类可以把对于接口方法的调用,转发到一个内部私有类的实例上,这个内部私有类扩展了骨架实现类。这种方法被称作模拟多重继承,这项技术具有多重继承的绝大多数优点,同时避免了相应的缺陷。

编写骨架实现类步骤:

  • 认真研究接口,并确定哪些方法是最基本的,其他方法可以根据它们来实现。这些基本方法将成为骨架实现类的抽象方法。
  • 为接口中其他方法提供具体的实现。

例如:

public abstract class AbstractMapEntry<K,V> implements Map.Entry<K,V> {

    //基本方法
public abstract K getKey();
public abstract V getValue(); public V setValue(V value) {
throw new UnsupportedOperationException();
} @Override public boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
return eq(getKey(), e.getKey()) && eq(getValue(), e.getValue());
} private static boolean eq(Object o1, Object o2) {
return o1 == null ? o2 == null : o1.equals(o2);
} @Override public int hashCode() {
return (getKey() == null ? 0 : getKey().hashCode()) ^
(getValue() == null ? 0 : getValue().hashCode());
} @Override public String toString() {
return getKey() + "=" + getValue();
}
}

其中将基本方法getKey()getValue()作为骨架实现类的抽象方法。骨架实现类是为继承而设计的,所以应该遵循第17条的规则。

简单实现类同样是为了继承而设计,并且是实现了接口,区别在于它不是抽象的,而是最简单的实现。

要想在公有接口中增加方法,而不破坏实现这个接口的所有现有类,这是不可能的。因此设计接口时要非常谨慎。接口一旦被公开发行,并且被广泛实现,再想改变这个接口几乎是不可能的。接口通常是定义允许多个实现的类型的最佳途径。但当演变的容易性比灵活性和功能更为重要的时候,应该使用抽象类。因为抽象类的演变比接口的演变要容易的多。

19、接口只用于定义类型

接口应该只被用来定义类型,为任何其他目的而使用接口都是不当的。

常量接口是对接口的不良使用。实现常量接口,会导致把类内部的实现细节泄漏到类的导出API中。为非final类实现常量接口,它的子类的命名空间就会被接口的常量“污染”。

导出常量的合理方案:

  • 若导出常量与某个现有类或接口密切相关,应该把这些常量添加到这个类或接口中。

  • 若导出常量最好被看做枚举类型的成员,就应该使用枚举类型。

  • 否则使用不是实例化的工具类。

public final class ConstantsUtility {
private ConstantsUtility() {} public static final double PI = 3.1415926;
public static final int INIT_NUM = 1000;
}

若大量利用工具类导出的常量,可使用静态导入机制避免用类名来修饰常量名。(jdk1.5后才引入)

import static com.alent.ConstantsUtility;
public class Test {
double circleArea(double radius) {
return PI*radius*radius;
}
}

20、类层次优于标签类

标签类过于冗长、容易出错并且效率低下。

例如:

class Figure {
enum Shape { RECTANGLE, CIRCLE}; final Shape shape;
double length;
doube width;
double radius; Figure(double radius) {
this.shape = Shape.CIRCLE;
this.radius = radius;
} Figure(double length, double width) {
this.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();
}
}
}

这种标签类,破坏了可读性;域不能做出final的,除非构造器初始化了不相关的域。

使用类层次

interface Figure {
double area();
} class Circle implements Figure {
final double radius; Circle(double radius) {
this.radius = radius;
} double area() {
return Math.PI*(radius*radius);
}
} class Rectangle implements Figure {
final double length;
final double width;
Rectangle(double length, double width) {
this.length = length;
this.width = width;
} double area() {
return length*width;
}
}

类层次实现的优点:每个类型的实现都配有自己的类,这些类没有受到不相关的数据域的拖累,所有的域都是final的。类层次可以反映类型之间本质上的层次关系,有助于增强灵活性,并进行更好的编译时类型检查。

21、用函数对象表示策略

函数指针、代理、lambda表达式允许程序把“调用特殊函数的能力”存储起来并进行传递。这种机制通常用于允许函数的调用者通过传入第二个函数,来指定自己的行为。(策略模式)

java没有函数指针,但可以用对象引用实现同样的功能。定义一个对象,它的方法执行其他对象(这个对象被显式传递给这些方法)上的操作。这中对象被称为函数对象。

public interface Comparator<T> {
public int compare(T t1, T t2);
} class StringLengthComparator implements Comparator<String> { } //具体的策略类往往使用匿名类声明
Arrays.sort(stringArray, new Comparator<String>() {
public int compare(String s1, String s2){
return s1.length() - s2.length();
}
});

使用匿名内部类方式时,每次执行调用时都会创建一个新的实例。若它被重复执行,可将函数对象存储到一个私有的静态final域里,并重用它。

例如:

class Host {
private static class StrLenCmp implements Comparator<String>, Serializable{
public int compare(String s1, String s2){
return s1.length() - s2.length();
}
} public static final Comparator<String> STRIGN_LENGTH_COMPARATOR = new StrLenCmp();
}

简而言之,函数指针的主要用途是实现策略模式。为了在Java中实现这种模式,要声明一个接口来表示该策略,并为每个具体策略声明一个实现了该接口的类。当一个具体策略只被使用一次时,通常使用匿名类来声明和实例化一个具体策略类。当一个具体策略是设计用来重复使用的时候,通常将类实现为私有的静态成员类,并通过公有的静态final域被 导出,其类型为该策略接口。

22、优先考虑静态成员类

嵌套类是指被定义在另一个类内部的类。嵌套类存在的目的:为它的外围类提供服务。嵌套类有四种:静态成员类、非静态成员类、匿名类和局部类。除了静态成员类,其它三种都被称为内部类。

1)、静态成员类最好把它看做普通的类,它可以访问外围类的所有成员,包括那些声明为私有的成员。静态成员类是外围类的一个静态成员,常作为公有的辅助类。

2)、非静态成员类的每个实例都隐含着与外围类的一个实例相关联,在非静态成员类的实例方法内部能够调用外围实例上的方法。在没有外围实例的情况下,不可能创建非静态成员类的实例。当非静态成员类的实例被创建时,它与外围实例间的关联关系随之被建立,而且这种关联关系以后不能被修改。

非静态成员类常用来定义一个Adapter。例如,Set和List集合接口的实现使用非静态成员类实现迭代器。

public class MySet<E> extends AbstractSet<E> {
....
public Iterator<E> iterator() {
return new MyIterator();
} private class MyIterator implements Iterator<E> {
....
}
}

若声明的成员类不需要访问外围类,就应该把它声明为静态成员类。私有静态成员类常用来代表外围类所代表对象的组件。例如,Map内部的Entry类,对应于Map中的键值对,若将Entry声明为非静态成员类,则每个Entry对象都将会包含一个指向该Map的引用,这将浪费时间和空间。

3)、匿名类使用有很多限制。①、无法声明一个匿名类来实现多个接口或扩展一个类;②、匿名类出现在表达式中,所以必须保持简洁(10行或更少),否则将影响可读性。

匿名类的应用:①、创建函数对象;②、创建过程对象;③、用在静态工厂方法的内部

4)、局部类,在可以声明局部变量的地方(方法内部)都可以声明局部类。很少使用。

如果一个嵌套类需要在单个方法之外仍然可见,就不能使用局部类。如果嵌套类的每个实例都需要一个指向其外围实例的引用,应该使用非静态成员类,否则就做成静态成员类。