О дизайне программного обеспечения трудно говорить. Для описания этого процесса мы часто полагаемся на метафоры из инженерного проектирования и других дисциплин. Мы можем использовать такие термины, как «архитектура», чтобы описать компоненты нашей системы, как они сочетаются друг с другом и почему мы собираем их именно таким образом.

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

Программное обеспечение является гибким

«Архитектура» на самом деле ужасное название для того, что мы имеем в виду, когда говорим о нашем процессе разработки программного обеспечения. Слово «архитектура» напоминает чертежи зданий, разработанные в мельчайших деталях с учетом как функциональности, так и эстетики.

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

На самом деле реально работающее программное обеспечение почти никогда не планируется в деталях заранее. Мы не знаем всех требований в мельчайших деталях в первый день. В лучшем случае у нас есть общий план. Так что чертежи в окно.

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

Архитектура по принципам проектирования

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

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

Я мог бы указать список принципов дизайна, но я думаю, что полезнее сначала понять некоторые более широкие концепции. А именно, нам нужно рассмотреть основы объектно-ориентированного дизайна.

Что такое объектная ориентация?

Проще говоря, объектная ориентация означает, что мы пытаемся смоделировать мир нашей системы в памяти (в частности, в куче). Объект — это просто набор данных и набор функций для работы с этими данными.

Объекты — это мощная концептуальная основа для разработки программного обеспечения, использующая метафоры для разработки программ, которые легко разрабатывать и развивать.

Объектно-ориентированный дизайн на примере

Допустим, мы пишем программу, которая имитирует посетителей ресторана, заказывающих свои любимые блюда из меню ресторана. Это глупый пример, но потерпите меня.

Мы создаем пару классов для моделирования этих различных объектов в нашей системе. У нас есть класс Customer для представления клиента. Мы также создаем класс для каждого продукта питания. RoastBeefSandwich, FrenchFries, Hamburger, TunaMelt. И так далее. (Заранее извиняюсь перед теми, кто читает эту статью прямо перед обедом.)

Теперь давайте напишем функцию, моделирующую, как клиент ест одно из наших вкусных блюд.

def eatRoastBeef(customer: Customer, rbs: RoastBeefSandwich) =  {
    // Do something
}

Кажется достаточно разумным. Но есть пара проблем с этим подходом сразу.

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

Другая проблема заключается в том, что программист, который ищет эту функцию, может не знать, где ее найти. Другими словами, у нас могут быть Customer и Hamburger, и мы понятия не имеем, как сложить их вместе. Было бы неплохо, если бы мы могли как-то привязать функцию к этим объектам.

Давайте решим эти две проблемы по очереди.

Замена Лискова

Итак, мы определили, что на самом деле между нашими Hamburger, RoastBeefSandwich и TunaMelt есть что-то общее. На самом деле, вы, вероятно, можете представить себе массу других типов объектов, которые следуют этому образцу. Назовем это Food.

Food - это категория, а не отдельная вещь. Если бы я спросил вас, что вы хотите на обед, и вы ответили бы «еду», мне пришлось бы добавить к этому вопросу «какую еду?» Напротив, RoastBeefSandwich является правильным ответом на этот вопрос. В терминах объектно-ориентированного проектирования мы говорим, что RoastBeefSandwich является конкретным, а Food абстрактным. Также принято называть абстрактный класс интерфейсом.

Существенная разница между абстрактным классом и конкретным классом заключается в том, что мы не можем создать экземпляр абстрактного класса (или интерфейса). Делать это было бы бессмысленно. Компилятор эквивалентен тому, что вы хотите съесть «еду» на обед.

Теперь мы можем начать представлять общий eat метод, который не заботится о деталях рассматриваемого продукта питания. Есть только одна загвоздка. Каждый подкласс Food должен принимать одни и те же аргументы и возвращать один и тот же тип. Пока этот контракт соблюдается, мы можем безопасно вызывать наш метод eat с любым типом Food.

Мы описали основы метода под названием замена Лисков. Это простая идея, которая является ключом к созданию поддерживаемого программного обеспечения.

Инкапсуляция

Теперь о нашей второй проблеме. Этот eatRoastBeef метод просто парит в пространстве. Мы должны помнить об этом, когда хотим заставить клиента съесть бутерброд. Что еще хуже, как узнать, что эта функция правильная?

Это эквивалент плотника, идущего на работу с гвоздями в отдельном от молотка ящике для инструментов. Мы хотим, чтобы наши инструменты были под рукой, когда мы работаем с объектом. Мы не хотим случайно потянуться за дрелью, когда нам нужен молоток.

