Хабрахабр

[Перевод] 23 рекомендации для читабельного кода

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

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

1. Сначала определить проблему
2. Выбрать правильный инструмент
3. Главное — простота
4. Функции, классы и компоненты должны иметь чёткое назначение
5. Придумывать имена трудно, но это важно
6. Не дублируйте код
7. Удалите мёртвый код, не оставляйте его в комментариях
8. Постоянные значения должны быть в статических константах или перечисляемых типах
9. Предпочитайте внутренние функции, а не кастомные решения
10. Читайте руководства для конкретного языка
11. Избегайте нескольких вложенных блоков
12. Суть не в лаконичности
13. Изучите шаблоны проектирования и когда их избегать
14. Разделите классы на хранение и обработку данных
15. Исправляйте корень проблемы
16. Скрытая ловушка абстракций
17. Приложение не подчиняется правилам реального мира
18. По возможности типизируйте переменные, даже если это не требуется
19. Пишите тесты
20. Проводите статический анализ
21. Код-ревью с человеком
22. Комментарии
23. Документация
Вывод
Независимо от того, исправляете вы ошибку, добавляете функцию или разрабатываете приложение, вы по сути решаете чью-то проблему. Нужно чётко понимать, какую проблему вы решаете своим шаблоном проектирования, рефакторингом, внешними зависимостями, базами данных и всем остальным, на что вы тратите драгоценное время.

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

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

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

Допустим, нужно реализовать фильтруемый список записей. Есть и другие типы проблем. Анализ задания клиента показал, что для такой фильтрации данных из-за структуры БД придётся потратить около 20 часов на создание сложных SQL-запросов с множеством операций соединения и внутренних запросов. Данные хранятся в БД со сложными связями между записями. Может оказаться, что дополнительная функция не стоит столько потраченного времени и денег. Почему бы не объяснить, что другое решение, пусть и неполное, займёт всего 1 час?

Возможно, этот модный язык, ваш любимый фреймворк или новый движок базы данных — не самый лучший инструмент для решения проблемы. В серьёзном проекте не полагайтесь только на один инструмент, о котором вы слышали много хорошего. Это рецепт катастрофы. Если нужны отношения между данными в базе, то желание поучиться MongoDB приведёт к печальному финалу. Да, всё можно реализовать, но часто приходится использовать обходные пути, а это дополнительный код и неоптимальные решения. Конечно, гвоздь забивается и деревянной доской, но быстрый поиск в Google наведёт на молоток. Возможно, со времени прошлого поиска решения уже появилась новая нейросеть, которая автоматически выполнит задачу вместо вас.
Возможно, вы слышали выражение «Преждевременная оптимизация — корень всего зла». Тут есть доля правды. Лучше выбрать простое решение, пока вы на 100% не уверены, что оно не сработает. Не просто предполагаете, но уже опробовали или заранее рассчитали — и точно уверены, что не сработает. Какова бы ни была причина для выбора более сложного решения — будь то скорость выполнения, экономия RAM, расширяемость, отсутствие зависимостей или другие причины — оно может сильно повлиять на читабельность кода. Не усложняйте без необходимости. Если только вы не знаете более эффективное решение, которое точно не повлияет на читабельность или требования по времени разработки.

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

Знаете принципы SOLID? Похоже, они неплохо подходят для разработки универсальных библиотек, но хотя я пару раз их использовал и видел несколько реализаций в рабочих проектах, но мне кажется, что они слишком запутаны и сложны.

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

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

Какой быстрее прочитать и понять его действия? Рассмотрим примеры кода с одинаковой функциональностью.

// C++
if (currentDistance < radius2) } // Это для вычислений света else { ASEngine::ivec3 region = World::inst().map.currentPosition; ASEngine::ivec2 pos = mapPosition; if (mapPosition.x > 63) { pos.x -= 64; region.x += 1; } else if (mapPosition.x < 0) { pos.x += 64; region.x -= 1; } if (mapPosition.y > 63) { pos.y -= 64; region.y += 1; } else if (mapPosition.y < 0) { pos.y += 64; region.y -= 1; } map.changeLight(pos, region, 1.0f - static_cast(currentDistance) / static_cast(radius2)); }
}

// C++
if (currentDistance < radius2) { // Это вид игрока if (!isLight) { this->markVisibleTile(hasInfravision, map, center, mapPosition); } // Это для вычислений света else { ASEngine::ivec3 region = World::inst().map.currentPosition; ASEngine::ivec2 pos = map.getRelativePosition(mapPosition, region); map.changeLight(pos, region, 1.0f - static_cast(currentDistance) / static_cast(radius2)); }
}

