Войти в систему

Home
    - Создать дневник
    - Написать в дневник
       - Подробный режим

LJ.Rossia.org
    - Новости сайта
    - Общие настройки
    - Sitemap
    - Оплата
    - ljr-fif

Редактировать...
    - Настройки
    - Список друзей
    - Дневник
    - Картинки
    - Пароль
    - Вид дневника

Сообщества

Настроить S2

Помощь
    - Забыли пароль?
    - FAQ
    - Тех. поддержка



Пишет ringill ([info]ringill)
@ 2007-08-17 01:08:00


Previous Entry  Add to memories!  Tell a Friend!  Next Entry
Code Reuse (повторное использование кода в ООП)

Коллекция ссылок по разным языкам программирования оформилась в небольшую статью. Многие языки здесь не упомянуты, либо потому, что не имеют отношения к ООП (как Prolog), либо потому, что автор их не знает (Perl), либо по обеим причинам сразу (Haskell). За множество ценных замечаний по черновику огромное спасибо Олегу. Комментарии приветствуются.
Во второй редакции учтены замечания про doesNotUnderstand и CLOS.

Все разработчики знают, что «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.


(Читать комментарии) - (Добавить комментарий)


[info]ilya666@lj
2007-08-17 04:43 (ссылка)
Механизмы подобные doesNotUnderstand: есть во многих языках, сходу можно назвать: Perl, Io, Python, Ruby.

(Ответить) (Ветвь дискуссии)


[info]ringill@lj
2007-08-17 18:05 (ссылка)
А я прощёлкал. Спасибо.

(Ответить) (Уровень выше) (Ветвь дискуссии)


(Анонимно)
2010-07-25 14:23 (ссылка)
test

(Ответить) (Уровень выше)


(Читать комментарии) -