Java.next: Общие черты Groovy, Scala и Clojure, часть 1

Как эти языки нового поколения на платформе JVM решают задачу перегрузки операторов

У языков Java.next (Groovy, Scala и Clojure) больше сходства, чем различий, и многие из их возможностей и удобств произрастают из общей почвы. Эта статья посвящена тому, как каждый из этих языков преодолевает застарелый недостаток языка Java™ – неспособность к перегрузке операторов. В ней также обсуждаются родственные понятия ассоциативности и приоритетности.

Нил Форд, Архитектор приложений, ThoughtWorks

Нил Форд (Neal Ford) работает архитектором приложений в ThoughtWorks, революционной компании, предоставляющей профессиональные IT-услуги и помогающей талантливым людям по всему миру эффективнее использовать программное обеспечение. Он также является проектировщиком и разработчиком приложений, учебных материалов, журнальных статей, учебных курсов, видео/DVD-презентаций и автором книг "Разработка с Delphi: Объектно-ориентированный подход", "JBuilder 3 Unleashed" и "Искусство разработки Web-приложений на Java". Он специализируется на консультациях по построению широкомасштабных корпоративных приложений. Он также является общепризнанным докладчиком и выступал на многочисленных конференциях разработчиков по всему миру. С ним можно связаться по адресу: nford@thoughtworks.com.



03.06.2013

Об этом цикле статей

Наследником Java будет не язык, а платформа. На платформе JVM работают более 200 языков программирования, каждый из которых несет в себе новые интересные возможности, выходящие за рамки языка Java. Этот цикл статей посвящен исследованию трех языков нового поколения для платформы JVM — Groovy, Scala и Clojure, — а также сравнению и противопоставлению новых возможностей и парадигм. Он позволяет Java-разработчикам заглянуть в свое собственное ближайшее будущее — и помогает им принимать обоснованные решения о том, стоит ли тратить время на освоение нового языка.

Хорошие идеи в области языков программирования сохраняются и распространяются на другие языки, проникая в них с течением времени. Поэтому неудивительно, что у языков Java.next — Groovy, Scala и Clojure — много общего. В этой и следующей статьях из цикла Java.next мы рассмотрим, как сближение функций проявляется в синтаксисе каждого языка. Я начну с возможности перегружать операторы — которая преодолевает застарелый недостаток языка Java.

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

Те, кому приходилось иметь дело с классом Java BigDecimal, скорее всего сталкивались с кодом, аналогичным приведенному в листинге 1.

Листинг 1. Слабая поддержка BigDecimal в коде Java
BigDecimal op1 = new BigDecimal(1e12);
BigDecimal op2 = new BigDecimal(2.2e9);
// (op1 + (op2 * 2)) / (op1/(op1 + (op2 * 1.5e2))
BigDecimal lhs = op1.add(op2.multiply(BigDecimal.valueOf(2)));
BigDecimal rhs = op1.divide(
        op1.add(op2.multiply(BigDecimal.valueOf(1.5e2))),
            RoundingMode.HALF_UP);
BigDecimal result = lhs.divide(rhs);
System.out.println(String.format("%,.2f", result));

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

Во всех трех языках Java.next перегрузка операторов реализована, хотя и несколько по-разному.

Операторы Scala

Scala решает задачу перегрузки операторов, стирая различия между операторами и методами. Операторы ― это просто методы со специфическими именами. Например, чтобы переопределить оператор умножения, достаточно переопределить метод *. [* ― это допустимое имя метода, что стало одной из причин, по которым для импорта в Scala используется знак подчеркивания (_), а не звездочка (*), как в Java.]

Для иллюстрации перегрузки я воспользуюсь комплексными числами. Это математическая запись чисел, состоящих из действительной и мнимой частей, которая обычно выглядит так: 3+4i (см. раздел Ресурсы). Комплексные числа применяются во многих областях науки и техники, включая инженерные расчеты, физику, электромагнетизм и теорию хаоса. Листинг 2 демонстрирует реализацию комплексных чисел в языке Scala.

