《Effective Java》——学习笔记(对于所有对象都通用的方法&类和接口)

版权声明:欢迎转载,请注明出处,谢谢! https://blog.csdn.net/benhuo931115/article/details/79376894

对于所有对象都通用的方法

第8条:覆盖equals时请遵守通用约定

不覆盖equals方法的情况

  • 类的每个实例本质上都是唯一的
  • 不关心类是否提供了“逻辑相等”的测试功能
  • 超类已经覆盖了equals,从超类继承过来的行为对于子类也是合适的
  • 类是私有的或是包级私有的,可以确定它的equals方法永远不会被调用,在这种情况下,应该覆盖equals方法,以防它被意外调用:

    @Override
    public boolean equals(Object o) {
        throw new AssertionError(); // Method is never called
    }
    

应该覆盖equals方法的情况

  • 类具有自己特有的“逻辑相等”概念

在覆盖equals方法的时候,必须遵守它的通用约定

  • 自反性,对于任何非null的引用值x,x.equals(x)必须返回true
  • 对称性,对于任何非null的引用值x和y,当且仅当y.equals(x)返回true时,x.equals(y)必须返回true
  • 传递性,对于任何非null的引用值x、y和z,如果x.equals(y)返回true,并且y.equals(z)也返回true,那么x.equals(z)也必须返回true
  • 一致性,对于任何非null的引用值x和y,只要equals的比较操作在对象中所用的信息没有被修改,多次调用x.equals(y)就会一致地返回true,或者一致地返回false
  • 对于任何非null的引用值x,x.equals(null)必须返回false

实现高质量equals方法:

  • 使用==操作符检查“参数是否为这个对象的引用”
  • 使用instanceof操作符检查“参数是否为正确的类型”
  • 把参数转换成正确的类型
  • 对于该类中的每个“关键”域,检查参数中的域是否与该对象中对应的域相匹配,对于既不是float也不是double类型的基本类型域,可以使用==操作符进行比较;对于对象引用域,可以递归地调用equals方法;对于float域,可以使用Float.compare方法;对于double域,则使用Double.compare;如果数组域中的每个元素都很重要,就可以使用Arrays.equals方法
  • 覆盖equals时总要覆盖hashCode
  • 不要企图让equals方法过于智能,例如,File类不应该试图把指向同一个文件的符号链接当作相等的对象来看待
  • 不要将equals声明中的Object对象替换为其他的类型,如

    public boolean equals(MyClass o) {
    
    }
    

第9条:覆盖equals时总要覆盖hashCode

一个很常见的错误根源在于没有覆盖hashCode方法,在每个覆盖了equals方法的类中,也必须覆盖hashCode方法。因为没有覆盖hashCode方法会违反Object规范的约定:相等的对象必须具有相等的散列码(hash code)

如下例

public final class PhoneNumber {

    private final short areaCode;
    private final short prefix;
    private final short lineNumber;

    public PhoneNumber(short areaCode, short prefix, short lineNumber) {
        rangeCheck(areaCode, 999, "area code");
        rangeCheck(prefix, 999, "prefix");
        rangeCheck(lineNumber, 999, "line number");
        this.areaCode = areaCode;
        this.prefix = prefix;
        this.lineNumber = lineNumber;
    }

    private void rangeCheck(short arg, int max, String name) {
        if(arg < 0 || arg > max) {
            throw new IllegalArgumentException(name + ": " + arg);
        }
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        PhoneNumber pn = (PhoneNumber) o;

        return pn.lineNumber == lineNumber
                && pn.prefix == prefix
                && pn.areaCode == areaCode;
    }

}

如果企图将这个类与HashMap一起使用

Map<PhoneNumber, String> m = new HashMap<PhoneNumber, String>();
m.put(new PhoneNumber(707, 867, 5309), "Jenny");

这个时候期望的是 m.put(new PhoneNumber(707, 867, 5309)) 会返回“Jenny”,但实际上返回的是null。这是由于PhoneNumber类没有覆盖hashCode方法,从而导致两个相等的实例具有不相等的散列码,修正这个问题需要为PhoneNumber类提供一个适当的hashCode方法

