Роберт Мартин представил принципы SOLID в 2000 году, когда объектно-ориентированное программирование стало настоящим искусством для программистов. Каждый хочет создать что-то долговечное, которое можно использовать повторно, насколько это возможно, с минимальными изменениями, которые потребуются в будущем. SOLID - идеальное название для этого.
Фактически, объектно-ориентированное программирование работает лучше всего, когда мы можем отделить то, что останется, от того, что изменится. Принципы SOLID помогают разделять это.
Мне лично нравится идея, лежащая в основе принципов SOLID и я многому из нее научился.
Тем не менее…
Здесь есть главный вызов. Всё программное обеспечение предназначено для изменения. А изменения действительно трудно предсказать, если вообще возможно. Поэтому нам действительно сложно определить четкую границу того, что останется и что изменится.
Чтобы проиллюстрировать, как эта проблема влияет на принципы SOLID, давайте рассмотрим каждый из принципов.
Принцип единственной ответственности
«У класса не должно быть более одной причины для изменения». Другими словами, у каждого класса должна быть только одна ответственность.
Предположим, нам нужна функция вычисления для нашей программы. Единственная обязанность класса - вычислить.
class Calculate {
fun add (a, b) = a + b
fun sub (a, b) = a - b
fun mul (a, b) = a * b
fun div (a, b) = a / b
}
Для некоторых это идеально, так как у него одна обязанность - вычислить.
Но кто-то может возразить: «Эй! Он делает 4 вещи! Сложить, вычесть, умножить и разделить! »
Кто прав? Я скажу, это зависит от обстоятельств.
Если ваша программа использует только Calculate
для выполнения вычислений, тогда все хорошо. Дальнейшее его абстрагирование было бы чрезмерной инженерией.
Но кто знает, в будущем кто-то может просто захотеть выполнить операцию сложения без необходимости использовать класс Calculate
. Тогда код выше нужно изменить!
Определение единственной ответственности зависит от контекста программы и может меняться со временем. Мы можем следовать принципу сейчас, но не навсегда.
Принцип открытости/закрытости
«Программные объекты ... должны быть открыты для расширения, но закрыты для модификации».
Этот принцип - идеалистический принцип. Предполагалось, что после того, как класс закодирован, если он сделан правильно, его больше не нужно изменять.
Давайте посмотрим на код ниже:
interface Operation {
fun compute(v1: Int, v2: Int): Int
}
class Add:Operation {
override fun compute(v1: Int, v2: Int) = v1 + v2
}
class Sub:Operation {
override fun compute(v1: Int, v2: Int) = v1 - v2
}
class Calculator {
fun calculate(op: Operation, v1: Int, v2: Int): Int {
return op.compute(v1, v2)
}
}
В приведённом выше коде есть класс Calculator
, который принимает объект Operation для вычисления. Мы можем легко расширить этот класс с помощью операций Mul
и Div
без изменения кода самого класса Calculator
.
class Mul:Operation {
override fun compute(v1: Int, v2: Int) = v1 * v2
}
class Div:Operation {
override fun compute(v1: Int, v2: Int) = v1 / v2
}
Отлично, мы соблюдаем принцип открытости/закрытости!
Но однажды появилось новое требование. Cкажем, нам нужна новая операция — Inverse. Она просто возьмет один операнд, например X, и вернет результат 1/X.
Кто на земле мог подумать, что это произойдет? Мы определили функцию сompute
нашего рабочего интерфейса так, чтобы она имела 2 параметра. Теперь нам нужна новая операция, у которой всего 1 параметр.
Как теперь избежать модификации класса Calculator
? Если бы мы знали это заранее, возможно, мы не писали бы наш класс калькулятора и интерфейс операций как таковые.
Изменения никогда нельзя полностью спланировать. Если бы это можно было полностью спланировать, возможно, программное обеспечение нам больше и не понадобилось бы :)
Принцип подстановки Лискоу
«Функции, использующие указатели или ссылки на базовые классы, должны иметь возможность использовать объекты производных классов, не зная об этом».
Когда мы были молоды, мы узнавали основные атрибуты животных. Они подвижны.
interface Animal {
fun move()
}
class Mammal: Animal {
override move() = "walk"
}
class Bird: Animal {
override move() = "fly"
}
class Fish: Animal {
override move() = "swim"
}
fun howItMove(animal: Animal) {
animal.move()
}
Это соответствует принципу замены Лискоу.
Но мы знаем, что сказанное выше не совсем правильно. Некоторые млекопитающие плавают, некоторые летают, а некоторые птицы ходят. Итак, мы меняем код на:
class WalkingAnimal: Animal {
override move() = "walk"
}
class FlyingAnimal: Animal {
override move() = "fly"
}
class SwimmingAnimal: Animal {
override move() = "swim"
}
Круто, все по-прежнему хорошо, так как наша функция все еще может использовать Animal
:
fun howItMove(animal: Animal) {
animal.move()
}
Но сегодня я кое-что обнаружил. Есть животные, которые вообще не двигаются. Они называются Sessile. Может нам стоит изменить код так:
interface Animal
interface MovingAnimal: Animal {
move()
}
class Sessile: Animal {}
Теперь это нарушит приведенный ниже код.
fun howItMove(animal: Animal) {
animal.move()
}
У нас нет возможности гарантировать, что мы никогда не изменим функцию howItMove
. Мы можем достичь этого, основываясь на том, что мы знаем на данный момент. Но по мере того, как мы осознаем новые требования, нам нужно меняться.
Даже в реальном мире существует множество исключений. Мир программного обеспечения - это не реальный мир. Все возможно.
Принцип разделения интерфейса
«Многие клиентские интерфейсы лучше, чем один интерфейс общего назначения».
Давайте посмотрим на животное царство. У нас есть интерфейс Animal
, как показано ниже.
interface Animal {
fun move()
fun eat()
fun grow()
fun reproduction()
}
Однако, как мы поняли выше, есть некоторые животные, которые не двигаются, и это Sessile
. Поэтому мы должны выделить функцию move
как отдельный интерфейс.
interface Animal {
fun eat()
fun grow()
fun reproduction()
}
interface MovingObject {
fun move()
}
class Sessile : Animal {
//...
}
class NonSessile : Animal, MovingObject {
//...
}
Затем мы хотели бы иметь еще и Plant
. Возможно, нам следует отделить grow
иreproduction
:
interface LivingObject {
fun grow()
fun reproduction()
}
interface Plant: LivingObject {
fun makeFood()
}
interface Animal: LivingObject {
fun eat()
}
interface MovingObject {
fun move()
}
class Sessile : Animal {
//...
}
class NonSessile : Animal, MovingObject {
//...
}
Мы довольны тем, что выделяем как можно больше клиентских интерфейсов. Это похоже на идеальное решение.
Однако, кто-то начинает кричать: «Дискриминация! Некоторые животные бесплодны, это не значит, что они больше не LivingObject!».
Похоже, теперь нам нужно отделить reproduction
от интерфейса LivingObject
.
Если мы это сделаем, у нас будет буквально одна функция на один интерфейс! Это очень гибко, но может быть слишком гибко, если нам не нужно такое тонкое разделение.
Насколько хорошо мы должны разделять наши интерфейсы зависит от контекста нашей программы. К сожалению, контекст нашей программы будет время от времени меняться. Следовательно, мы должны продолжить рефакторинг или разделить наши интерфейсы, чтобы убедиться, что это по-прежнему имеет смысл.
Принцип инверсии зависимостей
«Положитесь на абстракции, а не на что-то конкретное».
Мне нравится этот принцип, поскольку он относительно универсален. Он применяет идею концепции независимого решения, в которой некоторая сущность в программе хотя и зависит от класса, но по-прежнему независима от него.
Если мы будем практиковать это строго, ничто не должно напрямую зависеть от класса.
Давайте посмотрим на пример ниже. Он действительно применяет принцип инверсии зависимостей.
interface Operation {
fun compute (v1: Int, v2: Int): Int
fun name (): String
}
class Add: Operation {
override fun compute (v1: Int, v2: Int) = v1 + v2
override fun name () = "Add"
}
class Sub: Operation {
override fun compute (v1: Int, v2: Int) = v1 - v2
override fun name () = "Subtract"
}
class Calculator {
fun calculate (op: Operation, v1: Int, v2: Int): Int {
println ("Running $ {op.name ()}")
return op.compute (v1, v2)
}
}
Calculator
Не зависит от Add
или Sub
. Но вместо этого он выполняет Add
и Sub
, которые зависят от Operation
. Это выглядит хорошо.
Однако, если кто-то из группы разработчиков Android использует его, это будет проблемой. println
не работает в Android. Нам понадобится Lod.d
взамен.
Чтобы решить эту проблему, мы должны сделать Calculator
независящим напрямую от println
. Вместо этого мы должны внедрить интерфейс Printer
:
interface Printer {
fun print(msg: String)
}
class AndroidPrinter: Printer {
override fun print(msg: String) = Log.d("TAG", msg)
}
class NormalPrinter: Printer {
override fun print(msg: String) = println(msg)
}
class Calculator(val printer: Printer) {
fun calculate(op: Operation, v1: Int, v2: Int): Int {
printer.print("Running ${op.name()}")
return op.compute(v1, v2)
}
}
Это решает проблему соблюдения принципа инверсии зависимостей.
Но если Android никогда не будет использовать этот Calculator
, и мы создадим такой интерфейс заранее, возможно, мы нарушили YAGNI.
TL; DR;
Позвольте мне повторить, для меня принципы SOLID - это хорошие принципы для следования в разработке программного обеспечения. Это особенно полезно, когда мы хорошо знаем на данный момент, что меняться не будет, а что, скорее всего, изменится.
Однако изменения случаются сверх того, что мы можем ожидать. Когда это произойдет, новые требования приведут к тому, что наши оригинальные проекты больше не будут соответствовать принципам SOLID.
Это нормально. Это произойдет. Нам просто нужно применить новые требования и снова изменить наш код, чтобы он мог следовать принципам SOLID.
Программное обеспечение по своей природе МЯГКОЕ и сделать его навсегда следующим SOLID сложно. Для программного обеспечения применение принципов SOLID - это не состояние, а процесс.