пятница, 11 июня 2010 г.

Продолжение дискуссии: дизайн

Собственно, продолжаем обсуждение проблем разработки программного обеспечения. Тут придётся немного апеллировать к утверждениям из поста thesz, так что прощу прощения за перепечатки.
Если программа может быть написана двумя или более способами, то необходимо произвести выбор одного из них. Archimag предлагает выбрать дизайн наугад и потом оценить, подошёл ли он.
Во-первых, любая программа может быть написана бесконечным количеством способом. Т.е. ситуация, когда имеется только один вариант дизайна не бывает в принципе. Во-вторых, я никогда не действую наугад и, естественно, не рекомендую это делать другим.
Желательно бы делать лучше, например, уметь сравнивать дизайны между собой.
В том самом обсуждении я предложил следующий вариант: сперва сравниваем дизайны по тому, как много сценариев программы они покрывают, потом сравниваем по количеству кода, потребному для реализации дизайна. Такая несложная иерархия.

Если включить в рассмотрение важность сценариев и сложность их реализации и отсортировать их, как это рекомендует gaperton, то получится хорошая система оценки дизайна программы, ещё более иерархичная, быстрее отсеивающая относительно плохие дизайны.
Слава роботам! Так действуют машины, когда им надо решить какую-нибудь более-менее сложную задачу. Т.е. в идеале нужно перебрать все варианты и из них выбрать самый подходящий. Когда вариантов бесконечно много (как в случае с дизайном) надо действовать чуть хитрее. Поиск в пространстве состояний сейчас достаточно хорошо проработанная область, но она содержит алгоритмы для машин, не для людей. Точнее, мы используем эти алгоритмы для того, что бы научить машину делать нечто, для чего ранее требовался человеческий интеллект. Возможно, когда-нибудь программы будут писать роботы, который действительно будут действовать схожим образом. Хотя тоже сомнительно, ибо простой перебор вариантов не работает даже в шахматах, которые хоть и являются довольно сложной проблемой, но довольно ограниченной и, мало того, конечной. В целом, я считаю предложенный способ сравнения, по крайней мере, наивным.

Человек действует не так. Мы не может позволить себе писать программу несколько раз и выбирать лучший вариант (впрочем, подобный процесс происходит на более высоком уровне: разные команды пишут схожие продукты, среди которых практика использование отбирает лучшие.).
типы языка Хаскель основываются на логике, по-моему, на классическом её варианте (могу быть неправ, но в прилагательном;). Выражая типы компонент и пробуя прикинуть приблизительную их реализацию Хаскеле мы проверяем наш дизайн с помощью системы автоматического доказательства теорем. Это помогает отсевать те варианты дизайна, что не могут быть логически стройно сформулированы.
Слава роботам! Или не слава... Любое доказательство теорем основывается на других теоремах и аксиомах. Если нижележащие теоремы/аксиомы неверны, то любое основанное на них доказательство таковым не является и вообще ничего не значит, т.е. не имеет никакой ценности. При разработке программного обеспечения, в большинстве случаев (за исключением некоторых, достаточно редких в повседневной практике областей) мы не обладаем полнотой информации о создаваемой системе, а значит просто не можем физически положить в основу корректный набор аксиом (и теорем).

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

Цели

Я считаю, что разработка дизайна преследует следующие цели (необходимость удовлетворения требованиям задачи я считаю само-самой разумеющимся и отдельного рассмотрения не требующим, в конце концов - это цель всей программы, а не одного дизайна):
  • Уменьшение сложности
  • Устранение дублирования кода
  • Повышение гибкости, готовность к изменениям
Кстати, с этих позиций довольно просто обосновать, например, опасность перегрузки операторов в С++, ибо с точки зрения дизайна она только повышает сложность и больше ничего.

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

Можно ещё раз обратить внимание на особенную значимость простоты, поскольку она определённо снижает количество ошибок (в том числе, нетривиальных) и значительно упрощает понимание системы другими людьми.

Инструмент

Основным инструментов для работы над дизайном является декомпозиция. Если говорить в целом, то моя позиция заключается в том, что цель декомпозиции найти "хорошие простые ответы на правильные вопросы", при чём, как обычно "правильные вопросы" это больше половины дела. Применительно к языкам программирования имеет смысл выделять какие средства предоставляет язык для отображения выбранного способа декомпозиции в коде. Для Common Lisp я выделяю следующие возможности, которые определяют дизайн системы:
  • Функции (включая замыкания)
  • Структуры данных (структуры, классы, различные виды списков и т.д.)
  • Generic-фукнции (которые следует рассматривать отдельно и от функций, и от классов)
  • Макросы
  • Динамические переменные
  • Рестарты
  • Пакеты
