《Effective Java》笔记38-44:方法

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

绝大多数方法和构造器对于传递给它们的参数值都会有些限制。比如,索引值必须大于等于 0,且不能超过其最大值,对象不能为 null 等。这样就可以在导致错误的源头将错误捕获,从而避免了该错误被延续到今后的某一时刻再被引发,这样就是加大了错误追查的难度。就如同编译期能够报出的错误总比在运行时才发现要更好一些。事实上,我们不仅仅需要在函数的内部开始出进行这些通用的参数有效性检查,还需要在函数的文档中给予明确的说明,如在参数非法的情况下,会抛出那些异常,或导致函数返回哪些错误值等,见如下代码示例:

/**
 * Returns a BigInteger whose value is(this mod m). This method
 * differs from the remainder method in that it always returns a
 * non-negative BigInteger.
 * @param m the modulus, which must be positive.
 * @return this mod m.
 * @throws ArithmeticException if m is less than or equal to 0.
*/
public BigInteger mod(BigInteger m) {
    if (m.signum() <= 0)
        throw new ArithmeticException("Modulus <= 0: " + m);
    ... //Do the computation.
}

对于未被导出的方法,如包方法等,你可以控制这个方法将在哪些情况下被调用,因此,非公有方法可以使用断言来帮助进行参数的有效性检查,如:

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
}

和通用的检查方式不同,断言在其条件为真时,无论外部包得客户端如何使用它。断言都将抛出 AssertionError。

需要强调的是,对于有些函数的参数,其在当前函数内并不使用,而是留给该类其他函数内部使用的,比较明显的就是类的构造函数,构造函数中的很多参数都不一样用于构造器内,只是在构造的时候进行有些赋值操作,而这些参数的真正使用者是该类的其他函数,对于这种情况,我们就更需要在构造的时候进行参数的有效性检查,否则一旦将该问题释放到域函数的时候,再追查该问题的根源,将不得不付出更大的代价和更多的调试时间。

对该条目的说法确实存在着一种例外情况,在有些情况下有效性检查工作的开销是非常大的,或者根本不切实际,因为这些检查已经隐含在计算过程中完成了,如 Collections.sort(List),容器中对象的所有比较操作均在该函数执行时完成,一旦比较操作失败将会抛出 ClassCastException 异常。因此对于 sort 来讲,如果我们提前做出有效性检查将是毫无意义的。

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

如果你的对象没有做很好的隔离,那么对于调用者而言,则有机会破坏该对象的内部约束条件,因此我们需要保护性的设计程序。该破坏行为一般由两种情况引起,首先就是恶意的破坏,再有就是调用者无意识的误用,例如如下代码:

// 表示一段不可变的时间周期
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;
    }
}

从表面上看,该类的实现确实对约束性的条件进行了验证,然而由于 Date 类本身是可变了,因此很容易违反这个约束,见如下代码:

public void testPeriod() {
    Date start = new Date();
    Date end = new Date();
    Period p = new Period(start,end);
    end.setYear(78);  // 该修改将直接影响 Period 内部的 end 对象。
}

为了避免这样的攻击,我们需要对 Period 的构造函数进行相应的修改,即对每个可变参数进行保护性拷贝。

public Period(Date start,Date end) {
    this.start = new Date(start.getTime());
    this.end = new Date(end.getTime());
    if (start.compareTo(end) > 0)
        throw new IllegalArgumentException(start + "After " + end);
}

需要说明的是,保护性拷贝是在检查参数有效性之前进行的,并且有效性检查是针对拷贝之后的对象,而不是针对始对象的。这主要是为了避免在 this.start = new Date(start.getTime())if (start.compareTo(end) > 0) 这个时间窗口内,参数 start 和 end 可能会被其他线程修改。

现在构造函数已经安全了,后面我们需要用同样的方式继续修改另外两个对象访问函数。

public Date start() {
    return new Date(start.getTime());
}
public Date end() {
    return new Date(end.getTime());
}

经过这一番修改之后,Period 成为了不可变类,其内部的“周期的起始时间不能落后于结束时间”约束条件也不会被破坏。

对于参数类型可以被不可信任方子类化的参数,请不要使用 clone 方法进行保护性拷贝。

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