Листинг 2. Комплексные числа в языке Scala
final class Complex(val real: Int, val imaginary: Int) {
  require (real != 0 || imaginary != 0)

  def +(operand: Complex) =
      new Complex(real + operand.real, imaginary + operand.imaginary)

  def +(operand: Int) =
    new Complex(real + operand, imaginary)

  def -(operand: Complex) =
    new Complex(real - operand.real, imaginary - operand.imaginary)

  def -(operand: Int) =
    new Complex(real - operand, imaginary)

  def *(operand: Complex) =
      new Complex(real * operand.real - imaginary * operand.imaginary,
          real * operand.imaginary + imaginary * operand.real)

  override def toString() =
      real + (if (imaginary < 0) "" else "+") + imaginary + "i"

  override def equals(that: Any) = that match {
    case other : Complex => (real == other.real) && (imaginary == other.imaginary)
    case _ => false
  }

  override def hashCode(): Int =
    41 * ((41 + real) + imaginary)
}

Метод equals() и ключевое слово match

Еще одна интересная особенность, отражаемая в листинге 2 ― использование шаблона внутри метода equals(). Хотя в Scala возможно и приведение типа, чаще используют сопоставление типа. Параметр that объявляется как Any — верхний уровень иерархии наследования Scala. Тело метода состоит из вызова match, который при совпадении переданного типа проверяет значение полей, а в противном случае принимает значение по умолчанию false.

Scala исключает большую часть «многословности» языка Java, устраняя ненужные "строительные леса". Например, в листинге 2 определение класса содержит параметры конструктора и поля этого класса. В данном случае тело класса выступает как конструктор, так что вызов метода require() в качестве первого действия экземпляра проверяет наличие значения. Так как Scala предоставляет поля автоматически, оставшаяся часть класса содержит определения методов. Для операторов +, - и * я объявляю одноименные методы, которые в качестве параметров принимают комплексные числа. Умножение комплексных чисел выполняется сложнее, чем сложение и вычитание. В листинге 2 эта формула реализуется перегруженным методом *:

(x + yi)(u + vi) = (xu - yv) + (xv + yu)i

Метод toString() в листинге 2 иллюстрирует еще одну общую черту языков Java.next: использование выражений вместо операторов. В методе toString() нужно ставить знак "плюс" (+), если мнимая часть положительна, но в противном случае неявно подставляется знак "минус". В Scala if ― это выражение, а не оператор, что исключает необходимость в тернарном операторе Java (?:).

На практике добавленные методы +, -, и * неотличимы от стандартных операторов, как показано в модульных тестах в листинге 3.

Листинг 3. Примеры с комплексными числами Scala
class ComplexTest extends FunSuite {
  test("addition") {
    val c1 = new Complex(1, 3)
    val c2 = new Complex(4, 5)
    assert(c1 + c2 === new Complex(1+4, 3+5))
  }

  test("subtraction") {
    val c1 = new Complex(1, 3)
    val c2 = new Complex(4, 5)
    assert(c1 - c2 === new Complex(1-4, 3-5))
  }

  test("multiplication") {
    val c1 = new Complex(1, 3)
    val c2 = new Complex(4, 5)
    assert(c1 * c2 === new Complex(
        c1.real * c2.real - c1.imaginary * c2.imaginary,
        c1.real * c2.imaginary + c1.imaginary * c2.real))
  }
}

Тесты из листинга 3 не выявляют интересного противоречия. Я продемонстрирую эту проблему и решу ее чуточку позднее, когда буду говорить об ассоциативности. Пока же ― несколько слов о перегрузке в Groovy и Clojure.

Сопоставления Groovy

Groovy перегружает любые операторы Java, предоставляя методы сопоставления, которые можно переопределять. (Например, чтобы переопределить оператор +, нужно переопределить метод plus() класса Integer.) Я подробно рассказываю о перегрузке операторов в Groovy на том же примере с комплексными числами в Части 3: Шаблоны проектирования для функционального программирования своего цикла статей Функциональное мышление, посвященного расширяемости функциональных языков.

