转载

《Effective Java》Second Edition 第7章 方法

第7章 方法

第38条:检查参数的有效性

  1. 对于公有的方法,要用Javadoc的@throws标签(tag)在文档中说明违反参数值限制时会抛出的异常。这样的异常通常为IllegalArgumentException、IndexOutOfBoundsException或NullPointerException。
  2. 对于未被导出的方法(unexported method),作为包的创建者,你可以控制这个方法将在哪些情况下被调用,因此你可以,也应该确保只将有效的参数值传递进来。因此,非公有的方法通常应该使用断言(assertion)来检查它们的参数,具体做法如下:
// Private helper function for a recursive sort
private static void sort(long a[], int offset, int length) {
    assert a!=null;
    assert offset>=0 && offset<=a.length;
    assert length>=0 && length<=a.length-offset;
    ... // Do the computation
}
  • 1

如前所述,有些参数被方法保存起来供以后使用,构造器正是代表了这种原则的一种特殊情形。检查构造器参数的有效性是非常重要的,这样可以避免构造出来的对象违反了这个类的约束条件。

不要从本条目的内容中得出这样的结论:对参数的任何限制都是件好事。相反,在设计方法时,应该使他们尽可能地通用,并符合实际的需要。假如方法对于它能接受的所有参数值都能够完成合理的工作,对参数的限制就应该是越少越好。然而,通常情况下,有些限制对于被实现的抽象来说是固有的。

第39条:必要时进行保护性拷贝

// Broken "immutable" time period class
public final class Period {
    private final Date start;
    private final Date end;
    public Period(Date start, Date end) {
        if (start.compareTo(end) > 0)
            throw new IllegalArgumentException(
                start + " after " + end);
        this.start = start;
        this.end = end;
    }
    public Date start() {
        return start;
    }
    public Date end() {
        return end;
    }
}
  • 1

然而,因为Date类本身是可变的,因此很容易违反这个约束条件:

// Attack the internals of a Period instance
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
end.setYear(78); // Modifies internals of p!
  • 1

为了保护Period实例的内部信息避免受到这种攻击,对于构造器的每个可变参数进行保护性拷贝(defensive copy)是必要的,并且使用备份对象作为Period实例的组件,而不使用原始的对象:

// Repaired constructor - makes defensive copies of parameters
public Period(Date start, Date end) {
    this.start = new Date(start.getTime());
    this.end = new Date(end.getTime());
    if (this.start.compareTo(this.end) > 0)
        throw new IllegalArgumentException(start + " after " + end);
}
  • 1

注意,保护性拷贝是在检查参数的有效性之前进行的,并且有效性检查是针对拷贝之后的对象,而不是针对原始的对象。这样做可以避免在“危险阶段(window of vulnerability)”期间从另一个线程改变类的参数,这里的危险阶段是指从检查参数开始,直到拷贝参数之间的时间段。(在计算机安全社区中,这被称作Time-Of-Check/Time-Of-Use或者TOCTOU攻击。)

我们没有用Date的clone方法来进行保护性拷贝。因为Date是非final的,不能保证clone方法一定返回类为java.util.Date的对象:它有可能返回专门出于恶意的目的而设计的不可信子类的实例。

// Second attack on the internals of a Period instance
Date start = new Date();
Date end = new Date();
Period p = new Period(start, end);
p.end().setYear(78); // Modifies internals of p!
  • 1

为了防御这第二种攻击,只需修改这两个访问方法,使它返回可变内部域的保护性拷贝即可:

// Repaired accessors - make defensive copies of internal fields
public Date start() {
    return new Date(start.getTime());
}
public Date end() {
    return new Date(end.getTime());
}
  • 1

每当编写方法或者构造器时,如果它要允许客户提供的对象进入到内部数据结构中,则有必要考虑一下,客户提供的对象是否有可能是可变的。如果是,就要考虑你的类是否能够容忍对象进入数据结构之后发生变化。如果答案是否定的,就必须对该对象进行保护性拷贝,并且让拷贝之后的对象而不是原始对象进入到数据结构中。例如,如果你正在考虑使用由客户提供的对象引用作为内部Set实例的元素,或者作为内部Map实例的键(key),就应该意识到,如果这个对象在插入之后再被修改,Set或者Map的约束条件就会遭到破坏。