一个好的散列函数通常倾向于“为不相等的对象产生不相等的散列码”,理想情况下,散列函数应该把集合中不相等的实例均匀地分布到所有可能的散列值上,下面给出一种简单的解决办法:

  • 1.把某个非零的常数值,比如说17,保存在一个名为result的int类型的变量中
  • 2.对于对象中每个关键域f(指equals方法中涉及的每个域),完成以下步骤:

    • 1)为该域计算int类型的散列码c:

      • a. 如果该域是boolean类型,则计算(f ? 1 : 0)
      • b. 如果该域是byte、char、short或者int类型,则计算(int)f
      • c. 如果该域是long类型,则计算(int)(f ^ (f>>>32))
      • d. 如果该域是float类型,则计算Float.floatToIntBits(f)
      • e. 如果该域是double类型,则计算Double.doubleToLongBits(f),然后按照步骤c,为得到的long类型值计算散列值
      • f. 如果该域是一个对象引用,并且该类的equals方法通过递归地调用equals的方式来比较这个域,则同样为这个域递归地调用hashCode,如果需要更复杂的比较,则为这个域计算一个“范式”,然后针对这个范式调用hashCode,如果这个域的值为null,则返回0
      • g. 如果该域是一个数组,则要把每一个元素当做单独的域来处理,也就是说,递归地应用上述规则,对每个重要的元素计算一个散列码,然后根据2)中的做法把这些散列值组合起来。如果数组域中的每个元素都很重要,可以利用Arrays.hashCode方法
    • 2)按照下面的公式,把步骤1)中计算得到的散列码c合并到result中:

      result = 31 * result + c;
      // 31有个很好的特性,即用移位和减法来代替乘法,可以得到更好的性能:31 * i == (i<<5) - i
      
    • 3)返回result

    • 4)写完了hashCode方法之后,要编写单元测试来验证

在散列码的计算过程中,可以把冗余域排除在外,如果一个域的值可以根据参与计算的其他域值计算出来,则可以把这样的域排除在外。必须排除equals比较计算中没有用到的任何域,否则很有可能违反hashCode约定

上述示例中的hashCode方法如下

@Override
public int hashCode() {
    int result = 17;
    result = 31 * result + areaCode;
    result = 31 * result + prefix;
    result = 31 * result + lineNumber;
    return result;
}

如果一个类是不可变的,并且计算散列码的开销也比较大,就应该考虑把散列码缓存在对象内部,而不是每次请求的时候都重新计算散列码。如果这种类型的大多数对象会被用做散列键(hash keys),就应该在创建实例的时候计算散列码,否则,可以选择“延迟初始化”散列码,一直到hashCode被第一次调用的时候才初始化,实现如下:

// Lazily initialized, cached hashCode
private volatile int hashCode;

@Override
public int hashCode() {
    int result = hashCode;
    if(result == 0) {
        result = 17;
        result = 31 * result + areaCode;
        result = 31 * result + prefix;
        result = 31 * result + lineNumber;
        hashCode = result;
    }
    return result;
}

不要试图从散列码计算中排除掉一个对象的关键部分来提高性能,这可能会导致散列表慢到根本无法使用

第10条:始终要覆盖toString

虽然java.lang.Object提供了toString方法的一个实现,但它返回的字符串包含类的名称,以及一个“@”符合,接着是散列码的无符号十六进制表示法,例如“PhoneNumber@163b91”

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

当对象被传递给println、printf、字符串联操作符(+)以及assert或者被调试器打印出来时,toString方法会被自动调用。在实际应用中,toString方法应该返回对象中包含的所有值得关注的信息,如果对象太大,或者对象中包含的状态信息难以用字符串来表达,这样做就有点不切实际。在这种情况下,toString应该返回一个摘要信息

在实现toString的时候,必须要确定是否在文档中指定返回值的格式。对于值类(value class),比如电话号码类、矩阵类,也建议这么做。指定格式的好处是,它可以被用做一种标准的、明确的、适合人阅读的对象表示法。如果指定了格式,最好再提供一个相匹配的静态工厂或者构造器,以便很容易地在对象和它的字符串表示法之间来回转换。Java平台类库中的许多值类都采用了这种做法,包括BigInteger、BigDecimal和绝大多数的基本类型包装类

指定toString返回值的格式也有不足之处:如果这个类已经被广泛使用,一旦指定格式,就必须始终如一地坚持这种格式

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

如PhoneNumber类中的toString方法可以指定如下格式:

