查看原文
其他

通俗易懂讲解 KISS/DRY/YANGI/SOLID 等程序设计原则

fundroid AndroidPub
2024-08-24

前言

在我上一篇文章《Android 最新官方架构推荐引入 UseCase,这是个啥?该怎么写?》中提到了 SRPISPSOLID 原则,有小伙伴私信希望针对这些设计原则进行专门介绍,所以本文梳理了几个重要的设计原则,并配合简单的代码示例,帮助大家深入理解。

KISS

Keep It Simple, Stupid!

我们在工作中经常要经手他人的代码。设想一下当你看到一堆晦涩难懂且缺少注释的代码时,不会称赞美的难度,只会骂 shit mountain。写出逻辑复杂代码不代表编码能力强。将复杂业务写得简单易懂才是高水平的体现。KISS 原则是我们要时刻遵循的设计原则

“Any fool can write code that a computer can understand. Good programmers write code that humans can understand.” — Martin Fowler

假设项目中存在这样一个数据类:它代表一个学生对象,除了有 nameid 属性,还有代表课程信息的 Map,Key 和 value 分别是一个 Pair 类型。第一个 Pair 存储课程名和课程 id, 第二个 Pair 存储课程学分和已取得学分

data class Student(
  val name: String,
  val age: Int,
  val courses: Map<Pair<String, String>, Pair<IntInt>>
)

如果我们需要实现一段逻辑,输出所有学生的学分超过 80% 的课程名单列表,基于当前的数据结构定义,会写出下面这样的代码:

fun scholars(students: List<Student>): Map<String, List<String>> {
  val scholars = mutableMapOf<String, List<String>>()
  students.forEach { student ->
    scholars[student.name] = student.courses
      .filter { (_, (t1, t2)) -> t1 / t2 > 0.8}
      .map { ((t, _), _) -> t }
  }
  return scholars
}

代码高度精炼,但是对于不了解业务背景的人理解成本很高,或许过不了多久作者本人也会迷糊。让我们试着换一个“老老实实”的方式来定义数据结构:

data class Student(
  val name: String,
  val age: Int,
  val courses: Map<Course, Score>
)

data class Course(
  val name: String,
  val id: String
)

