Сообщений 119    Оценка 1192 [+1/-1]         Оценить  
Система Orphus

Багодром: Реализация операторов сравнения

Автор: Чистяков Влад (VladD2)
The RSDN Group

Источник: RSDN Magazine #4-2007
Опубликовано: 15.03.2008
Исправлено: 10.12.2016
Версия текста: 1.0
Введение
Выводы и советы
Паттерн безопасной перегрузки операторов
Заключение

Введение

На мысль о создании этой статьи меня натолкнула одна ошибка программистов из Microsoft, работавших над VS SDK (точнее над MPF, Managed Package Framework, содержащим обертки над COM API Visual Studio). Эта библиотека содержит довольно много ошибок, так что скучать не приходится, но одна из них потрясла до глубины души. Точнее, потрясла даже не сама ошибка, а то, как ее «исправили».

Если вы редактировали C#-файлы в VS, то наверняка видели выпадающие списки типов, находящихся в редактируемом файле, и членов типа, внутри которого в данный момент находится курсор. В MPF имеется ряд классов-оберток, упрощающих добавление такой же возможности для других типов файлов (в нашем случае для исходных файлов Nemerle). VS устроена по событийной системе, так что держать физические списки, хранящие информацию, не обязательно, но в MPF для этой цели создаются списки (причем почему-то на базе нетипизированного ArrayList, так что создается впечатление, будто вернулся в прошлый век). Элементом этих списков являются объекты класса DropDownMember. Его описание можно найти в файле CodeWindowManager.cs. У этого файла в последней версии VS SDK есть две версии. Одна, более ранняя, лежит в каталоге:

%VSSDK90Install%\VisualStudioIntegration\Common\Source\CSharp\LanguageService

а другая – в каталоге:

%VSSDK90Install%\VisualStudioIntegration\Common\Source\CSharp\LanguageService90

ПРИМЕЧАНИЕ

Microsoft VS SDK недоступен публично, но его можно скачать подключившись к программе VSIP (требующей регистрации). Однако подключают к ней всех желающих. По крайней мере пока отказов не было.

Что примечательного в этом классе, спросите вы? Да ничего. Это простой класс, хранящий информацию об одном элементе списка (текстовая строка, индекс иконки и атрибуты шрифта). Единственное примечательное в этом классе – это реализация интерфейса IComparable и переопределение метода Equals(object obj). Однако именно это и явилось причиной всех проблем (потрепавших лично мне немало нервов).

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

Если бы код этого класса писал ленивый человек вроде меня, то он наверняка выбрал бы использование сортировки с компаратором, но создатели MPF – народ «креативный» и плодотворный. Я подозреваю, что платят им за количество символов написанных в секунду. Так вот, они пошли на реализацию интерфейса IComparable, чтобы потом можно было «просто» воспользоваться методом ArrayList.Sort(). Возможно, ход мысли разработчиков MPF был еще более витиеватым. ArrayList мог быть использован, чтобы дать возможность добавлять в списки объекты других классов. Жаль, что этому мешает одна мелочь... Объекты из этого списка явно приводятся к DropDownMember, так что скорее всего использование нетипизированных списков не более чем небрежность.

В Microsoft есть ряд правил хорошего тона. Вам о них может напомнить FxCop. Так, если вы реализовали интерфейс IComparable, то по этим правилам следует переопределить и метод object.Equals(object obj). Ну а раз уж вы переопределили Equals(object obj), то нужно переопределить метод object.GetHashCode() и добавить реализацию операторов сравнения («==», «!=», «<» и «>»). Эдакая добрая традиция. Смысл ее в том, что объект не должен смущать программистов его использующих и вести себя по-разному в разных контекстах, например при сравнении через «==» и метод Equals(object obj).

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

Однако мыслительный процесс очень ресурсоемок, и куда проще все сделать «по-простому». И это «простое» решение приводит к множеству проблем.

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

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

      public
      int CompareTo(object obj)
{
  // if this overload is used then it assumes a case-sensitive current // culture comparison which allows for case-senstive languages to workreturn CompareTo(obj, StringComparison.CurrentCulture);
}

publicint CompareTo(object obj, StringComparison stringComparison)
{
  if (obj is DropDownMember)
    return String.Compare(this.Label, ((DropDownMember)obj).Label, 
      stringComparison);
  
  return -1;
}

// Omitting Equals violates FxCop rule: // IComparableImplementationsOverrideEquals.publicoverridebool Equals(Object obj)
{
  if (!(obj is DropDownMember))
    returnfalse;
  return (this.CompareTo(obj, StringComparison.CurrentCulture) == 0);
}

// Omitting getHashCode violates FxCop rule: // EqualsOverridesRequireGetHashCodeOverride.publicoverrideint GetHashCode()
{
  returnthis.Label.GetHashCode();
}


