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

Типы полиморфизма

Начнем с параметрического полиморфизма. Скажем, у нас есть список предметов; это может быть список целых чисел, двойников, строк и т. д. Теперь рассмотрим метод head (), который возвращает первый элемент из этого списка. Этому методу не важно, относится ли элемент к типу Int, String, Apple или Orange. Его тип возврата - это тот список, которым параметризован, и его реализация одинакова для всех типов: «вернуть первый элемент».

В отличие от параметрического полиморфизма, специальный полиморфизм привязан к типу. В зависимости от типа вызываются разные реализации метода. Перегрузка метода - один из примеров специального полиморфизма. Например, у вас могут быть две версии метода, который добавляет два элемента: одна принимает два целых числа и складывает их, а другая - две строки и объединяет их. Вы знаете, 2 плюс 3 равно 5, а «2» плюс 3 - «23».

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

Хорошо, давайте подробнее рассмотрим специальный полиморфизм и оставим два других на другой раз. Я уже упоминал перегрузку как один из способов достижения специального полиморфизма; каждая «версия» метода будет принимать разные параметры, и при вызове метода будет выбрана правильная реализация в зависимости от типа предоставленных параметров. Но предположим, что у нас другой сценарий - допустим, мы хотим иметь только один метод (мы можем назвать его appendItems ()), и мы хотим, чтобы этот метод принимал два «добавляемых» items. Если они вызываются с целыми числами, они должны быть добавлены с помощью сложения. Если они вызываются со строками, они должны быть добавлены с помощью конкатенации. Мы могли бы также реализовать его для различных других типов, но для нашего примера будет достаточно Integer и String.

Как добавить уток

Итак, мы хотим, чтобы метод appendItems () взял два экземпляра чего-то «добавляемого» и выполнил для них операцию добавления. Мы также хотим, чтобы эта операция имела разные реализации для разных добавляемых элементов. Для целых чисел добавление означает сложение, а для строк - конкатенацию. Это лучший полиморфизм ad-hoc.

Обратите внимание, что у нашего метода должна быть только одна реализация - без перегрузки или переопределения! Итак, как он может выполнять разные операции для разных типов? Идея в том, что appendItems () не должен даже знать, как реализована операция добавления; он должен просто вызвать его. Вот способ:

def appendItems[A](a: A, b: A) = a append b

Это сложная часть - нам нужно каким-то образом сделать операцию добавления, доступную для целых чисел и строк. Как? Что ж, в глазах нашего метода appendItems () они не будут целыми числами и строками. Они будут добавляемыми. По сути, это утиная печать; если он ходит, как утка, и крякает, как утка, для нас это утка. Нам все равно, что это на самом деле, все, что нас волнует, - это то, что он умеет крякать. Это то, что мы будем делать здесь, просто вместо того, чтобы наши ценности могли крякать, мы хотим, чтобы они могли складываться.

Конечно, описанный выше метод не компилируется, поскольку наш общий тип A не имеет метода append (). Так что же нам делать? Я объясню два подхода к решению этой проблемы - неявные преобразования и классы типов.

Неявные преобразования

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

def appendItems[A](a: A, b: A)(implicit ev: A => Appendable[A]) =
  a append b

Этот метод говорит: «Я беру два элемента типа A, и мне также нужно неявное преобразование из A в добавляемый [A], чтобы оно было доступно в рамках». Мы назвали это неявное преобразование «ev» как сокращение от «свидетельство», которое является одним из многих общих терминов для неявного параметра в подобных сценариях (еще один довольно распространенный термин - «свидетель»).

Мы как бы пообещали компилятору, что будет преобразование из A в Appendable [A], доступное в области видимости при вызове appendItems (). Компилятор отвечает: Хорошо, я позволю вам вызвать append () для значения типа A, потому что вы обещали, что в области будет неявное преобразование, и я рассчитываю на это. Если во время компиляции его не будет, компилятор рассердится, потому что мы нарушили наше обещание, и компиляция завершится с ошибкой с сообщением неявное представление недоступно. Если вы не знакомы с тем, где Scala ищет имплициты, какова область поиска и что имеет приоритет перед тем, я настоятельно рекомендую просмотреть некоторые материалы по этому поводу (глава 21 в Программирование на Scala неплохо справляется; вы можете также загляните здесь).

Хорошо, у нас есть метод appendItems (). Перейдем к типу Appendable.

Прежде всего, Appendable будет чертой. Это не может быть обычный класс, потому что append () должен быть абстрактным (он будет реализован отдельно для Integer и String). Это может быть абстрактный класс, но практическое правило - отдавать предпочтение абстрактному классу только в том случае, если для этого есть веская причина (например, нам нужно принять некоторые параметры в конструкторе, некоторый код Java унаследует его, эффективность чрезвычайно высока. важно и т. д.).

