Заметки о функциональном программировании. Статическая типизация и декомпозиция.
В этой статье речь пойдет о системе статической типизации и ее применениях. Начнем снова с темы ООП и покажем, наконец, что функциональный язык может быть чистым ОО языком. Далее я постараюсь продемонстрировать преимущества статической типизации и рассказать про ее основные возможности, такие как границы типов и вариантность. Потом мы поговорим о том, как механизмы сопоставления с образцом и implicit преобразования, основанные на статической типизации, позволяют декомпозировать программы. Все нижесказанное относится к большинству функциональных языков, поэтому будет полезно не только Scala разработчикам.
Везде объекты
В предыдущих постах (этом, этом и этом) мы определили в нашей модели вычислений три примитивных типа: функции, примитивные типы-значения и объекты классов. Три типа - это слишком много, поэтому в этом разделе мы сведем их все к объектам.
На самом деле в Scala функции реализованы в виде объектов. Например тип функции A => B просто псевдоним для класса, являющегося потомком scala.Function1[A,B]:
package scala
trait Function1[A,B] {
def apply(x: A): B
}
Теперь очевидно что функция - это просто объект с методом apply
. Синтаксический сахар позволяет добавить к имени объекта список аргументов и вызвать метод apply
: f(1) == f.apply(1)
. Для функций с двумя параметрами существует класс scala.Function2 и так далее до 22 (в текущей версии языка).
Стоит заметить (спасибо Ivan Yurchenko), что методы классов в целях оптимизации и защиты от бесконечной рекурсии (касается метода apply
) не являются функциями. Например метод def apply(x: Int): Boolean = ...
не является объектом Function1
. Подобная строка кода вообще не возвращает значения. В случае необходимости создается функциональный объект-обертка. В большинстве случаев Scala скрывает от нас разницу между функцией и ее оптимизированным представлением в виде метода. Но существуют исключения из этого правила, о которых мы поговорим в других статьях.
Анонимные функции реализованы схожим образом. В качестве примера рассмотрим реализацию функции (x: Int) => x * x
. Это выражение трактуется как следующий код:
{
class AnonFunc extends Function1[Int, Int] {
def apply(x: Int) = x * x
}
new AnonFunc
}
Результатом этого блока кода будет объект класса AnonFunc
. Этот класс недоступен вне своей области видимости. Похожего результата можно добиться, воспользовавшись анонимным классом:
val f = new Function1[Int, Int] {
def apply(x: Int) = x * x
}
Работа с анонимными классами в Scala аналогична Java.
Завершим разговор о функциях примером. Дополним реализацию списка из предыдущей статьи объектом-компаньоном, который будет использоваться для создания списков следующим образом:
List[Int]() // -> Nil
List(1) // Список из одного элемента
List(1, 2) // Список из 2-х элементов
Этого можно достичь, определив методы apply
в объекте-компаньоне:
object List {
def apply[T]() = new Nil[T]
def apply[T](x1: T) = new Cons(x1, Nil)
def apply[T](x1: T, x2: T): List[T] = new Cons(x1, new Cons(x2, Nil))
}
Заметим что:
- apply может быть параметрически полиморфной функцией;
- Scala умеет выводить параметр типа из значения аргумента, что видно на примере
List(1)
иList(1, 2)
; - Scala позволяет объявлять функции с одним именем, но разным количеством или типом аргументов. Подобные объявления называются перегрузкой функций и являются примером специального полиморфизма (или ad hoc полиморфизма).
С функциями закончили. Теперь приступим к более сложной задаче - выразим примитивные типы через объекты. Scala в целях оптимизации оптимизации представляет типы-значения в виде примитивных типов JVM. Но для полного понимания модели вычислений полезно уяснить, что примитивные типы не нужны. Я продемонстрирую справедливость этого утверждение на примере двух примитивных типов - логических значений и натуральных чисел.
Начнем с класса логических значений. Идеальная реализация класса Boolean
, не использующая ничего кроме объектов, может выглядеть так:
package idealized.scala
abstract class Boolean {
def ifThenElse[T](then: => T, else: => T): T
def && (x: => Boolean): Boolean = ifThenElse(x, false)
def || (x: => Boolean): Boolean = ifThenElse(true, x)
def unary_!: Boolean = ifThenElse(false, true)
def == (x: Boolean): Boolean = ifThenElse(x, x.unary_!)
def != (x: Boolean): Boolean = ifThenElse(x.unary_!, x)
}
Наибольший интерес вызывает абстрактный метод ifThenElse
. Он принимает (по имени) в качестве аргументов два произвольных объекта и возвращает один из них. Какой конкретно зависит от подкласса. Подклассов, как известно, два:
object true extends Boolean {
def ifThenElse[T](then: => T, else: => T): T = then
}
object false extends Boolean {
def ifThenElse[T](then: => T, else: => T): T = else
}
Метод &&
реализует логическое “И” и работает по следующим правилам:
this/x | true | false |
---|---|---|
true | 1 | 0 |
false | 0 | 0 |
Реализация метода &&
в true
возвращает значение, переданное в качестве аргумента. Реализация в false
возвращает false
. Наш &&
полностью соответствует таблице. Остальные методы работают схожим образом.
Теперь давайте рассмотрим задачу по-сложнее - числовой тип. Правда вместо класса Int
, из соображений экономии места и времени, рассмотрим его упрощенную версию - класс натуральных чисел Nat
с операцией сложения. Натуральные числа определим по индукции начиная с 0:
- ноль - натуральное число;
- число, следующее за натуральным числом тоже является натуральным числом.
Формально этот способ описан в виде аксиом Пеано. Код будет следующим:
abstract class Nat {
def isZero: Boolean // true если ноль
def predecessor: Nat // предыдущее натуральное число
def successor: Nat = new Succ(this) // следующее натуральное число
def + (that: Nat): Nat
def - (that: Nat): Nat
}
object Zero extends Nat {
def isZero: Boolean = true
def predecessor = throw new IlligalStateException("0.predecessor")
def + (that: Nat): Nat = that
def - (that: Nat): Nat = that.isZero.ifThenElse(Zero, () => (throw new IlligalStateException("0.predecessor")))
}
class Succ(n: Nat) extends Nat {
def isZero: Boolean = false
def predecessor = n
def + (that: Nat): Nat = new Succ(n + that)
def - (that: Nat): Nat = that.isZero.ifThenElse(this, n - that.predecessor)
}
Начиная с единицы каждое последующее число строится на базе предыдущего. Операции сложения и вычитания определены рекурсивно. Сложение на каждом шаге рекурсии пытается сложить аргумент с числом, на единицу меньшим чем на предыдущем шаге, пока рекурсивный процесс не спустится до Zero. К результату сложения на каждом шаге добавляется единица. Подобная схема не работает с вычитанием, так как n
может быть равно Zero
, поэтому код немного сложнее.
Подведем итоги. Scala чистый объектно-ориентированный язык, в котором все значения - объекты, а все типы - классы. Но этот факт не мешает Scala быть функциональным языком с соответствующей моделью вычислений.
Границы типов и вариантность
В этой части статьи я бы хотел рассмотреть одну важную вещь - связь параметрического полиморфизма и иерархии классов. Долгое время этой проблеме уделялось мало внимания, в том числе разработчиками основных языков программирования. Последнее время эта тенденция изменилась. Одним из преимуществ Scala является способность ее механизма типизации решать задачи соотнесения иерархии полиморфных типов с иерархией классов.
Начнем с демонстрации актуальности проблемы. Вспомним наш список целых чисел и напишем метод assertAllPos
, который:
- принимает аргумент
IntList
; - возвращает аргумент
IntList
если все его элементы положительны; - иначе бросает исключение.
Можно написать такой код:
def assertAllPos(s: IntList): IntList
Это рабочее решение, но есть один недостаток. Интерфейс не полностью описывает спецификацию. Хотелось бы при взгляде на метод понять что assertAllPos(Nil)
вернет Nil
, а assertAllPos(Cons(...))
вернет Cons
. В Scala решение достаточно простое:
def assertAllPos[S <: IntList](r: S): S = ...
Выражение S <: IntSet
задает верхнюю границу типа S
или, проще говоря, определяет, что тип S
должен быть потомком IntList
.
Границы типа позволяют определить связь параметра типа с иерархией классов. У аргумента типа существует три границы:
- верхняя
S <: T
, определяющая, чтоS
подклассT
; - средняя
S: T
, означающая что тип S точно соответствует T (нужна для implicit параметров, о них в конце статьи); - нижняя
S >: T
, означающая, чтоS
базовый классT
.
Scala позволяет задать верхнюю и нижнюю границы одновременно:
[S >: B <: A]
Этот код определяет место S
в иерархии классов между B
и A
.
Теперь давайте рассмотрим важный вопрос. Если NonEmpty <: IntSet
, то будет ли сохранено это отношение для производных типов, например будет ли верно утверждение Array[NonEmpty] <: Array[IntSet]
? Предположим, что массив не пустых множеств является массивом множеств. Другими словами предположим, что массив ковариантен.
Полиморфный класс или метод ковариантен, если иерархия производных по параметру типа классов совпадает с иерархией параметра типа.
Массивы Java или C# ковариантные и объявляются так: T[]
. Следующий, корректный с точки зрения синтаксиса, код имеет большие проблемы с типизацией:
NonEmpty[] a = new NonEmpty[]{ new NonEmpty(1, Empty, Empty) }
IntSet[] b = a
b[0] = Empty // OLOLOLOLOLOLO!!!
NonEmpty s = a[0]
Мы положили в массив непустых множеств пустое множество, затем мы присвоили пустое множество переменной, которая имеет тип непустого множества. Java решает эту проблему сохранением и анализом параметра типа во время выполнения. Код выше бросит исключение ArrayStoreException
на третей строке. Но это костыль, ведь суть проблемы в том, что массивы не могут быть ковариантными. Все ли операции с IntSet[]
применимы к NonEmpty[]
? Ответ - нет! Мы не можем, например, присваивать элементам NonEmpty[]
значения Empty
, в то время как IntSet[]
можем. В Scala массивы никак не связанны с иерархией параметра типа или, другими словами, являются невариантными
В Java и C# массивы ковариантные для того, чтобы можно было передавать любые массивы в методы принимающие Object[]
. До введения дженериков в Java 1.5 подобные костыли были единственным способом реализовать, например, универсальный метод сортировки массивов.
Заметим, что предыдущий пример не вписываются в нашу функциональную модель вычислений потому что массивы изменяемые. Операции изменения состояния (мутаторы) делают массивы невариантными. Неизменяемые списки Scala, напротив, ковариантные и отлично работают. Вообще полиморфные типы могут быть трех видов (для случая A <: B):
- невариантные, если C[A] и C[B] никак не связаны
- ковариантные, если C[A] <: C[B]
- контрвариантные, если C[A] >: C[B]
Scala использует следующий синтаксис для описания этих случаев вариантности:
- невариантность
class C[A] { ... }
- ковариантность
class C[+A] { ... }
- контрвариантность
class C[-A] { ... }
Можно сформулировать следующие упрощенные правила применимости разных типов вариантности для полиморфных методов (компилятор Scala проверяет соблюдение подобных правил автоматически):
- параметр типа, по отношению к которому метод невариантен, может быть безопасно использован как в списке аргументов, так и в качестве типа возвращаемого результата
- параметр типа, по отношению к которому метод ковариантен, могжет являться типом возвращаемого значения
- параметр типа, по отношению к которому метод контрвариантен, может являться типом аргумента
Продемонстрируем справедливость этих правил на примере двух типов:
type A = IntSet => NonEmpty
type B = NonEmpty => IntSet
Работать с A
можно так же как с B
, но не наоборот. Можно сказать, что A
является B
или A
потомок B
. Общее правило иерархии функциональных типов таково:
если A2 <: A1 и B1 <: B2, то A1 => B1 <: A2 => B2.
Это правило полностью подтверждает сказанное выше.
В качестве примера практического применения вариантности рассмотрим два случая. Первый из них - это класс Function1
из предыдущего раздела. Ранее мы давали упрощенную реализацию. Чтобы иметь возможность использовать одни функции вместо других нам нужно выстроить иерархию их типов. Сделать это можно следующим образом:
package scala
trait Function1[-T, +U] {
def apply(x: T): U
}
Теперь все хорошо и, например, объект типа IntSet => NonEmpty
может быть передан как аргумент в метод с типом NonEmpty => IntSet
.
Второй пример - улучшенная реализация списка из предыдущей статьи. Оригинальная реализация такова:
trait List[T] {
def isEmpty: Boolean
def head: T
def tail: List[T]
}
class Cons[T](val head: T, val tail: List[T]) extends List[T] {
def isEmpty = false
}
class Nil[T] extends List[T] {
def isEmpty: Boolean = true
def head: Nothing = throw new NoSuchElementException("Nil.head")
def tail: Nothing = throw new NoSuchElementException("Nil.tail")
}
Хорошим решением было бы заменить класс Nil
объектом-одиночкой, но этот класс типизирован. Мы можем решить эту проблему воспользовавшись тем фактом, что списки ковариантные и тип Nothing является подклассом любого другого типа:
trait List[+T] {
def isEmpty: Boolean
def head: T
def tail: List[T]
}
class Cons[T](val head: T, val tail: List[T]) extends List[T] {
def isEmpty = false
}
object Nil extends List[Nothing] {
def isEmpty: Boolean = true
def head: T = throw new NoSuchElementException("Nil.head")
def tail: T = throw new NoSuchElementException("Nil.tail")
}
Теперь все в порядке и код вроде val x: List[String] = Nil
синтаксически корректен.
Декомпозиция программы в функциональном стиле и сопоставление с образцом
Многие сталкивались с проблемой выбора способа разбиения программы на классы. С одной стороны стоят компактные варианты, но с грязными хаками вроде привидений типа, с другой - ОО паттерны-костыли и куча служебного кода. В функциональном программировании такие проблемы решаются просто и элегантно с помощью сопоставления с образцом (pattern matching).
Проиллюстрируем проблему на примере. Предположим нам нужен простой интерпретатор арифметических выражений. Требуется поддержка чисел и их сложения. Естественным желанием будет объявить интерфейс Expr
и реализовать два его подкласса - Number
и Sum
:
trait Expr {
def isNumber: Boolean
def isSum: Boolean
def numValue: Int
def leftOp: Expr
def rightOp: Expr
}
class Number(n: Int) extends Expr {
def isNumber: Boolean = true
def isNum: Boolean = false
def numValue: Int = n
def leftOp: Expr = throw new Error("Number.leftOp")
def rightOp: Expr = throw new Error("Number.rightOp")
}
class Sum(e1: Expr, e2: Expr) extends Expr {
def isNumber: Boolean = false
def isSum: Boolean = true
def numValue: Int = throw new Error("Sum.numValue")
def leftOp: Expr = e1
def rightOp: Expr = e2
}
В интерфейсе мы имеем два метода для определения типа объекта и три метода для получения данных (гетера). Использовать эти классы можно следующим образом:
def eval(e: Expr): Int = {
if (e.isNumber) e,numValue
else if (e.isSum) eval(e.leftOp) + eval(e.rightOp)
else throw new Error("Unknownt expression" + e)
}
Кому-то этот код понравится, но я считаю, что это эталонный быдлокод из палаты мер и весов. Предположим нам нужно добавить операцию умножения. Для этого придется добавить метод определения типа Prod
в Expr
, реализовать его во всех подклассах, добавить класс Prod
и дописать eval
. Количество методов, которые необходимо определить растет нелинейно от количества классов. Таким образом наше первое решение никуда не годится.
Лучшим решением будет убрать отовсюду методы для определения типа объекта. Для определения типа можно использовать механизмы определения и привидения типов. В Scala эти механизмы реализованы посредством двух методов Any
:
def isInstanceOf[T]: Boolean
def asInstanceOf[T]: T
Эти методы позволят убрать из интерфейса методы isNumber
, isSum
и им подобные. Нужно будет только изменить eval
:
def eval(e: Expr): Int =
if (e.isInstanceOf[Number]) e.asInstanceOf[Number].numValue
else if (e.isInstanceOf[Sum])
eval(e.asInstanceOf[Sum].leftOp) +
eval(e.asInstanceOf[Sum].rightOp)
else throw new Error("Unknown expression " + e)
Наше второе решение имеет следующие особенности:
- кода стало меньше, а масштабируемость гораздо больше;
- подобная работа с типами не позволяет компилятору проверить типизацию и может привести к ошибкам во время выполнения программы.
Избежать проблем с типизацией позволяет решение проблемы в ООП стиле с использованием паттерна компоновщик:
trait Expr {
def eval: Int
}
class Number(n: Int) extends Expr {
def Eval: Int = n
}
class Sum(e1: Expr, e2: Expr) extends Expr {
def eval: Int = e1.eval + e2.eval
}
Код eval
теперь “размазан” по всей программе. Если нам понадобиться добавить, например, метод display
для печати выражения, нам нужно будет реализовать его в каждом подклассе. Но настоящей проблемой является локальность подобных методов по отношению к объекту. Мы не сможем реализовать метод, расставляющий скобки в выражении вроде такого: (a + b) * (a + c)
. Подобный метод должен работать с несколькими выражениями, на что наши локальные методы не способны. Таким образом третье решение тоже отстой. К счастью, функциональные языки позволяют решать задачи декомпозиции лучше рассмотренных подходов.
Для первой реализации можно заметить, что методы определения типа и гетеры служат для того, чтобы получить информацию, доступную в момент создания объекта. Это информация о имени класса и его аргументах. Например результаты вычисления методов isSum, leftOp, rightOp
дают ту же информацию, что и код new Sum(exp1, exp2)
. Поэтому, если бы мы имели безопасный доступ к типу объекта и аргументам класса, мы бы могли устранить недостатки предыдущих реализаций. Такую возможность дает механизм сопоставления с образцом.
Для того, чтобы получать информацию о моменте создания объекта его класс нужно объявить как case-класс:
trait Expr
case class Number(n: Int) extends Expr
case class Sum(e1: Expr, e2: Expr) extends Expr
Для case-классов автоматически создаются объекты-компаньоны:
object Number {
def apply(n: Int) = new Number(n)
}
object Sum {
def apply(e1: Expr, e2: Expr) = new Sum(e1, e2)
}
Поэтому для создания объекта case-класса необязательно слово new
. Кроме того, для каждого case-класса создается метод equals
и его параметры доступны вне класса (как будто они объявлены с val
).
Мы видим, что методов у наших классов нет вообще и они не нужны! Нам достаточно информации о том, как объект создавался. Для анализа этой информации используется сопоставление с образцом - концепция, обобщающая switch
из Java или C#. В Scala для сопоставления с образцом используется синтаксическая конструкция match
:
def eval(e: Expr): Int = e match {
case Number(n) => n
case Sum(e1, e2) => eval(e1) + eval(e2)
}
Работает конструкция e match {case p1 => e1; ...; case pN => eN}
по следующим правилам:
- каждый
case
определяет соответствиепаттерн => выражение
, соответствия упорядочиваются в порядке объявления; - если найден паттерн, соответствующий первому операнду
match
, переменные паттерна подставляются в выражение (далее подробнее) и выражение возвращается как результат; - если совпадений не найдено бросается MatchException.
Паттерны состоят из следующих элементов:
- конструкторы case-классов;
- констант, например 1 или true;
- переменных, являющихся идентификаторами Scala, начинающихся с маленькой буквы;
- символа подстановки
_
, означающего в паттерне любое значение.
В момент сопоставления определяется тип входного выражения и аргументы класса. Далее происходит сопоставление с паттерном по следующим правилам:
- конструктор и константы должны совпадать с типом объекта и значениями соответствующих аргументов (применяется ==);
- на месте переменных в паттерне может находиться любое значение;
- на месте символа
_
может находиться любое значение.
В примере видно, что переменные могут входить как в паттерн, так и в выражение. В паттерне они связываются со значениями соответствующих аргументов case-классов и эти значения могут быть использованы в выражении. Переменная может встречаться в паттерне не более одного раза. Кроме того аргументами case-класса могут быть объекты case-классов и естественно, что match
работает рекурсивно. Например код case Sum(Number(x), Numer(y)) => x + y
вполне корректен.
В завершении темы добавим в нашу реализацию интерпретатора метод display
и возможность выполнять произведение чисел. Это делается очень просто:
trait Expr
case class Number(n: Int) extends Expr
case class Sum(e1: Expr, e2: Expr) extends Expr
case class Prod(e1: Expr, e2: Expr) extends Expr
def eval(e: Expr): Int = e match {
case Number(x) => x
case Sum(e1, e2) => eval(e1) + eval(e2)
case Prod(e1, e2) => eval(e1) * eval(e2)
}
def display(e: Expr): String = e match {
case Number(x) => x.toString
case Sum(e1, e2) => display(e1) + " + " + display(e2)
case Prod(e1, e2) => {
def inParentheses(e: Expr): String = e match {
case Sum(_,_) => "(" + display(e) + ")"
case _ => display(e)
}
inParentheses(e1) + " * " + inParentheses(e2)
}
}
Этот код лишен недостатков предыдущих решений - способен расставлять скобки, прост и лаконичен, очень хорошо масштабируется.
Классы типов и implicit преобразования
Реализация интерпретатора была бы еще удобнее, если бы мы могли вместо Number(2)
писать просто 2
. Этого можно добиться при помощи концепции, называемой классом типов. Непонятное определение:
Класс типов (type class) - это элемент системы типов, позволяющий использовать ограничения на параметры типа полиморфного типа. Ограничения, например, могут быть выражены в требовании к наличию определенных методов в интерфейсе параметра типа.
Проще говоря, если во время компиляции кода компилятор Scala не может найти метод, то он проверяет, есть ли метод с таким же именем, но другим типом или другим количеством аргументов. Например требования к интерфейсу аргумента являются классом типов. Компилятор автоматически выполняет поиск удовлетворяющих классу типов implicit-функций или implicit-аргументов. impliced-функции используются для прозрачного и безопасного приведения типов:
implicit def intToNumber(x: Int) = Number(x)
Теперь вместо Prod(Number(2), Number(3))
можно просто писать Prod(2, 3)
. При вызове Prod
компилятор поймет, что нужно преобразовать класс типа, который определяет интерфейс Int в класс типа, определяющего интерфейс Number
. Будет выполнен поиск объявлений implicit def и выбрано то, которое возвращает наиболее близкий по иерархии тип. В нашем примере есть единственное подходящее объявление implicit def intToNumber(x: Int)
.
Если компилятор не может найти имя метода, то он постарается преобразовать объект к типу, у которого этот метод есть. Подобные преобразования задаются implicit-классами. В качестве примера расширим интерфейс класса String
новым методом:
implicit class TunedString(x: String) {
def bang = x + "!!!"
}
"Ololo".bang //-> Ololo!!!
Мы расширили интерфейс класса String без изменения самого класса и наследования. По сути мы реализовали паттерн pimp-my-library.
implicit-классы имеют ограничения:
- должны быть определены внутри другого класса или объекта;
- имя класса должно быть уникально в рамках его области видимости;
- не может быть case-классом;
- класс может принимать только один не implicit аргумент.
И наконец, если компилятор обнаружит, что не все аргументы переданы и отсутствующие аргументы объявлены как implicit, то будет выполнен поиск implicit object или implicit val. Рассмотрим этот случай на примере сортировки. Пусть у нас есть метод sort
, который принимает список и объект-компаратор, определяющий порядок:
implicit val numComparator = new Comparator[Int] {
def compare(oneNumber: Int, anotherNumber: Int): Int =
oneNumber - anotherNumber
}
def sort[T](l: List[T])(implicit c: Comparator[T]): List[T] = ...
sort(List(3,2,4)) // не передаем c
Выше мы не передали в функцию последний аргумент, компилятор сам подставит вместо него implicit val numComparator
. В то же время, если мы изменим тип компаратора на implicit val personNameComparator = new Comparator[String]
, то его класс типа не совпадет с тем, который ищет компилятор и компаратор не сможет быть использован. В результате возникнет ошибка компиляции. Кроме этого implicit аргументы имеют следующие особенности:
- могут быть переданы явно, как обычные аргументы;
- они всегда должны находиться после обычных аргументов;
- должны находиться в отдельных списках аргументов.
implicit аргументы позволяют упростить код, убрав из него технические подробности вроде передачи служебных объектов. Но, что более важно, они уменьшают связанность кода. Мы в любой момент можем добавить или убрать (если ранее не передавались явно) impliced аргументы и это потребует минимальных усилий.
Поиск implicit val, imlicit object, implicit class, implicit def происходит в следующем порядке:
- implicit объявления в текущей области видимости
- точно заданные операции import
- заданные шаблоном (
_
) операции import - объекты-компаньоны
- области видимости каждого из аргументов метода
- области видимости аргумента типа
- если текущий тип вложенный, то в области видимости родительского типа
Заключение
В этой статье мы свели все типы к классам и увидели как мощная система типизации может помочь в правильной декомпозиции программ. Скудная типизация языков прошлого заставила некоторых программистов думать, что статическая типизация зло и искать пути ее обхода. Другие программисты выдумали целые справочники костылей-паттернов, призванных обойти ограничения системы типов. На самом же деле грамотно спроектированная система типизации облегчает жизнь программисту. Мы увидели насколько полезной может быть статическая система типов, связанная с ОО иерархией классов. С помощью умного компилятора мы можем лаконично описать хорошо декомпозированную и масштабируемую программу, избежав при этом кучи ошибок.
В следующей статье мы подробно поговорим о списках, так как они являются базовой структурой данных в функциональном программировании.