Pull to refresh

Исключительная красота исходного кода Doom 3

Reading time 14 min
Views 219K
Original author: Shawn McGrath
image

Сегодня вас ждет рассказ об исходном коде Doom 3 и о том, насколько он красив.
Да, красив. Позвольте мне объясниться.

После выпуска моей видеоигры Dyad я решил взять небольшой перерыв. Я наконец-то прочитал несколько книг и посмотрел фильмы, которые так долго откладывал в дальний ящик. Тогда я работал над европейской версией Dyad, но все это время я большей частью ждал фидбеков от отдела качества Sony, так что у меня было более чем достаточно времени. После месяца подобного времяпровождения я всерьез задумался над тем, что же мне делать дальше. Я вспомнил, что давно собирался отделить от Dyad куски исходного кода, которые хотел использовать в своем новом проекте.

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

За последние 6 недель разработки Dyad я дописал 13k строк кода. Один только исходник главного меню MainMenu.cc раздулся до 25 501 строки. Когда-то прекрасный код превратился в настоящий бардак из всяких #ifdef, указателей на функции, уродливых SIMD и ассемблерных вставок — а я открыл новый для себя термин «энтропия кода». С грустью взглянув на все это, я отправился в путешествие по интернету в поисках других проектов, которые помогли бы мне понять, как другие разработчики красиво управляются с сотнями тысяч строк кода. Но после того, как я взглянул на код пары больших игровых движков, я был просто обескуражен; мой «ужасный» исходный код по сравнению с остальными оказался еще чистейшим!

Я продолжил свои поиски, неудовлетворенный таким результатом. В конце концов, мне попался на глаза интересный анализ исходного кода Doom 3 от id Software, написанный Фабианом Санглардом.

Я провел несколько дней в изучении исходников Doom 3 и за чтением статей Фабиана, после чего выдал твит:
Я потратил некоторое время на изучение исходников Doom3. Это, наверное, самый понятный и симпатичный код из всех, что я видел.

И это было правдой. До этого момента я никогда не заботился об исходном коде. Да я, на самом деле, не слишком люблю называть себя «программистом». У меня неплохо получается, но для меня программирование длится ровно до того момента, как все начнет работать. После просмотра исходного кода Doom 3, я действительно научился ценить хороших программистов.

***
Чтобы вы получили какое-то представление: Dyad содержит 193k строк кода, все на С++. Doom 3 — 601k, Quake III — 229k и Quake II — 136k. Это большие проекты.

Когда меня попросили написать эту статью, я использовал это как оправдание для чтения еще некоторого количества исходного кода других игр и статей о стандартах программирования. После нескольких дней своих исследований, я бы смущен своим собственным твитом и это навело меня на мысль — так что же все-таки стоит считать «красивым» исходным кодом? Я спросил нескольких из своих друзей-программистов, что по их мнению это значило. Их ответы были очевидными, но все же имеет смысл их привести здесь:

  • Код должен быть сгруппирован локально и едино-функционален: Одна функция должна делать ровно одну вещь. Должно быть понятно, что делает конкретная функция.
  • Локальный код должен объяснять или хотя бы указывать на архитектуру всей системы.
  • Код должен быть задокументирован «сам по себе». Комментариев следует избегать во всех возможных ситуациях. Комментарии дублируют работу и для чтения, и для написания кода. Если вам требуется что-то прокомментировать, то скорее всего оно должно быть переписано с нуля.

Для idTech 4 стандарты кода выложены в открытый доступ (.doc) и я могу порекомендовать их как достойное чтиво. Я пройдусь по большинству из этих стандартов и попробую разъяснить, каким же образом они делают код Doom 3 настолько прекрасным.

Универсальный парсинг и лексический разбор

