Хабрахабр

[Перевод] Психология читабельности кода

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

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

Психологическое основание

Каждый программист знает, что возможности нашего мозга не безграничны. Есть ограничение на количество вещей, о которых мы можем думать. Это наш рабочий лимит памяти. Есть старый миф о том, что человек может держать в памяти одновременно 7±2 объектов. Это называется "Магическое число семь" и оно на самом деле не очень точное. Последние исследования говорят о числе 4±1, а то и меньше. В любом случае — количество идей, которые мы можем держать одновременно в голове, весьма ограниченно.

Это так: к счастью, есть ещё один процесс, который постоянно происходит в нашей голове — это группировка. Некоторые люди скажут, что способны легко оперировать одновременно более, чем четырьмя объектами в памяти. Вспомните, как вы называете даты или номера телефонов — не по одной цифре, а группами по две, три или четыре. Мы объединяем схожие мелкие сущности в чуть большие и оперируем уже ими. Более того, все цифры вместе формируют «дату» или «номер телефона» — тоже отдельные сущности. При этом каждая группа цифр является самостоятельной сущностью.

image

Я представляю её как большую паутину из таких вот группок и их последовательностей. Из этих групп мы строим нашу долговременную память.

image

И это действительно так. Исходя из данной картинки, вам может показаться, что перемещение из одной части памяти в другую происходит достаточно медленно. Название говорит само за себя — мы можем фокусироваться в каждый момент времени только на чём-то одном. В науке проектирования пользовательских интерфейсов есть концепция «единого фокуса внимания». Более того, кроме фокуса, есть ещё и «локус» — ограниченность внимания в пространстве.

Рабочий лимит памяти говорит о том, сколько всего сущностей мы можем держать в памяти. Вы можете подумать, что это то же самое, что и упомянутый выше рабочий лимит памяти, но есть важное отличие. Фокус и локус внимания говорят о том, что для выполнение какой-то полезной мыслительной работы данные сущности ещё и должны «находиться рядом», быть чем-то связанными.

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

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

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

Когда некоторые сущности кажутся схожыми, они располагаются в мозгу «рядом», являются связанными. Во-первых, ему сложновато работать с абстракциями. Пример: путаница между l и 1, 0 и О. Это приводит к тому, что мозг иногда ошибается какую из них следует извлечь и использовать в каждом конкретном случае. «Ключ» — это мы сейчас о предмете для открывания замков, построении стаи птиц или инструменте для работы с гайками? Ещё один пример — двусмысленность.

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

Это количество ментальных усилий, которые необходимы для решения некоторой задачи. Всё вышеуказанное создаёт когнитивную нагрузку. Если вы не даёте своей голове осознанного отдыха — природу вы всё-равно не обманете и через какое-то время ваш мозг начнёт «витать в облаках». Наша «операционная мощность» падает по ходу работы и возрастает после отдыха.

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

Именование сущностей

Давайте взглянем на простенький цикл for:

  • A. for(i=0 to N)
  • B. for(theElementIndex=0 to theNumberOfElementsInTheList)

Какой вариант нравится вам больше? Большинство программистов порекомендуют вариант А. Почему? А потому, что вариант B использует слишком длинные имена переменных, что мешает нам с одного взгляда увидеть единый (и хорошо знакомый) паттерн. Кроме того, в данном случае столь длинные имена и не помогают создать более качественный контекст, они просто добавляют шум.

Теперь давайте посмотрим на различные способы формирования пространств имён (это могут быть пакеты, модули или что-то ещё в вашем языке программирования):

  • A. strings.IndexOf(x, y)
  • B. s.IndexOf(x, y)
  • C. std.utils.strings.IndexOf(x, y)
  • D. IndexOf(x, y)

Вариант В плох, поскольку «s» — слишком короткое название и не помогает нам понять, что «это, наверное, строка».

