Тайпклассы и горечь полуроскошного существования

топ 100 блогов lex_kravetski20.10.2017 По дальнейшему тексту кому-то может показаться, будто тайпклассы — это совсем какая-то убогая херня. Но они нет, не она.

В конце концов, ведь, благодаря тайпклассам, в Scala можно реализовать конструкцию типа


50 times println("Тайпклассы не херня")




А это уже многого стоит.

Однако в общем и целом не всё с ними в порядке. Не всё.

Да, сейчас они — штука модная. Ассоциирующаяся со свежей струёй. Романтичненькая. Да и звучит красиво: «тайпклассы». Лет двадцать также красиво звучал «объектно-ориентированный подход», а до того — «арифметика указателей», но потом все как-то привыкли, и сейчас сами по себе эти термины уже экстаза не вызывают.

Ну да ладно. Для начала, что такое вообще «тайпкласс» («type class» или иногда слитно: «typeclass»).

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

Чем вам, например, шаблоны в C++ и дженерики в Java не «классы типов»? Ну или какие-то фокусы с рефлексией чем не «классы типов»? Или псевдонимы типов чем вам не «классы типов»? Или наследование классов? Про каждую из этих областей вполне можно обосновать, что это совершенно справедливо должно называться «классом типов».

А разгадка проста: каким-то людям показалось, что налёт элитарности у программирования начал пропадать, что очень обидно. Раньше только отдельные особо одарённые умели общаться с этой железякой, к ним на приём очереди стояли. Даже академики взирали на них, как на Прометея, который только что выкрал огонь у богов. А сейчас любой студент за две недели смотрит какой-нибудь тьюториал, а потом идёт говнокодить на PHP…

Чтобы это исправить, надо назвать относительно понятные вещи непонятными словами. Например, операции обработки коллекций — «монадными», а интерфейс с конечным количеством реализаций — «алгебраическим типом».

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

При работе GPS правда учитываются релятивистские поправки, но к вашей деятельности за рулём это не имеет никакого отношения.

Ну и вот ещё тайпклассы. Да. Тайпклассы. Блин, я могу повторять это вечно, настолько красиво это звучит.

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

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

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


trait Comparable[T] {
 def compare(other: T)
}

def sort[T <: Comparable[T]](list: List[T]) = 




Однако тут есть два минуса.

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

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

То есть внутри класса «Собака», который теперь тоже Comparable, у нас почему-то оказываются методы, связанные с сортировкой собак в очереди на прививку у ветеринара, и что-то ещё, относящееся к формату хранения данных об этих собаках в журнале «Собаководы Удмуртии».

Всё это подводит нас к мысли, что тут что-то не так с подходом: довольно неплохо иметь внутри класса «Собака» сведения о том, как она бегает и лает, но вот про то, как именно этих собак кто-то будет сортировать, лучше бы написать где-то извне, дабы оно нам глаза не мозолило.

Ну ОК, давайте тогда поменяем концепцию. Пусть функция sort сортирует что угодно, но требует, чтобы ей напрямую сообщили, как это дело сортировать.


def sort[T](list: List[T], comparator: (T, T) => Int) = 




Вот теперь всё зашибись, проблема решена, все счастливы и расходятся пьянствовать.

Единственная неприятность тут только в том, что стало чуть более многословно: теперь надо везде передавать эту функцию — даже если она везде одна и та же.

Что, впрочем, решается просто созданием ещё одой функции.

Точнее, решалось бы, если бы у нас на месте «T» был бы какой-то конкретный класс.

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

Но всё равно это ещё можно было бы как-то разрулить. В этом случае. А случаи бывают разные.

Например, какой-то хороший человек реализовал для нас чудесную иерархию.


 trait Animal {
  def say: String
 }

 class Dog extends Animal {
  override def say = "Гафф"
 }

 class Cat extends Animal {
  override def say = "Погладь котика, сука"
 }




И мы даже можем этим красиво пользоваться.


println( (new Dog).say ) // Гафф
println( (new Cat).say ) // Погладь котика, сука




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

Внутри объектной парадигмы мы могли бы вывернуться: сделать дополнительный интерфейс Named и реализовать расширенного кота и собаку, которые знают, кто они такие.


trait Named {
 def name: String
}

class DogEx extends Dog with Named {
 override def name = "Это ж пёс"
}

class CatEx extends Cat with Named {
 override def name = "Сто пудов, кот"
}




Но в оставшейся в наследство от получившего наследство слегка плохого хорошего человека библиотеке везде создаются прежние коты и собаки. Причём он их везде создаёт тупо в лоб — через new, а не через фабрику, — поэтому все эти наши расширенные коты и собаки водятся только там, где весь код написали мы сами. Абыдна, да?

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


