Fork me on GitHub

Effective Scala

Marius Eriksen, Twitter Inc.
marius@twitter.com (@marius)

[translated by Eugene Sysmanov(@appigram)]

Table of Contents

Перевод на другие языки

English 日本語 简体中文

Введение

Scala является одним из основных языков программирования, который используется в Twitter. Большая часть нашей инфраструктуры написана на Scala и у нас есть несколько крупных библиотек, которые нами поддерживаются.

Scala не только очень эффективный, но и большой язык. Полученный нами опыт, научил нас быть очень осторожным при его использовании в своих приложениях в боевых условиях. Какие есть подводные камни? Какие особенности стоит использовать, а от каких отказаться? Когда мы можем использовать «чисто функциональный стиль», а когда его следует избегать? Другими словами: что мы ежедневно используем, чтобы быть более эффективными используя этот язык? Это руководство пытается передать наш опыт в коротких заметках, представляя их как набор лучших практик. Мы используем Scala для создания высококачественных сервисов, которые представляют собой распределенные системы - наше мнение возможно будет предвзято - но большинство советов здесь должны работать без проблем и при переносе на другие системы. Все эти советы не являются истиной в последней инстанции, и небольшое отклонение должно быть вполне приемлемым.

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

Немного, о Scala-программе. Вы не пишете код ни на Java, ни на Haskell, ни на Python; написание Scala-программы отличается от написания на любом из этих языков. Для того чтобы использовать язык эффективно, вы должны описать свои проблемы, в терминах этого языка. Вас никто не принуждает использовать программу, написанную на Java, в Scala, в большинстве случаев она будет уступать оригиналу.

Данный документ не введение в язык Scala, мы предполагаем, что читатель знаком с языком. Вот некоторые ресурсы для обучения языку Scala:

Данный набор статей - это живой документ, который будет меняться с учетом наших текущих “лучших практик”, но основные идеи вряд ли изменятся: писать код, который всегда будет легко читаем; писать универсальный код, но не в ущерб ясности; пользоваться простыми функциями языка, которые обладают большой мощью, но избегать эзотерических функций (особенно в системе типов). Прежде всего, всегда нужно находить компромиссы в том, что вы делаете. Сложность языка требуется в комплексных реализациях, потому что она порождает сложность: в рассуждениях, в семантике, во взаимодействия между особенностями системы, а также в понимании между вашими сотрудниками. Таким образом, трудность является производной от сложности - вы всегда должны убедиться, что ее полезность превышает ее стоимость.

И получайте удовольствие.

Форматирование

Специфические способы форматирования кода - пока они полезны - не имеют большого значения. По определению, стиль не может быть хорошим или плохим, почти все определяет личное предпочтение. Однако, последовательное применение одних и тех же правил форматирования будет почти всегда увеличивать удобочитаемость. Читатель, уже знакомый с данным стилем, не должен разбираться в еще одном наборе местных соглашений или расшифровать еще одну часть языковой грамматики.

Это имеет особое значение для Scala, поскольку у его грамматики высокий уровень вхождения. Один говорящий пример - вызов метода: Методы могут быть вызваны с помощью “.”, либо с использованием пробела, либо без круглой скобки для не методов, не возвращающих значений, или для унарных методов, с круглой скобкой для этих же случаев, и так далее. Кроме того, различные стили вызова методов оставляют двусмысленность в его грамматике! Конечно, последовательное применение заранее определенного набора правил форматирования решит большую часть двусмысленности и для человека, и для машины.

