Чего нам не хватает в Java

Автор оригинала: Ben Evans
  • Перевод


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

Отсутствуют материализованные дженерики (reified generics). Об этом не писал только ленивый, причём большинство комментариев свидетельствуют о непонимании сути затирания типов. Если Java-разработчик говорит: «Я не люблю затирание типов», то в большинстве случаев это означает «Мне нужен List int». Вопрос примитивной специализации дженериков лишь косвенно связан с затиранием, а польза от дженериков, видимых в ходе исполнения, сильно преувеличена молвой.

Беззнаковые вычисления (unsigned arithmetic) на уровне виртуальной машины. Отсутствие в Java поддержки беззнаковых арифметических типов вызывает недовольство разработчиков уже многие годы. Но это является обдуманным решением создателей языка. Наличие лишь знаковых вычислений существенно упрощает язык. Если сегодня начать внедрять беззнаковые типы, то это повлечёт за собой очень серьёзную переработку Java, что чревато массой больших и маленьких багов, которые будет трудно вылавливать. Заодно сильно возрастает риск дестабилизации всей платформы.

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

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

Более выразительный синтаксис импорта


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

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

import java.util.{List, Map};

Читабельность кода повысилась бы за счёт возможности локального переименования типа (или создания алиаса). Заодно было бы меньше путаницы с типами, имеющими одинаковое короткое имя класса:

import java.util.{Date : UDate};
import java.sql.{Date : SDate};
import java.util.concurrent.Future;
import scala.concurrent.{Future : SFuture};

Пошло бы на пользу и внедрение расширенных подстановочных символов:

import java.util.{*Map};

Это небольшое, но полезное изменение, которое можно целиком реализовать с помощью javac.

Литералы коллекций


В Java присутствует синтаксис (хотя и ограниченный) для объявления литералов массива. Например:

int[] i = {1, 2, 3};

У этого синтаксиса есть ряд недостатков. Например, литералы должны использоваться только в инициализаторах.

Массивы в Java не являются коллекциями. «Мостовые методы», представленные во вспомогательном классе Arrays, также имеют изъяны. Скажем, метод Arrays.asList() возвращает ArrayList, который при ближайшем рассмотрении оказывается Arrays.ArrayList. Этот внутренний класс не содержит методов, альтернативных List, а похожие методы выбрасывают исключение OperationNotSupportedException. В результате в API возникает уродливый шов, затрудняющий переход между массивами и коллекциями.

Для отказа от синтаксиса объявления литералов массива нет причин, в той или иной форме он присутствует во многих языках. Например, на Perl можно написать так:

my $primes = [2, 3, 5, 7, 11, 13];
my $capitals = {'UK' => 'London', 'France' => 'Paris'};
На Scala — так:
val primes = Array(2, 3, 5, 7, 11, 13);
val m = Map('UK' -> 'London', 'France' -> 'Paris');

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

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


Именование играет очень большую роль в системе типов в Java. Все переменные должны относится к именованным типам, и невозможно выразить тип только через определение его структуры. В других языках, например, в Scala, можно выражать тип, не объявляя его при реализации интерфейса (или трейта Scala), а просто подтвердив, что он содержит определённый метод:

def whoLetTheDucksOut(d: {def quack(): String}) {
 println(d.quack());
}

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

Неслучайно в качестве примера был выбран quack() — структурная типизация имеет много общего с «утиной типизацией» в том же Python. Однако в Scala типизация осуществляется при компиляции, что говорит о гибкости языка с точки зрения выражения типов, которые было бы трудно или невозможно выразить в Java. К сожалению, здесь система типов имеет очень небольшие возможности по структурной типизации. Можно задать локальный анонимный тип с дополнительными методами, и если один из них будет сразу же вызван, то Java позволит скомпилировать код.

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

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


Благодаря дженерикам Java является языком с параметризованными типами (parameterized types), представляющими собой ссылочные типы (reference types) с параметрами типа. При их подстановке в одни типы образуются другие. То есть получившиеся типы состоят из «контейнеров» (обобщённых типов) и «полезной нагрузки» (значения параметров типа).