在内部组件被返回给客户端之前,对它们进行保护性拷贝也是同样的道理。不管类是否为不可变的,在把一个指向内部可变组件的引用返回给客户端之前,也应该加倍认真地考虑。解决方案是,应该返回保护性拷贝。记住长度非零的数组总是可变的。因此,在把内部数组返回客户端之前,应该总要进行保护性拷贝。另一种解决方案是,给客户端返回该数组的不可变视图(immutable view)。

可以肯定地说,上述的真正启示在于,只要有可能,都应该使用不可变的对象作为对象内部的组件,这样就不必再为保护性拷贝操心。在前面的Period例子中,值得一提的是,有经验的程序员通常使用Date.getTime()返回的long基本类型作为内部的时间表示法,而不是使用Date对象引用。他们之所以这样做,主要因为Date是可变的。

第40条:谨慎设计方法签名

  1. 避免过长的参数列表。目标是四个参数,或者更少。大多数程序员都无法记住更长的参数列表。
  2. 相同类型的长参数序列格外有害。
  3. 但是通过提升它们的正交性(orthogonality),还可以减少(reduce)方法的数目。例如,考虑java.util.List接口。它并没有提供“在子列表(sublist)中查找元素的第一个索引和最后一个索引”的方法,这两个方法都需要三个参数。相反,它提供了subList方法,这个方法带有两个参数,并返回子列表的一个视图(view)。这个方法可以与indexOf或者lastIndexOf方法结合起来,获得期望的功能,而这两个方法都分别只有一个参数。而且,subList方法也可以与其他任何“针对List实例进行操作”的方法结合起来,在子列表上执行任意的计算。这样得到的API就有很高的“功能-重量”(power-to-weight)比。
  4. 缩短长参数列表的第二种方法是创建辅助类(helper class),用来保存参数的分组。这些辅助类一般为静态成员类。如果一个频繁出现的参数序列可以被看作是代表了某个独特的实体,则建议使用这种方法。例如,假设你正在编写一个表示纸牌游戏的类,你会发现,经常要传递一个两参数的序列来表示纸牌的点数和花色。如果增加辅助类来表示一张纸牌,并且把每个参数序列都换成这个辅助类的单个参数,那么这个纸牌游戏类的API以及它的内部表示都可能会得到改进。
  5. 对于参数类型,要优先使用接口而不是类。只要有适当的接口可用来定义参数,就优先使用这个接口,而不是使用实现该接口的类。例如,没有理由在编写方法时使用HashMap类来作为输入,相反,应当使用Map接口作为参数。这使你可以传入一个Hashtable、HashMap、TreeMap、TreeMap的子映射表(submap),或者任何有待于将来编写的Map实现。
  6. 对于boolean参数,要优先使用两个元素的枚举类型。它使代码更易于阅读和编写,尤其当你在使用支持自动完成功能的IDE的时候。它也使以后更易于添加更多的选项。例如,你可能会有一个Thermometer类型,它带有一个静态工厂方法,而这个静态工厂方法的签名需要传入这个枚举的值:
public enum TemperatureScale { FAHRENHEIT, CELSIUS }
  • 1

Thermometer.newInstance(TemperatureScale.CELSIUS)不仅比Thermometer.newInstance(true)更有用,而且你还可以在未来的发行版本中将KELVIN添加到TemperatureScale中,无需非得给Thermometer添加新的静态工厂。你还可以将依赖于温度刻度单位的代码重构到枚举常量的方法中。例如,每个刻度单位都可以有一个方法,它带有一个double值,并将它规格化成摄氏度。

第41条:慎用重载

// Broken! - What does this program print?
public class CollectionClassifier {
    public static String classify(Set<?> s) {
        return "Set";
    }
    public static String classify(List<?> lst) {
        return "List";
    }
    public static String classify(Collection<?> c) {
        return "Unknown Collection";
    }
    public static void main(String[] args) {
        Collection<?>[] collections = {
            new HashSet<String>(),
            new ArrayList<BigInteger>(),
            new HashMap<String, String>().values()
        };
        for(Collection<?> c : collections)
            System.out.println(classify(c));
    }
}
  • 1

