Effective Java——方法

                    目录
三十八、检查参数的有效性
三十九、必要时进行保护性拷贝
四十、谨慎设计方法签名
四十一、慎用重载
四十二、慎用可变参数
四十三、返回零长度的数组或者集合,而不是null
四十四、为所有导出的API元素编写文档注释


三十八、检查参数的有效性

        绝大多数方法和构造器对于传递给它们的参数值都会有些限制。比如,索引值必须大于等于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。它们之间的另一个差异在于如果断言没有起到作用,即-ea命令行参数没有传递给java解释器,断言将不会有任何开销,这样我们就可以在调试期间加入该命令行参数,在发布时去掉该命令行选项,而我们的代码则不需要任何改动。

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

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

        简而言之,每当编写方法或者构造器的时候,应该考虑它的参数有哪些限制。应该把这些限制写到文档中,并且在这个方法体的开头,通过显式的检查来实施这些限制。


三十九、必要时进行保护性拷贝

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

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类本身是可变了,因此很容易违反这个约束,见如下代码:

Date start = new Date();
Date end = new Date();
Period p = new Period(start,end);
end.setYear(78);

        为了避免这样的攻击,我们需要对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可能会被其他线程修改。

        虽然替换构造器就可以成功地避免上述的攻击,但是改变Period实例仍然是有可能的,因为它的访问方法提供了对其可变的内部成员的访问能力:

Date start = new Date();
Date end = new Date();
Period p = new Period(start,end);
p.end.setYear(78);
        为了防御第二种攻击,只需要修改两个访问方法,使它返回可变内部域的保护性拷贝即可:
public Date start() {
    return new Date(start.getTime());
}
public Date end() {
    return new Date(end.getTime());
}

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

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

        简而言之,如果类具有从客户端得到或者返回到客户端的可变组件,类就必须保护性地拷贝这些组件。如果拷贝的成本受到限制,并且类信任它的客户端不会不恰当地修改组件,就可以在文档中指明客户端的职责是不得修改受到影响的组件,以此来代替保护性拷贝。


四十、谨慎设计方法签名

        谨慎地选择方法的名称。方法的名称应该始终遵循标准的命名习惯。首要目标是选择易于理解的,并且与同一个包中的其他名称风格一致的名称。第二个目标应该是选择与大众认可的名称相一致的名称。

        不要过于追求提供便利的方法。每个方法都应该尽其所能。方法太多会使类难以学习、使用、文档化、测试和维护。对于接口而言,这无疑是正确的,方法太多会使接口实现者和接口用户的工作变得复杂起来。对于类和接口所支持的每个动作,都提供一个功能齐全的方法。只有当一项操作被经常用到的时候,才考虑为它提供快捷方式。

        避免过长的参数列表。目标是四个参数,或者更少。大多数程序员都无法记住更长的参数列表,相同类型的长参数序列格外有害。API的用户不仅无法记住参数的顺序,而且,当他们不小心弄错了参数顺序时,他们的程序仍然可以编译和运行,但是不会按照作者的意图进行工作。

        有三种方法可以缩短过长的参数列表。第一种是把方法分解成多个方法,每个方法只需要这些参数的一个子集。并通过提升它们的正交性还可以减少方法的数目。第二种方法是创建辅助类,用来保存参数的分组。这些辅助类一般为静态成员类。如果一个频繁出现的参数序列可以被看作是代表了某个独特的实体,则建议使用这种方法。第三种方法是,从对象构建到方法调用都采用Builder模式。如果方法带有多个参数,尤其是当它们中有些是可选的时候,最好定义一个对象来表示所有的参数,并允许客户端在这个对象上进行多次“setter”调用,每次调用都设置一个参数,或者设置一个较小的相关的集合。一旦设置了需要的参数,客户端就调用对象的“执行”方法,它对参数进行最终的有效性检查,并执行实际的计算。

        对于参数类型,要优先使用接口而不是类。只要有适当的接口可用来定义参数,就优先使用这个接口,而不是使用实现该接口的类。例如,没有理由在编写方法时使用HashMap类来作为输入,相反,应当使用Map接口作为参数。这使你可以传入一个Hashtable、HashMap、TreeMap等,或者任何有待于将来编写的Map实现。

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

public enum TemperatureScale { FAHRENHEIT, CELSIUS }

        此时,使用Thermometer.newInstance(TemperatureScale.CELSIUS)比Thermometer.newInstance(true)更有用。


四十一、慎用重载

        见下面一个函数重载的例子:

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"。为什么会是这样呢?因为函数重载后,需要调用哪个函数是在编译期决定的,这不同于多态的运行时动态绑定。针对此种情形,最佳修正方案是,用单个方法来替换这三个重载的方法,并在方法中做一个显示的instanceof测试:

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

        该条目给出以下几种尽量不要使用重载的情形:
        1.函数的参数中包含可变参数;
        2.当函数参数数目相同时,你无法准确的确定哪一个方法该被调用时;
        3.对自动装箱机制保持警惕。

        对于第三种情形,考虑下面这个程序:

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集合中大于等于的元素均被移除出容器,然而在执行后却发现事实并非如此,其结果为:[-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)重载函数,而不是其他的,这样我们就需要对原有代码进行如下的修改:

