×Закрыть

Java 8/Scala — різниця в підходах та взаємні інновації

З великим задоволенням згадую JDays Lviv 2014. Я там розказував про відношення між Scala та Java 8. З подивом помітив, що кількість ретвітів презентації більша за звичайну для такого роду контенту, тому виділив час, щоб записати її конспект, заодно скористаюся нагодою та презентую декілька освітніх ініціатив.

Ми будемо говорити про різницю між Scala та Java, про місце цих мов в екосистемі і чому кожна з них по-своєму необхідна. Потік інновацій двонаправленний — є запозичення як із Scala в Java, так і навпаки.

Відповідь на питання — чи треба вчити Scala якщо є Java, і навпаки однозначна: чим більше суттєво різних мов та стилей програмування ви знаєте, тим ви кращий спеціаліст.

Якщо запитати Scala-програміста, чим же принципово відрізняється Scala від Java, то він з великою ймовірністю не буде розказувати про нюанси лямбда-функцій та трейтів, а просто наведе такий приклад:

Java:

Scala:

Тобто одному рядку на Scala відповідає 20 на Java. З іншого боку, відсутність лаконічності — це проблема не тільки Java як мови, але й культури, яка склалася в середовищі Java-розробників, адже можна написати і так:

В DTO-base hashMap та equals перевизначені за допомогою рефлексії. І те, що я можу відкрито написати поле без геттерів та сеттерів під час конференції, і мене відразу не закидають капцями, — досягнення того, що розвиток ідіоматичної Java потихеньку відбувається в цьому напрямку, адже Scala показала перспективність лаконічності.

В Java 8 додано ряд нововведень, що мають зробити зручним функціональний стиль програмування, що на перший погляд повторюють відповідні конструкції Scala. В першу чергу це:
— lambda-вирази (анонімні функції);
— default methods in interfaces (a-la traits in scala);
— потокові операції над коллекціями.

Давайте розлянемо їх детальніше.

Лямбда-вирази

Java

Scala

Бачимо, що код дуже схожий. Але:

Scala:

Java:

[?] (модифікувити контекст, з якого був викликаний lambda-вираз, неможливо).

Тобто лямбда-вирази в Java — це синтаксичний цукор над анонімними класами, що мають доступ лише до фінальних об’єктів контексту, а в Scala — повноцінні замикання (closure), що мають повний доступ до контексту.

Дефолтні методи в інтерфейсах

Інша фіча, яка також була запозичена в Java із Scala — це дефолтні методи в інтерфейсах, що приблизно відповідають трейтам в Scala.

Java:

Scala:

На перший погляд — однаково. Але:

Java:

[?] (за допомогою саме Java такої функціональності не добитись, деяким аналогом може бути аспектний підхід).

Ще один приклад (можливо, менш важливий):

Java:

[?] (перегрузити методи об’єктів в інтерфейсі неможливо).

Тобто бачимо, що конструкції trait та default методів також досить різні: в Java це специфікація диспетчеризації виклику, а в Scala trait — це більш загальна конструкція, що специфує побудову вихідного классу за допомогою процесса лінеарізації.

Потокові операції над коллекціями

І третє нововведення Java-8 — це stream інтерфейс до бібліотеки колекцій, що по дизайну дуже нагадує стандартну бібліотеку Scala.

Java:

Scala:

Дуже схоже, тільки в Java stream інтерфейс треба спочатку отримати з коллекції, а потім — перевести в інтерфейс результата. Основна причина для цього — сталість інтерфейсів.

Це означає, що якщо в Java вже є досить комплектне нефункціональне API коллекцій, то додавати до нього ще один функціональний інтерфейс не є доречним з точки зору дизайну API та легкості модифікації, використовування та засвоювання. Тобто це — ціна за поступовий еволюційний розвиток.

Добре, спробуємо порівняти далі:

Java:

persons.parallelStream().filter( x -> x.person==”Jon”).collect(Collectors.toList())

Scala:

persons.par.filter(_.person==”Jon”)

Тут рішення дуже схожі, в Java можна зробити «паралельний» stream, в Scala — паралельну коллекцію.

Доступ до баз SQL:

Scala:

db.persons.filter(_.firstName === “Jon”).toList 

