Две культуры программирования

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

yhhujsfn4s3h8mppdl3qetfzlco.jpeg

Изначально я человек первой культуры и очень долгое время считал вторую несерьёзной. Пару-тройку лет назад я окончательно понял, что ошибался. Многие «старички» ошибаются в ту же сторону, а в последние годы ещё большее число людей ошибаются в обратную. Знакомство с соседней культурой и понимание, почему дела в ней делаются так, как там принято, превратит вас в лучшего разработчика.

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


Культура 1: важны большие проекты
Культура 2: важны короткие фрагменты кода, насыщенные содержанием

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

6s5li4qllelpjogaezmxf8a_0z8.jpeg

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

Чтобы ориентироваться в большой кодовой базе, нужны средства навигации и рефакторинга. И то и другое гораздо проще в одних языках, нежели в других.

  • Возьмём, например, простейшую фичу go to definition: в месте вызова функции/метода посмотреть, где её код. В языках первой культуры сделать это тривиально: определение функции имеет чёткий синтаксис; их конечное число; омонимы, даже если есть, в конкретном месте различимы при помощи формальной процедуры проверки типов и областей видимости. VS Code и прочие IDE, конечно, притворяются, что и в Python или в JS тоже можно сделать go to definition, и иногда им это даже удаётся. Но поскольку функции — first class objects, «имя функции» и «тело функции», вообще говоря, относятся друг к другу как «многие к многим».
  • Или возьмём разделение доступов к членам класса — private/public. Эта фича на самом деле не имеет никакого отношения к безопасности в смысле security, как могло бы показаться со стороны, а важна она именно для выживания в больших проектах. Она позволяет очертить границы, «внутри» которых можно на обозримом пространстве определить API общения с данным куском кода, поддерживающее все нужные инварианты, а «снаружи» дёргать только это API, не имея физической возможности случайно, по ошибке, их нарушить. Если такого разделения нет, то в проекте на миллионы строк любой код может случайно нарушить работу любого другого и выживание, конечно, будет серьёзно затруднено.


Опять-таки для человека из первой культуры вторая начинает казаться игрушечной. Это совершенно неверно.

В проектах огромных размеров совершенно не важны разовые накладные расходы, поэтому создатели языков первой культуры их и не экономили. В частности, совершенно неважно, сколько занимает программа, выводящая «Hello, World!». Плюсовик начнёт её с заклинания #include , дальше напишет ещё несколько строк и не видит в этом ничего особенного; джавист должен для начала объектно-ориентированно определить специальный класс. Для питониста всё это дико, почему нельзя просто написать print("Hello, World!"), как у нормальных людей?

prt_wiyhzdw15mffmlc8v7zx9xu.jpeg

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

То же упомянутое выше разделение доступов очень плохо сочетается с концепцией, в которой объекты — просто коробочки с разнородными данными, способные возникать откуда угодно. Например, добавляться уже в момент исполнения, через eval. Это делает систему удивительно управляемой и конфигурируемой в рантайме: вы можете прямо в процессе дебага исправить код, мгновенно заменить его на лету и продолжить исполнение. Или вы можете иметь конфиг системы на том же языке, что и вся система.

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

Ясно, что при подобной выразительности скорость разработки в подходящих ситуациях драматически растёт.

Скорость


Культура 1: важна скорость работы программы
Культура 2: важна скорость работы программиста

Для программиста на любом, наверное, языке программирования, кроме C/C++, крайне странно слышать, что поведение написанной на них программы может оказаться «неопределённым» (undefined), «неуточнённым» (unspecified), «определяемым реализацией» (implementation-defined) — и это три разные вещи. «Неопределённое» поведение (худшее из трёх) означает, что программист ошибся, но не означает, что программа выдаст ошибку; стандарт официально разрешает ей в этот момент сделать буквально что угодно. Зачем такое вообще могло понадобиться?!

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

Если вам не приходится программировать на С/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. И опять один подход экономит время и когнитивную нагрузку программиста: как узнать время суток, если у вас есть timestamp? Должны ли вы в этот момент думать о том, может ли текущий timestamp оказаться старше 1970 года? Это не случайность, сам Гвидо ван Россум подтверждает, что выбрал такую семантику именно из подобных соображений. Ну, а второй подход лучше ложится на конкретное hardware и потому на сколько-то пикосекунд быстрее.

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

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


Ответ
>>> round(1.5)
2
>>> round(2.5)
2
>>> round(3.5)
4
>>> round(4.5)
4


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

