Идея в одно предложение
Программы Zero делают I/O, потому что им дают на это разрешение, а не потому что они тянутся к ambient-глобалу.
Это разрешение — значение типа World. Рантайм конструирует его перед вызовом main, а ваша программа протаскивает его (или его части) везде, где нужно взаимодействовать с внешним миром.
Почему без глобалов?
Большинство языков позволяет любой функции, где угодно, писать в stdout или открывать файл. У JavaScript есть console.log. У Python — print. У C — printf. Удобство реальное, но и цена тоже: по сигнатуре функции нельзя сказать, может ли она делать I/O. Чтобы узнать, надо читать тело — рекурсивно.
Zero занимает другую позицию. Глобального print нет. Никакого ambient os.Stdout. Если функция делает I/O, этот факт обязан появиться в её сигнатуре, потому что единственный способ делать I/O — это иметь полученный capability.
Выгоды проявляются в трёх местах:
- Чтение сигнатуры говорит, что функция может делать. Функция, у которой нет
World, не может писать в stdout, не может открыть сокет, не может прочитать файл. Система типов делает это твёрдой гарантией. - Тестировать чистый код тривиально. Чистым функциям не нужны заглушки для
printили моки файловой системы — у них доступа к этому в принципе нет. - Агенты могут рассуждать локально. ИИ-агент, генерирующий или чинящий код Zero, может — не читая всю кодовую базу — знать, есть ли у функции, на которую он смотрит, эффекты.
Каноничное использование
Базовая форма из hello-world вам уже знакома:
Три происходящих штуки:
mainобъявляет параметр какworld: World. Рантайм отдаёт программе значениеWorldи привязывает его здесь.world.out— это стандартный поток вывода, выставленный полем capabilityWorld.world.out.write(...)пишет строку. Возвращает fallible-значение (запись может упасть), котороеcheckпробрасывает.
Параметр можно переименовать — w: World или io: World, — но world это соглашение, и его стоит держать ради согласованности с более широкой экосистемой Zero.
Что живёт на World
Capability World выставляет поверхности, которые могут понадобиться программе. Точная форма зависит от того, что поддерживает рантайм, но можно ожидать записи для:
world.out— стандартный вывод.world.err— стандартный поток ошибок.world.in— стандартный ввод.- Способ открыть файл, прочитать переменные окружения и подключиться по сети.
Сверьтесь с актуальной документацией стандартной библиотеки Zero на счёт авторитетного списка полей. Некоторые поверхности (сеть, файловая система) могут жить за более узкими capability-типами, доступными через World, а не на верхнем уровне.
Протаскивание capability через код
Цена явных эффектов — любая функция, которой нужен I/O, обязана получить capability. Тянуться к нему неявно нельзя.
fun log(world: World, message: String) -> Void raises {
check world.out.write(message)
}
pub fun main(world: World) -> Void raises {
log(world, "starting\n")
log(world, "done\n")
}
log принимает world, чтобы через него писать. Если бы log не принимала world, тело не смогло бы вызвать world.out.write — привязки просто не существовало бы.
Это больше проброса параметров, чем в языке с ambient-I/O. Взамен весь граф вызовов из main теперь виден по одним только сигнатурам:
mainпринимаетworld, поэтому может делать I/O.logпринимаетworld, поэтому может делать I/O.- Любая функция без
worldв сигнатуре — не может.
Более узкие capability
Передавать весь World каждой функции — топорный подход; это как раздавать root-привилегии. Паттерн, который Zero поощряет, — принимать только тот кусочек World, который реально нужен:
fun log(out: Stream, message: String) -> Void raises {
check out.write(message)
}
pub fun main(world: World) -> Void raises {
log(world.out, "starting\n")
log(world.out, "done\n")
}
Теперь log получает доступ только к Stream (тот же тип, который выставляет world.out). Он может через него писать, но не может открыть файл или читать из сети. Вызывающий выбрал, что разрешено делать log.
Точные имена типов в реальном коде Zero (Stream, Writer, capability-срезы) будут следовать словарю стандартной библиотеки в вашей версии тулчейна. Паттерн — передавать минимум, не максимум — универсален.
Чистые функции
Функции, которым не нужен World, не должны принимать World. Это отчасти стилевое предпочтение, отчасти обеспечивается системой типов: без capability нельзя сделать I/O.
fun sum(point: Point) -> i32 {
return point.x + point.y
}
sum чиста по отношению к внешнему миру. Вызывающий, смотрящий на сигнатуру, точно знает, что эта функция ничего не напечатает, ничего не откроет и никуда не сходит. На это свойство может полагаться статический анализатор (или агент), не читая тело.
Capability и raises
Почти каждая операция над capability fallible. world.out.write может упасть, потому что поток закрыт. Открытие файла может упасть, потому что файла нет. Поверхность capability API спарена с raises и check — fallible-операции объявляют режимы отказа в сигнатурах, и вызывающие признают их через check.
Эта комбинация — сердце истории эффектов в Zero:
- Что может произойти →
raises { ... }. - Через что →
World(или его кусочек). - Где → везде, где видны capability и
raises.
Этой информации достаточно, чтобы рассуждать об эффектах функции точно, по одной только сигнатуре.
Заметка о тестировании
Capability-based I/O делает тестирование простым по построению. Хотите захватить вывод тестируемой функции? Передайте ей фейковый out capability, который записывает то, что её просили писать. Хотите протестировать функцию, которая должна быть чистой? Не передавайте ей никакого capability — её сигнатура не позволит ей трогать внешний мир.
Стандартная библиотека может предоставлять тестовые харнессы, которые строят фейковые или in-memory capability ровно для этого. Точный API будет эволюционировать с языком; принцип (capability — это значения, которые можно подменить) — это рычаг.
Дальше: Raises и Check
World — половина истории эффектов: поверхности, которые функция может трогать. Вторая половина — отказы: когда что-то идёт не так, как это распространяется? Это разбирается дальше — в Raises и Check.
Часто задаваемые вопросы
Что такое World в Zero?
World — это предоставляемый рантаймом capability-объект, дающий программе Zero доступ к внешнему миру: stdout, stdin, файлам, сети, переменным окружения и так далее. Рантайм конструирует значение World и передаёт его в main. Функции, которым нужно делать I/O, должны получить World (или более узкий его кусочек) — глобальной лазейки нет.
Почему main принимает параметр World?
В Zero нет ambient-глобалов. Нет эквивалента printf, console.log или os.Stdout, который любая функция могла бы вызвать без разрешения. Рантайм отдаёт main capability World, и main (и любая функция, которую она вызывает) может делать I/O только через это значение. Так каждый эффект становится виден в сигнатуре функции.
Чем capability-based I/O отличается от обычного I/O?
В большинстве языков I/O неявный — любая функция может писать в stdout или читать из файловой системы в любой момент. Capability-based I/O делает разрешение на I/O значением: вам нужно получить World (или его кусочек), чтобы им пользоваться. Чистые вычислительные функции не принимают World и потому в буквальном смысле не могут делать I/O — это обеспечивает система типов.
Могу ли я неявно получить World где-то глубоко в стеке вызовов?
Нет, по дизайну. Если помощнику глубоко в стеке нужно писать в stdout, вы обязаны передать ему World — или более узкий capability — явно. Это больше проброса параметров, чем в языке с ambient-I/O, но это и есть цена возможности по сигнатуре функции понять, может ли она делать I/O.
Что делает world.out.write?
world.out.write("text\n") пишет заданную строку в стандартный поток вывода программы через capability, который дал рантайм. Возвращает fallible-значение — запись может упасть — поэтому вызов оборачивают check, чтобы пробросить ошибку выше по стеку.