面试系列-如何设计一个类

1. 类和成员最小化可访问性

  • 问题

    要区分设计良好的模块和设计不好的模块,最重要的因素在于,这个模块对于外部其他模块而言,是否隐藏其内部数据和其他细节。设计良好的模块会隐藏所有的实现细节,把它的API与它的实现清晰的隔离起来,模块之间只通过它们的API进行通信,那么,在设计类和成员时有怎样的设计原则?

  • 解决

    设计类和成员有这样几个基本原则:

    1. 尽可能使每个类或者成员不被外界访问,应该使用与你正在编写的软件的对应功能相一致的、尽可能最小的访问级别;对于成员(域、方法、嵌套类或者嵌套接口)有四种访问级别:1. private--在该类中私有访问;2. 默认级别--包级访问;3. protected访问级别--该类的子类或者包类所有类均可访问到;4.public--在任何地方均可访问到;
    2. 如果类中覆盖了父类中的方法,那么子类中的访问级别不得低于父类中的访问级别,这样就可以保证在任何使用到父类实例的地方可以继续使用子类。如果一个类实现了某接口,那么在类中所有的接口的方法都必须是public的;
    3. 实例域决不能是公有的,如何非final实例域指向了可变对象,并且该实例域为public的话,那么包含该实例域的类就是线程不安全的;
  • 总结

    总之,在设计类和成员时,应该尽可能的降低可访问性,除了公有静态final域的特殊情形之外,公有类都不应该包含公有域,并且要确保公有的静态final域所引用的对象是不可变的。

2.使用访问方法

  • 问题

    有这样一个反例:

    class Point {
        public double x;
        public double y;
    }
    复制代码

    如上这样的类绝不应该声名为public,因为一旦声名为了public,该类中所有的数据就全部暴露出来,并且无法改变它的数据表示法,也无法强加任何约束条件,当被访问的时候,无法采取任何辅助措施,这么多问题,归结原因就是因为如果类声明不当,那么可能会将整个数据域全部暴露给客户端。虽然,对于可变类来说,应该用包含私有域和仅有设置方法的类代替:

    class Point {
        private double x;
        private double y;
    
        public Point(double x, double y) {
            this.x = x;
            this.y = y;
        }
    
        public double getX() { return x; }
        public double getY() { return y; }
    
        public void setX(double x) { this.x = x; }
        public void setY(double y) { this.y = y; }
    }
    复制代码

    那么,对类中的数据域的访问级别应该如何设计?

  • 解决

    1. 如果类的数据域可在它所在的包外部进行访问,就提供访问方法,这样可以保留该类内部表示的灵活性。如果公有类暴露了它的数据域,要想将来想改变公有类的内部数据接口,那是不太可能的事情了,因为使用公有类的数据域已经遍布整个系统中了;
    2. 对于公有类有一个约定,公有类永远都不应该暴露可变的域
  • 结论

    公有类永远都不应该暴露可变的域,有时候会需要用包级私有的或者私有的嵌套类来暴露域,无论这个类的域是可变的还是不可变的。

3.最小化可变性

  • 问题

    不可变类是其实例不能被修改的类,没有实例中所包含的数据域,在实例被创建的时候被初始化,且在实例的生命周期中不能被修改。JAVA中有许多不可变类,如String,值的基本包装类型,BigInteger和BigDecimal等,不可变类是线程安全的。不可变有很多优点,那么设计不可变类的原则有哪些?

  • 解决

    1. 设计不可变类有以下几条规则:

      • 不要提供任何修改实例数据域的setter方法
      • 保证类不会被扩展:防止子类恶意修改实例对象,应该禁止类被子类扩展,可以将其定义为final;或者让类所有的构造器都变成私有的或者包级私有的,并添加公有的静态工厂来代替公有的构造器;
      • 所有的域都是final的;
      • 所有的域都成为私有的,这样可以防止客户端获得访问被域引用的可变对象的权限,并防止客户端修改这些对象;
      • 确保对于任何可变组件的互斥性访问:如果类具有指向可变对象的域,则必须确保客户端无法获得指向这些对象的引用;
    2. 示例

      例如,String不可变类的具体实现为:

      public final class String
          implements java.io.Serializable, Comparable<String>, CharSequence
      {
          /** The value is used for character storage. */
          private final char value[];
          /** The offset is the first index of the storage that is used. */
          private final int offset;
          /** The count is the number of characters in the String. */
          private final int count;
          /** Cache the hash code for the string */
          private int hash; // Default to 0
          ....
          public String(char value[]) {
              this.value = Arrays.copyOf(value, value.length); 
           }
          ...
           public char[] toCharArray() {
           // Cannot use Arrays.copyOf because of class initialization order issues
              char result[] = new char[value.length];
              System.arraycopy(value, 0, result, 0, value.length);
              return result;
          }
          ...
      }
      复制代码

      如上代码所示,可以观察到以下设计细节:

      1. String类被final修饰,不可继承
      2. string内部所有成员都设置为私有变量
      3. 不存在value的setter
      4. 并将value和offset设置为final。
      5. 当传入可变数组value[]时,进行深拷贝而不是直接将value[]复制给内部变量.
      6. 获取value时不是直接返回对象引用,而是返回对象的copy.
  • 结论

    不可变类有很多好处,因此合适的适用场景下,可以考虑将类设计生不可变类,并遵守不可变类的设计原则。

