Сколько лет себя помню изучающим Rust [1], столько меня изводило весьма смутное понимание, что же такое "каст" в Rust. Я понимал, что это выглядит как "сделать из объекта А объект Б", но с таким уровнем владения материалом далеко не уедешь.
И вот теперь у меня появилась робкая надежда положить конец этим терзаниям. Тема довольно объемная, поэтому я разобью её обзор на несколько постов. Итак, приступим.
Знаете ли вы, что такое "абстракция"? Зачастую нам приходится иметь дело со сложной и плохо усваиваемой информацией, причем связанной между собой, поэтому её желательно поглощать пусть и по кускам, но системно. А умишко-то невелик! О чём, кстати, предупреждал ещё Козьма Прутков, глаголя, что нельзя объять необъятное. Поэтому давным-давно для преодоления этого затруднения принято использовать анализ, который позволяет увидеть за нагромождениями хаоса чёткие контуры главенствующей идеи, которая всем этим заправляет. Не всегда выявление этого сравнительно компактного ядра возможно, порою приходится использовать допущения, чтобы втиснуть всю информацию в задуманное нами прокрустово ложе, но итоговый результат того стоит, поскольку сия идея задумывается и является ключом к пониманию обозреваемой проблемы. Так вот, эта исключительно умозрительная конструкция и называется абстракцией. Кто-нибудь что-нибудь понял? Нет, ну да не беда, это и не было целью.
Главное понять, что грамотная абстракция позволяет усвоить информацию практически любого уровня сложности. Пример популярной абстракции — пресловутый "кот Шредингера".
Я долго размышлял над тем, какую абстракцию было бы удобно использовать для понимания сути кастования, каста. Примерно 1 час. Очень долго, согласитесь. И нашёл! Таковой абстракцией являются МНОЖЕСТВА. Я не помню математического определения сей субстанции из теории множеств, поэтому буду использовать упрощенное описательное. Множество состоит из элементов, но может быть и пустым. Множество может содержать подмножества; фактически любое множество содержит бесконечное число подмножеств, учитывая оговорку выше про пустые множества, поскольку множество пустых множеств не содержит ни одного элемента. Можно представить множества как сита с ячейками разного размера и формы, параметры ячеек описывают критерии отбора в множество: чем меньше ячейки и изощреннее форма, тем меньше элементов попадает в множество. Все эти смысловые экзерсисы, впрочем, нам не особенно нужны, мы и так не раз имели дело с пустыми множествами наличных денег и туалетной бумаги, и можем смело утверждать их небольшую практическую ценность. Итак, мы имеем, что:
• множество = подмножество_1 + подмножество_2 + … + подмножество_N
• множество = элемент_1 + элемент_2 + … + элемент_N
Запомнили и идём дальше.
Сразу — ага, через несколько абзацев от начала — обозначу два ключевых понятия:
• cast, casting — преобразование;
• coercion — приведение (не путать с ghost, привидением то бишь).
В дальнейшем я буду использовать эти понятия в их русском переводе, потому что это позволит их удобно склонять и создавать производные слова, но вы не затруднитесь напоминать себе англоязычные термины, которые за ними стоят.
Преобразование отвечает на вопрос "что?" ( "что делать?"), приведение расскажет, "как" это происходит. Преобразование указывает программист, привидение (чёрт, я и сам склонен ошибаться!) производит компилятор. Приведение валидно и допустимо, если удовлетворяет правилам приведения. Их много, и их все фиг запомнишь, но вы не расстраивайтесь: в случае чего, компилятор, как и всегда, оплюет вас стэктрейсом, который принесет с собой искру истины в виде сообщения об ошибке. Подробно правила приведения я разберу в следующих постах, которые войдут в открытое этим текстом множество постов о приведениях и преобразованиях (видите, как я ловко проверил вас на предмет усвоения изложенного материала?).
Главная пуанта заключается в том, что приводить элементы одного множества к элементам другого множества можно только в том случае, если эти множества являются подмножествами более общего множества.
Числовые преобразования
Закрепим и завершим первый пост рассмотрением преобразования чисел.
Удивительным образом числовые типы, соответствуя в плане семантики математическим числам, обладают таким свойством, как и различные подмножества всех действительных чисел. Поэтому будет достаточно просто привести пример на основе приведения экземпляров .
Таким образом, для закрепления, когда у вас возникает необходимость представить объект одного типа как объект другого типа, то вы указываете необходимость преобразования, а компилятор производит приведение, согласно правилам приведения. У приведения НЕТ синтаксиса, это строго вотчина компилятора. У преобразования синтаксис имеется, это бинарный оператор as:
let x = +1i8 as u8; // читается это как угодно, один из вариантов: "элемент `1` из множества знаковых 8-битных чисел представить как аналогичный элемент из множества беззнаковых 8-битных чисел"
Обратите пристальное внимание на мой комментарий. Как вы догадались, он не просто так написан. Согласно Чехову, висящее на стене ружье обязано выстрелить, и вот наша абстракция совершает пробную пристрелку.
Почему я записал 1 как +1? Знаковый числовой тип потому и знаковый, что его экземпляры имеют знак, указывающий по какую сторону от нуля на числовой оси они находятся, в области отрицательных чисел или же в области положительных чисел. Но в синтаксисе языков программирования принято то же допущение, что и в математике: у положительных чисел знак можно не указывать, потому что он не несет семантического значения (как и в жизни хорошим ребятам прощаются некоторые вольности просто потому, что они положительные). Но об этом не нужно забывать.
Как мы знаем, тип i8 является множеством, охватывающим … а сколько чисел он охватывает? Давайте посчитаем. i8 => для описания требуется 8 битов; каждый бит может иметь 2 значения: 0 или 1 (двоичная система счисления, дамы и господа!), отсюда, используя комбинаторику, получаем размер множества из формулы 2^8 = 256, то есть двойка в восьмой степени. Можно записать и по-другому, используя операцию побитового сдвига:
1 << 8 даёт 256
И как же располагаются эти 256 чисел? От -128 до +127, включая 0 как первый положительный элемент.
В то же время множество элементов типа u8 заключает в себе также 256 чисел, но поскольку тип беззнаковый, то числа эти исключительно положительные с добавлением нуля, то есть диапазон от 0 до 255.
И каким же образом мы можем преобразовать элементы из одного диапазона в элементы из другого диапазона? Здесь нужно вспомнить о свойстве, не присущем числам, но присущем числовым типам при преобразованиях: они кольцевые. В то время, как мат. числа находятся в диапазоне от -∞ до +∞, числовые типы данных, во-первых, не могут рассчитывать на такую роскошь ввиду того, что компьютерная память всегда ограничена какими-то конкретными пределами, а во-вторых, по большей части и не нуждаются в этом. Как мы уже выяснили выше, такой тип, как i8, охватывает всего две с половиной сотни значений. Но что же происходит, когда в результате операций мы выходим за границы диапазона числового типа? Оказывается, границы дипазона "соединены" друг с другом так, что диапазон образует кольцо, с тем лишь нюансом, что границы не совпадают, а идут друг за другом: +255_u8 + 1 даст 0_u8, -128_i8 - 1 даст +127_i8. То есть, если мы выходим из диапазона за пределы одной границы на N единиц, то этим самым входим в диапазон со стороны другой границы на N - 1 единиц.
Кстати, в Rust есть compile-time проверка выхода за пределы диапазона для числовых типов (это еще часто называется "переполнением") для констант (во всяком случае, работает она на этапе синтаксического анализа), поэтому примеры, которые я указал выше, наподобие +255_u8 + 1, не скомпилируются. Для успешной компиляции их нужно переписать с использованием промежуточной переменной:
fn main() {
let a = +255_u8; // правая граница диапазона типа `u8`
println!("{:?}", a + 1);
}
// выведет: 0 — левая граница диапазона типа `u8`
Вернемся же к поставленному ранее вопросу: каким же образом мы можем преобразовать элементы из одного диапазона в элементы из другого диапазона?
Идентификаторы числовых типов в Rust состоят из двух составляющих: категории и размера в битах. Первая обозначается символом, например, "i" в "i32", второй числом, например, "16" в "u16".
Итак, у нас есть число a типа CN (категории C, размера N), которое мы хотим преобразовать в число a1 типа C1N1 (категории C1, размера N1). Какими правилами приведения руководствуется компилятор при таких исходных (здесь мы резко переходим от моего словесного скоморошества к сухому и беспристрастному изложению документации):
Вы должно быть помните, что тыщей строк ранее я объяснял про объединение границ диапазона для его закольцовывания. На практике это трансформируется в следующее: остаток от деления исходного числа a на размер дипазона типа итогового числа a1, т.е. C2N1, в который оно преобразуется, даст нам число a1. Разумеется, если N меньше N1, то ничего переводить и не надо, ибо a1 = a, поскольку больший диапазон включает меньший диапазон той же категории. Также можно заметить, что никаких действий не нужно производить, если a меньше длины диапазона типа C1N1, поскольку нацело на него не делится и само по себе является искомым остатком. Нас интересуют все остальные случаи. Преобразование 257_u16 as u8 после вычисления 257 % 256 даст 1_u8, что нам и нужно.
• C != C1, N == N1 (i32 -> u32): преобразование между двумя целыми одинакового размера является "нулевой операцией", NOP. В это трудно поверить, но для подобного преобразования компилятору достаточно сменить у числа метку типа с исходного на конечный, и всё, но это будет сделано в compile-time, а для рантайма будет сгенерирована инструкция NOP, которая, естественно, будет удалена в ходе оптимизаций.
Давайте попробуем разобраться в сути на примере условных типов i2 и u2. Их нет среди числовых типов Rust, но умозрительно с ними будет проще работать. Возможно, вы обратили внимание, что максимальное значение, которое могут выразить знаковые типы, в почти 2 раза меньше максимального значения, которое могут выразить соответствующие беззнаковые типы! При этом для представления значений знаковые типы используют то же количество битов, что и соответствующие беззнаковые! Ситуация еще больше усложнится, если вспомнить, что компьютер представляет всю информацию в двоичном виде, в виде последовательности нулей и единиц, в том числе и числа. ОК, как перевести число из одной системы исчисления в другую мы знаем, но как выразить знаковость числа? "Плюс" или "минус" просто так не выразишь в двочном виде, это не числа.
Принято соглашение, что значение самого старшего бита знаковых типов указывает на знак числа этого типа: 0 — положительное число (т.е. знак необязателен и отсутствует), 1 — отрицательное число (знак обязателен и присутствует). Беззнаковые типы все биты используют для представления значения, поэтому имеют лишний бит для этого, с чем и связана способность выражать вдвое большее максимальное математическое число.
Типы i2 и u2 содержат по 2^2 = 4 значения. Выпишем их, учитывая соглашение об указании знака в старшем бите у знаковых типов:
i2:
-2 — 10 (знак обязателен, поэтому старший бит содержит 1)
-1 — 11 ^
0 — 00 (знак необязателен, поэтому старший бит содержит 0)
1 — 01 ^
u2:
0 — 00 (знаковый бит отсутствует, все биты служат для выражения значения)
1 — 01
2 — 10
3 — 11
Выпишем парами значения обоих типов, чьи двоичные представления совпадают:
0 0
1 1
2 -2
3 -1
Если проанализировать, то можно сделать заключение, что половина диапазона соответствующих друг другу знакового и беззнакового типа совпадают. Это положительная половина знакового диапазона знакового типа. Дальше начинаются чудеса. Выглядит так, словно дальнейшее соответствие приводит к тому, что отрицательная часть диапазона знакового типа "приставляется" после правой границы его диапазона, благодаря чему и достигается закольцованность диапазона, которая оказывается не математической абстракцией, а практическим следствием реализации представления чисел в машине! Действительно, если к двоичному представлению 1_i2 добавить единицу, то получится -2_i2 (01 + 1 = 10), то есть от правой границы происходит переход к левой, а если к двоичному представлению 3_u2 добавить единицу, то получится 0_u2 (11 + 1 = 10; на самом деле получится 100, но лишний старший бит откидывается, поскольку у нас тип ограничен 2-мя бита для представления значений).
Но постойте, остановит меня внимательный читатель. Разве при движении от 0 в отрицательную сторону двоичное представление каждого последующего числа не должно быть больше предыдущего, что не выполняется у нас, ведь соответствующее -2 10 меньше соответствующего -1 11?
Настало время раскрыть карты. Во-первых, в математике как раз чем ближе к 0, тем отрицательное число больше. Во-вторых, подобное соображение действительно справедливо, но для прямого кода. Но на большинстве популярных ныне процессорных архитектур используется дополнительный код ("two's complement", оригинальный англоязычный термин).
Приведу краткую цитату, чтобы пояснить как были получены двоичные представления для -1 и -2.
Преобразование числа из прямого кода в дополнительный осуществляется по следующему алгоритму:
* Если старший (знаковый) разряд числа, записанного в прямом коде, равен 0, то число положительное и никаких преобразований не делается;
* Если старший (знаковый) разряд числа, записанного в прямом коде, равен 1, то число отрицательное, все разряды числа, кроме знакового, инвертируются, а к результату прибавляется 1.
То есть бинарные представления положительных значений выглядят одинаково как при прямом кодировании, так и при дополнительном. Чтобы получить представления для -1, берем 01, инвертируем (т.е. вместо каждого значения битов записываем обратный: вместо 0 — 1, а вместо 1 — 0), получая 10 (так называемый "обратный код"), а прибавив 1, получим 11. Вуаля.
Так как я сам искал примерно минуту в черновике то место, к которому потребовалось приложить столь пространные лирические отступления, повторю его еще раз:
• C != C1, N == N1 (i32 -> u32): преобразование между двумя целыми одинакового размера является "нулевой операцией", NOP.
Как мы увидели, соответствующие друг другу значения знакового и беззнакового типа имеют одинаковое бинарное представление, поэтому преобразовывать их каким-либо образом друг в друга не нужно.
• C == C1, N > N1: преобразование из целого большего размера в целое меньшего размера одной категории (u32 -> u8) приведет к усечению пространства, которое используется для представления значения числа, т.е. освободится (N - N1) бит памяти.
Просто выкидываем нужное количество старших битов, и всего-то. Хотя есть один нюанс, ведь самый старший бит у знаковых типов является знаковым, и как быть с ним? Его тоже отбрасывают, НО новый самый старший бит заполняют 1.
• C == C1, N меньше N1: преобразование из целого меньшего размера в целое большего размера (i32 -> u32) приведёт к:
* zero extension, если исходное число беззнаковое целое.
* sign extension, если исходное число знаковое целое.
То есть если число положительное, то добавляем нужное количество разрядов и забиваем новые старшие биты нолями, если отрицательное — забиваем единицами.
Оказывается, это корректно и отлично работает. Если хотите, уделите время, чтобы восхититься еще раз этой великолепной абстракцией под названием "дополнительный код".
• Преобразование из числа с плавающей точкой к целому числу приведет к округлению первого в направлении нуля.
ПРИМЕЧАНИЕ: на текущий момент подобное округление приведёт к UB (Undefined Behavior), если округленное значение не может быть представлено конечным целым типом, включая Inf и NaN. Это баг, и будет исправлено.
• Преобразование из целого в число с плавающей точкой произведет соответствующее исходному целому число с плавающей точкой, округленное при необходимости (стратегия округления не определена).
• Преобразование из f32 в f64 совершенно и происходит без потерь.
• Преобразование из f64 в f32 произведет максимально близкое возможное значение (стратегия округления не определена).
ПРИМЕЧАНИЕ: на текущий момент подобное округление приведёт к UB (Undefined Behavior), если значение конечно, но больше или меньше, чем большее или меньшее конечное значение представимое f32. Это баг, и будет исправлено.
================================================================================
Неожиданно для меня материал получился довольно большим. Но остальное займет меньше времени. Стэй тюнед, о'ревуар.
Примечания:
[1] вообще-то я знаком с Rust'ом только первый год ;
Хм, обнаружились опечатки. Но исправить не могу, так как оригинальный черновик хранится на десктопе, а когда пытаешься отредактировать пост, сайт его выдает с похеренным местами форматированием, поэтому обычно я просто исправляю нужные места в черновике, а затем копипастой оттуда заменяю текст в веб-форме редактирования.
1 ответить