坚实的设计原则

2025-05-25

坚实的设计原则

面向对象编程本身就是一种优秀的软件开发方法,然而,随着软件变得越来越复杂,你可能会意识到,OOP 带来的问题和它解决的问题一样多,最终可能会导致软件维护不善。为了应对 OOP 带来的复杂性,我们需要一种合适的格式,这催生了 SOLID 应用程序设计原则。SOLID 原则是一套用于创建可读且可维护代码的软件设计指南。它们是使用 OOP 方法构建大型复杂软件的基石。

您应该知道,这些原则并非编写软件时必须严格遵循的清单,而只是作为指南,帮助您进行程序设计,尤其是在面向对象方面。如果在构建软件时遵循 SOLID 原则,它们可以帮助程序员做出更详细的决策,从而更准确地模拟情况,并更轻松地处理与软件设计方法相关的复杂性。SOLID 原则的顺序并不重要,我们将不分先后顺序地逐一介绍它们。

要阅读更多类似文章,请访问Netcreed

单一职责原则

这条原则直截了当。它要求代码中的一个类只关注一项职责,因此也应该只有一个修改的理由。在设计类时,应尽可能将相关功能放在一起,以确保它们可能出于相同的原因而发生变化。判断代码是否遵循此原则的一个关键检查是,代码中的类应该执行一些相关的任务。这使得类具有高度的内聚性。

类的内聚性指的是类内特性的关联程度,正确应用 SRP 的最终结果是高内聚性。SRP 不仅关注类,你还可以通过确保函数只关注一个功能或模块只关注一个职责范围来确保你的函数或模块遵循 SRP。让我们看一个 SRP 实现的示例,但首先我们先来考虑一下违反 SRP 的情况。

class Music {
    constructor(private artist: string, private title: string){}

    getArtist(){
        return this.artist
    }

    play(){
        console.log(`currently playing song by ${this.artist}`)
    }
}

let music = new Music('2 Pac', 'Hail Mary')

music.play()
Enter fullscreen mode Exit fullscreen mode

乍一看,这似乎没什么问题,但仔细想想。类Music作为对象还是数据结构,两者之间的界限非常模糊,将播放音乐的逻辑与类紧密耦合毫无意义,Music不如创建一个AudioPlayer负责播放音乐的类。这样做的好处是,音乐类的更改不会影响音频播放器类,反之亦然。这样就实现了高度的内聚性,音乐类只是音乐的数据结构,而音频播放器负责播放音乐。

class Music {
    constructor(private artist: string, private title: string){}

    getArtist(){
        return this.artist
    }
}

class AudioPlayer {
    constructor(){}

    playMusic(music: Music){
        let artist = music.getArtist()
        console.log(`currently playing song by ${artist}`)
    }
}

let music = new Music('2 Pac', 'Carlifonia');
let mp3Player = new AudioPlayer();

mp3Player.playMusic(music)

Enter fullscreen mode Exit fullscreen mode

我们也可以通过确保函数足够简单,只关注一件事来实现函数的 SRP。如果你的方法执行很多操作,你可以重构每个方法,使其只执行一件事。你还应该以一种能够揭示方法预期操作的方式命名方法。方法getArtist只关心获取艺术家的名字,而类playMusic中的方法AudioPlayer实际上播放音乐。

开放封闭原则

你的代码中的类多久更改一次?如果你像我一样更改你的类,那么你就没有遵守开放封闭原则。这也没关系。OCP 指出,类应该对扩展开放,但对修改关闭。修改是一些令人伤脑筋的 bug 的核心,应用程序中使用该类的任何部分都可能受到影响,让你不得不扫描不同的模块。如果你改变你的方法并坚持 OCP,扩展你的类会让你以后更少的担心。解决这个问题的关键是:试着在你的代码中识别出你知道以后可能会改变的特性或东西。你可以从它扩展来实现你想要的自定义功能,而不是修改现有的类。让我们看一个遵循这一原则的代码示例。

class Book {
    constructor(private title: string, protected author: string){}

