/
Поиск
RU

Основы: значения, функции, данные

Синтаксис 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.kl

kola test прогоняет каждый тест и на интерпретаторе, и через C-бэкенд и сравнивает результаты; --interp запускает только интерпретатор, и во время работы это быстрее.