среда, 26 октября 2011 г.

Проблемы передачи списка перечислений или Почему абстракции «текут»

Все нетривиальные абстракции дырявы
Джоэл Спольски – Закон дырявых абстракций

А иногда дырявятся и довольно простые абстракции
Автор этой статьи

Большинство современных разработчиков знакомы с «законом дырявых абстракций» по знаменитой заметке Джоэла Спольски с одноименным названием. Заключается этот закон в том, что как бы ни был хорош протокол взаимодействия, фреймворк или набор классов, моделирующих предметную область, рано или поздно нам приходится спускаться на уровень ниже и разбираться с тем, как же эта абстракция устроена. Внутреннее устройство абстракции должно быть проблемой самой абстракции, но возможно это только в наиболее общих случаев и лишь до тех пор, пока все идет хорошо (*).

Когда-то давно, в «небольшой» мелкомягкой компании решили, а почему бы нам не «абстрагироваться» от местоположения объекта и сделать сам факт того, является ли объект локальным или удаленным, лишь «деталью реализации». Так появились технологии DCOM и ее наследник .NET Remoting, которые скрывали от разработчика, является ли объект удаленным или нет. При этом появились все эти «прозрачные прокси», которые позволяли работать с удаленным объектом, даже не зная об этом. Однако, со временем выяснилось, что эта информация архиважна для разработчика, поскольку удаленный объект может генерировать совершенно другой перечень исключений, да и стоимость работы с ним несравнимо выше, чем взаимодействие с локальным объектом.

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

Подобных примеров, когда нам нужно знать не только видимое поведение (абстракцию), но и понимать внутреннее устройство (реализацию), довольно много. В большинстве языков программирования работа с разными типами коллекций делается очень похожим образом. Коллекции могут «прятаться» за базовыми классами или интерфейсами (как в .NET), или использовать какой-то другой способ обощения (как, например, в языке С++). Но, несмотря на то, что мы можем работать с разными коллекции практически одинаково, мы не можем полностью «отвязать» наши классы от конкретных типов коллекций. Несмотря на видимое сходство, нам нужно понимать, что лучше использовать в данный момент: вектор или двусвязный список, hash-set или sorted set. От внутренней реализации коллекции зависят сложности основных операций: поиска элемента, вставки в середину или в конец коллекции и знать о таких различиях просто необходимо.

Давайте рассмотрим конкретный пример. Все мы знаем, что такие типы как List<T> (или std::vector в С++) реализованы на основе простого массива. Если коллекция уже заполнена, то при добавлении нового элемента будет создан новый внутренний массив, при этом он «вырастит» не на один элемент, а несколько сильнее (**). Многие знают о таком поведении, но в большинстве случаев мы можем не обращать на это никакого внимания: это является «личной проблемой» класса List<T> и нам до нет никакого дела.

Но давайте предположим, что нам нужно передать список перечислений (enum-ов) через WCF или просто сериализовать такой список с помощью классов DataContractSerializer или NetDataContractSerializer(***). При этом перечисление объявлено следующим образом:

public enum Color
{
    Green = 1,
    Red,
    Blue
}

Не обращайте внимания на то, что это перечисление не помечено никакими атрибутами, это не является помехой для NetDataContractSerializer-а. Главная особенность этого перечисления заключается в том, что в нем нет нулевого значения; значения перечислений начинаются с 1.

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

public static string Serialize<T>(T obj)
{
    // Используем именно NetDataContractSerializer, хотя в данном случае
    // поведение DataContractSerializer аналогичным
    var serializer = new NetDataContractSerializer();
    var sb = new StringBuilder();
    using (var writer = XmlWriter.Create(sb))
    {
        serializer.WriteObject(writer, obj);
        writer.Flush();
        return sb.ToString();
    }
}
Color color = (Color) 55;
Serialize(color);

При попытке выполнить этот код, мы получим следующее сообщение об ошибке: Enum value '55' is invalid for type Color' and cannot be serialized.. Такое поведение является вполне логичным, ведь таким способом мы защищаемся от передачи неизвестных значений между разными приложениями.

Теперь давайте попробуем передать коллекцию из одного элемента:

var colors = new List<Color> {Color.Green};
var s = Serialize(colors);

Однако этот, с виду вполне безобидный код, также приводит к ошибке времени выполнения с тем же самым содержанием и отличие заключается лишь в том, что сериализатор не может справиться со значением перечисления, равным 0. На что за … Откуда мог вообще взялся 0? Мы ведь пытаемся передать простую коллекцию с одним элементом, при этом значение этого элемента абсолютно корректно. Однако DataContractSerializer/NetDataContractSerializer, как и старая добрая бинарная сериализация, использует рефлексию для получения доступа ко всем полям. В результате чего, все внутреннее представление объекта, которое содержится как в открытых, так и закрытых полях, будет сериализовано в выходной поток.

