Особенности Rust: определение поведения

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

Прежде чем приступить к изучению любого языка программирования, вы часто слышите о его «сильных сторонах» - функциях, которые обычно привлекают внимание, когда кто-то «знающий» пытается обобщить сильные стороны языка. В 2015 году, изучая Go, я часто слышал такие вещи, как поддержка параллелизма, каналы, поддержка параллелизма и интерфейсы. Также поддержка параллелизма. В Rust до сих пор основные моменты включали такие вещи, как сильная поддержка дженериков, управление нижнего уровня и упор на безопасность памяти, проявляющийся в модели неизбежного владения.

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

Что такое трэйты характера?

Часто, когда вы создаете набор функций для собственного использования или, может быть, для библиотеки, которую собираетесь опубликовать для использования другими, вы не хотите быть слишком жесткими в определении того, какие конкретные типы передаются. Это часто Вместо этого полезно определить более абстрактный набор действий, которые вам требуются, но разрешить пользователю вашей функции / библиотеки создавать свои собственные типы, удовлетворяющие этим ограничениям. Мне нравится думать об этом как о вышибале, позволяющем посещать ночной клуб самых разных людей, но только тех, кто ведет себя хорошо. Автор Xxinvictus34535 - Собственная работа (измененная), CC BY-SA 4.0, Ссылка

Между прочим, механизмы для определения и/или принуждения поведения не являются новыми концепциями в Rust или любом другом языке, если на то пошло. Например, Java имеет «интерфейсы» уже давно. Тем не менее, первые несколько лет моей карьеры в качестве штатного разработчика я писал почти исключительно на Python, в котором нет концепции для этого; Модель динамического типа Python часто означает, что для определения поведения используется метод «утиной печати», эффективно переносящий проблему во время выполнения (что, оглядываясь назад, все еще пугает меня).

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

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

trait Speak {
    fn say_hello(&self) -> String;
}

У нас есть трэйта под названием «Speak», и эта трэйта описывает только одну сигнатуру функции под названием «say_hello», которая принимает ссылку на себя и возвращает String. Мы еще не создали никаких типов, мы просто объявили эту трэйту как способ описания поведения.

Если бы мы хотели создать тип с именем Person и реализовать в нем эту трэйту, нам нужно было бы создать функцию в специальном блоке impl, в котором говорилось бы, что мы собираемся реализовать трэйту Speak, а затем убедиться, что мы создали правильные функции (включая параметры и возвращаемые типы), требуемые трейтом:

struct Person {}

impl Speak for Person {
    fn say_hello(&self) -> String {
        String::from("Hello!")
    }
}

В этом примере считается, что тип Person реализовал трэйту Speak. Мы знаем это, потому что, если бы этого не произошло, он бы даже не компилировался (одна из самых крутых частей того, как Rust делает здесь что-то, о чем я расскажу позже). Рабочий процесс здесь состоит из двух частей; мы сначала описываем поведение как трэйту, а затем реализуем это поведение в конкретном типе.

Реализации свойств по умолчанию

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

trait Speak {
    // This is the default implementation of the say_hello function
    fn say_hello(&self) -> String {
        String::from("Hello!")
    }
}

// Person1 and Person2 simply use the default implementation, and don't
// have to redefine it
struct Person1 {}
impl Speak for Person1 {}
struct Person2 {}
impl Speak for Person2 {}

// Person3 can redefine the say_hello function, provided it follows the
// signature required by the trait
struct Person3 {}
impl Speak for Person3 {
    fn say_hello(&self) -> String {
        String::from("Hello World!")
    }
}

fn main() {
    let p1 = Person1{};
    println!("{}", p1.say_hello());
    let p2 = Person2{};
    println!("{}", p2.say_hello());
    let p3 = Person3{};
    println!("{}", p3.say_hello());

    // Output:
    //
    //  Hello!
    //  Hello!
    //  Hello World!
}

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

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

Это приводит к использованию трэйтов в качестве параметров функции и синтаксиса «привязки к трэйтам».

Трэйты как параметры и синтаксис привязки трэйтов

Здесь вы можете спросить: «Почему это полезно? Я имею в виду, почему бы нам просто не отбросить биты черт и просто не объявить этот метод в обычном старом блоке impl Person? »

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

fn give_greeting(p: impl Speak) {
    println!("{}", p.say_hello());
}

Ключевое слово impl используется здесь, чтобы указать, что параметр p должен относиться к типу, реализующему трэйту Speak.

