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

Java类的设计原则与最佳实践

2022-07-304.5k 阅读

单一职责原则(SRP)

  1. 原则定义: 一个类应该只有一个引起它变化的原因。通俗来讲,就是一个类只负责一项职责,不要让一个类承担过多的功能。如果一个类承担了过多的职责,当其中一项职责发生变化时,可能会影响到其他职责的正常运行,从而违反了“高内聚,低耦合”的设计理念。
  2. 违反 SRP 的示例: 假设我们有一个 UserService 类,它既负责用户的注册逻辑,又负责用户的登录逻辑,同时还负责用户信息的查询逻辑。代码如下:
public class UserService {
    public void registerUser(String username, String password) {
        // 注册逻辑,例如将用户信息保存到数据库
        System.out.println("用户 " + username + " 注册成功");
    }

    public boolean loginUser(String username, String password) {
        // 登录逻辑,例如验证用户名和密码是否匹配
        System.out.println("用户 " + username + " 尝试登录");
        return true;
    }

    public String queryUserInfo(String username) {
        // 查询用户信息逻辑,例如从数据库中获取用户信息
        return "用户 " + username + " 的信息";
    }
}

在这个例子中,UserService 类承担了注册、登录和查询用户信息三项职责。如果注册逻辑发生变化,比如需要对密码进行加密处理,那么 registerUser 方法会修改,而这种修改有可能会影响到 loginUserqueryUserInfo 方法,尽管它们与密码加密逻辑并无直接关系。这就增加了代码维护的难度和出错的风险。 3. 遵循 SRP 的重构: 我们可以将 UserService 类按照职责拆分成三个类:UserRegistrationServiceUserLoginServiceUserInfoQueryService

public class UserRegistrationService {
    public void registerUser(String username, String password) {
        // 注册逻辑,例如将用户信息保存到数据库
        System.out.println("用户 " + username + " 注册成功");
    }
}

public class UserLoginService {
    public boolean loginUser(String username, String password) {
        // 登录逻辑,例如验证用户名和密码是否匹配
        System.out.println("用户 " + username + " 尝试登录");
        return true;
    }
}

public class UserInfoQueryService {
    public String queryUserInfo(String username) {
        // 查询用户信息逻辑,例如从数据库中获取用户信息
        return "用户 " + username + " 的信息";
    }
}

通过这样的拆分,每个类只负责一项职责,当某个职责的逻辑发生变化时,只需要修改对应的类,不会对其他类产生影响,代码的维护性和可扩展性都得到了提高。

开闭原则(OCP)

  1. 原则定义: 软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。也就是说,当软件系统需要增加新功能时,应该通过扩展现有代码来实现,而不是直接修改现有代码。这有助于保持系统的稳定性,减少因修改代码而引入的新错误。
  2. 违反 OCP 的示例: 假设我们有一个简单的图形绘制程序,当前只支持绘制圆形和矩形。我们定义了一个 Shape 类和两个具体的图形类 CircleRectangle,以及一个 ShapeDrawer 类来绘制图形。
class Shape {
}

class Circle extends Shape {
}

class Rectangle extends Shape {
}

class ShapeDrawer {
    public void drawShape(Shape shape) {
        if (shape instanceof Circle) {
            System.out.println("绘制圆形");
        } else if (shape instanceof Rectangle) {
            System.out.println("绘制矩形");
        }
    }
}

如果现在要增加一种新的图形,比如三角形,就需要修改 ShapeDrawer 类的 drawShape 方法,在 if - else 语句中添加对三角形的判断和绘制逻辑。这显然违反了开闭原则,因为每次添加新的图形都要修改 ShapeDrawer 类的代码。

class Triangle extends Shape {
}

class ShapeDrawer {
    public void drawShape(Shape shape) {
        if (shape instanceof Circle) {
            System.out.println("绘制圆形");
        } else if (shape instanceof Rectangle) {
            System.out.println("绘制矩形");
        } else if (shape instanceof Triangle) {
            System.out.println("绘制三角形");
        }
    }
}
  1. 遵循 OCP 的重构: 我们可以使用多态来重构代码,让 Shape 类定义一个抽象的 draw 方法,具体的图形类实现这个方法。这样,当添加新的图形时,只需要创建新的图形类并实现 draw 方法,而不需要修改 ShapeDrawer 类。
abstract class Shape {
    public abstract void draw();
}

class Circle extends Shape {
    @Override
    public void draw() {
        System.out.println("绘制圆形");
    }
}

