MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java编程中的常见设计反模式

2024-01-204.8k 阅读

1. 单例模式的滥用

1.1 单例模式介绍

单例模式是一种常用的设计模式,确保一个类只有一个实例,并提供一个全局访问点。在Java中,常见的单例模式实现方式有饿汉式、懒汉式以及使用枚举实现等。饿汉式在类加载时就创建实例,例如:

public class EagerSingleton {
    private static final EagerSingleton INSTANCE = new EagerSingleton();
    private EagerSingleton() {}
    public static EagerSingleton getInstance() {
        return INSTANCE;
    }
}

懒汉式则是在第一次调用 getInstance 方法时创建实例,如下:

public class LazySingleton {
    private static LazySingleton instance;
    private LazySingleton() {}
    public static LazySingleton getInstance() {
        if (instance == null) {
            instance = new LazySingleton();
        }
        return instance;
    }
}

1.2 单例模式滥用的表现

然而,单例模式如果使用不当,就会出现反模式。其中一种常见的滥用情况是在多线程环境下使用非线程安全的懒汉式单例。上述的懒汉式单例在多线程环境下会创建多个实例,这违背了单例模式的初衷。例如,在两个线程同时调用 getInstance 方法时,可能会同时判断 instancenull,进而各自创建一个实例。

public class UnsafeLazySingleton {
    private static UnsafeLazySingleton instance;
    private UnsafeLazySingleton() {}
    public static UnsafeLazySingleton getInstance() {
        if (instance == null) {
            instance = new UnsafeLazySingleton();
        }
        return instance;
    }
}
public class ThreadA extends Thread {
    @Override
    public void run() {
        UnsafeLazySingleton instance1 = UnsafeLazySingleton.getInstance();
        System.out.println("ThreadA got instance: " + instance1);
    }
}
public class ThreadB extends Thread {
    @Override
    public void run() {
        UnsafeLazySingleton instance2 = UnsafeLazySingleton.getInstance();
        System.out.println("ThreadB got instance: " + instance2);
    }
}
public class Main {
    public static void main(String[] args) {
        ThreadA threadA = new ThreadA();
        ThreadB threadB = new ThreadB();
        threadA.start();
        threadB.start();
    }
}

在上述代码中,由于 UnsafeLazySingletongetInstance 方法不是线程安全的,在多线程环境下运行 Main 类,可能会输出两个不同的实例。

另一种滥用情况是单例类承担了过多的职责。单例类本应专注于提供唯一实例的功能,但有时开发者会将大量与该实例无关的业务逻辑放入单例类中,导致单例类变得臃肿不堪。例如,一个单例类原本用于管理数据库连接,却又被添加了用户认证、日志记录等功能,这使得代码的可维护性和可测试性大大降低。

1.3 解决方法

对于多线程环境下懒汉式单例的线程安全问题,可以通过双重检查锁定(Double - Checked Locking)来解决。改进后的代码如下:

public class SafeLazySingleton {
    private static volatile SafeLazySingleton instance;
    private SafeLazySingleton() {}
    public static SafeLazySingleton getInstance() {
        if (instance == null) {
            synchronized (SafeLazySingleton.class) {
                if (instance == null) {
                    instance = new SafeLazySingleton();
                }
            }
        }
        return instance;
    }
}

这里使用 volatile 关键字确保 instance 变量的可见性,并且通过双重检查锁定机制,只有在 instancenull 时才进行同步操作,提高了性能。

对于单例类职责过多的问题,应该遵循单一职责原则,将不同的业务逻辑拆分到不同的类中。例如,将用户认证功能封装到一个专门的 UserAuthenticator 类中,将日志记录功能封装到 Logger 类中,而单例类只专注于管理数据库连接。

2. 过度使用继承

2.1 继承的基本概念

继承是Java面向对象编程的重要特性之一,它允许一个类(子类)继承另一个类(父类)的属性和方法。通过继承,子类可以复用父类的代码,减少代码冗余。例如:

class Animal {
    protected String name;
    public Animal(String name) {
        this.name = name;
    }
    public void eat() {
        System.out.println(name + " is eating.");
    }
}
class Dog extends Animal {
    public Dog(String name) {
        super(name);
    }
    public void bark() {
        System.out.println(name + " is barking.");
    }
}

在上述代码中,Dog 类继承自 Animal 类,拥有了 Animal 类的 name 属性和 eat 方法,并且还添加了自己特有的 bark 方法。

2.2 过度使用继承的问题

