вторник, 30 октября 2012 г.

Коллекция как тип свойства

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

К необходимости внести ясность в обсуждаемую тему сподвигло очень уж не редко встречающаяся на просторах интернета такая вот реализация свойства:

List<T> Items { get; set; }

или

T[] Items { get; set; }

Свойство тут не обязательно должно быть auto-implemented, а может быть и с самыми обычным accessor-ами. Важно то, что свойство имеет оба accessor-а и имеет изменяемую коллекцию в качестве типа.

Пользователь данного свойства может менять его значение несколькими способами:

  • модификацией содержимого списка:
    obj.Items.Add(value);
    obj.Items[0] = value;
  • присвоением значения:
    obj.Items = new List<T> { value, };

Плохи тут две вещи: во-первых, каждому пользователю свойства нужно знать, как (и в каких случаях) его изменять - когда добавлять элементы, а когда перезаписывать весь список. Подход с перезаписыванием плохо работает в некоторых случаях, например, при data binding - тут придётся реализовывать Property Change Notification, что уже значительно усложнит всё - и свойство, и работу с ним и объект, в котором свойство объявлено. Так же, если содержимое списка изменяют разные участки кода, все такие изменения должны быть согласованы, что бы не было такого: там добавили, тут перезаписали и здесь не нашли - куда всё "там" добавленное пропало.

Во-вторых, автор свойства должен или позволить присваивать значению свойства null - и тогда перед каждым обращением к свойству его значение придётся проверять на null; или запретить это (самостоятельно описав set-accessor с проверкой аргумента value) - и тогда нельзя будет очистить список, просто присвоив значению свойства null. Придётся делать так:

obj.Items = new List<T>(0 /* capacity */);

что совсем не "чисто" (лишние выделения памяти) и похоже больше на костыли. Можно сделать и этак:

obj.Items.Clear();

что не будет работать в случае, если типом свойства является массив, а так же приводит к тем самым недостаткам, что описаны в "во-первых". Вообще, по поводу изменяемых объектов очень точно сказано тут: Любые изменяемые данные… могут иметь … только одного владельца. И если кто-то в одном месте случайно сделает так:

obj.Items = other.Items;

а потом кто-то другой попробует очистить список оптимальным образом через obj.Items.Clear();, то результаты будут, скорее всего, самые неожиданные.

Но все подобные неожиданности можно очень просто обойти, придерживаясь простого правила: либо тип свойства должен быть read-only коллекцией и тогда (при необходимости дать возможность пользователю менять список) свойство должно иметь set-accessor; либо коллекция должна быть изменяемая и тогда свойство должно иметь [видимый пользователям] только get-accessor. Если же пользователь вообще не должен менять содержимое списка никоим образом - то и коллекция должна быть неизменяема для пользователя и свойство должно иметь лишь get-accessor. Вот тонкости в реализации этих подходов мы и рассмотрим.

Начнём с того, какой тип тут называется "изменяемой", а какой "read-only" коллекцией. "Изменяемый" тип коллекции - это такой тип, интерфейс которого позволяет изменять содержимое коллекции. Соответственно "read-only"-тип - тип, не предоставляющий возможности изменить содержимое коллекции. Immutable-коллекции здесь рассматриваются как частный случай коллекций read-only-типа.

К изменяемым типам я отношу, например, ICollection<> , IList<> , IList , их производные и массивы. К read-only: IEnumerable , ICollection , IEnumerable<> , ReadOnlyCollection<> , и конечно же новые IReadOnlyCollection<> , IReadOnlyList<> , и IReadOnlyDictionary<> . В самом конце отдельно остановимся на массивах и IEnumerable в типах свойств.

В случае, если объект типа, в который мы добавляем свойство, будет являться владельцем коллекции, тип коллекции следует сделать изменяемым, а свойство - read-only. Свойство должно инициализироваться в конструкторе непустым значением и более не меняться. Это можно реализовать или с помощью read-only backing field ( A private field that stores the data exposed by a public property is called a backing store or backing field.)

