关于类和接口设计的11个好习惯
当我们致力于封装组件,构建高内聚低耦合的模块化系统时,理解和熟练运用类与接口的设计原则显得尤为重要
本文基于Effective Java中类与接口
章节汇总出11个设计好习惯(文末附案例地址)
思维导图如下:
让类和字段的可访问性最小化
一个组件设计的好不好,一个重要的特性就是封装的好不好
(把组件当成黑盒使用,不需要了解内部实现)
组件封装的好处:
- 解耦:各个组件低耦合,不必关心其他组件实现
- 独立:组件之间独立变化,并行开发,加快开发速度
- 可重用:组件提高可重用性
Java中提供访问修饰符和类所在的位置来实现封装
访问修饰符分为:private、default、protected、public
访问范围 | private | default | protected | public |
类内 | ✅ | ✅ | ✅ | ✅ |
包内 | ❎ | ✅ | ✅ | ✅ |
父子类内 | ❎ | ❎ | ✅ | ✅ |
任意位置 | ❎ | ❎ | ❎ | ✅ |
设计原则:
- 尽可能的让类和字段的可访问性最小化
- 除了常量,公有类的字段绝不能是公有的(破坏封装性)
反例:
public class Student{ //该对象的公有字段都能被直接访问 public int age; }
- 类具有公有、静态的final字段最好是不可变对象(如果是可变对象就要注意并发问题)
public static final String OBJECT = "object";
要使用方法访问类的字段
对于可变的类,需要设置私有字段,并提供公共的方法(get/set)访问字段
正例 Java Bean:
public class Bean{ private int age; private String name; //get、set方法 }
如果非要使用公有字段,使用不可变对象会降低危害
也可以在一些内部类中使用公有字段
可变性最小化
不可变对象指字段不能被修改的类,在构造初期就设置好值,在整个生命周期不改变
不可变对象设计、实现更简单,而且使用更安全,如:String
实现不可变对象遵循规则:
- 不提供修改对象字段的方法
- 保证类不会被扩展,需要final class
- 所有字段都是private final
- 如果字段是可变对象,确保不会通过方法被泄漏(调用时不能获取可变字段)
如果不可变对象方法要提供修改的操作,往往是经过计算后返回一个新的对象
比如String、BigDecimal提供的写操作
public String substring(int beginIndex) { if (beginIndex < 0) { throw new StringIndexOutOfBoundsException(beginIndex); } int subLen = value.length - beginIndex; if (subLen < 0) { throw new StringIndexOutOfBoundsException(subLen); } return (beginIndex == 0) ? this : new String(value, beginIndex, subLen); }
public BigDecimal negate() { if (intCompact == INFLATED) { return new BigDecimal(intVal.negate(), INFLATED, scale, precision); } else { return valueOf(-intCompact, scale, precision); } }
不可变对象的好处:
- 简单易实现
- 线程安全可以被共享
- 充当散列表的key
- 原子性(因为不会被修改从而不会出现不一致)
缺点:性能不好,不同的值都需要一个对象
可以使用享元、缓存常用对象弥补缺点,如包装类的装箱
public static Integer valueOf(int i) { if (i >= IntegerCache.low && i <= IntegerCache.high) return IntegerCache.cache[i + (-IntegerCache.low)]; return new Integer(i); }
复合优于继承
Java提供继承:让子类继承父类的特性,并允许子类重写(扩展)父类方法
但是继承在某些情况下会打破封装性:子类任意滥用父类的字段(protected)或子类依赖父类的实现细节
比如想在HashSet的基础上进行扩展,记录总共添加元素的数量(即使删除也要统计数量)
public class MyHashSet extends HashSet { private int addSize; @Override public boolean add(Object o) { addSize++; return super.add(o); } @Override public boolean addAll(Collection c) { addSize += c.size(); return super.addAll(c); } }
重写添加元素的方法并记录本次添加元素的数量
如果你熟悉HashSet的内部实现,那么你会知道这次扩展反而会出现问题
因为HashSet在实现addAll方法时,是去循环调用add的
public boolean addAll(Collection<? extends E> c) { boolean modified = false; for (E e : c) if (add(e)) modified = true; return modified; }
在案例中子类依赖于父类的实现细节,这种场景下应该去使用复合(组合)
即让依赖的对象成为字段
public class CompositeHashSet { private Set set; private int addSize; public CompositeHashSet(Set set) { this.set = set; } public boolean add(Object o) { addSize++; return set.add(o); } public boolean addAll(Collection c) { addSize += c.size(); return set.addAll(c); } }
(实际上是要实现Set接口的,直接依赖组合的Set即可,我这里就不全展示了)
在这种不满足A is a B的场景下,复合更能保留封装性
要么设计继承并提供文档说明,要么禁止继承
如果要使用继承,必须在父类实现中提供文档说明其实现原理,否则子类扩展可能导致出现错误
一般只在接口的“骨架实现”——抽象类中定义文档说明
其他时候使用成本高,应该考虑禁止子类化final class 或 私有构造
如果实现继承,还需要遵循一些约定,如:不能在构造器中调用可重写的方法
如果父类在构造器中调用重写方法,子类又在重写方法中使用的值,父类构造器触发时,子类可能还未初始化,导致严重的错误
父类:
public class Super { public Super() { method(); } protected void method() { System.out.println("super method"); } }
子类:
public class Sub extends Super { private String msg; public Sub(String msg) { this.msg = msg; } @Override protected void method() { System.out.println("sub msg -> " + msg); } }
输出
sub msg -> null sub msg -> ok
接口优于抽象类
接口、抽象类都属抽象层面,接口比抽象类更具抽象
接口可以为单独功能定义,实现单一职责
对象可以通过接口来增强功能而不必改变原来的结构
同时接口JDK8后提供默认方法,接口不只可以定义抽象,还可以定义实现
使用接口定义功能时,可以加一层抽象类来实现接口,在抽象类中可以使用模板方法实现通用的业务流程,并把核心处理方法留给子类实现
比如JDK中的AbstractList、Set、Map等
//定义模板方法 public boolean addAll(int index, Collection<? extends E> c) { //检查参数 rangeCheckForAdd(index); boolean modified = false; for (E e : c) { //真正的核心方法——添加留给子类实现 add(index++, e); modified = true; } return modified; }
为后代设计接口
接口定义的方法默认为公共抽象的,所有实现类都必须实现
如果为接口新增方法时,必须让实现类都实现该方法
为了弥补这种缺点,JDK8提供默认方法,实现类可以直接使用接口的默认方法,而不需要实现
默认方法的提出可以看成是一种对历史的兼容
JDK8用大量的默认方法来配合lambda表达式
如Collection下的removeIf
default boolean removeIf(Predicate<? super E> filter) { Objects.requireNonNull(filter); boolean removed = false; //迭代器遍历 final Iterator<E> each = iterator(); while (each.hasNext()) { //满足条件删除 if (filter.test(each.next())) { each.remove(); removed = true; } } return removed; }
可以使用lambda表达式,判断是否满足条件,满足条件则进行删除
List<Integer> numbers = new ArrayList<>(); numbers.add(1); numbers.add(2); numbers.add(3); numbers.add(4); numbers.add(5); // 使用lambda表达式定义断言:元素是否为偶数 numbers.removeIf(n -> n % 2 == 0); // 输出:[1, 3, 5] System.out.println(numbers);
接口只用于定义类型
当实现类实现接口后,被认为是该接口的类型
在早期会使用常量接口,接口只定义常量,而没有抽象方法,这会违反接口的定义
public interface ObjectStreamConstants { final static short STREAM_MAGIC = (short)0xaced; final static short STREAM_VERSION = 5; }
类层次优于标签类
标签类指的是类中包含多种“标签”,标签理解成功能类型(类中包含多种类型、字段)
//标签类 class Figure { //标识类型:矩形、圆形 enum Shape {RECTANGLE, CIRCLE} Shape shape; //宽高用于矩形计算 double width, height; //半径用于圆形计算 double radius; double getArea() { switch (shape) { case RECTANGLE: return width * height; case CIRCLE: return Math.PI * radius * radius; default: throw new IllegalArgumentException("Unknown shape"); } } }
标签类违反单一职责原则,对于标签类可以重构为类层次
类层次指的是使用继承形成有层次结构的类,上层为抽象层,下层为实现层
比如可以实现一个计算面积的抽象类,再由矩形、圆形实现类去实现,从而形成类层次
public abstract class AbstractFigure { public abstract double getArea(); }
矩形:
public class Rectangle extends AbstractFigure { private final double width; private final double height; public Rectangle(double width, double height) { this.width = width; this.height = height; } @Override public double getArea() { return width * height; } }
圆形:
public class Circle extends AbstractFigure { private final double radius; public Circle(double radius) { this.radius = radius; } @Override public double getArea() { return radius * radius * Math.PI; } }
(案例中使用double计算可能会存在精度溢出,不要在意哈~)
标签类过于冗长、效率低、违反单一职责
使用类层次降低耦合、增强扩展性、遵守单一职责
静态内部类优于非静态内部类
内部类的存在只为外部类服务,内部类分为四种:静态(成员)内部类、非静态(成员)内部类、匿名内部类、局部内部类
如果作用域在类中考虑选择使用成员内部类(静态、非静态)
如果内部类中需要使用外部类的字段,则选择非静态成员内部类
它会隐式使用变量存储外部类实例,比如HashMap中的set或迭代器,可能使用外部类的字段
//非静态成员内部类 final class KeySet extends AbstractSet<K> { public final int size(){ return size; } } //使用方法创建内部类实例 public Set<K> keySet() { Set<K> ks = keySet; if (ks == null) { ks = new KeySet(); keySet = ks; } return ks; }
创建非静态内部类时,需要依赖外部类实例
ks = new KeySet(); //创建内部类实例等同于 ks = this.new KeySet();
如果外部类实例不再使用,而一直使用内部类实例,则会导致内存泄漏(内部是实例还存在外部类实例引用,导致外部类实例无法被回收)
如果内部类不需要使用外部类的字段,则选择静态成员内部类
比如:HashMap中的数据结构节点Node、TreeNode
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; //... }
创建静态内部类不需要依赖外部类实例
如果作用域在方法中考虑使用局部/匿名内部类
如果只使用一次,如lambda表达式则使用匿名内部类,方法中使用多次则使用局部内部类
限制源文件为单个顶级类
顶级类指的是public class
通常源文件会以顶级类的类名进行命名,源文件中不能存在多个顶级类
如果非要在源文件中定义类可以使用静态内部类
总结
设计组件时尽量让访问权限最小化,封装组件能够隐藏内部实现,让外部直接使用(降低耦合、组件独立、提升可重用)
设计时遵守:字段私有、常量最好是不可变对象、使用方法访问字段
不可变对象简单易实现、线程安全、满足原子性、可充当哈希表key,但是性能不好,每个值都需要一个对象,可以使用享元,缓存常用对象
设计不可变对象遵守:字段私有、final;类不被继承 final class;不提供修改字段的方法;不泄漏字段对象的引用
继承某些情况下会打破封装,必须理解父类实现使用才不会出错,如果只是依赖对象优先使用复合而不是继承
如果非要使用继承,需要满足A是一个B,并且提供说明文档实现细节,这样成本太大,一般只在抽象类中使用,并且在构造中不能调用可重写的方法
接口定义抽象、抽象类实现模板方法(充当中间层),核心方法留给子类实现
或者接口定义单一抽象功能,留给实现类扩展增强功能
接口还提供默认方法,能够定义默认实现,允许实现类自定义扩展,主要是为了兼容历是版本和提供lambda表达式
使用接口定义抽象,而不是用于只定义常量
使用多层继承代替冗余、复杂、违反单一的标签类
遵循单一职责,如果组件需要子组件可以使用内部类
作用域在类中时考虑成员内部类,在方法中考虑局部内部类、匿名内部类
非静态成员内部类依赖外部类实例,如果要使用外部类字段才使用,否则使用静态成员内部类
在方法中只使用一次则考虑匿名内部类,多次则考虑局部内部类
最后(不要白嫖,一键三连求求拉~)
本篇文章被收入专栏 Effective Java,感兴趣的同学可以持续关注喔
本篇文章笔记以及案例被收入 Gitee-CaiCaiJava、 Github-CaiCaiJava 感兴趣的同学可以stat下持续关注喔~
有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~
关注菜菜,分享更多干货,公众号:菜菜的后端私房菜