langliu1216@gmail.com

软件设计原则:如何写出优雅程序 - 2025/02/24

软件设计原则是程序员编写可维护、可扩展代码的指南针。本文将通过通俗易懂的JavaScript示例,讲解7个最实用的设计原则。

软件设计原则是程序员编写可维护、可扩展代码的指南针。本文将通过通俗易懂的JavaScript示例,讲解7个最实用的设计原则。

SOLID 原则

SOLID 是面向对象设计的五大核心原则,由 Robert C. Martin 提出,旨在提高代码的可维护性、扩展性和灵活性:

  • S – Single Responsibility Principle(单一职责原则)
  • O – Open/Closed Principle(开放封闭原则)
  • L – Liskov Substitution Principle(里氏替换原则)
  • I – Interface Segregation Principle(接口隔离原则)
  • D – Dependency Inversion Principle(依赖倒置原则)

1. 单一职责原则 (SRP)

一个类/函数只做一件事

违反原则的写法:用户类既处理数据又负责网络请求

class User {
  constructor (name) { this.name = name }

  saveToDB () { /* 数据库操作 */ }

  sendEmail () { /* 邮件发送逻辑 */ }
}

遵循原则的写法:拆分不同职责

class User { /* 只保留核心属性 */}

class UserRepository {save (user) { /* 存储逻辑 */ }}

class EmailService {send (user) { /* 发送逻辑 */ }}

2. 开放封闭原则 (OCP)

对扩展开放,对修改关闭

// 违反原则的写法
// 基础形状类
class Shape {
  area () { throw new Error('必须实现area方法') }
}

// 扩展时不需要修改基类
class Circle extends Shape {
  constructor (radius) {
    super();
    this.radius = radius;
  }

  area () { return Math.PI * this.radius ** 2 }
}

class Square extends Shape {
  constructor (side) {
    super();
    this.side = side;
  }

  area () { return this.side ** 2 }
}

3. 里氏替换原则(LSP)

子类必须能替换父类而不破坏程序逻辑

违反原则的写法

class Bird {
  fly () { return "飞行中..." }
}

class Penguin extends Bird { // 企鹅不会飞却继承了飞行方法
  fly () { throw new Error("企鹅不会飞!") }
}

正确写法:重新设计继承体系

class Bird {}

class FlyingBird extends Bird {
  fly () { return "飞行中..." }
}

class Penguin extends Bird {} // 只保留通用鸟类特性

优点

增强系统的可维护性

  • 代码结构清晰:遵循里氏替换原则,子类与父类之间的关系明确,使得代码结构更加清晰易懂。开发者可以更容易地理解和维护代码,当需要对某个功能进行修改或扩展时,能够快速定位到相关的类和方法。
  • 降低耦合度:子类可以无缝替换父类,使得父类和子类之间的耦合度降低。这意味着在修改父类或子类的代码时,对其他部分的代码影响较小,减少了因修改代码而引入新错误的风险。

提高系统的可扩展性

  • 方便添加新功能:可以通过创建新的子类来扩展系统的功能,而不需要修改现有的代码。新的子类可以继承父类的接口和行为,并且可以根据需要进行定制化的实现,从而满足不断变化的业务需求。
  • 支持多态性:里氏替换原则是实现多态性的基础。多态性允许在运行时根据对象的实际类型来调用相应的方法,提高了代码的灵活性和可扩展性。通过使用父类的引用指向子类的对象,可以在不修改现有代码的情况下,轻松地切换不同的实现。

增强系统的健壮性

  • 保证程序正确性:当子类对象能够完全替换父类对象时,程序的行为不会发生变化,这保证了系统的正确性和稳定性。即使在系统中引入新的子类,也不会影响原有功能的正常运行。
  • 便于测试:由于子类和父类的行为具有一致性,测试人员可以更容易地对系统进行测试。可以基于父类的接口编写测试用例,这些测试用例同样适用于子类,减少了测试的工作量和复杂度。

缺点(潜在挑战)

设计难度增加

  • 严格的继承关系设计:要遵循里氏替换原则,需要在设计类的继承关系时进行更深入的思考和规划。必须确保子类能够完全遵循父类的行为约定,这对设计者的能力和经验要求较高。
  • 可能限制子类的功能:为了满足里氏替换原则,子类的行为可能会受到一定的限制。有时候,子类可能有一些特殊的需求或行为,但由于要保持与父类的兼容性,这些特殊功能可能无法得到充分的发挥。

开发成本上升

  • 代码实现复杂度增加:为了确保子类能够正确地替换父类,在实现子类时可能需要编写更多的代码来保证行为的一致性。这增加了开发的工作量和复杂度,延长了开发周期。
  • 维护子类的成本较高:随着系统的发展,可能会有多个子类继承自同一个父类。为了保持里氏替换原则,需要对所有子类进行统一的管理和维护,确保它们的行为符合父类的约定,这增加了维护的难度和成本。

灵活性受限

  • 难以适应快速变化的需求:在某些情况下,业务需求可能会快速变化,需要子类具有更大的灵活性和自主性。但里氏替换原则强调子类与父类的一致性,可能会限制子类对变化的响应能力,使得系统在面对快速变化的需求时显得不够灵活。

4. 接口隔离原则(ISP)

多个专用接口优于单个通用接口