Во-вторых, Appendable будет параметризовано - у него будет параметр типа. Почему? Ну, потому что его операция append () (не путать с нашим методом appendItems ()!) Является универсальной; он будет реализован по-разному для разных типов, в нашем случае как добавление для целых чисел и конкатенация для строк. Поскольку метод append () зависит от типа, вся черта зависит от типа. Обратите внимание: если бы он не зависел от типа, то это был бы случай параметрического полиморфизма, но поскольку это так, это случай специального полиморфизма.

Итак, вот наша замечательная черта, зависящая от типа:

trait Appendable[A] {
  def append(a: A): A
}

Теперь, когда мы определили типаж, давайте напишем эти две разные его реализации: одну для Integer, а другую для String. Они здесь:

class AppendableInt(i: Int) extends Appendable[Int] { 
  override def append(a: Int) = i + a
}
class AppendableString(s: String) extends Appendable[String] { 
  override def append(a: String) = s.concat(a) 
}

Метод concat здесь для демонстрации; Я мог бы написать просто (и чаще) s + other, который так же хорошо их объединял бы, но я сознательно хотел, чтобы он отличался от AppendableInt, чтобы подчеркнуть, что реализация специфична для каждого типа.

Все, что осталось сделать, это обещанные нами неявные преобразования:

implicit def toAppendable(i: Int) = new AppendableInt(i)
implicit def toAppendable(s: String) = new AppendableString(s)

Это было легко, не правда ли? Теперь мы можем передавать чистые целые числа и строки в appendItems (), и все будет печатать check. Вот полный код, который вы можете скопировать / вставить и попробовать:

trait Appendable[A] {
  def append(a: A): A
}

class AppendableInt(i: Int) extends Appendable[Int] {
  override def append(a: Int) = i + a
}

class AppendableString(s: String) extends Appendable[String] {
  override def append(a: String) = s.concat(a)
}

implicit def toAppendable(i: Int) = new AppendableInt(i)
implicit def toAppendable(s: String) = new AppendableString(s)

def appendItems[A](a: A, b: A)(implicit ev: A => Appendable[A]) = 
  a append b

val res1 = appendItems(2, 3) // res1 is an Int with value 5
val res2 = appendItems("2", "3") // res2 is a String with value "23"

Краткий обзор того, что мы сделали: у нас есть метод appendItems (), который принимает два элемента типа A и добавляет их с помощью append (). Даже если мы ожидаем, что A будет Int или String (которые ничего не знают о методе append ()), все будет в порядке, потому что мы предоставили неявное преобразование в качестве доказательства. Мы «пообещали» компилятору, что A будет преобразован в Appendable. Мы сдерживаем свое обещание, определяя два метода преобразования, называемых toAppendable, один из Int в Appendable [Int], а другой из String в Appendable [String]. Компилятор рад видеть, что мы действительно предоставили appendItems () с неявным преобразованием, поэтому все работает отлично, и мы можем добавить наши базовые типы Int и String с помощью всего одного метода, даже если они не расширяют никаких общих черт. .

Типовые классы

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

Классы типов - это концепция, восходящая к Haskell. Самый простой способ описать их в терминах того, что мы узнали до сих пор, - это то, что вместо неявного преобразования в классы AppendableInt и AppendableString мы сделаем сами эти классы неявный. Это означает, что нам нужно немного изменить наш метод appendItems (). Для добавления по-прежнему требуется два элемента, но вместо неявного преобразования теперь требуется неявный экземпляр класса:

def appendItems[A](a: A, b: A)(implicit ev: Appendable[A]) = 
  a append b

В отличие от Haskell, классы типов не являются существующей структурой в Scala, и их необходимо моделировать (аналогично монадам в моем предыдущем сообщении в блоге). Обычно мы делаем это, объединяя признак, который определяет интерфейс класса типа и реализации этого интерфейса для различных конкретных типов. Позвольте мне представить вам две дюжины строк кода, которые демонстрируют эту концепцию во всей красе:

trait Appendable[A] {
  def append(a: A, b: A): A
}
object Appendable {
  implicit val appendableInt = new Appendable[Int] {
    override def append(a: Int, b: Int) = a + b
  }
  implicit val appendableString = new Appendable[String] {
    override def append(a: String, b: String) = a.concat(b)
  }
}
def appendItems[A](a: A, b: A)(implicit ev: Appendable[A]) =
  ev.append(a, b)
val res1 = appendItems(2, 3) // returns 5
val res2 = appendItems("2", "3") // returns "23"

Видеть? У нас есть типаж Appendable [A] и два экземпляра Appendable: один для Int и один для String.

