Сообщений 518    Оценка 1976 [+7/-5]         Оценить  
Система Orphus

Закон сохранения сложности

Автор: Игорь Ткачёв
The RSDN Group
Опубликовано: 06.12.2002
Исправлено: 07.08.2009
Версия текста: 2.0

Количественная сложность
Сложность восприятия
Сложность изменения кода
Алгоритмическая сложность
Структурная сложность
Сложность обучения
Сложность понимания задачи
Вместо заключения

Усложнять - просто, упрощать - сложно.
Закон Мейера.

Существует множество практик, принципов, паттернов и прочих страшных слов, которые мы используем в повседневной профессиональной деятельности и очень часто даже не задаём себе вопрос, зачем мы это делаем. Зачем это всё нужно, плохо это или хорошо, когда плохо и когда хорошо. Зачем нужны все эти принципы? На самом деле ответ до банального очевиден. Всё это в конце концов направлено на борьбу со сложностью разработки ПО. Теперь пришла очередь задать вопрос – а что же такое сложность и как знание того, что это такое, поможет нам лучше понять и использовать принципы, которые как раз и направлены на борьбу с ней?

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

Дело усложняется ещё и тем, что все эти виды сложности взаимосвязаны и не имеют чёткой границы, да и сама сложность может быть как объективной, так и из разряда "назло маме отморожу уши". К счастью, уши нас не будут интересовать вовсе, а вот объективная сложность представляет определённый интерес. Как бороться с объективной сложностью? Главная проблема, на мой взгляд, заключается в том, что не существует способов абсолютного устранения объективной сложности. Сложность можно уменьшить, увеличить, поделить и преумножить, можно создавать её на пустом месте, особенно назло маме, но её нельзя устранить бесследно. Сложность можно трансформировать в другие виды, оптимизировать, перераспределить и, в конце концов, получить над ней контроль. Фактически, именно на это направлены все принципы разработки софта – на трансформацию сложности, на оптимизацию усилий, требуемых для решения поставленной задачи. Трансформируя сложность, мы получаем возможность лучше контролировать её и, как следствие, лучше контролировать разрабатываемый код. А это и есть наша главная цель – мы должны контролировать код, а не код нас.

Трансформирование сложности означает одну простую вещь – устраняя сложность в одном месте, мы всегда добавляем её где-то в другом. Если этого не происходит, то, скорее всего, мы просто пока чего-то не видим и не осознаём. Сложность никогда не уходит без следа, она трансформируется и сохраняется в виде других видов сложности. Давайте условно назовём это законом сохранения сложности.

ПРИМЕЧАНИЕ

Во избежании недоразумений сразу хочется успокоить тех, кто слишком буквально воспринимает название статьи – автор вполне осознаёт, что он не открыл новый закон природы, более того, он понимает, что в строго научном смысле такого закона не существует, а выбор заголовка в большей степени обусловлен пристрастием автора к пафосным и провокативным названиям. Поэтому не стоит искать и не находить в статье формальные определения и доказательства. Такой цели автором не ставилось. Задача статьи – рассмотреть виды сложности, лучше понять их природу, неочевидные стороны и влияние на разрабатываемый код.

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

Рассмотрим каждый вид сложности отдельно.

Количественная сложность

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

Когда это плохо работает? Это плохо работает, когда мы пытаемся заранее выделить код, который по идее можно повторно использовать, но на практике его повторно использовать не получается. Дополнительную сложность в решаемую задачу мы внесли, а взамен ничего не получили. Ещё это плохо работает, когда у автора такого кода недостаточно опыта и/или мозгов. Не секрет, что написание библиотек и повторно используемых компонентов – это тоже скил, который нужно прокачивать. Если делать это без мозгов, то в лучшем случае таким кодом никто не будет пользоваться, а в худшем – усложнит и восприятие, и изменение везде, где такой код используется.

С другой стороны, качественный набор повторно используемых компонентов (библиотек) может дать существенное уменьшение сложности. Возьмём, например, класс System.String. Этот класс прост в использовании и самодостаточен. Вся сложность работы со строками перенесена из нашей задачи в головы инженеров из Microsoft и сотни серверов, которые 24x7 тестируют работоспособность этого класса во всех возможных версиях и конфигурациях. Для нас это уменьшение сложности иногда почти бесплатно, но это не означает, что оно бесплатно совсем.

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

