美文网首页
第十三条:谨慎地覆盖clone

第十三条:谨慎地覆盖clone

作者: Js_Gavin | 来源:发表于2021-02-03 08:48 被阅读0次

    Cloneable接口地目的是作为对象的一个mixin接口(详见第20条),表示这样的对象允许克隆(clone)。遗憾的是,他并没有成功地达到这个目的。它的主要缺陷在于缺少一个clone方法,而Object的clone方法是受保护的。如果不借助反射方法。即使是反射调用也可能失败,因为不能保证对象一定具有可访问的clone方法。尽管存在缺陷,这项设施仍然被广泛使用,因此值得我们进一步了解。本条目将告诉你如何实现一个行为良好的clone方法,并讨论何时适合这样做,同时也简单的讨论了其他的可替代做法。

    既然Cloneable接口并没有包含任何方法,那么它到底有什么作用呢?他决定了Object中受保护的clone方法实现的行为:如果一个类实现了Cloneable,Object的clone方法就会返回该对象的逐域拷贝,否则就会抛出CloneNotSupportedExcepton异常。这是接口的一种极端极端非典型的用法,也不值得效仿。通常情况下,实现接口是为了表示类可以为它的客户做些什么。然而,对于Cloneable接口,他改变了超类中受保护的方法的行为。

    虽然规范中没有明确指出,事实上,实现Cloneable接口的类是为了提供一个功能适当的共有的clone方法。为了达到这个目的,类及其所有超类都必须遵守一个相当复杂的、不可实施的,并且基本上没有文档说明的协议。由此得到一种语言之外的机制:它无须调用构造器就可以创建对象。

    clone方法的通用约定是非常脆弱的,下面是来自Object规范中的约定内容:
    创建和返回该对象的一个拷贝。这个拷贝的精确含义却决于该对象的类。一般的含义是,对于任何对象x,表达式

    x.clone() != x 
    

    将会返回结果true,并且表达式

    x.clone().getClass()  = x.getClass();
    

    将会返回结果true,但这些都不是绝对的要求,虽然通常情况下,表达式

    x.clone().equals(x);
    

    按照约定,返回的对象应该不依赖与被克隆的对象。为了成功的实现这种独立性,可能需要在super.clone返回对象之前,修改对象的一个或更多个域。

    这种机制大体上类似于自动的构造调用链,只不过它不是强制要求的:如果类的clone方法返回的实例不是通过调用super.clone方法获得,而是通过调用构造器获得,编译器就不会发出警告,但是该类的子类调用了super.clone方法,得到的对象就会拥有错误的类,并阻止了clone方法的子类正常工作。,如果final类覆盖了clone方法,那么这个约定就可以被安全的忽略,因为没有子类需要担心它。如果final类的clone方法没有调用super.clone方法,这个类就没有理由去实现Cloneable接口了,因为它不依赖于Object克隆实现的行为

    假设你希望在一个类中实现Cloneable接口,并且它的超类都提供了行为良好的clone方法。首先,调用super.clone方法。由此得到的对象将是原始对象功能完整的克隆。在这个类中声明的域将等同于被克隆对象中相应的域。如果每一个域包含一个基本类型的值,或者包含一个指向不可变对象的引用,那么被返回的对象则可能正是你所需要的对象,在这种情况下不需要再做进一步的处理。例如,第十一条的PhoneNumber类正式如此。但是要注意,不可变的类永远都不应该提供clone方法,因为它只会激发不必要的克隆。因此,PhoneNumber的clone方法应该是这样的:

    // Clone method for class with no references to mutable state
    @Override
    public PhoneNumber clone() {
        try {
            return (PhoneNumber) super.clone();
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();  // Can't happen
        }
    }
    

    为了让这个方法生效,应该修改PhoneNumber的类声明为实现Cloneable接口。虽然Object的clone方法返回的是Object,但是这个clone方法返回的却是PhoneNumber。这么做是合法的,也是我们所期望的,因为Java支持协变返回类型,换句话说,目前覆盖方法的返回类型可以是,被覆盖方法的返回类型的子类了。这样在客户端中就不必进行转换了。我们必须在返回结果之前,先将super.clone从Object转换成PhoneNumber,当然这种转换是一定会成功的。

    对super.clone方法的调用应当包含在一个try-catch块中。这是因为Object声明其clone方法抛出CloneNotSupportedException,这是一个受检异常,由于PhoneNumber实现了Cloneable接口,我们知道调用super.clone方法一定会成功。对于这个样板代码的需求表明,CloneNotSupportedException应该还没有被检查到。

    如果对象中包含包含引用了可变的对象,使用上述这种简单的clone实现可能会导致灾难性的后果。例如第7条中的Stack类:

    public class Stack {
    
        private Object[] elements;
        private int size = 0;
        private static final int DEFAULT_INITIAL_CAPACITY = 16;
    
        public Stack() {
            this.elements = new Object[DEFAULT_INITIAL_CAPACITY];
        }
    
        public void push(Object e) {
            ensureCapacity();
            elements[size++] = e;
        }
    
        public Object pop() {
            if (size == 0)
                throw new EmptyStackException();
            Object result = elements[--size];
    
            elements[size] = null; // Eliminate obsolete reference
            return result;
        }
    
        // Ensure space for at least one more element.
        private void ensureCapacity() {
            if (elements.length == size)
                elements = Arrays.copyOf(elements, 2 * size + 1);
        }
    }
    

    假设你希望把这个类做成可克隆的,如果它的clone方法仅仅返回super.clone(),这样得到的Stack实例,在其size域中具有正确的值,但是它的elements域将引用与原始Stack实例相同的数组。修改原始的实例会破坏被克隆对象中的约束条件,反之亦此。很快你就会发现,这个程序将产生毫无意义的结果,或者抛出NullPointerException异常。

    如果调用Stack类中唯一的构造器,这种情况就永远不会发生。实际上,clone方法就是另一个构造器,必须确保它不会伤害到原始的对象,并确保正确地创建被克隆对象中地约束条件。为了时Stack类中地clone方法正常工作,它必须要拷贝栈地内部信息,最容易的做法是,在elements数组中递归的调用clone;

    // Clone method for class with references to mutable state
    @Override 
    public Stack clone() {
        try {
            Stack result = (Stack) super.clone();
            result.elements = elements.clone();
            return result;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
    

    注意我们不一定要将elements.clone()的结果转换成Object[],在数组上调用clone返回的数组,其编译器的类型与被克隆数组的类型相同。这是复制数组的最佳习惯做法,事实上,数组是clone方法唯一吸引人的用法。

    还要注意如果elements域是final的,上述方案就不能正常工作,因为clone方法是被禁止给final域赋新值的。这是个根本问题:就像序列化一样,Cloneable架构与引用可变对象的final域的正常用法是不相兼容的除非在原始对象和克隆对象之间可以安全地共享此可变对象。为了使类成为可克隆地,可能有必要从某些域中去掉final修饰符

    递归地调用clone有时还不够。例如:假设你正在为一个散列表编写clone方法,它的内部数据结构包含一个散列桶数组,每个散列桶都指向”键 - 值“对链表地第一项,出于性能方面地考虑,该类实现了它自己地轻量级单项链表,而没有使用Java内部地java.util.LinkedList:

    public class HashTable implements Cloneable {
        private Entry[] buckets = ...;
        private static class Entry {
            final Object key;
            Object value;
            Entry  next;
    
            Entry(Object key, Object value, Entry next) {
                this.key   = key;
                this.value = value;
                this.next  = next;  
            }
        }
        ... // Remainder omitted
    }
    

    假设你仅仅递归地克隆这个散列桶数组,就像我们对Stack类所做的那样:

    // Broken clone method - results in shared mutable state!
    @Override 
    public HashTable clone() {
        try {
            HashTable result = (HashTable) super.clone();
            result.buckets = buckets.clone();
            return result;
        } catch (CloneNotSupportedException e) {
            throw new AssertionError();
        }
    }
    

    虽然被克隆对象有它自己的散列数组,但是,这个数组引用的链表于原始对象是一样的,从而很容易引起克隆对象和原始对象中不确定的行为。为了修正这个问题,必须单独的拷贝并组成每个桶的链表。下面是一种常见的做法:

    // Recursive clone method for class with complex mutable state
    public class HashTable implements Cloneable {
        private Entry[] buckets = ...;
    
        private static class Entry {
            final Object key;
            Object value;
            Entry  next;
    
            Entry(Object key, Object value, Entry next) {
                this.key   = key;
                this.value = value;
                this.next  = next;  
            }
    
            // Recursively copy the linked list headed by this Entry
            Entry deepCopy() {
                return new Entry(key, value,
                    next == null ? null : next.deepCopy());
            }
        }
    
        @Override
         public HashTable clone() {
            try {
                HashTable result = (HashTable) super.clone();
                result.buckets = new Entry[buckets.length];
                for (int i = 0; i < buckets.length; i++)
                    if (buckets[i] != null)
                        result.buckets[i] = buckets[i].deepCopy();
                return result;
            } catch (CloneNotSupportedException e) {
                throw new AssertionError();
            }
        }
        ... // Remainder omitted
    }
    

    私有类HashTable.Entry被加强了,他支持一个”深度拷贝”(deep copy)方法。HashTable上的clone方法分配了一个大小适中的、新的buckents数组,并且变量原始的buckents数组,对每一个非空散列桶进行深度拷贝,Entry类中的深度拷贝方法递归的调用它自身,以便拷贝整个链表(它是链表的头结点)。虽然这种方法很灵活,但是如果散列桶不是很长,也会工作得很好,但是,这样克隆一个链表并不是一种号办法,因为针对列表中得每一个元素,它都要消耗一段栈空间,如果链表比较长,这很容易导致栈溢出。为了避免发生这种情况,可以在deepCopy方法中用迭代替换递归:

    Entry deepCopy(){
      Entry result = new Entry(key, value, next);
      for(Entry p = result; p.next != null; p = p.next){
        p.next = new Entry(p.next.key, p.next.value, p.next.next)
      }
      return result;
    }
    

    克隆复杂对象的最后一种办法是,先调用super.clone方法,然后把结果对象中的所有域都设置成它们的初始化状态,然后调用高层的方法来重新产生对象的状态。在我们的HashTable例子中,buckets域将被初始化为一个新的散列桶数组,然后,对于正在被克隆的散列表中的每一个键 - 值映射,都将调用put(key, value)方法(上面没有给出其代码)。这种做法往往会产生一个简单、合理且相当优美的clone方法,但是它运行起来通常没有“直接操作对象及其克隆对象的内部状态的clone方法”块。但它与整个Cloneable架构是对立的,因为它完全抛弃了Cloneable架构基础的逐域对象复制的机制。

    像构造器一样,clone方法也不应该在构造器的过程中,调用可以覆盖的方法(详见第19条)。如果clone调用了一个在子类中被覆盖的方法,那么在该方法所在的子类有机会修正它在克隆对象中的状态之前,该方法就会先被执行,这样很有可能会导致克隆对象和原始对象之间不一致,因此,上一段中讨论到的put(key, value)方法要么应该是final的,要么应该是私有的。(如果是私有的,他应该算是非final共有方法的“辅助方法”)。

    Object的clone方法被声明为可抛出CloneNotSupportedException异常,但是,覆盖版本的clone方法可以忽略这个声明。共有的clone方法应该省略throw声明,因为不会抛出受检异常的方法使用起来更加轻松(详见第71条)。

    为继承(详见第19条)设计类有两种选择,但是无论选择其中的哪一种方法,这个类都不应该实现Cloneable接口。你可以选择模拟Object的行为:实现一个功能适当的受保护的clone方法,他应该被声明抛出CloneNotSupportedException异常。这样可以使子类具有实现或者不实现Cloneable接口的自由,就仿佛它们直接扩展了Object一样,或者,也可以选择不去实现一个有效的clone方法,并防止子类去实现它,只需要提供下列退化了的clone实现即可

    // clone method for extendable class not supporting Cloneable
    @Override
    protected final Object clone() throws CloneNotSupportedException {
        throw new CloneNotSupportedException();
    }
    

    还有一点值得注意。如果你编写线程安全的类准备实现Cloneable接口,要记住它的clone方法必须得到严格的同步,就像任何其他方法一样(详见第78条)。Object的clone方法没有同步,即使很满意可能也必须编写同步的clone方法来调用super.clone(),即实现synchronized clone() 方法。

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

    真的有必要这么复杂吗?很少有这种必要。如果你扩展一个实现了Cloneable接口的类,那么你除了实现一个行为良好的clone方法外,没有别的选择。否则,最好提供某些其他途径来代替对象拷贝。对象拷贝的更好的办法是提供一个拷贝构造器(copy constructor)或者拷贝工厂(copy factory)。拷贝构造器只是一个构造器,他唯一的参数类型是包含该构造器的类,例如:

    public Yum(Yum yum){......}
    

    拷贝工厂是类似域拷贝构造器的静态工厂(详见第1条):

    public Yum(Yum yum){......}
    

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

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

    既然所有的问题都是与Cloneable接口有关,新的接口就不应该扩展这个接口,新的可扩展的类也不应该实现这个接口。虽然final类实现Cloneable接口没有太大危害,这个应该被视同性能优化,留到少数必要的情况下才使用(详见第67条),总之,复制功能最好由构造器或者工厂提供。这条规则最绝对的例外是数组,最好利用clone方法复制数组

    相关文章

      网友评论

          本文标题:第十三条:谨慎地覆盖clone

          本文链接:https://www.haomeiwen.com/subject/ziwltltx.html