过度使用继承会导致代码的可维护性和可扩展性变差。一种常见的情况是子类与父类之间的耦合度过高。当父类的实现发生变化时,可能会影响到所有的子类。例如,假设 Animal 类的 eat 方法实现被修改,所有继承自 Animal 类的子类(如 Dog 类)的 eat 行为也会随之改变,即使有些子类并不希望这种改变发生。

另外,继承层次过深也是一个问题。随着继承层次的不断加深,代码变得越来越难以理解和维护。例如,在一个复杂的图形绘制系统中,可能有一个 Shape 类作为基类,然后有 Rectangle 类继承自 ShapeSquare 类又继承自 RectangleColoredSquare 类再继承自 Square。如果这个继承层次继续加深,当需要对 Shape 类进行修改时,很难预测这种修改会对整个继承体系产生什么样的影响。

2.3 解决方法

为了避免过度使用继承带来的问题,可以优先考虑使用组合(Composition)而不是继承。组合是一种通过将对象作为另一个对象的成员变量来实现代码复用的方式。例如,对于上述的 Dog 类,可以通过组合 Animal 类的功能来实现:

class Animal {
    protected String name;
    public Animal(String name) {
        this.name = name;
    }
    public void eat() {
        System.out.println(name + " is eating.");
    }
}
class Dog {
    private Animal animal;
    public Dog(String name) {
        this.animal = new Animal(name);
    }
    public void eat() {
        animal.eat();
    }
    public void bark() {
        System.out.println(animal.name + " is barking.");
    }
}

这样,Dog 类与 Animal 类之间的耦合度降低,当 Animal 类的实现发生变化时,Dog 类可以根据自身需求选择是否调整。同时,组合方式使得代码更加灵活,易于扩展和维护。

3. 神类(God Class)

3.1 神类的定义

神类是指一个类承担了过多的职责,拥有大量的方法和属性,几乎可以完成系统中各个方面的任务。例如,在一个电子商务系统中,可能有一个 SystemManager 类,它既负责用户管理(包括注册、登录、修改密码等操作),又负责商品管理(添加商品、修改商品信息、删除商品等),还负责订单管理(创建订单、处理订单支付、跟踪订单状态等)。

public class SystemManager {
    // 用户管理方法
    public void registerUser(String username, String password) {
        // 实现用户注册逻辑
    }
    public boolean loginUser(String username, String password) {
        // 实现用户登录逻辑
        return true;
    }
    public void changePassword(String username, String newPassword) {
        // 实现修改密码逻辑
    }
    // 商品管理方法
    public void addProduct(String productName, double price) {
        // 实现添加商品逻辑
    }
    public void updateProduct(String productId, String newName, double newPrice) {
        // 实现修改商品信息逻辑
    }
    public void deleteProduct(String productId) {
        // 实现删除商品逻辑
    }
    // 订单管理方法
    public void createOrder(String userId, List<String> productIds) {
        // 实现创建订单逻辑
    }
    public void processPayment(String orderId, double amount) {
        // 实现订单支付逻辑
    }
    public String getOrderStatus(String orderId) {
        // 实现获取订单状态逻辑
        return "Pending";
    }
}

3.2 神类带来的问题

神类的存在使得代码的可读性、可维护性和可测试性都很差。由于一个类承担了过多的职责,当需要修改其中某一项功能时,可能会影响到其他功能。例如,当修改用户登录逻辑时,可能不小心影响到商品管理或订单管理的功能。而且,由于方法和属性众多,阅读和理解这个类的代码变得非常困难。在测试方面,由于一个类涉及多个功能模块,很难对每个功能进行独立测试,增加了测试的复杂度。

3.3 解决方法

解决神类问题的关键是遵循单一职责原则(Single Responsibility Principle,SRP),将神类拆分成多个小的类,每个类只负责一个特定的职责。例如,将上述的 SystemManager 类拆分成 UserManager 类、ProductManager 类和 OrderManager 类:

public class UserManager {
    public void registerUser(String username, String password) {
        // 实现用户注册逻辑
    }
    public boolean loginUser(String username, String password) {
        // 实现用户登录逻辑
        return true;
    }
    public void changePassword(String username, String newPassword) {
        // 实现修改密码逻辑
    }
}
public class ProductManager {
    public void addProduct(String productName, double price) {
        // 实现添加商品逻辑
    }
    public void updateProduct(String productId, String newName, double newPrice) {
        // 实现修改商品信息逻辑
    }
    public void deleteProduct(String productId) {
        // 实现删除商品逻辑
    }
}
public class OrderManager {
    public void createOrder(String userId, List<String> productIds) {
        // 实现创建订单逻辑
    }
    public void processPayment(String orderId, double amount) {
        // 实现订单支付逻辑
    }
    public String getOrderStatus(String orderId) {
        // 实现获取订单状态逻辑
        return "Pending";
    }
}

