Многие знакомы с концепцией чистого кода. Одни программисты поддерживают её, другие же считают, что она вредит индустрии. Кейси Муратори, относясь ко второй группе, заявил, что чистый код на самом деле является вредным советом для программистов, заботящихся о производительности. В этой статье рассмотрим спор между основоположником концепции Робертом Мартином и Кейси Муратори, чтобы разобраться в сути вопроса.
"Открытое разногласие — часто — признак движения вперёд".
Махатма Ганди
Чистый код — явление, превратившееся из правил написания качественного кода в целую философию. Причём с этой философией знаком, мне кажется, каждый разработчик на планете. Однако есть некоторые доводы о том, что чистый код на самом деле является всего лишь красивой обёрткой, которая вредит индустрии.
Мы в PVS-Studio придерживаемся концепции написания качественного кода, поэтому нам и интересно глубже погрузиться в эту тему и разобраться, кто же прав.
В первой части я посчитал важным разобрать дискуссию Роберта Мартина и Кейси Муратори о чистом коде и производительности, поскольку это обсуждение является самым обширным на эту тему. Да и участники событий не заставляют сомневаться в своей компетентности:
Роберт Мартин (дядя Боб) — инженер, программист, консультант и автор книг в сфере разработки программного обеспечения ("Чистый код", "Идеальный программист", "Идеальная архитектура" и др.), один из авторов манифеста Agile, создатель принципов SOLID.
Кейси Муратори — программист, специализирующийся на исследовании и разработке игровых движков. Работал над проектами, которые в том числе широко применялись в разных игровых франшизах (Destiny, Age of Empires, Gears of War и др.).
Спусковым крючком для всех дальнейших событий стал ролик Кейси Муратори ("Чистый" код, ужасная производительность).
Примечание. Вместе с роликом появилась и текстовая версия материала, которую я буду цитировать далее.
В этом ролике Кейси ставит под сомнение необходимость чистого кода, обращая внимание на то, какой вред производительности он несёт:
Кратко обобщив содержание "чистого" кода, можно выделить несколько правил, влияющих на структуру кода:
- используйте полиморфизм вместо if/else и switch;
- код не должен знать о внутренней структуре объекта, с которым он работает;
- функции должны быть короткими;
- функции должны выполнять одну задачу;
- "DRY" — Don't Repeat Yourself (не повторяйся);
Эти правила конкретизируют, как написать код так, чтобы он считался "чистым". Но вот вопрос: если мы напишем код, следуя всем этим правилам, что у него будет с производительностью?
Далее для эксперимента берётся пример кода. В нём содержится базовый класс фигуры, а также классы, его наследующие: круг, треугольник, прямоугольник и квадрат. Код этого примера соответствует правилам чистого кода, в том числе описанным выше.
Для этой иерархии есть конкретная задача — нужно найти суммарную площадь всех фигур. Кейси приводит для неё "чистое" решение, а далее намеренно отходит от перечисленных им правил чистого кода: использует switch вместо полиморфизма, раскрывает функциям подробности используемых объектов, даёт им несколько обязанностей и не ограничивает их по размеру.
В итоге таких изменений финальная версия программы работает в 15 раз быстрее первоначального варианта, который соблюдает правила чистого кода. Сам Кейси использует довольно интересные сравнения для описания этой ситуации:
Это как перейти с железа 2023-го года на железо 2008-го!
Принцип Don't repeat yourself (DRY) Кейси, кстати, всё же признал вполне пригодным:
Вообще, "Don't Repeat Yourself" — вполне неплохой принцип. Как видно из листингов, мы и в самом деле особо не повторялись. <...> Но если "DRY" предписывает что-то более строгое, например, запрет на создание двух разных таблиц, хранящих разные версии одних и тех же коэффициентов, тогда я могу не согласиться с этим принципом, потому что так можно делать для повышения производительности. Но если в целом "DRY" просто рекомендует не писать один и тот же код дважды, то этот принцип вполне обоснован.
И, самое главное, нам не придётся нарушать его, чтобы сделать код производительнее.
В конце статьи автор констатирует невозможность получения достаточной производительности при использовании правил чистого кода:
Мы всё же можем выработать "практические правила чистого кода", которые помогут сохранить код организованным, поддерживаемым и читаемым. И цели эти, вроде бы, неплохие. Но вот сами правила не так просты. К ним стоит приписывать большую сопровождающую сноску с текстом: "...и ваш код после этого будет работать в 15 раз медленнее".
Такой материал не мог остаться незамеченным. После выхода ролика казалось, что о нём говорят везде. Как я и отмечал ранее, вопросы к производительности чистого кода возникали и ранее, но ролик Кейси Муратори будто стал манифестом этих обсуждений.
Примечание. Я намеренно не раскрываю всех подробностей исходного материала. Просто потому что его автор уже сделал это до меня и нет смысла повторять доводы Кейси ещё раз. Тем более что с первоисточником стоит ознакомиться, это действительно интересно. На Хабре, кстати, есть перевод текста на русский.
Собственно, я ни капли не драматизирую и не придаю лишней важности ролику Кейси Муратори, ведь на приведённые в нём доводы ответил сам амбассадор чистого кода (в каждой шутке есть доля шутки) — Роберт Мартин.
Дискуссия Мартина и Муратори проходила в GitHub репозитории и привлекла внимание немалого количества разработчиков. Далее я постараюсь выбрать всё наиболее интересное из этого диалога, приправив это горсткой своих комментариев.
Дискуссия началась, что называется, "с места в карьер". Кейси начал с конкретных вопросов:
Большинство ваших объяснений на тему чистого кода включают всё то, что я упомянул в видео: предпочтение иерархии наследования операторам if/switch, отказ от раскрытия внутренних компонентов (закон Деметры) и т.д. Но, кажется, вас удивило, что я вообще об этом говорю. Прежде чем начать, можете подробнее рассказать о том, что вы думаете по поводу проектирования типов? Это поможет нам лучше понять, где мы расходимся во мнениях.
Ответ Роберта был предсказуем и понятен:
<...> Да, безусловно, указанные вами принципы не обеспечивают максимальную производительность, если оценивать в наносекундах, ведь их использование может стоить множества наносекунд. Эти принципы неэффективны на наносекундном уровне. <...> Сейчас сценарии, требующие такой экономии времени, крайне редки. Большинство программных систем используют менее 1% ресурсов современных процессоров, <...> поэтому для организаций выгоднее оптимизировать рабочее время программистов, а не улучшать производительность компьютеров. Если между нами и есть разногласия, думаю, они касаются лишь того, какие сценарии мы ставим в приоритет.
Ответ Мартина на этот вопрос был для меня в некоторой степени отрезвляющим в этом диалоге. Ведь чистый код никогда и не был о том, как писать максимально производительный код. Написание кода в соответствии с правилами чистого кода позволяет долгосрочно обеспечивать его качество и сохранять силы и нервы программистов, которые будут этим заниматься.
Однако Кейси это не остановило и обсуждение продолжилось:
Давайте сделаем наш разговор более предметным: можете ли вы привести конкретные примеры ПО, чтобы я мог лучше понять, что вы имеете в виду? Например, если мы возьмём всем знакомые Visual Studio и CLANG/LLVM, подойдут ли они как примеры программ, использующих меньше 1% мощности современных процессоров?
Дядя Боб, отвечая на этот вопрос, затронул довольно интересную тему. Он предложил некоторую классификацию для модулей программы по необходимости оптимизации:
<...> IDE интересны тем, что охватывают широкий диапазон сценариев. Существуют части программ, где наносекунды играют большую роль, а в других их влияние минимально. <...> Сохранение наносекунд при парсинге может существенно повлиять на производительность. С другой стороны, код, настраивающий окно конфигурации, не нуждается и в малой доле такой оптимизации.
Собственно, в разных контекстах разный и эффект от выжимания производительности. Мы бесконечно можем ускорять код, разбивать задачу на потоки, процессы и так далее, однако где-то мы в любом случае будем зависеть, например, от ввода/вывода информации, получения данных из сети и тому подобное. А где-то лишние наносекунды не произведут вообще никакого эффекта на конечного пользователя продукта.
К этой же теме Мартин добавляет и общую классификацию программного обеспечения по необходимости производительности. Мол, зачем маленькому приложению-календарю, написанному на Python, выжимать максимальную производительность?
И спустя некоторое количество уточнений участники беседы пришли к соглашению в этом вопросе. Не весь код обязан быть производительным.
Кейси Муратори вернулся к самому первому вопросу, немного конкретизировав его:
Учитывая всё это, я хотел бы вернуться к первоначальному вопросу: почему вы удивились, что такие разработчики, как я, противопоставляют "Чистый код" производительности кода? Ничему из того, что я только что перечислил, не уделялось должного внимания в ваших материалах. Я не прошу вас прямо сейчас искать в своей книге или в блоге конкретные предложения, в которых говорится о производительности. Но вещи, о которых вы говорите здесь, едва ли удостаиваются упоминания в ваших материалах.
Вопрос уточнён настолько, что Кейси привёл даже конкретный цикл лекций Мартина, в которых о вопросах производительности чистого кода не говорится ни слова.
Дядя Боб, собственно, принял критику:
Честно говоря, я считаю, что это справедливая критика. Как раз вчера я проводил занятие, на котором уделил больше внимания обсуждению того, как эти принципы и дисциплины влияют на производительность, а также разобрал их преимущества в плане продуктивности. Так что спасибо, что подтолкнули меня к этому. <...> Вы спросили, не принимаю ли я важность производительности как должное. После некоторых размышлений я пришёл к выводу, что, скорее всего, так и есть. Я не эксперт в области оптимизации производительности. Я специализируюсь на практиках, дисциплинах, принципах проектирования и архитектурных шаблонах, которые помогают командам разработчиков программного обеспечения эффективно создавать и поддерживать большие и сложные программные системы. А вот с чем знаком каждый специалист, и с чем непременно нужно бороться, так это с тенденцией стричь всё под одну гребёнку.
И этот вопрос действительно интересен, потому что в моей голове понимание того, что чистый код не для производительности, будто было по умолчанию. С этой же проблемой столкнулся и дядя Боб. Об этом нужно говорить, поэтому мы сейчас здесь. Мартин пришёл к этому же выводу:
Тем не менее, сейчас я нахожу этот разговор более полезным, чем предполагал изначально. Он подтолкнул меня к изменениям, пусть и небольшим. Поэтому не ожидайте от меня видео на тему, чем плох "Чистый код" ;-) Но, если вы посмотрите мои следующие видео, возможно, заметите, что вопросу производительности я начал уделять чуть больше внимания.
Кейси принял такой ответ и оставил за Робертом право выбора, продолжать ли дискуссию далее:
Честно говоря, моя основная цель была в том, чтобы подтолкнуть вас к этому :) <...> Так что мы вполне можем закончить на этом дискуссию. Но, если вдруг захотите продолжить, то следующим пунктом можно обсудить ту "резкую критику" в моём видео, о которой вы упомянули. Это уже касается не только производительности, но и архитектуры. Готов продолжить обсуждение, если вы желаете. Выбор за вами!
Забавно, что проблемы с производительностью показали себя прямо во время диалога. Причём проблемы такие, что их получилось потрогать руками!
Кейси упомянул, что, когда он вводит текст через веб-редактор GitHub, задержка между нажатием клавиши и появлением символа на экране довольно велика. И на приведённом им видео видно, что это действительно создаёт неудобства. Причём такие проблемы происходят несмотря на довольно быстрый чип Zen2.
И здесь началось расследование!
Роберт предположил, что проблема в длине абзаца:
Почему это может происходить? Во-первых, думаю, мы оба набираем текст в одном JavaScript коде. В конце концов, никто не хочет использовать встроенные инструменты браузера, ведь JavaScript гораздо лучше ;-) Во-вторых, вероятно, автор этого кода не предполагал, что мы с вами будем вставлять целые абзацы в одну строку (обратите внимание на номера строк слева). Но даже в этом случае задержка становится очень заметной при скорости 25 кадров в секунду к примерно 200-300 символам. Так что же происходит?
Его предложение для решения проблемы — ставить отступы в конце строки:
Но Кейси чётко диагностировал проблему, проверив вкладку Производительность в Chrome:
Всё дело в "emoji picker"! Вы были недалеки от истины!
Эта функция сканирует введённый текст справа налево на наличие эмодзи. И, если абзац длинный, работа функции становится несовместимой с удобным использованием редактора. А также Кейси находит временное решение этой проблемы:
Когда текстовый редактор GitHub начинает сильно тормозить при наборе абзаца, можно обойти эту проблему, сделав вид, что вводим новый эмодзи. Это помогает, так как пикер не будет сканировать уже написанный текст. Для этого достаточно просто ввести двоеточие, потому что именно с него обычно начинаются эмодзи! Например, сейчас абзац, который я пишу, начинает тормозить. Поэтому я просто поставлю двоеточие здесь: и проблема решена!
Дядя Боб проверяет:
Don't:you:just:love:being:a:programmer:and:diagnosing:interesting:problems:
from:the:symptoms?
To:do:that:well:you:have:to:think:like:a:programmer.::
....
Прим. переводчика: Ну разве это не чудесно, быть программистом и диагностировать различные интересные проблемы исходя из их "симптомов"? А вот чтобы делать это хорошо, нужно думать, как программист.
Пусть данный фрагмент и не несёт непосредственной ценности для основной темы, для меня он показался важным с точки зрения некоторого раскрытия мотивации участников дискуссии. Роберт и Кейси здесь — отчаянные исследователи, которые пришли друг к другу не для того, чтобы поставить чью-либо компетентность под сомнение, а для того, чтобы разобраться в вопросе и понять, что делать дальше.
После раскрытия секретов ввода текста на GitHub дискуссия продолжилась. Кейси сменил вектор обсуждения на вопросы архитектуры, начав с тех вещей из "Чистого кода", с которыми он согласен. Первая из них — читаемость или, как сказал сам Кейси, "базовая читаемость кода".
Думаю, большинство разработчиков также согласны с этим правилом. Код, который читается сверху вниз и сам говорит о том, что и где он выполняет, это действительно хорошо.
Однако Кейси добавил, что в некоторых ситуациях переменные, названные по типу a, b, c и т. д., вполне могут быть приемлемыми. Чуть далее Мартин сформулировал это более конкретно:
Моё правило для имён переменных заключается в том, что их длина должна быть пропорциональна размеру области видимости, в которой находится. <...> Кстати, для функций у меня противоположное правило. Имя функции (или класса) должно быть обратно пропорционально размеру их области видимости.
То есть, в рамках маленького цикла переменная с названием i – вполне нормальное решение, но при большей области видимости стоит обозначить название более явно. А с функциями всё наоборот, (в основном из-за удобства). Если они используются во многих местах, то вызов функций с коротким названием будет быстрее и удобнее.
Вторым пунктом, который затронул Кейси в своих размышлениях, стали тесты. И здесь участники тоже согласны, что они полезны и нужны, но Кейси указывает на конкретное расхождение в понимании тестов:
Думаю, мы мыслим примерно одинаково в отношении процесса тестирования. Однако у нас может быть разное мнение о том, насколько тесты "двигают" процесс разработки. Полагаю, ваша позиция такова: "сначала всегда пишите тесты" или что-то в этом духе. Не уверен, что я бы был столь радикален в этом вопросе. Обычно я сначала разрабатываю функционал, а когда обнаруживаю моменты, где тесты могут предотвратить регрессии, то добавляю их.
И это расхождение действительно легко обнаружить. Дядя Боб в своих книгах довольно часто упоминает методологию экстремального программирования и разработку через тестирование (TDD), преподнося этот подход как один из лучших вариантов работы с тестами.
Кейси же в своём ответе говорит, что, во-первых, протестировать всё автоматически — довольно трудная задача, ведь в коде может быть достаточно много вещей, которые невозможно проверить с помощью тестов. Во-вторых, цель тестов — экономия времени разработки, но если писать тесты, которые не используются или не находят ошибок, то мало того, что экономии не произойдёт, так ещё и на процесс создания тестов будет потрачено много сил и времени.
У Мартина же своё понимание необходимости тестов:
Возможно, я немного строже отношусь к написанию тестов. Обычно сначала пишу тесты, которые заведомо не проходят, а затем исправляю код так, чтобы они проходили. И всё это в очень коротком цикле. Это помогает разобраться в проблеме. Кроме того, такой подход позволяет мне сначала видеть, как тесты используют код, что часто вынуждает меня декомпозировать элементы, чтобы их можно было тестировать независимо друг от друга. В итоге у меня получается набор тестов с высоким уровнем покрытия, которые я видел как пройденными, так и падающими. А это значит, что я доверяю этому набору тестов.
Но дядя Боб всё-таки говорит, что понимает проблему, связанную с невозможностью протестировать весь код, и сам признаётся, что оставляет его части без тестов именно по этой причине.
Основной причиной для использования правила "сначала тест" Мартин называет свою уверенность в том, что при внесении изменений в код тесты сразу покажут, что сломалось. Поэтому покрытый ими код можно быстро рефакторить, улучшать его производительность и так далее:
Считаю, что это равносильно ведению бухгалтерского учёта по принципу двойной записи. Я стараюсь повторять всё дважды и следить за тем, чтобы оба утверждения совпадали.
В вопросе тестов участники дискуссии пришли к согласию, а Мартин сформулировал их небольшие в этом расхождения следующим образом:
Кажется, разницу между нашими подходами к тестированию можно охарактеризовать так:
— Я пишу тесты, если нет веской причины этого не делать.
— Вы пишете тесты, когда для этого есть веская причина.
После обсуждения довольно богатого списка тем Кейси предложил перейти к самому главному:
Ну, думаю, теперь мы можем перейти к обсуждению наших разногласий: классы против оператора switch. Конечно, это лишь конкретные примеры более общей концепции, которая касается разделения между архитектурой, ориентированной на операнды, и архитектурой, ориентированной на операции.
Он говорит о том, что чистый код скорее относится к operand-primal и соответствует основным идеям объектно-ориентированного программирования. То есть методология благоприятствует добавлению новых типов для существующих операций, нежели добавлению новых операций для существующих типов. И Кейси, хоть и работал с таким кодом и видит его распространённость в индустрии, всё же не понимает преимуществ этого подхода.
Дядя Боб уточнил категоризацию и привёл к виду, в котором те же вопросы разбирались в книге "Чистый код". В главе "Объекты и структуры данных" он в одном из пунктов рассказывает про удобство использования полиморфизма. Там же он разделяет код на процедурный и объектно-ориентированный. Эти определения будут использованы им в ходе дискуссии.
Далее Мартин последовательно разобрал, какими преимуществами и недостатками обладает ОО в таком контексте:
С точки зрения подсчёта наносекунд объектно-ориентированный подход менее эффективен. Об этом вы говорили в своём видео. Однако затраты будут относительно небольшими, если развёртываемый функционал достаточно обширен. Пример с фигурами, использованный в вашем видео, один из тех случаев, когда развёртываемый функционал ограничен. С другой стороны, если вы развёртываете определённый алгоритм для расчёта зарплаты сотрудника, стоимость полиморфной диспетчеризации меркнет по сравнению со стоимостью развёрнутого алгоритма.
С точки зрения трудозатрат, объектно-ориентированный подход может быть как менее эффективным, так и наоборот. Он может быть менее эффективным, если вы имеете представление о всех возможных типах данных, с которыми вам предстоит работать. В этом случае операторы switch упрощают добавление новых функций к этим типам. Вы можете организовать код по функциям, а не по типам. Это помогает на определённом уровне понимания. При прочих равных условиях программы зависят от функций. Типы — искусственные, и поэтому организация по функциям часто оказывается более понятной. Но в реальности далеко не все условия равны...
С точки зрения гибкости и непрерывного обслуживания объектно-ориентированный подход может быть более эффективным. Если я организую код так, что типы будут определять основную функциональность, а её вариации храниться в подтипах, то добавлять новые вариации к уже существующей станет легко и потребует минимальных изменений в коде. Мне не нужно искать все операторы switch, которые развёртывают функции с учётом типов, и затем изменять все эти модули. Вместо этого я могу создать новый подтип, в котором собраны все вариации, и добавить этот новый модуль в существующую систему, не изменяя многие другие части.
То есть, исходя из последнего пункта, ОО помогает программистам соблюдать принцип открытости/закрытости (OCP), пусть это происходит и не во всех случаях.
Однако здесь Мартин подмечает ещё одну деталь: случаи использования switch создают исходящую цепь зависимостей к модулям более низкого уровня. В итоге любое изменение одного из модулей более низкого уровня приведёт к перекомпиляции и повторному развёртыванию оператора switch и всех модулей более высокого уровня, которые от него зависят.
А вот в случае использования ОО существует инверсия зависимостей, которая не позволяет изменениям в более низких уровнях вызывать описанную ранее волну перекомпиляции и повторного развёртывания. Именно этот момент дядя Боб называет своим главным архитектурным аргументом.
Однако Кейси Муратори не согласился с этим аргументом. Он допускает, что при переходе от n к n + 1 типу при использовании полиморфизма нужно будет перекомпилировать только один файл. А в случае использования switch, где имеется m функций по n случаев, для перехода к n + 1 типу нужно будет пройти по всем этим функциям и добавить туда новый случай, что потребует перекомпиляции m файлов. Проблема в том, что при добавлении новой операции получится обратная ситуация. В случае использования switch нужно будет просто добавить m + 1-ю функцию, тогда как при использовании полиморфизма потребуется пройти по всем классам и добавить туда операцию, то есть перекомпилировать m файлов против одного при использовании switch.
Кейси говорит, что в этом случае Роберт повернул ситуацию в свою сторону, оставляя другие подробности нераскрытыми:
Обратите внимание, что в обоих ситуациях вы всегда добавляете одинаковое количество элементов (функций-членов или case метки). Единственный вопрос: насколько они распределены по коду? В обоих ситуациях есть один тип изменений, при котором код сгруппирован вместе, и другой, при котором они распределены по всему коду. Эти два типа изменений как бы "инвертированы" по отношению друг к другу. Но победителя не будет, так как они одинаково хороши или одинаково плохи, если учитывать оба типа изменений (типы и операции).
Далее Роберт Мартин предложил ввести в дискуссию гипотетический компилятор, который преобразует процедурный и объектно-ориентированный код в один и тот же двоичный, чтобы увести тему от производительности непосредственно к архитектуре. Но Кейси посчитал это лишним:
Это не относится к вопросу о том, как именно мы должны писать реальный код. А именно об этом мы здесь и говорим.
Также дядя Боб отметил, что обсуждаемые стили мало что меняют в процессе управления исходными файлами, а выбор здесь является скорее делом вкусов, но инверсия зависимостей создаёт важное различие. Кейси сократил эти рассуждения для ясности:
Время выполнения: благоволит архитектуре, ориентированной на операции.
Исходный код: без разницы.
Граф зависимостей: благоволит архитектуре, ориентированной на операнды.
Кейси хотел понять, в чём, с точки зрения Роберта, состоит польза инверсии зависимостей. Дядя Боб ответил, что отделение деталей низкого уровня от деталей высокого уровня важно исключительно для людей, а машинам на это всё равно, а также привёл причины, по которым это разделение важно:
— Это позволяет нам использовать, изменять и заменять элементы низкого уровня (например, устройства ввода-вывода), не затрагивая исходный код высокого уровня.
— Это позволяет нам компилировать компоненты высокого уровня, не требуя от компилятора чтения компонентов низкого уровня. Таким образом, компоненты высокого уровня защищаются от изменений в компонентах низкого уровня.
— Это позволяет нам создать иерархию модулей, в которой нет циклов зависимостей. Нам не нужно будет использовать #ifndefs в заголовочных файлах, обеспечивая детерминированную компиляцию и развёртывание.
— Это позволяет нам разделить развёртывания на компоненты высокого и низкого уровня. Например, у нас могут быть DLL/JAR/EPROM для высокого уровня и DLL/JAR/EPROM для низкого уровня. (Я вспомнил EPROM, потому что именно такое разделение я использовал в начале 80-х со встроенным оборудованием, которое нужно было обслуживать в полевых условиях. Отправка одного 1К EPROM была гораздо удобнее, чем отправка всех 32 ;-))
— Это позволяет нам организовать исходный код по уровням, изолируя функции более высокого уровня от функций более низкого уровня. На каждом уровне мы обходим проблемы уровнем ниже, относя их к модулям исходного кода, от которых текущий уровень не зависит. Это создаёт иерархию проблем, которая интуитивно понятна и легка для восприятия человеком.
Для разъяснения, возможна ли описанная ранее изоляция в обоих видах архитектуры, Роберт Мартин приводит такой фрагмент Java-кода:
public class Payroll {
public void doPayroll(Date payDate) {
for (Employee e : DB.getAllEmployees()) {
if (isPayDay(e, payDate)) {
Paycheck check = calculatePaycheck(e, payDate);
pay(e, check);
}
}
}
}
Этот модуль, который является системой расчёта заработной платы, высокоуровневый. В нём не описаны все подробности того, как выплачивается зарплата. Они намеренно сокрыты, а import не раскрывает ни одного модуля, который бы эти детали реализовывал.
Вопрос Мартина состоит в том, возможно ли сделать такое же в процедурной архитектуре. Кейси ранее упоминал union, но даже он, по мнению Роберта, не поможет сокрыть детали реализации.
Кейси ответил, что при использовании union нет никакой разницы по сравнению с иерархиями с точки зрения раскрытия деталей:
Нет никакой необходимости раскрывать все эти вещи. Единственная причина, по которой вы могли бы их раскрыть, это желание получить прирост производительности, который достигается за счёт того, что компилятор оптимизирует все вызовы.
Более того, свои слова он подкрепил примером типичного h-файла:
struct file;
file *Open(...);
void Read(file *File, ...);
void Close(file *File);
Кейси утверждает, что неиерархический метод имеет меньше ограничений на границе интерфейса, поскольку в случае иерархии код диспетчеризации выносится за границы библиотеки, то есть фактически деталь реализации проталкивается через границу.
Мартин поправил своего собеседника относительно термина "иерархия", поскольку он скорее говорит о подходе "интерфейс/реализация", чем о глубоких иерархиях наследования. Также дядя Боб согласился, что разделение .c/.h хорошо скрывает реализацию, поскольку непосредственно в C оно является инверсией зависимостей.
После уточнения некоторых деталей Роберт написал, что от конкретики диалог перешёл к вопросам предпочтений и предложил его закончить.
К этому моменту я и сам, честно сказать, подумал, что дискуссия движется в странном направлении. Из-за того, что участники так сильно увлечены тем, о чём они говорят, вектор обсуждения менялся довольно сильно.
Но Кейси предложил продолжить диалог, дабы обсудить, в какой именно момент чистый код экономит время для программистов.
Мартин ответил, что существует множество стилей программирования, которые могут как сохранять время разработчиков, так и тратить его в больших количествах. Для иллюстрации он привёл такой фрагмент кода:
#include <stdio.h>
main(tp,pp,ap)
int tp;
char **pp;
char **ap;
{int t=tp;int _=pp;char *a=ap;return!0<t?t<3?main(-79,-13,a+main(-87,1-_,
main(-86, 0, a+1 )+a)):1,t<_?main(t+1, _, a ):3,main ( -94, -27+t, a
)&&t == 2 ?_<13 ?main ( 2, _+1, "%s %d %d\n" ):9:16:t<0?t<-72?main(_,
t,"@n'+,#'/*{}w+/w#cdnr/+,{}r/*de}+,/*{*+,/w{%+,/w#q#n+,/#{l,+,/n{n+\
,/+#n+,/#;#q#n+,/+k#;*+,/'r :'d*'3,}{w+K w'K:'+}e#';dq#'l q#'+d'K#!/\
+k#;q#'r}eKK#}w'r}eKK{nl]'/#;#q#n'){)#}w'){){nl]'/+#n';d}rw' i;# ){n\
l]!/n{n#'; r{#w'r nc{nl]'/#{l,+'K {rw' iK{;[{nl]'/w#q#\
n'wk nw' iwk{KK{nl]!/w{%'l##w#' i; :{nl]'/*{q#'ld;r'}{nlwb!/*de}'c \
;;{nl'-{}rw]'/+,}##'*}#nc,',#nw]'/+kd'+e}+;\
#'rdq#w! nr'/ ') }+}{rl#'{n' ')# }'+}##(!!/")
:t<-50?_==*a ?putchar(a[31]):main(-65,_,a+1):main((*a == '/')+t,_,a\
+1 ):0<t?main ( 2, 2 , "%s"):*a=='/'||main(0,main(-61,*a, "!ek;dc \
i@bK'(q)-[w]*%n+r3#l,{}:\nuwloca-O;m .vpbks,fxntdCeghiry"),a+1);}
Мне кажется важным, что чистый код это не только динамический полиморфизм. Об этом сказал и Мартин, а ещё упомянул вопросы именования, управления зависимостями, организации кода и прочие.
Кейси продолжил свой вопрос, предложив конкретные варианты для замены классов:
Но я пытаюсь понять, какие практики делают динамическую систему более или менее способной "экономить время программиста". Я бы утверждал (не только в этом конкретном случае, но и в большинстве случаев в целом), что перечисления (enums)/флаги и операторы if/switch гораздо лучше подходят для такой реализации, чем классы. Я считаю, что они лучше по всем параметрам. Они будут быстрее, проще в поддержке, чтении, написании и отладке, а также приведут к созданию системы, с которой пользователю будет легче работать.
Кейси предложил Роберту теоретически представить интерфейс операционной системы для ввода/вывода, разработанный в соответствии с принципами чистого кода.
Дядя Боб ответил на это ровно тем же способом, который был использован им когда-то: создание абстракции File с операциями open, close, read, write и seek. Устройства ввода-вывода были классифицированы как типы, и при дальнейшем использовании было неважно, какое именно устройство используется. Благодаря такой организации возможно реализовать следующую программу для копирования одного устройства на другое, не зная, что это за устройство:
void copy() {
int c;
while((c=getchar()) != EOF)
putchar(c)
}
Дядя Боб предложил посмотреть на тот же самый интерфейс, только реализованный с помощью switch:
#include "devids.h"
#include "console.h"
#include "paper_tape.h"
#include "...."
#include "...."
#include "...."
#include "...."
#include "...."
void read(file* f, char* buf, int n) {
switch(f->id) {
case CONSOLE: read_console(f, buf, n); break;
case PAPER_TAPE_READER: read_paper_tape(f, buf n); break;
case....
case....
case....
case....
case....
}
}
Понятно, что чем больше будет устройств, тем больше будет условий; количество исходящих зависимостей также растёт, так ещё и в контексте задачи таких файлов пять. То есть каждый из файлов должен быть обновлён при добавлении нового устройства. А ещё возникает необходимость перекомпиляции файлов, включающих devids.h.
Однако Мартин сказал, что бывает время и место как для switch, так и для динамического полиморфизма.
Кейси попросил у Роберта подробнее расписать этот пример и рассказать, где именно подход с принципами SOLID и чистым кодом экономит время программистов. И Мартин это сделал!
Для начала он реализовал базовый класс:
#include "file.h"
class raw_device {
public:
virtual file* open(char* name) = 0;
virtual void close(file* f) = 0;
virtual void read(file* f, size_t n, char* buf) = 0;
virtual void write(file* f, size_t n, char* buf) = 0;
virtual void seek(file* f, int n) = 0;
virtual char* get_name() = 0;
}
Далее добавил драйвер ввода-вывода:
#include "raw_device.h"
class new_device : public raw_device {
public:
virtual file* open(char* name);
virtual void close(file* f);
virtual void read(file* f, size_t n, char* buf);
virtual void write(file* f, size_t n, char* buf);
virtual void seek(file* f, int n);
virtual void get_name();
}
И, наконец, дошло до конкретной реализации:
#include "new_device.h"
file* new_device::open(char* name) {...}
void new_device::close(file* f) {...}
void new_device::read(file* f, size_t n, char* buf) {...}
void new_device::write(file* f, size_t n, char* buf) {...}
void new_device::seek(file* f int n) {...}
void new_device::get_name() {return "new_device";}
Осталось добавить модуль для создания экземпляров драйверов ввода-вывода и загрузки их в карту устройств:
io_driver_loader.cc
#include "new_device.h"
#include "new_device2.h"
#include "new_device3.h"
...
void load_devices(device_map& map) {
map.add(new new_device());
map.add(new new_device2());
map.add(new new_device3());
...
}
Теперь осталось только посчитать, сколько действий нужно сделать для добавления нового устройства:
А вот в случае использования switch действий становится побольше:
Кейси внёс некоторые изменения. Во-первых, задача была несколько проще, поскольку нужно просто сырое устройство, и необходимость в файле отсутствует. Поэтому абстракция драйвера стала чуть поменьше:
class raw_device {
public:
virtual void read(size_t offset, size_t n, char* buf) = 0;
virtual void write(size_t offset, size_t n, char* buf) = 0;
virtual char* get_name() = 0; // return the name of this device.
}
Соответственно, поменялась и реализация этого драйвера:
#include "raw_device.h" // from the DDK
class new_device : public raw_device {
public:
virtual void read(size_t offset, size_t n, char* buf);
virtual void write(size_t offset, size_t n, char* buf);
virtual void get_name();
}
Во-вторых, Кейси предложил добавить функцию для поиска используемого устройства через карту устройств:
raw_device *find_raw_device(char *name) {
raw_device *device = global_device_map[name];
return device;
}
Мартин согласился на такие изменения, обратив внимание, что количество операций сильно сократилось.
Кейси предложил представить API, которое позволит описанному выше коду отвечать на запросы пользователя.
Дядя Боб предложил реализацию этого API как подключаемой функции, которая будет предоставлять доступ к функциям делегирования:
read(char* name, size_t offset, size_t n, char* buf);
write(char* name, size_t offset, size_t n, char* buf);
Собственно, предложенная Кейси функция find_raw_device будет использоваться этими функциями, чтобы делегировать ответственность за исполнение запроса на конкретный экземпляр raw_device.
Роберт предложил вернуться от гипотетического примера к теме, которой он посвящён, констатируя, что от правил чистого кода он никуда не уйдёт, и ещё раз повторил свою мысль по поводу switch:
Операторы switch имеют право на существование. Динамический полиморфизм тоже. Динамические штуки более гибкие, чем статические, поэтому, когда вам нужна эта гибкость и вы можете себе её позволить, выбирайте динамические штуки. Если нет, то продолжайте использовать статические.
Кейси предложил ещё один вариант для интерфейса ввода/вывода, реализованный с помощью enum:
enum raw_device_operation : u32
{
RIO_none,
RIO_read,
RIO_write,
RIO_get_name,
RIO_private = 0x80000000,
};
struct raw_device_id
{
u32 ID;
};
struct raw_device_request
{
size_t Offset;
size_t Size;
void *Buffer;
raw_device_operation OP;
raw_device_id Device;
u64 Reserved64[4];
};
strcut raw_device_result
{
u32 error_code;
u32 Reserved32;
u64 Reserved64[7];
};
Кейси отметил, что такая реализация экономит больше времени для программиста по сравнению с использованием чистого кода. Более того, он выделил два конкретных пункта чистого кода, которые тратят много времени:
— Вы предпочитаете иметь по одному классу для каждого типа штуки (в данном случае драйвера), с одной виртуальной функцией-членом для каждой операции, которую этот объект выполняет (в данном случае: чтение, запись и получение имени).
— Перестановки операций не должны проходить через функции. То есть передача enum в функцию, которая определяет, что сделать — плохо. Лучше иметь одну функцию для каждой операции. Я видел, что это упоминалось несколько раз не только в контексте операторов switch, но и когда функции в параметрах содержат операторы if, которые изменяют её поведение. В таких случаях рекомендуется переписать функцию как две отдельные функции.
Также Кейси привёл доводы к большей эффективности использования enum в этом примере:
Дядя Боб с некоторыми оговорками согласился с приведёнными доводами и сказал, что при всём желании что-либо доказать, они говорят об одном и том же:
Мы в одной лодке с той лишь разницей, что я в футболке с надписью Clean Code, а вы — с надписью "Clean Code" в кавычках.
После уточнения некоторых деталей, Кейси всё же сказал, что не считает свои взгляды схожими со взглядами Роберта Мартина, однако предположил, что в ходе дальнейшей дискуссии они бы пришли к этому.
На этом дискуссия Кейси Муратори и Роберта Мартина закончилась, оставив аудитории пищу для размышлений о том, кто же был прав.
После окончания этой дискуссии я видел мнения о том, что чистый код был компрометирован в её процессе и больше не заслуживает внимания. Мне кажется, что это абсолютно неверно, как и неверно подобное утверждение в обратную сторону. Оба участника остались при своём, объяснив при этом свою позицию.
Отмечу, что в некоторых моментах чувствовалось недопонимание между участниками беседы. Например, когда Кейси утверждал о недостатках иерархий наследования, а Роберт Мартин о них и не говорил. Возможно, это связано с форматом проведения диалога. Я лишь констатирую свои ощущения как сторонний наблюдатель.
Если говорить про выводы, которые можно сделать по итогам этой дискуссии, то концепция чистого кода всё ещё остаётся для меня важной при написании своего кода. Важно писать его таким, чтобы в дальнейшем была возможность использовать и сопровождать его. Однако важно при этом думать и о производительности. Меня в этом случае можно отнести скорее к сторонникам избирательной производительности, о которой говорил дядя Боб. Не все задачи требуют выжимки максимального перфоманса из каждой написанной строки (немного утрирую, но, думаю, вы меня понимаете), и, как мне кажется, даже пример из статьи Кейси про фигуры не требовал сил, потраченных на увеличение его производительности аж в 15 раз.
Но помимо своего мнения хотелось бы спросить и других разработчиков о том, что они думают. Поэтому мы вернёмся к вопросам чистого кода и производительности во второй части статьи.