4.复合优于继承

  • 问题

    当通过子类继承父类并不是代码重用的最好手段,有这样的缺点:1. 与方法调用不同的是,继承打破封装性。子类依赖于父类,如果父类的具体实现细节改变,子类也会跟着相应改变。除非父类就是专门为扩展而设计的,并且有良好的文档说明;2. 父类方法中的”自用性“问题,导致的子类方法逻辑出错,比如统计HashSet自创建以来插入了多少个元素,需要覆盖add()方法和addAll()方法:

    public class TestHashSet<E> extends HashSet<E> {
        private int count = 0;
    
        public TestHashSet(int initCap, float loadFactor) {
            super(initCap, loadFactor);
        }
    
        @Override
        public boolean add(E e) {
            count++;
            return super.add(e);
        }
    
        @Override
        public boolean addAll(Collection<? extends E> c) {
            count += c.size();
            return super.addAll(c);
        }
    
        public int getCount() {
            return count;
        }
    
        public static void main(String[] args) {
            TestHashSet<String> hashSet = new TestHashSet<String>(16, 0.75f);
            hashSet.addAll(Arrays.asList(new String[]{"1","2","3"}));
            System.out.println(hashSet.getCount());
        }
    }
    复制代码

    按照预想的会打印输出3,但实际上打印输出6。这是因为,addAll()方法内部实现调用了add()方法,因此总共的次数就是3+3=6。这种情况就是父类方法中”自用性“导致的。那么,针对由继承带来的问题应该如何解决?

  • 解决

    针对继承带来的问题,可以采用复合的方式进行解决,即不用扩展现有的类,而是在新的类中增加一个私有域,它引用现有类的一个实例。因此现有类变成了一个新类的一个组件,新类中的每个实例方法就可以调用被包含的类的实例方法,并返回相应的结果,这称之为转发

    采用复合/转发的方式重写上面的TestHash,包含了两个部分:新类本身以及被包含的转发类:

    // Wrapper class - uses composition in place of inheritance
    public class InstrumentedSet<E> extends ForwardingSet<E> {
        private int addCount = 0;
        public InstrumentedSet(Set<E> s) {
            super(s);
        }
        @Override
        public boolean add(E e) {
            addCount++;
            return super.add(e);
        }
        @Override
        public boolean addAll(Collection<? extends E> c) {
            addCount += c.size();
            return super.addAll(c);
        }    
        public int getAddCount() {
            return addCount;
        }
    }
    // Reusable forwarding class
    public class ForwardingSet<E> implements Set<E> {
        private final Set<E> s;
        public ForwardingSet(Set<E> s) { this.s = s; }
        public void clear() { s.clear(); }
        public boolean contains(Object o) { return s.contains(o); }
        public boolean isEmpty() { return s.isEmpty(); }
        public int size() { return s.size(); }
        public Iterator<E> iterator() { return s.iterator(); }
        public boolean add(E e) { return s.add(e); }
        public boolean remove(Object o) { return s.remove(o); }
        public boolean containsAll(Collection<?> c) { return s.containsAll(c); }
        public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }
        public boolean removeAll(Collection<?> c) { return s.removeAll(c); }
        public boolean retainAll(Collection<?> c) { return s.retainAll(c); }
        public Object[] toArray() { return s.toArray(); }
        public <T> T[] toArray(T[] a) { return s.toArray(a); }
        @Override
        public boolean equals(Object o) { return s.equals(o); }
        @Override
        public int hashCode() { return s.hashCode(); }
        @Override
        public String toString() { return s.toString(); }
    }
    复制代码

    在上面这个例子里构造了两个类,一个是用来扩展操作的包裹类,一个是用来与现有类进行交互的转发类,可以看到,在现在这个实现中包裹类不再直接扩展Set,而是扩展了他的转发类,而在转发类内部,现有Set类是作为它的一个数据域存在的,转发类实现了Set接口,这样它就包括了现有类的基本操作。每个转发动作都直接调用现有类的相应方法并返回相应结果。这样就将信赖于Set的实现细节排除在包裹类之外。有的时候,复合和转发的结合被错误的称为"委托(delegation)"。从技术的角度来说,这不是委托,除非包装对象把自身传递给被包装的对象。

    • 什么时候使用继承?

      只有当子类真正是超类的子类型(subtype)时,才适合用继承。对于两个类A和B,只有当两者之间确实存在"is-a"的关系的时候,类B才应该扩展A。如果打算让类B扩展类A,就应该确定一个问题:B确实也是A吗?如果不能确定答案是肯定的,那么B就不应该扩展A。如果答案是否定的,通常情况下B应该包含A的一个私有实例,并且暴露一个较小的、较简单的API:A本质上不是B的一部分,只是它的实现细节而已(使用API的客户端无需知道)。

  • 总结

    简而言之,继承的功能非常强大,但是也存在诸多问题,因为它违反了封装原则只有当子类和超类之间确实存在子类型的关系时,使用继承才是恰当的。即使如此,如果子类和超类处在不同的包中,并且超类并不是为了继承而设计的,那么继承将会导致脆弱性。为了避免这种情况,可以使用复合和转发机制来代替继承,尤其是当存在适当的接口可以实现包装类的时候。包装类不仅比子类更加健壮,而且功能也更强大。

