В 1974 году знаменитый физик, лауреат Нобелевской премии Ричард Фейнман впервые употребил выражение сargo cult science — «наука самолетопоклонников». Этот феномен связан с бездумным копированием «проверенных методов» без понимания их истинного смысла. Сегодня мы убедимся, что такая проблема существует и в программировании, и поговорим о том, как в нее не впадать.
Ритуальное программирование (карго-культное программирование) — это распространенная метафора в разработке программ. Эрик Липперт определяет суть этого феномена следующим образом:
В годы Второй мировой войны американцы устраивали аэродромы на разных маленьких тихоокеанских островах. Когда война закончилась и американцы отправились домой, туземцы стали заниматься совершенно логичным с их точки зрения делом. Они наряжались, как диспетчеры, руководившие посадкой с земли, и поднимали палки вверх. Дело в том, что меланезийцы путали причину и следствие: они считали, что военные с оружием, бывавшие на островах, призывали своими действиями самолеты, полные ценного груза и провианта. И если правильно воспроизвести действия белых людей, то изобильные самолеты вернутся. Мы же знаем, что все было совершенно наоборот: диспетчеры с жезлами высаживались на острове именно потому, что без них летчик бы не смог посадить машину. Нет самолетов — нет диспетчеров.
Самолетопоклонники правильно улавливали неважные поверхностные элементы происходящего, но не имели о процессе достаточно полного представления — и, разумеется, никаких самолетов призвать не могли. Они понимали лишь форму, но не содержание. Такие ритуальные действия распространены и среди программистов. Многие кодеры понимают, что делает программа, но не понимают, как именно. Поэтому они не могут осмысленно его развивать. Такой «специалист» продолжает вносить в программу бессистемные изменения, тестировать, перекраивать, надеясь получить что-то, что будет работать.
Привычка писать код, не вполне понимая, почему он работает именно так, а не иначе, — типичная черта неопытного программиста. Чем больше опыта в работе с языком и библиотеками приобретает специалист, чем лучше начинает разбираться в программировании как таковом — тем менее вероятно, что он вернется к ритуальным действиям на уровне отдельных строк исходного кода. Но изжить такую «карго-культную» ментальность сложнее. Можно понимать, что делается в каждой отдельной строке кода, но туманно представлять, почему классы и интерфейсы организованы именно так, а не иначе.
Ритуальное проектирование
Итак, ритуальное программирование несложно заметить, и оно повсеместно порицается. Но существует и ритуальное проектирование — гораздо более коварная вещь. Никто не будет навязывать несколько строк кода как единственно верный способ реализации той или иной функции, но определенные варианты дизайна часто дорастают до титула «рекомендованной практики» и применяются где попало, без малейшего внимания к контексту и без раздумий о возможных альтернативах. Хуже того, подобные «рекомендованные практики» зачастую так врастают в профессиональную культуру, что любые отклонения от них автоматически расцениваются как порочные.
Если вам приходилось видеть программистов, спорящих о том, есть ли в этом проекте настоящий MVC или полноценная трехуровневая архитектура, то вы понимаете, какова карго-культная ментальность на деле.
Если программа сработана хорошо, то по ней сразу ясно, как она выполняет стоящие перед ней требования. Если же требования меняются, то такую программу легко изменить. Следовательно, качество дизайна можно оценить лишь в контексте актуальных и будущих требований, которые могут предъявляться к программе. Целесообразно писать лишь такие программы, принципиально отличные от других по функциональным требованиям (в противном случае, можно воспользоваться уже имеющейся программой или адаптировать ее под конкретные нужды). Поэтому дизайн каждой программы должен оцениваться в соответствии с уникальными критериями. Ритуальное программирование — это предложение ответа, когда вы еще не дослушали вопрос, и настаивание на таком ответе, даже если вопрос изменится. Чтобы программа получилась ясной, нужно начинать проектирование с изучения требований и следить за тем, к чему они нас ведут.
Выращивание программ на основе тестов
Существует ряд способов проектирования на основе требований. Я предпочитаю подход, который называется «разработка через тестирование» (TDD) или «разработка через реализацию поведений» (BDD). Суть этого подхода лучше всего передана в метафоричном названии книги Ната Прайса и Стива Фримена, которая называется «Выращивание объектно-ориентированных программ на основе тестов». Выращивание предполагает получение рабочей софтверной системы, которая развивается постепенно, а не создание программы, которая функционирует лишь при полном соответствии плану. «На основе» означает, что тесты играют роль своеобразной обратной связи, но для создания качественного дизайна не обойтись без опыта, интуиции, знания принципов и паттернов проектирования.
Я продемонстрирую принцип TDD/BDD в программировании на совсем небольшом примере. Наша задача — написать немного усовершенствованную браузерную программу «hello world». Программа принимает в качестве ввода имя человека и выводит на экран приветствие. Сначала приветствие довольно прохладное, но чем чаще программа получает конкретное имя в качестве ввода, тем дружелюбнее оно становится. Уровень дружелюбия определяется для каждой персоны отдельно, поэтому программа может приветливо встречать «Джона», но сравнительно холодно обращаться к кому-то, кто еще к ней не обращался. Здесь я воспользуюсь Java, контейнером Spring IoC (инверсия управления), веб-фреймворком Spring MVC, а также имитационным фреймворком Mockito, но, в принципе, программа реализуема на любом языке. Готовый пример находится на Github.
Программа для приветствий
Начну с минимальной рабочей единицы: напишу контроллер Spring MVC, считывающий имя персоны и реагирующий на него. Таким образом, у меня будет маленькая система с возможностью ввода-вывода, затем уже можно будет подумать о логике приложения. Контроллер является адаптером, его функция — подключать приложение к вебу. Таким образом, согласно принципу единственной ответственности, генерировать приветствие должен какой-то другой элемент. Давайте назовем этот элемент Greeter, и я вызывал бы Greeter из контроллера вот так:
Теперь, когда я написал интерфейс Greeter, можно полностью забыть о его клиентах (то есть, о контроллере) и заняться реализацией. В нашей маленькой спецификации описан лишь один из бесчисленных способов, которым можно реализовать интерфейс Greeter, поэтому давайте назовем эту реализацию GreeterImpl. Описанная стратегия выбора приветствий начинается с суровых вариантов, но постепенно приветствия становятся все более любезными, поэтому можно назвать ее SlowlyWarmingGreeter. Я буду постепенно реализовывать эту стратегию, начиная с самого жесткого варианта.
Ни один класс не изолирован
Тест, описывающий данный вариант поведения, я назову shouldGreetPeopleRudelyTheFirstTime. Уже в названии теста ставится вопрос о том, как Greeter узнает, сколько раз система сталкивалась с конкретным гостем. Greeter не может подсчитывать количество вызовов к методу приветствия, так как это нарушало бы принцип разделения команд и запросов. Достаточно уже того, что Greeter занимался приветствиями, подсчет людей, входивших в систему — не его работа. Я также не хочу добавлять к интерфейсу Greeter новый параметр или метод, так как от клиентов не требуется указывать количество встреч. Клиент должен просто выдать приветствие, остальное — детали реализации.
Остается единственный выход: Greeter нуждается в «помощнике», знающем, сколько раз тот или иной человек входил в систему. Логично назвать такой помощник MeetingCounter, но я назову его MeetingLog — просто на случай того, если я захочу сохранять здесь более подробную информацию, чем просто количество визитов. Немного подумал об этом варианте, но отверг его, так как «log» воспринимается как файл «только для чтения». В итоге останавливаюсь на MeetingHistory, доделываю первый тест и наблюдаю: тест не пройден.
public interface Greeter { public String greet(String name); } @Controller public class GreetingController { @Autowired private Greeter greeter; @RequestMapping("/greeting/{name}") @ResponseBody public String greeting(@PathVariable String name) { return greeter.greet(name); } }
Узнаешь его поближе — оказывается, не так он и плох
Изменю SlowlyWarmingGreeter.greet так, чтобы он возвращал строковый литерал — и тест пройден. Потом добавляю подобный тест для следующего типа приветствия и наблюдаю за его провалом. На этот раз для прохождения теста необходимо добавить в метод приветствия немного новой логики.
public class SlowlyWarmingGreeterTest { // ... @Test public void shouldGreetPeopleNeutrallyAfterTheFirstTime() { // Given given(meetingHistory.timesMet("John")).willReturn(1); // When String greeting = greeter.greet("John"); // Then assertEquals("Hello, John!", greeting); } } public class SlowlyWarmingGreeter implements Greeter { @Autowired private MeetingHistory meetingHistory; @Override public String greet(String name) { if (meetingHistory.timesMet(name) == 0) { return "What do you want? Beat it!"; } else { return String.format("Hello, %s!", name); } } }
Считаем
Когда я добавлю третий тип приветствия, SlowlyWarmingGreeter готов, и можно переходить к реализации MeetingHistory. Опять же, существует много допустимых способов реализации этого интерфейса, поэтому называть реализацию MeetingHistoryImpl немного нечестно. Чтобы не усложнять этот пример, я решил обойтись без базы данных и хранить MeetingHistory прямо в памяти. Итак, назову реализацию InMemoryMeetingHistory. Вариант реализации, взаимодействующий с базой данных, следовало бы проверить при помощи интегрированных тестов с реальной базой данных, но пример InMemoryMeetingHistory самодостаточен, хватит и обычных модульных тестов.
Первый тест, InMemoryMeetingHistoryTest.shouldContainZeroMeetingsWithUnknownPeople, проходим без труда, возвращая 0 от метода timesMet. Следующий тест я назову shouldIncreaseMeetingCountWhenPersonMet, но прежде чем написать его, придется решить, как уведомлять InMemoryMeetingHistory о встрече с персоной. Greeter не может уведомлять MeetingHistory из своего метода приветствия (это же запрос, а не команда). Поэтому, данную задачу придется решать в GreetingController.
Незнание — благо
Наиболее логичным продолжением было бы добавить метод уведомления к интерфейсу MeetingHistory, а также поставить ссылку от MeetingHistory к GreetingController, но это решение мне не нравится по двум причинам. Во-первых, если мы добавим командный метод к интерфейсу MeetingHistory, это будет означать, что любой элемент со ссылкой на MeetingHistory сможет изменять состояние, а это затронет работу всех компонентов, использующих историю. Таким образом, о системе станет гораздо сложнее судить. Во-вторых, GreetingController не нуждается в MeetingHistory для выполнения своей задачи, и я хочу, чтобы этот факт был понятен из кода. Я решил добавить отдельный интерфейс MeetingListener — контроллер сможет использовать его, чтобы уведомлять любой элемент, которому требуется знать о факте встречи. В нашем случае здесь всего один MeetingListener, но контроллеру совершенно не требуется знать, сколько их. Поэтому я запрограммирую его на уведомление коллекции слушателей.
public interface MeetingListener { void personMet(String name); } @Controller public class GreetingController { // ... @Autowired(required = false) private Collection<MeetingListener> meetingListeners = Collections.emptyList(); // ... public String greeting(@PathVariable String name) { String greeting = greeter.greet(name); for (MeetingListener listener : meetingListeners) { listener.personMet(name); } return greeting; } }
Вновь о записи истории
Теперь можно заканчивать написание теста, и когда я вижу, что он не пройден, я реализую InMemoryMeetingHistory при помощи HashMap.
Запускаю тесты — и, к моему удивлению, они не пройдены. Я допустил ошибку на одну позицию при инициализации счетчика.
public class InMemoryMeetingHistoryTest { // ... @Test public void shouldIncreaseMeetingCountWhenPersonMet() { // When inMemoryMeetingHistory.personMet("Bob"); // Then assertEquals(1, inMemoryMeetingHistory.timesMet("Bob")); } } @Component public class InMemoryMeetingHistory implements MeetingHistory, MeetingListener { private HashMap<String, Integer> meetingCount = new HashMap<String, Integer>(); @Override public synchronized int timesMet(String name) { Integer count = meetingCount.get(name); return (count == null) ? 0 : count; } @Override public synchronized void personMet(String name) { Integer oldCount = meetingCount.get(name); Integer newCount = (oldCount == null) ? 0 : oldCount + 1; meetingCount.put(name, newCount); } }
После исправления этой ошибки все тесты проходятся нормально. Готово!
Integer newCount = (oldCount == null) ? 1 : oldCount + 1;
Стоила ли игра свеч?
На данном примере хотел показать, как выстроить процесс проектирования, а не просто построить изолированное решение. Но уместен вопрос: а нельзя ли было обойтись без всех этих сложностей? Разумеется, мой окончательный дизайн ни в коем случае не является единственным решением проблемы. Как и в любой творческой задаче, решений несколько. Но все же у моего варианта есть несколько приятных свойств:
- новые стратегии приветствия, возможности хранения истории и действия, совершаемые при встрече, можно добавлять, совершенно не меняя имеющегося кода;
- классы отличаются высокой слаженностью и слабым связыванием, поэтому можно отдельно изменить любой компонент системы, не опасаясь повредить остальные;
- тесты служат нам страховкой, гарантирующей, что при изменении кода мы не внесем в него регрессий. Кроме того, они являются своеобразной документацией и предоставляют примеры того, как классы действуют в различных ситуациях.
Классы защищены простыми интерфейсами от внутренней сложности, присущей их помощникам (в широком смысле). Интерфейсы предоставляют лишь те аспекты, которые действительно важны для клиента. Таким образом, можно читать и понимать каждый класс, не задумываясь о том, каковы конкретные помощники и как они реализованы;
- компоненты системы совершенно не зависят друг от друга, а не переплетены в один большой действующий кусок. Поэтому при желании компоненты можно переиспользовать.
Надеюсь, мне удалось убедить вас, что хороший дизайн всегда зависит от контекста. На каждом этапе проектирования требуется ответить на множество вопросов, а вариантов ответов еще больше. Да, это усложняет процесс проектирования, но только так можно работать креативно и получать качественный результат.
Релоцировались? Теперь вы можете комментировать без верификации аккаунта.