记住长度非零的数组总是可变的。因此,在把内部数组返回给客户端之前,应该总要进行保护性拷贝。另一种解决方案是,给客户端返回该数组的不可变视图。两种方法见使类和成员的可访问性最小化

尽可能的应该使用不可变对象作为对象内部的组件,这样就可以不操心保护性拷贝了。通常使用 Date.getTime() 返回的 long 基本类型作为内部的时间表示法,而不是使用 Date 对象引用,主要因为 Date 是可变的。

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

谨慎地选择方法的名称

方法的名称应该始终遵循标准的命名习惯。

不要过于追求提供便利的方法

每个方法都应该尽其所能。方法太多会使得工作复杂起来。只有当一项操作被经常使用时,才考虑为它提供快捷方式。如果不能确定,还是不提供快捷为好。

避免过长的参数列表

尽量四个以下的参数。

相同类型的长参数序列格外有害。如果弄错了顺序,仍然可以编译运行。

缩短参数列表的方法:①可以把方法分解为多个方法,②创建辅助类,用来保存参数的分组,这样就是辅助类单个参数,③从对象构建到方法调用都采用 Builder 模式

对于参数类型优先使用接口而不是类

例如,没有理由在编写方法时使用 HashMap 类来作为参数,应该使用 Map 接口作为参数。

对于 boolean 参数,要优先使用两个元素的枚举类型

更易于阅读和编写,使以后更易于添加更多的选项。

public enum TemperatureScale { FAHRENHEIT, CELSIUS }

Thermometer.newInstance(TemperatureScale.CELSIUS) 不仅比 Thermometer.newInstance(true) 更有用,可以在以后将 KELVIN 添加到 TemperatureScale 中。

第41条:慎用重载

public class CollectionClassfier {
    public static String classify(Set<?> s) {
        return "Set";
    }
    public static String classify(List<?> l) {
        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));
    }
}

这里你可能会期望程序打印出,"Set","List","Unknown Collection"

然而实际上却不是这样,输出的结果是 3 个 "Unknown Collection"。为什么会是这样呢?因为函数重载后,需要调用哪个函数是在编译期决定的,这不同于多态的运行时动态绑定。针对此种情形,该条目给出了一个修正的方法,如下:

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

和 override 不同,重载机制不会像 override 那样规范,并且每次都能得到期望的结果。因此在使用时需要非常谨慎,否则一旦出了问题,就会需要更多的时间去调试。该条目给出以下几种尽量不要使用重载的情形:

  1. 函数的参数中包含可变参数;
  2. 当函数参数数目相同时,你无法准确的确定哪一个方法该被调用时;
  3. 在 Java 1.5 之后,需要对自动装箱机制保持警惕。

我们先简单说一下第二种情形。比如两个重载函数均有一个参数,其中一个是 int,另一个是 Collection<?>,对于这种情况,intCollection<?> 之间没有任何关联,也无法在两者之间做任何的类型转换,否则将会抛出 ClassCastException 的异常,因此对于这种函数重载,我们是可以准确确定的。反之,如果两个参数分别是 intshort,他们之间的差异就不是这么明显。

例如,ObjectOutputStream 类,对于每个基本类型引用类型,它的 write 方法都有一种变形,不是重载 write 方法,writeBoolean(boolean),writeInt(int) 等方法。同样读方法也提供了类似的方法,readBoolean(),readInt()

对于第三种情形,该条目给出了一个非常典型的用例代码,如下:

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

在执行该段代码前,我们期望的结果是Set和List集合中大于等于 0 的元素均被移除出容器,然而在执行后却发现事实并非如此,其结果为:[-3, -2, -1] [-2, 0, 2]

这个结果和我们的期望还是有很大差异的,为什么 Set 中的元素是正确的,而 List 则不是,是什么导致了这一结果的发生呢?下面给出具体的解释:

  1. s.remove(i) 调用的是 Set 中的 remove(E),这里的 E 表示 Integer,Java 的编译器会将i自动装箱到 Integer 中,因此我们得到了想要的结果。
  2. l.remove(i) 实际调用的是 List 中的 remove(int index) 重载方法,而该方法的行为是删除集合中指定索引的元素。这里分别对应第 0 个,第 1 个和第 2 个。