В Java-екосистемі все ж таки є аналог. Там можна написати:

dbStream(em,Person.class).filter(x -> x.firstName.equals(“Jon”)).toList

Цікаво порівняти, як саме це відображення колекцій в таблиці баз данних реалізовано в обох випадках.

У Scala-варіанті операції мають типи операцій над даними. Якщо приблизно описати типи:

persons має тип TableQuery[PersonTable]

де PersonTable <: Table[Person], що має структуру, у якої є методи firstName та lastName.

firstName === lastName - це бінарна операція === (так, у Scala можна визначати свої інфіксні операції), що має тип, подібний до Column[X] * Column[Y] => SqlExpr[Boolean],

а filtter SqlExpr[Boolean] Query[T] має метод filter: SqlExpr[Boolean] => Query[T] та якийсь метод для генерації SQL, і, таким чином, ми можемо виразити щось як вираз над Table[Person], що є відображенням Person.

Це досить просто і зрозуміло. Навіть можна сказати — тривіально.

Тепер давайте подивимось, як цей самий функціонал реалізован у jinq:

dbStream(em,Person.class).filter(x -> x.firstName.equals(“Jon”)).toList

Тут тип x - саме Person, а x.firstName - String, метод filter приймає на вхід функцію Person -> Boolean. Як же з неї генерується SQL?

Filter аналізує байт-код (там побудовано щось типу інтерпретатора байт-коду, який ‘символично’ виконує інструкції, а результатом цього виконання є траса визову геттерів та функцій, за якою можна побудувати SQL).

Взагалі можна зняти капелюха перед такою задумкою. З іншої точки зору — все це робиться динамічно і в рантаймі (тому — досить довго), і якщо ми використуємо в нашому filter функцію не з фіксованого списку (для якої ми не знаємо, як побудувати SQL), то відкриємо це теж тільки в рантаймі.

Ну і бачимо, що для cхожої функціональності код на Scala більш-менш тривіальний, в той час як на Java використовуються складні технології на грані фантастики.

Це були запозичення із Scala в Java, але, як бачимо, — Java-версії «фіч» сильно відрізняються від Scala.

Запозичення з Java8 в Scala

Тепер подивимось на запозичення з Java8 в Scala. Процесс інновацій двонаправлений, і є одне нововведення Java8, запозичене в Scala. В 2.11 воно включається опцією компілятора, а в 2.12 буде за замовчуванням. Це SAM-конверсія.

Давайте знову подивимось на два фрагменти коду:

Java:

Scala:

Як бачимо, в Java-версії типи і параметри методів — це Acceptor та Generator, що на рівні байт-коду представляються як відповідні класи, а в Scala — функції T=>Unit, та Unit=>T, що на рівні байт-коду представляються як Function1.class

SAM-type (Single Abstract Method) — класс або інтерфейс, у якому є один абстрактний метод. У Java, якщо метод приймає як параметр SAM-type, можна подати функцію. В Scala до 2.11 — не так, функція — це субклас Function[A,B].

На перший погляд, це не дуже значні зміни, крім того що можна буде об’єктно описувати функціональні API, але на практиці у цієї фічі є дуже важливе прикладення — застосування SAM-інтерфейсів у частинах, критичних до часу. Чому? Еффективність виконування байт-коду інтерпретатором JIT залежить від того, чи може він провести агресивний інлайнінг.

Але якщо ви працюєте з функціональними інтерфейсами, то класи параметрів виглядають як Function1 для будь-якої функції з одним параметром Function2 для всіх функцій з 2-ма параметрами, і так далі. Звичайно, їх сильно не проінлайниш. Тому була така неявна проблема: у критичних по часу низькорівневих частинах коду функціональні інтерфейси краще не використовувати, тому що JIT не зможе їх проінлайнити. З SAM можна переписати їх через локальні SAM-типи, які компілятор може проінлайнити, і ця проблема зникне.

Правда, вже існуючий код доведеться змінювати, а деякі речі (як, наприклад, інтерфейси колекцій) можна було би переписати через SAM, але скомбінувати новий та старий інтерфейси так, щоб усе виглядало сумісно.

