软件设计原则
软件设计原则是一组指导性的准则和方法,旨在帮助开发人员创建高质量、可维护和可扩展的软件系统。
在软件开发中,为了提高软件系统的可维护性和可复用性,增加软件的可拓展性和灵活性,程序员要尽量根据六条原则来开发程序,从而提高软件开发效率、节约软件开发成本和维护成本。
开闭原则
开闭原则(Open/Closed Principle - OCP)是面向对象设计中的一个重要原则,它是软件设计中的五个SOLID原则之一。OCP的核心思想是:软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。这意味着当需要添加新功能或变化时,不应该修改已有的代码,而是应该通过扩展现有代码来实现变化。
开闭原则是软件设计中的重要原则之一,有助于创建灵活、可维护且容易扩展的软件系统。
具体来说,OCP有以下要点:
-
对扩展开放:意味着当需求发生变化,需要添加新的功能或特性时,应该通过新增代码来扩展系统,而不是修改已有代码。这可以通过创建新的类、模块、或扩展已有类的方法来实现。
-
对修改关闭:不应该修改已经稳定的、经过测试的代码,因为这样可能会引入新的错误或不必要的复杂性。通过遵循OCP,可以降低维护成本和风险。
-
抽象和接口:OCP通常涉及使用抽象类、接口或基类来定义通用的行为和规范。这样,在扩展时,可以创建新的实现类,而不会影响现有的客户端代码。
-
多态性:多态性是实现OCP的关键概念之一。通过多态性,可以在不知道具体子类的情况下,调用通用的方法或接口来实现新的功能。
-
设计模式:一些设计模式,如策略模式、装饰器模式和观察者模式等,有助于实现OCP。它们提供了一种结构,可以轻松扩展功能而不修改现有代码。
OCP的好处包括:
- 提高系统的可维护性:因为不需要频繁修改现有代码,所以系统更容易维护。
- 降低风险:不修改现有代码可以减少引入错误的机会。
- 提高代码的可扩展性:通过扩展而不是修改代码,可以更容易地应对变化和需求的增加。
- 支持并行开发:多个开发人员可以同时扩展不同的功能,而不会相互干扰。
示例:创建一个简单的图形绘制应用程序
首先,创建一个Shape
抽象类,表示不同类型的图形:
abstract class Shape {
abstract void draw();
}
然后,创建两个具体的图形类,Circle
和Rectangle
,它们继承自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是面向对象编程和设计中的五个基本原则,其有助于创建可维护、可扩展、灵活和易于理解的软件系统。每个字母代表一个不同的原则:
-
单一职责原则(Single Responsibility Principle - SRP):
- 定义:一个类应该只有一个引起它变化的原因。或者说,一个类应该只负责一个明确定义的功能或任务。
- 意义:SRP有助于确保类的职责清晰明确,提高了代码的可维护性,因为每个类只需要关注一个方面的变化。
-
开放封闭原则(Open/Closed Principle - OCP):
- 定义:软件实体(类、模块、函数等)应该对扩展开放,对修改关闭。也就是说,应该通过扩展已有代码来引入新功能,而不是修改现有代码。
- 意义:OCP有助于降低代码修改的风险,同时提高代码的可维护性和可扩展性。
-
里氏替换原则(Liskov Substitution Principle - LSP):
- 定义:子类应该能够替代父类并且不会破坏程序的正确性。也就是说,子类应该继承父类的行为,并且可以在不引起问题的情况下替代父类。
- 意义:LSP有助于确保继承层次结构的一致性和稳定性,避免引入意外的行为。
-
依赖倒置原则(Dependency Inversion Principle - DIP):
- 定义:高层模块不应该依赖于低层模块,二者都应该依赖于抽象。抽象不应该依赖于具体实现,具体实现应该依赖于抽象。
- 意义:DIP有助于降低模块之间的紧耦合,提高了代码的可维护性和可扩展性。
-
接口隔离原则(Interface Segregation Principle - ISP):
- 定义:不应该强制客户端依赖于它们不使用的接口。也就是说,接口应该小而专注于一个特定的功能。
- 意义:ISP有助于防止客户端依赖于不必要的接口功能,降低了代码的依赖性和复杂性。
这些SOLID原则是面向对象编程和设计的基石,有助于编写高质量、可维护和可扩展的软件系统。它们通常结合使用,以获得更好的设计和代码结构。遵循这些原则可以降低代码的复杂性,减少错误的风险,并使代码更容易理解和维护。
里氏代换原则
里氏替换原则(Liskov Substitution Principle - LSP)是SOLID原则中的一项,由计算机科学家Barbara Liskov于1987年提出。这一原则强调子类应该能够替代父类并且不会破坏程序的正确性。换句话说,如果一个类是一个父类的子类,那么可以在不引起问题的情况下将子类对象替代父类对象。
-
子类必须具有父类的所有公共方法,并且方法的参数、返回类型和异常要求必须相同或更宽松。这确保了子类可以无缝地替代父类。
-
子类可以通过扩展或重写父类的方法来添加特定于子类的行为,但不能破坏父类方法的基本行为。如果需要修改基本行为,那么可能存在设计问题。
-
LSP有助于确保继承层次结构的一致性和稳定性。当使用父类引用时,可以在不知道具体子类的情况下调用方法,这提供了多态性的支持。
-
LSP与继承和多态性概念紧密相关。正确地应用继承和多态性有助于遵循LSP。
-
违反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的一个示例,因为Sparrow
和Ostrich
都可以替代Bird
,并且在不同的情况下表现不同的行为。
LSP有助于确保继承关系的合理性和稳定性,促使开发者谨慎地使用继承并确保子类不会破坏父类的行为。这有助于创建更具弹性和可维护性的代码。
依赖倒转原则
依赖倒置原则(Dependency Inversion Principle - DIP)是面向对象设计中的一个重要原则,它强调高层模块不应该依赖于低层模块,二者都应该依赖于抽象。换句话说,模块之间的依赖关系应该建立在抽象上,而不是具体的实现上。这有助于减少紧耦合,提高系统的灵活性和可维护性。
DIP 包括以下两个关键概念:
- 高层模块:高层模块是应用程序中负责协调和组织低层模块的模块。它们通常包含了应用程序的主要业务逻辑。
- 低层模块:低层模块是实现具体细节的模块,它们依赖于高层模块。低层模块可以包括数据库访问、外部服务调用等。
示例:
创建一个电子邮件通知系统,其中有两个模块: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)是面向对象设计中的一个重要原则,它强调客户端不应该被迫依赖于它们不使用的接口。简而言之,这个原则要求将一个庞大的接口拆分成多个更小的、特定于客户端需求的接口,以减少接口的复杂性和依赖关系,同时提高系统的灵活性和可维护性。
关于接口隔离原则的核心思想有:
- 接口应该小而专一:一个接口应该只包含客户端需要的方法,不应该包含不相关的方法。这有助于确保接口的高内聚性,使接口更容易理解和使用。
- 客户端不应该被迫实现它们不需要的方法:当一个类实现一个接口时,它必须提供接口中定义的所有方法的实现。如果接口过于庞大,会导致类需要实现大量不相关的方法,这违反了接口隔离原则。
- 接口设计应该基于使用场景:接口的设计应该基于实际的客户端使用情况。不同的客户端可能需要不同的接口,因此接口应该根据不同的使用场景进行划分。
- 倾向于多个小接口而不是一个大接口:多个小接口通常比一个大接口更容易管理和维护。客户端可以选择性地实现它们需要的接口,而不需要实现所有方法。
示例:
假设我们有一个动物接口 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
接口包含了所有可能的方法,但这导致了 Bird
和 Fish
类需要实现不相关的方法,这是违反接口隔离原则的。正确的设计应该根据实际需求将接口分解成多个小接口,以确保类只实现它们需要的方法。
接口隔离原则有助于构建更加灵活和可维护的系统,同时减少了不必要的依赖关系,提高了代码的可理解性和可维护性。
迪米特法则
迪米特法则(Law of Demeter,简称LoD),也被称为最少知识原则(Least Knowledge Principle),是面向对象设计的一个重要原则。这个原则的核心思想是,一个对象应该对其他对象有最少的了解,不应该暴露过多的内部细节,而应该通过接口与其他对象进行通信。LoD的目标是减少对象之间的耦合,提高系统的松耦合性,从而增强系统的可维护性和可扩展性。
迪米特法则鼓励建立松耦合的系统,通过最少的知识和依赖来促进对象之间的通信,从而提高代码的可维护性和可扩展性。这有助于降低系统复杂性,减少潜在的错误和问题。
迪米特法则包含以下几个要点:
- 一个对象应该对自己需要耦合或通信的对象保持最少的了解。
- 不要直接访问其他对象的内部数据,而是通过方法来进行通信。
- 不要暴露自己的内部数据和实现细节给外部对象。
遵循迪米特法则有助于降低代码的耦合度,减少不必要的依赖关系,从而提高了系统的灵活性和可维护性。这也有助于隔离变化,当一个类的内部实现发生变化时,不会对其它类产生不必要的影响。
示例:
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)。该原则的核心思想是,类应该通过它们所使用的对象来实现代码复用,而不是通过继承来实现复用。
尽量先使用组合或者聚合等关联关系来实现,其次才考虑使用继承关系来实现。
虽然继承复用有简单和易实现的优点,但其也具有以下的缺点:
- 继承复用会破坏类的封装性。因为继承会将父类的实线细节暴露给子类,所以这种复用又称为“白箱”复用。
- 子类与父类的耦合度高。父类的实线的任何改变都会导致子类的实现发生变化,这不利于类的扩展与维护。
- 其限制了复用的灵活性。从父类继承而来的实现是静态的,在编译时就已经定义,所以在运行时不可能发生变化。
采用组合或聚合复用时,可以将已有对象纳入新对象中,使其成为新对象的一部分,新对象可以调用已有对象的功能,其有以下优点:
- 它维持了类的封装性,因为成分对象的内部细节是新对象看不见的,所以这种复用被称为“黑箱”复用。
- 对象间的耦合度低,可以在类的成员位置声明抽象。
- 复用的灵活性高,这种复用可以在运行时动态进行,新对象可以动态地引用与成分对象类型相同的对象。
合成复用原则的关键概念包括:
-
对象组合:通过将已有的类组合起来创建新的类,以实现代码复用。这意味着在设计中,应该优先考虑将对象作为成员变量引入,而不是直接继承现有类。
-
松耦合:对象组合通常导致低耦合度,因为新的类不依赖于被组合类的具体实现细节,而只是依赖于它们的接口。这提高了代码的灵活性和可维护性。
-
继承的问题:虽然继承是一种代码复用的方式,但过度的继承可能导致类之间的紧耦合,难以维护和扩展。此外,继承通常会引入不必要的复杂性,因为子类可能继承了父类的行为,但并不需要这些行为。
-
“优先使用对象组合”:这是合成复用原则的核心建议。它鼓励开发者在设计时首先考虑使用对象组合来实现代码复用,只有在确实需要共享基类的行为时才考虑继承。
示例:
假设当前正在开发一个汽车系统,其中有多种不同类型的引擎和轮胎,需求是创建各种类型的汽车。
以下是一个反例,当不遵循合成复用原则时,可能使用继承来实现代码复用,导致不必要的耦合和复杂性增加:
// 反例 - 使用继承实现代码复用
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
类。虽然这样可以复用引擎的启动方法,但它导致了以下问题:
-
CarWithEngine
类在继承时不得不继承引擎的所有行为和状态,包括不需要的行为。这违反了合成复用原则,因为它引入了不必要的耦合和复杂性。 -
当需要创建其他类型的汽车时,例如具有不同类型引擎或不同数量轮胎的汽车,可能需要创建更多的子类,导致类层次结构变得复杂。
-
如果引擎类或轮胎类的实现发生变化,可能会影响所有子类,增加了维护的复杂性。
这个反例强调了合成复用原则的重要性,即应该优先使用组合而不是继承来实现代码的复用,以减少不必要的依赖关系和耦合,提高代码的灵活性和可维护性。
改进后的代码:
// 引擎类
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(); // 启动汽车
}
}