Как стать автором
Обновить

Scala. Введение

Время на прочтение 12 мин
Количество просмотров 86K
Привет хабралюди.

Не так давно я заинтересовался одним из многочисленных ныне языков под JVM — Scala. Причин тому много, основная — всё нарастающее со временем чувство неудобства при работе с cpp-подобными языками. Взгляд мой попеременно падал на Ruby, Groovy, Python, но все они оставляли впечатление инструментов, не совсем подходящих для моего обычного круга рабочих задач (Python-таки хорош, но у нетипизированных языков есть свои ограничения). Scala же, напротив, показалась вполне годным языком. Так как поиск по хабру никаких статей о ней не выловил (было несколько, но мягко говоря не вводных), я решил написать маленький обзор и поделиться им с массами.

Немного философии языка в вольном изложении


Какие основные цели преследовали создатели языка? Согласно моим мироощущениям они такие:
Во-первых, совместимость. Среди своих задач разработчики ставили поддержание совместимости с Java-языком и тысячами примеров говнокода разработок на ней для решения самых разнообразных задач.
Во-вторых, интенсивное насыщение языка функциональными фичами, которые, в основном, (но далеко не полностью) составляют его отличия от Java.
В-третьих, облегчение нашего с вами труда. Действительно, компилятор Scala понимает программиста с полуслова, писать код специально, чтобы втолковывать ему, что я не верблюд, мне не довелось пока.
В-четвёртых, поддержка и стимулирование написания модульных, слабосвязанных программных компонентов в сочетании с широкими возможностями адаптации уже существующих. Цели не то, чтобы совсем противоположные, но порождающие известные трудности для одновременного достижения. Что ж, посмотрим что получится.
В-пятых, это поддержка параллелизма. К сожалению у меня руки и голова до этой области не дошли (надеюсь пока), но акцент на этом моменте делается постоянно на всех ресурсах по языку.

Для экспериментов с языком достаточно поставить соответствующий плагин на любимую IDE отсюда.

Итак, давайте посмотрим на сам язык…

Общие идеи языка, примеры синтаксиса


Самое, пожалуй, важное, — это «унифицированная модель объектов». Этот термин расшифровывается авторами так: «каждое значение — объект, каждая операция — вызов метода». Это, конечно, не «всё — объект», но сущностей в сравнении с Java убыло, а чем меньше сущностей — тем легче жизнь :) В прикладном плане это означает, что числа и символы сделались неизменяемым объектами, обитающими в общей куче, все операции приобретают ссылочную семантику. Например, код 5 + 5 вполне валиден, и породит новый объект в куче, который оперативненько оприходует сборщик мусора (на самом деле, я тихо надеюсь, что компилятор поймёт глубину замысла и порождать он ничего не будет :) ).

После столь возвышенного введения можно глянуть на решение классической задачи:
object Main {
 def main(args:Array[String]) :Unit = {
  print("Hello, " + args(0) + "!")
 }
}


В нём мы видим следующее:
  • Можно объявлять отдельные объекты. Ничего необычного в этом нет, подобная возможность имеется, например в Groovy. Ведут себя такие объекты так же как написанные на Java реализации шаблона Singelton.
  • Объявление фукции выглядит непривычно, но вполне читабельно: [ключевое слово def] [имя]([список параметров]):[возвращаемый тип] = [блок кода].
  • В качестве типа, не несущего информационной нагрузки, выступает тип Unit. Он вполне аналогичен void в C-подобных языках.
  • Объявление параметра функции (а на самом деле и локальной переменной тоже) выглядит как [имя]:[тип].
  • Для параметризации типа используется не привычные нам <>, а казалось бы, навсегда закреплённые за масивами [].
  • Для обращения к элементам массива(экое непотребство) используются ().
  • Имеется какие-то встроенные функции, доступные в коде по умолчанию без всяких дополнительных импортов.

В дополнение, давайте взглянем на ещё один короткий пример:
println( ("Hello, " + args(0) + "!").toUpperCase )
println( "Hello, " + args(0) + "!" toUpperCase )


