第30条:用enum代替int常量

       枚举类型是指由一组固定的常量组成合法值的类型,例如一年中的季节,太阳系中的行星或者一副牌中的花色。在编程语言中还没有引入枚举类型之前,表示枚举类型的常用模式是声明一组具名的int常量,每个类型成员一个常量:

public static final int APPLE_FUJI=0;

public static final int APPLE_PIPPIN=1;

public static final int APPLE_GRANNY_SMITH=2;

public static final int ORANGE_NAVEL=0;

public static final int ORANGE_TEMPLE=1;

public static final int ORANGE_BLOD=2;

       这种方法称作int 枚举模式,存在着诸多不足。它在类型安全性和使用方便性方面没有任何帮助。如果你将apple传到想要orange的方法中,编译器也不会出现警告,还会用==操作符将apple与orange进行对比,甚至更糟糕。

int i = (APPLE_FUJI - ORANGE_TEMPLE) / APPLE_PIPPIN;

       注意每个apple常量的名称都以APPLE_作为前缀,每个orange常量都是以ORANGE_作为前缀,这是因为Java没有为int枚举组提供命名空间,当两个int枚举组具有相同的命名常量时,前缀可以防止名称发生冲突。

       采用int枚举模式的程序是十分脆弱的,因为int枚举是编译时常量,被编译到使用他们的客户端中,如果与枚举常量关联的int发生了变化,客户端就必须重新编译,如果没有重新编译,程序还是可以运行,但是他们的行为就是不确定的。

       将int枚举常量翻译成可打印的字符串,并没有很便利的方法,如果将这种常量打印出来,或者从调试器中将它显示出来,你所见到的就是一个数字,这就没有太大的用处。要遍历一组中的所有int枚举常量,甚至获得int枚举组的大小,这些都没有很可靠的方法。

       你还可能碰到这种模式的变体,在这种模式中使用的是String常,而不是int常量。这样 的变体被称作String枚举糢式,同样也是我们最不期望的。虽然它为这些常量提供了可打印的 字符串,但是它会导致性能问题,因为它依赖于字符串的比较操作。更糟糕的是,它会导致初级用户把字符串常量硬编码到客户端代码中,而不是使用适当的域(field)名。如果这样的硬编码字符串常量中包含有书写错误,那么,这样的错误在编译时不会被检测到,但是在运行的时候却会报错。

       幸运的是,从Javal.5发行版本开始,就提出了另一种可以替代的解决方案,可以避免int和String枚举模式的缺点,并提供许多额外的好处。下面以最简单的形式演示了这种模式:

            public enum Apple { FUJI, PIPPIN, CRANNY_SMITH }

             public enum Orange { NAVEL, TEMPLE, BLOOD }

        表面上看来,这些枚举类型与其他语言中的没有什么两样,例如c、C++和c#,但是实际 上并非如此。Java的枚举类型是功能十分齐全的类,功能比其他语言中的对等物要更强大得多,Java的枚举本质上是int值。

       Java枚举类型背后的基本想法非常简单:它们就是通过公有的静态final域为每个枚举常量 导出实例的类。因为没有可以访问的构造器,枚举类型是真正的final。因为客户端既不能创建枚举类型的实例,也不能对它进行扩展,因此很可能没有实例,而只有声明过的枚举常量。 换句话说,枚举类型是实例受控的。它们是单例(Singleton)的泛型化(见第3条),本质上是单元素的枚举。对于熟悉本书第一版的读者来说,枚举类型为类型安全的枚举(typesafe enum)模式[BlochOI,见第21条]提供了语言方面的支持。

       枚举提供了编译时的类型安全。如果声明一个参数的类型为Apple,就可以保证,被传到 该参数上的任何非null的对象引用一定属于三个有效的Apple值之一。试图传递类型错误的值 时,会导致编译时错误,就像试图将某种枚举类型的表达式赋给另一种枚举类型的变量,或者试图利用==操作符比较不同枚举类型的值一样。

       包含同名常量的多个枚举类型可以在一个系统中和平共处,因为每个类型都有自己的命名空间。你可以增加或者重新排列枚举类型中的常量,而无需重新编译它的客户端代码,因为导出常量的域在枚举类型和它的客户端之间提供了一个隔离层:常量值并没有被编译到客户端代码中,而是在int枚举模式之中。最终,可以通过调用toString方法,将枚举转换成可打印的字符串。

        除了完善了int枚举模式的不足之外,枚举类型还允许添加任意的方法和域,并实现任意的接口。它们提供了所有Object方法(见第3章)的髙级实现,实现了Comparable(见第12条) 和Serializable接口(见第11章),并针对梅举类型的可任意改变性设计了序列化方式。

