关于类和接口设计的11个好习惯
当我们致力于封装组件,构建高内聚低耦合的模块化系统时,理解和熟练运用类与接口的设计原则显得尤为重要
本文基于Effective Java中类与接口
章节汇总出11个设计好习惯(文末附案例地址)
思维导图如下:
让类和字段的可访问性最小化
一个组件设计的好不好,一个重要的特性就是封装的好不好
(把组件当成黑盒使用,不需要了解内部实现)
组件封装的好处:
- 解耦:各个组件低耦合,不必关心其他组件实现
- 独立:组件之间独立变化,并行开发,加快开发速度
- 可重用:组件提高可重用性
Java中提供访问修饰符和类所在的位置来实现封装
访问修饰符分为:private、default、protected、public
访问范围 | private | default | protected | public |
---|---|---|---|---|
类内 | ✅ | ✅ | ✅ | ✅ |
包内 | ❎ | ✅ | ✅ | ✅ |
父子类内 | ❎ | ❎ | ✅ | ✅ |
任意位置 | ❎ | ❎ | ❎ | ✅ |
设计原则:
-
尽可能的让类和字段的可访问性最小化
-
除了常量,公有类的字段绝不能是公有的(破坏封装性)
反例:
javapublic class Student{ //该对象的公有字段都能被直接访问 public int age; }
-
类具有公有、静态的final字段最好是不可变对象(如果是可变对象就要注意并发问题)
javapublic static final String OBJECT = "object";
要使用方法访问类的字段
对于可变的类,需要设置私有字段,并提供公共的方法(get/set)访问字段
正例 Java Bean:
java
public class Bean{
private int age;
private String name;
//get、set方法
}
如果非要使用公有字段,使用不可变对象会降低危害
也可以在一些内部类中使用公有字段
可变性最小化
不可变对象指字段不能被修改的类,在构造初期就设置好值,在整个生命周期不改变
不可变对象设计、实现更简单,而且使用更安全,如:String
实现不可变对象遵循规则:
- 不提供修改对象字段的方法
- 保证类不会被扩展,需要final class
- 所有字段都是private final
- 如果字段是可变对象,确保不会通过方法被泄漏(调用时不能获取可变字段)
如果不可变对象方法要提供修改的操作,往往是经过计算后返回一个新的对象
比如String、BigDecimal提供的写操作
java
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);
}
java
public BigDecimal negate() {
if (intCompact == INFLATED) {
return new BigDecimal(intVal.negate(), INFLATED, scale, precision);
} else {
return valueOf(-intCompact, scale, precision);
}
}
不可变对象的好处:
- 简单易实现
- 线程安全可以被共享
- 充当散列表的key
- 原子性(因为不会被修改从而不会出现不一致)
缺点:性能不好,不同的值都需要一个对象
可以使用享元、缓存常用对象弥补缺点,如包装类的装箱
java
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的基础上进行扩展,记录总共添加元素的数量(即使删除也要统计数量)
java
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的
java
public boolean addAll(Collection<? extends E> c) {
boolean modified = false;
for (E e : c)
if (add(e))
modified = true;
return modified;
}
在案例中子类依赖于父类的实现细节,这种场景下应该去使用复合(组合)
即让依赖的对象成为字段
java
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 或 私有构造
如果实现继承,还需要遵循一些约定,如:不能在构造器中调用可重写的方法
如果父类在构造器中调用重写方法,子类又在重写方法中使用的值,父类构造器触发时,子类可能还未初始化,导致严重的错误
父类:
java
public class Super {
public Super() {
method();
}
protected void method() {
System.out.println("super method");
}
}
子类:
java
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);
}
}
输出
java
sub msg -> null
sub msg -> ok
接口优于抽象类
接口、抽象类都属抽象层面,接口比抽象类更具抽象
接口可以为单独功能定义,实现单一职责
对象可以通过接口来增强功能而不必改变原来的结构
同时接口JDK8后提供默认方法,接口不只可以定义抽象,还可以定义实现
使用接口定义功能时,可以加一层抽象类来实现接口,在抽象类中可以使用模板方法实现通用的业务流程,并把核心处理方法留给子类实现
比如JDK中的AbstractList、Set、Map等
java
//定义模板方法
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
java
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表达式,判断是否满足条件,满足条件则进行删除
java
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);
接口只用于定义类型
当实现类实现接口后,被认为是该接口的类型
在早期会使用常量接口,接口只定义常量,而没有抽象方法,这会违反接口的定义
java
public interface ObjectStreamConstants {
final static short STREAM_MAGIC = (short)0xaced;
final static short STREAM_VERSION = 5;
}
类层次优于标签类
标签类指的是类中包含多种"标签",标签理解成功能类型(类中包含多种类型、字段)
java
//标签类
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");
}
}
}
标签类违反单一职责原则,对于标签类可以重构为类层次
类层次指的是使用继承形成有层次结构的类,上层为抽象层,下层为实现层
比如可以实现一个计算面积的抽象类,再由矩形、圆形实现类去实现,从而形成类层次
java
public abstract class AbstractFigure {
public abstract double getArea();
}
矩形:
java
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;
}
}
圆形:
java
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或迭代器,可能使用外部类的字段
java
//非静态成员内部类
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;
}
创建非静态内部类时,需要依赖外部类实例
java
ks = new KeySet();
//创建内部类实例等同于
ks = this.new KeySet();
如果外部类实例不再使用,而一直使用内部类实例,则会导致内存泄漏(内部是实例还存在外部类实例引用,导致外部类实例无法被回收)
如果内部类不需要使用外部类的字段,则选择静态成员内部类
比如:HashMap中的数据结构节点Node、TreeNode
java
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下持续关注喔~
有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~
关注菜菜,分享更多干货,公众号:菜菜的后端私房菜