Сообщений 7    Оценка 1020        Оценить  
Система Orphus

О дизайне

Автор: Тепляков Сергей Владимирович
Опубликовано: 27.10.2015
Исправлено: 10.12.2016
Версия текста: 1.1

Контекст важен
Ну, ты же говорил?!!
Хороший дизайн
Практические примеры
Эффективность и сопровождаемость
Безопасность и эффективность
Простота vs универсальность
Библиотеки и удобство использования
Заключение

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

Отношение к этапу проектирования (дизайна) может быть самым разным, начиная от подхода, принятого на ранних этапах развития методологии XP, когда считалось, что дизайн и архитектура – это динозавры, которым нет места в динамично развивающемся мире agile разработки. Многие и сейчас не задумываются о дизайне решения, считая, что итеративный процесс разработки + рефакторинг сделают все за нас и хороший дизайн появится сам собой.

Есть и другая крайность, когда команда можем потратить недели в поисках идеального решения (Святого Грааля архитектора), когда дизайн будет способен «расширяться» во всех возможных направлениях, и быть настолько «гибким», что реализовать его не будет никакой возможности.

За каждым из этих крайностей любопытно наблюдать, но только если они происходят не у тебя в команде. В большинстве же случаев разумное отношение к дизайну находится где-то посередине, когда этапы дизайна и разработки тесно связаны между собой и итеративно следуют один за другим практически непрерывно. При этом каждый раз, когда разработчик сталкивается с принятием какого-либо решения, то он старается найти компромисс среди бесконечного множества требований, которые на него давят: использовать более эффективное решение или более расширяемое; что важнее, согласованность или наличие ломающих изменений; как быть, нарушить SRP (Single Responsibility Principle) или сделать модуль более удобным в использовании; стоит ли пожертвовать сопровождаемостью кода ради эффективности и т.п.

В результате к большинству разработчиков приходят понимание, что…

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

ПРИМЕЧАНИЕ

Это определение дизайна является вольным переводом (с некоторым дополнением) мысли Эрика Липперта, которую он выразил в одном из своих постов: Design is the art of compromising amongst various incompatible design goals.

Контекст важен

Меня несколько напрягает категоричность многих авторов книг/статей или просто коллег, которые выражают свое мнение в абсолютной форме: никогда не пользуйтесь синглтонами, покрытие тестами должно быть 100%, открытые поля – всемирное зло. Проблема таких высказываний в том, что они вполне корректны в большинстве случаев, но это не значит, что этим советам следует слепо верить не задумываясь.

Да, в большинстве случаев открытые поля – это и правда опасная практика, но кто мешает их использовать в структурах (значимых типах .NET) для взаимодействия с существующими системами? Да, юнит-тесты – это отличная штука, но это не значит, что без стопроцентного покрытия тестами ваш проект провалится. Именно по этой причине, когда опытному программисту задается вопрос «Что лучше?», то «мудрый перец» не станет отвечать на него «в лоб», а задаст уточняющий вопрос, чтобы понять контекст решаемой задачи. Ведь то, что разумно применять в одном случае (писать Unit-тесты для рабочего кода), может быть совершенно не нужным в другом (а вдруг речь идет об однодневном прототипе?).

По этой же причине мы очень часто осуждаем решения других, и качество этого решения зачастую измеряется количеством WTF в секунду. Но дело в том, что зачастую у нас просто недостаточно информации о том, в каких условиях оно было принято. Вот пример: в языке C# существует сверхсомнительная возможность под названием ковариантность массивов. Это означает, что следующий фрагмент кода является корректным и приведет к ошибке времени выполнения, а не компиляции:

      object[] o = new string[] {"1", "2", "3"};
o[0] = 42;

Причина появления этой возможности связана с тем, что она была с первой версии в языке Java, а при разработке языка C# в конце 90-ых крайне важно было «подсадить» на новый язык существующих программистов. Именно поэтому используется привычный С-подобный синтаксис, который уже был знаком программистам С/С++ и Java, пусть у него и есть свои недостатки. Подобные решения могут казаться сомнительными сейчас, но понимание причин, их принятия (согласованность с другими языками vs. возможность ошибок времени выполнения), дают понять, почему языки или библиотеки реализованы так, а не иначе, и что влияет на их развитие.

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

