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

12 мая 2014, 08:53

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

Как писать код так, чтобы не было мучительно больно во время его отладки

Управление сложностью – это суть компьютерного программирования (Брайан Керниган).

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

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

  • Изучить код
  • Отметить стилистические и структурные соглашения, используемые в базе кода
  • Познакомиться с инфраструктурой проекта и инструментарием

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

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

Почему возникает сложность?

Любой дурак может написать код, который понятен компьютеру. Хорошие программисты пишут код, который понятен человеку (Мартин Фаулер)

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

Излишние вложения

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

public ResponseObject generateResponseObject(String route) {
  ResponseObject responseObject;
  if (route.equals("time")) {
    responseObject = new TimeResponse();
  } else if (route.equals("echo")) {
    responseObject = new EchoResponse();
  } else if (route.equals("redirect")) {
    responseObject = new RedirectResponse();
  } else if (route.equals("file")) {
    responseObject = new FileResponse();
  } else {
    responseObject = new FormResponse();
  }
  return responseObject;
}

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

Вот один из потенциальных способов избежать этой сложности.

public ResponseObject generateResponseObject(HashMap<String, ResponseObject> routes) {
  ResponseObject responseObject;
  String route = httpRequestContent.get("route");
  responseObject = routes.get(route);
  return responseObject;
}

Мы разобрали запрос и сохранили его в хеш-таблице. Теперь для доступа к маршруту мы просто получаем значение для ключа "route". Зная этот маршрут, мы сможем просмотреть конкретный подкласс ResponseObject из хеш-таблицы, где ключами будут, например, "time", "echo" и "redirect", а значениями – соответствующие объекты, полученные при отклике. Случайно возникшая сложность устранена, код более понятен и удобен для расширения.

Сильное связывание

Допустим, команда также работает с сервисом серверной части, написанном на языке Ruby. Вы знакомитесь с проектом и находите класс с длинным списком операторов require. Это характерный признак сильной связанности базы кода, а сильная связанность также чревата некоторой излишней сложностью. Возможно, вам будет трудно сориентироваться в запутанной сети зависимостей и сторонних библиотек. Это существенно скажется на сложности всей программы. Иногда контроль над управлением программы действительно настолько нетривиален, что упростить его не представляется возможным (сложность диктуется самой предметной областью, в которой мы работаем). Но порой мы сами чрезмерно усложняем код, поскольку нечетко (или неверно) представляем архитектуру нашей программы. 

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

При включении в код множества файлов, каждый из которых, в свою очередь, содержит множество файлов, обычно возникают баги (многие из которых сложно отслеживать). Любой, кому придется позже работать с этим кодом, будет путаться, особенно если местоположение конкретных функций явно не указано. В статье Modules called, they want their integrity back Джош Чик описывает некоторые подводные камни, связанные с непродуманным включением модулей, реализующих ненужный или неожиданный функционал.

Неверное форматирование

Существуют антипаттерны форматирования, связанные с расстановкой отступов, пробелов, именованиями и комментированием, из-за которых восприятие кода излишне усложняется. Особенно следует отметить важность правильного именования, поскольку грамотно составленные названия методов значительно облегчают жизнь разработчику. Аналогично, важно учитывать количество и имена параметров. Хотя, подобные ошибки усложняют код не так сильно, как излишне глубокие вложения, их не стоит сбрасывать со счетов. Рассмотрим следующий пример:

(defn resume-game []
  (let [file-name (get-file)
          game-type     (:game-type file-name)]
    (if (= 1 game-type)
      (display "We've loaded your game. It's the computer's turn.")
    (display "We've loaded your game. It's the human's turn."))))

Это простая функция, но чтобы ее понять, требуется приложить некоторые усилия. Дело в несогласованном использовании отступов, пробелов и имен. Давайте подробнее рассмотрим ошибки, допущенные в это функции. Отступы в специальной форме let отличаются на каждой из трех строк. Такая же проблема с отступами наблюдается и в специальной форме if. Поскольку в Clojure подразумевается else, важно правильно выравнивать код, чтобы обеспечить его удобочитаемость. Кроме того, отметим ненужный пробел после связки game-type. Наконец, имена тоже очень странные. Может показаться, что file-name соответствует содержимому, а не имени файла. Значение game-type выражается в целых числах; соответственно, речь идет не о типе игры, а о числе, ассоциируемом с типом игры.   

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

Параметры сложности кода

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

Сложность организации циклов в программе

Наиболее распространенный параметр для анализа сложности кода называется «Cyclomatic Metric» (уровень сложности циклов в программе). Он измеряет структурную сложность, определяемую в результате анализа логики управляющих конструкций (в сущности – количество путей в программе). Этот параметр был предложен Томасом Маккейдом, рекомендовавшим разработчикам измерять сложность кода, и если ее коэффициент превышает 10 – разбивать код на более мелкие модули. Данный параметр, конечно же, не гарантирует точной характеристики сложности софтверной системы, но он хорошо помогает при оценке покрытия кода тестами.

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

Параметры сложности Голстеда

В 1977 году Морис Голстед предложил учитывать ряд оценочных атрибутов, в частности, длину (length), словарь (vocabulary), объем (volume), трудоемкость (difficulty) и количество усилий (effort), затраченных на написание кода. В основном, эти параметры основаны на оценке операторов (изменений данных) и операндов (изменяемых данных) в коде.

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

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

Принцип абсолютного приоритета

Мика Мартин в одноименной статье предложил «условие абсолютного приоритета» (Absolute Priority Premise) как средство оценивания кода для объективного суждения о нем. Поскольку мы привыкли расценивать код в зависимости от его статических и динамических качеств – а такие оценки по определению являются субъективными – Мика попытался исключить из этой системы критериев динамический аспект. В качестве отправной точки он взял «условие очередности преобразований» (Transformation Priority Premise). В презентации Мики меня особенно заинтересовал тезис о том, что разработка через тестирование (TDD) может давать далеко не идеальные алгоритмы в случаях, когда решение достигается окольным путем. Подобный «далеко не идеальный» путь возникает именно из-за упомянутой выше случайной сложности. Не всегда удается с уверенностью определить, на каком этапе зародилась такая сложность, и как от нее избавиться. Потенциальная польза условия абсолютного приоритета (APP) заключается в том, что он позволяет присваивать различным операциям балльные значения. При этом, учитываются сущности шести категорий: константы, связки, вызовы методов, условные операторы, циклы и операторы присваивания. Каждый из этих компонентов добавляет баллы к общей сумме. Так, константа оценивается в 1 балл, а цикл while – в 5 баллов. В сумме получаем общую «массу» кода, по которой мы можем судить об общей сложности кода. Теоретически, малая сумма должна свидетельствовать о том, что данное решение является сравнительно простым, прямолинейным и не содержит лишних ненужных наворотов.    

Где и как можно снижать случайную сложность?

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

Более двадцати лет назад Дейкстра написал статью «О суровости настоящего преподавания информатики», в которой сделал важное замечание, не потерявшее актуальности и сегодня:

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

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

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

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

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

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

подписка на главные новости 
недели != спам
# ит-новости
# анонсы событий
# вакансии
Обсуждение