CobaltC
- title
- CobaltC
- type
- summary
- summary
- Спекулятивная спецификация преемника C из книги эссе The Wrong Memory: синтаксис C поверх модели владения, заимствований и вывода времён жизни в духе Rust
- tags
- language-design, memory-safety, c-language, rust, concurrency
- sources
- cobaltc-successor-to-c
- created
- 2026-09-14
- updated
- 2026-09-14
- lang
- ru
- translation_of
- cobaltc
- source_updated
- 2026-09-14
- translated
- 2026-09-14
- translator
- lllm/antigravity/gemini-3.7-flash-medium
CobaltC - язык, существующий в основном в виде документа: "CobaltC Programming Language Specification 1.0.3", опубликованного как приложение (шестое, судя по URL) к сборнику эссе The Wrong Memory на strawberry9.github.io cobaltc-successor-to-c. В сохранённом тексте автор не назван. Единственный указанный идентификатор - GitHub-аккаунт strawberry9, где выложены книга и "CobaltC Semantic Compiler".
Документ тщательно оговаривает свой статус, причём говорит две разные вещи. Примечание в самом начале называет CobaltC мысленным экспериментом, спекулятивным системным языком, чья задача в рамках книги - показать, как безопасный по памяти преемник C должен очерчивать свои семантические границы, а не служить готовой заменой стандарту уровня ISO. При этом основной текст читается именно как стандарт. В нём используются RFC-стили MUST и SHOULD, CobaltC 1.0 объявляется замороженной редакцией языка (а 1.0.3 - исправленной публикацией), проставлена пометка "Status: Normative", а также определены уровни соответствия и методология тестирования на соответствие. Связанная с ним эталонная реализация охватывает только семантический анализ: лексический анализ, парсинг, разрешение имён, проверку типов, инициализацию, а также анализ владения и заимствований, формируя так называемое Explainable Semantic Intermediate Representation (ESIR), которое фиксирует выведенную семантику программы вместе с правилами, использованными для её обоснования. Генерация кода в тексте не описывается вовсе. Объём фрагмента составляет около 9000 строк: 87 нумерованных разделов и приложения от A до H.
Суть предложения
Аргумент книги, как его пересказывает предисловие, состоит в том, что попытка навесить безопасное подмножество поверх небезопасного языка обычно проваливается: базовая семантика всё равно допускает нарушение инвариантов. В CobaltC владение и заимствования встроены непосредственно в базовую систему типов. Предисловие отдаёт должное Rust за демонстрацию того, что эти идеи (большинство из которых придуманы не им) объединяются в практичный высокопроизводительный системный язык, и отмечает, что CobaltC перенимает эту архитектуру безопасности без синтаксиса и общей философии Rust.
Вводный пример - два указателя на массив 2×2. В C программист знает, что они указывают на разные ячейки, и обязан сам за этим следить; лозунг C в спецификации: "You have the power. Don't screw it up." В CobaltC тот же код берёт два заимствования &mut, и компилятор доказывает, что области памяти различаются, обращения не пересекаются, а grid по-прежнему владеет значениями: "You have the power. Let's prove you're exercising it correctly."
mut i32[2][2] grid = [[1,2],[3,4]];
mut i32* a = &mut grid[0][0];
mut i32* b = &mut grid[1][1];
*a = 7;
*b = 8;
Как выглядит язык
Объявления остаются в формате "сначала тип", как в C, а символ * сохраняется, но меняет значение. Простой тип указателя теперь представляет собой заимствование, называется управляемым указателем (managed pointer) и никогда не владеет значением:
String* shared, read-only, non-null
String*? shared, nullable
mut String* exclusive, mutable, non-null
mut String*? exclusive, mutable, nullable
raw String* unmanaged address; dereference requires unsafe
&value берёт разделяемое заимствование, а &mut value - изменяемое. Некопируемые значения перемещаются только через явный move. В грамматике вообще нет let и аннотаций времён жизни: времена жизни всегда выводятся компилятором, а заимствование может завершиться при последнем использовании ещё до конца своей области видимости. Дженерики в 1.0 намеренно не имеют ограничений (constraints), и пользовательский синтаксис ограничений отсутствует, что спецификация называет границей редакции языка, а не упущением.
Здесь нет классов, наследования или динамической диспетчеризации. Функции можно связывать с типом в виде Type::name, но такое связывание влияет только на именование и поиск, поэтому неявный получатель (receiver) отсутствует: вызовы Stack<i32>::push(&mut stack, 10) и с квалификацией по значению stack::push(&mut stack, 10) вызывают одну и ту же функцию с теми же явными аргументами, а . никогда не выполняет вызов метода. Видимость существует только на уровне модулей. Объявление доступно снаружи, только если модуль перечисляет его в блоке export { ... }, причём экспорт типа экспортирует только его имя, но не внутреннее представление, поэтому экспортируемые типы по умолчанию непрозрачны (opaque).
Некоторые решения отличаются как от C, так и от Rust.
- Переполнение целых чисел, деление на ноль и сдвиги за пределы диапазона считаются арифметическими сбоями и никогда не приводят к переполнению с циклическим переносом (wrapping). Если сбой доказуем во время компиляции, возникает ошибка компиляции, иначе - сбой во время выполнения. Механизм информирования (trap, проверяемая ошибка, завершение процесса) остаётся на усмотрение реализации, которая обязана его задокументировать, но неудавшаяся операция ни в коем случае не должна возвращать значение. Переполнение не может незаметно привести к выделению памяти недостаточного размера - спецификация относит это к базовым свойствам безопасности.
- Диапазоны срезов (slice ranges) включают обе границы.
values[0..3]содержит четыре элемента,start > endприводит к ошибке выхода за границы вместо пустого среза, аvalues[...]выбирает весь срез. В Rust и Golang используются полуоткрытые диапазоны. - Наряду с деструкторами существует
defer. Отложенные блоки выполняются в обратном порядке перед уничтожением локальных переменных в области видимости, а отложенный блок ссылается на путь связывания, а не захватывает значение: переприсваиваниеvalueпосле регистрацииdefer { print(value); }напечатает новое значение, а перемещение изvalue, пока висит defer, вызывает ошибку компиляции. - Тип может определять хук уничтожения:
fn File::destroy(mut File* file), освобождающий ресурсы, которые не представлены полями с владением (например, сырой дескриптор ОС). Затем компилятор сам уничтожает принадлежащие поля. Хук выполняется ровно один раз и не может быть вызван явно. Это raii, где разделение между пользовательской очисткой и уничтожением полей зафиксировано в правилах. - Сопоставление с образцом (match) обязано быть исчерпывающим, восстановимые ошибки представляют собой значения
Result<T,E>, распространяемые постфиксным?, а паники или исключения отсутствуют как механизмы самого языка. Abort завершает процесс без гарантии вызова деструкторов. - Идентификаторы жёстко привязаны к Unicode 17.0.0 (
XID_Start/XID_Continueплюс символ подчёркивания), обязаны соответствовать профилю Moderately Restrictive из UTS #39, никогда не нормализуются и не приводятся к единому регистру, а также не могут содержать символы управления направлением текста или символы нулевой ширины. Более поздняя версия Unicode не может изменить то, что считается идентификатором в CobaltC 1.0.3.
Владение, заимствование и времена жизни
Правила во многом повторяют Rust. Каждое принадлежащее значение имеет ровно одного владельца, отвечающего за его уничтожение. Составной тип (aggregate) можно копировать, только если копируемы все входящие в него компоненты и у него не объявлен хук уничтожения. Отслеживаются частичные перемещения: после move person.name поле person.address остаётся доступным, person нельзя переместить целиком, а при уничтожении перемещённое поле пропускается. Правило заимствования сформулировано как "ноль или более совместимых разделяемых заимствований ЛИБО одно изменяемое заимствование", непересекающиеся поля можно заимствовать с возможностью изменения одновременно, а операция, способная переместить память (relocate), отклоняется, пока живо заимствование элемента:
mut Vector<String> values = ["hello"];
String* pointer = &values[0];
values::push(&mut values, "world"); // ERROR: active element borrow
print(*pointer);
При динамических индексах компилятор вправе считать обращения пересекающимися и не должен предполагать, что два вычисляемых во время выполнения индекса обязательно различаются.
Разделяемое владение (shared ownership) не встроено в сам язык. Библиотеки могут реализовать его, но только явно. Приложение C предлагает другой подход для графов, реестров и кэшей: отделить идентичность (identity) от владения. Контейнер владеет объектами, сторонний код хранит обычное значение идентичности вроде индекса вместе со счётчиком поколений, а доступ сводится к разрешению этой идентичности в обычное проверяемое заимствование. Суть выражена формулой: "Ownership -> determines lifetime; Identity -> identifies an object; Borrow -> provides access". arena-allocation описывает тот же паттерн дескриптора-индекса со стороны аллокатора.
Раздел 1 перечисляет свойства, которые соответствующий стандарту компилятор обязан гарантировать в безопасном коде: перемещённые значения нельзя использовать через старого владельца, к принадлежащей памяти нельзя обращаться по истечении её времени жизни, ни у одного значения не может быть двух владельцев или повторного уничтожения, конфликтующие изменяемые псевдонимы (aliases) и сочетания изменяемых и разделяемых заимствований не могут существовать одновременно, перемещение памяти не может инвалидировать активное заимствование, заимствования и срезы не могут пережить свою память или выйти за пределы времени жизни объекта, на который ссылаются, null нельзя разыменовать через ненулевой указатель, безопасная индексация не выходит за границы, а переполнение не может привести к выделению памяти недостаточного размера.
Конкурентность и модель памяти
Безопасный код не должен содержать гонок данных (data races), но в ядре языка нет API для потоков, каналов или атомиков: это относится к рантайму или стандартной библиотеке. Что спецификация действительно определяет (в разделе 66 и приложении E), так это модель упорядочивания. Вычисления внутри одного контекста выполнения упорядочены отношением sequenced-before. Определённые операции между контекстами находятся в отношении synchronize-with: разблокировка мьютекса со следующим успешным захватом, запуск потока с первым вычислением в нём, завершение потока с успешным join. Отношение happens-before является транзитивным замыканием этих двух, а гонка данных - это два конфликтующих неатомарных обращения, не упорядоченных отношением happens-before.
Различие, к которому спецификация возвращается снова и снова: владение определяет, какой контекст имеет полномочия на доступ к значению, синхронизация определяет порядок и видимость, и ни одно из них не влечёт за собой другое. Перемещение значения в поток передаёт полномочия, но не упорядочивает предшествующие записи, если только сама операция передачи не выполняет синхронизацию. Захват мьютекса не делает висячее заимствование валидным, а join потока не оживляет объект, уничтоженный до вызова join. Отсутствие взаимных блокировок (deadlock), активных блокировок (livelock) и ресурсного голодания (starvation) явно не гарантируется. Спецификация также подчеркивает, что happens-before не следует трактовать как требование машинного барьера памяти для каждой связи, и оставляет за каждым режимом упорядочивания атомиков документирование собственных гарантий. В shared-memory-consistency-causality подробно разбирается, какими должны быть эти атомарные гарантии на реальном оборудовании. Rust отвечает на вопрос о том, какие значения могут пересекать границы потоков, с помощью типажей Send и Sync (rust-send-sync). CobaltC оставляет разрешённые для передачи между потоками возможности (capabilities) на усмотрение реализации (implementation-defined) - и это самый большой разрыв между его гарантиями и практически реализуемым языком.
Небезопасный код и FFI
Блоки unsafe и unsafe fn работают так же, как в Rust, и спецификация на протяжении нескольких разделов настойчиво подчёркивает, чего они не делают: вход в небезопасный контекст не передаёт владение, не продлевает времена жизни, не инициализирует память, не подавляет диагностику и не меняет смысл окружающего безопасного кода. Правило для безопасных обёрток гласит: "Unsafe code MAY implement a safe abstraction, but it MUST NOT export an invariant that the safe interface cannot guarantee." Это ставит CobaltC на сторону Rust в споре из memory-safety-absolutists: unsafe - это явная локальная граница, а безопасность является свойством интерфейсов, а не следствием полного отсутствия небезопасного кода.
Главы о внешних функциях (foreign functions) разделяют два контракта. ABI (где базовой точкой отсчёта служит платформенный C ABI) определяет, как значения пересекают границу; семантический контракт определяет, кто ими владеет, как долго они остаются валидными и кто их уничтожает. Формулировка спецификации: "The ABI determines how values cross the boundary; the FFI contract determines what those values mean." Каждый чувствительный к владению внешний вызов должен подпадать под один из пяти контрактов: borrowed, caller retains, caller transfers, callee transfers или foreign-owned. Вызов, забирающий владение, обязан также определять поведение при ошибке: владение передаётся только при успехе, передаётся в момент начала вызова или остаётся у вызывающей стороны в любом случае. Управляемые указатели нельзя передавать туда, где ожидается указатель C, а String в CobaltC - это не строка C.
Формальная модель и соответствие стандарту
Приложение D задаёт последовательную семантику с абстрактным состоянием Σ = (ρ, μ, Λ, Δ): связывания, хранилище, времена жизни и ожидающие отложенные обязательства (defer). Заимствования представляют собой возможности-capabilities (Shared, Exclusive, Suspended во время повторного заимствования), а времена жизни - ограничения включения вроде λborrow ⊆ λreferent. Пример функции, возвращающей один из двух заимствованных аргументов в зависимости от условия, ограничивает результат обоими входами, поскольку компилятор не может выбрать время жизни из ветки, которая выполнится случайно. Приложение E расширяет модель на конкурентность, F - на границы с внешним кодом, G - на тестирование соответствия, а H описывает композицию подсистем, что сводится к принципу: "subsystem composition preserves existing guarantees unless a more specific normative rule explicitly defines otherwise".
Соответствие стандарту является накопительным на трёх уровнях. Core - сам язык, Standard добавляет обязательную библиотеку (Result, String, Vector, Slice, Mutex при наличии конкурентности и стандартный I/O), а Platform добавляет объявленный профиль рантайма и ABI. Тесты делятся на compile-pass, compile-fail, run-time, diagnostic и ABI, причём диагностический тест проверяет семантическое условие, а не конкретную формулировку сообщения. Раздел 84 формулирует теорему безопасности: соответствующая стандарту реализация, исполняющая корректную безопасную программу, не должна нарушать требования к владению, инициализации, заимствованиям, временам жизни, поддержке null, границам массивов или синхронизации. Раздел 86 затем признаёт, что спецификация не является формальным, машинно проверенным доказательством.
Критический взгляд
Значительная часть объёма текста - повторы. Разделы с 67 по 83 заканчиваются списком "Conformance Requirements" и блоком "Summary", завершающимся однострочным "fundamental rule" в цитате, а базовые правила повторяются по три-четыре раза (правило заимствования приводится в разделах 41, 43, 48 и приложении D). В то же время детали, которые разработчику компилятора понадобились бы в первую очередь, отданы на откуп реализации (implementation-defined): какие возможности могут пересекать границы потоков, какие режимы упорядочивания атомиков существуют, сигнатура main, правила константных выражений и способ сообщения об арифметических сбоях.
Текст также противоречит сам себе в мелочах. Раздел 42, "Shared Borrows", есть в оглавлении, но отсутствует в основном тексте, где за 41 сразу следует 43. В разделе 31 написано if condition без скобок, а в разделе 55 - if (value != null). В демонстрационной программе в разделе 87 импортируется io, тогда как в разделе 8 используется std.io. А пример безопасной обёртки в 67.13 обращается по индексу к сырому буферу с комментарием "The wrapper establishes that index is in bounds", не выполняя никакой проверки границ - и это в разделе, суть которого в том, что обёртка обязана гарантировать свои инварианты.
Всё это не противоречит рамке, заданной в предисловии. Если воспринимать CobaltC как мысленный эксперимент, это подробная инвентаризация инвариантов, за которые должен отвечать безопасный преемник C, и границ между ними: владение против синхронизации, ABI против семантического контракта, идентичность против владения, безопасный интерфейс против небезопасной реализации. Но как язык, который можно было бы реализовать по этому документу, он неполон. Другие преемники C в базе знаний идут иными путями: zig и c3-lang сохраняют ручное управление памятью и пересматривают значения по умолчанию в C, а simplified-model-of-fil-c делает существующий C безопасным по памяти во время выполнения за счёт сборщика мусора и возможностей указателей (pointer capabilities).