Как из него следует, использование оператора . совершенно не необходимо. Синтаксис языка вполне допускает использование вместо него пробела (также аргументы метода можно писать без скобок и запятых сразу после имени метода). И как мы видим, это оказывается вполне полезно: в первой строке высокоприоритетный оператор . заставляет нас писать ненужные, засоряющие код скобки, во второй получается более краткая и наглядная форма записи.

В качестве подспорья разработчику Scala поддерживает также интерактивный режим. То есть, можно запустить интерпретатор и по одной вводить комманды. Интерпретатор, встроенный в IDE, как-то нерегулярно работает, его отдельный вариант есть в репозитариях Убунты, думаю у остальных дистрибутивов тоже всё хорошо, счастливым обладателям Windows как всегда придётся помучаться :) Интерпретатор запускается самым необычным способом:
$ scala
Welcome to Scala version 2.7.3final (Java HotSpot(TM) Server VM, Java 1.6.0_16).
Type in expressions to have them evaluated.
Type :help for more information.
scala>

Совсем маленький пример:
scala> 1 to 10
res0: Range.Inclusive = Range(1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

Тут мы видим пример вызова метода с параметром. Если кто не догадался, у объекта класса Int 1 вызывается метод to с параметром того же типа 10, результат — диапазон значений.

Попробуем-ка мы теперь написать ещё одну функцию. Пусть она нам считает сумму чисел в заданном диапазоне, итак:
scala> def sum(a: Int, b: Int): Int = {
| var result = 0
| for (i <- a to b) result += i
| result
| }
sum: (Int,Int)Int

scala> sum(1, 5)
res3: Int = 15

Здесь видны ещё три важных момента:
  • При помощи ключевого слова var мы можем объявлять локальные переменные
  • Результатом вычисления блока является последнее выражение в нём
  • В нашем распоряжении имеется цикл for, который может выполнять вычисления для значений в заданном диапазоне (на самом деле для объектов в любом объекте — контейнере)

Операции над функциями


Что же мы такого можем с ними тут делать? Да что угодно =) Функции являются полноценными объектами программы. Их можно хранить как свойства объектов, передавать как параметры и возвращаемые значения и собственно создавать во время выполнения. Данные свойства позволяют строить так называемые функции высокого порядка, оперирующие себе подобными.

Для иллюстрации рассмотрим ставший классическим пример вычисления суммы:
scala> def sum(f: Int => Int, a: Int, b: Int): Int =
| if (a > b) 0 else f(a) + sum(f, a + 1, b) sum: ((Int) => Int,Int,Int)Int

В данном примере определяется функция sum, представляющая знакомый, надеюсь, всем оператор суммы. Параметры имеют следующий смысл:
f — функция преобразования целого числа из пределов суммирования в элемент суммы. Обратите внимание на объявление типа параметра: знак => означает, что параметр — функция, типы принимаемых значений перечисляются слева от него в круглых скобках (если параметр один, как в данном примере, их допустимо опустить), тип возвращаемого результата справа.
Работает она тривиально: вычисляет значение функции в нижней границе диапазона и складывает его с результатом вычисления себя самой в диапазоне на 1 меньшем.
Также в этом примере видна ещё одна особенность языка — if является выражением, имеющим значение (кстати, использованный ранее for — тоже выражение, его результат типа Unit). Если условие истина, то его результат первый вариант, иначе — второй.
a и b — пределы суммирования.

Ещё пара функций id и square, они равны своему параметру и его квадрату соответственно.
scala> def id(x: Int): Int = x
id: (Int)Int
scala> def square(x: Int): Int = x * x
square: (Int)Int

Тут надо сделать ещё одно лирическое отступление: функции в Scala имеют декларативный стиль объявления. Они описывают не как получить результат, а чему он равен. Но если требуется организовать последовательные вычисления в теле функции, нет проблем — у нас есть блоки.

Теперь можно воспользоваться тем, что мы написали ранее.
scala> sum(id, 1, 5)
res1: Int = 15
scala> sum(square, 1, 5)
res2: Int = 55

