четверг, 18 сентября 2014 г.

LSP Часть 2. О сложностях наследования

Цикл статей о SOLID принципах

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

Бытует мнение, что генерация исключений InvalidOperationException или NotSupportedException методами наследника означает нарушение этим классом принципа замещения Лисков. И хотя в некоторых случаях это действительно так, судить так однозначно нельзя.

Является ли нарушением LSP, что ImmutableList<T> и ReadOnlyCollection<T> реализует IList<T>, поскольку попытка добавления элемента в такую коллекцию приводит к генерации NotSupportedException? Может показаться, что нарушают, однако в «контракте» интерфейса IList<T> четко сказано, что метод Add будет добавлять элемент, только в случае выполнения «предусловия» – коллекция должна быть изменяемой! Аналогично дела обстоят с потоками ввода вывода, методы Read/Write которых могут генерировать исключения, если свойствами CanRead/CanWrite возвращают false. Во всех этих случаях мы можем говорить о неудачном дизайне, но не можем говорить о нарушении принципа подстановки Лисков!

ПРИМЕЧАНИЕ
«Контракт» и «предусловие» специально взяты в кавычки. На самом деле, это логический контракт и логическое предусловие, описанные в документации интерфейса IList<T>, а не в классе его контракта: классе IListContract<T>.

Аналогичная картина происходит и с методами наследников, которые генерируют InvalidOperationException: тот факт, что к наследнику применимы не все методы интерфейса или базового класса еще не говорит о проблемах с наследованием.

Давайте еще раз вернемся к определению принципа подстановки Лисков:

...если для каждого объекта o1 типа S существует объект o2 типа T такой, что для всех программ P, определенных в терминах T, поведение P не изменяется при замене o2 на o1, то S является подтипом (subtype) для T.

В нем сказано, что если поведение клиента не изменится, то S является подтипом для T. А что если оно изменится? Означает ли это, что мы нарушаем принципы наследования, или это значит, что S не является подтипом T?

Согласно принципу наименьшего удивления, мы привыкли считать, что должна быть возможность использовать экземпляр наследника вместо экземпляра базового класса. Но ведь это не значит, что мы никогда не используем классы наследники напрямую! Мы добавляем свойства и методы в производные классы, хотя и понимаем, что они не будут доступны через «призму базового класса». Нас при этом совсем не волнует, что клиент должен знать о классе наследнике, чтобы ими воспользоваться.

Наследование подтипов (subtype inheritance) является одним из многих видов наследования, но далеко не единственным. Бертран Мейер в своей книге «Объектно-ориентированное конструирование программных систем» описывает 12 разных видов наследование, но принцип подстановки Лисков касается лишь одного – наследование подтипов.

Наследник имеет право добавлять новые методы («расширяющее наследование»), «удалять» или не реализовывать некоторые методы базового класса («сужающее наследование»), переименовывать методы базового класса для более точной передачи семантики производного класса.

Может показаться, что подобные техники применяются лишь в языке Eiffel, и в практике современного .NET разработчика не встречаются. Однако это не так.

Явная реализация интерфейсов в C# (explicit interface implementation) является примером «сужающего наследования»: класс реализует интерфейс, но при этом методы интерфейса видны лишь в случае явного приведения экземпляра класса к интерфейсу.

var list = new List<int> { 42 }.AsReadOnly();

//list.Add(42); // ошибка компиляции!
((IList<int>)list).Add(42); // ошибка времени исполнения
IList<int> list2 = list; // к сожалению, здесь явное приведение не требуется!
list2.Add(42); // ошибка времени исполнения

К сожалению, в языке C# остается возможность неявного приведения экземпляра класса к явно реализованному интерфейсу. В противном случае сходство с сужающим наследованием было бы максимальным: нам обязательно пришлось бы приводить экземпляр класса к нужному интерфейсу, чтобы воспользоваться его членами.

Явная реализация интерфейса используется также для «переименования членов интерфейса». Классическим примером использования этой возможности, является класс Socket, который реализует интерфейс IDisposable явно и при этом вводит «синоним» спрятанного метода Dispose с именем Close.

Socket s = CreateSocket();
s
.Dispose(); // Не компилируется до.NET 4.0
((IDisposable)s).Dispose(); // Всегда OK
s.Close(); // Всегда OK

Об этой технике явно говорится в книге “Framework Design Guidelines” в разделе “Dispose Pattern Guidelines”: автор класса имеет полное право при реализации интерфейса воспользоваться более подходящим именем, и «спрятать» от клиентов имя из интерфейса. Можно говорить о том, что реализация интерфейсов в таких языках, как C# моделирует особое отношение между типами – “Can Do Relationship”, в то время, как наследование моделирует отношение “Is A Relationship”. Но это говорит лишь об ограничении системы типов языка C# и невозможности использования в нем всего арсенала наследования, доступного в других языках, таких как Eiffel или даже C++.

ПРИМЕЧАНИЕ
В плане наследования С++ является существенно более функциональным языком, нежели C#. Помимо множественного наследования, С++ поддерживает большую часть техник, описанных Мейером. Так, в С++ есть наследование реализации (закрытое наследование), при этом автоматическая конвертация экземпляров наследников в экземпляры базовых классов производиться не будет. С++ поддерживает изменение видимости функции при переопределении: видимость можно как расширить (от защищенной в базовом классе к открытой в наследнике), так и сузить (от открытой в базовом классе к закрытой/защищенной в наследнике). В С++ легче моделировать «примеси» и др.

