Тед Ньювард
В статье за прошлый месяц мы лишь прикоснулись к синтаксису Scala, получив необходимый минимум для запуска Scala-программы и отметив некоторые простые особенности языка. Примеры Hello World и Timer из этой статьи позволили вам увидеть Scala-класс Application
- его синтаксис для определения методов и анонимных функций, бегло познакомили с Array[]
и немного - с выведением типов. Scala может предложить намного больше, поэтому в этой статье мы продолжим разбираться с хитросплетениями кодирования на Scala.
Возможности функционального программированя на Scala неоспоримы, но они не являются единственной причиной, по которой Java-разработчикам следует обратить внимание на этот язык. По сути Scala объединяет в себе функциональные концепции и объектную ориентированность. Чтобы дать Java+Scala -программисту большее ощущение комфорта, имеет смысл рассмотреть особенности объектов в Scala и понять - каково же их лингвистическое отражение на Java. Имейте в виду - для некоторых таких возможностей не существует прямого соответствия, а в ряде случаев "соответствие" будет скорее аналогом, чем точной параллелью. Но там, где различия окажутся важными, я буду отдельно обращать ваше внимание.
Вместо того, чтобы вдаваться в утомительную и абстрактную дискуссию по поводу поддерживаемых в Scala особенностей работы с классами, давайте взглянем на определение класса, который мог бы использоваться для представления рационального числа на платформе Scala:
Листинг 1. rational.scala
class Rational(n:Int, d:Int)
{
private def gcd(x:Int, y:Int): Int =
{
if (x==0) y
else if (x<0) gcd(-x, y)
else if (y<0) -gcd(x, -y)
else gcd(y%x, x)
}
private val g = gcd(n,d)
val numer:Int = n/g
val denom:Int = d/g
def +(that:Rational) =
new Rational(numer*that.denom + that.numer*denom, denom * that.denom)
def -(that:Rational) =
new Rational(numer * that.denom - that.numer * denom, denom * that.denom)
def *(that:Rational) =
new Rational(numer * that.numer, denom * that.denom)
def /(that:Rational) =
new Rational(numer * that.denom, denom * that.numer)
override def toString() =
"Rational: [" + numer + " / " + denom + "]"
}
|
Хотя общая структура в листинге 1 лексически эквивалентна тому, что вам доводилось видеть в Java-коде на протяжении последнего десятилетия, нельзя не заметить и наличия новых элементов. Прежде чем скрупулезно разобрать это определение, посмотрим на код, в котором задействован новый класс Rational
:
Листинг 2. RunRational
class Rational(n:Int, d:Int)
{
// ... см. выше
}
object RunRational extends Application
{
val r1 = new Rational(1, 3)
val r2 = new Rational(2, 5)
val r3 = r1 - r2
val r4 = r1 + r2
Console.println("r1 = " + r1)
Console.println("r2 = " + r2)
Console.println("r3 = r1 - r2 = " + r3)
Console.println("r4 = r1 + r2 = " + r4)
}
|
Приведенное в листинге 2 не выглядит слишком устрашающе: я создал пару рациональных чисел, добавил еще два Rational
-а как разность и сумму двух первых, затем вывел все в консоль. (Заметьте: Console.println()
пришло из библиотеки ядра Scala, расположенной в scala.*
и неявно импортируемой в каждую Scala-программу, в точности как java.lang
в Java-программировании).
Теперь снова посмотрим на первую строку в определении класса Rational
:
Листинг 3. Конструктор по умолчанию в Scala
class Rational(n:Int, d:Int)
{
// ...
|
Хотя можно было бы подумать, что в листинге 3 перед вами - некое подобие синтаксиса, характерного для универсальных типов ( generics ), на самом деле - это обособленный конструктор по умолчанию для класса Rational
: n и d - просто параметры этого конструктора.
Такое предпочтение одиночным конструкторам в Scala не лишено здравого смысла - большинство классов в конечном счете располагают одним конструктором или их набором, где все они "завязаны" на один конструктор из соображений удобства. При желании вы можете определить для Rational
дополнительные конструкторы, предположим, так:
Листинг 4. Набор конструкторов
class Rational(n:Int, d:Int)
{
def this(d:Int) = { this(0, d) }
|
Обратите внимание, что цепочка Scala-конструкторов реализует привычное в Java "сцепление" конструкторов, передавая вызов в выделенный (более предпочтительный) конструктор (здесь - в версию с Int,Int
).
При работе с рациональными числами бывает полезно выполнить несколько числовых трюков, скажем, найти общий знаменатель, чтобы облегчить выполняемые операции. Если вы хотите сложить одну вторую (1/2) с двумя четвертыми (2/4), класс Rational
должен быть достаточно сообразительным, чтобы понять, что 2/4 это то же самое, что и 1/2 и провести соответствующее преобразование, прежде чем выполнить сложение.
Для этого предусмотрена вложенная приватная функция gcd()
и значение g внутри класса Rational
. При вызове конструктора в Scala вычисляется все тело класса, а это значит, что g будет проинициализирована наибольшим общим делителем для n и d , а затем использована для соответствующей установки n и d .
Вернитесь к листингу 1 - здесь также отчетливо видно, что я создал перекрытый метод toString
для возврата значений Rational
, что будет весьма полезным, когда я начну испытания кода под управлением RunRational
.
Присмотритесь внимательнее к синтаксису для toString
: служебное слово override
в начале определения необходимо, т.к. позволяет Scala убедиться в том, что соответствующее определение присутствует в базовом классе. Это помогает предотвратить коварные ошибки, производимые случайными нажатиями на клавиатуре. (Та же мотивация побудила к созданию аннотации @Override
в Java 5). Кроме того, отметьте - тип возвращаемого значения не указан, ведь это очевидно из определения тела метода, более того - возвращаемое значение явным образом не обозначено служебным словом return
, как того требует Java. Вместо этого последнее значение в функции неявно рассматривается как возвращаемое. (При этом если вы предпочитаете синтаксис Java, вы всегда можете использовать ключевое слово return
).
Далее переходим к определениям numer
и denom
. Приведенный синтаксис в первом приближении может подтолкнуть Java-программиста к мысли, что numer
и denom
- открытые поля типа Int
, инициализируемые, соответственно, значениями n-на-g и d-на-g , но это предположение ошибочно.
На самом деле Scala вызывает не имеющие параметров методы numer
и denom
, предоставляющие быстрый и удобный синтаксис для определения методов доступа (аксессоров). Класс Rational
по прежнему содержит три приватных поля n , d и g , но они скрыты от внешнего мира: назначенным по умолчанию приватным типом доступа для n и d , и явно указанным модификатором private
для g .
Живущий в вас Java-программист, возможно, спросит в этом месте: "А где же соответствующие "сеттеры" (установщики) для n и d ?". А их и нет. Одна из сильных сторон Scala состоит в том, что язык изначально ориентирует разработчиков на создание неизменяемых ( immutable ) объектов. И хотя сам синтаксис создания методов для изменения внутренних элементов Rational
доступен, поступая так, вы нарушаете внутреннюю потокобезопасную сущность этого класса. А потому, по крайней мере для этого примера, я собираюсь оставить в Rational
все как есть.
Естественно, это порождает вопрос о том, как же теперь манипулировать Rational
. По аналогии с java.lang.String
-ами, вы не можете взять имеющийся экземпляр Rational
и изменить его значения. Раз так - единственной альтернативой остается создание новых Rational
-ов на основе существующего экземпляра или же создание с нуля. И это фокусирует наше внимание на следующем наборе из четырех методов, по странному стечению обстоятельств названных +
, -
, *
и /
.
И на что бы это ни было похоже - это вовсе не перегрузка операторов.
Вспомните - в Scala все является объектом . В предыдущей статье вы увидели, как этот принцип применим к идее о том, что функции сами по себе есть объекты, что позволяет Scala-программистам назначать функции переменным, передавать их как объектные параметры и т.д. Равнозначным по важности является и такой принцип: все является функцией ; в частности, в нашем конкретном случае нет различия между функцией, названной add
, и функцией, названной +
. В Scala все операторы являются функциями класса. И, конечно, случается, что они имеют забавные имена.
Вот и в классе Rational
для рациональных чисел определены четыре операции. Это канонические математические операции сложения, вычитания, умножения и деления. Каждая из них поименована соответствующим математическим символом: +
, -
, *
и /
.
При этом заметьте, что каждый из этих операторов в результате конструирует новый объект Rational
. Опять-таки - это очень похоже на то, как работает java.lang.String
, и это является подразумеваемой реализацией, порождающей потокобезопасный код. (Раз нет внутреннего состояния с общим доступом - а внутренний статус объекта по умолчанию считается состоянием с общим доступом - то нет и причин для беспокойства относительно одновременного доступа к этому статусу.)
Правило все является функцией обладает двумя мощными эффектами:
Во-первых, как вы уже видели, это то, что функции допускают манипулирование и сопровождение как для объектов. Это приводит нас к развитым сценариям повторного использования, подобно рассмотренному в первой статье этой серии.
Второй эффект заключается в том, что не существует особых различий между операторами, которые создатели Scala сочтут необходимым предоставить и операторами, которые Scala-программисты посчитают таковыми, которые следовало бы предоставить. К примеру, представим на секунду, что возникла потребность в операторе "переворота", который менял бы местами числитель и знаменатель и возвращал новый Rational
(т.е. для Rational(2,5)
вернул бы Rational(5,2)
). И если вы решите, что символ ~
наилучшим образом отражает эту концепцию, то вы можете определить новый метод используя такое имя и оно будет вести себя как любой другой Java-оператор, как показано в листинге 5:
Листинг 5. Перевертыш
val r6 = ~r1
Console.println(r6) // распечатает [3 / 1], т.к. r1 = [1 / 3]
|
Определение такого унарного "оператора" в Scala выглядит несколько мудрено, но это чисто синтаксическая фишка:
Листинг 6. Переворачиваем
class Rational(n:Int, d:Int)
{
// ... как и раньше ...
def unary_~ : Rational =
new Rational(denom, numer)
}
|
Мудреность, безусловно, в том, что мы задаем префикс "unary_
" для ~
, чтобы сообщить Scala-компилятору: это следует рассматривать как унарный оператор; поэтому синтаксис был "перевернут" по сравнению с традиционным синтаксисом ссылка-потом-метод, типичным для многих объектных языков.
Обратите внимание, как это сочетается с правилом "все является объектом" при создании некоторых продвинутых, но легко воспринимаемых, вариаций кода:
Листинг 7. Суммируем
1 + 2 + 3 // то же что и 1.+(2.+(3))
r1 + r2 + r3 // то же что и r1.+(r2.+(r3))
|
Как и следовало ожидать, компилятор Scala правильно делает свое дело в примерах с простым сложением целых, но синтаксически это все одно и то же. Это значит, что вы можете разрабатывать типы, ничем не отличающиеся от встроенных, являющихся частью языка Scala.
Компилятор Scala даже попытается вывести некий смысл для "операторов", имеющих некоторое предопределенное значение, таких как оператор +=
. Например, следующий код сработает как должно, несмотря на тот факт, что класс Rational
не содержит явного определения для +=
:
Листинг 8. Scala делает логический вывод
var r5 = new Rational(3,4)
r5 += r1
Console.println(r5)
|
При выводе в консоль r5
имеет значение [13 / 12]
, а это именно то, что и должно быть.
Вспомните - Scala компилируется в байт-код Java, т.е. код запускается под JVM. Если вам необходимо доказательство, достаточно того факта, что компилятор порождает .class -файлы, начинающиеся с 0xCAFEBABE
, как и в случае javac
. Также отметьте, что происходит, если запустить для этого байт-кода Java дизассемблер, поставляемый с JDK (javap
), и указать ему на сгенерированный класс Rational
, как показано в листинге 9:
Листинг 9. Классы, скомпилированные из rational.scala
C:\Projects\scala-classes\code>javap -private -classpath classes Rational
Compiled from "rational.scala"
public class Rational extends java.lang.Object implements scala.ScalaObject{
private int denom;
private int numer;
private int g;
public Rational(int, int);
public Rational unary_$tilde();
public java.lang.String toString();
public Rational $div(Rational);
public Rational $times(Rational);
public Rational $minus(Rational);
public Rational $plus(Rational);
public int denom();
public int numer();
private int g();
private int gcd(int, int);
public Rational(int);
public int $tag();
}
C:\Projects\scala-classes\code>
|
"Операторы", определенные в Scala-классе, превращаются в вызовы методов в лучших традициях Java-программирования, хотя имена эти методов несколько странноваты. В классе определены два конструктора: один принимает int
, а другой - пару int
-ов. И если вас, между делом, заинтересует то, что запись Int
в верхнем регистре является определенным способом маскировки для java.lang.Integer
, учтите, что компилятор Scala достаточно сообразителен, чтобы преобразовать это в обыкновенные Java-примитивы int
в определении класса.
Общеизвестно, что хорошие программисты пишут код, а великие программисты пишут тесты; до сих пор я плохо следовал этому правилу для своего Scala-кода, поэтому давайте посмотрим - что произойдет, когда вы разместите класс Rational
в традиционном тестовом модуле JUnit, как показано в листинге 10:
Листинг 10. RationalTest.java
import org.junit.*;
import static org.junit.Assert.*;
public class RationalTest
{
@Test public void test2ArgRationalConstructor()
{
Rational r = new Rational(2, 5);
assertTrue(r.numer() == 2);
assertTrue(r.denom() == 5);
}
@Test public void test1ArgRationalConstructor()
{
Rational r = new Rational(5);
assertTrue(r.numer() == 0);
assertTrue(r.denom() == 1);
// 1 - т.к. на этапе конструирования вызывается gcd();
// 0-на-5 это то же что и 0-на-1
}
@Test public void testAddRationals()
{
Rational r1 = new Rational(2, 5);
Rational r2 = new Rational(1, 3);
Rational r3 = (Rational) reflectInvoke(r1, "$plus", r2); //r1.$plus(r2);
assertTrue(r3.numer() == 11);
assertTrue(r3.denom() == 15);
}
// ... некоторые детали опущены
}
|
|
SUnit
Уже существует ориентированный на Scala комплект модульного тестирования, распространяемый под именем SUnit. Если вы задействуете SUnit для теста, показанного в листинге 10, вам не придется иметь дело с уловками на базе технологии отражения (reflection). Scala-специфичный код модульного тестирования скомпилируется прямо в Scala-класс, поэтому компилятор сможет выстроить символьную информацию. Некоторые разработчики действительно найдут более привлекательным процесс написания модульных тестов в Scala, при котором вместо обходных маневров используются простые объекты POJO.
SUnit является частью стандартной поставки Scala и расположен в пакете scala.testing . | |
Помимо проверки того, что класс Rational
ведет себя, скажем так, рационально, приведенный выше тестовый комплект убеждает нас в возможности вызова Scala-кода из Java-кода (хотя и с некоторыми сложностями, когда дело доходит до операторов). Что действительно впечатляет в этой ситуации, так это возможность осваивать Scala постепенно, выполняя миграцию от Java-классов к Scala-классам без какой-либо необходимости менять стоящие за этим тесты.
Единственная странность, которую можно было бы отметить в тестовом коде, связана с вызовом операторов, в данном случае метода +
класса Rational
. Вернитесь назад к выводу javap
- Scala напрямую транслирует функцию +
в метод $plus
для JVM, но спецификация языка Java не допускает наличия символа $
в идентификаторах (по причине его использования в именах вложенных и анонимных вложенных классов).
Для организации вызова таких методов вам либо нужно писать тесты на Groovy или JRuby (или на каком-то другом языке, в котором не заявлено ограничений на символ $
), или же вы можете обойтись для вызова небольшим количеством Reflection
-кода. Я принял второй подход, который совсем не интересен с точки зрения Scala, но результат вас позабавит (см. подборку исходных кодов к этой статье)
Нужно понимать, что приемы наподобие этого необходимы лишь для имен функций, не являющихся легитимными идентификаторами Java.
В прошлом, когда я только начинал изучать C++, Бьерн Страуструп высказал совет, что один из путей при изучении C++ - это восприятие его как "улучшенного C". Таким же образом сегодняшние Java-разработчики могут рассматривать Scala как "улучшенный Java" потому что он обеспечивает более краткий и лаконичный способ написания традиционных POJO-объектов Java. Рассмотрим обычный POJO Person
, приведенный в листинге 11:
Листинг 11. JavaPerson.java (исходный POJO)
public class JavaPerson
{
public JavaPerson(String firstName, String lastName, int age)
{
this.firstName = firstName;
this.lastName = lastName;
this.age = age;
}
public String getFirstName()
{
return this.firstName;
}
public void setFirstName(String value)
{
this.firstName = value;
}
public String getLastName()
{
return this.lastName;
}
public void setLastName(String value)
{
this.lastName = value;
}
public int getAge()
{
return this.age;
}
public void setAge(int value)
{
this.age = value;
}
public String toString()
{
return "[Имя персоны: " + firstName + " Фамилия:" + lastName +
" возраст:" + age + " ]";
}
private String firstName;
private String lastName;
private int age;
}
|
Теперь взгляните на его эквивалент, написанный на Scala:
Листинг 12. person.scala (потокобезопасный POJO)
class Person(firstName:String, lastName:String, age:Int)
{
def getFirstName = firstName
def getLastName = lastName
def getAge = age
override def toString =
"[Имя персоны: " + firstName + " Фамилия:" + lastName +
" возраст:" + age + " ]"
}
|
Это не полный эквивалент принимая во внимание, что исходный объект Person
содержит некоторые изменяющие состояние сеттеры ( setters ). Но учитывая, что исходный Person
к тому же не имеет кода синхронизации вокруг этих сеттеров, версия Scala будет безопаснее в использовании. Более того, если действительно задаться целью уменьшить количество строк кода в Person
, вы бы могли полностью удалить get
-методы доступа к свойствам, т.к. Scala сгенерирует методы-аксессоры ( accessors ) для каждого из параметров конструктора - firstName()
, возвращающий String
, lastName()
, возвращающий String
и age()
, возвращающий int
.
Даже если без этих изменяющих состояние сеттеров нельзя обойтись, версия Scala по-прежнему проще, как вы можете увидеть из листинга 13:
Листинг 13. person.scala (полное определение POJO)
class Person(var firstName:String, var lastName:String, var age:Int)
{
def getFirstName = firstName
def getLastName = lastName
def getAge = age
def setFirstName(value:String):Unit = firstName = value
def setLastName(value:String) = lastName = value
def setAge(value:Int) = age = value
override def toString =
"[Имя персоны: " + firstName + " Фамилия:" + lastName +
" возраст:" + age + " ]"
}
|
Кстати, обратите внимание на появившееся рядом с параметрами конструктора служебное слово var
. Если не вдаваться в детали, var
сообщает компилятору, что это значение - изменяемое. В результате Scala сгенерирует и метод-аксессор (String firstName(void)
) и метод-мутатор (void firstName_$eq(String)
). Ну а дальше не представляет особых трудностей создать прочие setХХХ
-мутаторы, использующие внутри себя сгенерированные методы.
Scala является попыткой реализовать концепции и лаконичность функционального программирования без ущерба для богатств объектной парадигмы. Как вы уже, возможно, стали понимать из этой серии, Scala также устраняет некоторые очевидные (с учетом наших текущих познаний) синтаксические проблемы, выявленные в языке Java.
Эта вторая статья из серии Путеводитель по Scala для Java-разработчиков сфокусирована на объектных возможностях Scala, которые позволяют вам начать использование Scala без слишком глубокого погружения в функциональный омут. Вооружившись всем тем, что вам известно на данный момент, вы уже в состоянии использовать Scala для сокращения трудозатрат на программирование. Среди прочего, вы можете использовать Scala для порождения таких же POJO, какие необходимы для сторонних сред программирования, типа Spring или Hibernate.
Так что натягивайте свои гидрокостюмы и прочее дайверское снаряжение, потому что статья следующего месяца обозначит начало нашего погружения в глубины функционального программирования.