Вариант С плох, поскольку std.utils.strings — слишком длинное название, мы и так поняли, что это строка, не нужно каждый раз напоминать о том, где она находится.
Вариант D плох, поскольку без пространств имён мы вообще не очень хорошо понимаем, что за функцию вызываем, откуда она и над какими объектами будет работать.

В таком случае, даже упоминание пространства имён «strings» будет излишним, как, например, операция сложения на целых числах более понятна в виде a + b, а не в виде int16. Важно заметить, что если уж речь в коде зашла о строках, то логичным будет предположить, что вызов IndexOf для строки выполняет какую-то работу именно на строке. Add(a, b).

Состояние переменной

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

// A.
func foo() (int, int) return sum, sumOfSquares
}

// B.
func GCD(a, b int) int { for b != 0 { a, b = b, a % b } return a
}

// C.
func GCD(a, b int) int { if b == 0 { return a } return GCD(b, a % b)
}

Здесь первую функцию (foo), наверное, легче всего понять. Почему? Потому, что проблема не в модификации переменных, а в том, как именно они модифицируются. Пример А не содержит никаких сложных вычислений, в отличии от B и С.

// D.
sum = sum + v.x
sum = sum + v.y
sum = sum + v.z
sum = sum + v.w

// E.
sum1 = v.x
sum2 := sum1 + v.y
sum3 := sum2 + v.z
sum4 := sum3 + v.w

Вот ещё один пример кода, где версия с модификацией значения переменной (D) читается проще. Вариант Е не модифицирует существующие переменные, но добавляет 3 новых сущности для описания той же идеи. Больше шума — сложнее понимание.

Идиомы

Давайте посмотрим ещё на несколько циклов:

  • A. for(i = 0; i < N; i++)
  • B. for(i = 0; N > i; i++)
  • D. for(i = 0; i <= N-1; i += 1)
  • C. for(i = 0; N-1 >= i; i += 1)

Насколько долго у вас заняло понять, что делает каждый из них? Бьюсь об заклад, вариант А вы восприняли на лету. Остальные три варианта пришлось читать и понимать. Главная причина — опыт. Вариант А у многих программистов лежит в отдельной, быстро-доступной ячейке памяти. Остальные три — нет. Для них нужно строить в голове новые временные модели.

Опытный программист с первого взгляда скажет «а, ну это проход по массиву». Но для новичка все четыре варианта будут выглядеть одинаково — их сложность действительно примерно равна и ни один из них не покажется человеку «со стороны» лучше другого. Новичок же расскажет о том, что «здесь мы обнуляем переменную i, потом сравниваем её с N, выполняем код внутри тела цикла, увеличиваем i и снова сравниваем и т.д.».

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

Есть классические документы и книги, типа APL idioms, C++ idioms а также более высокоуровневые вещи вроде паттернов Банды Четырёх. Большинство языков программирования имеют идиоматический способ написания тех или иных вещей. Используя идиомы из подобных классических книг, мы можем строить более сложные программы, отдельные куски которых будут понятны остальным программистам (ведь они, наверное, читали те же книги).

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

Консистентность

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

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

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

Неопределённость

Неопределённость может замедлить как написание, так и понимание кода. В качестве примера можно рассмотреть двусмысленность. Например, вот такой код:

[1,2,3].filter(v => v >= 2)

при всей своей простоте всё-же оставляет открытым вопрос, что же будет получено в итоге «2 и 3» или «1»? То есть мы здесь «фильтруем» или «отфильтровываем»? Скорее всего вы быстро найдёте ответ в документации вашей платформы или используемой библиотеки — но вам придётся отвлечься, а потом ещё и запомнить найденную информацию. Правда, было бы лучше, если бы название и синтаксис говорили сами за себя? Значительно лучше подошли бы названия функций типа select, discard или keep.

Например, функция GetUser(string) одними людьми может быть воспринята как поиск пользователя по имени, а другие посчитают, что это поиск по уникальному ключу пользователя. Мы также можем по-разному понимать значение той или иной сущности. Здесь уже нет никакой неопределённости. Из этой ситуации можно легко выйти, создав специальный тип CustomerID (пусть даже он будет алиасом на ту же строку) и использовав его в прототипе функции GetUser(CustomerID), а вот поиск пользователя по имени можно назвать GetUserByName(string).

