掘金 后端 ( ) • 2024-03-29 14:38

theme: smartblue highlight: a11y-dark

8条枚举与注解技巧,提升代码质量与设计美学

Java支持两种特殊用途的引用类型:

  1. 类实现的枚举类型
  2. 接口实现的注解类型

枚举与注解作为Java语言的重要特性,如同艺术家手中的画笔和调色板,赋予代码独特的语义与生命力

本文基于 Effective Java 枚举与注解 章节总结8条相关技巧(文末附案例地址)

思维导图如下:

使用枚举取代部分常量

在早起没有枚举时,会使用int、String等定义常量,会存在他们无法关联、无法遍历获取所有常量等缺点

枚举的出现解决这些问题并提升类型安全、代码可读性、扩展性等

使用枚举类时,实际上会去实现抽象类Enum,其有两个字段:name和ordinal,ordinal用于实现Comparable的排序

    public abstract class Enum<E extends Enum<E>>
            implements Comparable<E>, Serializable {
        private final String name;
        private final int ordinal;
    }

枚举类常用来定义常量,该常量可以由多个字段组成

比如以下枚举类,有重量、半径字段,提供构造,其中每个常量(星球)MERCURY、VENUS..由重量、半径字段组成

    public enum Planet {
        MERCURY(3.302e+23, 2.439e6),
        VENUS  (4.869e+24, 6.052e6),
        NEPTUNE(1.024e+26, 2.477e7);

        //重量
        private final double mass;
        //半径
        private final double radius;

        //构造
        Planet(double mass, double radius) {
            this.mass = mass;
            this.radius = radius;
        }
    }

通过方法能够获取常量的信息,如果需要遍历所有常量,枚举还提供values方法

    double mass = Planet.EARTH.mass();

    Planet[] values = Planet.values();
    for (Planet planet : values) {
       System.out.println(planet);
    }

当枚举对应不同类型时可以使用策略枚举,策略枚举生成多种枚举类型提供给外界

使用抽象方法让不同的策略枚举实现具体细节

    enum PayrollDay {
        MONDAY(PayType.WEEKDAY), 
        FRIDAY(PayType.WEEKDAY),
        SATURDAY(PayType.WEEKEND), 
        SUNDAY(PayType.WEEKEND);

        private final PayType payType;

        PayrollDay(PayType payType) {
            this.payType = payType;
        }

        int pay(int minutesWorked, int payRate) {
            return payType.pay(minutesWorked, payRate);
        }

        // 工作日和周末的加班费计算策略枚举
        enum PayType {
            WEEKDAY {
                int overtimePay(int minsWorked, int payRate) {
                    return minsWorked <= MINS_PER_SHIFT ? 0 :
                            (minsWorked - MINS_PER_SHIFT) * payRate / 2;
                }
            },
            WEEKEND {
                int overtimePay(int minsWorked, int payRate) {
                    return minsWorked * payRate / 2;
                }
            };
            //抽象计算加班费由每个枚举常量实现
            abstract int overtimePay(int mins, int payRate);

            private static final int MINS_PER_SHIFT = 8 * 60;

            //计算工资
            int pay(int minsWorked, int payRate) {
                int basePay = minsWorked * payRate;
                //基本工资 + 加班费
                return basePay + overtimePay(minsWorked, payRate);
            }
        }
    }

策略枚举分为两种策略:工作日、周末的加班费

即使后续需要扩展(删减、增加)枚举常量也十分方便

用字段代替ordinal

ordinal用于标识常量在枚举中的顺序,当位置发生改变时其值也会发生改变

如果需要记录顺序最好使用字段记录(不要使用ordinal),避免位置发生改变时ordinal值变动影响业务

    public enum Ensemble {
        DUET(2),
        SOLO(1),
        TRIO(3);

        //使用字段代替ordinal
        private final int numberOfMusicians;

        Ensemble(int size) {
            this.numberOfMusicians = size;
        }
    }

善用EnumSet

位域指的是通过位运算用少量的空间高效的记录集合中存储的常量内容

如果枚举常量都存在一个集合中,可以使用EnumSet取代位域

    public enum Style {BOLD, ITALIC, UNDERLINE, STRIKETHROUGH}

