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

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

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

[sendpulse-form id=”278″]

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

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

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

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

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

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

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

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

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

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

Во-первых, ему сложновато работать с абстракциями. Когда некоторые сущности кажутся схожыми, они располагаются в мозгу «рядом», являются связанными. Это приводит к тому, что мозг иногда ошибается какую из них следует извлечь и использовать в каждом конкретном случае. Пример: путаница между 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 плох, поскольку без пространств имён мы вообще не очень хорошо понимаем, что за функцию вызываем, откуда она и над какими объектами будет работать.

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

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

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


// A.
func foo() (int, int) {
    sum, sumOfSquares := 0, 0
    for _, v := range values {
        sum += v
        sumOfSquares += v * v
    }
    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 его назвали вот так». Всё хорошо, пока всё хорошо. А потом что-то ломается и какой-то другой программист, чертыхаясь, пытается понять как связаны эти, совершенно не совпадающие по именам, вещи.

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

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

Вместо заключения

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