5.正确使用继承

  • 问题

    之前阐述了贸然的将不是为了继承而设计的类进行继承,而实现子类化,是多么的危险,那么,在实际开发中,针对继承而设计的类怎样的处理才算是安全可靠的?

  • 解决

    1. 对于专门为了继承而设计并且具有良好文档说明的类而言,该类的文档必须精确地描述覆盖每个方法所带来的影响。该类必须有文档说明它可覆盖的方法的自用性。对于每个公有的或受保护的方法或者构造器,它的文档必须指明该方法或者构造器调用了哪些可覆盖的方法,是以什么顺序调用的,每个调用的结果又是如何影响后续的处理过程的。更一般的,类必须在文档中说明,在哪些情况下它会调用可覆盖的方法。
    2. 关于程序文档的格言:好的API文档应该描述一个给定的方法做了什么工作,而不是描述它是如何做到的。由此看来,上面的这段文档违背了这一格言,这正是继承破坏了封装性所带来的不幸后果,因为在上面这段文档中它必须要说明清楚调用可覆盖方法所带来的影响。所以,为了设计一个类的文档,以便它能够被安全的子类化,必须描述清楚那些有可能未定义的实现细节
    3. 还要注意,因继承而需要的特殊文档会打乱正常的文档信息,普通的文档被设计用来让程序员可以创建该类的实例,并调用类的方法。而特殊文档看起来混用了很多内容。为了允许继承,类还必须遵守其他的一些约束。构造器决不能调用可被覆盖的方法,无论是直接调用还是间接调用。这是因为超类的构造器在子类的构造器之前运行,所以,子类中覆盖版本的方法将会在子类的构造器运行之前就先被调用。如果该覆盖版本的方法信赖于子类构造器所执行的任何初始化操作,该 方法将不会如预期执行;
    4. 对于那些并非为了安全地进行子类化而设计和编写文档的类,要禁止子类化。有两种办法可以禁止子类化:第一,把这个类声名为final。第二、把所有的构造器都声名为private,或者包级私有并增加一些静态工厂来替代构造器。
  • 结论

    1. 如果具体的类没有实现标准的接口,那么禁止继承可能会给有些程序员带来不便,如果认为必须允许从这样的类继承,一种合理的办法就是确保这个类永远不会调用它的任何可覆盖的方法,并在文档中说明这一点。否则,就禁止被子类化。
    2. 针对自用性问题,也可以机械的消除类中可覆盖方法的自用特征,而不改变它们的行为。将每个可覆盖方法的代码体移动到一个私有的辅助方法中。并且让每个可覆盖的方法调用它的私有辅助方法。然后,用”直接调用可覆盖方法的私有辅助方法“来代替”可覆盖方法的每个自用调用“。