Это довольно много (по сравнению с большинством других языков) и с одной стороны предоставляет весьма мощный набор инструментов, которые могу облегчить процесс создания качественного дизайна, а с другой такое богатство может завести разработчика в дебри. Кстати, проблема многообразия средств также характерна и для С++, который заслужил благодаря этому довольно дурную славу, но это скорей не проблема языка, а проблема уровня самодисциплины у разработчиков. В Common Lisp же эта проблема выражена ещё сильнее и разработчик должен постоянно контролировать свое желание "заюзать ещё пару крутых фич".

Кстати, насчёт "хороших простых ответов на правильные вопросы" хочу привести однин, как мне кажется, вполне уместный исторический пример: Коперник vs Птолемей. С точки зрения математики, особой разницы между подходами Коперника и Птолемея нет, они оба вполне корректны, мало того, опять же с точки зрения математики последователи Птолемея делали совершенно потрясающую вещь - раскладывали орбиты планет в ряды Фурье и это за 1000 лет до появления математического анализа! Коперник же задал правильный вопрос (что вокруг чего вращается?) и дал на него простой ответ, в результате смог объяснить наблюдаемое движение небесных тел с помощь значительно более простого математического аппарата.

Процесс
Определившись с целями и доступным инструментом можно попробовать разобраться в процессе создания дизайна. Если рассматривать вопросы декомпозиции, то думаю все согласятся, что "правильная декомпозиция с первого раза" для достаточно сложных проектов может является замечательным примером человеческого гения, периодически освещающего наш мир. В большинстве же случаев необходим достаточно длительный поиск подходящего решения, основанный на интуиции, способности предвидеть и понимании создаваемой системы. Ключевым фактором является склонность человека к ошибкам, а также замечательная способность мозга создавать иллюзию того, что мы обладаем полнотой информации. Единственным известным мне действенным способ проверки правильности принятых решений является непосредственное написание кода (который можно реально запустить), во время которого выявляются все не-стыковки и противоречия в дизайне. Одним из, уже можно считать, традиционных и устоявшихся способов проверки дизайна является написание unit-тестов: хотя разработка программы может находиться ещё только в самой ранней стадии, благодаря им мы получаем реальный код, который можно запустить и проверить принятые решения. Другим, ещё более заслуженным способом является создание прототипов. Common Lisp предоставляет ещё одну блестящую возможность, которая может быть эффективно использована для работы над дизайном - REPL. Тут я хочу отдельно отметить, что под REPL я понимаю не только командную строку (которая есть сейчас для многих динамических языков: Python, Ruby, JavaScript и т.п.), но целый комплекс средств, предоставляемых SLIME или IDE коммерческих реализаций, которые значительно упрощают интерактивную разработку (например, простая перекомпиляция отдельной функции или класса непосредственно во время работы над исходным кодом, наглядное раскрытие макросов и т.п.). Очень простая возможность проверки решений буквально подталкивает разработчика к большому количеству экспериментов с исходным кодом, что создаёт все предпосылки для создания качественного дизайна. При этом, весьма важную роль играет динамическая типизация, которая позволяет нарушать общую корректность программы ради проверки какой-либо идеи. В языках со статической проверкой типов внесение изменений ради эксперимента может оказаться болезненным, что препятствует эффективному поиску (обычно люди избегают боли).

В целом, хоть я и считаю, что Common Lisp предоставляет выдающийся набор инструментов для декомпозиции, но полагаю, что наиболее сильная его сторона заключается в предлагаемом процессе разработки (для поддержки которого, правда, нужен соответствующий инструмент, например SLIME).