В некоторых языках поддерживаемые составные типы сильно отличаются от дженериков Java. В качестве примера сразу напрашиваются кортежи (tuples), хотя куда больший интерес вызывают тип-суммы (sum type), иногда называемые также «несвязными объединениями типов» (disjoint union of types) или «размещенными объединениями» (tagged union).

Тип-сумма — это однозначный тип, то есть в каждый момент времени переменные могут иметь лишь одно значение. Но при этом оно может быть любым валидным значением, относящимся к указанному диапазону различных типов. Это справедливо даже в том случае, если являющиеся значениями несвязные типы никак не связаны друг с другом с точки зрения наследования. К примеру, в языке F# можно задать тип Shape, экземплярами которого могут быть прямоугольники или круги:

type Shape =
| Circle of int
| Rectangle of int * int

F# сильно отличается от Java, но и в Scala эти типы реализованы с ограничениями: запечатанные типы (sealed types) применяются с case-классами. Запечатанный класс нельзя расширять за пределами текущей единицы компиляции (compilation unit). Это практически аналог терминального класса в Java, но в Scala базовой единицей компиляции является файл, и многочисленные высокоуровневые открытые классы (public classes) могут объявляться в единственном файле.

Это приводит нас к паттерну, при котором запечатанный абстрактный базовый класс объявляется вместе с несколькими подклассами, которые соответствуют возможным несвязным типам из тип-суммы. В стандартной библиотеке Scala содержится много примеров использования этого паттерна, включая Option[A], который аналогичен типу Optional T из Java 8.

В Scala к несвязным объединениям двух возможностей относятся Option и Some, а также None и Option type.

Если бы мы реализовали в Java такой же механизм, то столкнулись бы с ограничением, когда единица компиляции, по сути, является классом. Получается не так удобно, как в Scala, но всё же можно придумать способы решения. Например, можно было бы использовать javac для обработки нового синтаксиса применительно к классам, которые мы хотим запечатать:

final package example.algebraic;

Подобный синтаксис означал бы, что компилятор должен допускать расширение класса с учётом конечной упаковки в рамках текущей папки, отклоняя все иные попытки расширения. Это изменение тоже можно было бы реализовать с помощью javac, но без проверок в ходе исполнения его нельзя полностью защитить от циклического кода (reflective code). Кроме того, Java-реализация была бы менее полезной, чем в Scala, поскольку в Java не хватает развитых выражений сопоставления (match expressions).

Точки динамического вызова (Dynamic call sites)


Начиная с версии 7 в Java появился на удивление полезный инструмент: байткод invokedynamic, призванный выполнять роль основного механизма вызова. Это позволяет исполнять динамические языки поверх JVM, а также расширять систему типов в Java путём добавления встроенных методов и изменения интерфейса, в то время как раньше это было невозможно. Расплачиваться за это приходится несколько возросшей сложностью. Но при умелом обращении invokedynamic является мощным инструментом.

Правда, у него есть одно странное ограничение. Несмотря на объявленную поддержку в Java 7, почему-то до сих пор не обеспечивается прямой доступ к методам динамического вызова. Хотя весь смысл динамической диспетчеризации заключается в том, чтобы позволить разработчикам самим принимать решение, какой метод вызывать из конкретной точки вызова, причём принятие решения можно отложить до момента исполнения кода.

Примечание: не путайте этот способ динамического связывания с ключевым словом dynamic из C#. В нашем случае вводится объект, в ходе выполнения динамически определяющий свои привязки; это не сработает, если объект не поддерживает запрашиваемые вызовы методов. Экземпляры подобных динамических объектов в ходе выполнения неотличимы от «обычных» объектов, а сам механизм получается небезопасным.

В то время, как для реализации лямбда-выражений и встроенных методов в Java используется invokedynamic, разработчики не имеют прямого доступа и не могут осуществлять диспетчеризацию во время выполнения. Иными словами, в Java нет ключевого слова или иной конструкции для создания точек вызова invokedynamic общего назначения. Компилятор javac просто не транслирует инструкции invokedynamic за рамками инфраструктуры языка.

Можно достаточно просто добавить в Java эту функциональность. Например, с помощью какого-нибудь ключевого слова или аннотирования. Также потребуется дополнительная библиотека и поддержка на стадии сборки.