Названия должны хорошо различаться и давать общее представление о том, что делает переменная или функция. Поскольку с этим кодом работает вся команда, важно соответствовать соглашениям, выбранным для проекта. Даже если лично вы с ними не согласны. Если каждый запрос на запись в базе данных начинается со слова “find”, например, “findUser”, то ваши коллеги могут запутаться, если вы вдруг назовёте функцию “getUserProfile”, потому что вы к ней привыкли. По возможности группируйте названия. Например, если есть много классов для проверки ввода, то суффикс “Validator” сразу покажет назначение класса.

Всё становится запутанным, если в разных файлах одного проекта встречются camelCase, snake_case, kebab-case и beercase. Выберите какой-нибудь тип записи и соблюдайте его.

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

if ($user->role == "admin") { // user is an admin
}

Это совсем не классно. Прежде всего, если имя admin изменится, вам придётся менять его во всём приложении. Говорите, такое редко случается, мол, в современных IDE массовую замену сделать несложно? Это правда. Другая причина — отсутствие автодополнения, и из-за этого повышается вероятность ошибки. Её может быть трудно искать.

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

// PHP
const ROLE_ADMIN = "admin"; if ($user->role == ROLE_ADMIN) { // user is an admin
}

// C++
enum class Role { GUEST, ADMIN }; // Можно сопоставить такой enum со строкой, но это не требуется. if (user.role == Role.ADMIN) { // user is an admin
}

Это не просто типы ваших объектов. В PHP в качестве имён полей можно указать массивы со строками. В сложных структурах легко сделать опечатку, поэтому объекты предпочтительнее. Старайтесь избегать кодирования строками — и вы уменьшите количество опечаток и увеличите скорость работы благодаря автозаполнению.
Если в языке или платформе есть встроенное решение проблемы, используйте его. Всегда можно быстро погуглить функцию, даже если она используется нечасто. Вероятно, на поиск кастомного решения уйдёт больше времени. Если вы обнаружили у себя в коде фрагмент, который делает то же самое, что и внутренняя функция, просто быстро замените, не оставляйте его. Удалённый код перестаёт быть проблемой, так что удаление кода — это здорово!
Если вы пишете на PHP, то должны знать PSR. Для JavaScript есть приличное руководство от Airbnb. Для C++ есть руководство от Google и основные рекомендации Бьёрна Страуструпа, создателя C++. В других языках могут быть свои рекомендации по качеству кода, или вы даже можете придумать собственные стандарты для своей команды. Важно всем придерживаться выбранного ориентира для проекта, каким бы он ни был. Это предотвращает проблемы из-за того, что люди с разным уникальным опытом делают то, к чему привыкли.
Просто сравните два блока кода:

void ProgressEffects::progressPoison(Entity entity, std::shared_ptr<Effects> effects)
{ float currentTime = DayNightCycle::inst().getCurrentTime(); if (effects->lastPoisonTick > 0.0f && currentTime > effects->lastPoisonTick + 1.0f) { if (effects->poison.second > currentTime) { std::shared_ptr<Equipment> eq = nullptr; int poisonResitance = 0; if (this->manager.entityHasComponent(entity, ComponentType::EQUIPMENT)) { eq = this->manager.getComponent<Equipment>(entity); for (size_t i = 0; i < EQUIP_SLOT_NUM; i++) { if (eq->wearing[i] != invalidEntity && this->manager.entityHasComponent(eq->wearing[i], ComponentType::ARMOR)) { std::shared_ptr<Armor> armor = this->manager.getComponent<Armor>(eq->wearing[i]); poisonResitance += armor->poison; } } } int damage = effects->poison.first - poisonResitance; if (damage < 1) damage = 1; std::shared_ptr<Health> health = this->manager.getComponent<Health>(entity); health->health -= damage; } else { effects->poison.second = -1.0f; } }
}

void ProgressEffects::progressPoison(Entity entity, std::shared_ptr effects)
{ float currentTime = DayNightCycle::inst().getCurrentTime(); if (effects->lastPoisonTick < 0.0f || currentTime < effects->lastPoisonTick + 1.0f) return; if (effects->poison.second <= currentTime) { effects->poison.second = -1.0f; return; } int poisonResitance = this->calculatePoisonResistance(entity); int damage = effects->poison.first - poisonResitance; if (damage < 1) damage = 1; std::shared_ptr health = this->manager.getComponent(entity); health->health -= damage;
}

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

В некоторых языках есть сокращённый вариант if, например:

$variable == $x ? $y : $z; // if ($variable == x) { $result = $y; } else { $result = $z; }

Это хороший вариант, если без крайностей:

$variable == $x ? ($x == $y ? array_merge($x, $y, $z) : $x) : $y; // Что за ересь?!

Такое легче понять после разгруппировки.

$result = $y;
if ($variable == $x && $x == $y) $result = array_merge($x, $y, $z);
else if ($variable == $x) $result = $x;

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

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

Класс-хранитель (data holder) хранит некоторые данные в своих внутренних структурах. Когда нужно, доступ к ним осуществляется через геттеры и сеттеры, но этот класс не манипулирует данными, если только они не должны изменяться при хранении или при доступе.

