Реализация арифметики натуральных чисел с помощью чисел Пеано — популярная задача в обучение программированию. Мне было интересно, можно ли реализовать их на Rust. Таким образом моя задача: записать и сложить натуральные числа с проверкой на уровне типов.
Если верить википедии "Аксио?мы Пеа?но — одна из систем аксиом для натуральных чисел, введённая в XIX веке итальянским математиком Джузеппе Пеано."
Нас интересуют две из них — с помощью которых можно ввести и использовать натуральные числа:
- 1 является натуральным числом
- Число, следующее за натуральным, тоже является натуральным.
Дословно запишем на расте с помощью:
enum Nat {
Zero,
Succ(Nat)
}
Nat — это либо ноль, либо следующее натуральное число. Но у такой записи есть проблема — мы получили бесконечно много натуральных чисел:
error: recursive type `Nat` has infinite size
Очевидным решением было бы вместо Succ(Nat)
хранить какой-нибудь указатель на следующее натуральное число.
Но тогда на уровне типов будет всего два значения — Zero
и Box<Succ>
. Не годится: нет информации о числах на уровне типов — 5 и 7 имеют один тип.
Запустить код на play.rust-lang.org
Запишем те же числа немного подругому:
use std::marker::PhantomData;
struct Zero;
struct Succ<T> {
_marker : PhantomData<T>
}
Zero — это тип данных с одним возможным значением — Zero. Succ — это тип данных также с одним значением — Succ, но с полезной нагрузкой в виде типа данных. Может быть тип Succ<i32>
, Succ<String>
, Succ<Succ<_>>
и т.д.
Теперь у нас есть отдельно ноль и отдельно следующее число. Теперь тип Succ<Succ<Succ<Succ<Zero>>>>
— валиден. Но есть проблема — Zero не связан c Succ. Решение — введём типаж Nat:
trait Nat {
fn new() -> Self;
}
Nat
— типаж который должен реализовать любой тип, являющийся частью натуральных чисел, то есть Zero и Succ.
Функция new — позволяет спустить значение с уровня типов на уровень данных, создав конкректный экземпляр.
Реализуем его для Zero и Succ:
impl Nat for Zero {
fn new() -> Self {
Zero
}
}
impl<T : Nat> Nat for Succ<T> {
fn new() -> Self {
Succ {
_marker : PhantomData
}
}
}
Теперь мы уже можем создать натуральное число!
Запустить код на play.rust-lang.org
let two : Succ<Succ<Zero>> = Nat::new();
let three : Succ<Succ<Succ<Zero>>> = Nat::new();
Наше следующая цель — получить число 4 из числа 3! Введём типаж Incr:
trait Incr : Nat { type Result; }
От нашего прошлого типажа Nat он отличается тем, что содержит не только функции, но и тип. Инкремент типа T на 1 согласно нашим правилам натуральных чисел — это Succ. Что мы и запишем:
impl<T : Nat> Incr for T { type Result = Succ<T>; }
Таким образом мы реализовали типаж Incr для всех типов T с реализованным Nat.
Теперь можно написать функцию, которая принимает что-нибудь с типом Incr (который реализован для всех натуральных чисел) и возвращает то, что написано в реализации типажа Incr. Это дословно и запишем:
fn incr<T : Incr, Res : Nat>(_ : T) -> Res where T : Incr<Result = Res> {
Res::new()
}
Теперь мы можем получить 4!
let three : Succ<Succ<Succ<Zero>>> = Nat::new();
let four = incr(three);
И можем, например, написать функцию по работе с числами и быть уверенным, что она не сделает ничего кроме инкремента:
fn next<In : Nat, Out : Nat>(x : In) -> Out where In : Incr<Result = Out> {
incr(x)
}
Полезно! Теперь попробуем сложить числа: согласно аксиоматике Пеано сложение можно записать вот так:
x + 0 = x
x + Succ(y) = Succ(x + y)
Попробуем записать это в терминах раста:
trait Sum<B> : Nat { type Result; }
Введём типаж Sum. Появился еще один вид синтаксиса — типаж, параметризированный типом. В данном случае Sum<B>
— это всё, что может быть сложено с B.
Реализуем сложение нуля:
impl<A : Nat> Sum<A> for Zero { type Result = A; }
и сложение не нуля:
impl<A : Nat, B : Nat, Res> Sum<A> for Succ<B> where B : Sum<A, Result = Res> { type Result = Succ<Res>; }
Если присмотрется — видно что это x + Succ(y) = Succ(x + y)
.
По аналогии с incr напишем удобную функцию:
fn sum<A : Sum<B>, B : Nat, Res : Nat>(_ : A, _ : B) -> Res where A : Sum<B, Result = Res> {
Res::new()
}
Теперь мы можем складывать числа!
let two : Succ<Succ<Zero>> = Nat::new();
let three : Succ<Succ<Succ<Zero>>> = Nat::new();
let four = incr(three);
let six = sum(two, four);
Но этого явно не достаточно. Хочется, например, вывести результат на экран. Самый простой вариант — написать так:
let six : () = sum(two, four)
Компилятор сообщит об ошибке и красиво покажет нужный нам тип в поле "ожидаемый":
note: expected type `Succ<Succ<Succ<Succ<Succ<Succ<Zero>>>>>>`
note: found type `()`
Но хочется вывести наше число более честным способом. Воспользуемся типажом Display.
Начнём с Zero:
use std::fmt::{Display, Result, Formatter};
impl Display for Zero {
fn fmt(&self, f: &mut Formatter) -> Result {
write!(f, "Zero")
}
}
Ноль просто вывести.
Теперь немного рекурсии:
impl<T : Nat + Display> Display for Succ<T> {
fn fmt(&self, f: &mut Formatter) -> Result {
write!(f, "(Succ {})", T::new())
}
}
Запустить код на play.rust-lang.org
Теперь можно напечатать нашу 6:
(Succ (Succ (Succ (Succ (Succ (Succ Zero))))))
У нас всё получилось! Оставлю читателю ввести умножение:
x * Zero = Zero
x * Succ(y) = x * y + x
Вот так с помощью мономорфизации растовых типажей можно во время компиляции складывать числа. Я не уверен что представленное решение является правильным или идиоматичным, но оно показалось мне любопытным.
Комментарии (12)
Avadd
21.09.2016 12:24-8Серьезно? а 'Hello world' на нем можно написать? давайте целый цикл статей что можно написать на Rust'e
webmasterx
21.09.2016 12:28+1А разве это и не есть hello world? По моему хорошой туториал. только мало информации для новичков (лично я почти ничего не понял)
ozkriff
21.09.2016 12:38+5А разве это и не есть hello world?
Хз ирония или нет, но по мне это уже довольно продвинутое изнасилование системы типов.
только мало информации для новичков
Думаю, предполагается что читатель уже ознакомился с базовыми понятиями в, допустим, книге.
webmasterx
21.09.2016 13:11+1Мне правда показалось что это из разряда туториала, только для чуть более продвинутых новичков
potan
21.09.2016 19:41+4Реализация арифметики в системе типов, даже без вычитания, вещь важная, вполне заслуживающая отдельной статьи.
Avadd
22.09.2016 20:03-4Ну если надо писать целую статью, что бы рассказать как складывать числа, я боюсь представить какой консилиум собирается из специалистов, когда вам надо сделать чего по-серьезней на нем. В общем, ваш rust — говно
potan
22.09.2016 21:23+5Если кто-то не понимает назначения и принципов построения систем типов в языках программирования, лучше сообщать об этом менее завуалировано.
kagamin
21.09.2016 20:27+3Добавив немного магии, можно получить обратно нормальный вывод чисел:
impl ::std::fmt::Display for Zero { fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result { write!(f, "0") } } impl<T : Nat + ::std::fmt::Display> ::std::fmt::Display for Succ<T> { fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result { let res = format!("{}", T::new()); match res.parse::<i32>() { Ok(n) => write!(f, "{}", 1 + n), Err(e) => write!(f, "ERROR") } } }
FL3
22.09.2016 11:46+4для преобразование в число бы логичнее не заморачиваться с Display и parse а сделать так:
impl Into<i32> for Zero { fn into(self) -> i32 { 0 } } impl<T : Nat + Into<i32>> Into<i32> for Succ<T> { fn into(self) -> i32 { Into::into(T::new()) + 1 } }
let x : i32 = Into::into(nat); println!("{}", x);
webmasterx
А почему такие странные имена? Succ Nat
FL3
Succ — successor,
Nat — от natural number, бездумно взял из хаскелевого примера, о котором думал.
Googolplex
del