Почти каждый, кто имеет какой-либо опыт программирования, знаком с получившимся дизайном. Вместо того чтобы размещать наш метод eat в отдельном месте, мы делаем его методом класса Customer.

class Customer {
    def eat(food: Food)
}
someCustomer = new Customer()
rbs = new RoastBeefSandwich()
someCustomer.eat(rbs)

Теперь у нас есть класс Customer и метод eat(food: Food) в нем. Теперь мы можем создать общее определение еды.

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

Мы знаем, что eat — это способ для Customer работать с некоторыми Food данными. И мы знаем, что он будет работать с любым Food.

Другими словами, мы скрываем детали реализации за интерфейсом.

Создание стабильных интерфейсов

Теперь предположим, что мы хотим написать класс, имитирующий поедание целой еды. Мы назовем его Meal, что вполне уместно. У нас будет Customer и несколько Food предметов в нашем Meal. Это может выглядеть примерно так:

class Meal {
    customer: Customer
    foods: Set[Food]
    
  def eatMeal(customer Customer, foods: Set[Food]) = {
      for (food in foods) {
        customer.eat(food)
      }
  }
}

Обратите внимание, что структура нашей программы начинает обретать смысл. Просто на основе этого класса Food и применения метода eat к Customer мы можем начать представлять целый мир объектов в нашей системе: Meals, CustomerOrders и так далее. Мы можем сделать это с уверенностью, потому что мы знаем из замены Лискова, что любое Food будет совместимо с любым Customer независимо от того, какое конкретное Food у нас есть на самом деле.

Один из способов выразить это — сказать, что метод eatMeal обеспечивает стабильный интерфейс между Customer и Food.

Когда не стоит выбирать стабильность

Однако иногда стабильность — это не то, чего мы хотим. Если бы у нас был специальный метод класса Hamburger с именем withPickles, мы, вероятно, не хотели бы, чтобы он был частью каждого класса Food. Поэтому мы оставляем этот единственный метод только для Hamburgerclass, вот так:

abstract class Food {
  def calorieCount() // We may define some default behavior here
}
class Hamburger(pickles = false) extends Food {
  override def calorieCount() = {
    1000
  }
  
  def withPickles() = {
     new Hamburger(pickles = true)
  }
}

Мы не хотим, чтобы пользователи класса Food строили свои программы вокруг метода withPickles, потому что мы знаем, что это не то, что применимо к каждому Food. Мы не хотим, чтобы кто-то писал компонент системы, предполагая, что withPickles будет там для каждого Food, а затем должен будет удалить его позже.

В общем, мы хотим, чтобы наши методы были как можно более конкретными. Мы не хотим делать метод абстрактным только потому, что можем.

Причина проста. Когда мы программируем интерфейсы, используя замену Лискова, стабильный интерфейс сигнализирует пользователям класса, что эти методы вряд ли сильно изменятся. Мы знаем, что мы должны строить основу нашей программы, используя эти стабильные методы в абстрактном классе (также известном как интерфейс), где это возможно.

Собираем все вместе

К настоящему времени у нас есть пара мощных инструментов в нашем наборе инструментов:

  • Абстрактные классы, которые позволяют нам создавать полезные концептуальные абстракции и определять операции, поддерживаемые классом, ничего не говоря о том, как он работает.
  • Замена Лискова, которая позволяет нам определять стабильные интерфейсы между компонентами, будучи уверенными, что наш очень умный современный компилятор правильно сопоставит абстрактное с конкретным во время выполнения.
  • Понятие когда определять стабильные интерфейсы. Иногда нам не нужна стабильность, например. когда мы не хотим, чтобы другие системные компоненты были построены вокруг конкретной операции.

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

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

Я утверждаю, дорогой читатель, что одних этих концепций достаточно для 98% ваших потребностей в разработке программного обеспечения. Существует множество книг по шаблонам, философии и методам проектирования, таким как внедрение зависимостей, которые предлагают различные способы применения этих концепций. Но концептуально это все, что вам нужно знать.

Что произойдет, если мы напортачим?

Неизбежно, мы совершим какую-то ошибку в процессе проектирования. У нас может быть плохая абстракция, которая затрудняет работу с каким-то классом. Или мы можем забыть установить стабильный интерфейс для вызова общего метода, и внезапно наша система станет многословной и повторяющейся.

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

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

Вывод

Программное обеспечение — это не что иное, как абстракции над абстракциями. Процесс разработки программного обеспечения просто решает, какими должны быть эти абстракции.

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

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

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