Здесь происходит кульминация этой части — мы берём и передаём функцию в другую функцию. Никаких интерфейсов, анонимных классов, делегатов: вот оно — маленькое счастье.

Я здесь намеренно не стал приводить примеры вложенных и анонимных функций, карринга. Всё это Scala умеет, но всё нельзя включить в небольшой обзор. Думаю приведёный пример достаточен для понимания важности и, главное, — удобства функций высокого порядка как инструмента программирования. Напоследок могу посоветовать почитать эту главу замечательной книги по программированию.

Особенности классов


Давайте опишем несложный класс. Пусть это будет комлексное число. Создадим следующий код:
class Complex(r: Double, i: Double) {
 def real = r
 def image = i
 def magnitude = Math.sqrt(r*r + i*i)
 def angle = Math.atan2(i, r)
 
 def + (that: Complex) = new Complex(this.real + that.real, this.image + that.image)
 
 override def toString = real+" + i*"+image+" | "+magnitude+"*e^(i*"+angle+"))"
}

object Main {
 def main(args:Array[String]) :Unit = {
  val first = new Complex(1, 5)
  val second = new Complex(2, 4)
  val sum = first + second
  println(first)
  println(second)
  println(sum)
 }
}


Во-первых, клас объявлен с какими-то параметрами. Как несложно догадаться по продолжению, это параметры конструктора, которые доступны всё время жизни объекта.
Во-вторых, в классе объявлено несколько методов — селекторов. Одно семейство для декартового представления и одно для полярного. Как видим оба они используют параметры конструктора.
В-третьих, в классе объявлен оператор сложения. Объявлен он как обычный метод, принимает также Complex и возвращает его же.
Ну и наконец, для этого класса переопределена, без сомнения, знакомая всем Java-программистам функция toString. Важно отметить что на переопределение методов в Scala всегда необходимо явно указывать при помощи ключевого слова override.

Несмотря на огромную практическую ценность данный класс обладает рядом недостатков, а именно:
  • Занимает неоправданно много для своей функциональности места на экране
  • Не умеет сравнивать себя с себе подобными

Что же, попробуем исправить недочёты, средствами этого прекрасного языка.
class Complex(val real: Double, val image: Double) extends Ordered[Complex] {
 def magnitude = Math.sqrt(real*real + image*image)
 def angle = Math.atan2(image, real)
 def + (that: Complex) = new Complex(this.real + that.real, this.image + that.image)
 def compare(that: Complex): Int = this.magnitude compare that.magnitude
 override def toString = real+" + i*"+image+" | "+magnitude+"*e^(i*"+angle+"))"
}

object Main {
 def main(args:Array[String]) :Unit = {
  val first = new Complex(1, 5)
  val second = new Complex(2, 4)
  if (first > second )
   println("First greater")
  if (first < second )
   println("Second greater")
  if (first == second )
   println("They're equal")
 }
}