Ну, ты же говорил?!!

Сколько раз вам говорили: «Эй, ну ты же сам говорил, что так не нужно делать, а сам делаешь!» или «Ну, блин, ты даешь, мы же с тобой все уже обсудили, а теперь говоришь, что нужно по-другому!». Дело все в том, что наши решения меняются с течением времени. Со временем важность одних критериев (эффективность кода) может уменьшаться, а важность других критериев (согласованность архитектуры и простота сопровождения) может возрастать.

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

Хороший дизайн

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

Отличный способ найти проблемы дизайна – это посмотреть на него со стороны самому, или попытаться объяснить его кому-то. Хороший дизайн – это дизайн, который вы сможете объяснить своему коллеге за 10 минут, не жертвуя при этом полнотой или точностью. Если же при объяснении «как это работает» приходится учитывать множество факторов, закапываться во множество деталей, и 15 раз возвращаться к одному и тому же, то с дизайном явно что-то не так. Хороший дизайн зачастую оказывается достаточно простым, с минимальным количеством хитросплетений и минимумом лишних или неочевидных связей. Хороший дизайн, как и хорошая архитектура, борется с неотъемлемой сложностью, а не привносит дополнительную сложность, которой и так с избытком хватает в самой природе решаемой задачи.

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

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

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

Практические примеры

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

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

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

Но хватит философствовать, давайте рассмотрим несколько более или менее конкретных примеров.

Эффективность и сопровождаемость

Думаю, что одним из самых распространенных компромиссов является компромисс между эффективностью (производительностью) решения и его сопровождаемостью.

Давайте рассмотрим такой пример.

        internal class SomeType
{
    private readonly int _i = 42;
    private readonly string _s = "42";
 
    private readonly double _d;
    public SomeType()
    { }
 
    public SomeType(double d)
    {
        _d = d;
    }
}

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

        public SomeType()
{
    _i = 42;
    _s = "42";
    // System.Object::ctor();
}
 
public SomeType(double d)
{
    _i = 42;
    _s = "42";
    // System.Object::ctor();
    _d = d;
}

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

Джеффри Рихтер в своей замечательной книге “CLR via C#” дает следующий совет: поскольку использование инициализации полей при объявлении может приводить к распуханию кода, то стоит подумать о выделении отдельного конструктора, выполняющего всю базовую инициализацию, и явно вызывать его из других конструкторов.

Очевидно, что здесь мы сталкиваемся с классическим компромиссом читаемости и сопровождаемости против эффективности. В целом, совет Рихтера вполне обоснован, просто не нужно слепо ему следовать. При решении подобной дилеммы мы должны четко понимать, стоит ли то снижение читаемости (ведь придется искать нужный конструктор каждый раз, чтобы посмотреть на значения по умолчанию) и сопровождаемости (что, если кто-то добавит новый конструктор и забудет вызвать конструктор по умолчанию) того увеличения производительности, которое мы получим? В большинстве случаев ответ будет таким: «Нет, не стоит!», но если данный класс является библиотечным или просто он инстанцируется миллионы раз, то мой ответ уже не будет столь однозначным.

ПРИМЕЧАНИЕ

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

Безопасность и эффективность

Еще одним распространенным компромиссом при дизайне является выбор между структурой и классом (между значимым типом и ссылочным типом). С одной стороны, структуры до определенного размера (порядка 24 байт на платформе x86) могут существенно повысить производительность за счет отсутствия выделения памяти в управляемой куче. Но с другой стороны, в случае изменяемых значимых типов мы можем получить ряд весьма нетривиальных проблем, поскольку поведение может быть далеко не таким, как предполагают многие разработчики.

ПРИМЕЧАНИЕ

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