Сложность восприятия

Этот вид сложности в первую очередь относится к форме кода, а не к его содержанию. В принципе, здесь всё просто – открываем код в определённом месте и засекаем секундомером, сколько времени нам понадобилось на то, чтобы понять, что и как он делает. Сложность восприятия зависит от множества факторов. Оформление кода, следование соглашениям об именованиях, запутанность/ясность алгоритма, выразительность используемых средств, поддержка среды разработки, включая подсветку кода и навигацию, и многое другое. Также сложность восприятия имеет непосредственное отношение к алгоритмической сложности и порогу вхождения.

Чтобы не путать этот вид сложности с другими, достаточно представить, что у вас отобрали любимую игрушку, как-то Visual Studio, IDEA или прочий Eclipse, и заставили править в NotePad чужой, плохо отформатированный, без намёка на какие-либо соглашения об именованиях код, отняв при этом все моноширинные шрифты и заменив половину табуляций пробелами. Представили? Жуть!

Сложность изменения кода

Простота восприятия кода не означает простоту изменения кода. Конечно же, простой код легче менять, но главная характеристика сложности изменения кода – это его гибкость. Простой код не всегда гибкий, а гибкий – не всегда простой. И это лишний раз подтверждает вывод о том, что сложность нельзя только уменьшать, её можно трансформировать. Многие практики и паттерны направлены на увеличение гибкости кода и, как правило, они же увеличивают сложность восприятия кода и повышают порог вхождения. Возьмём любой паттерн из этой серии, например, IoC или Visitor. Некоторые реализации IoC вносят в код дополнительные сущности, делают алгоритм менее очевидным и усложняют восприятие кода, в частности, навигацию по нему. Visitor разбивает цельный алгоритм обработки иерархической структуры данных на много частей, что, в свою очередь, существенно увеличивает сложность восприятия кода. И так всегда. Убрали в одном месте, добавилось в другом. Стоит ли тогда вообще добиваться тотальной гибкости кода? Решать нужно в каждом конкретном случае отдельно. Иногда можно получить существенный выигрыш и усилить контроль над кодом, иногда можно привнести дополнительную сложность в код, ничего не получив взамен, и окончательно потерять контроль над ним. Главное – помнить, что в соответствии с выведенным нами законом сохранения сложности абсолютно каждый паттерн одновременно является и антипаттерном, и оценивать нужно не только положительные качества паттерна, но и обязательно учитывать отрицательный эффект. В некоторых сценариях использования может оказаться, что у самого замечательного паттерна интегральная оценка будет отрицательной.

Алгоритмическая сложность

Если совсем обобщённо, то алгоритмическая или интеллектуальная сложность – это минимально необходимый уровень интеллекта для решения поставленной задачи. В частности, более конкретно можно говорить о способности удержать задачу в голове целиком. Часто эту сложность путают с обучением, с уровнем знаний, но это не одно и тоже. Собственно, занятие программированием уже предполагает наличие определённого количества мозгов, но не секрет, что для решения одной и той же задачи разным людям требуется разный уровень умственного напряжения. Но не это главное. На самом деле алгоритмов, непосильных для простых смертных, в жизни встречается не так много. Главное то, что алгоритмическая сложность легко увеличивается в разы путём несложных манипуляций с кодом. Достигается это обычно путём смешивания реализаций разных алгоритмов в одном месте. Допустим, у нас есть входной поток данных, выходной поток данных и алгоритм их обработки. Давайте теперь поиграем в абстрактных попугаев.

