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

Все знают, что 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-инженерам на разных этапах развития карьеры. Регистрация доступна по ссылке.

Комментарии (6)


  1. Gromilo
    18.01.2024 07:06
    +7

    Меня всегда в примерах про S из SOLID смущают примеры: нам показали как сделать 2 кирпича, которые ни от чего не зависят, а вот как из них дом стоить не показали. Ведь каждое использование такого кирпича тоже должно быть по S.

    надо избегать повторения одного и того же кода в разных частях вашей программы.

    На мой вкус сильно механистическое упрощение. Важно не то, что код одинаковый, а то что это один и тот же код. Разница в том, что один и тот же код должен всегда одинаково изменяться во всех своих копиях. А просто одинаковый код, может иметь разные причины для изменения. Сейчас одинаковый, а завтра разный. И усилия потраченные на объединение одинакового кода будут дополнены усилиями по его разделению. В общем нужно смотреть не только на код как на набор символов, но и вникать что это за код.


    1. adal
      18.01.2024 07:06

      К сожалению статья довольно проходная, с уже оскомину набившими примерами. Определение S в частности и в оригинале и в 99 процентов материалов очень мутное, которое красиво звучит, но только если не задавать лишних вопросов. Небольшая ремарка касательно повторения сам же Мартин выделяет два типа дублирования - ложное и истинное. Так вот ложное дублирование не требует устранения и наоборот - вредно. Так что одержимо пытаться вырезать все дублирования значит привести в тому от чего хотелось уйти - к сильной связности. Если говорить про SRP, то тут как мне кажется, несколько уровней непонимания - первый трактовка в лоб, одна сущность должна делать что-то одно. Что одно и какая сущность остается за скобками. Мартин пишет, что этот принцип применим только к функциям/методам. Второй уровень как раз представлен в статье, одна причина для изменения. Опять же не понятно, очень завуалировано, если изменились бизнес требования, то одним изменением можно и не отделаться, кроме того как это бьется со вторым требованием, где изменения текущей функциональности не приветствуются (OCP). По разъяснениям самого Мартина итоговый верный вариант звучит так: "Модуль должен отвечать за одного и только за одного актора". Приведу тривиальный пример. Модуль должен быть спроектирован таким образом, что изменяется только по требованию стейкхолдера (владельца продукта, бизнес аналитика), но не скажем DBA. А там где изменяется по требованиям DBA не меняется по требованиям бизнеса. То есть каждая часть кода зависит от одного и только одного актора. Причем внутри бизнес требований это правило так же соблюдается, но чтобы это поддержать нужно уходить в проектирование по доменам (DDD), если делать все так тогда это работает.


      1. Gromilo
        18.01.2024 07:06

        Модуль должен отвечать за одного и только за одного актора

        Интересный подход, не знал о таком. Плохо, что у меня у всех модулей 1 стекхолдер :)

        На практике я делаю так: ответственность одна, если я могу вменяемо сформулировать эту ответственность. Так подход ставит рамки, на то что может содержаться в сущности и становится понятно, когда можно добавлять функционал, а когда пора делать новую сущность.


  1. yanekd
    18.01.2024 07:06
    +3

    В go нет наследования.


  1. DevilSter
    18.01.2024 07:06
    +3

    Я конечно новичек на хабре.. Может здесь есть какое-то разделение на "рекламные" посты и полезные? Прихожу обычно за полезными...

    Но выскажу свое субъективное мнение мнение - определения копи-паст из книг (название не скажу, но совсем недавно читал, у Фаулера что-ли), в применении к Го никак не объяснены, только через очень фантастические надуманные примеры.

    Прочтение по примерам может нанести и вред. Потом начинают писать абстракции на абстракции и превращают Го в Java, читать код невозможно. Сотни интерфейсов там где они не нужны.

    В этом плане мне нравится подход из ardanlabs - никаких "DI" и передачи через интерфейсов до тех пор, пока оно действительно не нужно будет. "Наследование" в Го также довольно интересная штука в плане проблем - если честно не так часто встречаю кейсы когда оно действительно нужно, чаще от него проблемы только. Дробление на функции и "вынос" функционала - тоже надо использовать с умом, иначе потом задолбаешься в ИДЕшке скакать по модулям, и быстрее забудешь зачем вообще зашел.

    И вот эти все моменты, что выше обычно в нормальной литературе подсвечиваются, объясняются побочные моменты и варианты использования.

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


    1. sshikov
      18.01.2024 07:06
      +1

      Это и есть рекламный пост. Это компания, она рекламирует свои услуги - в данном случае обучение.

      Вы правда этого сами не видите? Если нет - то вот вам хороший признак. Смотрите на автора: 128 публикаций и 29 комментариев, с марта 2023 года, т.е. менее чем за год. По-моему, выводы вполне очевидны.