пятница, 17 августа 2007 г.

Code Reuse (повторное использование кода в ООП)

Все разработчики знают, что «Code reuse» — это Билет в Программистский Рай, Где Ничего Не Надо Писать Заново, и вообще ничего не надо писать, потому что всё уже есть. Любую программу в этом раю можно создать, совместив несколько готовых блоков.

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

Повторное использование кода (1) не обязывает к ООП и (2) не всегда ему сопутствует.

  1. ООП — не единственный способ писать программы, и не единственный способ писать их хорошо. Изобретатель веб-приложений и байесовых спам-фильтров Пол Грэм утверждает, что повторное использование кода стимулируется программированием «снизу вверх», а «объектная ориентированность» не играет большой роли. Сам он никогда не прибегал к ООП. Хотя, это смотря что считать за ООП: при взгляде с нужной стороны и Lisp покажется более «объектно-ориентированным», чем Java.
  2. Множество алгоритмов, реализованных в объектно-ориентированных программах, из года в год переписывается заново. Тому есть множество разнообразных причин. Выходит, что жить по идеологии «Code Reuse» сложно; легче оступиться и потерять вожделенный рай. Создатель XEmacs Ричард Гэбриэл считал трудность повторного использования кода одним из признаков провала парадигмы ООП.

Но сейчас речь идёт об ООП, а именно — о средствах компоновки и расширения классов (и не только) для создания новых классов.

Композиция — это объединение объектов, которые общаются между собой с помощью только внешних интерфейсов (black box reuse). Друг для друга объекты являются «чёрными ящиками».

Гибкость композиции объектов обеспечивает делегирование — передача ответственности за выполнение метода связанному объекту. Есть две разновидности делегирования:

  • Методы-заглушки, явно передающие вызов связанному объекту. Они делаются более или менее одинаково во всех языках программирования. При необходимости создать «пачку» заглушек, делегирующих все методы, поддерживаемые связанным объектом, в статических языках не обойтись без автоматической генерации кода. В Smalltalk такую делегацию можно осуществить, перегрузив в объекте стандартную заглушку для несуществующих методов под названием doesNotUnderstand:. Аналогичные механизмы имеются в Python и Ruby. В COM тоже имеется механизм агрегации (aggregation) для автоматического делегирования группы методов.
  • Методы-делегаты, передаваемые из связанного объекта в главный. Связанный объект при этом, как правило, не принадлежит главному, а существует сам по себе (с заглушками ситуация обратная). Можно даже сказать, что главный объект является вспомогательным для связанного. К примеру, в библиотеках GUI объекты с помощью делегатов «подписываются» на события оконной среды. В Smalltalk, Ruby и Scala стандартные контейнеры самостоятельно осуществляют процедуры типа перебора элементов; объект, которому нужно перебрать элементы контейнера, передаёт ему делегат.

    Разумеется, при передаче делегата должна сохраняться привязка к свободным переменным в первоначальном контексте метода (в т.ч. переменных-членов его класса), иначе это не делегат, а просто указатель на функцию. По сути, процедура, сохраняющая привязку к свободным переменным в своём лексическом контексте, является замыканием (closure). Подразумевается, что процедура не обязана иметь имени. Замыкания в той или иной форме поддерживаются в почти во всех ОО-языках: в C# с версии 2.0 есть специальная конструкция под названием «анонимный метод» (anonymous method), в языке D тоже есть делегаты, в Java делегатов нет, но есть возможность получить аналогичный эффект с помощью механизма Reflection, или с помощью внутреннего класса (inner class). Последний вариант работает даже в C++, но там класс не может быть анонимным, то есть любой сколь угодно мелкий делегат должен иметь имя.

    Наиболее элегантная запись замыканий имеет место в языках с динамической типизацией — Smalltalk (в нём любой блок кода в [] — замыкание), Lisp (лямбда-выражения), Javascript, Lua, Ruby. Хотя, в статически типизированном языкеScala тоже удобная запись, и в грядущем C# 3.0 будет похожая. Урезанные лямбда-выражения есть в Python, но в будущем они исчезнут; останутся именованные вложенные функции.

Композиция с делегированием обладает большим потенциалом гибкости, чем все техники, изложенные ниже. Однако, (1) как было сказано, в популярных языках делегирование может усложнить код и (2) в любых языках чрезмерная гибкость затрудняет понимание структуры программы; в целях эффективности совместной работы имеет смысл использовать более ограниченные средства.

Наследование (inheritance, white box reuse) — это формирование подкласса, который имеет доступ ко всем данным и методам родительского класса и может перегружать методы. О «чёрном ящике», как при композиции, речи нет.

Наследование часто считают одним из основных принципов ООП (инкапсуляция-полиморфизм-наследование). Это так, но лишь в рамках языка Simula, созданного Кристеном Нигардом в 1960-х годах, идеи которого использовались позже в C++, Java и C#. Невозможно представить эти языки без наследования, но оно в них столь востребовано не от хорошей жизни: из-за синтаксических ограничений этих языков наследование с перегрузкой виртуальных методов широко используется для передачи вызовов, а в C++ и делегатов без наследования не сделать. Иными словами, наследование используется как вспомогательное средство для делегирования.