Предположим, мы разбираем XML, делаем с ним несложные манипуляции и генерируем HTML. Алгоритм разбора входного потока весьма прост и имеет интеллектуальную сложность, скажем, 3 попугая, алгоритм обработки данных – 4 попугая, а алгоритм генерации HTML – 2 попугая. Допустим, смешивание всего этого в одном месте даст нам число, равное произведению 3 * 4 * 2 = 24. Предположим, что наш интеллектуальный потолок равен ста попугаям. Итого, мы легко справились с поставленной задачей без особого умственного напряжения. Теперь предположим, что мы пишем компилятор, и в одном месте мы производим парсинг текста, генерируем исполняемый код и проводим некоторые оптимизации. Эти алгоритмы очевидно сложнее приведенных в предыдущем примере. Оценим каждый из них, для простоты, в 10 попугаев. Умножаем и получаем 1000, т.е. цифру на порядок превышающую наши интеллектуальные возможности.

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

Рассмотрим пример с single responsibility principle (SRP).

ПРИМЕЧАНИЕ

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

Итак, SRP. SRP – это как раз тот принцип, который позволяет контролировать алгоритмическую сложность с помощью декомпозиции. Этот принцип предписывает разделение алгоритма на составляющие, каждая из которых занимается своей конкретной задачей. В результате сложность решаемой задачи становится уже не произведением, а суммой и, что самое важное, алгоритмическая сложность каждой из составляющих остаётся на начальном уровне. Таким образом, мы имеем дело не с одним алгоритмом со сложностью в тысячу попугаев, а с тремя со сложностью в десять. Но что здесь может быть плохого? Вспомним закон сохранения сложности. SRP не только уменьшает алгоритмическую сложность кода, он ещё и увеличивает сложность восприятия кода, вносит количественную сложность и в определённой степени уменьшает гибкость. Для примера с компилятором такое перераспределение даёт эффект, при котором вносимая новая сложность по сравнению с выгодой фактически не видна в микроскоп. Но что будет, если мы применим этот принцип для нашего первого примера, для преобразования XML в HTML? Прежде всего нам придётся изобрести промежуточную структуру данных (по типу AST для нашего компилятора), все три алгоритма придётся разнести по разным методам или даже классам, для объединения всего этого в единое целое придётся добавить управляющий код. Если наш исходный алгоритм занимал сотню простых и понятных строк, то теперь мы получим в сумме три, четыре, может быть, пять сотен строк, разнесённых по разным модулям. Будет ли полученная выгода адекватна усложнению? Ответ очевиден.

Можно было бы предположить, что такие принципы следует использовать только в сложных системах, там, где SRP даёт мощный положительный эффект. Но проблема заключается в том, что тот же SRP предписывает разбивать сложное на несколько более простых, и не факт, что более простое будет совсем простым, и к нему не надо будет опять применять SRP. Так где же следует остановиться? Решать нужно в каждом конкретном случае отдельно. Лично я, если ответ на этот вопрос не очевиден сразу, предпочитаю откладывать применение SRP до того момента, когда задача перестаёт целиком умещаться у меня в голове, и я начинаю чувствовать дискомфорт. Если это происходит, то стоп – рефакторинг.

ПРИМЕЧАНИЕ

Отступление для менеджеров.

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

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

Третьим и наименее надёжным способом борьбы с интеллектуальной сложностью можно назвать метод погружения. Если долго и упорно думать над решением задачи, то в конце концов можно построить её адекватную модель со всеми нюансами, которую удастся целиком затолкать в серое вещество и более или менее успешно решить. Я почти уверен, что таким образом можно решить практически любую задачу. Но, к сожалению, здесь почему-то вспоминается мысль об академиках, которая гласит, что академиком может стать каждый, только одному для этого нужно тридцать лет, а другому – триста. Кроме всего прочего, данный метод обладает ещё одним существенным недостатком – как правило, после решения задачи серое вещество старается всеми силами избавиться от заполнявшего его всё это время ужаса и со временем переводит чёткую и стройную модель задачи в разряд смутных сомнений. А повторный подход к снаряду требует снова почти такого же длительного погружения и не факт, что новая модель будет полностью соответствовать предыдущей.

