Дженерики
Обобщённая функция (дженерик) — это функция, которая написана один раз, а работает со многими типами. В C того же добиваются макросами или через void* с приведениями типов; в Python обобщена любая функция, потому что до момента выполнения ничего не проверяется. kola, как Rust и C++, проверяет обобщённый код при компиляции, а параметры записывает в квадратных скобках.
Чего в kola нет, так это механизмов, которые другие языки строят вокруг дженериков. В Rust есть трейты (именованные наборы методов, которые тип обязуется предоставить, — аналог интерфейсов в Java или Go), значения dyn, выбирающие нужный метод во время выполнения через таблицу указателей на функции (vtable), и специализация. В kola ничего этого нет. Каждая обобщённая функция и структура мономорфизируется: для каждого конкретного набора аргументов компилятор порождает отдельную обычную функцию, как если бы вы сами написали first_u64 рядом с first_bool. В главе Основы уже встречалась first[T: data]. Здесь речь пойдёт обо всём остальном: как регионы, типы и константы делят один список в скобках, какие бывают ограничения, как хеш-таблица стандартной библиотеки обходится без трейтов и во что обходится мономорфизация.
Шаблоны C++ устроены так же. Альтернатива, которую используют Java и dyn в Rust, — динамическая диспетчеризация: одно общее тело, которое во время выполнения решает, какой конкретный код вызвать. Рецензенту такой подход неудобен тем, что прячет решение: какая реализация выполнится, определяется за косвенным вызовом, которого в месте вызова не видно. В kola, если известно, с какими аргументами инстанцирована функция, код перед глазами — это ровно тот код, который выполнится.
Один список в скобках для регионов, типов и констант
Регион — это арена: область памяти, из которой выделяется всё внутри блока region r { ... } и которая освобождается целиком на закрывающей скобке (см. Память: регионы и ссылки). Функция, принимающая ссылку в регион, называет этот регион своим параметром точно так же, как называет тип. Дженерики и регионы пишутся в одних и тех же квадратных скобках, а <...> в kola не встречается вовсе. Различает их регистр имени. Имя со строчной буквы связывает регион, имя с заглавной — тип, и параметр const тоже пишется с заглавной. fn f[r, T](x: &r T) читается так: «для любого региона r, который есть у вызывающего, и любого типа T, если дана ссылка в r». Вот пример контракта из самой карты, дополненный тестом:
fn split_at[r, T](xs: &r [T], mid: usize) -> (&r [T], &r [T])
req mid <= xs.len
ens ret.0.len == mid
{
(xs[0..mid], xs[mid..xs.len])
}
test split_works {
region r {
let xs = alloc in r [10u64, 20u64, 30u64, 40u64];
let (left, right) = split_at(xs, 2);
assert left.len == 2 && right.len == 2;
assert left[0] == 10 && right[0] == 30;
}
}Обе половины указывают в тот же регион, что и входной срез. В вызове нет ни r, ни T: оба выводятся из аргумента. Аргументы-типы и аргументы-константы можно написать явно (split_at[u64](xs, 2)), допускается и неполный список — остальное будет выведено. А вот регион в вызове писать нельзя никогда. Компилятор откажет и предложит убрать регион из списка, потому что параметр-регион всегда выводится из вызова. Так задумано: если добавить параметр-регион к существующей обобщённой функции, ни один её вызов не сломается.
С обобщёнными по региону типами иначе. В позиции типа их аргументы-регионы пишутся всегда (Header[r], а не просто Header), потому что это тип, а не вызов.
Ставить регионы первыми, а типы после них — соглашение стандартной библиотеки; чекер порядка не требует.
Ограничения: нет, data и move
Ограничение (bound) — это требование, которое обобщённый параметр предъявляет к принимаемым типам. В Rust ограничение называет трейт, например «T должен поддерживать Clone». Трейтов в kola нет, поэтому ограничение не может потребовать операцию. Оно говорит лишь о том, как можно обращаться со значением параметра: можно ли его копировать и можно ли выбросить. Это важно, потому что значения в kola бывают трёх видов. Обычные данные (числа, структуры из чисел) копируются свободно. Ресурс (открытый файл, сокет) используется ровно один раз: его нельзя ни скопировать, ни молча потерять. Владеющее значение (растущий Vec, которому принадлежит буфер в куче, или Map) переходит от одного владельца к другому и никогда не копируется, так что два имени никогда не делят один буфер. Соответственно, вариантов ограничения три.
Параметр без ограничения принимает любой тип, включая ресурс. Внутри тела T считается линейным: значение типа T используется ровно один раз, его нельзя скопировать и нельзя выбросить. Option[T] и Result[T, E] не ограничены, поэтому Result[File, FsErr] работает так же, как Result[u32, ParseErr], а линейность следит за ресурсом внутри обобщённой обёртки так, будто обёртки нет:
resource struct Ticket { id: u64 }
struct Box[T] { v: T }
fn box_new[T](v: T) -> Box[T] !{} {
Box { v }
}
fn box_open[T](b: Box[T]) -> T !{} {
let Box { v } = b;
v
}
fn close_ticket(t: Ticket) -> u64 !{} {
let Ticket { id } = t;
id
}
test unbounded_generic_holds_a_resource {
let b = box_new(Ticket { id: 7 });
let t = box_open(b);
let id = close_ticket(t);
assert id == 7;
}Раз тело должно рассчитывать на худшее, значение неограниченного T нельзя продублировать. Функция вроде fn dup[T](x: T) -> (T, T) { (x, x) } будет отвергнута с сообщением «use of consumed resource x», а подсказка предложит ограничение [T: data].
[T: data] обещает, что T — простые копируемые данные: внутри нет ни ресурса, ни чего-либо владеющего (ни собственного Vec, ни Map, ни значения со ссылкой &mut). Тогда тело вправе свободно копировать T, возвращать его по индексу и сравнивать через ==. Передать собственный Vec[u8] в параметр с : data не получится: компилятор откажет в месте вызова и посоветует передать ссылку или убрать ограничение.
[T: move] занимает промежуточное положение. Он принимает данные и владеющие значения, но не ресурсы. Значение T перемещается, его можно выбросить и положить в Vec, но нельзя скопировать. Это ограничение для контейнеров и вспомогательных функций, которые перекладывают собственные буферы:
// Оставить одно из двух значений, а другое выбросить.
fn pick[T: move](old: T, new: T, take_new: bool) -> T !{} {
if take_new { new } else { old }
}
fn stack_push[T: move](s: &mut Vec[T], x: T) !{alloc(heap)} {
s.push(x);
}
test move_bound_takes_owned_vecs {
let a: Vec[u8] = Vec::new();
let mut b: Vec[u8] = Vec::new();
b.push(7u8);
let kept = pick(a, b, true);
assert kept.len == 1;
let mut all: Vec[Vec[u8]] = Vec::new();
stack_push(&mut all, kept);
assert all.len == 1;
}Инстанцировать pick типом Ticket нельзя: параметр с : move разрешено выбросить, а ресурс обязан быть израсходован. Любое другое имя ограничения компилятор отвергает, называя его:
fn first[T: copyable](xs: &[T]) -> T !{} {
xs[0]
}error[card §2]: unknown bound `copyable` (kola has no traits)
--> a.kl:1:13
|
1 | fn first[T: copyable](xs: &[T]) -> T !{} {
| ^^^^^^^^
= help: the bounds are `data` (copyable, no resources inside) and `move` (moves, may be dropped, never copied; no resources inside)Обобщённый тип из стандартной библиотеки: Map[a, K, V]
У хеш-таблицы из std::collections::map один параметр-регион и два параметра-типа, и обходится она без всяких трейтов. Map::new(hint, hash, eq) принимает подсказку о ёмкости и две fn-ссылки: одна хеширует ключ, другая сравнивает два ключа. fn-ссылка — это функция, используемая как значение, вроде указателя на функцию в C; из окружения она ничего не захватывает. Таблица хранит обе ссылки и вызывает их напрямую. Для ключей u64 в модуле есть готовые id_u64 и eq_u64, для любого другого типа ключа из data пару функций пишут сами.
Map — владеющее значение: оно перемещается, а не копируется. Функции, которая таблицу читает, передают &m, а той, что её меняет, — &mut m. Методы — get, insert, has, remove, key_at, val_at и cursor, а m.len читается как любое другое .len (вызов m.len() отвергается):
use std::collections::map;
use std::collections::map::Map;
fn total_values[r](m: &Map[r, u64, u64]) -> u64 !{} {
let mut s: u64 = 0;
let mut c = m.cursor();
while c.next() {
s = s +% c.val();
}
s
}
test map_insert_get_replace {
region r {
let mut m: Map[r, u64, u64] = Map::new(16, map::id_u64, map::eq_u64);
let a = m.insert(100, 1);
let b = m.insert(200, 2);
assert a && b;
assert m.get(100) == Some(1u64);
assert m.get(300) == None;
let fresh = m.insert(100, 9);
assert !fresh;
assert m.get(100) == Some(9u64);
assert m.has(200) && m.len == 2;
assert total_values(&m) == 11;
}
}insert возвращает true для нового ключа и false, если значение заменено. Результаты вставок связаны с именами до assert, потому что assert не может менять состояние: доказанный assert из сборки удаляется, так что его выражение должно значить одно и то же, выполнится оно или нет.
На практике важны два ограничения. Ёмкость — жёсткий предел, перехеширования нет, поэтому если вставить намного больше различных ключей, чем указано в подсказке (примерно вчетверо), сработает трап, а не постепенная деградация. И курсор — это представление таблицы: пока он используется, вставлять в таблицу и удалять из неё нельзя.
total_values объявляет собственный параметр-регион [r], чтобы назвать Map[r, u64, u64] в своей сигнатуре. Регион, входящий в тип, должен быть где-то связан: объемлющим блоком region r { ... } или списком параметров функции. Типы — элементы модулей, поэтому вне своего модуля таблицу либо импортируют по имени (use std::collections::map::Map;, как здесь), либо пишут с путём: map::Map[r, K, V].
Свой обобщённый тип
Блок impl добавляет методы к обобщённому типу. Его параметры видны во всех методах, а при вызове метода берутся из получателя. Этот небольшой ассоциативный список устроен так же, как Map, и сравнение ключей тоже получает как fn-ссылку:
struct Assoc[a, K, V] {
eq: fn(K, K) -> bool !{},
keys: &a mut Vec[K],
vals: &a mut Vec[V],
}
impl Assoc[a, K: data, V: data] {
pub fn new(eq: fn(K, K) -> bool !{}) -> Assoc[a, K, V] !{alloc(a)} {
Assoc { eq, keys: alloc in a mut Vec::new(), vals: alloc in a mut Vec::new() }
}
pub fn put(self: &mut Assoc[a, K, V], k: K, v: V) -> bool !{alloc(a)} {
let eqf = self.eq;
for i in 0..self.keys.len {
if eqf(self.keys[i], k) {
self.vals[i] = v;
return false;
}
}
self.keys.push(k);
self.vals.push(v);
true
}
pub fn get(self: &Assoc[a, K, V], k: K) -> Option[V] !{} {
let eqf = self.eq;
for i in 0..self.keys.len {
if eqf(self.keys[i], k) {
return Some(self.vals[i]);
}
}
None
}
}
fn same_u64(a: u64, b: u64) -> bool !{} {
a == b
}
test assoc_put_and_get {
region r {
let mut m: Assoc[r, u64, u64] = Assoc::new(same_u64);
let fresh = m.put(7, 700);
assert fresh;
assert m.get(7) == Some(700u64);
assert m.get(8) == None;
}
}Обратите внимание на let eqf = self.eq; перед вызовом. Поле функционального типа вызывают через промежуточное имя или в скобках ((self.eq)(a, b)), потому что self.eq(a, b) было бы вызовом метода.
Одни и те же три функции могут обслуживать два разных типа одновременно. В примере для песочницы ниже ячейка на одно значение живёт в регионе, а преобразование приходит обычной fn-ссылкой. Нажмите «Check», затем «Tests»: cell_new, cell_get и cell_apply инстанцируются один раз для u64 и ещё раз для структуры из двух полей.
use std::log;
struct Cell[a, T] { v: &a mut Vec[T] }
fn cell_new[a, T: data](x: T) -> Cell[a, T] !{alloc(a)} {
let c = Cell { v: alloc in a mut Vec::new() };
c.v.push(x);
c
}
fn cell_get[a, T: data](c: Cell[a, T]) -> T !{} {
c.v[0]
}
// Преобразование приходит обычной fn-ссылкой.
fn cell_apply[a, T: data](c: Cell[a, T], f: fn(T) -> T !{}) !{} {
c.v[0] = f(c.v[0]);
}
fn double_u64(x: u64) -> u64 !{} {
x *% 2
}
struct Point { x: i64, y: i64 }
fn negate(p: Point) -> Point !{} {
Point { x: -p.x, y: -p.y }
}
fn main(sys: Sys) -> u8 !{io, block, alloc(heap), log} {
region r {
let c = cell_new(21u64);
cell_apply(c, double_u64);
log::info(f"cell holds {cell_get(c)}");
}
0
}
test cell_over_u64 {
region r {
let c = cell_new(21u64);
cell_apply(c, double_u64);
assert cell_get(c) == 42u64;
}
}
test cell_over_a_struct {
region r {
let c = cell_new(Point { x: 3, y: -5 });
cell_apply(c, negate);
let p = cell_get(c);
assert p.x == -3 && p.y == 5;
}
}Обобщение по константам
Иногда функцию нужно обобщить не по типу, а по числу, например по длине массива фиксированного размера. Параметр [const N: usize] связывает такое число типа usize, известное при компиляции. N можно использовать как длину массива ([T; N]), в константной арифметике внутри типа ([T; 2*N], допустимы +, - и *) и как обычное значение usize в теле функции. Каждое отдельное значение N даёт свой тип, поэтому экземпляры одной функции для [u64; 3] и [u64; 8] никогда не совпадают. N выводится из аргумента, если это возможно, или пишется явно: sum[3](&a).
fn sum[const N: usize](a: &[u64; N]) -> u64 !{} {
let mut s = 0u64;
for i in 0..N { s = s + a[i]; }
s
}
fn twice[const N: usize](a: &[u8; N]) -> [u8; 2*N] !{} {
let mut out = [0u8; 2*N];
for i in 0..N {
out[i] = a[i];
out[N + i] = a[i];
}
out
}
test const_generic_fns {
let a = [1u64, 2u64, 3u64];
assert sum(&a) == 6;
assert sum[3](&a) == 6;
let p = twice(&[7u8, 9u8]);
assert p.len == 4 && p[3] == 9u8;
}Параметр-константа может быть и у обобщённого типа. Если в вызове нет ничего, откуда можно вывести N (например, у конструктора без аргументов), его указывают в скобках после имени вызываемой функции:
struct Recent[const N: usize] { slots: [u64; N], next: usize }
impl Recent[const N: usize] {
pub fn new() -> Recent[N] !{} {
Recent { slots: [0u64; N], next: 0 }
}
pub fn push(self: &mut Recent[N], x: u64) !{} {
self.slots[self.next % N] = x;
self.next = self.next + 1;
}
pub fn sum(self: &Recent[N]) -> u64 !{} {
let mut s = 0u64;
for i in 0..N { s = s +% self.slots[i]; }
s
}
}
test recent_keeps_the_last_n {
let mut w = Recent::new[3]();
w.push(1);
w.push(2);
w.push(3);
w.push(10);
assert w.sum() == 15;
}С параметрами-константами связаны три правила. Имя пишется с заглавной: [const n: usize] отвергается, и подсказка предлагает переименовать параметр в N, потому что имена со строчной буквы связывают регионы. Тип должен быть usize: параметр const B: bool или const K: u32 будет отвергнут с советом написать usize. И длина массива разрешается в константной области видимости, так что локальная переменная времени выполнения не может заменить N: [0u64; n], где n — локальная переменная, отвергается. Если количество вычисляется во время выполнения, нужен Vec.
Чего в дженериках нет
Несколько привычных возможностей дженериков из Rust, C++ или Java в kola просто не к чему прикрепить:
Трейт-объекты и vtable. Значений вида «какой-то тип, реализующий X» с выбором метода во время выполнения не бывает. Каждый параметр при компиляции превращается в один конкретный тип.
Специализация и перекрывающиеся impl. У обобщённой функции ровно одно тело, и для более конкретного типа его не переопределить.
Вариантность. Между экземплярами нет отношения подтипов, так что ковариантности и контравариантности описывать нечего.
Типы высших родов. Параметр — это тип, регион или константа
usize, но не конструктор типов.Параметры-типы по умолчанию и обобщённые реализации.
struct Pair[A, B = A]не разбирается, и сказать «реализовать это для всехT: data» нельзя.Перегрузка операторов.
==— всегда встроенное структурное сравнение языка. Чтобы сравнивать иначе, передайте функцию сравнения fn-ссылкой, как делаетMap::newс параметромeq.
Во что обходится мономорфизация
Каждый экземпляр компилируется в собственную копию — отдельно названную функцию, а не общее тело с меткой типа, которую проверяют во время выполнения. Если бы main из примера с ячейкой использовал ещё и Cell с Point, kola build породил бы такие функции (выдержка из сгенерированного C; префикс k_cell__ взят из имени файла cell.kl):
struct k_cell__Cell__uint64_t k_cell__cell_new__uint64_t(KRegion* k_rgn_a, uint64_t x);
uint64_t k_cell__cell_get__uint64_t(struct k_cell__Cell__uint64_t c);
struct k_cell__Cell__struct_k_cell__Point k_cell__cell_new__struct_k_cell__Point(KRegion* k_rgn_a, struct k_cell__Point x);
struct k_cell__Point k_cell__cell_get__struct_k_cell__Point(struct k_cell__Cell__struct_k_cell__Point c);cell_new и cell_get написаны в исходнике один раз, а в бинарнике встречаются дважды, каждая со своим типом структуры. Размер кода растёт с числом экземпляров, которые программа действительно использует (экземпляр, который никто не вызывает, не порождается вовсе). Взамен нет ни указателя на vtable, ни косвенного вызова, и оптимизатору нечему помешать встроить функцию. Параметр-регион превращается в обычный аргумент KRegion*. Бэкенды, которые порождают этот код, описаны в главе Под капотом.