Давайте рассмотрим более конкретный пример. При реализации перечислителя коллекции ее автор должен принять решение о том, как этот самый перечислитель реализовывать: в виде класса или в виде структуры. В первом случае мы получаем значительно более безопасное решение (ведь перечислитель – это «мутабельный» тип), а во втором случае – более эффективное.

Так, например, перечислитель класса List<T> является структурой, а это значит, что в следующем фрагменте кода вы получите поведение, которое будет неожиданным для большинства ваших коллег:

        var x = new { Items = new List<int> { 1, 2, 3 }.GetEnumerator() };
while (x.Items.MoveNext())
{
    Console.WriteLine(x.Items.Current);
}

Большая часть разработчиков, которые видят подобное поведение, вполне разумно возмущаются глупостью камрадов из Редмонда, которые явно решили поиздеваться над бедным братом-программистом. Однако все не так просто.

В жизни любой коллекции рано или поздно возникает момент, когда кто-то захочет посмотреть на ее внутреннее содержимое. В некоторых случаях (например, в случае массивов и списков) для этих целей может быть использован индексатор, но в большинстве случаев перебор коллекции осуществляется с помощью цикла foreach (прямо или косвенно). Для большинства из нас одно дополнительное выделение памяти в куче для каждого цикла кажется пустяком, но ведь .NET – среда довольно универсальная, а циклы – это одна из самых распространенных конструкций современных языков программирования. И если все это будет происходить не на четырехядерном процессоре, а на мобильном устройстве, то подобное решение разработчиков BCL уже не будет казаться таким уж бредовым.

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

Простота vs универсальность

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

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

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

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

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

При проектировании классов и методов я пользуюсь следующим правилом: любой модуль, класс или метод должен «выставлять наружу» минимальное количество информации. Это значит, что все классы и методы по умолчанию должны иметь минимально возможную область видимости: классы – внутренние (internal), методы – закрытые (private). Это звучит подобно заявлению известного капитана, но очень уж часто мы выставляем наружу «ну вот еще один метод, хуже-то от этого не станет». Изначальное решение должно быть максимально простым; чем меньше зависимостей у клиентов от реализации, тем проще жить этим клиентам и тем проще нам изменять наши классы. Помните, что инкапсуляция – это не только сокрытие классом или модулем деталей реализации, это еще и оберегание клиентов от ненужных подробностей.

Библиотеки и удобство использования

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

Если полистать замечательную книгу “Framework Design Guidelines”, то уже с первых страниц станет понятно, что приоритеты у разработчика библиотек очень сильно смещаются по сравнению с разработчиком прикладных приложений. Если у разработчика приложений основным критерием является простота, удобство сопровождения кода и уменьшения time-to-market, то разработчику библиотеки приходится думать не столько о себе, сколько о своем главном клиенте: пользователе библиотеки.

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

Если при дизайне приложения мы можем позволить себе ошибиться и изменить десяток даже открытых интерфейсов, то все становится значительно сложнее, когда у нашего класса появляется пара десятков внешних пользователей. У Мартина Фаулера есть замечательная статья, под названием Published vs Public Interfaces, в которой он дает четкое различие между этими двумя понятиями. Стоимость изменений любого «опубликованного» интерфейса резко возрастает, а это значит, что ошибка, допущенная при разработке первых версий библиотеки, может и будет преследовать ее разработчика многие годы (вот отличный пример, описанный недавно Эриком Липпертом “Foolish Consistency is Foolish”). Именно по этой причине Майкрософт не спешит делать публичными сотни, если не тысячи очень полезных классов из .NET Framework, поскольку каждый новый публичный класс существенно повышает стоимость сопровождения.

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

Заключение

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

Другим очень важным критерием при выборе того или иного решения, может служить понимание долгосрочных и краткосрочных выгод (long term vs short term benefits). Одно решение может быть хорошим для решения сегодняшней задачи, но обязательно добавит ряд проблем в будущем. Не забывайте «техническом долге», и о том, что подобные метафоры смогут убедить не только коллег, но и заказчика в важности «долгосрочных перспектив» при принятии того или иного решения.

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


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