/**
 * Returns the string representation of this phone number,
 * The string consists of fourteen characters whose format
 * is "(XXX) YYY-ZZZZ", where XXX is the area code, YYY is
 * the prefix, and ZZZZ is the line number. (Each of the
 * capital letters represents a single decimal digit.)
 * 
 * if any of the three parts of this phone number is too small
 * to fill up its field, the field is padded with leading zeros.
 * For example, if the value of the line number is 123, the last
 * four characters of the string representation will be "0123".
 * 
 * Note that there is a single space separating the closing
 * parenthesis after the area code from the first digit of the
 * prefix
 * @return
 */
@Override
public String toString() {
    return String.format("(%03d) %03d-%04d",
            areaCode, prefix, lineNumber);
}
// 示例
// (408) 867-5309

如果不指定格式,那么文档注释部分应该如下:

/**
 * Returns a brief description of this potion. The exact details
 * of the representation are unspecified and subject to change,
 * but the following may be regarded as typical:
 * 
 * "[Potion #9: type=love, smell=turpentine, look=india ink]"
 * @return
 */
@Override
public String toString() { ... }

第11条:谨慎地覆盖clone

Cloneable接口的目的是作为对象的一个mixin接口,表明这样的对象允许克隆(clone)。遗憾的是,它并没有成功地达到这个目的,其主要的缺陷在于,它缺少一个clone方法,Object的clone方法是受保护的,如果不借助于反射(reflection),就不能仅仅因为一个对象实现了Cloneable,就可以调用clone方法,即使是反射调用也可能会失败,因为不能保证该对象一定具有可访问的clone方法

Cloneable决定了Object中受保护的clone方法实现的行为:如果一个类实现了Cloneable,Object的clone方法就返回该对象的逐域拷贝,否则就抛出CloneNotSupportedException异常,对于Cloneable接口,它改变了超类中受保护的方法的行为

拷贝对象往往会导致创建它的类的一个新实例,但它同时也会要求拷贝内部的数据结构,这个过程中没有调用构造器

如果覆盖了非final类中的clone方法,则应该返回一个通过调用super.clone而得到的对象,如果类的所有超类都遵守这条规则,那么调用super.clone最终会调用Object的clone方法,从而创建出正确类的实例

如果一个类中实现了Cloneable,并且它的超类都提供行为良好clone方法,从super.clone()中得到的对象可能会接近于最终要返回的对象,也可能相差甚远。如果在这个类中声明的域(如果有的话),每个域包含一个基本类型的值,或者包含一个指向不可变对象的引用,那么返回的对象就是所需要的,如PhoneNumber类

@Override
public PhoneNumber clone() {
    try {
        return (PhoneNumber) super.clone();
    } catch (CloneNotSupportedException e) {
        throw new AssertionError(); // Cannot happen
    }
}

如果对象中包含的域引用了可变的对象,使用上述简单的clone实现可能会导致灾难性的后果,因为修改原始的实例会破坏被克隆对象中的约束条件

clone架构与引用可变对象的final域的正常用法是不兼容的,除非在原始对象和克隆对象之间可以安全地共享此可变对象。为了使类成为可克隆的,可能有必要从某些域中去掉final修饰符

简而言之,所有实现了Cloneable接口的类都应该用一个公有的方法覆盖clone,此公有方法首先调用super.clone,然后修正任何需要修正的域,一般情况下,这意味着要拷贝任何包含内部“深层结构”的可变对象,并用指向新对象的引用代替原来指向这些对象的引用。虽然,这些内部拷贝操作往往可以通过递归地调用clone来完成,但这通常并不是最佳方法,如果该类只包含基本类型的域,或者指向不可变对象的引用,那么多半的情况是没有域需要修正。这条规则也有例外,譬如,代表序列号或其他唯一ID值的域,或者代表对象的创建时间的域,不管这些域是基本类型还是不可变的,它们都需要被修正

另一个实现对象拷贝的好办法是提供一个拷贝构造器或拷贝工厂。拷贝构造器只是一个构造器,它唯一的参数类型是包含该构造器的类,例如:

public Yum(Yum yum);

拷贝工厂是类似于拷贝构造器的静态工厂:

public static Yum newInstance(Yum yum);