// Omitting any of the following operator overloads// violates FxCop rule: IComparableImplementationsOverrideOperators.publicstaticbooloperator ==(DropDownMember m1, DropDownMember m2)
{
  return m1.Equals(m2);
}

publicstaticbooloperator !=(DropDownMember m1, DropDownMember m2)
{
  return !(m1 == m2);
}

publicstaticbooloperator <(DropDownMember m1, DropDownMember m2)
{
  return (m1.CompareTo(m2, StringComparison.CurrentCulture) < 0);
}

publicstaticbooloperator >(DropDownMember m1, DropDownMember m2)
{
  return (m1.CompareTo(m2, StringComparison.CurrentCulture) > 0);
}
ПРЕДУПРЕЖДЕНИЕ

C# допускает использование операторов «==» и «!=» и для объектов, не реализующих оных. При этом операторы не сравнивают значение объектов, а сравнивают ссылки. Равными признаются только ссылки на один и тот же объект. В принципе, это логичное поведение для многих объектов, не переопределяющих Equals(object obj), но, к сожалению, такое поведение по умолчанию является еще одними замаскированными граблями, на которые трудно не наступить. Языки, уделяющие больше внимания предупреждению случайных ошибок программистов, попросту не допускают использования операторов сравнения для объектов, не реализующих оных, или выдают предупреждение. Например, Nemerle заставляет программиста явно привести объект к типу object, если программист хочет проверить ссылочную эквивалентность. Например, если попытаться написать код «someReference == null», то компилятор будет ругатся и советовать использовать явное приведение: «someReference : object == null». Эквивалентный код в C#: «(object)someReference == null», но компилятор C# не требует его использования. Именно это и приводит к возможности возникновения ошибок вроде разбираемых в этой статье. Но это я предпочитаю обсудить позже. А пока продолжим рассмотрение анти-примера.

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

      public
      virtual
      int GetEntryAttributes(int combo, int entry, 
  outuint fontAttrs) 
{
  fontAttrs = (uint)DROPDOWNFONTATTR.FONTATTR_PLAIN;
  DropDownMember member = GetMember(combo, entry);

  if (member != null)
      fontAttrs = (uint)member.FontAttr;

  return NativeMethods.S_OK;
}

public DropDownMember GetMember(int combo, int entry)
{
  if (combo == TypeAndMemberDropdownBars.DropClasses)
  {
    if (this.dropDownTypes != null && entry >= 0 
&& entry < this.dropDownTypes.Count
    )
      return (DropDownMember)this.dropDownTypes[entry];
  }
  else
  {
    if (this.dropDownMembers != null && entry >= 0 
&& entry < this.dropDownMembers.Count
    )
      return (DropDownMember)this.dropDownMembers[entry];
  }

  return null;
}

Традиционно самые интересные фрагменты кода выделены красным. Теперь взгляните на реализацию оператора «!=» и вызываемого из него оператора «==»:

      public
      static
      bool
      operator ==(DropDownMember m1, DropDownMember m2)
{
  return m1.Equals(m2);
}

publicstaticbooloperator !=(DropDownMember m1, DropDownMember m2)
{
  return !(m1 == m2);
}

Надеюсь, теперь все понятно? Конечно же! Опытные исполнители воли FxCop-а забыли, что операторы вызываются для ссылок на два объекта DropDownMember, и что любая из ссылок может быть null. Более того, они же сами написали код, вызывающий этот оператор и передающий null в качестве обоих параметров, если, скажем, выпадающие списки пусты. Ну, а так как код реализации операторов не рассчитан на null, он моментально генерирует исключения NullReferenceException, которые в данном случае хотя и не приводят к полному падению программы (их можно перехватить выше по коду), но изрядно затрудняют отладку, так генерируются часто и совсем некстати.

Заметив эту ошибку, Павел Блудов сообщил о ней в Microsoft, и (о боги!) это сообщение (или аналогичное ему) было замечено, и ошибка была исправлена!

Так что же я разразился статьей, если конфликт исчерпан? Дело в том, что правильнее было бы написать «исправили». Реально ошибку, назойливую, но не смертельную, заменили ошибкой более качественной. Новая ошибка начала приводить к переполнению стека, а так как переполнение стека является так называемым асинхронным исключением (неуправляемым), то это исключение стало приводить к «срубанию» VS, как говорится, без объявления войны. Причем, так как по умолчанию при отладке управляемых приложений не включается режим Native-отладки, место ошибки не удается обнаружить.

Как же выглядит «исправленная» версия кода? А вот как:

      // Omitting any of the following operator overloads
      // violates FxCop rule: IComparableImplementationsOverrideOperators.
      public
      static
      bool
      operator ==(DropDownMember m1, DropDownMember m2)
{
  if (null == m1)
    return (null == m2);

  return m1.Equals(m2);
}
publicstaticbooloperator !=(DropDownMember m1, DropDownMember m2)
{
  if (null == m1)
    // Assume null < anything else and == null.return (null != m2);

  return !(m1 == m2);
}
...

