golang

SOLID и DRY в Go

  • пятница, 19 января 2024 г. в 00:00:16
https://habr.com/ru/companies/otus/articles/786314/

Привет, Хабр!

Все знают, что SOLID и DRY делают код более чистым, гибким и, что немаловажно, понятным для других разрабов. Каждый компонент выполняет свою функцию и вместе они создают гармонию.

В этой статье рассмотрим как эти принципы применяются в golang.

SOLID

Single Responsibility Principle гласит, что класс или модуль должен иметь только одну причину для изменения. Корочег говоря - каждый класс или функция должны решать лишь одну задачу, не более. Если у вас есть функция или класс, который меняется по нескольким причинам, это первый звоночек, что вы нарушаете SRP.

Когда есть компонент, который в ответе только за одну задачу, его намного проще изменять, не затрагивая остальные части системы.

Неправильное применение SRP:

package main

import (
    "fmt"
    "net/http"
)

type User struct {
    ID        int
    FirstName string
    LastName  string
}

// функция обрабатывает HTTP-запросы И управляет пользователями
func (u *User) HandleRequest(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case "GET":
        // получение данных пользователя
    case "POST":
        // создание нового пользователя
    }
}

HandleRequest класса User выполняет две задачи: обрабатывает HTTP-запросы и управляет пользователями, это большая ошибка

Правильное применение SRP:

package main

import (
    "fmt"
    "net/http"
)

type User struct {
    ID        int
    FirstName string
    LastName  string
}

type UserHandler struct {
    // ...
}

// UserHandler отвечает только за обработку HTTP-запросов
func (h *UserHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    switch r.Method {
    case "GET":
        // получение данных пользователя
    case "POST":
        //создание нового пользователя
    }
}

User хранит данные о пользователе, а UserHandler управляет HTTP-запросами. Каждый класс фокусируется на своей уникальной задаче. Если потребуется изменить логику обработки HTTP-запросов, можно это сделать в UserHandler, не затрагивая класс User.

Open/closed Principle - программные сущности (классы, модули, функции и т.д.) должны быть открыты для расширения, но закрыты для изменения. Нужно свой код таким образом, чтобы для добавления новой функциональности не требовалось менять существующий код. Соблюдение этого уменьшает вероятность возникновения багов, т.к вам не нужно трогать уже работающий код

Пример без использования OCP:

package main

import "fmt"

type Printer struct {}

func (p *Printer) Print(data string) {
    fmt.Println("Data: ", data)
}

// Допустим, нам нужно добавить функционал печати в HTML
// придется измнить класс Printer, что нарушает OCP
func (p *Printer) PrintHTML(data string) {
    fmt.Println("<habr>" + data + "</habr>")
}

func main() {
    printer := Printer{}
    printer.Print("Hello, World!")
    printer.PrintHTML("Hello, HABR World!")
}

Для добавления новой функциональности (печати в HTML), мы изменили класс Printer. Это нарушает OCP.

Пример с использованием OCP:

package main

import "fmt"

type Printer interface {
    Print(data string)
}

type TextPrinter struct {}

func (p *TextPrinter) Print(data string) {
    fmt.Println("Data: ", data)
}

type HTMLPrinter struct {}

func (h *HTMLPrinter) Print(data string) {
    fmt.Println("<html>" + data + "</html>")
}

func main() {
    var printer Printer

    printer = &TextPrinter{}
    printer.Print("Hello, World!")

    printer = &HTMLPrinter{}
    printer.Print("Hello, HTML World!")
}

Вместо изменения существующего кода, мы расширили функциональность системы, добавив новую реализацию интерфейса Printer. Соблюдаем OCP: существующий код не изменяется, а новый функционал добавляется через новые реализации.

LSP гласит, что объекты в программе должны быть заменяемыми на экземпляры их подтипов без изменения правильности работы программы. Это звучит как-то непонятно, но на самом деле всё просто: если у вас есть класс-родитель и класс-потомок, то любой код, который использует родительский класс, должен работать так же хорошо и с объектами дочернего класса.

Пример:

package main

import "fmt"

// Bird базовый тип
type Bird struct {}

func (b *Bird) Fly() {
    fmt.Println("Птица летит")
}

// Penguin - подтип Bird, но не может летать
type Penguin struct {
    Bird
}

func main() {
    var bird = &Bird{}
    bird.Fly()

    var penguin = &Penguin{}
    penguin.Fly() // Нарушение LSP, т.к. пингвины не летают
}

Penguin наследуется от Bird, но не соответствует поведению, ожидаемому от Bird, что нарушает LSP.

Автор https://reactor.cc/user/Бабос