因为classify方法被重载(overloaded)了,而要调用哪个重载(overloading)方法是在编译时做出决定的。对于for循环中的全部三次迭代,参数的编译时类型都是相同的:Collection<?>。

这个程序的行为有悖常理,因为对于重载方法(overloaded method)的选择是静态的,而对于被覆盖的方法(overridden method)的选择则是动态的。

在CollectionClassifier这个示例中,该程序的意图是:期望编译器根据参数的运行时类型自动将调用分发给适当的重载方法,以此来识别出参数的类型,就好像Wine的例子中的name方法所做的那样。方法重载机制完全没有提供这样的功能。假设需要有个静态方法,这个程序的最佳修正方案是,用单个方法来替换这三个重载的classify方法,并在这个方法中做一个显式的instanceof测试:

public static String classify(Collection<?> c) {
    return c instanceof Set ? "Set" :
            c instanceof List ? "List" : "Unknown Collection";
}
  • 1

因为覆盖机制是规范,而重载机制是例外,所以,覆盖机制满足了人们对于方法调用行为的期望。

安全而保守的策略是,永远不要导出两个具有相同参数数目的重载方法。如果方法使用可变参数(varargs),保守的策略是根本不要重载它,除第42条中所述的情形之外。

这项限制并不麻烦,因为你始终可以给方法起不同的名字,而不使用重载机制。

例如,考虑ObjectOutputStream类。对于每个基本类型,以及几种引用类型,它的write方法都有一种变形。这些变形方法并不是重载write方法,而是具有诸如writeBoolean(boolean)、writeInt(int)和writeLong(long)这样的签名。与重载方案相比较,这种命名模式带来的好处是,有可能提供相应名称的读方法,比如readBoolean()、readInt()和readLong()。实际上,ObjectInputStream类正是提供了这样的读方法。

对于构造器,你没有选择使用不同名称的机会;一个类的多个构造器总是重载的。在许多情况下,可以选择导出静态工厂,而不是构造器。对于构造器,还不用担心重载和覆盖的相互影响,因为构造器不可能被覆盖。或许你有可能导出多个具有相同参数数目的构造器,所以有必要了解一下如何安全地做到这一点。

如果对于每一对重载方法,至少有一个对应的参数在两个重载方法中具有“根本不同(radically different)”的类型,就属于这种情形。如果显然不可能把一种类型的实例转换为另一种类型,这两种类型就是根本不同的。

public class SetList {
    public static void main(String[] args) {
        Set<Integer> set = new TreeSet<Integer>();
        List<Integer> list = new ArrayList<Integer>();
        for (int i=-3; i<3; i++) {
            set.add(i);
            list.add(i);
        }
        for (int i=0; i<3; i++) {
            set.remove(i);
            list.remove(i);
        }
        System.out.println(set + " " + list);
    }
}
  • 1

实际发生的情况是:set.remove(i)调用选择重载方法remove(E),这里的E是集合(Integer)的元素类型,将i从int自动装箱到Integer中。这是你所期待的行为,因此程序会从集合中去除正值。另一方面,list.remove(i)调用选择重载方法remove(int i),它从列表的指定位置上去除元素。为了解决这个问题,要将list.remove的参数转换成Integer,迫使选择正确的重载方法。另一种方法是,可以调用Integer.valueOf(i),并将结果传给list.remove。这两种方法都如我们所料,打印出[-3, -2, -1] [-3, -2, -1]:

for (int i=0; i<3; i++) {
    set.remove(i);
    list.remove((Integer) i); // or remove(Integer.valueOf(i))
}
  • 1

当它在Java 1.5发行版本中被泛型化之前,List接口有一个remove(Object)而不是remove(E),相应的参数类型:Object和int,则根本不同。但是自从有了泛型和自动装箱之后,这两种参数类型就不再根本不同了。换句话说,Java语言中添加了泛型和自动装箱之后,破坏了List接口。幸运的是,Java类库中几乎再没有API受到同样的破坏,但是这种情形清楚地说明了,自动装箱和泛型成了Java语言的一部分之后,谨慎重载显得更加重要了。

数组类型和Object之外的类截然不同。数组类型和Serializable与Cloneable之外的接口也截然不同。如果两个类都不是对方的后代,这两个独特的类就是不相关的(unrelated)。例如,String和Throwable就是不相关的。任何对象都不可能是两个不相关的类的实例,因此不相关的类是根本不同的。