Надеюсь, вы понимаете, какой эффект это дало? Если нет, поясню. Ранее ошибка возникала изредка, когда в оператор сравнения первым параметром передавался null. Более того, ошибка сразу локализовывалась отладчиком. Теперь же любой вызов оператора сравнения стал приводить к рекурсивному вызову этого же оператора. Точнее, оператор «!=» передает управление оператору «==», а тот уже, в свою очередь, зацикливается. Вкупе с безмолвным срубанием студии это дало потрясающий эффект. Чтобы отловить эту ошибку, пришлось подключаться к отлаживаемому процессу вручную, явно указывая, что требуется как управляемая, так и native-отладка.

Я сейчас не хочу обсуждать вопрос компетентности тех, кто написал этот код (а что тут, в общем-то, обсуждать, она явно ниже плинтуса). Но ведь протестировать же измененный код точно можно было (точнее нужно)!

Как бы то ни было, но надо было исправлять чужую ошибку. И тут на поверхность всплыл еще один «баг». На этот раз архитектурный. Я, конечно, мог создать наследника DropDownMember, переопределить в нем все и вся, перегрузить операторы, но, к сожалению, это ровным счетом ничего не дало бы! Ведь операторы реализуются в виде статических методов, и компилятор подставляет их вызовы на основании статической информации о типах переменных (доступной ему во время компиляции). Даже если бы я подсунул компилятору свою версию DropDownMember (скажем NemerleDropDownMember, наследника DropDownMember) он все равно считал бы, что это DropDownMember, и вызвал бы именно эти операторы. Точнее, код вызова уже зашит в библиотеке и ничего не знает о существовании наследников.