На более богатой модели Алана Кея (объекты-сообщения), реализованной в Smalltalk в 1970-х годах, очевидно, что наследование — это удобный механизм для повторного использования кода, и ничего более. Инкапсуляция и полиморфизм в этой модели ООП предполагаются абсолютными, а наследование (и даже классы) не обязательны вовсе. Есть группа т.н. прототипных (prototype-based) ОО-языков, в которых нет классов. К этой группе относятся Self, Io, Lua и JavaScript (до выхода ECMAScript 4/JavaScript 2.0).

Наследование, к тому же, (a) нарушает принцип инкапсуляции, т.к. родительский класс открывает свои данные подклассу, и (b) действует без гибкости, «раз и навсегда»: после создания объекта наследованного класса его базовый класс уже не изменить. В известной книге «Банды четырёх» даётся совет предпочитать композицию наследованию. Создатель Java Джеймс Гослинг также признался, что считает наследование не лучшей техникой. (Впрочем, второй из указанных пороков присущ также и всем приёмам, изложенным далее.)

При одиночном наследовании (single inheritance) у класса может быть только один «родитель». Стандартная библиотека классов и вся среда разработки Smalltalk были написаны на Smalltalk с применением только одиночного наследования. Из этого можно сделать вывод, что одиночного наследования (вкупе с делегатами-замыканиями) в принципе достаточно для создания программных комплексов любого размера; ограниченные возможности по повторному использованию кода искупаются простотой объектной модели.

Множественное наследование (multiple inheritance) является плодом естественного стремления «повторно использовать» не один класс, а сразу несколько. Этот вид наследования реализуют языки C++, CLOS (объектная надстройка над Common Lisp) и Python.

На фоне очевидных преимуществ проступает ряд недостатков:

  • В перегруженном методе нельзя просто вызвать-метод-суперкласса, потому что суперкласс неоднозначен. В C++ требуется явно прописывать суперкласс, к которому производится обращение, что делает код метода зависимым от названия суперкласса, и, следовательно, от общей иерархии классов. В Python и CLOS можно, не зная имени, обратиться к «ближайшему по порядку» суперклассу. Порядок определяется автоматически по неким правилам. Эти правила опять же делают код уязвимым к иерархии и к порядку перечисления суперклассов при объявлении, что иногда приводит к неожиданным эффектам.
  • «Проблема ромба» (diamond problem) возникает, когда у класса D два базовых класса B и C в свою очередь наследуются от одного базового для них класса A. C++ — единственный язык, который в такой ситуации позволит существовать двум объектам класса A. При вызове из D метода, описанного в A, возникнет конфликт методов: непонятно, какому из объектов класса A передать вызов. Конфликт разрешается, как и в предыдущем пункте, явным указанием родительского класса (B или C). В остальных языках (и в C++ при т.н. виртуальном наследовании) объект класса A будет существовать в одном экземпляре. В этом случае возникает конфликт состояний — вещь более серьёзная, чем конфликт методов. Объекты классов B и C «не знают» о том, что объект класса A у них общий; для объекта класса B всё выглядит так, будто данные его суперкласса самопроизвольно меняются. Стандартного решения у этой проблемы нет.

Параметризованные классы — это классы, определение которых содержит неспецифицированные классы. Последние задаются в качестве параметров при непосредственном использовании параметризованного класса. Этот подход называется «обобщённым программированием» (generic programming), и успешно применяется в тандеме с ООП, а именно в C++, D, и Scala. В C# с версии 2.0 и и в Java с версии 1.5 есть т.н. группы (generics), внешне похожие на параметризованные классы. Группы предназначены не для создания новых классов, и вообще не для «code reuse», а для усиления контроля типов. Стандартные коллекции в C# и Java хранят объекты базового класса object. Группы позволяют программисту «убедить» компилятор в том, что в конкретной коллекции хранятся объекты более конкретного класса, чем object.

В динамически типизированных ОО-языках обобщённое программирование теряет смысл: все классы в них и так параметризованы настолько, что никогда не требуют конкретизировать классы используемых объектов на стадии компиляции. Иными словами, параметризованные классы — это издержки контроля типов, целесобразность которого является предметом многолетней «holy war».

Шаблоны C++ и D заслуживают отдельного рассмотрения тем, что дают компилятору возможность сразу выдавать эффективный машинный код. Преждевременная оптимизация (корень всех зол) в C++ дошла до того, что шаблоны являются полным по Тьюрингу «языком в языке», на котором можно написать любую программу, например процедуру вычисления чисел Фибоначчи или игру «Жизнь». В языке D шаблоны также являются отдельным языком, но совпадают с D по синтаксису.

