Передача намерений

https://github.com/jaheba/stuff/blob/master/communicating_intent.md
  • Перевод

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

    0

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

      +4

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

      +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>()
      			)
      		}
      	}
      }
      
        0

        А можно ли в Расте делать такие выкрутасы?


            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));
          0

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

            0

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

              +1

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

                0

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

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

                    0

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


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

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

                        0

                        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() );
                          +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.


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

                            0

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

                              0

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

                                +2

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


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

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

                                    +1

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

                                      0

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

                                        0

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


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

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

                                            0

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


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

                                              0

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

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

                            0

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

                              0

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


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

                                0

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

                                  +1

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


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


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

                  +1

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


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

                    0

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

                      +1

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

                        0

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

                  +2

                  Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                  Самое читаемое