Также следует упомянуть об оптимизациях. Большинство оптимизаций сопровождается количественным и алгоритмическим усложнением кода (иногда многократным) и понижением его гибкости, превращая один простой алгоритм в плохо читаемую смесь двух алгоритмов, исходного и алгоритма оптимизации. Если положительный эффект от такой оптимизации стремится к нулю, то от неё лучше отказаться. К сожалению, это не всегда очевидно. Но, к счастью, сегодня начинают появляться декларативные способы оптимизации, которые решают конкретную проблему, оставляя исходный алгоритм практически в неизменном виде. Например, к такой оптимизации можно отнести PLinq и его метод AsParallel, атрибуты кэширования результатов вызываемых методов в Nemerle и BLToolkit, и тому подобные вещи. Думаю, в будущем таких средств (и эффекта от их применения) будет становится только больше. Тем не менее уже сегодня важно понимать, что главная проблема оптимизации – это алгоритмическое усложнение кода за счёт смешивания разных алгоритмов в одном месте, и наша задача заключается в том, чтобы минимизировать отрицательный эффект от такого смешивания.

Структурная сложность

Мне кажется вполне логичным разделять код на два вида: тот, который находится внутри методов, и тот, который снаружи. Код внутри метода – это собственно алгоритмы плюс управляющий код. Код снаружи – компоновка алгоритмов в модули, компоненты, классы и прочая структуризация. Испытывая сложности с изменением кода, очень важно понимать и разделять, где именно проблема – внутри методов или снаружи. Как это ни странно, но проблема часто оказывается в компоновке кода, и рефакторинг нужно начинать именно с неё. С алгоритмами всё просто – они либо работают хорошо и так, как указано в постановке задачи, либо работают плохо или не так как надо, либо не работают совсем. С компоновкой алгоритмов всё не так однозначно. Сильная связность, слабая связность, куча соответствующих паттернов и практик – всё это из этой оперы. К сожалению, технические задания и функциональные требования не определяют правил структуризации кода (кроме случаев, когда структуризация является частью задания). В результате компоновка переходит в разряд задач, которые решают прежде всего проблемы самого программиста, т.е. в проблемы сопровождения кода. А проблемы сопровождения кода – это проблемы гибкости кода, сложности его изменения. Мы компонуем код в модули и классы для того, чтобы им было легче управлять, модифицировать его и расширять. В сложных системах компоновка и грамотная организация кода являются ключевым моментом для достижения его приемлемой гибкости, и чем сложнее система, тем больше внимания должно уделяться компоновке кода и тем более высокие требования предъявляются к его структуризации.

Структурная сложность тесно переплетена с алгоритмической сложностью и часто является средством уменьшения и обратной стороной последней. Пример с SRP тому наглядное подтверждение. В приведённых выше примерах всё ясно и просто – здесь SRP применять нужно, а здесь нет. Но не так всё просто в жизни. Часто грань между «нужно» и «не нужно» настолько зыбка, что место остаётся только для интуиции и предпочтений. А, как мы знаем, там где начинаются предпочтения, логика заканчивается. К счастью, в обратном направлении это правило тоже работает. Но иногда случается, что не срабатывает ни логика, ни интуиция и, казалось бы, самые благие паттерны и практики приводят к усложнению кода. В программировании это называется overarchitecture (архитектурное переусложнение), штука, встречающаяся чуть ли не чаще, чем вообще полное отсутствие архитектуры. Но на самом деле, находясь в затруднительной ситуации и не зная какое решение принять, не так сложно оценить полезность применяемого инструмента. То, что этот инструмент привнесёт дополнительную сложность в наш код, мы уже знаем, теперь осталось спросить себя или того, кто настаивает на его использовании, а какие проблемы решаются с помощью данного инструмента и какие выгоды мы получим. Зачастую одного этого вопроса бывает вполне достаточно для принятия правильного решения.

Сложность обучения

Это пожалуй самый неочевидный вид сложности, так как он относится к коду опосредованно, через работающего над ним человека. Другими словами, один и тот же код может быть как простым, так и сложным для разных людей. Знание парадигм программирования, умение применять современные инструменты, – всё это позволяет снизить все уровни сложности за счёт увеличения порога вхождения и трансформации части сложности в используемые средства. Это в свою очередь даёт возможность существенно упрощать код и в итоге решать более сложные задачи. И наоборот. С помощью примитивных и устаревших средств задачу тоже можно решить, но решение может оказаться куда более сложным для восприятия, многословным и менее гибким.

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