将枚举常量存入集合

    Text text = new Text();
    EnumSet<Style> enumSet = EnumSet.of(Style.BOLD, Style.ITALIC);
    enumSet.add(Style.UNDERLINE);
    //打印集合内容 [BOLD, ITALIC, UNDERLINE] 
    text.applyStyles(enumSet);

EnumSet.of 方法能够使用位运算记录枚举加入集合

注意它返回的结果并不是不可变对象

    //返回的集合可以继续添加对象
    public static <E extends Enum<E>> EnumSet<E> of(E e1, E e2) {
        EnumSet<E> result = noneOf(e1.getDeclaringClass());
        result.add(e1);
        result.add(e2);
        return result;
    }
    
    //加入集合时位运算
	  public boolean add(E e) {
        typeCheck(e);

        long oldElements = elements;
        elements |= (1L << ((Enum<?>)e).ordinal());
        return elements != oldElements;
    }

当枚举常量都在同一集合时,使用EnumSet存储会更简单、高效

善用EnumMap

当需要为不同的枚举进行分组时可以考虑使用EnumMap

定义枚举类型为植物的成熟周期

    class Plant {
        //植物成熟周期
        enum LifeCycle {
            ANNUAL,
            PERENNIAL,
            BIENNIAL
        }
        final String name;
        final LifeCycle lifeCycle;

        Plant(String name, LifeCycle lifeCycle) {
            this.name = name;
            this.lifeCycle = lifeCycle;
        }
    }

不同的植物有不同的成熟周期

    //不同植物成熟周期不同
    Plant[] garden = {
            new Plant("Basil", LifeCycle.ANNUAL),
            new Plant("Carroway", LifeCycle.BIENNIAL),
            new Plant("Dill", LifeCycle.ANNUAL),
            new Plant("Lavendar", LifeCycle.PERENNIAL),
            new Plant("Parsley", LifeCycle.BIENNIAL),
            new Plant("Rosemary", LifeCycle.PERENNIAL),
            new Plant("Rosemary", LifeCycle.PERENNIAL)
    };

如果要以成熟周期进行分组,每个成熟周期下可能有多个植物,又有多个成熟周期

使用stream流将其转化为EnumMap

EnumMap<LifeCycle, Set<Plant>> enumMap = Arrays.stream(garden)
            .collect(groupingBy(p -> p.lifeCycle, () -> new EnumMap<>(LifeCycle.class), toSet()));
//{ANNUAL=[Basil, Dill], PERENNIAL=[Lavendar, Rosemary, Rosemary], BIENNIAL=[Parsley, Carroway]}
System.out.println(enumMap);

EnumMap基于序数(ordinal)进行索引下标,这样特点就是高效、线性、空间紧凑,只为枚举服务

public V put(K key, V value) {
    typeCheck(key);
    //基于ordinal线性存储
    int index = key.ordinal();
    Object oldValue = vals[index];
    vals[index] = maskNull(value);
    if (oldValue == null)
        size++;
    return unmaskNull(oldValue);
}

如果要根据枚举类型分组,考虑使用EnumMap

使用接口扩展枚举

如果想像添加新类那样扩展枚举值,枚举虽然无法实现,但可以通过接口来进行扩展

使用接口定义抽象方法由枚举类型实现

//计算
public interface Operation {
    double apply(double x, double y);
}

基础拥有加减乘除的枚举

//基础枚举 加
public enum BasicOperation implements Operation {
    PLUS("+") {
        public double apply(double x, double y) {
            return x + y;
        }
    };

    private final String symbol;

    BasicOperation(String symbol) {
        this.symbol = symbol;
    }
}

可以使用新的枚举类实现接口从而完成新增功能

//扩展枚举 模
public enum ExtendedOperation implements Operation {
    REMAINDER("%") {
        public double apply(double x, double y) {
            return x % y;
        }
    };
    private final String symbol;

    ExtendedOperation(String symbol) {
        this.symbol = symbol;
    }

    public static void main(String[] args) {
        double x = Double.parseDouble("9.0");
        double y = Double.parseDouble("1.0");
        //打印:9.000000 + 1.000000 = 10.000000
        test(Arrays.asList(BasicOperation.values()), x, y);
        //打印:9.000000 % 1.000000 = 0.000000
        test(Arrays.asList(ExtendedOperation.values()), x, y);
    }
}

可以使用接口模拟增加新枚举类的方式进行扩展枚举值

标记注解优于命名模式