为了解决这个问题,我们需要让 List 明确的知道,我们需要调用的是 remove(E) 重载函数,而不是其他的,这样我们就需要对原有代码进行如下的修改:

l.remove((Integer)i);

Java1.4 时,String 类就有了一个 contentEquals(StringBuffer) 方法,在 Java1.5 中新增了一个 CharSequence 接口,用来为 StringBuffer、StringBuilder、String、CharBuffer等提供公共接口,为了实现这个接口,对原类进行了改造。String 新增了 contentEquals(CharSequence) 的重载方法,这样违反了本条目的原则,但它们执行的是相同的功能返回相同的结果,就没有影响。确保这种行为的方法是:

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

总之,通常,最好避免重载具有相同数量参数的多个签名的方法。在某些情况下,特别是涉及构造方法的情况下,可能无法遵循此建议。在这些情况下,至少应该避免通过添加类型转换将相同的参数集传递给不同的重载。如果这是无法避免的,例如,因为要对现有类进行改造以实现新接口,那么应该确保在传递相同的参数时,所有重载的行为都是相同的。

第42条:慎用可变参数

可变参数方法接受 0 个或者多个指定类型的参数。可变参数机制通过先创建一个数组,数组的大小为在调用位置所传递的参数数量,然后将参数值传到数组中,最后将数组传递给方法,如:

static int sum(int...args) {
    int sum = 0;
    for (int arg : args)
        sum += arg;
    retrun sum;
}

上面的方法可以正常的工作,但是在有的时候,我们可能需要至少一个或者多个某种类型参数的方法,如:

static int min(int...args) {
    if (args.length == 0)
        throw new IllegalArgumentException("Too few arguments.");
    int min = args[0];
    for (int i = 0; i < args.length; ++i) {
        if (args[i] < min)
            min = args[i];
    }
    return min;
}

对于上面的代码主要存在两个问题,一是如果调用者没有传递参数是,该函数将会在运行时抛出异常,而不是在编译期报错。另一个问题是这样的写法也是非常不美观的,函数内部必须做参数的数量验证,不仅如此,这也影响了效率。将编译期可以完成的事情推到了运行期。下面提供了一种较好的修改方式,如下:

static int min(int firstArg, int...remainingArgs) {
    int min = firstArgs;
    for (int arg : remainingArgs) {
        if (arg < min)
            min = arg;
    }
    return min;
}

由此可见,当你真正需要让一个方法带有不定数量的参数时,可变参数就非常有效。

下面看看 1.4 与 1.5 中的 Arrays.asList,1.4 中的是这样 public static List asList(Object[] a),到了 1.5 中改成了可变参数成这样 public static <T> List<T> asList(T... a) ,现在我们使用这个方法时要格外小心,看看下面几个问题:

public static void main(String[] args) {

    String strs[] = new String[] { "str1", "str2" };
    int ints[] = new int[] { 1, 2 };

    /*
    * 1.4输出:[str1, str2]
    * 1.5输出:[str1, str2]
    */
    System.out.println(Arrays.asList(strs));

    /*
    * 1.4编译不能通过!!
    * 1.5输出:[[I@757aef]
    */
    System.out.println(Arrays.asList(ints));
}

由于 1.5 版本中,令人遗憾地决定将 Arrays.asList 改造成可变参数方法,现在上面这个程序在 1.5 中可以通过编译,但是运行时,输出的不是我们想要的结果而是 [[I@757aef],这主要是由于基本类型不能用于泛型的原因所致,所以在将一个基本类型数组传给 asList(T... a) 方法时,将整个基本类型数组看作是可能参数集中的第一个参数了。

但从好的方面来看,本来 asList 方法就不是用来打印数组中的元素字面值的,它的作用是将数组转换成 List 而已,这在 1.5 中得到了修补,并增加了 Arrays.toString 的方法,它正是专门为了将任何类型的数组转变成字符串而设计的。如果用 Arrays.toString 来代替 Arrays.asList,我们将会得到想要的结果:

System.out.println(Arrays.toString(ints));
//[1, 2]

有的时候在重视性能的情况下,使用可变参数机制要特别小心。可变参数方法的每次调用都会导致进行一次数组分配和初始化。如果确定确实无法承受这一成本,但又需要可变参数的灵活性,还有一种模式可以弥补这一不足。假设确定对某个方法 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) {}