拷贝构造器的做法,及其静态工厂方法的变形,都比Cloneable/clone方法具有更多的优势:它们不依赖于某一种很有风险的、语言之外的对象创建机制;它们不要求遵守尚未制定好文档的规范;它们不会与final域的正常使用发生冲突;它们不会抛出不必要的受检异常;它们不需要进行类型转换

拷贝构造器或者拷贝工厂可以带一个参数,参数类型是通过该类实现的接口。例如所有通用集合实现都提供了一个拷贝构造器,它的参数类型为Collection或者Map,基于接口的拷贝构造器和拷贝工厂(或称为转换构造器和转换工厂),允许客户选择拷贝的实现类型,而不是强迫客户接受原始的实现类型。例如,假设有一个HashSet,并且希望把它拷贝成一个TreeSet,clone方法无法提供这样的功能,但是用转换构造器很容易实现:new TreeSet(s)

既然Cloneable具有上述那么多问题,可以肯定地说,其他的接口都不应该扩展(extend)这个接口,为了继承而设计的类也不应该实现(implement)这个接口

第12条:考虑实现Comparable接口

compareTo方法是Comparable接口中唯一的方法,compareTo方法不但允许进行简单的等同性比较,而且允许执行顺序比较。类实现了Comparable接口,就表明它的实例具有内在的排序关系,为实现Comparable接口的对象数组进行排序就这么简单:

Arrays.sort(a);

对存储在集合中的Comparable对象进行搜索、计算极限值以及自动维护也同样简单

一旦类实现了Comparable接口,它就跟许多泛型算法以及依赖于该接口的集合实现进行协作从而可以获得非常强大的功能。事实上,Java平台类库中的所有值类,都实现了Comparable接口,如果你正在编写一个值类,它具有非常明显的内在排序关系,比如按字母顺序、按数值顺序或者按年代顺序,就应该考虑实现这个接口

public interface Comparable<T> {
    int compareTo(T t);
}

将这个对象与指定的对象进行比较,当该对象小于、等于或大于指定对象的时候,分别返回一个负整数、零或者正整数,如果由于指定对象的类型而无法与该对象进行比较,则抛出ClassCastException异常

如果想为一个实现了Comparable接口的类增加值组件,应该编写一个不相关的类,其中包含第一个类的一个实例,然后提供一个“视图(View)”方法返回这个实例。这样既可以自由地在第二个类上实现compareTo方法,同时也允许它的客户端在必要的时候,把第二个类的实例视为同第一个类的实例

如果一个类有多个关键域,必须从最关键的域开始,逐步进行到所有的重要域,如果某个域的比较产生了非零的结果(零代表相等),则整个比较操作结束,并返回该结果,如果最关键的域是相等的,则进一步比较次最关键的域,以此类推

下面通过PhoneNumber类的compareTo方法来说明:

public int compareTo(PhoneNumber pn) {
    // Compare area codes
    if(areaCode < pn.areaCode) {
        return -1;
    }
    if(areaCode > pn.areaCode) {
        return 1;
    }
    // Area codes are equal, compare prefixes
    if(prefix < pn.prefix) {
        return -1;
    }
    if(prefix > pn.prefix) {
        return 1;
    }
    // Area codes and prefixes are equal, compare line numbers
    if(lineNumber < pn.lineNumber) {
        return -1;
    }
    if(lineNumber > pn.lineNumber) {
        return 1;
    }
    return 0; // All fields are equal
}

可以简化代码如下:

public int compareTo(PhoneNumber pn) {
    // Compare area codes
    int areaCodeDiff = areaCode - pn.areaCode;
    if(areaCodeDiff != 0) {
        return areaCodeDiff;
    }
    // Area codes are equal, compare prefixes
    int prefixDiff = prefix - pn.prefix;
    if(prefixDiff != 0) {
        return prefixDiff;
    }
    // Area codes and prefixes are equal, compare line numbers
    return lineNumber - pn.lineNumber; // All fields are equal
}

类和接口

第13条:使类和成员的可访问性最小化

设计良好的模块会隐藏所有的实现细节,把它的API与它的实现清晰地隔离开来,然后,模块之间只通过它们的API进行通信,一个模块不需要知道其他模块的内部工作情况,这个概念被称为信息隐藏或封装,是软件设计的基本原则之一

Java程序设计语言提供了许多机制来协助信息隐藏。访问控制机制决定了类、接口和成员的可访问性,实体的可访问性是由该实体声明所在的位置,以及该实体声明中所出现的访问修饰符(private、protected和public)共同决定的

