Привет, Хабр! Сегодня я хотел бы поделиться опытом, как писать код так, чтобы системы в проекте были с одной стороны гибкими и модульными, а с другой — простыми и компактными.
Поскольку я являюсь разработчиком игр на Unity, то и примеры будут из этой сферы. Надеюсь, изложенные подходы будут понятны и тем, кто работает в продуктовой разработке.
Довольно часто бывает так, что разработчик приходит на новый проект, открывает чужой код и видит одну из двух ситуаций:
Жирные God-Object классы, которые делают все;
Мелкие классы, сплетенные в Spagetti-Code.
Знакомо? И в том и в другом случае неудобно поддерживать проект, поскольку в первом случае нарушен принцип единственной ответственности, который гласит, что каждый класс должен иметь одну причину для изменения; а во втором — нарушен принцип KISS, который говорит о том, что более простые системы лучше поддерживаются, в них меньше багов, и они реже дают сбои.
На самом деле, когда нужно реализовать новую фичу в проекте, необходимо соблюдать баланс между простотой и гибкостью.
Это значит, что иногда нужно нарушать принципы SOLID ради простоты и компактности системы, и закладывать гибкость только в тех местах, где это действительно нужно. Никакого "сделаю тут более универсально на будущее" не приветствуется, потому что вы никогда не знаете на 100%, в каких местах ваш проект будет развиваться. В результате вы потеряете время, силы и энергию. Особенно печально будет, когда поймете, что ваше "универсальное решение" не состыкуется с реальным техническим заданием.
Тут же парирую: если команда точно знает проект, будущий объем задач, модули и архитектуру, то тогда можно заранее заложить точки гибкости.
Теперь расскажу, как это делаю я:
В первую очередь, когда я разрабатываю фичу или провожу рефакторинг в коде, я думаю о том, чтобы система была простой и компактной. Чем меньше скриптов в проекте, тем проще команде ориентироваться в нем. Но если я вижу, что класс выполняет несколько ответственностей разных по смыслу, то разбиваю его. Другой кейс: если я вижу, что нужно обрабатывать разные компоненты общим контрактом, то применяю интерфейсы и полиморфизм.
Еще раз подчеркну: не нужно делать сразу универсальную систему, которую можно масштабировать в любом направлении, потому что чем гибче система, тем сложнее она будет выглядеть. Точки гибкости добавляются по ходу эволюции проекта.
Конечно, на словах звучит просто, но на практике все гораздо сложнее. Поэтому я вывел для себя уровень допущения в применении принципа единственной ответственности, чтобы был баланс.
Критическое нарушение SRP. Класс нужно разбить по SRP, если он выполняет ответственности разного рода или в нем прослеживается сильное зацепление. Например, система врагов, которая помимо управления противниками, занимается квестами игрока; или класс Race на 600+ строк кода, который управляет машинками противников (ИИ), запускает обратный отсчет и контролирует чекпоинты, которые проехал игрок. Это реальные примеры, которые я видел в GameDev.
Некритическое нарушение SRP: Класс желательно разбить по SRP, если он выполняет группу ответственностей одного рода. Например, менеджер квестов, который занимается созданием, хранением квестов, а также их генерацией и выдачей награды. Несмотря на то, что менеджер имеет много ответственностей, это класс на 300 строк легко читается, поскольку все ответственности сфокусированы вокруг квестов, и в нем соблюдается высокая связность. Если этот менеджер будет в процессе разработки и дальше развиваться, то его можно будет подразбить на более маленькие классы, с целью повышения читаемости кода. Другой пример, который я встречал в разработке, — это жирный класс пользовательского интерфейса игрока, который имеет много ответственностей, но в целом он отвечает за логику представления. Такой класс тоже можно подразбить, если какая-то логика View будут переиспользоваться, или класс будет разрастаться.
Теперь расскажу, как рефакторить спагетти-код.
Первый вариант простой, если не хочется париться с чужим кодом. Тут можно применить паттерны Фасад или Адаптер. Применив тот или другой паттерн, вы сделаете себе "удобную обёрточку", через которую ваш код будет взаимодействовать с другим.
Второй вариант более сложный. Нужно нарисовать диаграмму-классов и выписать все ответственности, которые есть в этой "паутине". После этого у вас будет понимание, какие классы можно объединить, а какие наоборот разделить по ответственностям. Другими словами применить шаблоны GRASP: низкое зацепление и высокая связность.
В результате код-база проекта не всегда будет следовать принципам SOLID. Самое главное — чтобы код был простым, понятным и читаемым. Поэтому взял себе на вооружение такой подход:
делай раз: пишем код, который будет работать;
делай два: рефакторим код, соблюдая баланс SOLID / KISS.
Таким образом, искать золотую середину в коде сложно. Нужен огромный опыт практики, самоанализа, знание шаблонов GRASP, паттернов GoF и принципов SOLID, KISS, DRY, YAGNI. Надеюсь, что изложенный материал поможет вам писать код лучше.
В завершении скажу, что я буду разрабатывать игровые механики 26-го декабря в 19:00 по МСК на Youtube у себя на канале. Более подробная информация будет на онлайн-курсе. Также, если у вас будет желание посетить мой телеграмм канал, буду рад!
Предыдущие стримы по разработке игр: Введение в атомарный подход, Компоненты и секции.
Благодарю! ????
Комментарии (23)
konst3d
25.12.2023 12:16делай раз: пишем код, который будет работать;
делай два: рефакторим код, соблюдая баланс SOLID / KISS.
А сразу написать чистый код, с соблюдением всех принципов?
StarKRE Автор
25.12.2023 12:16Такое сработает, если разработчик уже решал похожую задачу ранее. Если для специалиста задача новая, то легко угодить в ловушку двух зайцев. Думать одновременно о том, чтобы код и работал и был причесан, может очень сильно тормозить процесс разработки (по себе знаю). В результате будет потрачено больше времени, сил и энергии. Поэтому рабочий код и чистый код — это разного рода задачи, и лучше рефакторить после проверки работоспособности кода.
KamiSempai
25.12.2023 12:16Писать какой-то код, а потом рефакторить - ещё большая ошибка. Вы лишь перекладываете необходимую работу на будущего себя (или других разработчиков, которые будут разбирать в вашем коде, после того как вы выгорите и уйдете в закат). Беда в том, что плохой код порождает ещё больше плохого кода и в конечном счёте придется потратить ещё больше времени и сил. Но что ещё более вероятно, никто ничего не будет делать. Все будут "плакать, колоться но есть кактус". А потом релиз. И в итоге мы получаем очередного убийцу <вставить_свой_вариант> от российских разработчиков.
Gromilo
25.12.2023 12:16Это если рефакторинг в отдельной задаче. Если в этой же, то никаких проблем нет. Задачу нужно загрузить в голову и она грузится в процессе написания, пусть даже не самого лучшего код, потом рефачишь, потом делаешь сефл ревью, потом отдаёшь пул рек.
Sazonov
25.12.2023 12:16Не пренебрегать архитектурным планированием и изучать различные практики и их мотивацию. Тот же CppCoreGuidelines порой кажется избыточным, пока не набьёшь собственных шишек.
Coast
25.12.2023 12:16Я нашел баланс путем перехода на процедурный стиль без использования классов. Классы - интеграционная вещь, на голом Си - функциональная. В одном модуле можно спокойно разложить по функциям то, что спокойно заняло бы 3 уровня иерархии.
Gromilo
25.12.2023 12:16А какая сфера? Я например веб сервисы пишу нарпах и не умею в процедурный стиль.
Sazonov
А почему вы решили что KISS и SOLID это разное? Можно писать простой код, соблюдая SOLID.
Понятно что есть учебные примеры на которых можно противопоставить эти принципы, но в реальности это не так. Тот же спагетти код и KISS это разное. Писать лапшу вовсе не значит делать проще.
Gromilo
Например? проще править класс, чем делать его закрытым для изменения и открытым для расширения. Или не во все места стоит вставлять инвертированные зависимости.
Вот принцип подстановки нужно соблюдать неукоснительно, ибо когда наследник нарушает контракт, это фу-фу-фу и код становится сложнее.
Вообще, хорошо зная принципы разработки, можно обосновать любой код.
Sazonov
KISS не запрещает править существующие классы. Люди часто его не правильно понимают и ассоциируют с подходом «вжух вжух и в продакшн». Это разные вещи. KISS это про то чтобы не заводить ненужные абстракции пока они не понадобятся.
StarKRE Автор
Это да, но аббревиатура KISS расшифровывается: Keep It Simple Stupid. Последнее слово как-раз имеется ввиду, что ты можешь сделать решение более топорное, возможно даже немного накостылять и захардкодить
KamiSempai
Вы все неправильно поняли. Последнее слово - это обращение.
"Делай проще, тупица".
Данный принцип говорит лишь о том, что не нужно переусложнять и писать максимально понятный и простой код, а принципы SOLID должны в этом помогать.
feelamee
Я думаю, Gromilo имел ввиду, что править класс это просто и удобно(т.е. соответствует KISS), но противоречит Open/Close принципу из SOLID. Выходит, что не всегда KISS и SOLID дружат.
С моей точки зрения все немного иначе.
Что означает принцип Open/Close? Наверно везде, где я читал, имеется ввиду - не меняй исходный код класса, а сделай наследника и расширяй его. Но я думаю что на самом деле имеется ввиду - не изменяй интерфейс(т.е. контракт, который класс предоставляет), а расширяй его. А что касается имплементации - это инкапсулировано внутри класса и (в лучшем случае) на всю остальную систему это не повлияет. Хорошо, если код покрыт тестами.
Gromilo
Да, именно это я и имел ввиду.
michael_v89
Тут немного другой критерий. Не знаю, что имели в виду авторы принципа, но на практике это неплохо работает.
Делать наследника нужно только если оба варианта используются в рабочем коде. Это означает, что не нужно делать наследников при изменении требований в бизнес-логике, так как бизнес-логика обычно одна. Также не нужно таким образом фиксить баги, версия с багом скорее всего вам не нужна.
StarKRE Автор
В идеале так и нужно писать простой код, соблюдая SOLID. В статье речь идет о том, что можно уйти в крайности:
Простая система, может превратиться в God Object
Супер гибкая система, может превратиться в спагетти.
Поэтому в статье объясняется, что нужно держать баланс. Нельзя просто следовать SOLID. В тоже время нужно контролировать, чтобы Stupid классы не превращались в God Object'ы.
Sazonov
Не сочтите за рекламу, я к сайту не имею никакого отношения. Но часто даю его почитать коллегам, которые злоупотребляют быстрокодингом: тыц
Myxach
И тогда код будет противоречить KISS. Не, может в теории для человека это и будет простым кодом. но на практике обычно система из божественных объектов приводит к головной боли
keep it это значит что код всегда должен оставаться простым, god object'ы же делает код трудным
megahertz
God object в некоторых случаях может все ещё оставаться сравнительно простым и понятным. Сложным будет тестирование его и зависимы компонентов, а так-же внесение изменений в зависимо коде.