Effective Java解读
Effective Java
第一章 引言
建议配合书本一起看
第二章 创建和销毁对象
本章的主题是创建和销毁对象:何时以及如何创建对象,何时以及如何避免创建对象,如何确保它们能够适时地销毁,以及如何管理对象销毁之前必须进行的各种清理动作。
第1条:用静态工厂方法代替构造器
1、静态工厂方法与构造器不同的第一大优势在于,它们有名称
public class Person {
private String name;
private int age;
private Person(String name, int age) {
this.name = name;
this.age = age;
}
// 创建一个Person对象
public static Person create(String name, int age) {
return new Person(name, age);
}
}
// 使用静态工厂方法创建对象
Person person = Person.create("John", 25);
通过使用静态工厂方法,我们可以清晰地知道我们正在创建一个Person对象。
2、静态工厂方法与构造器不同的第二大优势在于,不必在每次调用它们的时候都创建一个新对象
这个就是我们所熟悉的单例模式,可以避免创建不必要的重复对象
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {
// 私有构造器
}
public static Singleton getInstance() {
return INSTANCE;
}
}
// 获取单例对象
Singleton singleton1 = Singleton.getInstance();
Singleton singleton2 = Singleton.getInstance();
System.out.println(singleton1 == singleton2); // true
3、静态工厂方法与构造器不同的第三大优势在于,它们可以返回原返回类型的任何子类型的对象
这样我们在选择返回对象的类时就有了更大的灵活
public interface Shape {
void draw();
}
public class Circle implements Shape {
@Override
public void draw() {
System.out.println("Drawing a circle");
}
public static Circle create() {
return new Circle();
}
}
public class Rectangle implements Shape {
@Override
public void draw() {
System.out.println("Drawing a rectangle");
}
public static Rectangle create() {
return new Rectangle();
}
}
// 使用静态工厂方法创建不同类型的对象
Shape circle = Circle.create();
Shape rectangle = Rectangle.create();
circle.draw(); // Drawing a circle
rectangle.draw(); // Drawing a rectangle
在上面的示例中,Shape接口有两个实现类Circle和Rectangle,它们都提供了一个名为create()的静态工厂方法来创建对象。通过使用静态工厂方法,我们可以根据需要返回不同类型的对象,而不仅仅是返回原返回类型。
通过使用静态工厂方法,我们可以获得更好的可读性、灵活性和控制对象的创建过程。这些优势使得静态工厂方法成为替代构造器的一种常用模式。
4、静态工厂的第四大优势在于,所返回的对象的类可以随着每次调用而发生改变,这取决于静态工厂方法的参数值
这条与第三大优势一样,只是多了参数,根据参数返回不同类型
public interface Animal {
void makeSound();
}
public class Dog implements Animal {
@Override
public void makeSound() {
System.out.println("Woof!");
}
public static Dog create() {
return new Dog();
}
}
public class Cat implements Animal {
@Override
public void makeSound() {
System.out.println("Meow!");
}
public static Cat create() {
return new Cat();
}
}
public class AnimalFactory {
public static Animal createAnimal(String type) {
if (type.equalsIgnoreCase("dog")) {
return Dog.create();
} else if (type.equalsIgnoreCase("cat")) {
return Cat.create();
} else {
throw new IllegalArgumentException("Invalid animal type");
}
}
}
// 使用静态工厂方法创建不同类型的动物对象
Animal dog = AnimalFactory.createAnimal("dog");
Animal cat = AnimalFactory.createAnimal("cat");
dog.makeSound(); // Woof!
cat.makeSound(); // Meow!
5、静态工厂的第五大优势在于,方法的返回的对象所属的类,在编写包含该静态工厂方法的类时可以不存在
这条主要讲述:通过静态工厂方法来创建对象,从而提供更好的封装和控制。如下面创建对象之前可以进行一些验证和处理逻辑。
public class DatabaseConnection {
private String url;
private String username;
private String password;
private DatabaseConnection(String url, String username, String password) {
this.url = url;
this.username = username;
this.password = password;
}
public static DatabaseConnection createConnection(String url, String username, String password) {
// 这里可以进行一些验证和处理逻辑
return new DatabaseConnection(url, username, password);
}
public void connect() {
// 连接数据库的逻辑
System.out.println("Connected to database: " + url);
}
}
// 在其他类中使用静态工厂方法创建数据库连接对象
DatabaseConnection connection = DatabaseConnection.createConnection("jdbc:mysql://localhost:3306/mydb", "root", "password");
connection.connect(); // Connected to database: jdbc:mysql://localhost:3306/mydb
6、静态工厂方法的主要缺点在于,类如果不含有公有的或者受保护的构造器,就不能被子类化
就是不能继承,但是这样也会因祸得福,因为它鼓励程序员使用复合,而不是继承
public class Parent {
private Parent() {
// 私有构造器
}
public static Parent create() {
return new Parent();
}
}
public class Child extends Parent {
// 编译错误:无法从父类继承私有构造器
}
7、静态工厂方法的第二个缺点在于,程序员很难发现它们
在API文档中,它们没有像构造器那样在API文档中明确标识出来,所以较难发现,但是可以通过在类或者接口注释中遵守标准的命名习惯,也可以弥补这一劣势。
第2条:遇到多个构造器参数时要考虑使用构建器
静态工厂和构造器有个共同的局限性:它们都不能很好地扩展到大量的可选参数。
对于多个参数构造类时,通常会使用重叠构造器或者javaBeans模式,但是都有想应得缺点,如下例(为了代码简单,只写三个参数):
重叠构造器:
public class User {
private String id;
private String name;
private Integer age;
// ...其他属性
public User(String id) {
this.id = id;
}
public User(String id, String name) {
this.id = id;
this.name = name;
}
public User(String id, String name, Integer age) {
this.id = id;
this.name = name;
this.age = age;
}
// ...其他参数构造方法
}
重叠构造器的缺点主要有以下几点:
-
参数顺序容易混淆:当有多个构造器时,每个构造器接受的参数组合可能不同,参数的顺序容易混淆,导致使用者难以记住正确的参数顺序。
-
可读性差:重叠构造器的参数列表可能会很长,特别是在参数较多的情况下,代码可读性较差,难以理解和维护。
-
扩展性差:如果需要添加新的参数或者修改参数的默认值,需要修改所有的构造器,并且可能需要修改调用构造器的代码,这样会导致代码的维护成本增加。
-
容易出错:由于参数顺序容易混淆,使用重叠构造器创建对象时容易出错,传入错误的参数导致对象状态不正确。
javaBeans模式:
先调用一个无参构造器来创建对象,然后再调用setter方法来设置每个必要得参数,以及几个可选参数。
User user = new User();
user.setId("11");
user.setName("22");
这种模式弥补了重叠构造器得不足,创建实例很容易,代码也易读。遗憾得是,
javaBeans模式自身有着很严重得缺点:
-
对象可能处于不一致的状态:在使用JavaBeans模式创建对象时,对象可能处于不一致的状态。因为对象的构造过程被分成了多个步骤,每个步骤只设置了部分属性,可能会导致对象在某些属性上缺失值或者属性之间存在依赖关系,从而导致对象的不一致状态。
-
对象的可变性:使用JavaBeans模式创建的对象是可变的,即可以通过调用setter方法来修改对象的属性。这种可变性可能会导致对象在多线程环境下出现并发问题,需要额外的同步措施来保证对象的线程安全性。
-
缺乏强制性:JavaBeans模式没有强制要求在构造过程中设置必要的属性,因此可能会导致对象在创建后缺少必要的属性,从而导致对象无法正常使用。
-
不可变性的缺失:使用JavaBeans模式创建的对象是可变的,无法保证对象的不可变性。不可变对象具有线程安全性、安全共享和更好的可靠性等优点,但是JavaBeans模式无法提供这些优点。
所以这条建议的主要目的是解决在构造器参数较多时,使用构造器创建对象可能会导致参数列表冗长、难以理解和容易出错的问题。使用构建器可以提供更好的可读性和易用性。
构建器:
采用了建造者模式
public class Person {
private final String name;
private final int age;
private final String address;
// 其他属性...
private Person(Builder builder) {
this.name = builder.name;
this.age = builder.age;
this.address = builder.address;
// 其他属性...
}
public static class Builder {
private final String name;
private int age;
private String address;
// 其他属性...
public Builder(String name) {
this.name = name;
}
public Builder age(int age) {
this.age = age;
return this;
}
public Builder address(String address) {
this.address = address;
return this;
}
// 其他方法...
public Person build() {
return new Person(this);
}
}
}
// 使用构建器创建Person对象
Person person = new Person.Builder("John")
.age(30)
.address("123 Main St")
// 设置其他属性...
.build();
构建器提供了一系列方法来设置对象的属性,每个方法都返回构建器本身,以实现链式调用。最后,通过调用build()方法来创建一个完整的Person对象。
第3条:用私有构造器或者枚举类型强化Singletion属性
在这条建议中主要讲了两种实现Singleton(单例)模式的方法:使用私有构造器和使用枚举类型
来避免对象的重复创建,保证类的唯一性
私有构造器
饿汉模式
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {
// 私有构造器
}
public static Singleton getInstance() {
return INSTANCE;
}
}
懒汉模式
public class Singleton {
private static Singleton instance;
private Singleton() {
// 私有构造器
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
上方饿汉模式如果没有使用,也已经建好了,就造成资源
懒汉模式容易起到了Lazy Loading 的效果,但是多线程下,容易出现线程安全问题
并且上面两种方式都可以通过反射机制调用私有构造方法,造成重复创建,枚举模式可以防止这种行为
枚举
public enum Singleton {
INSTANCE;
// 枚举类型的字段和方法
}
第4条:通过私有构造器强化不可实例化的能力
这种技术通常被用于工具类或者包含静态方法和静态字段的类,以防止被实例化。
public class UtilityClass {
// 私有构造器,防止类被实例化
private UtilityClass() {
throw new AssertionError("Utility class cannot be instantiated");
}
// 静态方法
public static void doSomething() {
// 执行某些操作
}
// 静态字段
public static final int MAX_VALUE = 100;
}
在上面的示例中,UtilityClass 是一个工具类,它包含了一个私有构造器和一些静态方法和静态字段。私有构造器通过抛出 AssertionError 异常来防止类被实例化。这样,其他类就无法通过 new UtilityClass() 来创建 UtilityClass 的实例。
通过这种方式,我们可以确保 UtilityClass 只能被用作静态方法和静态字段的容器,而不能被实例化,也提供了更好的代码可读性和维护性。如果允许实例化,就违反了设计意图,可能导致误用和滥用。
第5条:优先考虑依赖注入来引用资源
依赖注入是一种设计模式,通过将依赖关系从代码中移除,使得代码更加灵活、可测试和可维护。
这个是策略模式,如果一个接口,可能会有多个实现方式的话特别适合,例如一个查询接口,传不同参数,查询的逻辑都不同,且较多种的话,会一直if else,使用这个方式,可以减少if else
public class UserService {
private UserRepository userRepository;
// 通过构造器注入依赖
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
public void addUser(User user) {
userRepository.save(user);
}
}
public interface UserRepository {
void save(User user);
}
public class UserRepositoryImpl1 implements UserRepository {
public void save(User user) {
// 第一个实现类,处理逻辑
}
}
public class UserRepositoryImpl2 implements UserRepository {
public void save(User user) {
// 第二个实现类,处理逻辑
}
}
public class Main {
public static void main(String[] args) {
UserRepository userRepository = new UserRepositoryImpl1();
UserService userService = new UserService(userRepository);
User user = new User("John", "Doe");
userService.addUser(user);
}
}
在上面的示例中,UserService 类依赖于 UserRepository 接口来保存用户信息。通过构造器注入的方式,将 UserRepository 的实现类 UserRepositoryImpl 传递给 UserService,从而实现了依赖注入。
使用依赖注入的好处是,UserService 类不需要关心具体的 UserRepository 实现类,只需要依赖于 UserRepository 接口。这样可以使得代码更加灵活,可以轻松地替换不同的 UserRepository 实现类,例如使用不同的数据库或者模拟数据源进行测试。
第6条:避免创建不必要的对象
创建对象是有开销的,包括内存分配、初始化和垃圾回收等。因此,如果可以避免创建不必要的对象,可以提高性能和减少内存消耗。
示例1:拼接字符串
// 不推荐的写法
String result = "";
for (int i = 0; i < 10; i++) {
result += i;
}
// 推荐的写法
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10; i++) {
sb.append(i);
}
String result = sb.toString();
不推荐的写法使用了字符串拼接操作符 + 来拼接字符串,每次拼接都会创建一个新的字符串对象。而推荐的写法使用了 StringBuilder 类来进行字符串拼接,避免了不必要的字符串对象的创建。
示例2:自动装箱和拆箱
// 不推荐的写法
Integer sum = 0;
for (int i = 0; i < 10; i++) {
sum += i;
}
// 推荐的写法
int sum = 0;
for (int i = 0; i < 10; i++) {
sum += i;
}
不推荐的写法使用了自动装箱和拆箱操作,将 int 类型的变量转换为 Integer 类型的对象进行计算。每次装箱和拆箱都会创建新的对象,导致不必要的对象创建。而推荐的写法直接使用 int 类型的变量进行计算,避免了不必要的对象创建。
示例3:使用静态工厂方法:
包括一些我们的实体类也是
// 不推荐的写法
Date now = new Date();
// 推荐的写法
Date now = DateUtils.getCurrentDate();
不推荐的写法直接使用 new 关键字创建 Date 对象,每次调用都会创建一个新的对象。而推荐的写法使用了静态工厂方法 getCurrentDate() 来获取当前日期,可以复用已经创建的对象,避免了不必要的对象创建。
第7条:消除过期的对象引用
过期的对象引用是指已经不再需要的对象引用,但仍然被保留在内存中,导致内存泄漏和资源浪费。
public class Cache {
private Map<String, Object> cache = new HashMap<>();
public void addToCache(String key, Object value) {
cache.put(key, value);
}
public Object getFromCache(String key) {
return cache.get(key);
}
public void removeFromCache(String key) {
cache.remove(key);
}
}
Cache 类实现了一个简单的缓存功能,使用 HashMap 来存储缓存的对象。然而,如果没有及时从缓存中移除不再需要的对象引用,就会导致内存泄漏。
下面是一个使用示例:
Cache cache = new Cache();
cache.addToCache("key1", new Object());
cache.addToCache("key2", new Object());
// 从缓存中获取对象
Object obj1 = cache.getFromCache("key1");
Object obj2 = cache.getFromCache("key2");
// 从缓存中移除对象
cache.removeFromCache("key1");
// obj1 引用的对象已经不再需要,但仍然被保留在缓存中
通过 addToCache() 方法将两个对象添加到缓存中,然后通过 getFromCache() 方法从缓存中获取对象。然而,当调用 removeFromCache() 方法从缓存中移除一个对象时,该对象的引用仍然被 obj1 保留,导致内存泄漏。
为了消除过期的对象引用,可以在不再需要对象时及时从缓存中移除。修改示例代码如下:
public void removeFromCache(String key) {
cache.remove(key);
System.gc(); // 显式调用垃圾回收器
}
修改了 removeFromCache() 方法,在移除对象引用后,显式调用了垃圾回收器。这样可以加速垃圾回收的过程,及时释放不再需要的对象。总之,消除过期的对象引用是为了避免内存泄漏和资源浪费。
第8条:避免使用终结方法和清除方法
避免使用终结方法和清除方法,因为它们存在一些问题和风险。以下是一些例子来说明为什么要避免使用终结方法和清除方法:
-
不可靠的执行时机:终结方法的执行时机是由Java虚拟机(JVM)决定的,而不是由程序员控制。这意味着无法确定终结方法何时会被调用,甚至可能永远不会被调用。这会导致资源无法及时释放,可能会影响程序的性能和可靠性。
-
性能影响:终结方法的执行是由JVM的垃圾回收器负责的,而垃圾回收器的执行是一个相对昂贵的操作。如果一个类中定义了终结方法,那么每次垃圾回收时都需要执行终结方法,这会导致额外的性能开销。
-
安全问题:终结方法的执行时机是不确定的,这可能导致一些安全问题。例如,如果一个对象中的终结方法打开了一个文件或网络连接,但终结方法没有被及时调用,那么这些资源可能会一直保持打开状态,从而导致资源泄漏或安全漏洞。
-
替代方案:Java提供了其他更可靠和更灵活的资源管理机制,如try-with-resources语句和使用finally块来确保资源的释放。这些机制可以在资源使用完毕后立即释放资源,而不依赖于终结方法的执行。
第9条:try-with-resources优先于try-finally
建议在处理需要关闭的资源时,优先使用try-with-resources语句,而不是传统的try-finally块。这是因为try-with-resources语句提供了更简洁、更安全、更易读的资源管理方式。
下面是一个使用try-with-resources语句的示例代码:
public void processFile(String filePath) {
try (FileReader reader = new FileReader(filePath);
BufferedReader bufferedReader = new BufferedReader(reader)) {
String line;
while ((line = bufferedReader.readLine()) != null) {
// 处理文件内容
}
} catch (IOException e) {
// 处理异常
}
}
-
简洁性:使用try-with-resources语句可以将资源的创建和关闭操作放在同一个代码块中,使代码更加简洁和易于理解。传统的try-finally块需要在try块中创建资源,在finally块中关闭资源,导致代码分散在不同的块中,可读性较差。
-
安全性:try-with-resources语句可以确保资源在使用完毕后被正确关闭,即使在处理过程中发生异常。它会自动调用资源的close()方法来释放资源,无需手动编写关闭代码。而传统的try-finally块需要手动编写关闭代码,容易出现遗漏或错误的关闭操作。
-
支持多个资源:try-with-resources语句可以同时管理多个资源,只需在try语句的括号中以分号分隔多个资源的创建语句。这样可以更方便地管理多个相关资源的关闭操作,避免了嵌套的try-finally块。
反例:
public void copy(String src,String dst) throws IOException{
InputStream in = new FileInputStream(src);
try{
OutputStream out = new FileOutputStream(dst);
try{
byte[] buf = new byte[BUFFER_SIZE];
int n;
while((n =in.read(buf)) >= 0)
out.write(buf,0,n);
} finally {
out.close();
}
} finally {
in.close();
}
}
第三章 对于所有对象都通用的方法
第10条:覆盖equals时请遵守通用约定
在覆盖equals方法时应该遵守的通用约定。equals方法用于比较两个对象是否相等,而通用约定规定了equals方法应该满足的特定条件。建议不要覆盖equals方法,自己写equals相关判断,除非迫不得已。
通用约定要求equals方法具有以下特性:
-
自反性(Reflexive):对于任何非null的引用值x,x.equals(x)应该返回true。
-
对称性(Symmetric):对于任何非null的引用值x和y,如果x.equals(y)返回true,那么y.equals(x)也应该返回true。
-
传递性(Transitive):对于任何非null的引用值x、y和z,如果x.equals(y)返回true,y.equals(z)返回true,那么x.equals(z)也应该返回true。
-
一致性(Consistent):对于任何非null的引用值x和y,如果对象中的信息没有被修改,那么多次调用x.equals(y)应该始终返回相同的结果。
-
非空性(Non-nullity):对于任何非null的引用值x,x.equals(null)应该返回false。
下面是一个符合通用约定的equals方法的示例代码:
public class Person {
private String name;
private int age;
// 构造方法、getter和setter等省略
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null || getClass() != obj.getClass()) {
return false;
}
Person person = (Person) obj;
return age == person.age && Objects.equals(name, person.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
在上面的代码中,我们重写了equals方法来比较Person对象的相等性。首先,我们检查两个对象是否引用同一个对象,如果是,则返回true。然后,我们检查传入的对象是否为null或者是否属于不同的类,如果是,则返回false。最后,我们比较两个对象的属性值是否相等,使用Objects.equals方法来比较字符串属性name,使用==运算符来比较基本类型属性age。同时,我们还重写了hashCode方法来保证相等的对象具有相同的哈希码。
反例:
public class Person {
private String name;
private int age;
// 构造方法、getter和setter等省略
@Override
public boolean equals(Object obj) {
if (this == obj) {
return true;
}
if (obj == null) {
return false;
}
if (getClass() != obj.getClass()) {
return false;
}
Person person = (Person) obj;
return age == person.age && Objects.equals(name, person.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
在上面的反例中,我们在equals方法中没有正确处理传入对象为null的情况,而是直接返回false。这违反了通用约定中的非空性要求。如果传入对象为null,应该返回false而不是抛出NullPointerException。
第11条:覆盖 equals 时总要覆盖 hashCode
建议在覆盖equals方法时,总是要同时覆盖hashCode方法。这是因为在使用哈希表(如HashMap、HashSet等)存储对象时,hashCode方法的正确实现是非常重要的。
-
equals和hashCode的关系:根据Java规范,如果两个对象通过equals方法比较是相等的,那么它们的hashCode方法应该返回相同的值。换句话说,如果两个对象相等,它们的哈希码应该相等。
-
hashCode的作用:哈希码是用来确定对象在哈希表中的存储位置的。当我们向哈希表中插入一个对象时,首先会计算该对象的哈希码,然后根据哈希码找到对应的存储位置。如果两个对象的哈希码不同,那么它们会被存储在不同的位置,即使它们通过equals方法比较是相等的。
-
覆盖hashCode方法的规则:为了保证对象在哈希表中的存储和查找的正确性,我们需要确保以下规则:
如果两个对象通过equals方法比较是相等的,那么它们的hashCode方法应该返回相同的值。
如果两个对象通过equals方法比较是不相等的,那么它们的hashCode方法返回的值可以相同,也可以不同。
示例代码与上方一致。
覆盖了equals方法来比较Person对象的name和age属性是否相等。同时,我们也覆盖了hashCode方法,使用Objects.hash方法来计算哈希码。
反例:如果我们只覆盖了equals方法而没有覆盖hashCode方法,那么在使用哈希表存储对象时会出现问题。例如:
Person person1 = new Person("Alice", 25);
Person person2 = new Person("Alice", 25);
Set<Person> set = new HashSet<>();
set.add(person1);
set.add(person2);
System.out.println(set.size()); // 输出2,因为person1和person2被认为是不同的对象
尽管person1和person2通过equals方法比较是相等的,但由于没有正确实现hashCode方法,它们被认为是不同的对象,导致它们都被添加到了HashSet中。
第12条:始终要覆盖 toString
建议始终要覆盖toString方法。toString方法用于返回对象的字符串表示,它对于调试和日志记录非常有用。
-
toString方法的作用:toString方法用于返回对象的字符串表示。它通常用于调试和日志记录,可以方便地查看对象的内容。
-
覆盖toString方法的规则:为了提供有用的字符串表示,我们需要确保以下规则:
返回的字符串应该包含对象的重要信息,如属性值等。
返回的字符串应该是简洁、易读的,方便人们阅读和理解。
示例代码:
public class Person {
private String name;
private int age;
// 构造方法、getter和setter省略
@Override
public String toString() {
return "Person{" +
"name='" + name + '\'' +
", age=" + age +
'}';
}
}
覆盖toString方法可以提供更有用的字符串表示,方便调试和日志记录。例如:
Person person = new Person("Alice", 25);
System.out.println(person); // 输出:Person{name='Alice', age=25}
反例:如果我们没有覆盖toString方法,那么默认的toString方法会返回对象的类名和哈希码。例如:
Person person = new Person("Alice", 25);
System.out.println(person); // 输出:Person@1f32e575
第13条:谨慎地覆盖clone
谨慎地覆盖clone方法。clone方法用于创建对象的副本,但它存在一些问题和潜在的风险。
-
clone方法的作用:clone方法用于创建对象的副本。它可以用于创建一个与原始对象相同的新对象,但是它并不是一个安全和通用的方法。
-
clone方法的问题和风险:
-
clone方法是Object类中的一个受保护的方法,它需要在子类中进行覆盖才能使用。但是,clone方法的设计存在一些问题,它违反了面向对象的封装原则,需要直接访问对象的内部状态。
-
clone方法返回的是一个浅拷贝,即只复制了对象的引用,而不是创建了一个全新的对象。这意味着如果原始对象中包含了可变的引用类型属性,那么克隆对象和原始对象之间的修改会相互影响。
-
clone方法的使用需要非常小心,因为它容易引发错误和混乱。如果不正确地使用clone方法,可能会导致对象状态的不一致和意外的行为。
-
示例:
public class Person implements Cloneable {
private String name;
private int age;
// 构造方法、getter和setter省略
@Override
public Person clone() {
try {
return (Person) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
反例:如果我们不小心地使用clone方法,可能会导致意外的行为。例如,如果对象中包含了可变的引用类型属性,那么克隆对象和原始对象之间的修改会相互影响。示例如下:
public class Person implements Cloneable {
private String name;
private List<String> hobbies;
// 构造方法、getter和setter省略
@Override
public Person clone() {
try {
return (Person) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
Person person1 = new Person();
person1.setName("Alice");
person1.setHobbies(new ArrayList<>(Arrays.asList("reading", "swimming")));
Person person2 = person1.clone();
person2.getHobbies().add("hiking");
System.out.println(person1.getHobbies()); // 输出:[reading, swimming, hiking]
System.out.println(person2.getHobbies()); // 输出:[reading, swimming, hiking]
在上面的例子中,我们克隆了person1对象得到了person2对象。然后,我们向person2对象的hobbies列表中添加了一个新的爱好。结果,person1对象的hobbies列表也被修改了,这是因为克隆对象和原始对象共享了同一个引用类型属性。
因此,为了避免clone方法的问题和风险,我们应该谨慎地使用它,或者考虑使用其他方式来创建对象的副本,如拷贝构造函数或工厂方法。
1、拷贝构造函数:
拷贝构造函数是一个特殊的构造函数,它接受一个相同类型的对象作为参数,并使用该对象的属性值来初始化新对象。通过拷贝构造函数,我们可以创建一个与原始对象相同的新对象。
public class Person {
private String name;
private int age;
// 拷贝构造函数
public Person(Person other) {
this.name = other.name;
this.age = other.age;
}
// 构造方法、getter和setter省略
}
Person person1 = new Person("Alice", 25);
Person person2 = new Person(person1); // 使用拷贝构造函数创建副本
System.out.println(person1.getName()); // 输出:Alice
System.out.println(person2.getName()); // 输出:Alice
2、工厂方法:
工厂方法是一个静态方法,它返回一个新的对象作为副本。通过工厂方法,我们可以更加灵活地创建对象的副本,可以在方法中进行必要的处理和验证。
public class Person {
private String name;
private int age;
// 构造方法、getter和setter省略
// 工厂方法
public static Person createCopy(Person other) {
Person copy = new Person();
copy.setName(other.getName());
copy.setAge(other.getAge());
return copy;
}
}
Person person1 = new Person("Alice", 25);
Person person2 = Person.createCopy(person1); // 使用工厂方法创建副本
System.out.println(person1.getName()); // 输出:Alice
System.out.println(person2.getName()); // 输出:Alice
第14条:考虑实现Comparable接口
Comparable接口是Java中的一个泛型接口,用于定义对象之间的自然排序顺序。实现Comparable接口的主要目的是为了使对象可以进行比较和排序。通过实现Comparable接口,我们可以定义对象之间的比较规则,并使用排序算法对对象进行排序。
示例:
public class Person implements Comparable<Person> {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
// 实现compareTo方法,定义对象之间的比较规则
@Override
public int compareTo(Person other) {
// 按照年龄进行比较
return Integer.compare(this.age, other.age);
}
// 其他代码和方法省略...
}
通过实现Comparable接口,我们可以使用排序算法对Person对象进行排序,例如使用Collections.sort方法:
List<Person> personList = new ArrayList<>();
personList.add(new Person("Alice", 25));
personList.add(new Person("Bob", 30));
personList.add(new Person("Charlie", 20));
Collections.sort(personList);
for (Person person : personList) {
System.out.println(person.getName() + " - " + person.getAge());
}
输出结果将按照年龄从小到大的顺序进行排序。
建议:每当实现一个对排序敏感的类时,都应该让这个类实现Comparable接口,以便其实例可以轻松地被分类、搜索,以及用在基于比较的集合种。每当在compareTo方法的实现中比较域值时,都要避免使用 < 和 > 操作符,而应该在装修基本类型中类中使用静态的compare方法,或者在Comparator接口中使用比较器构造方法。
第四章 类和接口
类和接口是Java编程语言的核心,它们也是Java语言的基本抽象单元。Java语言提供了许多强大的基本元素,供程序员用来设计类和接口。本章阐述的一些指导原则,可以帮助你更好地利用这些元素,设计出更加有用、健壮和灵活的类和接口。
第15条:使类和成员的可访问性最小化
这个原则是指在设计类和成员时,应该尽量将其访问权限限制在最小范围内,以提高封装性、安全性和可维护性。
具体来说,可以采取以下几个方面的措施来最小化类和成员的可访问性:
-
将类声明为final或abstract:如果一个类不打算被继承,可以将其声明为final,这样其他类就无法继承它。如果一个类只作为父类存在,不打算被直接实例化,可以将其声明为abstract。这样可以限制类的访问范围,避免不必要的继承和实例化。
-
将成员声明为private:将类的成员(字段、方法等)声明为private,只允许类内部访问。这样可以隐藏实现细节,防止外部直接访问和修改类的内部状态。
-
提供有限的访问接口:通过将部分成员声明为public或protected,提供有限的访问接口,使外部只能通过这些接口来访问类的功能。这样可以控制对类的访问权限,避免外部直接访问类的内部实现细节。
-
使用包级私有访问:将类和成员声明为包级私有(即不加访问修饰符),只允许同一个包中的其他类访问。这样可以限制类的访问范围,避免被其他包中的类访问和修改。
示例:
public class MyClass {
private int privateField;
public int publicField;
private void privateMethod() {
// 私有方法的实现
}
public void publicMethod() {
// 公共方法的实现
}
}
在上面的示例中,privateField和privateMethod被声明为private,只能在MyClass类内部访问。publicField和publicMethod被声明为public,可以被其他类直接访问。
通过将成员的访问权限限制在最小范围内,我们可以提高类的封装性,隐藏实现细节,减少对外部的依赖,提高代码的安全性和可维护性。同时,这也是一种良好的设计原则,符合面向对象编程的封装思想。
第16条:要在公有类中使用访问方法而非公有域
这个原则是指在设计公有类时,应该尽量避免直接暴露类的内部数据域,而是通过访问方法来访问和修改数据。
使用访问方法(也称为getter和setter方法)的好处有以下几点:
-
封装性:通过使用访问方法,可以将类的内部数据域隐藏起来,只允许通过方法来访问和修改数据。这样可以提高类的封装性,隐藏实现细节,减少对外部的依赖。
-
可控性:通过访问方法,可以对数据的访问和修改进行控制。可以在方法中添加逻辑判断、数据验证等,确保数据的有效性和一致性。
-
可扩展性:通过使用访问方法,可以在不改变类的接口的前提下,对类的内部实现进行修改。如果直接暴露数据域,一旦需要修改数据的表示方式或实现逻辑,就会破坏类的兼容性。
示例:
public class Person {
private String name;
private int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
if (age < 0) {
throw new IllegalArgumentException("Age cannot be negative");
}
this.age = age;
}
}
在上面的示例中,Person类有两个私有的数据域name和age。通过提供getName和setName方法来访问和修改name,提供getAge和setAge方法来访问和修改age。通过使用访问方法,我们可以对数据的访问和修改进行控制,确保数据的有效性和一致性。
反例:
public class Person {
public String name;
public int age;
}
在上面的反例中,Person类的数据域name和age被声明为公有的,外部可以直接访问和修改这些数据。这样做的问题是,外部可以随意修改数据,没有任何限制和验证。这可能导致数据的不一致和不安全。
第17条:使可变性最小化
这个原则是指在设计类时,应该尽量将类的可变性限制在最小范围内,以提高类的安全性和可维护性。
使用不可变类的好处有以下几点:
-
线程安全:不可变类是线程安全的,因为它们的状态不会发生变化,所以多个线程可以同时访问不可变对象而不需要额外的同步措施。
-
安全性:不可变类不可被修改,这意味着它们的状态是固定的,不会被意外或恶意修改。这可以防止一些潜在的安全问题。
-
可重用性:不可变类可以被自由地共享和重用,因为它们的状态不会发生变化。这可以提高代码的性能和效率。
示例:
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public int getY() {
return y;
}
}
在上面的示例中,Point类是一个不可变类,它有两个私有的final字段x和y,它们在对象创建后就不可被修改。通过提供getX和getY方法来访问这些字段的值。由于Point类是不可变的,所以它是线程安全的,可以被自由地共享和重用。
反例:
public class MutablePoint {
private int x;
private int y;
public MutablePoint(int x, int y) {
this.x = x;
this.y = y;
}
public int getX() {
return x;
}
public void setX(int x) {
this.x = x;
}
public int getY() {
return y;
}
public void setY(int y) {
this.y = y;
}
}
在上面的反例中,MutablePoint类是一个可变类,它的字段x和y可以被外部修改。这样做的问题是,外部可以随意修改对象的状态,可能导致对象的不一致和不安全。
对于某些类而言,其不可变性是不切实际的,如果类不能被做成不可变的,仍然应该尽可能地限制它的可变性。除非有令人信服的理由要使域变成是非final的,否则要使每个域都是private final的。
第18条:复合优先于继承
这个原则强调了在设计类和对象之间的关系时,应该优先选择使用复合(Composition)而不是继承(Inheritance)。
复合是指通过在一个类中包含其他类的实例来构建更复杂的对象。这种方式可以实现代码的重用和灵活性,同时避免了继承可能带来的一些问题。
以下是一些使用复合而不是继承的优点:
-
灵活性:复合允许在运行时动态地改变对象的行为,而不需要在编译时确定。这使得代码更加灵活,可以根据需求进行扩展和修改。
-
代码重用:通过将功能封装在独立的类中,可以在多个类之间共享和重用代码。这样可以减少代码的重复,提高代码的可维护性和可读性。
-
松耦合:使用复合可以实现松耦合的设计,即对象之间的依赖关系更加灵活和可配置。这样可以减少类之间的耦合度,提高代码的可测试性和可扩展性。
示例:
public class Car {
private Engine engine;
private Wheels wheels;
public Car(Engine engine, Wheels wheels) {
this.engine = engine;
this.wheels = wheels;
}
public void start() {
engine.start();
}
public void drive() {
wheels.rotate();
}
}
在上面的示例中,Car类通过包含一个Engine对象和一个Wheels对象来实现其功能。这种复合的方式允许我们在运行时选择不同的引擎和轮子,从而改变汽车的行为。
简而言之,继承的功能非常强大,但是也存在诸多问题,因为它违背了封装原则。只有当子类和超类之间确实存在子类型关系时,使用继承才是恰当的。即便如此,如果子类和超类处在不同的包中,并且超类并不是为了继承而设计的,那么继承将会导致脆弱性。为了避免这种脆弱性,可以用复合和转发机制来代替继承,尤其是当存在适当的接口可以实现包装类的时候。包装类不仅比子类更加健壮,而且功能更加强大。
第19条:要么设计继承并提供文档说明,要么禁止继承
这个原则强调了在设计类时,应该明确地决定是否允许其他类继承该类,并相应地进行设计和文档说明。
以下是一些关于这个原则的要点:
-
明确设计:如果一个类被设计为可继承的,那么它应该提供适当的扩展点和可覆盖的方法。这样可以确保子类可以正确地扩展和定制父类的行为。
-
提供文档说明:如果一个类被设计为可继承的,那么应该在文档中明确说明该类的设计意图、可继承的方法和行为的约束。这样可以帮助其他开发人员正确地使用和扩展该类。
-
禁止继承:如果一个类不适合被继承,那么应该使用final关键字将其声明为最终类,或者将所有的构造函数声明为私有的,以防止其他类继承该类。这样可以避免其他开发人员错误地继承和修改该类。
示例:
public class Shape {
protected int x;
protected int y;
public Shape(int x, int y) {
this.x = x;
this.y = y;
}
public void draw() {
// 绘制形状的逻辑
}
/**
* 计算形状的面积
* @return 面积
*/
public double calculateArea() {
// 计算面积的逻辑
}
}
在上面的示例中,Shape类被设计为可继承的,并提供了文档说明。子类可以继承Shape类,并根据需要扩展和定制其行为。
总而言之,要么设计继承并提供文档说明,要么禁止继承的原则强调了在设计类时应该明确地决定是否允许继承,并相应地进行设计和文档说明。这样可以避免在不适合继承的类上使用继承,从而减少潜在的问题和困惑。
第20条:接口优于抽象类
接口具有一些优势,可以提供更大的灵活性和可复用性。下面是对这条建议的详细说明以及一些例子和反例:
1、接口提供更大的灵活性:
- 接口可以被多个类实现,而类只能继承一个抽象类。这意味着使用接口可以更灵活地组合和扩展功能。
- 接口支持多重继承,一个类可以实现多个接口,但是只能继承一个抽象类。这使得接口可以更好地支持多个功能的组合。
2、接口提供更好的可复用性:
- 接口可以被多个类实现,从而提供更好的可复用性。如果一个类需要实现多个功能,可以通过实现多个接口来实现,而不是继承多个抽象类。
例子:
interface Flyable {
void fly();
}
interface Swimmable {
void swim();
}
class Bird implements Flyable {
public void fly() {
System.out.println("Bird is flying");
}
}
class Fish implements Swimmable {
public void swim() {
System.out.println("Fish is swimming");
}
}
class Duck implements Flyable, Swimmable {
public void fly() {
System.out.println("Duck is flying");
}
public void swim() {
System.out.println("Duck is swimming");
}
}
反例:如果使用抽象类来实现上述功能,就无法同时让Duck类既能飞又能游泳。
总结:接口提供了更大的灵活性、支持多重继承和更好的可复用性。但并不是所有情况下都适合使用接口而不是抽象类。有时候抽象类更适合,例如需要提供默认实现或强制子类实现某些方法的情况。在设计时,需要根据具体的需求和情况来选择使用接口还是抽象类。
第21条:为后代设计接口
这意味着在设计接口时要预测未来可能的变化和扩展,并为后代提供足够的灵活性和可扩展性。
1、考虑后代的需求:
- 在设计接口时,要考虑后代可能需要添加新的方法或功能。接口应该提供足够的灵活性,以便后代可以轻松地扩展接口而不破坏现有的实现类。
- 接口的设计应该是稳定的,避免频繁的修改和变化。这样可以确保后代的实现类不会受到不必要的影响。
2、提供默认实现:
- 在设计接口时,可以提供一些默认的方法实现,以便后代可以选择性地覆盖这些方法。这样可以在不破坏现有实现类的情况下,为后代提供一些通用的功能。
示例:
interface Drawable {
void draw();
default void fill() {
System.out.println("Filling the shape");
}
}
class Rectangle implements Drawable {
public void draw() {
System.out.println("Drawing a rectangle");
}
}
class Circle implements Drawable {
public void draw() {
System.out.println("Drawing a circle");
}
public void fill() {
System.out.println("Filling the circle");
}
}
总结:在设计接口时要考虑后代的需求,提供足够的灵活性和可扩展性。可以通过提供默认实现和稳定的接口设计来实现这一点。但是,也要注意不要过度设计接口,避免频繁的修改和变化。在设计时,需要根据具体的需求和情况来平衡灵活性和稳定性。
第22条:接口只用于定义类型
当类实现接口时,接口就充当可以引用这个类的实例的类型。因此,类实现了接口,就表明客户端可以对这个类的实例实施某些动作。为了任何其他目的而定义接口是不恰当的。
常量接口模式是对接口的不良使用。常量接口模式是指在接口中定义一些常量,并且实现类通过实现该接口来获取这些常量。这种模式的问题在于,它违反了接口只用于定义类型的原则。
常量接口示例:
public final class Constants {
public static final int MAX_LENGTH = 100;
public static final int MIN_LENGTH = 10;
public static final String DEFAULT_NAME = "John Doe";
// 其他常量定义...
}
常量接口模式的问题有以下几点:
- 接口应该用于定义类型,而不是用于定义常量。
常量应该与具体的类或者枚举相关联
,而不是与接口相关联。 - 将常量定义在接口中会导致
实现类必须实现这些常量
,即使它们与实现类的逻辑无关。这增加了实现类的负担,并且可能导致不必要的代码冗余。 - 如果接口中的常量发生变化,所有实现该接口的类都必须重新编译和部署,即使它们与这些常量无关。
使用常量类相比使用常量接口有以下几个好处:
-
避免命名冲突:常量类中的常量是通过类名来访问的,因此可以避免不同模块之间的命名冲突。如果不同的模块定义了相同的常量,编译器会报错,提醒开发人员解决冲突。而常量接口中的常量是通过接口名来访问的,如果不同的模块定义了相同的常量,编译器不会报错,可能会导致混淆和错误的使用。
-
提供类型安全:常量类中的常量是静态的,可以直接通过类名访问,而常量接口中的常量是隐式的公共静态常量,需要通过实现接口的类来访问。使用常量类可以提供类型安全,编译器可以在编译时检查常量的类型和使用方式,减少类型错误的可能性。
-
提高代码的可读性和可维护性:常量类可以将相关的常量集中在一个类中,使得代码更加清晰和易于理解。开发人员可以直接通过常量类来查找和使用常量,而不需要在代码中硬编码常量值。而常量接口中的常量可能分散在不同的实现类中,不易于查找和维护。
-
隐藏实现细节:常量类可以隐藏常量的实现细节,只暴露常量的访问接口。这样可以在不影响使用常量的地方修改常量的实现,而不需要修改调用方的代码。而常量接口将常量的实现细节暴露给了实现类,修改常量的实现可能需要修改调用方的代码。
因此,常量接口模式不推荐使用。相反,应该将常量定义在具体的类或者枚举中,以便与其相关的逻辑和语义。如果需要在多个类中共享常量,可以将它们定义在一个工具类中,或者使用静态导入来直接使用常量。
第23条:类层次优于标签类
标签类是指一个类中包含一个标签字段(tag field),用于标识对象所属的类型,然后根据标签字段的值来执行相应的逻辑。而类层次结构是指通过继承和多态来表示不同类型的对象,每个子类都代表一个具体的类型,而不需要使用标签字段。
标签类代码示例:
根据标签type字段,判断不同类型不同逻辑方法。
public class Shape {
private String type;
private double radius;
private double width;
private double height;
public Shape(String type) {
this.type = type;
}
public void setRadius(double radius) {
this.radius = radius;
}
public void setWidth(double width) {
this.width = width;
}
public void setHeight(double height) {
this.height = height;
}
public double calculateArea() {
if (type.equals("circle")) {
return Math.PI * radius * radius;
} else if (type.equals("rectangle")) {
return width * height;
}
return 0;
}
}
标签类过于冗长,容易出错,并且效率低下。
类层次代码示例:
public abstract class Shape {
public abstract double calculateArea();
}
public class Circle extends Shape {
private double radius;
public Circle(double radius) {
this.radius = radius;
}
@Override
public double calculateArea() {
return Math.PI * radius * radius;
}
}
public class Rectangle extends Shape {
private double width;
private double height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
@Override
public double calculateArea() {
return width * height;
}
}
使用类层次结构的代码更加清晰和易于理解。每个子类都有自己的 calculateArea() 方法来计算面积,不需要使用标签字段来判断对象的类型。当需要添加新的图形类型时,只需要添加一个新的子类即可,不需要修改现有的代码。
使用类层次结构而不是标签类有以下几个优点:
-
可读性更好:使用类层次结构可以更清晰地表达对象的类型和行为。每个子类都有自己的特定行为和属性,代码更易于理解和维护。
-
可扩展性更好:当需要添加新的类型时,只需要添加一个新的子类即可,而不需要修改现有的代码。这符合开闭原则(Open-Closed Principle),即对扩展开放,对修改关闭。
-
类型安全性更高:使用类层次结构可以在编译时进行类型检查,减少类型错误的可能性。而标签类需要在运行时通过标签字段的值来确定对象的类型,容易出现类型错误。
第24条:静态成员类由于非静态成员类
静态成员类是指被声明为静态的内部类,它与外部类之间没有直接的关联,可以独立存在。而非静态成员类是指没有被声明为静态的内部类,它与外部类之间有直接的关联,可以访问外部类的成员。
下面是一个示例,演示了静态成员类和非静态成员类的区别:
public class OuterClass {
private static int outerStaticField;
private int outerNonStaticField;
// 静态成员类
public static class StaticMemberClass {
private int staticMemberField;
public void staticMemberMethod() {
// 可以访问外部类的静态成员
outerStaticField = 10;
// 不能访问外部类的非静态成员
// outerNonStaticField = 20; // 编译错误
}
}
// 非静态成员类
public class NonStaticMemberClass {
private int nonStaticMemberField;
public void nonStaticMemberMethod() {
// 可以访问外部类的静态成员和非静态成员
outerStaticField = 10;
outerNonStaticField = 20;
}
}
}
使用静态成员类而不是非静态成员类有以下几个优点:
-
更好的封装性:静态成员类可以被外部类以外的代码访问,而非静态成员类只能被外部类的实例访问。这样可以更好地控制类的可见性,提高封装性。
-
减少内存占用:非静态成员类会隐式地持有外部类的引用,导致内存占用增加。而静态成员类不会持有外部类的引用,可以减少内存占用。
-
更清晰的代码结构:将静态成员类作为外部类的静态成员,可以更清晰地表示它们之间的关系,提高代码的可读性。
-
独立性和可复用性:静态成员类可以独立存在,不依赖于外部类的实例。这使得静态成员类可以更容易地被其他类使用和复用。静态成员类可以在不同的上下文中使用,而不需要依赖于外部类的实例。
第25条:限制源文件为单个顶级类
每个源文件中只能包含一个顶级类,并且该类的名称必须与文件名相同。
假设我们有两个顶级类:Person和Address。按照建议,我们应该将它们分别放在两个不同的源文件中,person类和address类中,不要两个类放在同一个源文件中:
public class Person {
// 类的定义和实现
}
public class Address {
// 类的定义和实现
}
这个建议的目的是为了提高代码的可读性和可维护性。将每个顶级类放在单独的源文件中可以使代码更加清晰和易于理解。以下是一些详细说明和示例:
-
可读性:将每个顶级类放在单独的源文件中可以使代码结构更加清晰。开发人员可以更容易地找到特定类的定义和实现。这样可以提高代码的可读性,减少阅读和理解代码的难度。
-
维护性:将每个顶级类放在单独的源文件中可以使代码的修改和维护更加方便。当需要修改一个类时,只需要打开对应的源文件,而不需要在一个文件中查找和编辑多个类的定义。这样可以减少出错的可能性,提高代码的可维护性。
第五章 泛型
从Java 5开始,泛型已经成了Java编程语言的一部分。在没有泛型之前,从集合中读取到的每一个对象都必须进行转换。如果有人不小心插入了类型错误的对象,在运行时的转换处理就会出错。有了泛型之后,你可以告诉编译器每个集合中接受哪些对象类型。编译器自动为你的插入进行转换,并在编译时告知是否插入了类型错误的对象。这样可以使程序更加安全、也更加清楚,但是要享有这些有试(不限于集合)有一定的难度。本章就是教你如何最大限度地享有这些优势,又能使整个过程尽可能简单化。
第26条:请不要使用原生态类型
原生态类型是指没有指定类型参数的泛型类或泛型接口的使用方式。
原生态类型示例:
List list = new ArrayList();
list.add("Hello");
list.add(10);
for (Object item : list) {
String str = (String) item; // 运行时会抛出ClassCastException
System.out.println(str);
}
原生态类型问题:
- 缺乏类型安全性:原生态类型会导致编译器无法对类型进行检查,从而可能引发运行时错误。
- 缺乏可读性:使用原生态类型会使代码的意图不明确,降低代码的可读性。泛型的目的是为了在编译时提供类型安全性和更好的代码可读性。使用原生态类型会使代码中的类型信息丢失,使得其他开发人员难以理解代码的意图。
为了避免使用原生态类型,应该始终使用泛型,并为泛型类或泛型接口提供类型参数。例如,对于上述代码,应该使用泛型类型List来明确列表中的元素类型:
List<String> list = new ArrayList<>();
list.add("Hello");
list.add(10); // 编译时会报错
for (String item : list) {
System.out.println(item);
}
第27条:消除非受检的警告
消除非受检的警告的主要目的是确保代码的类型安全性,并减少在运行时可能出现的错误。
List list = new ArrayList();
list.add("Hello");
String str = (String) list.get(0); // 非受检的警告
System.out.println(str);
在这个例子中,我们使用了原生态类型List,并将元素强制转换为String类型。这会导致编译器发出非受检的警告,因为编译器无法确定列表中的元素类型是否为String。为了消除这个警告,我们可以使用泛型类型List来明确列表中的元素类型:
List<String> list = new ArrayList<>();
list.add("Hello");
String str = list.get(0);
System.out.println(str);
使用注解:在某些情况下,可以使用注解来消除非受检的警告。例如,考虑以下代码:
@SuppressWarnings("unchecked")
List list = new ArrayList();
list.add("Hello");
String str = (String) list.get(0);
System.out.println(str);
在这个例子中,我们使用了@SuppressWarnings(“unchecked”)注解来告诉编译器忽略非受检的警告。虽然这种方法可以消除警告,但它并不是最佳实践。应该尽量避免使用注解来消除警告,而是通过改进代码来消除警告。
第28条:列表优于数组
建议在大多数情况下使用列表(List)而不是数组(Array)。列表是一种动态大小的数据结构,可以方便地添加、删除和访问元素,而数组的大小是固定的。
列表的相比于数组的好处:
- 灵活性:列表具有更大的灵活性,可以根据需要动态调整大小。您可以使用列表的方法(如add、remove、set)来添加、删除和修改元素,而数组的大小是固定的,无法直接添加或删除元素。
List<String> list = new ArrayList<>();
list.add("apple");
list.add("banana");
list.remove(0);
- 类型安全性:列表可以使用泛型来指定元素的类型,从而提供类型安全性。这意味着您可以在编译时捕获类型错误,而不是在运行时出现错误。数组没有类型检查,因此可能会导致运行时错误。
List<String> list = new ArrayList<>();
list.add("apple");
list.add(123); // 编译错误,类型不匹配
String[] array = new String[2];
array[0] = "apple";
array[1] = 123; // 运行时错误,类型不匹配
- 功能丰富:列表提供了许多有用的方法和功能,如排序、查找、迭代等。这些方法可以方便地操作和处理列表中的元素。而数组的功能相对较少,需要手动编写代码来实现这些功能。
- 与泛型的兼容性:列表与泛型类型更好地兼容。您可以使用通配符(wildcard)来处理不同类型的列表,而数组只能存储同一类型的元素。
List<?> list = new ArrayList<>();
list.add("apple");
list.add(123);
list.add(true);
List<String> stringList = new ArrayList<>();
stringList.add("apple");
stringList.add("banana");
List<Integer> integerList = new ArrayList<>();
integerList.add(1);
integerList.add(2);
List[] array = new List[2];
array[0] = stringList;
array[1] = integerList;
第29条:优先考虑泛型
强调在编写代码时应优先考虑使用泛型来增加代码的类型安全性和重用性。
使用泛型的好处:
- 类型安全:泛型可以在编译时捕获类型错误,避免在运行时出现类型转换异常。
- 代码重用:泛型可以使代码更通用,可以在不同类型之间进行重用,减少代码的重复编写。
- 可读性和可维护性:泛型可以提供更清晰的代码,使代码更易读和维护。
泛型类:
public class Box<T> {
private T item;
public void setItem(T item) {
this.item = item;
}
public T getItem() {
return item;
}
}
在这个示例中,Box类是一个泛型类,使用类型参数T来表示存储的物品的类型。通过使用泛型,我们可以在编译时检查存储的物品的类型,并避免类型错误。
泛型方法:
public <T> T getFirstElement(List<T> list) {
if (list.isEmpty()) {
throw new NoSuchElementException();
}
return list.get(0);
}
在这个示例中,getFirstElement方法是一个泛型方法,使用类型参数T来表示列表中元素的类型。通过使用泛型方法,我们可以在编译时检查传入的列表的元素类型,并返回第一个元素。
通配符类型:
public void printList(List<?> list) {
for (Object item : list) {
System.out.println(item);
}
}
在这个示例中,printList方法接受一个通配符类型的列表作为参数。通过使用通配符类型,我们可以接受任意类型的列表作为参数,并在方法内部进行遍历和打印。
第30条:优先考虑泛型方法
它强调在编写代码时应优先考虑使用泛型方法来增加代码的灵活性和可读性。
使用泛型方法的好处,参考上面第29条。
1、泛型方法:
public <T> T getFirstElement(List<T> list) {
if (list.isEmpty()) {
throw new NoSuchElementException();
}
return list.get(0);
}
在这个示例中,getFirstElement方法是一个泛型方法,使用类型参数T来表示列表中元素的类型。通过使用泛型方法,我们可以在编译时检查传入的列表的元素类型,并返回第一个元素。
2、泛型方法与多个类型参数:
public <K, V> Map<K, V> createMap(K key, V value) {
Map<K, V> map = new HashMap<>();
map.put(key, value);
return map;
}
createMap方法是一个泛型方法,使用类型参数K和V来表示键和值的类型。通过使用泛型方法和多个类型参数,我们可以创建一个具有指定键和值类型的Map对象。
第31条:利用有限制通配符来提升API的灵活性
在Java中,通配符(wildcard)用于表示未知类型。有限制通配符是指在通配符后面添加了上界或下界的通配符。上界通配符使用extends关键字,表示通配符可以是指定类型或其子类型。下界通配符使用super关键字,表示通配符可以是指定类型或其父类型。
下面是一些使用有限制通配符的示例:
1、有限制通配符作为方法参数:
public static void printList(List<? extends Number> list) {
for (Number number : list) {
System.out.println(number);
}
}
在这个示例中,printList方法接受一个List类型的参数,该参数的元素类型必须是Number或其子类。通过使用有限制通配符? extends Number,我们可以在方法内部安全地使用Number类型的方法,而不会对集合进行非法操作。
2、有限制通配符作为方法返回类型:
public static <T extends Comparable<T>> T max(List<T> list) {
if (list.isEmpty()) {
throw new IllegalArgumentException("List is empty");
}
T max = list.get(0);
for (int i = 1; i < list.size(); i++) {
T current = list.get(i);
if (current.compareTo(max) > 0) {
max = current;
}
}
return max;
}
在这个示例中,max方法接受一个List类型的参数,并返回该列表中的最大元素。通过使用有限制通配符<T extends Comparable>,我们可以确保列表中的元素类型实现了Comparable接口,从而可以使用compareTo方法进行比较。
通过使用有限制通配符,我们可以提高API的灵活性,使其适用于更广泛的类型参数。它可以帮助我们编写更通用的方法,并提高代码的可读性和安全性。因此,在编写API时,应优先考虑使用有限制通配符来获得这些好处。
第32条:谨慎并用泛型和可变参数
使用泛型时要注意类型安全性。泛型可以提供编译时的类型检查,但在运行时会擦除类型信息。因此,在使用泛型时要确保类型参数的正确性,并避免出现类型转换错误。
例如,考虑以下示例:
public class GenericExample<T> {
private T value;
public void setValue(T value) {
this.value = value;
}
public T getValue() {
return value;
}
}
GenericExample<String> example = new GenericExample<>();
example.setValue("Hello");
String value = example.getValue(); // 正确,返回类型为String
example.setValue(123); // 错误,编译时会报错,因为类型参数为String
在使用可变参数时要注意参数的安全性。可变参数允许传入任意数量的参数,但在处理可变参数时要小心。
例如,考虑以下示例:
public void printValues(String... values) {
for (String value : values) {
System.out.println(value);
}
}
printValues("Hello", "World"); // 正确,输出Hello和World
printValues("Hello", "World", 123); // 错误,编译时不会报错,但在运行时会抛出ClassCastException异常
第33条:优先考虑类型安全的异构容器
建议优先考虑使用类型安全的异构容器,以提高代码的可读性和类型安全性。
异构容器是指可以存储不同类型的对象的容器。传统的容器类如List、Map等都是同构容器,即只能存储相同类型的对象。而异构容器可以存储不同类型的对象,但要求在编译时就能够确定每个对象的类型。
使用类型安全的异构容器可以避免在运行时进行类型转换,提供更好的类型安全性和代码可读性。下面是一个示例:
public class HeterogeneousContainer {
private Map<Class<?>, Object> container = new HashMap<>();
public <T> void put(Class<T> type, T instance) {
container.put(type, instance);
}
public <T> T get(Class<T> type) {
return type.cast(container.get(type));
}
}
在上述示例中,HeterogeneousContainer类使用了一个Map来存储不同类型的对象。通过put方法可以将对象按照其类型存入容器中,而get方法可以根据类型获取对应的对象。
使用异构容器的好处是可以在编译时就能够确定每个对象的类型,避免了在运行时进行类型转换的风险。同时,通过使用泛型和类型参数,可以保证在获取对象时返回正确的类型,提高了代码的可读性和可维护性。
使用异构容器的一个典型应用场景是在框架中存储和管理不同类型的插件或扩展点。通过使用异构容器,可以方便地将不同类型的插件注册到框架中,并在需要时获取对应类型的插件实例。
总之,优先考虑使用类型安全的异构容器可以提高代码的可读性和类型安全性,避免在运行时进行类型转换的风险。
第六章 枚举和注解
Java支持两种特殊用途的引用类型:一个是类,称作枚举类型;一种是接口,称作注解类型。本章将讨论这两个新类型的最佳使用实践。
第34条:用enum代替int常量
建议使用枚举(enum)类型来代替int常量,以提高代码的可读性、类型安全性和可维护性。
使用枚举类型可以将一组相关的常量值组织在一起,并为每个常量值提供更多的信息和行为。相比于使用int常量,使用枚举类型可以提供更好的类型安全性,避免了使用错误的常量值。
下面是一个示例:
public enum DayOfWeek {
MONDAY("星期一"),
TUESDAY("星期二"),
WEDNESDAY("星期三"),
THURSDAY("星期四"),
FRIDAY("星期五"),
SATURDAY("星期六"),
SUNDAY("星期日");
private String chineseName;
DayOfWeek(String chineseName) {
this.chineseName = chineseName;
}
public String getChineseName() {
return chineseName;
}
}
在上述示例中,DayOfWeek是一个枚举类型,表示一周的每一天。每个枚举常量都有一个对应的中文名称,并通过构造函数进行初始化。枚举类型还可以定义其他方法,以提供更多的行为。
使用枚举类型的好处:
- 可以提供更好的可读性和类型安全性。
- 在使用枚举常量时,可以直接使用枚举类型的名称,而不需要记住对应的int常量值。
- 编译器会在编译时进行类型检查,避免了使用错误的常量值的风险。
- 枚举类型还可以方便地进行扩展和添加新的常量值
总之,使用枚举类型来代替int常量可以提高代码的可读性、类型安全性和可维护性。枚举类型可以将一组相关的常量值组织在一起,并为每个常量值提供更多的信息和行为。
第35条:用实例域代替序数
建议使用实例域代替枚举的序数(ordinal),以提高代码的可读性和可维护性。
枚举的序数是指每个枚举常量在枚举中的位置,从0开始计数。默认情况下,枚举类型提供了一个ordinal()方法来获取枚举常量的序数。然而,使用枚举的序数来表示枚举常量的属性或行为是不推荐的,因为它具有以下问题:
- 序数是基于枚举常量的位置,如果枚举常量的顺序发生变化,序数也会发生变化,导致代码的可读性和可维护性变差。
- 序数是从0开始计数的,不具有可读性,不容易理解。
为了解决这些问题,作者建议使用实例域来代替序数。通过在枚举类型中定义实例域,并在构造函数中进行初始化,可以为每个枚举常量提供更多的属性和行为。
下面是一个示例:
public enum DayOfWeek {
MONDAY("星期一", 1),
TUESDAY("星期二", 2),
WEDNESDAY("星期三", 3),
THURSDAY("星期四", 4),
FRIDAY("星期五", 5),
SATURDAY("星期六", 6),
SUNDAY("星期日", 7);
private String chineseName;
private int value;
DayOfWeek(String chineseName, int value) {
this.chineseName = chineseName;
this.value = value;
}
public String getChineseName() {
return chineseName;
}
public int getValue() {
return value;
}
}
在上述示例中,DayOfWeek是一个枚举类型,表示一周的每一天。每个枚举常量都有一个对应的中文名称和一个值。通过定义实例域chineseName和value,可以为每个枚举常量提供更多的属性。通过定义相应的getter方法,可以获取枚举常量的属性值。
使用实例域代替序数的好处是可以提高代码的可读性和可维护性。通过使用具有可读性的实例域,可以更清晰地表示枚举常量的属性。同时,实例域不受枚举常量顺序的影响,即使枚举常量的顺序发生变化,代码也不会受到影响。
总之,使用实例域代替序数可以提高代码的可读性和可维护性。通过在枚举类型中定义实例域,并在构造函数中进行初始化,可以为每个枚举常量提供更多的属性和行为。这样可以避免使用不具有可读性的序数,并提供更清晰的代码结构。
第36条:用EnumSet代替位域
建议使用EnumSet代替位域(bit fields)来表示包含枚举值的集合。使用EnumSet可以提供更好的类型安全性、可读性和性能。
位域是使用整数类型来表示一组枚举值的集合。例如,假设我们有一个表示权限的枚举类型:
public enum Permission {
READ,
WRITE,
EXECUTE
}
使用位域来表示权限集合,可以将每个权限映射到一个位上,然后使用位运算来进行集合操作。例如,使用一个int类型的变量来表示权限集合:
public static final int READ_PERMISSION = 1 << 0; // 1
public static final int WRITE_PERMISSION = 1 << 1; // 2
public static final int EXECUTE_PERMISSION = 1 << 2; // 4
int permissions = READ_PERMISSION | WRITE_PERMISSION; // 3
然而,使用位域存在一些问题。首先,位域没有类型安全性,可以将任意整数值赋给位域变量,即使该值不是有效的权限值。其次,位域的可读性较差,不容易理解和维护。最后,位域的性能可能较差,特别是在进行集合操作时。
相比之下,EnumSet提供了更好的解决方案。EnumSet是一个专门用于存储枚举值的集合的高效实现。它具有以下优点:
- 类型安全性:EnumSet只能存储指定枚举类型的值,提供了更好的类型安全性。
- 可读性:EnumSet的代码更易读,可以直接使用枚举值进行操作,而不需要进行位运算。
- 性能:EnumSet在内部使用位向量(bit vector)来表示集合,因此具有很高的性能。
下面是使用EnumSet来表示权限集合的示例:
import java.util.EnumSet;
public class Example {
public enum Permission {
READ,
WRITE,
EXECUTE
}
public static void main(String[] args) {
EnumSet<Permission> permissions = EnumSet.of(Permission.READ, Permission.WRITE);
System.out.println(permissions); // [READ, WRITE]
}
}
在上面的示例中,我们使用EnumSet.of()方法创建了一个包含READ和WRITE权限的EnumSet对象。通过直接使用枚举值,我们可以更清晰地表示和操作权限集合。
总之,根据《Effective Java》的建议,使用EnumSet代替位域可以提供更好的类型安全性、可读性和性能。它是表示包含枚举值的集合的首选方式
。
第37条:用EnumMap代替序数索引
建议使用EnumMap代替序数索引来实现基于枚举的数据结构。使用EnumMap可以提供更好的类型安全性、可读性和性能。
序数索引是指使用枚举值的序数(ordinal)作为数组或列表的索引来存储和访问数据。例如,假设我们有一个表示星期几的枚举类型:
public enum DayOfWeek {
MONDAY,
TUESDAY,
WEDNESDAY,
THURSDAY,
FRIDAY,
SATURDAY,
SUNDAY
}
使用序数索引来存储和访问数据,可以创建一个数组或列表,并使用枚举值的序数作为索引:
String[] tasks = new String[7];
tasks[DayOfWeek.MONDAY.ordinal()] = "Do laundry";
tasks[DayOfWeek.TUESDAY.ordinal()] = "Go grocery shopping";
// ...
然而,使用序数索引存在一些问题。首先,序数索引没有类型安全性,可以使用任意整数值作为索引,即使该值不是有效的枚举序数。其次,序数索引的可读性较差,不容易理解和维护。最后,序数索引的性能可能较差,特别是在稀疏数组或大型数组的情况下。
相比之下,EnumMap提供了更好的解决方案。EnumMap是一个专门用于存储枚举类型键值对的高效实现。它具有以下优点:
- 类型安全性:EnumMap只能存储指定枚举类型的键值对,提供了更好的类型安全性。
- 可读性:EnumMap的代码更易读,可以直接使用枚举值作为键进行操作,而不需要使用序数。
- 性能:EnumMap在内部使用数组来表示键值对,因此具有很高的性能,尤其在稀疏数组或大型数组的情况下。
下面是使用EnumMap来存储星期几对应任务的示例:
import java.util.EnumMap;
public class Example {
public enum DayOfWeek {
MONDAY,
TUESDAY,
WEDNESDAY,
THURSDAY,
FRIDAY,
SATURDAY,
SUNDAY
}
public static void main(String[] args) {
EnumMap<DayOfWeek, String> tasks = new EnumMap<>(DayOfWeek.class);
tasks.put(DayOfWeek.MONDAY, "Do laundry");
tasks.put(DayOfWeek.TUESDAY, "Go grocery shopping");
// ...
System.out.println(tasks.get(DayOfWeek.MONDAY)); // "Do laundry"
}
}
在上面的示例中,我们使用EnumMap来存储星期几对应的任务。通过直接使用枚举值作为键,我们可以更清晰地表示和访问数据。
总之,根据《Effective Java》的建议,使用EnumMap代替序数索引可以提供更好的类型安全性、可读性和性能。它是实现基于枚举的数据结构的首选方式。
第38条:用接口模拟可扩展的枚举
介绍了一种使用接口来模拟可扩展的枚举的技术。这种技术可以在不修改现有代码的情况下,通过实现接口来扩展枚举类型。
通常情况下,枚举类型是不可扩展的,即不能在运行时动态添加新的枚举值。但是,通过使用接口,我们可以模拟出可扩展的枚举的行为。
下面是使用接口模拟可扩展的枚举的示例:
首先,定义一个表示枚举值的接口:
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; }
},
MINUS("-") {
public double apply(double x, double y) { return x - y; }
},
MULTIPLY("*") {
public double apply(double x, double y) { return x * y; }
},
DIVIDE("/") {
public double apply(double x, double y) { return x / y; }
};
private final String symbol;
BasicOperation(String symbol) {
this.symbol = symbol;
}
public String toString() {
return symbol;
}
}
在这个例子中,BasicOperation是一个枚举类,实现了Operation接口。每个枚举值都实现了apply方法,用于执行相应的操作。
现在,如果我们想要扩展这个枚举类型,只需要实现Operation接口即可:
public enum ExtendedOperation implements Operation {
POWER("^") {
public double apply(double x, double y) { return Math.pow(x, y); }
},
REMAINDER("%") {
public double apply(double x, double y) { return x % y; }
};
private final String symbol;
ExtendedOperation(String symbol) {
this.symbol = symbol;
}
public String toString() {
return symbol;
}
}
通过实现Operation接口,我们可以在不修改BasicOperation枚举类的情况下,扩展枚举类型并添加新的操作。
使用这种技术,我们可以实现可扩展的枚举,同时保持类型安全性和可读性。
下面是一个使用示例:
public class Main {
public static void main(String[] args) {
double x = 10.0;
double y = 5.0;
Operation op1 = BasicOperation.PLUS;
System.out.println(op1.apply(x, y)); // 输出 15.0
Operation op2 = ExtendedOperation.POWER;
System.out.println(op2.apply(x, y)); // 输出 100000.0
}
}
在这个示例中,我们使用了BasicOperation和ExtendedOperation枚举类来执行不同的操作,并输出结果。通过使用接口模拟可扩展的枚举,我们可以方便地添加新的操作,而不需要修改现有的代码。
第39条:注解优先于命名模式
介绍了一种使用注解优于命名模式的技术。这种技术可以提供更加灵活和可读性更好的代码。
传统上,我们使用命名模式来表示某些特殊的情况或属性。例如,我们可能使用命名模式来表示某个方法是一个测试方法,或者某个字段是一个非空字段。
然而,使用命名模式存在一些问题。首先,命名模式需要开发人员遵循一定的命名规范,以确保代码的可读性和一致性。其次,命名模式可能会导致代码冗余,因为每个特殊情况都需要使用不同的命名来表示。
为了解决这些问题,Java引入了注解机制。通过使用注解,我们可以在代码中直接标记特殊情况或属性,而不需要依赖命名规范。
下面是使用注解优于命名模式的示例:
首先,定义一个注解:
public @interface Test {
}
然后,使用注解标记测试方法:
public class MyClass {
@Test
public void testMethod() {
// 测试方法的实现
}
}
在这个例子中,我们使用@Test注解来标记一个测试方法。这样,我们就可以直观地知道哪些方法是测试方法,而不需要依赖命名规范。
另外,注解还可以带有参数,以提供更多的信息。例如,我们可以定义一个带有参数的注解来表示某个字段是一个非空字段:
public @interface NonNull {
String message() default "Field cannot be null";
}
然后,使用注解标记非空字段:
public class MyClass {
@NonNull(message = "Name cannot be null")
private String name;
}
在这个例子中,我们使用@NonNull注解来标记一个非空字段,并提供了一个默认的错误消息。这样,我们可以在编译时或运行时检查字段的非空性,并提供相应的错误消息。
通过使用注解,我们可以提供更加灵活和可读性更好的代码。注解可以直观地标记特殊情况或属性,而不需要依赖命名规范。另外,注解还可以带有参数,以提供更多的信息。这样,我们可以在编译时或运行时对代码进行更加精确的检查和处理。
第40条:坚持使用Override注解
建议坚持使用@Override注解来标记覆盖(重写)父类方法的方法。这样可以提高代码的可读性和可维护性。
在Java中,当我们重写父类的方法时,通常会使用@Override注解来标记这个方法。这个注解的作用是告诉编译器,我们是有意覆盖了父类的方法,如果父类的方法签名发生了变化或者不存在,编译器会给出错误提示。
下面是使用@Override注解的示例:
public class Parent {
public void printMessage() {
System.out.println("Parent class");
}
}
public class Child extends Parent {
@Override
public void printMessage() {
System.out.println("Child class");
}
}
在这个例子中,Child类继承自Parent类,并重写了printMessage方法。在Child类中,我们使用@Override注解来标记这个方法。这样,编译器会在编译时检查是否正确地覆盖了父类的方法。
使用@Override注解的好处有以下几点:
-
提高代码的可读性:通过使用@Override注解,我们可以清楚地知道哪些方法是重写了父类的方法,而不需要查看父类的源代码。
-
提供编译时错误检查:如果我们错误地重写了父类的方法,或者父类的方法签名发生了变化,编译器会给出错误提示,帮助我们及早发现和修复问题。
-
防止意外覆盖:有时候,我们可能会意外地重写了父类的方法,而不是想要覆盖。使用@Override注解可以帮助我们避免这种情况的发生。
总之,坚持使用@Override注解可以提高代码的可读性和可维护性。它可以清楚地标记出重写了父类方法的方法,并提供编译时错误检查,帮助我们编写更加健壮的代码。
第41条:用标记接口定义类型
建议使用标记接口(Marker Interface)来定义类型。标记接口是一种不包含任何方法的接口,仅用于标记某个类属于特定的类型。
使用标记接口的主要目的是为了提供一种更加灵活和可读性更好的类型检查机制。通过使用标记接口,我们可以在编译时或运行时对对象进行类型检查,以确定其是否属于特定的类型。
下面是使用标记接口的示例:
public interface Serializable {
// 空接口,不包含任何方法
}
public class Person implements Serializable {
private String name;
private int age;
// 省略构造方法和其他方法
// ...
}
在这个例子中,我们定义了一个名为Serializable的标记接口。然后,我们将Person类实现了Serializable接口,表示Person类是可序列化的。
使用标记接口的好处有以下几点:
-
提供更加灵活的类型检查:通过使用标记接口,我们可以在编译时或运行时对对象进行类型检查,以确定其是否属于特定的类型。这样可以提供更加灵活的类型检查机制,而不仅仅局限于继承关系。
-
提高代码的可读性:通过使用标记接口,我们可以清楚地知道某个类属于特定的类型,而不需要查看类的实现细节。这样可以提高代码的可读性和可维护性。
-
支持扩展性:标记接口可以用于定义一组相关的类型,而不仅仅是单个类型。这样可以支持更加灵活的扩展性,可以根据需要定义新的标记接口来表示新的类型。
需要注意的是,使用标记接口可能会增加代码的复杂性,因为我们需要在适当的地方进行类型检查。因此,在使用标记接口时,需要权衡使用的场景和复杂性。
总之,使用标记接口可以提供一种更加灵活和可读性更好的类型检查机制。它可以在编译时或运行时对对象进行类型检查,以确定其是否属于特定的类型。通过使用标记接口,我们可以提高代码的可读性和可维护性,并支持更加灵活的扩展性。
第七章 Lambda 和 Stream
在Java 8中,增加了函数接口、lambda和方法引用,使用创建函数对象变得更容易。与此同时,还增加了Stream API,为处理数据元素的序列提供了类库级别的支持。在本章中,将讨论如何最佳地利用这些机制。
第42条:Lambda优先于匿名类
Lambda表达式是Java 8引入的一种简洁的语法,用于表示函数式接口的实例。
Lambda表达式相比于匿名类具有以下优势:
-
简洁性:Lambda表达式可以大大减少代码的冗余,使代码更加简洁易读。相比于匿名类,Lambda表达式的语法更加简洁明了。
-
可读性:Lambda表达式可以更好地表达代码的意图,使代码更易于理解。通过使用Lambda表达式,可以将代码的重点放在实现逻辑上,而不是在类的声明和实例化上。
-
灵活性:Lambda表达式可以更灵活地处理函数式接口。Lambda表达式可以直接传递给函数式接口的方法,而不需要创建额外的类和实例。
下面是一个简单的示例,展示了Lambda表达式和匿名类的对比:
// 使用Lambda表达式
Runnable runnable1 = () -> System.out.println("Hello, Lambda!");
// 使用匿名类
Runnable runnable2 = new Runnable() {
@Override
public void run() {
System.out.println("Hello, Anonymous Class!");
}
};
// 调用run方法
runnable1.run(); // 输出:Hello, Lambda!
runnable2.run(); // 输出:Hello, Anonymous Class!
在上面的示例中,我们创建了一个Runnable接口的实例。使用Lambda表达式,我们可以直接通过() -> System.out.println(“Hello, Lambda!”)来表示一个Runnable接口的实例。而使用匿名类,则需要创建一个新的匿名类,并实现其run方法。
通过使用Lambda表达式,我们可以更简洁地表示函数式接口的实例,并且代码更易读。因此,在使用函数式接口时,建议优先选择使用Lambda表达式而不是匿名类。
第43条:方法引用优先于Lambda
优先选择使用方法引用而不是Lambda表达式。方法引用是一种更简洁的语法,用于直接引用已经存在的方法。
方法引用相比于Lambda表达式具有以下优势:
-
简洁性:方法引用可以进一步减少代码的冗余,使代码更加简洁易读。相比于Lambda表达式,方法引用的语法更加简洁明了。
-
可读性:方法引用可以更好地表达代码的意图,使代码更易于理解。通过使用方法引用,可以直接引用已经存在的方法,而不需要编写额外的逻辑。
-
灵活性:方法引用可以更灵活地处理函数式接口。方法引用可以直接传递给函数式接口的方法,而不需要编写额外的Lambda表达式。
下面是一个简单的示例,展示了方法引用和Lambda表达式的对比:
// 使用方法引用
List<String> names1 = Arrays.asList("Alice", "Bob", "Charlie");
names1.forEach(System.out::println);
// 使用Lambda表达式
List<String> names2 = Arrays.asList("Alice", "Bob", "Charlie");
names2.forEach(name -> System.out.println(name));
// 使用方法引用
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.forEach(String::toUpperCase);
// 使用Lambda表达式
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.forEach(name -> name.toUpperCase());
// 使用方法引用
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<Person> persons = names.stream()
.map(Person::new)
.collect(Collectors.toList());
// 使用Lambda表达式
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<Person> persons = names.stream()
.map(name -> new Person(name))
.collect(Collectors.toList());
在上面的示例中,我们使用了一个List的forEach方法来遍历列表中的元素并打印出来。使用方法引用,我们可以直接通过System.out::println来引用System.out对象的println方法。而使用Lambda表达式,则需要编写一个Lambda表达式来实现打印逻辑。
通过使用方法引用,我们可以更简洁地表示已经存在的方法的引用,并且代码更易读。因此,在使用Lambda表达式时,建议优先选择使用方法引用而不是Lambda表达式。
第44条:坚持使用标准的函数接口
标准的函数接口是指Java标准库中已经定义好的函数接口,例如java.util.function包中的接口。
使用标准的函数接口有以下好处:
-
可读性:标准的函数接口具有明确的命名和用途,可以更好地表达代码的意图,使代码更易于理解。
-
重用性:标准的函数接口已经在Java标准库中定义好了,可以直接使用,无需自己定义新的接口。这样可以提高代码的重用性,减少重复的工作。
-
互操作性:标准的函数接口可以与其他库和框架进行良好的互操作。许多库和框架都已经支持标准的函数接口,因此使用标准的函数接口可以更方便地与这些库和框架进行集成。
以下是一些使用标准的函数接口的示例:
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<String> filteredNames = names.stream()
.filter(name -> name.length() > 4)
.collect(Collectors.toList());
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
List<Integer> nameLengths = names.stream()
.map(name -> name.length())
.collect(Collectors.toList());
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
names.forEach(name -> System.out.println(name));
这些示例展示了在不同情况下使用标准的函数接口的方式。使用标准的函数接口可以使代码更加清晰、可读,并且具有良好的互操作性。因此,在编写函数接口时,建议优先选择使用标准的函数接口。
第45条:谨慎使用Stream
Stream是Java 8引入的一个强大的API,用于处理集合数据的流式操作。然而,虽然Stream提供了很多便利的方法,但过度使用Stream可能会导致代码变得复杂、难以理解和维护。
以下是书中详细说明Stream使用的几个方面:
-
可读性:Stream提供了一种流式的编程风格,可以将多个操作链接在一起,形成一个流水线。这种风格可以使代码更加简洁和可读。然而,当流水线中的操作过多或过于复杂时,代码可能会变得难以理解。因此,在使用Stream时,需要注意保持代码的可读性。
-
性能:Stream的操作是延迟执行的,只有在终止操作时才会触发实际的计算。这种延迟执行的特性可以提高性能,避免不必要的计算。然而,有时候过度使用Stream可能会导致性能下降。例如,在某些情况下,使用传统的循环可能比使用Stream更高效。因此,在使用Stream时,需要根据具体情况进行评估和权衡。
-
可变性:Stream是不可变的,它不会修改原始数据。这种不可变性可以确保数据的安全性和线程安全性。然而,有时候需要对数据进行修改或更新,这时候使用Stream可能会变得不太方便。因此,在需要修改数据的场景下,需要谨慎使用Stream。
以下是一个使用Stream的示例:
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
// 使用Stream过滤出长度大于4的字符串,并将结果收集到一个新的List中
List<String> filteredNames = names.stream()
.filter(name -> name.length() > 4)
.collect(Collectors.toList());
然而,需要注意的是,当流水线中的操作过多或过于复杂时,代码可能会变得难以理解。因此,在使用Stream时,需要根据具体情况进行评估和权衡,确保代码的可读性和性能。
总之,Stream是一个强大的API,可以提高代码的简洁性和可读性。然而,过度使用Stream可能会导致代码变得复杂、难以理解和维护。因此,在使用Stream时,需要谨慎权衡,并根据具体情况进行评估。
第46条:优先选择Stream中无副作用的函数
副作用是指函数对除了返回值之外的其他状态进行了修改或产生了其他可观察的行为。在使用Stream时,应该尽量避免使用具有副作用的函数,而是优先选择无副作用的函数。
以下是书中详细说明无副作用函数的几个方面:
-
可读性:无副作用的函数更容易理解和推理。由于它们不会修改状态或产生其他可观察的行为,所以可以更好地理解函数的行为和结果。
-
可测试性:无副作用的函数更容易进行单元测试。由于它们不依赖于外部状态或其他因素,所以可以更方便地编写和执行测试用例。
-
可组合性:无副作用的函数更容易进行组合和重用。由于它们不会修改状态,所以可以更方便地将它们组合在一起,形成更复杂的操作。
以下是一个使用无副作用函数的示例:
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
// 使用无副作用的函数进行转换和过滤操作
List<String> filteredNames = names.stream()
.map(String::toUpperCase)
.filter(name -> name.length() > 4)
.collect(Collectors.toList());
在上面的示例中,我们使用无副作用的函数对一个字符串列表进行转换和过滤操作。首先,我们使用map函数将所有字符串转换为大写形式,然后使用filter函数过滤出长度大于4的字符串,并最终将结果收集到一个新的List中。这个示例展示了无副作用函数的可读性、可测试性和可组合性。
需要注意的是,有些函数可能会具有副作用,例如forEach函数,它可以对每个元素执行一个操作。在使用这些具有副作用的函数时,需要谨慎评估和权衡,并确保它们的使用不会导致意外的行为或不良的影响。
总之,优先选择Stream中无副作用的函数可以提高代码的可读性、可测试性和可组合性。在使用具有副作用的函数时,需要谨慎评估和权衡,并确保它们的使用不会产生意外的行为。
第47条:Stream要优先用Collection作为返回类型
这是因为Collection是Java中常见的集合类型,使用Collection作为返回类型可以提供更好的互操作性和兼容性。
以下是书中详细说明使用Collection作为返回类型的几个方面:
-
互操作性:使用Collection作为返回类型可以方便地与其他集合类型进行互操作。由于Stream是Java 8引入的新特性,不是所有的代码库和框架都对Stream提供了良好的支持。而使用Collection作为返回类型,可以更容易地将Stream转换为其他集合类型,或者将其他集合类型转换为Stream。
-
兼容性:使用Collection作为返回类型可以提供更好的兼容性。在Java中,Collection是一个广泛使用的接口,几乎所有的集合类都实现了Collection接口。因此,使用Collection作为返回类型可以使代码更加通用和灵活,可以适应不同的集合实现。
以下是一个使用Collection作为返回类型的示例:
public Collection<String> getNames() {
List<String> names = Arrays.asList("Alice", "Bob", "Charlie");
return names.stream()
.filter(name -> name.length() > 4)
.collect(Collectors.toList());
}
// 转换为List:
List<String> list = collection.stream().collect(Collectors.toList());
// 转换为Set:
Set<String> set = collection.stream().collect(Collectors.toSet());
// 转换为数组:
String[] array = collection.stream().toArray(String[]::new);
在上面的示例中,我们定义了一个getNames方法,该方法返回一个Collection类型的结果。在方法内部,我们使用Stream对一个字符串列表进行过滤操作,并最终将结果收集到一个新的List中。由于返回类型是Collection,所以调用者可以根据需要将结果转换为其他集合类型,例如Set或ArrayList。
需要注意的是,如果返回类型是具体的集合类型,例如List或Set,那么调用者只能得到该具体类型的集合,而无法灵活地将结果转换为其他集合类型。而使用Collection作为返回类型,可以提供更好的灵活性和兼容性。
总之,使用Collection作为Stream的返回类型可以提供更好的互操作性和兼容性。这样可以使代码更加通用和灵活,适应不同的集合实现。
第48条:谨慎使用Stream并行
因为并行操作可能会引发线程安全问题和性能问题。
以下是书中详细说明使用Stream并行的几个方面:
-
线程安全问题:并行操作会将数据分成多个部分,并使用多个线程同时处理这些部分。如果在并行操作中修改了共享的数据,可能会导致线程安全问题。因此,在使用并行操作时,需要确保操作是无状态的或线程安全的。
-
性能问题:并行操作需要将数据分成多个部分,并创建多个线程来处理这些部分。这样会增加线程调度和数据传输的开销,可能导致性能下降。在某些情况下,串行操作可能比并行操作更快。
因此,书中建议在使用Stream并行时需要谨慎,并在以下情况下考虑使用并行操作:
- 数据量大且操作是无状态的或线程安全的。
- 操作是计算密集型的,而不是I/O密集型的。
- 经过测试证明,并行操作确实能够提高性能。
以下是一个使用Stream并行的示例:
List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
int sum = numbers.parallelStream()
.filter(n -> n % 2 == 0)
.mapToInt(n -> n)
.sum();
System.out.println(sum);
在上述示例中,我们使用parallelStream()方法将Stream转换为并行流,并对数字进行过滤和求和操作。由于这个示例中的操作是无状态的,并且计算密集型,因此使用并行操作可以提高性能。但是需要注意,如果操作涉及到共享数据或是I/O密集型的操作,就需要谨慎使用并行操作。
第八章 方法
本章要讨论方法设计的几个方面:如何处理参数和返回值,如何设计方法签名,如何为方法编写文档。本章大部分内容既适用于构造器,也适用于普通的方法。与第4章一样,本章的焦点也集中在可用性、健壮性和灵活性上。
第49条:检查参数的有效性
建议了在方法中检查参数的有效性,以确保方法的正确性和健壮性。
以下是书中详细说明参数有效性检查的几个方面:
-
检查参数是否为null:在方法中检查参数是否为null是一种良好的编程习惯,可以避免空指针异常。如果参数为null,可以抛出NullPointerException或者提前返回错误结果。
-
检查参数的取值范围:对于有限的取值范围的参数,可以在方法中检查参数的取值是否合法。如果参数的取值不在合法范围内,可以抛出IllegalArgumentException或者提前返回错误结果。
-
检查参数之间的关联性:有些方法的参数之间可能存在关联性,需要检查这些参数的关联性是否满足要求。如果参数之间的关联性不满足要求,可以抛出IllegalArgumentException或者提前返回错误结果。
以下是一个示例,演示了如何在方法中检查参数的有效性:
public void processOrder(String orderId, int quantity) {
if (orderId == null) {
throw new NullPointerException("orderId cannot be null");
}
if (quantity <= 0) {
throw new IllegalArgumentException("quantity must be positive");
}
// 执行订单处理逻辑
// ...
}
在上述示例中,processOrder方法接收一个订单ID和数量作为参数。在方法中,首先检查订单ID是否为null,如果为null,则抛出NullPointerException。然后检查数量是否小于等于0,如果小于等于0,则抛出IllegalArgumentException。通过这些参数有效性检查,可以确保方法在执行之前参数的有效性,提高方法的健壮性。
需要注意的是,参数有效性检查应该在方法的开头进行,以便尽早发现错误并提前返回。同时,还可以使用断言(assert)来进行参数有效性检查,但是断言默认是关闭的,需要在运行时启用。
第50条:必要时进行保护性拷贝
建议了在需要时进行保护性拷贝(Defensive Copy),以确保不可变性和安全性。
保护性拷贝是指在对外部提供访问对象的方法时,返回一个拷贝而不是原始对象,以防止外部修改对象的状态。这样可以保持对象的不可变性和安全性。
以下是书中详细说明保护性拷贝的几个方面:
-
不可变性:如果一个类的实例是不可变的,那么在对外部提供访问该实例的方法时,应该返回一个拷贝而不是原始实例。这样可以防止外部修改原始实例的数据,从而保持不可变性。
-
安全性:如果一个类的实例包含可变的成员变量,并且这些成员变量对外部是可见的,那么在对外部提供访问这些成员变量的方法时,应该返回一个拷贝而不是原始实例。这样可以防止外部修改原始实例的成员变量,从而保证安全性。
以下是一个示例,演示了如何进行保护性拷贝:
public class Person {
private final String name;
private final Date birthDate;
public Person(String name, Date birthDate) {
this.name = name;
this.birthDate = new Date(birthDate.getTime()); // 进行保护性拷贝
}
public String getName() {
return name;
}
public Date getBirthDate() {
return new Date(birthDate.getTime()); // 进行保护性拷贝
}
}
在上述示例中,Person类包含一个不可变的name属性和一个可变的birthDate属性。在构造方法中,对传入的birthDate进行了保护性拷贝,使用new Date(birthDate.getTime())创建了一个新的Date对象。在getBirthDate方法中,也进行了保护性拷贝,返回了一个新的Date对象。这样,外部无法修改Person实例的birthDate属性,保证了不可变性和安全性。
需要注意的是,进行保护性拷贝时,需要根据具体情况选择合适的方式进行拷贝。对于不可变的对象,可以直接返回原始实例或者返回一个拷贝。对于可变的对象,应该返回一个拷贝,以防止外部修改原始实例的数据。保护性拷贝的目的是为了保护对象的状态,确保对象在外部使用时不会被修改。
第51条:谨慎设计方法签名
建议了谨慎设计方法签名(Carefully Design Method Signatures),以提高代码的可读性和灵活性。
方法签名是指方法的名称、参数列表和返回类型。一个好的方法签名应该清晰地表达方法的功能和用途,并且应该尽量避免使用具有歧义的参数类型或返回类型。
以下是书中详细说明谨慎设计方法签名的几个方面:
-
使用明确的命名:方法名应该能够清晰地表达方法的功能和用途。避免使用模糊或不相关的名称,以免给其他开发人员造成困惑。
-
使用具体的参数类型:在方法的参数列表中,应该尽量使用具体的类型而不是抽象类型或接口。这样可以提高代码的可读性,并且可以避免在方法内部进行类型转换的操作。
-
避免使用过多的参数:方法的参数列表应该尽量简洁,避免使用过多的参数。过多的参数会增加方法的复杂性,并且容易引发错误。如果方法需要接收大量的参数,可以考虑使用构建器模式或者将参数封装成一个对象。
-
考虑使用重载方法:如果一个类中有多个方法具有相似的功能,但是参数类型不同,可以考虑使用重载方法。重载方法可以提高代码的可读性,并且可以根据不同的参数类型选择合适的方法进行调用。
下面是一个示例,演示了如何谨慎设计方法签名:
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public double add(double a, double b) {
return a + b;
}
public int multiply(int a, int b) {
return a * b;
}
public double multiply(double a, double b) {
return a * b;
}
}
在上面的示例中,Calculator类中定义了两个add方法和两个multiply方法。这些方法具有相似的功能,但是参数类型不同。通过使用重载方法,可以根据不同的参数类型选择合适的方法进行调用,提高了代码的可读性和灵活性。
第52条:慎用重载
重载是指在同一个类中定义多个具有相同名称但参数列表不同的方法。重载可以提供更多的灵活性和方便性,但过度使用重载可能会导致代码难以理解和维护。
该条建议的详细说明如下:
-
避免混淆:当存在多个重载方法时,调用者可能会因为参数类型的相似性而产生混淆,导致选择错误的方法。这会增加代码的复杂性和错误的可能性。
-
可读性和可维护性:过多的重载方法会增加代码的复杂性,使代码难以理解和维护。当代码中存在多个重载方法时,阅读代码的人需要仔细查看每个方法的参数类型和返回类型,才能确定调用的是哪个方法。
-
重载与重写的混淆:当一个类中同时存在重载方法和重写方法时,容易混淆两者的概念。重载是指在同一个类中定义多个具有相同名称但参数列表不同的方法,而重写是指子类覆盖父类中的方法。混淆两者可能导致意外的行为和错误。
下面是一个正例和反例来说明慎用重载的原因:
正例:
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public double add(double a, double b) {
return a + b;
}
}
在上面的示例中,Calculator 类定义了两个重载的 add 方法,分别接受两个整数和两个浮点数作为参数,并返回它们的和。这种情况下,重载方法的使用是合理的,因为参数类型不同,调用者可以根据需要选择正确的方法。
反例:
public class Calculator {
public int add(int a, int b) {
return a + b;
}
public int add(int a, int b, int c) {
return a + b + c;
}
}
在上面的示例中,Calculator 类定义了两个重载的 add 方法,分别接受两个整数和三个整数作为参数,并返回它们的和。这种情况下,重载方法的使用可能会导致混淆和错误。调用者在使用 add 方法时,如果传递了三个整数的参数,可能会错误地调用了第二个重载方法,而不是期望的第一个重载方法。
为了避免重载带来的混淆和复杂性,可以考虑使用不同的方法名或使用不同的参数类型来区分功能相似但参数不同的方法。这样可以提高代码的可读性和可维护性。
第53条:慎用可变参数
可变参数(Varargs)是Java中的一种特殊语法,它允许方法接受任意数量的参数。在方法声明中,使用省略号(…)来表示可变参数。可变参数实际上是一个数组,方法内部可以像操作数组一样来处理这些参数。
该条建议的详细说明如下:
-
可变参数的使用应该谨慎。可变参数允许方法接受任意数量的参数,但这种灵活性可能会导致一些问题。
-
可变参数的主要问题是类型安全性。可变参数是通过数组来实现的,因此在编译时无法对参数类型进行检查。这意味着在运行时,如果传递了错误类型的参数,可能会导致运行时异常。
-
可变参数还会导致一些模糊的调用。当方法有多个重载版本时,如果传递的参数数量和类型与多个重载方法匹配,编译器可能会选择错误的方法。
下面是一个例子来说明慎用可变参数的原因:
public class Calculator {
public static int sum(int... numbers) {
int sum = 0;
for (int number : numbers) {
sum += number;
}
return sum;
}
}
在上面的示例中,Calculator 类定义了一个可变参数的 sum 方法,用于计算传入参数的总和。这种情况下,可变参数的使用是合理的,因为它提供了一种方便的方式来接受任意数量的参数。
然而,可变参数的使用也可能导致一些问题。考虑以下示例:
public class Calculator {
public static int sum(int... numbers) {
int sum = 0;
for (int number : numbers) {
sum += number;
}
return sum;
}
public static int sum(int a, int b) {
return a + b;
}
}
在上面的示例中,Calculator 类定义了两个重载的 sum 方法,一个是可变参数的版本,另一个是接受两个整数的版本。如果调用者传递两个整数作为参数,编译器可能会选择错误的方法,因为可变参数的版本也可以接受两个整数作为参数。
为了避免可变参数带来的类型安全性和调用模糊性的问题,可以考虑使用重载方法或明确指定参数类型来替代可变参数。这样可以提高代码的可读性和可维护性。
第54条:返回零长度的数组或者集合、而不是null
该条建议的详细说明如下:
-
返回零长度的数组或者集合比返回null更好。返回null可能会导致调用者需要额外的空指针检查,增加代码的复杂性和出错的可能性。
-
返回零长度的数组或者集合可以简化调用者的代码逻辑。调用者可以直接使用返回的空数组或者集合,而不需要进行额外的判断和处理。
-
返回零长度的数组或者集合可以提供更好的API一致性。如果一个方法在某些情况下返回null,在其他情况下返回非null的数组或者集合,会导致调用者需要编写不同的处理逻辑,增加了使用的复杂性。
下面是一个示例来说明返回零长度的数组或者集合的使用:
public class StringUtils {
public static String[] split(String input, String delimiter) {
if (input == null || input.isEmpty()) {
return new String[0];
}
return input.split(delimiter);
}
}
在上面的示例中,split 方法用于将字符串按照指定的分隔符进行拆分。如果输入字符串为空或者null,方法会返回一个零长度的字符串数组。这样,调用者可以直接使用返回的数组,而不需要进行额外的空指针检查。
调用者可以按照以下方式使用 split 方法:
String[] result1 = StringUtils.split("Hello,World", ","); // result1 = ["Hello", "World"]
String[] result2 = StringUtils.split("", ","); // result2 = []
在上面的示例中,无论输入字符串是非空还是空,split 方法都会返回一个合法的字符串数组。这样,调用者可以直接使用返回的数组,而不需要担心空指针异常。
总结起来,返回零长度的数组或者集合比返回null更好。它可以简化调用者的代码逻辑,提供更好的API一致性,并减少空指针异常的可能性。在设计方法时,应该考虑返回零长度的数组或者集合,而不是null。
第55条:谨慎返回optional
该条建议的详细说明如下:
-
Optional是Java 8引入的一个用于表示可能为空的值的容器类。它可以用于替代返回null的情况,提供更好的语义和错误处理机制。
-
虽然Optional可以提供更好的可读性和错误处理,但并不是所有情况下都适合使用Optional。过度使用Optional可能会导致代码变得复杂,增加了调用者的负担。
-
应该谨慎使用Optional,只在以下情况下使用:
1. 方法的返回值可能为空,但是调用者需要明确处理这种情况。
2. 方法的返回值可能为空,但是调用者可以使用默认值或者采取其他合适的处理方式。
下面是一个示例来说明谨慎使用Optional的情况:
import java.util.Optional;
public class Person {
private String name;
private Optional<Integer> age;
public Person(String name, Optional<Integer> age) {
this.name = name;
this.age = age;
}
public Optional<Integer> getAge() {
return age;
}
public static void main(String[] args) {
Person person1 = new Person("John", Optional.of(25));
Person person2 = new Person("Jane", Optional.empty());
int age1 = person1.getAge().orElse(0);
int age2 = person2.getAge().orElse(0);
System.out.println("Person 1 age: " + age1); // 输出:Person 1 age: 25
System.out.println("Person 2 age: " + age2); // 输出:Person 2 age: 0
}
}
在上面的示例中,Person类有一个age字段,类型为Optional,表示年龄信息。在构造方法中,我们可以传入一个年龄值或者空值。
在main方法中,我们创建了两个Person对象,分别是person1和person2。person1的年龄信息存在,而person2的年龄信息为空。
我们使用getAge方法来获取人的年龄信息。如果年龄信息存在,我们可以使用orElse方法来获取年龄值;如果年龄信息为空,我们可以使用orElse方法提供一个默认值。
在上面的示例中,person1的年龄信息存在,所以age1的值为25;person2的年龄信息为空,所以age2的值为0。
通过使用Optional,我们可以明确处理可能为空的值,并且可以提供默认值或者其他合适的处理方式。这样可以使代码更加清晰和健壮。
谨慎使用Optional的原因有以下几点:
-
增加复杂性:使用Optional可能会增加代码的复杂性。在使用Optional的过程中,需要使用一些特定的方法来处理Optional对象,如orElse、orElseGet、orElseThrow等。这些方法的使用需要一定的学习成本,并且可能会使代码变得更加冗长。
-
过度使用:过度使用Optional可能会导致代码变得冗长和难以理解。Optional应该被用于表示可能为空的值,而不是用于替代所有的null检查。如果在每个可能为空的地方都使用Optional,会使代码变得过于冗长,降低代码的可读性。
-
引入新的问题:使用Optional可能会引入一些新的问题。例如,如果在Optional中存储了null值,那么在使用Optional的过程中可能会出现NullPointerException。此外,如果在方法的返回类型中使用Optional,可能会导致调用者需要处理Optional对象,增加了调用者的负担。
-
兼容性问题:Optional是在Java 8中引入的,如果代码需要与旧版本的Java兼容,那么使用Optional可能会导致兼容性问题。
综上所述,虽然Optional可以提供更好的语义和错误处理机制,但在使用Optional时需要谨慎考虑,避免过度使用和增加代码的复杂性。在某些情况下,使用传统的null检查可能更加简单和直观。
第56条:为所有导出的API元素编写文档注释
该条建议的原因如下:
-
提供清晰的文档:编写文档注释可以提供清晰、准确的文档,帮助其他开发人员理解和正确使用API。文档注释应该包含API的用途、参数的含义、返回值的含义以及可能抛出的异常等信息,使其他开发人员能够快速了解API的使用方式和限制条件。
-
提高代码的可读性:文档注释可以提高代码的可读性。通过文档注释,其他开发人员可以更容易地理解代码的意图和设计思路,从而更好地维护和扩展代码。
-
促进团队协作:编写文档注释可以促进团队协作。通过清晰的文档注释,团队成员可以更好地理解和使用彼此编写的代码,减少沟通成本,提高开发效率。
-
支持自动生成文档:文档注释可以支持自动生成API文档。许多开发工具和框架都支持从代码中提取文档注释并生成API文档,这样可以节省编写文档的时间和精力。
下面是一个示例,展示了如何为一个导出的API元素编写文档注释:
/**
* 计算两个整数的和。
*
* @param a 第一个整数
* @param b 第二个整数
* @return 两个整数的和
*/
public int add(int a, int b) {
return a + b;
}
在上面的示例中,文档注释清楚地说明了方法的用途、参数的含义和返回值的含义。其他开发人员可以通过阅读文档注释,快速了解该方法的使用方式和预期结果。
第九章 通用编程
本章主要讨论Java语言的细枝末节,包含局部变量的处理、控制结构、类库的用法、各种数据类型的用法,以及两种不是由语言本身提供的机制(反射机制和本地方法)的用法。最后讨论了优化和命名规则。
第57条:将局部变量的作用域最小化
将局部变量的作用域最小化,这是为了提高代码的可读性、可维护性和安全性。通过将局部变量的作用域限制在尽可能小的范围内,可以减少变量的生命周期,避免变量被误用或意外修改,同时也可以减少内存占用。
具体来说,将局部变量的作用域最小化可以遵循以下几个原则:
-
在第一次使用变量的地方声明它:在需要使用变量的地方直接声明,而不是在方法的开头或其他不必要的地方声明。这样可以使代码更加清晰,读者可以更容易地理解变量的用途和范围。
-
尽量延迟变量的声明:只有在需要使用变量之前才进行声明,而不是在方法的开头或其他不必要的地方。这样可以减少变量的生命周期,避免变量被误用或意外修改。
-
尽量使用final修饰符:对于不需要修改的变量,可以使用final修饰符来明确表示其不可变性。这样可以提高代码的可读性,并且编译器可以进行更多的优化。
下面是一个示例代码,演示了如何将局部变量的作用域最小化:
public void processOrder(Order order) {
// 声明并初始化变量
int quantity = order.getQuantity();
// 使用变量进行计算
double totalPrice = calculateTotalPrice(quantity, order.getPrice());
// 打印结果
System.out.println("Total price: " + totalPrice);
}
private double calculateTotalPrice(int quantity, double price) {
// 在方法内部声明变量
double discount = 0.1;
// 使用变量进行计算
double discountedPrice = price * (1 - discount);
double totalPrice = discountedPrice * quantity;
return totalPrice;
}
在上面的示例中,变量quantity、totalPrice和discount的作用域都被最小化到了需要使用它们的地方。这样可以使代码更加清晰,读者可以更容易地理解变量的用途和范围。同时,变量的生命周期也被限制在了需要使用它们的方法内部,避免了变量被误用或意外修改的风险。
第58条:for-each循环优先于传统的for循环
建议在遍历集合或数组时优先使用for-each循环,而不是传统的for循环。使用for-each循环可以使代码更加简洁、易读,并且可以减少出错的可能性。
传统的for循环需要手动管理索引和长度,并且需要使用索引来访问集合或数组中的元素。这样容易出现索引越界错误或者忘记更新索引的情况。而for-each循环则不需要手动管理索引,它会自动迭代集合或数组中的每个元素,使代码更加简洁和易读。
具体来说,使用for-each循环有以下几个优点:
-
简洁:for-each循环可以将遍历集合或数组的代码简化为一行,不需要手动管理索引和长度。
-
安全:for-each循环在编译时会进行类型检查,可以避免类型不匹配的错误。
-
可读性:for-each循环可以使代码更加清晰易读,不需要关注索引和长度的细节。
下面是一个示例代码,演示了如何使用for-each循环和传统的for循环遍历数组:
public void printArray(int[] array) {
// 使用for-each循环遍历数组
for (int num : array) {
System.out.println(num);
}
// 使用传统的for循环遍历数组
for (int i = 0; i < array.length; i++) {
System.out.println(array[i]);
}
}
在上面的示例中,使用for-each循环可以将遍历数组的代码简化为一行,不需要手动管理索引和长度。而传统的for循环需要使用索引来访问数组中的元素,并且需要手动管理索引和长度。使用for-each循环可以使代码更加简洁、易读,并且可以减少出错的可能性。
第59条:了解和使用类库
建议了解和使用类库,这是为了避免重复造轮子,提高开发效率和代码质量。Java类库提供了丰富的功能和工具,可以帮助开发人员解决常见的问题,减少开发工作量,并且经过了广泛的测试和优化,具有较高的可靠性和性能。
具体来说,了解和使用类库可以遵循以下几个原则:
-
熟悉常用的类库:了解Java标准库中常用的类和方法,例如集合框架、IO操作、日期时间处理、正则表达式等。这些类库提供了常见问题的解决方案,可以减少开发人员的工作量。
-
使用第三方类库:除了Java标准库,还有许多优秀的第三方类库可供使用。这些类库提供了更丰富的功能和更高级的特性,可以帮助开发人员更快地实现复杂的功能。例如,Apache Commons提供了许多常用的工具类,Google Guava提供了更强大的集合类,Jackson提供了JSON处理功能等。
-
避免重复造轮子:在开发过程中,遇到常见的问题时,先查看是否有现成的类库可以使用。避免重复实现已经存在的功能,可以节省开发时间,并且可以使用经过测试和优化的类库,提高代码的质量和可靠性。
下面是一个示例代码,演示了如何使用Java标准库中的类库和第三方类库:
import java.util.ArrayList;
import java.util.List;
public class LibraryExample {
public static void main(String[] args) {
// 使用Java标准库中的ArrayList类
List<String> list = new ArrayList<>();
list.add("Java");
list.add("Library");
System.out.println(list);
// 使用第三方类库Apache Commons中的StringUtils类
String str = " Hello World ";
String trimmedStr = org.apache.commons.lang3.StringUtils.trim(str);
System.out.println(trimmedStr);
}
}
在上面的示例中,使用了Java标准库中的ArrayList类来创建一个列表,并使用add方法添加元素。同时,使用了第三方类库Apache Commons中的StringUtils类来去除字符串两端的空格。通过使用类库,可以简化开发过程,提高开发效率和代码质量。
第60条:如果需要精确的答案,请避免使用float和double
建议在需要精确答案的情况下,避免使用float和double类型。float和double是Java中表示浮点数的数据类型,它们可以表示非常大或非常小的数值范围,但是由于浮点数的内部表示方式的限制,它们无法精确地表示所有的数值。
浮点数的内部表示方式采用了二进制的科学计数法,即使用一个小数点和指数来表示一个数值。由于二进制无法精确地表示某些十进制数,因此在进行浮点数计算时,可能会出现舍入误差和精度丢失的问题。
具体来说,使用float和double可能会导致以下问题:
-
舍入误差:由于浮点数的内部表示方式的限制,进行浮点数计算时可能会出现舍入误差。例如,对于0.1这个十进制数,在float或double中无法精确表示,因此进行计算时可能会出现舍入误差。
-
精度丢失:由于浮点数的内部表示方式的限制,某些十进制数在float或double中无法精确表示,因此可能会丢失一些精度。例如,对于0.1这个十进制数,在float或double中无法精确表示,因此可能会丢失一些小数位的精度。
为了避免这些问题,如果需要精确答案,可以使用BigDecimal类来进行精确计算。BigDecimal类可以表示任意精度的十进制数,并且提供了精确的计算方法。
下面是一个示例代码,演示了使用BigDecimal进行精确计算的例子:
import java.math.BigDecimal;
public class PrecisionExample {
public static void main(String[] args) {
BigDecimal num1 = new BigDecimal("0.1");
BigDecimal num2 = new BigDecimal("0.2");
BigDecimal sum = num1.add(num2);
System.out.println(sum); // 输出0.3,精确计算结果
BigDecimal product = num1.multiply(num2);
System.out.println(product); // 输出0.02,精确计算结果
}
}
在上面的示例中,使用BigDecimal类创建了两个精确的十进制数,并使用add方法和multiply方法进行精确计算。通过使用BigDecimal类,可以避免浮点数计算中的舍入误差和精度丢失问题,得到精确的答案。
第61条:基本类型优先于装箱基本类型
建议在可能的情况下,优先使用基本类型而不是装箱基本类型。基本类型是Java中的原始数据类型,而装箱基本类型是对应的包装类,用于将基本类型包装成对象。
使用基本类型而不是装箱基本类型可以带来以下几个好处:
-
性能更好:基本类型的操作通常比装箱基本类型更高效。因为装箱基本类型需要将基本类型转换为对象,涉及到额外的内存分配和对象初始化的开销。而基本类型的操作直接在栈上进行,不需要额外的内存分配和对象初始化。
-
内存占用更小:基本类型占用的内存空间通常比装箱基本类型更小。装箱基本类型需要额外的对象头和引用字段,而基本类型只需要存储对应的数值。
-
避免空指针异常:装箱基本类型可以为null,而基本类型不可以。如果使用装箱基本类型时没有进行null检查,可能会导致空指针异常。
下面是一个示例代码,演示了使用基本类型和装箱基本类型的区别:
public class BoxingExample {
public static void main(String[] args) {
int primitive = 10;
Integer boxed = 10;
// 基本类型的操作
int result1 = primitive + 5;
System.out.println(result1); // 输出15
// 装箱基本类型的操作
Integer result2 = boxed + 5;
System.out.println(result2); // 输出15
// 装箱基本类型可能导致空指针异常
Integer nullBoxed = null;
int result3 = nullBoxed + 5; // 抛出NullPointerException
}
}
在上面的示例中,使用基本类型int和装箱基本类型Integer进行了加法操作。可以看到,基本类型的操作直接在栈上进行,而装箱基本类型的操作需要将Integer对象拆箱为int类型进行计算。此外,如果装箱基本类型为null,进行操作时会抛出空指针异常。
因此,根据第61条的建议,在可能的情况下,应优先使用基本类型而不是装箱基本类型,以获得更好的性能和内存占用,并避免空指针异常。只有在需要使用对象的特性时,才使用装箱基本类型。
第62条:如果其他类型更适合,则尽量避免使用字符串
建议在其他类型更适合的情况下,尽量避免使用字符串。虽然字符串是Java中常用的数据类型之一,但是在某些情况下,使用其他类型可以提供更好的性能、可读性和安全性。
以下是一些使用其他类型替代字符串的情况:
- 枚举类型:如果需要表示一组固定的值,且这些值是预定义的,可以使用枚举类型来替代字符串。枚举类型提供了类型安全性和可读性,同时也可以方便地进行比较和遍历。
enum Day {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}
Day today = Day.MONDAY;
- 数值类型:如果需要进行数值计算或比较,使用数值类型(如int、double等)比使用字符串更高效。数值类型可以直接进行算术运算和比较操作,而字符串需要进行解析和转换。
int count = 10;
double price = 19.99;
- 类型安全的类:如果需要表示特定的数据类型,可以使用自定义的类型安全的类来替代字符串。这样可以提供更好的类型检查和可读性。
class EmailAddress {
private String value;
public EmailAddress(String value) {
// 验证邮箱地址的格式
if (!isValidEmailAddress(value)) {
throw new IllegalArgumentException("Invalid email address");
}
this.value = value;
}
// 其他方法...
}
EmailAddress email = new EmailAddress("example@example.com");
- 集合类型:如果需要存储一组元素,并进行增删查改操作,使用集合类型(如List、Set等)比使用字符串更方便和高效。
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
names.add("Charlie");
总之,根据第62条的建议,在其他类型更适合的情况下,尽量避免使用字符串。选择合适的数据类型可以提供更好的性能、可读性和安全性。只有在需要处理文本内容或字符串操作时,才使用字符串。
第63条:了解字符串连接的性能
建议了解字符串连接的性能,并选择合适的方法进行字符串连接操作。字符串连接是在Java中常见的操作,但是不同的连接方法会对性能产生不同的影响。
在Java中,有以下几种字符串连接的方法:
- 使用"+“操作符:使用”+“操作符进行字符串连接是最简单直观的方法,但是它的性能较差。每次使用”+"操作符连接字符串时,都会创建一个新的String对象,导致频繁的内存分配和对象复制。
String result = "Hello" + " " + "World";
- 使用StringBuilder或StringBuffer:StringBuilder和StringBuffer是可变的字符串类,它们提供了高效的字符串连接操作。StringBuilder是非线程安全的,而StringBuffer是线程安全的。使用StringBuilder或StringBuffer的append方法可以避免频繁的内存分配和对象复制。
StringBuilder sb = new StringBuilder();
sb.append("Hello");
sb.append(" ");
sb.append("World");
String result = sb.toString();
- 使用String.join方法:Java 8引入了String类的join方法,可以更方便地进行字符串连接操作。它接受一个分隔符和一个字符串数组(或集合),将数组中的元素用分隔符连接起来。
String[] words = {"Hello", "World"};
String result = String.join(" ", words);
了解字符串连接的性能可以帮助我们选择合适的方法。在大量字符串连接的场景中,使用StringBuilder或StringBuffer比使用"+“操作符更高效。而在连接固定数量的字符串时,使用”+"操作符可能更简洁明了。
总之,根据第63条的建议,了解字符串连接的性能,并选择合适的方法进行字符串连接操作,可以提高程序的性能和效率。
第64条:通过接口引用对象
建议使用接口类型来引用对象,而不是使用具体的实现类。这样做可以提高代码的灵活性和可扩展性,使代码更易于维护和修改。
使用接口类型引用对象的好处有以下几点:
-
降低耦合性:通过使用接口类型引用对象,可以将代码与具体的实现类解耦。这意味着可以在不修改现有代码的情况下,轻松地替换实现类或添加新的实现类。
-
提高可扩展性:使用接口类型引用对象可以方便地添加新的实现类,从而扩展系统的功能。这样可以遵循开闭原则,即对扩展开放,对修改关闭。
-
支持多态性:通过接口类型引用对象,可以实现多态性。这意味着可以在运行时根据实际对象的类型调用相应的方法,而不需要在编译时确定具体的实现类。
下面是一个示例,演示了如何使用接口类型引用对象:
// 定义一个接口
interface Animal {
void makeSound();
}
// 实现接口的具体类
class Dog implements Animal {
@Override
public void makeSound() {
System.out.println("Dog barks");
}
}
class Cat implements Animal {
@Override
public void makeSound() {
System.out.println("Cat meows");
}
}
public class Main {
public static void main(String[] args) {
// 使用接口类型引用对象
Animal animal1 = new Dog();
Animal animal2 = new Cat();
// 调用接口方法
animal1.makeSound(); // 输出: Dog barks
animal2.makeSound(); // 输出: Cat meows
}
}
在上面的示例中,Animal接口定义了一个makeSound()方法,Dog和Cat类分别实现了该接口。在Main类中,使用Animal接口类型引用了Dog和Cat对象,然后调用了makeSound()方法。由于使用了接口类型引用对象,可以根据实际对象的类型来调用相应的方法,实现了多态性。
通过使用接口类型引用对象,可以将代码与具体的实现类解耦,提高代码的灵活性和可扩展性。这是一种良好的编程实践,可以使代码更易于维护和修改。
第65条:接口优先于反射机制
建议在可能的情况下,优先使用接口而不是反射机制。接口是一种定义行为的契约,可以提供更清晰、更可读、更可维护的代码。而反射机制则是一种动态获取和操作类的能力,它可以在运行时通过类的名称来获取类的信息并进行操作。
使用接口而不是反射机制的好处有以下几点:
-
易于理解和维护:接口提供了一种清晰的契约,定义了类应该具有的行为。通过使用接口,可以更容易地理解代码的意图和功能。而反射机制则是一种动态的、隐式的方式来获取和操作类的信息,容易使代码变得复杂和难以理解。
-
提高性能:反射机制在运行时需要进行额外的检查和处理,这会导致一定的性能损耗。而使用接口,可以在编译时进行类型检查,避免了运行时的额外开销,提高了代码的性能。
-
编译时类型检查:使用接口可以在编译时进行类型检查,确保代码的类型安全性。而反射机制则是在运行时才能确定类的信息,容易导致类型错误和运行时异常。
下面是一个示例,演示了使用接口和反射机制的对比:
// 定义一个接口
interface Animal {
void makeSound();
}
// 实现接口的具体类
class Dog implements Animal {
@Override
public void makeSound() {
System.out.println("Dog barks");
}
}
public class Main {
public static void main(String[] args) {
// 使用接口
Animal animal = new Dog();
animal.makeSound(); // 输出: Dog barks
// 使用反射机制
try {
Class<?> clazz = Class.forName("Dog");
Animal animal2 = (Animal) clazz.newInstance();
animal2.makeSound(); // 输出: Dog barks
} catch (ClassNotFoundException | IllegalAccessException | InstantiationException e) {
e.printStackTrace();
}
}
}
在上面的示例中,Animal接口定义了一个makeSound()方法,Dog类实现了该接口。在Main类中,首先使用接口类型引用了Dog对象,并调用了makeSound()方法。接着使用反射机制,通过类的名称获取了Dog类的信息,并实例化了一个Dog对象,然后调用了makeSound()方法。
通过对比可以看出,使用接口的代码更加简洁、清晰,易于理解和维护。而使用反射机制的代码则需要额外的异常处理和类型转换,增加了代码的复杂性。
因此,根据第65条的建议,在可能的情况下,应该优先使用接口而不是反射机制,以提高代码的可读性、可维护性和性能。只有在必要的情况下才使用反射机制。
第66条:谨慎地使用本地方法
谨慎地使用本地方法(Native Methods)。本地方法是指使用非Java语言(如C、C++)编写的方法,可以通过Java Native Interface(JNI)在Java程序中调用。
使用本地方法的好处有以下几点:
-
提高性能:本地方法可以直接调用底层系统的功能,可以获得更高的执行效率。对于一些对性能要求较高的场景,使用本地方法可以提升程序的运行速度。
-
访问底层资源:本地方法可以访问一些Java无法直接访问的底层资源,如操作系统的API、硬件设备等。通过使用本地方法,可以扩展Java程序的功能和能力。
然而,使用本地方法也存在一些潜在的问题和风险,需要谨慎使用:
-
可移植性问题:本地方法依赖于底层系统的特定实现,因此在不同的平台上可能存在不同的实现。这会导致程序在不同平台上的行为不一致,降低了程序的可移植性。
-
安全性问题:本地方法可以直接访问底层资源,这可能导致安全漏洞。如果本地方法没有正确地处理输入数据或没有进行足够的安全检查,可能会导致程序受到攻击。
-
调试和维护问题:本地方法的调试和维护相对复杂,需要熟悉底层语言和工具。如果本地方法出现问题,可能需要使用底层语言的调试工具进行排查和修复。
下面是一个简单的示例,演示了如何使用本地方法:
public class NativeExample {
// 声明本地方法
private native void nativeMethod();
// 加载本地库
static {
System.loadLibrary("nativeLibrary");
}
public static void main(String[] args) {
NativeExample example = new NativeExample();
example.nativeMethod(); // 调用本地方法
}
}
在上面的示例中,NativeExample类声明了一个本地方法nativeMethod()。在静态代码块中,使用System.loadLibrary()方法加载了名为"nativeLibrary"的本地库。然后在main方法中,创建了NativeExample对象,并调用了nativeMethod()方法。
需要注意的是,本地方法的实现是在外部的本地库中,需要使用底层语言(如C、C++)编写,并通过JNI与Java程序进行交互。
总之,使用本地方法可以提高性能和访问底层资源的能力,但也存在一些潜在的问题和风险。在使用本地方法时,需要谨慎考虑可移植性、安全性、调试和维护等方面的问题,并确保正确地使用和管理本地方法。
第67条:谨慎地进行优化
建议谨慎地进行优化(Optimize judiciously)。优化是指对代码进行改进以提高性能或减少资源消耗。虽然优化可以带来一些好处,但过度优化可能会导致代码变得复杂、难以理解和维护,并且可能无法带来明显的性能提升。
在进行优化时,需要谨慎考虑以下几点:
-
确定性能瓶颈:在进行优化之前,首先需要确定代码的性能瓶颈所在。通过使用性能分析工具,可以找到代码中耗时的部分,然后有针对性地进行优化。
-
优化可读性和可维护性:在进行优化时,需要权衡代码的可读性和可维护性。过度优化可能会导致代码变得复杂和难以理解,从而增加了维护的难度。因此,需要确保优化后的代码仍然具有良好的可读性和可维护性。
-
使用合适的数据结构和算法:在优化代码时,可以考虑使用更高效的数据结构和算法。例如,使用哈希表代替线性搜索,使用快速排序代替冒泡排序等。选择合适的数据结构和算法可以显著提高代码的性能。
-
避免过早优化:过早优化是指在没有明确性能问题的情况下进行优化。在代码的早期阶段,应该更关注代码的可读性、可维护性和正确性。只有在性能问题确实存在时,才进行优化。
下面是一个简单的示例,演示了如何进行优化:
public class OptimizationExample {
public static void main(String[] args) {
List<Integer> numbers = new ArrayList<>();
// 添加一百万个整数到列表中
for (int i = 0; i < 1000000; i++) {
numbers.add(i);
}
// 计算列表中所有整数的和
int sum = 0;
for (int number : numbers) {
sum += number;
}
System.out.println("Sum: " + sum);
}
}
在上面的示例中,我们使用一个列表存储一百万个整数,并计算列表中所有整数的和。这段代码的性能可能不够理想,因为使用了一个简单的线性搜索来遍历列表。为了优化性能,我们可以使用Java 8引入的流(Stream)API来计算和:
int sum = numbers.stream().mapToInt(Integer::intValue).sum();
通过使用流API,我们可以将计算和的操作转换为一条流水线,从而提高了代码的性能。
需要注意的是,优化并不总是必要的。在大多数情况下,代码的可读性和可维护性更为重要。只有在性能问题确实存在,并且通过优化可以获得明显的性能提升时,才应该进行优化。否则,过度优化可能会带来更多的问题和麻烦。
第68条:遵守普遍接受的命名惯例
遵守普遍接受的命名惯例(Adhere to generally accepted naming conventions)。命名是编程中非常重要的一部分,良好的命名可以使代码更易读、易理解和易维护。遵守普遍接受的命名惯例可以使代码更具一致性,并与其他开发人员共享代码时更易于理解。
以下是一些普遍接受的命名惯例:
-
使用有意义的名称:变量、方法和类的名称应该能够清楚地表达其用途和含义。避免使用无意义的名称或缩写,以免给其他人阅读代码带来困扰。
-
使用驼峰命名法:驼峰命名法是一种常见的命名约定,其中单词之间使用大写字母分隔。例如,myVariableName、calculateSum等。
-
使用具体的名称:尽量使用具体的名称来描述变量、方法和类的用途。例如,使用firstName而不是name来表示一个人的名字。
-
避免使用缩写:尽量避免使用缩写,除非它们是广为接受的缩写。如果必须使用缩写,应该在注释或文档中解释其含义。
-
使用一致的命名风格:在整个代码库中使用一致的命名风格可以提高代码的可读性。例如,如果使用驼峰命名法,那么所有的变量、方法和类都应该遵循这个命名风格。
下面是一个示例,演示了如何遵守普遍接受的命名惯例:
public class NamingConventionExample {
private int numberOfStudents;
private String studentName;
public void setNumberOfStudents(int numberOfStudents) {
this.numberOfStudents = numberOfStudents;
}
public String getStudentName() {
return studentName;
}
public void setStudentName(String studentName) {
this.studentName = studentName;
}
}
在上面的示例中,我们使用驼峰命名法来命名变量和方法。变量numberOfStudents和studentName具有具体的名称,能够清楚地表达其用途。同时,我们还遵循了一致的命名风格,使代码更易读和易理解。
遵守普遍接受的命名惯例可以使代码更易于理解和维护,并与其他开发人员共享代码时更具可读性。因此,在编写代码时,应该始终遵守这些命名惯例。
第十章 异常
充分发挥异常的优点,可以提高程序的可读性,可靠性和可维护性。如果使用不当,它们也会带来负面的影响。本章提供了一些关于有效使用异常的指导原则。
第69条:只针对异常的情况才使用异常
在Java中,异常处理机制是一种用于处理程序运行时错误和异常情况的机制。然而,异常处理机制的开销相对较高,因此不应该滥用异常。只有在以下情况下才应该使用异常处理:
-
异常是正常的控制流之外的情况:异常应该用于处理那些在正常情况下不应该发生的错误或异常情况。例如,当尝试打开一个不存在的文件时,会抛出FileNotFoundException异常,这是一种正常控制流之外的情况。
-
异常是无法通过返回值进行处理的情况:有些错误或异常情况无法通过返回特定的值来处理,这时可以使用异常处理机制。例如,当尝试除以零时,会抛出ArithmeticException异常,这是一种无法通过返回值来处理的情况。
-
异常是需要中断当前执行流程的情况:有些错误或异常情况需要中断当前的执行流程,并进行相应的处理。异常处理机制提供了一种方便的方式来中断当前的执行流程,并跳转到异常处理代码块。例如,当发生网络连接错误时,可以抛出IOException异常,中断当前的网络操作,并进行相应的错误处理。
然而,如果异常处理被滥用,会导致代码的可读性和性能下降。因此,应该遵循以下准则来正确使用异常处理机制:
-
不要将异常用于正常的控制流程:异常处理应该用于处理异常情况,而不是作为正常的控制流程的一部分。如果某个操作的结果是可以预见的,并且可以通过返回值进行处理,那么就不应该使用异常。
-
不要将异常用于性能优化:异常处理机制的开销相对较高,因此不应该将其用于性能优化。如果某个操作的错误或异常情况是可以预见的,并且可以通过返回特定的值来处理,那么就应该使用返回值而不是异常。
-
使用标准的异常类:Java提供了一系列标准的异常类,用于表示常见的错误和异常情况。应该优先使用这些标准的异常类,而不是自定义异常类。
-
提供有意义的异常信息:在抛出异常时,应该提供有意义的异常信息,以便于调试和错误处理。异常信息应该清晰地描述异常的原因和上下文信息。
以下是一个示例,演示了如何正确使用异常处理机制:
public class FileProcessor {
public void processFile(String filePath) throws FileNotFoundException, IOException {
try (FileInputStream fis = new FileInputStream(filePath)) {
// 读取文件内容并进行处理
} catch (FileNotFoundException e) {
// 文件不存在的异常处理
System.err.println("File not found: " + filePath);
throw e;
} catch (IOException e) {
// 文件读取错误的异常处理
System.err.println("Error reading file: " + filePath);
throw e;
}
}
}
在上述示例中,processFile方法用于处理文件内容。如果文件不存在或者读取文件时发生错误,会抛出相应的异常,并在异常处理代码块中进行错误处理。这里使用了标准的异常类FileNotFoundException和IOException,并提供了有意义的异常信息。这样,调用者可以根据异常信息进行相应的错误处理。
对于一个返回类型为void的方法,如果在service层发生了错误,可以考虑以下几种方式来处理错误:
- 返回布尔值:可以将方法的返回类型改为boolean,在方法执行成功时返回true,在发生错误时返回false。调用方可以根据返回值来判断方法是否执行成功,并根据需要进行相应的处理。
public boolean addData(Data data) {
try {
// 处理数据
return true;
} catch (Exception e) {
// 处理错误
return false;
}
}
- 使用回调函数:可以将错误处理逻辑封装成一个回调函数,并将其作为参数传递给方法。在方法执行过程中,如果发生错误,可以调用回调函数来处理错误。
public void addData(Data data, ErrorCallback errorCallback) {
try {
// 处理数据
} catch (Exception e) {
// 处理错误
errorCallback.onError(e);
}
}
- 使用返回值对象:可以定义一个包含执行结果和错误信息的返回值对象,将其作为方法的返回值。在方法执行成功时,返回包含执行结果的对象;在发生错误时,返回包含错误信息的对象。
public Result addData(Data data) {
try {
// 处理数据
return new Result(true, "添加成功");
} catch (Exception e) {
// 处理错误
return new Result(false, "添加失败:" + e.getMessage());
}
}
public class Result {
private boolean success;
private String message;
// getters and setters
}
以上是几种处理错误的方式,您可以根据具体的业务需求和开发团队的约定选择适合的方式。无论选择哪种方式,都应该保证错误信息能够传递给调用方,并且能够进行相应的错误处理。
第70条:对可恢复的情况使用受检异常,对编程错误使用运行时异常
受检异常(Checked Exception)是指在方法声明中显式声明的异常,调用该方法时必须处理或者继续抛出该异常。受检异常通常表示程序在运行过程中可能遇到的外部条件或错误,需要在编译时进行处理,以保证程序的健壮性和可靠性。
运行时异常(Runtime Exception)是指在方法声明中没有显式声明的异常,调用该方法时可以选择处理或者继续抛出该异常。运行时异常通常表示程序中的编程错误或逻辑错误,是由程序员在编码过程中犯下的错误,需要在运行时进行调试和修复。
我们应该将可能发生的可恢复的情况抛出受检异常,以强制调用方在编译时处理这些异常。这样可以提醒调用方注意可能发生的异常情况,并且可以在编译时捕获和处理这些异常,以保证程序的正确性和可靠性。
而对于编程错误或逻辑错误,我们应该抛出运行时异常。这样可以在运行时快速发现并修复这些错误,同时也可以减少代码中的冗余异常处理逻辑,提高代码的可读性和可维护性。
下面是一个示例,演示了如何根据第70条的建议来使用受检异常和运行时异常:
// 受检异常示例
public class FileProcessor {
public void processFile(String filePath) throws FileNotFoundException, IOException {
try {
// 打开文件
FileInputStream fileInputStream = new FileInputStream(filePath);
// 处理文件
// ...
// 关闭文件
fileInputStream.close();
} catch (FileNotFoundException e) {
// 处理文件不存在的情况
throw e;
} catch (IOException e) {
// 处理文件读写错误的情况
throw e;
}
}
}
// 运行时异常示例
public class Calculator {
public int divide(int dividend, int divisor) {
if (divisor == 0) {
throw new ArithmeticException("除数不能为0");
}
return dividend / divisor;
}
}
在上面的示例中,FileProcessor类中的processFile方法抛出了受检异常FileNotFoundException和IOException,调用方必须在编译时处理这些异常,以保证文件的正确处理。
而Calculator类中的divide方法抛出了运行时异常ArithmeticException,这是一个表示算术错误的异常,它是由程序员在编码过程中犯下的错误。调用方可以选择处理或者继续抛出这个异常,以便在运行时进行调试和修复。
通过合理地使用受检异常和运行时异常,我们可以提高程序的可靠性和可维护性,同时也可以更好地区分可恢复的情况和编程错误。
第71条:避免不必要地使用受检异常
在设计和使用异常时,应该避免过度使用受检异常,以免给程序的编写和使用带来不必要的复杂性和负担。
受检异常(Checked Exception)是指在方法声明中显式声明的异常,调用该方法时必须处理或者继续抛出该异常。受检异常通常表示程序在运行过程中可能遇到的外部条件或错误,需要在编译时进行处理,以保证程序的健壮性和可靠性。
然而,过度使用受检异常可能会导致以下问题:
-
异常处理代码的冗余和复杂性:每次调用一个可能抛出受检异常的方法时,都需要编写相应的异常处理代码,这会增加代码的冗余和复杂性。
-
异常处理代码的传递性:如果一个方法抛出了受检异常,那么调用该方法的方法也必须处理或者继续抛出该异常,这种异常处理代码的传递性可能会导致异常处理代码的层层嵌套,使得代码难以理解和维护。
-
异常处理代码的限制性:受检异常的处理方式是固定的,要么处理异常,要么继续抛出异常。这种限制性可能会限制程序的灵活性和可扩展性。
因此,根据第71条的建议,我们应该避免不必要地使用受检异常,只在真正需要时才使用受检异常。对于那些不太可能发生或者不太需要处理的异常情况,可以考虑使用运行时异常或者其他方式来表示和处理。
下面是一个示例,演示了如何避免不必要地使用受检异常:
// 不必要地使用受检异常的示例
public class FileProcessor {
public void processFile(String filePath) throws FileNotFoundException, IOException {
try {
// 打开文件
FileInputStream fileInputStream = new FileInputStream(filePath);
// 处理文件
// ...
// 关闭文件
fileInputStream.close();
} catch (FileNotFoundException e) {
// 处理文件不存在的情况
throw e;
} catch (IOException e) {
// 处理文件读写错误的情况
throw e;
}
}
}
// 避免不必要地使用受检异常的示例
public class FileProcessor {
public void processFile(String filePath) {
try {
// 打开文件
FileInputStream fileInputStream = new FileInputStream(filePath);
// 处理文件
// ...
// 关闭文件
fileInputStream.close();
} catch (IOException e) {
// 处理文件读写错误的情况
throw new RuntimeException("文件处理错误", e);
}
}
}
在上面的示例中,第一个FileProcessor类中的processFile方法抛出了受检异常FileNotFoundException和IOException,调用方必须在编译时处理这些异常。
而第二个FileProcessor类中的processFile方法没有显式声明任何受检异常,而是将可能发生的异常包装成了运行时异常RuntimeException并抛出。这样可以避免调用方在编译时处理这些异常,同时也减少了异常处理代码的冗余和复杂性。
通过避免不必要地使用受检异常,我们可以简化代码,提高代码的可读性和可维护性,同时也可以减少异常处理代码的负担。
第72条:优先使用标准的异常
在设计和使用异常时,应该优先使用标准的异常类来表示常见的错误和异常情况,而不是自定义异常类。
标准的异常类是指Java语言提供的已经定义好的异常类,例如NullPointerException、IllegalArgumentException、IOException等。这些异常类具有更好的可读性和可维护性,并且符合开发人员的预期。
使用标准的异常类有以下好处:
-
可读性和可维护性:标准的异常类具有明确的命名和语义,可以更清晰地表达代码中的错误和异常情况,提高代码的可读性和可维护性。
-
代码一致性:使用标准的异常类可以使代码保持一致性,使得不同的代码模块之间更容易理解和交流。
-
开发人员的预期:标准的异常类是开发人员熟悉的,他们已经习惯了处理这些异常类,因此使用标准的异常类可以符合开发人员的预期,减少错误和异常处理的困惑和错误。
下面是一个示例,演示了如何优先使用标准的异常类:
// 不优先使用标准的异常类的示例
public class CustomException extends Exception {
// 自定义异常类
// ...
}
public class Calculator {
public int divide(int dividend, int divisor) throws CustomException {
if (divisor == 0) {
throw new CustomException("除数不能为0");
}
return dividend / divisor;
}
}
// 优先使用标准的异常类的示例
public class Calculator {
public int divide(int dividend, int divisor) {
if (divisor == 0) {
throw new IllegalArgumentException("除数不能为0");
}
return dividend / divisor;
}
}
在上面的示例中,第一个Calculator类中的divide方法抛出了自定义的异常类CustomException,这增加了代码的复杂性和可读性。
而第二个Calculator类中的divide方法使用了标准的异常类IllegalArgumentException来表示除数为0的错误情况。这样可以使代码更加简洁和易读,同时也符合开发人员的预期。
通过优先使用标准的异常类,我们可以提高代码的可读性和可维护性,使得代码更加清晰和易于理解。同时,也可以减少自定义异常类带来的复杂性和不必要的开销。
第73条:抛出和抽象对应的异常
在设计和使用异常时,应该抛出和抽象对应的异常,以便于调用者能够更好地理解和处理异常情况。
抛出和抽象对应的异常意味着在方法声明中抛出的异常应该是方法实现中可能抛出的具体异常的抽象。这样做的好处是可以提供更高层次的异常信息,使得调用者能够更好地理解和处理异常情况。
下面是一个示例,演示了如何抛出和抽象对应的异常:
// 不抛出和抽象对应的异常的示例
public class FileReader {
public String readFile(String filePath) {
try {
// 读取文件的代码
// ...
} catch (IOException e) {
// 处理异常的代码
// ...
}
return null;
}
}
// 抛出和抽象对应的异常的示例
public class FileReader {
public String readFile(String filePath) throws FileNotFoundException {
try {
// 读取文件的代码
// ...
} catch (IOException e) {
throw new FileNotFoundException("文件不存在");
}
return null;
}
}
在上面的示例中,第一个FileReader类中的readFile方法捕获了IOException异常,并在异常处理代码中进行了处理。然而,这样的处理方式并没有提供足够的异常信息给调用者,调用者可能无法准确地知道发生了什么错误。
而第二个FileReader类中的readFile方法抛出了更具体的异常类FileNotFoundException,并在异常处理代码中将IOException异常转换为FileNotFoundException异常。这样可以提供更高层次的异常信息,使得调用者能够更好地理解和处理异常情况。
通过抛出和抽象对应的异常,我们可以提供更准确和有意义的异常信息,使得调用者能够更好地理解和处理异常情况。这样可以提高代码的可读性和可维护性,同时也方便调试和排查问题。
第74条:每个方法抛出的所有异常都要建立文档
在设计和使用异常时,应该为每个方法明确地文档化该方法可能抛出的所有异常,以便调用者能够了解和处理这些异常情况。
为每个方法建立文档可以提供以下好处:
-
提供使用指导:文档化异常可以告诉调用者该方法可能抛出哪些异常,以及在什么情况下会抛出这些异常。这样可以帮助调用者正确地使用该方法,并在必要时进行异常处理。
-
提高代码可读性:文档化异常可以使代码更加清晰和易读。调用者可以通过查看方法的文档来了解该方法可能抛出的异常,而不需要深入查看方法的实现细节。
-
方便异常处理:文档化异常可以帮助调用者更好地处理异常情况。调用者可以根据文档中提供的信息,选择适当的异常处理策略,例如捕获异常并进行处理、向上层方法传递异常等。
下面是一个示例,演示了如何使用@throws为方法建立文档化异常:
/**
* 从数据库中获取用户信息
* @param userId 用户ID
* @return 用户信息
* @throws SQLException 如果数据库访问出现问题
* @throws UserNotFoundException 如果用户不存在
*/
public UserInfo getUserInfo(String userId) throws SQLException, UserNotFoundException {
// 从数据库中查询用户信息的代码
// ...
}
在上面的示例中,getUserInfo方法的文档明确地列出了可能抛出的两种异常:SQLException和UserNotFoundException。调用者可以根据文档中提供的信息,正确地处理这些异常情况。
通过为每个方法建立文档化异常,我们可以提供更清晰和准确的异常信息,使得调用者能够更好地了解和处理异常情况。这样可以提高代码的可读性和可维护性,并减少因异常处理不当而导致的错误。
第75条:在细节消息中包含失败-捕获信息
在编写异常的细节消息时,应该包含失败-捕获信息,以便于调试和定位问题。
细节消息是异常对象中的一部分,用于提供关于异常原因和上下文的详细信息。包含失败-捕获信息可以提供以下好处:
-
提供调试信息:包含失败-捕获信息可以帮助开发人员更好地理解异常的发生原因和上下文。这对于调试和定位问题非常有帮助。
-
提供错误追踪:包含失败-捕获信息可以提供异常发生的堆栈跟踪信息,从而可以追踪异常的发生路径。这对于定位问题和分析异常的传播路径非常有帮助。
下面是一个示例,演示了如何在细节消息中包含失败-捕获信息:
public class DatabaseConnectionException extends Exception {
private String connectionUrl;
public DatabaseConnectionException(String message, String connectionUrl) {
super(message);
this.connectionUrl = connectionUrl;
}
@Override
public String getMessage() {
return super.getMessage() + " (Connection URL: " + connectionUrl + ")";
}
}
在上面的示例中,自定义的DatabaseConnectionException异常类包含了一个connectionUrl字段,用于存储数据库连接的URL。在重写getMessage方法时,将细节消息中包含了连接URL的信息。这样,在抛出该异常时,调用者可以通过异常对象的getMessage方法获取到包含连接URL的详细信息。
通过在细节消息中包含失败-捕获信息,我们可以提供更详细和有用的异常信息,帮助开发人员更好地理解和处理异常情况。这样可以加快问题定位和修复的速度,并提高代码的可维护性。
第76条:努力使失败保持原子性
在设计和实现方法时,应该尽量保证方法的操作是原子的,即要么全部成功执行,要么全部失败,以避免出现部分成功和部分失败的情况。
努力使失败保持原子性可以提供以下好处:
-
数据一致性:保持操作的原子性可以确保数据在操作过程中保持一致性。如果操作部分成功,部分失败,可能会导致数据不一致的情况发生。
-
简化错误处理:保持操作的原子性可以简化错误处理的逻辑。如果操作失败,可以直接抛出异常或返回错误码,而不需要进行回滚或清理操作。
下面是一个示例,演示了如何努力使失败保持原子性:
public class BankAccount {
private int balance;
public synchronized void deposit(int amount) {
balance += amount;
}
public synchronized void withdraw(int amount) throws InsufficientFundsException {
if (amount > balance) {
throw new InsufficientFundsException("Insufficient funds");
}
balance -= amount;
}
}
在上面的示例中,BankAccount类表示一个银行账户,其中的deposit和withdraw方法都使用synchronized关键字进行同步,以保证操作的原子性。如果在执行withdraw方法时发现余额不足,会抛出自定义的InsufficientFundsException异常。
通过努力使失败保持原子性,我们可以确保在多线程环境下对共享资源的操作是安全的,并且可以避免数据不一致的情况发生。这样可以提高系统的可靠性和稳定性。
第77条:不要忽略异常
在编写代码时,应该避免忽略异常,即不要仅仅使用空的catch块来捕获异常而不做任何处理。
不要忽略异常可以提供以下好处:
-
提供错误处理:异常是程序中可能出现的错误情况的表示。忽略异常意味着没有对错误进行处理,可能导致程序继续执行下去,产生更严重的问题。
-
提供调试信息:异常通常包含有关错误原因和上下文的信息。忽略异常会导致这些信息丢失,使得调试问题变得更加困难。
下面是一个示例,演示了不要忽略异常的情况:
public class FileProcessor {
public void processFile(String filePath) {
try {
// 读取文件内容
FileReader fileReader = new FileReader(filePath);
BufferedReader bufferedReader = new BufferedReader(fileReader);
String line;
while ((line = bufferedReader.readLine()) != null) {
// 处理文件内容
System.out.println(line);
}
bufferedReader.close();
} catch (IOException e) {
// 空的catch块,忽略异常
}
}
}
在上面的示例中,processFile方法用于处理文件内容。在读取文件内容的过程中,使用了FileReader和BufferedReader来读取文件,并对每一行进行处理。然而,在异常处理中,使用了一个空的catch块来忽略IOException异常。
这种情况下,如果在读取文件时发生了IO错误,程序将继续执行下去,而不会对错误进行处理。这可能导致文件内容无法正确处理,或者产生其他不可预料的问题。
为了避免忽略异常,我们应该在catch块中添加适当的处理逻辑,例如记录日志、抛出新的异常或者进行回滚操作,以确保错误得到适当的处理。
第十一章 并发
线程机制允许同时进行多个活动。并发程序设计比单线程程序设计要困难得多,因为有更多得东西可能出错,也很难重现失败。但是你无法避免并发,因为我们所做的大部分事情都需要并发,并且并发也是能否从多核的处理器中获得好的性能的一个条件,这些现在都是很平常的事了。本章阐述的建议可以帮助你编写出清晰、正确、文档组织良好的并发程序。
第78条:同步访问共享的可变数据
在多线程环境下,当多个线程同时访问和修改共享的可变数据时,应该使用同步机制来保证数据的一致性和线程安全性。
同步访问共享的可变数据可以提供以下好处:
-
数据一致性:同步机制可以确保多个线程对共享数据的访问和修改是有序的,避免出现数据不一致的情况。
-
线程安全性:同步机制可以保证多个线程对共享数据的访问是互斥的,避免出现竞态条件和并发问题。
下面是一个示例,演示了同步访问共享的可变数据的情况:
public class Counter {
private int count;
public synchronized void increment() {
count++;
}
public synchronized void decrement() {
count--;
}
public synchronized int getCount() {
return count;
}
}
在上面的示例中,Counter类表示一个计数器,其中的count变量是共享的可变数据。为了保证多个线程对count的访问和修改是同步的,我们使用了synchronized关键字来修饰increment、decrement和getCount方法。
通过使用synchronized关键字,我们确保了每次对count的访问和修改都是原子的,避免了多个线程同时修改count导致的数据不一致性和线程安全性问题。
需要注意的是,同步机制会引入一定的性能开销,因此在设计和实现时需要权衡性能和线程安全性的需求。在某些情况下,可以使用更细粒度的同步机制,如使用锁或并发容器来提高并发性能。
第79条:避免过度同步
在设计和实现多线程程序时,应该避免过度使用同步机制,只在必要的地方使用同步,以避免性能下降和死锁等问题。
避免过度同步可以提供以下好处:
-
提高性能:同步机制会引入一定的性能开销,包括线程切换、锁竞争等。过度使用同步会导致性能下降,降低程序的并发性能。
-
避免死锁:过度使用同步可能导致死锁的发生。当多个线程相互等待对方释放锁时,就会发生死锁,导致程序无法继续执行。
下面是一个示例,演示了避免过度同步的情况:
public class Counter {
private int count;
public void increment() {
synchronized (this) {
count++;
}
}
public int getCount() {
synchronized (this) {
return count;
}
}
}
在上面的示例中,Counter类表示一个计数器,其中的count变量是共享的可变数据。为了保证多个线程对count的访问和修改是同步的,我们使用了synchronized关键字来修饰increment和getCount方法。
然而,我们只在必要的地方使用了同步机制,即在对count进行访问和修改的代码块中。这样可以避免过度同步,提高了程序的并发性能。
需要注意的是,在设计和实现时需要仔细考虑同步的粒度和范围,以确保线程安全性的同时尽量减少同步的开销。可以使用锁分离、细粒度同步等技术来优化同步机制。
第80条:executor、task和stream优先于线程
在编写多线程程序时,应该优先使用Executor框架、Task和Stream API来管理和执行任务,而不是直接使用线程。
使用Executor、Task和Stream可以提供以下好处:
-
简化编程模型:使用Executor框架可以将任务的提交和执行进行解耦,使得编程模型更加简单和易于理解。通过将任务封装成Runnable或Callable对象,并提交给Executor来执行,可以避免手动创建和管理线程的复杂性。
-
提高可维护性:使用Executor框架可以更好地组织和管理任务,使得代码结构更清晰和可维护。通过使用ExecutorService接口提供的方法,可以方便地控制任务的执行、取消和获取执行结果等操作。
-
提高性能:Executor框架可以根据实际情况自动管理线程池,根据系统资源和任务负载的情况动态调整线程数量,从而提高程序的性能和效率。
下面是一个示例,演示了使用Executor框架来执行任务的情况:
public class Task implements Runnable {
private int taskId;
public Task(int taskId) {
this.taskId = taskId;
}
@Override
public void run() {
System.out.println("Task " + taskId + " is running.");
}
}
public class Main {
public static void main(String[] args) {
ExecutorService executor = Executors.newFixedThreadPool(5);
for (int i = 0; i < 10; i++) {
Task task = new Task(i);
executor.execute(task);
}
executor.shutdown();
}
}
在上面的示例中,我们定义了一个Task类,实现了Runnable接口,表示一个任务。然后,我们使用ExecutorService接口提供的execute方法将任务提交给线程池执行。
通过使用Executor框架,我们可以方便地管理和执行任务,而不需要手动创建和管理线程。同时,线程池可以根据需要动态调整线程数量,提高程序的性能和效率。
需要注意的是,Executor框架还提供了其他的功能和特性,如定时执行任务、获取任务执行结果等,可以根据实际需求进行使用。此外,Java 8引入的Stream API也提供了一种更加简洁和函数式的方式来处理集合数据的并行操作,可以进一步简化多线程编程。
第81条:并发工具优先于wait和notify
在编写多线程程序时,应该优先使用Java并发工具类(如Lock、Condition、Semaphore等)来实现线程间的协作和同步,而不是直接使用wait和notify方法。
使用并发工具类可以提供以下好处:
-
更安全的线程同步:并发工具类提供了更高级别的线程同步机制,可以更安全地实现线程间的协作和同步。相比于wait和notify方法,它们提供了更细粒度的控制和更强大的功能,可以避免一些常见的线程同步问题,如死锁、饥饿等。
-
更灵活的线程协作:并发工具类提供了更灵活的线程协作方式,可以实现更复杂的线程间通信和同步逻辑。例如,使用Condition接口可以实现更细粒度的等待和唤醒机制,可以根据特定条件来控制线程的执行。
-
更好的性能和可伸缩性:并发工具类在设计上考虑了性能和可伸缩性,可以更好地利用多核处理器和线程池等资源,提高程序的性能和并发能力。
下面是一个示例,演示了使用Lock和Condition来实现线程间的协作和同步的情况:
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Task {
private Lock lock = new ReentrantLock();
private Condition condition = lock.newCondition();
private boolean isReady = false;
public void doSomething() throws InterruptedException {
lock.lock();
try {
while (!isReady) {
condition.await();
}
// 执行任务逻辑
System.out.println("Task is running.");
} finally {
lock.unlock();
}
}
public void setReady() {
lock.lock();
try {
isReady = true;
condition.signalAll();
} finally {
lock.unlock();
}
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Task task = new Task();
Thread thread1 = new Thread(() -> {
try {
task.doSomething();
} catch (InterruptedException e) {
e.printStackTrace();
}
});
Thread thread2 = new Thread(() -> {
task.setReady();
});
thread1.start();
Thread.sleep(1000); // 等待1秒钟
thread2.start();
}
}
在上面的示例中,我们定义了一个Task类,其中包含一个Lock对象和一个Condition对象,用于实现线程间的协作和同步。在doSomething方法中,线程会等待isReady变量为true,然后执行任务逻辑。在setReady方法中,线程会将isReady变量设置为true,并通过signalAll方法唤醒等待的线程。
通过使用Lock和Condition,我们可以更安全地实现线程间的协作和同步,避免了直接使用wait和notify方法可能引发的问题。同时,Lock和Condition提供了更灵活的线程协作方式,可以根据实际需求进行控制和调整。
第82条:线程安全性的文档化
在编写多线程程序时,应该明确地文档化每个类或方法的线程安全性,以便其他开发人员能够正确地使用和理解这些类或方法。
线程安全性的文档化可以提供以下好处:
-
提供使用指南:通过明确地文档化线程安全性,可以为其他开发人员提供使用指南,告知他们如何正确地使用和调用这些类或方法。这可以避免一些常见的线程安全问题,如竞态条件、数据不一致等。
-
增强可维护性:文档化线程安全性可以增强代码的可维护性。当其他开发人员需要修改或扩展已有的线程安全类或方法时,他们可以根据文档了解到哪些部分是线程安全的,哪些部分需要额外的同步措施。
-
促进代码审查和测试:文档化线程安全性可以促进代码审查和测试的进行。其他开发人员可以根据文档来检查代码是否符合线程安全的要求,并进行相应的测试和验证。
下面是一个示例,演示了如何文档化线程安全性:
/**
* 线程安全的计数器类
*/
public class Counter {
private int count;
/**
* 增加计数器的值
* 线程安全:多个线程可以同时调用该方法而不会出现竞态条件
*/
public synchronized void increment() {
count++;
}
/**
* 获取计数器的值
* 线程安全:多个线程可以同时调用该方法而不会出现竞态条件
*/
public synchronized int getCount() {
return count;
}
}
在上面的示例中,我们定义了一个线程安全的计数器类Counter。通过使用synchronized关键字修饰increment和getCount方法,我们确保了多个线程可以同时调用这些方法而不会出现竞态条件。
同时,我们在类和方法的注释中明确地说明了这些方法的线程安全性,告知其他开发人员可以安全地使用这些方法。
通过这样的文档化,其他开发人员可以根据文档了解到Counter类是线程安全的,并且可以在多线程环境下正确地使用和调用increment和getCount方法。
第83条:慎用延迟初始化
在编写代码时,应该慎重考虑是否使用延迟初始化,因为延迟初始化可能会引入一些潜在的问题和复杂性。
延迟初始化是指在需要时才进行对象的初始化,而不是在对象创建时立即进行初始化。延迟初始化的目的是为了延迟对象的创建和初始化过程,以提高性能和节省资源。
然而,延迟初始化可能会引入以下问题:
-
线程安全性问题:延迟初始化通常需要使用同步机制来保证线程安全性。如果不正确地处理线程安全性,可能会导致竞态条件和数据不一致的问题。
-
复杂性增加:延迟初始化会增加代码的复杂性,因为需要处理对象的创建和初始化时机。这可能会导致代码更难理解、维护和调试。
-
性能损失:延迟初始化可能会导致性能损失,因为在第一次使用对象之前需要进行额外的初始化操作。如果对象的初始化成本较高,延迟初始化可能会导致性能下降。
下面是一个示例,演示了延迟初始化可能引入的问题:
public class LazyInitializationExample {
private ExpensiveObject expensiveObject;
public ExpensiveObject getExpensiveObject() {
if (expensiveObject == null) {
expensiveObject = new ExpensiveObject();
}
return expensiveObject;
}
}
在上面的示例中,我们定义了一个LazyInitializationExample类,其中包含一个expensiveObject对象。在getExpensiveObject方法中,我们使用延迟初始化的方式来创建和返回expensiveObject对象。
然而,这种延迟初始化的方式存在线程安全性问题。如果多个线程同时调用getExpensiveObject方法,并且expensiveObject为null,那么它们可能会同时执行对象的创建和初始化操作,导致竞态条件和数据不一致的问题。
为了解决这个问题,我们可以使用双重检查锁定(double-checked locking)来确保线程安全性:
public class LazyInitializationExample {
private volatile ExpensiveObject expensiveObject;
public ExpensiveObject getExpensiveObject() {
if (expensiveObject == null) {
synchronized (this) {
if (expensiveObject == null) {
expensiveObject = new ExpensiveObject();
}
}
}
return expensiveObject;
}
}
在上面的示例中,我们使用了双重检查锁定来保证线程安全性。通过使用volatile关键字修饰expensiveObject变量,我们确保了多个线程在访问expensiveObject时能够看到最新的值。同时,通过在同步块内再次检查expensiveObject是否为null,我们避免了多个线程同时执行对象的创建和初始化操作。
需要注意的是,双重检查锁定需要在Java 5及以上版本中使用,并且需要将expensiveObject变量声明为volatile。此外,双重检查锁定也可能存在一些细微的问题,因此在使用时需要仔细考虑和测试。
volatile
关键字在Java中用于确保变量的可见性和禁止指令重排序。
-
可见性:当一个变量被声明为volatile时,它的值在多个线程之间是可见的。也就是说,当一个线程修改了volatile变量的值时,其他线程能够立即看到最新的值,而不会使用缓存中的旧值。这样可以避免由于线程之间的数据不一致性而引发的问题。
-
禁止指令重排序:在Java中,编译器和处理器可能会对指令进行重排序,以提高程序的执行效率。然而,有时候指令重排序可能会导致程序的行为出现问题。当一个变量被声明为volatile时,编译器和处理器会禁止对该变量的指令重排序,从而确保程序的执行顺序符合预期。
需要注意的是,volatile关键字只能保证单个变量的可见性和禁止指令重排序,并不能保证一系列操作的原子性。如果需要保证一系列操作的原子性,可以考虑使用synchronized关键字或java.util.concurrent.atomic包中的原子类。
下面是一个示例,演示了volatile关键字的作用:
public class VolatileExample {
private volatile boolean flag = false;
public void setFlag(boolean value) {
flag = value;
}
public void printFlag() {
System.out.println("Flag: " + flag);
}
}
在上面的示例中,我们定义了一个VolatileExample类,其中包含一个flag变量。在setFlag方法中,我们将flag的值设置为指定的值。在printFlag方法中,我们打印flag的值。
如果flag变量没有被声明为volatile,那么在一个线程中调用setFlag方法修改flag的值后,另一个线程调用printFlag方法可能会看到旧的值,因为没有保证可见性。但是,如果将flag变量声明为volatile,那么在一个线程中调用setFlag方法修改flag的值后,另一个线程调用printFlag方法将能够立即看到最新的值,保证了可见性。
需要注意的是,volatile关键字的使用需要谨慎,只有在确实需要保证可见性和禁止指令重排序的情况下才使用。过度使用volatile关键字可能会导致性能下降。
第84条:不要依赖于线程调度器
在编写多线程程序时,应该避免依赖于线程调度器的行为,因为线程调度器的行为是不确定的,可能会导致程序的行为出现问题。
线程调度器是操作系统的一部分,负责决定哪个线程在某个时间点上运行。线程调度器根据一些策略(如时间片轮转、优先级等)来决定线程的执行顺序。然而,线程调度器的行为是不可预测的,不同的操作系统和硬件平台可能有不同的实现和策略。
依赖于线程调度器的行为可能会导致以下问题:
-
竞态条件:如果程序的正确性依赖于线程的执行顺序,那么在不同的操作系统和硬件平台上,可能会出现不同的线程执行顺序,从而导致竞态条件和数据不一致性。
-
死锁:如果程序中存在死锁的情况,依赖于线程调度器的行为可能会导致死锁的发生或解决。
为了避免依赖于线程调度器的行为,可以采取以下措施:
-
使用同步机制:使用同步机制(如锁、信号量等)来保证线程之间的协调和同步,而不依赖于线程调度器的行为。
-
使用线程池:使用线程池来管理线程的创建和执行,线程池可以提供更可控的线程执行环境,而不依赖于线程调度器的行为。
下面是一个示例,演示了不要依赖于线程调度器的问题:
public class ThreadSchedulerExample {
private static boolean flag = false;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
while (!flag) {
// do something
}
System.out.println("Thread 1 finished");
});
Thread thread2 = new Thread(() -> {
flag = true;
System.out.println("Thread 2 finished");
});
thread1.start();
thread2.start();
}
}
在上面的示例中,我们创建了两个线程thread1和thread2。thread1在一个循环中等待flag变量的值为true,而thread2在将flag变量的值设置为true后输出一条消息。
如果我们依赖于线程调度器的行为,那么我们期望thread2先执行,将flag的值设置为true,然后thread1检测到flag的值为true后退出循环。然而,由于线程调度器的行为是不确定的,实际上可能会出现thread1先执行的情况,导致thread1陷入无限循环,程序无法正常结束。
为了解决这个问题,我们可以使用同步机制(如volatile关键字或锁)来保证flag变量的可见性和同步,而不依赖于线程调度器的行为。
第十二章 序列化
本章讨论对象序列化,它是java的一个框架,用来将对象编码成字节流(序列化),并从字节流编码中重新构建对象(反序列化)。一旦对象被序列化,它的编码就可以从一台正在运行的虚拟机被传递到另一台虚拟机上,或者被存储到磁盘上,供后续反序列化使用。本章主要关注序列化的风险,以及如何将风险降到最低。
第85条:其他方法优先于Java序列化
在设计可序列化的类时,应该优先考虑其他方法,而不是依赖于Java序列化机制。
Java序列化是一种将对象转换为字节流的机制,可以用于对象的持久化、网络传输等场景。然而,Java序列化机制存在一些问题和限制:
-
性能问题:Java序列化机制的性能通常较低,序列化和反序列化过程需要大量的时间和资源。
-
版本兼容性问题:当类的结构发生变化时,如添加、删除或修改字段,使用Java序列化机制的类可能会导致版本兼容性问题。反序列化时,如果序列化的字节流与当前类的结构不匹配,会抛出InvalidClassException。
-
安全问题:Java序列化机制存在安全风险,恶意的序列化数据可能导致远程代码执行、拒绝服务等安全问题。
为了避免Java序列化机制的问题,可以考虑以下替代方法:
-
自定义序列化:通过实现Serializable接口的writeObject和readObject方法,手动控制对象的序列化和反序列化过程。这样可以提高性能,并且可以处理版本兼容性问题。
-
使用JSON或XML序列化:将对象转换为JSON或XML格式的字符串,可以使用第三方库(如Jackson、Gson、XStream等)来实现。这种方式通常比Java序列化更高效,并且具有更好的版本兼容性。
-
使用协议缓冲区(Protocol Buffers):协议缓冲区是一种轻量级、高效的序列化机制,可以将结构化数据序列化为二进制格式。它具有较高的性能和较小的序列化大小。
下面是一个示例,演示了使用JSON序列化代替Java序列化的情况:
import com.google.gson.Gson;
public class SerializationExample {
public static void main(String[] args) {
Person person = new Person("John", 25);
// 使用Java序列化
byte[] serializedData = serialize(person);
Person deserializedPerson = deserialize(serializedData);
System.out.println(deserializedPerson.getName()); // 输出:John
// 使用JSON序列化
String json = toJson(person);
Person deserializedPerson2 = fromJson(json);
System.out.println(deserializedPerson2.getName()); // 输出:John
}
// 使用Java序列化
private static byte[] serialize(Person person) {
// 实现序列化逻辑
// ...
return null;
}
private static Person deserialize(byte[] data) {
// 实现反序列化逻辑
// ...
return null;
}
// 使用JSON序列化
private static String toJson(Person person) {
Gson gson = new Gson();
return gson.toJson(person);
}
private static Person fromJson(String json) {
Gson gson = new Gson();
return gson.fromJson(json, Person.class);
}
}
class Person {
private String name;
private int age;
// 省略构造方法、getter和setter
// 使用Java序列化
private void writeObject(java.io.ObjectOutputStream out) throws IOException {
// 实现自定义序列化逻辑
// ...
}
private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
// 实现自定义反序列化逻辑
// ...
}
}
在上面的示例中,我们定义了一个Person类,包含name和age字段。我们使用Java序列化和JSON序列化分别对Person对象进行序列化和反序列化。
通过比较使用Java序列化和JSON序列化的方式,我们可以看到JSON序列化更简洁、性能更高,并且不会受到版本兼容性问题的影响。因此,根据第85条的建议,我们应该优先考虑使用其他方法而不是Java序列化。
第86条:谨慎地实现Serializable接口
在实现Serializable接口时,需要谨慎考虑类的可序列化性,并采取适当的措施来保护类的不变性和安全性。
Serializable接口是Java提供的一个标记接口,用于标识一个类可以被序列化。当一个类实现了Serializable接口,它的对象可以被转换为字节流,以便在网络传输、持久化等场景中使用。然而,实现Serializable接口可能会引入一些问题:
-
版本兼容性问题:当类的结构发生变化时,如添加、删除或修改字段,使用Java序列化机制的类可能会导致版本兼容性问题。反序列化时,如果序列化的字节流与当前类的结构不匹配,会抛出InvalidClassException。
-
安全问题:Java序列化机制存在安全风险,恶意的序列化数据可能导致远程代码执行、拒绝服务等安全问题。
为了谨慎实现Serializable接口,可以采取以下措施:
-
显式声明serialVersionUID:serialVersionUID是一个序列化版本号,用于标识类的版本。在类的结构发生变化时,可以通过显式声明serialVersionUID来控制版本兼容性。如果不显式声明serialVersionUID,Java序列化机制会根据类的结构自动生成一个版本号,这可能导致版本兼容性问题。
-
谨慎处理不可序列化的字段:如果一个类中包含不可序列化的字段,可以通过自定义序列化和反序列化方法来处理这些字段。在writeObject方法中,可以手动将不可序列化的字段转换为可序列化的形式;在readObject方法中,可以手动将可序列化的字段转换为不可序列化的形式。
-
谨慎处理敏感信息:如果一个类中包含敏感信息,如密码、密钥等,应该考虑将这些字段标记为transient,以防止被序列化。在writeObject方法中,可以清除敏感信息;在readObject方法中,可以重新初始化敏感信息。
下面是一个示例,演示了谨慎实现Serializable接口的情况:
import java.io.*;
public class SerializationExample {
public static void main(String[] args) {
Person person = new Person("John", 25, "password123");
// 序列化
byte[] serializedData = serialize(person);
// 反序列化
Person deserializedPerson = deserialize(serializedData);
System.out.println(deserializedPerson.getName()); // 输出:John
System.out.println(deserializedPerson.getAge()); // 输出:25
System.out.println(deserializedPerson.getPassword()); // 输出:null
}
private static byte[] serialize(Person person) {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(person);
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
private static Person deserialize(byte[] data) {
try (ByteArrayInputStream bais = new ByteArrayInputStream(data);
ObjectInputStream ois = new ObjectInputStream(bais)) {
return (Person) ois.readObject();
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
return null;
}
}
}
class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
private transient String password;
public Person(String name, int age, String password) {
this.name = name;
this.age = age;
this.password = password;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
public String getPassword() {
return password;
}
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
out.writeObject(encryptPassword(password));
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
password = decryptPassword((String) in.readObject());
}
private String encryptPassword(String password) {
// 实现密码加密逻辑
// ...
return null;
}
private String decryptPassword(String encryptedPassword) {
// 实现密码解密逻辑
// ...
return null;
}
}
在上面的示例中,我们定义了一个Person类,实现了Serializable接口。Person类包含name、age和password字段,其中password字段被标记为transient,以防止被序列化。
在Person类中,我们显式声明了serialVersionUID,并实现了writeObject和readObject方法来处理不可序列化的字段password。在writeObject方法中,我们将password字段加密后序列化;在readObject方法中,我们将序列化的password字段解密后重新初始化。
通过以上措施,我们可以谨慎地实现Serializable接口,保护类的不变性和安全性,并处理版本兼容性问题。根据第86条的建议,我们应该在实现Serializable接口时谨慎考虑类的可序列化性,并采取适当的措施来保护类的不变性和安全性。
第87条:考虑使用自定义的序列化形式
在实现Serializable接口时,可以考虑使用自定义的序列化形式,以提高性能、灵活性和安全性。
Java的序列化机制会自动将对象的所有字段进行序列化和反序列化,包括私有字段和继承的字段。然而,有时候我们可能只需要序列化对象的一部分字段,或者需要对字段进行特殊处理。这时,可以使用自定义的序列化形式来满足需求。
使用自定义的序列化形式可以带来以下好处:
-
提高性能:自定义的序列化形式可以选择性地序列化对象的字段,避免不必要的序列化操作,从而提高性能。
-
灵活性:自定义的序列化形式可以处理不可序列化的字段,如transient字段、静态字段等。通过自定义的序列化方法,可以手动处理这些字段的序列化和反序列化。
-
安全性:自定义的序列化形式可以对敏感信息进行加密、解密等处理,提高数据的安全性。
下面是一个示例,演示了使用自定义的序列化形式的情况:
import java.io.*;
public class CustomSerializationExample {
public static void main(String[] args) {
Person person = new Person("John", 25, "password123");
// 序列化
byte[] serializedData = serialize(person);
// 反序列化
Person deserializedPerson = deserialize(serializedData);
System.out.println(deserializedPerson.getName()); // 输出:John
System.out.println(deserializedPerson.getAge()); // 输出:25
System.out.println(deserializedPerson.getPassword()); // 输出:null
}
private static byte[] serialize(Person person) {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(person);
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
private static Person deserialize(byte[] data) {
try (ByteArrayInputStream bais = new ByteArrayInputStream(data);
ObjectInputStream ois = new ObjectInputStream(bais)) {
return (Person) ois.readObject();
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
return null;
}
}
}
class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
private transient String password;
public Person(String name, int age, String password) {
this.name = name;
this.age = age;
this.password = password;
}
private void writeObject(ObjectOutputStream out) throws IOException {
out.defaultWriteObject();
out.writeObject(encryptPassword(password));
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
password = decryptPassword((String) in.readObject());
}
private String encryptPassword(String password) {
// 实现密码加密逻辑
// ...
return null;
}
private String decryptPassword(String encryptedPassword) {
// 实现密码解密逻辑
// ...
return null;
}
}
在上面的示例中,我们定义了一个Person类,实现了Serializable接口。Person类包含name、age和password字段,其中password字段被标记为transient,以防止被序列化。
在Person类中,我们实现了writeObject和readObject方法来处理自定义的序列化形式。在writeObject方法中,我们将password字段加密后序列化;在readObject方法中,我们将序列化的password字段解密后重新初始化。
通过自定义的序列化形式,我们可以灵活地处理字段的序列化和反序列化,提高性能和安全性。根据第87条的建议,我们应该考虑使用自定义的序列化形式,以满足特定的需求。
第88条:保护性地编写readObject方法
在实现自定义的readObject方法时,需要采取一些措施来保护类的不变性和安全性。
在Java的序列化机制中,readObject方法用于反序列化对象。当一个类实现了Serializable接口并定义了readObject方法时,该方法会在反序列化过程中被调用,用于恢复对象的状态。
然而,由于readObject方法可以访问对象的私有字段和方法,它可能会被恶意使用来破坏类的不变性和安全性。为了防止这种情况发生,我们需要保护性地编写readObject方法,采取以下措施:
-
检查输入参数:在readObject方法中,应该对输入参数进行检查,确保它们符合预期的格式和内容。如果输入参数不符合要求,可以抛出InvalidObjectException来阻止对象的反序列化。
-
使用readResolve方法:如果一个类实现了readObject方法,那么最好也实现readResolve方法。readResolve方法可以在对象反序列化后被调用,用于返回一个替代的对象。通过使用readResolve方法,可以确保反序列化后的对象是预期的对象,而不是readObject方法中创建的新对象。
-
使用ObjectInputValidation接口:ObjectInputValidation接口可以用于在反序列化过程中对对象进行验证。通过实现ObjectInputValidation接口,并在readObject方法中注册验证对象,可以在反序列化完成后对对象进行额外的验证操作。
下面是一个示例,演示了保护性地编写readObject方法的情况:
import java.io.*;
public class ProtectiveReadObjectExample {
public static void main(String[] args) {
Person person = new Person("John", 25);
// 序列化
byte[] serializedData = serialize(person);
// 反序列化
Person deserializedPerson = deserialize(serializedData);
System.out.println(deserializedPerson.getName()); // 输出:John
System.out.println(deserializedPerson.getAge()); // 输出:25
}
private static byte[] serialize(Person person) {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(person);
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
private static Person deserialize(byte[] data) {
try (ByteArrayInputStream bais = new ByteArrayInputStream(data);
ObjectInputStream ois = new ObjectInputStream(bais)) {
return (Person) ois.readObject();
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
return null;
}
}
}
class Person implements Serializable {
private static final long serialVersionUID = 1L;
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
in.defaultReadObject();
// 检查输入参数
if (age < 0) {
throw new InvalidObjectException("Invalid age");
}
}
private Object readResolve() {
// 返回预期的对象
return new Person(name, age);
}
private void validateObject() throws InvalidObjectException {
// 对象验证逻辑
if (name == null || name.isEmpty()) {
throw new InvalidObjectException("Invalid name");
}
}
}
在上面的示例中,我们定义了一个Person类,实现了Serializable接口。Person类包含name和age字段。
在Person类中,我们实现了readObject方法来保护性地处理反序列化过程。在readObject方法中,我们检查了age字段的值,如果小于0,则抛出InvalidObjectException。
此外,我们还实现了readResolve方法和validateObject方法。readResolve方法返回一个预期的对象,以确保反序列化后的对象是预期的对象。validateObject方法用于在反序列化完成后对对象进行验证,如果验证失败,则抛出InvalidObjectException。
通过保护性地编写readObject方法,我们可以确保对象的不变性和安全性在反序列化过程中得到保护。根据第88条的建议,我们应该在实现readObject方法时采取适当的措施,以防止恶意使用和数据损坏。
第89条:对于实例控制,枚举类型优先于readResolve
在需要控制对象实例化的情况下,枚举类型优先于readResolve方法。
在Java的序列化机制中,readResolve方法可以用于在反序列化过程中返回一个替代的对象。通过实现readResolve方法,可以确保反序列化后的对象是预期的对象,而不是readObject方法中创建的新对象。
然而,使用readResolve方法来控制对象实例化存在一些问题。首先,readResolve方法只在反序列化时被调用,而在其他情况下(如反射、克隆等),仍然可以创建新的对象。其次,readResolve方法的实现可能会被继承类覆盖,导致实例化控制失效。
相比之下,枚举类型提供了更好的实例控制机制。枚举类型的实例是唯一的,无法通过反射、克隆等方式创建新的实例。因此,使用枚举类型可以确保对象的唯一性和实例控制。
下面是一个示例,演示了使用枚举类型进行实例控制的情况:
import java.io.*;
public class InstanceControlExample {
public static void main(String[] args) {
Singleton singleton = Singleton.INSTANCE;
// 序列化
byte[] serializedData = serialize(singleton);
// 反序列化
Singleton deserializedSingleton = deserialize(serializedData);
System.out.println(singleton == deserializedSingleton); // 输出:true
}
private static byte[] serialize(Serializable object) {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(object);
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
private static <T> T deserialize(byte[] data) {
try (ByteArrayInputStream bais = new ByteArrayInputStream(data);
ObjectInputStream ois = new ObjectInputStream(bais)) {
return (T) ois.readObject();
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
return null;
}
}
}
enum Singleton implements Serializable {
INSTANCE;
private Singleton() {
// 构造方法
}
}
在上面的示例中,我们定义了一个枚举类型Singleton,它实现了Serializable接口。Singleton枚举类型只有一个实例INSTANCE。
通过使用枚举类型,我们可以确保Singleton类的实例是唯一的。在序列化和反序列化过程中,INSTANCE实例会被正确地保留和恢复,而不会创建新的实例。
相比之下,如果我们使用readResolve方法来控制Singleton类的实例化,可能会存在一些问题。例如,如果readResolve方法被继承类覆盖,那么实例化控制可能会失效。
因此,根据第89条的建议,当需要实例控制时,枚举类型是一个更好的选择。枚举类型提供了更好的实例控制机制,可以确保对象的唯一性和实例控制,而不依赖于readResolve方法。
第90条:考虑用序列化代理代替序列化实例
在某些情况下,使用序列化代理可以提供更好的灵活性、安全性和性能。
在Java的序列化机制中,对象的序列化和反序列化是通过对象的字段来完成的。当一个对象被序列化时,它的所有字段都会被序列化。而当一个对象被反序列化时,它的所有字段都会被恢复。
然而,有时候直接序列化对象可能存在一些问题。例如,如果对象的字段包含敏感信息,直接序列化可能会导致敏感信息泄露。另外,如果对象的字段发生变化,直接序列化可能会导致反序列化失败。
为了解决这些问题,可以使用序列化代理。序列化代理是一个中间类,它充当了对象的代理,负责对象的序列化和反序列化。通过使用序列化代理,可以控制序列化和反序列化的过程,从而提供更好的灵活性、安全性和性能。
下面是一个示例,演示了使用序列化代理的情况:
import java.io.*;
public class SerializationProxyExample {
public static void main(String[] args) {
Person person = new Person("John", 25);
// 序列化
byte[] serializedData = serialize(person);
// 反序列化
Person deserializedPerson = deserialize(serializedData);
System.out.println(person.equals(deserializedPerson)); // 输出:true
}
private static byte[] serialize(Serializable object) {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos)) {
oos.writeObject(object);
return baos.toByteArray();
} catch (IOException e) {
e.printStackTrace();
return null;
}
}
private static <T> T deserialize(byte[] data) {
try (ByteArrayInputStream bais = new ByteArrayInputStream(data);
ObjectInputStream ois = new ObjectInputStream(bais)) {
return (T) ois.readObject();
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
return null;
}
}
}
class Person implements Serializable {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
private Object writeReplace() {
return new SerializationProxy(this);
}
private void readObject(ObjectInputStream stream) throws InvalidObjectException {
throw new InvalidObjectException("Proxy required");
}
private static class SerializationProxy implements Serializable {
private String name;
private int age;
public SerializationProxy(Person person) {
this.name = person.name;
this.age = person.age;
}
private Object readResolve() {
return new Person(name, age);
}
}
}
在上面的示例中,我们定义了一个Person类,它实现了Serializable接口。Person类有两个字段:name和age。
为了使用序列化代理,我们在Person类中定义了一个私有的writeReplace方法和一个私有的readObject方法。writeReplace方法返回一个序列化代理对象,用于在序列化过程中替代Person对象。readObject方法抛出InvalidObjectException异常,防止直接反序列化Person对象。
同时,我们定义了一个私有的SerializationProxy类,它实现了Serializable接口。SerializationProxy类包含了Person对象的字段,并在readResolve方法中创建并返回Person对象。
通过使用序列化代理,我们可以控制Person对象的序列化和反序列化过程。在序列化过程中,Person对象会被替代为SerializationProxy对象,从而保护了对象的字段。在反序列化过程中,SerializationProxy对象会被替换为Person对象,从而恢复了对象的状态。
因此,根据第90条的建议,当需要更好的灵活性、安全性和性能时,可以考虑使用序列化代理代替直接序列化对象。通过使用序列化代理,可以控制序列化和反序列化的过程,从而提供更好的控制和保护。
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。 如若内容造成侵权/违法违规/事实不符,请联系我的编程经验分享网邮箱:veading@qq.com进行投诉反馈,一经查实,立即删除!