Таку саму проблему ми бачили у Java, коли розглядали інтерфейси колекцій. Це дозволяє побачити, як працює еволюція. Покращили Java в одному напрямку — неідеально, але краще, ніж було. Покращили Scala в іншому напрямку — теж неідеально. І тепер у нас є дві «кривості» у двох мовах, що викликані повільною адаптацією. І є місце для третьої мови, яка може зробити «ідеальний» інтерфейс на якийсь наступний проміжок часу. Так еволюція і іде.

Взагалі конструкції Scala, яких немає в Java, можна розділити на 2 класи:
— ті, які в ідеальному світі колись будут внесені в Java-9,10,11,12... (якщо ці релізи будуть існувати і Java ще буде комусь цікава) — така логіка розвитку, так само як Fortan-90 став об’єктно-оріентованим;
— ті, що показують саме різницю в ідеології Java та Scala.

До першої групи можна віднести case-класи та автоматичній вивід типів, а до другої — майже все інше.

Пам’ятаєте, на самому початку ми почали з наступного фрагменту коду:

case class Person(firstName: String, lastName: String)

Чому case-класи називаються case? Тому що їх можна використовувати в match/case операторі:

Перший case спрацьовує на ім’я Jon Galt, другий — на будь-які інші значення Person. При чому, в області дії другого case вводяться два локальних імені — firstName та lastName

Взагалі це називається ML-style pattern matching. ML-style — тому ще вперше така конструкція (матчинг, в якому заповняються зміни) була запропонована в мові ML, що виникла в 1973 році. Зараз більшість «нових» мов (Scala, Kotlin, Ceylon, Apple Swift) її підтримують.

Особливості Scala

Спробуємо поставити питання: а в чому саме особливість Scala? Які можливості вона дає, яких принципово немає в Java?

Відповідь — побудова внутрішніх DSL [Domain Specific Language]. Тобто Scala пристосована для того, щоб для кожної предметної області можна було побудувати жорстко типізовану модель та виразити її у мовних конструкціях.

Ці конструкції будуються в статично-типизованому середовищі. Які основні властивості дають нам можливість будувати такі конструкції?
— гнучкий синтакс, синтаксичний цукор,
— синтаксис передачі параметрів по імені,
— макроси.

Почнемо з гнучкості синтаксису. Що це означає на практиці?

1. Методи можуть називатися як завгодно:

def  +++(x:Int, y:Int) = x*x*y*y

2. Будь-який метод з одним параметром можна визвати як інфіксний:

1 to 100  ==  1.to(100)

3. Фігурні та квадратні дужки відрізняються тільки тим, що в фігурних дужках може бути кілька виразів. Один параметр можна передавити і в фігурних дужках:

future(1) та future{1}

4. Функції можна визначати з декількома списками аргументів:

def until(cond: =>Boolean)(body: => Unit):Unit

5. Як параметр функції можна передати блок коду, що буде викликатись кожен раз, коли відповідний аргумент буде називатись (передача аргументів «за ім’ям»):

def until(cond: =>Boolean)(body: => Unit):Unit =
  { body; while(!cond) { body } }

until(x==10)(x += 1)

Давайте спробуємо зробити для DSL для Do/until:

object Do
{
   def apply(body: => Unit) = new DoDody(body)
} 

class DoBody(body: => Unit)
{
   def until(cond:  =>Unit): Unit =
      { body  
        while(!cond) 
            body
      }
}

Тепер ми можемо написати щось в стилі

Do {
 x += 1
}  until ( x != 10 )

Ще одна властивість, що дозволяє створювати DSL, — це спеціальний синтаксис для деяких виділених функцій.

Скажімо, наступний вираз:

for(x <- collection){ doSomething }. 

Це просто синтаксис для виклику методу:

collection.foreach(x => doSomething)

Отже якщо ми напишемо свій клас, у якому буде метод foreach, що приймає на вхід певну функцію з чогось в Unit, ( [X] => Unit ) то далі в коді ми зможемо використовувати синтаксис for для свого типу.

Те саме з конструкцією for/yield (для map), вкладеними ітераціями (flatMap) та умовними оператором в циклі.

Тому, наприклад,

for(x <- fun1 if (x.isGood);
    y <- fun2(x) ) yield z(x,y)

— це просто інший синтаксис для

fun1.withFilter(_.isGood).flatMap(x => fun2.map(y => z(x,y)))

