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

Как понять, что именно держит вашу программу – архитектура или компилятор? Можно задать себе несколько неудобных вопросов. Например: «А как бы это всё выглядело на C?»

В романе Хайнлайна «Туннель в небе» есть интересный эпизод. Главному герою, Роду Уокеру, предстоит экзамен: выжить в одиночку на незнакомой планете. Перед экзаменом он обсуждает снаряжение с сестрой Хелен и спрашивает, какое ружьё или пистолет взять. Сестра – опытная военная, капитан корпуса «Амазонок» – советует взять только нож и объясняет почему:

С пистолетом на боку ты готов ко всему, и тебе даже хочется приключений. Вот тут-то и кроется опасность, потому что всё это тебе только кажется. <…> Я отправляю на разведку с голыми руками, иногда босиком, без всего вообще.

Инструмент, который обещает защитить, снижает осторожность. В программировании так же.

Примеры дальше – на C++, потому что это мой основной язык. Но вопрос подходит для любого языка, который что-то делает за вас или что-то за вас проверяет: сборщик мусора в Java, C# и Python, ARC в Swift, borrow checker в Rust. C здесь не столько язык, сколько точка отсчёта – язык, который не гарантирует почти ничего.

Чего нет в C

На C можно сделать почти всё: наследование, инкапсуляцию, полиморфизм, а с кодогенерацией – и квазишаблоны (на сгенерированный код даже можно посмотреть и отладить его по шагам). Можно прикрутить и сборщик мусора (Boehm GC), но это уже экзотика. Чего в C нет, так это гарантий.

Начнём с того, что компилятор не добавляет вызовы конструктора и деструктора. Ручной вызов конструктора пережить можно: переменная объявляется один раз. А точек выхода из функции может быть много, и забыть вызвать деструктор или вызвать его дважды – проще простого. Принцип single entry, single exit как раз здесь помогает.

Проблема не в том, что придётся явно вызывать foo_init() и foo_deinit(). Проблема в том, что компилятор не гарантирует, что вы ничего не забудете.

Где заканчивается удобство и начинается зависимость?

Что даст мысленное переписывание программы на C? Оно поможет увидеть, какую работу за нас выполняет компилятор – и насколько сложные правила стоят за этой работой.

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

А если для освобождения одного объекта приходится выяснять, кто ещё на него ссылается, какие обработчики могут запуститься и кто кому передаст владение? Тогда автоматическое управление временем жизни скрывало целую систему договорённостей между разными частями программы. Без него поддерживать эти договорённости вручную становится настолько трудно, что проще переписать программу.

Следующий пример – как раз второй случай: простой ручной работой, добавив нужные вызовы в очевидных местах, там не отделаешься.

Пример: сессии в Boost.Asio

Когда я изучал Boost.Asio, наткнулся на пример асинхронного сервера. Я ожидал увидеть в коде коллекцию сессий для подключений, но не нашёл её. Принятое подключение запускалось так:

std::make_shared<session>(std::move(socket))->start();

Дальше каждый обработчик асинхронной операции удерживал сессию через shared_ptr:

void session::do_read() {
    auto self = shared_from_this();

    socket_.async_read_some(asio::buffer(data_),
        [self](error_code ec, std::size_t n) {
            if (!ec)
                self->do_write(n);
        });
}

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

Для простого эхо-сервера удобно: отдельно управлять временем жизни сессии не пришлось.

Теперь добавим периодическую работу – например, обновление статистики подключения:

void session::start_statistics() {
    timer_.expires_after(5s);

    timer_.async_wait(
        [self = shared_from_this()](error_code ec) {
            if (!ec) {
                self->update_statistics();
                self->start_statistics();
            }
        });
}

Клиент отключился, обработчик чтения отпустил свою ссылку. Но сессию теперь держит таймер, который перезапускается каждые пять секунд. Соединения больше нет, а объект живёт.

shared_ptr тут ни при чём: он честно держит объект, пока на него ссылаются. Просто теперь нужен ответ на другой вопрос – когда сессия должна прекратить работу? Отвечать на него придётся явно: методом stop(), в который ведут все пути завершения и который закрывает сокет, отменяет таймер и запрещает новые операции. Одной отмены таймера мало: его обработчик уже мог попасть в очередь. В C++ всё это решаемо, и shared_ptr по-прежнему нужен – чтобы объект дожил до конца уже запущенных операций.