public abstract class Enum <E extends Enum<E>> implements Comparable, Serializable {}

        那么我们为什么要将方法或者域填加到枚举类型中呢?首先,你可能是想将数据与它的常量关联起来。例如,一个能够返回水果颜色或者返回水果图片的方法,对于我们的Apple和Orange类型来说可能很有好处。你可以利用任何适当的方法来增强枚举类型。枚举类型可以 先作为枚举常量的一个简单集合,随着时间的推移再演变成为全功能的抽象。

        举个有关枚举类型的好例子,比如太阳系中的8颗行星。每颗行星都有质量和半径,通过这两个属性可以计算出它的表面重力。从而给定物体的质量,就可以计算出一个物体在行星 表面上的重量。下面就是这个枚举。毎个枚举常量后面括号中的数值就是传递给构造器的参数。在这个例子中,它们就是行星的质量和半径:

public enum Planet {

        MERCURY(3.302e+23, 2.439e6),

        VENUS (4.869e+24, 6.052e6),

        EARTH (5.975e+24, 6.378e6),

        MARS (6.419e+23, B.393e6),

        JUPITER(1.899e+27, 7.149e7),

        SATURN (5.685e+26, 6.027e7),

        URANUS (8.683e+25, 2.556e7),

        NEPTUNE(1.024e+26, 2.477e7);

        private final double mass;               //In  kilograms 质量

        private final double radius;              //In meters 半径

        private final double surfaceGravity; // In m / s^2  表面重力

        // Universal gravitational constant in m^3 / kg s^2 

        private static final double G = 6.67300E-11;

       // Constructor

      Planet(double mass, double radius) {

               this.mass = mass;

               this.radius = radius;

               surfaceGravity = G * mass / (radius * radius);

       }

       public double mass() { return mass; }

       public double radius() { return radius; }

       public double surfaceCravity() { return surfaceGravity; }

       public double surfaceWeight(double mass) { 

               return mass * surfaceGravity;    // F = ma

       }

}

       编写一个像Planet这样的枚举类型并不难。为了将数据与枚举常量关联起来,得声明实例域,并编写一个带有数据并将数据保存在域中的构造器。枚举天生就是不可变的,因此所有的域都应该为final的(见第15条)。它们可以是公有的,但最好将它们做成是私有的,并提供公有的访问方法(见第14条)。在Planet这个示例中,构造器还计算和保存表面重力,但这正是一种优化。每当surfaceWeight方法用到重力时,都会根据质量和半径重新计算,并返回它在该常量所表示的行星上的重量。

       虽然Planet枚举很简单,它的功能却强大得出奇。下面是一个简短的程序,根据某个物体在地球上的重量,打印出一张很棒的表格,显示出该物体在所有8颗行星上的重量:

public class WeightTable {

      public static void main(String[] args) {

            double earthWeight =  Double.parseDouble(args[0]);

            double mass = earthWeight / Planet . EARTH.surfaceGravity();

            for (Planet p : Planet .values())

                  System.out.printf("Weight on %s is %f%n" , p , p.surfaceWeight(mass));

            }

}

       注意Planet就像所有的枚举一样,它有一个静态的values方法,按照声明顺序返回它的值数组。还要注意toString方法返回每个枚举值的声明名称,使得println和 printf的打印变得更加容易。如果你不满意这种字符串表示法,可以通过覆盖toString方法对它进行修改。下面就是用命令行参数175运行这个小小的WeightTable程序时的结果:

Weight on MERCURY is 66.133672

Weight on VENUS is 158.383926 

Weight on EARTH is 175.000000 

