Thursday, August 16, 2007

Подтипы и наследование

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

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

Обычно есть несколько видов наследования. Первый — самый понятный и полезный — это наследование интерфейса. Он есть во все ОО-языках.

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

Другими словами, конкретный класс Cow, реализующий интерфейс IAnimal, должен иметь соответствующий подтип Cow <: IAnimal. То есть везде, где используется IAnimal можно впихнуть Cow и все будет все ок. По сути, так оно и работает.

 

Но ситуация заметно усложняется, если наследование происходит не от абстрактного интерфейса, а от другой реализации. В C++ это public наследование, в Java — кейворд extends.

Жизнь усложняется из-за двух основных обстоятельств: Во-первых, поскольку в классах обычно есть какое-то состояние, две эти реализации это состояние по разному дергают. А во-вторых, в большинстве языков есть такая фича: пусть функция sayMoo() в Cow вызывает Cow::open(), и если open() в наследнике переопределено, а sayMoo() — нет, то sayMoo() будет вызывать переопределенную версию open(). Это называется "открытой рекурсией" (open recursion).

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

Чтобы избежать всяких отвратительных ошибок, был изобретен всем известный Liskov Substitution Principle — по которому подкласс должен себя вести в точности как родитель в отношении ряда заранее определенных инвариантов, чтобы его можно было считать подтипом родителя. К сожалению, соблюдение этого принципа ложится на плечи программиста, а дело это не очень тривиальное.

Чистое же наследование реализации (private inheritance в C++) страдает похожими недостатками. Только подкласс при таком наследовании уже подтипом родителя не является совсем.

Ссылки:

5 comments:

lvccgd said...

Может наследование и вносит какую-то путаницу и является источником ошибок (тем более если задействовано множественное наследование), но только у прогеров, опыт которых связан с средствами программирования, которые не могут диагностировать ошибки на стадии компиляции так же хорошо, как С++. Именно, наследование помогает упорядочить понятия до горя абстрактных и категоричных понятий (например нейронные сети, нечеткое множество, библиотека контейнерных классов и т.д.) Кроме того механизм наследования не должен решать всех проблем...

migmit said...

которые не могут диагностировать ошибки на стадии компиляции так же хорошо, как С++.
Гыгыг.

lrrr said...

lvccgd> Насчет наследования от интерфейсов -- там особенных проблем нет. А вот с остальными видами наследования, когда наследование используется для code reuse -- больше вреда или пользы оно приносит это большой вопрос.

Да, и зачем нужно наследование в контейнерных классах?

Sergey Rozovik said...

Реализацию интерфейса называть "самым понятным и полезным видом наследования" я бы поостерегся. Реализация интерфейса классом вообще с большой натяжкой попадает под определение наследования.
Второе. "Открытая рекурсия" не так уж страшна. Это нормальное поведение для иерархий, обусловленных наследованием. Единственное место, где она может создать проблемы и ее следует избегать - это при создании повторно используемых библиотек.

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

lrrr said...

> Реализацию интерфейса называть "самым понятным и полезным видом
> наследования" я бы поостерегся. Реализация интерфейса классом вообще с
> большой натяжкой попадает под определение наследования.

В этих вещах вообще сложно с определениями.. Я тут про ООП в стиле C++/C#, в их терминах это вполне себе наследование.

> Второе. "Открытая рекурсия" не так уж страшна. Это нормальное поведение для
> иерархий, обусловленных наследованием. Единственное место, где она может
> создать проблемы и ее следует избегать - это при создании повторно
> используемых библиотек.

Да, тут может я немного сгустил краски. Но проблемы с этим, тем не менее, есть.

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

Но наследование действительно довольно сложная вещь. В том числе и с точки зрения теории типов.
Да, его суть можно "на пальцах" объяснить за пять минут -- но много важных моментов остается за кадром. Это не только (и не столько) возможные неприятные баги, сколько сильное связывание и размазывание логики по иерархии классов. Читать и править код, где 5-10 уровневая иерархия классов -- очень неудобно, пускай даже он в остальном написан по всем канонам C++-ного ОО.

В конце концов, не зря в правильных книжках по ООД предлагается по возможности заменять наследование композицией.

Да, здесь везде читать "наследование" как "наследование реализации", конечно. Ну и совсем отказываться от него я не призываю :)