data class Score(
  val achieved: Double,
  val maximum: Double
) {
  fun isAbove(percentage: Double)Boolean {
    return achieved / maximum * 100 > percentage
}

fun scholars(students: List<Student>): Map<String, List<String>> {
  val scholars = mutableMapOf<String, List<String>>()
  students.forEach { student ->
    val coursesAbove80 = student.courses
      .filter { (_, score) -> score.isAbove(80.0)}
      .map { (course, _) -> course.name }

    scholars[student.name] = coursesAbove80
  }
  return scholars
}

如上,虽然定义变多了、代码变长了,但是理解成本大大降低。

如果我们要写一个内核或者SDK,关注包体且不会经常改动,可以用第一类写法(其实编译成二进制后包体也差不了多少)。但如果我们写的是一段业务逻辑,未来有频繁改动的预期,那么请遵循 KISS 原则,像第二类代码那样定义更清晰的数据结构。

第一类代码是炫技,第二类代码才叫专业。

DRY

Don't Repeat Yourself

程序中不要写重复代码,可复用的代码应该提取公共函数。

public class Spreadsheet implements BasicSpreadsheet {
  private final Set<Cell> cells;

  @Override
  public double getCellValue(CellLocation location) {
    Cell cell = cells.stream()
        .filter(cell -> cell.location.equals(location))
        .findFirst()
        .orElse(null);
    
    return cell == null ? 0d : cell.getValue();
  }

  @Override
  public String getCellExpression(CellLocation location) {
    Cell cell = cells.stream()
        .filter(cell -> cell.location.equals(location))
        .findFirst()
        .orElse(null);
    
    return cell == null ? "" : cell.getExpression();
  }

  @Override
  public void setCellExpression(CellLocation location, String input) throws InvalidSyntaxException {
    Cell cell = cells.stream()
        .filter(cell -> cell.location.equals(location))
        .findFirst()
        .orElse(null);

    // ...
  }

  // ...
}

上面代码很清晰,提取表格内容,或者是 value 或者是 expression,但是其中存在代码冗余,因此我们抽取一个公共函数

public class Spreadsheet implements BasicSpreadsheet {
  private final Set<Cell> cells;

  @Override
  public double getCellValue(CellLocation location) {
    return getFromCell(location, Cell::getValue, 0d);
  }

  @Override
  public String getCellExpression(CellLocation location) {
    return getFromCell(location, Cell::getExpression, "");
  }

  @Override
  public void setCellExpression(CellLocation location, String input) throws InvalidSyntaxException {
    Cell cell = findCell(location);

    // ...
  }

  // ...

  private Cell findCell(CellLocation location) {
    return cells.stream()
        .filter(cell -> cell.location.equals(location))
        .findFirst()
        .orElse(null);
  }

  private <T> T getFromCell(CellLocation location,
                            Function<Cell, T> function,
                            T defaultValue) 
{
    Cell cell = findCell(location);
    return cell == null ? defaultValue : function.apply(cell);
  }
}

这样当公共代码出现 Bug 后者需要升级时,我们只需修改一处即可。

YANGI

You Ain't Gonna Need It

“你不需要它”。强调在编写代码时避免不必要的功能和复杂性。它的核心思想是不要在项目中添加任何当前不需要的功能,而是在实际需要时再进行添加。

YAGIN 和 KISS 有点类似,都强调简洁性,但它们的应用点略有不同。YAGNI 更侧重于避免不必要的功能和复杂性,以避免过度设计和浪费资源。而 KISS 更注重代码的可读性和简洁性,鼓励使用简单明了的解决方案。

class MathUtils {
    fun add(num1: Int, num2: Int)Int {
        return performOperation(num1, num2, Operation.ADD)
    }

    fun subtract(num1: Int, num2: Int)Int {
        return performOperation(num1, num2, Operation.SUBTRACT)
    }

    fun multiply(num1: Int, num2: Int)Int {
        return performOperation(num1, num2, Operation.MULTIPLY)
    }

    fun divide(num1: Int, num2: Int)Int {
        return performOperation(num1, num2, Operation.DIVIDE)
    }

    private fun performOperation(num1: Int, num2: Int, operation: Operation)Int {
        return when (operation) {
            Operation.ADD -> num1 + num2
            Operation.SUBTRACT -> num1 - num2
            Operation.MULTIPLY -> num1 * num2
            Operation.DIVIDE -> num1 / num2
        }
    }

    private enum class Operation {
        ADD, SUBTRACT, MULTIPLY, DIVIDE
    }
}

fun main() {
    val mathUtils = MathUtils()
    val result = mathUtils.add(53)
    println("Result: $result")
}

上面代码实现了四则运算的工具函数,并封装了 performOperation 方法供四则运算调用。performOperation 内部并没有什么可复用的逻辑,所以这个函数定义其实必要性不大,属于过度设计,不如各函数直接执行相应运算操作来的简单。

fun add(num1: Int, num2: Int)Int {
    return num1 + num2
}

fun subtract(num1: Int, num2: Int)Int {
    return num1 - num2
}

fun multiply(num1: Int, num2: Int)Int {
    return num1 * num2
}

fun divide(num1: Int, num2: Int)Int {
    return num1 / num2
}

fun main() {
    val result = add(53)
    println("Result: $result")
}

这种简化的设计更符合YAGNI原则,因为我们只关注最基本的数学运算,避免了不必要的复杂性和功能冗余。

KISS,DRY 以及 YANGI, 与其说是设计原则不如说是开发常识。这几个名字不一定被经常提起,但是背后的思想想必大多数人都能理解。相较而言,SOLID 这个名字的知名度更高,但是其具体内容并非人人都能脱口而出。

SOLID 出自 Uncle Bob 著名的《敏捷软件开发》一书,是五个重要软件设计原则的缩写。

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

这些设计原则是构筑高质量面向对象代码的基础,也是面试中的常见题目,值得每个开发人员深入理解和掌握。

SOLID - SRP

Single Responsibility Principle 单一职责原则

A class should have one, and only one, reason to change.

SOLID 中最简单的原则,每个 class 或者 function 只做一件事情。比如下面代码,是 Android 在数据层常用的 Repository Pattern,其中定义了一个网络请求

class Repository(
  private val api: MyRemoteDataSource,
  private val local: MyLocalDatabase
) {
  fun fetchRemoteData() = flow {
    // Fetching API data
    val response = api.getData()

    // Saving data in the cache
    var model = Model.parse(response.payload)
    val success = local.addModel(model)
    if (!success) {
      emit(Error("Error caching the remote data"))
      return@flow
    }

    // Returning data from a single source of truth
    model = local.find(model.key)
    emit(Success(model))
  }
}

从设计的角度看,这个 Repository 类违反了 SRP,它不仅负责网络请求,还承担了一些本地存储的逻辑,例如异常处理等。比较好的做法是对将本地存储逻辑拆分到单独的类中:

class Repository(
  private val api: MyRemoteDataSource,
  private val cache: MyCachingService /* Notice I changed the dependency */
) {
  fun fetchRemoteData() = flow {
    // Fetching API data
    val response = api.getData()

    val model = cache.save(response.payload)

    // Sending back the data
    model?.let {
      emit(Success(it))
    } ?: emit(Error("Error caching the remote data"))
  }
}

// Shifted all caching logic to another class
class MyCachingService(
  private val local: MyLocalDatabase
) {
  suspend fun save(payload: Payload): Model? {
    var model = Model.parse(payload)
    val success = local.addModel(model)
    return if (success)
      local.find(model.key)
    else
      null
  }
}

如上,本地存储的异常处理逻辑由 MyCachingService 负责,Repository 只负责最基本的调用。单一职责是“关注点分离”精神的集中体现。

SOLID - OCP

Open/Closed Principle 开闭原则

Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.

“拥抱扩展,避免修改”。开闭原则可以说是 SOLID 的灵魂。仔细体会可以发现,SOLID 其它几原则都是在践行 OCP 的精神。

我们在代码设计上要兼顾代码的稳定性和扩展性。功能迭代尽量通过在存量代码上做扩展实现,要尽可能少地修改存量代码本身。存量代码的稳定性得到保证,当功能升级时,可以更低成本地向前兼容,降低回归测试成本。

比如我们定义一组类似 Web Dom 的协议,其中定义了 ParagraphTag,  AnchorTagImageTag 等标签类型,同时提供了相应的高度比较的 API

class ParagraphTag(
  val width: Int,
  val height: Int
)

class AnchorTag(
  val width: Int,
  val height: Int
)

class ImageTag(
  val width: Int,
  val height: Int
)

// Client-code
infix fun ParagraphTag.tallerThan(anchor: AnchorTag)Boolean {
  return this.height > anchor.height
}

infix fun AnchorTag.tallerThan(anchor: ParagraphTag)Boolean {
  return this.height > anchor.height
}

infix fun ParagraphTag.tallerThan(anchor: ImageTag)Boolean {
  return this.height > anchor.height
}

// ... more functions

上面这样的代码毫无扩展性可言。

设想当我们需要增加新的 HeadTag 时,我们要增加多达至少六组 tallerThan 逻辑。注意 Kotlin 的扩展函数容易让人误以为这只是一种“扩展”,其实本质上是 tallerThan 这个静态函数对内部逻辑的“修改”。

合理的做法是,通过 OOP 抽象的方式,提取公共接口 PageTag,统一实现 tallerThan

interface PageTag {
  val width: Int
  val height: Int
}

class ParagraphTag(
  override val width: Int,
  override val height: Int
) : PageTag

class AnchorTag(
  override val width: Int,
  override val height: Int
) : PageTag

class ImageTag(
  override val width: Int,
  override val height: Int
) : PageTag


// Client Code
infix fun PageTag.tallerThan(other: PageTag)Boolean {
  return this.height > other.height
}

这样,我们只增加一个 PageTag 的派生类即可,通过“扩展”避免了对 tallerThan 等存量代码的“修改”。

SOLID - LSP

Liskov Substitution Principle 里氏替换原则

If S is a subtype of T, then any properties provable by T must also be provable by S.

简单来说,就是代码中父类可以出现的地方,均可以被子类所替换。

在 Java 等面向对象语言的设计中都遵循了这个原则:父类型参数传入子类对象是可以工作的,反之则不能通过编译。我们在自己的程序设计中也要遵循这个原则:

open class Bird {
  open fun fly() {
    // ... performs code to fly
  }

  open fun eat() {
    // ...
  }
}

class Penguin : Bird() {
  override fun fly() {
    throw UnsupportedOperationException("Penguins cannot fly")
  }
}

Penguin 不会飞,所以上面代码中它的 fly 方法会抛异常。但是其父类 Birdfly 方法默认行为是不会抛出异常的,这种行为差异让 Penguin 无法在代码中随意出现在 Bird 的位置,违反 LSP。

Penguin 在继承 Bird 后对 fly 进行了“修改”而不只是“扩展”,这本身也是对 OCP 的违反。

open class FlightlessBird {
  open fun eat() {
    // ...
  }
}

open class Bird : FlightlessBird() {
  open fun fly() {
    // ...
  }
}

class Penguin : FlightlessBird() {
   // ...
}

class Eagle : Bird() {
  // ...
}

我们可以如上这样修改,通过 Penguin 继承 FlightlessBird,让代码符合 LSP 。

SOLID - ISP

Interface Segregation Principle 接口隔离原则

Interfaces should not force their clients to depend on methods it does not use.

ISP 跟 SRP 有点像,SRP 面相类或者方法,而 ISP 可以理解成 Interface 版的 SRP。接口的职责应该保持简单,如果接口能力太多,派生类实现的成本就会很高

interface Vehicle {
  fun turnOn()
  fun turnOff()
  fun drive()
  fun fly()
  fun pedal()
}

class Car : Vehicle {
  override fun turnOn() { /* Implementation */ }
  override fun turnOff() { /* Implementation */ }
  override fun drive() { /* Implementation */ }
  override fun fly() = Unit
  override fun pedal() = Unit
}

class Aeroplane : Vehicle {
  override fun turnOn() { /* Implementation */ }
  override fun turnOff() { /* Implementation */ }
  override fun drive() = Unit
  override fun fly() { /* Implementation */ }
  override fun pedal() = Unit
}

class Bicycle : Vehicle {
  override fun turnOn() = Unit
  override fun turnOff() = Unit
  override fun drive() = Unit
  override fun fly() = Unit
  override fun pedal() { /* Implementation */ }
}

看上面的例子,CarBicycleAeroplane 都是 Vehicle 的子类,你会发现 Vehicle 中的方法并非对所有子类都有意义。这些子类不是抽象类,也无法选择性对接口方法进行实现,因此多了很多无效样板代码。

通过接口隔离原则,我们改为下面这样

interface SystemRunnable {
  fun turnOn()
  fun turnOff()
}

interface Drivable() {
  fun drive()
}

interface Flyable() {
  fun fly()
}

interface Pedalable() {
  fun pedal()
}

class Car : SystemRunnable, Drivable {
  override fun turnOn() { /* Implementation */ }
  override fun turnOff() { /* Implementation */ }
  override fun drive() { /* Implementation */ }
}

class Aeroplane : SystemRunnable, Flyable {
  override fun turnOn() { /* Implementation */ }
  override fun turnOff() { /* Implementation */ }
  override fun fly() { /* Implementation */ }
}

class Bicycle : Pedalable {
  override fun pedal() { /* Implementation */ }
}

DrivableFlyablePedalable,每个接口只做一个事情,通过实现多个接口组合,更灵活地定义子类功能

SOLID - DIP

Dependency Inversion Principle 依赖倒置原则

  1. High-level modules should not depend on low-level modules; both should depend on abstractions.
  2. Abstractions should not depend on details. Details should depend upon abstractions.

有依赖关系的多个组件,越处于下层应该越保持稳定。下层组件的变动造成上层组件的被动修改,破坏整个系统稳定性。

当我们发现下层组件不具备较好的稳定性时,要考虑避免上层对下层的直接依赖。通常做法是提供抽象接口,上层依赖接口,下层实现接口。而这个接口服务上层,所以很多时候由上层模块提供定义,因此某种程度上说依赖关系发生了倒置,下层依赖上层(提供的接口)。

class Repository(
  private val api: MyRemoteDatabase,
  private val cache: MyCachingService
) {
  fun fetchRemoteData() = flow {
    // Fetching API data
    val response = api.getData()

    val model = cache.save(response.payload)

    // Sending back the data
    model?.let {
      emit(Success(it))
    } ?: emit(Error("Error caching the remote data"))
  }
}

class MyRemoteDatabase {
  suspend fun getData(): Response { /* ... */ }
}

class MyCachingService(
  private val local: MyLocalDatabase
) {
  suspend fun save(): Model? { /* ... */ }
}

class MyLocalDatabase {
  suspend fun add(model: Model)Boolean { /* ... */ }
  suspend fun find(key: Model.Key): Model { /* ... */ }
}

上面是我们 SRP 中举的 Repository 的例子。

假设 MyCachingService 会随着 MyLocalDatabase 的改动而变化,例如经常要在 MongoDBPostgreSQL 之间做切换,那么 Repository 也会因为 MyCachingService 的变化而变得不稳定。

我们像下面这样提供更多的抽象接口做隔离:

interface CachingService {
  suspend fun save(): Model?
}

interface SomeLocalDb() {
  suspend fun add(model: Model)Boolean
  suspend fun find(key: Model.Key): Model
}

class Repository(
  private val api: SomeRemoteDb,
  private val cache: CachingService
) { /* Implementation */ }

class MyCachingService(
  private val local: SomeLocalDb
) : CachingService { /* Implement methods */ }

class MyAltCachingService(
  private val local: SomeLocalDb
) : CachingService { /* Implement methods */ }

class PostgreSQLLocalDb : SomeLocalDb { /* Implement methods */ }
class MongoLocalDb : SomeLocalDb { /* Implement methods */ }

下层变动不会再传导到上层,上层的稳定性得以保证。

最后

本文特意选取了一些简单的例子,希望帮助大家快速掌握这些设计原则的核心思想。本文的例子简单,但是实际项目代码往往情况复杂得多,不一定能一眼洞穿其中的设计问题,希望大家能够举一反三,将这些设计原则融会贯通到你写的每一行代码中。

-- END --


推荐阅读


继续滑动看下一个
AndroidPub
向上滑动看下一个

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存