核心概念:客户端不应该被迫依赖它们不使用的接口。也就是说,应该将庞大的接口拆分成更小、更具体的接口,这样客户端只需知道它们需要的方法,减少不必要的依赖。

违反原则的写法

class Worker {
  work () { /* 开发工作 */ }

  eat () { /* 午餐休息 */ }  // 非必要方法
}

class Developer extends Worker {} // 被迫实现eat方法
class Waiter extends Worker {}    // 需要全部方法

遵循原则的写法


class Workable {
  work () {}
}

class Eatable {
  eat () {}
}

class Developer extends Workable {}  // 只需实现必要接口
class Waiter extends Workable, Eatable {} // 按需组合接口

5. 依赖倒置原则(DIP)

高层模块不应依赖低层实现,二者都应依赖抽象

核心概念:高层模块不应该依赖低层模块,两者都应该依赖抽象。抽象不应该依赖细节,细节应该依赖抽象。这意味着应该通过接口或抽象类来解耦模块之间的直接依赖。

违反原则的写法

class EmailService {
  send (message) { /* 邮件发送实现 */ }
}

class Notification {
  constructor () {
    this.sender = new EmailService() // 直接依赖具体实现
  }
}

遵循原则的写法


class MessageService { // 抽象层
  send (message) { throw new Error('必须实现send方法') }
}

class EmailService extends MessageService {
  send (message) { /* 邮件实现 */ }
}

class Notification {
  constructor (sender) { // 依赖抽象接口
    this.sender = sender
  }
}

DRY原则(Don’t Repeat Yourself)

消除重复代码,保持单一真相来源

核心概念:避免重复代码,通过抽象和复用减少冗余。重复的代码会增加维护成本,并可能导致不一致性。

违反原则的写法

function calculateProductPrice (basePrice, discount) {
  const finalPrice = basePrice * (1 - discount / 100)
  return finalPrice.toFixed(2)
}

function calculateServicePrice (basePrice, discount) {
  const finalPrice = basePrice * (1 - discount / 100) // 重复计算逻辑
  return finalPrice.toFixed(2)
}

遵循原则的写法

function calculateDiscount (basePrice, discount) {
  return basePrice * (1 - discount / 100)
}

function calculateProductPrice (basePrice, discount) {
  return calculateDiscount(basePrice, discount).toFixed(2)
}

function calculateServicePrice (basePrice, discount) {
  return calculateDiscount(basePrice, discount).toFixed(2)
}

核心价值:

  • 🛠️ 降低维护成本:修改逻辑只需改动单一位置
  • 🧩 提升复用效率:公共逻辑可被多次调用
  • 🚨 减少人为错误:消除多副本更新不一致风险
  • 📐 增强可读性:业务逻辑集中管理更清晰

实施风险:

  • ⚠️ 过度抽象陷阱:将偶然相似的代码强制复用
  • 🔗 错误抽象层级:在不合适的模块提取公共代码
  • 🧩 忽视业务差异:强行统一不同场景的处理逻辑
  • 📦 过早优化负担:在需求稳定前过度设计

KISS原则(Keep It Simple, Stupid)

用最简单的方式实现需求

违反原则的写法

function processUserData (user) {
  if (user.age >= 18 && user.age <= 65) {
    if (user.subscriptions.includes('premium')) {
      return {
        status: 'active',
        discount: user.country === 'US' ? 0.2 : 0.1
      }
    }
    // 更多嵌套条件判断...
  }
}

遵循原则的写法

function isEligible (user) {
  return user.age >= 18 && user.age <= 65
}

function getBaseDiscount (user) {
  return user.subscriptions.includes('premium') ? 0.1 : 0
}

function applyRegionBonus (discount, country) {
  return country === 'US' ? discount + 0.1 : discount
}

核心价值:

  • 🎯 提升可读性:直白的代码逻辑更易理解
  • 🛠️ 降低维护成本:简单结构减少认知负担
  • 🚨 减少隐藏缺陷:复杂条件嵌套容易产生漏洞
  • ⚡ 优化执行效率:避免不必要的计算开销

实施风险:

  • ⚠️ 过度简化陷阱:忽略必要的异常处理
  • 📉 功能完整性缺失:为追求简单牺牲关键需求
  • 🔄 扩展性不足:没有预留合理的抽象空间
  • 🤹 平衡难度:在简单与完备性之间难以取舍

YAGNI(You Ain’t Gonna Need It)

只实现当前需要的功能

违反原则的写法:预先实现未来可能需要的功能

class NotificationService {
  constructor () {
    // 提前实现多种通知方式
    this.emailService = new EmailService()
    this.smsService = new SMSService()
    this.pushService = new PushService()
  }
}

遵循原则的写法:只实现当前需要的功能

class NotificationService {
  constructor (channel) {
    this.channel = this.initChannel(channel)
  }

  initChannel (type) {
    switch (type) {
      case 'email':
        return new EmailService()
      default:
        return new BaseService()
    }
  }
}

核心价值:

  • 🗑️ 避免过度开发:专注当前需求
  • ⚡ 加速交付速度:减少无用代码
  • 🔧 降低维护成本:消除冗余功能

潜在风险:

  • ⏳ 预测失误导致重构成本
  • 🔮 需求频繁变更时适得其反