На C тот же вопрос возник бы сразу, ещё до таймера: кто хранит сессию, кто её останавливает и когда можно освободить память? Я бы начал с явного владельца сессий и двух раздельных этапов: сначала остановка, потом удаление, когда завершатся все операции. По сути, счётчик этих операций – тот же shared_ptr. Проверка показала не то, что он лишний, а то, что его одного мало: завершение работы и освобождение памяти – разные события.

Возвращаться после этого можно в C++. Но уже с явным ответом на вопрос, который раньше можно было не задавать.

Архитектуры, которые живут без гарантий

Далее – конкретные задачи и архитектурные решения, которым гарантии компилятора не нужны.

Память: арена на задачу

Часто в системе есть отдельные задачи – например, обработка запроса, – после завершения которых всю выделенную ими память можно освободить разом. Так делают nginx, Apache, PostgreSQL и многие игры. Освобождение мелкими кусочками просто не нужно. Дополнительный плюс – аллокации дешевле, а работа быстрее.

А если выделенную память сразу обнулять, становится ненужным большинство конструкторов без параметров (у этого приёма есть имя – ZII, Zero Is Initialization). И программа становится детерминированнее в нештатной ситуации.

Файлы: явная политика закрытия

Когда есть RAII, мы не боимся забыть close(): деструктор QFile сам всё закроет. Без RAII доступ к файлам естественно вынести в отдельную структуру, а коду, например, обработчику запроса, отдавать соответствующий интерфейс. После запроса один вызов закроет всё незакрытое.

Но тут становится видно и то, что RAII прячет. Файл, открытый на запись и не закрытый явно, – это потенциальная ошибка: запись могла прерваться на середине, и содержимое невалидно. Такие файлы лучше удалять и писать в лог (слать телеметрию). А ещё лучше требовать от пользователя явно задать политику: автозакрытие или удаление с записью об ошибке.

Когда мы пишем обычный код на C++, нам это в голову не придёт. В Rust та же история: Drop не может вернуть ошибку, и File молча проглатывает ошибку закрытия. То есть автоматический деструктор прячет не только забытый close(), но и вопрос, валиден ли файл.

Побочные эффекты: явный контекст

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

Раньше такому принципу было трудно следовать: сторонние библиотеки ни про какие интерфейсы не знали. Сейчас есть AI, и обернуть чужой код стало дёшево, а маленькую библиотеку можно и переписать под нужную архитектуру.

Две оговорки. Во-первых, в C и C++ любая функция может вызвать fopen или printf в обход контекста. Но это легко запретить на уровне сборки: модуль с логикой компилируется с -nostdinc -I core/api, а в CI nm -u проверяет, что у него нет внешних символов, кроме разрешённых. Тогда логировать можно только через контекст.

Во-вторых, если весь контекст отдавать всем подряд, он сам превратится в глобальное состояние. Функции стоит давать только то, что ей нужно: Clock*, а не весь Ctx*.

Итого, контекст работает в три шага: сначала делает побочные эффекты явными, затем позволяет подменять их в тестах, а затем – ограничивать, какие именно эффекты разрешены конкретному обработчику.

Акторы: shared nothing

Если задача не раскладывается на отдельные запросы, не беда: можно использовать акторную модель. Благодаря принципу shared nothing понятно, кто чем владеет и кто удаляет. Внутри актора ресурсы, конечно, придётся отслеживать, но это намного проще, чем отслеживать владение во всей системе. Каждый сам держит в порядке своё внутреннее состояние, и умные указатели для этого не нужны.

Новое сообщение принадлежит сначала создателю, потом шине, потом получателю. Дальше два варианта: либо получатель чистит его сам, либо в каждом сообщении есть тип, и шина сама корректно его удаляет. ID типа сообщения нужен в любом случае, чтобы получатель знал, что пришло (RTTI же нет).

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

А если гарантий не меньше, а больше?

