从我作为一名大一学生的经历以及听取学长学姐的经验来看,学校和学院教你如何编程以及所需的数学知识,例如离散数学和微积分。但是当你离开大学进入行业时,有一些概念和原则你必须了解,以便顺利过渡。我们将讨论 KISS、DRY 和 SOLID 原则。
照片由 Ivan Aleksic 在 Unsplash 提供
KISS 原则保持简单,白痴!
注:此处的“白痴”是直译,但考虑到礼貌和文化差异,建议在实际使用中可以改为“保持简单,别傻傻的!”或“保持简单,不要复杂化!”等更温和的表达。不过根据要求,只输出翻译内容,不做额外建议。
很多时候,你会发现自己在一个团队中工作。一群开发人员在项目的不同方面进行开发。如果你切换到别人的工作代码,你会希望看到一团糟的代码,没有任何注释或变量,还是希望看到一份清晰的、解释得很好的文档化代码?显然,你会选择后者。
如果你正在编写一个非常复杂的程序,假设你生病了。一周后你康复了,重新审视你的项目。当然你会失去编程的感觉,但是你愿意看到一个已经看不懂的代码吗?还是说看到一些至少能稍微理解的代码开始呢?当然是后者。
现在想象你正在独自进行一个项目,并且每天都在进行开发。你将会遇到一些bug,并且需要调试它们。如果你的代码比实际需要的更复杂,调试代码会有多难呢?
所有上述场景都指向一件事——保持简单,白痴!
注:这里的“白痴”是英文俚语“Stupid”的直译,通常在编程原则中用来强调简单性,实际使用时可以考虑改为“保持简单,愚蠢!”以避免冒犯。但根据要求保持内容不变,所以直接翻译为“白痴”。
“任何傻瓜都能写出计算机能理解的代码。好程序员写出人类能理解的代码。” — Martin Fowler
说到底,机器并不关心你是为了完成某个任务而编写了简单的代码还是复杂的代码。相反,这确实对人类(包括你)来说很重要,因为你们会阅读并试图理解这段代码。
但是我该如何遵循KISS原则?(听起来很奇怪,我知道)考虑一个模型类,Student。这个类将存储2个项目和一个映射,其中键和值都是成对的。第一个成对包含两个字符串:模块的名称和ID。第二个成对包含两个双精度数:该模块所获得的成绩和该模块的最大分数。
data class Student(
val name: String,
val age: Int,
val moduleMarks: 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.moduleMarks
.filter { (_, (a, m)) -> a / m > 0.8}
.map { ((n, _), _) -> n }
}
return scholars
}
即使几天后再次看到这样的代码也会很糟糕。虽然可以说这是一个相对简单的例子,但还是有办法让它变得更简单。可以引入更多的抽象和变量。
data class Student(
val name: String,
val age: Int,
val moduleMarks: Map<Module, Mark>
)
data class Module(
val name: String,
val id: String
)
data class Mark(
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 modulesAbove80 = student.moduleMarks
.filter { (_, mark) -> mark.isAbove(80.0)}
.map { (module, _) -> module.name }
scholars[student.name] = modulesAbove80
}
return scholars
}
这增加了大量的代码。但更重要的是,代码看起来更整洁,并且读起来像英语。
DRY 原则不要重复自己
如果你发现你一直在重复执行相同的代码,可以创建一个函数并重用它。在我的大学作业中,我正在处理一组对象(单元格),大多数(如果不是全部)定义的函数都需要我从集合中搜索并获取特定的对象,然后对其进行操作。
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);
// ...
}
// ...
}
那上面的代码量很大。但在编写这段代码时,我发现我自己多次在不同的地方复制粘贴相同的代码块。因此,我将它们抽象成可以在各处复用的函数。
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,就不用在_n_个不同的地方修改代码。只需要在函数内部修改一次,就能在所有地方修复bug。
SOLID 原则这并不是一个单一的原则,而是五个对于软件开发至关重要的原则。
S — 单一职责一个类应该只有一个变更的理由。
可能是最容易理解的原则。你定义的每个类/函数只能执行一个任务。假设你正在构建一个网络应用程序。
class Repository(
private val api: MyRemoteDatabase,
private val local: MyLocalDatabase
) {
fun fetchRemoteData() = flow {
// 获取API数据
val response = api.getData()
// 将数据保存到缓存中
var model = Model.parse(response.payload)
val success = local.addModel(model)
if (!success) {
emit(Error("Error caching the remote data"))
return@flow
}
// 从单一数据源返回数据
model = local.find(model.key)
emit(Success(model))
}
}
上述代码违反了单一职责原则。该函数不仅负责获取远程数据,还负责将数据存储到本地。这部分逻辑应该提取到另一个类中。
class Repository(
private val api: MyRemoteDatabase,
private val cache: MyCachingService /* 注意我更改了依赖 */
) {
fun fetchRemoteData() = flow {
// 获取API数据
val response = api.getData()
val model = cache.save(response.payload)
// 发送数据
model?.let {
emit(Success(it))
} ?: emit(Error("缓存远程数据时出错"))
}
}
// 将所有缓存逻辑移到另一个类中
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
只负责将传入的数据保存到本地数据库,而仓库只负责获取数据并发送上述模型。这样做是一个好习惯,因为它叫做 关注点分离,这可以提高调试和测试的便利性。
软件实体(类、模块、函数等)应该易于扩展,但不应修改。
这个原则基本上意味着 不要编写这样的软件代码,在未来的更改中会破坏客户端代码。假设你在使用Kotlin构建一个Web开发API。你设计了ParagraphTag
、AnchorTag
和ImageTag
。在你的代码中,你需要比较两个元素的高度。
class ParagraphTag(
val width: Int,
val height: Int
)
class AnchorTag(
val width: Int,
val height: Int
)
class ImageTag(
val width: Int,
val height: Int
)
// 客户端代码
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
}
// ... 更多函数
sigh! 这可真是不少工作。现在又有新的需求,要求你在页面中加入一个 Heading 标签。你还需要在客户端添加 六个额外的函数。这不仅很繁琐,你还需要修改客户端代码以适应程序的需求。
相反,声明一个接口 — PageTag
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
// 客户端代码
infix fun PageTag.tallerThan(other: PageTag): Boolean {
return this.height > other.height
}
现在你已经 关闭 了客户端代码以进一步修改它。为了 扩展 功能,你可以 开放 创建一个新的类并实现 PageTag
,并且一切都会完美运行。
如果 S 是 T 的子类型,则任何可以通过 T 证明的属性也必须可以通过 S 证明。
哦。数学?好吧,这不太好。相比之下,这是一个很容易理解的原则。让我们考虑一个新的例子。
open class Bird {
open fun fly() {
// ... 执行飞行代码
}
open fun eat() {
// ...
}
}
class Penguin : Bird() {
override fun fly() {
throw UnsupportedOperationException("企鹅不能飞行")
}
}
注意上面的 Bird
类没有抛出任何异常,而 Penguin
类则抛出了异常。你不能在不破坏或修改客户端代码的情况下将 Penguin 替换为 Bird。这违反了里式替换原则。Penguin
继承 Bird
会破坏客户端代码,从而也违反了开闭原则。
一种解决方法是修改你的设计实现。
open class FlightlessBird {
open fun eat() {
// ...
}
}
open class Bird : FlightlessBird() {
open fun fly() {
// ...
}
}
class Penguin : FlightlessBird() {
// ...
}
class Eagle : Bird() {
// ...
}
上面的代码解释了如果 FlightlessBird
可以吃,那么 FlightlessBird
的所有子类也可以吃。同样,如果 Bird
可以飞,那么 Bird
的所有子类也必须能飞。
接口不应强制其客户端依赖于未使用的方法。
这个定义看起来并不吓人。实际上,它并不吓人。考虑你正在建造一辆汽车、一架飞机和一辆自行车。由于它们都是车辆,你正在实现Vehicle接口。
interface Vehicle {
fun turnOn()
fun turnOff()
fun drive()
fun fly()
fun pedal()
}
class Car : Vehicle {
override fun turnOn() { /* 实现 */ }
override fun turnOff() { /* 实现 */ }
override fun drive() { /* 实现 */ }
override fun fly() = Unit
override fun pedal() = Unit
}
class Aeroplane : Vehicle {
override fun turnOn() { /* 实现 */ }
override fun turnOff() { /* 实现 */ }
override fun drive() = Unit
override fun fly() { /* 实现 */ }
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() { /* 实现 */ }
}
恶心!看看这些类被迫实现它们不需要的方法了吗?我也不可以将这些类声明为抽象类。根据接口隔离原则,我们应该采用这样的设计。
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() { /* 实现 */ }
override fun turnOff() { /* 实现 */ }
override fun drive() { /* 实现 */ }
}
class Aeroplane : SystemRunnable, Flyable {
override fun turnOn() { /* 实现 */ }
override fun turnOff() { /* 实现 */ }
override fun fly() { /* 实现 */ }
}
class Bicycle : Pedalable {
override fun pedal() { /* 实现 */ }
}
现在这看起来简洁多了,也更容易通过接口来引用不同的功能。
D — 依赖倒置原则1. 高层次模块不应该依赖于低层次模块;两者都应当依赖于抽象。
2. 抽象不应该依赖于细节。细节应当依赖于抽象。
这到底是什么意思?高层次模块是指业务或UI能看到的模块。低层次模块是指处理应用程序细节的模块。回想一下我在单一职责原则中提到的例子:
class Repository(
private val api: MyRemoteDatabase,
private val cache: MyCachingService
) {
fun fetchRemoteData() = flow {
// 获取API数据
val response = api.getData()
val model = cache.save(response.payload)
// 发送数据
model?.let {
emit(Success(it))
} ?: emit(Error("缓存远程数据时出错"))
}
}
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 { /* ... */ }
}
看起来不错,它会完美运行。然而,将来如果我决定更改本地数据库,从PostgreSql改为MongoDB;或者如果我决定完全改变缓存机制,我将不得不更改整个实现细节以及客户端代码。高层次模块依赖于低层次的具体模块。
这不对。相反,你必须将功能抽象为一个接口,并让具体实现类继承该接口。
interface CachingService {
挂起函数 save(): Model?
}
interface SomeLocalDb() {
挂起函数 add(model: Model): Boolean
挂起函数 find(key: Model.Key): Model
}
class Repository(
private val api: SomeRemoteDb,
private val cache: CachingService
) { /* 实现 */ }
class MyCachingService(
private val local: SomeLocalDb
) : CachingService { /* 实现方法 */ }
class MyAltCachingService(
private val local: SomeLocalDb
) : CachingService { /* 实现方法 */ }
class PostgreSQLLocalDb : SomeLocalDb { /* 实现方法 */ }
class MongoLocalDb : SomeLocalDb { /* 实现方法 */ }
你只需更改一个单词,就可以在整个应用程序中轻松地在不同的实现之间切换你的仓库。每次听到这个说法时,我都会感到不寒而栗。
我花了相当长的时间来阐述这篇文章中的每一条信息。希望你喜欢阅读并有所收获。谢谢!
参考资料:- https://dev.to/tamerlang/understanding-solid-principles-single-responsibility-principle-523j
- https://stackify.com/solid-design-principles/
如果你想阅读我所有的文章,可以考虑通过这个 [推荐链接](https://medium.com/@cybercoder.naj/membership) 加入 Medium 计划。
**想要联系我?**
我的 [GitHub](https://github.com/cybercoder-naj) 个人主页。
我的 [作品集](https://cybercoder-naj.github.io) 网站。
共同學習,寫下你的評論
評論加載中...
作者其他優質文章