object WhoIsThis {
 def whoIs(dog: Dog) = "Это ж пёс"

 def whoIs(cat: Cat) = "Сто пудов, кот"

 def whoIs(animal: Animal) = "Неведома зверушка"
}




Тут у кого-то может возникнуть вопрос: а зачем нам хуис от Animal? Ведь это — интерфейс: мы даже экземпляр-то создать не сможем.

Отвечаю: на всякий случай. И этот случай скоро внезапно настанет.


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

Вот сравните.


val cat = new CatEx

println(WhoIsThis.whoIs(cat))

println(cat.name)




Сразу ведь понятно, что первый вариант хуже второго.

И тут под барабанную дробь на сцену выходят тайпклассы.

Для начала они выходят с функцией сортировки наперевес. И повергают всех в недоумение. Ват зе фак из гоин он?


trait Comparator[T] {
 def compare(first: T, second: T): Int
}

def sort[T](list: List[T])(implicit comparator: Comparator[T]) = …

implicit val stringComparator = new Comparator[String] {
 override def compare(first: String, second: String) = …
}

sort( List("AAA", "BBB") )




Зе фак тут состоит вот в чём. Сначала мы заявили, что бывают компараторы, которые умеют сравнивать экземпляры произвольного класса T.

Потом мы сказали, что у нас есть функция sort, которая умеет сортировать списки с элементами произвольного класса T, но ей, чтобы работать, кроме списка, нужен компаратор для класса T, у которого она, видимо, будет спрашивать, какой элемент больше другого.

Причём, такой компаратор ей можно дать напрямую, но, в принципе, если мы сами компаратора не дали, но где-то в области видимости есть нужный компаратор, помеченный кодовым словом «implicit» (причём единственный такой), то сойдёт и он.

Потом мы говорим: вот вам такой компаратор для класса String.

Потом мы говорим: а ну-ка отсортируй нам список строк. И всё, такое, бабах и работает. Поскольку концы с концами отлично сошлись. И последняя строка коротенечко так выглядит-то. А ведь именно она будет писаться нами чаще всего.

Но и это ещё не всё. Вернёмся к нашим баранам. Хотя, нет, баранов у нас не было — к котам и собакам.


implicit class NamedDog(dog: Dog) {
 def name = "Это ж пёс"
}

implicit class NamedCat(cat: Cat) {
 def name = "Сто пудов, кот"
}

implicit class NamedAnimal(animal: Animal) {
 def name = "Неведома зверушка"
}

val dog = new Dog
println(dog.name) // Это ж пёс




Практически тот же фокус. Мы говорим, что у нас есть три специальных класса, экземпляры которых можно, если очень надо, создавать незаметно для нас. А очень надо нам в том случае, когда…


  1. У некоторого экземпляра класса вызывается метод, которого у этого класса нет.
  2. В области видимости лежит implicit класс, который в конструктор принимает экземпляр того класса, у которого нет нужного метода.
  3. У этого implicit класса такой метод есть.
  4. implicit класс, удовлетворяющий этим условиям, в области видимости единственный.


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

Типа, вуаля, мы теперь как бы можем приделывать к классам методы со стороны. И показывать с двух рук факи в сторону Баден-Бадена.

Во всяком случае, оно выглядит так, будто можем.

А на самом деле, хрен там.

Потому что реальность сурова и неприглядна.


val dog = new Dog
val cat = new Cat

println( dog.say ) // Гафф
println( cat.say ) // Погладь котика, сука
println( dog.name ) // Это ж пёс
println( cat.name ) // Сто пудов, кот




Не, ну зашибись же? Что не нравится-то?


List(dog, cat) map (_.say) foreach println // Гафф; Погладь котика, сука
List(dog, cat) map (_.name) foreach println // Неведома зверушка; Неведома зверушка




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

И даже гаффкало из списка оно как надо! Почему, вселенная, ты к нам так несправедлива?

А штука вот в чём. Поскольку в список попали экземпляры двух разных классов, он для определения себя нашёл их ближайший общий тип — Animal. Именно этого класса экземпляры с его точки зрения в нём лежат.

Однако методы классов — виртуальные. Поэтому say вызывается правильный, даже в том случае, когда сама ссылка считается ссылкой на экземпляр класса Animal.

А вот с тайпклассами не так. Если ссылка на Animal, то и искать надо такой implicit, который в конструктор принимает Animal. А то, что по ссылке, на самом деле, кошка или собака, это циничного компилятора вообще не волнует. Поэтому подождите факать в Баден-Баден — нифига это у нас не «методы класса, определённые извне».

Можно ли с этим что-то сделать? О да, можно.


implicit class NamedAnimal(animal: Animal) {
 def name = animal match {
  case _: Cat => "Сто пудов, кот"
  case _: Dog => "Это ж пёс"
  case _ => "Неведома зверушка"
 }
}




