Почему стоит полностью переходить на Ceylon или Kotlin (часть 2)

в 7:17, , рубрики: ceylon, java, kotlin, Программирование

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

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

Автор: Михаил Малютин

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js