Когда полиморфизм терпит неудачу

в 8:41, , рубрики: перевод, переводы, Песочница, Программирование, метки: ,

Большинство фанатов ООП одновременно являются и фанатами полиморфизма. Многие хорошие в других отношениях книги (например, «Рефакторинг» Фаулера) впадают в крайность, утверждая, что если вы используете проверки типов во время выполнения (такие как операция instanceof в Java), то вы, по всей вероятности, в душе злодейский злодей из тех, что пугают маленьких детей операторами switch.

Вообще говоря, я согласен с тем, что использование instanceof и его аналогов обычно является признаком недостаточных навыков ООП проектирования. Полиморфизм лучше проверок типов, он делает код гибче и понятнее. Однако, по крайней мере в одном случае, достаточно распространенном чтобы считаться паттерном сам по себе, вы просто не можете использовать полиморфизм. Я бы применил его с удовольствием, честно, и если вы знаете как это сделать – расскажите мне. Но не думаю что это возможно, особенно в статических языках типа Java или C++.

Определение полиморфизма

На тот случай если вы незнакомы с терминологией ООП, полиморфизм – это претенциозное обозначение для концепции позднего связывания. Позднее связывание – это претенциозное обозначение (вы обнаружите здесь паттерн если копнете глубже) для отсрочки решения о том какой метод будет вызван до начала выполнения программы. Когда и будет выполнена проверка соответствия объекта и сообщения (метода).

В языках программирования, ориентированных на производительность, таких как C++, Java или OCaml, методам ставятся в соответствие числа, а для каждого класса заводится таблица его методов, по которой и производится поиск во время выполнения. В языках же отдающих предпочтение гибкости и динамизму, поиск осуществляется с использованием хэширования названий методов. В остальном эти два подхода практически совпадают.

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

Полиморфизм в действии

В качестве примера давайте взглянем на классическую интервьюерскую задачу про вычисление математического выражения, которая (насколько я знаю) впервые была применена Роном Браунштейном в Амазоне. Задание является достаточно комплексным и позволяет проверить владение большим числом важных навыков таких как: ООП проектирование, рекурсия, двоичные деревья полиморфизм и динамическая типизация, общее умение программировать и (если вам вдруг захочется предельно усложнить задачу) теория синтаксического анализа.

В какой-то момент кандидат с надеждой осознает, что если использовать только бинарные операции, такие как «+», «-», «*», «/», то арифметическое выражение можно представить в виде двоичного дерева. Все листья такого дерева будут числами, а все промежуточные узлы – операциями. Вычисляться же выражение будет путем обхода дерева. Если соискатель не может самостоятельно прийти к такому решению, вы можете деликатно подвести его к нему или, если необходимо, сказать в лоб. Задача все равно останется интересной.

Первая ее половина, которую некоторые люди (чьи имена я сохраню в тайне до последнего вздоха, но чьи инициалы Вилли Льюис) считают Необходимым Требованием Для Желающих Называть Себя Разработчиком И Работать В Амазоне, на самом деле достаточно сложна. Вопрос заключается в том как перейти от строки с арифметическим выражением, такой как «2 + (2)», к дереву выражений. И это серьезный вопрос.

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

Вы будете поражены тем, как многих испытуемых эта часть ставит в тупик.

Я, кажется, уже проговорился насчет правильного ответа, но Стандартное Плохое Решение заключается в использовании операторов switch или case (или старых добрых каскадных if-ов). Немного Улучшенное Решение будет использовать таблицу указателей на функции, и Возможно Самое Лучшее Решение применит полиморфизм. Попробуйте их все на досуге. Это забавно!

По иронии судьбы (как вы увидите в дальнейшем), решение с полиморфизмом идеально подходит для расширяемой системы. Если вы хотите добавлять новые функции без необходимости перекомпилировать все от и до и, в частности, без необходимости добавлять все новые и новые кейсы в ваш Гигантский Оператор Состоящий Из 500 Кейсов, то вам просто придется использовать полиморфизм.

Троекратное полиморфное ура в честь полиморфизма

Таким образом, полиморфизм так или иначе, но кажется достаточно полезным. Самым полезным его применением, пожалуй, можно назвать полиморфный оператор вывода print. Если вы программируете на Java, Python или Ruby, или любом другом «настоящем» объектно-ориентированном языке, то наверняка считаете его само собой разумеющимся. Вы просите объект распечатать себя и, ей-богу, он это делает. Каждый объект сообщает о себе ровно столько, сколько нужно вам знать о его внутреннем состоянии. Это очень полезно для отладки, трассировки, протоколирования и, возможно, даже документирования.