    getAuthor(){
      return this.author
    }
}
// RATHER THAN MODIFYING THIS CLASS
class TextBook extends Book {

    private subject: string

    changeAuthor(author: string){
      this.author = author
    }

    assignSubject(subject: string){
      this.subject = subject
    }

  }

let textBook = new TextBook('chemistry text book', 'sam')
let book = new Book('Perrils of Hell', 'Unknown')

// get the author of a text book
console.log(textBook.getAuthor())
// change the author of a text book
textBook.changeAuthor('Jack')
// assign a subject to a text book
textBook.assignSubject('Chemistry')
console.log(textBook.getAuthor())

// Only get the author of a book
console.log(book.getAuthor())

Enter fullscreen mode Exit fullscreen mode

这只是一个简单的演示,但它可以作为一个很好的入门指南。该类Book有一个针对作者的getter,但没有setter,因为更改书名没有任何意义。现在我们面临的是实现一个,TextBook而不是修改Book类并添加一个type属性,我们只需从它扩展并创建一个TextBook类即可。我们知道有些文本有不同的版本和修订版本,所以名称可能会略有变化,所以我们为其定义了一个getter和一个setter。现在我们可以确定它TextBook不会破坏任何东西,因为现有的代码都与它无关。这样您就可以高枕无忧,而不必担心每次需要实现新功能。

里氏替换原则

Babara Liskov 在 1988 年左右想出了这个天才的想法,但它究竟是怎么回事呢?如果你可以a用另一个类替换一个类b,那么这个类b就是 的子类a。你该如何实现这一点?你可以确保使用超类的代码a无法识别它b是 的子类a。实现这一点的关键可以概括如下。

确保子类中的方法在接收的参数类型和返回的变量类型上保持一致。如果超类a有一个接受 类型参数的方法e,那么子类也应该接受 类型或 子类的b参数。如果超类有一个返回 的函数,那么子类也应该返回或其任何子类。它们还应该抛出相同类型的错误或该错误的子类,我们可以通过实现 Error 接口来创建自定义 Error 类。eeaebe

// SUPER CLASS
class Letter {
    constructor(readonly symbol: string){}

    changeCase(_case: string){
        switch (_case){
            case "upper":
                return this.symbol.toUpperCase()
                break;
            case "lower":
                return this.symbol.toLowerCase()
                break;
            default:
                throw new Error('incorrect case type, use "upper" or "lower"');
                break;
        }
    }
}
// SUBCLASS
class VowelLetter extends Letter {
    changeCase(_case: string){
        if(_case === 'upper'){
            return this.symbol.toUpperCase()
        } else if(_case === 'lower') {
            return this.symbol.toLowerCase()
        } else {
            throw new VowelLetterError('incorrect case', 'use "upper" or "lower"');
        }
    }
}

class VowelLetterError implements Error {
    constructor(public name: string, public message: string){}
}
Enter fullscreen mode Exit fullscreen mode

在上面的例子中,我们创建了一个超类Letter和一个子类VowelLetter。您可能已经注意到,它们都有一个方法changeCase(),用于返回一个以我们传入的 case 格式格式化的字符串。在超类中我们使用了switch语句,而在子类中我们使用了if语句,但要注意参数类型和返回类型以及抛出的错误类型的一致性。让我们看看在什么情况下您可以利用这一原则。

class Word {
    constructor(readonly letters: Letter[]){}

    findLetter(letter: Letter){
        return this.letters.find(l => l === letter)
    }

    makeUpperCase(){
        return this.letters.map(letter => letter.changeCase('upper'))
    }

    makeLowerCase(){
       return this.letters.map(letter => letter.changeCase('lower'))
    }
}

let a = new VowelLetter('a')
let d = new Letter('d')
let e = new VowelLetter('e')
let g = new Letter('g')

let word = new Word([a,d,d])
let egg = new Word([e,g,g])

console.log(word.makeUpperCase()) //["A", "D", "D"]
console.log(egg.makeLowerCase()) //["e", "g", "g"]
g.changeCase('dffgl') // Will throw an error
e.changeCase('ssde') // Will throw an error
Enter fullscreen mode Exit fullscreen mode