for (int i = 0; i < 3; ++i) {
    s.remove(i);
    l.remove((Integer)i); //or remove(Integer.valueOf(i));
}

        该条目还介绍了一种实现函数重载,同时又尽可能避免上述错误发生的方式。即其中的一个重载函数,在其内部通过一定的转换逻辑转换之后,再通过转换后的参数类型调用其他的重载函数,从而确保即便使用者在使用过程中出现重载误用的情况,也因两者可以得到相同的结果而规避了潜在错误的发生。

        简而言之,“能够重载方法”并不意味着就“应该重载方法”。一般情况下,对于多个具有相同参数数目的方法来说,应该尽量避免重载方法。至少应该避免这样的情形:同一组参数只需经过类型转换就可以被传递给不同的重载方法。


四十二、慎用可变参数

        可变参数方法接受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;
}

        有的时候在重视性能的情况下,使用可变参数机制要特别小心。可变参数方法的每次调用都会导致进行一次数组分配和初始化。如果确定确实无法承受这一成本,但又需要可变参数的灵活性,还有一种模式可以弥补这一不足。假设确定对某个方法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个的调用需要创建数组。就像大多数的性能优化一样,这种方法通常不恰当,但是一旦真正需要它时,还是非常有用处的。

        简而言之,在定义参数数目不定的方法时,可变参数方法时一种很方便的方式,但是它们不应该被过度滥用。如果使用不当,会产生混乱的结果。


四十三、返回零长度的数组或者集合,而不是null

        见如下代码:

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

        从以上代码可以看出,当没有Cheese的时候,getCheeses()函数返回一种特例情况null。这样做的结果会使所有的调用代码在使用前均需对返回值数组做null的判断,如下:

public void testGetCheeses(CheesesShop shop) {
    Cheese[] cheeses = shop.getCheeses();
    if (cheese != null && Array.asList(cheeses).contains(Cheese.STILTON))
        System.out.println("Jolly good, just the thing.");
}

        对于一个返回null而不是零长度数组或者集合的方法,几乎每次用到该方法时都需要这种曲折的处理方式。很显然,这样是比较容易出错的。如果我们使getCheeses()函数在没有Cheese的时候不再返回null,而是返回一个零长度的数组,那么我的调用代码将会变得更加简洁,如下:

public void testGetCheeses2(CheesesShop shop) {
    if (Array.asList(shop.getCheeses()).contains(Cheese.STILTON))
        System.out.println("Jolly good, just the thing.");
}

        简而言之,返回类型为数组或集合的方法没理由返回null,而不是返回一个零长度的数组或者集合。


四十四、为所有导出的API元素编写文档注释

        Java语言环境提供了一种被称为Javadoc的实用工具,它利用特殊格式的文档注释,根据源代码自动产生API文档。

        为了正确地编写API文档,必须在每个被导出的类、接口、构造器、方法和域声明之前增加一个文档注释。方法的文档注释应该简洁地描述出它和客户端之间的约定,约定应该说明这个方法做了什么。文档注释应该列举出这个方法的所有前提条件和后置条件。一般情况下,前提条件是由@throws标签针对未受检的异常所隐含描述的;每个未受检的异常都对应一个前提违例。同样的,也可以在一些受影响的参数@param标记中指定前提条件。

        为了完整地描述方法的约定,方法的文档注释应该让每个参数都有一个@param标签,以及一个@return标签(除了返回类型为void),以及对于该方法抛出的每个异常,都有一个@throws标签。如下:

/**
 * Returns the element at the specified position in this list.
 *
 * <p>This method is <i>not</i> guaranteed to run in constant
 * time.In some implementations it may run in time proportional
 * to the element position.
 *
 * @param  index of element to return:must be
 *         non-negative and less than the size of this list
 * @return the element at the specified position in this list
 * @throws IndexOutOfBoundsException if the index is out of range
 *         ({@code index < 0 || index >= this.size()})
 */
E get(int index);

        注意,这份文档注释使用了HTML标签。Javadoc工具会把文档注释翻译成HTML,文档注释中包含的任意HTML元素都会出现在结果HTML文档中。

        另外,@throws子句的代码片段中使用了Javadoc的{@code}标签。它有两个作用:造成该代码片段以代码字体进行呈现,并限制HTML标记和嵌套的Javadoc标签在代码片段中进行处理。后一种属性正是允许我们在代码片段里使用小于号(<)的东西,虽然它是一个HTML元字符。为了将多个代码示例包含在一个文档注释中,要使用包在HTML的<pre>标签里面的Javadoc{@code}标签,即在多行的代码示例前使用字符<pre>{@code }</pre>。

        为了产生包含HTML元字符的文档,比如大于小于号,最佳的办法是用{@literal}标签将它们包围起来。除了它不以代码字体渲染文本之外,其余方面就像{@code}标签一样。

        简而言之,要为API编写文档,文档注释是最好、最有效的途径。对于所有可导出的API元素来说,使用文档注释应该被看作是强制性的。要采用一致的风格来遵循标准的约定。记住,在文档注释内部出现任何HTML标签都是允许的,但是HTML元字符必须要经过转义。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值