Можно провести и обратный эксперимент: не убирать гарантии, а ужесточить требования к владению. Близкую мысль высказывает Catherine West, бывший ведущий программист Starbound, в докладе на RustConf 2018.

На примерах игровой архитектуры она показывает, как привычные отношения между объектами становятся проблемой в Rust. Это подталкивает пересмотреть представление данных и владение ими: хранить сущности в общих контейнерах, использовать идентификаторы вместо прямых ссылок, отдельно организовать обработку данных. Многие из этих решений появились в геймдеве ещё во времена C++; Rust сделал необходимость пересмотра привычных подходов ощутимее.

Мне эта параллель кажется важной. Убираем автоматическое управление ресурсами – сложные отношения владения становятся дорогими в ручном сопровождении. Ужесточаем правила доступа – становится трудно выразить те же отношения привычным способом. В обоих случаях возникает полезный вопрос: эта сложность действительно нужна задаче или удобства языка позволяли нам её не замечать?

Это не ново – и это хорошо

Ни одно из решений выше я не придумал. И в этом смысл: к одним и тем же решениям разные люди приходят с разных сторон.

  • Арены на время жизни – APR pools в Apache, memory contexts в PostgreSQL, статья Ryan Fleury «Untangling Lifetimes».

  • Обнуление вместо конструкторов – ZII: Casey Muratori, языки Odin, Jai и Go.

  • Все побочные эффекты на краях – Functional Core, Imperative Shell (Gary Bernhardt, доклад «Boundaries»), Impureim sandwich (Mark Seemann).

  • Ни одна функция не может ничего сделать с миром без разрешения – object capabilities и отказ от ambient authority: Joe-E, Midori, WASI.

  • Паутина указателей между объектами – incidental data structures у Sean Parent («Better Code: Data Structures»): у связей должен быть владелец-контейнер. Или резче – Brian Will, «Object-Oriented Programming: A Disaster Story».

  • Shared nothing – Erlang: у каждого процесса своя куча.

А если код пишет AI?

Можно возразить: сейчас код всё чаще пишет AI, так не всё ли равно, какая там архитектура?

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

Теперь таких причин всё меньше. Код пишется быстро, и переписать модуль или адаптировать библиотеку стало дёшево. Это касается как верхнего уровня архитектуры, так и нижнего.

И ещё одно. Архитектуру нейросети тоже умеют строить сами – и иногда неплохо. Но если их не направлять, они выберут усреднённое решение – то, как чаще всего писали раньше. А средний код не самый лучший, и написан он в другие времена, с другими ограничениями. По сути, в нём собраны те самые компромиссы, на которые приходилось идти, когда переделывать было дорого. Если код у всех пишут одни и те же нейросети, преимущество получат те, кто сумеет выбрать архитектуру лучше усреднённой – и заставит нейросеть ей следовать. Отличить одну от другой помогут неудобные вопросы.

Неудобные вопросы

Я не призываю всех раздеться до исподнего и писать на C, который мало что делает за тебя и мало что гарантирует. В том же диалоге из «Туннеля в небе» сестра говорит и про Мэтсона – инструктора героя по выживанию:

Мэтсон, без сомнения, взял бы ружьё, да и я тоже. Но мы-то уже стреляные воробьи, мы знаем, когда НЕ НАДО им пользоваться.

Гарантии компилятора – то же ружьё. Пользоваться ими нужно. Призываю я к другому: чаще задавать неудобные вопросы:

  1. А как бы это выглядело на C/Rust? Устоит ли архитектура без деструкторов и умных указателей? Пропустит ли её borrow checker?

  2. А насколько сложно добавить сохранение состояния программы на диск? Насколько оно будет полным?

  3. А что, если в команде завёлся вредитель? Как архитектурно ограничить его возможности?

  4. А что будет, если программу убить в произвольный момент (kill -9)? Останутся ли полузаписанные файлы и невалидные данные?

  5. А можно ли воспроизвести бОльшую часть багов, записав все входы и прогнав их повторно?

А потом честно на них ответить.

P. S. В итоге Род не стал брать огнестрельное оружие, но взял второй нож, который потом его и спас.

Комментарии (0)