关于类和接口设计的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下持续关注喔~

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

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

相关推荐
on the way 1232 分钟前
行为型设计模式之Mediator(中介者)
java·设计模式·中介者模式
保持学习ing4 分钟前
Spring注解开发
java·深度学习·spring·框架
wuhunyu5 分钟前
基于 langchain4j 的简易 RAG
后端
techzhi5 分钟前
SeaweedFS S3 Spring Boot Starter
java·spring boot·后端
异常君30 分钟前
Spring 中的 FactoryBean 与 BeanFactory:核心概念深度解析
java·spring·面试
weixin_4612594143 分钟前
[C]C语言日志系统宏技巧解析
java·服务器·c语言
cacyiol_Z1 小时前
在SpringBoot中使用AWS SDK实现邮箱验证码服务
java·spring boot·spring
竹言笙熙1 小时前
Polarctf2025夏季赛 web java ez_check
java·学习·web安全
写bug写bug1 小时前
手把手教你使用JConsole
java·后端·程序员
异常君1 小时前
Java 中 try-catch 的性能真相:全面分析与最佳实践
java·面试·代码规范