Вот теперь всё сработает правильно.

Однако остаётся какой-то осадочек: вроде бы нужный метод доопределили извне, но как-то оно коряво смотрится в виде цепочки match-case. Пока зверюг только две, ещё ничего так, но вот что если их двадцать будет?

Надо как-то поупорядоченнее что ли.

И тут мы приходим к гениальной в своём идиотизме конструкции.


implicit class NamedAnimal(animal: Animal) {
 def whoIs(dog: Dog) = "Это ж пёс"

 def whoIs(cat: Cat) = "Сто пудов, кот"

 def name = animal match {
  case dog: Dog => whoIs(dog)
  case cat: Cat => whoIs(cat)
  case _ => "Неведома зверушка"
 }
}




Хуисы можно было бы рассовать по разным классам или объектам, но суть не в этом. Суть — в чарующе тавтологическом идиотизме метода name, который будет особенно заметен при паре сотен животных. Это, блин, как молитва Капитана Очевидность! Кот — это кот. Собака — это собака. А пингвин — это пингвин.

И так для каждого дополнительного метода, который мы типа хотели бы «определить снаружи класса». Сто пудов, тут есть, чем гордиться.

Практически закат солнца вручную.

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

Правда, в языках, где диспетчеризация делается в рантайме, а потому возможен параметрический полиморфизм (то есть выбор вызываемой функции по фактическому содержимому ссылки во время работы программы, а не по классу ссылки, известному на момент компиляции), — Python, Wolfram и т.п. — подобного пирдуха бы не было, но вот в Scala оно, увы, так.

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

Дело в том, что если мы в Animal заявили наличие метода say, то забыть про него в наследниках мы никак не сможем. Компилятор нам сразу скажет: «эй-эй, так несчитово! Реализуйте этот метод или признайте свои труды абстрактным искусством».

Однако наличие методов в каких-то там тайпклассах компилятор в общем случае не проверяет. Если вы добавили класс Пингвин, но не вызывали у экземпляра этого класса name, то всё отлично, даже если тайпкласса с этим методом для пингвинов не существует. Всё ОК, продолжайте в том же духе. Сдавайте библиотеку на Гитхаб и валите в Баден-Баден — вот благодарные пользователи-то обрадуются, когда обнаружат, что хуискать пингвинов они должны своими силами.

Ну а в варианте с match-case, к которому сподвигает Scala, можно ещё и пингвиновый хуис завести, но вот про тавтологический case для пингвинов забыть.

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

Однако в другом целом ряде случаев, напротив, без тайпклассов было бы очень тоскливо.

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

Для разнообразия воспользуемся тут методом, аналогичным тому, который был в реализации sort. Тем более, что с тайпклассами обычно гораздо теснее ассоциируется именно он.


trait Named[T] {
 def name: String
}

implicit object NamedDog extends Named[Dog] {
 def name = "пёс"
}

implicit object NamedCat extends Named[Cat] {
 def name = "кот"
}

def makePair[A : Named, B : Named](x: A, y: B) = {
 val a = implicitly[Named[A]]
 val b = implicitly[Named[B]]
 s"В этой паре у нас ${a.name} и ${b.name}."
}

val dog = new Dog
val cat = new Cat
println(makePair(dog, cat)) // В этой паре у нас пёс и кот.




В методе makePair мы в данном случае наложили на типы A и B ограничение: «такие, что для них существует implicit экземпляр, являющийся наследником Named[A] и Named[B], соответственно».

Поскольку в области видимости для собаки и кошки такие действительно есть, makePair сработает. Но что нам это вообще даёт? Где тут фокус?

Фокус тут в том, что мы при помощи данного подхода радикально снизили количество текста.

Да, можно было бы просто определить makePair так, чтобы она принимала две строки, а сами строки получать, вызывая name напрямую.

Однако


makePair(dog, cat)




всё таки смотрится красивее, чем


makePair(name(dog), name(cat))




А это ведь — простейший случай. В более сложных количество вызываемых по цепочке функций может оказаться существенно больше.

Можно подумать, что «да и ладно» — мы б просто написали ещё одну функцию, внутри которой для аргументов вызывался бы name, и в результате всё записывалось бы так же красиво.

Но фиг там. Нам бы не хватило одной функции.

Ведь даже при наличии только кошек и собак уже есть четыре варианта: пёс и пёс, пёс и кот, кот и пёс, кот и кот. А это всё — разные классы. Которые в общем случае могут даже не иметь общего предка. Поэтому придётся писать вот такую байду.


def makePair(x: String, y: String): String = s"В этой паре у нас ${x} и ${y}."