В Groovy нельзя создавать новые операторы (хотя, конечно, можно создавать новые методы). Некоторые среды (такие как среда тестирования Spock; см. раздел Ресурсы) перегружают редко используемые, но существующие операторы, такие как >>>. Scala и Clojure рассматривают операторы и методы более унифицированно, хотя и по-разному.

В Groovy к тому же есть несколько удобных новых операторов, таких как ?.— оператор безопасной навигации, который гарантирует, что ни один из вызывающих объектов не есть null, — и оператор Elvis (?:), который сокращает тернарный оператор Java, упрощая задание значений по умолчанию. Groovy не предусматривает методов расширения своих новых операторов, чтобы разработчики не перегружали их. Однако не ясно, зачем разработчикам перегружать эти операторы: типичная причина перегрузки операторов связана с использованием предыдущего опыта работы с этим оператором, чтобы сделать код более удобочитаемым. Но вряд кто-либо накопил опыт работы с данными операторами за пределами Groovy. Перегрузка оператора становится опасной, если операторы используются для удобства, но в ущерб удобочитаемости.

Операторы Clojure

Как и в Scala, в Clojure операторы ― это просто методы с символическими именами. Так что создать, например, метод + для своего особого типа очень легко. Однако чтобы должным образом переопределить операторы в Clojure, надо понимать протоколы и технику создания набора методов из общего ядра. Я оставлю обсуждение этого вопроса для последующих статей.


Ассоциативность

Ассоциативность операторов указывает, находится ли метод оператора в левой или в правой части уравнения. В Scala пробел используется иначе, чем в большинстве других языков, в том плане, что практически любой метод Scala может выступать в качестве оператора. Например, выражение x + y на самом деле ― вызов метода x.+(y), как показано в сеансе Scala REPL (интерпретатора) в листинге 4.

Листинг 4. Перевод пробела в Scala
scala> val sum1 = x.+(y)
sum1: Int = 22

scala> val sum2 = (12).+(10)
sum2: Int = 22

В листинге 4 видно, что перевод пробела работает и для констант. Если хотите, в Scala все методы можно считать операторами. Например, в классе String есть метод indexOf(), который возвращает положение индекса в строке символов, переданной в качестве аргумента. В Scala его можно вызвать традиционным образом ― через s.indexOf('a') ― или как оператор, — например, s indexOf 'a'. (Этот конкретный метод интересен тем, что у него есть перегруженная версия, принимающая дополнительный параметр для указания положения индекса, с которого начинается поиск. Его тоже можно вызвать посредством записи оператора, но нужно указать параметры в скобках, например, s indexOf ('a' 3)).

Groovy следует соглашению об ассоциативности Java, так что правила для конкретного оператора определены в самом языке. В Clojure нет никаких опасений по поводу ассоциативности; его Lisp-подобный синтаксис не полагается на ассоциативность, потому что все операторы однозначны.

Одна из целей Scala ― позволить разработчикам использовать в качестве оператора все что угодно, поэтому он не может полагаться на произвольные правила ассоциативности. Как язык может допускать произвольные операторы и в то же время устанавливать правила? Scala решает эту проблему инновационным способом, предоставляя разработчикам максимальную свободу — благодаря соглашению об именах операторов. По умолчанию операторы в Scala левоассоциированные: выражение преобразуется в вызов метода для левого операнда, что означает, что выражение x + y, например, преобразуется в x.+(y). Однако если имя метода заканчивается на :, то оператор будет правоассоциированным. Например, вызов i +: j переводится в j.+:(i).

Ассоциативность объясняет, почему тесты из листинга 3 проверяют не всё. В определении комплексных чисел Scala, приведенном в листинге 2, я реализую версии операторов + и -, которые принимают параметры типа Complex и Int. Эта гибкость в отношении типов позволяет комплексным числам взаимодействовать с обычными целыми числами (которые суть комплексные числа с нулевой мнимой частью). Листинг 5 иллюстрирует такое взаимодействие в виде модульных тестов.

Листинг 5. Тесты для смешанных типов
test("mixed addition from Complex") {
  val c1 = new Complex(1, 3)
  assert(new Complex(7, 3) == c1 + 6)
}

