Статьи

Если бы Java была Haskell — система типов

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

Типы

Что бы класс Java стал в мире Haskell?

В моем предыдущем посте мы закончили со статическими методами, работающими с неизменяемыми структурами данных. В этом контексте нет места для Объектов, поскольку разработчики Java знают их. Первое, что ясно видно, это то, что эти статические методы не привязаны к структуре данных, с которыми они работают. Таким образом, они не должны быть в классе. Им нужно только какое-то пространство имен, файл для организации нашего кода. Итак, представьте, что вы можете создать метод прямо в корне файла. Осталось ли что-нибудь сделать для занятий? Да, данные. Вот почему Haskell использует ключевое слово data вместо класса.

Поскольку структуры данных являются неизменяемыми, их необходимо инициализировать с помощью конструкторов. Вот пример базового класса:

class Animal {
  String name;
  Color color;
 
  Animal(String name, Color color) {
    this.name = name;
    this.color = color;
  }
 
  String getName() {
    return name;
  }
 
  Color getColor() {
    return color;
  }
}

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

class Animal {
  String name;
  Color color;
 
  Animal(String name, Color color) {
    this.name = name;
    this.color = color;
  }
}
 
String getName(Animal a) {
  return a.name;
}
 
Color getColor(Animal a) {
  return a.color;
}

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

class Animal {
  Animal(String name, Color color);
}

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

boolean isDangerous(Animal a) {
  if (getColor(a) == Color.PINK) {
    return false;
  }
  return true;
}

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

class Animal {
  Cat(String name, Color color);
  Dog(String name, Color color);
}

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

String speak(Animal a) {
  switch a {
    case (Cat name _) : return name + " says Miaow!";
    case (Dog name _) : return name + " says Woof!";
  }
}

Обратите внимание, что деструктуризация также дает нам имя поля, поэтому нам не нужен доступ к сгенерированному геттеру. Мы используем «_» для параметра цвета, что означает, что мы не заботимся об этом. Для простого класса, такого как Animal, нам даже не нужно присваивать имена параметрам конструктора, у нас не будет получателей, но это нормально благодаря деструктуризации. Таким образом, мы можем упростить Animal в:

class Animal {
  Cat(String, Color);
  Dog(String, Color);
}

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

String speak(Animal a)
speak(Cat name _) { name + " says Miaow!" }
speak(Dog name _) { name + " says Woof!" }

Последнее замечание о типах. Что происходит, когда конструкторы класса не имеют параметров? Мы получаем нечто очень знакомое Java-разработчикам: enums. Но нам не нужно другое имя, это просто классы. Мы можем даже смешивать константы перечисления и параметризованные конструкторы. Например, класс Color может иметь  Pink и  Color(int int int) конструкторы.

Интерфейсы (классы типов)

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

interface Showable {
  String toString(Showable s);
}

Затем для каждого конкретного типа, реализующего этот интерфейс, вы определяете желаемое поведение:

class Animal {
  Cat(String, Color);
  Dog(String, Color);
}
 
Animal implements Showable {
  String toString(Animal a) {
    speak(a); // see function speak above
  }
}

После этого любая функция, требующая аргумента Showable, может быть вызвана с Animal.

Вывод типа

Как только у нас появятся типы данных и интерфейсы, мы сможем полагаться на язык для максимально возможного определения типа переменных. Это область, в которой сияет Хаскелл, в то время как Java явно отстой. Вот тривиальный пример:

myCat = Cat("Puss", ORANGE);
message = speak(myCat);

Нет необходимости говорить, что myCat относится к типу Animal, компилятор уже знает, что Cat является конструктором для Animal. Компилятор также делает вывод, что сообщение имеет тип String, потому что это то, что говорит функция.

Радость вывода типа становится более очевидной с функциями высшего порядка. Например, представьте, что у нас есть список Animal с именем myPets, и мы хотим преобразовать этот список в список String, содержащий имена животных. В Java, используя  API коллекций Guava , мы получаем следующее:

List<String> names = transform(myPets, new Function<Animal, String>() {
  @Override
  public String apply(Animal a) {
    return a.getName();
  }
});

В Haskellized-версии мы можем сделать:

names = transform(getName, myPets);

Это оно! Компилятор знает, что myPets — это список Animal, и эта карта преобразует список в другой список, применяя данную функцию ко всем элементам. Если мы передадим функцию, которая не принимает животное в качестве аргумента, компилятор будет жаловаться. При передаче getName не только компилятор не жалуется, но и выводит, что выводом будет список String.

Для версии Java требовалось 3 строки, 2 животных и 1 список. Вывод типов позволил хакелизированной версии избавиться от этих 6 объявлений без потери безопасности типов.

Некоторые специальные типы

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

  • Список, который намного ближе к массиву Java, чем список Java, и может быть определен с помощью синтаксиса в квадратных скобках, например [1, 2, 3]. Список может быть даже бесконечным, поскольку он оценивается лениво (как и все в Haskell). Например, бесконечный список четных чисел может быть определен как [2, 4, ..].
  • Строка, представляющая собой просто список символов, поэтому «привет» — это то же самое, что [‘h’, ‘e’, ​​’l’, ‘l’, ‘o’] 
  • Кортеж, который представляет собой специальный небольшой список, содержащий значения разных типов. Это в основном контейнер для простой структуры данных, которая не стоит создавать тип. Примером может быть (42, «Мо», «Молибден»)

Особый интерфейс: Monad

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

Монада — это класс типов (интерфейс в нашем мире). Он в основном определяет контейнер, который может содержать значение и быть связан с другими контейнерами того же типа в соответствии с указанным механизмом. Чтобы сделать что-то полезное с этим, мы не связываем напрямую те контейнеры, а вычисления, которые их возвращают.

Типичным примером монад является монада Maybe, которая может содержать либо значение, либо ничего. Контейнер Maybe параметризован и имеет 2 конструктора. В нашем haskellized Java, Maybe ofString будет:

class Maybe<String> {
  Just(String);
  Nothing;
}

Механизм связывания для Maybe гарантирует, что ввод ничего не всегда приводит к выводу ничего. Другими словами, как только в цепочке ничего не происходит, выход цепочки становится ничем.

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

// utility function which returns a Maybe
Maybe<String> changeSex(String sex) {
 switch sex {
    case "XX" : Just("XY");
    case "XY" : Just("XX");
    default: Nothing
  }
}
 
changeSexFiveTimes() {
  do {
    s1 <- changeSex("ZY"); // the "<-" is used to extract the value 
                           // from a monad (we say bind) 
                           // but oops ... bad input yields Nothing
 
    s2 <- changeSex(s1);   // this method is not called because the 
                           // chaining mechanism of Maybe yields Nothing
 
    s3 <- changeSex(s2);   // this one is skipped too
    s4 <- changeSex(s3);   // and so is this one
    changeSex(s4);  // returns Nothing as the result of the whole block
  }
}

Магическое поведение цепочки определяется в классе Maybe. Вывод типа используется компилятором, чтобы угадать, какой тип механизма сцепления применить (то есть первое значение — это Maybe, чтобы компилятор знал, что применяется цепочка Maybe).

Есть еще много чего сказать о монадах и много литературы в Интернете. Давайте просто отметим, что Haskell использует монады для вещей, которые в противном случае были бы невозможны в чисто функциональном мире, а именно для ввода-вывода и управления состоянием.

Вывод

Эти две статьи царапали поверхность Хаскелла глазами и словами разработчика Java. Если вы ничего не знаете о Haskell, я надеюсь, что мне удалось разжечь ваш аппетит, и я рекомендую вам взглянуть на «  Learn You a Haskell for Great Good»,  чтобы открыть для себя чудеса, которые может предложить этот другой мир.