понедельник, 27 мая 2013 г.

О комментариях. Часть 2

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

1. Комментарии из серии «ни о чем». Или зачем дублировать код?

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

public void ProcessEmployee(int id)
{
   
// Получаем всех сотрдуников
    IEnumerable<Employee
> employees = GetEmployees();

   
Employee employee = null
;
   
// Перебираем коллекцию всех сотрудников
    foreach (var e in
employees)
    {
       
// Если Id текущего сотрудника совпадает с заданным
        if
(e.Id == id)
        {
           
// Нашли требуемого сотрудника
            employee = e;
           
// Прерываем цикл
            break
;
        }
    }

   
// Генерируем исключение, если сотрудник не найден
    if (employee == null
)
    {
       
throw new InvalidOperationException("Employee with specified Id was not found"
);
    }

   
// Работаем с найденным сотрудником
}

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

2. Комментарий блока кода

Вместо повторения каждого шага, мы можем добавить комментарий, который скажет, что же мы делаем в нижележащем блоке кода.

public void ProcessEmployee(int id)
{
   
// Находим сотрудника с заданным идентификатором, бросаем исключение,
    // если такого сотрудника нет
    IEnumerable<Employee
> employees = GetEmployees();

   
Employee employee = null
;
   
foreach (var e in
employees)
    {
       
if
(e.Id == id)
        {
            employee = e;
           
break
;
        }
    }

   
if (employee == null
)
    {
       
throw new InvalidOperationException("Employee with specified Id was not found"
);
    }

   
// Работаем с найденным сотрудником
}
3. Заменяем комментарий высокоуровневым кодом

Но вместо того, чтобы описывать императивный код, мы можем переписать его в более декларативной форме. В этом случае сам код будет говорить, что он делает, а в комментарие мы можем дать пояснение текущему поведению:

public void ProcessEmployee(int id)
{
   
IEnumerable<Employee
> employees = GetEmployees();
   
   
// Отсутствие сотрудника с заданным Id означает рассогласованное состояние системы;
    var
employee = employees.Single(e => e.Id == id);

   
// Работаем с найденным сотрудником
}
4. Используем контракты вместо комментариев

С самого начала, логика поиска сотрудника требовала отдельного метода. Но помимо простого выделения метода GetEmployeeById мы можем перенести наш комментарий в виде постусловия метода и в качестве упражнения добавить еще и предусловие: 

private Employee GetEmployeeById(int id)
{
   
Contract
.Requires(id > 100,
       
"Идентификаторы меньшие 100 зарезервированы и использоваться не могут."
);
 
   
Contract.Ensures(Contract.Result<Employee>() != null
,
    
"Отсутствие сотрудника с заданным Id означает рассогласованное состояние системы."
);
           
   
IEnumerable<Employee
> employees = GetEmployees();
   
var
employee = employees.SingleOrDefault(e => e.Id == id);

   
if (employee == null
)
       
throw new InvalidOperationException
(
           
string.Format("Employee with Id = '{0}' was not found."
, id));

   
return
employee;
}

public void ProcessEmployee(int
id)
{
   
var
employee = GetEmployeeById(id);

   
// Работаем с найденным сотрудником
}

ПРИМЕЧАНИЕ
В случае использования LINQ можно использовать SingleOrDefault с последующей проверкой результата или использовать метод Single. Разница заключается в том, насколько информативным будет сообщение в генерируемом исключение; в случае SingleOrDefault мы генерируем исключение с более информативным сообщением об ошибке, а в случае использования метода Single, сообщение об ошибке будет более туманным: “Sequence contains no matching elements”. Так что, какой вариант вы будете использовать – решать вам.

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