Поскольку класс List<T> построен на основе массива, то при сериализации будет сериализован массив целиком, не зависимо от того, сколько элементов содержится в списке. Так, например, при сериализации коллекции из двух элементов:

var list = new List<int> {1, 2};
string s = Serialize(list);

В выходном потоке мы получим не два элемента, как мы могли бы ожидать, а 4 (т.е. количество элементов, соответствующих свойству Capacity, а не Count):

<ArrayOfint>
<_items z:Id="2" z:Size="4">
<int>1</int>
<int>2</int>
<int>0</int>
<int>0</int>
</_items>
<_size>2</_size>
<_version>2</_version>
</ArrayOfint>

В таком случае, причина сообщения об ошибке, которое возникает при сериализации списка перечислений, становится понятной. Наше перечисление Color не содержит значение, равного 0, а именно таким значением заполняются элементы внутреннего массива списка:

image

Это еще один пример «протекания» абстракции, когда внутренняя реализация даже такого простого класса, как List<T> может помешать нам его нормально сериализовать.

Решение проблемы

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

1. Добавление значения по умолчанию

Самым простым решением этой проблемы является добавление в перечисление значения, равного 0 либо изменить значение одного из существующих элементов:

public enum Color
{
    None = 0,
    Green = 1, // или Green = 0
    Red,
    Blue
}

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

2. Передача коллекции без «пустых» элементов

Вместо того чтобы делать что-либо с перечислением, можно добиться того, чтобы коллекция не содержала таких пустых элементов. Сделать это можно, например, таким образом:

var li1 = new List<Color> { Color.Green }; // Содержит еще 3 элемента, равных 0
var li2 = new List<Color>(li1);

В этом случае, переменная li1 будет содержать три дополнительных пустых элемента (при этом Count будет равен 1, а Capacity4), а переменная li2 – нет (внутренний массив второго списка будет содержать только 1 элемент).

Этот вариант вполне работоспособный, но весьма «хрупкий»: сломать работающий код не составит никакого труда. Безобидное изменение со стороны вашего коллеги в виде удаления ненужной промежуточной коллекции и все, приплыли.

3. Использование других типов коллекций в интерфейсе сервисов

Использование других структур данных, таких как массив, либо вместо DataContractSerializer-а использовать XML-сериализацию, которая использует только открытые члены, решит эту проблему. Но насколько это удобно или нет, решать уже вам.

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

З.Ы. Кстати, дважды подумайте, чтобы передавать значимые типы через WCF в типе List<T>. Если у вас будет коллекция из 524-х элементов, то будут переданы еще 500 дополнительных объектов значимого типа!

---------------------

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

(**) Обычно подобные структуры данных увеличивают свой внутренний массив в два раза. Так, например, при добавлении элементов в List<T>, «емкость» будет изменяться таким образом: 0, 4, 8, 16, 32, 64, 128, 256, 512, 1024 …

(***) Разница между двумя основными типами сериализаторов WCF достаточно важна. NetDataContractSerializer в отличие от DataContractSerializer, нарушает принципы SOA и добавляет информацию о CLR типе в выходной поток, что нарушает «кроссплатформенность» сервис-ориентированной парадигмы. Подробнее об этом можно почитать в заметках: Что такое WCF или Декларативное использование NetDataContractSerializer.