接口隔离原则

接口就像一个契约,所有实现它的类都必须遵守。随着时间的推移,你可能已经习惯了创建包含大量属性和方法的大型接口,这本身并没有什么不好,但它很容易导致代码难以管理和升级。ISP 规范要求我们创建更小的接口,让每个类都能实现它,而不是把所有东西都放在一个大类中,从而避免了这种做法。

// WITHOUT ISP
interface PhoneContract {
    call(): string
    ring(): string
    browseInternet(): string
    takePicture(): string
    turnOnBluetooth(): boolean
}
Enter fullscreen mode Exit fullscreen mode

一开始这看起来可能没什么大不了的,但当你需要实现一些略有不同的功能时,你可能会开始头疼不已,甚至连代码都没动一下。然后,实际的修改就成了一场噩梦。首先,你不可能设计出一部不能上网的手机,任何实现手机接口的类都PhoneContract必须包含手机接口上的所有方法。然而,我们可以通过创建更小的接口,每个接口负责手机的某个特定功能,来简单地解决这个问题。

// WITH ISP
interface CallContract {
     call(): string
}

interface RingContract {
    ring(): string
}

interface BrowsingContract {
    browseInternet(): string
}

interface PictureContract {
    takePicture(): string
}

class SmartPhone implements CallContract, RingContract, BrowsingContract, PictureContract {
    constructor(){}
}

class Phone implements CallContract, RingContract {
    constructor(){}
}
Enter fullscreen mode Exit fullscreen mode

而这已经解决了我们头疼的问题和噩梦。通过这种方法,您可以创建任何其他类型的手机,甚至可以创建完全不同于手机的设备,但仍实现手机的部分接口。遵循这一原则,您可以确保代码的每个部分或每个类只实现其实际需要和使用的功能。与其像我在示例中那样实现那么多功能,不如将相关功能进一步分组到类将要实现的单独接口中。这将有助于保持代码的简洁。

依赖倒置原则

这条原则适用于抽象。如果一个类high level依赖于另一个类low level。假设高级类有一个接受低级类的方法,那么由于整个系统的僵化结构,如果你尝试重用高级类,你很可能不得不背负一大堆依赖项。与其依赖于某个类,不如依赖于该低级类的抽象。接下来,我们所依赖的抽象本身也应该依赖于其他抽象。首先,让我们违反这条定律:

class Footballer {
    constructor(private name: string, private age: number){}

    showProfile() {
        return { name: this.name, age: number}
    }
}

class Club {
    constructor(private squad: Footballer[]){}

    getSquad(){
        return this.squad.map(player => player.showProfile())
    }
}
Enter fullscreen mode Exit fullscreen mode

现在你明白了,任何需要俱乐部的东西都会自动涉及到足球运动员,即使它和足球运动员之间没有任何关系。我们可以提供一个接口作为抽象层,然后该接口反过来实现其他接口,从而提供进一步的抽象。

type profile = {    name: string    age: number}interface Footballer {    showProfile:() => profile}class Club {    constructor(private squad: Footballer[]){}        getSquad(){        return this.squad.map(player => player.showProfile())    }}
Enter fullscreen mode Exit fullscreen mode

使用依赖于类型的接口,我们为代码添加了更多的抽象,记住 typescript 的结构类型,这将确保我们可以轻松地移动事物,甚至提供更适合的解决方案来满足我们的需求。

最终,遵循这些原则将帮助你维护一个易于维护且易于升级的代码库,但这并非最终的解决方案。如果你的抽象层不合适,那么问题就从这里开始。希望你觉得这篇文章有用且有趣,请在下方留言。

要阅读更多类似文章,请访问Netcreed

文章来源:https://dev.to/kalashin1/solid-design-principles-5621
PREV
使用 Tailwind 开发时我使用的 4 个 VSCode 扩展
NEXT
Automate the hell out of your code Automate the hell out of your code