Weight on MARS is 66.430699 

Weight on JUPITER is 442.693902 

Weight on SATURN is 186.464970

Weight on URANUS is 158.349709 

Weight on NEPTUNE is 198.846116

       如果这是你第一次在实践中见到Java的printf方法,要注意它与C语言的区别,你在这里用的是%n,在C中则用\n。

       与枚举常量关联的有些行为,可能只需要用在定义了枚举的类或者包中。这种行为最好被实现成私有的或者包级私有的方法。于是,每个枚举常量都带有一组隐蔽的行为,这使得包含该枚举的类或者包在遇到这种常量时都可以做出适当的反应。就像其他的类一样,除非迫不得已要将枚举方法导出至它的客户端,否则都应该将它声明为私有的,如有必要,则声明为包级私有的(见第13条)。

       如果一个枚举具有普遍适用性,它就应该成为一个顶层类(top-level class),如果它只是被用在一个特定的顶层类中,它就应该成为该顶层类的一个成员类(见第22条)。例如,java.math.RoundingMode枚举表示十进制小数的舍入模式(rounding mode)。这些舍入模式用于BigDecimal类,但是它们提供了一个非常有用的抽象,这种抽象本质上又不属于BigDecimal类。通过使RoundingMode变成一个顶层类,库的设计者鼓励任何需要舍入模式的程序员重用这个枚举,从而增强API之间的一致性。.

       Planet示例中所示的方法对于大多数枚举类型来说就足够了,但你有时候会需要更多的方法。每个Planet常量都关联了不同的数据,但你有时需要将本质上不同的行为(behavior)与每个常量关联起来。例如,假设你在编写一个枚举类型,来表示计算器的四大基本操作(即加减乘除),你想要提供一个方法来执行每个常量所表示的算术运算。有一种方法是通过启用枚举的值来实现:

//Enum type that switches on its own value - questionable

public enum Operation {

      PLUS, MINUS, TIMES, DIVIDE;

      //Do the arithmetic op represented by this constant 

