通俗易懂讲解 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 */ }
下层变动不会再传导到上层,上层的稳定性得以保证。
最后
本文特意选取了一些简单的例子,希望帮助大家快速掌握这些设计原则的核心思想。本文的例子简单,但是实际项目代码往往情况复杂得多,不一定能一眼洞穿其中的设计问题,希望大家能够举一反三,将这些设计原则融会贯通到你写的每一行代码中。
-- END --
推荐阅读