Как бы то ни было, мне не нравится этот синтаксис. Я имею в виду, что раньше я видел похожие ключевые слова, используемые в параметрах функции, чтобы предоставить дополнительную информацию о них (на ум приходит ключевое слово mut), но этот синтаксис все еще кажется мне странным. Я не уверен, что это повторное использование ключевого слова impl или что-то в этом роде, но что-то в этом отношении по какой-то причине все еще не подходит. Я думаю, идея заключается в том, что синтаксис impl упрощает чтение для простых случаев, но, по моему опыту работы с несколькими сторонними библиотеками, этот синтаксис используется редко. Так что, хотя может быть полезно запомнить этот синтаксис на всякий случай, мне кажется, что следующий синтаксис «границ трэйтов», описанный ниже, является более практичным форматом для изучения и использования.

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

fn give_greeting<T: Speak>(p: T) {
    println!("{}", p.say_hello());
}

В этом случае мы определяем универсальный тип T, который должен реализовывать трэйту Speak. Это то, что находится внутри <...> части сигнатуры функции. После определения мы можем просто указать, что параметр p должен иметь тип T.

Здесь начинается намек на использование Generics в Rust, с которым, учитывая мой путь через Python и Go, у меня фактически не было большого практического опыта. Мы более подробно рассмотрим обобщенные шаблоны в следующих статьях, но использование универсальных шаблонов в качестве параметров функций - это то место, где границы трэйтов становятся чрезвычайно полезными.

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

fn give_greeting<T>(p: T)
    where T: Speak
{
    println!("{}", p.say_hello());
}

Здесь объявление T (как, непосредственно перед круглыми скобками) по-прежнему требуется, но привязка к трейту Speak обрабатывается в отдельной строке. Если бы у нас было несколько общих параметров, которым нужны были бы собственные аналогичные привязки, это было бы намного понятнее, чем предыдущий синтаксис.

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

Недавний пример этого был в моем текущем проекте, где я искал интегрировать Rust и Lua с помощью rula crate. Я хотел иметь возможность определять набор типов Lua, которые можно было бы транслировать в типы Rust. rlua позволяет это сделать и использует трэйты вроде FromLua, чтобы требовать от меня выполнения необходимых переводов с Lua-land на Rust-land. Если я не использую эту трэйту в типах Rust, которые я собираюсь сделать доступными для сценариев Lua, тогда некоторые вещи не будут работать, например, создание функций Lua, которые работают в Rust как своего рода FFI. Обратите внимание на использование синтаксиса where для метода create_function; упомянутые здесь трэйты характера требуют от меня наведения порядка, прежде чем пытаться использовать его.

Еще несколько интересных моментов, пока мы говорим о трэйтах и функциях:

Явное усиление свойств Rust

При работе с поведением, описывающим / усиливающим такие функции, как трэйты характера, часто самый большой вопрос заключается в том, как они будут реализованы. Большинство языков позволяют объявлять поведение (Rust в трэйтах, Go / Java и т.д. В «интерфейсах»), но то, как / когда это поведение применяется, может варьироваться.

Например, в Go мы определяем поведение с помощью «интерфейсов». Они применяются неявно, когда вы пытаетесь использовать тип в месте (например, параметре функции), которое принимает интерфейс. Если у вас есть поле, которое принимает тип интерфейса, и вы передаете что-то, что не соответствует этому интерфейсу, вы получите сообщение об ошибке. Как и в Rust, это проверяется во время компиляции. Однако, поскольку эта проверка неявно выполняется при использовании, возможно, у вас может быть код, который, по вашему мнению, реализует определенный интерфейс, но если вы его нигде не используете, это не проверяется.

package main

import "fmt"

type Speak interface {
	sayHello() string
}

type Person struct {
}

// This function has a different return type than required by the "Speak" interface
// so the "Person" type doesn't currently satisfy it.
func (p Person) sayHello() int {
	return 42
}

func main() {
  // Compliance with the interface "Speak" is only checked if we try to use the
  // Person type as a parameter to the "give_greeting" function as below.
  //
  // The code as-is will compile just fine, but if we un-comment the below,
  // it will fail to compile because the type we're trying to pass to give_greeting
  // doesn't satisfy the "Speak" interface.
  //
  // p := Person{}
  // give_greeting(p)
}

func give_greeting(p Speak) {
	fmt.Println(p.sayHello())
}

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

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

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

trait Speak {
    fn say_hello(&self) -> String;
}

struct Person {}
impl Speak for Person {
    fn say_hello(&self) -> String {
        String::from("Hello!")
    }
}

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

trait Speak {
    fn say_hello(&self) -> String;
}

struct Person {}
impl Speak for Person {
    fn say_hello(&self) -> usize { // <-- doesn't compile!
        42
    }
}

Мне нравится эта модель, потому что в ней нет двусмысленности. Rust буквально не будет компилироваться, если тип действительно не реализует эту трэйту должным образом, независимо от того, пытаетесь ли вы использовать этот тип где-либо. Более того, это нужно сделать полностью. После того, как вы введете оператор impl для , вы должны определить все функции, требуемые для характеристики, прежде чем ваш код снова скомпилируется. Частичной реализации нет.

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

