JavaRush /Java блог /Android /Принципы SOLID, которые сделают код чище
Paul Soia
26 уровень
Kiyv

Принципы SOLID, которые сделают код чище

Статья из группы Android
Что такое SOLID? Вот как расшифровывается акроним SOLID: - S: Single Responsibility Principle (Принцип единственной ответственности). - O: Open-Closed Principle (Принцип открытости-закрытости). - L: Liskov Substitution Principle (Принцип подстановки Барбары Лисков). - I: Interface Segregation Principle (Принцип разделения интерфейса). - D: Dependency Inversion Principle (Принцип инверсии зависимостей). SOLID принципы советуют, как проектировать модули, т.е. кирпичики, из которых строится приложение. Цель принципов — проектировать модули, которые: - способствуют изменениям - легко понимаемы - повторно используемы Итак, давайте разберем что же это такое на примерах.

class MainRepository(
    private val auth: FirebaseAuth
) {
    suspend fun loginUser(email: String, password: String) {
        try {
            auth.signInWithEmailAndPassword(email, password)
        } catch (e: Exception) {
            val file = File("errors.txt")
            file.appendText(text = e.message.toString())
        }
    }
}
Что мы видим в этом примере? В конструкторе передается FirebaseAuth, с помощью него мы пытаемся логиниться. Если получаем в ответ ошибку, то записываем месседж в файл. Теперь давайте посмотрим что же с этим кодом не так. 1. S: Single Responsibility Principle (Принцип единственной ответственности): Класс (или функция/метод) должен быть ответственен лишь за что-то одно. Если класс отвечает за решение нескольких задач, его подсистемы, реализующие решение этих задач, оказываются связанными друг с другом. Изменения в одной такой подсистеме ведут к изменениям в другой. В нашем примере функция loginUser отвечает за две вещи - логин и запись ошибки в файл. Для того, чтобы ответственность была одна надо вынести логику записи ошибки в отдельный класс.

class FileLogger{
    fun logError(error: String) {
        val file = File("errors.txt")
        file.appendText(text = error)
    }
}

class MainRepository(
    private val auth: FirebaseAuth,
    private val fileLogger: FileLogger,
) {
    suspend fun loginUser(email: String, password: String) {
        try{
            auth.signInWithEmailAndPassword(email, password)
        } catch (e: Exception) {
            fileLogger.logError(e.message.toString())
        }
    }
}
Логика записи ошибки вынесена в отдельный класс и теперь метод loginUser имеет только одну ответсвенность - авторизация. Если логику логирования ошибки надо будет изменить, то это будем делать в классе FileLogger, а не в функции loginUser 2. O: Open-Closed Principle (Принцип открытости-закрытости): Программные сущности (классы, модули, функции) должны быть открыты для расширения, но не для модификации. В этом контексте открытость для расширения — это возможность добавить для класса, модуля или функции новое поведение, если необходимость в этом возникнет, а закрытость для изменений — это запрет на изменение исходного кода  программных сущностей. На первый взгляд, это звучит сложно и противоречиво. Но если разобраться, то принцип вполне логичен. Следование принципу OCP заключается в том, что программное обеспечение изменяется не через изменение существующего кода, а через добавление нового кода. То есть созданный изначально код остаётся «нетронутым» и стабильным, а новая функциональность внедряется либо через наследование реализации, либо через использование абстрактных интерфейсов и полиморфизм. Итак, давайте посмотрим на класс FileLogger. Если нам надо будет записывать логи в другой файл, то мы можем изменить название:

class FileLogger{
    fun logError(error: String) {
        val file = File("errors2.txt")
        file.appendText(text = error)
    }
}
Но тогда все логи будут записываться в новый файл, чего, скорее всего, нам не надо. Но как же изменить наш логгер, не изменяя сам класс?

open class FileLogger{

    open fun logError(error: String) {
        val file = File("error.txt")
        file.appendText(text = error)
    }

}

class CustomErrorFileLogger : FileLogger() {

    override fun logError(error: String) {
        val file = File("my_custom_error_file.txt")
        file.appendText(text = error)
    }

}
Помечаем класс FileLogger и функцию logError как open, создаем новый класс CustomErrorFileLogger и пишем новую реализацию логирования. В итоге наш класс доступен для расширения функциональности, но закрыт для модификации. 3. L: Liskov Substitution Principle (Принцип подстановки Барбары Лисков). Необходимо, чтобы подклассы могли бы служить заменой для своих суперклассов. Цель этого принципа заключаются в том, чтобы классы-наследники могли бы использоваться вместо родительских классов, от которых они образованы, не нарушая работу программы. Если оказывается, что в коде проверяется тип класса, значит принцип подстановки нарушается. Если бы мы класс-наследник написали так:

