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

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

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

相关文章
|
5天前
|
设计模式 API 数据安全/隐私保护
探索设计模式的魅力:外观模式简化术-隐藏复杂性,提供简洁接口的设计秘密
外观模式是一种关键的设计模式,旨在通过提供一个简洁的接口来简化复杂子系统的访问。其核心价值在于将复杂的内部实现细节封装起来,仅通过一个统一的外观对象与客户端交互,从而降低了系统的使用难度和耦合度。在软件开发中,外观模式的重要性不言而喻。它不仅能够提高代码的可读性、可维护性和可扩展性,还能促进团队间的协作和沟通。此外,随着业务需求和技术的发展,外观模式能够适应变化,通过修改外观对象来灵活调整客户端与子系统之间的交互方式。总之,外观模式在软件设计中扮演着举足轻重的角色,是构建高效、稳定且易于维护的软件系统的关键
78 1
探索设计模式的魅力:外观模式简化术-隐藏复杂性,提供简洁接口的设计秘密
|
5天前
|
存储 编译器 数据安全/隐私保护
【软件设计师备考 专题 】面向对象开发方法:理解类、对象和封装
【软件设计师备考 专题 】面向对象开发方法:理解类、对象和封装
56 0
|
5天前
针对抽象编程与对应的好处
针对抽象编程与对应的好处
22 1
|
9月前
|
Oracle Java 关系型数据库
想要造轮子,你知道反射机制吗?
平时写代码的过程中,我们使用不同的工具框架来提升开发效率,除了基础框架之外,我们自己也想造轮子,封装各种业务平台功能; 一旦需造轮子的时候,那么就需要使用Java造轮子利器:反射; 一些项目中常见的反射应用场景: • 泛化调用: 提前不知道目标RPC的接口和方法,而是开发在后台输入值,根据输入的配置动态请求。 这也是提升效率的一部分,因为不可能所以得RPC接口都要亲自对接的,总要有一部分可以灵活的调用不同接口。
35 0
|
前端开发 测试技术
前端测试代码怎么做抽象?
哈喽,大家好,我是海怪。 不知道大家在写前端单测的时候,是否有出现测试代码和测试数据重复冗余的情况?然后不得不写一些函数和类来封装他们的。然而,慢慢地会发现:过度的封装会致使你的测试用例变得越来越难读。 那到底在写测试代码时,怎样的封装才是好的封装呢?今天就把 Kent 的这篇 《AHA Testing》 分享给大家~
前端测试代码怎么做抽象?
|
程序员
六石编程学:略谈抽象能力
六石编程学:略谈抽象能力
209 0
|
设计模式 Java
快速分清抽象类与接口 | 带你学《Java面向对象编程》之六十一
本节结合实际情景,直观地为读者列表展示了抽象类与接口在不同维度上的区别。
快速分清抽象类与接口   | 带你学《Java面向对象编程》之六十一
|
Java
编程之代码抽象三原则
编程之代码抽象三原则,这三原则仔细推敲,与23种设计模式不无关系。 23种设计模式,在此我不做详细介绍和说明,因为我目前也正在学习,在学习设计模式的时候,有一点非常重要, 引用王阳明先生的理念“知行合一”,将理论同实践集合起来,这样就不空中楼阁了。
1313 0
类设计:设计卖车的4S店
class Car(object): # 定义车的方法 def move(self): print('---车在移动---') def stop(self): print('---停车---') # 定义一个销售车的店类 class Car...
739 0
|
Java 开发工具 Android开发
Android编程开发基本规范和原则
Android编程开发基本规范和原则 一,代码的命名,遵循必要的Java和Android规范。 沿用标准Java和Android的命名规则,使得代码足够简单易读。
1223 0