Почему интерфейсы должны быть объявлены на Java?

#java #interface #static-typing #duck-typing #structural-typing

#java #интерфейс #статическая типизация #ввод с уткой #структурная типизация

Вопрос:

Иногда у нас есть несколько классов, которые имеют некоторые методы с одинаковой сигнатурой, но которые не соответствуют объявленному интерфейсу Java. Например, оба JTextField и JButton (среди нескольких других в javax.swing.* ) имеют метод

 public void addActionListener(ActionListener l)
  

Теперь предположим, что я хочу что-то сделать с объектами, у которых есть этот метод; затем я хотел бы иметь интерфейс (или, возможно, определить его самостоятельно), например

   public interface CanAddActionListener {
      public void addActionListener(ActionListener l);
  }
  

чтобы я мог написать:

   public void myMethod(CanAddActionListener aaa, ActionListener li) {
         aaa.addActionListener(li);
         ....
  

Но, к сожалению, я не могу:

      JButton button;
     ActionListener li;
     ...
     this.myMethod((CanAddActionListener)button,li);
  

Это приведение было бы незаконным. Компилятор знает, что JButton это не a CanAddActionListener , потому что класс не был объявлен для реализации этого интерфейса … однако он «на самом деле» реализует это.

Иногда это создает неудобства — и сама Java модифицировала несколько базовых классов для реализации нового интерфейса, созданного из старых методов ( String implements CharSequence , например).

Мой вопрос: почему это так? Я понимаю полезность объявления того, что класс реализует интерфейс. Но в любом случае, глядя на мой пример, почему компилятор не может сделать вывод, что класс JButton «удовлетворяет» объявлению интерфейса (заглядывая внутрь него) и принять приведение? Это проблема эффективности компилятора или есть более фундаментальные проблемы?

Мое краткое изложение ответов: Это тот случай, когда Java могла бы предусмотреть некоторую «структурную типизацию» (своего рода утиный ввод — но проверяется во время компиляции). Этого не произошло. Помимо некоторых (неясных для меня) трудностей с производительностью и реализацией, здесь есть гораздо более фундаментальная концепция: в Java объявление интерфейса (и вообще всего) должно быть не просто структурным (иметь методы с этими сигнатурами), но семантическим: предполагается, что методы реализуют некоторое конкретное поведение / намерение. Итак, класс, который структурно удовлетворяет некоторому интерфейсу (т. Е. у него есть методы с требуемыми сигнатурами), не обязательно удовлетворяет ему семантически (крайний пример: вспомните «маркерные интерфейсы», у которых даже нет методов!). Следовательно, Java может утверждать, что класс реализует интерфейс, потому что (и только потому, что) это было явно объявлено. Другие языки (Go, Scala) имеют другие философии.

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

1. Возможно, вас заинтересует язык программирования Go. Смотрите golang.org . Он имеет семантику интерфейса точно так, как вы хотите в своем вопросе.

Ответ №1:

Выбор дизайна Java, позволяющий реализующим классам явно объявлять интерфейс, который они реализуют, — это просто выбор дизайна. Безусловно, JVM была оптимизирована для этого выбора, и реализация другого варианта (скажем, структурной типизации Scala) может теперь потребовать дополнительных затрат, если только не будут добавлены некоторые новые инструкции JVM.

Итак, в чем именно заключается выбор дизайна? Все сводится к семантике методов. Подумайте: являются ли следующие методы семантически одинаковыми?

  • draw(String graphicalShapeName)
  • нарисовать (строковое имя_ручки)
  • нарисовать (String playingCardName)

Все три метода имеют сигнатуру draw(String) . Человек может сделать вывод, что они имеют разную семантику, исходя из имен параметров или прочитав некоторую документацию. Есть ли какой-либо способ для машины определить, что они разные?

Выбор дизайна Java заключается в том, чтобы требовать, чтобы разработчик класса явно указывал, что метод соответствует семантике предопределенного интерфейса:

 interface GraphicalDisplay {
    ...
    void draw(String graphicalShapeName);
    ...
}

class JavascriptCanvas implements GraphicalDisplay {
    ...
    public void draw(String shape);
    ...
}
  

Нет сомнений в том, что draw метод в JavascriptCanvas предназначен для соответствия draw методу для графического отображения. Если кто-то попытался передать объект, который собирался вытащить пистолет, машина может обнаружить ошибку.

Выбор дизайна Go более либеральный и позволяет определять интерфейсы постфактум. Конкретному классу не обязательно объявлять, какие интерфейсы он реализует. Скорее всего, разработчик нового компонента карточной игры может объявить, что объект, предоставляющий игральные карты, должен иметь метод, соответствующий подписи draw(String) . Это имеет то преимущество, что любой существующий класс с этим методом может быть использован без необходимости изменять его исходный код, но недостаток в том, что класс может вытащить пистолет вместо игральной карты.

Дизайнерский выбор языков с утиным набором текста заключается в том, чтобы полностью отказаться от формальных интерфейсов и просто сопоставлять сигнатуры методов. Любая концепция интерфейса (или «протокола») является чисто идиоматической, без прямой языковой поддержки.

Это всего лишь три из многих возможных вариантов дизайна. Все три можно кратко изложить следующим образом:

Java: программист должен явно объявить о своем намерении, и машина проверит это. Предполагается, что программист, скорее всего, допустит семантическую ошибку (графика / пистолет / карта).

Переход: программист должен объявить хотя бы часть своего намерения, но машине остается меньше времени для его проверки. Предполагается, что программист, вероятно, может допустить техническую ошибку (целое число / строка), но вряд ли допустит семантическую ошибку (графика / пистолет / карта).

Ввод с уткой: программисту не нужно выражать никаких намерений, и машине нечего проверять. Предполагается, что программист вряд ли допустит канцелярскую или семантическую ошибку.

Рассмотрение вопроса о том, являются ли интерфейсы и набор текста в целом адекватными для проверки на канцелярские и семантические ошибки, выходит за рамки этого ответа. Для полноценного обсуждения необходимо было бы рассмотреть технологию компиляции во время сборки, методологию автоматизированного тестирования, компиляцию во время выполнения / по горячим точкам и множество других вопросов.

Признается, что draw(String) пример намеренно преувеличен, чтобы подчеркнуть значимость. Реальные примеры включали бы более богатые типы, которые дали бы больше подсказок для устранения неоднозначности методов.

Ответ №2:

Почему компилятор не может определить, что класс JButton «удовлетворяет» объявлению интерфейса (заглядывая внутрь него) и принять приведение? Это проблема эффективности компилятора или есть более фундаментальные проблемы?

Это более фундаментальная проблема.

Смысл интерфейса заключается в том, чтобы указать, что существует общий API / набор поведений, которые поддерживает ряд классов. Итак, когда класс объявлен как implements SomeInterface , любые методы в классе, сигнатуры которых соответствуют сигнатурам методов в интерфейсе, предполагаются как методы, обеспечивающие такое поведение.

В отличие от этого, если бы язык просто сопоставлял методы, основанные на сигнатурах … независимо от интерфейсов … тогда мы были бы склонны получать ложные совпадения, когда два метода с одинаковой сигнатурой на самом деле означают / делают что-то семантически несвязанное.

(Название для последнего подхода — «утиный ввод» … и Java это не поддерживает.)


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

«Системы типов, подобные Java, в которых имена [типов] являются значимыми, а подтипы явно объявлены, называются номинальными. Системы типов, подобные большинству описанных в этой книге, в которых имена несущественны, а подтипы определяются непосредственно в структуре типов, называются структурными

Итак, по определению Пирса, утиная типизация — это форма структурной типизации, хотя обычно она реализуется с использованием проверок во время выполнения. (Определения Пирса не зависят от проверки во время компиляции или во время выполнения.)

Ссылка:

  • «Типы и языки программирования» — Benjamin C. Pierce, MIT Press, 2002, ISBN 0-26216209-1.

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

1. 1 Не было бы тогда более уместным (более согласованным) назвать мое желаемое поведение «структурной типизацией», а не «утиной типизацией»?

2. @leonbloy — возможно, так и было бы. Но вы, скорее всего, услышите такую вещь, как утиный ввод. Если вы называете это структурной типизацией, вы, вероятно, получите людей, говорящих, что речь идет о поведении, а не о (репрезентативной) структуре. Проблема в том, что терминология системы типов очень резиновая.

Ответ №3:

Вероятно, это особенность производительности.

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

Позже, во время выполнения, когда объект приводит свой класс к типу интерфейса, все, что нужно сделать во время выполнения, это проверить метаданные класса, чтобы увидеть, совместим ли класс, в который он приводится, тоже (через интерфейс или иерархию наследования).

Это достаточно дешевая проверка, поскольку компилятор выполнил большую часть работы.

Имейте в виду, это не авторитетно. Класс может СКАЗАТЬ, что он соответствует интерфейсу, но это не означает, что фактический метод, отправляемый для выполнения, действительно будет работать. Соответствующий класс вполне может устареть, а метод может просто не существовать.

Но ключевым компонентом производительности java является то, что, хотя он все еще должен фактически выполнять форму динамической отправки метода во время выполнения, существует соглашение о том, что метод не собирается внезапно исчезать за временем выполнения. Таким образом, как только метод найден, его местоположение может быть кэшировано в будущем. В отличие от динамического языка, где методы могут появляться и исчезать, и они должны продолжать пытаться отыскивать методы при каждом вызове. Очевидно, что в динамических языках есть механизмы, обеспечивающие хорошую производительность.

Теперь, если среда выполнения должна была убедиться, что объект соответствует интерфейсу, выполнив всю работу самостоятельно, вы можете видеть, насколько это может быть дороже, особенно с большим интерфейсом. Результирующий набор JDBC, например, содержит более 140 методов и тому подобное.

Утиный ввод — это эффективное динамическое сопоставление интерфейсов. Проверьте, какие методы вызываются для объекта, и сопоставьте его во время выполнения.

Вся информация такого рода может быть кэширована и собрана во время выполнения и т.д. Все это возможно (и есть на других языках), но выполнение большей части этого во время компиляции на самом деле довольно эффективно как для процессора среды выполнения, так и для его памяти. Хотя мы используем Java с несколькими гигабайтными кучами для длительно работающих серверов, на самом деле она вполне подходит для небольших развертываний и ограниченного времени выполнения. Даже за пределами J2ME. Таким образом, все еще есть мотивация попытаться сохранить объем времени выполнения как можно меньше.

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

1. Хорошее объяснение. Но — что касается проблемы с производительностью и динамической утиной типизации — я думаю, что мое желаемое поведение касается только времени компиляции. То, что JButton «может» быть приведен к CanAddActionListener, может быть определено во время компиляции.

2. Верно, но не обязательно. Итак, я не знаю специфики различных реализаций DT. Во время компиляции может быть захвачено много информации. Но если у вас есть «Список<Объект>» и вы можете выполнять произвольные вызовы в нем, это стирает много информации, позволяя мне добавлять «что угодно» в этот список. Вся эта отправка и обработка должны выполняться динамически во время выполнения. Таким образом, вероятно, всегда будет какое-то влияние при начальной отправке.

Ответ №4:

Утиный ввод может быть опасным по причинам, которые обсуждал Стивен Си, но это не обязательно зло, которое нарушает всю статическую типизацию. Статическая и более безопасная версия duck typing лежит в основе системы типов Go, а версия доступна в Scala, где она называется «структурная типизация». Эти версии по-прежнему выполняют проверку во время компиляции, чтобы убедиться, что объект соответствует требованиям, но имеют потенциальные проблемы, поскольку они нарушают парадигму проектирования, согласно которой реализация интерфейса всегда является преднамеренным решением.

Смотрите http://markthomas.info/blog/?p=66 и http://programming-scala.labs.oreilly.com/ch12.html и http://beust.com/weblog/2008/02/11/structural-typing-vs-duck-typing для обсуждения функции Scala.

Ответ №5:

Я не могу сказать, что знаю, почему определенные дизайнерские решения были приняты командой разработчиков Java. Я также подчеркиваю свой ответ тем фактом, что эти люди намного умнее, чем я когда-либо буду, в том, что касается разработки программного обеспечения и (особенно) языкового дизайна. Но вот попытка ответить на ваш вопрос.

Чтобы понять, почему они, возможно, не решили использовать интерфейс типа «CanAddActionListener», вы должны взглянуть на преимущества ОТКАЗА от использования интерфейса и, вместо этого, предпочтения абстрактных (и, в конечном счете, конкретных) классов.

Как вы, возможно, знаете, при объявлении абстрактной функциональности вы можете предоставить подклассам функциональность по умолчанию. Okay…so что? Большое дело, верно? Что ж, особенно в случае разработки языка, это большое дело. При разработке языка вам нужно будет поддерживать эти базовые классы на протяжении всего срока службы языка (и вы можете быть уверены, что по мере развития вашего языка будут вноситься изменения). Если вы решили использовать интерфейсы вместо предоставления базовой функциональности в абстрактном классе, любой класс, реализующий интерфейс, сломается. Это особенно важно после публикации — как только клиенты (в данном случае разработчики) начнут использовать ваши библиотеки, вы не сможете изменять интерфейсы по прихоти, иначе у вас будет много недовольных разработчиков!

Итак, я предполагаю, что команда разработчиков Java полностью осознала, что многие из их классов AbstractJ * совместно используют одни и те же имена методов, было бы невыгодно, если бы они совместно использовали общий интерфейс, поскольку это сделало бы их API жестким и негибким.

Подводя итог (спасибо этому сайту здесь):

  • Абстрактные классы можно легко расширить, добавив новые (неабстрактные) методы.
  • Интерфейс не может быть изменен без нарушения его контракта с классами, которые его реализуют. Как только интерфейс отправлен, набор его элементов постоянно фиксируется. API, основанный на интерфейсах, может быть расширен только путем добавления новых интерфейсов.

Конечно, это не означает, что вы могли бы сделать что-то подобное в своем собственном коде (расширить AbstractJButton и реализовать интерфейс CanAddActionListener), но имейте в виду подводные камни при этом.

Ответ №6:

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

В качестве простого примера, если бы кто-то разрабатывал фреймворк с нуля, можно было бы начать с Enumerable<T> интерфейса (который можно использовать так часто, как желательно, для создания перечислителя, который будет выводить последовательность T, но разные запросы могут выдавать разные последовательности), но затем вывести из него интерфейс, ImmutableEnumerable<T> который вел бы себя как указано выше, но гарантировал, что каждый запрос будет возвращать одну и ту же последовательность. Изменяемый тип коллекции поддерживал бы все элементы, необходимые для ImmutableEnumerable<T> , но поскольку запросы на перечисление, полученные после мутации, сообщали бы о последовательности, отличной от запросов, сделанных ранее, это не соответствовало бы ImmutableEnumerable контракту.

Способность интерфейса рассматриваться как инкапсулирующий контракт за пределами подписей его участников является одной из вещей, которая делает программирование на основе интерфейса более семантически мощным, чем простой набор текста.