class CustomErrorFileLogger : FileLogger() {

    fun customErrorLog(error: String) {
        val file = File("my_custom_error_file.txt")
        file.appendText(text = error)
    }

}
И теперь заменим класс FileLogger на CustomErrorFileLogger в репозитории

class MainRepository(
    private val auth: FirebaseAuth,
    private val fileLogger: CustomErrorFileLogger,
) {

    suspend fun loginUser(email: String, password: String) {
        try{
            auth.signInWithEmailAndPassword(email, password)
        }catch(e: Exception) {
            fileLogger.logError(e.message.toString())
        }
    }

}
В этом случае logError вызовется из родительского класса, либо прийдется менять вызов fileLogger.logError(e.message.toString()) на fileLogger.customErrorLog(e.message.toString()) 4. I: Interface Segregation Principle (Принцип разделения интерфейса). Создавайте узкоспециализированные интерфейсы, предназначенные для конкретного клиента. Клиенты не должны зависеть от интерфейсов, которые они не используют. Звучит сложно, но на самом деле всё очень просто. Для примера давайте класс FileLogger сделаем интерфейсом, но добавим в него еще одну функцию:

interface FileLogger{

    fun printLogs()

    fun logError(error: String) {
        val file = File("errors.txt")
        file.appendText(text = error)
    }

}
Теперь все наследники будут обязаны реализовать функцию printLogs, даже если нам не надо это во всех классах наследниках.

class CustomErrorFileLogger : FileLogger{

    override fun printLog() {

    }

    override fun logError(error: String) {
        val file = File("my_custom_error_file.txt")
        file.appendText(text = error)
    }

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

interface FileLogger{

    fun printLogs() {
        //какая-то дефолтная реализация
    }

    fun logError(error: String) {
        val file = File("errors.txt")
        file.appendText(text = error)
    }

}

class CustomErrorFileLogger : FileLogger{

    override fun logError(error: String) {
        val file = File("my_custom_error_file.txt")
        file.appendText(text = error)
    }

}
Теперь классы, которые будут реализовать интерфейс FileLogger будут чище. 5. D: Dependency Inversion Principle (Принцип инверсии зависимостей). Объектом зависимости должна быть абстракция, а не что-то конкретное. Давайте вернемся к нашему основному классу:

class MainRepository(
    private val auth: FirebaseAuth,
    private val fileLogger: FileLogger,
) {

    suspend fun loginUser(email: String, password: String) {
        try{
            auth.signInWithEmailAndPassword(email, password)
        } catch (e: Exception) {
            fileLogger.logError(e.message.toString())
        }
    }

}
Кажется мы всё тут уже настроили и пофиксили. Но всё же есть еще один момент, который стоит изменить. Это использование класса FirebaseAuth. Что будет если в какой-то момент нам надо будет изменить авторизацию и логиниться не через Firebase, а, например, по какому-то api запросу? Тогда прийдется изменять много чего, а нам этого не хотелось бы. Для этого создаем интерфейс с функцией signInWithEmailAndPassword(email: String, password: String):

interface Authenticator{
    fun signInWithEmailAndPassword(email: String, password: String)
}
Этот интерфейс и есть наша абстракция. И теперь делаем конкретные реализации логина

class FirebaseAuthenticator : Authenticator{

    override fun signInWithEmailAndPassword(email: String, password: String) {
        FirebaseAuth.getInstance().signInWithEmailAndPassword(email, password)
    }

}

class CustomApiAuthenticator : Authenticator{

    override fun signInWithEmailAndPassword(email: String, password: String) {
        //другой способ логина
    }

}
И в классе `MainRepository` теперь нету зависимости от конкретной реализации, а только от абстракции

class MainRepository (
    private val auth: Authenticator,
    private val fileLogger: FileLogger,
) {

    suspend fun loginUser(email: String, password: String) {
        try{
            auth.signInWithEmailAndPassword(email, password)
        }catch(e: Exception) {
            fileLogger.logError(e.message.toString())
        }
    }

}
И теперь чтобы изменить способ авторизации нам надо изменить только одну строчку в классе модуля.
Комментарии (5)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Владимир Уровень 108
3 февраля 2023
на интервью спросили о SOLID, никогда не читал о них до этого, но слышал. лайк, что без воды. а что за ЯП?
Perl Developer Уровень 9
6 января 2023
Пятый пункт так и не понял. Класс MainRepository наследует интерфейс Authenticator и вызывает метод этого интерфейса через try, но ведь тело метода в интерфейсе Authenticator пустое. Или что я пропустил?
Oleg Oshurkov Уровень 34
18 ноября 2021
спасибо за статью