«Перегрузка операторов» в Scala

в 13:54, , рубрики: java, operator overloading, scala, Блог компании GolovachCourses, функциональное программирование

Некоторое время назад я анонсировал курс по Scala. Он стартовал и выкладывается на MOOC-платформу UDEMY — «Scala for Java Developers». Больше о курсе вы можете прочитать в конце статьи.

Сейчас я бы хотел представить материал по одной из тем курса — перегрузке операторов в Scala.


Введение

в Scala нет перегрузки операторов, так как нет операторов (как сущностей отличных от методов). Есть методы с символическими (операторными) именами вида '+', '/', '::', '<~' и префиксная/инфиксная/посфиксная форма записи. Однако для удобства далее будет использовать термин оператор.

Infix operators

В Scala методы одного аргумента можно записывать в так называемой инфиксной форме (infix operations). А именно

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

Пример:

object Demo {
  // "normal" notation
  val x0 = I(1).add(I(2))
  // infix notation
  val x1 = I(1) add I(2)
}

case class I(k: Int) {
  def add(that: I): I = I(this.k + that.k)
}

Далее в примерах будет фигурировать case-класс I, который я буду в каждом конкретном случае наделять разными методами. Он сделан case исключительно для краткости кода (автоматически генерируются и инициализируются поля по primary constructor + автоматически генерируется companion object с методом apply с сигнатурой идентичной primary constructor, что позволяет создавать экземпляры через I(k), а не new I(k). Напомню, что I(k) эквивалентно I.apply(k), а метод apply в Scala можно опускать). Класс I представляет собой «обертку» вокруг одного Int и может рассматриваться как прототип для полноценного класса комплексных чисел, полиномов, матриц.

Все становится интереснее, если методу давать «символическое» / «операторное» имя

object Demo {
  // "normal" notation
  val x0 = I(1).+(I(2))
  // infix notation
  val x1 = I(1) + I(2)
}

case class I(k: Int) {
  def +(that: I): I = I(this.k + that.k)
}

JVM (class file format) не поддерживает имена из «операторных символов», потому при компиляции генерируются синтетические имена.

Запустим по классу

class I {
  def +(that: I): I = new I
  def -(that: I): I = new I
  def *(that: I): I = new I
  def /(that: I): I = new I
  def (that: I): I = new I
  def ::(that: I): I = new I
  def ->(that: I): I = new I
  def <~(that: I): I = new I
}

джавовскую рефлексию

import java.lang.reflect.Method;

public class Demo {
    public static void main(String[] args) {
        for (Method m: I.class.getDeclaredMethods()) {
            System.out.println(m);
        }
    }
}

>> public I.$plus(I)
>> public I.$minus(I)
>> public I.$times(I)
>> public I.$div(I)
>> public I.$bslash(I)
>> public I.$colon$colon(I)
>> public I.$minus$greater(I)
>> public I.$less$tilde(I)

Да, из Java методы и видны с такими именами (как в class file)

public class Demo {
    public static void main(String[] args) {
        new I().$plus(new I());
        new I().$minus(new I());
        new I().$times(new I());
        new I().$div(new I());
        new I().$bslash(new I());
        new I().$colon$colon(new I());
        new I().$minus$greater(new I());
        new I().$less$tilde(new I());
    }
}

Вы же помните про прозрачную интеграцию всех компилируемых под JVM языков?

Да вообще половина «синтаксических фокусов» Scala состоит их смеси инфиксной нотации и implicid conversions.

Пример #1:

object Demo {
  for (k <- 1 to 10) {
    println(k)
  }
}

Инфиксная нотация преобразовывается в нормальную

object Demo {
  for (k <- 1.to(10)) {
    println(k)
  }

У Int нет метода 'to', поэтому ищется такое implicid conversion, которое позволяет преобразовать Int в какой-то тип с методом 'to' и подходящей сигнатурой.

И находится в Predef.scala (напомню, что в каждый файл перед компиляцией неявно импортируются java.lang.* + scala.* + Predef.*)

// сокращенные исходники Predef.scala
package scala

object Predef extends LowPriorityImplicits with DeprecatedPredef {...}

private[scala] trait DeprecatedPredef {...}

private[scala] abstract class LowPriorityImplicits {
  ...
  @inline implicit def byteWrapper(x: Byte)       = new runtime.RichByte(x)
  @inline implicit def shortWrapper(x: Short)     = new runtime.RichShort(x)
  @inline implicit def intWrapper(x: Int)         = new runtime.RichInt(x)
  @inline implicit def charWrapper(c: Char)       = new runtime.RichChar(c)
  @inline implicit def longWrapper(x: Long)       = new runtime.RichLong(x)
  @inline implicit def floatWrapper(x: Float)     = new runtime.RichFloat(x)
  @inline implicit def doubleWrapper(x: Double)   = new runtime.RichDouble(x)
  @inline implicit def booleanWrapper(x: Boolean) = new runtime.RichBoolean(x)   
  ...
}

А вот у RichInt уже есть метод 'to' с одним аргументом типа Int.

// сокращенный исходники scala.runtime.RichInt
package scala.runtime

import scala.collection.immutable.Range

final class RichInt(val self: Int) ... {
  ...
   def to(end: Int): Range.Inclusive = Range.inclusive(self, end)
  ...
}

И потому при компиляции «раскручивается» в нечто типа

import scala.runtime.RichInt

object Demo {
  val tmp: Range = new RichInt(1).to(10)
  for (k <- tmp) {
    println(k)
  }
}

После «раскрутки» for в map/flatMap/foreach имеем

import scala.runtime.RichInt

object Demo {
  val tmp: Range = new RichInt(1).to(10)
  tmp.foreach(elem => println(elem))
}

Пример #2:

object Demo {
  var map = Map("France" -> "Paris")
  map += "Japan" -> "Tokyo"
}

После перехода от инфиксной формы вызова методов '->' и '+' к нормальной

object Demo {
  var map = Map("France".->("Paris"))
  map = map.+("Japan".->("Tokyo"))
}

и поиска подходящего implicid conversion String до какого-то типа с методом '->' (опять же находят в Predef.scala) получают «десахаризированную форму» (String в Scala — это, по сути, java.lang.String и у него нет метода '->')

object Demo {
  var map: Map[String, String] = Map.apply(new ArrowAssoc("France").->("Paris"))
  map = map.+((new ArrowAssoc("Japan").->("Tokyo")))
}

Из забавного: вот исходный код (сокращенный) класса ArrowAssoc из Predef.scala

  implicit class ArrowAssoc[A](private val self: A) extends AnyVal {
    def -> [B](y: B): Tuple2[A, B] = Tuple2(self, y)
  }

благодаря generics мы можем ставить стрелку между представителями ЛЮБЫХ ДВУХ ТИПОВ! Если вы сделаете 1 -> true, то type variable A будет принят за Int, а type variable B — за Boolean!

«Pointless style» (infix notation) это не «point-free style» (tacit programming)

Не надо путать pointless style (infix notation), который мы рассматриваем, с так называемым point-free стилем или же по другому — tacit programming.

Point-free стиль предполагает, что вы строите новые функции из неких примитивов и других функций не указывая явно аргументы, не вводя формальные имена параметров. Название произошло из топологии, где часто ведут размышления в терминах окрестностей а не конкретных точек.

Рассмотрим простой пример: функция Int => Int, которая возвращает аргумент увеличенный на 1.

Вот НЕ pointless и НЕ point-free стиль

object Demo {
  val f: Int => Int = x => 1.+(x)
}

Напомню, что в Scala '+' — это метод, принадлежащий типу Int, а не оператор. Хотя при компиляции под JVM преобразуется таки в оператор '+' над примитивом int.

Вот pointless и НЕ point-free стиль

object Demo {
  val f: Int => Int = x => 1 + x
}

Вот НЕ pointless и point-free стиль (f — с placeholder, g — без placeholder)

object Demo extends App {
  val f: Int => Int = 1.+(_)
  val g: Int => Int = 1.+
}

Вот и pointless и и point-free стиль (f — с placeholder, g — без placeholder)

object Demo {
  val f: Int => Int = 1 + _
  val g: Int => Int = 1 +
}

Далее мы не будем рассматривать point-free/tacit-programming, это может быть тематикой отдельной статьи.

Приоритет операторов

Если мы начнем определять свои «операторы», то мы можем столкнуться с отсутствием приоритетов

object Demo extends App {
  println(I(1) add I(2) mul I(3))
}

case class I(k: Int) {
  def add(that: I): I = I(this.k + that.k)
  def mul(that: I): I = I(this.k * that.k)
}

>> 9

мы бы хотели, что бы у умножения (mul) был приоритет перед сложением (add) (то есть мы хотим 1 + (2 * 3) = 7, а не (1 + 2) * 3 = 9). Однако запись вида

I(1) add I(2) mul I(3)

Эквивалентна следующей

I(1).add.(I(2)).mul(I(3))

Которая эквивалентна такой

( I(1).add.(I(2)) ).mul(I(3))

А не

I(1).add( I(2).mul(I(3)) )

Так как вызов метода — это лево-ассоциативная операция, то есть идет расстановка скобок (свертка) слева на право.

Это можно исправить явной расстановкой скобок

object Demo extends App {
  println(I(1) add ( I(2) mul I(3) ))
}
case class I(k: Int) {
  def add(that: I): I = I(this.k + that.k)
  def mul(that: I): I = I(this.k * that.k)
}

>> 7

либо пользуясь приоритетом обычных вызовов перед инфиксными (не рекомендуемый стиль, не смешивайте инфиксную и нормальные формы вызовов, скобки — лучше)

object Demo extends App {
  println(I(1) add I(2).mul(I(3)))
}
case class I(k: Int) {
  def add(that: I): I = I(this.k + that.k)
  def mul(that: I): I = I(this.k * that.k)
}

>> 7

Однако, если мы переименуем методы ('mul' -> '*', 'add' -> '+'), то произойдет немножко магии без всякого указания приоритета '*' над '+'!

object Demo extends App {
  println(I(1) + I(2) * I(3))
}

case class I(k: Int) {
  def +(that: I): I = I(this.k + that.k)
  def *(that: I): I = I(this.k * that.k)
}

>> 7

Откроем Священную Книгу на разделе «6.12.3 Infix Operations» и прочитаем:

The precedence of an infix operator is determined by the operator's first character. Characters are listed below in increasing order of precedence, with characters on the same line having the same precedence.

(all letters)
|
^
&
= !
< >
:
+ -
* / %
(all other special characters)

Итак, если наш метод начинается с '*', то он имеет приоритет над методом начинающимся с '+'. Который, в свою очередь, имеет приоритет над любым именем начинающимся с «обычной буквы».

Значит вот так тоже будет работать (не рекомендуется схожие операторы (умножение, сложение) называть и строковыми и операторными именами)

object Demo extends App {
  println(I(1) add I(2) * I(3))
}

case class I(k: Int) {
  def add(that: I): I = I(this.k + that.k)
  def *(that: I): I = I(this.k * that.k)
}

>> 7

Рассмотрим следующее выражение: 1 * 2 * 3 + 4 * 5 * 6 + 7 * 8 * 9.

Если операторам «сложить» и «умножить» дать строковые имена add и mul

object Demo extends App {
  println(I(1) mul I(2) mul I(3) add I(4) mul I(5) mul I(6) add I(7) mul I(8) mul I(9))
}

case class I(k: Int) {
  def add(that: I): I = I(this.k + that.k)
  def mul(that: I): I = I(this.k * that.k)
}

то все подвергнется свертке слева
1 * 2 * 3 + 4 * 5 * 6 + 7 * 8 * 9 -> (((((((1 * 2) * 3) + 4) * 5) * 6) + 7) * 8) * 9

Но в случае имен '+' и '*'

object Demo extends App {
  println(I(1) * I(2) * I(3) + I(4) * I(5) * I(6) + I(7) * I(8) * I(9))
}

case class I(k: Int) {
  def +(that: I): I = I(this.k + that.k)
  def *(that: I): I = I(this.k * that.k)
}

строка будет разбита на группы по равным приоритетам

1 * 2 * 3 + 4 * 5 * 6 + 7 * 8 * 9 -> 
(1 * 2 * 3) + (4 * 5 * 6) + (7 * 8 * 9)

внутри каждой группы (группы берутся слева направо) будет свертка слева-направо

(1 * 2 * 3) + (4 * 5 * 6) + (7 * 8 * 9) ->
((1 * 2) * 3) + ((4 * 5) * 6) + ((7 * 8) * 9)

после чего будут свернуты слева-направо операнды по сложению

((1 * 2) * 3) + ((4 * 5) * 6) + ((7 * 8) * 9) ->
(((1 * 2) * 3) + ((4 * 5) * 6)) + ((7 * 8) * 9)

Ассоциативность операторов

Читаем Священную Книгу, радел «6.12.3 Infix Operations» дальше:

The associativity of an operator is determined by the operator's last character. Operators ending in a colon `:' are right-associative. All other operators are left-associative.

The right-hand operand of a left-associative operator may consist of several arguments enclosed in parentheses, e.g. e;op;(e1,…,en). This expression is then interpreted as e.op(e1,…,en).

A left-associative binary operation e1;op;e2 is interpreted as e1.op(e2). If op is right-associative, the same operation is interpreted as { val x=e1; e2.op(x) }, where x is a fresh name.

Что это значит практически? Значит, что левоассоциативность сверток стоит по умолчанию, но для методов в инфиксной форме, оканчивающихся на двоеточие работает — правоассоциативность. Более того, аргументы оператора меняются местами.

Это значит, что в следующем коде

object Demo {
  println(I(1) ++ I(2) ++ I(3) ++ I(4))
  println(I(1) +: I(2) +: I(3) +: I(4))
}

case class I(k: Int) {
  def ++(that: I): I = I(this.k + that.k)
  def +:(that: I): I = I(this.k + that.k)
}

строка

I(1) ++ I(2) ++ I(3) ++ I(4)

сворачивается до (левоассоциативность)

((I(1) ++ I(2)) ++ I(3)) ++ I(4)

и потом до

(((I(1).++(I(2))).++(I(3)) ++ I(4)

а строка

I(1) +: I(2) +: I(3) +: I(4)

сворачивается до (правоассоциативность)

I(1) +: (I(2) +: (I(3) +: I(4)))

и при переходе от инфиксной формы к обычной происходит инверсия аргументов оператора (магия ':' в конце имени оператора)

I(1) +: (I(2) +: (I(3) +: I(4))) -> 
I(1) +: (I(2) +: (I(4).+:(I(3)))) ->
I(1) +: ((I(4).+:(I(3))).+:(I(2))) ->
((I(4).+:(I(3))).+:(I(2))).+:(I(1))

Вопрос: какому больному разуму это может быть полезно?

Ну… вот пример из стандартной библиотеки (создание List)

object Demo {
  val list =   0 ::  1 ::  2 ::  Nil
}

Вопрос: как произошла эта магия? И почему в конце стоит пустой список Nil?
Все крайне просто: '::' — это метод класса List! С правоассоциативностью и реверсом операндов.

List определен примерно вот так (сокращенная и измененная версия)

sealed abstract class List[+A] {
  def head: A
  def tail: MyList[A]
  def isEmpty: Boolean
  def ::[B >: A](x: B): List[B] = new Node(x, this)
}

final case class Node[A](head: A, tail: List[A]) extends List[A] {
  override def isEmpty: Boolean = false
}
object Nil extends List[Nothing] {
  override def head: Nothing = throw new Error
  override def tail: MyList[Nothing] = throw new Error
  override def isEmpty: Boolean = true
}

И код

object Demo {
  val list =   0 ::  1 ::  2 ::  Nil
}

разворачивается компилятором в

object Demo {
  val list =  ( ( Nil.::(2) ).::(1) ).::(1)
}

То есть мы просто набиваем элементы в односвязный список (стек) начиная с пустого списка (Nil).

Infix types

Инфиксные типы (infix types) — это просто запись type constructors от двух аргументов в инфиксной форме.

Итак, по порядку. Что такое type constcructor от двух аргументов? Это просто generic class/trait с двумя type variable. Имея такой класс (назовем его 'ab'), даем ему два типа, например Int и String и получаем (конструируем) тип ab[Int, String]

Смотрим

object Demo extends App {
  val x0: ab[Int, String] = null
  val x1: Int ab String = null
}

case class ab[A, B](a: A, b: B)

Тип ab[Int, String] просто можно записать в инфиксной форме как Int ab String.

Все становится веселее, если мы type constructor называем не банально 'ab' а волшебно, например '++'.

object Demo extends App {
  val x0: ++[Int, String] = null

  val x1: Int ++ String = null

  val x2: List[Int ++ String] = null

  val f: Int ++ String => String ++ Int = null
}
case class ++[A, B](a: A, b: B)

Если Вы встретите где-то магию вида

def f[A, B](x: A <:< B)

или

def f[A, B](x: A =:= B)

То просто знайте, что в Predef.scala есть пара классов с именами '=:=' и '<:<'

object Predef extends ... {
  ..
  ... class <:<[-From, +To] extends ...
  ... class =:=[From, To] extends ...
  ..
}

Prefix operators

Из спецификации Scala

A prefix operation op;e consists of a prefix operator op, which must be one of the identifiers ‘+’, ‘-’, ‘!’ or ‘~’. The expression op;e is equivalent to the postfix method application e.unary_op.

Prefix operators are different from normal function applications in that their operand expression need not be atomic. For instance, the input sequence -sin(x) is read as -(sin(x)), whereas the function application negate sin(x) would be parsed as the application of the infix operator sin to the operands negate and (x).

В Scala программист может определять только 4 префиксных оператора с именами '+', '-', '!', '~'. Они задаются как методы без аргументов с именами 'unary_+', 'unary_-', 'unary_!', 'unary_~'.

object Demo {
  val x0 = +I(0)
  val x1 = -I(0)
  val x2 = !I(0)
  val x3 = ~I(0)
}

case class I(k: Int) {
  // не ищите логики в реализации
  def unary_+(): I = I(2 * this.k) 
  def unary_-(): I = I(3 * this.k)
  def unary_!(): I = I(4 * this.k)
  def unary_~(): I = I(5 * this.k)
}

Если мы посмотрим с помощью Java Redflection API, то увидим, во что эти методы компилируются

import java.lang.reflect.Method;

public class Demo {
    public static void main(String[] args) {
        Class clazz = I.class;
        for (Method m : clazz.getDeclaredMethods()) {
            System.out.println(m);
        }
    }
}

>> public I I.unary_$plus()
>> public I I.unary_$minus()
>> public I I.unary_$bang()
>> public I I.unary_$tilde()
...

Надо заметить, что наравне с префиксной формой сохранились и оригинальные названия (то есть краткая форма '+'/'-'/'!'/'~' — это просто синтаксический сахар к существующей и после компиляции полной форме 'unary_+'/'unary_-'/'unary_!'/'unary_~')

object Demo extends App {
  val x0 = +I(0)
  val x1 = -I(0)
  val x2 = !I(0)
  val x3 = ~I(0)

  // не рекомендуемая практика
  val y0 = I(0).unary_+()
  val y1 = I(0).unary_-()
  val y2 = I(0).unary_!()
  val y3 = I(0).unary_~()
}

case class I(k: Int) {
  // не ищите логики в реализации
  def unary_+(): I = I(2 * this.k)
  def unary_-(): I = I(3 * this.k)
  def unary_!(): I = I(4 * this.k)
  def unary_~(): I = I(5 * this.k)
}

Postfix operators

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

object Demo {
  val tailList0 = List(0, 1, 2).tail // "normal" notation
  val tailList1 = List(0, 1, 2) tail // postfix/suffix notation
}

Попробуем определить факториал на целых числах (def !).
Для начала обратим внимание на 100500 способов вызвать метод в Scala

object Demo extends App {
  val a = I(0).!()
  val b = I(0).!
  val c = I(0) !()
  val d = I(0) !   // postfix notation
}

case class I(k: Int) {
  def !(): I = I(2 * this.k) // не ищите тут логики, ее пока нет
}

Сделаем метод '!' на классе-обертке

object Demo extends App {
  val x: I = I(5)!;
  println(x)
}

case class I(k: Int) {
  def !(): I = if (k == 0) I(1) else I(k) * (I(k - 1)!)
  def *(that: I): I = I(this.k * that.k)
}

>> I(120)

Обратите внимание, точка с запятой — обязательны в конце первой строчки, иначе — НЕ КОМПИЛИРУЕТСЯ (постфикс — это боль, да)!

Спрячем явное наличие класса-обертки под implicit-ами (Int -> I, I -> Int)

object Demo extends App {

  implicit class I(val k: Int) {
    def !(): I = if (k == 0) I(1) else I(k) * (I(k - 1)!)
    def *(that: I): I = I(this.k * that.k)
  }

  implicit def toInt(x: I): Int = x.k

  val x: Int = 5!;
  println(x)
}

>> 120

А теперь спрячем и сами implicit-ы

object Demo extends App {
  import MathLib._

  val x: Int = 5!;
  println(x)
}

object MathLib {

  implicit class I(val k: Int) {
    def !(): I = if (k == 0) I(1) else I(k) * (I(k - 1)!)
    def *(that: I): I = I(this.k * that.k)
  }

  implicit def toInt(x: I): Int = x.k
}

>> 120

О курсе

Анонсированный курс по Scala выкладывается на MOOC-платформу UDEMY — «Scala for Java Developers». Первоначальная идея написать всеобъемлющий курс по всем аспектам языка и наиболее популярным «type acrobatic» библиотекам (scalaz, shapeless) сохранилась, но претерпела небольшие изменения.
Оригинальный большой 32-часовой курс за 399$ решено «разрезать» на два 16-часовых курса по 199$ (если вы введете на UDEMY код купона HABR-OPERATOR или просто зайдете по ссылке udemy.com/scala-for-java-developers-ru/?couponCode=HABR-OPERATOR, то цена со скидкой будет 179$, количество и срок действия скидочных купонов ограничено). Решено насытить курс тестами (будет более 50 тестов по 5-15 вопросов с примерами кода на каждую часть курса).
Первый курс снят на 75% (12 часов из 16) и выложен на UDEMY на 50% (8 часов из 16), так как часть видео находится в обработке.

В первую часть входят такие темы

  • «Intro»
  • «OOP — I: class, object, trait, case class»
  • «OOP — II: operator overloading»
  • «Type — I: Scala Type Hierarchy»
  • «Generics — I: covariance/contravariance, Scala vs Java»
  • «Collections: Lists»
  • «Implicits»
  • «Functional Programming — I: functional literals, closures, eta-expansions»
  • «Lazyness: call-by-name, keyword 'lazy', trait DelayedInit»
  • «Build-in control structures, expression-oriented programming: if, while, for, case»
  • «Custom control flow constructions»
  • «Pattern matching: case classes, extractors»
  • «List-comprehentions»
  • «Комбинаторика: порождение комбинаторных объектов (перестановки, разбиения, подмножества, деревья)»
  • «Tuples»
  • «Алгебра: магма, полугруппа, моноид, группа»
  • «Теория множеств: бинарные отношения»
  • «Теория множеств: морфизмы»

Замечание #1: ряд тем (OOP, Generics, Scala types, ...) решено разбить на 2 или даже 3 части в виду сложности и важности вопроса (первые части располагаются в первой части курса, последние — во второй части курса («OOP-III: наследование, Cake Pattern», «Generics-II: existential types, higher-king types», ...)).

Замечание #2: в виду того, что у многих программистов есть определенные проблемы с математикой (учим 3 семестра «матан», но не более полезные для программиста «дискретные дисциплины» — теорию множеств, дискретную математику, математическую логику, алгебру, комбинаторику, теорию категорий, формальные языки/грамматики, ...) и потому, что функциональное программирование сильно использованием математических концепций, в курс введено несколько разделов математики (вся математика «кодируется на Scala», так что без университетских «сферических коней в вакууме»).

P.S. На все вопросы отвечу в комментариях / в сообщениях в «личку» или по контактам
skype: GolovachCourses
email: GolovachCourses@gmail.com

P.P.S Одновременно с разработкой курса по Scala автор ведет внутренние тренинги по Scala в IT-компаниях, тренинги в рамках конференций, делает доклады по Scala и оказывает консультационные услуги при переводе проектов с Java на Scala.

Автор: IvanGolovach

Источник

Поделиться новостью

* - обязательные к заполнению поля