9.0 Enums, Generic, SOLID
Enums
Concepts
编程当中固定以及有限的值直接用整数表示有缺点,比如 1 到底是周一还是周日:类型不安全、含义不明确、维护困难,因此可以使用枚举
// 定义一个表示星期的枚举类型
enum DayOfWeek {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}
// 定义一个表示交通方式的枚举类型
enum Transport {
BUS, TRAIN, FERRY, TRAM
}
- 枚举常量名通常使用全大写字母 s
枚举名.常量名
的方式来访问具体的值。注意:不使用new
关键字
这样可读性较强也可以使用 ==
比较
- Java 中的
enum
实际上是一种特殊的类 - 所有枚举都隐式地继承自
java.lang.Enum
类 - 不能再继承其他类,也不能被其他类继承
- 可以像类一样拥有访问修饰符
public, protected, private
以及其和 switch
语句也可以结合的比较好
public static int typicalSpeed(Transport transport) {
switch (transport) {
case BUS:
return 50;
case TRAIN:
return 100;
case FERRY:
return 20;
case TRAM:
return 30;
default:
return 0;
}
}
public static boolean isWeekend(DayOfWeek day) {
return day == DayOfWeek.Saturday || day == DayOfWeek.Sunday;
}
public static void main(String[] args) {
DayOfWeek day = DayOfWeek.Monday;
System.out.println(isWeekend(day));
System.out.println(isWeekend(DayOfWeek.Saturday));
System.out.println(typicalSpeed(Transport.BUS));
System.out.println(typicalSpeed(Transport.TRAIN));
}
Functions
values()
枚举含有内置方法,values()
静态方法,返回一个包含该枚举所有常量的数组
-
ordinal()
: 返回枚举常量在声明时的序号(从 0 开始)。例如DayOfWeek.MONDAY.ordinal()
返回 0 -
valueOf(String name)
: 静态方法,根据给定的名称字符串返回对应的枚举常量
枚举类可以添加自己的方法,也可以添加自己的构造函数以及字段,构造器默认必须 private
可以不写,不能使用 new
,JVM
会在创建枚举常量实例时自动调用。
enum Transport {
BUS(50), TRAIN(100), FERRY(20), TRAM(30); // 调用构造器
private final int typicalSpeed; // 添加字段
Transport(int typicalSpeed) { // 构造器 (隐式 private)
this.typicalSpeed = typicalSpeed;
}
public int getTypicalSpeed() { // 添加方法
return typicalSpeed;
}
}
// 使用: Transport.BUS.getTypicalSpeed() 返回 50
可以通过这种方式直接在内部写好值就不用写 switch
语句
Autoboxing and Unboxing
Java 为每个基本数据类型 (primitive type: int
, double
, boolean
, char
等) 都提供了一个对应的包装类 (wrapper class: Integer
, Double
, Boolean
, Character
等)。这些包装类是对象。
- 自动装箱 (Autoboxing):
Java 编译器自动地将基本数据类型的值转换为对应的包装类对象。
- 自动拆箱 (Unboxing):
Java 编译器自动地将包装类对象转换为对应的基本数据类型的值。
Usage
- 赋值
Integer wrapperInt = 10; // Autoboxing: int 10 -> Integer(10)
int primitiveInt = wrapperInt; // Unboxing: Integer(10) -> int 10
- 泛型集合
List<Integer> list = new ArrayList<>();
list.add(1); // Autoboxing: int 1 -> Integer(1)
int first = list.get(0); // Unboxing: Integer(1) -> int 1
ArrayList
这种只能储存对象不能直接储存基本数据类型用这种方法传入
- 方法调用:类型自动转换
// 方法期望 int,传入 Integer -> 自动拆箱
public static int sum(int a, int b) { return a + b; }
Integer x = 1, y = 2;
int result = sum(x, y);
// 方法期望 Double,传入 double -> 自动装箱
public static Double max(Double a, Double b) { return a > b ? a : b; }
Double maxVal = max(1.0, 2.0);
- 运算:自动拆箱成基本类型再计算
Problems
- reference
自动装箱/拆箱创建的是不同的对象(或从缓存获取)。它不能用来像对象引用那样共享对原始基本类型值的修改。
Integer a = 1;
Integer b = a; // b 指向和 a 相同的 Integer 对象 (对于小数值,通常是缓存的)
a += 2; // 实际是: a = Integer.valueOf(a.intValue() + 2); a 指向了新的 Integer(3) 对象
System.out.println(a); // 3
System.out.println(b); // 1 (b 仍然指向原来的 Integer(1) 对象)
- performance
设计到对象的创建和转换,相比直接使用基本数据类型会有一定数据开销:时间与空间
NullPointerException
:
如果一个包装类变量的值是 null
,在自动拆箱时会抛出 NullPointerException
。
使用包装类/自动装箱
- 当需要将基本类型值存入只能存储对象的集合类时(如
ArrayList<Integer>
)。 - 当需要使用基本类型值作为泛型类型参数时。
- 当需要调用需要对象参数的方法时。
- 当需要使用包装类提供的静态工具方法或常量时。
Wrapper Class Utilities
包装类提供了静态方法与常量
数值类型 (Integer
, Long
, Double
, Float
等):
- 字符串转换:
Integer.parseInt("123")
,Double.parseDouble("3.14")
(注意可能抛出NumberFormatException
)。 - 数值范围:
Integer.MAX_VALUE
,Integer.MIN_VALUE
,Double.POSITIVE_INFINITY
,Double.NaN
等。 - 进制转换/位运算:
Integer.toBinaryString(10)
,Integer.highestOneBit(10)
等。
Character
:
- 字符判断:
Character.isDigit('1')
,Character.isLetter('a')
,Character.isUpperCase('A')
,Character.isWhitespace(' ')
等。 - 大小写转换:
Character.toUpperCase('a')
,Character.toLowerCase('Z')
。
Generics
想象一下,我们想创建一个栈(Stack)数据结构。如果我们为 String
类型写一个 StringStack
,那么当我们想存 int
或者其他类型时,就需要重写整个类 。这非常低效。
-
一个方法是使用
Object
类来创建ObjectStack
结合 Autoboxing 以及 Unboxing 特性,甚至可以储存原始类型,但是取出数据的时候需要强制类型转换:int three = (Integer) ss.pop();
-
如果在
pop
时转换的类型与存入时的类型不匹配,程序不会在编译时报错,而是在运行时抛出ClassCastException
错误 。这相当于丢失了 Java 强大的类型检查功能 。
而泛型可以更好地解决这个问题:在类名后面加上 <T>
这里的 T 就是类型参数 Type Parameter,他是一个占位符代表某种具体的类型,因此可以用任何合法标识代替 T
public class GenericStack<T> {
public GenericStack(int capacity) { ... }
public void push(T s) { ... } // 方法参数使用类型参数 T
public T pop() { ... } // 返回值使用类型参数 T
}
那么在实例化的时候需要为其指定一个具体的类型参数如 Integer 或 String
GenericStack<Integer> intStack = new GenericStack<Integer>(5); // T 被指定为 Integer
intStack.push(3); // OK
// intStack.push("Hello"); // 编译时错误!类型不匹配
int value = intStack.pop(); // 不需要强制类型转换
- 使用 generics 在编译时会检查类型是否匹配,避免了运行时的
ClassCastException
- 只需写一次
GenericStack<T>
就可以用于任何非原始类型 - 无需强制转换
类型参数必须是类类型,不能是原始类型(如 int)。但由于自动装箱/拆箱,使用原始类型通常没有障碍 。
Multiple Generics Parameters
public class GenericPair<T, V> {
public T first;
public V second;
public GenericPair(T first, V second) {
this.first = first;
this.second = second;
}
}
泛型类可以有多个类型参数,用逗号分隔。
// 创建一个存储 <Integer, String> 对的 ArrayList
var studentList = new ArrayList<GenericPair<Integer, String>>();
studentList.add(new GenericPair<Integer, String>(10243549, "Alan Turing"));
第二行出现了 var
关键词,是 Java 10 引入的一个关键字
- 用于局部变量类型推断 Local Variable Type Inference
// 原本需要这样写,类型名称很长:
ArrayList<GenericPair<Integer, String>> studentList = new ArrayList<GenericPair<Integer, String>>();
用 var
替代后编译器可以自动从右边推断出左边减少冗余,只能用于局部变量
Bounded Type Parameters
有时我们希望限制可以用作类型参数的类型范围。使用 extends
关键字,例如 <T extends SuperClass>
。这表示 T
必须是 SuperClass
本身或者是它的子类 。SuperClass
就是这个类型参数的上界 (Upper Bound) 。
abstract class Bird { }
class Emu extends Bird { }
class Hawk extends Bird { }
class BirdPair<T extends Bird> { // T 必须是 Bird 或其子类
public T first;
public T second;
public BirdPair (T first, T second) { ... }
}
var emuPair = new BirdPair<Emu>(new Emu(), new Emu()); // OK
var hawkPair = new BirdPair<Hawk>(new Hawk(), new Hawk()); // OK
var birdPair = new BirdPair<Bird>(new Emu(), new Hawk()); // OK
// var badPair = new BirdPair<String>("Hello", "World"); // 编译时错误!String 不是 Bird 的子类
<T>
实际上等价于 <T extends Object>
,因为所有类都继承自 Object
Generic Interfaces
接口也可以是泛型的,拥有类型参数,类型参数可以用在接口的方法签名中。Comparable<T>
就是一个泛型接口,它要求实现 compareTo(To)
方法,用于比较当前对象和另一个 T
类型的对象
With bounded type parameters
public class RunningMaximum<T extends Comparable<T>> { // T 必须实现 Comparable<T>
private T currentMax;
public RunningMaximum (T initial) { currentMax = initial; }
public void addNumber (T number) {
if (number.compareTo(currentMax) > 0) { // 调用 compareTo 方法
currentMax = number;
}
}
public T getCurrentMax() { return currentMax; }
}
- 可以将泛型接口用作有界类型参数的边界。
- 可以用于任何实现了
Comparable
的类型,如Integer
,String
等
RunningMaximum<Integer> runningMax = new RunningMaximum<>(1); // 使用 Integer
runningMax.addNumber(5);
System.out.println(runningMax.getCurrentMax()); // 输出 5
实现过程中第一行在 Java 7
之前你必须这样写:
RunningMaximum<Integer> runningMax = new RunningMaximum<Integer>(1);
// ^^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
// (类型声明) (构造函数调用时指定类型)
Java 7
之后允许直接右侧写 <>
减少冗余从左侧推断出范型参数,结合 Java 10
最终简写成
Generic Methods
方法也可以独立于类定义自己的类型参数
类型参数列表放在修饰符(如 public static
)之后,返回类型之前。
public class GenericMax {
// 定义了一个类型参数 T,它必须实现 Comparable<T>
public static <T extends Comparable<T>> T max(T a, T b) {
if (a.compareTo(b) > 0) {
return a;
} else {
return b;
}
}
}
T
的具体含义是跟对象绑定的,而静态方法属于类,不属于任何特定的对象。因为静态方法不与任何特定对象关联,所以它无法知道当前上下文中 T
应该代表哪个具体类型。因此当你需要在静态方法中使用泛型时,你不能使用类定义的类型参数(如 T
)。相反,你需要定义一个泛型静态方法,它有自己独立的类型参数。
Type Erasure
Java 中的泛型主要是编译时的特性。在编译代码时,编译器会检查泛型的类型安全性,编译完成后,所有的泛型类型信息都会被擦除 (erased) 。类型参数(如 T
)在字节码中会被替换成它们的边界。编译器会在需要的地方自动插入强制类型转换,以保证运行时逻辑的正确性 。类型擦除是 Java 泛型实现的一种方式,但它会导致一些限制和“陷阱” (sharp edges)。
-
不能创建类型参数的实例 (如
new T()
-
不能创建泛型数组 (如
new T[100]
) -
不能对带有参数化类型的对象使用
instanceof
检查 (如obj instanceof ArrayList<String>
) -
擦除后方法签名相同会造成歧义
Method Signature:在 Java 中,一个方法的签名主要由它的方法名和参数类型列表组成。
public class MyGenericClass<T, V> {
public void process(T data) {
System.out.println("Processing T: " + data);
}
public void process(V data) { // 看起来和上面那个不同
System.out.println("Processing V: " + data);
}
}
擦除后都变成了 process(Objec data)
编译阶段就会报错
SOLID
“Clean Code” by Robert Martin
Robert Martin 给 OOP 代码规范提出了原则,主要是为了让代码容易理解、灵活、可重用、易于维护,让你感觉像是个“真正的”工程师
Single Responsibility Principle - SRP
- 一个类应该只有一个引起它变化的原因 。这意味着一个类应该只承担一项职责,或者说只做一件事情 。 这使得代码更容易测试和维护 。如果一个类只做一件事,那么思考如何使用和测试它会更容易 。
例如,将文件读取、写入和压缩的功能分散到FileReader
、FileWriter
和FileCompressor
三个不同的类中,而不是放在一个FileManager
类里,就是遵循SRP的例子 。同样,将迷宫数据的管理(Grid
类)和路径查找(MazeSolver
类)分开也是SRP的应用 。
Open-Closed Principle - OCP
软件实体(如类、模块、函数等)应该对扩展开放,对修改关闭 。这意味着我们应该能够通过添加新代码(如新类或新方法)来增加新功能,而不是修改现有代码 。
- 因此鼓励使用接口和抽象类,降低在添加新功能时引入错误的风险 。
通过计算图形面积的例子说明,使用 Shape
接口,并让 Circle
和 Square
类实现该接口来计算各自的面积,比在一个 AreaCalculator
类中使用 if-else
判断形状类型来计算面积要好 。添加新形状(如三角形)时,只需创建一个新的实现 Shape
接口的类,而无需修改现有代码 。
public enum ShapeType {
CIRCLE, SQUARE
}
public class Shape {
public ShapeType type;
public double radius; // for Circle
public double side; // for Square
}
public class AreaCalculator {
public double area(Shape shape) {
if (shape.type == ShapeType.CIRCLE) {
return Math.PI * shape.radius * shape.radius;
} else if (shape.type == ShapeType.SQUARE) {
return shape.side * shape.side;
}
return 0;
}
}
以上这样的程序一旦出现新的 Shape 就不得不修改 AreaCalculator
类
public interface Shape {
double area();
}
class Circle implements Shape {
private double radius;
public double area() {
return Math.PI * radius * radius;
}
}
class Square implements Shape {
private double side;
public double area() {
return side * side;
}
}
我们应该做的是利用接口制定准则,这一块是 open
的,但 implements
是 close
的
Liskov Substitution Principle - LSP
里氏替换原则:一个子类应该能装作自己是它的父类,而不会弄坏什么东西。
-
这鼓励了正确的继承和多态性。
-
确保新的派生类不会带来意外的行为。
class Bird {
void fly() {
}
}
class Penguin extends Bird {
@Override
void fly() {
throw new UnsupportedOperationException("Penguins can't fly");
}
}
以上这样的程序违反了 LSP
因为直接修改了 fly
的方法,这样如果让企鹅替代鸟类使用就会出现错误,企鹅不会飞
abstract class Bird {
abstract void move();
}
class FlyingBird extends Bird {
@Override
void move() {
fly();
}
void fly() { ... }
}
class NonFlyingBird extends Bird {
@Override
void move() {
walk();
}
void walk() { ... }
}
因此我们使用更抽象思想派生出会飞与不会飞两个抽象类型
以及用 function
互相调用的方式最小化影响
你觉得电动人造鸭看起来像鸭子抽象为鸭,但是前者需要电池,那就是错误抽象
Interface Segregation Principle - ISP
接口隔离原则
- 客户端不应该被强迫依赖它们不使用的方法 。也就是说,使用多个专门的小接口通常比使用一个庞大臃肿的接口要好,这样可以提高代码的可维护性和可读性
public class GameEntity {
public Model3D getModel() { ... }
public Point3D getPosition() { ... }
public int getHP() { ... }
public int getAttack() { ... }
}
class Renderer {
public void render(GameEntity entity) {
// We don't care about getHP() and getAttack()
}
}
比如上述代码中,游戏开发商继承游戏实体后需要开发一个渲染类,这个渲染类不需要血量和攻击力参数,就造成了内存浪费,所以从设计之初就要考虑到使用的小抽象接口:
interface Renderable {
Model3D getModel();
Point3D getPosition();
}
interface Fighter {
int getHP();
int getAttack();
}
public class GameEntity implements Renderable, Fighter {
public Model3D getModel() { ... }
public Point3D getPosition() { ... }
public int getHP() { ... }
public int getAttack() { ... }
}
class Renderer {
public void render(Renderable entity) {
// Much better
}
}
用接口实现可渲染、有战士这两个准则
这样 Renderer
类就可以只依赖于 Renderable
接口
Dependency Inversion Principle - DIP
依赖倒置原则
- 高层模块不应该依赖于低层模块,两者都应该依赖于抽象 。抽象不应该依赖于细节,细节应该依赖于抽象 。简单来说,要面向接口编程,而不是面向实现编程 。
这样使代码更加灵活。如果改变了一个具体类的实现,不需要检查所有依赖它的类 。
class EmailService {
void sendEmail(String message, String recipient) { ... }
}
class Notification {
EmailService emailService;
void sendNotification(String message, String recipient) {
emailService.sendEmail(message, recipient);
}
}
可以看到 Notification
直接依赖于 EmailService
而不是接口,正确实现应该是
interface MessageService {
void sendMessage(String message, String recipient);
}
class EmailService implements MessageService {
@Override
public void sendMessage(String message, String recipient) {
// Not real code
}
}
public class Notification {
MessageService messageService;
void sendNotification(String message, String recipient) {
// Now we can change the message service whenever we want
// Depending on an interface is better than depending on a class
messageService.sendMessage(message, recipient);
}
}
将来如果需要短信通知服务再实现一个 MessageService
就行,其他类不需要被更改