所有调用中只有 5% 参数数量超过 3 个的调用需要创建数组。就像大多数的性能优化一样,这种方法通常不恰当,但是一旦真正需要它时,还是非常有用处的。EnumSet 类的静态工厂方法就是使用了这种方法。

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

对于一个返回null而不是零长度数组或者集合方法,几乎每次用到该方法时都需要额外处理是否为null,这样做很容易出错,因为缩写客户端程序的程序员可能会忘记写这种专门的代码来处理null返回值,如:

private final List<Cheese> cheesesInStock = …;
public Cheese[] getCheeses(){
    if(cheesesInStock.size() == 0)
        return null;
    //  …
}

客户端使用如下:

Cheese[] cheeses = shop.getCheeses();
if(cheeses != null && …);

有时候有人会认为:null 返回值比零长度数组更好,因为它避免了分配数组所需要的开销,这种观点是站不住脚的,原因有二:一是,除非这个方法正是造成性能问题的真正源头。二是,完全可以使用一个零长度的数组共享。

使用标准做法把一些元素从一个集合转存到一个类型化的数组中时,就是使用了一个零长度的数组共享

private final List<Cheese> cheesesInStock = …;
//将零长度的数组设为静态的,以便共享用
private static final Cheese[] EMPTY_CHEESE_ARRAY = new Cheese[0];

public Cheese[] getCheeses(){
       //借助于List的toArray方法,将列表转换成数组,如果传进的数组长度为零,则会返这个零长度数组本身,并且这个零长度数组是共享的
       return cheesesInStock.toArray(EMPTY_CHEESE_ARRAY);
}

下面看一下返回空集合的做法:

Collection 能转换成安全的数组,Collections 能在需要返回空集合时都返回同一个不可变的空集合,如 emptySet、emptyList、emptyMap:

public List<Cheese> getCheeseList(){
    if(cheesesInStock.isEmpty())
        return Collections.emptyList();  // 总是返回相同的空的 list
    else
        return new ArrayList<Cheese>(cheesesInStock);
}

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

为了正确地编写API文档,必须在每个导出类、接口、构造器、方法和域声明之前增加一个文档注释。如果类是可序列化的,也应该对它的序列化形式编写文档。为了编写出可维护的代码,还应该为那些没有被导出的类、接口、构造器、方法和域编写文档注释。

方法的文档注释应该简洁地描述出它和客户端之间的约定。除了专门为继承而设计的类中的方法之外,这个约定应该说明这个方法做了什么,而不是说明它是如何完成这功项工作的。文档注释应该列举这个方法的前置条件与后置条件,前提条件指调用该方法要得到预期的结果必须满足的条件,如参数的约束。而后置就是指调用方法完后要完成预期的功能。

跟在 @param 标签或者 @return 标签后面的文字应该是一个名词短语,描述了这个参数或者返回值所表示的值,跟在 @throws 标签之后的文字应该包含单词 if ,紧接着是一个名词短语,它描述了这个异常将在什么样的条件下会被抛出。有时候,也会用表达式来代替名词短语。并且按照惯例,这个标签后面的短语或者子名都不用句点来结束。

类是否线程安全的,也应该在文档中对它的线程安全级别进行说明。

在文档注释内部出现任何 HTML 标签都是允许的,但是 HTML 元字符必须要经过转义。

代码片段使用 {@code} 标签,{@literal} 标签内的字符可以省去 HTML 转义。

当为泛型或者方法编写文档时,确保要在文档中说明所有的类型参数。当为枚举类型编写文档时,要确保在文档中说明常量,以及类型,还有所有的公有方法。为注解类型编写文档时,要确保在文档中说明所有成员,以及类型本身。


转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 bin07280@qq.com

文章标题:《Effective Java》笔记38-44:方法

文章字数:5.3k

本文作者:Bin

发布时间:2019-06-16, 18:57:28

最后更新:2019-08-06, 00:43:25

原始链接:http://coolview.github.io/2019/06/16/Effective-Java/%E3%80%8AEffective%20Java%E3%80%8B%E7%AC%94%E8%AE%B038-44%EF%BC%9A%E6%96%B9%E6%B3%95/

版权声明: "署名-非商用-相同方式共享 4.0" 转载请保留原文链接及作者。

目录