Статьи

На пути к универсальным API для открытого мира

В моем последнем посте о том, как протоколы Clojure поощряют открытые абстракции, я провел несколько быстрых обходов между классами типов в Haskell и протоколами в Clojure. В конце раздела «Не совсем класс типа» я упомянул о функции чтения класса чтения Хаскелла. read принимает String и возвращает тип — следовательно, он отправляет не аргумент функции, а тип возвращаемого значения. Протоколы Clojure не могут этого сделать, я не знаю ни одного динамического языка, который может это сделать. Посмотрите проницательный комментарий Джеймса Ири на эту тему в этом посте.

С классами типов вся диспетчеризация является статической — карта диспетчеризации передается как словарь типов и выводится компилятором. Какую пользу это приносит нам? Действительно ли мы получаем что-то особенное, когда язык поддерживает такие API, как метод read класса чтения типа Haskell?

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

Этот пост частично вдохновлен прекрасной статьей « Обобщение API » Эдварда З. Янга. Для этого поста я буду использовать Scala, мой текущий язык выбора для большинства вещей, которые я делаю сегодня.

Мой общий API
Я хочу реализовать API чтения, подобный тому, который есть в Haskell, закодированном в классе типов Scala. Давайте сделаем его универсальным в типе, который он возвращает.

// type class
// reads a string, returns a T
trait Read[T] {
def read(s: String): T
}

Для открытого мира Мы можем определить экземпляры этого класса типов, создавая черты как объекты. Классы типов реализованы в Scala с использованием имплицитов. Если вы не знакомы с концепцией, вот что я написал о них некоторое время назад.


// instance for Int
implicit object IntRead extends Read[Int] {
def read(s: String) = s.toInt
}

// instance for Float
implicit object FloatRead extends Read[Float] {
def read(s: String) = s.toFloat
}


Это очень похоже на то, что вы делаете с экземплярами классов типов в Haskell. Вы даже можете создавать экземпляры для своих собственных абстракций.

case class Name(last: String, first: String)

object NameDescription {
def unapply(s: String): Option[(String, String)] = {
val a = s.split("/")
Some((a(1), a(0)))
}
}

// instance for Name
import NameDescription._
implicit object NameRead extends Read[Name] {
def read(s: String) = s match {
case NameDescription(l, f) => Name(l, f)
case _ => error("invalid")
}
}


Таким образом, класс типа Read в Scala достаточно универсален, чтобы его можно было создавать для всех видов абстракций. Обратите внимание, что в отличие от интерфейсов в Java, полиморфизм не связан с иерархиями наследования. С интерфейсом ваша абстракция должна реализовывать интерфейс статически, что означает, что интерфейс должен существовать до того, как вы создадите абстракцию. В классах типов абстракции для Int и Float существовали задолго до того, как мы определили класс типов Read.

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

def foo[T : Read](s: String) = implicitly[Read[T]].read(s)

foo[Int]("123") // 123
foo[Float]("123.0") // 123.0
foo[Name]("debasish/ghosh") // Name("ghosh", "debasish")

Хорошо … так что это был наш универсальный API чтения, адаптирующийся к уже существующим абстракциям. В данном случае это именно Scala-вариант поведения экземпляров классов простых типов в Haskell. Авторы Real World Haskell используют термин « открытый мир» для описания этой функции системы классов типов.

Контекст для выбора экземпляра API

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

foo :: Read a => String -> a

Независимо от Haskell или Scala, наш API становится чрезвычайно выразительным благодаря таким ограничениям, которые позволяет нам писать статическая система типов. И все эти ограничения проверяются во время компиляции.

Контекст в реализации конкретных случаев

При определении универсального API вы также можете установить контекст для конкретных экземпляров класса типа. Рассмотрим наш метод чтения для типа данных List в Scala. Haskell определяет экземпляр как ..

instance Read a => Read [a] where ..


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

Мы делаем это в Scala, используя условные импликации как …

implicit def ListRead[A](implicit r: Read[A]) = 
new Read[List[A]] {
def read(s: String) = {
val es = s.split(" ").toList
es.map(r.read(_))
}
}

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

foo[List[Int]]("12 234 45 678") // List(12, 234, 45, 678)
foo[List[Float]]("12.0 234.0 45.0 678.0") // List(12.0, 234.0, 45.0, 678.0)
foo[List[Name]]("debasish/ghosh maulindu/chatterjee nilanjan/das")
// List(Name("ghosh", "debasish"), Name("chatterjee", "maulindu"), Name("das", "nilanjan"))

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

instance Read a => Read [a] where ..
instance Read [Int] where ..


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


В этой статье я рассказал о возможностях, которые вы получаете с помощью общего API-дизайна на основе классов типов. В функциональных языках, таких как Haskell, классы типов являются наиболее эффективным способом реализации расширяемых API для открытого мира. Конечно, в объектно-функциональных языках, таких как Scala, у вас также есть возможность подтипирования, что хорошо во многих обстоятельствах. Будет интересно составить сравнительный анализ ситуаций, когда мы предпочитаем одно другому. Но это на другой день, другой пост …

От http://debasishg.blogspot.com/2010/09/towards-generic-apis-for-open-world.html