Если у вас есть переменные типа total1, total2, total3 — очень легко скопировать-вставить кусок кода и забыть исправить индекс. Подобие — ещё одна распространённая причина ошибок. Назвать эти переменные именами вроде sum, sum_of_squares, total_error — намного безопаснее. Код скомпилируется, а ошибка будет найдена (если будет) намного позже.

Это кажется не такой большой проблемой: «я назвал это так, в базу его другой программист положил под вот таким именем, а в UI его назвали вот так». Ещё одна беда — именование одной и той же сущности разными именами в разных модулях вашего кода. А потом что-то ломается и какой-то другой программист, чертыхаясь, пытается понять как связаны эти, совершенно не совпадающие по именам, вещи. Всё хорошо, пока всё хорошо.

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

Комментарии

Все мы видели примеры глупых комментариев новичков, типа:

// увеличиваем в цикле переменную i от 0 до 99
for(var i = 0; i < 100; i++) { // присваиваем переменной а значение 4
var a = 4;

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

По ходу того, как программист набирается опыта, его комментарии несут всё меньше информации о том, ЧТО делает код и всё больше о том ПОЧЕМУ и В КАКОМ КОНТЕКСТЕ он это делает. Как только эта привязка произошла — эти комментарии станут ненужным мусором, поскольку объяснение происходящего будет возникать у вас в голове уже при взгляде на сам код. «Подход Х был выбран потому, что альтернативные подходы Y и Z не подошли по таким-то причинам», «при модификации данного кода следует помнить о том, что ...».

Хорошие комментарии дополяют ментальную модель понимания кода.

Контексты

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

Каждый шаг вглубь стека вызовов — шаг к лимиту наших ментальных возможностей. То же самое и с отслеживаем вызовов функций.

Одним из примеров может служать концепция раннего возврата («early return»): Один из способов уменьшить глубину нашей ментальной модели контекстов — чётко разделить их.

public void SomeFunction(int age)
{ if (age >= 0) { // сделать что-то } else { System.out.println("Не верный возраст"); }
} public void SomeFunction(int age)
{ if (age < 0){ System.out.println("Не верный возраст"); return; } // сделать что-то
}

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

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

Эмпирические правила

Одно из фундаментальных правил программирования гласит «Избегайте использования глобальных переменных». Но как на счёт случая, когда значение такой переменной присваивается лишь раз при инициализации и никогда не меняется в дальнейшем — это тоже проблема? Да, проблема. Дело здесь даже не в «переменности» или «глобальности». Мы вводим сущность, которая доступна отовсюду, а значит она явно или неявно будет присутствовать в любой ментальной модели кода, которую вы будете строить у себя в голове. Даже если это константа, даже если в данной функции она не используется — само по себе знание того, что есть нечто, что по своей воле (а не по воле данной функции) является видимым и доступным — уже даёт ему право претендовать на место в голове читателя данного кода. Конечно, мы не пишем «программы в вакууме», все они работают в каком-то окружении, и даже некоторые «допустимые» идиомы вроде Singleton обладают теми же свойствами. Так почему же они считаются лучшим вариантом, чем глобальные переменные?

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

Он показал вот эти три куска кода: Хорошим примером на эту тему может быть комментарий Кармака.

// A
void MinorFunction1( void ) {
} void MinorFunction2( void ) {
} void MinorFunction3( void ) {
} void MajorFunction( void ) { MinorFunction1(); MinorFunction2(); MinorFunction3();
} // B
void MajorFunction( void ) { MinorFunction1(); MinorFunction2(); MinorFunction3();
} void MinorFunction1( void ) {
} void MinorFunction2( void ) {
} void MinorFunction3( void ) {
} // C.
void MajorFunction( void ) { { // MinorFunction1 } { // MinorFunction2 } { // MinorFunction3 }
}

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

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

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть