软件设计原则

软件设计原则

软件设计原则是一组指导性的准则和方法,旨在帮助开发人员创建高质量、可维护和可扩展的软件系统。

在软件开发中,为了提高软件系统的可维护性和可复用性,增加软件的可拓展性和灵活性,程序员要尽量根据六条原则来开发程序,从而提高软件开发效率、节约软件开发成本和维护成本。

开闭原则

开闭原则(Open/Closed Principle - OCP)是面向对象设计中的一个重要原则,它是软件设计中的五个SOLID原则之一。OCP的核心思想是:软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。这意味着当需要添加新功能或变化时,不应该修改已有的代码,而是应该通过扩展现有代码来实现变化。

开闭原则是软件设计中的重要原则之一,有助于创建灵活、可维护且容易扩展的软件系统。

具体来说,OCP有以下要点:

  1. 对扩展开放:意味着当需求发生变化,需要添加新的功能或特性时,应该通过新增代码来扩展系统,而不是修改已有代码。这可以通过创建新的类、模块、或扩展已有类的方法来实现。

  2. 对修改关闭:不应该修改已经稳定的、经过测试的代码,因为这样可能会引入新的错误或不必要的复杂性。通过遵循OCP,可以降低维护成本和风险。

  3. 抽象和接口:OCP通常涉及使用抽象类、接口或基类来定义通用的行为和规范。这样,在扩展时,可以创建新的实现类,而不会影响现有的客户端代码。

  4. 多态性:多态性是实现OCP的关键概念之一。通过多态性,可以在不知道具体子类的情况下,调用通用的方法或接口来实现新的功能。

  5. 设计模式:一些设计模式,如策略模式、装饰器模式和观察者模式等,有助于实现OCP。它们提供了一种结构,可以轻松扩展功能而不修改现有代码。

OCP的好处包括:

  • 提高系统的可维护性:因为不需要频繁修改现有代码,所以系统更容易维护。
  • 降低风险:不修改现有代码可以减少引入错误的机会。
  • 提高代码的可扩展性:通过扩展而不是修改代码,可以更容易地应对变化和需求的增加。
  • 支持并行开发:多个开发人员可以同时扩展不同的功能,而不会相互干扰。

示例:创建一个简单的图形绘制应用程序

首先,创建一个Shape抽象类,表示不同类型的图形:

abstract class Shape {
    abstract void draw();
}

然后,创建两个具体的图形类,CircleRectangle,它们继承自Shape类:

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

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

再创建一个图形绘制程序,它接受Shape对象并绘制它:

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

现在,便可以轻松地扩展这个程序,添加新的图形类型,而不需要修改现有的绘制程序。例如,可以添加一个新的Triangle类:

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

不需要修改DrawingProgram类的代码,就可以轻松地绘制新的三角形图形:

public class Main {
    public static void main(String[] args) {
        DrawingProgram drawingProgram = new DrawingProgram();

        Shape circle = new Circle();
        Shape rectangle = new Rectangle();
        Shape triangle = new Triangle();

        drawingProgram.drawShape(circle);     // 绘制圆形
        drawingProgram.drawShape(rectangle);  // 绘制矩形
        drawingProgram.drawShape(triangle);   // 绘制三角形
    }
}

这个示例演示了开闭原则的应用。通过创建抽象的Shape类和具体的子类,使系统对扩展开放。当需要添加新的图形类型时,只需创建新的子类,而不需要修改现有的绘制程序,这符合开闭原则的要求。这种方式让代码更加灵活和可维护。

拓展:SOLID原则

