Двойная диспетчеризация – двойное удовольствие

Вчера я получил интересный вопрос:

Если у тебя есть перегруженный operator ==, то каждый вызов этого оператора подвергается «раннему связыванию» во время компиляции в соответствии с формальными типами операндов. Но метод Equals() у объекта вызывается как виртуальный; настоящий метод определяется во время выполнения в соответствии с фактическим типом получателя. Эта разница кажется мне кривоватой. Какой принцип дизайна здесь уместен?

Краткий ответ – в том, что дизайнеры языков и дизайнеры библиотек по-разному смотрят на задачи.

Перегрузка оператора == - это языковое соглашение; метод Equals() – это соглашение библиотеки. Они отличаются потому, что их дизайнеры по-разному мыслили над задачей, исходили из разных требований, и имели разные инструменты для решения задачи.

Длинный ответ – в том, что тут всё криво и ничто не работает так, как должно бы в идеале.

Перед тем, как я продолжу, мне нужно кратко определить, что я имею в виду под разными способами диспетчеризации методов:

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

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

Это то, что мы будем называть «одинарная виртуальная диспетчеризация». То есть, мы рассматриваем фактический тип только у получателя. Фактические типы аргументов значения не имеют. Можно представить себе язык, в котором они были бы важны, придумав новую «множественно-виртуальную» диспетчеризацию:

class B
{
  public multivirtual void M(object x) { }
}

class D : B
{
  public multioverride void M(object x){ }
  public multioverride void M(string x){ }
}

[...]

    object argument = "hello";
    B receiver = new D();
    receiver.M(argument);

В языке с одиночно-виртуальной диспетчеризацией это приведет к вызову первого метода в D, потому что только фактический тип получателя важен при диспетчеризации вызова. В множественно-виртуальном языке мы могли бы сделать здесь то, что называется «двойная виртуальная диспетчеризация», направляя вызов во второй метод D, используя фактические типы как получателя, так и аргумента.

Конечно, вам не нужно это представлять, поскольку можно убедиться самим. Есть языки, которые поддерживают «мультиметоды», то есть методы, которые выполняют одинарную, двойную, тройную и так далее, виртуальную диспетчеризацию. Первым в голову приходит Dylan, хотя есть много других.

Тем не менее, в CLR «вшит» механизм для эффективной одинарной диспетчеризации, но не вшито механизмов для эффективной множественной диспетчеризации. (Если вы и вправду хотите множественную диспетчеризацию, то, в принципе, в C#4 можете использовать «dynamic», и мы сделаем весь обычный анализ времени компиляции во время выполнения. Это не очень эффективно по сравнению с одним разыменованием указателя для одиночной диспетчеризации, но делает то что нужно, и с приемлемой во многих случаях производительностью).

Какое отношение это имеет к сравнениям?

Чтобы ответить на заданный вопрос, сначала нужно понять «почему Equals диспетчеризуется одинарно-виртуально?» Хороший вопрос! В идеальном мире здесь бы была двойная виртуальная диспетчеризация! Когда вы сравниваете два объекта на предмет эквивалентности при помощи метода Equals, конечно же тип аргумента до последнего бита важен не меньше, чем у получателя! Мы все полагаем эквивалентность симметричной операцией, но Equals глубоко асимметричен. Левая часть Equals – получатель – обрабатывается особым образом, а правая часть – аргумент – нет.

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

Так, теперь подумайте обо всём этом с точки зрения дизайнера библиотеки. Вы проектируете объектно-ориентированную библиотеку с чистого листа и хотите, чтобы все объекты были сравнимы на эквивалентность со всеми остальными объектами. У вас есть четыре варианта:

  1. Реализовать двойную диспетчеризацию в CLR, и сделать Equals дважды виртуальным. Потребовать от всех языков понимать логику двойной диспетчеризации CLR и генерировать корректные вызовы Equals.
  2. Сделать метод одинарно-виртуальным, введя асимметрию. Позволить разработчику разобраться с этим, если он хочет двойную диспетчеризацию, путем написания дополнительного кода, который они и так пишут если перегружают Equals, так что это не намного больше работы для них.
  3. Сделать метод не виртуальным. Всегда вызывать реализацию из System.Object, которая просто сравнивает биты обеих частей.
  4. Выбросить эту фичу.

Ни один из вариантов не является безусловным победителем – дизайн, в конце концов, это искусство принимать хорошие компромиссные решения. С точки зрения дизайнера библиотек, (2) – лучший вариант. Без огромных архитектурных изменений в CLR, но с максимумом гибкости для разработчика, ценой некоторой дополнительной работы для них. И эта работа «не едешь – не плати» - разработчики, которым всё равно, могут просто взять реализацию по умолчанию и никогда об этом не думать.

В целом, это не так плохо. Лексически, вызов x.Equals(y) выглядит как вызов виртуального метода, так что мы скорее всего будем ожидать от него одинарно-виртуальной диспетчеризации, как и от любого другого виртуального вызова.

Но использование оператора == - это совсем отдельный огород. Пользователи разумно полагают, что оператор == симметричен. Эта штука не выглядит как вызов метода.

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

  1. Реализовать систему двойной диспетчеризации, генерируемую компилятором. Кодогенерация весьма сложна и операции, которые нужно будет сделать, не имеют особенно эффективного представления в CLR
  2. Реализовать систему одинарной диспетчеризации, которая нарушает разумные ожидания пользователей по поводу симметрии и не дает никакой ценности по сравнению с простым вызовом Equals, кроме незначительного улучшения синтаксиса вызова. В сущности это ничуть не лучше чем объявить «x==y» сахаром для «x.Equals(y)».
  3. Реализовать симметричную не виртуальную диспетчеризацию в статический метод на основе формальных типов операндов; выдавать ошибку компиляции, если симметричный анализ обнаруживает неопределенность. Позволить статическим методам выполнять при вызове любую диспетчеризацию, если этого захочет разработчик. В конце концов, они уже пишут специальный метод.
  4. Выбросить эту фичу.

Для дизайнера языка, вариант 3 значительно более привлекателен, чем любой другой. Поэтому его мы и выбрали.

Анализ оператора равенства в C# симметричен; мы ищем перегруженные операторы в иерархиях типов обоих операндов, кидаем их все в одну корзину, и пытаемся выбрать «наилучшего» кандидата. Если мы не можем найти единственного лучшего, то выдаем ошибку.

В C# 4.0, если операнды == помечены «dynamic», то мы выполним весь обычный анализ времени компиляции во время выполнения, основываясь на фактических типах, что в результате даёт вам что-то типа дважды-виртуальной диспетчеризации, ценой вызова компилятора при выполнении. Мы кэшируем результат анализа, так что при втором проходе по месту вызова эффективность будет приемлемой.

Весьма неудачно, что мы даём вам два разных способа сделать одно и то же. Лично я бы легко обошелся совсем без метода Equals в System.Object, и просто позволил каждому языку придумать свой способ сравнивать два объекта на эквивалентность. Но есть мнение, что эквивалентность – это концепция, выходящая за пределы любого языка, и что объекты должны отвечать за определение своей эквивалентности независимо от того, что по этому поводу думает какой-то язык. Трудно выяснить, где провести черту.

оригинал сообщения