Scala: абстрактные классы с анонимными типами

Я читаю "Scala for the Impatient", и в 8.8 они говорят:

[..] вы можете использовать ключевое слово abstract для обозначения класса, который не может быть создан [..]

abstract class Person { val id: Int ; var name: String }

И несколько строк позже:

Вы всегда можете настроить абстрактное поле с помощью анонимного типа:

val fred = new Person {

  val id = 1729

  var name = "Fred"

}

Итак, они искусственно создали экземпляр класса Person с анонимным типом. В каких ситуациях в реальном мире это хотелось бы сделать?

Ответ 1

Немного подумав о моем собственном ответе, я пришел к выводу, что все, что он говорит, по существу справедливо:

"Анонимные локальные экземпляры классов - это плохие литералы функции человека"

Предложил награду +150 за ответ, который помогает расширить это узкое зрение.


TL; DR

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


обзор

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

  1. Простой пример с Runnable
  2. Простой пример с построением 2d-функций
  3. Исторически важный пример Function<X, Y>
  4. Продвинутый пример реального мира, когда создание анонимных локальных классов кажется неизбежным
  5. Краткое описание кода, который вы использовали для представления своего вопроса.

Отказ от ответственности: некоторый код не является идиоматическим, потому что он "изобретает колесо" и не скрывает создание абстрактных локальных классов в lambdas или SingleAbstractMethod -syntax.


Простой вводный пример: Runnable

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

def repeat(numTimes: Int, whatToDo: <someCleverType>): Unit = ???

Предполагая, что вы хотите изобретать все с нуля и не хотите использовать какие-либо параметры имени или интерфейсы из стандартных библиотек, что вы помещаете вместо <someCleverType>? Вы должны были бы предоставить свой базовый класс, который выглядит примерно так:

abstract class MyRunnable {
  def run(): Unit  // abstract method
}

Теперь вы можете реализовать свой метод repeat следующим образом:

def repeat(numTimes: Int, r: MyRunnable): Unit = {
  for (i <- 1 to numTimes) {
    r.run()
  }
}

Теперь предположим, что вы хотите использовать этот метод для печати "Hello, world!". десять раз. Как вы создаете правильный MyRunnable? Вы можете определить класс HelloWorld который расширяет MyRunnable и реализует метод run, но он будет только загрязнять пространство имен, потому что вы хотите использовать его только один раз. Вместо этого вы можете создать экземпляр анонимного класса напрямую:

val helloWorld = new MyRunnable {
  def run(): Unit = println("Hello, world!")
}

а затем передать его, чтобы repeat:

repeat(10, helloWorld)

Вы даже можете опустить переменную helloWorld:

repeat(10, new MyRunnable {
  def run(): Unit = println("Hello, world!")
})

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


Чуть более интересный пример: RealFunction

В предыдущем примере run принимал аргументов, он выполнял один и тот же код каждый раз.

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

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

plot(f: RealFunction): Unit = ???

который отображает график действительной функции R → R, где RealFunction является абстрактным классом, определенным как

abstract class RealFunction {
  def apply(x: Double): Double
}

Чтобы построить параболу, вы можете теперь сделать следующее:

val xSquare = new RealFunction {
  def apply(x: Double): Double = x * x
}

plot(xSquare)

Вы можете даже протестировать его отдельно без plot: например, p(42) вычисляет 1764.0, что является квадратом 42.


Общие функции Function[X, Y]

Предыдущий пример обобщает на произвольные функции, которые могут иметь типы X и Y как домен и codomain. Это, пожалуй, самый важный пример с исторической точки зрения. Рассмотрим следующий абстрактный класс:

abstract class Function[X, Y] {
  def apply(x: X): Y // abstract method
}

Он похож на RealFunction, но вместо фиксированного Double вы теперь имеете X и Y

С учетом этого интерфейса вы можете повторно создать функцию xSquare следующим образом:

val xSquare = new Function[Double, Double] {
  def apply(x: Double) = x * x
}

Действительно, этот пример настолько важен, что стандартная библиотека Scala заполнена такими интерфейсами FunctionN[X1,...,XN, Y] для различного количества аргументов N

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

val xSquare = (x: Double) => x * x

вместо

val xSquare = new Function[Double, Double] {
  def apply(x: Double) = x * x
}

Аналогичная ситуация наблюдается на других языках JVM. Например, даже Java-версия 8 ввела в java.util.function множество очень похожих интерфейсов. Несколько лет назад вы бы написали что-то вроде

Function<Integer, Integer> f = new Function<Integer, Integer>() {
  public Integer apply(Integer x) {
    return x * x;
  }
};

в Java, потому что пока не было лямбда, и каждый раз, когда вы хотели передать какой-либо обратный вызов или Runnable или Function, вам пришлось реализовать анонимный класс, который расширяет абстрактный класс. В настоящее время в новых версиях Java он скрыт lambdas и SingleAbstractMethod -syntax, но принцип все тот же: построение экземпляров анонимных классов, реализующих интерфейс, или расширение абстрактного класса.


Продвинутый "почти реальный мир" -example

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

new AbstractClassName(){ } -syntax все еще появляется там, где нет синтаксического сахара. Например, поскольку Scala не имеет синтаксиса для полиморфных лямбда, для построения естественного преобразования в библиотеке, такой как Scalaz или Cats, вы обычно пишете что-то вроде:

val nat = new (Foo ~> Bar) {
  def apply[X](x: Foo[X]): Bar[X] = ???
}

Здесь Foo и Bar будут чем-то вроде встроенных доменных языков, которые работают на разных уровнях абстракции, а Foo более высокоуровневый, тогда как Bar более низкоуровневый. Это точно такой же принцип снова, и такие примеры повсюду. Вот почти " (KVStoreA ~> Id) " пример использования в реальном мире: определение (KVStoreA ~> Id) -interpreter. Я надеюсь, что вы сможете узнать new (KVStoreA ~> Id) { def apply(...)... } часть там. К сожалению, пример довольно продвинутый, но, как я упоминал в комментариях, все простые и часто используемые примеры были в основном скрыты синтаксисом lambdas и Single-Abstract-Method за последнее десятилетие.


Вернуться к вашему примеру

Код, который вы указали

abstract class Person(val name: String) {
  def id: Int
}

val fred = new Person {
  val id = 1729
  var name = "Fred"
}

похоже, не компилируется, поскольку аргумент конструктора отсутствует.

Я предполагаю, что автор хотел продемонстрировать, что вы можете переопределить def val:

trait P {
  def name: String
}

val inst = new P {
  val name = "Fred"
}

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

Ответ 2

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


Вот несколько примеров, где можно было бы переопределить несколько методов в абстрактном локальном классе и где переопределенные методы тесно связаны друг с другом, поэтому их разделение практически не имеет смысла. Я действительно пытался придумать "неприводимые" примеры, где нет пути определения нескольких когерентных методов.

Графоподобные структуры данных

Рассмотрим направленные графы, определяемые:

  • Набор узлов
  • Набор ребер
  • Функция source от краев к узлам
  • target функция от краев к узлам

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

trait Digraph {
  type E
  type N
  def isNode(n: N): Boolean
  def isEdge(e: E): Boolean
  def source(e: E): N
  def target(e: E): N
}

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

val g = new Digraph {
  type E = (Int, Int)
  type N = Int
  def isNode(n: Int) = n >= 0
  def isEdge(e: (Int, Int)) = e._1 >= 0 && e._2 == e._1 + 1
  def source(e: (Int, Int)) = e._1
  def target(e: (Int, Int)) = e._2
}

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

* for each 'e' in domain of 'source' and 'target', 'isEdge(e)' must hold
* for each 'n' in codomain of 'source' and 'target', 'isNode(n)' must hold

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

Примечание. Если вам нравится общий абстрактный абсурд, вы легко поймете это как особый случай предпучка в крошечной категории с двумя объектами и двумя параллельными стрелками:

   --->
*        *
   --->

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

Элиминаторы для взаимно рекурсивных структур данных

Другой пример: складчатые элиминаторы для сложных взаимно-рекурсивных структур.

Рассмотрим следующий абстрактный синтаксис небольшого языка, который позволяет записывать простые выражения с 2d-векторами и скалярами:

sealed trait VecExpr
case class VecConst(x: Double, y: Double) extends VecExpr
case class VecAdd(v1: VecExpr, v2: VecExpr) extends VecExpr
case class VecSub(v1: VecExpr, v2: VecExpr) extends VecExpr
case class VecMul(v1: VecExpr, a: ScalarExpr) extends VecExpr

sealed trait ScalarExpr
case class ScalarConst(d: Double) extends ScalarExpr
case class DotProduct(v1: VecExpr, v2: VecExpr) extends ScalarExpr

Если мы попытаемся определить интерпретатор, который может оценить такое выражение, мы быстро заметим, что повторение повторяется довольно часто: по существу, мы просто продолжаем называть тот же взаимно-рекурсивный eval -methods, который, похоже, не зависит ни от чего, кроме типы. Мы можем скрыть некоторые из шаблонов, предоставив следующий базовый класс для переводчиков:

trait Evaluator[S, V] {
  def vecConst(x: Double, y: Double): V
  def vecAdd(v1: V, v2: V): V
  def vecSub(v1: V, v2: V): V
  def vecMul(v: V, s: S): V

  def scalarConst(x: Double): S
  def dotProduct(v1: V, v2: V): S

  def eval(v: VecExpr): V = v match {
    case VecConst(x, y) => vecConst(x, y)
    case VecAdd(v1, v2) => vecAdd(eval(v1), eval(v2))
    case VecSub(v1, v2) => vecSub(eval(v1), eval(v2))
    case VecMul(v, s) => vecMul(eval(v), eval(s))
  }

  def eval(s: ScalarExpr): S = s match {
    case ScalarConst(d: Double) => scalarConst(d)
    case DotProduct(v1, v2) => dotProduct(eval(v1), eval(v2))
  }
}

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

val ev = new Evaluator[Double, (Double, Double)] {
  def vecConst(x: Double, y: Double) = (x, y)
  def vecAdd(v1: (Double, Double), v2: (Double, Double)): (Double, Double) = (v1._1 + v2._1, v1._2 + v2._2)
  def vecSub(v1: (Double, Double), v2: (Double, Double)): (Double, Double) = (v1._1 - v2._1, v1._2 - v2._2)
  def vecMul(v: (Double, Double), s: Double): (Double, Double) = (v._1 * s, v._2 * s)

  def scalarConst(x: Double): Double = x
  def dotProduct(v1: (Double, Double), v2: (Double, Double)): Double = v1._1 * v2._1 + v1._2 * v2._2
}

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

val expr = VecSub(
  VecConst(5, 5),
  VecMul(
    VecConst(0, 1),
    DotProduct(
      VecSub(
        VecConst(5, 5),
        VecConst(0, 2)
      ),
      VecConst(0, 1)
    )
  )
)

println(ev.eval(expr))

Это успешно проецирует точку (5,5) на плоскость, проходящую через (0, 2) с нормальным вектором (0, 1), и выдает:

(5.0,2.0)

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


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

Ответ 3

Еще один пример создания экземпляра анонимного типа - это создание признака.

scala> :paste
// Entering paste mode (ctrl-D to finish)

trait ServiceProvider {
  def toString(int: Int): String
  def fromString(string: String): Int
}

val provider = new ServiceProvider {
  override def toString(int: Int) = int.toString
  override def fromString(string: String): Int = string.toInt
}
// Exiting paste mode, now interpreting.

defined trait ServiceProvider
provider: ServiceProvider = [email protected]

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

Эта возможность пригодится, когда приходит на тестирование - она позволяет предоставлять заглушки и подделки без использования сторонних библиотек, таких как Mockito, scalamock и т.д.

Продолжение предыдущего примера

class Converter(provider: ServiceProvider) {
  def convert(string: String): Int = provider.fromString(string)
  def convert(int: Int): String = provider.toString(int)
}

// somewhere in ConverterSpec
// it("should convert between int and string")
val provider = new ServiceProvider {
  override def toString(int: Int) = int.toString
  override def fromString(string: String): Int = string.toInt
}
val converter = new Converter(provider)
converter.convert("42") shouldBe 42
converter.convert(1024) shouldBe "1024"
converter.convert(converter.convert("42")) shouldBe "42"

// it("should propagate downstream exceptions")
val throwingProvider = new ServiceProvider {
  override def toString(int: Int) = throw new RuntimeException("123")
  override def fromString(string: String): Int = throw new RuntimeException("456")
}
val converter = new Converter(throwingProvider)
a[RuntimeException] shouldBe thrownBy { converter.convert(42) }
a[RuntimeException] shouldBe thrownBy { converter.convert("1024") }

Преимущества такого подхода по сравнению с использованием какой-либо надлежащей библиотеки заглушки/макета:

  1. Легко для того чтобы обеспечить терпеливейшие испытания двойные
  2. Несколько проще в использовании - зависит от выбора тестовой двойной библиотеки - огромная разница по сравнению с Mockito, не большая разница по сравнению с scalamock
  3. Несколько более надежные/поддерживаемые тесты - с использованием анонимного экземпляра все абстрактные члены должны быть реализованы + вы получаете компилятор для проверки реализации против абстрактных членов, добавленных в базовый класс/признак, в то время как с заглушками такая помощь недоступна.

Конечно, есть несколько недостатков, например, метод анонимного типа экземпляра не может использоваться для предоставления mocks/spies - т.е. двойников теста, которые позволяют утверждать на сделанные им вызовы.

Ответ 4

Нет никакого реального требования для использования синтаксиса устаревшего анонимного класса. Вы всегда можете создать свой собственный класс, который расширяет Person а затем создайте его один раз, чтобы получить значение fred.

Вы можете рассматривать этот синтаксис как ярлык для создания одного экземпляра одноразового класса без необходимости придумывать имя для класса.

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