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

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

Размер кодовой базы

Культура 1: ценит большие проекты

Культура 2: ценятся короткие, осмысленные фрагменты кода.

Эта разница, вероятно, диктует остальное. Молодые разработчики могут не в полной мере осознать масштабы проектов в первой культуре. Например, современная игра AAA, написанная на языке с синтаксисом, подобным C, может иметь миллионы строк кода, больше, чем кто-либо когда-либо мог прочитать. Еще более ярким примером является Linux с более чем 15 миллионами строк кода. Windows и macOS во много раз больше. Некоторые говорят, что производители автомобилей превзошли эти цифры: автомобиль Mercedes использует до 100 миллионов строк кода. Я не уверен, что это правда, но даже если это так, большинство этих строк, вероятно, избыточны. В любом случае, даже 100 миллионов — это ничто по сравнению с кодовой базой компании FAANG, которая может содержать буквально миллиарды строк кода.

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

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

  • В качестве примера возьмем простую функцию «перейти к определению» — возможность перейти к коду функции из того места, где она вызывается. Для кода, написанного на языках первой культуры, реализовать такую ​​функцию несложно; синтаксис определения функции ясен, и их известное количество, поскольку функции обычно не могут каким-то образом появляться «на лету». Омонимы, если они есть, можно отличить с помощью формальной процедуры проверки типа и объема. Такие IDE, как VS Code, могут попытаться предложить «перейти к определению» в таких языках, как Python и JS. Однако это только имитация и до некоторой степени имитация: поскольку функции являются первоклассными объектами, между «именем функции» и «телом функции» существует отношение «многие ко многим».
  • Или давайте рассмотрим контроль доступа участников: частный/публичный. Вопреки тому, что вы могли бы подумать, эта функция не связана с безопасностью в традиционном смысле; это необходимо для выживания в крупных проектах. Эта функция позволяет вам определить границы, в которых вы можете определить API, взаимодействующий с конкретным фрагментом кода и поддерживающий все необходимые инварианты. За пределами этих границ вы можете взаимодействовать только с этим API без возможности случайного нарушения инвариантов. Без этого разделения доступа в проекте с миллионами строк кода любой код может непреднамеренно помешать работе другого, что сильно затруднит выживание.

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

В больших проектах единовременные накладные расходы незначительны, поэтому создатели языков программирования первой культуры не заботились об этих затратах. Например, неважно, сколько места занимает программа, которая печатает «Hello, World!». Программист на C++ запустит его с помощью команды #include <iostream>, затем напишет еще несколько строк и ни о чем не думает. Java-разработчик должен сначала определить специальный класс. Программист Python посмотрит на них обоих как на сумасшедших: «Разве вы не можете просто написать print("Hello, World!"), как это делают нормальные люди?»

Этот принцип применим к любой ситуации, когда несколько строк кода должны нести смысл. REPL и первая культура вряд ли совместимы; например, блокноты Jupyter были бы невозможны на Java, несмотря на букву «J» в названии (теоретически можно было бы найти способы сделать это, но люди из первой культуры даже не подумали бы об этом).

Та же функция контроля доступа, упомянутая выше, также трудно согласуется с концепцией, согласно которой объекты — это просто ящики с разнородными данными, которые могут появляться откуда угодно. Например, они могут появиться во время выполнения через «eval». Это делает систему удивительно управляемой и настраиваемой во время выполнения; вы можете исправить код во время отладки, заменить его на лету и продолжить выполнение или иметь конфигурацию системы на том же языке, на котором написана вся система.

Для меня было огромным шоком узнать, что реализация нейросетей-трансформеров (великих и ужасных) занимает около 100 строк кода на Python. Конечно, это очень высокоуровневый код, и без PyTorch, NumPy, CUDA и т. д. этих строк не бывает. Тем не менее такой компактный код немыслим в первой культуре.

Когда код наполнен смыслом, скорость разработки может резко возрасти в соответствующих ситуациях.

Скорость

Культура 1: ценит скорость кода

Культура 2: ценит скорость кодирования

Большинству программистов, за исключением тех, кто кодирует на C/C++, может показаться странным слышать, что поведение программы, написанной на их языке, может быть «неопределенным», «неопределенным» или «определяемым реализацией», и что три совсем другие. «Неопределенное» поведение (наихудшее) означает, что программист допустил ошибку, но это не обязательно означает, что программа выдаст ошибку. Стандарт официально разрешает программе делать все, что она хочет, когда это происходит, буквально все, что угодно. Но зачем это кому-то нужно?

