Всё ещё не могу начать писать игру, потому что всплыла очередная тема, без которой двигаться вперёд невозможно – дженерики. Я мог бы обойтись без них, но они используются в библиотеке SDL2 и потому деваться некуда.
Предыдущие части: Композиция, Модули, Начальное проектирование, Итоги про память, Что там с памятью, Колхозим интерфейсы, Где у него классы, Поддержка SDL2, Полируем ржавчину
Дженерики есть во многих языках программирования, и если вы уже сталкивались с ними, то в Rust проблем у вас они не вызовут.
Что такое дженерик (generic)?
Докапываясь до смысла слова, мы придём к латинскому genus, то есть да, те самые гены. В значении "общий", "родственный".
В фармацевтике есть фирменные наименования лекарств и дженерики – то есть аналоги, имеющие такой же состав. Например, у "Аспирина" дженерик – ацетилсалициловая кислота, а у "Эффералгана" – парацетамол.
Что касается программирования, дженерики были внедрены как один из инструментов полиморфизма. Посмотрим, как это работает.
Напишем для примера функцию, которая складывает два числа:
Мы передаём в функцию add() два числа с типом i32 (целое число размером 32 бита). Функция также возвращает результат с типом i32.
Вызывая функцию и передавая в неё два целых числа:
add(1, 2)
Мы получим результат 3. Но если мы захотим в ту же функцию передать два вещественных числа:
add(0.5, 0.25)
То получим ошибку: несоответствие типов. Значит, чтобы складывать вещественные числа, нам нужна отдельная функция:
Ну и далее можно представить, что нас ждёт: допустим, мы обрабатываем данные из какой-то таблицы, где есть и целые, и вещественные числа, и значит надо или все числа приводить к одному типу, или вызывать свою функцию для каждого типа числа.
Однако можно видеть, что функции add() и add_float() по коду идентичны. И было бы неплохо одну функцию заставить работать с несколькими типами. То есть – сделать из конкретных типов дженерики.
Для начала напишем так:
Ничего не изменилось, просто вместо типа мы написали T. В Rust принято типы для дженериков указывать одной большой буквой.
Суть, думаю, понятна – буква T выступает как дженерик (заменитель) любых конкретных типов, таких как i32, f32 и т.п. Функция получает два параметра с типом T и возвращает результат также типа T (хотя это и необязательно).
Но буква T не является магической. Встретив её, Rust будет искать именно такой тип, и конечно не найдёт. Поэтому правильная дженерик-функция должна выглядеть так:
После имени функции написано <T> – именно это даёт понять, что T это не конкретный тип, а дженерик, а функция, соответственно, привязана (bound) к нему.
Можно праздновать победу, но не всё так просто. При попытке запустить программу Rust вывалит ошибку:
О чём, в двух словах, говорит иностранец?
Rust сообщает, что не может сложить тип T с типом T. С чего вдруг? Так как T это дженерик, то вместо него можно подставить любой тип. А если любой, то это могут быть необязательно два числа, а например строки, списки, структуры и т.д.
Становится очевидно, что действительно нельзя сложить T и T. Потому что для некоторых конкретных типов операция сложения может быть вообще не определена.
Что же делать? Оказывается, Rust сразу пишет подсказку:
То есть, попробуйте ограничить дженерик T так, чтобы ему соответствовали не все конкретные типы, а только те, которые можно складывать. Так как предлагаемое решение
fn add<T: std::ops::Add<Output = T>>(a:T, b:T) -> T {
сложно для восприятия, поясню его в несколько этапов.
Сначала рассмотрим простое ограничение дженерика. Оно делается через указание типа, условно так:
<T:i32> – ограничивает дженерик типом i32
Но во-первых, это бессмысленно (зачем делать дженерик для одного типа?), во-вторых, если мы попытаемся так написать, то увидим, что Rust не приемлет конкретные типы в ограничениях дженерика. Вместо этого нужно указывать трейты. Например, все типы, которые можно складывать, имеют трейт Add.
Таким образом, мы получаем разновидность интерфейса:
<T:Add>
Дженерик T ограничивается всеми типами, имеющими трейт (интерфейс) Add, и значит их можно складывать.
Теперь посмотрим на ту подсказку, что предложил Rust. Вместо Add там написано:
std::ops::Add
Это просто полный путь к трейту Add через модуль std и подмодуль ops. Теперь можно уложить в голову:
<T: std::ops::Add>
И наконец, после Add написано <Output = T>. Что это такое?
Как нетрудно догадаться, трейты также могут использовать дженерики. В данном случае они работают немного по-другому. У трейта Add есть собственное свойство Output, которое используется для назначения выходного типа. И с помощью <Output = T> мы назначаем этому свойству наш дженерик T. Полная запись получается:
<T: std::ops::Add<Output = T>>
Проговорим то, что получилось, ещё раз:
Дженерик T, который ограничен типами с трейтом Add, которому (трейту) мы задали свойство выходного типа Output = T.
Звучит и пишется это очень громоздко, но если разбирать по частям, то сложностей быть не должно.
Забегая вперёд, скажу, что в ограничении дженерика можно использовать несколько трейтов, например, есть трейты Add и Sort, и мы хотим, чтобы дженерик T был ими ограничен:
<T: Add + Sort>
Зачем дженерики, если есть трейты?
В прошлых выпусках мы уже рассматривали трейты как решения для полиморфизма.
Так как трейт можно указать в качестве типа, то, получается, можно обойтись без дженериков, просто указав соответствующие типы параметров функции, например:
fn add(a: Add, b: Add) -> Add
Здесь мы передаем параметры a и b, имеющие трейт Add, и возвращаем также тип, имеющий трейт Add.
Вроде бы условия соблюдены: мы можем передавать в функцию любые типы, имеющие трейт (или, другими словами, реализующие интерфейс) Add.
Но здесь непонятно, что делать с возвращаемым значением. Да, оно имеет трейт Add, но какой конкретно тип должен возвращаться? Это мы можем указать, опять же теоретически, написав:
fn add(a: Add, b: Add) -> Add<Output = i32>
Но во-первых, это не работает. Хотя приблизительно Rust понимает эту логику, но чтобы её реализовать правильно, там нужно наломать столько дров, что я просто пас, нет, не хочу и не буду.
Во-вторых, получилась какая-то чушь. Мы с тем же успехом можем указать выходным типом i32:
fn add(a: Add, b: Add) -> i32
И это в общем-то даже законно, но мы тем самым сводим все разные входные типы к одному выходному типу. Например, на входе строки, а на выходе что, число? Но нам надо не так.
Мы хотим, чтобы тип на выходе совпадал с типами на входе. А описать эту зависимость не можем. Поэтому тут на помощь и приходят дженерики: обозначив некий тип как T, мы задаём в функции параметры и выходные значения именно этого типа. Это не трейт, а именно конкретный тип, который просто не определён сейчас, но будет подставлен во время компиляции.
Как это получится?
Например, Rust видит. что в коде написано:
add(2, 3)
Он видит, что параметры 2 и 3 это целые числа, и вызывает функцию add() с типом T, равным i32. Фактически же он генерирует конкретный вариант функции add() для целочисленного типа. Это как если бы мы написали руками разные функции для разных типов, но Rust делает это автоматически за нас.
Благодаря такому механизму использование дженериков не создаёт дополнительной нагрузки на процессор во время исполнения программы. Всё разруливается на этапе компиляции.
Дальше я попробую осилить прокладку к SDL2 с использованием дженериков, и наверное тогда уж можно будет писать игру.
Читайте дальше: Графическая прокладка