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

NVI и C#

NVI - Non-Virtual Interface, одна из идиом программирования, предписывающая, что открытый интерфейс класса не должен быть виртуальным. Подробнее о ней вы можете прочитать в wikibooks и далее в References оттуда. Здесь я расскажу, почему в C# данный подход к проектированию типов имеет не меньшее значение, чем в С++ (почему-то не редко сталкивался с мнением, что это чисто С++-нутая заморочка), а так же о некоторых аспектах применения этого подхода, не описанных (явно) в популярной литературе.

Итак, NVI требует, что бы открытый интерфейс класса небыл бы виртуальным. Это не значит, что в открытом интерфейсе у вас не должно быть виртуальных методов. Тут вкрадывается небольшой и маловажный, но терминологический ньюанс, вызванный особенностями .NET: виртуальные методы в .NET могут быть запечатанными (sealed в C#) и они не являются "виртуальными" в том смысле, который слово "виртуальный" содержит в NVI. Применительно к .NET точнее будет сказать, что NVI обязывает открытый интерфейс класса не содержать методы, которые могут быть переопределены (overridden) в наследниках. Например, в такой формулировке абстрактный (abstract в C#) метод так же является "виртуальным" с точки зрения определения NVI. Попутно, на всякий случай, уточним, что NVI имеет смысл рассмаривать только для ссылочных типов, наследников object, в .NET. Далее под "типом" (если иное отдельно не оговорено) будет подразумеваться именно такой тип.

Самое время сказать, зачем это нужно. Зайду издалека. Большинство типов данных имеют то, что в самом общем смысле можно назвать "контракт" - способ, которым внешний мир может взаимодействовать с объектами типа. Не каждый тип обязан иметь [осмысленный] контракт (например, Data Transfer Object - "…a DTO does not have any behavior…"), но такие типы нас сейчас не интересуют - раз у них нет контракта, NVI к ним применить невозможно. "Контракт" описывает некоторые обязательства типа по отношению к внешнему миру, а так же декларирует условия, при которых взятые типом обязательства будут (и в каких рамках) выполнены. Этот "контракт" и есть то, что в определении NVI называется "открытый интерфейс".

Но вся тонкость в том, что помимо контракта с внешним миром, тип может заключить контракт со своими же наследниками. И делается это посредством виртуальных методов и защищённых (protected) данных. Получается, что любой (не sealed) тип должен поддерживать два контракта - с "внешним миром" и с наследниками. Каждый из этих контрактов отвечает за различные задачи: "внешний" контракт должен (в первую очередь) обеспечить удобство работы с типом, а "внутренний" - обеспечивать согласованность данных типа. Конечно, и "внутренний" контракт должен быть удобен, но кому он такой удобный будет нужен, если не позволит соблюдать инвариант состояния типа? Так вот: контрактов два, с разными целями и задачами.

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

Так же, использование NVI провоцирует к более тщательному дизайну типа: ведь, по сути, NVI заключается в том, что контракт между типом и его наследниками должен осуществляться исключительно посредством Template method. Не нужно бояться, что "более тщательный дизайн" займёт у вас значительно больше времени: по истечении некоторого времени всё будет "делаться само" и самым естественным образом. Нужно лишь действительно осознать разницу между "внутренним" и "внешним".

Выше были изложены доводы, говорящие о том, что у вас нет причин не следовать NVI. В продолжении самое время рассказать, почему следовать NVI полезно. Причина полезности - в двойственности задач типа - обеспечить надёжный контракт для наследников и удобный - для пользователей. В какой-то начальный момент времени в чём-то эти цели могут совпадать и вы можете выбрать public virtual метод при дизайне типа для реализации задачи. Часто, это бывает вызвано просто нежеланием писать лишний код для обеспечения NVI. Но, как не редко повторял на лекциях мой декан, "там где тонко - рвётся", то есть оттуда, где мы на что-то ненадёжное понадеялись, и стоит ждать неприятностей. А неприятности тут могут быть самыми разнообразными: ведь наш противник - время, и чем больше его пройдёт, тем большие изменения может потребоваться внести в логику работы типа. И здесь нас ждут два сценария: в Виллабаджо снова проблемы - при изменениях во внутренностях класса нужно учесть, что часть методов, необходимых для обеспечения правильной внутренней работы доступны снаружи и необходимость изменить сигнатуру или вовсе удалить метод заставляет призадуматься о том, как бы обойтись малой кровью. А в Вилларибо, где используют NVI, нужно провести заметно меньше работы - все методы разделены на "внутренние" и "внешние".

Учитывая вышесказанное, может быть понятно, почему так грустно видеть пренебрежение данным подходом в используемых библиотеках: если это какая-то сторонняя библиотека, то при необходимости внесения некоторого рода изменений (авторами) мы, скорее всего, получим какие-нибудь костыли - ведь изменять публичные методы в новой версии библиотеки мало кто решится. Если это код, написанный соседней командой, то в той же ситуации скорее всего от нас потребуется переписать старые вызовы на новые, в чём то же мало приятного. Безусловно, далеко не факт, что то, что вы в данный момент пишите, ждёт именно такой сценарий развития, именно те изменения в будущем, которые я описал и для которых рекомендую повсеместно использовать NVI. Но, учитывая как просто на самом деле придерживаться этого принципа - действительно ли стоит рисковать?