27 комментариев:

  1. "Любое доказательство теорем основывается на других теоремах и аксиомах. Если нижележащие теоремы/аксиомы неверны, то любое основанное на них доказательство таковым не является и вообще ничего не значит, т.е. не имеет никакой ценности ... мы не обладаем полнотой информации о создаваемой системе, а значит просто не можем физически положить в основу корректный набор аксиом (и теорем)." -- я считаю, что это полностью притянуто за уши. thesz показал как статическая типизация позволяет установить контракт и обеспечивать его выполнение в случае, когда совершенно точно понятно, что требуется от системы. А Вы это обобщили до масштабов абстрактной системы в вакууме.

    Текст, однако, интересный.

    ОтветитьУдалить
  2. @kostix

    Нет, thesz использовал данную аргументацию для обоснования общей идеи, а не конкретного примера. А конкретная система, где всё точно понятно, на мой взгляд вообще никакого интереса не представляет и что там можно вообще обсуждать?.

    ОтветитьУдалить
  3. Кстати да, аксиомы не нуждаются в доказательствах. От них требуется непротиворечивость. И думается, что система типов здорово помогает в обеспечении такой непротиворечивости. Например, чтобы Int внезапно не стал Double или чтобы неожиданно не вылез null.

    ОтветитьУдалить
  4. > аксиомы не нуждаются в доказательствах.

    Разве я что-то говорил про доказательство аксиом?

    > И думается, что система типов здорово помогает
    > в обеспечении такой непротиворечивости

    Непротиворечивости чего? Аксиом???

    > чтобы Int внезапно не стал Double или
    > чтобы неожиданно не вылез nul

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

    ОтветитьУдалить
  5. Так все языки с типами направлены на проекцию возможного в предметной области в систему типов, а операций над объектами в предметной области - в код. Если система типов не может ограничить то, что есть в предметной области или может, но очень громоздко - имеем содом и гоморру. А если может - имеем возможность писать код практически в терминах предметной области с гарантированным соблюдением правильностей и вкусностями.

    Ну и еще одно: передавая переменную в нетипизированном языке вы передаете только переменную. А в типизированном вы ещё фиксируете тип переменной, т. е. передаете в функцию больше информации на аргумент. Это удобно.

    ОтветитьУдалить
  6. @permea-kra

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

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

    ОтветитьУдалить
  7. Возникает ощущение, что "работа идёт на низком уровне". Когда я пишу на сях, обычно доходит до готового кода, который компилируется, запускается - и тут выясняется "так делать нельзя!". Ну или не выясняется. На более высокоуровневых языках ощущение "да тут фигня какая-то" возникает обычно задолго до того, как код становится пригоден для компилятора. То есть, вылизывание дизайна и подбор правильных вариантов стартует раньше.

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

    ОтветитьУдалить
  8. @nealar
    Если я правильно понял, то ты используешь способ проектирования "сверху вниз", от которого я уже давно отказался.

    > Рекомендую почитать про полноту и корректность
    > и чем одно отличается от другого.

    Спасибо, я изучал логику первого порядка, так что думаю, вполне ориентируюсь в теме. Так что ты хотел сказать то?

    ОтветитьУдалить
  9. 1. мы не обладаем полнотой информации о создаваемой системе
    2. не можем физически положить в основу корректный набор аксиом (и теорем)

    (1 => 2) неверно

    Если я правильно понял, то ты используешь способ проектирования "сверху вниз"
    1. Из чего делается такой интересный вывод?
    2. Почему этот способ проектирования по-разному работает на разных языках?

    ОтветитьУдалить
  10. Апплодирую стоя :) И, как говорится, ППКС или почти КС.
    Не совсем согласен с тем, что минимизация дублирования кода выносится на один уровень c простотой и расширяемостью. В то же время, могу предложить еще и другие цели дизайна: например, увеличение robustness системы, о чем хорошо написано здесь: http://groups.csail.mit.edu/mac/users/gjs/6.945/readings/robust-systems.pdf
    Также могут быть еще и специфические цели дизайна, связанные с особенностями той или иной предметной области: безусловно, есть такие области, где одной из таких целей есть минимизация количества ошибок...

    ОтветитьУдалить
  11. ... тем не менее, этот заочный обмен мнениями похож на разговор слепого с глухим: ну не объяснишь ты математику, что его подход не очень применим в тех сферах, где имеешь дело с людьми и их поведением, а он тебе, разумеется, не докажет красоту и правду своих теорий

    ОтветитьУдалить
  12. @near
    > (1 => 2) неверно

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

    Такой фантасмагорический пример: скажем мы не знаем, можно ли провести через точку только одну прямую, параллельную данной, или их может быть несколько. Положив в основу "гибкое" предположение, что их может быть несколько, придём к геометрии Лобачевского, а нам на самом деле была нужна Евклида.

    ОтветитьУдалить
  13. @Vsevolod

    Ну, мне казалось, что программисты пишут полезные программы, а не доказывают теоремы ;)

    ОтветитьУдалить
  14. > Из чего делается такой интересный вывод?

    Из фразы "обычно доходит до готового кода".

    > Почему этот способ проектирования по-разному
    > работает на разных языках?

    Какой? И кто говорил, что он по разному работает на разных языках?

    Мне почему-то тяжело отслеживать логическую цепочку в ваших словах.

    ОтветитьУдалить
  15. @archimag

    Так поиск решения отталкивается от условий. Чем больше условий (при гарантии их непротиворечивости) - тем проще искать и тем точнее решение.

    ОтветитьУдалить
  16. @permea-kra

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

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

    ОтветитьУдалить
  18. > каждый конкретный модуль должен решать вполне
    > конкретную задачу

    И что, модули надо будет выбрасывать, если окажется, что они делали не то?

    Вот конкретная ситуация, в условиях которой работал я. Крупная российская розничная сеть решила открыть сеть гипермакетов. Первый гипер должен был открыться через год. Задача IT-отдела написать софт для автоматизации техпроцессов. Раньше компания с магазинами такого формата не работала. Соответственно, разработка софта для автоматизации техпроцессов началась параллельно с разработкой самих этих техпроцессов. Тех процессы менялись в течении всего года и влияли на это "высшие силы" (топ-менеджмент). Соответственно, софт нужно было постоянно адаптировать. Когда открылся первый гипер оказалось, что большая часть техпроцессов с изъянами и их начали переделывать. Софт в экстренном порядке надо менять, при том, что он уже реально работает.

    Со мной было ещё веселей, меня наняли за 2 месяца до открытия первого гипера и возложила ответственность за разработку модуля, который вообще изначально не был предусмотрен, и для которого вообще не было никаких техпроцессов. Что мы именно делаем мне стало более-менее понятно только через полгода разработки, когда уже было запущено несколько гиперов. И мы не могли взять паузу, выкинуть старые глупости и делать с начала, потому что надо было резко наращивать функционал - после нескольких пробных гиперов, они должны были открываться пачками.

    Это не какая-то надуманная ситуация, а совершенно обычные условия работы.

    ОтветитьУдалить
  19. >И что, модули надо будет выбрасывать, если окажется, что они делали не то?

    А как иначе-то? Если вы ошиблись в постановке задачи, её в любом случае надо перерешивать.

    ОтветитьУдалить
  20. > А как иначе-то?

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

    ОтветитьУдалить
  21. @archimag, да нет, выкинуть можно практически без зазрений совести и затрат если система грамотно побита на мелкие модули. Чем мельче модуль, тем проще его выкинуть. Как показывает практика, существует даже некий предел размера, при достижении которого модуль всегда проще выкинуть, чем переделывать.
    И к чести статической типизации — в ситуации грамотно побитой на модули системы при замене одного другим у программиста меньше шансов накосячить с интерфейсами. Хотя именно эта ситуация прекрасно покрывается тестами и контрактами даже в динамически типизированных языках.
    Вообще я прочитал обсуждение и рекомендую обратить внимание на Smalltalk, я думаю вам понравится.

    ОтветитьУдалить
  22. @sdfgh153
    Вот тут уже начало попахивать сферическими конями. Впрочем, можно ссылка на литературу, где описана практика выбрасывания кода как нормальная? А то очень плохо понимаю, о чём конкретно речь.

    И зачем мне Smalltalk, если у меня есть Common Lisp?

    ОтветитьУдалить
  23. Любая книга по рефакторингу, выбрасывание кода и написание его заново — тоже рефакторинг, хе хе. Вы же не пугаетесь, когда вам нужно переписать функцию и сначала удаляете ее код и начинаете писать заново. Чем же вам так претит выбросить класс или пэкэдж? Если они приемлимого размера (чему нас учит Smalltalk), то это не сложнее чем переписать функцию или метод.

    >И зачем мне Smalltalk, если у меня есть Common Lisp?
    Ну хотя бы для того, что бы выбирать the right tool, у меня вот есть Smalltalk, ObjC/C, Common Lisp, Scheme, самописный лисп и еще Haskell, а у вас только CL. Или вы правда думаете, что CL это и есть серебряная пуля? Вы в этом плане очень с Сергеем похожи.

    ОтветитьУдалить
  24. > выбрасывание кода и написание его заново — тоже рефакторинг

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

    > что бы выбирать the right tool

    Мои основные инструменты, кроме Common Lisp, это: C++, Python, JavaScript и XSLT. Я говорю об языках, по которым действительно имею хорошую практику. Для реального освоения языка необходимо от 3-ёх лет плотного ипользования. Приходиться уж что-то выбирать, ибо поверхностное знакомство никакого реального профита не даёт.

    ОтветитьУдалить
  25. >Адаптировать, переделывать
    А зачем? Типичный модуль в том, что я иногда пишу и ковыряю, имеет размер не более 200 - 400 строк, содержа при этом законченную абстракцию либо часть системы. Его проще написать заново, чем разбираться в коде и адаптировать под задачу.

    ОтветитьУдалить
  26. @permea-kra
    модули не существуют в вакууме, они с чем-то сопрягаются, и как раз важны не модули, а интерфейсы. В более крупных масштабах — еще и протоколы взаимодействия. Адаптировать как раз нужно их. То, что вы говорите — это была сортировка пузырьком, выбросили, поменяли на квиксорт. А то, что говорит archimag, в моем понимании — это то, что была сортировка последовательности, а стала выборка случайного элемента из хеш-таблицы. Т.е. менять надо не только вызываемый код, но и вызывающий. И так на всех уровнях абстракции. Возможно, эксперты, которые много лет занимаются одной и той же областью, в такие ситуации не попадают, но мы, простые смертные, довольно часто в них оказываемся...

    ОтветитьУдалить