class Rectangle extends Shape {
    @Override
    public void draw() {
        System.out.println("绘制矩形");
    }
}

class ShapeDrawer {
    public void drawShape(Shape shape) {
        shape.draw();
    }
}

class Triangle extends Shape {
    @Override
    public void draw() {
        System.out.println("绘制三角形");
    }
}

现在,当需要添加新的图形时,只需要创建新的类继承 Shape 并实现 draw 方法,ShapeDrawer 类无需修改。例如添加 Triangle 类后,使用 ShapeDrawer 绘制三角形的代码如下:

ShapeDrawer drawer = new ShapeDrawer();
Shape triangle = new Triangle();
drawer.drawShape(triangle);

里氏替换原则(LSP)

  1. 原则定义: 所有引用基类(父类)的地方必须能透明地使用其子类的对象。也就是说,子类对象必须能够替换掉它们的父类对象,而程序的功能不受到影响。这是实现开闭原则的重要基础,保证了继承体系的稳定性。
  2. 违反 LSP 的示例: 假设我们有一个 Rectangle 类和一个 Square 类,Square 类继承自 RectangleRectangle 类有 setWidthsetHeight 方法来设置矩形的宽和高。
class Rectangle {
    private int width;
    private int height;

    public int getWidth() {
        return width;
    }

    public void setWidth(int width) {
        this.width = width;
    }

    public int getHeight() {
        return height;
    }

    public void setHeight(int height) {
        this.height = height;
    }
}

class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(int height) {
        super.setWidth(height);
        super.setHeight(height);
    }
}

在这个例子中,Square 类重写了 setWidthsetHeight 方法,使得设置宽和高时保持相等,符合正方形的特性。但是,如果有一个依赖于 Rectangle 类的方法,期望通过设置不同的宽和高来得到不同的矩形,当传入 Square 对象时,就会出现不符合预期的结果。

public class LspViolationExample {
    public static void resizeRectangle(Rectangle rectangle) {
        rectangle.setWidth(5);
        rectangle.setHeight(10);
        // 期望得到宽为5,高为10的矩形,但如果传入Square对象,结果不符合预期
    }
}
  1. 遵循 LSP 的重构: 为了遵循里氏替换原则,我们可以重新设计类结构。不再让 Square 直接继承 Rectangle,而是让它们都实现一个接口 Shape,并分别实现自己的 getWidthgetHeight 等方法。
interface Shape {
    int getWidth();
    int getHeight();
}

class Rectangle implements Shape {
    private int width;
    private int height;

    @Override
    public int getWidth() {
        return width;
    }

    public void setWidth(int width) {
        this.width = width;
    }

    @Override
    public int getHeight() {
        return height;
    }

    public void setHeight(int height) {
        this.height = height;
    }
}

class Square implements Shape {
    private int sideLength;

    @Override
    public int getWidth() {
        return sideLength;
    }

    public void setSideLength(int sideLength) {
        this.sideLength = sideLength;
    }

    @Override
    public int getHeight() {
        return sideLength;
    }
}

这样,依赖于 Shape 接口的方法可以透明地使用 RectangleSquare 对象,符合里氏替换原则。例如:

public class LspComplianceExample {
    public static void resizeShape(Shape shape) {
        // 这里可以根据不同的Shape实现类进行不同的操作
    }
}

接口隔离原则(ISP)

  1. 原则定义: 客户端不应该依赖它不需要的接口。一个类对另一个类的依赖应该建立在最小的接口上。也就是说,我们应该将大的接口拆分成多个小的接口,让客户端只依赖它们实际需要的接口,避免因依赖不必要的接口而导致的耦合和复杂性增加。
  2. 违反 ISP 的示例: 假设我们有一个 Animal 接口,包含 eatflyswim 方法,然后有 BirdFish 类实现这个接口。
interface Animal {
    void eat();
    void fly();
    void swim();
}

class Bird implements Animal {
    @Override
    public void eat() {
        System.out.println("鸟吃东西");
    }

    @Override
    public void fly() {
        System.out.println("鸟飞翔");
    }

    @Override
    public void swim() {
        // 大多数鸟不会游泳,这里实现可能不合理
        System.out.println("鸟尝试游泳");
    }
}

class Fish implements Animal {
    @Override
    public void eat() {
        System.out.println("鱼吃东西");
    }

    @Override
    public void fly() {
        // 鱼不会飞,这里实现不合理
        System.out.println("鱼尝试飞");
    }

    @Override
    public void swim() {
        System.out.println("鱼游泳");
    }
}