Одна из замечательных особенностей классов типов заключается в том, что библиотеки легко расширять, не затрагивая существующий код. Например, если вы хотите поддерживать некоторые типы, отличные от Int и String, вам просто нужно предоставить неявные значения для этих типов. Вы можете импортировать их откуда-нибудь (как мы это сделали в первой строке TypeClassTest) или определить их самостоятельно. Таким образом, вы можете не только предоставить реализации для некоторых новых типов, но и переопределить реализации для существующих типов, например для добавления целых чисел с помощью умножения вместо сложения.

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

Вот пример переопределения реализации для существующего типа Int:

trait Appendable[A] {
  def append(a: A, b: A): A
}

object Appendable {
  implicit val appendableInt = new Appendable[Int] {
    override def append(a: Int, b: Int) = a + b
  }
  implicit val appendableString = new Appendable[String] {
    override def append(a: String, b: String) = a.concat(b)
  }
}

implicit val appendableInt2 = new Appendable[Int] {
  override def append(a: Int, b: Int) = a * b
}

def appendItems[A](a: A, b: A)(implicit ev: Appendable[A]) =
  ev.append(a, b)

val res1 = appendItems(2, 3) // returns 6

Имейте в виду, что имплицит здесь, чтобы облегчить вашу жизнь. Все это можно было бы сделать и без них - например, мы могли бы опустить ключевое слово «implicit» из appendableInt2 и передать этот объект явно как appendItems (2, 3) (appendableInt2). Использование имплицитов упрощает задачу, если вы используете их разумно (наличие множества имплицитов повсюду было бы довольно беспорядочным, и было бы трудно отслеживать, какой именно имплицит вызывается в каком месте).

Итак, подведем итог:

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

Границы просмотра и контекста

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

Начнем с границ просмотра. Вот как мы могли бы переписать наш метод appendItems () в сценарии неявных преобразований:

// def appendItems[A](a: A, b: A)(implicit ev: A => Appendable[A]) = 
//   a append b
def appendItems[A <% Appendable[A]](a: A, b: A) = a append b

Видите это [A ‹% Appendable [A]]? Это точка зрения. Это интерпретируется как «метод appendItems () параметризован с помощью A и требует, чтобы в области видимости было неявное преобразование из A в Appendable [A]». Это то же поведение, что и раньше, только другой синтаксис.

Теперь контекстная привязка. Вот как мы могли бы переписать наш метод appendItems () в сценарии класса типов:

// def appendItems[A](a: A, b: A)(implicit t: Appendable[A]) =
//   t.append(a, b)
def appendItems[A : Appendable](a: A, b: A) =
  implicitly[Appendable[A]].append(a, b)

На этот раз мы параметризовали с помощью [A: Appendable], который интерпретируется как «метод foo () параметризован с помощью A и требует, чтобы в области видимости было неявное значение Appendable [A]». Опять же, то же поведение, что и в примере с классом исходного типа, только другой синтаксис. Обратите внимание, что у нас есть один дополнительный бит нового синтаксиса в теле метода - ключевое слово implicitly. Он используется для ссылки на предоставленный экземпляр Appendable [A] (поскольку у нас больше нет «ev»).

Обратите внимание, что конструкция, связанная с контекстом, не связана строго только с классами типов. Он просто требует, чтобы для некоторого типа A объект или значение типа Appendable [A] были неявно доступны в точке вызова. Неважно, есть ли у нас какая-то общая параметризованная черта или нет. Итак, контекстные границы могут быть полезны при работе с классами типов, но вы не должны рассматривать их как сахар синтаксиса класса типов; они немного более общие и не привязаны только к шаблону класса.

Большой вопрос - кто победит?

Собственно, я уже испортил ответ на этот вопрос, упомянув, что классы типов предоставляют нам больше гибкости и мощности. Если вы уделили достаточно внимания, то, возможно, уже сами это поняли (подсказка: класс типа может иметь * несколько * реализаций для одного и того же типа). Но давайте подкрепим это реальным примером.

Допустим, мы хотим отсортировать последовательность целых чисел. Мы могли бы использовать метод sorted () из стандартной библиотеки Scala:

def sorted[B >: A](implicit ord: math.Ordering[B]): Seq[A]

Заказ - это типовой класс; вы можете сказать из документации:

Сопутствующий объект Ordering определяет множество неявных объектов для работы с подтипами AnyVal (например, Int, Double), String и другими.

Видеть? Его сопутствующий объект определяет неявные объекты для работы с различными типами, точно так же, как наш объект Appendable имел неявные объекты AppendableInt и AppendableString.

