четверг, 20 декабря 2012 г.

Типизированное клонирование

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

Обычно встречается следующая реализация:

public abstract class Base : ICloneable
{
  public abstract object Clone();
}

public class Derived : Base
{
  public override object Clone() {
    // <Clone implementation>
    return new Derived();
  }
}

или

public abstract class Base
{
  public abstract Base Clone();
}

public class Derived : Base
{
  public override Base Clone() {
    // <Clone implementation>
    return new Derived();
  }
}

Во-первых, реализация ICloneable является моветоном и в хороших API встречаться не должна (и вот почему). Во-вторых, неудобно здесь то, что Derived::Clone() возвращает совсем не Derived и, зачастую, к результату придётся применять операцию приведения типа (type cast). Для того, что бы таких неудобств ни возникало, достаточно оформить код классов следующим образом:

public abstract class Base
{
  protected abstract Base CloneBase();

  public Base Clone() {
    return CloneBase();
  }
}

public sealed class Derived : Base
{
  protected override Base CloneBase() {
    return Clone();
  }

  public new Derived Clone() {
    // <Clone implementation>
    return new Derived();
  }
}

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

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

Рассмотрим пример ещё раз, в параллель с правилом:

// В классе, у которого могут быть наследники…
public abstract class Base
{
  // …объявляется два метода, каждый из которых возвращает "текущий" тип - "Base":

  // …защищённый виртуальный с реализацией клонирования текущего типа
  // (или защищённый абстрактный, если класс абстрактный)…
  protected abstract Base CloneBase();

  // …и открытый, …
  public Base Clone() {
    // …который возвращает вызов виртуального (абстрактного).
    return CloneBase();
  }
}

// В классе, у которго нет наследников…
public sealed class Derived : Base
{
  // …тоже делается два метода:

  // …возвращающий "текущий" тип открытый…
  public new Derived Clone() {
    // …с реализацией клонирования
    return new Derived();
  }

  // …и перегрузка метода базового класса, …
  protected override Base CloneBase() {
    // …возвращающая вызов открытого метода.
    return Clone();
  }
}

(В коде выше я переставил местами объявления методов класса Derived для того, что бы они соответствовали формулировке правила.)

Посмотрим, зачем же все эти сложности:

static Base GetBase() { return /* Some Base */; }
static Derived GetDerived() { return /* Some Derived */; }

static void Main() {
  Base b = GetBase();
  Base b2 = b.Clone();

  Derived d = GetDerived();
  Derived d2 = d.Clone();
}

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

Бывают случаи и запутаннее, когда иерархия более глубокая, и между Base и Derived возникают различные Middle. Тогда описанное выше правило работает точно так же, с небольшим дополнением: не корневой класс в иерархии клонирования, у которого так же будут (могут быть) наследники должен написать запечатанную (sealed) перегрузку метода клонирования базового класса, из которой вернуть результат объявленного в нём виртуального метода:

// Не корневой класс в иерархии клонирования,
// у которого так же будут (могут быть) наследники…
public class Middle : Base
{
  // …должен написать запечатанную перегрузку метода клонирования
  // базового класса, …
  protected sealed override Base CloneBase() {
    // из которой вернуть результат объявленного в нём виртуального метода.
    return CloneMiddle();
  }

  protected virtual Middle CloneMiddle() {
    // <Clone implementation>
    return new Middle();
  }

  public new Middle Clone() {
    return CloneMiddle();
  }
}

Осталось рассмотреть последний случай, когда такой middle-класс является абстрактным. В соответствии с описанными правилами, его объявление будет таким:

public abstract class Middle2 : Middle
{
  protected sealed override Middle CloneMiddle() {
    return CloneMiddle2();
  }

  protected abstract Middle2 CloneMiddle2(); {

  public new Middle2 Clone() {
    return CloneMiddle2();
  }
}

А "листовой" (не имеющий своих наследников) наследник уже этого класса будет таким:

public sealed class Derived2 : Middle2
{
  protected override Middle2 CloneMiddle2() {
    return Clone();
  }

  public new Derived2 Clone() {
    // <Clone implementation>
    return new Derived2();
  }
}

Да, конечно, кода получается несколько больше, чем вы, возможно, привыкли. Но код, сравнительно, используется намного чаще, чем пишется и читается намного чаще, чем используется. Показанный здесь "синтаксический оверхед" вызван невозможностью в C# применять ко-/контр-вариантность к типам возвращаемого значения и параметрам при перегрузке методов, а так же желанием иметь более дружественные в использовании классы.