def makePair(x: Dog, y: Dog): String = makePair(NamedDog.name, NamedDog.name)
def makePair(x: Dog, y: Cat): String = makePair(NamedDog.name, NamedCat.name)
def makePair(x: Cat, y: Dog): String = makePair(NamedCat.name, NamedDog.name)
def makePair(x: Cat, y: Cat): String = makePair(NamedCat.name, NamedCat.name)




Единственный же альтернативный способ реализовать всё это в один приём — как и раньше городить match-case, в который включены вообще все случаи жизни.


def makePair(x: Animal, y: Animal): String = s"В этой паре у нас ${name(x)} и ${name(y)}."

def name(x: Animal) = x match {
 case _: Dog => NamedDog.name
 case _: Cat => NamedCat.name
 case _ => "Неведома зверушка"
}




Однако это, во-первых, как уже раньше говорилось, довольно криво, а во-вторых, неявно подразумевает, что мы не предполагаем, добавления к программе ещё одного класса, экземпляры которого тоже имеют своё собственное имя — ведь для добавления имени этого класса придётся менять тело данной функции. А это ещё надо догадаться сделать. Да и сам объект, её содержащий, вообще может лежать в библиотеке — что ж теперь, каждому пользователю её пересобирать, что ли?

Наконец, третий вариант — городить огород с регистрациями связей имён и классов в какой-то хэшмапе, откуда они потом будут извлекаться, и о коей надо будет предупредить пользователя, а он не прочитает, или мы сами забудем там что-то зарегистрировать…

В общем, шах и мат, аметисты.

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

Иными словами, вместо роскоши — полуроскошь, но без неё было бы ещё хуже.

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

Дело в том, что эта самая строка


50 times println("Тайпклассы не херня")




по правилам Scala идентична вот такой строке


50.times(println("Тайпклассы не херня"))




То есть вызову метода у экземпляра класса.

Однако, кто у нас тут экземпляр? Внезапно им оказывается число 50. Это число имеет тип Int, который, вообще говоря, примитивный тип, а не класс.

Но даже если бы это была используемая в Java объектная обёртка для примитивного типа — Integer, то и это бы нам не помогло: ведь у неё явно нет метода «times».

К счастью, при наличии механизма implicit нам это слегка пох. Нет метода — так мы добавим. Не трогая при этом исходники стандартных библиотек и компилятор.


implicit class Times(n: Int) {
 def times(f: => Unit) = for(_ <- 1 to n) f
}




Фокус аналогичен предыдущим: мы завели implicit-класс, у которого есть конструктор от типа Int. Поэтому, если этот класс находится в области видимости, то у всех Интов появляется метод times, который берёт некую функцию f и повторяет её столько раз, сколько в написано в данном Инте.

Для переживающих по поводу того, что «оно будет медленнее работать, ведь тут объекты создаются», в Scala запилен особый чит код как раз для таких случаев.


implicit class Times(val n: Int) extends AnyVal {
 def times(f: => Unit) = for(i <- 1 to n) f
}




При таком манёвре — наследовании от AnyVal (то есть от примитивного типа) — экземпляры создаваться не будут, а вместо этого всё будет превращаться в вызов метода напрямую. Аналогичное чему-то, типа такого


times(50, println("Тайпклассы не херня"))




По идее, во всех тех случаях, когда нам implicit-экземпляр нужен только для вызова какого-то метода, а хранить чего-то внутри себя или куда передаваться он не должен, надо использовать именно такой подход. Однако в данной статье я — для краткости — положил на это болт.



doc-файл

Оставить комментарий

Предыдущие записи блогера :
Архив записей в блогах:
Скачалось около 2 Гб , версия изменилась всего лишь на 1.37.32.0 Ждем оналитегов со списком измененных файлов ^^ А пока вот вам секретный скриншотик с DEVа: [ ........................................................................... ] UPD.: Говорят что скачалось только ...
Всезнающий Дмитрий Львович: Юнцы храбрятся по кабакам, хотя их грызет тоска, Но все их крики "Я им задам!" — до первого марш-броска, До первого попадания снаряда в пехотный строй И дружного обладания убитою медсестрой. Юнцам не должно воевать и в армии служить. Солдат пристойней ...
...
Лучшие посты в сообществе picturehistory за 1 марта: Чудо-оружие Рейха: военно-транспортный самолет Me.323 Gigant Вот так мылись наши бабушки! ) В отсеках Холодной войны. Противостояние ВМФ СССР и ВМС США Висло-Одерская операция (21 фото) Нелепый сasus belli ...
Звучит как название третьесортного романа, но это правда. Сначала предыстория. О существовании клещей я узнала перед переездом на юг Германии, весь он - т.наз. "красная зона". До этого природа интересовала меня мало, соответственно, и точек пересечения не было никаких. Накануне, как по ...