суббота, 28 июля 2012 г.

О дизайне. Часть 2. Практические примеры

Как мы обсудили в прошлый раз, дизайн штука не простая; постоянно приходится держать в голове кучу всяких вариантов и стараться найти компромисс среди множества разных требований, раздирающих ваше элегантное решение на части. С одной стороны, хочется, чтобы решение было простым в сопровождении, хорошо расширяемым, с высокой производительностью, при этом оно должно быть понятным не только его автору, но еще как минимум одному человеку; хочется, чтобы решение ело мало памяти и не нарушало ни одного из 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 комментариев:

  1. Интересный пост!

    Есть пара моментов на которые я бы хотел обратить внимание:

    1) "но если данный класс является библиотечным или просто он инстанцируется миллионы раз, то мой ответ уже не будет столь однозначным."
    Я не понял, а что такого если данный класс будет инстанциироваться миллионы раз? Чем поможет вынос инициализации в дефолтный конструктор?

    2) "И если все это будет происходить не на четырех ядерном процессоре, а на мобильном устройстве, то подобное решение разработчиков BCL уже не будет казаться таким уж бредовым."
    Ты правда думаешь что что мобильные устройства могут быть оправданием такому решению? Честно? Имхо тут явный fail разработчиков.

    ОтветитьУдалить
  2. 1) чисто теоретически любое распухание кода ведет к падению производительности, поскольку весь код может не поместиться в кэш процессора, что приведет к его промахам. У меня есть дикие сомнения по поводу того, что будет какая-то ощутимая разница даже в performance ctirical частях, но тем не менее, она может быть.

    2) Использование структур в качестве енумераторов - "не баг, это фича". Если серьезно, то это абсолютно осознанное решение, сделанное командой BCL после тщательных исследований (вот тут немного подробнее об этом: http://stackoverflow.com/questions/3168311/why-bcl-collections-use-struct-enumerators-not-classes).

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

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

    ОтветитьУдалить
  3. Если двойная инициализация полей влияет на производительность, то с большой долей вероятности можно сказать что в консерватории проблемы и очень большие :)

    ОтветитьУдалить
  4. @Slava: вот аналогичный пример: в С++ в некоторых случаях не рекомендуют использовать исключения только потому, что от этого "пухнет" код и это может повлиять на производительность.

    Это не значит, что если мы будем писать под Win8 в Метро стиле на С++11, то об этом стоит задумываться, но если у вас embedded приложение с 2К памяти, вот тогда это может быть принципиально.

    Точно также и здесь: подобные мысли о распухании кода из-за двойной инициализации должны приходить в голову лишь после профилирования, или при работе со сверх ограниченными ресурсами (типа на Micro Framework-е каком-нибудь). В остальных же 99.99999% случаях об этом можно не беспокоиться, поскольку недостатки такого подхода будут явно перевешивать потенциальные выгоды.

    ОтветитьУдалить
  5. Отличный пост!
    Сергей, появился вопрос - при создании новых классов и их методов лучше идти от минимальной открытости. Т.е. объявлять сначала видимость внутри сборки (internal), а расширять видимость (public) только при необходимости? Другими словами если класс SomeType из примера используется только внутри одной сборки, то есть смысл сделать его конструкторы internal?

    ОтветитьУдалить
    Ответы
    1. Я пользуюсь таким правилом: если тип уже internal, то все его методы public. Нет смысла дублировать информацию. Но если тип public, то тут уже можно часть методов сделать internal, чтобы четко ограничить use case-ы и показать, что часть функционала является внутренней.

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

      Удалить
  6. Этот комментарий был удален автором.

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