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

Одним из ключей к написанию поддерживаемого, гибкого и многократно используемого кода является следование принципам SOLID. В этой записи блога мы рассмотрим, как принципы SOLID могут помочь вам писать более качественный код на Kotlin, и рассмотрим несколько примеров кода, чтобы показать вам, как применять эти принципы на практике.

К концу этой статьи у вас будет четкое представление о принципах SOLID и о том, как они могут помочь вам стать лучшим разработчиком. Итак, приступим!

S — принцип единой ответственности

Принцип единой ответственности (SRP) гласит, что каждый класс или модуль должен иметь только одну ответственность. Это означает, что класс или модуль должны делать что-то одно и делать это хорошо. Если у класса или модуля более одной ответственности, со временем становится все труднее поддерживать и изменять его.

Вот пример класса, нарушающего SRP:

class UserService {
    fun registerUser(username: String, password: String) {
        // validation logic
        // database insert logic
        // email sending logic
    }
}

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

Вот пример того, как реорганизовать этот класс, чтобы он соответствовал SRP:

class UserValidator {
    fun validate(username: String, password: String): Boolean {
        // validation logic
    }
}

class UserRepository {
    fun saveUser(username: String, password: String) {
        // database insert logic
    }
}

class EmailService {
    fun sendEmail(to: String, subject: String, body: String) {
        // email sending logic
    }
}

class UserService(
    private val userValidator: UserValidator,
    private val userRepository: UserRepository,
    private val emailService: EmailService
) {
    fun registerUser(username: String, password: String) {
        if (!userValidator.validate(username, password)) {
            // handle validation error
            return
        }

        userRepository.saveUser(username, password)

        emailService.sendEmail(username, "Welcome to our app", "Thanks for registering!")
    }
}

В этом рефакторинге кода у каждого класса есть только одна обязанность — проверка ввода пользователя, вставка пользователя в базу данных и отправка электронной почты. Класс UserService теперь зависит от этих трех классов, а не сам несет все обязанности.

O — принцип «открыто-закрыто»

Принцип открытости-закрытости (OCP) гласит, что классы или модули должны быть открыты для расширения, но закрыты для модификации. Это означает, что вы должны иметь возможность добавлять новые функции в класс или модуль без изменения существующего кода.

Вот пример класса, нарушающего OCP:

class PaymentService {
    fun processPayment(paymentMethod: String, amount: Double) {
        when (paymentMethod) {
            "creditCard" -> {
                // credit card processing logic
            }
            "paypal" -> {
                // PayPal processing logic
            }
        }
    }
}

Этот класс нарушает OCP, потому что если необходимо добавить новый способ оплаты, необходимо изменить метод processPayment. Вместо этого мы можем использовать шаблон Strategy, чтобы сделать этот класс расширяемым:

interface PaymentMethod {
    fun processPayment(amount: Double)
}

class CreditCardPayment : PaymentMethod {
    override fun processPayment(amount: Double) {
        // credit card processing logic
    }
}