Если же вы используете искалеченную подделку под ООП язык, типа C++ или Perl, к которым вся объектно-ориентированность прикручена как пара дисков за $2500 к Subaru Legacy 1978-го года выпуска, то вы погрязли в дебаггере или Data::Dumper'е, или чем-то подобном. Хреново вам!

(Риторический вопрос: почему мы выбираем C++ или Perl? Это два самых ужасных языка в мире! Мы могли бы с таким же успехом применять Pascal или Cobol, неужели не ясно?)

Между прочим, полиморфный print – это главная причина почему я не говорю про OCaml в последнее время. В OCaml, по мотивам, которые я пока что до конца не осознал, но которые, как мне кажется, должны находиться в списке «самых невменяемых мотивов проектировщиков языков», нет полиморфного print’а. Поэтому вы не можете выводить произвольные объекты на консоль для отладки. Я пытаюсь верить, что это понадобилось для достижения легендарной, превосходящей даже C++ производительности, потому что любая другая причина была бы чудовищным оскорблением для юзабилити. Что ж, по крайней мере у них есть дебаггер способный возвращать программу назад по времени. Он определенно понадобится.

Итак, все мы любим полиморфизм. Это альтернатива микроменеджменту. Вы просите объекты что-то сделать не говоря как это сделать, и они послушно подчиняются, проводя день за онлайн просмотром клипов Strong Bad. Эти глупые объекты! Как их не любить!

Но полиморфизм, как и все достойные герои, имеет и Темную Сторону. Конечно, в его случае она не настолько темна как в случае Энакина Скайуокера, но тем не менее.

Парадокс полиморфизма

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

Есть определенный класс систем, для которых это невыполнимо. Это так называемые расширяемые системы.

Предположим, гипотетически, что вы проектируете воображаемую систему, которая позволяет пользователям добавлять их собственный код. Это нетривиальная задача по многим причинам, включая необходимость защиты от несанкционированного доступа, обеспечения потоковой безопасности и многого другого. Но такие системы существуют! Например, есть онлайн игры разрешающие игрокам вносить свои изменения в код, не имея доступа к оригинальному исходному коду. На самом деле, в этом направлении движется большинство мультиплеерных онлайн игр – в руководстве компаний осознали, что пользователи могут и будут сами создавать отличный контент, поэтому игры открывают свои API и позволяют игрокам расширять программы, создавая своих собственных монстров, свои заклинания и так далее.

Что-то подсказывает мне, что и веб-сервисы находятся с онлайн играми в одной лодке.

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

Хороший пример – Java Swing. Каждой расширяемая система сталкивается с парадоксом изобретателя, о котором вы можете прочитать подробнее где-нибудь в другом месте, но суть которого заключается в том, что вы не можете предсказать заранее какие изменения захочется внести пользователям. Вы можете пойти на все – даже выставить каждую строчку кода наружу как отдельную виртуальную функцию – но пользователи неизбежно столкнутся с чем-то, что они захотят, но не смогут модифицировать. Это настоящая трагедия, и я не в состоянии придумать изящное решение. Swing, например, пытается справиться с этим предоставляя уйму хуков, что делает его API ужасно громоздким и сложным для освоения.

Суть проблемы

Чтобы разговор стал более конкретным, давайте вернемся к примеру с онлайн играми. Предположим, вы отлично все спроектировали и опубликовали API и классы для создания и управления заклинаниями, монстрами и другими игровыми объектами. Предположим, у вас есть большая база монстров. Уверен, вы можете это вообразить если постараетесь.

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

Пусть единственным смыслом жизни нашего Оценочного Эльфа будет оглашение того, нравятся ли ему другие монстры или нет. Он сидит на вашем плече и всякий раз когда вы встречаете, скажем Орка, он кровожадно кричит: «Я ненавижу орков!!! Аааааааа!!!» (Между прочим, именно такие чувства я испытываю по отношению к С++)

Полиморфное решение этой задачи нехитрое: перебрать каждого из ваших 150-ти монстров и добавить им метод ненавидитЛиМеняОценочныйЭльф().

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

Очевидно, этот подход не сработает в нашем случае и даже если бы мог сработать (а он не может – ведь у пользователя написавшего этого маленького эльфа нет доступа к исходным кодам), он определенно имел бы привкус Плохого Дизайна. Разумеется, нет никаких причин добавлять такой специфический метод к каждому монстру в игре. Что если позже выясниться, что Оценочный Эльф нарушает авторские права и должен быть удален? Вам придется вернуть все в исходное состояние удалив этот метод изо всех 150-ти классов.