Я обошел эту ошибку, попросту отказавшись от всего комплекса классов-оберток, предоставляемых MPF для работы с выпадающими списками. В итоге я потерял день на поиск ошибки и еще день на полное переписывание кода. Но самое неприятное, что я испытал массу неприятных эмоций (как по отношению к тем, кто писал этот код, так и к тем, кто принял архитектурное решение в области реализации перегрузки операторов в C#, которое впоследствии стало использоваться везде).

Выводы и советы

Что же делать? Тут есть два выхода. Один – радикальный. Не использовать языков, допускающих подобные ошибки.

Например, Nemerle, хотя и поддерживает все соглашения C# о перегрузке операторов, на практике оберегает программиста от удара ручкой граблей о лоб. Этот язык попросту не дает неявно использовать операторы «==» и «!=» для проверки ссылочной эквивалентности. Вместо этого он заставляет явно привести тип объекта к object или воспользоваться для сравнения ссылок специальной функцией:

      public
      static
      bool ReferenceEquals(object objA, object objB)

Эта функция ничем не отличается от кода «(object)objA == (object)objB», но гарантированно приводит к правильному результату. Причем не бойтесь непроизводительных расходов процессорного времени, так как в релиз-версии, при запуске программы без отладчика, JIT устраняет вызовы таких методов, заменяя их кодом методов. Другими словами, производительность не пострадает.

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

Паттерн безопасной перегрузки операторов

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

  1. В качестве параметров оператору могут быть переданы null-значения, если, конечно, тип не является value-типом.
  2. Передаваемые в качестве параметров типы могут быть типа, производного от того, в котором реализуются операторы, причем объекты могут быть разных типов. Конечно, этого не может случиться, если тип, в котором реализуются операторы, объявлен как запечатанный (sealed) или является value-типом.
  3. Сравнение с помощью операторов сравнения объектов типа, для которого реализованы эти операторы, приведет к тому, что компилятор вызовет эти операторы. Даже сравнение с null приведет к этому. Причем неважно, где производится это сравнение. Если сравнение производится внутри самого оператора, то это приведет к рекурсивному вызову и соответственно может привести к зацикливанию.

Следствием из этого является то, что вы должны:

  1. Проверять переданные (в операторы сравнения) значения на равенство null.
  2. При проверке на null помнить о возможности зацикливания, а стало быть, не забывать приводить проверяемые значения к object или использовать ReferenceEquals.
  3. Если класс не запечатанный (sealed) или value-тип, осуществлять реальную работу по сравнению в методе Equals(Object obj) или неком другом виртуальном методе, вызываемом из Equals(Object obj).
  4. Естественно, что перегружая операторы, вы должны переопределить Equals(Object obj) и другие виртуальные и не виртуальные методы сравнения (например, реализацию методов IComparable и т.п.).

Таким образом, общий паттерн реализации операторов будет следующим (паттерн приводится на основе кода класса DropDownMember, приведенного в начале статьи):

        public
        int CompareTo(object obj)
{
  return CompareTo(obj, StringComparison.CurrentCulture);
}

publicint CompareTo(object obj, StringComparison stringComparison)
{
  var member = obj as DropDownMember
  // Переменная member будет равна null в двух случаях:
  // 1) если null-у была равна переменная obj;
  // 2) если obj имеет тип, несовместимый (не являещийся наследником)
  //    с DropDownMember.
  // Таким образом, мы защищаемся и от того, что нам может быть передано
  // значение null, и от того, что нам может быть передана ссылка на
  // объект неверного типа.
if (!object.ReferenceEquals(member, null))
    returnstring.Compare(this.Label, member.Label, stringComparison);
  
  return -1;
}

publicoverridebool Equals(Object obj)
{
  return CompareTo(obj, StringComparison.CurrentCulture) == 0;
}

publicoverrideint GetHashCode()
{
  returnthis.Label.GetHashCode();
}

publicstaticbooloperator ==(DropDownMember m1, DropDownMember m2)
{
  // Если оба объекта будут равны null, то это условие сработает, и// и дальнейших проверок производиться не будет. Таким образом,// с одной стороны мы защищаемся от передачи null-значений, а с другой -// правильно реагируем на передачу сразу двух null-значений.// Как положительный эффект, мы так же достигаем некоторого ускорения // выполнения кода при сравнении ссылок на один и тот же объект// (ведь ReferenceEquals работает очень быстро).
  // Обратите особое внимение на то, что сравнивать ссылки вот так:
  // «m1 == m2» ни в коем случае нельзя! Такой код приведет к рекурсивному
  // вызову этого же самого оператора и, в итоге, к переполнению стека.
  // Если уж вы очень хотите избавиться от использования ReferenceEquals,
  // то позаботьтесь о том, чтобы привести один из операндов к object:
  // «(object)m1 == m2». Это приведет (в C#) к сравнению ссылок, так как 
  // класс object не реализует операторов сравнения.
if (object.ReferenceEquals(m1, m2))
    returntrue;
  
  // В этом месте, если m1 равна null, то m1 уже не может быть // равна null. Стало быть, объекты не равны, и можно вернуть false.if (object.ReferenceEquals(m1, null))
    returnfalse;

  // В этом месте null не может быть ни в одном параметре, так что// можно смело вызывать Equals.// Equals является виртуальным, так что пользователи нашего класса// смогут переопределить его, если им потребуется изменить поведение// операции сравнения.return m1.Equals(m2);
}

publicstaticbooloperator !=(DropDownMember m1, DropDownMember m2)
{
  // Здесь управление передается оператору «==», реализованному выше.// Так как он защищен от проблем с null, то можно делать это без// предварительных проверок.return !(m1 == m2);
}

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

ПРЕДУПРЕЖДЕНИЕ

Если вам важна производительность до такой степени, что счет идет на такты процессора, то стоит подумать об использовании запечатанных (sealed) классов или вообще использовать value-типы.

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

Естественно, что проверки на null должны быть в обязательном порядке, если конечно вы не используете value-типы.

Заключение

В заключение хотелось бы поговорить о двух вещах. Во-первых, посетовать на то, что создатели C# плохо относятся к метапрограммированию, считая его «слишком большой пушкой». Посему мы, видимо, никогда не увидим в C# чего-то аналогичного макросам Nemerle (исп. поиск на www.rsdn.ru). В ином случае реализацию операторов, как и многую другую рутинную работу, можно было бы переложить на макросы (мета-программы). Представьте, как здорово было бы пометить тип атрибутом вроде ImplementComparisonOperator, а все подробности реализовал бы мета-атрибут (макрос). Однако кое-что можно сделать и в C#. Можно вынести реализацию операторов в библиотеку. Если приглядеться к реализации операторов «==» и «!=», то можно заметить, что их код будет работать, даже если параметры объявить как object. Да и реализацию Equals можно свести к вызову библиотечной функции, получающей ссылку на делегат, параметры которого заведомо не могут содержать null-значения. Конечно, придется по-прежнему перегружать операторы, но все же ошибиться в коде будет крайне сложно.

Второй вопрос, о котором хотелось бы упомянуть, – это качество проектирования языков программирования. Когда проектировали C# 1.0, в нем пытались устранить все «грабли», которые были обнаружены в Java и C++. Однако по иронии судьбы при этом были заложены другие грабли, части из которых нет в прародителях. Очень бы хотелось, чтобы подобных ошибок было меньше, так как ошибки в дизайне обычно обходятся дороже всего.

Ну, а вам желаю, чтобы те, кто будет использовать написанный вами код, вспоминали вас только хорошими словами! ;)


Эта статья опубликована в журнале RSDN Magazine #4-2007. Информацию о журнале можно найти здесь
    Сообщений 119    Оценка 1192 [+1/-1]         Оценить