Числовые классы типов в Rust07.10.2014 14:03
Абстракции Rust отличаются от привычных в ООП. В частности вместо классов (классов объектов) используются классы типов, которые называются «trait» (не следует путать с trait из Scala, где под этим термином прячутся примеси — mixin).Классы типов не уникальны для Rust, они поддержаны в Haskell, Mercury, Go, из можно реализовать слегка извращенным способом на Scala и C++.Я хочу показать, как они реализуются в Rust на примере дуальных чисел и разобрать отдельные нетривиальные (или плохо проработанные) моменты.
Интерфейсы числовых типов довольно громоздки, и я буду вставлять здесь только фрагменты кода. Весь код доступен на githab.Большинство реализованных здесь интерфейсов имеют статус experemental или unstable и скорее всего будут меняться. Я постараюсь поддерживать код и текст актуальными.
Rust поддерживает перегрузку операций, но, в отличие от C++, у операций есть метод-синоним с обычным буквенным именем. Так a+b может быть записано a.add (b), а для переопределения операции '+' надо просто реализовать метод add.
Класс типов часто сравнивают с интерфейсом. Действительно, он определяет что можно делать с некоторыми типами данных, но реализовать эти операции надо отдельно. В отличие от интерфейса, реализация класса типов для некоторого типа не создает нового типа, а живет со старым, хотя старый тип про реализуемый интерфейс может ни чего не знать. Что бы код, использующий данный интерфейс, стал работать с данным типом данных, не требуется править ни тип данных, ни интерфейс, ни код — достаточно реализовать интерфейс для типа.В отличие от интерфейса в стиле ООП, класс типов может ссылаться на тип несколько раз. Например в Haskell метод '+' требует что бы оба аргумента имели строго один тип и ожидался возврат объекта строго этого же типа (в Rust, в классе типов Add эти типы могут быть разными — в частности, можно складывать Duration и Timespec). Тип возвращаемого значения тоже важен — в аргументах может вообще не используется тип из класса, а какую реализацию метода использовать компилятор решает на основе того, какой тип надо получить. Например в Rust есть класс типов Zero и код
let float_zero: f32 = Zero: zero ();
let int_zero: i32 = Zero: zero ();
присвоит переменным разных типов разные нули.
Описание
Класс типов создается ключевым словом 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.Позже мы столкнемся с тем, что этот аргумент не обязателен — получается что-то типа статических методов, хотя на самом деле они все-таки остаются «динамическими» — диспечеризация осуществляется по другим параметрам или по типу ожидаемого результата.
pub trait Add {
/// The method for the `+` operator
fn add (&self, rhs: &RHS) → Result;
}
Операция '+' в Rust не обязана требовать одинаковости типов аргументов и результатов. Для этого класс типов сделан шаблонным: аргументы шаблона — типы второго аргумента и результата.Для сравнения, в Haskell классы типов не параметризованы (кроме как самим типом), но могут содержать не отдельные типы, а пары, тройки и прочие наборы типов (расширение MultiParamTypeClasses), что позволяет делать аналогичные вещи. К релизу в Rust обещают добавить поддержку этой возможности.Стоит обратить внимание на синтаксическое отличие от C++ — описание сущности в Rust (в данном случае класса типов) само по себе является шаблоном, а в C++ шаблон объявляется отдельно с помощью ключевого слова. Подход C++, в чем-то, более логичен, но сложнее в восприятии.Рассмотрим еще пример Zero:
pub trait Zero: Add {
/// 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 Add, Complex> for Complex {
#[inline]
fn add (&self, other: &Complex) → Complex {
Complex: new (self.re + other.re, self.im + other.im)
}
}
Комплексные числа — обобщенный тип, параметризуемый представлением действительного числа. Реализация сложения тоже параметризована — она применима к комплексным числам над различными вариантами действительных, если для этих действительных реализован некий интерфейс. В данном случае требуемый интерфейс излишне богатый — он предполагает наличие реализаций Clone (позволяющего создавать копию) и Num (содержащий базовые операции над числами, в частности наследующий Add).Deriving
Если лень самому писать реализации простых стандартных интерфейсов, эту рутинную работу можно передать компилятору с помощью директивы deriving.
#[deriving (PartialEq, Clone, Hash)]
pub struct Complex {
/// 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 {
pub val: T,
pub der: T
}
В отличие от комплексных чисел из стандартной библиотеки, я старался реализовывать интерфейс исходя из минимальных предположений. Так реализация Add у меня требует только интерфейса Add у исходного типа, а Mul — только Mul+Add.Иногда это приводило к странному коду. Например, Signed не обязан поддерживать Clone, и, что бы для положительного дуального числа в методе abs вернуть его копию, пришлось сложить его с нулем
impl Signed for Dual {
fn abs (&self) → Dual {
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 на момент компиляции еще не известен — он задается параметром шаблона. А значит метод zero динамически находится в таблице методов реализации Num для Dual! Еще отмечу интересный прием, как в Float реализованы целочисленные константы, характеризующие весь тип. То есть они не могут на вход получать экземпляр (его в нужном контексте может и не быть), а должны быть аналогом статических методов. Та же проблема часто возникает в Haskell, и для ее решения таким методам добавляется фейковый параметр с нужным типом. Haskell язык ленивый и в качестве неиспользуемого аргумента всегда можно передать error «Not used». В строгом языке Rust такой прием не проходит, а создавать объект для этого может быть слишком дорого. По этому используется обходной трюк — передается None типа Option
#[allow (unused_variable)]
impl Float for Dual {
fn mantissa_digits (_unused_self: Option>) → uint {
let n: Option = None;
Float: mantissa_digits (n)
}
}
Так как параметр не используется, по умолчанию компилятор выдает предупреждение. Подавить его можно двумя способами — начав название параметр с символа '_' или с помощью директивы #[allow (unused_variable)].
© Habrahabr.ru