Проблески надежды?


Развитие архитектуры языка и его реализация — это искусство достижения возможного. Существует немало примеров, когда важные изменения очень долго пробивают себе дорогу. Например, в С++ лямбда-выражения появились только в 14 версии.

Многим не нравится неторопливость развития Java. Но Джеймс Гослинг придерживается позиции, что нельзя реализовывать функциональность, пока она не будет полностью понята и осознана. Хотя консервативность архитектуры Java является одной из причин успеха этого языка, в то же время она не нравится многим нетерпеливым молодым разработчикам, жаждущим быстрых перемен. Ведутся ли работы над внедрением каких-то из вышерассмотренных возможностей? Можно это осторожно предположить.

Некоторые из описанных идей можно реализовать с помощью того же invokedynamic. Как вы помните, он должен выполнять роль основного механизма вызова, отложенного до момента выполнения. Согласно предложению по улучшению языка JEP276, можно стандартизировать библиотеку Dynalink, которая изначально создавалась Аттилой Жегеди (Attila Szegedi) для реализации «протокола мета-объектов» в JVM. Позднее автор библиотеки перешёл работать в Oracle, который использовал Dynalink в Nashorn, реализации JavaScript на JVM. Описание библиотеки есть на Github, но сама она оттуда удалена.

По существу, Dynalink позволяет говорить об объектно-ориентированных операциях — «получить значение свойства», «присвоить свойству значение», «создать новый объект», «вызвать метод» — без необходимости воплощения их семантики с помощью соответствующих статически типизированных, низкоуровневых операций JVM.

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

Некоторыми ключевыми разработчиками Scala этот механизм рассматривался в роли возможной замены при реализации структурных типов в этом языке. Хотя в текущей версии ставка сделана на рефлексии, но появление Dynalink на сцене может всё изменить.
NIX
Компания

