Мир программного обеспечения

Почему принципы SOLID не являются надежным решением для разработки программного обеспечения

Применение принципов SOLID - это цель, а не судьба

Роберт Дж. Мартин представил SOLID Principles в 2000 году, когда объектно-ориентированное программирование стало для программистов настоящим произведением искусства. Каждый хочет создать что-то долговечное, которое можно использовать повторно, насколько это возможно, с минимальными изменениями, которые потребуются в будущем. SOLID - идеальное название для этого.

Фактически, объектно-ориентированное программирование работает лучше всего, когда мы можем отделить то, что останется, от того, что изменится. SOLID Principle помогает отстаивать это.

Мне лично нравится идея, лежащая в основе SOLID Principles, и я многому из нее научился.

Тем не мение…

Есть одна серьезная проблема: все программное обеспечение должно меняться. А изменения действительно трудно предсказать, если вообще возможно. Поэтому нам действительно сложно определить четкую границу того, что останется и что изменится.

Чтобы проиллюстрировать, как эта проблема влияет на принципы 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
}

Отлично, мы соблюдаем принцип открытости-закрытости!

Но однажды появилось новое требование, скажем, им нужен новый вызов операции Inverse. Это займет всего один оперант, например. X и вернуть результат 1 / X.

Кто может подумать, что это произойдет? Мы исправили функцию Compute нашего рабочего интерфейса, чтобы она имела 2 параметра. Теперь нам нужна новая операция с одним параметром.

Как теперь избежать модификации класса 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 функцию. Мы можем достичь этого, основываясь на том, что мы знаем на данный момент. Но по мере того, как мы осознаем новые требования, нам нужно меняться.

Даже в реальном мире существует множество исключений. Мир программного обеспечения - это не реальный мир. Все возможно.

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

«Многие клиентские интерфейсы лучше, чем один интерфейс общего назначения».

Давайте посмотрим на животный мир. У нас есть интерфейс с животными, как показано ниже.

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

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 - это цель, а не судьба.