Итак, что появилось нового:
  • У параметров конструктора появилось ключевое слово val и исчезли соответствующие селекторы. Да, всё вполне очевидно, это разрешение компилятору создать селекторы для них автоматически.
  • Добавилось наследование от незнакомого нам класса (а точнее trait'а) Ordered. Да не простого, а параметризованного нашим классом. Как следует из названия, он должен помочь нам с упорядочиванием наших экземпляров.
  • Появился метод compare, который сравнивает два комплексных числа посредством сравнения их модулей.
  • В тестовом методе появились использования операторов >, <, ==. Их мы явно не определяли.

Пришло время сказать несколько слов об идее trait'а. Это особый тип класса, который не может иметь конструкторов, но может иметь любые методы и аттрибуты. Обычно они устанавливают некоторый протокол для взаимодействия со своими возможными наследниками. Используя этот протокол они могут получить необходимую информацию от потомка и реализовать в нём некоторое поведение. Соответственно, любой класс (или объект) может наследоваться от произвольного количества trait'ов(и лишь от одного class'а). Например Ordered объявляет абстрактный метод compare и на его основании дополняет класс-наследник операторами <, <=, > и т.д. Здесь надо заметить, что по-хорошему стоит переопределить предоставленный нам оператор ==, так как он даёт истину и для неодинаковых объектов, да и методы equals с hashCode также стоит переопределять в таких случаях.
«Всё это хорошо» — скажет бывалый боец аутсорсерного рынка, «но что делать если требуется банальный domain-класс, с богомерзкими модификаторами атрибутов?».
Решение у нас, естественно, есть :)
class User {
 private[this] var _name: String = ""
 def name = _name toUpperCase
 def name_=(name: String) = {
  _name = if (name != null) name else ""
 }
}

  • Во-первых, этот класс использует уже знакомое нам ключевое слово var в своём теле, да не просто а с диковинным модификатором private[this]. Значение этого ключевого слова в теле класса абсолютно аналогично таковому в внутри блока(и даже, скажу по секрету, в конструктор его тоже можно запихать) и делает из имени после него изменяемый аттрибут класса. Диковинный модификатор заявляет, что переменная должна быть доступна только данному объекту. Можно было написать, например, private[User] и она стала бы доступна другим, нам подобным, объектам, или указать имя пакета (что-то это мне напоминает).
  • Далее объявлена функция возвращающая наше поле в верхнем регистре.
  • И в заключение, странная функция name_=, получающая строку в виде параметра, проверяющая что она не null и записывающая её в наше поле.

Чтобы понять, как это всё использовать давайте взглянем на результат выполнения следующего кода(для краткости я не стал включать сюда описание объекта и main-метода):
val user = new User("Scala!!!")
println(user.name)
user.name = "M. Odersky"
println(user.name)

SCALA!!!
M. ODERSKY


Внимание, вывод: метод с именем <что-то>_= вызывается при использовании конструкции <объект>.<что-то> = <что-то другое>. Насколько я знаю в Scala это второй хак (первый — преобразование () в вызов метода apply), как Гвидо завещал c неявным преобразованием использования оператора в вызов метода.

Pattern-matching


Начать придётся немного издалека. В Scala есть так называеммые сase classes(естественно и objects тоже). Они объявляются с ключевым словом case, после чего компилятор берёт на себя смелость сделать следующее:
  1. Создать функцию-конструктор с именем, совпадающим с класом.
  2. Имплементировать в классе toString, equals, hashCode на основе аргументов конструктора.
  3. Создать селекторы для всех аргументов конструктора.

Вся эта магия открывает нам путь к использованию метода match. Давайте взглянем на пример:
abstract class User

case class KnownUser(val name: String) extends User

case class AnonymousUser() extends User

object Test {
 val users = List(KnownUser("Mark"), AnonymousUser(), KnownUser("Phil"))
 
 def register(user: User): Unit = user match {
   case KnownUser(name) => println("User " + name + " registered")
   case AnonymousUser()     => println("Anonymous user can't be registered")
 }
 
 def main(args: Array[String]) =
  users.foreach( register )
}

Итак, общая картина кода: есть абстрактный класс пользователя, есть два его казуальных потомка: известный и анонимный пользователи. Мы хотим зарегистрировать некий список пользователей на (здесь включаем фантазию) встречу. Для чего и используем pattern-matching, который позволяет нам определить разное поведение метода для разных типов объектов и обеспечивает выборку данных из этих объектов.

После столь жизненного примера можно немного теории о работе метода match. Для каждого выражения case он выполняет проверку на совпадения типа с классом шаблона и соответствия параметров конструктора шаблону. Шаблон в общем случае может включать в себя:
  1. Конструкторы других case-классов. Тут всё вполне рекурсивно, глубина вложенности шаблона ограничивается безумием программиста не ограничивается.
  2. Переменные шаблона. Они становятся доступны в теле функции вычисления результата.
  3. Символы _ обозначающие любое, неинтересующее нас значение.
  4. Литералы языка. Например 1 или "Hello".

Таким образом мы получаем инструмент, позволяющий описать получение некоторого значения из объекта на основе его структуры (класса) и/или хранимых в нём данных.

Люди знакомые с базовыми принципами ООП конечно сразу заметят, что проблема эта вполне решается использованием виртуальных функций (более того предлагаемый подход является не лучшей практикой). Однако их использование несёт в себе две трудности: во-первых усложняет поддержку кода при большом числе таких функций (нам ведь захочется регистрировать пользователей и на события, и в группы, и в блоги и т.п., что для каждого случая создавать виртуальный метод?), во-вторых не решает проблемы с тем, что объекты одного типа могут принципиально иметь разную структуру и не иметь возможности предоставлять некоторые данные.

На вторую проблему хочется обратить особое внимание. Как выглядил бы приведённый выше код в Java? Один класс, если пользователь анонимный выставляем в имени null и проверяем каждый раз (эстеты вроде меня заводят методы типа isAnonymous, состоящие из сравнения поля с тем же null). Проблемы налицо — неявно и небезопасно. Таких примеров великое множество, когда разные вариации структуры объектов объединяются в один класс, а неиспользуемые в конкретном случае забиваются null'ами, или того хуже придумывается значение по умолчанию. Scala позволяет явно описывать вариации структуры объектов, и предоставляет удобный механизм для работы с этими вариациями.

В заключение, пара мыслей насчёт того, когда данная техника может быть эффективно применена как замена виртуальным функциям:
  • У нас много функций. Да если у нас пара сотен операций, используемых по паре раз, зависящих от структуры и содержания объектов, система на основе case classes — pattern matching будет явно лучше поддерживаема.
  • У нас мало классов. match из пары выриантов всегда хорошо читаем.
  • У нас есть значительные вариации структуры объектов, которые однако надо хранить и обрабатывать единообразно.

Для всех пунктов действуют и обратные утвердения, например использование pattern matching для десятка классов не кажется мне хорошей идеей.

Вывод типов


Думаю, вы уже заметили, что в коде я указывал типы только при объявлении классов и методов. В блоках кода я их практически всегда опускал. Дело в том, что если программист не указывает тип явно, Scala пытается определить его из контекста. Например при инициализации значения константы в определении def s = "Scala" компилятор определит тип константы как строку. Всё это также работает на обобщённых типах, например фрагмент выше val users = List(KnownUser("Mark"), AnonymousUser(), KnownUser("Phil")), создаёт константу типа List[User], автоматически поднимаясь до подходящего уровня в иерархии наследования и используя его для параметризации типа-контейнера. На практике это означает, что можно значительно сэкономить на подобных объявлениях (для развлечения напишите делающий то же самое код на Java или C# :) ).

Заключение


Мда… К началу поста уже и скролить долго. Явно пора заканчивать. А сказать хотелось бы ещё про многое: про интереснейший механизм описания обобщённых классов, про неявные преобразования и то, что они на самом деле явные, ленивую инициализацию констант.

Мне и самому ещё только предстоит изучить модель многопоточности и своеобразный набор примитивов для её реаизации, разобраться с языковой поддержкой xml, поиграться с DSL-строением, посмотреть на их флагманский проект — Lift…

Однако всё равно осмелюсь сделать пару выводов:
  • Scala является весьма лаконичным и выразительным языком
  • Она предоставляет мощный инструментарий для создания простых и красивых программ

Вот и всё. Критика приветствуется.
Напоследок вопрос к массам: интересна ли данная тема, стоит ли писать продолжение?

UPD: поправил грамматику, спасибо всем оказавшим в этом помощь. Особенно ganqqwerty за массовые разборки с запятыми.

_________
All source code was highlighted with Source Code Highlighter.
Текст подготовлен в ХабраРедакторе
Теги:
Хабы:
+47
Комментарии 106
Комментарии Комментарии 106

Публикации

Истории

Ближайшие события

Московский туристический хакатон
Дата 23 марта – 7 апреля
Место
Москва Онлайн
Геймтон «DatsEdenSpace» от DatsTeam
Дата 5 – 6 апреля
Время 17:00 – 20:00
Место
Онлайн