Основы: значения, функции, данные
Синтаксис kola близок к синтаксису Rust, но сам язык гораздо меньше и всё существенное записывает явно. То, о чём здесь пойдёт речь, покажется знакомым по любому C-подобному языку, а знание Rust не предполагается. Каждый пример — законченный файл, который можно проверить через kola check или запустить через kola test.
Разбор main
Вот main, которая делает две вещи. Наведите курсор на выделенные фрагменты:
use std::log;
fn main(sys: Sys) -> u8 !{io, log} {
log::info("hello, kola");
let t = sys.clock.now();
log::info(f"booted at {t}");
0
}Любая функция имеет вид fn name(parameters) -> Type !{effects} { body }. Ряд эффектов !{...} — та часть, которой в C нет аналога. Это список того, что функции разрешено делать помимо вычисления результата: выделять память, обращаться к внешнему миру, писать в журнал. Компилятор проверяет, что тело не выходит за пределы этого списка. Функция, которая ничего не возвращает, пишется без -> Type; её результат — unit, (), аналог void из C, и писать его вручную приходится редко.
main возвращает u8 или ничего и принимает либо ни одного параметра, либо один Sys по значению. Возвращаемое число — код завершения. Когда программа заканчивается, операционная система передаёт это небольшое число тому, кто её запустил (оболочке, скрипту, системе сборки); по соглашению 0 означает успех, всё остальное — неудачу. В оболочке его можно посмотреть в $?. Sys — набор капабилити, то есть значений, без которых программа не может добраться до часов, файлов, сети и прочего мира: у функции, которой их не дали, нет никакого способа это сделать. Вычисляемый код завершения сужают явно: narrow[u8](n) возвращает Option, и тогда сам код решает, что сообщать при 256 ошибках. Option[T] — тип kola для значения, которого может не быть: это либо Some(x), либо None, а null в языке нет.
Переменные и целые числа
let объявляет неизменяемую привязку (переменную, которой нельзя присвоить новое значение), let mut — изменяемую. Целые типы фиксированной ширины: i8–i64 (знаковые) и u8–u64 плюс u128 (беззнаковые). Кроме них есть bool, char, f64 (о нём ниже) и unit (). Двух типов нет намеренно: i128 и isize.
Целые литералы записываются в четырёх системах счисления: десятичной 42, шестнадцатеричной 0x2A, восьмеричной 0o644 и двоичной 0b1010. Подчёркивание между цифрами только группирует их: 1_000_000. Суффикс задаёт тип, как в 3u64, и значение обязано в него помещаться (32768i16 — ошибка компиляции). Суффикс нужен там, где тип больше ничем не определяется, например в [1u64, 2u64] или 1u32 << n.
Ширина целого типа — это число бит, которое он занимает; от неё зависит диапазон значений (u8 вмещает числа от 0 до 255). Литерал без суффикса берёт ширину из окружения: из аннотации, типа параметра, второго операнда или позиции индекса (а это usize). Если ширину ничто не задаёт, она равна u64 или i64, если в дело хоть раз вмешивается отрицательное значение (let mut c = 0; c = -1; делает c типом i64).
На этом часто спотыкаются: счётчик без явного типа, начинающийся с 0, получает тип u64, и let mut c = 0; c = c - 1; вызывает трап, потому что - над беззнаковым типом проверяется так же, как любой другой оператор. Если значение может стать отрицательным, укажите i64.
const MODE: u32 = 0o644;
const FLAGS: u8 = 0b1010_0101;
test literal_forms {
assert 42u32 == 0x2Au32; // десятичная и шестнадцатеричная запись
assert MODE == 420u32; // восьмеричная: маска прав доступа Unix
assert FLAGS == 165u8; // двоичная
assert 1_000_000u32 == 1000000u32; // `_` только группирует цифры
assert 0x5741_4C00u32 == 1463897088u32;
}Символьных и байтовых литералов вроде 'v' нет: пишите 118u8 и комментарий рядом. Зато есть байтовые строки, о них ниже.
usize — тип размеров и индексов: такой тип имеют .len и i в xs[i]. На 64-битной машине он такой же ширины, как u64, но это отдельный тип, так что количество элементов нельзя случайно перепутать с обычными данными.
Преобразования: почему нет as
В C и Rust as под одним названием делает две разные вещи. Он безобидно расширяет (u32 в u64) и сужает, молча отбрасывая биты (300 as u8 даёт 44). В kola вместо этого три слова, и каждое говорит, что именно имелось в виду:
widen[u64](x)расширяет. Это всегда без потерь, результат — обычное число.narrow[u8](x)сужает точно. Значение может не поместиться, поэтому результат имеет типOption[u8], и что делать сNone, решаете вы.wrap[u8](x)намеренно усекает: оставляет младшие 8 битxи толкует их в целевом типе; трапа здесь не бывает. Это неявное преобразованиеintвint16_tиз C, только записанное явно, как в хешировании или в финальной свёртке контрольной суммы. Целевой тип не может быть шире исходного; одинаковая ширина допустима, и такu16перетолковывают какi16.
test conversions {
let x: u32 = 300;
assert widen[u64](x) == 300u64; // всегда помещается
assert narrow[u8](x) == None; // 300 не помещается в u8
assert narrow[u8](255u32) == Some(255u8);
assert wrap[u8](x) == 44u8; // намеренно оставляем младшие 8 бит
assert wrap[i16](0x18000i32) == -32768i16; // та же ширина, другое толкование
}Неявных преобразований нет вообще. Писать приходится больше, зато в коде, где переполнение — дыра в безопасности (длины пакетов, индексы), каждое преобразование на виду, и читатель, не уходя со строки, отличает «это может не получиться» от «здесь биты отбрасываются нарочно».
Арифметика: трап вместо тихого переполнения
В C переполнение знаковых чисел — неопределённое поведение: стандарт ничего не говорит о том, что произойдёт, поэтому компилятор вправе считать, что этого не бывает, и программа может повести себя так, что причину потом трудно найти. Беззнаковые числа при переполнении молча переходят через ноль, так что 255 + 1 в байте превращается в 0. kola не делает ни того, ни другого. При переполнении и делении на ноль обычная арифметика + - * / % вызывает трап: программа останавливается в этой точке, каждый раз одинаково, вместо того чтобы продолжать с неверным числом (как ограничить последствия трапа, объясняет глава Контракты, трапы и тесты). Сдвиг на ширину операнда или больше тоже вызывает трап, а унарный -x — это ровно 0 - x, поэтому на минимальном значении типа срабатывает трап.
Знаковое деление отбрасывает дробную часть в сторону нуля, а % получает знак делимого: -7 / 2 равно -3, а -7 % 2 равно -1. Единственное переполняющееся знаковое деление, MIN / -1, вызывает трап.
Иногда перенос по модулю и есть суть алгоритма: хеши, контрольные суммы, криптография, кольцевые счётчики. Для них есть отдельные операторы +%, -% и *% (деления и % с переносом нет). Хеш FNV-1a держится на переполнении при умножении:
fn fnv1a(bytes: &[u8]) -> u64 !{} {
let mut h: u64 = 14695981039346656037;
for i in 0..bytes.len {
h = (h ^ widen[u64](bytes[i])) *% 1099511628211;
}
h
}
test fnv1a_known_value {
assert fnv1a(&b"kola") == 119015361278554776;
}Побитовые операторы & | ^ << >> работают как в Rust, а унарный ~ даёт дополнение. ~x определён как x ^ значение из одних единиц ширины x, поэтому он допустим везде, где допустим целочисленный ^, в том числе для знаковых типов, и трапа не вызывает никогда (~MIN равно MAX).
test bitwise_and_wrapping {
assert ~0b1u8 == 0b11111110u8; // ~x — это x ^ все единицы
assert ~0u32 == 4294967295u32;
assert (0b1010u8 & 0b0110u8) == 0b0010u8;
assert (1u32 << 5) == 32u32;
assert 250u8 +% 10u8 == 4u8; // сложение с переносом
assert 0u8 -% 1u8 == 255u8;
assert -7i64 / 2 == -3; // отбрасывание в сторону нуля
assert -7i64 % 2 == -1; // знак делимого
}f64: детерминированная плавающая точка
Тип с плавающей точкой в kola один — f64: 64-битный формат IEEE 754, который в C называется double, а в Python — float. Литералы выглядят как 1.5, 2.5e-3 и 1f64. Доступны + - * /, унарный -, сравнения и f64::sqrt. Каждая операция округляет к ближайшему и даёт побитово одинаковый результат на всех бэкендах. Деление на 0.0 даёт ±Inf, а операция, результатом которой оказался NaN («не число», например 0.0 / 0.0), вызывает трап. Преобразования в целые и обратно явные.
test f64_basics {
let s = 0.1 + 0.2;
assert s == 0.30000000000000004; // binary64, как и везде
assert f64::sqrt(9.0) == 3.0;
assert f64::of_i64(3) / 2.0 == 1.5; // явно, как и любое преобразование
assert f64::to_i64(2.9) == Some(2i64); // None для NaN и значений вне диапазона
assert f64::to_str(1.5) == "1.5";
}f64::to_str выделяет память под результат по тому же правилу, что и f-литерал (см. ниже). Трансцендентных функций вроде sin или exp нет: они берутся из libm, математической библиотеки C, а её результаты на разных платформах расходятся в последних битах. В ct fn тип f64 запрещён (Константное время).
Блоки и чистые функции
Блок { ... } — это выражение, значение которого равно последнему выражению без точки с запятой в конце. if/else тоже выражение:
fn describe(n: u64) -> &heap str !{} {
if n == 0 { "zero" } else if n < 10 { "small" } else { "big" }
}
test describe_sizes {
assert describe(0) == "zero";
assert describe(3) == "small";
}describe и fnv1a помечены !{}, и поэтому они чистые. Чистую функцию легко тестировать и безопасно вызывать откуда угодно, а рецензент, не читая тела, знает, что она не лезет в сеть и не расходует память. Нечистые функции — это нормально; их эффекты объявлены в сигнатуре, и компилятор проверяет это объявление: пометить !{} функцию, которая пишет в журнал, — ошибка компиляции.
Строки
Обычный строковый литерал "..." — это текст. В нём ничего не подставляется (фигурные скобки остаются просто скобками), и это статические данные, хранящиеся прямо в исполняемом файле: литерал не выделяет память и имеет тип &heap str, то есть ссылку во встроенный регион heap, который живёт столько же, сколько программа. Регион — единица управления памятью в kola: кусок памяти, который раздаётся по частям и потом освобождается целиком, вместо пары malloc/free на каждый объект. Что из этого следует, объясняет глава Память: регионы и ссылки. str — неизменяемый UTF-8. s.len — длина в байтах, а s.bytes — те же байты в виде &[u8], без копирования. Обратно из байтов в str можно попасть только через проверяющую utf8::to_str из std::text::utf8, которая возвращает Option.
Чтобы собрать текст из значений, поставьте перед литералом f. В каждой дыре {...} f-литерала стоит одно выражение, значение которого можно вывести как текст:
use std::log;
struct Point { x: i64, y: i64 }
fn show(n: u64, p: Point) !{log} {
log::info(f"n={n} doubled={n * 2} point=({p.x},{p.y})");
}
test interpolation {
show(3, Point { x: 4, y: -2 }); // в журнале: n=3 doubled=6 point=(4,-2)
}f-литерал собирается во время выполнения, поэтому результат должен где-то жить. Если передать его прямо в параметр, который только читает строку, как в log::info(f"..."), он собирается во временном буфере на стеке, который исчезает после возврата из вызова, и память не выделяется. Если же привязать его через let, получится новая строка. Она попадает в регион, который назван в месте назначения (let s: &r str = f"..." или тип возврата -> &r str), а если такого нет — в самый внутренний открытый блок region. Если нет ни того, ни другого, компилятор отвергнет код, а не станет молча выделять память на всё время жизни программы. Запись let s: &heap str = ... говорит, что имелось в виду именно это, и требует эффекта alloc(heap). Внутри test f-литерал, которому больше некуда деться, попадает в heap.
Ещё несколько правил записи литералов:
{{и}}внутриf-литерала дают одну скобку. В обычном литерале они запрещены: там скобки и так записываются как есть.В дыре должно быть значение, которое можно вывести: дыра типа
()запрещена, как и дыра, состоящая из одного числа, напримерf"{0}".Экранирующие последовательности —
\n \t \r \0 \\ \", и других нет.\0— обычный символ, поэтому"a\0b".lenравно 3.Сырой литерал,
r"..."илиr#"..."#, — это ровно текст между ограничителями, без экранирования и интерполяции. Так удобно записывать JSON, команды оболочки и фрагменты на C.
Байтовые строки
Сетевые протоколы оперируют байтами, а не текстом, поэтому для них в kola есть второй литерал: b"GET /". Это сокращённая запись фиксированного массива [u8; N], где N — число байтов. Байты — статические данные программы, поэтому &b"..." имеет тип &heap [u8]: такую ссылку можно хранить и возвращать, а формы &mut у неё нет. Именно её передают в API, принимающие байты, например в хеш-функцию или в поиск капабилити по имени.
Содержимое — печатаемые символы ASCII плюс небольшой набор экранирований: \n \t \r \\ \" \0 \xNN. Любой другой байт в исходном виде — ошибка компиляции с подсказкой записать его как \xNN. Дыр для интерполяции в байтовых строках нет.
test byte_strings {
let tag = b"GET /"; // [u8; 5] из статических данных программы
assert tag.len == 5;
assert tag[0] == 71u8; // 'G': символьных литералов нет
let line = &b"ok\r\n"; // &heap [u8]: можно хранить и возвращать, &mut нет
assert line.len == 4;
assert line[3] == 10u8; // '\n'
assert (&b"\x00\xFF")[1] == 255u8;
}Циклы: for, while, loop
Цепочек итераторов (.map().filter()) в kola нет; пишется обычный цикл одного из трёх видов.
for проходит по диапазону a..b (без b) или по срезу и ни по чему другому. При проходе по срезу переменная цикла по очереди принимает значение каждого элемента:
fn count_even(xs: &[u64]) -> usize !{} {
let mut n = 0;
for x in xs {
if x % 2 == 0 { n = n + 1; }
}
n
}
test counting {
let xs = [1u64, 2u64, 3u64, 4u64];
assert count_even(&xs) == 2;
}while нужен, когда шаг нерегулярный или условие сложнее простого счётчика:
fn sum_while(xs: &[u64]) -> u64 !{} {
let mut s: u64 = 0;
let mut i: usize = 0;
while i < xs.len {
s = s +% xs[i];
i = i + 1;
}
s
}loop крутится до break; он нужен, когда условие выхода находится посередине логики:
fn pow2_at_least(n: u64) -> u64 !{}
req n <= 9223372036854775808
{
let mut p: u64 = 1;
loop {
if p >= n { break; }
p = p * 2;
}
p
}
test powers {
assert pow2_at_least(9) == 16;
assert pow2_at_least(1) == 1;
}Строка req n <= ... — предусловие; без него p * 2 могло бы переполниться. Контрактам посвящена глава Контракты, трапы и тесты. Досрочный выход из функции — обычный return.
Ссылки в сигнатуре
В count_even(xs: &[u64]) амперсанд делает параметр ссылкой: функция видит массив вызывающего (count_even(&a)), не копируя его и не забирая во владение. У среза &[u64] есть .len, индексация и подсрезы xs[a..b]. Внутри ссылка — это указатель, но оператора разыменования * и арифметики указателей нет; ссылкой пользуются так же, как самим значением. Что такое &r, ссылка с именем региона, объясняет глава Память: регионы и ссылки.
Структуры, перечисления, match
struct Point { x: i64, y: i64 }
enum Shape {
Dot,
Rect(u32, u32),
}
fn shape_area(s: Shape) -> u64 !{} {
match s {
Shape::Dot => 0,
Shape::Rect(w, h) => widen[u64](w) * widen[u64](h),
}
}
test areas {
assert shape_area(Shape::Rect(2, 5)) == 10;
assert shape_area(Shape::Dot) == 0;
let p = Point { x: 1, y: 2 };
assert p.x + p.y == 3;
}Конструкторов нет. Значение структуры создаётся перечислением всех её полей, Point { x: 1, y: 2 }, а поле читается через точку, p.x. Собственной видимости у полей нет: поле доступно везде, где доступен тип его структуры, поэтому pub на поле не пишут никогда.
Образец — это форма, с которой сопоставляется значение; при совпадении имена в образце связываются с соответствующими частями значения. Образцы встречаются в match и в let, а если имя переменной совпадает с именем поля, есть сокращённая запись:
struct Point { x: i64, y: i64 }
test shorthand_both_ways {
let x: i64 = 1;
let y: i64 = 2;
let p = Point { x, y }; // сокращение: `x` означает `x: x`
let q = Point { x: x, y: y }; // то же самое без сокращения
assert p == q;
let Point { x: px, y: py } = p; // разбор на px и py
assert px + py == 3;
let Point { x, y } = q; // с сокращением: поля попадают в x и y
assert x + y == 3;
}Вариант перечисления может нести данные, как Rect(u32, u32), и match их распаковывает: в ветке Shape::Rect(w, h) имена w и h связываются с содержимым варианта. match обязан покрыть все варианты, иначе:
error[card §1]: match is not exhaustive: missing Yellow
= help: match is exhaustive in kola; add the missing arms or `_`Ветка _ => ... покрывает всё остальное. Вариант без данных всегда пишется с именем перечисления, Shape::Dot: голое Dot в образце читалось бы как новая привязка, совпадающая с чем угодно, поэтому компилятор его отвергает.
Ещё одно правило образцов стоит усвоить сразу, потому что привычки из Rust и из C здесь расходятся. Голый идентификатор в образце, который называет видимую целую константу const, сравнивается со значением этой константы, а не вводит новую переменную. Это правило Rust, и с ним разбор токенов читается естественно:
const TOK_INT: u8 = 1u8;
const TOK_STR: u8 = 2u8;
fn classify(op: u8) -> &heap str !{} {
match op {
TOK_INT => "int", // сравнение со значением константы
TOK_STR => "str",
_ => "unknown", // ветка с константой не ловит всё подряд
}
}
test const_patterns_compare_by_value {
assert classify(1u8) == "int";
assert classify(2u8) == "str";
assert classify(9u8) == "unknown";
}Ветка с константой — та же ветка с литералом, поэтому match из одних таких веток всё равно требует _. А образец никогда не смотрит на локальные переменные: let, где-то в функции повторяющий имя константы, не меняет того, с чем сравнивается ветка.
Кортежи
Кортеж — безымянная группа значений фиксированного размера, (a, b); его тип записывается так же: (u64, u64). Он подходит функции, которая возвращает две вещи сразу, когда отдельная именованная структура была бы лишним шумом. Разобрать кортеж можно образцом в let или обратиться к элементам через .0 и .1:
fn min_max(xs: &[u64]) -> (u64, u64) !{} req xs.len > 0 {
let mut lo = xs[0];
let mut hi = xs[0];
for i in 0..xs.len {
if xs[i] < lo { lo = xs[i]; }
if xs[i] > hi { hi = xs[i]; }
}
(lo, hi)
}
test tuples {
let xs = [5u64, 2u64, 9u64];
let (lo, hi) = min_max(&xs); // разбираем пару
assert lo == 2;
assert hi == 9;
let p = (1u32, true);
assert p.0 == 1u32; // или берём элемент по номеру
assert p.1;
}Равенство — по значению
Point и Shape — типы данных: они свободно копируются, а == сравнивает их поле за полем. Ссылки сравниваются по тому, на что указывают, и никогда по адресу:
struct Point { x: i64, y: i64 }
test equality_is_by_value {
let a = Point { x: 1, y: 2 };
let b = Point { x: 1, y: 2 };
assert a == b; // две переменные, одно значение
region r {
let ra = alloc in r Point { x: 1, y: 2 };
let rb = alloc in r Point { x: 1, y: 2 };
assert ra == rb; // две ссылки: сравниваются значения
}
}Последовательности тоже сравниваются поэлементно: срез, фиксированный массив и Vec с одинаковыми элементами равны, так что писать вручную цикл сравнения байтов не нужно. (== не работает за константное время; секреты сравнивают в ct fn.)
Другой вид значений — линейные ресурсы, объявляемые через resource struct; их нельзя копировать вовсе. См. Ресурсы и линейность.
Дженерики
Параметры-типы пишутся в квадратных скобках; <...> в kola не используется нигде. Ограничение T: data означает, что T должен быть копируемым:
fn first[T: data](xs: &[T]) -> Option[T] !{} {
if xs.len == 0 { None } else { Some(xs[0]) }
}
test generic_first {
let xs = [1u64, 2u64, 3u64];
assert first(&xs) == Some(1u64); // T = u64, выводится сам
let e = [0u64; 0];
assert first(&e) == None;
}Обобщённая функция мономорфизируется: компилятор создаёт отдельную специализированную копию для каждого типа, с которым она используется, как если бы вы вручную написали first_u64. Во время выполнения программе не нужно выяснять, какую версию вызвать, поэтому обобщённая функция работает так же быстро, как написанная вручную.
Те же скобки могут нести значение времени компиляции. [const N: usize] связывает N как usize, которым можно пользоваться и как длиной массива ([T; N], в том числе с арифметикой вроде [T; 2*N]), и как обычным числом в теле. Имя константного параметра пишется с заглавной буквы, как и имя параметра-типа. Для каждого различного N создаётся своя копия, а само N выводится из аргументов:
fn sum_fixed[const N: usize](a: &[u64; N]) -> u64 !{} {
let mut s: u64 = 0;
for i in 0..N {
s = s +% a[i];
}
s
}
test const_generics {
let a = [1u64, 2u64, 3u64];
assert sum_fixed(&a) == 6; // N = 3, выведено из аргумента
let b = [10u64, 20u64];
assert sum_fixed(&b) == 30; // N = 2: отдельная копия
}В квадратных скобках бывает и третий вид параметров — регион, он пишется строчными буквами (fn f[r](x: &r T)). Регионам посвящена следующая глава. Все три вида целиком, включая обобщённые блоки impl и явное указание параметров, разбирает глава Дженерики.
Тесты с первого дня
Блок test живёт в том же файле, что и проверяемый код, и служит одновременно проверкой и запускаемым примером использования. Он есть у большинства примеров выше:
kola test basics.klkola test прогоняет каждый тест и на интерпретаторе, и через C-бэкенд и сравнивает результаты; --interp запускает только интерпретатор, и во время работы это быстрее.