Продолжаем рассказ о языке цейлон. В первой части статьи С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 языков в реальных проектах.


Для заинтересованных еще немного интересных ссылок