«!throws выглядит как not». Этой фразы хватило, чтобы Claude отказался от своего синтаксиса эффектов. А на синтаксис объявления метода понадобилось пять раундов. Иногда Claude переубеждает и меня.
Я делаю Nova — язык для бэкенда и серверных программ, похожий на Go и Rust. Одна из ключевых его особенностей — эффекты: функция прямо в сигнатуре объявляет, что делает с внешним миром (работает с сетью, часами, файлами, может завершиться ошибкой и др.), а реализацию эффекта можно подменить в рантайме, например в тестах. Компилируется в C, есть сборщик мусора и рантайм M:N: лёгкие задачи поверх потоков ОС, как горутины в Go.
Язык я проектирую вместе с ИИ-агентами, в основном с Claude: я задаю направление, Claude предлагает варианты и обоснования, я выбираю и часто говорю «нет». Почти у каждого заметного решения в синтаксисе Nova есть такая история: Claude держался за свою позицию, и её приходилось переламывать. Можно, конечно, просто отдавать агенту приказы. Но я всегда даю ему аргументированно изложить свою идею и принимаю её, если она действительно хороша и согласуется с моим видением. И почти каждый раз итог оказывался лучше того, что Claude защищал вначале.
Ниже одиннадцать таких историй. Каждая объясняет, почему в Nova сейчас устроено именно так, и что я из неё вынес для работы с ИИ на длинном проекте.
С чего всё началось: сборщик мусора
Самый крупный разворот, отказ от префиксов памяти ~T в пользу сборщика мусора после аргумента «на Go написан Kubernetes», описан в другой статье. Главное оттуда: Claude неделями защищал решение, которое сам когда-то предложил, и сдался только перед прецедентом из реального мира. Ниже эта механика встретится ещё не раз.
«!throws выглядит как not»
В одной из ранних версий синтаксиса Claude предложил помечать эффекты восклицательным знаком:
fn save(u User) !throws io async -> ()
Обоснование было логичным: ! свободен, ни для чего важного не используется, похоже на Swift. Я ответил одной фразой: «!throws выглядит как not». Claude согласился почти сразу. Эффекты стали обычными словами между ) и -> в объявлении функции или метода, без дополнительных маркеров:
fn save(u User) Throws Io Async -> ()
Потом язык менялся ещё: Throws переименовали в Fail, а Async вообще убрали из системы типов, асинхронность стала делом рантайма M:N. Но принцип остался: эффекты пишутся словами, и сигнатура читается без расшифровки.
![Как менялась запись эффектов: !throws io async, затем Throws Io Async, сегодня Fs Fail[IoError]](https://habrastorage.org/r/w780/webt/6a/c0/fe/6ac0fed853ccc894273657bab414c442.png)
ИИ рассуждает логически («символ свободен»), а человек видит, как строка читается с первого взгляда. Этого ИИ пока не умеет.
Массивы, которые читались бы задом наперёд
Сначала массивы в Nova писались как [T], в стиле Rust и Swift. Claude защищал это так: «согласовано с дженериками Option[T], HashMap[K, V], парсер однозначен». Я не соглашался и задавал вопросы: как объявить вложенный массив, что с map, совпадает ли порядок размеров в объявлении и при обращении arr[i][j].
Последний вопрос и решил дело. В Go [2][3]int читается в том же порядке, что и arr[i][j]. В Rust [[T; 3]; 2] читается наоборот, и на это давно жалуются. Claude перевзвесил: совпадение с индексацией важнее красивой согласованности с дженериками, а однозначность парсера достигается позицией. Остались массивы в стиле Go: []T, [N]T, [N1][N2]T. Пришлось поправить около тридцати мест в документах. Право на жизнь у записи Rust есть, но мне она не по душе.
![Порядок размеров: в Go и Nova [2][3]int и a[i][j] идут в одном порядке, в Rust [[T; 3]; 2] записан наоборот](https://habrastorage.org/r/w780/webt/5a/2d/72/5a2d727a9a0459e1eaf295138bd7a46a.png)
&T, который пережил всех по инерции
После отказа от префиксов памяти Claude оставил один: &T, заимствование, как в Rust. Функция получает доступ к значению без копирования и без участия сборщика мусора. Claude обосновывал его производительностью: такой параметр пригодился бы в коде, который выполняется очень часто и где важна каждая аллокация.
fn analyze(data &[]f64) -> Stats => Stats { mean: data.mean(), sum: data.sum() }
Я спросил, зачем это нам. Claude сначала защищал, потом разобрал по пунктам. В Rust заимствование существует потому, что нет сборщика мусора. В Nova со сборщиком передача объекта и так передача указателя. Большую часть случаев «ради производительности» закрывает escape-анализ. Срез уже передаётся дёшево, как указатель с длиной. А проверка времён жизни стоит очень дорого в реализации.
Получилось, что &T попал в дизайн из Rust и пережил отмену остальных префиксов просто потому, что никто не задал вопрос, нужен ли он ещё. В итоге мы с Claude его убрали. В языке со сборщиком мусора проверка времён жизни не нужна в том виде, в каком она нужна Rust.
Мелочи, замеченные глазами
Четыре короткие истории. В трёх из них причина одна: ИИ смешивает привычки из разных языков и не замечает этого.
Тело функции: = или =>. В примерах Claude лямбда писалась (x) => x + 1, а анонимная функция fn(x int) -> int = x * 2. Я спросил: «у нас что, функция объявляется без fn?» Две записи одного и того же жили рядом. Claude пересмотрел свои примеры и нашёл ещё одно расхождение: тело из одного выражения у лямбды шло после =>, а у анонимной функции после =, оставшегося от ранней версии синтаксиса. Знак выровняли: если тело функции, объявленной через fn, состоит из одного выражения, оно идёт после =>, например fn(x int) -> int => x * 2. Тело из нескольких инструкций, как и раньше, пишется в фигурных скобках. Заодно => совпал с веткой match, где после него тоже идёт результат: Circle(r) => 3.14 * r * r. Теперь у этого знака один смысл. А короткая лямбда позже получила свою запись, совсем без стрелки: |x| x + 1.
pub и import. Для экспорта было pub fn, как в Rust, а для импорта import, как в Java и Python. Я спросил: если есть import, почему не export? Пара в духе Rust дала бы use, но это слово в Nova уже было занято, да и не нравилось мне. В итоге сделали export и import, слова-противоположности.
IO, FS и Db. В документах эффекты назывались то Db, то IO и FS капсом. Оправдания у Claude не нашлось: капс для аббревиатур пришёл из Java (IOException, URLConnection). Ввели одно правило для всех: Db, Io, Fs, Http, Json.
Тип результата: : или ->. Эта история не про ИИ, а про мою давнюю претензию к TypeScript: там тип результата в объявлении идёт после двоеточия, function f(x: number): number, а в типе функции после стрелки, (x: number) => number. В Nova он везде идёт после ->: fn f(x int) -> int и fn(int) -> int. Так у двух стрелок разные роли: -> вводит тип результата, => вводит само выражение.

Каждое решение по отдельности выглядело разумно, ошибка была в смеси школ. ИИ её не видит: для него это просто разные места в документах.
Ошибка, которая стала правилом
Эта история обратная. В одном примере Claude написал обработчик эффекта короткой лямбдой. Я заметил: «ты присваиваешь эффекту функцию, а говорил, что эффект не функция». По тогдашним правилам Nova обработчик был значением с реализацией операций, и короткая форма была ошибкой. Причём по правилам, которые записал сам Claude, отвергнув такое сокращение раньше.
Но исправлять пример я не стал. Сказал: краткость важна, язык должен давать минимум строк, давай пересмотрим. Claude перевзвесил, и правило поменяли: если у эффекта одна операция, обработчик можно записать лямбдой. Пример из «неправильного» стал правильным. Сегодня это выглядит так:
with Fail[TimeoutError] = |_e| { timed_out.store(true) } { // код, в котором ошибка TimeoutError обрабатывается обработчиком выше }
Здесь переубеждать пришлось в обратную сторону: отменить собственный запрет Claude. ИИ применяет записанные правила механически и считает их окончательными. Если правило мешает практике, пересматривать нужно правило, и предложить это должен человек.
Пять раундов ради одной строки: объявление метода
Самая длинная история: как записать объявление метода. В Go ресивер бывает значением или указателем, func (a Account) и func (a *Account), и вокруг этого выбора много путаницы: какой ресивер взять, почему метод с указателем не входит в набор методов значения. В Nova мы с тем же боролись и в итоге убрали этот выбор совсем. В объявлении пишется только режим ресивера: по умолчанию только чтение, mut, чтобы изменять, а позже добавился consume. Как передать экземпляр, решает компилятор. Но к такой записи пришли не сразу. Начали с привычной формы:
fn Account.deposit(mut self, amount money) => self.balance += amount
Здесь mut self означает, что метод изменяет экземпляр.
Сначала я предложил писать @balance вместо self.balance. Идея не новая: я взял её из своего прошлого языка ObjectScript, где @ тоже означал текущий объект, this. Claude согласился, думая, что это просто сахар. Потом я захотел убрать mut self из каждой сигнатуры. Тут пошли возражения, и раунд за раундом Claude находил новое:
неявный
selfнарушает локальность: читая сигнатуру, не видно, что метод работает с экземпляром;в варианте
fn mut @Account.deposit(...)у@два разных смысла: «маркер типа» в сигнатуре и «маркер поля» в теле;в варианте
fn Account mut @deposit(...)нет точки между типом и именем, а это расходится с путями вродеColor.Red.
Каждый раз я приходил с новой формой, которая закрывала прошлое возражение. Последнее сняла идея разделить два вида функций по записи: статический метод через точку, метод экземпляра через пробел и @:
fn Account.new(owner str) -> Account => ... // статический метод: точка fn Account @balance() -> money => @balance // метод экземпляра: пробел и @ fn Account mut @deposit(amount money) => // изменяющий метод экземпляра: mut @balance += amount
Возражение про точку исчезло, потому что запись теперь отражает разницу между статическим методом (он часто и служит конструктором, как Account.new) и методом экземпляра. Ключевое слово self из языка убрали, поля пишутся как @поле, сам экземпляр как @. Из пяти вариантов этот оказался лучшим, и я им доволен.

У этой истории есть продолжение. Когда в языке появились ресурсы, которые метод забирает целиком (consume), им нашлось место там же, где стоит mut:
fn File consume @close()
Claude эта запись понравилась даже больше, чем мне. Он увидел в ней два смысла. Первый: File consume перекликается с ограничением дженерика [T consume] («T — ресурс») и с объявлением type File consume { … }, то есть строка говорит, что File — ресурсный тип. Второй: consume @ значит, что метод забирает экземпляр, и после close() файлом пользоваться нельзя.
Через пару месяцев оказалось, что первый смысл Claude принимал слишком всерьёз. Я заметил, что в сигнатурах встречалось и -> ro T, и -> MutexGuard consume: модификатор то перед типом, то после. Агент предложил исправить возврат, а fn File consume @close() оставить «постфиксом», который якобы помечает тип. Я показал, что consume здесь относится к ресиверу @, как в привязке consume x = …, а то, что File ресурс, уже сказано в объявлении типа. Агент согласился, но тут же записал правило «единственный постфикс остаётся у объявления типа», и его пришлось поправить ещё раз: в type File consume { … } модификатор относится к телу типа, как enum в type Shape enum …. Красивая аналогия, которая так понравилась Claude, его и сбила с толку.
В итоге постфиксов в языке не осталось совсем: модификатор ro, mut или consume всегда стоит перед тем, что описывает.
А у возвращаемого значения из трёх модификаторов в итоге остался один. -> mut T убрали: возвращённое значение принадлежит вызывающему, и изменяемым его делает привязка на принимающей стороне, mut x = f(), а не сигнатура функции. -> consume T убрали по той же логике: линейность несёт сам тип, объявленный как consume. А -> ro T оставили, потому что это обещание функции, которое вызывающий своей привязкой не отменит:
fn str @bytes() -> ro []u8 // вид на байты строки без копирования
Строки в Nova неизменяемы, но байты строки можно получить как массив: bytes() отдаёт ro-массив прямо поверх байтов самой строки, без копии. Без ro через такой массив можно было бы изменить строку, которую язык обещает не менять. Кому нужна запись, делает явную копию, например "123".bytes().clone().

От let к ro
Сначала переменные объявлялись как в Rust: let x и let mut x. Для полей, которые нельзя менять, понадобилось отдельное слово, и тут Claude сначала защищал прежнее правило. Сдался он, когда я показал реальную структуру, где иначе mut пришлось бы написать 18 раз. Так появилось readonly.
Потом readonly расползлось на типы и параметры, а рядом появилось consume x = …, сразу без всякого let. Получилось неровно: два вида привязок с let, один без. За один день let убрали совсем: слово ничего не сообщает, всё говорит модификатор. А readonly сократили до ro. Вышла тройка одинаковой формы: ro x = …, mut x = …, consume x = ….
Kubernetes вместо спора: видимость полей
В ранних примерах поле называлось @_balance: подчёркивание означало «не трогать снаружи», как в Python. Но это лишь соглашение, компилятор его не проверяет, и _balance всё равно доступен. От подчёркивания отказались, и встал вопрос: какими делать поля по умолчанию? В Rust поля приватны, пока не помечены pub. В Kotlin свойства публичны, в Go видимость задаётся регистром первой буквы, а регистр в Nova уже занят соглашениями об именах.
Этот вопрос решили измерением. Взяли Kubernetes, большой проект на Go, и посчитали поля (исследование с методикой и скриптом подсчёта): из 35 тысяч в 11 тысячах структур публичных оказалось 59%. Во внутренней логике примерно поровну, а в описаниях API, например core/v1/types.go, публичны 92% полей. Поэтому в Nova поля публичны по умолчанию, а скрыть поле можно явным priv, который проверяет компилятор. Если в типе почти всё приватное, пишется type X priv { … }, и тогда открывать нужно отдельные поля.

Где Claude оказался прав: указатели
Указатели — единственная история, где переубеждали в обе стороны.
Сначала о том, к чему относится mut в *mut T. Claude считал такую запись наследием C, где модификатор относится к самому указателю, пометил её устаревшей и предлагал писать mut * T. Я возражал: в *mut T модификатор относится к T, к тому, на что указывает указатель. А можно ли переприсвоить сам указатель, решает привязка, как и у любой переменной. Claude согласился, и вышло как в Rust:
ro p *mut int // p переприсвоить нельзя, p.write(1) можно mut q *int // q переприсвоить можно, q.write(1) нельзя, только q.read()
Модификатор перед * теперь ошибка компиляции: два разных mut в одном типе путали всех, включая самого Claude. Агент потом сам записал урок: «„Две mut“ в типе — это запах».
Во втором вопросе переубедили меня. Я предлагал, чтобы модификатор действовал на всё, что справа, до конца типа: ro перед именем замораживал бы и указатель, и то, на что он указывает, а голый *T наследовал бы изменяемость от привязки. Claude вместе с отдельным агентом-критиком разобрал эту модель и нашёл четыре серьёзные проблемы. Главная: один и тот же *T в одном месте значил бы «только чтение», а в другом «можно писать», то есть тип перестаёт читаться без контекста. Указатель на указатель становится ребусом, а приведения типов и дженерики требуют особых правил.
Тут уже мне пришлось посмотреть на вопрос с чистого листа, то есть сделать то, о чём я обычно прошу агента. Я взглянул на свою идею со стороны, и довод Claude меня убедил. Теперь модификатор останавливается на границе *: ro замораживает всё, чем владеет значение, но за указатель не проходит. Что можно делать с памятью за указателем, говорит только тип: *T значит «только чтение», *mut T значит «можно писать», в любом месте программы одинаково. В языке со сборщиком мусора и без проверки заимствований свою память от чужой статически не отличить, поэтому границу проводят там, где её видно в тексте, на *. Цена известна по C++: глубокую неизменяемость сквозь указатель снаружи не навязать.

Когда спорить с ИИ: короткий чек-лист
ИИ защищает решение, потому что оно уже записано. Конкретный прецедент из реального мира весит больше недели накопленных обоснований. Просите пересмотреть решение с чистого листа.
ИИ говорит «логично». Спросите, как это читается. Глазной аргумент бывает важнее логического.
В дизайне остался кусок от большой отменённой системы. Проверьте его отдельно: ИИ оставит его просто потому, что он был в документах.
Термины или конвенции из разных языков. Спросите, что было образцом. Смешение школ ИИ не видит.
ИИ отвергает идею ссылкой на правило. Если правило мешает практике, пересматривайте правило.
Возражения идут каскадом. Не сдавайтесь после первого «нет»: выясните, что конкретно мешает, и предложите другую форму.
ИИ показывает, где ваша идея ломается. Если это конкретные примеры, соглашайтесь.
Что из этого вышло
Все решения из этих историй живут в языке: сейчас у Nova вышли альфа-версии двух компиляторов, и второй, написанный на самой Nova, собрал себя сам. Об этом отдельная статья. Каждое решение записано в спецификации вместе с обоснованием, и привычка спорить с ИИ, описанная выше, никуда не делась.
Что ИИ делает хорошо
Чтобы это не выглядело разносом. Claude помнит десятки страниц решений одновременно и ссылается на то, что я сам давно забыл. Сразу замечает, когда новое решение противоречит старому. Предлагает варианты с плюсами и минусами. А главное, делает массовые правки: когда меняется одно решение, он обновляет тридцать файлов и объясняет каждую правку. Без этого в спецификации не набралось бы больше трёхсот согласованных между собой решений.
Так у нас и делится работа: ИИ пишет и помнит, а я спорю. Многое в Nova выглядит так, как выглядит, потому что ИИ что-то хотел добавить, а я не дал. И кое-что — потому что не дал он.
А у вас было, что ИИ упорно защищал решение, которое потом оказалось неверным? Чем вы его переубедили?
Репозиторий: github.com/nv-lang/nova, спецификация как журнал решений.
pureooplover
Аффтар, ты клауд убери уже и перепиши всë сам (ну и с Интернетом, но не с ИИ) - волшебное чувство будет, когда ты меняешь всего 5-6 строк - и синтатикс меняется без клаудов))))