KISS/DRY/YANGI/SOLID 等程式設計原則 第一類程式碼是炫技,第二類程式碼才叫專業。

papering發表於2024-09-10

小結:

SOLID   
Uncle Bob 《敏捷軟體開發》

SOLID - SRP
Single Responsibility Principle 單一職責原則
A class should have one, and only one, reason to change.
SOLID 中最簡單的原則,每個 class 或者 function 只做一件事情。


Open/Closed Principle 開閉原則
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
“擁抱擴充套件,避免修改”。開閉原則可以說是 SOLID 的靈魂。仔細體會可以發現,SOLID 其它幾原則都是在踐行 OCP 的精神。
我們在程式碼設計上要兼顧程式碼的穩定性和擴充套件性。功能迭代儘量透過在存量程式碼上做擴充套件實現,要儘可能少地修改存量程式碼本身。存量程式碼的穩定性得到保證,當功能升級時,可以更低成本地向前相容,降低迴歸測試成本。

SOLID - LSP
Liskov Substitution Principle 里氏替換原則
If S is a subtype of T, then any properties provable by T must also be provable by S.
簡單來說,就是程式碼中父類可以出現的地方,均可以被子類所替換。


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。介面的職責應該保持簡單,如果介面能力太多,派生類實現的成本就會很高

SOLID - DIP
Dependency Inversion Principle 依賴倒置原則
High-level modules should not depend on low-level modules; both should depend on abstractions.
Abstractions should not depend on details. Details should depend upon abstractions.
有依賴關係的多個元件,越處於下層應該越保持穩定。下層元件的變動造成上層元件的被動修改,破壞整個系統穩定性。

通俗易懂講解 KISS/DRY/YANGI/SOLID 等程式設計原則 https://mp.weixin.qq.com/s/ToZQt70i-RvgP2_73M8E9A

通俗易懂講解 KISS/DRY/YANGI/SOLID 等程式設計原則

前言

在我上一篇文章《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<Int, Int>>
)

如果我們需要實現一段邏輯,輸出所有學生的學分超過 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(5, 3)
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(5, 3)
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 */ }

下層變動不會再傳導到上層,上層的穩定性得以保證。

最後

本文特意選取了一些簡單的例子,希望幫助大家快速掌握這些設計原則的核心思想。本文的例子簡單,但是實際專案程式碼往往情況複雜得多,不一定能一眼洞穿其中的設計問題,希望大家能夠舉一反三,將這些設計原則融會貫通到你寫的每一行程式碼中。

相關文章