Использование универсальных типов в Rust

Перевод | Автор оригинала: Matt Oswalt

В предыдущем посте я исследовал «общее программирование» с целью выделить не зависящие от языка преимущества и основные концепции. Особенно, если это новая тема для вас, я рекомендую вам сначала прочитать этот пост, так как в нем освещено много идей, которые применяются гораздо шире, чем то, что мы рассмотрим в этом посте, который вместо этого будет сосредоточен на том, как Rust делает что-то.

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

Я обнаружил, что существующие библиотеки и API Rust намного легче понять теперь, когда я занялся этими двумя темами. Я действительно изо всех сил пытался прочитать код Rust, прежде чем потратил дополнительное время на понимание не только обобщений и трейтов в rust, но и концепций, которые они представляют, и их базовой реализации. Если вам это тоже сложно, надеюсь, эта серия постов поможет.

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

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

Параметры типа

Если вы использовали Rust в течение даже небольшого количества времени, вы, вероятно, использовали дженерики, даже не осознавая этого. Возьмем простой пример создания нового вектора:

let myvec = Vec::new();

Этот код не компилируется; мы не предоставили параметр типа, указывающий, какой тип должен содержать этот вектор.

error[E0282]: type annotations needed for `std::vec::Vec<T>`
 --> src/main.rs:7:17
  |
7 |     let myvec = Vec::new();
  |         -----   ^^^^^^^^ cannot infer type for type parameter `T`
  |         |
  |         consider giving `myvec` the explicit type `std::vec::Vec<T>`, where the type parameter `T` is specified

Это связано с тем, что для реализации векторов требуется общий параметр T, который является заполнителем, который мы должны выполнить с определенным типом.

// We can provide the type parameter on creation
let myvec: Vec<i32> = Vec::new();

// Or, we can add to the previous example by making our vector mutable,
// and then pushing elements to it.
let mut myvec = Vec::new();
// The compiler will automatically infer the inner type of the vector using the type of
// the pushed element.
myvec.push(34);

Работает любой из вышеперечисленных подходов - важно то, что компилятор должен знать в какой-то момент, какой конкретный тип вы собираетесь использовать для замены заполнителя T.

Важно понимать, что это НЕ то же самое, что динамический набор текста. Динамическая типизация - это концепция гораздо более ориентированных на время выполнения языков, таких как Python, которая включает в себя возможность изменять тип для любой переменной во время выполнения или создавать коллекции / массивы нескольких различных типов. В Rust это невозможно.

Теперь мы рассмотрим, как использовать Generics для определения подобных заполнителей в вашем собственном коде Rust.

Определение универсальных типов в Rust

Rust может использовать дженерики в нескольких местах:

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

С помощью дженериков мы можем обойти этот выбор. Вместо этого мы можем писать наш код кратко и позволять компилятору выполнять работу по преобразованию всех наших «заполнителей» в конкретные статические типы, что дает нам необходимую скорость и безопасность.

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

struct PointI32 {
    x: i32,
    y: i32,
}

struct PointF32 {
    x: f32,
    y: f32,
}

Однако с универсальными шаблонами нам нужно написать только один экземпляр этой структуры и использовать «заполнитель», чтобы указать, что мы хотим, чтобы тип предоставлялся при создании экземпляра, как мы видели выше в векторном примере.

struct Point<T> {
    x: T,
    y: T,
}

Когда Rust компилирует этот код, он анализирует, как эта структура используется, и «мономорфизирует» ее. Это процесс создания дубликатов этих типов, но с конкретными типами, а не с универсальными типами. Это позволяет нам, как разработчикам, писать простой общий код, но мы по-прежнему получаем все преимущества использования конкретных типов.

Это общий принцип, по которому вы можете захотеть использовать универсальные шаблоны в своем коде - теперь давайте перейдем к некоторым примерам.

Generics предлагают простые и автоматические ограничения типа

Я уже рассказывал об использовании границ трэйтов с универсальными типами, чтобы помочь вам разработать ограничения для типов на основе их поведения, например, для параметров функций. Однако сами дженерики предлагают некоторые основные, но полезные ограничения сами по себе.

Например, в определении структуры мы можем указать, что данное поле является универсальным типом:

struct Point<T> {
    x: T,
}

Мы определили общий тип T, а затем указали, что поле x имеет тип T. На данный момент нигде не указан конкретный тип. Однако, как только мы создадим экземпляр этого типа и присвоим конкретное значение x, он примет тип любого используемого конкретного значения:

// For this instance of Point, the type of `x` is assumed
// to be `i32` (https://doc.rust-lang.org/book/ch03-02-data-types.html#integer-types):
let point = Point{x: 42};

Использование универсальных типов также может дать нам некоторые простые ограничения, которые автоматически применяются в нашей структуре. Например, добавление второго поля, которое использует один и тот же универсальный параметр T, означает, что когда конкретные типы используются для создания экземпляра нашей структуры, один и тот же конкретный тип должен использоваться для обоих полей x и y:

struct Point<T> {
    x: T,
    y: T,
}

Это становится очевидным, когда мы пытаемся использовать разные типы - приведенный ниже пример не будет компилироваться:

let point = Point{
    x: 42,
    y: 24.1,
};