Многие из вас уже знают ответ: это позволяет компилятору выполнять различные низкоуровневые оптимизации. Например, если компилятор видит, что определение макроса или шаблона привело к выражению (x+1 › x), он может свободно заменить его на «true». Но что, если x == INT_MAX? Поскольку целочисленное переполнение является поведением undefined, компилятор оставляет за собой право игнорировать этот экзотический случай. В этом простом примере мы замечаем нечто пугающее: во время выполнения программы на самом деле нет момента, когда «возникает» неопределенное поведение; вы не можете «обнаружить» его, потому что он остался где-то в параллельной вселенной, но все еще влияет на нашу.

Если вы не программируете на C/C++, вы можете быть в шоке: Люди действительно пишут такие программы? Действительно, люди занимаются этим уже 50 лет, и все это время регулярно стреляют себе в ногу. Удобство компилятора стоит выше удобства программиста.

Напротив, в Python есть много примеров противоположного поведения. Начнем с простого:

>>> x = 2**32
>>> x*x
18446744073709551616
>>> x*x*x
79228162514264337593543950336

Никакого переполнения и никаких связанных с этим проблем! Уму программиста легче: целое число — это просто целое число, и вам больше не нужно думать о INT_MAX. Конечно, это блаженство имеет свою цену: арифметика BigInt намного медленнее, чем встроенная арифметика.

Вот менее известный пример:

>>> 15 % 10
5
>>> -15 % 10
5

Целочисленное деление в Python гарантирует, что остаток после деления на N всегда будет числом от 0 до N-1, даже для отрицательных чисел. С другой стороны, в C/C++ остаток после деления -15 на 10 равен -5. Опять же, первый подход экономит время и снижает когнитивную нагрузку на программиста. Например, при определении времени суток по временной метке программисту не нужно беспокоиться о том, старше ли временная метка 1970 года. Это не случайно — сам Гвидо ван Россум выбрал эту семантику, основываясь на аналогичной строке мысль. Последний подход лучше подходит для определенного оборудования и, таким образом, иногда на несколько пикосекунд быстрее.

Чтобы продемонстрировать степень беспокойства создателя Python подобными проблемами, вот последний пример: что вы ожидаете увидеть после запуска этого примера?

>>> round(1.5)
>>> round(2.5)
>>> round(3.5)
>>> round(4.5)

Вот ответ.

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

Наконец, интересно отметить, что при глубоком понимании обеих культур становится возможным найти компромисс между скоростью программы и скоростью программиста. Сложные вычисления можно выполнять с помощью библиотеки, написанной на C++ (иногда Fortran) с привязками к Python или Lua. Затем вы можете использовать эти библиотечные функции для создания сложных структур, таких как блоки Lego. Например, обучение и инференс больших нейронных сетей — одни из самых высоконагруженных ИТ-проектов на сегодняшний день — развиваются преимущественно в границах второй культуры. Программа для обработки чисел спрятана под капотом. Знать его особенности по-прежнему полезно, но эти знания уже не нужны даже для достижения результатов мирового уровня.

Тестирование

Культура 1. Можно строго математически доказать, что код не содержит ошибок определенного типа.

Культура 2: есть статистические данные о том, что код почти всегда ведет себя так, как ожидалось.

Сопоставив скорость программы и скорость программиста, может показаться, что первая культура не заботится о безопасности и удобстве. Это обобщение совершенно неверно (как и любое чрезмерное обобщение). На самом деле программисты ценят, когда компилятор все проверяет за них. Идея «а что, если бы компилятор мог проверить все, что только возможно» лежит в основе Rust — всего языка, который быстро становится мейнстримом.

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

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

Как видите, четкой границы между двумя культурами нет. Некоторые программисты старой школы C++ могут также писать модульные тесты, а некоторые программисты Python могут создавать анализаторы кода. Однако каждая культура склонна использовать свой набор устоявшихся практик. Это верно и для всех других различий между двумя культурами.

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

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

  • В среднем компиляторы во много раз надежнее обычных программ, а их поведение гораздо более строго регламентировано,
  • Даже если компилятор не «заметит» ошибку, она будет обнаружена после небольшого изменения кода,
  • Речь идет не об одном редком событии, а о совпадении двух маловероятных условий (компилятор не увидел ошибку программиста, и этот дефект тоже невероятно редок),
  • И так далее.

Большой пример

В течение многих лет, обсуждая эту тему, я использовал этот пример. Давным-давно, когда я работал над игрой RTS для Nintendo DS, тестировщики обнаружили проблему рассинхронизации в многопользовательском режиме.

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

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

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

