дженерики (программистское)
May. 22nd, 2010 12:26 am(эта запись может быть интересна разве что программистам)
Прочитал Java Generics FAQ. 500 страниц, между прочим, в PDF-версии. 500 страниц. Когда вы в последний раз читали FAQ на 500 страниц? Ну вот и я тогда же. С трудом верится даже теперь, по прочтении. Правда, там много места занимают бесконечные индексы и оглавления и заголовки, так что реально "мяса" страниц на 350. Но и этого довольно.
Теперь я понимаю, зачем в Джаве класс Enum определяется, как Enum<E extends Enum<E>>, что это в точности значит, и зачем это нужно. Не уверен, что мне нравится, что я это теперь понимаю.
Многие объяснения в этом фэке читаются, как один сплошной WTF. Ты понимаешь, в процессе чтения, почему это сделано так, а не иначе. Почему и тут исключение, и тут, и это надо делать через задницу, а то даже через задницу не сделать. Почему - один пример наугад из сотни - все обычные вещи с параметризованными типами можно делать, а вот создавать массивы из них нельзя. Кроме случая, когда они параметризованы неограниченными вопросиками. Ты понимаешь, почему это так получается, но не оставляет ощущение глубокого WTF на тему того, как мучительно и болезненно эти дженерики врастают в плоть языка. Сказать, что это leaky abstraction - ничего не сказать; эта лодка не протекает, она буквально состоит из воды.
Вот один из прекрасных вопросов из этого FAQ:
По-моему, внесение дженериков в Джаву было огромной ошибкой. Главная польза от них - строгая типизация коллекций - не оправдывает той огромной цены, которую пришлось заплатить: сложностью языка, читабельностью и понимабельностью кода. Учитывая то, что динамическая безопасность у коллекций и так была, необходимость безопасности на уровне компиляции была кем-то, по-видимому, сильно преувеличена. Да, конечно, намного удобнее и проще написать ArrayList<String>, и знать, что попытка всунуть туда что-то другое вообще не скомпилируется. Это удобнее, чем всунуть что-то не то в ArrayList и получить исключение в рантайме, когда это взяли и попытались привести к String. Баги ловятся быстрее. Но насколько быстрее, как часто на практике это оказывалось существенным, и оправдывает ли эта польза введение архитектуры, приводящей к Enum<E extends Enum<E>> или вопросам, подобным процитированному выше?
Одним из главных преимуществ Джавы по сравнению с C++ было именно grokability исходного кода средним программистом: посмотрев на исходник какого-то класса, который он раньше не видел, средний программист на Джаве - не гуру, не хватающий звезды с неба итд. - мог сказать, что делает каждая строка и зачем это нужно. Дженерики это свойство языка с помпой похоронили. Кто были люди, которые приняли решение так поступить, и радовались ли они красивому трюку само-референтных типов? И это только ущерб понятности кода, не говоря о всем этом огромном числе исключений и плохой совместимости с существующими частями языка - массивами, рефлекцией, исключениями, итд. итд. итд.
Можно ли было добиться той же пользы, что дали дженерики, менее разрушительными способами? Пусть не той же, но главной ее части, мне кажется, можно было. Скажем, какая-нибудь аннотация в момент создания коллекции, подсказывающая компилятору, что там должно быть. Просто сказать: в эту коллекцию я хочу класть строки, или такие-то пары, итп. И пусть компилятор проверяет, что сможет, и вставляет эксплицитное приведение к строкам или парам, когда мы из коллекции что-то достаем. И все. Без extends, без wildcards, для всех мест, где это действительно надо, пусть программист продолжает приводить эксплицитно. Конечно, такая аннотация не покрыла бы все случаи, но самые простые и важные, думаю, покрыла бы. Главное ведь то, что у дженериков почти нулевой эффект на рантайм, поэтому необязательно было вносить их глубоко в ткань языка, so to speak; подсказки компилятору достигают схожей цели. Простая (простая!) и удобная подсказка захватила бы, мне кажется, большинство багов с типами в коллекцях, которые до дженериков проявлялись только в рантайме.
(disclaimer: я редко и мало пишу на Джаве, и не эксперт в ней)
Прочитал Java Generics FAQ. 500 страниц, между прочим, в PDF-версии. 500 страниц. Когда вы в последний раз читали FAQ на 500 страниц? Ну вот и я тогда же. С трудом верится даже теперь, по прочтении. Правда, там много места занимают бесконечные индексы и оглавления и заголовки, так что реально "мяса" страниц на 350. Но и этого довольно.
Теперь я понимаю, зачем в Джаве класс Enum определяется, как Enum<E extends Enum<E>>, что это в точности значит, и зачем это нужно. Не уверен, что мне нравится, что я это теперь понимаю.
Многие объяснения в этом фэке читаются, как один сплошной WTF. Ты понимаешь, в процессе чтения, почему это сделано так, а не иначе. Почему и тут исключение, и тут, и это надо делать через задницу, а то даже через задницу не сделать. Почему - один пример наугад из сотни - все обычные вещи с параметризованными типами можно делать, а вот создавать массивы из них нельзя. Кроме случая, когда они параметризованы неограниченными вопросиками. Ты понимаешь, почему это так получается, но не оставляет ощущение глубокого WTF на тему того, как мучительно и болезненно эти дженерики врастают в плоть языка. Сказать, что это leaky abstraction - ничего не сказать; эта лодка не протекает, она буквально состоит из воды.
Вот один из прекрасных вопросов из этого FAQ:
What is the difference between a Collection<Pair<String,Object>>, a Collection<Pair<String,?>> and a Collection<? extends Pair<String,?>>?Ответ начинается так: "All three types refer to collections that hold pairs where the first part is a String and the second part is of an arbitrary type. The differences are subtle." Потом на двух страницах объясняются эти subtle differences.
По-моему, внесение дженериков в Джаву было огромной ошибкой. Главная польза от них - строгая типизация коллекций - не оправдывает той огромной цены, которую пришлось заплатить: сложностью языка, читабельностью и понимабельностью кода. Учитывая то, что динамическая безопасность у коллекций и так была, необходимость безопасности на уровне компиляции была кем-то, по-видимому, сильно преувеличена. Да, конечно, намного удобнее и проще написать ArrayList<String>, и знать, что попытка всунуть туда что-то другое вообще не скомпилируется. Это удобнее, чем всунуть что-то не то в ArrayList и получить исключение в рантайме, когда это взяли и попытались привести к String. Баги ловятся быстрее. Но насколько быстрее, как часто на практике это оказывалось существенным, и оправдывает ли эта польза введение архитектуры, приводящей к Enum<E extends Enum<E>> или вопросам, подобным процитированному выше?
Одним из главных преимуществ Джавы по сравнению с C++ было именно grokability исходного кода средним программистом: посмотрев на исходник какого-то класса, который он раньше не видел, средний программист на Джаве - не гуру, не хватающий звезды с неба итд. - мог сказать, что делает каждая строка и зачем это нужно. Дженерики это свойство языка с помпой похоронили. Кто были люди, которые приняли решение так поступить, и радовались ли они красивому трюку само-референтных типов? И это только ущерб понятности кода, не говоря о всем этом огромном числе исключений и плохой совместимости с существующими частями языка - массивами, рефлекцией, исключениями, итд. итд. итд.
Можно ли было добиться той же пользы, что дали дженерики, менее разрушительными способами? Пусть не той же, но главной ее части, мне кажется, можно было. Скажем, какая-нибудь аннотация в момент создания коллекции, подсказывающая компилятору, что там должно быть. Просто сказать: в эту коллекцию я хочу класть строки, или такие-то пары, итп. И пусть компилятор проверяет, что сможет, и вставляет эксплицитное приведение к строкам или парам, когда мы из коллекции что-то достаем. И все. Без extends, без wildcards, для всех мест, где это действительно надо, пусть программист продолжает приводить эксплицитно. Конечно, такая аннотация не покрыла бы все случаи, но самые простые и важные, думаю, покрыла бы. Главное ведь то, что у дженериков почти нулевой эффект на рантайм, поэтому необязательно было вносить их глубоко в ткань языка, so to speak; подсказки компилятору достигают схожей цели. Простая (простая!) и удобная подсказка захватила бы, мне кажется, большинство багов с типами в коллекцях, которые до дженериков проявлялись только в рантайме.
(disclaimer: я редко и мало пишу на Джаве, и не эксперт в ней)
no subject
Date: 2010-05-22 03:34 pm (UTC)Будет ещё противнее
Не уверен; возможно; но как минимум будет понятнее.
no subject
Date: 2010-05-22 03:56 pm (UTC)У. Так, конечно, можно. Но как-то назвать общую для них сущность не получится. Допустим, я хочу функцию
applyFuncWithSameArgs(x) {return func(x,x);}. Что мне прописать в качестве типа x? base? Ошибка типизации - для двух аргументов базового типа такой функции просто нет. Написать дефолтную реализацию и пусть падает в рантайме? Кошмар и ужас.Жабские дженерики хоть как-то, но решают эту проблему:
interface HasFunc<X extends HasFunc<X>> { int func(X, X); } class Foo implements HasFunc<Foo> { ... } class Bar implements HasFunc<Bar> { ... }Просто, понятно, вольготно. Хаскелевские классы решают её ещё лучше. Multiple dispatch её НЕ решает; то, что для её маленькой части его можно использовать - это в лучшем случае счастливая случайность, в худшем - источник немеряных багов.> как минимум будет понятнее.
Нет, не будет.
Если вместо некоей схемы, базирующейся, всё-таки, на каких-то общих принципах, мы вводим какие-то способы заткнуть частные дырки там и тут - становится ГОРАЗДО непонятнее.
no subject
Date: 2010-05-22 04:23 pm (UTC)Не могу согласиться, что multiple dispatch не решает эту проблему; мне кажется, что наоборот - именно он ее и решает, а автореферентный джава-дженерик - грязный хак, который добивается этого невероятно извращенным путем, и намного более ограничен в том, чего можно им добиться.
Хаскель тоже решает проблему "по-настоящему", но пользуется для этого совсем другим видом полиморфизма.
Если вместо некоей схемы, базирующейся, всё-таки, на каких-то общих принципах, мы вводим какие-то способы заткнуть частные дырки там и тут - становится ГОРАЗДО непонятнее.
В качестве общего принципа я с этим согласен. Но в этом случае у нас "некая схема" слишком сложна для понимания простыми смертными, а "частные дырки" исполнены в виде совершенно необязательных подсказок компилятору, специально не выглядящих как "настоящая" часть языка. Такие подсказки всегда затыкают только частные дырки, в этом их суть.
no subject
Date: 2010-05-22 04:49 pm (UTC)А зачем нам вводить base в рассмотрение?
> и в рантайме вы получите желанное исключение
НЕжеланное; это исключение должно возникать при КОМПИЛЯЦИИ.
> Не могу согласиться, что multiple dispatch не решает эту проблему
Я, как бы, показал, почему он её не решает. Нет общей сущности "та штука, для которой определена func(эташтука, эташтука)".
> Хаскель тоже решает проблему "по-настоящему", но пользуется для этого совсем другим видом полиморфизма.
Да нет же, Хаскель использует ТОТ же вид полиморфизма. Параметрический. Он его записывает более приятно, да и реализует более грамотно (в жабе невозможно, например, иметь в интерфейсе метод типа
Integer -> X, где X - реализующий его тип), но суть та же.> "частные дырки" исполнены в виде совершенно необязательных подсказок компилятору, специально не выглядящих как "настоящая" часть языка.
Со временем их станет немыслимо много. И они будут восприниматься как "настоящая" часть языка, скорее даже - как "более настоящая", чем собственно код.
no subject
Date: 2010-05-22 04:59 pm (UTC)Хаскель и дженерики в Джаве решают ту же проблему с помощью другого вида полиморфизма - параметрического - поэтому я сказал 'другой вид'. В Джаве этот полиморфизм натянут очень неуклюжим способом на существующую систему OOP-типов, о чем и вся моя запись. В Хаскеле этот полиморфизм фундаментален в языке и ни на что не натянут. Судя по некоторым комментариям и одной презентации Kennedy, которую я успел посмотреть по одной из ссылок выше, в C# параметрический полиморфизм натянут на OOP-типы гораздо более элегантным и не порождающим тысячу исключений и специальных случаев способом.
no subject
Date: 2010-05-22 05:22 pm (UTC)Нет. В рамках OOP-полиморфизма эта задача НЕ РЕШАЕТСЯ. Вообще.
> Это решение элегантно
Я понимаю, что это в чистом виде вкусовщина, но удержаться не могу: по-моему, multiple dispatch вещь в принципе неэлегантная.
> Если вы хотите поймать func(foo, bar) уже на уровне компиляции в статическом языке, то решение типа func(base,base)=0 даст вам и это.
Интересно, каким образом.
Допустим, мы опишем нашу функцию func(base,base)=0. Что это должно означать? По логике вещей, это должно значить, что любой другой класс, наследующий от base, обязан её переопределить. В противном случае, мы, например, не можем создавать объекты этого класса (решение из C++, но я других, честно говоря, не вижу).
Ладно, пусть так. Но тогда, если подкласс foo определил функцию func(foo, foo), то функция func(foo, bar) остаётся неопределённой. По этой логике, компилятор всё равно не должен нам позволить создавать объекты класса foo, нет?
Или, может быть, считать, что функция вполне себе определена, если определена хоть какая-то её реализация, возможно, не для всех вариантов? Тогда как исключить, например, случай, когда кто-то намеренно определяет функцию func(foo, bar) и не определяет func(foo, foo)? Или, другой случай - допустим, нам НУЖНО, чтобы любой подкласс определял функцию func(foo, base) (а не func(foo, foo)) - как это указать явно?
Дженериками всё это делается:
interface HasFunc<T extends HasFunc<T>> { void func(T second); } class Foo implements HasFunc<Func> { public void func(Foo second) { ... } } ... <T extends HasFunc<T>> void grrm(T arg) { ... }> В Джаве этот полиморфизм натянут очень неуклюжим способом на существующую систему OOP-типов, о чем и вся моя запись.
Да, спорить не о чем, неуклюже. Но это как документация или секс: если есть и хорошее - отлично, если есть и плохое - всё же лучше, чем ничего.
> в C# параметрический полиморфизм натянут на OOP-типы гораздо более элегантным и не порождающим тысячу исключений и специальных случаев способом.
Ой, не очень-то. То есть, в теории у них всё заметно лучше - как я понимаю, каждый класс у них представляется некоторым объектом, и если нужен дженерик, то соответствующий объект-класс создаётся в рантайме. На практике это выливается в то, что при активном использовании дженериков этих объектов-классов создаётся слишком много и система мрёт.
no subject
Date: 2010-05-22 05:58 pm (UTC)no subject
Date: 2010-05-22 09:41 pm (UTC)import java.io.*; interface ScalarProduct<A extends ScalarProduct<A>> { Integer scalarProduct(A second); Cons<A> deepDown(Integer n); A revert(); } class Nil implements ScalarProduct<Nil> { public Nil(){} public Integer scalarProduct(Nil second) { return 0; } public Cons<Nil> deepDown(Integer n) { return new Cons<Nil>(n, new Nil()); } public Nil revert(){ return new Nil(); } } class Cons<A extends ScalarProduct<A>> implements ScalarProduct<Cons<A>> { public Integer value; public A tail; public Cons(Integer _value, A _tail) { value = _value; tail = _tail; } public Integer scalarProduct(Cons<A> second){ return value * second.value + tail.scalarProduct(second.tail); } public Cons<Cons<A>> deepDown(Integer n) { return new Cons<Cons<A>>(value, tail.deepDown(n)); } public Cons<A> revert() { return tail.revert().deepDown(value); } } class _Test{ public static Integer main(Integer n){ return _main(n, 0, new Nil(), new Nil()); } public static <A extends ScalarProduct<A>> Integer _main(Integer n, Integer i, A first, A second) { if (n == 0) { return first.scalarProduct(second.revert()); } else { return _main(n-1, i+1, new Cons<A>(2*i+1,first), new Cons<A>(i*i, second)); } } } public class Test{ public static void main(String[] args) throws IOException { BufferedReader input = new BufferedReader(new InputStreamReader(System.in)); System.out.println("Enter a number: "); Integer val = Integer.parseInt(input.readLine()); System.out.println(_Test.main(val)); } }no subject
Date: 2010-05-22 09:43 pm (UTC)using System; interface ScalarProduct<A> where A : ScalarProduct<A> { int scalarProduct(A second); Cons<A> deepDown(int n); A revert(); } class Nil : ScalarProduct<Nil> { public Nil(){} public int scalarProduct(Nil second) { return 0; } public Cons<Nil> deepDown(int n) { return new Cons<Nil>(n, new Nil()); } public Nil revert(){ return new Nil(); } } class Cons<A> : ScalarProduct<Cons<A>> where A : ScalarProduct<A> { public int value; public A tail; public Cons(int _value, A _tail) { value = _value; tail = _tail; } public int scalarProduct(Cons<A> second){ return value * second.value + tail.scalarProduct(second.tail); } public Cons<Cons<A>> deepDown(int n) { return new Cons<Cons<A>>(value, tail.deepDown(n)); } public Cons<A> revert() { return tail.revert().deepDown(value); } } class _Test{ public static int main(int n){ return _main(n, 0, new Nil(), new Nil()); } public static int _main<A>(int n, int i, A first, A second) where A : ScalarProduct<A> { if (n == 0) { return first.scalarProduct(second.revert()); } else { return _main(n-1, i+1, new Cons<A>(2*i+1,first), new Cons<A>(i*i, second)); } } } public class Test{ public static void Main(){ Console.Write("Enter a number: "); int val = Convert.ToInt32(Console.ReadLine()); Console.WriteLine(_Test.main(val)); } }no subject
Date: 2010-05-22 06:00 pm (UTC)no subject
Date: 2010-05-22 07:21 pm (UTC)А мужики-то и не знают!
Допустим, мы опишем нашу функцию func(base,base)=0. Что это должно означать? По логике вещей, это должно значить, что любой другой класс, наследующий от base, обязан её переопределить. В противном случае, мы, например, не можем создавать объекты этого класса (решение из C++, но я других, честно говоря, не вижу).
Multiple dispatch не так работает. Методы не принадлежат классам, а существуют независимо от них. Классы содержат только данные. Подклассы наследуют данные обычным способом, а методы "наследуют" благодаря тому, что методы, подходящие для суперкласса, подойдут и для подкласса тоже.
У base, foo, bar никаких "своих" методов нет, есть только функции func(base, base)=0, func(foo, foo), func(bar, bar). Создавать объекты типа foo или bar можно в любом случае. При попытке вызвать func(foo, bar), если компилятор знает достоверно типы аргументов, он может определить уже во время компиляции, что единственная подходящая функция абстрактна.
no subject
Date: 2010-05-22 09:17 pm (UTC)Пока что мужики решения не предложили.
> Multiple dispatch не так работает.
Да знаю я, как он работает.
> При попытке вызвать func(foo, bar), если компилятор знает достоверно типы аргументов, он может определить уже во время компиляции, что единственная подходящая функция абстрактна.
Тьфу. Не будет такой попытки в коде (в явном виде, по крайней мере).
Есть следующее:
1) Иерархия классов - как вы предлагаете, если я правильно понимаю, такая: класс base (условно говоря, абстрактный - в том смысле, что нас интересуют только его наследники, но не он сам) и унаследованные от него классы foo и bar.
2) Метод func - вы, как я понял, предлагаете определить его как функцию с двумя аргументами типа base.
3) Две функции, реализующие func - и мне не важно, написаны эти реализации внутри классов или как-то ещё - определённые, соответственно, как func(foo, foo) и func(bar, bar)
4) Функция grrm, которая принимает один аргумент x и вызывает func(x, x). Как я понял - поправьте, если я понял неверно - вы предлагаете определить её как grrm(base). Реализация примерно такая:
grrm(base x) {func(x, x);}Верно?Всё хорошо, всё замечательно. Наш воображаемый компилятор, допустим, это всё скомпилил.
Теперь допустим, что какой-то идиот выкинул реализацию func(bar, bar), оставив только реализацию func(foo, foo). Очевидно, теперь вызов grrm(bar) становится ошибочным. Я вижу следующие варианты действий:
i) При вызове grrm(bar) программа кидает исключение (просто падает, или как-то ещё неправильно себя ведёт). Это - вариант для динамического языка. ИМХО, не годится совершенно; подобная (не в точности) ошибка МОЖЕТ быть обнаружена при компиляции, значит, она ДОЛЖНА быть обнаружена при компиляции.
ii) Возникает ошибка при компиляции функции grrm(base). Я вижу только одну возможную причину кинуть подобную ошибку - компилятор каким-то образом обнаруживает, что для некоторых потомков base, а именно для класса bar, функция func(bar, bar) не определена. Это означает, что в момент компиляции функции grrm компилятор должен знать обо ВСЕХ потомках класса base. Иными словами, после того, как функция grrm скомпилирована, программист не имеет права вводить дополнительных потомков base; эта иерархия каким-то образом замораживается. Кроме того, он не имеет права удалять методы, ставшие ненужными - ведь это может повлиять на уже скомпилированную функцию grrm. Вам ещё не кажется, что это полный бред? Мне неизвестны языки, исповедующие такой подход.
iii) Функция grrm компилируется без замечаний, однако при попытке создать объект класса bar возникает ошибка компиляции. Опять же, единственная разумная причина для этого - неопределённая функция func(bar, bar). Какие на этом пути могут быть проблемы - я уже написал.
iv) Функция grrm компилируется без замечаний, объект класса bar отлично создаётся, но передать его в функцию grrm нет возможности, компилятор не даёт. Я не вижу, какой логикой он должен при этом руководствоваться. В интерфейсе функции grrm сказано - один объект класса base или его подкласса; о чём ещё можно говорить?
iv) Функцию grrm не компилируем вообще; требуем, чтобы её исходный код всё время был доступен. Это - подход шаблонов C++. Очень корявое решение; теоретически, я могу предположить, что при таком подходе будет настоящий ПП, а не то убожество, которое имеется в C++; однако, тогда получится, что мы тащим весь исходный код исключительно для проверки типизации, то есть, фактически, недостаточно специфицируем интерфейс.
v) Сигнатура указывается не grrm(base), а что-то вроде grrm(x | func(x,x) != 0). Это - ровно то, что делают Haskell и Java, только они ещё и оборачивают такое требование в дополнительную сущность - класс в Хаскеле и интерфейс в Джаве.