Rust — элегантный язык, который несколько отличается от многих других популярных языков. Например, вместо использования классов и наследования, Rust предлагает собственную систему типов на основе типажей. Однако я считаю, что многим программистам, начинающим свое знакомство с Rust (как и я), неизвестны общепринятые шаблоны проектирования.
В этой статье, я хочу обсудить шаблон проектирования новый тип (newtype), а также типажи From
и Into
, которые помогают в преобразовании типов.
Скажем, вы работаете в европейской компании, создающей замечательные цифровые термостаты для обогревателей, готовые к использованию в Интернете Вещей. Чтобы вода в обогревателях не замерзала (и не повреждала таким образом обогреватели), мы гарантируем в нашем программном обеспечении, что если есть опасность замерзания, мы пустим по радиатору горячую воду. Таким образом, где-то в нашей программе есть следующая функция:
fn danger_of_freezing(temp: f64) -> bool;
Она принимает некоторую температуру (полученную с датчиков по Wi-Fi) и управляет потоком воды соответствующим образом.
Все идет отлично, покупатели довольны и ни один обогреватель в итоге не пострадал. Руководство решает перейти на рынок США, и вскоре наша компания находит местного партнера, который связывает свои датчики с нашим замечательным термостатом.
Это катастрофа.
После расследования выясняется, что американские датчики передают температуру в градусах Фаренгейта, в то время как наше программное обеспечение работает с градусами Цельсия. Программа начинает подогрев как только температура опускается ниже 3° Цельсия. Увы, 3° по Фаренгейту ниже точки замерзания. Впрочем, после обновления программы нам удается справиться с проблемой и ущерб составляет всего несколько десятков тысяч долларов. Другим повезло меньше.
Новые типы
Проблема возникла из-за того, что мы использовали числа с плавающей запятой, имея в виду нечто большее. Мы присвоили этим числам смысл без явного указания на это. Другими словами, наше намерение заключалось в работе именно с единицами измерения, а не с обычными числами.
Типы, на помощь!
#[derive(Debug, Clone, Copy)]
struct Celsius(f64);
#[derive(Debug, Clone, Copy)]
struct Fahrenheit(f64);
Программисты, пишущие на Rust, называют это шаблоном проектирования новый тип
. Это структура-кортеж, содержащая единственное значение. В этом примере мы создали два новых типа, по одному для градусов Цельсия и Фаренгейта.
Наша функция приобрела такой вид:
fn danger_of_freezing(temp: Celsius) -> bool;
Использование её с чем-либо кроме градусов Цельсия приводит к ошибкам во время компиляции. Успех!
Преобразования
Все что нам остается — это написать функции преобразования, которые будут переводить одни единицы измерения в другие.
impl Celsius {
to_fahrenheit(&self) -> Fahrenheit {
Fahrenheit(self.0 * 9./5. + 32.)
}
}
impl Fahrenheit {
to_celsius(&self) -> Celsius {
Celsius((self.0 - 32.) * 5./9.)
}
}
А потом использовать их, например, так:
let temp: Fahrenheit = sensor.read_temperature();
let is_freezing = danger_of_freezing(temp.to_celsius());
From и Into
Преобразования между различными типами — обычное дело в Rust. Например, мы можем превратить &str
в String
, используя to_string
, например:
// "Привет" имеет тип &'static str
let s = "Привет".to_string();
Однако, также возможно использовать String::from
для создания строк так:
let s = String::from("привет");
Или даже так:
let s: String = "привет".into();
Зачем же все эти функции, когда они, на первый взгляд, делают одно и то же?
В дикой природе
Примечание переводчика: в этом заголовке содержалась непереводимая игра слов. Оригинальное название Into the Wild можно перевести как "В дикой природе", а можно "Великолепный Into
"
Rust предлагает типажи, которые унифицируют преобразования из одного типа в другой. std::convert
описывает, помимо других, типажи From
и Into
.
pub trait From<T> {
fn from(T) -> Self;
}
pub trait Into<T> {
fn into(self) -> T;
}
Как можно увидеть выше, String
реализует From<&str>
, а &str
реализует Into<String>
. Фактически, достаточно реализовать один из этих типажей, чтобы получить оба, так как можно считать, что это одно и то же. Точнее, From реализует Into.
Так что давайте сделаем то же самое для температур:
impl From<Celsius> for Fahrenheit {
fn from(c: Celsius) -> Self {
Fahrenheit(c.0 * 9./5. + 32.)
}
}
impl From<Fahrenheit> for Celsius {
fn from(f: Fahrenheit) -> Self {
Celsius((f.0 - 32.) * 5./9. )
}
}
Применяем это в нашем вызове функции:
let temp: Fahrenheit = sensor.read_temperature();
let is_freezing = danger_of_freezing(temp.into());
// или
let is_freezing = danger_of_freezing(Celsius::from(temp));
Слушаюсь и повинуюсь
Вы можете возразить, что мы получили не так уж много преимуществ от типажа From
, по сравнению реализацией функций преобразования вручную, как делали раньше. Можно даже утверждать обратное, что into
— гораздо менее очевидно, чем to_celsius
.
Давайте переместим преобразование величин внутрь функции:
// T - любой тип, который можно перевести в градусы Цельсия
fn danger_of_freezing<T>(temp: T) -> bool
where T: Into<Celsius> {
let celsius = Celsius::from(temp);
...
}
Эта функция волшебным образом принимает и градусы Цельсия, и Фаренгейта, оставаясь при этом типобезопасной:
danger_of_freezing(Celsius(20.0));
danger_of_freezing(Fahrenheit(68.0));
Мы можем пойти еще дальше. Можно не только обрабатывать множество преобразуемых типов, но и возвращать значения разных типов схожим образом.
Допустим, нам нужна функция, которая возвращает точку замерзания. Она должна возвращать градусы Цельсия или Фаренгейта — в зависимости от контекста.
fn freezing_point<T>() -> T
where T: From<Celsius> {
Celsius(0.0).into()
}
Вызов этой функции немного отличается от других функций, где мы точно знаем возвращаемый тип. Здесь же мы должны запросить тип, который нам нужен.
// вежливо просим градусы Фаренгейта
let temp: Fahrenheit = freezing_point();
Есть второй, более явный способ вызвать функцию:
// вызываем функцию, которая возвращает градусы Цельсия
let temp = freezing_point::<Celsius>();
Упакованные (boxed) значения
Эта техника не только полезна для преобразования величин друг в друга, но также упрощает обработку упакованных значений, например результатов из баз данных
let name: String = row.get(0);
let age: i32 = row.get(1);
// вместо
let name = row.get_string(0);
let age = row.get_integer(1);
Заключение
У Python есть замечательный Дзен.
Его первые две строки гласят:
Красивое лучше, чем уродливое.
Явное лучше, чем неявное.
Программирование — это акт передачи намерений компьютеру. И мы должны явно указывать, что именно имеем в виду, когда пишем программы. Например, совершенно невыразительное булево значение для указания порядка сортировки не отразит наше намерение в полной мере. В Rust мы можем просто использовать перечисление, чтобы избавиться от любой двусмысленности:
enum SortOrder {
Ascending,
Descending
}
Таким же образом новые типы помогают придать смысл простым значениям. Celsius(f64)
отличается от Miles(f64)
, хотя они могут иметь одно и то же внутреннее представление (f64
). С другой стороны, использование From
и Into
помогает нам упрощать программы и интерфейсы.
Примечание переводчика:
Благодарю sumproxy и ozkriff за помощь при переводе.
Если вы заинтересовались Rust и у вас есть вопросы, присоединяйтесь!
Комментарии (33)
snuk182
19.04.2017 18:52+5Жуть полезные типажи. Особенно спасают при приведении заимствованных типов, если имена типов разные, но внутри по факту одно и то же:
pub struct Vertex([f32; 3]); impl <'a> From<&'a cgmath::Matrix<f32>> for &'a [Vertex] { fn from(a: &'a cgmath::Matrix<f32>) -> &'a [Vertex] { use std::mem; use std::slice; unsafe { slice::from_raw_parts( a as *const _ as *const Vertex, mem::size_of::<cgmath::Matrix<f32>>() / mem::size_of::<Vertex>() ) } } }
vintage
19.04.2017 22:31А можно ли в Расте делать такие выкрутасы?
auto distance = 384_400 * kilo(meter); auto speed = 299_792_458 * meter/second; Time time; time = distance / speed; writefln("Travel time of light from the moon: %s s", time.value(second));
enum inch = 2.54 * centi(meter); enum mile = 1609 * meter; writefln("There are %s inches in a mile", mile.value(inch));
Sirikid
19.04.2017 22:52Можно, вам один в один или идиоматично?
DarkEld3r
19.04.2017 23:31+1Хм… разве штуки типа
kilo(meter)
илиmeter/second
прямо один в один сделать получится? С вещами типаsi!"384_400 km"
и правда не должно быть проблем.Sirikid
20.04.2017 00:25Думаю да, например можно сделать
meter
пустой структурой.
vintage это будет довольно сложно, не ждите скоро.DarkEld3r
20.04.2017 01:31+2Не соображу как с пустой структурой извернуться, но додумался до такого:
trait SiUnit { type ValueType: Mul<Self::ValueType, Output = Self::ValueType> + From<u64>; fn get(&self) -> Self::ValueType; fn set(&mut self, value: Self::ValueType); } fn kilo<T: SiUnit>(mut val: T) -> T { let v = val.get(); val.set(v * 1000.into()); val } struct Metr(u64); impl SiUnit for Metr { type ValueType = u64; fn get(&self) -> Self::ValueType { self.0 } fn set(&mut self, value: Self::ValueType) { self.0 = value; } } const metr: Metr = Metr(1); let a = kilo(metr);
Получается очень близко к D, хотя про реализацию
time.value(second)
ещё придётся подумать.
Может уже плохо соображаю на ночь глядя, но кажется, что сильно красивее/короче это дело не реализовать. В таких вещах шаблоны D (или С++) смотрятся элегантнее, особенно, если вынести за скобки обработку ошибок. С другой стороны, все эти ужасы (в расте) можно спрятать в библиотеку.
vintage
20.04.2017 09:26Там есть ещё такой нюанс:
meter/second
возвращает тип "метры в секунду", а вdistance/speed
метры сокращаются и получается тип "секунды". Можно ли в Расте также выводить новые типы из библиотечных?
enum euro = unit!(double, "C"); // C is the chosen dimension symol (for currency...)
Sirikid
20.04.2017 09:35+2Операторы перегружать можно, а вот литералы свои делать нельзя. Через реализацию
From<какой-то примитивный тип>
можно писатьлитерал.into()
.vintage
20.04.2017 10:14into — это же то же фактически автоматическое приведение типов. Его имело бы смысл вынести на уровень языка, чтобы:
- Не заниматься однообразной ручной работой.
- Не путаться, когда одни функции приводят тип, а другие — нет.
В том же D можно перегрузить метод opCast, и он будет вызываться, например, автоматически в условиях:
struct X { bool opCast( T : bool )( ) { return false; } } writeln( X() ? 1 : 2 );
Правда условиями всё и ограничивается, для остальных случаев приведение опять приходится писать явно :-(
struct X { int x = 5; int opCast( T : int )( ) { return x; } } writeln( 1 + cast(int) X() );
DarkEld3r
20.04.2017 10:31+2into — это же то же фактически автоматическое приведение типов.
Не совсем. Разница как раз в том, что это приведение будет работать именно в конкретных функциях, а не везде:
struct S1 {} struct S2 {} impl From<S1> for S2 { fn from(_: S1) -> Self { S2{} } } let s1 = S1 {}; let s2: S2 = s1/*.into()*/; // error
И это уже ответственность функции принимать ли типы, которые можно преобразовать или нет. И хорошей практикой считается не лепить это везде, а только в специальных случаях вроде
String
/&str
.
В общем, можно спорить, но мне отсутствие автоматического приведения типов как раз очень нравится в расте.
Sirikid
20.04.2017 11:03Ну всмысле автоматическое, надо функцию вызвать. И, как уже сказал DarkEld3r, into можно вызвать только если для типов реализован соответствующий трейт, а у вас в примере получается утиная типизация.
vintage
20.04.2017 20:35Нет, утиная типизация и приведение типов — разные вещи. Into в зависимости от контекста приводит к разным типам. Так что плохого в автоматическом приведении типов?
Sirikid
21.04.2017 00:49+2Вы можете потребовать от типа наличия какого-то конкретного приведения?
fn foo<T, U>(...) where T : Into<U> { ... }
vintage
21.04.2017 00:59Могу, но зачем?
DarkEld3r
21.04.2017 10:25+1Примерно затем же, зачем в С++ есть
explicit
(только наоборот).vintage
21.04.2017 13:39Я C++ не трогал лет десять. Не напомните зачем там этот
explicit
?DarkEld3r
21.04.2017 17:00Как раз для того, чтобы запретить неявные преобразования:
struct S { explicit S(int) {} }; void foo(S) {} //foo(10); // Error foo(S(10));
vintage
21.04.2017 17:54И зачем их запрещать?
DarkEld3r
21.04.2017 18:23Я не знаю как ответить, чтобы не вызвать флейм. На языке вертится "примерно затем же, зачем нужна статическая типизация", но очевидно, такой ответ не устроит. (:
Можно поискать аргументацию зачем
explicit
был добавлен (причём в стандарте 11 года его действие расширили и на операторы). Сильно убедительных примеров у меня нет, могу только повторить, что меня вполне устраивает когда ситуация когда неявных приведений вообще нет в языке. Одна из причин — сделать создание "дорогого" объекта более явным.vintage
22.04.2017 00:15Для "дорогого" объекта достаточно не реализовывать implicit кастинг. Но для тех же единиц измерения — какая разница какую единицу измерения принимает функция, если у меня есть температура лишь в градусах цельсия?
DarkEld3r
20.04.2017 10:22+3Там есть ещё такой нюанс: meter/second возвращает тип "метры в секунду", а в distance/speed метры сокращаются и получается тип "секунды".
Так можно и в расте:
struct Meters {} struct Seconds {} struct MetersPerSecond {} impl Div<Seconds> for Meters { type Output = MetersPerSecond; fn div(self, _: Seconds) -> Self::Output { MetersPerSecond {} } } impl Div<MetersPerSecond> for Meters { type Output = Seconds; fn div(self, _: MetersPerSecond) -> Self::Output { Seconds {} } } // Аннотации типов просто чтобы показать, что они действительно такие. let m: Meters = Meters {}; let s: Seconds = Seconds {}; let ms: MetersPerSecond = m / s; let s: Seconds = m / ms;
Можно ли в Расте также выводить новые типы из библиотечных?
А имя типа в D после
unit!
руками написать можно? В смысле, это подставляется какой-то заранее известный тип или создаётся новый? Или из переданной строки имя типа и сформируется? В принципе, в расте можно и так и так.vintage
20.04.2017 20:42Не, там фишка в том, что руками объявляются лишь базовые единицы измерения, а производные собираются автоматически. На этапе компиляции тип специфицируется строкой. грубо говоря unit!"meter/second/second" — тип ускорения.
DarkEld3r
20.04.2017 22:28Любопытно. А можно всё-таки на пальцах объяснить как это работает? Ну то есть, есть у нас типы
meter
иsecond
. Пишемunit!"meter/second"
и создаётся типmeter/second
являющийся результатом деления метров на секунды?
На первый взгляд, не вижу причин почему такое не получится изобразить на расте, хотя придётся прибегать к ("нестабильным") процедурным макросам.
vintage
21.04.2017 00:05У нас есть значения
meter
типаUnit!("meter",1)
иsecond
типаUnit!("second",1)
. Операция деления перегружена таким образом, что она берёт размерности обоих операндов, вычисляет итоговую размерность и возвращает соответствующий тип. Например, дляmeter/second/second
будет типUnit!("meter",1,"second",-2)
. Ну, я бы реализовал это именно так. Конкретно в той библиотеке реализовано как-то замороченно. Возможно, чтобы можно было вычислять размерности не только во время компиляции, но и в рантайме.lgorSL
22.04.2017 20:21+1На rust можно реализовать числа во время компиляции:
https://habrahabr.ru/post/310572/
Наверно, можно по аналогии сделать описываемую структуру многомерной — под три измерения. Только не очень понятно, что делать с нецелыми или отрицательными степенями (вроде бы можно представлять их как дробь и ещё знак впереди, но это кажется извращением).
P.S. если что, я ни rust ни D не знаю.
ozkriff
22.04.2017 10:02+1Или я что-то не понял, или это просто динамическая типизация, реализованная уже поверх системы типов D.
Т.е. D же видит везде один и тот же тип, просто у этого типа там уже разные поля с закодированными в строках "типами", а во время компиляции эта штука работает только за счет CTFE, нет?
vintage
22.04.2017 10:33Скорее автоматическое выведение типов, реализованное, через ctfe.
ozkriff
22.04.2017 10:45+1Мне не хватает знания D, что бы толком понять как именно это работает, но насколько я понимаю, когда в ржавчине стабилизируют CTFE, примерно такое же вполне можно будет сделать, если еще и макросы подключить к вопросу.
vintage
22.04.2017 22:57Скорее всего тут ещё необходим.и eval времени компиляции. То есть создавать не только значения, но и типы. Или макросы в расте покрывают это?
k12th
Осталось только научить датчики на МК сообщать, в цельсиях они температуру передают или в фаренгейтах.
DarkEld3r
Ну да, не панацея. Но если у нас функция принимает какое-то абстрактное числовое значения, то "потерять бдительность" довольно легко. Если же компилятор заставит приводить к тем или иным величинам, то шанс осмысленно их обработать увеличивается.