JavaRush /Java блог /Random UA /Принципи SOLID, які зроблять код чистішим
Paul Soia
26 рівень
Kiyv

Принципи SOLID, які зроблять код чистішим

Стаття з групи Random UA
Що таке 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, а, наприклад, за якимсь запитом? Тоді доведеться змінювати багато чого, а нам цього не хотілося б. Для цього створюємо інтерфейс з функцією 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())
        }
    }

}
І тепер щоб змінити спосіб авторизації нам треба змінити лише один рядок у класі модуля.
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