Когда наследования бывает слишком мало!

Обычно проблемы с наследованием возникают из-за слишком «широких» или «глубоких» иерархий наследования. Но иногда проблемы в дизайне возникают из-за того, что класс унаследован не от того базового класса или не реализуют нужный интерфейс.

Классическим нарушением принципа Открыт/Закрыт является «перебор типов»: если переданный объект – это круг, рисуем круг, если квадрат – рисуем квадрат, если слон – рисуем слона; при этом такой перебор размазан по коду приложения. Обычно такие подходы характерны структурному подходу, но часто бывают и в полностью ОО мире.

ПРИМЕЧАНИЕ
Вполне корректные примеры выноса логики за пределы иерархии наследования были рассмотрены при сравнении ОО и ФП-расширяемости в статье Open/Closed Principle. ФП vs. ООП

В идеальном ОО-мире, все, что делает иерархия классов должно находиться прямо в ней, однако в реальном мире мы часто выносим логику за ее пределы. Типичным примером является, LINQ – Language Integrated Query, который содержит унифицированную логику, доступную для всех последовательностей. Некоторые операции, такие как Count(), ElementAt(), Last() могут иметь более эффективную реализацию для конкретных типов коллекций, таких как Collection<T>, List<T> и т.п.

Вот, как примерно выглядит реализация метода Enumerable.Count():

public static int Count<TSource>(this IEnumerable<TSource> source)
{
   
var collectionoft = source as ICollection<TSource>
;
   
if (collectionoft != null) return collectionoft.
Count;

   
var collection = source as ICollection
;
   
if (collection != null) return collection.
Count;

   
int count = 0
;
   
using (IEnumerator<TSource> e = source.
GetEnumerator())
    {
       
while (e.MoveNext()) count++
;
    }

   
return count;
}

Мы видим, что эта реализация имеет более эффективное решение лишь для коллекций (интерфейсов ICollection). Это значит, что как только в .NET Framework появится класс «коллекции» со свойством Count, который не будет реализовывать ICollection, сложность метода Count будет неэффективной – O(n) вместо O(1). И такой класс существует в .NET Framework – Lookup:

var lookup = Enumerable.Range(1, 10000).ToLookup(x => x);
Console.WriteLine(lookup.Count); // Сложность О(1)
Console.WriteLine(lookup.Count()); // Сложность О(n)

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

Заключение

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

Когда тип предназначен лишь для полиморфного использования, то такое наследование является наследованием подтипов и должно следовать принципу подстановки Лисков. Если же создание наследника нужно для повторного использования кода базового класса, или же в основном он будет использован напрямую, то вполне возможно, что его интерфейс и контракт будет изменен: добавлены новые методы и/или не реализованы некоторые методы базового класса.

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

Четкое следование принципам проектирования вообще, и принципа замещения в частности, не гарантирует хорошего дизайна или удобной иерархии наследования.

6 комментариев:

  1. Вопрос, который я люблю задавать на собеседованиях: если я переопределяю ToString() в классе наследнике, нарушаю ли я LSP?
    У людей иногда вызывает легкую панику.

    ОтветитьУдалить
    Ответы
    1. Костя, есть все шансы, что легкая паника у людей возникает от некоторой некорректности вопроса. Вот я, например, не знаю, как я бы отвечал на этот вопрос. Это все равно, что если спросить: "А использование System.Console в классе нарушает SRP?".

      Удалить
  2. А мне одному кажется, что “Can Do Relationship” есть частный случай “Is A Relationship”?
    Т.е. "Can Do <...>" - это "Is A Thing Than Can Do <...>".

    ОтветитьУдалить
    Ответы
    1. Ну, IS A показывает существенные вещи абстракции, а Can Do - второстепенные: список является последовательностью. Список является упорядочиваемым. И там и там можно впихнуть слово является, но в первом случае - это существенная характеристика списка, а во-втором - второстепенная.

      З.Ы. Нельзя сказать, что реализация интерфейсов - это всегда Can Do. Это точно. Но два вида наследования удобно разделять, ИМХО.

      Удалить
    2. Хмм.. не уверен, что понял. "Список является последовательностью" (т.е. реализует IEnumerable, я правильно понимаю?) - это существенная характеристика?

      Интерфейс нужен для полиморфизма. Т.е. у нас должен быть где-то код, работающий с объектами через этот интерфейс. Например, метод с параметром, имеющим тип IMyInterface. Если такого кода нет, то и интерфейс нафиг не нужен. А первостепенность\существенность штука контекстная. В контексте вешеупомянутого метода существенным является то, что объект реализует IMyInterface, а все остальное не важно.

      З.Ы. Явная реализация интерфейса, на сколько понимаю, задумывалась для разрешения неоднозначности, когда мы реализуем несколько интерфейсов и у них есть пересечения по сигнатурам методов. Запрет на прямой вызов явно реализованых методов (без приведения к интерфейсу) имеет смысл "у нас же тут неоднозначность, нужно точно указать какой метод имеется ввиду". А то, что оно запрещается даже когда неоднозначности нет - это просто упрощение языка, т.к. "чувак, а зачем ты явно реализуешь, если неоднозначности-то и нет?". Поэтому неявное приведение к интерфейсу, даже если часть или все его методы реализованы явно, имеет смысл - неоднозначность пропадает.

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

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

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

      Удалить