Скомпилированный программный код не содержит шаблонов. Обычные конструкции C++ вроде if() в языке шаблонов недоступны, но в них конечно можно сделать свой if(). При этом шаблоны не могут польностью заместить основной язык, и дело здесь даже не в наличии входных данных, неизвестных компилятору. Тьюринг доказал, что не существует алгоритма, предсказывающего, закончится ли произвольная программа или будет работать вечно. Из этого следует, что возможности компилятора по обнаружению ошибок в коде ограничены. Программы надо запускать, а не только компилировать из шаблонов.

Безотносительно проблем статически типизированных языков, композиция и наследование имеют один принципиальный изъян, состоящий в использовании класса как единицы «code reuse», тогда как класс является в первую очередь генератором объектов. Объекты должны быть большими, а единицы повторно используемого кода — маленькими; чтобы разрешить это противоречие, имеет смысл завести отдельные сущности, предназначенные сугубо для повторного использования.

Примесь (mixin) — как раз такая сущность. Примесь представляет собой набор данных и методов, который можно «подмешать» к какому-либо классу. Сама по себе примесь не может служить генератором объектов; в методах примеси могут использоваться данные и другие методы, отсутствующие в ней (соответственно, они должны присутствовать в классе, к которому добавляется примесь). Использование примесей, как и наследование, нарушает инкапсуляцию.

По аналогии с «абстрактным суперклассом», от которого необходимо наследоваться, примесь можно считать абстрактным подклассом, который необходимо отнаследовать от какого-либо суперкласса. Такой подход позволяет удобно совместить новую концепцию с классическим наследованием. Примеси реализованы в Ruby и Scala. В Python и C++ примеси доступны как частный случай множественного наследования. В CLOS примеси есть, но их отличие от классов чисто условное и состоит в вызове call-next-method при отсутствии суперклассов.

Разумеется, в класс можно добавлять и более одной примеси. Что касается «проблемы ромба» при множественном «подмешивании»: в Ruby, если «подмешиваемые» модули включают другие модули, ромбовидная диаграмма примесей может возникнуть, с последующим конфликтом состояния. В Python и C++ здесь та же ситуация, что и с множественным наследованием, а в Scala ромбовидные иерархии примесей попросту запрещены.

В CLOS в каждом классе древовидная иерархия его суперклассов (и примесей) принудительно раскладывается в линию. Это устраняет «проблему ромба», но создаёт другую, не менее серьёзную: когда в класс добавляется несколько примесей, они идут не все сразу, а по очереди. Каждая следующая примесь наследуется от совокупности класса и предыдущих примесей. Это означает, что:

  • Метод, определённый в примеси A, может быть «тихонько» перегружен примесью B. А при другом порядке добавления примесей может быть и наоборот — метод из B перегружен в A. В условиях «повторного использования» примесей во многих классах одновременно легко столкнуться с ситуацией, когда любой порядок «примешивания» вызывает ошибки.
  • Из подкласса нельзя получить доступ к методам его суперкласса, которые были перегружены в примесях. Это практически уничтожает возможность аккуратного разрешения конфликтов имён «в последней инстанции», т.е. в подклассе.

Штрих (trait) — это абстрактный подкласс без переменных, то есть примесь, не имеющая состояния, или, по словам авторов, «единица поведения». Штрих имеет доступ к методам класса, но не к его данным. Как и примеси, штрихи могут наследоваться.

Запрет на доступ к данным класса восстанавливает инкапсуляцию, а запрет на состояние убирает разом все проблемы с конфликтами имён переменных и с конфликтами состояний при ромбовидном наследовании. Конфликты имён методов в штрихах обязаны разрешаться явно с помощью (a) переименования методов в наследованном классе или штрихе и (b) явного переопределения конфликтующего метода в наследованном классе или штрихе. Порядок наследования штриховне имеет значения.

Штрихи были впервые реализованы в диалекте Smalltalk под названием Squeak. Они присутствуют в Scala, а в C++ и Python их можно, как и примеси, реализовать через множественное наследование. Вероятно, штрихи появятся как особый элемент языка и в Python 3000.

В завершение — небольшая сводная таблица.

Средство Время появления Ограничения Преимущества Неудобства Языки
Композиция 1960-е годы (Simula) Соблюдается инкапсуляция. Наибольшая гибкость в процессе выполнения. Много «промежуточного» кода. Все.
Одиночное наследование 1960-е годы (Simula) Одиночное. Простая объектная модель. Все, кроме прототипных.
Множественное наследование 1980-е годы (C++) Наибольшие возможности при проектировании. Неоднозначность суперкласса; проблема ромба. CLOS, C++, Python.
Параметризованные классы 1980-е годы (Ada) Эффективная компиляция. Синтаксические излишества. C++, D, Scala.
Примеси 1990 В Scala запрещена ромбовидная иерархия. Проблема ромба (кроме Scala и CLOS). Очерёдность наследования в CLOS. CLOS, C++, Python, Ruby, Scala.
Штрихи 2002 Нет состояния; соблюдается инкапсуляция. Не нужно следить за порядком наследования.
Squeak, C++, Python, Scala.