class PaypalPayment : PaymentMethod {
    override fun processPayment(amount: Double) {
        // PayPal processing logic
}

class PaymentService(
  private val paymentMethod: PaymentMethod
) {

    fun processPayment(amount: Double) {
        paymentMethod.processPayment(amount)
     }

}

В этом рефакторинге кода мы создали интерфейс с именем PaymentMethod, который определяет метод processPayment. Затем мы создали два класса, CreditCardPayment и PaypalPayment, которые реализуют интерфейс PaymentMethod. Наконец, мы реорганизовали класс PaymentService так, чтобы он принимал экземпляр PaymentMethod в своем конструкторе, поэтому мы можем передать любую реализацию PaymentMethod во время выполнения.

L — принцип замены Лисков

Принцип замещения Лискова (LSP) гласит, что объекты суперкласса должны иметь возможность заменяться объектами подкласса без ущерба для корректности программы. Это означает, что подклассы должны иметь возможность использоваться вместо своих родительских классов без нарушения кода.

Вот пример иерархии классов, нарушающей LSP:

open class Shape {
    open fun area(): Double = 0.0
}

class Rectangle(
    private val width: Double,
    private val height: Double
) : Shape() {
    override fun area(): Double = width * height
}

class Square(
    private val side: Double
) : Shape() {
    override fun area(): Double = side * side
}

Эта иерархия классов нарушает LSP, потому что Square нельзя использовать вместо Rectangle — если мы попытаемся создать Square с разными шириной и высотой, мы получим неправильную область.

Вот пример того, как реорганизовать эту иерархию классов, чтобы следовать LSP:

interface Shape {
    fun area(): Double
}

class Rectangle(
    private val width: Double,
    private val height: Double
) : Shape {
    override fun area(): Double = width * height
}

class Square(
    private val side: Double
) : Shape {
    override fun area(): Double = side * side
}

В этом рефакторинге кода мы создали интерфейс с именем Shape, который определяет метод area. И Rectangle, и Square реализуют этот интерфейс, поэтому их можно использовать взаимозаменяемо.

I — Принцип разделения интерфейсов

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

Вот пример иерархии классов, нарушающей ISP:

interface Car {
    fun startEngine()
    fun stopEngine()
    fun drive()
    fun reverse()
}

class Sedan : Car {
    override fun startEngine() { ... }
    override fun stopEngine() { ... }
    override fun drive() { ... }
    override fun reverse() { ... }
}

class SportsCar : Car {
    override fun startEngine() { ... }
    override fun stopEngine() { ... }
    override fun drive() { ... }
    override fun reverse() { ... }
}

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

Вот пример того, как реорганизовать эту иерархию классов, чтобы следовать ISP:

interface Car {
    fun startEngine()
    fun stopEngine()
    fun drive()
}

interface ReverseableCar : Car {
    fun reverse()
}

class Sedan : Car {
    override fun startEngine() { ... }
    override fun stopEngine() { ... }
    override fun drive() { ... }
}

class SportsCar : ReverseableCar {
    override fun startEngine() { ... }
    override fun stopEngine() { ... }
    override fun drive() { ... }
    override fun reverse() { ... }
}

В этом рефакторинге кода мы разделили интерфейс Car на два отдельных интерфейса — Car и ReverseableCar. Интерфейс Car включает только те методы, которые должны быть реализованы всеми клиентами, а интерфейс ReverseableCar включает дополнительный метод reverse. Клиенты, которым нужно вести машину только вперед, могут использовать интерфейс Car, а клиенты, которым нужно вести машину в обоих направлениях, могут использовать интерфейс ReverseableCar.

D — Принцип инверсии зависимостей

Принцип инверсии зависимостей (DIP) гласит, что модули высокого уровня не должны зависеть от модулей низкого уровня. Вместо этого оба должны зависеть от абстракций. Это означает, что модули должны быть разработаны так, чтобы они зависели от абстрактных интерфейсов или классов, а не от конкретных реализаций.

Вот пример иерархии классов, которая нарушает DIP:

class UserService {
    private val userRepository = UserRepository()

    fun createUser(user: User) {
        userRepository.save(user)
    }
}

class UserRepository {
    fun save(user: User) {
        // save the user to the database
    }
}

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

Вот пример того, как реорганизовать эту иерархию классов, чтобы следовать DIP:

interface UserRepository {
    fun save(user: User)
}

class UserService(
    private val userRepository: UserRepository
) {
    fun createUser(user: User) {
        userRepository.save(user)
    }
}

class DatabaseUserRepository : UserRepository {
    override fun save(user: User) {
        // save the user to the database
    }
}

В этом рефакторинге кода мы создали абстрактный интерфейс UserRepository, который определяет методы, необходимые UserService. Мы также создали класс DatabaseUserRepository, который реализует интерфейс UserRepository и предоставляет конкретную реализацию метода save.

Теперь класс UserService зависит только от абстрактного интерфейса UserRepository, а не от конкретного класса UserRepository. Это делает код более гибким и простым в обслуживании, потому что мы можем заменить класс DatabaseUserRepository другой реализацией (например, реализацией веб-сервиса) без необходимости изменять класс UserService.

Совет коллегам-разработчикам

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

Хотя примеры в этой статье были написаны на Kotlin, принципы SOLID применимы к любому объектно-ориентированному языку программирования. Если вы новичок в SOLID, начните с принципа единой ответственности, который является наиболее важным из пяти принципов. Как только вы освоите SRP, вы можете перейти к другим принципам и начать применять их к своему собственному коду.

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

Спасибо, что прочитали эту статью. Вы можете связаться со мной в LinkedIn, Twitter и Instagram.

Если вы нашли эту статью полезной, порекомендуйте ее, нажав на значок хлопка столько раз, сколько пожелаете 👏 Давайте поддержим друг друга силой знаний.