Одна из самых умных вещей, которые я увидел в Doom, это использование их лексического анализатора и парсера по всей программе. Все файлы ресурсов представляют собой ascii файлы с единым синтаксисом включая: скрипты, файлы анимации, конфиги и пр.; все одно и то же. Это позволяет читать и обрабатывать все файлы одним и тем же куском кода. Парсер особенно надежен, и поддерживает главное подмножество С++. Приверженность единому парсеру и лексическому анализатору помогает остальным компонентам движка не беспокоиться о сериализации данных, так как код, отвечающий за эту часть приложения, уже написан. Благодаря этому, остальной код становится куда как яснее.

Const и строгие параметры (Rigid Parameters)

Код Doom достаточно строг, но (на мой взгляд) недостаточно строг по отношению к const. Const служит нескольким причинам, которые как я уверен игнорирует слишком много программистов. Мое правило таково: «везде должно использоваться const, кроме тех случаев, где оно не может быть использовано». Я мечтаю о том, чтобы все переменные в C++ по умолчанию были const. Doom почти всегда придерживается политики «без входно-выходных» для параметров; имеется в виду, что все параметры передаваемые в функцию являются или вводом, или выводом, и никогда не совмещают эту роль в одном лице. Этот нехитрый прием позволяет куда как быстрее видеть, что происходит с какой-нибудь переменной, когда вы передаете ее в функцию. К примеру:

image

Одно лишь определение этой функции уже делает меня счастливым!

Лишь из нескольких вещей, которые сразу бросаются мне в глаза, уже становится понятно очень многое:
  • idPlane передается функции как неизменяемый аргумент. Я могу спокойно использовать эту же плоскость после вызова этой функции без проверки на изменение idPlane.
  • Я знаю, что эпсилон не будет изменено внутри функции (несмотря на то, что оно может быть без проблем скопировано в другую переменную и использовано для ее инициализации — такой способ будет непродуктивным)
  • front, back, frontOnPlaneEdges и backOnPlaceEdges — это ВЫХОДНЫЕ переменные. В них будет осуществляться запись.
  • финальный модификатор const после списка параметров — мой самый любимый. Он указывает на то, что idSurface::Split() не сможет изменить саму поверхность (surface). Это одна из моих самых любимых возможностей в С++, по которой я так скучаю в других языках. Она позволяет мне вытворять подобное:
    void f(const idSurface &s) {
    s.Split(....);
    }

    если Split не была бы определена как Split(...) const; этот код бы не скомпилировался. Теперь я всегда буду знать, что при любом вызове f() не изменит поверхность, даже если f() передается поверхности другой функцией или вызывает какой-либо из методов Surface::method(). Const говорит мне о многом насчет этой функции и также дает намеки насчет общей архитектуры системы. Одно чтение объявления этой функции дает понять о том, что поверхности могут разделяться плоскостями динамически. Вместо изменения исходной поверхности, нам будут возвращены новые поверхности — передняя и задняя, а также, возможно, боковые frontOnPlaneEdges and backOnPlaneEdges.


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

Минималистичные комментарии

Данный пункт, конечно, все-таки больше касается вопросов стиля написания кода, но тем не менее — в Doom есть такая замечательная вещь, как отсутствие излишнего комментирования. Я в своей практике встречал слишком много кода, весьма похожего на подобный:
image
Такие приемы, на мой взгляд, весьма и весьма раздражающая штука. Почему? Потому что я и так смогу назвать, что делает этот код, — достаточно лишь взглянуть на его имя. Если же мне неясно назначение метода из его названия, то его название должно быть изменено. Если название слишком длинное — сократите его. Если оно не может быть изменено и так уже сокращено — что ж, тогда можно и использовать комментарий. Всех программистов со школьной скамьи учат, что комментирование — это хорошо; но это не совсем так. Комментарии — это плохо, до той поры, пока они не станут необходимы. А необходимы они крайне редко. Создатели Doom проделали ответственную работу для того, чтобы свести число комментариев к минимуму. Используя в качестве примера idSurface::Split(), посмотрим на то, как она закомментирована:

// разделяет поверхность на переднюю и заднюю поверхности, сама поверхность остается неизменной
// frontOnPlaneEdges и backOnPlaneEdges опционально хранят индексы вершин, которые лежат на краях разделяющей плоскости
// возвращает SIDE_?

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

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

Отступы

Doom не склонен тратить понапрасну свободное вертикальное пространство экрана.
Вот пример из t_stencilShadow::R_ChopWinding():

image

Я могу прочитать весь алгоритм без проблем, потому что он помещается на 1/4 моего экрана, оставляя другие 3/4 для того, чтобы понять, какое может этот код иметь отношение к окружающему его. Я за свою жизнь видел слишком много подобного:

image

Здесь будет еще одно замечание, подпадающее под категорию «стиль». Я более 10 лет программировал в стиле последнего примера, и заставил себя перейти к более компактному коду только шесть лет назад во время работы над одним из проектов. Я рад, что вовремя переключился.

Второй способ занимает 18 строк по отношению к 11 строкам в первом. Почти в два раза больше строк кода при той же самой функциональности. К тому же, следующий кусок кода явно не поместится на моем экране. А что там в нем?

image

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

Другая вещь, которую id решила принять как постоянное правило, я тоже настоятельно поддерживаю — это решение всегда использовать { }, даже когда это не является необходимым. Я повидал слишком много кода подобного этому:

image

Я не смог найти ни одного примера в коде id, где они хоть раз пропустили бы { }. Если опустить дополнительные { }, то разбор блока while() займет в разы больше времени, чем должен был бы. Кроме того, любая правка превращается в настоящее страдание — достаточно представить, что мне потребуется вставить if-условие на пути else if (c > d).

Минимальное использование шаблонов

id нарушили один из величайших запретов в мире С++. Они переписали все потребовавшиеся функции STL. Лично я состою с STL в отношениях «от любви до ненависти один шаг». В Dyad я использовал ее в отладочных билдах для управления динамическими ресурсами. В релизе я запаковал все ресурсы так, что их стало возможным загружать настолько быстро, насколько вообще можно, и они перестали использовать функционал STL. STL довольно удобная вещь благодаря тому, что она дает доступ к основным структурам данных; ее главная беда в том, что ее использование приводит к некрасивому коду и подвержено ошибкам. Например, взгляните на класс std::vector. Скажем, если мне нужно перебрать все элементы:

image

В C++11 то же самое выглядит куда как проще:

image

Лично мне не нравится использование auto, мне кажется, оно делает код проще для написания, но тяжелее для чтения. Я иногда использовал auto в прошлые годы, но сейчас мне кажется, что это было неправильным решением. Я не собираюсь сейчас даже начинать обсуждать нелепость некоторых из алгоритмов на STL, таких как std:for_each или std::remove_if.

Удаление значения из std::vector — тоже тот еще ужас:

image

Представьте, каждый программист каждый раз должен набирать эту строчку правильно!

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

Код на С++ быстро становится неуправляемым и некрасивым, так что программистам постоянно приходится прилагать свои усилия для получения обратного эффекта. А чтобы вы поняли, насколько вещи могут далеко зайти, посмотрите на этот исходный код STL. Имплементация STL у Microsoft и в GCC — один из самых страшных исходных кодов из всех, что мне приходилось видеть. Даже если программист сдувает с кода шаблона любые пылинки, код все равно превращается в полный бардак. Для примера, взгляните на библиотеку Loki от Андрея Александреску, или на библиотеки boost – эти строки написаны одним из самых лучших программистов на С++ в мире, и даже его старания сделать их настолько красивыми, насколько это возможно, смогли выродиться лишь в некрасивый и совершенно нечитаемый код.

