Числовые классы типов в Rust

    Абстракции Rust отличаются от привычных в ООП. В частности вместо классов (классов объектов) используются классы типов, которые называются «trait» (не следует путать с trait из Scala, где под этим термином прячутся примеси — mixin).
    Классы типов не уникальны для Rust, они поддержаны в Haskell, Mercury, Go, из можно реализовать слегка извращенным способом на Scala и C++.

    Я хочу показать, как они реализуются в Rust на примере дуальных чисел и разобрать отдельные нетривиальные (или плохо проработанные) моменты.

    Интерфейсы числовых типов довольно громоздки, и я буду вставлять здесь только фрагменты кода. Весь код доступен на github (Update: работающая версия доступна на crates.io).
    Большинство реализованных здесь интерфейсов имеют статус experemental или unstable и скорее всего будут меняться. Я постараюсь поддерживать код и текст актуальными.

    Rust поддерживает перегрузку операций, но, в отличие от C++, у операций есть метод-синоним с обычным буквенным именем. Так a+b может быть записано a.add(b), а для переопределения операции '+' надо просто реализовать метод add.


    Что же такое — класс типов?

    Класс типов часто сравнивают с интерфейсом. Действительно, он определяет что можно делать с некоторыми типами данных, но реализовать эти операции надо отдельно. В отличие от интерфейса, реализация класса типов для некоторого типа не создает нового типа, а живет со старым, хотя старый тип про реализуемый интерфейс может ни чего не знать. Что бы код, использующий данный интерфейс, стал работать с данным типом данных, не требуется править ни тип данных, ни интерфейс, ни код — достаточно реализовать интерфейс для типа.

    В отличие от интерфейса в стиле ООП, класс типов может ссылаться на тип несколько раз. В Rust эта ссылка называется Self, в Haskell ее можно назвать почти как угодно. Например в Haskell метод '+' требует что бы оба аргумента имели строго один тип и ожидался возврат объекта строго этого же типа (в Rust, в классе типов Add эти типы могут быть разными — в частности, можно складывать Duration и Timespec). Тип возвращаемого значения тоже важен — в аргументах может вообще не используется тип из класса, а какую реализацию метода использовать компилятор решает на основе того, какой тип надо получить. Например в Rust есть класс типов Zero и код
    let float_zero:f32 = Zero::zero();
    let int_zero:i32 = Zero::zero();
    присвоит переменным разных типов разные нули.

    Как это сделано в Rust

    Описание

    Класс типов создается ключевым словом trait, за которым следует имя (возможно с параметрами, примерно как в C++) и список методов. Метод может иметь реализацию по умолчанию, но такая реализация не имеет доступа к внутренностям типа и должна пользоваться другими методами (например выражать неравенство (!=,ne) через отрицание равенства).
    pub trait PartialEq {
        /// This method tests for `self` and `other` values to be equal, and is used by `==`.
        fn eq(&self, other: &Self) -> bool;
    
        /// This method tests for `!=`.
        #[inline]
        fn ne(&self, other: &Self) -> bool { !self.eq(other) }
    }
    Здесь описание класса типов из стандартной библиотеки, который включает типы, допускающие сравнение на равенство.
    Первый аргумент, названный self или &self каждого метода — аналог this из классического ООП. Наличие амперсенда указывает на способ передачи владения объектом и, в отличие от C++, на возможность его изменять не влияет (передача по ссылке или по значению). Право на модификации объекта дает явное указание mut.
    Второй аргумент должен иметь такой же тип, как и первый — на это указывает Self.
    Позже мы столкнемся с тем, что этот аргумент не обязателен — получается что-то типа статических методов, хотя на самом деле они все-таки остаются «динамическими» — диспечеризация осуществляется по другим параметрам или по типу ожидаемого результата.
    pub trait Add<RHS,Result> {
        /// The method for the `+` operator
        fn add(&self, rhs: &RHS) -> Result;
    }
    Операция '+' в Rust не обязана требовать одинаковости типов аргументов и результатов. Для этого класс типов сделан шаблонным: аргументы шаблона — типы второго аргумента и результата.
    Для сравнения, в Haskell классы типов не параметризованы (кроме как самим типом), но могут содержать не отдельные типы, а пары, тройки и прочие наборы типов (расширение MultiParamTypeClasses), что позволяет делать аналогичные вещи. К релизу в Rust обещают добавить поддержку этой возможности.
    Стоит обратить внимание на синтаксическое отличие от C++ — описание сущности в Rust (в данном случае класса типов) само по себе является шаблоном, а в C++ шаблон объявляется отдельно с помощью ключевого слова. Подход C++, в чем-то, более логичен, но сложнее в восприятии.
    Рассмотрим еще пример Zero:
    pub trait Zero: Add<Self, Self> {
        /// Returns the additive identity element of `Self`, `0`.
        ///
        /// # Laws
        ///
        /// ```{.text}
        /// a + 0 = a       ∀ a ∈ Self
        /// 0 + a = a       ∀ a ∈ Self
        /// ```
        ///
        /// # Purity
        ///
        /// This function should return the same result at all times regardless of
        /// external mutable state, for example values stored in TLS or in
        /// `static mut`s.
        // FIXME (#5527): This should be an associated constant
        fn zero() -> Self;
    
        /// Returns `true` if `self` is equal to the additive identity.
        #[inline]
        fn is_zero(&self) -> bool;
    }
    В описании этого класса типов можно усмотреть наследование — для реализации Zero требуется реализовать сначала Add (параметризованный тем же типом). Это привычное наследование интерфейсов без реализации. Допускается и множественное наследование, для этого предки перечисляются через '+'.
    Обратите внимание на метод fn zero() -> Self;. Это можно рассматривать как статический метод, хотя далее мы увидим, что он несколько динамичнее, чем статические методы в ООП (в частности, они могут быть использованы для реализации «фабрик»).

    Реализация

    Рассмотрим реализацию Add для комплексных чисел:
    impl<T: Clone + Num> Add<Complex<T>, Complex<T>> for Complex<T> {
        #[inline]
        fn add(&self, other: &Complex<T>) -> Complex<T> {
            Complex::new(self.re + other.re, self.im + other.im)
        }
    }
    Комплексные числа — обобщенный тип, параметризуемый представлением действительного числа. Реализация сложения тоже параметризована — она применима к комплексным числам над различными вариантами действительных, если для этих действительных реализован некий интерфейс. В данном случае требуемый интерфейс излишне богатый — он предполагает наличие реализаций Clone (позволяющего создавать копию) и Num (содержащий базовые операции над числами, в частности наследующий Add).

    Deriving

    Если лень самому писать реализации простых стандартных интерфейсов, эту рутинную работу можно передать компилятору с помощью директивы deriving.
    #[deriving(PartialEq, Clone, Hash)]
    pub struct Complex<T> {
        /// Real portion of the complex number
        pub re: T,
        /// Imaginary portion of the complex number
        pub im: T
    }
    Здесь разработчики библиотеки просят создать реализацию интерфейсов PartialEq, Clone и Hash, если тип T поддерживает все необходимое.
    На данный момент автогенерация реализаций поддерживается для классов типов Clone, Hash, Encodable, Decodable, PartialEq, Eq, PartialOrd, Ord, Rand, Show, Zero, Default, FromPrimitive, Send, Sync и Copy.

    Числовые классы типов

    В модуле std::num описано большое количество классов типов, связанных с разными свойствами чисел.
    Они могут ссылаться на некоторые другие трейты — для операций сравнения и размещения в памяти (например Copy подсказывает компилятору, что этот тип можно копировать побайтно).
    Я выделил интерфейсы, которые реализовал для дуальных чисел в диаграмму.

    Реализация дуальных чисел

    Тип данных устроен тривиально:
    pub struct Dual<T> {
      pub val:T,
      pub der:T
    }

    В отличие от комплексных чисел из стандартной библиотеки, я старался реализовывать интерфейс исходя из минимальных предположений. Так реализация Add у меня требует только интерфейса Add у исходного типа, а Mul — только Mul+Add.
    Иногда это приводило к странному коду. Например, Signed не обязан поддерживать Clone, и, что бы для положительного дуального числа в методе abs вернуть его копию, пришлось сложить его с нулем
    impl<T:Signed> Signed for Dual<T> {
     fn abs(&self) -> Dual<T> {
       if self.is_positive() || self.is_zero() {
        self+Zero::zero() // XXX: bad implementation for clone
       } else if self.is_negative() {
        -self
       } else {
         fail!("Near to zero")
       }
     }
    }
    Иначе компилятор не может проследить владение этим объектом.
    Обратите внимание, что тип Zero::zero() явно не задан. Компилятор догадывается, какой он должен быть, по попытке сложения с self, который реализует Num, а, следовательно, и Add<Self,Self>. Но тип Self на момент компиляции еще не известен — он задается параметром шаблона. А значит метод zero динамически находится в таблице методов реализации Num для Dual<T>!

    Еще отмечу интересный прием, как в Float реализованы целочисленные константы, характеризующие весь тип. То есть они не могут на вход получать экземпляр (его в нужном контексте может и не быть), а должны быть аналогом статических методов. Та же проблема часто возникает в Haskell, и для ее решения таким методам добавляется фейковый параметр с нужным типом. Haskell язык ленивый и в качестве неиспользуемого аргумента всегда можно передать error «Not used». В строгом языке Rust такой прием не проходит, а создавать объект для этого может быть слишком дорого. По этому используется обходной трюк — передается None типа Option<Self>
    #[allow(unused_variable)]
    impl<T:Float> Float for Dual<T> {
     fn mantissa_digits(_unused_self: Option<Dual<T>>) -> uint {
      let n: Option<T> = None;
      Float::mantissa_digits(n)
     }
    }
    Так как параметр не используется, по умолчанию компилятор выдает предупреждение. Подавить его можно двумя способами — начав название параметр с символа '_' или с помощью директивы #[allow(unused_variable)].
    Поделиться публикацией
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 15

      +1
      Спасибо за статью!

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

      Трюк с None, к счастью, временный — с добавлением UFCS (хотя я не уверен, что его добавят до 1.0) нужда в нём отпадёт. Кстати, емнип, это
        let n: Option<T> = None;
        Float::mantissa_digits(n)
      

      можно переписать как
        Float::mantissa_digits(None::<T>)
      
        0
        Множественная диспечеризация и ассоциированные типы и в Haskell не в базовом стандарте, а в расширениях.
        Типов высших порядков сильно не хватает. Думаю, рано или поздно их вынуждены будут добавить.
        0
        >не следует путать с trait из Scala

        А можете в двух словах пояснить принципиальное отличие? Я по приведенным примерам никаких отличий кроме синтаксиса не заметил.
          +1
          В Scala trait создает новый тип или добавляется при создании типа. В отличие от интерфейсов Java он может содержать реализацию некоторых методов. Есть еще тонкости взаимодействия нескольких трейтов, которые отличают их от множественного наследования (как в C++), но это совсем в сторону от Rust :-).
          В любом случае этот интерфейс не будет присутствовать в объектах, созданных с помощью исходного типа.
          В Rust описание trait не создает тип (в Haskell «вид» класса типов будет «функция из типа в ограничение» Num :: * -> Constraint, в Rust виды пока не реализованы, но смысл такой же). Реализация трейта тоже не создает новый тип — новым интерфейсом будут обладать объекты старого типа.
            0
            Но в чем разница с функциональной точки зрения, так сказать. Какая разница, будет ли интерфейс присутствовать в объектах, создаваемых с помощью исходного типа?
            Т.е. ваш пример мог быть реализован в Scala как-нибудь так: Dual extends AnyRef with Add with Sub with…
            ну и соответственно дальше реализация для Dual всех методов, предоставляемых трейтами Add, Sub и т.д.
            Т.е. пока только разная форма одного и того же. А вопрос скорее про функционал. Т.е. что можно сделать с типом Dual в Rust, чего нельзя сделать с ним же в Scala, или наоборот. Или может при каком-то подходе кода меньше надо писать, или ещё что-то, важное с точки зрения потребителя этих технологий.
              0
              Например, я поленился реализовать FloatMath, а реализовал только Float. И решил не давать исходников. Но пользователь моей библиотеки сможет реализовать FloatMath, при этом сохранив весь код, который работает с моим Dual. В Scala это делается с помощью implicits.
              Еще есть отличие в использовании памяти — в ООП ссылка на таблицу методов хранится в каждом объекте, с использованием классов типов эта ссылка таскается по стеку. Но это не сейчас уже принципиально :-).
                0
                Спасибо, теперь понятно.
          0
          Очень интересно (побольше бы таких статей на Хабре!), правда слишком кратко и сжато, хотелось бы более подробно и последовательно.
          А что такое «классы типов» в Go? То что там называется ключевым словом «interface» или что-то другое?
          Что такое «типы высших порядков»?
            +1
            Тип высших порядков, это когда параметризованный тип получает параметром другой параметризованный тип (без фиксации значения параметра). Например, тип может быть параметризован контейнером, а внутри себя создавать этот контейнер и с целыми, и со строками. Haskell поддерживает классы «шаблонов типов», параметризованных типов без параметра. Например, Monad содержет IO, Maybe, списки [] — все это еще не типы, им нужен параметр IO String, [Int] и тп.

            В Go я глубоко не заглядывал. К сожалению, не могу сейчас сказать, как там описываются классы типов. Помню только, что они есть.
              0
              А что такое «классы типов» в Go? То что там называется ключевым словом «interface» или что-то другое?
              Что такое «типы высших порядков»?


              Кмк, в Go нет полноценных классов типов. Например, неясно, как там сделать тот же Zero или Add, сохранив строгую типизацию. Т.е. в теории можно везде использовать object и потом приводить тип, но это уже совсем не то.
              0
              Но тип Self на момент компиляции еще не известен — он задается параметром шаблона. А значит метод zero динамически находится в таблице методов реализации Num для Dual!
              Методы трейтов могут искаться либо на этапе компиляции (аналогично тому как работают шаблоны в C++) либо во время выполнения (аналогично тому как работают абстрактные классы и виртуальные функции в С++).
                0
                Что такое Self, не сказано (хотя догадаться можно, конечно). Сказать тем более полезно, что это отличие от классов типов в других языках.
                  0
                  Thanks!
                  Добавил описание.
                  0
                  А кстати, можно ли написать дефолтовые реализации для всех методов через друг друга, чтобы достаточно было определить хотя бы одну, как можно в Haskell? Например, для eq:
                  pub trait PartialEq {
                      /// This method tests for `self` and `other` values to be equal, and is used by `==`.
                      fn eq(&self, other: &Self) -> bool { !self.ne(other) }
                  
                      /// This method tests for `!=`.
                      #[inline]
                      fn ne(&self, other: &Self) -> bool { !self.eq(other) }
                  }
                  

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

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