Існує розширення Scala — Scala-virtualized. Це окремий проект. На жаль він, скоріше за все, не ввійде в стандарт Scala. Тут схожим чином віртуалізується взагалі всі синтаксичні конструкції — if-u, match та інші. Можна було закласти повністю іншу семантику. Приклади прикладень: генерація коду для GCPU, спеціалізована мова для машинного навчання, трансляція в JavaScript.

До речі, компіляція програм в Javascript все ж таки є в існуючій екосистемі: функціональність перенесли в плагін до Scala-компілятора scala.js, що генерує JavaScript. Їм вже можна користуватись. У зв’язці з мініфікатором кода при написанні типової функціональності рантайм важить вже менше мегабайта.

Ще одна можливість Scala, корисна для DSL, — макроси. Макрос — це перетворення коду програми під час компіляції. Давайте для ілюстрації ідеї подивимось на простий приклад:

object Log 
{
  

  def apply(msg: String): Unit = macro applyImpl

  def applyImpl(c: Context)(msg: c.Expr[String]):c.Expr[Unit] =
  {
   import c.universe._
   val tree = q"""if (Log.enabled) {
                      Log.log(${msg})
                  }
               """
   c.Expr[Unit](tree)
  }

}

Тут вираз Log(message) буде замінений на:

if (Log.enabled) {
    Log.log(message)
 }

Чим вони корисні?

По-перше, за допомогою макросів часто можна генерувати те, що називають ‘boilterplate’ кодом, який очевидний, але має бути якось написаним. Як приклад можна навести конвертори xml/json або маппінг case-классів в бази даних. В java boilterplate код теж можна скорочувати за допомогою рефлексії, але це накладає обмеження на місця, критичні для швидкості виконання, адже сама рефлексія не безкоштовна.

По-друге, з макросами можна проводити більш глобальні зміни програми, ніж просто передача функцій. Фактично можна реалізувати свою інтерпритацію конструкцій або їх глобально переписати.

Приклад: async інтерфейси. Копія async/await інтерфейс C#, тобто всередині async блоку:

async {
     val x = future{ long-running-code-1}
     val y = future{ long-running-code-1}
     val z = await(x)+await(y)
}

Якщо прочитати цей блок кода напряму, побачимо що x та y запустять обчислення, потім z буде чекати завершення цих обчислень. А фактично код в async переписується таким чином, що всі переключення контексту неблокуючі.

Цікавість в тому, що async/await API зроблено як бібліотеку макросів. Тобто там, де в C# треба було випустити нову версію компілятору, в Scala можна написати бібліотеку.

Ще один приклад — jscala. Це макрос, який перетворює підмножину Scala коду в JavaScript. Тобто якщо вам хочеться дати якісь команди фронтенду і не хочеться переходити на JavaScript, ви можете написати їх прямо на Scala, а макрос сам перекладе.

Резюме

Резуюмуючи вищенаписане, можна сказати, що Java та Scala більш-менш має сенс порівнювати в області роботи з існуючим змістом, де рівень абстракції — це класси та об’єкти. А коли треба підвищити рівень абстракції та описати щось нове, там для Scala можна придумувати internal DSL, а в Java — пробувати суміжні рішення, такі як побудова external-DSL або aspect-oriented програмування.

Сказати, що якійсь підхід однозначно краще у всіх ситуаціях буде неправдою. Просто в Java ми повинні чітко бачити, що ми виходимо за межі прикладення мови і треба будувати якусь інфраструктуру, а в Scala цю інфраструктуру можна побудувати «в самій мові».

Там є досить багато внутрішніх проблем, можливості Scala іноді незбалансовані, про що можна довго розказувати: є багато експериментальних розробок, які хотілось би бачити в основному вектору розвитку. Але тут ми ніби вийшли в новий вимір і бачимо як можливості побудуви цього виміру, так і проблеми в існуючій конструкції. В Java ж цього виміру просто немає.

P.S. Освітні ініціативи:

1. 15 вересня на coursera почався курс Одерського по Scala: якщо ви вирішите його пройти, і у вас виникнуть якісь питання по цьому курсу, можна буде просто в суботу з 14:00 до 18:00 підійти в коворкінг «Білий Простір» (Ільїнська, 9) та задати їх мені.

