关于类和接口设计的11个好习惯

关于类和接口设计的11个好习惯

当我们致力于封装组件,构建高内聚低耦合的模块化系统时,理解和熟练运用类与接口的设计原则显得尤为重要

本文基于Effective Java中类与接口章节汇总出11个设计好习惯(文末附案例地址)

思维导图如下:

让类和字段的可访问性最小化

一个组件设计的好不好,一个重要的特性就是封装的好不好

(把组件当成黑盒使用,不需要了解内部实现)

组件封装的好处:

  1. 解耦:各个组件低耦合,不必关心其他组件实现
  2. 独立:组件之间独立变化,并行开发,加快开发速度
  3. 可重用:组件提高可重用性

Java中提供访问修饰符和类所在的位置来实现封装

访问修饰符分为:private、default、protected、public

访问范围 private default protected public
类内
包内
父子类内
任意位置

设计原则:

  1. 尽可能的让类和字段的可访问性最小化

  2. 除了常量,公有类的字段绝不能是公有的(破坏封装性)

    反例:

    java 复制代码
    public class Student{
        //该对象的公有字段都能被直接访问
        public int age;
    }
  3. 类具有公有、静态的final字段最好是不可变对象(如果是可变对象就要注意并发问题)

    java 复制代码
    public static final String OBJECT = "object";

要使用方法访问类的字段

对于可变的类,需要设置私有字段,并提供公共的方法(get/set)访问字段

正例 Java Bean:

java 复制代码
public class Bean{
    private int age;
    private String name;
    
    //get、set方法
}

如果非要使用公有字段,使用不可变对象会降低危害

也可以在一些内部类中使用公有字段

可变性最小化

不可变对象指字段不能被修改的类,在构造初期就设置好值,在整个生命周期不改变

不可变对象设计、实现更简单,而且使用更安全,如:String

实现不可变对象遵循规则:

  1. 不提供修改对象字段的方法
  2. 保证类不会被扩展,需要final class
  3. 所有字段都是private final
  4. 如果字段是可变对象,确保不会通过方法被泄漏(调用时不能获取可变字段)

如果不可变对象方法要提供修改的操作,往往是经过计算后返回一个新的对象

比如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);
        }
    }

不可变对象的好处:

  1. 简单易实现
  2. 线程安全可以被共享
  3. 充当散列表的key
  4. 原子性(因为不会被修改从而不会出现不一致)

缺点:性能不好,不同的值都需要一个对象

可以使用享元、缓存常用对象弥补缺点,如包装类的装箱

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-CaiCaiJavaGithub-CaiCaiJava 感兴趣的同学可以stat下持续关注喔~

有什么问题可以在评论区交流,如果觉得菜菜写的不错,可以点赞、关注、收藏支持一下~

关注菜菜,分享更多干货,公众号:菜菜的后端私房菜

相关推荐
爱吃土豆的马铃薯ㅤㅤㅤㅤㅤㅤㅤㅤㅤ8 分钟前
idea 弹窗 delete remote branch origin/develop-deploy
java·elasticsearch·intellij-idea
Code成立10 分钟前
《Java核心技术 卷I》用户图形界面鼠标事件
java·开发语言·计算机外设
鸽鸽程序猿35 分钟前
【算法】【优选算法】二分查找算法(下)
java·算法·二分查找算法
遇见你真好。1 小时前
自定义注解进行数据脱敏
java·springboot
NMBG221 小时前
[JAVAEE] 面试题(四) - 多线程下使用ArrayList涉及到的线程安全问题及解决
java·开发语言·面试·java-ee·intellij-idea
王二端茶倒水1 小时前
大龄程序员兼职跑外卖第五周之亲身感悟
前端·后端·程序员
像污秽一样1 小时前
Spring MVC初探
java·spring·mvc
计算机-秋大田1 小时前
基于微信小程序的乡村研学游平台设计与实现,LW+源码+讲解
java·spring boot·微信小程序·小程序·vue
LuckyLay1 小时前
Spring学习笔记_36——@RequestMapping
java·spring boot·笔记·spring·mapping
醉颜凉2 小时前
【NOIP提高组】潜伏者
java·c语言·开发语言·c++·算法