      double apply(double x, double y) { 

             switch(this) {

                   case PLUS:return x + y;

                   case MINUS:return x - y;

                   case TIMES:return x * y;

                   case DIVIDE: return x / y;

             }

             throw new AssertionError("Unknown op: " + this);

       这段代码可行,但是不太好看。如果没有throw语句,它就不能进行编译,虽然从技术角度来看代码的结束部分是可以执行到的,但是实际上是不可能执行到这行代码的。更糟糕的是,这段代码很脆弱。如果你添加了新的枚举常量,却忘记给switch添加相应的条件,枚举仍然可以编译,但是当你试图运用新的运算时,就会运行失败。

       幸运的是,有一种更好的方法可以将不同的行为与每个枚举常最关联起来:在枚举类型中声明一个抽象的apply方法,并在特定于常量的类主体(constant-specific class body)中,用具体的方法覆盖毎个常量的抽象apply方法。这种方法被称作特定于常量的方法实现(constant-specific method implementation):

//Enum type with constant-specific method implementation

public enum Operation {

      PLUS    { double apply(double x,double y){return x+y;} },

      MINUS  { double apply(double x,double y){return x-y;} },

      TIMES  { double apply(double x,double y){return x*y;} },

      DIVIDE { double apply(double x,double y){return x/y;} };

      abstract double apply (double x, double y);

}

       如果给Operation的第二种版本添加新的常量,你就不可能会忘记提供apply方法,因为该方法就紧跟在每个常量声明之后。即使你真的忘记了,编译器也会提醒你,因为枚举类型中的抽象方法必须被它所有常量中的具体方法所覆盖。特定于常量的方法实现可以与特定于常量的数据结合起来。例如,下面的Operation覆盖了toString来返回通常与该操作关联的符号:

//Enum type with constant-specific class bodies and data

public enum Operation {

      PLUS(" + ") {

              double apply (double x, double y) { return x + y; }

      },

      MINUS(" - ") {

               double apply (double x, double y) { return x - y; }

      }, 

      TIMES(" * ") {

               double apply (double x, double y) { return x * y; }

      },

      DIVIDE(" / ") {

               double apply (double x, double y) { return x / y; }

      };

      private final String symbol;

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

      @Override public String toString() { return symbol; }

      abstract double apply (double x , double y);

}

      在有些情况下,在枚举中覆盖toString非常有用。例如,上述的toString实现使得打印算术表达式变得非常容易,如这段小程序所示:

public static void main(String[] args) { 

      double x = Double.parseDouble(args[0]); 

      double y = Double.parseDouble(args[1]); 

      for (Operation op : Operation .values())

           System.out.printf (" %f %s %f ",x, op, y, op.apply(x, y));

}

      用2和4作为命令行参数运行这段程序,会输出:

      2.000000 + 4.000000 = 6.000000

      2.000000 - 4.000000 = -2.000000

      2.000000 * 4.000000 = 8.000000

      2.000000 / 4.000000 = 0.500000

       枚举类型有一个自动产生的value〇f(String)方法,它将常量的名字转变成常量本身。如果在枚举类型中覆盖toString,要考虑编写一个fromString方法,将定制的字符串表示法变回相应的枚举。下列代码(适当地改变了类型名称)可以为任何枚举完成这一技巧,只要每个常量都有一个独特的字符串表示法:

//Implementing a fromString method on an enum type

private static final Map<String , Operation> stringToEnum 

       = new  HashMap<String , Operation>();

static { // Initialize map from constant name to enum constant 

       for (Operation op : values())

             stringToEnum . put (op.toString() , op);

}

// Returns Operation for string, or null if string is invalid

public static Operation fromString(String symbol) {

       return stringToEnum.get (symbol);

}

       注意,在常量被创建之后,Operation常量从静态代码块中被放入到了stringToEnum的map中。试图使每个常量都从自己的构造器将自身放入到map中,会导致编译时错误。这是好事,因为如果这是合法的,就会抛出NullPointerException异常。枚举构造器不可以访问枚举的静态域,除了编译时常量域之外。这一限制是有必要的,因为构造器运行的时候,这些静态域还没有被初始化。

       特定于常量的方法实现有一个美中不足的地方,它们使得在枚举常量中共享代码变得更加困难了。例如,考虑用一个枚举表示薪资包中的工作天数。这个枚举有一个方法,根据给定某工人的基本工资(按小时)以及当天的工作时间,来计算他当天的报酬。在五个工作日中, 超过正常八小时的工作时间都会产生加班工资.,在双休日中,所有工作都产生加班工资。利用switch语句,很容易通过将多个case标签分别应用到两个代码片断中,来完成这一计算。为 了简洁起见,这个示例中的代码使用了double,但是注意double并不是适合薪资应用程序(见第48条)的数据类型。

//Enum that switches on its value to share code - questionable

enum PayrollDay {

        MONDAY, TUESDAY, WEDNESDAY , THURSDAY , FRIDAY , SATURDAY , SUNDAY;

        private static final int HOURS_PER_SHIFT = 8; 

        double pay(double hoursWorked, double payRate) { 

                double basePay = hoursWorked * payRate;

                double overtimePay; //Calculate overtime pay

                switch(this) {

                    case SATURDAY: 

                    case SUNDAY : overtimePay = hoursWorked *payRate / 2;

                    default : //Weekdays

                         overtimePay = hoursWorked <= HOURS_PER_SHIFT ? 

                         0 : (hoursWorked - HOURS_PER_SHIFT) * payRate / 2; 

                    break;

                }

          return basePay + overtimePay;

          }

}

       不可否认,这段代码十分简洁,但是从维护的角度来看,它非常危险。假设将一个元素添 加到该枚举中,或许是一个表示假期天数的特殊值,但是忘记给switch语句添加相应的case。程序依然可以编译,但pay方法会悄悄地将假期的工资计算成与正常工作H的相同。

       为了利用特定于常量的方法实现安全地执行工资计算,你可能必须重复计算每个常量的加班工资,或者将计算移到两个辅助方法中(一个用来计算工作日,一个用来计算双休日),并从每个常量调用相应的辅助方法。这任何一种方法都会产生相当数量的样板代码,结果降低了可读性,并增加了出错的机率。

       通过用计算工作日加班工资的具体方法代替PayrollDay中抽象的overtimePay方法,可以减少样板代码。这样,就只有双休日必须覆盖该方法了。但是这样也有着与switch语句一样的不足:如果又增加了一天而没有覆盖overtimePay方法,就会悄悄地延续工作日的计算。

       你真正想要的就是每当添加一个枚举常量时,就强制选择一种加班报酬策略。幸运的是, 有一种很好的方法可以实现这一点。这种想法就是将加班工资计算移到一个私有的嵌套枚举中,将这个策略枚举(strategyenum)的实例传到PayrollDay枚举的构造器中。之后PayrollDay枚举将加班工资计算委托给策略枚举,PayrollDay中就不需要switch语句或者特定于常量的方法实现了。虽然这种模式没有switch语句那么简洁,但更加安全,也更加灵活:

//The strategy enum pattern

enum PayrollDay {

        MONDAY(PayType.WEEKDAY),

        TUESDAY(PayType.WEEKDAY),

        WEDNESDAY(PayType.WEEKDAY),

        THURSDAY(PayType.WEEKDAY),

        FRIDAY(PayType.WEEKDAY),

        SATURDAY(PayType.WEEKEND),

        SUNDAY(PayType.WEEKEND);

        private final PayType payType;

        Payroll Day ( PayType payType ) { this.payType = payType; }

        double pay ( double hoursWorked , double payRate ) {

              return payType.pay ( hoursWorked , payRate );

        }

        //The strategy enum type

        private enum PayType {

              WEEKDAY {

                         double overtimePay(double hours, double payRate) {

                                return hours <= HOURS_PER_SHIFT ? 0 :

                                      (hours - HOURS_PER_SHIFT) * payRate / 2;

                         }

               },

              WEEKEND {

                        double overtimePay(double hours, double payRate) {

                                 return hours * payRate / 2;

                         }

               };

               private static final int HOURS_PER_SHIFT = 8;

               abstract double overtimePay(double hrs, double payRate);

               double pay(double hoursWorked, double payRate) {

                       double basePay = hoursWorked*payRate;

                       return basePay + overtimePay ( hoursWorked, payRate );

               }

       }

}

       如果枚举中的switch语句不是在枚举中实现特定于常量的行为的一种很好的选择,那么它 们还有什么用处呢?枚举中的switch语句适合于给外部的枚举类型增加特定于常量的行为。例如,假设Operation枚举不受你的控制,你希望它有一个实例方法来返回每个运算的反运算。 你可以用下列静态方法模拟这种效果:

//Switch on an enum to simulate a missing method

public static Operation inverse(Operation op) {

       switch(op) {

              case PLUS:         return Operation.MINUS;

              case MINUS:       return Operation.PLUS;

              case TIMES:        return Operation.DIVIDE;

              case DIVIDE:       return Operation.TIMES;

              default:     throw new  AssertionError ( "Unknown op: " + op );

        }

}

       一般来说,枚举会优先使用comparable而非int常量。与int常量相比,枚举有个小小的性能缺点,即装载和初始化枚举时会有空间和时间的成本。除了受资源约束的设备,例如手机和烤面包机之外,在实践中不必太在意这个问题。

       那么什么时候应该使用枚举呢?每当需要一组固定常量的时候。当然,这包括“天然的枚举类型”,例如行星、一周的天数以及棋子的数目等等。但它也包括你在编译时就知道其所有可能值的其他集合,例如菜单的选项、操作代码以及命令行标记等。枚举类型中的常集并不一定要始终保持不变。专门设计枚举特性是考虑到枚举类型的二进制兼容演变。

       总而言之,与int常量相比,枚举类型的优势是不言而喻的。枚举要易读得多,也更加安全,功能更加强大。许多枚举都不需要显式的构造器或者成员,但许多其他牧举则受益于“每个常量与属性的关联”以及“提供行为受这个属性影响的方法”。只有极少数的枚举受益于将多种行为与单个方法关联。在这种相对少见的情况下,特定于常量的方法要优先于启用自有值的枚举。如果多个枚举常量同时共享相同的行为,则考虑策略枚举。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容