Мы придерживаемся [Правила форматирования в языке Scala] (http://docs.scala-lang.org/style/) и дополнительно следующих правил.

Пробельные символы

При отступе используется 2 пробельных символа. Мы стараемся избегать строк, длиной более 100 символов. Мы используем одну пустую строку между методом, классом и определениями объекта.

Именование

Используйте короткие имена для небольших областей видимости
Применяйте i,j и k и тому подобные переменные в циклах
Используйте длинные имена для больших областей видимости
Внешние API должны иметь длинные и понятные имена, которые придают смысл. Future.collect вместо Future.all.
Используйте стандартные сокращения, и откажитесь от эзотерических
Всем известны ok, err или defn,а вот sfri используется не так часто.
Не используйте одни и те же имена для различных целей
Применяйте val
Избегайте использования ` для перегрузки зарезервированных имен.
Используйте typ вместо `type`
Используйте в имени active для операций с побочными эффектами
user.activate() вместо user.setActive()
Используйте описательные имена для методов, которые возвращают значения
src.isDefined вместо src.defined
Не используйте у геттеров(getter) префикс get
В соответствии с предыдущим правилом: лучше применять site.count вместо site.getCount
Не используйте повторно имена, которые уже есть в пакете или в названии объекта
Предпочтительно:
 object User {
   def get(id: Int): Option[User]
}
вместо
object User {
   def getUser(id: Int): Option[User]
}
Они являются избыточными, так как при использовании: User.getUser дает не больше информации, чем User.get.

Импорт

Располагайте строки импорта в алфавитном порядке
Так их проще определить визуально, и так проще для автоматизации.
Используйте фигурные скобки при импортировании нескольких имен из пакета
import com.twitter.concurrent.{Broker, Offer}
Используйте символ подчеркивания, когда импортируйте более 6 имен
например: import com.twitter.concurrent._
Не применяйте этот знак без оглядки, некоторые пакеты экспортируют слишком много имен
Когда используете коллекции, уточняйте имена при импортировании scala.collection.immutable и/или scala.collection.mutable
Изменяемые и неизменяемые коллекции имеют двойные имена. Уточнение имен сделает очевидным для читателя, какой вариант используется (например "immutable.Map")
Не используйте относительный импорт из других пакетов
Избегайте
import com.twitter
import concurrent
в пользу более одназначного
import com.twitter.concurrent
Располагайте строки импорта вверху файла
Читатель может обратиться ко всем строкам импорта в одном месте

Фигурные скобки

Фигурные скобки используются для создания сложных выражений (они служат другим целям в “module language”), где значение соответствующего выражения является последним выражением в списке. Старайтесь не использовать скобки для простых выражений; пишите

def square(х: Int) = х*х

.LP, вместо

def square(х: Int) = {
  х * х
}

.LP, хотя это может быть привлекательным, чтобы отличить тело метода синтаксически. Первый вариант имеет меньший беспорядок, и его легче читать. Избегайте лишних синтаксических конструкций, если это не уточняется дополнительно.

Сравнение с образцом

Используйте сравнение с образцом в определении функций, когда это необходимо; Вместо

list map { item =>
  item match {
    case Some(x) => x
    case None => default
  }
}

лучше написать так

list map {
  case Some(x) => x
  case None => default
}

Видно, что элементы списка сейчас отображаются более ясно — дополнительно уточнять ничего не нужно.

Комментарии

Используйте ScalaDoc, чтобы предоставлять документацию по API. Используйте следующий стиль:

/**
 * ServiceBuilder builds services 
 * ...
 */

вместостандартного стиля ScalaDoc:

/** ServiceBuilder builds services
 * ...
 */

Не прибегайте к ASCII искусству или другим визуальным украшениям. Документируйте API, но не добавляйте ненужных комментариев. Если вы добавляете комментарии, чтобы объяснить поведение вашего кода, сначала спросите себя, может ли код быть переписан так, чтобы стало очевидным, что он делает. Лучше предпочесть “Очевидно, это работает” вместо “Это работает, очевидно” (цитата Энтони Хоара).

(прим. переводчика: “Есть два метода создания программного обеспечения. Один из них — сделать программу настолько простой, что, очевидно, в ней нет недостатков. И другой, сделать приложение настолько сложным, что в нем не видно явных недостатков.” - Энтони Хоар выдержка из лекции, Премия Тьюринга)

Типы и обобщенные типы

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

Использование системы типов должно отражать эту цель, но мы должны не забывать и о читателе: разумное использование типов может служить повышением ясности. Все слишком усложнять - значит запутывать остальных.

Мощная система типов в Scala является результатом общих усилий различных академических опытов и разработок (например, [Scala программирование на уровне системы типов] (http://apocalisp.wordpress.com/2010/06/08/type-level-programming-in-scala/)). Несмотря на то, что это увлекательная академическая статья, эти методы редко находят полезное применение в реальном коде приложений. Их можно избегать.

Возвращаемый тип аннотаций

Хотя Scala позволяет опускать их, такие аннотации обеспечивают хорошую документацию: это особенно важно для публичных методов. Там где возвращаемый тип метода очевиден, их можно опустить.

Это особенно важно при создании экземпляров объектов с миксинами(Mixins), так как компилятор Scala создает тип синглтон(singleton) для них. Например, make в примере:

trait Service
def make() = new Service {
  def getId = 123
}

не имеет возвращаемого типа Service; компилятор создает Object with Service{def getId: Int}. Вместо того, чтобы использовать явную аннотацию:

def make(): Service = new Service{}

В настоящее время автор может смешивать множество трейтов(traits) без изменения общедоступного типа make, делая возможность легче управлять обратной совместимостью.

Расхождение

Расхождение возникает, когда обобщенные типы объединены с выделением подтипов. Они определяют, как выделение подтипов типа contained относится к выделению подтипов типа container. Поскольку в Scala имеется определенное соглашение по комментированию, то авторы общих библиотек - особенно коллекций - должны активно писать комментарии. Такие комментарии важны для удобства работы с общим кодом, но неверные комментарии могут быть опасны.

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

Коллекции Immutable должны быть ковариантны. Методы, которые получают contained тип должны быть “понижены”(“downgrade”) до соответствующей коллекции:

trait Collection[+T] {
  def add[U >: T](other: U): Collection[U]
}

Mutable коллекции должны быть инвариантны. Ковариация обычно бессмысленна с изменяемыми(mutable) коллекциями. Рассмотрим

trait HashSet[+T] {
  def add[U >: T](item: U)
}

и следующую иерархию типов:

trait Mammal
trait Dog extends Mammal
trait Cat extends Mammal

Допустим, у нас сейчас есть хеш-коллекция из объектов Собака

val dogs: HashSet[Dog]

создаем хеш-коллекцию Млекопитающих и добавляем в колллекцию объект Кошка

val mammals: HashSet[Mammal] = dogs
mammals.add(new Cat{})

Теперь эта хеш-коллекция не является коллекцией объектов Собака!

(прим. переводчика: Подробнее о ковариантности и контрвариантности)

Псевдонимы типов

Используйте псевдонимы типов, когда они позволяют удобно именовать или разъяснять цели, но не искажают типы, которые и так очевидны.

() => Int

данная запись более понятна чем

type IntMaker = () => Int
IntMaker

так как это короче и используется общий тип, однако

class ConcurrentPool[K, V] {
  type Queue = ConcurrentLinkedQueue[V]
  type Map   = ConcurrentHashMap[K, Queue]
  ...
}

более полезен, так как передает цель и улучшает краткость.

Не используйте разделение на подклассы, когда псевдоним делает тоже самое

trait SocketFactory extends (SocketAddress => Socket)

SocketFactory это функция которая создает Socket. Использование псевдонима

type SocketFactory = SocketAddress => Socket

более правильно. Теперь мы можем обеспечить функциональные идентификаторы для значений типаSocketFactory, а также использовать композицию функций:

val addrToInet: SocketAddress => Long
val inetToSocket: Long => Socket

val factory: SocketFactory = addrToInet andThen inetToSocket

Псевдонимы типов связаны с именами, которые стоят выше в иерархии, при помощи объектов пакета:

package com.twitter
package object net {
  type SocketFactory = (SocketAddress) => Socket
}

Обратите внимание на то, что псевдонимы, это не новые типы - они эквивалентны синтаксической замене типа новым именем.

Неявные преобразования

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

Если действительно хотите использовать неявные преобразования, прежде всего спросите себя, есть ли способ достигнуть той же цели без их помощи.

Не используйте неявные преобразования, чтобы сделать автоматическое преобразование между похожими типами данных (например, преобразование списка в поток); это лучше сделать явно, потому что у типов есть различная семантика, и читатель должен остерегаться подобных реализаций.

Коллекции

У Scala есть универсальная, богатая, мощная, и прекрасно составленная библиотека коллекций; коллекции - высокоуровневые реализации и они представляют собой большой набор различный операций. Множество действий с коллекциями и преобразования над ними могут быть выражены кратко и четко, но небрежное применение функций может привести к противоположному результату. Каждый Scala-программист должен прочитать Сollections design document; он даст большее понимание при работе с библиотекой коллекций в Scala.

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

Иерархия

Библиотека коллекций очень большая: в дополнение к сложной иерархии - корнем которой является Traversable[T] - существуют immutable и mutable варианты для большинства коллекций. Несмотря на сложность, на следующей диаграмме содержатся важные различия между immutable и mutable иерархиями

Iterable[T] - это любая коллекция, элементы которой могут быть проитерированы, она имеет метод iterator(а также метод foreach).Seq[T]- это коллекция, элементы которой отсортированы,Set[T]- является аналогом математического множества (неупорядоченная коллекция уникальных элементов), и Map[T] - который представляет собой неотсортированный ассоциативный массив.

Применение

так становится ясно, что используется mutable вариант

Этот стиль отделяет семантику коллекции от ее реализации, позволяя библиотеке коллекций использовать наиболее подходящий тип: если вам нужен Map, не обязательно использовать Красно-черное дерево(Red-Black Tree). Кроме того, стандартные конструкторы будут часто использовать специализированные представления: например, Map() будет использовать объект с 3 полями для объектов с 3 ключами(Map3).

В заключение, к сказанному выше: в ваших собственных методах и конструкторах старайтесь использовать самую универсальную коллекцию. Обычно это сводится к одной из приведенных: Iterable, Seq, Set, или Map. Если ваш метод нуждается в последовательности, используйте Seq[T], а не List[T].

Стиль

Функциональное программирование призывает к цепочечным преобразованиям immutable коллекций для получения желаемого результата. Это часто приводит к очень коротким решениям, но также может ввести в заблуждение читателя - часто трудно понять намерения автора, или отслеживать все промежуточные результаты, которые подразумеваются. Например, предположим, что мы хотим подсчитать голоса за разные языки программирования из определенного набора (язык, число голосов); показывая их, в порядке убывания числа голосов, мы могли бы написать:

val votes = Seq(("scala", 1), ("java", 4), ("scala", 10), ("scala", 1), ("python", 10))
val orderedVotes = votes
  .groupBy(_._1)
  .map { case (which, counts) => 
    (which, counts.foldLeft(0)(_ + _._2))
  }.toSeq
  .sortBy(_._2)
  .reverse

это кратко и правильно, но почти любому читателю нужно время, чтобы восстановить в голове первоначальные намерения автора. Стратегия, которая призвана уточнить решение использование промежуточных результатов и параметров:

val votesByLang = votes groupBy { case (lang, _) => lang }
val sumByLang = votesByLang map { case (lang, counts) =>
  val countsOnly = counts map { case (_, count) => count }
  (lang, countsOnly.sum)
}
val orderedVotes = sumByLang.toSeq
  .sortBy { case (_, count) => count }
  .reverse

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

val orderedVotes = {
  val votesByLang = ...
  ...
}

Производительность

Высокоуровневые библиотеки коллекций (как обычно и с высокоуровневыми конструкциями), делают определение производительности более трудоемким: чем дальше вы отклоняетесь от указания конкретных команд компьютеру - другими словами, императивного стиля - тем тяжелее предсказать точное значение производительности участка кода. Обосновать правильность, однако, обычно проще; также повышается удобство чтения кода. Со Scala ситуация осложнена средой исполнения Java; Scala скрывает операции упаковки/распаковки от нас, но они могут серьезно влиять на производительность или оказывать противоположное действие.

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

Первое правило оптимизации производительности состоит в том, чтобы понять почему ваше приложение медленно работает. Не стоит действовать без оглядки; профилируйте^ Yourkit - неплохой профилировщик] приложение перед тем как действовать дальше. Фокусируйтесь сначала на интенсивно используемых циклах и больших структурах данных. Чрезмерные усилия на оптимизацию обычно тратятся впустую. Помните принцип Кнута: “Преждевременная оптимизация - корень все зол.”

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

Java Коллекции

Используйте scala.collection.JavaConverters для взаимодействия с коллекциями Java. Эта коллекция неявно добавляет методы преобразования asJava и asScala. Их использование гарантирует, что такие преобразования являются явными, помогая читателю:

import scala.collection.JavaConverters._

val list: java.util.List[Int] = Seq(1,2,3,4).asJava
val buffer: scala.collection.mutable.Buffer[Int] = list.asScala

Параллелизм

Современные сервисы обладают высоким уровнем параллелизма - серверы выполняют 10–100 тысяч одновременных операций - и их обработка подразумевают сложность, которая является центральной темой в надежных программных системах.

Потоки являются средством выражения параллелизма: они дают вам независимые контексты выполнения с общей разделяемой памятью, которая управляется операционной системой. Тем не менее, создание потоков является затратной операцией в Java и этим ресурсом необходимо управлять, как правило, с использованием пулов. Это создает дополнительные сложности для программиста, а также обладает высокой степенью связности: трудно отделить логику приложения от используемых им основных ресурсов.

Эта сложность особенно заметна при создании сервисов, которые имеют высокую степень параллелизма: каждый приходящий результат запроса в множество всех запросов на каждый уровень системы. В таких системах, пулы потоков должны быть организованы таким образом, чтобы они быди сбалансированы в зависимости от количества запросов на каждом уровне: беспорядок в одном пуле потоков негативно влияет на другие.

Надежная система должна также не упускать из виду тайм-ауты и отказы, оба этих элемента требуют введения дополнительного «контроля» потоков, тем самым усложняя проблему еще больше. Заметим, что если бы потоки были более дешевыми, то эти проблемы стали бы меньше: необходимость в пулах, тайм-аутах потоков можно было бы отбросить, и никаких дополнительных ресурсов для управления не потребовалось бы.

Таким образом, управление ресурсами ставит под угрозу модульность.

Futures

Используйте futures(актор с возможностью блокировки создающего его потока, если создающий поток запросил результат вычисления - прим. переводчика) для управления параллелизмом. Они позволяют отделить параллельные операции от управления ресурсами: например, Finagle объединяет параллельные операции в несколько потоков эффективным образом. Scala имеет легковесный синтаксис замыканий, поэтому futures вводят немного синтаксического сахара; и они становятся все более популярны среди программистов.

Futures позволяют программисту выразить параллельные вычисления в декларативном стиле, скомпоновать, и управлять источником ошибки. Эти качества убедили нас в том, что они особенно хорошо подходят для использования в функциональных языках программирования, где подобный стиль поощряется.

Изменяйте futures вместо создания собственных. Futures позволяют поймать ошибку, определить сигнал отказа, и позволяет программисту не думать о релизации модели памяти в Java. Осторожный программист может написать следующее решение для RPC последовательности из 10 элементов, а затем напечатать результаты:

val p = new Promise[List[Result]]
var results: List[Result] = Nil
def collect() {
  doRpc() onSuccess { result =>
    results = result :: results
    if (results.length < 10)
      collect()
    else
      p.setValue(results)
  } onFailure { t =>
    p.setException(t)
  }
}

collect()
p onSuccess { results =>
  printf("Got results %s\n", results.mkString(", "))
}

Программист должен убедиться, что RPC ошибки будут распространяться дальше, объединяя код и контроль потока выполнения; хуже того, этот код неверен! Без объявления переменной results, мы не можем гарантировать, что results содержит предыдущее значение на каждой итерации. Модель памяти Java не так проста как кажется, но, к счастью, мы можем избежать всех этих ошибок с помощью декларативного стиля:

def collect(results: List[Result] = Nil): Future[List[Result]] =
  doRpc() flatMap { result =>
    if (results.length < 9)
      collect(result :: results)
    else
      result :: results
  }

collect() onSuccess { results =>
  printf("Got results %s\n", results.mkString(", "))
}

Мы используем flatMap в последовательности операций и сохраняем результат в список, пока идут вычисления. Это общая идея функциональных языков программирования реализована в Futures. Для этого требуется меньше заготовок, возникает меньше ошибок, а также читается лучше.

Используйте Futures комбинаторы. Future.select, Future.join, и Future.collect реализуют общие шаблоны при работе над несколькими Futures, которые должны быть объединены.

Коллекции

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

Если необходимо использовать immutable коллекцию, используйте - она совершенно прозрачна, поэтому рассуждать о них в контексте параллельных вычислений очень просто. Изменения в immutable коллекциях, как правило, выполняются путем обновления ссылки на текущее значение (в var ячейке или AtomicReference). Необходимо соблюдать осторожность, чтобы верно это применять: атомы должны быть повторно объявлены, и переменные должны быть объявлены на лету в порядке их объявления в других потоках.

Mutable параллельные коллекции имеют сложную семантику, и используют тонкости модели памяти в Java, поэтому убедитесь, что вы понимаете последствия - особенно при распространении обновлений - прежде чем начинать использовать их. Синхронные коллекции тоже неплохой вариант: операции, такие как getOrElseUpdate не могут быть правильно реализованы для параллельных коллекций, и создание сложных коллекций особенно подвержено ошибкам.

Управляющие структуры

Программы в функциональном стиле, как правило, требуют меньше традиционных управляющих структур, да и читать код лучше, когда он написан в декларативном стиле. Это обычно означает, разделение вашей логики на несколько небольших методов или функций, и склеивание их вместе с match выражениями. Функциональные программы также имеют тенденцию быть более ориентированными на выражения: ветви условных выражений для значений того же типа, for (..) yield вычисление дополнений, и рекурсия являются обычным делом.

Рекурсия

Формулировка проблемы в терминах рекурсии обычно упрощает ее, и если применяется оптимизация хвостовой рекурсии (которая может быть проверена с помощью аннотации @tailrec), компилятор преобразует ​​код в обычный цикл.

Рассмотрим довольно стандартную императивную реализацию кучи fix-down:

def fixDown(heap: Array[T], m: Int, n: Int): Unit = {
  var k: Int = m
  while (n >= 2*k) {
    var j = 2*k
    if (j < n && heap(j) < heap(j + 1))
      j += 1
    if (heap(k) >= heap(j))
      return
    else {
      swap(heap, k, j)
      k = j
    }
  }
}

Каждый раз, при входе в цикл, мы работаем с состоянием предыдущей итерации. Значением каждой переменной является результат вычисления функции определенной ветви выполнения, и значение возвращается в середине цикла, если был найден верный результат вычисления (внимательный читатель найдет похожие аргументы в Дейкстры “О вреде оператора Go To”).

Рассмотрим реализацию (хвостовой) рекурсии[1]:

@tailrec
final def fixDown(heap: Array[T], i: Int, j: Int) {
  if (j < i*2) return

  val m = if (j == i*2 || heap(2*i) < heap(2*i+1)) 2*i else 2*i + 1
  if (heap(m) < heap(i)) {
    swap(heap, i, m)
    fixDown(heap, m, j)
  }
}

здесь каждая итерация начинается с чистого листа, и нет никаких ссылочных полей: инвариантов достаточно. Об этом гораздо легче рассуждать, и этот код проще для чтения. Не существует потери производительности: поскольку метод является хвостовой рекурсией, компилятор переводит его в обычный цикл.

Возврат значений

Это не означает, что императивные структуры бесполезны. Во многих случаях они хорошо подходят для прекращения вычислений вместо условных переходов для всех возможных вариантов окончания вычислений: ведь в приведенном выше fixDown, return используется для досрочного прекращения, если мы в конце кучи.

Возврат значений может быть использован, чтобы сократить количество ветвлений и установить инварианты. Он помогает читателю за счет уменьшения вложенности и делает легче рассуждения о правильности последующего кода (доступ к элементу массива не может происходить за границей массива). Это особенно полезно в “guard” выражениях:

def compare(a: AnyRef, b: AnyRef): Int = {
  if (a eq b)
    return 0

  val d = System.identityHashCode(a) compare System.identityHashCode(b)
  if (d != 0)
    return d

  // slow path..
}

Используйте return для уточнения и улучшения читаемости, но не так, как в императивных языках; избегайте его использования для возврата результатов вычислений. Вместо

def suffix(i: Int) = {
  if      (i == 1) return "st"
  else if (i == 2) return "nd"
  else if (i == 3) return "rd"
  else             return "th"
}

лучше использовать:

def suffix(i: Int) =
  if      (i == 1) "st"
  else if (i == 2) "nd"
  else if (i == 3) "rd"
  else             "th"

но использование выражения match подходит лучше:

def suffix(i: Int) = i match {
  case 1 => "st"
  case 2 => "nd"
  case 3 => "rd"
  case _ => "th"
}

Обратите внимание, что использование returns тоже имеет свою цену: при использовании внутри замыкания,

seq foreach { elem =>
  if (elem.isLast)
    return

  // process...
}

в байт-коде это реализовано в виде пары исключений catching/throwing, использование которых в реальном коде, влияет на производительность.

Циклы for и генераторы

for обеспечивает краткий и естественный способ для циклической обработки данных и их накопления. Это особенно полезно, когда обрабатывается много последовательностей. Синтаксис for противоречит основному механизму выделения и управления замыканиями. Это может привести к непредвиденным расходам и двусмысленности, например

for (item <- container) {
  if (item != 2) return
}

. LP может привести к ошибке выполнения, если произойдет задержка при вычислении container, что делает return не локальным!

По этим причинам, обычно предпочтительнее, вызвать foreach, flatMap, map и filter напрямую - но стоит использовать for для того, чтобы прояснить вычисления.

require и assert

require и assert, оба оператора описаны в документации. Оба полезны для ситуаций, в которых система типов не может определить нужный вариант типа. assert, используется для инвариантов, которые предполагаются в коде (или внутреннем или внешнем), например

val stream = getClass.getResourceAsStream("someclassdata")
assert(stream != null)

В то время как require используется для представления API контрактов:

def fib(n: Int) = {
  require(n > 0)
  ...
}

Функциональное программирование

Программирование, ориентированное на преобразование значений (value oriented programming), имеет много преимуществ, особенно когда используется вместе с конструкциями функционального программирования. Этот стиль делает акцент на преобразование значений вместо изменения состояния, что в результате дает сравнительно простой код, предоставляет конструкции, которые меньше подвержены изменениям, и, как следствие, обеспечивает легкость понимания. Case-классы, сопоставление с образцом, деконструкция, вывод типов, а также простой синтаксис создания замыканий и методов являются неотъемлемыми элементами такого подхода.

Case-классы как алгебраические типы данных

Case-классы используются для описания алгебраических типов данных (АТД): они полезны при реализации большого числа структур данных и дают возможность писать компактный код с однотипными конструкциями, особенно когда используются в сочетании с сопоставлением с образцом. Механизм сопоставления осуществляет исчерпывающий анализ, предоставляя еще более сильные статические гарантии.

Используйте следующий шаблон, когда реализуете АТД, с использованием case-классов:

sealed trait Tree[T]
case class Node[T](left: Tree[T], right: Tree[T]) extends Tree[T]
case class Leaf[T](value: T) extends Tree[T]

тип Tree[T]имеет два конструктора: Node и Leaf. Объявление типа с модификатором sealed позволяет компилятору выполнить исчерпывающий анализ, поскольку так конструкторы не могут быть добавлены в другом исходном файле.

При использовании вместе с сопоставлением с образцом, такая реализация дает код, который краток и “очевидно правилен”:

def findMin[T <: Ordered[T]](tree: Tree[T]) = tree match {
  case Node(left, right) => Seq(findMin(left), findMin(right)).min
  case Leaf(value) => value
}

Несмотря на то что классическое пременение АТД это рекурсивные структуры данных, такие как деревья, область, где они могут быть с успехом использованы значительно шире. В частности, с применением алгебраических типов данных без особого труда реализуются несвязные объединения, часто встречающиеся в машинах состояний.

Option

Тип Option является контейнером, который может быть либо пустым (None), либо содержать значение (Some(value)). Он предоставляет безопасную альтернативу использованию null, и его следует применять на месте null везде, где это возможно. None и Some представляют из себя коллекции (содержащие не более одного элемента) и они оснащены характерными для коллекций операциями - чаще используйте их!

Вместо

var username: String = null
...
username = "foobar"

следует писать

var username: Option[String] = None
...
username = Some("foobar")

поскольку такой вариант безопаснее: использование типа `Option` обеспечивает статическую проверку значения `username` на пустоту.

Применение Option в условных конструкциях должно выполняться с использованием метода foreach; вместо

if (opt.isDefined)
  operate(opt.get)

пишите

opt foreach { value =>
  operate(value)}

Такой стиль может показаться странным, тем не менее он обеспечивает большую безопасность (мы не используем метод get, который может вызывать исключения) и краткость. Если нужно обработать оба возможных варианта, используйте сопоставление с образцом:

opt match {
  case Some(value) => operate(value)
  case None => defaultAction()
}

но если все, что требуется, это предоставить значение по-умолчанию, используйте метод `getOrElse`

operate(opt getOrElse defaultValue)

Не следует злоупотреблять применением Option: если есть осмысленное значение по-умолчанию - Null Object - лучше использовать его. Кроме того, Option имеет удобный конструктор для упаковки значений, которые могут быть null

Option(getClass.getResourceAsStream("foo"))

результат этого выражения имеет тип `Option[InputStream]` и примет значение None, в случае, если `getResourceAsStream` вернет `null`.

Сопоставление с образцом

Сопоставление с образцом (x match { ...) широко распространено в хорошо написанном Scala коде: оно совмещает в одной конструкции условные выражения, деконструкцию и приведение типов. При правильном использовании сопоставление улучшает безопасность и делает код более понятным:

Используйте сопоставление с образцом для реализации переключения типов:

obj match {
  case str: String => ...
  case addr: SocketAddress => ...

Лучше всего сопоставление с образцом работает в сочетании с деконструкцией (например, если вы сопоставляете case-классы); вместо

animal match {
  case dog: Dog => "dog (%s)".format(dog.breed)
  case _ => animal.species
  }

лучше написать

animal match {
  case Dog(breed) => "dog (%s)".format(breed)
  case other => other.species
}

Пишите собственные экстракторы, но только вместе с сопровождающим конструктором (apply), в противном случае их использование может быть неуместным.

Не используйте сопоставление с образцом для условных выражений, когда имеет смысл использовать значения по-умолчанию. Библиотечные коллекции обычно предоставляют методы, возвращающие Option; избегайте

val x = list match {
  case head :: _ => head
  case Nil => default
}

потому, что

val x = list.headOption getOrElse default

и короче и больше соответствует цели.

Частичные функции

Scala предоставляет краткий синтаксис для определения частичных функций:

val pf: PartialFunction[Int, String] = {
  case i if i%2 == 0 => "even"
}

Кроме того их можно группировать, используя `orElse`:

val tf: (Int => String) = pf orElse { case _ => "odd"}

tf(1) == "odd"
tf(2) == "even"

Частичные функции вречаются во многих ситуациях и эффективно описываются с использованием типа PartialFunction, например в качестве аргументов методов

trait Publisher[T] {
  def subscribe(f: PartialFunction[T, Unit])
}

val publisher: Publisher[Int] = ..
publisher.subscribe {
  case i if isPrime(i) => println("found prime", i)
  case i if i%2 == 0 => count += 2
  /* ignore the rest */
}

или в ситуациях, где для возвращения резултата, в противном случае, потребовалось бы использовать Option:

// Attempt to classify the the throwable for logging.
type Classifier = Throwable => Option[java.util.logging.Level]

C использованием `PartialFunction` это может быть выражено лучше:

type Classifier = PartialFunction[Throwable, java.util.Logging.Level]

потому что так выражения отлично группируются:

val classifier1: Classifier
val classifier2: Classifier

val classifier = classifier1 orElse classifier2 orElse { _ => java.util.Logging.Level.FINEST }

Деконструкция

Деконструкция значений (Destructuring bindings) тесно связана с сопоставлению с образцом. Она использует тот же механизм, но применяется там, где нет необходимости выбирать между несколькими вариантами (что устраняет возможность возникновения исключения при сопоставлении). Деконструкция особенно полезна при использовании кортежей и case-классов.

val tuple = ('a', 1)
val (char, digit) = tuple

val tweet = Tweet("just tweeting", Time.now)
val Tweet(text, timestamp) = tweet

Ленивые вычисления

Если в коде на Scala при объявлении поля перед ключевым словом val указан модификатор lazy, то значение этого поля будет вычислено только когда оно потребуется. Так как поля и методы в Scala эквивалентны (не считая того, что поля неявно объявляются с модификатором private[this]), то

lazy val field = computation()

это (примерное) сокращение для

var _theField = None
def field = if (_theField.isDefined) _theField.get else {
  _theField = Some(computation())
  _theField.get
}

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

Ленивые поля являются потокобезопасными.

Передача по имени

Параметры метода могут быть определены как передаваемые по имени, что означает, что они привязываются не к значению, а к некоторому вычислению, которое может быть выполнено повторно. Эта возможность должна применяться с осторожностью; если тот, кто вызывает этот метод, ожидает что параметры передаются по значению, то он может быть сильно удивлен. Эта возможность нужна для описания предметно-ориентированных языков (DSL) с естественным синтаксисом - в частности новые управляющие конструкции могут выглядеть так же, как если бы они являлись естественной возможностью языка.

Используйте передачу по имени только в тех управляющих конструкциях, при вызове которых очевидно, что то, что передается в них является “блоком”, а не результатом непредвиденного вычисления. Указывайте передаваемые по имени аргументы только в последней позиции последнего списка аргументов. Когда используете передачу по имени, убедитесь, что метод имеет название, из которого очевидно, что аргумент является передаваемым по имени.

Если вы хотите, чтобы значение вычислялось несколько раз, особенно, если это вычисление сопровождается побочными эффектами, вам следует явно использовать функции:

class SSLConnector(mkEngine: () => SSLEngine)

Ваши намерения останутся очевидными и тот, кто будет использовать этот код, не столкнется с неожиданностями.

flatMap

Метод flatMap, сочетающий в себе методы map и flatten, заслуживает особого внимания, так как обладает не сразу заметной мощью и может оказаться очень полезным. Как и его собрат map, он часто доступен в нетрадиционных коллекция, таких как Future или Option. Его поведение легко понять по его сигнатуре; для некоторого Container[A]

flatMap[B](f: A => Container[B]): Container[B]

flatMap вызывает функцию f для элементов (либо элемента) коллекции, таким образом создавая новые коллекции, которые в конце объединяются в одну. Например, чтобы получить все перестановки двух неодинаковых символов:

val chars = 'a' to 'z'
val perms = chars flatMap { a => 
  chars flatMap { b => 
    if (a != b) Seq("%c%c".format(a, b)) 
    else Seq() 
  }
}

что равносильно более краткой конструкции с использованием спискового включения (for-comprehension) (являющейся синтаксическим сахаром для написанного выше):

val perms = for {
  a <- chars
  b <- chars
  if a != b
} yield "%c%c".format(a, b)

flatMap часто бывает полезен, когда приходится иметь дело с Option - здесь он свернет цепочки Option в одну:

val host: Option[String] = ..
val port: Option[Int] = ..

val addr: Option[InetSocketAddress] =
  host flatMap { h =>
    port map { p =>
      new InetSocketAddress(h, p)
    }
  }

Это можно сделать еще короче, используя `for`

val addr: Option[InetSocketAddress] = for {
  h <- host
  p <- port
} yield new InetSocketAddress(h, p)

Применение flatMap для Future обсуждается в разделе разделе futures.

Объектно-ориентированное программирование

Большая часть возможностей Scala обеспечивается благодаря объектной системе. Scala является чистым языком в этом смысле, потому что все элементы являются объектами, нет различия между примитивными типами и составными. Scala также имеет примеси (Mixins), позволяющие более четкое построение модулей, которые можно гибко собрать на этапе компиляции со всеми преимуществами статической проверки типов.

Основная идея системы примесей в том, чтобы избежать необходимости в традиционном построение зависимостей. Кульминацией этого “компонентного стиля” программирования является the cake pattern.

Внедрение зависимостей

На своем опыте мы обнаружили, что Scala на самом деле удаляет большую часть синтаксических издержек “классического” (в конструкторе) внедрения зависимостей, и мы можем это использовать это: более ясное описание, зависимости все еще закодированы в типе, а конструкция класса так синтаксически проста, что это становится незаметно. Это скучно и просто, и это работает. Используйте внедрение зависимостей для модульного построения программы, и в частности предпочитайте композицию наследованию - это приведет к более модульным и легко тестируемым программам. Если возникает ситуация, когда требуется наследование, то спросите себя: как бы вы спроектировали программу, если бы язык не имел такой возможности, как наследование? Ответ может быть не так прост.

Для внедрения зависимостей обычно используются трейты(traits),

trait TweetStream {
  def subscribe(f: Tweet => Unit)
}
class HosebirdStream extends TweetStream ...
class FileStream extends TweetStream ..

class TweetCounter(stream: TweetStream) {
  stream.subscribe { tweet => count += 1 }
}

Они обычно исопользуются для внедрения фабрик - объектов, которые генерируют другие объекты. В этом случае предпочтительно использовать более простые функции вместо специализированных фабрик типов.

class FilteredTweetCounter(mkStream: Filter => TweetStream) {
  mkStream(PublicTweets).subscribe { tweet => publicCount += 1 }
  mkStream(DMs).subscribe { tweet => dmCount += 1 }
}

Трейты(Traits)

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

Старайтесь писать трейты короткими и простыми: но не стоит разделять функциональности между трейтами, думайте о них, как о небольших кусочках, которые связаны друг с другом. Например, представьте, что у вас есть то, что может сделать IO:

trait IOer {
  def write(bytes: Array[Byte])
  def read(n: Int): Array[Byte]
}

разделите код на два поведения:

trait Reader {
  def read(n: Int): Array[Byte]
}
trait Writer {
  def write(bytes: Array[Byte])
}

или объедините их вместе, чтобы получить то, что было в IOer: new Reader with Writer… минимализм трейта приводит к простоте и более "чистой" модульности.

Видимость

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

private[this]

Член класса помеченный как private,

private val x: Int = ...

видим для всех экземпляров этого класса (но не и подклассов). В большинстве случаев вам нужен private[this].

private[this] val x: Int = ..

который ограничивает видимость для конкретного экземпляра.Компилятор Scala также может переводить private[this] в простое поле для доступа (поскольку доступ ограничен для статически определенного класса), которое иногда может помочь при оптимизации производительности.

Класс синглтон (Singleton)

Общепринятая в Scala практика по созданию класса синглтона, например

def foo() = new Foo with Bar with Baz {
  ...
}

В таких ситуациях, видимость может быть ограничена, при объявлении возвращаемого типа:

def foo(): Foo with Bar = new Foo with Bar with Baz {
  ...
}

где вызывающие foo() участки кода будут ограничены с помощью (Foo with Bar) для возвращаемого экземпляра.

Структурная типизация

Не используйте структурных типов в обычных случаях. Они являются удобным и мощным средством, но к сожалению не имеют эффективной реализации для JVM. Однако - в связи с особенностями реализации - они обеспечивают очень краткое выражение для написания отражений(reflection).

val obj: AnyRef
obj.asInstanceOf[{def close()}].close()

Сборка мусора

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

Прежде чем пытаться бороться с проблемами производительности сборщика мусора, посмотрите это Выступление Аттилы, который иллюстрирует некоторый наш опыт по настройке сборщика мусора.

В Scala собственно вашим единственным инструментом облегчения проблем со сборщиком мусора является создание меньшего количества мусора, но не стоит дествовать без предварительных данных! Пока вы не сделали того, что явно ухудшит ситуацию, используйте различные инструменты профилирования Java - Наши собственные инструменты включают heapster и gcprof.

Java совместимость

Когда мы пишем код на Scala, который используется в Java, мы уверены, что эта возможность осталась из чисто идеалогическох соображений. Обычно для этого не требуется лишних усилий - классы и чистые трейты в точности эквивалентны их Java коллегам - но иногда нужно предоставить некоторые Java API. Хороший способ получить это для вашего библиотечного Java API, это написать юнит тест в Java (только для компиляции), это также обеспечит то, что поведение вашей Java библиотеки остается стабильным с течением времени, потому что компилятор Scala может быть неустойчив в этом отношении.

Трейты, которые реализуют некоторый функционал, не пригодны для непосредственного использования в Java: для этого нужно расширить абстрактный класс с трейтом.

// Не используется напрямую в Java
trait Animal {
  def eat(other: Animal)
  def eatMany(animals: Seq[Animal) = animals foreach(eat(_))
}

// А так можно:
abstract class JavaAnimal extends Animal

Стандартные библиотеки Twitter

Наиболее важными стандартными библиотеками в Twitter являются Util и Finagle. Util должна рассматриваться как расширение для стандартной библиотеки Scala и Java, предоставляя недостающую функциональность и более соответствует конкретной реализации. Finagle - это наша RPC система, ядро ​​распределенных системных компонентов.

Futures

Futures обсуждались коротко в разделе о параллельных вычислениях. Они являются центральным механизмом координации асинхронных процессов и широко распространены в нашем кода и в ядре Finagle. Futures позволяют объединять одновременные события, а также упрощают рассуждения о высокопараллельных операциях. Их можно эффективно реализовать на JVM.

Futures в Twitter являются асинхронными, так что блокирование операций - в основном любой операции, может быть приостановлено при выполнении своим потоком; сетевой ввод-вывод и дисковый ввод-вывод, например - должны быть обработаны системой, что обеспечивают сами futures для результатов указанных операций. Finagle предоставляет такую систему для сетевого ввода-вывода.

Futures являются простыми и понятными: они ждут результата вычислений, которые еще не завершились. Они представляют собой просто контейнер - заполнитель. Вычисление может и не закончится, и это должно быть закодировано: Future может находится ровно в одном из 3-х состояний: в ожидании, провалено или завершено.

Aside: Композиция

Давайте вернемся к тому, что мы подразумеваем под композицией: сочетание простых компонентов в более сложных. Самый распространенный пример, это функция композиции: Даны функции f и g, функция композиции (g∘f)(x) = g(f(x)) — сначала результат получаем применения x в f, и потом результат этого выражения применяется в g — может быть записано на Scala:

val f = (i: Int) => i.toString
val g = (s: String) => s+s+s
val h = g compose f  // : Int => String
    
scala> h(123)
res0: java.lang.String = 123123123

функция h будет композитной. Это новая функция, которая объединяет f и g.

Futures являются своего рода коллекцией - это контейнер из 0 или 1 элементов - и вы увидите, что они имеют стандартные методы коллекций (например, map, filter, и foreach). С Future значение откладывается(deferred), результат применения любого из этих методов также откладывается, в

val result: Future[Int]
val resultStr: Future[String] = result map { i => i.toString }

функция { i => i.toString } не вызывается, пока целочисленное значение не становится доступно, и преобразвание коллекции resultStr также откладывается до этого момента.

Списки могут быть свернуты;

val listOfList: List[List[Int]] = ..
val list: List[Int] = listOfList.flatten

и тоже самое можно сделать для futures:

val futureOfFuture: Future[Future[Int]] = ..
val future: Future[Int] = futureOfFuture.flatten

с futures происходит задержка, реализация flatten — должна немедленно — вернуть future, который ждет завершения внешнего future (Future[Future[Int]]) и после этого выполняется внутренний (Future[Future[Int]]). Если внешний future завершается с ошибкой, свернутый future должен также завершиться с ошибкой.

Futures (подобны Спискам) также имеют flatMap; Future[A] объявление:

flatMap[B](f: A => Future[B]): Future[B]

который похож на комбинацию map и flatten, и можем реализовать это следующим способом:

def flatMap[B](f: A => Future[B]): Future[B] = {
  val mapped: Future[Future[B]] = this map f
  val flattened: Future[B] = mapped.flatten
  flattened
}

Это мощная комбинация! С помощью flatMap мы можем определить Future который является результатом двух последовательных futures, второй future вычисляется на основе результатов первого. Представьте себе, что мы должны были бы сделать два RPC для аутентификации пользователя (ID), то мы могли бы определить составную операцию следующим образом:

def getUser(id: Int): Future[User]
def authenticate(user: User): Future[Boolean]

def isIdAuthed(id: Int): Future[Boolean] = 
  getUser(id) flatMap { user => authenticate(user) }

Дополнительным преимуществом этого типа композиции является то, что есть встроенная обработка ошибок: future не возвратиться из isAuthed(..) если в одной из функций getUser(..) или authenticate(..) не пишется дополнительного кода для обработки ошибок.

Стиль

Future методы обратного вызова (callback) (respond, onSuccess, onFailure, ensure) вернут новый future, который сцеплен со своим родителем. Этот future гарантировано будет завершен только после завершения своего родителя, что позволяет писать так:

acquireResource() onSuccess { value =>
  computeSomething(value)
} ensure {
  freeResource()
}

где freeResource() гарантированно будет выполнен после computeSomething, позволяя эмуляцию шаблона try .. finally.

Используйте onSuccess вместо foreach — он симметричен для onFailure это лучшее имя для подобного случая, и позволяет цепочечные вызовы.

Всегда старайтесь избегать Promise экземпляров напрямую: почти каждая задача может быть решена с помощью использования предопределенных комбинаций. Эти комбинации обеспечивают распространение ошибки и отказа, и в целом поощряют стиль программирования потоков данных, который обычно <href = “#Параллелизм-Futures”>устраняет необходимость синхронизации и управления объявлениями.

Код, написанный в стиле хвостовой рекурсии не способствует утечки области стека, позволяет эффективно реализовывать циклы в стиле потоков данных:

case class Node(parent: Option[Node], ...)
def getNode(id: Int): Future[Node] = ...

def getHierarchy(id: Int, nodes: List[Node] = Nil): Future[Node] =
  getNode(id) flatMap {
    case n@Node(Some(parent), ..) => getHierarchy(parent, n :: nodes)
    case n => Future.value((n :: nodes).reverse)
  }

Future определяют множество полезных методов: Use Future.value() и Future.exception() для создания заранее определенных futures. Future.collect(), Future.join() и Future.select() предоставляют комбинации, которые обращают многие futures в один (например, объединяются как часть операции сбора-разбора).

Отказ

Futures реализуют слабую форму отказа. Вызов Future#cancel непосредственно не прекращает вычисления, но вместо этого устанавливает триггер сигнал, который может быть запрошен процессом, который в конечном счете удовлетворит требованиям future. Отказ распространяется в противоположном направлении, чем значения: сигнал “отказ”, установленный принимающим объектом распространяется до объекта, установившего этот сигнал. Объект, установивший сигнал, использует onCancellation и Promise слушать этот сигнал и действовать соответственно.

Это означает, что смысл отказа зависит от объекта, который его вызвал, и не существует реализации по умолчанию. Отказ - всего лишь подсказка.

Locals

Утилита Local предоставляет ссылочную ячейку, которая является локальной для конкретного future дерева диспетчеризации. Установка значения local делает это значение доступны для любого вычисления отложенного в том же потоке. Они, аналогичны locals потоков, за исключением того, что они не используются в потоках Java, а в дереве “future потоков”. Например, в

trait User {
  def name: String
  def incrCost(points: Int)
}
val user = new Local[User]

...

user() = currentUser
rpc() ensure {
  user().incrCost(10)
}

user() в блоке ensure будет ссылаться на значение user, локальное во время добавления функции обратного вызова.

Как и в потоковых locals, Local могут быть очень удобны, но их следует почти всегда избегать: убедитесь, что проблема не может быть решена путем передачи данных везде явно, даже если это несколько обременительно.

Locals эффективно используются в базовых библиотеках для очень общих задач - работа с потоками через RPC, передача мониторов, создание «трассировки стека» для будущих обратных вызовов - там, где любое другое решение было бы слишком обременительными для пользователей. Locals почти не подходят в любой другой ситуации.

Offer/Broker

Работа параллельных систем сильно осложняется необходимостью координации доступ к общим данным и ресурсам.Акторы предоставляют одну из стратегий упрощения работы: каждый актор это последовательный процесс, который сохраняет свое собственное состояние и ресурсы, а данные передаются путем передачи сообщений другим акторам. Обмен данными требует взаимодействия между акторами.

Offer/Broker состоит из трех важных компонентов. Во-первых, каналы связи (Broker) являются первым элементом - то есть, вы отправляете сообщения через Broker, а не актору напрямую. Во-вторых, Offer/Broker является синхронным механизмом: общение синхронизировано. Это значит, мы можем использовать Broker в качестве механизма координации: когда процесс a отправляет сообщение процессу b, оба процесса и a и b располагают состоянием системы. И наконец, связь может осуществляться выборочно: процесс может предложить несколько различных способов коммуникаций, а в итоге получит ровно один из них.

Для того, чтобы поддерживать выборочные связи (а также другие соединения) в общем случае, мы должны отделить описание связи от процесса общения. Это то, что делает Offer - это постоянная величина, которая описывает связи, для выполнения коммуникации (действующей на Offer), мы синхронизируем процесс через метод sync()

trait Offer[T] {
  def sync(): Future[T]
}

который возвращает Future[T], что дает возможность обменяться значениями, когда возникла связь.

Broker координирует обмен значениями через Offers - это канал связи

trait Broker[T] {
  def send(msg: T): Offer[Unit]
  val recv: Offer[T]
}

так что при создании двух Offers

val b: Broker[Int]
val sendOf = b.send(1)
val recvOf = b.recv

и sendOf и recvOf оба синхронизированы

// In process 1:
sendOf.sync()

// In process 2:
recvOf.sync()

обе Offers обменяются значением 1.

Выборочная связь осуществляется путем объединения нескольких Offers с помощью Offer.choose

def choose[T](ofs: Offer[T]*): Offer[T]

что дает новые Offers, которые, при синхронизации, получают только один из ofs — первая из них станет доступной для дальнейших действий. Когда их становится несколько, доступных одновременно, один из них выбирается случайным образом.

Объект Offer имеет ряд одноразовых Offers, которые используются для объединения Offer с Broker.

Offer.timeout(duration): Offer[Unit]

это Offer, который активируется после передачи данного периода времени (duration). Offer.never никогда не получит данного значения, и Offer.const(value) сразу получает заданное значение. Они полезны при объединении, когда используется выборочная свзяь. Например, чтобы использовать задержку по времени операции отправки:

Offer.choose(
  Offer.timeout(10.seconds),
  broker.send("my value")
).sync()

Выглядит заманчиво, если сравнивать Offer/Broker с SynchronousQueue, но они отличаются небольшими, но важными деталями. Offers могут быть составлены таким образом, что таких очередей просто не существует. Например, рассмотрим набор очередей, представленых как Broker:

val q0 = new Broker[Int]
val q1 = new Broker[Int]
val q2 = new Broker[Int]

Теперь давайте создадим общую очередь для чтения:

val anyq: Offer[Int] = Offer.choose(q0.recv, q1.recv, q2.recv)

anyq является Offer, который будет читать из первой доступной очереди. Обратите внимание, что anyq является все еще синхронизированной - кроме этого у нас еще есть основная очередь. Такая композиция просто не возможна с использованием очередей.

Пример: Простой пул соединений

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

Используя Offer/Broker, мы можем выразить это вполне естественно:

class Pool(conns: Seq[Conn]) {
  private[this] val waiters = new Broker[Conn]
  private[this] val returnConn = new Broker[Conn]

  val get: Offer[Conn] = waiters.recv
  def put(c: Conn) { returnConn ! c }

  private[this] def loop(connq: Queue[Conn]) {
    Offer.choose(
      if (connq.isEmpty) Offer.never else {
        val (head, rest) = connq.dequeue
        waiters.send(head) { _ => loop(rest) }
      },
      returnConn.recv { c => loop(connq enqueue c) }
    ).sync()
  }

  loop(Queue.empty ++ conns)
}

loop всегда будет сделкой, чтобы иметь возможность восстановления связи, но только Offer может отправить сообщение, когда очередь не пуста. Использование стойких очередей упрощает дальнейшие рассуждения. Взаимодействие с пулом идет через Offer, поэтому, если вызывающий объект хочет использовать тайм-аут, он может это сделать с помощью комбинаторов:

val conn: Future[Option[Conn]] = Offer.choose(
  pool.get { conn => Some(conn) },
  Offer.timeout(1.second) { _ => None }
).sync()

Никаких дополнительных телодвижений не требуется для реализации тайм-аутов, это связано с семантикой Offers: если выбран Offer.timeout, не требуется больше Offer получать информацию их пула - пул и вызывающая функция никак не могут одновременно получать и отправлять данные, особенно, для ожидающих Broker.

Пример: Решето Эратосфена

Часто бывает полезно - а иногда и значительно упрощает жизнь - представление параллельных программ в виде набора последовательных процессов, которые взаимодействуют синхронно. Offers и Brokers предоставляют набор инструментов, чтобы сделать это просто и единообразно. В самом деле, их применение выходит за рамки того, что можно считать “классическими” проблемами параллелизма - параллельное программирование (с помощью Offers/Broker) является полезным структурированным инструментом, таким же как подпрограммы, классы и модули.

Одним из этих примеров является Решето Эратосфена, который может быть представлен как последовательное применение фильтров для потоков целых чисел. Во-первых, мы должны иметь источник целых чисел:

def integers(from: Int): Offer[Int] = {
  val b = new Broker[Int]
  def gen(n: Int): Unit = b.send(n).sync() ensure gen(n + 1)
  gen(from)
  b.recv
}

integers(n) это просто Offer всех последовательных целых чисел, начиная с n. Затем нужно отфильтровать:

def filter(in: Offer[Int], prime: Int): Offer[Int] = {
  val b = new Broker[Int]
  def loop() {
    in.sync() onSuccess { i =>
      if (i % prime != 0)
        b.send(i).sync() ensure loop()
      else
        loop()
    }
  }
  loop()

  b.recv
}

filter(in, p) возвращает Offer, который извлекает множество простых чисел p из in. В конце концов мы составляем наше решето:

def sieve = {
  val b = new Broker[Int]
  def loop(of: Offer[Int]) {
    for (prime <- of.sync(); _ <- b.send(prime).sync())
      loop(filter(of, prime))
  }
  loop(integers(2))
  b.recv
}

loop() работает просто: он читает следующее (простое) число из of, потом применяет фильтр of, который вытаскивает это число. Так как loop - это рекурсия, то отфильтровывая последовательно простые числа, мы получваем наше решето. Теперь мы можем напечатать первые 10000 простых чисел:

val primes = sieve
0 until 10000 foreach { _ =>
  println(primes.sync()())
}

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

Благодарности

Уроки в этом документе, принадлежат Scala сообществу в Twitter, - и я надеюсь, Я был верным летописцем.

Blake Matheny, Nick Kallen, Steve Gury, и Raghavendra Prabhu предоставили множество полезных советов и превосходных предложений


  1. Из Finagle’s heap balancer [back]