这样拆分后,每个类的职责清晰,代码的可读性、可维护性和可测试性都得到了显著提高。

4. 重复代码(Duplicated Code)

4.1 重复代码的表现形式

重复代码是指在程序中存在多处相似或完全相同的代码片段。一种常见的形式是在不同的类中存在相似的方法。例如,在一个图形绘制系统中,有 Circle 类和 Rectangle 类,它们都需要计算图形的面积和周长,并且计算逻辑相似:

class Circle {
    private double radius;
    public Circle(double radius) {
        this.radius = radius;
    }
    public double calculateArea() {
        return Math.PI * radius * radius;
    }
    public double calculatePerimeter() {
        return 2 * Math.PI * radius;
    }
}
class Rectangle {
    private double width;
    private double height;
    public Rectangle(double width, double height) {
        this.width = width;
        this.height = height;
    }
    public double calculateArea() {
        return width * height;
    }
    public double calculatePerimeter() {
        return 2 * (width + height);
    }
}

虽然计算面积和周长的具体公式不同,但方法名和功能逻辑的结构是相似的。

另一种形式是在同一个类的不同方法中存在重复代码。例如:

public class FileProcessor {
    public void processFile1(String filePath) {
        File file = new File(filePath);
        if (file.exists()) {
            try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    // 处理每一行数据
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    public void processFile2(String filePath) {
        File file = new File(filePath);
        if (file.exists()) {
            try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    // 处理每一行数据
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

processFile1processFile2 方法中,打开文件、检查文件是否存在以及读取文件内容的代码是重复的。

4.2 重复代码带来的问题

重复代码增加了代码的冗余度,使得代码量增大,占用更多的存储空间。更严重的是,当需要修改这些重复代码的功能时,必须在多个地方进行修改,如果遗漏了其中一处,就会导致程序出现不一致的行为。例如,在上述图形绘制系统中,如果需要修改计算面积的方法的返回值格式,就需要同时修改 Circle 类和 Rectangle 类的 calculateArea 方法。在 FileProcessor 类中,如果需要修改文件读取的异常处理逻辑,就需要在 processFile1processFile2 方法中同时修改。

4.3 解决方法

对于不同类中相似方法的重复代码问题,可以通过提取公共抽象类或接口来解决。例如,在图形绘制系统中,可以创建一个 Shape 抽象类,定义 calculateAreacalculatePerimeter 抽象方法,然后让 Circle 类和 Rectangle 类继承自 Shape 类并实现这些抽象方法:

abstract class Shape {
    public abstract double calculateArea();
    public abstract double calculatePerimeter();
}
class Circle extends Shape {
    private double radius;
    public Circle(double radius) {
        this.radius = radius;
    }
    @Override
    public double calculateArea() {
        return Math.PI * radius * radius;
    }
    @Override
    public double calculatePerimeter() {
        return 2 * Math.PI * radius;
    }
}
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;
    }
    @Override
    public double calculatePerimeter() {
        return 2 * (width + height);
    }
}

对于同一个类中不同方法的重复代码问题,可以将重复代码提取到一个单独的方法中。例如,在 FileProcessor 类中:

public class FileProcessor {
    private void processFile(String filePath) {
        File file = new File(filePath);
        if (file.exists()) {
            try (BufferedReader reader = new BufferedReader(new FileReader(file))) {
                String line;
                while ((line = reader.readLine()) != null) {
                    // 处理每一行数据
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    public void processFile1(String filePath) {
        processFile(filePath);
    }
    public void processFile2(String filePath) {
        processFile(filePath);
    }
}

这样,通过提取公共方法,减少了代码的重复,提高了代码的可维护性。

5. 魔法数字(Magic Numbers)

5.1 魔法数字的定义

魔法数字是指在代码中直接出现的常量值,没有任何解释或定义。例如,在一个计算商品折扣的方法中:

public class Product {
    private double price;
    public Product(double price) {
        this.price = price;
    }
    public double calculateDiscountedPrice() {
        return price * 0.8;
    }
}

这里的 0.8 就是一个魔法数字,它表示商品的折扣率,但从代码中很难直接看出这个数字的含义。

5.2 魔法数字带来的问题

魔法数字使得代码的可读性变差,其他人阅读代码时很难理解这些数字的意义。而且,如果需要修改这些常量值,例如将折扣率从 0.8 改为 0.75,就需要在代码中查找所有出现 0.8 的地方并进行修改,容易遗漏,导致程序出现错误。另外,魔法数字也不利于代码的可维护性和可扩展性,当业务需求发生变化,需要对这些常量进行调整时,修改代码变得比较困难。

5.3 解决方法

解决魔法数字问题的方法是使用常量来代替直接出现的数字。例如,在上述 Product 类中:

public class Product {
    private static final double DISCOUNT_RATE = 0.8;
    private double price;
    public Product(double price) {
        this.price = price;
    }
    public double calculateDiscountedPrice() {
        return price * DISCOUNT_RATE;
    }
}

这样,通过定义常量 DISCOUNT_RATE,代码的可读性得到提高,当需要修改折扣率时,只需要修改常量的值即可,降低了出错的风险,同时也提高了代码的可维护性和可扩展性。

6. 全局变量的滥用

6.1 全局变量的概念

在Java中,全局变量通常是指类的静态成员变量。例如:

public class GlobalVariableExample {
    public static int globalValue;
    public static void updateGlobalValue(int value) {
        globalValue = value;
    }
    public static int getGlobalValue() {
        return globalValue;
    }
}

这里的 globalValue 就是一个全局变量,可以在类的任何方法中访问和修改,并且所有使用这个类的地方都可以共享这个变量的值。

6.2 全局变量滥用的问题

全局变量的滥用会导致代码的可维护性和可测试性变差。由于全局变量可以在程序的任何地方被修改,很难追踪变量值的变化过程,当程序出现问题时,调试变得非常困难。例如,在一个大型项目中,多个类可能都会访问和修改 GlobalVariableExample 类中的 globalValue 变量,当 globalValue 出现异常值时,很难确定是哪个类在什么情况下修改了它。

另外,全局变量还会增加类与类之间的耦合度。一个类对全局变量的修改可能会影响到其他依赖这个全局变量的类的行为,使得代码的独立性和可复用性降低。

6.3 解决方法

为了避免全局变量滥用带来的问题,应该尽量减少全局变量的使用。如果确实需要在多个地方共享数据,可以通过参数传递的方式来实现。例如,将上述代码修改为:

public class NoGlobalVariableExample {
    public int calculateValue(int input) {
        // 根据输入计算值
        return input * 2;
    }
}

在使用这个类时:

public class Main {
    public static void main(String[] args) {
        NoGlobalVariableExample example = new NoGlobalVariableExample();
        int result = example.calculateValue(5);
        System.out.println("Result: " + result);
    }
}

这样,通过参数传递数据,避免了使用全局变量,提高了代码的可维护性和可测试性,同时降低了类与类之间的耦合度。

7. 硬编码(Hard - Coding)

7.1 硬编码的表现

硬编码是指在代码中直接使用具体的值,而不是将这些值提取出来作为配置或参数。例如,在一个连接数据库的方法中:

public class DatabaseConnection {
    public Connection getConnection() {
        try {
            return DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "root", "password");
        } catch (SQLException e) {
            e.printStackTrace();
            return null;
        }
    }
}

这里的数据库连接URL、用户名和密码都是硬编码在代码中的。

7.2 硬编码带来的问题

硬编码使得代码的灵活性和可维护性很差。当数据库的地址、用户名或密码发生变化时,就需要修改代码并重新部署应用程序。而且,将敏感信息(如密码)硬编码在代码中存在安全风险,一旦代码泄露,数据库的安全就会受到威胁。

7.3 解决方法

解决硬编码问题的方法是将这些值提取到配置文件中,然后在代码中读取配置文件。例如,可以使用Java的属性文件(.properties)。创建一个 db.properties 文件:

db.url=jdbc:mysql://localhost:3306/mydb
db.username=root
db.password=password

在代码中读取这个配置文件:

import java.io.IOException;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.Properties;
public class DatabaseConnection {
    public Connection getConnection() {
        try (InputStream inputStream = getClass().getClassLoader().getResourceAsStream("db.properties")) {
            Properties properties = new Properties();
            properties.load(inputStream);
            String url = properties.getProperty("db.url");
            String username = properties.getProperty("db.username");
            String password = properties.getProperty("db.password");
            return DriverManager.getConnection(url, username, password);
        } catch (IOException | SQLException e) {
            e.printStackTrace();
            return null;
        }
    }
}

这样,当数据库相关信息发生变化时,只需要修改配置文件,而不需要修改代码,提高了代码的灵活性和可维护性,同时也增强了安全性。