В данном случае, так как пингвины не умеют летать (или все же умеют?), нам следует отделить способность летать от базового класса Bird:

package main

import "fmt"

// Bird базовый тип
type Bird struct {}

func (b *Bird) MakeSound() {
    fmt.Println("Птица издает звук")
}

// FlyingBird интерфейс для летающих птиц
type FlyingBird interface {
    Fly()
}

// Sparrow подтип Bird, который умеет летать
type Sparrow struct {
    Bird
}

func (s *Sparrow) Fly() {
    fmt.Println("Воробей летит")
}

// Penguin подтип Bird, но не реализует интерфейс FlyingBird
type Penguin struct {
    Bird
}

func main() {
    var sparrow FlyingBird = &Sparrow{}
    sparrow.Fly()

    var penguin = &Penguin{}
    penguin.MakeSound() // Penguin может издавать звук, но не летать
}

Bird остается базовым классом для всех птиц, обеспечивая общее поведение (например, издавать звук). Создается интерфейс FlyingBird для птиц, которые могут летать. Sparrow реализует интерфейс FlyingBird, так как воробьи умеют летать. Penguin является подтипом Bird, но не реализует интерфейс FlyingBird, поскольку пингвины не летают.

ISP утверждает, что юзеры не должны быть вынуждены зависеть от интерфейсов, которые они не используют. Это означает, что вместо одного наполненного интерфейса лучше иметь несколько тонких и специализированных

Пример:

package main

type Printer interface {
    Print(document string)
}

type Scanner interface {
    Scan(document string)
}

// MultiFunctionDevice наследует от обоих интерфейсов
type MultiFunctionDevice interface {
    Printer
    Scanner
}

// класс, реализующий только функцию печати
type SimplePrinter struct {}

func (p *SimplePrinter) Print(document string) {
    // реализация печати
}

// класс, реализующий обе функции
type AdvancedPrinter struct {}

func (p *AdvancedPrinter) Print(document string) {
}

func (p *AdvancedPrinter) Scan(document string) {
}

Не заставляем SimplePrinter реализовывать функции сканирования, которые он не использует, соблюдая ISP.

DIP гласит, что высокоуровневые модули не должны зависеть от низкоуровневых модулей. Оба типа модулей должны зависеть от абстракций.

Приме:

package main

import "fmt"

// Интерфейс для абстракции хранения данных
type DataStorage interface {
    Save(data string)
}

// Низкоуровневый модуль для хранения данных в файле
type FileStorage struct {}

func (fs *FileStorage) Save(data string) {
    fmt.Println("Сохранение данных в файл:", data)
}

// Высокоуровневый модуль, не зависит напрямую от FileStorage
type DataManager struct {
    storage DataStorage // зависит от абстракции
}

func (dm *DataManager) SaveData(data string) {
    dm.storage.Save(data) // делегирование сохранения
}

func main() {
    fs := &FileStorage{}
    dm := DataManager{storage: fs}
    dm.SaveData("Тестовые данные")
}

DataManager не зависит напрямую от FileStorage. Вместо этого он использует интерфейс DataStorage, что позволяет легко заменить способ хранения данных без изменения DataManager.

DRY

Каждый кусочек знаний в системе должен иметь единственное, недвусмысленное, авторитетное представление в рамках системы. Проще говоря, надо избегать повторения одного и того же кода в разных частях вашей программы. Когда логика дублируется, любое изменение в ней требует обновления во всех местах, где она встречается.

Нарушение DRY:

package main

import "fmt"

type User struct {
    Name string
    Age  int
}

func (u User) PrintName() {
    fmt.Println(u.Name)
}

func (u User) PrintAge() {
    fmt.Println(u.Age)
}

func main() {
    user := User{Name: "Alex", Age: 30}
    user.PrintName()
    user.PrintAge()
}

Соблюдение DRY:

package main

import "fmt"

type User struct {
    Name string
    Age  int
}

func (u User) PrintInfo() {
    fmt.Printf("Name: %s, Age: %d\n", u.Name, u.Age)
}

func main() {
    user := User{Name: "Alex", Age: 30}
    user.PrintInfo()
}

В первом примере мы видм, что методы PrintName и PrintAge дублируют логику вывода информации о пользователе. Во втором примере мы исправляем это, объединяя логику в одном методе PrintInfo.


SOLID и DRY это крутые и очень полезные инструменты, которые позволяют строить код, который не только решает текущие задачи, но и готов к будущим вызовам в виде багов, которых будет меньше при соблюдение данных принципов.

В завершение хочу порекомендовать вам вебинар, в рамках которого эксперты отрасли расскажут, какие конкретные компетенции необходимы Golang-инженерам на разных этапах развития карьеры. Регистрация доступна по ссылке.