Наконец, интересно, что при глубоком понимании обеих культур вполне возможен удачный компромисс между скоростью работы программы и программиста. Тяжёлыми вычислениями занимается библиотека, написанная на С++ (а то и на Фортране), снабжённая биндингами для Python или Lua, а уже на последних из этих библиотечных функций, как из кубиков Лего, собираются сложные конструкции. Приведу пример: наверное, самые вычислительно нагруженные IT-проекты современности — обучение и инференс больших нейросетей — сейчас в основном развиваются в рамках второй культуры. «Числодробилка» находится где-то глубоко под капотом; знать о её особенностях всё ещё полезно, но уже не обязательно — даже для получения результатов мирового класса.

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


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

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

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

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

Как уже можно заметить, строгой границы между культурами нет; некоторые плюсовики старой школы с удовольствием пишут юнит-тесты, а некоторые питонисты — анализаторы кода. И всё же каждая культура тяготеет к своему набору практик. Это замечание верно и для всех остальных отличий между ними.

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

auk-s98vdgm6aqa4rx1cpser9km.jpeg

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

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


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

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

Это крайне неприятный тип ошибки. Многопользовательская игра у нас была организована по p2p-схеме: разные DS-ки передавали друг другу только команды пользователей и каждая просчитывает состояние всего мира. Это идеальная для RTS схема, позволяющая организовать богатую фичами игру на основе очень узкого канала передачи данных и минимального «языка общения». Основной её недостаток в том, что вся игровая логика должна быть абсолютно детерминированной, то есть, получая один и тот же набор команд, на выходе всегда иметь одинаковое состояние игрового мира. Нельзя допустить никакого рандома, никакой зависимости от системного таймера, никаких шалостей с битами округления, неинициализированных переменных и тому подобного.

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

Вот какое описание бага я получил от тестировщика: «Иногда при невыясненных обстоятельствах происходит рассинхронизация. Что именно к ней приводит, непонятно. Reproduce: создать игру на четырёх человек и играть до посинения, активно пользуясь героями и их возможностями. Если игра закончилась и всё хорошо — повторить. Рано или поздно, сейчас или через день, ошибка случится».

Неприятно. В попытках повторить баг я провёл пару часов, но лишь убедился, что да, оно случается. Что делать?

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

Тут я осознал, что передо мной задача проверки типизации. Не очень, правда, распространённая: меня интересовали не типы в смысле языка, а «логические типы» функций. Какой-нибудь Haskell не видит особенных различий между этими понятиями, и правильно делает, но у нас-то C++.

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

Что здесь важно:

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

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

Ошибка оказалась в том месте, где интерпретировалась команда сбрасывания наковальни (как известно, это такой мультяшный аналог cупероружия). Чтобы проверить, сброшена она куда-то в пустую точку игрового поля или же наведена на конкретного юнита, по ошибке использовалась конструкция isVisible(getCurrentPlayer()), а не isVisible(игрок, который сбрасывает наковальню).

Вот как воспроизводился баг: одному игроку нужно было построить Скаута, сделать его невидимым и пойти на вражескую базу. Второму нужно было построить Снайпера и использовать способность «сбросить наковальню» на точку, в которой стоит (или через которую проходит) невидимый Скаут — вернее, на верхнюю половину его туловища. Эта команда на одной DS-ке означала «сбросить наковальню в точку за торсом Скаута», а на другой — «сбросить наковальню на Скаута» (в точку у него под ногами). Мало того, важно было не подходить череcчур близко, чтобы не «заметить» Скаута и не вывести его из состояния невидимости.


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

f6dtir6dh9r1bcqndhfz3nvepje.jpeg

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


Культура 1: проверка типов на этапе компиляции
Культура 2: duck typing

Почему так, уже должно быть примерно понятно по предыдущим разделам. Элементы и того, и другого, применённые к соседней культуре, точечно могут оказаться крайне полезными, если понимать, что «так можно было».

Культура 1: «Я прочитал код всех библиотек, от которых зависит мой проект, и уверен в них»
Культура 2: npm install left-pad

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

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

Есть два вопроса:

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


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

Культура 1: документация ведётся локально
Культура 2: документация находится в интернете (на сайте, Гитхабе, Read the Docs, Stack Overflow)

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

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

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

Во второй культуре герои — люди, которые заставляют всех удивиться тому, что теперь компьютеры умеют ещё и ТАКОЕ. Довольно часто инсайдерам это понятно заранее, что ничуть не умаляет достижение. Например, понятно, что кто-нибудь первым обучит diffusion для генерации видео без хаков, end-to-end. И, скорее всего, это произойдет до наступления Q2 2023, но результат всё равно будет поражать, и тот, кто это сделает, всё равно будет героем.


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

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

9mao5f5ks014qoyzjh8z7_n3oca.jpeg

© Habrahabr.ru