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

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

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

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

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

思维导图如下:

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

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

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

组件封装的好处:

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

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

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

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

设计原则:

  1. 尽可能的让类和字段的可访问性最小化
  2. 除了常量,公有类的字段绝不能是公有的(破坏封装性)
    反例:
public class Student{
    //该对象的公有字段都能被直接访问
    public int age;
}
  1. 类具有公有、静态的final字段最好是不可变对象(如果是可变对象就要注意并发问题)
public static final String OBJECT = "object";

要使用方法访问类的字段

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

正例 Java Bean:

public class Bean{
    private int age;
    private String name;
    
    //get、set方法
}

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

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

可变性最小化

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

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

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

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

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

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

不可变对象的好处:

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

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

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

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

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

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

相关文章
|
6月前
|
设计模式 API 数据安全/隐私保护
探索设计模式的魅力:外观模式简化术-隐藏复杂性,提供简洁接口的设计秘密
外观模式是一种关键的设计模式,旨在通过提供一个简洁的接口来简化复杂子系统的访问。其核心价值在于将复杂的内部实现细节封装起来,仅通过一个统一的外观对象与客户端交互,从而降低了系统的使用难度和耦合度。在软件开发中,外观模式的重要性不言而喻。它不仅能够提高代码的可读性、可维护性和可扩展性,还能促进团队间的协作和沟通。此外,随着业务需求和技术的发展,外观模式能够适应变化,通过修改外观对象来灵活调整客户端与子系统之间的交互方式。总之,外观模式在软件设计中扮演着举足轻重的角色,是构建高效、稳定且易于维护的软件系统的关键
175 1
探索设计模式的魅力:外观模式简化术-隐藏复杂性,提供简洁接口的设计秘密
|
1月前
|
存储 设计模式 Java
为什么我们在程序开发设计中要基于接口而非实现编程?
为什么我们在程序开发设计中要基于接口而非实现编程?
57 1
|
30天前
|
Java 程序员 C++
抽象类 vs 接口:如何在实际项目中做出正确选择?
小米讲解了Java中的抽象类和接口,分析了两者的异同及使用场景。抽象类适合共享状态和行为逻辑,接口适用于提供统一行为规范,尤其在多继承场景下。文中通过实例说明了如何选择使用抽象类或接口,帮助开发者更好地理解这两者在实际开发中的应用。
19 0
|
3月前
|
项目管理
类与类之间的协作模式问题之抽象工厂模式在工作中体现的问题如何解决
类与类之间的协作模式问题之抽象工厂模式在工作中体现的问题如何解决
|
5月前
|
算法 安全 编译器
【简洁的代码永远不会掩盖设计者的意图】如何写出规范整洁的代码
【简洁的代码永远不会掩盖设计者的意图】如何写出规范整洁的代码
52 1
|
Oracle Java 关系型数据库
想要造轮子,你知道反射机制吗?
平时写代码的过程中,我们使用不同的工具框架来提升开发效率,除了基础框架之外,我们自己也想造轮子,封装各种业务平台功能; 一旦需造轮子的时候,那么就需要使用Java造轮子利器:反射; 一些项目中常见的反射应用场景: • 泛化调用: 提前不知道目标RPC的接口和方法,而是开发在后台输入值,根据输入的配置动态请求。 这也是提升效率的一部分,因为不可能所以得RPC接口都要亲自对接的,总要有一部分可以灵活的调用不同接口。
56 0
|
6月前
针对抽象编程与对应的好处
针对抽象编程与对应的好处
49 1
|
存储 NoSQL MongoDB
变形记---抽象接口,屎山烂代码如何改造成优质漂亮的代码
在游戏服务器开发过程中,我们经常会在动手码代码之前好好的设计一番,如何设计类,如何设计接口,如何调用,有没有什么隐患,在这些问题考虑评审可以Cover现阶段的需求的情况下再动手。不过,对于一些初级,甚至中高级开发者,仍然不可避免的进入了一个死胡同,缺少设计,屎山代码堆积,越堆越臭,越写越烂,直到很难维护必须要重新改造。最近我给M部门面试服务器主程序开发的职位,我不问开发语言的语法,我只问他们的架构设计经验,我发现相当一部分5-12年“本应该有足够开发经验。
|
设计模式 前端开发 Java
项目开发-依赖倒置、里式替换、接口隔离的应用深入理解
项目开发-依赖倒置、里式替换、接口隔离的应用深入理解
120 0
|
前端开发
案例14-代码结构逻辑混乱,页面设计不美观
代码结构逻辑混乱,页面设计不美观