Как избежать когнитивной перегрузки: способы оптимизации кода для разработчиков

в 13:32, , рубрики: cognitive complexity, когнитивная нагрузка, програмирование, сложность разработки
Как избежать когнитивной перегрузки: способы оптимизации кода для разработчиков - 1

По мнению Артема Закируллина*, одна из фундаментальных проблем, с которой сталкиваются разработчики при анализе кода – высокая когнитивная нагрузка. Это не абстрактное, а реальное ограничение возможностей, которое стоит времени и денег. На чтение и понимание кода, тратится больше времени, чем на его написание. Поэтому, разработчику нужно постоянно задаваться вопросом: не пишет ли он код, чтение которого создает чрезмерную когнитивную нагрузку?

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

*Обращаем ваше внимание, что позиция автора может не всегда совпадать с мнением МойОфис.


Что такое когнитивная нагрузка

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

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

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

Изображение было переведено*. Лицензия

Изображение было переведено*. Лицензия

Сложность в том, что предыдущий автор мог не ощущать этой нагрузки из-за своего знакомства с проектом.

Знакомо или просто?

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

Вероятно, автор (в одиночку или с кем-то еще) пришел к этому невероятному хаосу постепенно, а не в один момент. Но именно вам приходится быть первопроходцем и разбираться во всем этом сразу.

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

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

Благодарю Дэна Норта за его комментарий выше.

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

Типы когнитивной нагрузки

Внутренняя — вызвана изначальной сложностью задачи. Ее нельзя уменьшить, поскольку она является самой сутью разработки ПО.

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

Как избежать когнитивной перегрузки: способы оптимизации кода для разработчиков - 3

Давайте рассмотрим конкретные примеры внешней когнитивной нагрузки.

P.S.: Обратная связь приветствуется!


Уровень нагрузки будем обозначать так:

🤯: свежая рабочая память, отсутствие когнитивной нагрузки

🧠++: два факта в нашей рабочей памяти, повышенная нагрузка

🤯: переполнение рабочей памяти, более четырех фактов

Сложные условные операторы

if val > someConstant // 🧠+
    && (condition2 || condition3) // 🧠+++, предыдущее условие должно быть верным, одно из условий c2 или c3 должно выполняться
    && (condition4 && !condition5) { // 🤯, всё, потеряли нить
    ...
}

Вводите промежуточные переменные с понятными именами:

isValid = var > someConstant
isAllowed = condition2 || condition3
isSecure = condition4 && !condition5 
// 🧠 , нам не нужно запоминать условия, благодаря описательным переменным
if isValid && isAllowed && isSecure {
    ...
}

Вложенные конструкции if

if isValid { // 🧠+, ладно, вложенный код применим только к валидному вводу
    if isSecure { // 🧠++, выполняем действия только для валидного и безопасного ввода
        // 🧠+++
    }
} 

Сравните это с предыдущими returns:

if !isValid
    return
 
if !isSecure
    return

// 🧠, на самом деле нас не волнуют предыдущие return, если мы здесь, то все хорошо

// 🧠+

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

Кошмар наследования

Нам нужно кое-что поменять для администраторов: 🧠

AdminController наследуется от UserController, который наследуется от GuestController, который наследуется от BaseController

Ого, часть функциональности находится в BaseController, смотрим: 🧠+

В GuestController внедрена базовая механика ролей: 🧠++

Кое-что частично изменено в UserController: 🧠+++

Вот мы и добрались, AdminController, пишем код! 🧠++++

Подождите, есть еще SuperuserController который наследуется от AdminController. Изменяя AdminController, мы можем сломать что-нибудь в наследуемом классе, поэтому сначала посмотрим SuperuserController: 🤯

Предпочитайте композицию наследованию. Не будем углубляться в детали — информации и так предостаточно.

Слишком много мелких методов, классов или модулей

Метод, класс и модуль в этом контексте взаимозаменяемы.

Оказалось, что такие мантры, как «методы должны быть короче 15 строк кода» или «классы должны быть маленькими», не совсем верны.