class MyObject
{
  private readonly List<int> items = new List<int>();

  public List<int> Items {
    [DebuggerStepThrough]
    get { return items; }
  }
}

или, например, так:

class MyObject
{
  public MyObject() {
    Items = new List<int>();
  }

  public List<int> Items { get; private set; }
}

Такие read-only-свойства имеют отличную поддержку как со стороны языка:

var obj = new MyObject { Items = { 1, 2, 3, }, };

так и со стороны фреймворка: вопреки [почему-то] распространённому [и ошибочному] мнению такие свойства отлично сериализуются и с помощью XmlSerializer, и BinaryFormatter, и DataContractSerializer, и NetDataContractSerializer.

Помимо прочего, несомненным плюсом такого подхода является то, что объект-владелец коллекции имеет возможность обрабатывать манипуляции пользователя с элементами коллекции (то есть реагировать на добавление/изменение/очистку элементов). Тип коллекции для этого должен быть, естественно, не List<>, а, например, наследник Collection<> , но о замечательных типах из System.Collections.ObjectModel Namespace я обязательно расскажу как-нибудь в другой раз.

Если же вам нужно просто хранить набор неких элементов в вашем объекте, не беря на себя владение набором, сделай тип коллекции read-only и снабдите свойство обоими accessor-ами:

public class MyObject
{
  private static readonly ReadOnlyCollection<int> EmptyItems = new ReadOnlyCollection<int>(new int[0]);

  private ReadOnlyCollection<int> items;

  public ReadOnlyCollection<int> Items {
    [DebuggerStepThrough]
    get { return items ?? EmptyItems; }
    set { items = value; }
  }
}

EmptyItems тут нужен для того, что бы свойство никогда не возвращало бы null. На это стоит обращать особое внимание всегда - свойства с типом-коллекцией не должны возвращать null, потому что в этом случае пользователям такого свойства всегда будет неудобно с ним работать.

С сериализацией здесь не так здорово (XmlSerializer не сможет работать со свойством Items), но обходные пути всегда есть.

Так же бывает следующая ситуация - объект сам заполняет некую коллекцию и необходимо дать доступ к этой коллекции окружающему миру. Но менять эту коллекцию снаружи нельзя. То есть внутри объекта коллекция изменяема, а снаружи - read-only. Это реализуется, например, так:

class MyObject
{
  public MyObject() {
    ItemsCore = new List<int>();
    Items = ItemsCore.AsReadOnly();
  }

  private List<int> ItemsCore { get; set; }
  public ReadOnlyCollection<int> Items { get; private set; }
}

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

Теперь несколько слов о том, как делать не нужно. Не нужно объявлять [открытые] свойства, тип которого - массив. Очень уж редко требуется коллекция, размер которой пользователь не может менять, а содержимое может менять как угодно (и это изменение "владельцу" коллекции не отловить). Замените массив или на список, или на какую-либо read-only-коллекцию.

Так же не стоит создавать такие свойства:

public IEnumerable<int> Items {
  [DebuggerStepThrough]
  get {
    yield return 1;
    yield return 2;
    yield return 3;
  }
}

Вместо IEnumerable, если содержимое Items неизменно, следует использовать какую-либо read-only-коллекцию или, если требуется именно ленивость, нужно заменить свойство методом. Просто представьте, что кто-то решит использовать ваш объект в data binding или где-то ещё, где станет важным тот факт, что при каждом обращении к вашему свойству возвращается физически новый объект.

Вот так вот: создавая какой-либо новый тип, какую-либо сущность, думайте прежде всего о пользователе (а в нулевую очередь - ну мы же не на Бейсике пишем - о надёжности, ибо ненадёжное не нужно a priori), а не о том, как бы побыстрее да поудобнее для вас всё сделать - использовать данную сущность придётся чаще и дольше, чем выписывать её и чем меньше сценариев неправильного использования - тем успешнее будет результат.