6.用函数对象表示策略

  • 问题

    有些语言支持函数指针、代理、lambda表达式,或者支持类似的机制,允许程序把”调用特殊函数的能力”储存起来并传递这种能力。最常用的例子就是比较函数,通过传入不同的比较策略会得到不同的比较结果,这也正是策略模式的一个例子。可是Java没有提供函数指针。

  • 解决方案

    Java没有提供函数指针,但是可以用对象引用实现同样的功能。调用对象上的方法通常是执行该对象上的某个操作。然而,我们也可能定义这样一种对象,它的方法执行其他对象上的操作。如果一个类仅仅导出这样的一个方法,它的实例上就等同于一个指向该方法的指针。这样的实例被称为函数对象。考虑这样一个类:

    1. class StringLengthComparator {
          public int compare(String s1, String s2) {
              return s1.length() - s2.length();
          }
      }
      复制代码

    在这里,指向StringLengthComparator对象的引用可以被当作是一个指向该对象内部比较器compare的“函数指针”,可以在任意一对字符串上被调用,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();
        }
    }
    复制代码

    但是,用这述这种方法有个问题,就是规定了参数的类型,这样就无法传递任何其他的比较策略。相反,对于这种情况,应该定义一个Comparator接口,并修改StringLengthComparator来实现这个接口。换句话说,在设计具体的策略类时,还需要定义一个策略接口:

    // Strategy interface
    public interface Comparator<T> {
        public int compare(T t1, T t2);
    }
    复制代码

    此时,前面的具体策略类声名如下:

    class StringLengthComparator implements Comparator<String> {
        ......
    }
    复制代码

    这样,在传递具体策略类的对象的时候,只需要将参数类型定为接口类型(使用接口做类型定义),现在可以传递其他的比较策略了, 具体策略类往往使用匿名类声明:

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

    这里存在一个问题,就是在每次执行调用的时候都会创建一个新的实例。如果它被重复执行,那就应该考虑将函数对象存储到一个私有的静态final域里并重用它。这样做的另一个好处就是为这个函数对象取一个有意义的声明。

    因为策略接口被用做所有具体策略实例的类型,所以我们并不需要为了导出具体策略而把具体策略类做成公有的。可以导出公有的静态域或者静态工厂方法,其类型是策略接口,具体的策略类可以是宿主类的私有嵌套类:

    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();
    }
    复制代码
  • 结论

    1. 函数指针的主要作用就是实现策略模式,为了在Java中实现这种模式,要声名一个接口来表示策略,并且为每个具体策略声名一个实现了该接口的类;
    2. 当一个具体策略只被使用一次时,通常使用匿名类来声名和实例化这个具体策略;
    3. 当一个具体策略是设计用来重复使用的时候,它的类通常就要被实现为私有的静态成员类,并通过公有的静态final域或静态工厂方法导出,其类型为策略接口