SOLID是面向对象编程和设计中的五个基本原则,其有助于创建可维护、可扩展、灵活和易于理解的软件系统。每个字母代表一个不同的原则:

  1. 单一职责原则(Single Responsibility Principle - SRP)

    • 定义:一个类应该只有一个引起它变化的原因。或者说,一个类应该只负责一个明确定义的功能或任务。
    • 意义:SRP有助于确保类的职责清晰明确,提高了代码的可维护性,因为每个类只需要关注一个方面的变化。
  2. 开放封闭原则(Open/Closed Principle - OCP)

    • 定义:软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。也就是说,应该通过扩展已有代码来引入新功能,而不是修改现有代码。
    • 意义:OCP有助于降低代码修改的风险,同时提高代码的可维护性和可扩展性。
  3. 里氏替换原则(Liskov Substitution Principle - LSP)

    • 定义:子类应该能够替代父类并且不会破坏程序的正确性。也就是说,子类应该继承父类的行为,并且可以在不引起问题的情况下替代父类。
    • 意义:LSP有助于确保继承层次结构的一致性和稳定性,避免引入意外的行为。
  4. 依赖倒置原则(Dependency Inversion Principle - DIP)

    • 定义:高层模块不应该依赖于低层模块,二者都应该依赖于抽象。抽象不应该依赖于具体实现,具体实现应该依赖于抽象。
    • 意义:DIP有助于降低模块之间的紧耦合,提高了代码的可维护性和可扩展性。
  5. 接口隔离原则(Interface Segregation Principle - ISP)

    • 定义:不应该强制客户端依赖于它们不使用的接口。也就是说,接口应该小而专注于一个特定的功能。
    • 意义:ISP有助于防止客户端依赖于不必要的接口功能,降低了代码的依赖性和复杂性。

这些SOLID原则是面向对象编程和设计的基石,有助于编写高质量、可维护和可扩展的软件系统。它们通常结合使用,以获得更好的设计和代码结构。遵循这些原则可以降低代码的复杂性,减少错误的风险,并使代码更容易理解和维护。

里氏代换原则

里氏替换原则(Liskov Substitution Principle - LSP)是SOLID原则中的一项,由计算机科学家Barbara Liskov于1987年提出。这一原则强调子类应该能够替代父类并且不会破坏程序的正确性。换句话说,如果一个类是一个父类的子类,那么可以在不引起问题的情况下将子类对象替代父类对象。

  1. 子类必须具有父类的所有公共方法,并且方法的参数、返回类型和异常要求必须相同或更宽松。这确保了子类可以无缝地替代父类。

  2. 子类可以通过扩展或重写父类的方法来添加特定于子类的行为,但不能破坏父类方法的基本行为。如果需要修改基本行为,那么可能存在设计问题。

  3. LSP有助于确保继承层次结构的一致性和稳定性。当使用父类引用时,可以在不知道具体子类的情况下调用方法,这提供了多态性的支持。

  4. LSP与继承和多态性概念紧密相关。正确地应用继承和多态性有助于遵循LSP。

  5. 违反LSP可能导致意外的行为和错误,因为客户端代码通常假定可以将子类对象视为父类对象来使用。

示例:

class Bird {
    void fly() {
        System.out.println("Bird can fly");
    }
}

class Sparrow extends Bird {
    // 麻雀继承了鸟能飞的方法
}

class Ostrich extends Bird {
    void fly() {
        // 鸵鸟不会飞,重写此方法
        System.out.println("Ostrich cannot fly");
    }
}

在上述示例中,Sparrow类继承了Bird类的fly方法而没有修改它,而Ostrich类重写了fly方法以提供不同的行为。这是LSP的一个示例,因为SparrowOstrich都可以替代Bird,并且在不同的情况下表现不同的行为。

LSP有助于确保继承关系的合理性和稳定性,促使开发者谨慎地使用继承并确保子类不会破坏父类的行为。这有助于创建更具弹性和可维护性的代码。

依赖倒转原则

依赖倒置原则(Dependency Inversion Principle - DIP)是面向对象设计中的一个重要原则,它强调高层模块不应该依赖于低层模块,二者都应该依赖于抽象。换句话说,模块之间的依赖关系应该建立在抽象上,而不是具体的实现上。这有助于减少紧耦合,提高系统的灵活性和可维护性。

DIP 包括以下两个关键概念:

  1. 高层模块:高层模块是应用程序中负责协调和组织低层模块的模块。它们通常包含了应用程序的主要业务逻辑。
  2. 低层模块:低层模块是实现具体细节的模块,它们依赖于高层模块。低层模块可以包括数据库访问、外部服务调用等。

示例:

创建一个电子邮件通知系统,其中有两个模块:EmailSender 用于发送电子邮件,和 NotificationService 用于通知用户。将使用 DIP 来确保高层模块 NotificationService 不依赖于低层模块 EmailSender,而是依赖于抽象接口 NotificationProvider

// 高层模块
class NotificationService {
    private NotificationProvider provider;

