З великим задоволенням згадую 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 для всіх функцій з
Правда, вже існуючий код доведеться змінювати, а деякі речі (як, наприклад, інтерфейси колекцій) можна було би переписати через 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
Взагалі це називається
Особливості 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:
Тепер ми можемо написати щось в стилі
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, — макроси. Макрос — це перетворення коду програми під час компіляції. Давайте для ілюстрації ідеї подивимось на простий приклад:
Тут вираз 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, а офлайн-частину буду вести я на пару з Ярославом Ілічем.