7.优先考虑静态成员类

  • 问题

    嵌套类(nested class)是指被定义在另一个类的内部的类。嵌套类存在的目的应该是为它的外围类(enclosing class)提供服务。如果嵌套类将来可能会用于其他的某个环境中,它就应该是顶层类(top-level class)。嵌套类有四种:静态成员类(static member class)、非静态成员类(nonstatic member class)、匿名类(anonymous class)和局部类(local class)。除了第一种之外,其他三种都被称为内部类(inner class)。那么,在什么情况下使用应该使用哪种嵌套类才是合适的?

  • 解决方案

    1. 静态成员类

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

      静态成员类的一种常见的用法就是作为公有的辅助类,仅当它的外部类一起使用时才有意义。例如,一个描述了计算器支持的各种操作的枚举。Operation枚举应该是Calculator类的公有静态成员类,然后使用Calculator类的客户端就可以用诸如Calculator.Operation.PLUS这样的名称来引用这些操作。

      私有静态成员类的一种常见用法用来代表外围类所代表的对象的组件。例如,考虑一个Map实例,他把键和值关联起来。许多Map实现的内部都有一个Entry对象,对应于map中的每个键值对。虽然每个entry都与一个map关联,但是entry上的方法并不需要访问该map。因此,使用非静态成员来表示entry是很浪费的:private修饰的静态成员类是最佳的选择。如果不小心漏掉了entry声明中的static修饰符,该map依然可以工作,但是每个entry中将会包含一个指向该map的引用,这样就浪费了空间和时间。

    2. 非静态成员类

      从语法上讲,静态成员类和非静态成员类之间唯一的区别是,静态成员类的声明中包含了修饰符static。尽管他们的语法非常的相似,但是这两种嵌套类有很大的不同。非静态成员类的每个实例都隐含着与外围类的一个外围实例(enclosing instance)相关联。在非静态成员类的实例方法内部,可以调用外围实例上的方法,或者利用修饰过的this获得外围实例的引用。如果嵌套类的实例可以在外围类的实例之外独立存在,这个嵌套类就必须是静态成员类,在没有外围实例的情况下,要想创建非静态成员类的实例是不可能的。

      当非静态成员类的实例被创建的时候,它和外围实例之间的关联关系也随之建立起来;而且,这种关联关系以后也不能被修改。通常情况下,当在外围类的某个实例方法的内部调用了非静态成员类的构造器时,这种管理就自动建立起来。使用表达式enclosingInstance.new MemberClass(args)来手工建立这种关系也是有可能的,但是很少使用。

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

      public class MySet<E> extends AbstractSet<E>
      {
          public Iterator<E> iterator(){
              return new MyIterator();
          }
          private class MyIterator implements Iterator<E>{
          }
      }
      复制代码

      静态成员类 VS 非静态成员类

      1. 如果声明成员类不要求访问外围实例,就要始终把static修饰符放在它的生命中,使它成为静态成员类,而不是非静态成员类。如果省略了static修饰符,则每个实例都将包含一个额外的指向外围对象的引用。保存这份引用要消耗时间和空间,并且会导致外围实例在符合垃圾回收时仍然得以保留,造成内存泄漏
      2. 如果没有外围实例的情况下,也需要分配内部类实例,就不能使用非静态成员类,因为非静态成员类的实例必须要有一个外围实例。
    3. 匿名类

      匿名类没有类名,它不是外围类的一个成员,并不与其他的成员一起被声明,而是在使用的同时被声明和实例化。匿名类可以出现在代码中任何允许存在表达式的地方。当且仅当匿名类出现在非静态的环境中时,它才有外围实例。但是即使它们出现在静态的环境中,也不可能拥有任何静态成员。

      匿名类的适用性受到诸多的限制。除了在它们被声明的时候之外,是无法将它们实例化的,你不能执行instanceof测试。你无法声明一个匿名类来实现多个接口,或者扩展一个类,并同时扩展类和实现接口。由于匿名类出现在表达式当中,它们必须保持简短——大约10行或者更少些——否则会影响程序的可读性。

      匿名类多用于表示具体策略的函数对象,比如Arrays.sort()方法中定义的比较器Comparator,还可用于创建Thread时的Runnable等。

    4. 局部类

      局部类是四种嵌套类中用的最少的类。在任何“可以声明局部变量”的地方,都可以声明局部类,并且局部类也遵守同样的作用域规则。局部类与其他三种嵌套类中的每一种都有一些共同的属性。与成员类一样,局部类有名字,可以被重复使用。与匿名类一样,只有当局部类实在非静态环境中定义的时候,才有外围实例,它们也不能包含静态成员。与匿名类一样,它们必须简短以便不会影响到可读性

  • 总结

    简而言之,共有四种不同的嵌套类,每一种都有自己的用途。

    1. 如果一个嵌套类需要在单个方法之外仍然可见的,或者它太长了,不适合于放在方法内部,就应该使用成员类;
    2. 如果成员类的每个示例都需要一个指向外围实例的引用,就要把成员类做成非静态的;否则,就做成静态的;
    3. 假设调用一个方法(如Array.sort()),该方法的入参需要创建一个实例,并且已经有一个确定的类型,创建该类的实例时就可以采用创建匿名类的方式来创建该类的实例。如果没有一个现有的类型,就设计成局部类。
posted @ 2020-09-16 23:58  工程师搁浅  阅读(439)  评论(0编辑  收藏  举报