Комментарии 42

    +4
    Запечатанный класс нельзя применять за пределами текущей единицы компиляции (compilation unit).
    В оригинале:
    A sealed type in Scala is not extensible outside of the current compilation unit.
    .
    Не «применять», а «расширять» (наследовать). Если бы sealed классы нельзя было применять вне файла, в котором они объявлены, то в них было бы мало смысла.
      0
      def whoLetTheDucksOut(d: {def quack(): String}) {
       println(d.quack());
      }

      Неслучайно в качестве примера был выбран quack() — структурная типизация имеет много общего с «утиной типизацией» в том же Python. Однако в Scala типизация осуществляется при компиляции, что говорит о гибкости языка с точки зрения выражения типов, которые было бы трудно или невозможно выразить в Java. К сожалению, здесь система типов имеет очень небольшие возможности по структурной типизации. Можно задать локальный анонимный тип с дополнительными методами, и если один из них будет сразу же вызван, то Java позволит скомпилировать код.

      В данном примере используются structural types, которые реализуются через reflection. Так что проверки компилятором нет.

        +2
        Проверка компилятором как раз есть.
        scala> whoLetTheDucksOut(new { def quack() = "quack!" })
        quack!
        
        scala> whoLetTheDucksOut(new { })
        <console>:13: error: type mismatch;
         found   : java.lang.AnyRef
         required: java.lang.AnyRef{def quack(): String}
               whoLetTheDucksOut(new { })
                                 ^
        

        Но есть и рефлексия. Из-за нее проседает производительность, но и эту проблему, возможно, пофиксят. По крайней мере есть пути решения.
      • НЛО прилетело и опубликовало эту надпись здесь
          +1
          А в чем огромная польза, на ваш взгляд?
          • НЛО прилетело и опубликовало эту надпись здесь
              +3
              оооо, как богато.

              рефлекшен и оверлоадинг методов не стыкуются, надо каждый раз указывать все аргументы, чтоб найти метод — это неудобно

              а как бы вы сделали?

              Представьте себе аналог jdbc, который вам отдаёт итератор над по-человечески типированными кортежами

              а как типы данных определяются? Контежи типизированные? Чем вам Spring-JDBC не нравится?

              ораклу стоило бы больше внимания уделать эффективной работе новых языков на JVM

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

              Разрабы утверждают

              разрабы могут утвеждать всё, что угодно. Только вот цейлона в TIOBE нет даже в Top-50. (там доля в 0.2% является достаточной для попадания, если что).
              • НЛО прилетело и опубликовало эту надпись здесь
                  +2
                  слушайте, я вам задал вполне конкретные вопросы, а вы мне в ответ философию какую-то.

                  Давайте я совсем просто переформулирую:
                  1. Как сделать рефлекшен по куче перегруженных методов?
                  2. как типизировать кортежи (ruple, row), прилетающие из БД?
                  3. зачем вендору вкладываться в развитие языков, на которые никто не пишет?
                  4. как можно сравнивать «клевый» язык, которым мало кто пользуется с языком, которым пользуются десятки миллионов людей?
                  5. насчет языков и хабраопроса — вы знаете, что такое «survivor bias»?
                  • НЛО прилетело и опубликовало эту надпись здесь
                      0
                      Хотел у вас спросить, несколько отклоняясь от основной линии дискуссии. Я уже не первый раз вижу негативное отношение к перегруженным методам с scala-сообществе. И, если я правильно понимаю, вы разделяете это мнение. Но я никак не могу понять, чем можно было бы их заменить, и в чем конкретно проблемы с ними связанные, кроме более сложного рефлекшена. Просто, только лишь усложнение рефлекшна мне кажется гораздо менее важным, чем наличие перегрузок, т.к. рефлекшн, по большому счету некий костыль, которым пытаются компенсировать негибкость языка и его использование в любом случае должно быть минимальным.
                      • НЛО прилетело и опубликовало эту надпись здесь
                        • НЛО прилетело и опубликовало эту надпись здесь
                0
                Паттерн матчинг, возможность дважды наследовать интерфейсы с разным параметром типов, перегрузка методов.

                Каждый раз когда я пытаюсь написать-то нибудь достаточно сложное в scala, у меня это не выходит из-за того, что в JVM есть type erasure.

                Java-программистов, понятное дело, это не напрягает. Они вместо сложных отношений типов фигачат везде Object и приводят его по мере необходимости. Концепция статических типов и типобезопасного программирования прошла мимо них.
              +7
              Не хватает только некоторым, большинству хватает
                +1
                В течение многих лет большинству хватало (да и сейчас, поверьте, многим хватает) if, else и goto :)
                  +1
                  if, else

                  … которые, в сущности, выражаются через goto, так что список можно сократить до одного элемента :)
                    +1
                    else не нужен
                  –14
                  Тормоза бы лучше починили, все остальное терпимо
                    +2
                    Мне кажется, или я вижу вас в каждом треде про JVM-based langs и вы везде говорите, что они тормозят?
                      –4
                      Ну я же правду говорю :) К сожалению приходится пользоваться тулами, написанными на JVM-based langs и их производительность меня категорически не устраивает.
                        +1
                        Мыши кололись, но ели кактус. (с) Если вам придется пользоваться кривонаписанной криворукими разработчиками программой на C, вы будете в каждой статье тогда язык С обвинять? Найдите аналогичные тулы, которые не написаны на JVM, смените работу в конце концов, зачем же так мучится и тратить нервы.
                          0
                          Не для всего есть альтернатива.

                          >Если вам придется пользоваться кривонаписанной криворукими разработчиками программой на C
                          Это вы сейчас про JVM?
                            +1
                            Вам уже говорили, что дело не в JVM, а в ваших утилитах, кривые руки они хоть на асемблере — кривые руки. Ниже я написал про конвертеры в нативный код, попробуйте может поможет.
                          0
                          Кстати, попробуйте тут описаны тулзы по конвертации jar файлов в нативный код: такие как Launch4j и packr. Если я понял ваша главная проблема загрузка JVM, попробуйте перегнать все в натив и может быть вам будет счастье.
                            +6
                            Если он решит проблему — он больше не сможет ныть.
                              0
                              Натив? Он же просто пакует выбранную JVM в бандл
                                0
                                Например, в описании packr указано что он перегоняет в найтив и jar и jre, при этом есть возможность ограничить только нужной частью jre. И что он наиболее подходит для GUI приложений. Не знаю, не пользовался, но описание на github'e выглядит интересно.
                        0
                        Уж хотя бы свойства могли бы добавить. Но видимо не несущий функциональности, синтаксический сахар считается раздувающим язык и затрудняющим понимание.
                          0
                          Автор, что Вы можете сказать о Kotlin?
                            0
                            Лично мне не хватает typedef'ов для повышения читабельности параметризованных типов — страдаешь сначала записывая объявление, а потом еще и читая его
                              +1
                              Например, в С++ лямбда-выражения появились только в 14 версии
                              Лямбды появились в С++11
                                +4
                                Более выразительный синтаксис импорта

                                не помню когда в последний раз писал импорт руками
                                  +2
                                  Я пишу на Java и на Ruby, после Ruby больше всего не хватает именованных (непозиционных) аргументов и значений аргументов по умолчанию.
                                  def foo(arg1:, arg2: nil)

                                  end

                                  Куда больше не хватает unsigned Integers, но эта тема в статье раскрыта.
                                    +1
                                    Литералов регулярных выражений не хватает. Экранирование регулярного выражения выраженного строкой распухает сильнее, чем могло бы, будь отдельный литерал для регэкспов.
                                      0
                                      Вот чего мне реально не хватает:
                                      К примеру переделать сравнение примитивных типов Integer == int, чтобы не падало NPE, А выдавало false если null сравнивают с 666 к примеру.
                                      Ещё чтобы == работал для String, а не надо было писать «bla».equals(«bla»)
                                        0
                                        Давайте разделим фичи на три типа:
                                        1. тяжелые, значительно затрагивающие работу VM
                                        2. средние, добавляющие в язык определенный функционал, но контролируемых на уровне компилятора
                                        3. легкие — всякий синтаксический сахар

                                        К первому типу будет относиться value types, если когда-нибудь вообще выйдут. За все время существования Java не было сделано столь серьезных изменений. Даже появление generics и лямбд фактически не затрагивало VM. Любую подобную фичу реализовать с гарантией обратной совместимости будет огромной проблемой. Придется решать сразу огромный круг задач из всевозможных областей, менять спецификации, JMM, валидатор кода, поддерживать на всех архитектурах, etc…

                                        Второй тип — это все, что до сих пор делалось в Java. Тем не менее всегда проблемно что-то встроить, не поломав совместимости. С одной стороны у нас есть крутая фича, с другой — API Java и куча легаси-кода, который нужно мигрировать. Основной затык в лямбдах был именно встроить их в коллекции. Разработчики реально задолбались, в итоге выкинули все нахрен и сделали отдельно Stream. Даже такая простая вещь как unsigned тип поломает все и сразу: сериализацию, все библиотеки, работающие с рефлекшном, абсолютно все фреймворки, etc…

                                        Сахар? Ну а как бы зачем? Семантически запись не меняется, лишь делается чуть короче, что не имеет большого смысла, т.к. сегодня IDE берет на себя всю нагрузку по печатанию кода. Посмотрите, какая полемика развернулась по поводу var. Так что импорты, литералы и прочее — это то, что меньше всего волнует.

                                        Поэтому если вам нужна гибкость и плюхи, и вас не беспокоит, то, что через год-два ваш код не будет компилироваться или работать — всегда есть альтернативные JVM языки.
                                          0
                                          Мне, как C# программисту, но которому приходится программировать и на java (android) самым главным минусом является отсутствие делегатов, и, как следствие, нормальных событий.
                                          Написание интерфейсов OnXXXListener на каждый чих весьма утомляет. Делегаты делают все гибче и проще.

                                          Не хватает и свойств, геттеры-сеттеры функции не так удобны и красивы. Но это чисто сахар.

                                          Так же не хватает неких аналогов async, но C# они реализуются на уровне компилятора через корутины, которых тоже нет (?).
                                            +1
                                            Пишите на scala под andoid.
                                              0
                                              Вместо делегатов можно использовать interface с нужной функцией. После реализовать его в нужном классе и отдать класс туда где его будут вызывать.
                                                0
                                                Я так и делаю, что я и написал. Но это менее удобно и более утомительно.
                                              +1
                                              Мне, например, не хватает Case classes, как в Scala.

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

                                              Самое читаемое