Но почему игра вообще рассинхронизируется? К счастью, неинициализированные переменные можно исключить: мы написали собственную систему управления памятью, а данные игрового процесса хранятся в отдельной локации, изначально заполненной нулями. Это гарантирует, что без рассинхронизации их состояния будут идентичны до бита, даже если будет несколько неинициализированных переменных. Это означает, что один из разработчиков должен был сделать что-то неожиданное, например, вызвать функцию, которая не обязана быть синхронной, например, обращение к пользовательскому интерфейсу, из логики игры. Технически это сделать непросто: как минимум в логике игры придется прописать #include <../../interface/blah-blah.h>, не учитывая очевидных последствий этого. Простой поиск по регулярному выражению показал, что никто не был настолько глуп.

Именно тогда я понял, что передо мной стоит задача проверки типов. Меня интересовали не типы в смысле языка, а «логические типы» функций, так что это не типовая задача. Что-то вроде Haskell не делает различий между этими двумя понятиями, но мы говорим здесь о C++.

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

Основные выводы:

• Кодекс содержал некоторые нарушения этих правил; в противном случае ошибка рассинхронизации не возникнет.

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

Отлично, теперь мне пришлось взять на себя роль компилятора и переименовать все функции «doSmth» на границе между игровым миром, пользовательским интерфейсом и графикой либо в «doSmthSync», либо в «doSmthAsync», при этом отслеживая, какая из них вызывает какую. Менее чем за час все типографские ошибки были исправлены, и я смог восстановить цепочку событий, приведших к рассинхронизации.

Я нашел ошибку, в которой интерпретировалось падение наковальни (как мы все знаем, наковальня — это мультяшное супероружие). Для проверки того, что наковальня упала на пустое место или она была направлена ​​на конкретного юнита, по ошибке использовалась неправильная функция: isVisible(getCurrentPlayer()) вместо isVisible(player sho is dropping the anvil).

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

Я не могу придумать ни одного модульного теста или любого другого теста, который смог бы поймать такое фантастическое совпадение. Чтобы такие баги не попадали в игру, вам нужны самоотверженные тестеры-люди. Крайне желательно также обеспечить математически строгий код.

Коротко об остальном

Культура 1: проверка типов во время компиляции

Культура 2: утиный набор текста

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

Культура 1: «Я прочитал код всех библиотек, от которых зависит мой проект, и мне с ними комфортно».

Культура 2: npm install left-pad

Новый код, делающий что-то новое, — это одновременно и благословение, и проклятие. Рассмотрим огромный проект, в котором каждые 10 000 строк кода реализуют свою функцию, строго определенную спецификациями (чтобы не наступил хаос и все не развалилось). В таком проекте каждая строчка кода, особенно если вы ее не писали сами, — болевая точка и потенциальная бомба замедленного действия. С другой стороны, иногда вам просто нужно загрузить изображение в формате PNG и определить, есть ли в нем хот-дог. Либо вы можете сделать это в четыре строчки, не слишком задумываясь, либо вы не можете — и люди второй культуры могут сделать это чаще, чем нет.

Культура 1: статическая сборка

Культура 2: динамическая сборка, включая загрузку библиотек из глубин Интернета.

В игре два вопроса:

  • Могут ли незнакомцы внезапно сломать ваш продукт без каких-либо действий с вашей стороны?
  • Могут ли незнакомцы значительно улучшить ваш продукт без каких-либо действий с вашей стороны? (Например, залатайте дыру в безопасности, о существовании которой вы даже не подозревали)

Как вы, наверное, догадались, ответы на эти два вопроса тесно связаны.

Культура 1: документация хранится локально

Культура 2: документация хранится в Интернете (на вашем веб-сайте, GitHub, в разделе «Прочтите документы» или в Stack Overflow).

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

Культура 1: формальные языки, алгоритмы поиска, анализ конечных автоматов, сложные структуры данных.

Культура 2: глубокое обучение

В каждой культуре есть свои герои и великие волшебники. Они делают что-то настолько крутое и научное, что хочется быть на них похожими. В первой культуре эти люди являются создателями компиляторов и стандартных библиотек. Любой, кто читал «Книгу дракона», знает, что даже простой компилятор — невероятно сложная штуковина. Написать еще один контейнер данных на C++ очень просто, но сделать такой, который будут использовать другие, — само по себе искусство. Технологические достижения читаются как доказательство математической теоремы: «Таким образом, мы обнаружили, что существует структура данных с амортизированным временем поиска O(1) и временной сложностью O(In In N)».

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

Средний возраст «передовой технологии» в первой культуре — 25 лет. Как правило, такая технология была создана одним из многих известных деятелей, таких как Керниган, Томпсон, Вирт, Хоар, Дейкстра или Торвальдс.

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