有时候,尤其在更新现有类的时候,可能会被迫违反本条目的指导原则。例如,自从Java 1.4发行版本以来,String类就已经有一个contentEquals(StringBuffer)方法。在Java 1.5发行版本中,新增了一个称作CharSequence的接口,用来为StringBuffer、StringBuilder、String、CharBuffer以及其他类似的类型提供公共接口,为实现这个接口,对它们全都进行了改造。在Java平台中增加CharSequence的同时,String也配备了重载的contentEquals方法,即contentEquals(CharSequence)方法,这个方法表示,当且仅当此String表示与CharSequence序列相同的char值时,返回true。

尽管这样的重载很显然违反了本条目的指导原则,但是只要当这两个重载方法在同样的参数上被调用时,它们执行相同的功能,重载就不会带来危害。程序员可能并不知道哪个重载函数会被调用,但只要这两个方法返回相同的结果就行。确保这种行为的标准做法是,让更具体化的重载方法把调用转发给更一般化的重载方法:

public boolean contentEquals(StringBuffer sb) {
    return contentEquals((CharSequence) sb);
}
  • 1

虽然Java平台类库很大程度上遵循了本条目中的建议,但是也有诸多的类违背了。例如,String类导出两个重载的静态工厂方法:valueOf(char[])和valueOf(Object),当着两个方法被传递了同样的对象引用时,它们所做的事情完全不同。没有正当的理由可以解释这一点,它应该被看作是一种反常行为,有可能会造成真正的混淆。

第42条:慎用可变参数

// The right way to use varargs to pass one or more arguments
static int min(int firstArg, int... remainingArgs) {
    int min = firstArg;
    for(int arg : remainingArgs)
        if(arg<min)
            min = arg;
    return min;
}
  • 1
  1. 可变参数是为printf而设计的,它是在Java 1.5发行版本中添加到平台中的,为了核心的反射机制,在该发行版本中被改造成利用可变参数。printf和反射机制都从可变参数中极大地受益。
  2. 从好的方面看,将数组转变成字符串的Arrays.asList做法现在是过时的,当前的做法要健壮得多。也是在Java 1.5发行版本中,Arrays类得到了补充完整的Arrays.toString方法(不是可变参数方法!),专门为了将任何类型的数组转变成字符串而设计的。
  3. 不必改造具有final数组参数的每个方法;只当确实是在数量不定的值上执行调用时才使用可变参数。
  4. 可变参数方法的每次调用都会导致进行一次数组分配和初始化。如果凭经验确定无法承受这一成本,但又需要可变参数的灵活性,还有一种模式可以让你如愿以偿。假设确定对某个方法95%的调用会有3个或者更少的参数,就声明该方法的5个重载,每个重载方法带有0至3个普通参数,当参数的数目超过3个时,就使用一个可变参数方法:
public void foo() { }
public void foo(int a1) { }
public void foo(int a1, int a2) { }
public void foo(int a1, int a2, int a3) { }
public void foo(int a1, int a2, int a3, int... rest) { }
  • 1

EnumSet类对它的静态工厂使用这种方法,最大限度地减少创建枚举集合的成本。当时这么做是有必要的,因为枚举集合为位域提供在性能方面有竞争力的替代方法,这是很重要的。

第43条:返回零长度的数组或者集合,而不是null

  1. 把没有奶酪可买的情况当作是一种特例,这是不合常理的。这样做会要求客户端中必须有额外的代码来处理null返回值。
  2. 对于一个返回null而不是零长度数组或者集合的方法,几乎每次用到该方法时都需要这种曲折的处理方式。这样做很容易出错,因为编写客户端程序的程序员可能会忘记写这种专门的代码来处理null返回值。
  3. 有时候会有人认为:null返回值比零长度数组更好,因为它避免了分配数组所需要的开销。这种观点是站不住脚的,原因有两点。第一,在这个级别上担心性能问题是不明智的,除非分析表明这个方法正是造成性能问题的真正源头。第二,对于不返回任何元素的调用,每次都返回同一个零长度数组是有可能的,因为零长度数组是不可变的,而不可变对象有可能被自由地分享。实际上,当你使用标准做法(standard idiom)把一些元素从一个集合转存到一个类型化的数组(typed array)中时,它正是这样做的:
// The right way to return an array from a collection
private final List<Cheese> cheesesInStock = ...;
private static final Cheese[] EMPTY_CHEESE_ARRAY = new Cheese[0];
public Cheese[] getCheeses() {
    return cheesesInStock.toArray(EMPTY_CHEESE_ARRAY);
}
  • 1

在这种习惯用法中,零长度数组常量被传递给toArray方法,以指明所期望的返回类型。正常情况下,toArray方法分配了返回的数组,但是,如果集合是空的,它将使用零长度的输入数组,Collection.toArray(T[])的规范保证:如果输入数组大到足够容纳这个集合,它就将返回这个输入数组。因此,这种做法永远也不会分配零长度的数组。 
List有<nobr aria-hidden="true" style="outline: 0px; transition: none; border: 0px; max-width: none; max-height: none; min-width: 0px; min-height: 0px; vertical-align: 0px; line-height: normal; word-break: break-all;">Object[] toArray()</nobr><math xmlns="http://www.w3.org/1998/Math/MathML"><mi>O</mi><mi>b</mi><mi>j</mi><mi>e</mi><mi>c</mi><mi>t</mi><mo stretchy="false">[</mo><mo stretchy="false">]</mo><mtext> </mtext><mi>t</mi><mi>o</mi><mi>A</mi><mi>r</mi><mi>r</mi><mi>a</mi><mi>y</mi><mo stretchy="false">(</mo><mo stretchy="false">)</mo></math>和<nobr aria-hidden="true" style="outline: 0px; transition: none; border: 0px; max-width: none; max-height: none; min-width: 0px; min-height: 0px; vertical-align: 0px; line-height: normal; word-break: break-all;"><T> T[] toArray(T[]a)</nobr><math xmlns="http://www.w3.org/1998/Math/MathML"><mo><</mo><mi>T</mi><mo>></mo><mtext> </mtext><mi>T</mi><mo stretchy="false">[</mo><mo stretchy="false">]</mo><mtext> </mtext><mi>t</mi><mi>o</mi><mi>A</mi><mi>r</mi><mi>r</mi><mi>a</mi><mi>y</mi><mo stretchy="false">(</mo><mi>T</mi><mo stretchy="false">[</mo><mo stretchy="false">]</mo><mi>a</mi><mo stretchy="false">)</mo></math>两个方法。它对a的说明是:the array into which the elements of this list are to be stored, if it is big enough; otherwise, a new array of the same runtime type is allocated for this purpose.

同样的,集合值的方法也可以做成在每当需要返回空集合时都返回同一个不可变的空集合。Collections.emptySet、emptyList和emptyMap方法提供的正是你所需要的,如下所示:

// The right way to return a copy of a collection
public List<Cheese> getCheeseList() {
    if(cheesesInStock.isEmpty())
        return Collections.emptyList(); // Always returns same list
    else
        return new ArrayList<Cheese>(cheesesInStock);
}
  • 1

第44条:为所有导出的API元素编写文档注释

  1. 除了前提条件和后置条件之外,每个方法还应该在文档中描述它的副作用(side effect)。所谓副作用是指系统状态中可以观察到的变化,它不是为了获得后置条件而明确要求的变化。例如,如果方法启动了后台线程,文档中就应该说明这一点。最后,文档注释也应该描述类或者方法的线程安全性(thread safety)。
  2. 在Java 1.5发行版本之前,是通过使用HTML标签和HTML转义,将代码片段包含在文档注释中。现在再也没有必要在文档注释中使用HTML<code>或者<tt>标签了:Javadoc{@code}标签更好,因为它避免了转义HTML元字符。
  3. 不要忘记,为了产生包含HTML元字符的文档,比如小于号(<)、大于号(>)以及“与”号(&),必须采取特殊的动作。让这些字符出现在文档中的最佳办法是用{@literal}标签将它们包围起来,这样就限制了HTML标记和嵌套的Javadoc标签的处理。除了它不以代码字体渲染文本之外,其余方面就像{@code}标签一样。

原文地址:http://www.jsdblog.com/article/63   

正文到此结束
Loading...