Функциональные возможности систем типов Julia и Rust

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

Примечание: я люблю Julia и Rust, кроме того они прекрасно подходят для демонстрации моей точки зрения. Однако, если вы не пишите на этих языках, не бросайте чтение! Во-первых, синтаксис обоих языков интуитивно понятен, а код в примерах действительно простой, так что следовать ему не составит труда. Во-вторых, вы наверняка сможете заменить Julia другим языком, использующим наследование свойств, например Java, a Rust  —  языком, использующим несвязное объединение, например Haskell. В любом случае вы узнаете что-то новое!

Сценарий

Предположим, мы создаём межгалактическую игру, в которой игроки могут заселить три звёздные системы: Солнце (solarians), Полярную звезду (polarians) и Альфу Центавра (centaurians). Очевидно, сначала нужно определить типы для каждого из трёх классов.

Julia:

struct Solarian end
struct Polarian end
struct Centaurian end

Rust:

struct Solarian;
struct Polarian;
struct Centaurian;

Разумеется, в эти структуры можно добавить ещё поля, но здесь речь не об этом.

Теперь, скажем, вам нужно сохранить значение игрока в другом объекте, например состояние игры.

Julia:

struct GameState
    daytime::UInt
    player::???
end

Rust:

struct GameState {
    daytime: u64,
    player: ???
}

Какой тип применить для player в каждом случае?

Julia:

function homestar(player::???)::String
    ...
end

Rust:

fn homestar(player: ???) -> String {
    ...
}

Разберём ответы на эти вопросы.

Стратегия 1: наследование / выделение подтипа

Julia

В Julia это очень просто. Определяем:

abstract type Player end

и создаём структуры из подтипов выше.

struct Solarian <: Player end
struct Polarian <: Player end
struct Centaurian <: Player end

Затем динамическая диспетчеризация позволяет написать:

homestar(player::Solarian) = "sun"
homestar(player::Polarian) = "polar star"
homestar(player::Centaurian) = "Alpha Centauri"
homestar(player::Player) = "unknown" # или `error("Unknown player class")`

Rust

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

trait Player {
    fn homestar(&self) -> String;
}

а затем:

impl Player for Solarian {
    fn homestar(&self) -> String {
        String::from("sun")
    }
}

А как же структура GameState? Мы можем воспользоваться объектами типажей, о которых знаем лишь то, что они реализуют определённый типаж. В том числе мы не знаем размер объекта, поэтому нам нужно ссылаться на него. Если вы знакомы с Rust, вы в курсе, что это означает либо использование “нормальной” ссылки (& или &mut) и работы с аннотациями времени жизни (не моё любимое, честно говоря), либо использование упакованного значения. Если вы не знакомы с этой концепцией, здесь это не имеет значения. GameState выглядит так:

struct GameState {
    daytime: u64,
    player: Box<dyn Player>
}

Этот код работает, но ограничивает нас в том, что мы можем делать с типажом Player, поскольку он должен быть безопасным для объектов. Например, приветствие другого игрока произвольного класса больше не будет работать:

trait Player {
    fn homestar(&self) -> String;
    fn greet<T: Player>(&self, other: &T);
}

А с Julia все было бы просто:

greet(self::Polarian, other::Player) = println("Hi!")
greet(self::Polarian, other::Solarian) = println("Your star doesn't even consist of three separate stars? Pathetic!")

Стратегия 1b: ограниченная параметризация

И Julia, и Rust предлагают определение параметризованных типов с помощью параметров типа. Кроме того, на эти параметры можно наложить ограничения. Это позволяет нам делать то же самое, что в стратегии 1, не скрывая конкретный тип игрока в структуре GameState:

abstract type Player end
struct Solarian <: Player end

struct GameState{P <: Player}
    daytime::UInt
    player::P
end

или

trait Player { ... }
struct Solarian;
impl Player for Solarian { ... }

struct GameState<P: Player> {
    daytime: u64,
    player: P
}

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

Однако у этого подхода есть серьёзная проблема: утечка абстракции. Пользователи структуры GameState теперь должны решать, какой конкретный тип выбирать для параметра P очень строго в Rust и менее строго в Julia. В целом это нормально, но довольно часто GameState является подходящим местом для решения этого вопроса и должна скрывать эту деталь.

Итак, простое и надёжное правило: рассматривайте параметризацию для таких задач по существу в вашем коде, но не упускайте из виду тот уровень абстракции, на котором параметризация должна оставаться внутри.

Стратегия 2: перечисления

Пока что рассмотренные решения не очень подходили для Rust. Но на выручку приходят перечисления! Перечисления, или enums,  —  это типы, состоящие из конкретного и явного списка возможных значений, также называемых вариациями.

Rust

В Rust этим значения могут иметь собственные данные, что идеально подходит для нашей ситуации:

enum PlayerClass {
    Sol(Solarian),
    Pol(Polarian),
    Cent(Centaurian)
}

struct GameState {
    daytime: u64,
    player: PlayerClass
}

impl PlayerClass {
    fn homestar(&self) -> String {
        use PlayerClass::*;
        match self {
            Sol(_) => String::from("sun"),
            ...
        }
    }

    fn greet(&self, other: &PlayerClass) {
        use PlayerClass::*;
        match (self, other) {
            (Cent(_), Pol(_)) => println!("Hello, fellow three-star-systemer!"),
            ...
        };
    }
}

Это эталонный вариант в данной ситуации, действительно решающий все наши предыдущие задачи.

Julia

В Julia вроде бы есть перечисления, но в действительности нет. Если написать так:

@enum PlayerClass Sol Pol Cent

… перечисление предоставит вам тип PlayerClass в точности с этими тремя возможными значениями, но перечисления не являются гражданами первого класса, и, что гораздо важнее для нас, вариации перечислений в Julia не способны нести с собой дополнительные данные! Поэтому вся информация в полях структур Solarian, Polarian и Centaurian не может быть встроена в перечисление PlayerClass. Не существует способа разумно реализовать следующее:

function takedamage!(player::PlayerClass)    ...end

Тогда как в Rust это проще простого при условии, что в структуре Solarian есть поле health: u64:

function takedamage!(player::PlayerClass)
    ...
end

Заключение

Возможно, вы уже догадались, что вкратце совет такой: применяйте выделение подтипа в Julia и перечисления в Rust. Одно предостережение: перечисления нельзя расширить “извне”. Если вы планируете позволить пользователям вашей библиотеки добавлять новые вариации типов, вам придётся использовать подход с типажами.

Если вы уже знакомы с Rust или Julia, большая часть статьи для вас, вероятно, весьма очевидна. Вы можете сказать: “Разумеется, я применяю выделение подтипа, именно за это все так любят Julia!”, или “Конечно, я применяю перечисления, они же являются одной из ключевых фич Rust!”

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

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Andreas Kröpelin: Optionality in the type systems of Julia and Rust