test("mixed subtraction from Complex") {
  val c1 = new Complex(10, 3)
  assert(new Complex(5, 3) == c1 - 5)
}

Оба теста из листинга 5 проходят без проблем — запускается версия Int метода оператора. Однако если попробовать следующий тест, то он не пройдет:

test("mixed subtraction from Int") {
  val c1 = new Complex(10, 3)
  assert(new Complex(15, 3) == 5 + c1)
}

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

Чтобы решить эту проблему, я определяю неявное приведение между Int и Complex. Есть несколько способов задать это преобразование, о котором я подробнее расскажу в последующих статьях. В данном случае я создаю объект-спутник— место для методов, которые на языке Java нужно объявлять как static, — называемый Complex:

final object Complex {
  implicit def intToComplex(x: Int) = new Complex(x, 0)
}

Это определение содержит единственный метод, который принимает Int и возвращает его как Complex. Поместив эту декларацию в том же исходном файле, что и класс Complex, я разрешил неявное преобразование, импортировав этот метод в свой тест с помощью команды import nealford.javaNext.complexnumbers.Complex.intToComplex. С этим преобразованием тест проходит, потому что "знает", как обрабатывать вызов метода, производимый через оператор.


Приоритетность

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

В Scala для определения порядка выполнения операций используется первый символ имени оператора со следующей иерархией старшинства:

  • все прочие специальные символы
  • * / %
  • + -
  • :
  • = !
  • < >
  • &
  • ^
  • |
  • все буквы
  • все операторы присваивания.

Операторы, которые начинаются с вышестоящих символов, имеют более высокий приоритет. Например, выражение x *** y ||| z вычисляется как (x.***(y)).|||(z). Единственное исключение из этого правила касается операторов присваивания или любых других операторов, которые заканчивается знаком равенства (=), ― они автоматически получают самый низкий приоритет.


Заключение

Общая цель языков Java.next ― облегчить обременительные ограничения, которые имеют место в языке Java. Перегрузка операторов ― отличный пример того, как каждый из языков подходит к решению этой проблемы. Все три языка допускают перегрузку операторов, но реализуют эту возможность по-разному. Тонкие нюансы решения таких задач, как приоритетность и ассоциативность, демонстрируют, как разные части языка взаимодействуют друг с другом. В языке Clojure неоднозначность при использовании приоритетности и ассоциативности устраняется одной из интересных особенностей его синтаксиса — каждое выражение по самой своей природе является скобочным.

В следующей статье мы рассмотрим, насколько глубоко в языки Java.next проникает философия «всё суть объекты».

Ресурсы

Научиться

Получить продукты и технологии

  • Spock: платформа тестирования и спецификации Java- и Groovy-приложений.

Комментарии

developerWorks: Войти

Обязательные поля отмечены звездочкой (*).


Нужен IBM ID?
Забыли Ваш IBM ID?


Забыли Ваш пароль?
Изменить пароль

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Профиль создается, когда вы первый раз заходите в developerWorks. Информация в вашем профиле (имя, страна / регион, название компании) отображается для всех пользователей и будет сопровождать любой опубликованный вами контент пока вы специально не укажите скрыть название вашей компании. Вы можете обновить ваш IBM аккаунт в любое время.

Вся введенная информация защищена.

Выберите имя, которое будет отображаться на экране



При первом входе в developerWorks для Вас будет создан профиль и Вам нужно будет выбрать Отображаемое имя. Оно будет выводиться рядом с контентом, опубликованным Вами в developerWorks.

Отображаемое имя должно иметь длину от 3 символов до 31 символа. Ваше Имя в системе должно быть уникальным. В качестве имени по соображениям приватности нельзя использовать контактный e-mail.

Обязательные поля отмечены звездочкой (*).

(Отображаемое имя должно иметь длину от 3 символов до 31 символа.)

Нажимая Отправить, Вы принимаете Условия использования developerWorks.

 


Вся введенная информация защищена.


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=40
Zone=Технология Java, Open source
ArticleID=932416
ArticleTitle=Java.next: Общие черты Groovy, Scala и Clojure, часть 1
publish-date=06032013