Все разработчики знают, что «Code reuse» — это Билет в Программистский Рай, Где Ничего Не Надо Писать Заново, и вообще ничего не надо писать, потому что всё уже есть. Любую программу в этом раю можно создать, совместив несколько готовых блоков.
Все успешные менеджеры знают, как выгоден «Code reuse». Нахального программиста можно выгнать в три шеи или извести мизерным окладом, а написанный им код останется работать, поддерживаемый его более скромными коллегами. Налицо чёткая корпоративная этика и экономия средств.
Повторное использование кода (1) не обязывает к ООП и (2) не всегда ему сопутствует.
- ООП — не единственный способ писать программы, и не единственный способ писать их хорошо. Изобретатель веб-приложений и байесовых спам-фильтров Пол Грэм утверждает, что повторное использование кода стимулируется программированием «снизу вверх», а «объектная ориентированность» не играет большой роли. Сам он никогда не прибегал к ООП. Хотя, это смотря что считать за ООП: при взгляде с нужной стороны и Lisp покажется более «объектно-ориентированным», чем Java.
- Множество алгоритмов, реализованных в объектно-ориентированных программах, из года в год переписывается заново. Тому есть множество разнообразных причин. Выходит, что жить по идеологии «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. |