    public NotificationService(NotificationProvider provider) {
        this.provider = provider;
    }

    public void sendNotification(String message) {
        provider.sendNotification(message);
    }
}

// 低层模块 - 具体的邮件发送实现
class EmailSender implements NotificationProvider {
    @Override
    public void sendNotification(String message) {
        // 实现发送电子邮件的具体逻辑
        System.out.println("发送电子邮件: " + message);
    }
}

// 抽象接口
interface NotificationProvider {
    void sendNotification(String message);
}

在这个示例中,NotificationService 高层模块依赖于 NotificationProvider 接口,而不是具体的 EmailSender。这遵循了依赖倒置原则,因为高层模块和低层模块都依赖于抽象,而不是具体的实现。

这种设计使得可以轻松地扩展系统,例如,如果需要添加短信通知功能,只需创建一个新的 SmsSender 类并实现 NotificationProvider 接口,然后将其注入到 NotificationService 中,而不需要修改现有代码。

依赖倒置原则有助于减少紧耦合,提高代码的灵活性和可维护性,同时促使编写松耦合的代码,更容易进行单元测试和维护。

接口隔离原则

接口隔离原则(Interface Segregation Principle - ISP)是面向对象设计中的一个重要原则,它强调客户端不应该被迫依赖于它们不使用的接口。简而言之,这个原则要求将一个庞大的接口拆分成多个更小的、特定于客户端需求的接口,以减少接口的复杂性和依赖关系,同时提高系统的灵活性和可维护性。

关于接口隔离原则的核心思想有:

  1. 接口应该小而专一:一个接口应该只包含客户端需要的方法,不应该包含不相关的方法。这有助于确保接口的高内聚性,使接口更容易理解和使用。
  2. 客户端不应该被迫实现它们不需要的方法:当一个类实现一个接口时,它必须提供接口中定义的所有方法的实现。如果接口过于庞大,会导致类需要实现大量不相关的方法,这违反了接口隔离原则。
  3. 接口设计应该基于使用场景:接口的设计应该基于实际的客户端使用情况。不同的客户端可能需要不同的接口,因此接口应该根据不同的使用场景进行划分。
  4. 倾向于多个小接口而不是一个大接口:多个小接口通常比一个大接口更容易管理和维护。客户端可以选择性地实现它们需要的接口,而不需要实现所有方法。

示例:

假设我们有一个动物接口 Animal,但不同的动物需要实现不同的方法。如果我们将所有方法都包含在一个大接口中,可能会导致问题。

// 不好的设计 - 一个大接口包含所有方法
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("鱼游泳");
    }
}

在上面的设计中,Animal 接口包含了所有可能的方法,但这导致了 BirdFish 类需要实现不相关的方法,这是违反接口隔离原则的。正确的设计应该根据实际需求将接口分解成多个小接口,以确保类只实现它们需要的方法。

接口隔离原则有助于构建更加灵活和可维护的系统,同时减少了不必要的依赖关系,提高了代码的可理解性和可维护性。

迪米特法则

迪米特法则(Law of Demeter,简称LoD),也被称为最少知识原则(Least Knowledge Principle),是面向对象设计的一个重要原则。这个原则的核心思想是,一个对象应该对其他对象有最少的了解,不应该暴露过多的内部细节,而应该通过接口与其他对象进行通信。LoD的目标是减少对象之间的耦合,提高系统的松耦合性,从而增强系统的可维护性和可扩展性。

迪米特法则鼓励建立松耦合的系统,通过最少的知识和依赖来促进对象之间的通信,从而提高代码的可维护性和可扩展性。这有助于降低系统复杂性,减少潜在的错误和问题。

迪米特法则包含以下几个要点:

  1. 一个对象应该对自己需要耦合或通信的对象保持最少的了解。
  2. 不要直接访问其他对象的内部数据,而是通过方法来进行通信。
  3. 不要暴露自己的内部数据和实现细节给外部对象。

遵循迪米特法则有助于降低代码的耦合度,减少不必要的依赖关系,从而提高了系统的灵活性和可维护性。这也有助于隔离变化,当一个类的内部实现发生变化时,不会对其它类产生不必要的影响。

示例:

class Teacher {
    public void instruct(Student student) {
        student.study();
    }
}