При этом контракты продолжают играть роль комментариев, поскольку предусловия и постусловия «интегрируются» в сгенерированную xml-документацию в виде отдельных секций <pre> и <post>, что делает их легко доступными из среды разработки.

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

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

  1. Опечатка "// Отсутствие сотркдника".

    Использование .SingleOrDefault() вместо .Single() ради информативного сообщения об ошибке - сомнительная радость, ведь мы всё равно получим нечитаемое исключение от .SingleOrDefault() если подходящих под предикат объектов будет несколько.

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

    Ещё не понятно почему сообщения в контактах локализованы :)

    Спасибо за пост!

    ОтветитьУдалить
  2. > Опечатка
    Спасибо, поправил.

    > Использование .SingleOrDefault() вместо .Single() ради информативного сообщения об ошибке - сомнительная радость, ведь мы всё равно получим нечитаемое исключение от .SingleOrDefault() если подходящих под предикат объектов будет несколько.

    Тут мысль такая. Если рассмотреть хранилище, то наличие двух записей с одним Id (чтобы упал SingleOrDefault по этому поводу) является багом реализации этого хранилища, поскольку инвариантом хранилища может быть отсутствие дубликатов.

    Но вот отсутствие сотрудника с заданным Id можно рассматривать, как системный баг или баг конфигурации.

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

    В целом же, твое предложение очень ОК и вполне можно сделать реюзабл перегрузку Single.

    > Еще не понятно, почему сообщения в контрактах локализованы :)
    Да, рассогласовано вышло:) Просто решил не заморачиваться в примере :)

    > Спасибо за пост!
    Саш, спасибо за коммент!

    ОтветитьУдалить
  3. Хороший пост.
    А что делать с интерфейсами вида GetUserById. Нужно ли их комментировать?

    Лично я считаю что не нужно, но увы команда против этого и даже не знаю что им ответить.

    ОтветитьУдалить
  4. @casino: ИМХО, при наличии контрактов комментарии метода GetUserById будут откровенно излшиними.

    Отвечать коллегам следует примерно следующее: комментарий должен прояснять что-то, что не очевидно из кода. Если коллегам что-то не очевидно, то комментарий нужен; в противном случае - они только будут вредить.

    Можно привести авторитетное мнение: сослаться на Боба Мартина, Стива Макконнелла и Бертрана Мейера, которые придерживаются мнения, что комментарии должны помогать и говорить что-то новое.

    Если и это не поможет, то тогда хз:)

    ОтветитьУдалить
  5. Использование более информативных имен ф-ий ,вместо безликих ProcessAnything, разделение ответственности ф-ии, использование нормальных исключении вместо Invalid*, наличие тестов и коде ревью командой было бы гораздо полезнее добавления комментариев в стиле "Отсутствие ... рассогласование". Тот же исходный код Fitnesse замечательный пример очень грамотно написанного кода.
    В своей практике вижу огромное число комментариев к тысячисточным ф-ям, которые остались,а код давным давно изменился.

    ОтветитьУдалить
  6. @Eduard: этот пример намеренно синтетический. Если интересно покритиковать (и я буду этому рад) реальный код, то можно полистать чего-нибудь на github-е.
    Там далеко не все production quality, но, как минимум, там нет выдуманных примеров.

    А по поводу Invalid* и "рассогласования..", то пока не вижу обоснования, почему это плохо. В данном случае я приводил относительно реальный пример из одной из систем, над которой я работал, когда рассогласование хранилищ являлось проблемой. И тут, как по мне, важно выразить как-то факт, что поведение метода Single не подходит и почему мы используем SingleOrDefault.

    > В своей практике вижу огромное число комментариев к тысячистрочным ф-ям, которые остались, а код давным давно изменился.
    Да я вроде бы и не защищаю комментарии;) Вот контракты, да, защищаю. Но они же являются частью кода, поэтому не устаревают;)

    ОтветитьУдалить
  7. @Sergey Критиковать не буду ибо дело не благодарное даже в рамках командных CodeReview. :)

    По поводу Invalid* исключения то оно мало отличаются от Exception() т.к. не поддается последующей обработке. В данном примере, лично мне не очевидно, зачем вообще ф-я Get бросает исключение если не нашла объект, это вроде как ответственность вызывающей стороны. Например такое поведение делает создание ф-ии IsEmployeeExist менее удачным. А уж если и бросает, то было бы удачнее иметь EmployeeNotFound, чтобы иметь возможность красиво эту ситуацию обработать наверху.

    На счет CodeContract то мне это сильно напоминает assert'ы модные в свое время в C++. Толку с них сильно меньше чем с тестов. Например в примере они явно избыточны т.к. простая ф-я Get почему то начинает заботится о весьма нетривиальных особенностях системы, типа ограничения на идентификатор. Это приведет к необходимости ее править если мы решим например убрать этих служебных юзеров, или же работать и с ним тоже. На мой взгляд это противоречит LSP.

    ОтветитьУдалить
  8. @Eduard: по поводу Invalid*. Все очень сильно зависит, является ли это исключение предусмотренным или нет. Если да, то даже его типа будет достаточно для обработки, хотя EmployeeNotFound будет предпочтительнее. Если же это системный баг, то зачастую мы не сможем от него программно восстановиться и тогда специализированный тип исключения не слишком нужен.

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

    По поводу контрактов: посмотрите вот этот раздел или что-то из Мейера; это совсем не тоже самое, что ассерты. Ну а ограничение на идентификатор - это просто пример, возможно, не совсем удачный, чтобы показать, что вместо комментария гораздо удачнее использовать более строгие языковые конструкции.

    Ну и контракты - точно не конкуренты юнит-тестам. У меня как раз есть заметка "Контакты vs Юнит-тесты".

    ОтветитьУдалить
  9. А если имеем WCF. Какие рекомендации по документированию?

    ОтветитьУдалить
  10. @Евгений: а что вы имеете ввиду?

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

    С точки зрения комментариев в коде пока не видится особых различий...

    ОтветитьУдалить
  11. @Sergey Исключения весьма не простая тема. Увы их слишком часто используют не задумываясь о том как и где они будут ловится. Во многих случаях они не нужны. В данном случае IMHO лучше возврат null и последующие реакции системы (вплоть до падения). Сам по себе вызов Get для несуществующего элемента не выглядит угрожающе и может быть вполне законен.

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

    ОтветитьУдалить
  12. @Eduard: Уважаемые мужи считают наличие null в языках программирования ужаснейжей ошибкой своей жизни (это я про Тони Хоара;)). Проблема с ними в том, что возврат null-а может привести к эффекту бабочки: мы подсунули неверные данные в метод Get, результат его вызова пошел каскадом через несколько уровней и уже там что-то рухнуло с NullReferenceException; отлаживать сие поведение не есть просто.

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

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

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

    ОтветитьУдалить
  13. Вставлю своих 5 копеек. Ты в своей статье написал, что в комменте можно описывать "что" мы делаем и "почему". Мне кажется, "что" - надо убрать. Функция с контрактами - говорит о том - "что" мы хотим. Документировать это - уже будет избыточно. Иначе, надо переписывать функцию. Вот "почему" - это вопрос интересный. Действительно, иногда бывает, что мы что-то знаем о "контексте использования". Правда в этом случае - лучше вынести этот код в отдельный (класс, файл, whatever...) и в заголовке файла накатать отдельный текст - почему мы так делаем и чем руководствуемся. Почему отделить - контекст может измениться и тогда некоторая "заумь" становится просто ненужной и ее легко вырезать.
    Однако, используется это довольно редко(ну может мне так встречается).
    Еще вариант использования комментов - "по требованию". Если ревьюверы - очень просят. Хотя тут тоже надо делить - где-то надо поставить коммент, а где - зажать свое эго и отрефакторить.

    ОтветитьУдалить
  14. Вставлю своих 5 копеек. Ты в своей статье написал, что в комменте можно описывать "что" мы делаем и "почему". Мне кажется, "что" - надо убрать. Функция с контрактами - говорит о том - "что" мы хотим. Документировать это - уже будет избыточно. Иначе, надо переписывать функцию. Вот "почему" - это вопрос интересный. Действительно, иногда бывает, что мы что-то знаем о "контексте использования". Правда в этом случае - лучше вынести этот код в отдельный (класс, файл, whatever...) и в заголовке файла накатать отдельный текст - почему мы так делаем и чем руководствуемся. Почему отделить - контекст может измениться и тогда некоторая "заумь" становится просто ненужной и ее легко вырезать.
    Однако, используется это довольно редко(ну может мне так встречается).
    Еще вариант использования комментов - "по требованию". Если ревьюверы - очень просят. Хотя тут тоже надо делить - где-то надо поставить коммент, а где - зажать свое эго и отрефакторить.

    ОтветитьУдалить
  15. Вскользь прочитал обе статьи и не увидел четкого разделения между комментариями и документированием.
    под коментами я понимаю какие-то пояснения к коду, а под документированием описание интерфейсов типов. По поводу комментариев сказано много, а вот по поводу документирования мало (либо очень быстро прочитал и решил написать, из серии про чукчу:) ). Так вот не писать документацию к коду мне кажется плохо, пусть лучше документирование дублирует метод, нежели его совсем не будет. Т.к. при дальнейшем сопровождении может что-то измениться или расшириться. А документации нет.



    ОтветитьУдалить
  16. @Слава: про документацию в этих статьях нет ни слова и сделано это осознанно.

    Single Responsibility Principle применяется не только при дизайне классов, но и при написани статей, поэтому я бы эти понятия не смешивал. Документирование в более общем контексте - это более общая тема, достойная отдельных статей (как минимум).

    А теперь по теме: документация к коду - тема сверх неоднозначная. Проблема тщательного документирования, которое будет хранится в далеке от кода в том, что она часто устарвает.

    Поэтому вот с этим: "не писать документацию к коду мне кажется плохо, пусть документирование дублирует метод, нежели его совсем не будет". Я в корне не согласен с этим подходом. И проблема тут не в самом дублировании, а в том, что один из дубликатов постоянно устаревает, и как вы думаете, что устареет быстрее - код или его документация?

    Документация не должна дублировать код; аналогично тому, как комментарии должны говорить что-то, что не очевидно из самого кода, документация не должна дублировать то, о чем уже сказано в самом коде и его комментариях.

    Вот, например, вы не задумывались, почему сдохли CASE средства? Да просто потому, что это попытка решить с помощью инструментов задачу, которую нужно решать в коде. Детализированные UML диаграммы не просто бесполезны, они вредны, поскольку моментально устаревают. Диаграммы должны описывать некоторые аспекты системы *на более высоком уровне абстракции* нежели код!

    Аналогично и другие виды документации. Я "за" средней формальности спеки (которые могут быть написаны во время создания продукта), я "за" architecture overview и high level design diagrams, но это все вещи не являются документацией *к коду*, это документация систем, подсистем или модулей. Нам нужны описания ключевых архитектурных решений и ограничений, другими словами всего, что дает необходимый контекст, который затем поможет разобраться в коде.

    Ведь именно поэтому в последние 10 лет стали так популярными различные инструменты, позволяющие генерировать документацию по коду (xml-doc в C#, java-doc в java, плоская форма в Eiffel etc). Все это для того, чтобы следовать тому саму Don't Repeat Yourself принципу, о котором писали Хант и Томас в "Программисте прагматике" и Мейер в своей ООКПС.

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

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