Глубокий модуль: простой интерфейс, сложная функциональность

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

Как избежать когнитивной перегрузки: способы оптимизации кода для разработчиков - 4

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

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

У меня есть два любимых проекта, в каждом — примерно по пять тысяч строк кода. В первом проекте 80 мелких классов, тогда как во втором — всего семь глубоких классов. Я не занимался поддержкой этих проектов уже полтора года.

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

Лучшие компоненты — это те, которые обеспечивают мощную функциональность при простом интерфейсе.

Джон К. Оустерхаут

Интерфейс ввода-вывода UNIX очень простой. В нем всего пять основных вызовов:

open(path, flags, permissions)
read(fd, buffer, count)
write(fd, buffer, count)
lseek(fd, offset, referencePosition)
close(fd)

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

Этот пример глубокого модуля взят из книги Джона К. Оустерхаута «Философия разработки программного обеспечения». Книга не только раскрывает суть сложности разработки программного обеспечения, но и предлагает лучшую интерпретацию значимой работы Парнаса «О критериях для декомпозиции систем на модули». Обе книги стоит прочитать. Что еще почитать: «Вероятно, хватит рекомендовать "Чистый код"», «Вредные маленькие функции», «Линейный код более читаем».

Не подумайте, что мы выступаем за раздутые объекты-монстры с огромной функциональностью.

Слишком много неглубоких микросервисов

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

Однажды я консультировал стартап, где команда из трех разработчиков внедрила 17(!) микросервисов. Они отставали от графика на десять месяцев и, похоже, были далеки от публичного релиза. Каждое новое требование приводило к изменениям более чем в четырех микросервисах. Сложность диагностики в интеграции резко возросла. И время выхода на рынок, и когнитивная нагрузка были неприемлемо высокими. 🤯

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

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

Языки программирования с обилием функций

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

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

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

Эти слова принадлежат Робу Пайку.

Снижайте когнитивную нагрузку, ограничивая количество вариантов.

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

Мысли инженера с 20+ летним опытом работы с C++ ⭐️

На днях я просматривал свою RSS-ленту и заметил, что у меня скопилось около трехсот непрочитанных статей по тегу «C++». Я не читал ни одной статьи об этом языке с прошлого лета, и прекрасно себя чувствую!

Уже 20 лет я программирую на C++, это почти две трети моей жизни. Большая часть моего опыта связана с самыми темными закоулками этого языка (такими как неопределенное поведение всех видов). Этот опыт сложно применить где-то еще, но и отбрасывать его полностью не хочется.

Представьте себе, например, что токен || имеет разное значение в requires ((!P<T> || !Q<T>)) и в requires (!(P<T> || Q<T>)). В первом случае это дизъюнкция ограничений, во втором — старый добрый логический оператор ИЛИ, и они ведут себя по-разному.

Раньше в C++ нельзя было просто выделить память под тривиальный тип и скопировать туда набор байтов с помощью memcpy  — это не инициализировало объект. И так было до появления C++20, где это исправили, но когнитивная нагрузка языка только выросла.

Нагрузка растет постоянно, даже несмотря на исправления. Я должен знать, что было исправлено, когда и каким образом это работало раньше. В конце концов, я профессионал. Конечно, C++ хорошо поддерживает устаревший код, а это значит, что вам с ним придется столкнуться. Например, в прошлом месяце коллега спрашивал меня об определенном аспекте работы C++03. 🤯

Существовало 20 способов инициализации. В обновлённой версии добавлен синтаксис единообразной инициализации. Теперь у нас есть 21 способ. Кстати, кто-нибудь помнит правила выбора конструкторов из списка инициализации? Что-то про неявное преобразование с наименьшей потерей информации, но если значение известно статически, тогда... 🤯

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

Мне пришлось придумать правила. Например, если строка кода не очевидна, и мне нужно помнить стандарт, лучше ее так не писать. Кстати, стандарт насчитывает около 1500 страниц.

Я ни в коем случае не хочу обвинять C++. Я люблю этот язык.