命名模式指的是在早期开发中,人员想要标记一些代码(类、方法、字段)时,会约定一些标记的方式

比如:需要测试的方法以test开头,后续通过判断方法名是否以test开头来进行判断是否处理标记的代码

这种命名模式一不小心就会出现问题,比如忘记遵守约定

使用注解时,则需要先定义注解,再标记时使用注解,最后编写处理标记的流程

/**
 * 定义注解
 * 只在无参静态方法上使用
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}

使用注解标记需要处理的方法

public class Sample {

    //满足无参静态能通过
    @Test
    public static void m1() {
    }

    //抛出异常 不能通过
    @Test
    public static void m2() {
        throw new RuntimeException("Boom");
    }

    //不能通过 不是静态
    @Test
    public void m3() {
    }

    // Test should fail
    @Test
    public static void m4() {
        throw new RuntimeException("Crash");
    }

}

编写处理标记的流程

public class RunTests {
    public static void main(String[] args) throws Exception {
        int tests = 0;
        int passed = 0;
        Class<?> testClass = Sample.class;
        for (Method m : testClass.getDeclaredMethods()) {
            //方法有注解则进行处理
            if (m.isAnnotationPresent(Test.class)) {
                tests++;
                try {
                    m.invoke(null);
                    passed++;
                } catch (InvocationTargetException wrappedExc) {
                    Throwable exc = wrappedExc.getCause();
                    System.out.println(m + " failed: " + exc);
                } catch (Exception exc) {
                    //非静态 空指针
                    System.out.println("Invalid @Test: " + m);
                }
            }
        }
        System.out.printf("Passed: %d, Failed: %d%n", passed, tests - passed);
    }
}

结果

public static void _6枚举和注解.F注解优于命名模式.Sample.m4() failed: java.lang.RuntimeException: Crash
public static void _6枚举和注解.F注解优于命名模式.Sample.m2() failed: java.lang.RuntimeException: Boom
Invalid @Test: public void _6枚举和注解.F注解优于命名模式.Sample.m3()
Passed: 1, Failed: 3

不要使用约定的命名模式标记代码,而是使用注解处理更靠谱

坚持使用Override注解

@Override 注解用于覆写父类方法或抽象方法

如果想要对方法进行覆写(重写)时,不小心对其进行重载,那么编译器不会报错,反而运行时才出现错误,导致排查浪费时间

需要覆写方法时使用@Override注解,如果发生这种情况编译器会提前报错,提示进行修改

好在现在的IDE工具基本上在覆写时都会自动生成Override注解

善用标记接口

标记接口指的是没有抽象方法,只用于定义类型的接口,如:序列化 Serializable、克隆 Cloneable、随机访问 RandomAccess

标记接口只能由接口继承或类实现,所以只适用于类和接口上

当标记需要在其他地方(方法、字段)上时优先使用标记注解

当使用标记接口时,能够得到编译期间检查类型的好处,尽早暴露问题

比如反序列化 ObjectOutputStream.writeObject(Object) 并没有使用标记接口的好处

如果申明参数为Serializable,传入参数未实现序列化接口则可以在编译期间就提前暴露问题

总结

枚举类继承抽象类Enum,用于定义常量,可由多个字段组成,并提供name\ordinal字段、values遍历方法等,使用枚举代替常量提升类型安全、可读性、扩展性

ordinal用于标识枚举类型顺序,位置变动会发生改变,如果要依赖顺序性,最好使用字段记录

EnumSet 使用位运算,在少量的空间高效的记录存储在同一集合的枚举常量

EnumMap 使用ordinal索引下标,能够更高效、空间紧凑线性的对枚举常量类型进行分组

如果想像新增类一样扩展枚举,可以定义接口类型由新增枚举实现

命名模式需要约定并且容易遗忘,使用标记注解,标记代码,特殊处理

覆写方法始终使用override注解,如果写成重载能够在编译期间暴露问题

标记接口用于定义类型,能够在编译期间检查类型,但只能用于类或接口,若用于方法、字段只能使用标记注解

最后(不要白嫖,一键三连求求拉~)

本篇文章被收入专栏 Effective Java,感兴趣的同学可以持续关注喔

本篇文章笔记以及案例被收入 Gitee-CaiCaiJavaGithub-CaiCaiJava 感兴趣的同学可以stat下持续关注喔~

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

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