2. Moocology запускає низку курсів «комбінованого» навчання, де з пропонується пройти курс MOOC паралельно з серією очних занять з локальними викладачами. 2 серпня почався курс «Мови програмування», де онлайн-частина доступна на Coursera, а офлайн-частину буду вести я на пару з Ярославом Ілічем.

  • Популярное

26 комментариев

Подписаться на комментарииОтписаться от комментариев Комментарии могут оставлять только пользователи с подтвержденными аккаунтами.
Відповідь на питання — чи треба вчити Scala якщо є Java, і навпаки однозначна: чим більше суттєво різних мов та стилей програмування ви знаєте, тим ви кращий спеціаліст.

А как же альтернативные издержки?

Интересно как с гибкостью у функциональных интерфейсов.

Filter аналізує байт-код (там побудовано щось типу інтерпретатора байт-коду, який ‘символично’ виконує інструкції, а результатом цього виконання є траса визову геттерів та функцій, за якою можна побудувати SQL).
это можно как-то расширять?

Ну насколько я смотрел в www.jinq.org сейчас встроенного метода расширения нет (хотя в теории какую-то систему встаривания плагинов построить не очень сложно, может быть нет еще, потому что проект молодой)

В DTO-base hashMap та equals перевизначені за допомогою рефлексії
тут скорее всего имелся ввиду hashCode

«Тобто лямбда-вирази в Java — це синтаксичний цукор над анонімними класами, що мають доступ лише до фінальних об’єктів контексту, а в Scala — повноцінні замикання (closure), що мають повний доступ до контексту» -> false
Лямбда в джаве реализована с помощью InvokeDynamic и не имеет никакого отношения к анонимным классам.

Ну — і так, і ні. Тобто з одного боку доку дійсно неточність, треба було вставити «на концептуальному рівні», з іншого — вам же CallSite в ivnvokedynamic треба вставити. Який — який видає LambdaMetaFactory. Що для кожного типу набору ’зібранних’ змінних генерує відповідний (який саме — деталі реалізації JVM) об’єкт (можливо і класс), що делегує визов методу в method-handler. (можно сказати що LambdaMetaFactory цей анонімний класс нам дає).
( stackoverflow.com/...ons-be-compiled )

Тобто сказати що ніякого відношення не має — все-ж таки не можна, хоча з іншого боку — зауваження слушне.

Я був занадто категоричний :)

Все в скале просто отлично, кроме скорости компиляции. sbt compile в проекте на 300 файлов компилится около 20 минут на HHD и 15 минут на SSD при процессоре i7. Это как-то печально. Есть ли возможность хоть как-то это ускорить?

Для 300 — это много. Наверное scalaz. Я бы попробовал отпрофайлить компиляцию — нет ли там какого-то экспотенциального роста из-за компиляции на типах (баги такого рода плавали в мейл-листах).

Кстати, стандартный ответ Мартина: «scala компилятор очень много делает но мы работаем над этим», ну и действительно вроде в последнее время быстрей, с другой стороны — очень быстрым он вряд-ли будет.

Для больших проектов рекомендуют писать части так, что-бы sbt мог разресолвить зависимости между частями и мог делать инкрементальную компиляцию а не полную (вот статья с рекомендациями:www.chrisstucchio.com/...discipline.html и www.scala-sbt.org/...ompilation.html ).

Извините, не смог разобрать пример с LoggedAsyncInput[T]. Вы могли бы прояснить, что там такого происходит, чего нельзя сделать в Java 8?

LoggedAsyncInput предназначается для домешивания в уже существующий AsyncInput и вызывает ’его’ метод перед этим печатая аргумени. То есть если:
val input = MyAsyncInput( ... ) input.onReceive( f )
что-то сделает (например вызовет f(1) при получении 1), то следующий фрагмент:
val input = MyAsyncInput( ... ) with LoggedAsyncInput input.onReceive( f )
в той-же ситуации напечатает «received: 1» а потом вызовет оригинальный MyAsyncInpit.onReceived который в свою очередь вызовет f(1)

То як краще починати вивчати. Спочатку Java -> Scala або просто Scala?

Мене лякає багатослівність Яви, яка на мою думку створює зайву складність (таке відчуття виникло після прочитання «Архитектура корпоративных программных приложений»).