Полиморфизм Rust с использованием Trait Objects

Если вы пришли из динамических языков, таких как Python, вы привыкли делать такие вещи, как хранить много разных типов в списке. Список Python может содержать целое число, затем строку, затем класс, затем экземпляр класса и так далее. В Python нет правил, все в хаосе. Действительно, это большая проблема, если вы новичок в Rust или статических языках в целом, где простые типы коллекций (такие как «вектор» Rust или «фрагмент» Go) должны быть объявлены с определенным типом, а все члены из этой коллекции должны быть этого типа. Этот вектор v объявлен с типом i32, и только значения этого типа могут быть помещены в v:

let mut v: Vec<i32> = Vec::new();

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

Оказывается, мы можем использовать ту же тактику, чтобы привнести эту гибкость в коллекции, такие как векторы. Что, если бы нам нужен вектор, который не был определен одним конкретным типом, а мог бы содержать любой тип, реализующий данную трэйту? Это возможно благодаря так называемым «чертовым объектам».

Прежде чем углубляться в детали, я хочу упомянуть, что при использовании трейт-объектов есть компромисс. Как правило, многие абстракции в Rust (включая трейты в целом) называются абстракциями с «нулевой стоимостью» (пожалуйста, посмотрите это видео и прочтите это сообщение в блоге, если вы хотите узнать больше об этом термине). Это означает, что в максимально возможной степени Rust пытается позволить вам писать действительно выразительный, поддерживаемый код, не заставляя вас снижать производительность во время выполнения за эту привилегию. Однако типажные объекты - исключение. Из-за того, как объекты-характеристики используют динамическую диспетчеризацию «за кулисами» для поиска заданного метода на этом объекте-характеристике, который вызывается во время выполнения, это немного снижает производительность. Похоже, что компромисс здесь заключается в том, что вы получаете гораздо более читаемый и поддерживаемый код; вы на крючке, чтобы оценить, слишком ли сильно снижается производительность. Для получения дополнительной информации я бы порекомендовал эту страницу по ключевому слову dyn - оно довольно хорошо объясняет этот компромисс.

Когда мы создали вектор v выше, мы объявили его как тип i32. Опять же, это означает, что любое значение, которое мы добавляем к нему, должно быть i32. Если вы думаете об использовании нашей трэйты Speak вместо конкретного типа, у вас может возникнуть соблазн изменить это на что-то вроде:

let mut v: Vec<Speak> = Vec::new();

Однако это не сработает:

error[E0277]: the size for values of type `dyn Speak` cannot be known at compilation time
   --> src/main.rs:41:16
    |
41  |     let mut v: Vec<Speak> = Vec::new();
    |                ^^^^^^^^^^ doesn't have a size known at compile-time

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

Правильный способ создания вектора трейт-объектов - это следующий синтаксис:

let mut v: Vec<Box<dyn Speak>> = Vec::new();

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

Зная это, использование трэйтов-объектов становится довольно простым. Во-первых, мы переопределим нашу знакомую трэйту Speak с реализацией по умолчанию и создадим несколько структур, которые используют эту реализацию как есть:

trait Speak {
    fn say_hello(&self) -> String {
        String::from("Hello!")
    }
}

struct Person1 {}
impl Speak for Person1 {}
struct Person2 {}
impl Speak for Person2 {}
struct Person3 {}
impl Speak for Person3 {}

Затем мы можем создать структуру Crowd, в которой есть поле speak_people, в которое мы поместим наш вектор. Поскольку мы используем типажные объекты, наша функция main() может вызывать функцию say_hello() для каждой итерации вектора, даже если базовые типы совершенно разные!

struct Crowd {
    // We're creating our trait object with `dyn Speak`, and wrapping it
    // in a Box, so we can understand at compile-time, the size of the elements of
    // our vector.
    speaking_people: Vec<Box<dyn Speak>>,
}

fn main() {
    let crowd = Crowd {

        // Note that we're using three different types in this vector!
        // Trait objects are bonkers.
        speaking_people: vec![
            Box::new(Person1 {}),
            Box::new(Person2 {}),
            Box::new(Person3 {}),
        ],
    };
    for person in crowd.speaking_people.iter() {
        println!("{}", person.say_hello());
    }
}

Дополнительные ресурсы

Есть несколько «официальных» ссылок, на которые вы обязательно наткнетесь, если начнете гуглить трэйты характера, как я, так что вот краткий список:

Если вышеупомянутое было большим TL;DR и вместо этого вас интересует бурный тур, этот доклад был хорошим, коротким и явно хорошо отрепетированным: