[Перевод] Как разработчики Factorio оптимизировали код игры
Этот пост будет довольно техническим и связанным с программированием, поэтому если вас интересуют только новости об игре, то можете спокойно его пропустить.
Дядя Боб
Теперь, когда здесь остались одни разработчики, я могу поделиться своим новым открытием — дядей Бобом и его очень хорошим объяснением некоторых фундаментальных принципов, связанных с управлением проектами. Если у вас есть в распоряжении есть восемь с половиной свободных часов, то рекомендую посмотреть эти видео, потому что в дальнейшем я буду на них ссылаться.
Раньше я думал, что мы поддерживаем достаточно высокий уровень кода и имеем вполне неплохую методологию работы. Но на самом деле мы были жертвами избирательной слепоты. Интересно, что некоторые части кода изначально были хорошими, и оставались довольно неплохими все эти годы, даже когда игра сильно расширилась…, а другие части кода значительно деградировали.
Ответ заключается в метафоре искусственной вощины.
Что такое искусственная вощина и как она связана с программированием? Мой дед очень любил содержать пчёл. Я провёл детство в нашем саду, где нужно было смотреть под ноги, проверять, куда садишься и нельзя было оставлять ничего сладкого, потому что довольно скоро это оказывалось облепленным пчёлами. Я должен был помогать деду и время от времени узнавал что-то новое о пчёлах, которых, честно говоря, ненавидел и знал, что у меня никогда их не будет. Но в одном он был прав — всё, что ты узнаёшь, в той или иной степени будет тебе полезно.
Одна из работ с пчёлами заключается в том, что при извлечении мёда нужно вставить в улей искусственную вощину, которая выглядит вот так:
Её основная функция заключается в том, что пчёлы строят свои соты равномерно и довольно быстро, потому что для них естественно повторять уже существующую оптимизированную структуру. То же самое происходит и с кодом, изначально имеющим качественную и расширяемую архитектуру.
С другой стороны, существует код, или изначально спроектированный лениво, или не предназначавшийся для повышения его сложности, и каждое изменение становится ещё одним мелким дополнением к хаосу. Со временем мы просто привыкаем, что эта часть кода представляет собой сущий ад, и что внесение мелких изменений напрягает. Это подразумевает, что нам просто не нравится эта часть кода, и мы хотим тратить на него как можно меньше времени. В результате проблема постепенно выходит из-под контроля.
Но потом я посмотрел на мир глазами дяди Боба и быстро увидел несколько подобных проблемных мест. Эти места неслучайно отнимают непропорционально много времени разработчиков — не только потому, что вносить изменения тяжело, но и потому, что в них полно регрессионных багов; к тому же, они обычно являются бесконечным источником проблем.
И вот что здорово в том, что владеешь компанией, которая не выставлена на фондовый рынок. Представьте, что у вас компания, которая каждый квартал развивается всё медленнее и медленнее, после чего вы заявляете держателям акций, что решить эту проблему можно, вообще не выпуская один-два квартала новых фич, отрефакторив код, изучив новые методологии и т.п. Я сомневаюсь, что держатели акций на такое согласились бы. К счастью, у нас нет никаких держателей акций, и мы понимаем жизненную необходимость таких инвестиций в длительной перспективе. Не только для проекта, но и для наших навыков и знаний, чтобы в будущем мы работали лучше.
Вот график количества строк кода Factorio с течением времени:
Он бы выглядел вполне логичным, если бы над проектом от начала до конца работало одно и то же количество людей, но это не так. В самом начале был только я, а теперь у нас девять программистов. Можно объяснить это тем, что игра становится больше, и в ней появляется множество взаимосвязанных механик, которые сложнее поддерживать. Или же это может означать, что сильно увеличилась плотность кода. Но этих причин недостаточно для объяснения того, почему увеличение количества программистов не приводит к ускорению разработки.
Это показывает, что описываемые дядей Бобом проблемы актуальны и для нас, и что на самом деле решение заключается не увеличении количества людей, а в изменении подхода к разработке. Если создать хороший чёткий фундамент, нанять новых программистов и дать им освоиться с кодом, то это будет намного быстрее.
Позвольте мне объяснить несколько типичных примеров проблем, которые у нас были, а также рассказать о том, как мы их решали:
Пример 1 — взаимодействие с GUI
Мы много писали о GUI (например, в FFF-216) и о том, как мы итеративно поднимали планку приемлемого с точки зрения пользователей и программистов. Обычно из FFF и кодинга мы делали выводы о том, что часто недооценивали возрастающую сложность логики GUI/стилей/структур. Из этого можно понять, что усовершенствовав код GUI, потенциально мы можем очень много выиграть.
Мы довольны тем, как структурированы и выстроены объекты GUI, начиная с обновления 0.17. Однако код по-прежнему кажется гораздо более раздутым, чем должен быть. Основная проблема заключалась в том, что для добавления интерактивного элемента требовалось вносить изменения во множество мест. Позвольте показать пример — простую кнопку, используемую для сброса пресетов в окне генератора карты.
В заголовке класса:
class MapGeneratorGui
{
...
у нас есть определение объекта кнопки
...
IconButton resetPresetButton;
...
В конструкторе класса MapGenerator нам нужно создать кнопку с параметрами
...
, resetPresetButton(&global->utilitySprites->reset, // normal
&global->utilitySprites->reset, // hovered
&global->utilitySprites->resetWhite, // disabled
global->style->toolButtonRed())
...
Нам нужно зарегистрировать слушатель этой кнопки
...
this->resetPresetButton.addActionListener(this);
...
Затем нам нужно переопределить метод ActionListener в классе MapGeneratorClass, чтобы мы могли слушать действия нажатия мыши.
...
void onMouseClick(const agui::MouseEvent& event) override;
...
И наконец мы можем реализовать метод, в котором обрабатываем через if/else элементы, которые нам важны, чтобы выполнить саму логику
void MapGeneratorGui::onMouseClick(const agui::MouseEvent& event)
{
if (event.getSourceWidget() == &this->resetPresetButton)
this->onResetSettings();
else if (event.getSourceWidget() == &this->randomizeSeedButton)
this->randomizeSeed();
...
Слишком много бойлерплейта для одной кнопки с одним простым действием. У нас было более 500 мест, где регистрировались actionListener, так что представьте, насколько распухшим был код.
Мы заметили, что когда используем лямбды для передачи колбэков и подобных вещей в GUI, то работать с ними удобнее. Что будет, если мы сделаем их основным способом реализации GUI?
Мы решили полностью переписать его, чтобы вместо добавления слушателей и фильтрации в функциях отслеживания событий можно было просто написать:
this->resetPresetButton.onClick(this, [this]{ this->onResetSettings(); });
Что само по себе является серьёзным улучшением, ведь теперь для добавления и поддержки логики достаточно смотреть только в одно место, а код становится более читаемым и менее подверженным ошибкам.
А поскольку нам не нужно хранить указатель для сравнений, мы можем полностью удалить его определение из класса и сделать его анонимным во многих местах подобным образом:
*this << agui::iconButton(&global->utilitySprites->reset,
global->style->toolButtonRed(),
this, [this]{ this->resetPreset(); })
Переписывание (повторное) внутреннего устройства GUI было большой задачей, но в конечном итоге нам показалось, что оно того стоило, потому что теперь я не представляю, как бы мы могли продолжать работать по-старому. Также оно привело к удалению нескольких тысяч строк кода.
Единственный способ двигаться быстро — двигаться правильно!
Пример 2 — строительство вручную
Когда стремишься сделать код чище, то преследуешь несколько основных целей. Самое важное — это устранение дублирующегося кода. Эту проблему решить достаточно легко, когда код плохо структурирован, функции слишком длинные или странно выбраны имена, но если у тебя есть пять версий одного и того же кода с незначительными отличиями, то всё гораздо хуже. Возникновение проблем — просто вопрос времени: исправления багов/изменения вносятся только в некоторые из вариантов, и постепенно становится всё менее очевидно, были ли различия между вариантами преднамеренными или случайными.
Логика строительства вручную — это настоящее чудовище, потому что она поддерживает множество вещей:
Кроме того, всю эту логику нужно умножить на два (если ты ленив и копипейстишь), потому что бывают обычные здания и прозрачные здания-призраки.
После этого ты умножаешь весь этот чудовищный код снова в два раза. Почему? Потому что нам нужна вся эта логика и в режиме сокрытия задержек. Выглядит уже плохо, но и это ещё не всё, ведь эту логику постоянно патчат и изменяют множество разных людей, а ядро кода представляло собой безумный длинный метод, код которого походил на упомянутый дядей Бобом горизонт.
А теперь представьте, что вам нужно изменить что-то в этом коде, особенно если учесть, что в коде, естественно, многие пограничные случаи обрабатываются неверно, или исправлены только в одном из вариантов кода. Это отличный пример того, как ленивое проектирование на долгую перспективу приводит к низкой производительности.
Сначала это воспринималось как мой побочный хобби-проект, для завершения которого потребуется несколько недель, но в конечном итоге я объединил весь дублирующийся код, создал красивую структуру и тщательно его протестировал. Теперь для управления кодом требуется гораздо меньше времени, чем в предыдущем состоянии, потому что читателю необязательно читать огромную кучу кода, чтобы получить общее представление о нём и что-то в нём менять.
Это напомнило мне фразу Лу, которую он сказал после подобного рефакторинга:»Когда мы с этим закончим, добавление новых функций в код будет настоящим удовольствием.». Разве это не прекрасно? Код не только становится более эффективным и менее забагованным, с ним ещё и приятнее работать, а работа с чем-то приятным ускоряет выполнение задачи, вне зависимости от других аспектов.
Единственный способ двигаться быстро — двигаться правильно!
Пример 3 — тесты GUI
Очевидно, что мы бы не добрались до этого этапа без автоматизированных тестов, и мы уже несколько раз говорили о них (в FFF-29, FFF-62, FFF-288 и в других постах). Мы постоянно стремимся повышать планку того, какие области кода покрываются тестами, и это постепенно привело нас к тому, что нужно покрывать и ещё одну область — GUI. Это вполне согласуется с постоянным пренебрежением к GUI, из-за чего ему не хватало внимания разработчиков. Отсутствие его тестов стало частью этого пренебрежения, и очень часто бывало, что после выпуска релиза он ломался из-за какой-то глупой проблемы в GUI, просто потому, что у нас не было теста, нажимающего кнопки. А в конечном итоге оказалось, что автоматизировать тесты GUI не так сложно.
Мы просто создали режим, в котором тестовая среда создаётся с GUI (даже когда тесты проводятся без графики). Мы объявили несколько вспомогательных методов, позволяющих задавать места, в которые нужно перемещать курсор или выполнять нажатия кнопки мыши, вот так:
TEST(ClearQuickbarFilter)
{
TestScenarioGui scenario;
scenario.getPlayer()->quickBar[0].setID(ItemFilter("iron-plate"));
CHECK_EQUAL(scenario.player()->getQuickBar()[0].getFilter(), ItemFilter("iron-plate"));
scenario.click(scenario.gameView->getQuickBarGui()->getSlot(ItemStackLocation(QuickBar::mainInventoryIndex, 0)),
global->controlSettings->toggleFilter);
CHECK_EQUAL(scenario.player()->getQuickBar()[0].getFilter(), ItemFilter());
}
Затем метод нажатия вызывает низкоуровневые события ввода, чтобы тестировались все слои обработки событий и логики GUI. Это пример сквозного тестирования, которое является спорной темой, поскольку некоторые «школы» методологии тестирования считают, что всё нужно тестировать по отдельности. Поэтому в данном случае мы теоретически должны тестировать только нажатие на кнопку, которое создаёт InputAction для дальнейшей обработки, а затем проводить отдельный тест правильности работы InputAction. В некоторых случаях мне нравится такой подход, но чаще всего я люблю проникать сквозь все слои логики всего несколькими строками кода. (Подробнее об этом в разделе «Тестовые зависимости».)
Единственный способ двигаться быстро — двигаться правильно!
Пример 4 — TDD — разработка через тестирование
Должен признаться, что до недавнего времени почти ничего не знал о TDD. Я думал, что это какая-то чушь, потому что кажется очень непрактичным и нереалистичным сначала писать все тесты какой-то фичи (без возможности проверить или даже скомпилировать её), а затем пытаться реализовать нечто, что удовлетворяет этим тестам.
Но это — не TDD, и чтобы понять, насколько я ошибался, мне пришлось увидеть пример «для чайников».
На самом деле TDD — это постоянное быстрое переключение между расширением тестов и обеспечением их прохождения. В процессе написания тестов ты практически одновременно пишешь удовлетворяющий им код. Это позволяет мгновенно тестировать то, что ты пишешь, и использовать тесты в основном в качестве спецификации того, что должен делать код, что формирует процесс мышления, чтобы ты думал, в каком направлении двигаешься, и чтобы с самого начала писать более структурированный и тестируемый код.
После момента осознания того, что же такое TDD, я сразу же стал его фанатом. Теперь я прикладываю много усилий к тому, чтобы максимально следовать методологии TDD, и чтобы заставлять следовать ей и других участников команды. Кажется, что это медленнее, тебе приходится писать тесты даже для простых фрагментов кода, в которых вроде бы не может быть ошибок, но тесты уже множество раз доказывали то, что я был не прав, и позволяли избежать раздражающей низкоуровневой отладки в будущем.
Единственный способ двигаться быстро — двигаться правильно!
Пример 5 — тестовые зависимости
Это продолжение темы тестовых зависимостей в тестах GUI.
Если тесты должны быть по-настоящему независимыми, то нужно протестировать C и иметь некие имитации A и B, чтобы тест C не зависел от правильности работы системы A + B. Похоже, консенсус заключается в этом, и это приводит к более независимому проектированию и т.п.
Возможно, это применимо во многих случаях, но я считаю, что попытка реализации такого подхода везде почти невозможна, она приведёт к хаосу. К тому же, подобные проблемы возникают не только у меня.
Например, допустим, что у нас есть тест правильности соединения на карте опор ЛЭП. Но я вряд ли смогу протестировать его, если не знаю, правильно ли работает поиск сущностей на карте.
Я считаю, что наличие подобных зависимостей вполне допустимо, при условии, что эти зависимости тоже тестируются, но проблема возникает, когда внезапно начинает сбоить множество тестов. Когда ты вносишь небольшие изменения, индикации «да-нет» достаточно, но это не всегда возможно, особенно когда ты рефакторишь какую-то внутреннюю структуру. В этом случае ты, скорее всего, сломаешь многое, и тебе нужен способ исправлять всё это пошагово, пока код снова не станет компилироваться.
Если тесты не имеют какой-то особой структуры, ситуация, в которой одновременно сбоят сто тестов, очень неприятна. Тебе остаётся выбирать некоторые тесты полуслучайным образом и начинать их отлаживать. Но сильно вводит в заблуждение ситуация, когда какой-то сложный тестовый случай даёт сбой и ты тратишь много времени на его отладку, а потом выясняется, что сбой вызывает какой-то очень простой низкоуровневый баг.
Задача довольно проста — мне нужно найти самый простой уровень сбоя, вызванного моими изменениями.
Для этого я реализовал простую систему тестовых зависимостей. Тесты исполняются и перечисляются таким образом, что когда ты отлаживаешь и проверяешь тест, то знаешь, что все его зависимости уже работают правильно. Я пытался найти, как другие разработчики используют зависимости, и как они их реализуют, но, на удивление, ничего не нашёл.
Вот пример графа тестовых зависимостей, связанных с опорами ЛЭП:
Я создал и использовал эту структуру при рефакторинге дублируемой логики соединения призрачных/реальных опор ЛЭП, и это определённо ускорило процесс обеспечения правильности их работы. Я уверен, что этот способ структурного тестирования мы будем использовать в обозримом будущем. Он не только делает более полезными результаты тестов, но и заставляет нас разделять наборы тестов на мелкие, более специализированные юниты, что тоже помогает в работе.
Единственный способ двигаться быстро — двигаться правильно!
Пример 6 — покрытие тестами
Когда Boskid присоединился к нашей команде, чтобы работать в QA, одна из основных его задач заключалась в том, чтобы любой обнаруженный баг сначала покрывался тестами, а уже потом устранялся, а также в общем улучшении покрытия кода тестами. Благодаря этому релизы стали гораздо более уверенными, и у нас стало возникать меньше регрессионных багов, что напрямую ведёт к эффективности на длительную перспективу. Я верю, что это подтверждает сказанное дядей Бобом. Работа с тестами кажется более медленной, но на самом деле быстрее.
Покрытие тестами — показатель того, какие части кода выполняются при работе приложения (что обычно означает выполнение тестов в этом контексте). Я никогда раньше не использовал инструменты для измерения покрытия тестами, но поскольку они были одной из тем, о которых говорил дядя Боб, я впервые попробовал поработать с ними. Я нашёл инструмент, работающий только в Windows, но требующий самой минимальной настройки — OpenCppCoverage. Он возвращает результаты в HTML вот в таком виде:
Сразу же становится заметно, что обе условные команды не сработали в тестах. По сути, это означает, что или код не тестируется, то есть его нужно покрыть тестами, или это мёртвый код, и его нужно удалить. Я уверен, что использование этого инструмента сильно поможет нам в написании чистого высококачественного кода.
Единственный способ двигаться быстро — двигаться правильно!
Вывод
Единственный способ двигаться быстро — двигаться правильно!