Ще сам Флаувер багатослівно пише... З чого починати — навіть не знаю. Я би оцінював задачу — а вже для неї би вибирав мову

Архитектура корпоративных программных приложений
Хорошая книжка. Купил себе в бумажном виде. Но вот перевод просто ужасный. Использовать словосочетание «типовое решение» вместо устоявшегося паттерн это преступление против читателя. Что самое главное — принципы и паттерны описанные в ней 12 лет назад до сих пор актуальны. Хотя, сам текст оддает началом двухтысячных.

Добавьте обязательно про именованные параметры (и дефолтные). Одна из фич, которых иногда на самом деле не хватает, и потом начинаются пляски с прoверками типа if (arg != null) и т.д. Или такое: gist.github.com/...bdebefd8e32f1cb

Для сравнения, та же самая лапша, только с Option, которые здесь тоже не нужны: gist.github.com/...0479255848c50fc и, наконец, нормальное объявление: gist.github.com/...914cf2c8911b96a.

В качестве бесплатных плюшек получаем возможность делать f(argX = x, argY = y) ИЛИ f(argY = y, argX = x), но это сомнительная радость.

А еще напишите, что если человеку нужно воспользоваться перехваленными стримами и блямбами, то ничего сложнее списка он так лаконично, как в мaркетоидных слайдах, не сделает. Попробуйте сделать map на стриме entrySet’a, чтобы понять, о чем я.

Концепція стрімів призначена для побудови складних лінивих ітераторів, які вже у свою чергу можна зібрати в результуючий об’єкт, застосовуючи Collector (Collectors.toList(), Collectors.toMap() і т.д.)

Ну так правильно. А в моем примере показано, что «построить сложный итератор» над картой гораздо сложнее, чем над списком, потому что если в списке мы мапим одну интегральную сущность, то в карте эта сущность сложная, и мапить ее сложно и некрасиво. А то, что в терминальном collect() можно вообще все сделать — это понятно. Просто иногда хочется чего-то попроще. Важно помнить, что я все это говорю в контексте сравнения со скалой, где есть, во-первых, mapEntries & mapValues, а во-вторых, есть удобные способы создания кортежей и вообще. Но это все тоже понятно почему.

але й культури, яка склалася в середовищі Java-розробників, адже можна написати і так:
public  class  Person extends DTOBase
{
  public String  firstName;
  public String  lastName;
}

Можна, але не варто. Втрачається контроль над тим, яким чином беруться поля на call-site, а також не можна застосувати method references.

Все же в Java вот это

var (maxFirstLen, maxSecondLen) = (0,0)
  list.foreach{
      x => maxFirstLen = max(maxFirstLen, x.firstName.length)
              maxSecondLen = max(maxSecondLen, x.secondName.lenght)
}

таки реализуется, хотя и через костыль

        int[] x = {0}, y = {0};
        list.forEach(
                p -> {
                    x[0] = max(x[0], p.firstName.length();
                    y[0] = max(y[0], p.lastName.length();
                }
        );

И я бы добавил, что max, пожалуй, лучше искать в «потоковом» режиме

        OptionalInt x = list.stream().mapToInt(p -> p.firstName.length()).max();
        OptionalInt y = list.stream().mapToInt(p -> p.lastName.length()).max();
а потоки позволяют избежать мутирующих переменных.

Ну и конечно автоматическое распараллеливание

        OptionalInt x = list.parallelStream().mapToInt(p -> p.firstName.length()).max();
        OptionalInt y = list.parallelStream().mapToInt(p -> p.lastName.length()).max();

Я бы вместо этого

list.sort((x,y)-> { 
      int cmp = x.lastName.compareTo(y.lastName);
      return cmp!=0 ? cmp : x.firstName.compareTo(y.firstName)
  }

писал бы так

import static java.util.Comparator.comparing;
...
        list.sort(comparing((Person p) -> p.lastName)
                .thenComparing(comparing(p -> p.firstName)));

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

Ну скорее третий (там же еще будет @Data аннотация). Но вобще да — про Lombok [projectlombok.org/...ures/index.html ] как средство расширения стоило упомянуть. (странно, я помнил что это расширение IDE, а не библиотека)

Подписаться на комментарии