class Student {
    public void study() {
        System.out.println("学生正在学习");
    }
}

public class Main {
    public static void main(String[] args) {
        Teacher teacher = new Teacher();
        Student student = new Student();

        teacher.instruct(student); // 教师通过方法调用学生的学习行为,而不需要了解学生的内部细节
    }
}

在上面的示例中,Teacher 类通过 instruct 方法来指导学生 Student 学习,而不需要知道学生的内部细节。这遵循了迪米特法则,因为Teacher 类只与它需要通信的对象交互,而不涉及其它不必要的依赖。

再或者,通过一个反例来理解迪米特法则:

以下是一个违反迪米特法则的反例:

class Library {
    private List<Book> books;

    public Library() {
        books = new ArrayList<>();
    }

    public void addBook(Book book) {
        books.add(book);
    }

    public List<Book> getAllBooks() {
        return books;
    }
}

class Patron {
    private String name;

    public Patron(String name) {
        this.name = name;
    }

    public void borrowBook(Library library,String bookTitle) {
        List<Book> books = library.getAllBooks();
        for (Book book : books) {
            if(book.getTitle().equals(bookTitle)){
                System.out.println(name + "借阅了书籍:" + book.getTitle());
                break;
            }else{
                System.out.println("书籍未找到");
            }
        }
    }
}

class Book {
    private String title;

    public Book(String title) {
        this.title = title;
    }

    public String getTitle() {
        return title;
    }
}

public class Main {
    public static void main(String[] args) {
        Library library = new Library();
        library.addBook(new Book("Java编程入门"));
        library.addBook(new Book("Python实战"));
	
        Patron patron = new Patron("Alice");
        patron.borrowBook(library,"Java编程入门");
    }
}

在上述示例中,Patron 类通过 borrowBook 方法借阅图书,但它需要访问 Library 类的内部数据,直接调用 getAllBooks 方法来获取书籍列表。这违反了迪米特法则,因为Patron 类需要了解 Library 类的内部细节,即它需要知道如何获取所有的书籍。

为了符合迪米特法则,应该修改设计,使得 Patron 类不需要了解 Library 类的内部结构。可以通过在 Library 类中提供一个更高级别的接口,如 borrowBook,来实现这一点。

class Library {
    private List<Book> books;

    public Library() {
        books = new ArrayList<>();
    }

    public void addBook(Book book) {
        books.add(book);
    }

    public void borrowBook(String patronName,String bookTitle) {
        Book book = findBook(bookTitle);
        if (book != null) {
            books.remove(book);
            System.out.println(patronName + "借阅了书籍:" + book.getTitle());
        } else {
            System.out.println("书籍未找到");
        }
    }

    private Book findBook(String title) {
        for (Book book : books) {
            if (book.getTitle().equals(title)) {
                return book;
            }
        }
        return null;
    }
}


class Patron {
    private String name;

    public Patron(String name) {
        this.name = name;
    }

    public void borrowBook(Library library,String bookTitle) {
        library.borrowBook(name,bookTitle);
    }
}

class Book {
    private String title;

    public Book(String title) {
        this.title = title;
    }

    public String getTitle() {
        return title;
    }
}

public class Main {
    public static void main(String[] args) {
        Library library = new Library();
        library.addBook(new Book("Java编程入门"));
        library.addBook(new Book("Python实战"));

        Patron patron = new Patron("Alice");
        patron.borrowBook(library,"Java编程入门");
    }
}

通过这种方式,Patron 类不再需要知道 Library 类的内部细节,只需调用 borrowBook 方法即可。这个设计更符合迪米特法则,减少了类之间的耦合。

合成复用原则

合成复用原则(Composite Reuse Principle,CRP)是面向对象设计中的一个原则,它强调在软件设计中应该优先使用对象组合(Composition)而不是继承(Inheritance)。该原则的核心思想是,类应该通过它们所使用的对象来实现代码复用,而不是通过继承来实现复用。

尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。

虽然继承复用有简单和易实现的优点,但其也具有以下的缺点:

  1. 继承复用会破坏类的封装性。因为继承会将父类的实线细节暴露给子类,所以这种复用又称为“白箱”复用。
  2. 子类与父类的耦合度高。父类的实线的任何改变都会导致子类的实现发生变化,这不利于类的扩展与维护。
  3. 其限制了复用的灵活性。从父类继承而来的实现是静态的,在编译时就已经定义,所以在运行时不可能发生变化。

