Pull to refresh

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

Reading time11 min
Views11K
Большинство фанатов ООП одновременно и фанаты полиморфизма. Многие хорошие книги (взять хотя бы «Рефакторинг» Фаулера) даже впадают в крайность и утверждают: если вы используете проверки типов во время выполнения (такие как операция 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
Tags:
Hubs:
-8
Comments12

Articles