понедельник, 17 мая 2010 г.

Проектирование по контракту. Наследование

Рассмотренные ранее механизмы взаимодействия между двумя программными элементами моделируют взаимоотношение “использует” (HAS-A relationship) или взаимоотношение типа “клиент-поставщик”. Механизмы наследования, играющие ключевую роль в объектном подходе с точки зрения расширяемости и повторного использования, моделируют взаимоотношение “является” (IS-A relationship) и добавляют взаимоотношения между базовым классом и его потомком.

 Figure4
Рисунок 4 – Взаимоотношения между объектами

Опытные разработчики знают, что чрезмерное использование наследования – это один из ключевых факторов, который приводит к созданию сложного в понимании и сопровождении кода. Связано это, прежде всего с тем, что отношение «является» обладает более сильной связью, чем отношение “использует”, и каждый раз, при анализе кода производного класса нужно анализировать поведение всех его базовых классов и четко понимать «контракт», который базовые классы предоставляют своим наследникам. Использование утверждений совместно с наследованием не только упрощает создание производных классов, но и гарантирует клиентам базового класса согласованное поведение при использовании динамического связывания и полиморфизма.

Инварианты

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

ПРИМЕЧАНИЕ
Правило родительских инвариантов: инварианты всех родителей применимы и к самому классу

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

Предусловия и постусловия

Полиморфизм и динамическое связывание добавляет некоторые особенности при работе с предусловиями и постусловиями.

Давайте рассмотрим гипотетический пример. Предположим у нас есть класс С, который содержит ссылку на класс B. Вследствие динамического связывания, в период выполнения вместо объекта класса B может быть использован объект класса D или любого другого наследника класса B.

Предположим, что в классе B определена открытая функция int Foo(int x), с предусловием x > 5 (pre_b) и постусловием Result > 0 (post_b):

class B {

  public virtual int Foo(int x) {

      Contract.Requires(x > 5, "x > 5");

      Contract.Ensures(Contract.Result<int>() > 0,

                       "Result > 0");

      // Реализация метода

  }

}

Тогда использование объекта класса B внутри класса C может выглядеть следующим образом:

class C {

  //...

  B b = GetFromSomewhere();

  int x = GetX();

  if (x > 5) { //Проверяем предусловие pre_b

    int result = b.Foo(x);

    Contract.Assert(result > 0); // Проверяем постусловие post_b

  }

}

Благодаря проверке предусловия, класс С выполняет свою часть контракта и может рассчитывать на выполнение контракта классом B (или одним из го потомков). Согласно принципу подстановки Лисков [Coplien1992] поведение приведенного фрагмента кода не должно измениться, если во время выполнения динамическим типом объекта B будет один из наследников класса B.

Но что, если при переопределении функции Foo один из наследников класса B захочет изменить предусловия и постусловия?

Figure5  
Рисунок 5 – Некорректное переопределение предусловия и постусловия

Давайте предположим, что функция int Foo(int x) в класс D начинает требовать больше (содержит более сильное предусловие вида: x > 10), и гарантировать меньше (содержит более слабое постусловие вида: x > -5):

class D : B {

    public override int Foo(int x) {

        Contract.Requires(x > 10, "x > 10");

        Contract.Ensures(Contract.Result<int>() > -5,

                         "Result > -5");

        return -1;

    }

}

В этом случае, хотя клиент класса B полностью выполняет свою часть контракта и предоставляет входное значение функции Foo, удовлетворяющее предусловию он может не получить требуемого результата. Усиление предусловия означает, что данные корректные для базового класса станут некорректными для его наследника (в нашем примере, это может быть значение x равное 6), а ослабление постусловия означает, что результат, на который рассчитывает клиент базового класса может быть не выполнен классом наследником (в нашем примере, это может быть результат выполнения функции Foo равный -1).

Отсюда можно сделать вывод, что при переопределении методов предусловие может заменяться лишь равным ему или более слабым (требовать меньше), а постусловие – лишь равным ему или более сильным (гарантировать больше). Новый вариант метода не должен отвергать вызовы, допустимые в оригинале, и должен, как минимум, представлять гарантии, эквивалентные гарантиям исходного варианта. Он вправе, хоть и не обязан, допускать большее число вызовов или давать более сильные гарантии [Meyer2005].

class D : B {

    public override int Foo(int x) {

        Contract.Requires(x > 0, "x > 0");

        Contract.Ensures(Contract.Result<int>() > 10,

                         "Result > 10");

        return 25;

    }

}

Ковариантность и контравариантность

Хорошим примером изменения гарантий методов при переопределении является возможность многих языков программирования изменять тип возвращаемого значения при переопределении с базового на производный. Это понятие называется «ковариантность по типу возвращаемого значения» (return type covariance) и выглядит следующим образом:

class B1 {};

class D1 : public B1 {};

class B2 {

public:

  virtual B1 Foo();

};

class D2 : public B2 {

public:

  virtual D1 Foo();

};


Figure6  
Рисунок 6 – Ковариантность по типу возвращаемого значения

Эта  возможность (которая поддерживается в C++, Java, D и других языках) полностью согласуется с принципами проектирования по контракту и, в частности, с принципом усиления постусловия, поскольку можно считать, что для типов D1 и B1 выполняется соотношение typeof(D1) < typeof(B1).


Figure7  
Рисунок 7 – Отношение порядка между объектами иерархии наследования

Аналогичным примером может служить ковариантность по типу возвращаемого значения и контрвариантность по типу принимаемого значения обобщенных интерфейсов и делегатов в C# 4.0. И хотя в этом случае речь идет не о наследовании, а о совместимости по присваиванию, можно говорить о тех же самых принципах и правилах, что и при переопределении утверждений классами потомками. Так, например, делегату d1 с предусловием pre1 и постусловием post1 может быть присвоен делегат d2 с предусловием pre2, равном pre1 или более слабым, и постусловием post2, равным post1 или более сильным:

void Foo(object obj) {}

string Boo() {returnBoo”;}

 

//...

// Контравариантность аргументов:

// предусловие делегата Action<object> и, соответственно

// метода Foo, слабее предусловия делегата

// Action<string>, поскольку typeof(object) < typeof(string)

Action<string> action = Foo;

// что аналогично следующему коду:

Action<string> action = new Action<object>(Foo);

 

// Ковариантность возвращаемого значения:

// постусловие делегата Func<string> и, соответственно

// метода Boo, сильнее постусловия делегата

// Func<object>, поскольку typeof(string) > typeof(object)

Func<object> func = Boo;

// что аналогично следующему коду:

Func<object> func = new Func<string>(Boo);

Комментариев нет:

Отправить комментарий