EnglishРусский Map

CobaltC

title
CobaltC
type
summary
summary
Спекулятивная спецификация преемника C из книги эссе The Wrong Memory: синтаксис C поверх модели владения, заимствований и вывода времён жизни в духе Rust
tags
language-design, memory-safety, c-language, rust, concurrency
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).