Бизнес-логика и коды состояния HTTP

На бэкенде мы возвращаем:

401 для истекшего JWT-токена

403 для недостатка прав доступа

418 для заблокированных пользователей

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

401 истекший JWT-токен // 🧠+, ладно, временно запомнить

403 для недостатка прав доступа // 🧠++

418 для заблокированных пользователей // 🧠+++

Фронтенд-разработчики (хотелось бы надеяться) ввели бы переменные/функции вроде isTokenExpired(status), чтобы последующим поколениям разработчиков не приходилось заново создавать в голове это сопоставление статус -> значение.

Затем в игру вступают специалисты по QA-тестированию: «Я получил статус 403. Это истекший токен или у меня недостаточно прав»? Сотрудники QA не могут сразу перейти к тестированию, потому что сначала им нужно воссоздать когнитивную нагрузку, которую когда-то создали люди на бэкенде.

Зачем держать это пользовательское сопоставление в рабочей памяти? Лучше абстрагировать детали бизнес-логики от протокола передачи HTTP и возвращать самоописывающие коды непосредственно в теле ответа:

{
    "code": "jwt_has_expired"
}

Когнитивная нагрузка со стороны фронтенда: 🧠 (свежая, факты не удерживаются в памяти)

Когнитивная нагрузка со стороны QA: 🧠

Это правило применимо ко всем видам числовых статусов (в базе данных или где-либо еще) — отдавайте предпочтение самоописывающим строкам. Мы уже не живем во времена компьютеров с 640 КБ памяти, чтобы оптимизировать хранилище.

Люди тратят время на споры между статусами 401 и 403, делая выбор, исходя из своего уровня понимания. Но в итоге это абсолютно бессмысленно. Мы можем разделить ошибки на связанные с пользователем или с сервером, но в остальном все довольно туманно. Что касается следования таинственному RESTful API и использования всевозможных HTTP-методов и статусов, то стандарта попросту не существует. Единственный достоверный документ по этому вопросу — статья Роя Филдинга, опубликованная еще в 2000 году, и в ней ничего не говорится о методах и статусах. Люди прекрасно справляются, используя несколько базовых HTTP-статусов и только методом POST.

Злоупотребление принципом DRY (Don't Repeat Yourself)

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

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

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

Роб Пайк однажды сказал:

«Небольшое копирование лучше, чем небольшая зависимость».

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

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

Тесная интеграция с фреймворком

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

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

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

Мы ни в коем случае не призываем изобретать все с нуля!

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

Гексагональная/Луковая архитектура

Вокруг всей этой темы существует определенный инженерный ажиотаж.

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

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

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

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

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

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

  • принцип инверсии зависимостей; 

  • изоляция; 

  • единый источник истины; 

  • истинная инвариантность; 

  • сложность; 

  • когнитивная нагрузка и скрытие информации.

DDD

У доменно-ориентированного проектирования множество преимуществ, хотя часто его трактуют неправильно. Люди говорят: «Мы пишем код по DDD», что странно, потому что DDD фокусируется на пространстве проблем, а не решений.

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

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

Учимся у ИТ-гигантов

Вот основные принципы проектирования одной из крупнейших технологических компаний:

  • ясность: цель и обоснование кода понятны читающему.

  • простота: код достигает своей цели наиболее простым способом.

  • лаконичность: в коде легко различить важные детали, а именование и структура помогают читающему в них ориентироваться.

  • поддерживаемость: следующему программисту должно быть легко правильно изменять код.

  • Согласованность: код согласован с остальной кодовой базой.

Соответствует ли новый модный термин этим принципам? Или это лишь создает излишнюю когнитивную нагрузку?

Вот забавная картинка

Знакомо или просто? Автор: @flaviocopes

Знакомо или просто? Автор: @flaviocopes

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

Брайан Керниган

Заключение

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

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

Как избежать когнитивной перегрузки: способы оптимизации кода для разработчиков - 6

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

*"UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS, IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION, WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR     PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS, ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT  ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU".

Автор: МойОфис

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js