小結:
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,這是個啥?該怎麼寫?》中提到了 SRP
,ISP
等 SOLID
原則,有小夥伴私信希望針對這些設計原則進行專門介紹,所以本文梳理了幾個重要的設計原則,並配合簡單的程式碼示例,幫助大家深入理解。
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
假設專案中存在這樣一個資料類:它代表一個學生物件,除了有 name
和 id
屬性,還有代表課程資訊的 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
, AnchorTag
和 ImageTag
等標籤型別,同時提供了相應的高度比較的 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
方法會拋異常。但是其父類 Bird
的 fly
方法預設行為是不會丟擲異常的,這種行為差異讓 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 */ }
}
看上面的例子,Car
,Bicycle
,Aeroplane
都是 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 */ }
}
Drivable
,Flyable
,Pedalable
,每個介面只做一個事情,透過實現多個介面組合,更靈活地定義子類功能
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.
有依賴關係的多個元件,越處於下層應該越保持穩定。下層元件的變動造成上層元件的被動修改,破壞整個系統穩定性。
當我們發現下層元件不具備較好的穩定性時,要考慮避免上層對下層的直接依賴。通常做法是提供抽象介面,上層依賴介面,下層實現介面。而這個介面服務上層,所以很多時候由上層模組提供定義,因此某種程度上說依賴關係發生了倒置,下層依賴上層(提供的介面)。
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
的改動而變化,例如經常要在 MongoDB
和 PostgreSQL
之間做切換,那麼 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 */ }
下層變動不會再傳導到上層,上層的穩定性得以保證。
最後
本文特意選取了一些簡單的例子,希望幫助大家快速掌握這些設計原則的核心思想。本文的例子簡單,但是實際專案程式碼往往情況複雜得多,不一定能一眼洞穿其中的設計問題,希望大家能夠舉一反三,將這些設計原則融會貫通到你寫的每一行程式碼中。