Часто бывает, что некоторые менеджеры пытаются снизить свои риски за счёт поддержания в команде достаточно низкого уровня обучения. Это даёт возможность быстрой и безболезненной замены одной посредственности другой. Нетрудно догадаться, что в соответствии с законом сохранения сложности, вся сложность разрабатываемых приложений у таких команд находится в самом коде. Как правило, такого кода много, он не гибок, трудно читаем и обладает завышенной алгоритмической сложностью, которая часто зашкаливает за допустимый для членов команды предел. Результат, как правило, тоже предсказуем – затягивание сроков, превышение бюджетов, провалы проектов. Как с этим бороться? Правильно, обучать людей, перекладывая сложность из кода в их головы и используемые ими инструменты, балансировать между своими рисками и управлением сложностью. И всегда помнить, что примитивными могзами сложные задачи просто не решаются.

Сложность понимания задачи

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

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

Другим широкоиспользуемым способом управления сложностью понимания задачи является откладывание понимания задачи до лучших времён. То есть мы начинаем решать задачу здесь и сейчас, имея лишь общее представление о задаче. А детальное понимание того, что же за задачу мы решаем прийдёт значительно позже, фактически тогда, кода задача будет решена. XP и Agile - методологии из этой серии, методологии, позволяющие управлять хаосом в постановке задачи. Данный подход характеризуется повышенными требованиями к гибкости кода. Фактически гибкость в данном случае является фундаментальным требованием.

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

Вместо заключения

Многое стало бы проще, если бы мы нашли способ количественного измерения сложности, но, боюсь, это невозможно. Я умышленно избегал любых намёков на какие бы то ни было метрики (кроме, пожалуй, абстрактных попугаев), так как считаю, что нельзя количественно сравнить сложность восприятия безобразно оформленного кода, алгоритмическую сложность кода, объём кода, сложность обучения, необходимую для понимания кода, и сложность понимания поставленной задачи. Пожалуй, для каждого вида сложности в отдельности можно найти приемлемые метрики и даже некоторые из них выразить через время, подведя под общий знаменатель несколько видов сложности. Но по отдельности разные виды сложности мало интересны, так как не дают целостной картины происходящего. Пока же в целом мы можем оперировать только понятиями «меньше, больше, много, мало». Но часто бывает достаточно и этого. В конце концов, наша задача заключается не в вычислении количества попугаев, а в управлении сложностью, в получении и сохранении контроля над кодом. И в этом нам поможет понимание того, что в соответствии с законом сохранения сложности абсолютно любое средство может быть использовано как во благо, так и совсем наоборот. На практике это означает критическое отношение к любому (абсолютно) паттерну или принципу. Даже к таким незыблемым вещам, как декомпозиция, упорядочивание и абстрагирование, так как декомпозиция, упорядочивание и абстрагирование всегда в 100% случаев автоматически ведёт к усложнению кода, а вот для достижения упрощения ещё нужно попотеть. Вообще любые практики и используемые инструменты гораздо лучше рассматривать через призму сложности, а не наоборот. Если руководствоваться принципом, что в разработке ПО управление сложностью первично, а всё остальное вторично, то большинство применяемых практик становятся более понятными и очевидными.

В завершении следует отметить, что сложность имеет свойство накапливаться. Немного недооформили код там, не до конца выдержали соглашения здесь, местами чуть переборщили с SRP и прочими паттернами (или наоборот). Сначала кода становится чуть больше, затем он становится чуть менее читаемым и гибким, затем неоправданно вносится дополнительная алгоритмическая или структурная сложность, и вот мы уже начинаем терять контроль. Чтобы этого не происходило, нужно всегда, на всех этапах разработки бороться с излишней сложностью, выявлять её и устранять, стремясь добиться максимально простого решения. Но, к сожалению, это не всегда получается по одной простой причине - как известно, самая сложная вещь в мире - это простота.

На этом, пожалуй, всё. Комментарии, дополнения, опровержения и разоблачения приветствуются.


Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.
    Сообщений 518    Оценка 1976 [+7/-5]         Оценить