Продолжаем рассказ о языке цейлон. В первой части статьи Сeylon выступал как гость на поле Kotlin. То есть брались сильные стороны и пытались их сравнить с Ceylon.
В этой части Ceylon выступит как хозяин, и перечислим те вещи, которые близки к уникальным и которые являются сильными сторонами Ceylon.
Поехали:
18# Типы — объединения (union types)
В большинстве языков программирования функция может может возвратить значения строго одного типа.
function f() { value rnd = DefaultRandom().nextInteger(5); return switch(rnd) case (0) false case (1) 1.0 case (2) "2" case (3) null case (4) empty else ComplexNumber(1.0, 2.0); } value v = f();
Какой реальный тип будет у v? В Kotlin или Scala тип будет Object? или просто Object, как наиболее общий из возможных вариантов.
В случае Ceylon тип будет Boolean|Float|String|Null|[]|ComplexNumber.
Исходя из этого знания, если мы, допустим, попробуем выполнить код
if (is Integer v) { ...} //Не скомпилируется, v не может физически быть такого типа if (is Float v) { ...} //Все нормально
Что это дает на практике?
Во первых, вспомним про checked исключения в Java. В Kotlin, Scala и других языках по ряду причин от них отказались. Однако потребность декларировать, что функция или метод может вернуть какое то ошибочное значение, никуда не делась. Как и никуда не делась потребность обязать пользователя как то обработать ошибочную ситуацию.
Соответственно можно, например, написать:
Integer|Exception p = Integer.parse("56");
И далее пользователь обязан как то обработать ситуацию, когда вместо p вернется исключение.
Можно выполнить код, и прокинуть далее исключение:
assert(is Integer p);
Или мы можем обработать все возможные варианты через switch:
switch(p) case(is Integer) {print(p);} else {print("ERROR");}
Все это позволяет писать достаточно лаконичный и надежный код, в котором множество потенциальных ошибок проверяется на этапе компиляции. Исходя из опыта использования, union types — это реально очень удобно, очень полезно, и это то, чего очень не хватает в других языках.
Также за счет использования union types в принципе можно жить без функционала перегрузки функций. В Ceylon это убрали с целью упрощения языка, за счет чего удалось добиться весьма чистых и простых лямбд.
19# Типы — пересечения (Intersection types)
Рассмотрим код:
interface CanRun { shared void run() => print("I am running"); } interface CanSwim { shared void swim() => print("I am swimming"); } interface CanFly { shared void fly() => print("I am flying"); } class Duck() satisfies CanRun & CanSwim & CanFly {} class Pigeon() satisfies CanRun & CanFly {} class Chicken() satisfies CanRun {} class Fish() satisfies CanSwim {} void f(CanFly & CanSwim arg) { arg.fly(); arg.swim(); } f(Duck()); //OK Duck can swim and fly f(Fish());//ERROR = fish can swim only
Мы объявили функцию, принимающую в качестве аргумента объект, который должен одновременно уметь и летать и плавать.
И мы можем без проблем вызывать соответствующие методы. Если мы передадим в эту функцию объект класса Duck — все хорошо, так как утка может и летать и плавать.
Если же мы передадим объект класса Fish — у нас ошибка компиляции, ибо рыба может только плавать, но не летать.
Используя эту особенность, мы можем ловить множество ошибок в момент компиляции, при этом мы можем пользоваться многими полезными техниками из динамических языков.
20# Типы — перечисления (enumerated types)
Можно создать абстрактный класс, и у которого могут быть строго конкретные наследники. Например:
abstract class Point() of Polar | Cartesian { // ... }
В результате можно писать обработчики в switch не указывая else
void printPoint(Point point) { switch (point) case (is Polar) { print("r = " + point.radius.string); print("theta = " + point.angle.string); } case (is Cartesian) { print("x = " + point.x.string); print("y = " + point.y.string); } }
В случае, если в дальнейшем при развитии продукта мы добавим еще один подтип, компилятор найдет за нас те места, где мы пропустили явную обработку добавленного типа. В результате мы сразу поймаем ошибки в момент компиляции и их подсветит IDE.
С помощью данного функционала в Ceylon делается аналог enum в Java:
shared abstract class Animal(shared String name) of fish | duck | cat {} shared object fish extends Animal("fish") {} shared object duck extends Animal("duck") {} shared object cat extends Animal("cat") {}
В результате мы получаем функционал enum практически не прибегая к дополнительным абстракциям.
Если нужен функционал, аналогичный valueOf в Java, мы можем написать:
shared Animal fromStrToAnimal(String name) { Animal? res = `Animal`.caseValues.find((el) => el.name == name); assert(exists res); return res; }
Соответствующие варианты enum можно использовать в switch и т.д, что во многих случаях помогает находить потенциальные ошибки в момент компиляции.
23# Алиасы типов (Type aliases)
Ceylon является языков с весьма строгой типизацией. Но иногда тип может быть довольно громоздким и запутанным. Для улучшения читаемости можно использовать алиасы типов: например можно сделать алиас интерфейса, в результате чего избавиться от необходимости указания типа дженерика:
interface People => Set<Person>;
Для типов объединений или пересечений можно использовать более короткое наименование:
alias Num => Float|Integer;
Или даже:
alias ListOrMap<Element> => List<Element>|Map<Integer,Element>;
Можно сделать алиасы на интерфейс:
interface Strings => Collection<String>;
Или на класс, причем класс с конструктором:
class People({Person*} people) => ArrayList<Person>(people);
Также планируется алиас класса на кортеж.
За счет алиасов можно во многих местах не плодить дополнительные классы или интерфейсы и добиться большей читаемости кода.
21# Кортежи
В цейлоне очень хорошая поддержка кортежей, они органично встроены в язык. В Kotlin посчитали, что они не нужны. В Scala они сделаны с ограничениями по размеру. В Ceylon кортежи представляют собой связанный список, и соответственно могут быть произвольного размера. Хотя в реальности использование кортежей из множества разнотипных элементов это весьма спорная практика, достаточно длинные кортежи могут понадобиться, например, при работе со строками таблиц баз данных.
Рассмотрим пример:
value t = ["Str", 1, 2.3];
Тип будет довольно читаемым — [String, Integer, Float]
А теперь самое вкусное — деструктуризация. Если мы получили кортеж, то можно легко получить конкретные значения. Синтаксис по удобству будет практически как в Python:
value [str, intVar, floatType] = t; value [first, *rest] = t; value [i, f] = rest;
Деструктуризацию можно проводить внутри лямбд, внутри switch, внутри for — выглядит это достаточно читаемо. На практике за счет функционала кортежей и деструктуризации во многих случаях можно отказаться от функционала классов практически без ущерба читаемости и типобезопасности кода, это позволяет очень быстро писать прототипы как в динамических языках, но совершать меньше ошибок.
22# Конструирование коллекций (for comprehensions)
Очень полезная особенность, от которой сложно отказаться после того, как ее освоил. Попробуем проитерировать от 1 до 25 с шагом 2, исключая элементы делящиеся без остатка на 3 и возведем их в квадрат.
Рассмотрим код на python:
res = [x**2 for x in xrange(1, 25, 2) if x % 3 != 0]
На Ceylon можно писать в подобном стиле:
value res = [for (x in (1:25).by(2)) if ( (x % 3) != 0) x*x];
Можно тоже самое сделать лениво:
value res = {for (x in (1:25).by(2)) if ( (x % 3) != 0) x*x};
Синтаксис работает в том числе с коллекциями:
value m = HashMap { for (i in 1..10) i -> i + 1 };
К сожалению пока нет возможности так элегантно конструировать Java коллекции. Пока из коробки синтаксис будет выглядеть как:
value javaList = Arrays.asList<Integer>(*ObjectArray<Integer>.with { for (i in 1..10) i});
Но написать функции, которые конструируют Java коллекции можно самостоятельно очень быстро. Синтаксис в этом случае будет как:
value javaConcurrentHashMap = createConcurrentHashMap<Integer, Integer> {for (i in 1..10) i -> i + 1};
22# Модульность и Ceylon Herd
Задолго до выхода Java 9 в Ceylon существовала модульность.
module myModule "3.5.0" { shared import ceylon.collection "1.3.2"; import ceylon.json "1.3.2"; }
Система модулей уже интегрирована с maven, соответственно зависимости можно импортировать традиционными средствами. Но вообще, для Ceylon рекомендуется использовать не Maven артефакторий, а Ceylon Herd. Это отдельный сервер (который можно развернуть и локально), который хранит артефакты. В отличие от Maven, здесь можно сразу хранить документацию, а также Herd проверяет все зависимости модулей.
Если все делать правильно, получается уйти от jar hell, весьма распространенный в Java проектах.
По умолчанию модули иерархичны, каждый модуль загружается через иерархию Class Loaders. В результате мы получаем защиту, что один класс будет по одному и тому же пути в ClassPath. Можно включить поведение, как в Java, когда classpath плоский — это бывает нужно когда мы используем Java библиотеки для сериализации. Ибо при десериализации ClassLoader библиотеки не сможет загрузить класс, в который мы десериализуем, так как модуль библиотеки сериализации не содержит зависимостей на модуль, в котором определен класс, в который мы десериализуем.
24# Улучшенные дженерики
В Ceylon нет Erasure. Соответственно можно написать:
switch(obj) case (is List<String>) {print("this is list of string)}; case (is List<Integer>) {print("this is list of Integer)};
Можно для конкретного метода узнать в рантайме тип:
shared void myFunc<T>() given T satisfies Object { Type<T> tclass = `T`; //some actions with tClass
Есть поддержка self types. Предположим, мы хотим сделать интерфейс Comparable, который умеет сравнивать элемент как с собой, так и себя с другим элементом. Попытаемся ограничить типы традиционными средствами:
shared interface Comparable<Other> given Other satisfies Comparable<Other> { shared formal Integer compareTo(Other other); shared Integer reverseCompareTo(Other other) { return other.compareTo(this); //error: this not assignable to Other } }
Не получилось! В одну сторону compareTo работает без проблем. А в другую не получается!
А теперь применим функционал self types:
shared interface Comparable<Other> of Other given Other satisfies Comparable<Other> { shared formal Integer compareTo(Other other); shared Integer reverseCompareTo(Other other) { return other.compareTo(this); } }
Все компилируется, мы можем сравнивать объекты строго одного типа, работает!
Также для дженериков гораздо более компактный синтаксис, поддержка ковариантности и контравариантности, есть типы по умолчанию:
shared interface Iterable<out Element, out Absent=Null> ...
В результате снова имеем гораздо лучшую и строгую типизацию по сравнению с Java. Правда некоторыми особенностями в узких местах программы пользоваться не рекомендуется, получение информации о типах в рантайме не бесплатно. Но в некритичных по скорости местах это может быть очень полезно.
24# Метамодель
В Ceylon мы в рантайме можем очень детально проинспектировать весьма многие элементы программы. Мы можем проинспектировать поля класса, мы можем проинспектировать атрибут, конкретный пакет, модуль, конкретный обобщенный тип и тому подобное.
Рассмотрим некоторые варианты:
ClassWithInitializerDeclaration v = `class Singleton`; InterfaceDeclaration v =`interface List`; FunctionDeclaration v =`function Iterable.map`; FunctionDeclaration v =`function sum`; AliasDeclaration v =`alias Number`; ValueDeclaration v =`value Iterable.size`; Module v =`module ceylon.language`; Package v =`package ceylon.language.meta`; Class<Singleton<String>,[String]> v =`Singleton<String>`; Interface<List<Float|Integer>> v =`List<Float|Integer>`; Interface<{Object+}> v =`{Object+}`; Method<{Anything*},{String*},[String(Anything)]> v =`{Anything*}.map<String>`; Function<Float,[{Float+}]> v =`sum<Float>`; Attribute<{String*},Integer,Nothing> v =`{String*}.size`; Class<[Float, Float, String],[Float, [Float, String]]> v =`[Float,Float,String]`; UnionType<Float|Integer> v =`Float|Integer`;
Здесь v — объект метамодели, который мы можем проинспектировать. Например мы можем создать экземпляр, если это класс, мы можем вызвать функцию с параметром, если это функция, мы можем получить значение, если это атрибут, мы можем получить список классов, если это пакет и т.д. При этом справа от v стоит не строка, и компилятор проверит, что мы правильно сослались на элемент программы. То есть в Ceylon мы по существу имеем типобезопасную рефлексию. Соответственно благодаря метамодели мы можем написать весьма гибкие фреймворки.
Для примера, найдем средствами языка, без привлечения сторонних библиотек, все экземпляры класса в текущем модуле, которые имплементят определенный интерфейс:
shared interface DataCollector {} service(`interface DataCollector`) shared class DataCollectorUserV1() satisfies DataCollector {} shared void example() { {DataCollector*} allDataCollectorsImpls = `module`.findServiceProviders(`DataCollector`); }
Соответственно достаточно тривиально реализовать такие вещи, как инверсия зависимостей, если нам это реально нужно.
#25 Общий дизайн языка
На самом деле, сам язык весьма строен и продуман. Многие достаточно сложные вещи выглядят интуитивно и однородно
Рассмотрим, например, синтаксис прямоугольных скобок:
[] unit = []; [Integer] singleton = [1]; [Float,Float] pair = [1.0, 2.0]; [Float,Float,String] triple = [0.0, 0.0, "origin"]; [Integer*] cubes = [ for (x in 1..100) x^3 ];
В Scala, эквивалентный код будет выглядеть следующим образом:
val unit: Unit = () val singleton: Tuple1[Long] = new Tuple1(1) val pair: (Double,Double) = (1.0, 2.0) val triple: (Double,Double,String) = (0.0, 0.0, "origin") val cubes: List[Integer] = ...
В язык очень органично добавлены аннотации synchronized, native, variable, shared и т.д — это все выглядит как ключевые слова, но по существу это обычные аннотации. Ради аннотаций, чтобы не требовалось добавлять знак @ в Ceylon даже пришлось пожертвовать синтаксисом — к сожалению точка с запятой является обязательной. Соответственно Ceylon сделан таким образом, чтобы код, предполагающий использование уже существующих распространенных Java библиотеки вроде Spring, Hibernate, был максимально приятным для глаз.
Например посмотрим как выглядит исп��льзование Ceylon с JPA:
shared entity class Employee(name) { generatedValue id shared late Integer id; column { lenght = 50;} shared String name; column shared variable Integer? year = null; }
Это уже заточено на промышленное использование языка с уже существующими Java библиотеками, и здесь мы получаем весьма приятный для глаза синтаксис.
Посмотрим как будет выглядеть код Criteria API:
shared List<out Employee> employeesForName(String name) { value crit = entityManager.createCriteria(); return let (e = crit.from(`Employee`)) crit.where(equal(e.get(`Employee.name`), crit.parameter(name)) .select(e) .getResultList(); }
По сравнению с Java мы здесь получаем типобезопасность и более компактный синтаксис. Именно для промышленных приложений типобезопасность очень важна. Особенно для тяжелых сложных запросов.
Итого, в данной статье мы играли на поле Ceylon и рассмотрели некоторые особенности языка, которые выгодно выделяют его на фоне конкурентов.
В следующей, заключительной части, попробуем поговорить не о языке как таковом, а об организационных аспектах и возможностях использовать Ceylon и других JVM языков в реальных проектах.
Ceylon on android https://www.youtube.com/watch?v=zBtSimUYALU
Ceylon swarm https://ceylon-lang.org/community/presentations/ceylon-swarm.pdf