Ошибка заключается в использовании числа с плавающей запятой для y: ожидаемое целое число, найденное число с плавающей запятой. Это потому, что мы использовали целое число для x, и поскольку x и y имеют общий параметр, они должны быть одного и того же конкретного типа, каким бы он ни был. Проще говоря, это можно было бы сказать:

Меня не волнует, какой конкретный тип используется для поля x или y, но мне важно, чтобы они были одного типа.

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

fn genericfn<T, U>(foo: T, bar: U) -> T {
    foo
}

Здесь использование второго универсального параметра U означает, что у нас есть два заполнителя. Параметры bar и foo не обязательно должны быть одного и того же типа - главное, чтобы эта функция возвращала тот же тип T, который используется для параметра foo(что, очевидно, верно в этом простом примере, поскольку мы просто возвращаем foo сразу).

Важное замечание: в этом примере, хотя foo и bar не обязательно должны быть одного и того же типа из-за того, что мы используем разные общие параметры, они все же могут быть. Тот факт, что мы используем разные общие типы, не означает, что типы должны быть разными. Они по-прежнему могут быть экземпляром Point, например.

Как видите, используя общие параметры сами по себе, мы можем добиться большой гибкости и краткости нашего кода, при этом оставив при этом некоторые полезные ограждения.

Generics + Trait Bounds == Сверхспособности

В предыдущих примерах мы не особо много работали с общими параметрами, поэтому нас не особо затрудняли:

fn returnt<T>(foo: T) -> T {
    foo
}

Когда вы указываете общие параметры, а затем пытаетесь что-то сделать с этими параметрами, это может стать немного интереснее - этот код не будет компилироваться:

fn printme<T>(x: T) {
    println!("{:?}", x);
}
error[E0277]: `T` doesn't implement `std::fmt::Debug`
  --> src/main.rs:37:22
   |
37 |     println!("{:?}", x);
   |                      ^ `T` cannot be formatted using `{:?}` because it doesn't implement `std::fmt::Debug`

Эта ошибка вызвана тем фактом, что компилятор Rust знает, что для макроса println требуется тип, реализующий трэйту std::fmt::Debug, и в настоящее время нет гарантии, что универсальный тип T реализует это.

Как мы узнали в предыдущем посте, «привязка трэйты» может помочь исправить это. Это накладывает дополнительные ограничения на типы типов, которые могут использоваться для нашей функции printme, разрешая только типы, реализующие данную трэйту:

fn printme<T: std::fmt::Debug> (x: T) {
    println!("{:?}", x);
}

Итак, там, где универсальные типы дают нам возможность писать краткий код, который работает для многих различных конкретных типов, трэйты (при привязке к универсальному типу) позволяют нам гарантировать, что эти типы демонстрируют определенное поведение. Более того, это всего лишь еще одна проверка во время компиляции. Результирующая программа по-прежнему имеет такую же статическую и конкретную типизацию.

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

Когда я писал свой пост о Rust Traits, я чувствовал, что Traits могут более или менее концептуально стоять самостоятельно, но даже все же я не мог избежать краткого упоминания общих типов в этом посте. Сама книга Rust, кажется, признает эту взаимосвязанную природу двух концепций, охватывая их обе в одном разделе. Они действительно предназначены для совместного использования, и именно по этой причине мне было трудно читать код Rust, пока я не овладел этими двумя концепциями.

Суть в том, что при написании и чтении кода Rust мы получаем гибкость и производительность, которые могли бы соперничать с языком с динамической типизацией, но как только наша программа скомпилирована, мы получаем всю безопасность и предсказуемость, как если бы мы только что написали все с конкретным типы самих себя и все ненужное дублирование кода, которое может потребоваться.

Обобщения в определениях методов

Определение метода для типа, который использует универсальные типы, требует, чтобы мы указали полную сигнатуру типа в операторе impl, который включает эти типы. Поскольку наша структура Point на самом деле является Point, мы должны повторить это в объявлении impl. Более того, мы должны переопределить и этот общий параметр, что приведет к следующему:

// Re-defining the parameter T for this `impl` statement, which makes it available to
// the methods defined below for this generic type.
impl<T> Point<T> {
    // This method returns the same concrete type used for
    // the `x` field of `Point` - we know this because it
    // uses the same generic parameter.
    fn x(&self) -> &T {
        &self.x
    }
}

Мы уже узнали, что можем объявлять общие параметры для функций. Что интересно, мы можем сделать это и для методов, даже если тип, которому принадлежат эти методы, нигде не использует универсальные типы:

struct NewStruct {}

// NewStruct doesn't use any generic types,
// so we don't need to specify any here.
impl NewStruct {

    // We can still, however, define our own generic parameters
    // on an individual method as desired
    fn x<T>(&self, foo: T) -> T {
        foo
    }
}

Вывод

Это было довольно легкое введение в использование универсальных типов в Rust. Как указывалось ранее, в документации также есть немало убедительных примеров, так что, надеюсь, вы сможете начать определять места в своем собственном коде, где универсальные шаблоны могут упростить вам задачу.

В следующем посте мы более подробно рассмотрим, как это скрыто реализовано в Rust. Здесь есть не только некоторые интересные подробности о том, как здесь работает Rust, но и некоторые интересные варианты реализации полиморфизма в Rust, каждая из которых имеет свои собственные компромиссы, которые стоит рассмотреть.