Как же решает эту проблемы id? Они просто не пытаются привести все к «общему знаменателю», сверх-обобщая свои функции. У них есть классы HashTable и HashIndex, первый обязывает тип ключа быть const char *, а второй — пару int->int. В случае С++, подобное решение принято считать плохим — «следовало бы» завести единый класс HashTable, и написать в нем две разные обработки для KeyType = const char * и <int, int>. Но то, что сделала id, также корректно, и более того — сделало их код в разы красивее.

Убедиться в этом совсем несложно, достаточно проследить контраст между «хорошим стилем программирования на С++» для генерации хэша и способом, которым с ней разобралась id.

Многим покажется неплохой мыслью создать специальный класс вычислений, который можно передать как параметр в HashTable:

image

это может быть задано как определенный тип:

image

Теперь вы можете передавать ComputeHashForType в качестве HashComputer для HashTable:

image

Похожим образом я сделал у себя. Выглядит умным решением, но… как же некрасиво! Что если мы стоклнемся с большим числом параметров в шаблоне? С аллокатором памяти? С отладкой? Тогда у нас получится что-то вроде этого:

image

Брутальное определение функции, не так ли?

image

Так к чему это все? Я едва ли смог бы найти имя метода без яркой подсветки синтаксиса. Вполне вероятно, что определение функции займет больше места, чем ее тело. Однозначно тяжело для восприятия и не слишком красиво.

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

image

и

image

и вы использовали их вместе и сделали что-то вроде этого:

image

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

Doom идет вразрез с принципами логики C++: код написан настолько конкретным, насколько это вообще возможно, использует обобщения только там, где это имеет смысл. Что же делает HashTable из Doom, когда ему требуется сгенерировать хэш или еще что-нибудь? Он вызывает idStr::GetHash(), потому что единственный тип ключей, которые он принимает, это const char *. Что случится, если потребуется другой ключ? Мне кажется, что они шаблонизируют ключ и просто принудительно вызывают key.getHash(), а компилятор обеспечивает, что типы ключей имеют метод int getHash().

Остатки в «наследство» от С

Не знаю точно, сколько из программистов id в 90-ых работает в компании сейчас, но как минимум сам Джон Кармак имеет большой опыт программирования на С. Все игры id до Quake III были написаны на С. Мне встречались программисты С++, которые не имели большого опыта программирования на С, так что их код был слишком С++зирован. Прошлый пример был лишь одним из множества — вот другие, которые встречаются мне довольно часто:

  • слишком частое использование get/set методов
  • использование stringstream
  • чрезмерная перегрузка операторов.

id строго следит за всеми этими случаями.

Часто бывает, что кто-то создает класс подобным образом:

image

Это пустая трата строк кода и последующего времени на его чтение. Такой вариант съест больше вашего времени, чем

image

А что если вам часто приходится увеличивать var на некоторое число n?

image

в сравнении с

image

Первый пример гораздо проще для записи и чтения.

id не использует stringstream. stringstream содержит одну из самых главных «бастардизаций» перегрузки операторов, которую мне доводилось встречать: <<.

К примеру,

image

Это некрасиво. У подобного способа есть сильное преимущество: можно определить эквивалент функции toString() из Java для определенного класса, который затронет переменные класса, но синтаксис станет слишком неудобным, и id принимает решение не использовать данный метод. Выбор в пользу printf() вместо stringstream делает код более простым для восприятия, и я считаю этот выбор правильным.

image

Гораздо лучше!

Синтаксис оператора << для SomeClass доходит до смешного:

image

[Примечание: Джон Кармак однажды заметил, что программы статистического анализа кода помогли выяснить, что их общий баг был вызван некорректным соответствием параметров в printf(), Интересно, перешли ли они на stringstream в Rage из-за этого?.. GCC и clang оба выдают сообщения о подобной ошибке в случае использования флага -Wall, так что вы все можете увидеть сами, не прибегая к дорогостоящим анализаторам для поиска данных ошибок.]

Еще один принцип, который делает код Doom таким красивым, это минимальное использование перегрузки операторов. Это очень популярная и удобная функция, появившаяся в С++, позволяет вам делать что-то вроде этого:

image

Без перегрузки эти операции станут менее очевидными и потребуют больше времени на написание и чтение. Здесь Doom и останавливается. Я видел код, которые идет дальше. Я видел код, который перегружает оператор '%' чтобы обозначить скалярное произведение векторов, или оператор Vector * Vector, который выполняет умножение векторов. Бессмысленно заводить оператор * для подобного действия, которое будет осуществимо только в 3D. Ведь если вам захочется сделать some_2d_vec * some_2d_vec, то что вы прикажете делать? А что вы скажете насчет 4d или больше? Вот почему принцип минимального вмешательства от id правилен — он не оставляет нам разночтений.

Горизонтальные отступы

Одной из самых важных вещей, которые я узнал из исходного кода Doom, стала простая смена стиля. Я привык, что мои классы выглядят примерно так:

image

По стандарту кода для Doom 3, id используют реальную табуляцию, которая соответствует 4 пробелам. Одинаковая табуляция по умолчанию позволяет всем программистам выравнивать определения их классов по горизонтали без лишних раздумий:

image

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

image

Сам я против излишнего набора символов на клавиатуре. Главное, что мне надо — это сделать свою работу настолько быстро, насколько это возможно — но в данной ситуации небольшой перебор с набором текста при определении класса окупается не раз и не два в том случае, когда программисту приходится просматривать определение класса. Есть еще несколько примеров стиля кодинга, которые описаны в документе Doom 3 Coding Standards (.doc), ответственному за все красоты исходного кода Doom 3.

Имена методов

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

Например:

image

гораздо лучше, чем:

image

Да, он невероятно красив.

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

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

Я бы хотел посоветовать всем заглянуть в исходный код Doom 3, потому что такой исходный код увидите не каждый день: тут вам полный набор, от дизайна архитектуры системы до табуляции между символами.

Шон МакГраф (Shawn McGrath) — игровой разработчик, проживающий в Торонто, создатель популярной психоделической игры для Playstation 3 — паззла-гонки Dyad. Советуем взглянуть на его игру и подписаться на него в Твиттере.

Примечания

Прим. Джона Кармака

Спасибо! Несколько комментариев:

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

Я порядком «напетлял» с С++ в Doom 3 — дело в том, что я был опытным программистом на С с умениями в ООП, оставшимися со времен NeXT и Objective-C, так что я начал писать код на С++ без полного изучения всех принципов использования языка. Оглядываясь назад, я могу заметить, что сильно жалею о том, что не прочитал Effective C++ и еще кое-что по этой теме. Пара других программистов имела достаточный опыт на С++, но они большей частью следовали моим стилистическим выборам.

Я не доверял шаблонам много лет, да и сейчас использую их с опаской, но я как-то решил, что прелести строгой типизации перевешивают чашу весов в сторону, противоположную странному коду в заголовочных файлах. Так что споры вокруг STL все еще не утихают у нас в id, и теперь они получили дополнительного «огонька». Возвращаясь к тем дням, когда начиналась разработка Doom 3, я могу практически наверняка сказать, что использование STL определенно стало бы неудачной идеей, но сейчас… появилось много разумных аргументов «за», даже и в случае игр.

Сейчас я стал страшным «const nazi», и я отчитываю любого программиста, который не делает переменную или параметр константой, если они могли бы быть ею.

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

[www.altdevblogaday.com]

Прим. переводчика

Я сам наткнулся на блог Фабиена около полутора лет тому назад, и могу смело порекомендовать его всем интересующимся — если и не ради вдумчивого чтения, то хотя бы ради вдохновения.
По поводу «чистого» кода — в Твиттере я спрашивал не так давно у Кармака, чего бы он порекомендовал почитать по теме. Он настоятельно советовал книгу «Art of the Readable Code» (Amazon).
Tags:
Hubs:
+231
Comments 245
Comments Comments 245

Articles