在这个例子中,BirdFish 类都实现了 Animal 接口,但它们并不需要所有的方法。Bird 类不需要 swim 方法,Fish 类不需要 fly 方法,这就导致了实现类不得不实现一些不必要的方法,违反了接口隔离原则。 3. 遵循 ISP 的重构: 我们可以将 Animal 接口拆分成多个小接口,比如 EatableFlyableSwimmable,然后让 BirdFish 类根据自身特性实现相应的接口。

interface Eatable {
    void eat();
}

interface Flyable {
    void fly();
}

interface Swimmable {
    void swim();
}

class Bird implements Eatable, Flyable {
    @Override
    public void eat() {
        System.out.println("鸟吃东西");
    }

    @Override
    public void fly() {
        System.out.println("鸟飞翔");
    }
}

class Fish implements Eatable, Swimmable {
    @Override
    public void eat() {
        System.out.println("鱼吃东西");
    }

    @Override
    public void swim() {
        System.out.println("鱼游泳");
    }
}

这样,BirdFish 类只依赖它们实际需要的接口,代码的耦合度降低,更符合接口隔离原则。

依赖倒置原则(DIP)

  1. 原则定义: 高层模块不应该依赖低层模块,两者都应该依赖其抽象;抽象不应该依赖细节,细节应该依赖抽象。在 Java 中,通常表现为依赖接口而不是具体实现类,这样可以提高代码的可维护性和可扩展性。
  2. 违反 DIP 的示例: 假设我们有一个 EmailSender 类用于发送邮件,还有一个 UserService 类依赖 EmailSender 类来给用户发送注册成功邮件。
class EmailSender {
    public void sendEmail(String to, String content) {
        System.out.println("发送邮件给 " + to + ",内容:" + content);
    }
}

class UserService {
    private EmailSender emailSender;

    public UserService() {
        this.emailSender = new EmailSender();
    }

    public void registerUser(String username) {
        // 注册逻辑
        System.out.println("用户 " + username + " 注册成功");
        emailSender.sendEmail(username, "注册成功通知");
    }
}

在这个例子中,UserService 类直接依赖 EmailSender 具体类,这使得 UserService 类与 EmailSender 类紧密耦合。如果以后需要更换邮件发送方式,比如使用短信发送代替邮件发送,就需要修改 UserService 类的代码。 3. 遵循 DIP 的重构: 我们可以定义一个 MessageSender 接口,EmailSender 类实现这个接口,然后 UserService 类依赖 MessageSender 接口。

interface MessageSender {
    void sendMessage(String to, String content);
}

class EmailSender implements MessageSender {
    @Override
    public void sendMessage(String to, String content) {
        System.out.println("发送邮件给 " + to + ",内容:" + content);
    }
}

class UserService {
    private MessageSender messageSender;

    public UserService(MessageSender messageSender) {
        this.messageSender = messageSender;
    }

    public void registerUser(String username) {
        // 注册逻辑
        System.out.println("用户 " + username + " 注册成功");
        messageSender.sendMessage(username, "注册成功通知");
    }
}

现在,UserService 类依赖 MessageSender 接口,而不是具体的 EmailSender 类。如果需要更换消息发送方式,只需要创建一个实现 MessageSender 接口的新类,比如 SmsSender,并将其传递给 UserService 类的构造函数即可,无需修改 UserService 类的核心逻辑。

class SmsSender implements MessageSender {
    @Override
    public void sendMessage(String to, String content) {
        System.out.println("发送短信给 " + to + ",内容:" + content);
    }
}

// 使用SmsSender发送消息
MessageSender smsSender = new SmsSender();
UserService userService = new UserService(smsSender);
userService.registerUser("testUser");

类的封装性实践

  1. 成员变量私有化: 在 Java 类中,应该将成员变量声明为 private,这样可以防止外部直接访问和修改成员变量,保证数据的安全性和一致性。通过提供公共的 gettersetter 方法来访问和修改成员变量,可以在方法中添加必要的逻辑验证。 例如,我们有一个 Person 类:
public class Person {
    private String name;
    private int age;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        if (name != null &&!name.isEmpty()) {
            this.name = name;
        }
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        if (age > 0 && age < 150) {
            this.age = age;
        }
    }
}

在这个例子中,nameage 成员变量被声明为 private,通过 gettersetter 方法来访问和修改。在 setNamesetAge 方法中,添加了一些逻辑验证,确保设置的值是合理的。 2. 方法的合理封装: 类中的方法应该具有明确的职责,并且将实现细节封装起来。例如,一个银行账户类 BankAccount 可能有存款和取款的方法,这些方法内部处理账户余额的增减逻辑,外部只需要调用这些方法而不需要了解具体的实现细节。

public class BankAccount {
    private double balance;

    public BankAccount(double initialBalance) {
        this.balance = initialBalance;
    }

    public void deposit(double amount) {
        if (amount > 0) {
            balance += amount;
        }
    }

    public boolean withdraw(double amount) {
        if (amount > 0 && amount <= balance) {
            balance -= amount;
            return true;
        }
        return false;
    }

    public double getBalance() {
        return balance;
    }
}

BankAccount 类中,depositwithdraw 方法封装了账户余额的操作逻辑,外部调用者只需要关心如何使用这些方法,而不需要知道余额是如何具体增减的。

类的继承与多态的最佳实践

  1. 谨慎使用继承: 继承虽然是一种强大的代码复用机制,但也会带来一定的耦合性。在使用继承时,应该确保子类与父类之间存在“is - a”的关系,即子类确实是父类的一种具体类型。例如,Dog 类继承自 Animal 类是合理的,因为狗确实是动物的一种。但如果将 Car 类继承自 Animal 类,就不符合逻辑,即使这样可能会复用一些 Animal 类的属性和方法,但这种继承关系是错误的。 另外,过多的继承层次也会导致代码的复杂性增加,难以维护。所以在设计类的继承结构时,要尽量保持层次的简洁。
  2. 利用多态实现灵活性: 多态允许我们通过基类的引用调用子类的方法,从而实现代码的灵活性和扩展性。例如,我们有一个 Shape 类和它的子类 CircleRectangle,可以通过多态来实现图形的统一绘制。
abstract class Shape {
    public abstract void draw();
}

class Circle extends Shape {
    @Override
    public void draw() {
        System.out.println("绘制圆形");
    }
}

class Rectangle extends Shape {
    @Override
    public void draw() {
        System.out.println("绘制矩形");
    }
}

public class ShapeDrawingApp {
    public static void main(String[] args) {
        Shape circle = new Circle();
        Shape rectangle = new Rectangle();

        Shape[] shapes = {circle, rectangle};
        for (Shape shape : shapes) {
            shape.draw();
        }
    }
}

在这个例子中,通过 Shape 类型的数组存储不同的图形对象,并调用 draw 方法,实现了多态。当需要添加新的图形时,只需要创建新的子类继承 Shape 并实现 draw 方法,而不需要修改遍历图形数组并调用 draw 方法的代码。

类的设计与性能考量

  1. 避免不必要的对象创建: 在类的设计中,如果某些对象的创建开销较大,应该尽量避免不必要的重复创建。例如,我们有一个工具类 MathUtils,其中有一个方法 calculatePi 用于计算圆周率,每次调用都创建一个新的 BigDecimal 对象来进行高精度计算。
import java.math.BigDecimal;

public class MathUtils {
    public BigDecimal calculatePi() {
        BigDecimal pi = new BigDecimal("3.14159265358979323846");
        // 可能还有更复杂的计算逻辑
        return pi;
    }
}

如果这个方法会被频繁调用,每次都创建新的 BigDecimal 对象会带来性能开销。我们可以将 pi 声明为 static final,这样只在类加载时创建一次。

import java.math.BigDecimal;

public class MathUtils {
    private static final BigDecimal PI = new BigDecimal("3.14159265358979323846");

    public BigDecimal calculatePi() {
        // 可能还有更复杂的计算逻辑
        return PI;
    }
}
  1. 合理使用缓存: 对于一些计算结果不会频繁变化的方法,可以使用缓存来提高性能。例如,一个计算斐波那契数列的类 FibonacciCalculator,如果不使用缓存,每次计算都会重复计算很多中间结果。
public class FibonacciCalculator {
    public int calculateFibonacci(int n) {
        if (n <= 1) {
            return n;
        }
        return calculateFibonacci(n - 1) + calculateFibonacci(n - 2);
    }
}

我们可以使用一个数组来缓存已经计算过的结果。

public class FibonacciCalculator {
    private int[] cache;

    public FibonacciCalculator(int maxN) {
        cache = new int[maxN + 1];
        cache[0] = 0;
        cache[1] = 1;
    }

    public int calculateFibonacci(int n) {
        if (n <= 1) {
            return n;
        }
        if (cache[n] != 0) {
            return cache[n];
        }
        cache[n] = calculateFibonacci(n - 1) + calculateFibonacci(n - 2);
        return cache[n];
    }
}

这样,在计算斐波那契数列时,如果已经计算过某个值,就直接从缓存中获取,避免了重复计算,提高了性能。

类的设计与可测试性

  1. 单一职责与可测试性: 具有单一职责的类更容易测试。因为每个类只负责一项功能,在编写测试用例时,可以更专注地测试该功能。例如,前面提到的 UserRegistrationService 类,只负责用户注册逻辑,测试这个类只需要关注注册功能是否正常,而不需要考虑其他无关的功能。
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

public class UserRegistrationServiceTest {
    @Test
    public void testRegisterUser() {
        UserRegistrationService service = new UserRegistrationService();
        service.registerUser("testUser", "testPassword");
        // 这里可以添加更多断言,例如检查数据库中是否插入了用户信息
        assertTrue(true);
    }
}
  1. 依赖注入与可测试性: 通过依赖注入,我们可以在测试时方便地替换掉真实的依赖对象,使用模拟对象来进行测试。例如,在 UserService 类依赖 MessageSender 接口的例子中,在测试 UserService 类的 registerUser 方法时,可以创建一个模拟的 MessageSender 对象来验证消息是否正确发送。
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;

class MockMessageSender implements MessageSender {
    private String lastSentTo;
    private String lastSentContent;

    @Override
    public void sendMessage(String to, String content) {
        lastSentTo = to;
        lastSentContent = content;
    }

    public String getLastSentTo() {
        return lastSentTo;
    }

    public String getLastSentContent() {
        return lastSentContent;
    }
}

public class UserServiceTest {
    @Test
    public void testRegisterUser() {
        MockMessageSender mockSender = new MockMessageSender();
        UserService userService = new UserService(mockSender);
        userService.registerUser("testUser");

        assertEquals("testUser", mockSender.getLastSentTo());
        assertEquals("注册成功通知", mockSender.getLastSentContent());
    }
}

这样,通过依赖注入和模拟对象,我们可以更好地对 UserService 类进行单元测试,隔离其依赖,专注于测试 UserService 类自身的逻辑。

类的设计与代码复用

  1. 继承与代码复用: 继承是一种常见的代码复用方式。通过继承,子类可以复用父类的属性和方法。例如,我们有一个 Vehicle 类,包含 startEnginestopEngine 方法,Car 类继承自 Vehicle 类,就可以复用这些方法。
class Vehicle {
    public void startEngine() {
        System.out.println("发动机启动");
    }

    public void stopEngine() {
        System.out.println("发动机停止");
    }
}

class Car extends Vehicle {
    // 可以添加Car类特有的属性和方法
}

但是,如前面提到的,使用继承时要谨慎,确保继承关系合理,避免过度耦合。 2. 组合与代码复用: 组合也是一种重要的代码复用方式。通过在一个类中包含另一个类的实例,实现代码复用。例如,我们有一个 Engine 类和一个 Car 类,Car 类通过组合的方式包含 Engine 类的实例。

class Engine {
    public void start() {
        System.out.println("发动机启动");
    }

    public void stop() {
        System.out.println("发动机停止");
    }
}

class Car {
    private Engine engine;

    public Car() {
        this.engine = new Engine();
    }

    public void startCar() {
        engine.start();
    }

    public void stopCar() {
        engine.stop();
    }
}

组合相比于继承更加灵活,因为它不依赖于继承关系,当需要更换 Engine 的实现时,只需要在 Car 类中替换 Engine 实例即可,而不会影响到其他类。同时,组合也避免了继承可能带来的过度耦合问题。

在实际的 Java 类设计中,综合运用继承和组合,可以有效地实现代码复用,同时保持代码的灵活性和可维护性。例如,在一个图形绘制库中,可以通过继承 Shape 类来复用一些通用的图形属性和方法,同时通过组合的方式将一些具体的绘制逻辑封装在其他类中,然后在 Shape 的子类中进行组合使用,这样可以实现高效的代码复用和灵活的功能扩展。

在进行 Java 类的设计时,遵循上述设计原则并结合各种最佳实践,能够创建出高质量、可维护、可扩展的代码。从单一职责原则确保类的功能清晰,到开闭原则允许系统方便地添加新功能,再到依赖倒置原则降低模块间的耦合,以及在性能、可测试性和代码复用等方面的实践,每一个环节都对构建优秀的 Java 软件系统起着至关重要的作用。在实际项目中,要根据具体的需求和场景,灵活运用这些原则和实践,不断优化类的设计,以满足软件系统不断变化和发展的要求。