21 комментарий:

  1. Нисколько не оспаривая основную мысль, замечу, что в первом способе решения не использован самый эффективный вариант - TrimExcess списка перед пересылкой.

    А, вообще - передавать лучше не бизнес-объекты, а DTO, а в них использовать примитивы, например массивы.

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

    ОтветитьУдалить
  2. О, а сноски-то я и пропустил :о))

    ОтветитьУдалить
  3. @_FRED_: Да, за TrimExcess спасибо! Это действительно более удобное решение. Чего-то я про него забыл:(

    ОтветитьУдалить
  4. TrimExcess - это не решение, а костыль - подпорка под _очевидный_ алгоритм передачи списка. Презираю дотнетину всё больше и больше...

    ОтветитьУдалить
  5. Чей-то вы злой какой-то:)

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

    ОтветитьУдалить
  6. Наврал я с TrimExcess, он, к сожалению, не даёт никаких гарантий в том, что вообще хоть что-то сделает (первая же строка в ремарках).

    Хм, очень странное решение - но тем лучше, простые как две копейки DTO и массивы в них становятся более полезными :о)

    ОтветитьУдалить
  7. Этот комментарий был удален автором.

    ОтветитьУдалить
  8. Этот комментарий был удален автором.

    ОтветитьУдалить
  9. Этот комментарий был удален автором.

    ОтветитьУдалить
  10. @_FRED_: Да, я тоже сходу подумал, что TrimExcess должен сработать и тоже не обратил внимание на примечание.

    ОтветитьУдалить
  11. Может вопрос странный, а что мешает реализовать тип с частным сериализатором? Если нужно реализовать список - ну так по списку можно и пройтись и сериализовать его элементы. Просто то, что я увидел - похоже на некоторые (не совсем очевидные) трюки. И сто пудов найдется чел, который оптимизнёт. В общем, если есть выбор использовать какие-то трюки или написать ванилла код - лучше выбрать второе. Но конечно, все без фанатизма... А по поводу абстракций.... хм... а как быть челу, что пишет на T-SQL - что для него будет нижележащем уровнем абстракции
    ?

    ОтветитьУдалить
  12. @eugene: как по мне, что в лоб, что по лбу, и то и другое останется хаком. Можно прикрутить суррогат и перехватить процесс сериализации, можно задействовать интерсепторы. Но это все равно останется хаком, ИМХО.
    Так что, как по мне, это все эти решения в равной мере будут затычками и в равной мере могут поломаться в будущем (из-за недосмотра, недопонимания etc).

    Что касается T-SQL (и, в частности, SQL) - то более нижним уровнем абстракции явлется что-то типа: способа хранения данных и все, что связано с внутренними реализациями СУБД. Если судить по нашим камрадам, которые в этом деле специализируются, то они уровень ниже знают отлично.

    ОтветитьУдалить
  13. var colors = new List {Color.Green};
    var s = Serialize(colors.ToArray());

    тоже должно помочь

    ОтветитьУдалить
  14. @Семен: Да, это поможет. Но у этого решения есть все те же проблемы, что и у создания нового списка: это нужно делать всегда, иначе получишь ошибку времени выполнения. А вот это, уже проблематично.

    ОтветитьУдалить
  15. Не буду спорить насчёт истинности высказывания о дырявых абстракциях в принципе, хотя не до конца уверен в этом...
    Но пример приведённый в даннойс статье видится мне принципиально не правильным.
    Проблема не в том, что абстракция "течёт". Проблема в том, что сериализация во фреймворке реализована криво. Capacity (коротко говоря) - это ведь по сути память выделенная заранее. Т.е. это низкоуровневая хрень. А объекты, которые пользователь засунул в список - это часть его бизнес логики. Вместо того, чтобы сериализовать пользовательские полезные данные, фреймворк зачем-то сериализует пустую/неинициализированную/или как в данном случае не подходящую по типу память. С таким же успехом он мог начать сериализацию всей имеющейся на хосте памяти, жестких дисков и т.д. Не правильно делать из этого вывод о том, что абстракция списка "течёт". Скорее течёт мозг у разработчиков механизма сериализации, который мало того, что создаёт мнимую лёгкость использования, нарушает заявленные уровни абстракции (насколько я понимаю - сам я не .NET-программер), так ещё и безсовестно сжирает кучу лишних ресурсов (например, трафик для передачи Capacity по сети).

    ОтветитьУдалить
  16. Александр, Вы совершенно правы.

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

    Здесь же мы имеем ситуацию, когда из-за неудачной реализации эти детали начинают играть очень важную роль в определенных случаях, что приводит к малопонятному поведению.

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

    ОтветитьУдалить
  17. Да, но зачем называть это "дырявой абстракцией"? :)
    Это ведь реализация дырявая.

    Дырявая абстракция - это когда пользуешься списком (add, remove, ...) - всё ок. А вот чтобы сделать indexOf - тут нужно указать его точный класс! О_о Или, скажем, передать ему какой-то указатель (в то время как в обычное время вся работа идёт на уровне ссылок). Вот это действительно дыра в абстракции.

    Т.е. как я понимаю, коряво (исторически) сложившеяся терминология... Рефакторинг по ней плачет.

    ОтветитьУдалить
  18. Кривость реализации бывает разной.

    Одна реализация может быть тормозной, другая неюзабельной, третья - дырявой.

    Дырявость абстракции - это просто одна конкретная форма кривизны реализации.

    ОтветитьУдалить
  19. NeDataContractSerializer - это пять
    А что тогда :)?

    ОтветитьУдалить
  20. @Костя: очепятку поправил.

    А вопрос твой не понял.

    ОтветитьУдалить