Другой вариант — шаблон репозитория, связанный с внешней БД, где класс Model представляет данные из БД в структурах, зависящих от языка, а класс Repository синхронизирует данные с БД, либо сохраняя изменения в Model, либо извлекая их. Очень хороший пример — архитектурный шаблон Entity Component System, в котором компоненты содержат только данные, а системы управляют и обрабатывают их.

Рассмотрим пример репозитория. Такое разделение упрощает понимание разных частей приложения. Как хранятся в БД и как сопоставлены со структурами языка? Если вы хотите вывести список данных, хранящихся в наборе «моделей», то нужно ли вам знать, откуда они получены? Пропускаете модели через существующие методы репозитория и концентрируетесь только на своей задаче, которая отображает данные. Ответ на оба вопроса отрицательный.

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

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

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

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

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

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

Вы создаёте класс из 10−15 строк читабельного кода, который импортирует данные из CSV-файла и помещает их в базу данных. Рассмотрим на примере. Зачем вызывать внешнюю библиотеку на 5000 строк кода, которая не нужна в данный момент для решения вопроса? Зачем создавать два класса и обобщать решение, чтобы оно потенциально могло быть расширено в будущем на импорт XLS или XML, если сейчас даже нет намёка, что это понадобится для вашего приложения?

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

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

Во время реализации ООП-парадигмы для приложения у меня возник интересный спор о моделировании «реального мира». Допустим, нужно обработать много данных для рекламной системы. У нас два лога: в первом информация о показах рекламы, во втором — о кликах. Во втором логе есть все данные из первого лога плюс несколько дополнительных полей.

Поэтому путём моделирования реального мира создаём основной класс “Log”, который расширяется на классы “ClickLog” и “EmissionLog”: В реальном мире просмотр и клик рассматриваются как отдельные, но похожие действия.

struct Log { int x; int y; int z;
}
struct EmissionLog : public Log {}
struct ClickLog : public Log { float q;
}

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

Этот журнал обрабатывается теми же классами, что и EmissionLog. В отличие от реального мира, в нашем приложении ClickLog — расширение EmissionLog. Если вы расширите логи показов на логи кликов, то вы сообщаете коллегам: все события для показов работают и с кликами без необходимости уведомлять в приложении обо всех возможных обработчиках логов.

struct EmissionLog { int x; int y; int z;
}
struct ClickLog : public EmissionLog { float q;
}

Можете пропустить этот раздел, если пишете только на языках со статической типизацией. В динамически типизированных языках, таких как PHP или JavaScript, может быть трудно понять функцию кода, не посмотрев содержимое переменных. По той же причине код становится непредсказуемым, если одна переменная в зависимости от условий может оказаться объектом, массивом или нулевым объектом. Чем меньше типов переменных в параметрах функции — тем лучше. В PHP с версии 7 доступны типизированные аргументы и возвращаемые типы, и можно выбрать TypeScript вместо чистого JavaScript. Это улучшит удобочитаемость кода и предотвратит глупые ошибки.

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

// PHP >= 7.1
function get(?int count): array { //... }

// Typescript
interface IUser = { name?: string; // name field might not be available type: number;
}

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

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

Есть множество инструментов с открытым исходным кодом для статического анализа. Продвинутые IDE часто позволяют осуществлять её в реальном режиме времени. В среде Docker некоторые процессы можно автоматизировать на каждом коммите.

Надёжные варианты для PHP:

  • Copy / Paste detector.
  • PHP Mess Detector — проверяет потенциальные баги и запутанные фрагменты.
  • PHP Code Sniffer — проверка на соответствие стандартам.
  • PHPMetrics — инструмент статического анализа c панелью инструментов и диаграммами.

JavaScript:

  • JsHint / JsLint — обнаружение ошибок и потенциальных проблем, инструмент можно интегрировать в IDE для анализа в реальном времени.
  • Plato — инструмент визуализации исходного кода и сложности.

C++:

  • Cppcheck — находит баги и неопределённое поведение.
  • OClint — улучшает качество кода.

Разные языки:

  • pmd — поиск запутанных фрагментов кода.

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

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

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

Это вопрос дисциплины. Я считаю, что комментарии должны сопровождать каждую функцию, включая конструкторы, каждое свойство класса, статическую константу и каждый класс. Если пропустить и не прокомментировать какие-то «очевидные» фрагменты, то лень в итоге победит.

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

Честно говоря, это отличное название. Я понимаю что название класса “InjectorToken” говорит само за себя. Было бы прекрасно прочитать это в комментариях, чтобы не приходилось искать в коде приложения, верно? Но при рассмотрении этого класса я хочу знать, для чего этот токен, что он делает, как его использовать и что это за инжектор.

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

Для автоматической генерации документации можно использовать Doxygen.

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

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

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

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

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

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

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