采用组合或聚合复用时,可以将已有对象纳入新对象中,使其成为新对象的一部分,新对象可以调用已有对象的功能,其有以下优点:

  1. 它维持了类的封装性,因为成分对象的内部细节是新对象看不见的,所以这种复用被称为“黑箱”复用。
  2. 对象间的耦合度低,可以在类的成员位置声明抽象。
  3. 复用的灵活性高,这种复用可以在运行时动态进行,新对象可以动态地引用与成分对象类型相同的对象。

合成复用原则的关键概念包括:

  1. 对象组合:通过将已有的类组合起来创建新的类,以实现代码复用。这意味着在设计中,应该优先考虑将对象作为成员变量引入,而不是直接继承现有类。

  2. 松耦合:对象组合通常导致低耦合度,因为新的类不依赖于被组合类的具体实现细节,而只是依赖于它们的接口。这提高了代码的灵活性和可维护性。

  3. 继承的问题:虽然继承是一种代码复用的方式,但过度的继承可能导致类之间的紧耦合,难以维护和扩展。此外,继承通常会引入不必要的复杂性,因为子类可能继承了父类的行为,但并不需要这些行为。

  4. “优先使用对象组合”:这是合成复用原则的核心建议。它鼓励开发者在设计时首先考虑使用对象组合来实现代码复用,只有在确实需要共享基类的行为时才考虑继承。

示例:

假设当前正在开发一个汽车系统,其中有多种不同类型的引擎和轮胎,需求是创建各种类型的汽车。

以下是一个反例,当不遵循合成复用原则时,可能使用继承来实现代码复用,导致不必要的耦合和复杂性增加:

// 反例 - 使用继承实现代码复用
class Engine {
    public void start() {
        System.out.println("引擎启动");
    }
}
// 轮胎类
class Tire {
    public void rotate() {
        System.out.println("轮胎旋转");
    }
}
// 反例 - 使用继承实现代码复用
class CarWithEngine extends Engine {
    private Tire[] tires;

    public CarWithEngine() {
        tires = new Tire[4];// 假设汽车有4个轮胎
        for (int i = 0; i < 4; i++) {
            tires[i] = new Tire();
        }
    }

    public void start() {
        super.start(); // 启动引擎
        for (Tire tire : tires) {
            tire.rotate();
        }
        System.out.println("汽车启动");
    }
}

public class Main {
    public static void main(String[] args) {
        CarWithEngine car = new CarWithEngine();
        car.start(); // 启动汽车
    }
}

在上述反例中,我们使用了继承来实现代码的复用,创建了 CarWithEngine 类,该类继承了 Engine 类。虽然这样可以复用引擎的启动方法,但它导致了以下问题:

  1. CarWithEngine 类在继承时不得不继承引擎的所有行为和状态,包括不需要的行为。这违反了合成复用原则,因为它引入了不必要的耦合和复杂性。

  2. 当需要创建其他类型的汽车时,例如具有不同类型引擎或不同数量轮胎的汽车,可能需要创建更多的子类,导致类层次结构变得复杂。

  3. 如果引擎类或轮胎类的实现发生变化,可能会影响所有子类,增加了维护的复杂性。

这个反例强调了合成复用原则的重要性,即应该优先使用组合而不是继承来实现代码的复用,以减少不必要的依赖关系和耦合,提高代码的灵活性和可维护性。

改进后的代码:

// 引擎类
class Engine {
    public void start() {
        System.out.println("引擎启动");
    }
}

// 轮胎类
class Tire {
    public void rotate() {
        System.out.println("轮胎旋转");
    }
}

// 汽车类,使用组合实现
class Car {
    private Engine engine;
    private Tire[] tires;

    public Car() {
        engine = new Engine();
        tires = new Tire[4]; // 假设汽车有4个轮胎
        for (int i = 0; i < 4; i++) {
            tires[i] = new Tire();
        }
    }

    public void start() {
        engine.start();
        for (Tire tire : tires) {
            tire.rotate();
        }
        System.out.println("汽车启动");
    }
}

public class Main {
    public static void main(String[] args) {
        Car car = new Car();
        car.start(); // 启动汽车
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值