Насколько я знаю (а я не претендую на лавры хорошего дизайнера, просто хочу найти правильный ответ), верным решение является динамическое определение типов. Код будет выглядеть как-то так:

public boolean нравитсяЛиОнЭльфу(Монстр mon)
{
    if (mon instanceof Орк) { return false; }
    if (mon instanceof Эльф) { return true; }
    ... <повторить 150 раз>
}

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

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

class Орк
def нравлюсьЛиЯЭльфу; return false; end
end

class Тролль
def нравлюсьЛиЯЭльфу; return false; end
end

class ЭльфийскаяДева
def нравлюсьЛиЯЭльфу; return true; end
end

...

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

Но у такого подхода есть как плюсы, так и минусы. Как это работает? В Ruby (как и в большинстве других языков высокого уровня) методы – это всего лишь записи в хэш-таблице соответствующей классу, и тут появляетесь вы и добавляете свою запись в хэш-таблицу каждого из подклассов Монстра. Преимущества:

  • весь код Оценочного Эльфа содержится в его файле;
  • код не добавляется в классы до тех пор, пока файл эльфа не будет загружен;
  • не только эльф, но и любой другой в системе может спросить у монстра, нравится он эльфу или нет.

Недостаток же заключается в том, что придется предусмотреть поведение по умолчанию для случая, когда эльф не распознает монстра, потому что тот был добавлен в игру уже после написания эльфа. Если кто-то придумает Гремлина, ваш эльф зависнет, крича что-нибудь вроде «Черт возьми, что ЭТО такое?!» до тех пор, пока вы не обновите его код и не включите в него гремлинов.

Я думаю, если можно было бы каким-то образом перебрать все классы в системе и проверить, являются ли они потомками Монстра, то все решилось бы несколькими строчками кода. В Ruby, бьюсь об заклад, это возможно… но только для уже загруженных классов. Для классов все еще находящихся на диске это не сработает! Можно обойти и эту проблему, но ведь существует еще и сеть…

Впрочем, необходимость в поведении по умолчанию еще не самое плохое. Есть гораздо более серьезные минусы. Скажем, потокобезопасность; она серьезно меня беспокоит – не думаю что семантика Ruby для потокобезопасности в этом случае четко определена. Будет ли синхронизация на уровне класса? Что случится с потоками экземпляров до-эльфовского класса? Я недостаточно умею читать по-японски, чтобы найти доказательство этому в спецификациях или реализации.

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

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

Пересмотр полиморфизма

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

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

Никоим образом! Вам пришлось бы добавить в код определенные проверки времени выполнения:

public boolean запретитьВходВЗдание(Субъект s)
{
    return (s.неИмеетБейджа() || s.подозрительноВыглядит() || s.вооруженАвтоматом());
}

Но постойте – здесь нигде не используется напрямую проверка класса. Я ведь не написал, например, s instanceof НосительАвтомата. В чем же тут дело?

Что ж, «тип» объекта является, в сущности, совокупностью его класса (который очень четко фиксирован и неизменен) и его свойств, которые могут быть как фиксированными, так и меняющимися во время выполнения программы.

Это тема для отдельного разговора, но мне кажется это означает, что тип определяется скорее свойствами, чем классами, из-за врожденной негибкости последних. Но в «традиционных» языках типа C++ и Java такой подход сделал бы повторное использование кода чуть сложнее из-за отсутствия синтаксической поддержки делегирования. Если вам вдруг показалось что это не имеет смысла, все в порядке: я допиваю уже третий бокал вина приближаясь к предпоследней стадии. Так что остановимся на том, что это тема не для этой заметки.

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

Подведение итогов

Итак, я надеюсь вы вынесли что-то полезное из сегодняшней заметки. Насчет себя я точно уверен. Например, я узнал что поисковый движок Google и в самом деле достаточно умен чтобы исправить «Эникин Скайуокер» спросив «Возможно, вы имели в виду: Энакин Скайуокер?». Ох уж эти надменные парни. Не то чтобы авторские права принадлежали им.

Я также узнал что идеальная длина для записи в блоге – это ровно два бокала вина. Если вы заходите дальше, то начинаете разглагольствовать практически бессвязно. Да и скорость набора летит ко всем чертям.

Всего хорошего.


Оригинал – When Polymorphism Fails. Steve Yegge. Stevey's Drunken Blog Rants
.

Автор: groaner

Источник

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


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