对于顶层的(非嵌套的)类和接口,只有两种可能的访问级别:包级私有的(package-private)和公有的(public),如果类或接口能够被做成包级私有的,它就应该被做成包级私有的

如果一个包级私有的顶层类(或者接口)只是在某一个类的内部被用到,就应该考虑使它成为唯一使用它的那个类的私有嵌套类

对于成员(域、方法、嵌套类和嵌套接口)有四种可能的访问级别,下面按照可访问性的递增顺序罗列出来:

  • 私有的(private)——只有在声明该成员的顶层类内部才可以访问这个成员
  • 包级私有的(package-private)——声明该成员的包内部的任何类都可以访问这个成员,它被称为“缺省(default)访问级别”,如果没有为成员指定访问修饰符,就采用这个访问级别
  • 受保护的(protected)——声明该成员的类的子类可以访问这个成员,并且,声明该成员的包内部的任何类也可以访问这个成员
  • 公有的(public)——在任何地方都可以访问该成员

实例域决不能是公有的,如果域是非final的,或者是一个指向可变对象的final引用,那么一旦使这个域成为公有的,就放弃了对存储在这个域中的值进行限制的能力;这意味着,也放弃了强制这个域不可变的能力。同时,当这个域被修改的时候,也失去了对它采取任何行动的能力,因此,包含公有可变域的类并不是线程安全的,即使域是final的,并且引用不可变的对象,当把这个域变成公有的时候,也就放弃了“切换到一种新的内部数据表示法”的灵活性

同样的建议也适用于静态域,只是有一种例外情况,假设常量构成了类提供的整个抽象中的一部分,可以通过公有的静态final域来暴露这些常量

长度非零的数组总是可变的,所以,类具有公有的静态final数组域,或者返回这种域的访问方法,这几乎总是错误的,如果类具有这样的域或者访问方法,客户端将能够修改数组中的内容,这是安全漏洞的一个常见根源:

// Potential security hole!
public static final Thing[] VALUES = { ... };

许多IDE会产生返回指向私有数组域的引用的访问方法,这样就会产生这个问题。修正这个问题有两种方法,可以使公有数组变成私有的,并增加一个公有的不可变列表:

private static final Thing[] PRIVATE_VALUES = { ... };
public static final List<Thing> VALUES = Collections.unmodifiableList(Arrays.asList(PRIVATE_VALUES));

另一种方法是,可以使数组变成私有的,并添加一个公有方法,它返回私有数组的一个备份:

private static final Thing[] PRIVATE_VALUES = { ... };
public static final Thing[] values() {
    return PRIVATE_VALUES.clone();
}

第14条:在公有类中使用访问方法而非公有域

公有类不应该直接暴露数据域,而应该提供公有的访问方法(getter、setter)

第15条:使可变性最小化

不可变类只有其实例不能被修改的类,每个实例中包含的所有信息都必须在创建该实例的时候就提供,并在对象的整个生命周期内固定不变

不可变的类比可变的类更加易于设计、实现和使用,它们不容易出错,且更加安全

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

  • 1.不要提供任何会修改对象状态的方法
  • 保证类不会被扩展,为了防止子类化,一般做法是使这个类成为final的
  • 使所有的域都是final的
  • 使所有的域都成为私有的
  • 确保对于任何可变组件的互斥访问。如果类具有指向可变对象的域,则必须确保该类的客户端无法获得指向这些对象的引用。并且,永远不要用客户端提供的对象引用来初始化这样的域,也不要从任何访问方法中返回该对象引用。在构造器、访问方法和readObject方法中请使用保护性拷贝技术

如下例:

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);
    }

    public Complex subtract(Complex c) {
        return new Complex(re - c.re, im - c.im);
    }

    public Complex multiply(Complex c) {
        return new Complex(re * c.re - im * c.im, re * c.im + im * c.re);
    }

    public Complex divide(Complex c) {
        double tmp = c.re * c.re + c.im * c.im;
        return new Complex((re * c.re + im * c.im) / tmp, (im * c.re - re * c.im) / tmp);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        Complex c = (Complex) o;

        return Double.compare(re, c.re) == 0 &&
                Double.compare(im, c.im) == 0;
    }

    @Override
    public int hashCode() {
        int result = 17 + hashDouble(re);
        result = 31 * result + hashDouble(im);
        return result;
    }

    private int hashDouble(double val) {
        long longBits = Double.doubleToLongBits(re);
        return (int) (longBits ^ (longBits >>> 32));
    }

    @Override
    public String toString() {
        return "(" + re + " + " + im + ")";
    }
}

