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)


  1. k12th
    19.04.2017 18:34

    Осталось только научить датчики на МК сообщать, в цельсиях они температуру передают или в фаренгейтах.


    1. DarkEld3r
      19.04.2017 18:39
      +4

      Ну да, не панацея. Но если у нас функция принимает какое-то абстрактное числовое значения, то "потерять бдительность" довольно легко. Если же компилятор заставит приводить к тем или иным величинам, то шанс осмысленно их обработать увеличивается.


  1. 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>()
    			)
    		}
    	}
    }
    


  1. 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));


    1. Sirikid
      19.04.2017 22:52

      Можно, вам один в один или идиоматично?


      1. vintage
        19.04.2017 23:00

        Давайте оба варианта :-)


      1. DarkEld3r
        19.04.2017 23:31
        +1

        Хм… разве штуки типа kilo(meter) или meter/second прямо один в один сделать получится? С вещами типа si!"384_400 km" и правда не должно быть проблем.


        1. Sirikid
          20.04.2017 00:25

          Думаю да, например можно сделать meter пустой структурой.
          vintage это будет довольно сложно, не ждите скоро.


          1. 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 (или С++) смотрятся элегантнее, особенно, если вынести за скобки обработку ошибок. С другой стороны, все эти ужасы (в расте) можно спрятать в библиотеку.


            1. vintage
              20.04.2017 09:26

              Там есть ещё такой нюанс: meter/second возвращает тип "метры в секунду", а в distance/speed метры сокращаются и получается тип "секунды". Можно ли в Расте также выводить новые типы из библиотечных?


              enum euro = unit!(double, "C"); // C is the chosen dimension symol (for currency...)


              1. Sirikid
                20.04.2017 09:35
                +2

                Операторы перегружать можно, а вот литералы свои делать нельзя. Через реализацию From<какой-то примитивный тип> можно писать литерал.into().


                1. vintage
                  20.04.2017 10:14

                  into — это же то же фактически автоматическое приведение типов. Его имело бы смысл вынести на уровень языка, чтобы:


                  1. Не заниматься однообразной ручной работой.
                  2. Не путаться, когда одни функции приводят тип, а другие — нет.

                  В том же 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() );


                  1. DarkEld3r
                    20.04.2017 10:31
                    +2

                    into — это же то же фактически автоматическое приведение типов.

                    Не совсем. Разница как раз в том, что это приведение будет работать именно в конкретных функциях, а не везде:


                    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.


                    В общем, можно спорить, но мне отсутствие автоматического приведения типов как раз очень нравится в расте.


                  1. Sirikid
                    20.04.2017 11:03

                    Ну всмысле автоматическое, надо функцию вызвать. И, как уже сказал DarkEld3r, into можно вызвать только если для типов реализован соответствующий трейт, а у вас в примере получается утиная типизация.


                    1. vintage
                      20.04.2017 20:35

                      Нет, утиная типизация и приведение типов — разные вещи. Into в зависимости от контекста приводит к разным типам. Так что плохого в автоматическом приведении типов?


                      1. Sirikid
                        21.04.2017 00:49
                        +2

                        Вы можете потребовать от типа наличия какого-то конкретного приведения?


                        fn foo<T, U>(...) where T : Into<U> { ... }


                        1. vintage
                          21.04.2017 00:59

                          Могу, но зачем?


                          1. DarkEld3r
                            21.04.2017 10:25
                            +1

                            Примерно затем же, зачем в С++ есть explicit (только наоборот).


                            1. vintage
                              21.04.2017 13:39

                              Я C++ не трогал лет десять. Не напомните зачем там этот explicit?


                              1. DarkEld3r
                                21.04.2017 17:00

                                Как раз для того, чтобы запретить неявные преобразования:


                                struct S {
                                    explicit S(int) {}
                                };
                                
                                void foo(S) {}
                                
                                //foo(10); // Error
                                foo(S(10));


                                1. vintage
                                  21.04.2017 17:54

                                  И зачем их запрещать?


                                  1. DarkEld3r
                                    21.04.2017 18:23

                                    Я не знаю как ответить, чтобы не вызвать флейм. На языке вертится "примерно затем же, зачем нужна статическая типизация", но очевидно, такой ответ не устроит. (:


                                    Можно поискать аргументацию зачем explicit был добавлен (причём в стандарте 11 года его действие расширили и на операторы). Сильно убедительных примеров у меня нет, могу только повторить, что меня вполне устраивает когда ситуация когда неявных приведений вообще нет в языке. Одна из причин — сделать создание "дорогого" объекта более явным.


                                    1. vintage
                                      22.04.2017 00:15

                                      Для "дорогого" объекта достаточно не реализовывать implicit кастинг. Но для тех же единиц измерения — какая разница какую единицу измерения принимает функция, если у меня есть температура лишь в градусах цельсия?


              1. 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! руками написать можно? В смысле, это подставляется какой-то заранее известный тип или создаётся новый? Или из переданной строки имя типа и сформируется? В принципе, в расте можно и так и так.


                1. vintage
                  20.04.2017 20:42

                  Не, там фишка в том, что руками объявляются лишь базовые единицы измерения, а производные собираются автоматически. На этапе компиляции тип специфицируется строкой. грубо говоря unit!"meter/second/second" — тип ускорения.


                  1. DarkEld3r
                    20.04.2017 22:28

                    Любопытно. А можно всё-таки на пальцах объяснить как это работает? Ну то есть, есть у нас типы meter и second. Пишем unit!"meter/second" и создаётся тип meter/second являющийся результатом деления метров на секунды?


                    На первый взгляд, не вижу причин почему такое не получится изобразить на расте, хотя придётся прибегать к ("нестабильным") процедурным макросам.


                    1. vintage
                      21.04.2017 00:05

                      У нас есть значения meter типа Unit!("meter",1) и second типа Unit!("second",1). Операция деления перегружена таким образом, что она берёт размерности обоих операндов, вычисляет итоговую размерность и возвращает соответствующий тип. Например, для meter/second/second будет тип Unit!("meter",1,"second",-2). Ну, я бы реализовал это именно так. Конкретно в той библиотеке реализовано как-то замороченно. Возможно, чтобы можно было вычислять размерности не только во время компиляции, но и в рантайме.


                      1. lgorSL
                        22.04.2017 20:21
                        +1

                        На rust можно реализовать числа во время компиляции:
                        https://habrahabr.ru/post/310572/


                        Наверно, можно по аналогии сделать описываемую структуру многомерной — под три измерения. Только не очень понятно, что делать с нецелыми или отрицательными степенями (вроде бы можно представлять их как дробь и ещё знак впереди, но это кажется извращением).


                        P.S. если что, я ни rust ни D не знаю.


    1. ozkriff
      22.04.2017 10:02
      +1

      Или я что-то не понял, или это просто динамическая типизация, реализованная уже поверх системы типов D.


      Т.е. D же видит везде один и тот же тип, просто у этого типа там уже разные поля с закодированными в строках "типами", а во время компиляции эта штука работает только за счет CTFE, нет?


      1. vintage
        22.04.2017 10:33

        Скорее автоматическое выведение типов, реализованное, через ctfe.


        1. ozkriff
          22.04.2017 10:45
          +1

          Мне не хватает знания D, что бы толком понять как именно это работает, но насколько я понимаю, когда в ржавчине стабилизируют CTFE, примерно такое же вполне можно будет сделать, если еще и макросы подключить к вопросу.


          1. vintage
            22.04.2017 22:57

            Скорее всего тут ещё необходим.и eval времени компиляции. То есть создавать не только значения, но и типы. Или макросы в расте покрывают это?