К настоящему времени мы узнали, что sorted () пытается сказать своей подписью. В нем говорится: «Когда меня вызывают для последовательности элементов типа A, я сортирую эту последовательность, но для того, чтобы знать, как сравнивать элементы типа A, должен быть неявным упорядочиванием в области видимости ». Кстати, обратите внимание, что порядок должен быть параметризован с помощью A или некоторого супертипа A. Это имеет смысл; если Integer является числом, и я знаю, как сортировать числа, то я знаю, как сортировать числа. Но это не имеет значения, просто хотел прояснить это.

Итак, метод sorted () не всегда выглядел так. Черта упорядочивания (мы также можем назвать ее упорядочивание классов типов) - это новый хипстерский пацан на блоке; до этого улицы принадлежали к признаку Упорядоченность. Это была простая и ясная черта; ваш класс расширяет его, реализует свой метод compare (), и его можно отсортировать. Что случилось с этим? Ничего, пока вы не достигнете точки, в которой вы хотите поддерживать более одного критерия сортировки (например, у вас есть строки, содержащие только числа, и вы хотите иметь возможность выбирать, сортировать ли их в алфавитно-цифровом порядке, чтобы 11 стояло перед 2 или, естественно, так что 2 стоит перед 11).

Классы типов, как мы обсуждали ранее, предоставляют нам такую ​​гибкость. Поэтому вместо того, чтобы мой класс расширял черту Ordered и предоставлял только один жестко заданный критерий для сортировки, с чертой Ordering ему не нужно ничего расширять. Вместо этого, когда я буду выполнять сортировку, я предоставлю порядок, который соответствует моим потребностям - я импортирую буквенно-цифровое упорядочение, естественное или какое-то третье. Черт, может, я напишу свой и предоставлю это. Об этом даже сказано в документации для заказа (выделено мной жирным шрифтом):

[Ordering] и scala.math.Ordered предоставляют одни и те же функции, но по-разному. Типу T можно дать единственный способ упорядочить себя, расширив Ordered. Используя сортировку, этот же тип можно отсортировать многими другими способами. И Ordered, и Ordering обеспечивают неявные значения, позволяющие использовать их взаимозаменяемо.

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

Границы контекста - это, по сути, замена границ просмотра. Это то, что мы должны были сделать с самого начала, но тогда мы не знали лучшего ».

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

Заключение

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

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

Что касается синтаксиса, привязанного к контексту, использование вместо него неявных параметров (как мы использовали «ev» в нашем примере) полностью допустимо. Какой стиль предпочесть - дело личного вкуса и существующих в вашей команде правил кодирования.

Прежде чем мы попрощаемся, позвольте мне заявить для протокола, что бывают ситуации, когда классы типов являются лишь одним из возможных решений. Например, классы типов отлично подходят, если вы хотите иметь общий код, который работает для многих различных типов, не имеющих общего базового класса. Но если у вас есть типы, которые имеют один и тот же базовый класс (например, Apple, Banana и Cherry все расширяют Fruit), вы можете, например, переопределить общий метод Fruit в каждом экземпляре фруктов, чтобы все яблоки, бананы и т. д. имели разные реализации одного и того же метода. Однако я должен быть честным и сказать, что я не большой поклонник подтипов, поэтому даже в этом случае я бы выбрал типовой класс. Подтипов даже нет в чистых языках программирования FP, таких как Haskell. А в Scala в одних ситуациях это сбивает с толку компилятор, а в других - пользователя. Просто попробуйте иметь два класса Sub и Sup, где Sub расширяет Sup, иметь метод, который принимает неявный параметр типа Sup и имеет неявные значения для Sub и Sup, доступных в области видимости; как вы думаете, что происходит? Ошибка неоднозначных неявных значений? Неа. Sup используется, т. К. Нужен Sup? Неа. Что происходит, так это то, что используется Sub, потому что компилятор видит их достаточно разными, чтобы не было двусмысленности, но достаточно равными, чтобы решить, что Sub на самом деле не только совершенно действительный Sup, но и даже «лучший», поскольку он более конкретен. Теперь вы видите проблему с подтипами. Основное правило наследования ООП - это правило «является», поэтому Apple является Fruit, но с точки зрения системы типов это не так. Тип Apple - это тип Apple, тип Fruit - это тип Fruit. Это причина, по которой расширение классов case устарело (возможно, даже больше невозможно, мне нужно будет проверить). Потому что как вы относитесь к яблоку («красное») и к фруктам («красное»)? Правда или ложь? Хорошо, я слишком отвлекся, возможно, мне стоит написать отдельный пост в блоге на эту тему. Но да, всякий раз, когда кто-то говорит, что вы можете просто использовать подтипы, не стесняйтесь говорить: «Я бы предпочел придерживаться истинных концепций FP».

Вот и все. Не стесняйтесь обращаться ко мне по адресу [email protected] с любыми отзывами, хорошими или плохими, а также подписывайтесь на меня в Twitter.