不可变对象本质上是线程安全的,它们不要求同步,所以,不可变对象可以被自由地共享

不可变类的缺点是,对于每个不同的值都需要一个单独的对象,创建这种对象的代价可能很高,特别是对于大型对象的情形。如果执行一个多步骤的操作,并且每个步骤都会产生一个新的对象,除了最后的结果之外其他的对象最终都会被丢弃,此时性能问题就会显露出来。处理这种问题有两种方法

  • 第一种办法,先猜测一下会经常用到哪些多步骤的操作,然后将它们作为基本类型提供。如果某个多步骤操作已经作为基本类型提供,不可变的类就可以不必在每个步骤单独创建一个对象
  • 第二种方法,如果无法预测,最好的办法是提供一个公有的可变配套类,在Java平台类库中,这种方法的主要例子是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 static Complex valueOf(double re, double im) {
        return new Complex(re, im);
    }
    ...
}

不可变的类在实际使用过程中,为了提高性能有所放松,事实上是:没有一个方法能够对对象的状态产生外部可见的改变。然而,许多不可变的类拥有一个或者多个非final的域,它们在第一次被请求执行这些计算的时候,把一些开销昂贵的计算结果缓存在这些域中,如果将来再次请求同样的计算,就直接返回这些缓存的值,从而节约了重新计算所需要的开销

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

总之,除非有很好的理由要让类成为可变的类,否则就应该是不可变的。如果类不能被做成是不可变的,仍然应该尽可能地限制它的可变性,降低对象可以存在的状态数,可以更容易地分析该对象的行为,同时降低出错的可能性。因此,除非有令人信服的理由要使域变成非final的,否则要使每个域都是final的

第16条:复合优先于继承

在包的内部使用继承是非常安全的,在那里,子类和超类的实现都处在同一个程序员的控制之下,对于专门为了继承而设计、并且具有很好的文档说明的类来说,使用继承也是非常安全的。然而,对普通的具体类进行跨越包边界的继承,则是非常危险的

与方法调用不同的是,继承打破了封装性,子类依赖于其超类中特定功能的实现细节,因而,子类必须要跟着其超类的更新而演变

不用扩展现有的类,而是在新的类中增加一个私有域,它引用现有类的一个实例,这种设计被称做“复合”,因为现有的类变成了新类的一个组件,这样得到的类将会非常稳固,它不依赖于现有类的实现细节,即使现有的类添加了新的方法,也不会影响新的类

只有当子类真正是超类的子类型时,才适合用继承,换句话说,对于两个类A和B,只有两者之间确实存在“is-a”关系的时候,类B才应该扩展类A

如果在适合于使用复合的地方使用了继承,则会不必要地暴露实现细节,这样得到的API会限制在原始的实现上,永远限定了类的性能。更为严重的是,由于暴露了内部的细节,客户端就有可能直接访问这些内部细节

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

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

该类必须有文档说明它可覆盖的方法的自用性,对于每个公有的或受保护的方法或者构造器,它的文档必须指明该方法或者构造器调用了哪些可覆盖的方法,是以什么顺序调用的,每个调用的结果又是如何影响后续的处理过程的(所谓可覆盖的方法,是指非final的,公有的或受保护的)

为了允许继承,类还必须遵守其他一些约束。构造器决不能调用可被覆盖的方法,无论是直接调用还是间接调用,如果违反了这条规则,很有可能导致程序失败,超类的构造器在子类的构造器之前运行,所以,子类中覆盖版本的方法将会在子类的构造器运行之前就先被调用

如下例:

public class Super {

    // Broken - constructor invokes an overridable method
    public Super() {
        overrideMe();
    }

    public void overrideMe() {
    }
}

public final class Sub extends Super {
    private final Date date; // Blank final, set by constructor

    public Sub() {
        date = new Date();
    }

    // Overriding method invoked by superclass constructor
    @Override
    public void overrideMe() {
        System.out.println(date);
    }

    public static void main(String[] args) {
        Sub sub = new Sub();
        sub.overrideMe();
    }
}

运行结果为:第一次打印出的是null,因为overrideMe方法被Super构造器调用的时候,构造器Sub还没有机会初始化date域,第二次才打印出了日期

如果决定在一个为了继承而设计的类中实现Cloneable或者Serializable接口,就应该意识到,因为clone和readObject方法在行为上非常类似于构造器,所以类似的限制规则也是适用的:无论是clone和readObject,都不可以调用可覆盖的方法,不管是以直接还是间接的方式。对于readObject方法,覆盖版本的方法将在子类的状态被反序列化(deserialized)之前先被运行;而对于clone方法,覆盖版本的方法将在子类的clone方法有机会修正被克隆对象的状态之前先被运行

最后,如果决定在一个为了继承而设计的类中实现Serializable,并且该类有一个readResolve或者writeReplace方法,就必须使readResolve或者writeReplace成为受保护的方法,而不是私有的方法。如果这些方法是私有的,那么子类将会不声不响地忽略掉这两个方法

如果必须继承普通的具体类,应该完全消除这个类中可覆盖方法的自用特性,这样做之后,就可以创建“能够安全地进行子类化”的类,覆盖方法将永远也不会影响其他任何方法的行为

消除类中可覆盖方法的自用特性,而不改变它的行为,可以将每个可覆盖方法的代码体移到一个私有的“辅助方法”中,并且让每个可覆盖的方法调用它的私有辅助方法,然后,用“直接调用可覆盖方法的私有辅助方法”来代替“可覆盖方法的每个自用调用”

第18条:接口优于抽象类

现有的类可以很容易被更新,以实现新的接口。一般来说,无法更新现有的类来扩展新的抽象类

接口是定义mixin(混合类型)的理想选择。例如,Comparable是一个mixin接口,它允许类表明它的实例可以与其他的可相互比较的对象进行排序

接口允许我们构造非层次结构的类型框架。如singer(歌唱家)和songwriter(作曲家),在现实生活中,有些歌唱家本身也是作曲家,所以对于单个类而言,它同时实现了Singer接口和Songwriter接口是完全允许的

虽然接口不允许包含方法的实现,但是,使用接口来定义类型可以提供实现上的帮助。通过对导出的每个重要接口都提供一个抽象的骨架实现类,可以把接口和抽象类的优点结合起来,接口的作用仍然是定义类型,但是骨架实现类接管了所有与接口实现相关的工作

使用抽象类来定义允许多个实现的类型,与使用接口相比有一个明显的优势:抽象类的演变比接口的演变要容易得多。如果在后续的发行版本中,希望在抽象类中增加新的方法,始终可以增加具体方法,它包含合理的默认实现,然后,该抽象类的所有现有实现都将提供这个新的方法,对于接口,这样做是行不通的

设计公有的接口要非常谨慎,接收一旦被公开发行,并且已被广泛实现,再想改变这个接口几乎是不可能的。简而言之,接口通常是定义允许多个实现的类型的最佳途径,这条规则有个例外,即当演变的容易性比灵活性和功能更为重要的时候,在这种情况下,应该使用抽象类来定义类型,但前提是必须理解并且可以接受这些局限性

第19条:接口只用于定义类型

当类实现接口时,接口就充当可以引用这个类的实例的类型(type),为了任何其他目的而定义接口是不恰当的

第20条:类层次优于标签类

下例是一个标签类,它能够表示圆形或者矩形

public class Figure {

    enum Shape {RECTANGLE, CIRCLE}

    final Shape shape;

    double length;
    double width;

    double radius;

    Figure(double radius) {
        shape = Shape.CIRCLE;
        this.radius = radius;
    }

    public 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();
        }
    }
}

这种标签类有着许多缺点,充斥着样板代码,包括枚举声明、标签域以及条件语句,总之,标签类过于冗长、容易出错,并且效率低下

将原始标签类转变成类层次

abstract class Figure {

    abstract double area();
}

class Circle extends Figure {
    final double radius;

    public Circle(double radius) {
        this.radius = radius;
    }

    double area() {
        return Math.PI * (radius * radius);
    }
}

class Rectangle extends Figure {
    final double length;
    final double width;

    public Rectangle(double length, double width) {
        this.length = length;
        this.width = width;
    }

    double area() {
        return 0;
    }
}

这个类层次纠正了前面标签类的所有缺点,每个类型的实现都配有自己的类,不受不相关的数据域的影响,所有的域都是final的,编译器确保每个类的构造器都初始化它的数据域

类层次的另一种好处在于,它们可以用来反映类型之间本质上的层次关系,有助于增强灵活性,并进行更好的编译时类型检查

标签类很少有适用的时候,应该考虑用类层次来替换

第21条:用函数对象表示策略

调用对象上的方法通常是执行该对象上的某项操作,如果它的方法执行其他对象(这些对象被显式传递给这些方法)上操作,就等同于一个指向该方法的指针,这样的实例被称为函数对象

class StringLengthComparator {
    public int compare(String s1, String s2) {
        return s1.length() - s2.length();
    }
}

指向StringLengthComparator对象的引用可以被当作是一个指向该比较器的“函数指针”,可以在任意一对字符串上被调用。换句话说,StringLengthComparator实例是用于字符串比较操作的具体策略

作为典型的具体策略类,StringLengthComparator类是无状态的,它没有域,所以,这个类的所有实例在功能上都是相互等价的,因此,作为一个Singleton是非常合适的,可以节省不必要的对象创建开销

class StringLengthComparator {
    private StringLengthComparator() {
    }
    public static final StringLengthComparator 
            INSTANCE = new StringLengthComparator();

    public int compare(String s1, String s2) {
        return s1.length() - s2.length();
    }
}

具体的策略类往往使用匿名类声明,下面的语句根据长度对一个字符串数组进行排序:

Arrays.sort(stringArray, new Comparator<String>() {
    public int compare(String s1, String s2) {
        return s1.length() - s2.length();
    }
});

但是以这种方式使用匿名类时,将会在每次执行调用的时候创建一个新的实例,如果它被重复执行,考虑将函数对象存储到一个私有的静态final域里,并重用它

因为策略接口被用做所有具体策略实例的类型,所以不需要为了导出具体策略,而把具体策略类做成公有的。相反,“宿主类”还可以导出公有的静态域(或者静态工厂方法),其类型为策略接口,具体的策略类可以是宿主类的私有嵌套类,下面的例子使用静态成员类,而不是匿名类,以便允许具体的策略类实现第二个接口Serializable:

class Host {
    private static class StrLenCmp 
            implements Comparator<String>, Serializable {

        public int compare(String s1, String s2) {
            return s1.length() - s2.length();
        }
    }

    // Returned comparator is Serializable
    public static final Comparator<String> 
            STRING_LENGTH_COMPARATOR = new StrLenCmp();

}

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

第22条:优先考虑静态成员类

嵌套类是指被定义在一个类的内部类,嵌套类存在的目的应该只是为了它的外围类提供服务

嵌套类有四种:

  • 静态成员类
  • 非静态成员类
  • 匿名类
  • 局部类

静态成员类是最简单的一种嵌套类,可以把它看作是普通的类,只是被声明在另一个类的内部而已,它可以访问外围类的所有成员,包括那些声明为私有的成员。静态成员类是外围类的一个静态成员,与其他的静态成员一样,也遵守同样的可访问性规则

非静态成员类的每个实例都隐含着与外围类的一个外围实例相关联,在非静态成员类的实例方法内部,可以调用外围实例上的方法,或者利用修饰过的this构造获得外围实例的引用。通常情况下,当在外围类的某个实例方法的内部调用非静态成员类的构造器时,外围实例与它的非静态成员类的关联关系会被自动建立起来

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

public class MySet<E> extends AbstractSet<E> {

    public Iterator<E> iterator() {
        return new MyIterator();
    }

    private class MyIterator implements Iterator<E> {
        ...
    }
}

如果声明成员类不要求访问外围实例,就要始终把static修饰符放在它的声明中,使它成为静态成员类,而不是非静态成员类。如果省略了static修饰符,则每个实例都将包含一个额外的指向外围对象的引用,保存这份引用要消耗时间和空间,并且会导致外围实例在符合垃圾回收时却仍然得以保留

匿名类没有名称,它不是外围类的一个成员,它并不与其他的成员一起被声明,而是在使用的同时被声明和实例化

在任何“可以声明局部变量”的地方,都可以声明局部类,并且局部类也遵守同样的作用域规则

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

展开阅读全文

没有更多推荐了,返回首页