Развитие идей ООП с попутным отбрасыванием лишнегоМожет ли быть технология программирования лучше чем объектно-ориентированная,
Содержание
Объектно-ориентированная технология разработки программ стала стандартом де-факто, используемым подавляющим большинством разработчиков. За время своего существования она получила мощную методологическую поддержку и поддержку со стороны разработчиков инструментальных средств. По сути, про ООП сейчас можно говорить не только как о концепции и методологии, но и как о своего рода религии, имеющей, как это и положено, своих пророков, миссионеров, адептов и еретиков. Тем не менее, стоит сказать, что всякая технология имеет границы своей применимости. Об этом необходимо помнить и специалистам по металлообработке, и медикам, и программистам. Обычно декларируются следующие преимущества использования технологии объектно-ориентированного программирования:
По всем трём пунктам ООП явилось огромным шагом вперёд по сравнению с применявшимся ранее процедурным подходом. ООП нашло широкое и весьма эффективное применение в системном программировании, программировании пользовательских интерфейсов прикладных программ. Но интересно не это, а то, что ООП не нашло достойного применения в некоторых прикладных областях. Например, несмотря на все усилия (в том числе и маркетинговые), ООП полноценно не применяется при проектировании бизнес-логики автоматизированных систем управления предприятиями. И это при том, что на недостаток квалификации проектировщиков и программистов это положение дел вряд ли удастся списать, так как рынок систем автоматизации является высококонкурентным, туда привлечены весьма серьёзные материальные и интеллектуальные ресурсы. Несмотря на всё богатство методологии, многие задачи просто не могут быть адекватно и удобно описаны в рамках объектно-ориентированного подхода. Может быть, многие проблемы современной индустрии софтостроения кроются в теоретических просчётах, спрятанных глубоко в фундаменте объектно-ориентированной технологии? Начнём, как водится, с определений (путаться в понятиях – последнее дело), потом разложим по косточкам очень скользкое и неоднозначное понятие класса. Кстати, именно в понятии класса я и нашёл очень неприятную идеологическую недоработку ООП. Дальнейшее повествование сводится к латанию этой дыры и вольным фантазиям на тему того, как хотелось бы жить дальше. Основные понятияВ этом разделе я ничего нового не скажу. Просто приведу список понятий, которыми буду в дальнейшем оперировать. Объект, экземпляр – информационная сущность, которой можно оперировать как единым целым. Заметьте, что под понятием «Объект» здесь подразумевается не тип данных, а именно экземпляр (instance). Например, дата – это не объект (а класс, но понятием класса сейчас голову не забиваем, а подойдём к нему позже), а переменная, содержащая значение 01.04.2003 – это объект; целое число – не объект, а переменная, содержащая целое число 8934 – объект. Интерфейс объекта – способ информационного воздействия на него. Например, «Установить реквизит», «Получить реквизит», «Инициализировать», «Распечатать», «Отформатировать», «Обработать текстовый запрос». Если что-то хочется сделать с объектом, нужно обращаться к его интерфейсу (вот она, инкапсуляция). Иного способа взаимодействия кроме как через интерфейс, нет. Интерфейсом, например, может быть процедура, функция, свойство, public переменная. В интерфейсном программировании под понятием «интерфейс» подразумевается нечто другое, по своей семантике больше похожее на класс. В этом тексте интерфейс – это именно процедура, функция, свойство и т.д. Реализация интерфейса – алгоритм, который будут выполнен при вызове интерфейса. Классификация объектовРабота программы – это выполнение кода, инкапсулированного в объекты и взаимодействие объектов между собой. Взаимодействие объектов – это вызов интерфейсов. Для того, чтобы вызывающий объект мог взаимодействовать с вызываемым, вызывающий должен обладать некоторым набором сведений о вызываемом. Это может быть изначальное знание перечня необходимых интерфейсов либо некий протокол запроса сведений. Изначальное знание всех необходимых интерфейсов – это знание типа объекта или, как принято выражаться, класса. Например, объект А знает, что объект Б имеет тип Поставщик и, соответственно, весь тот набор интерфейсов, которые обычно присущи поставщикам. Даже если в системе программирования вовсе нет механизма типизации (скажу честно, таких прецедентов я не знаю), типизация всё равно неявно присутствует. В принципе, конечно, каждый объект может иметь собственный, свойственный только ему набор интерфейсов и/или их реализаций (т.е. классов в программе столько же, сколько и объектов), но это экзотика. Итак, принадлежность объекта к классу означает, что объект имеет набор интерфейсов, специфичных для данного класса и выполняющих именно те функции, которые для него подразумеваются. Может ли объект принадлежать одновременно к нескольким классам? Однозначный ответ – да. Более того, подавляющее большинство объектов принадлежат нескольким классам. В классическом ООП вопрос множественной классификации решается на уровне описаний классов (наследование либо агрегация). Наследование – это заложенный в методологию ООП способ объявить тот факт, что всякий объект класса X также является и объектом класса Y. То есть если у нас есть класс "собака", то в описании классов записано, что каждый объект этого класса также принадлежит классу "млекопитающее", каждое млекопитающее также принадлежит классу "животное", каждое животное также принадлежит некоему базовому классу "объект". В результате в нашей программе каждая собака автоматически является ещё и млекопитающим, животным и объектом. Использование наследования слишком жёстко и однозначно определяет классификацию объектов. В частности, имея набор классов "объект" – "животное" – "млекопитающее" – "собака" пространство нашего манёвра сужено тем фактом, что собака обязана быть живым существом. Появление среди объектов собаки Айбо, не являющейся живым существом, создаст определённые проблемы. Агрегация – это включение объекта (объектов) одного класса в состав объекта другого класса. Данный подход широко применяется в интерфейсном подходе к проектированию программ, ярчайшим примером которого является COM-технология. В частности, COM-объект может предоставлять наборы интерфейсов тех объектов (делегирование), которые он, грубо говоря, он в себе содержит. Подобный подход также может быть реализован и без использования COM.
Применение агрегации с делегированием интерфейсов также не является лекарством от всех болезней. В частности, там, где применение COM нецелесообразно либо невозможно, полноценная реализация делегирования "вручную" может стать весьма дорогим удовольствием. Кроме того, есть ряд принципиальных моментов:
Mixin-технология – весьма остроумный способ создания составных классов, при котором класс собирается из составных частей, используя множественное наследование, и при этом сохраняется возможность использования свойств и методов одной составной части другой составной частью.
Особая прелесть заключается в том, что при объявлении составного класса мы можем скомпоновать его именно из тех частей, которые нам необходимы. Например, вместо Class1<Class3> мы можем взять Class1_xp<Class3> (важно только, чтобы Class1_xp<> был потомком IClass1). Эта технология свободна ото всех недостатков, перечисленных мной при рассмотрении методики "Агрегация", но остаётся ещё ряд нерешённых проблем:
А предлагается на секундочку вынырнуть из зияющих глубин существующих технологий и посмотреть на философскую сторону вопроса. Для начала разберёмся с тем, что подразумевается под множественной классификацией объектов. А подразумевается исключительно то, что система должна обеспечивать возможность иметь каждому экземпляру именно тот набор интерфейсов, который он иметь обязан. Например, я являюсь человеком. Для работодателя у меня есть набор интерфейсов "Сотрудник", для жены – "Муж", для детей – "Отец", для родителей – "Сын", для продавца в магазине – "Покупатель", для водителя автобуса – "Пассажир", для программы Notepad, в которой пишется этот текст – "User". По крайней мере, половины из вышеперечисленных наборов интерфейсов волею судьбы у меня могло бы и не быть. Заметьте, нельзя сказать, что во мне содержатся перечисленные объекты. Я ими всеми являюсь одновременно. Все мои ипостаси мало того что сосуществуют, но ещё и взаимно переплетаются, давая качественно новый эффект. Например, придя в магазин, я говорю: "Здравствуйте, у вас есть памперсы 4-го размера?". Слово "здравствуйте" – это продукт ипостаси "вежливый человек". Интересуюсь памперсами потому что я отец. Смысл фразы – это предложение начать транзакцию между магазином и мной, розничным покупателем. Итак, информационные сущности (экземпляры) имеют интерфейсы (методы и свойства). Многие интерфейсы можно сгруппировать. Далее будем говорить о наборах интерфейсов. Частный случай набора интерфейсов – "одинокий" интерфейс. О наборах интерфейсов можно сказать следующее:
Пункт 2 будет использован как обоснование необходимости множественной классификации на уровне экземпляров, пункты 4 и 5 – для обоснования мутации объектов, которая будет рассмотрена ниже. А теперь давайте (наконец то) введём определение: И ещё одно: В классическом ООП эти понятия трактуются немножко по-другому. Двумя словами это можно выразить так: тип – вся совокупность интерфейсов и их реализаций, присущая объекту; класс – тип, не являющийся простым. Пожалуйста, почувствуйте разницу, иначе дальнейшее изложение будет для Вас бессмысленной тарабарщиной. Можно возразить, что всю совокупность интерфейсов объекта тоже можно назвать одним словом, и тогда класс также становится типом (так, как это показано на рисунке справа). Для того, чтобы разночтений не возникало, введём определение элементарного класса: Элементарный класс – именованный набор интерфейсов, не подлежащий дальнейшей декомпозиции. Каждый отдельно взятый интерфейс может принадлежать только одному элементарному классу. Элементарный класс – это тот платоновский эйдос, который может быть присущ объекту, действию или явлению, и глубже которого двигаться либо невозможно, либо не нужно. Теоретически возможно два подхода к типизации объектов:
Пока программа оперирует достаточно простыми, однозначными, и, как правило, искусственными сущностями (Button, ComboBox, Stack, Connection, 3DLine и т.д.), первый способ типизации оказывается вполне достаточен. Но при столкновении с чудовищным многообразием реального мира отсутствие персонализированной "тонкой настройки" набора интерфейсов объекта может иметь неприятные последствия. А именно:
Итак, из всех этих рассуждений следует, что при разработке многих систем для типизации объектов недостаточно поддержки множественной классификации на уровне описаний классов. Должна присутствовать возможность задания перечня классов непосредственно для каждого объекта.
Смысл технологий программирования уже давно смещается с построения эффективных алгоритмов в сторону построения эффективных моделей предметных областей. Если технологическое нововведение разумно с точки зрения более точного отражения внутренней логики предметных областей, то это нововведение уже оправдано. Давайте попробуем отказаться от основополагающей роли концепции наследования. И увидим, как это хорошо. Синергизм классовПринадлежность объекта двум классам может качественно его преображать. Рассмотрим, что может произойти, если объект А принадлежит классам X и Y одновременно.
Сочетание классов X и Y назовём виртуальным классом XY, появляющимся в системе как следствие появления классов X и Y. Вооружившись комбинаторикой, несложно подсчитать, что с ростом количества элементарных классов количество виртуальных растёт лавинообразно. Например, для трёх элементарных классов количество виртуальных равно 4, для четырёх – 10, для десяти – 1013. Но это не должно нас пугать. В классическом ООП полиморфизм объектов основывается на механизме наследования. Отказавшись от наследования, мы можем всю тяжесть полиморфизма переложить на синергизм классов. Поясню на примере. Допустим, проектируется система, в которой некоторые прикладные объекты сохраняются в базу данных. Например, клиенты записываются в таблицу Clients, заказы – в таблицу Orders. Обычно создают родительский класс clPersistent, имеющий методы Read() и Save(), и классы clClient и clOrder порождают (возможно, косвенно) от Persistent. Мы тоже создадим класс clPersistent, имеющий методы Read() и Save() и классы clClient и clOrder, таких методов не имеющие. Запись реквизитов контрагента в базу данных будет производиться в процедуре Save() виртуального класса (clPersistent, clClient), реквизитов заказа – в процедуре, реализованной для (clPersistent, clOrder). Виртуальный класс – это тоже класс. Он может иметь конструктор и деструктор (весьма полезные идиомы ООП). Оставим вопрос открытым, может ли виртуальный класс иметь собственные интерфейсы либо он должен для этого делать дополнительную классификацию объекта (проще говоря, в список элементарных классов добавлять что-то ещё). Абстрактные классы
Есть у абстрактных классов одно спорное, но весьма полезное свойство – крайняя неприязнь к вызову абстрактных методов, в результате чего в релизах программ, как правило, все абстрактные методы получают свою реализацию в классах-потомках. При реализации полиморфизма через виртуальные классы программа, написанная забывчивым разработчиком, не будет аварийно завершаться с сообщением об ошибке. Она просто не будет выполнять требуемое действие. Как к этому относиться – не знаю. Шаблоны (templates)При использовании технологии множественной классификации шаблоны не нужны. В частности, вместо CFoo<CBar> FooBar;можно написать что-то вроде (CFoo, CBar) FooBar; Не знаю как кто, но я бы об избавлении от шаблонов не жалел ни секунды. Реализации интерфейсов при множественной классификацииИнтерфейс объекта может быть процедурой, функцией либо атрибутом. Особенно интересны процедуры и функции, так как только они включают в себя программный код и могут быть полиморфными. Итак, Процедура – интерфейс, получающий от вызывающей стороны некий набор параметров и выполняющий заложенные в реализацию действия. Функция – интерфейс, получающий от вызывающей стороны некий набор параметров, выполняющий заложенные в реализацию действия и возвращающий вызывающей стороне одно и, как любят говорить математики, только одно значение. Рассмотрим абстрактный пример. В системе есть классы X и Y. Класс X имеет метод M, реализованный в контексте класса X (Mx) и в контексте виртуального класса XY (Mxy). Если вызвать метод M объекта A, одновременно принадлежащего классам X и Y, какая из реализаций (Mx или Mxy) должна отработать? "Mxy" – неправильный ответ. В системе ведь может оказаться ещё один класс Z и ещё одна реализация метода M (Mxz), и выбор между Mxy и Mxz объекта B, принадлежащего одновременно классам X, Y и Z, может стать совсем невыносимым. Выход из этой ситуации только один – всегда вызывать все подходящие реализации методов. В каком порядке вызывать реализации методов? Можно предложить следующие варианты:
Вместо того, чтобы мучаться выбором того, какой вариант является более универсальным, предлагается предоставить право выбора разработчику того элементарного класса, в котором метод объявлен. И предоставить возможность управлять последовательностью вызова в процессе исполнения, для чего выдумать несколько операторов: call_next_method – вызывает следующую реализацию из заранее сформированной цепочки и затем возвращается в это же место (как в CLOS). stop_method – останавливает выполнение цепочки и передаёт управление вызывающей программе. Для функций я бы ещё открыл доступ к возвращаемому значению на чтение. И пусть функция сама решает, как быть – вписать свою версию, оставить без изменений ранее вычисленную, вернуть сумму или... есть масса вариантов. Естественно, варианты реализации функции нельзя пускать в параллельные потоки. Статические и динамические методыРассмотренные ранее "умные" методы всем хороши. И полиморфны, и расширяемы. Но есть у них два недостатка:
Поэтому в любом случае должна присутствовать возможность делать методы статическими. Если разработчик твёрдо уверен, что для метода Mx никогда не придётся делать расширение Mxy или Mxz, почему бы не вызывать такой метод простой инструкцией CALL? Мутация объектовМутация – это динамическое изменение типа экземпляра в ходе выполнении программы. Некоторые теоретики утверждают, что от этой идеи нужно отказаться раз и навсегда, т.к. она потенциально очень опасна. Другие говорят, что мутация объектов имеет место в реальном мире, и поэтому она логична и в мире виртуальном. Где-то выше по тексту мы признали главенство эффективного построения моделей предметных областей, и поэтому постановим, что возможность мутации должна быть. А от опасностей попробуем защититься. Давайте разберёмся, чем опасна мутация. Единственное, что приходит в голову, так это то, в результате какой-то операции тип переменной может измениться так, что дальнейший код работать не будет: CFoo *Foo = new CFoo(); Foo->Name = "My Foo"; DoSomethingStrange(&Foo); // переменную Foo мутировали в CBar* cout << Foo->Name; // Не работает! Класс CBar не имеет свойства Name. Компилятор твёрдо уверен, что переменная Foo имеет тип CFoo*, знает по какому смещению лежит свойство Name, и в результате либо считает какой-то мусор вместо Name, либо сразу вылетит с GPF. В тех языках программирования, в которых все переменные принадлежат типу Variant (как правило, такое бывает в интерпретируемых языках), иногда можно встретить тексты вроде такого: MyVar = 1; Message(MyVar+1); // "2" MyVar = "Text string"; Message(MyVar+1); // "Text string1" Такой стиль программирования безусловно ужасен. Подобные упражнения мы не будем рассматривать в качестве той полноценной мутации, которой хотим достигнуть. В тех языках программирования, в которых класс объекта не является составным (т.е. во всех существующих на настоящий момент), любая мутация представляет собой кардинальную смену типа объекта. Если переменная Foo меняет свой класс с CFoo на CBar, то в результате выполняется две операции:
Концепция множественной классификации допускает более мягкий вариант мутации объектов. Можно так изменять тип объекта, чтобы (как это сказать по-русски?) объявленный тип переменной не терялся. Заранее извиняюсь за объёмный код на неизвестном науке языке программирования. enum eGender { Male, Female } class cMale { autoclassify as cHuman; // Проинформируем компилятор о том, что cMale = (cHuman, cMale) public var Wife as cFemale; } class cFemale { autoclassify as cHuman; public var Husband as cMale; } class cHuman ( private var Geneder as eGender; public var Name as String; function GetGender() as eGender { Result = Gender; } procedure SetGender(AGender as eGender) { if (AGender = Female) { declassify this as cMale; classify this as cFemale; } else if (AGender = Male) { declassify this as cFemale; classify this as cMale; } else { declassify this as cMale; declassify this as cFemale; } } ) // cHuman // Процедура, внутри которой проблем не возникнет procedure QueryAndSetGender(Somebody as cHuman) { var Choice as eGender; Choice = App.AskUserChoice("Somebody is", Somebody.GetGender(), eGender::Male, "male", eGender::Female, "female"); // Такая гипотетическая функция if (Choice <> null) Somebody.SetGender(Choice); // Ничего не нарушилось. Somebody по-прежнему принадлежит классу cHuman // declassify Somebody as cHuman; // <<<< А вот это не пропустит компилятор } // QueryAndSetGender // Процедура, в которой мы обманем компилятор procedure Male2Female(AMale as cMale) { if (App.MessageBox("Are you sure?", MB_YES + MB_NO + MB_DEFBUTTON2) = IDYES) AMale.SetGender(eGender::Female); // Проблема: как такое запретить компилятором? if (AMale.Wife <> null) // Ой, началось... App.MessageBox(AMale.Name + " is married to " + AMale.Wife.Name, MB_OK); else App.MessageBox(AMale.Name + " isn`t married", MB_OK); } // Male2Female Компилятор можно обмануть и каким-нибудь более изощрённым методом. Как всегда, проблема обмана компилятора за счёт запутывания логики может быть решена несколькими способами:
Строгая типизацияСпустимся с вершин теоретических умопостроений и вспомним, что алгоритмы пишутся на языках программирования в виде обычных текстов, содержащих имена переменных, константы, операторы и т.п. Есть программисты, которые любят строгую типизацию, есть программисты которые не любят строгую типизацию. Это почти религиозный вопрос. Можно долго рассуждать о преимуществах того и другого, скажу только, что при множественной классификации без неё не обойтись. И вот почему. Предположим, у нас есть переменная objWindow. Есть класс clDialog (диалоговое окно, которое можно открыть функцией Open) и класс clFoldingWindow (створчатое окно, элемент справочника товаров, который можно прочитать из БД функцией Open) В программе встречается следующая строка: objWindow.Open(); Если нет строгой типизации на этапе синтаксического разбора текста программы, то может возникнуть такая ситуация, что objWindow в момент выполнения этого оператора будет одновременно принадлежать к классу clDialog и к классу clFoldingWindow (очень удобно, между прочим). Что мы при этом должны сделать? Открыть диалоговое окно или прочитать информацию из БД? В системах программирования с поздним связыванием хотя бы на этапе выполнения можно это выяснить. Получается, что мы вынуждены использовать строгую типизацию на этапе синтаксического разбора. И если уж у нас получилось так, что переменная objWindow объявлена как: Var objWindow as (clDialog, clFoldingWindow); то будь добр написать конструкцию вроде clDialog::(objWindow.Open()); Если же переменная objWindow объявлена как: Var objWindow as clDialog; то вызов команды открытия окна достаточно написать и так: objWindow.Open(); и не важно, что реально в момент выполнения objWindow будет принадлежать ещё и классу clFoldingWindow. Из объявления переменной понятно, что нужно открыть диалоговое окно. Будем считать вопрос строгой типизации закрытым. Реляционные БД и множественная классификацияНи для кого не секрет, что объектно-ориентированное программирование весьма посредственно сочетается с теорией и практикой реляционных баз данных. Общепринятым мнением насчёт возможных путей разрешения возникающих противоречий является необходимость внесения изменений и дополнений в теорию реляционных БД. Считается, что проблемы кроются именно там, а не в теоретических основах ООП.
Проблема связана с тем, что при хранении объектов, классы которых образуют сложную иерархию наследования, очень сложно бывает выбрать такую стратегию отображения классов на таблицы РБД, которая бы устраивала всех. Выработано (и даже канонизировано) несколько подходов, но ни один из них, на мой взгляд, не является универсальным решением, свободным ото всех недостатков. Смею заявить, что отказ от концепции наследования в пользу множественной классификации снимает проблему отображения классов на таблицы РБД раз и навсегда. Из всех подходов к проблеме мэппинга остаётся один (самый логичный!): один класс – одна таблица (или таблица с подчинёнными таблицами). Подчинённые таблицы бывают нужны потому, что требование атомарности полей никто не отменял. Хорошая сочетаемость предлагаемой в этой статье парадигмы со стройной и математически обоснованной теорией реляционных баз данных является неплохим аргументом в пользу того, что описанный подход имеет право на жизнь. Изменения в технологии проектированияВ этом разделе попробуем разобраться, каким образом предлагаемая технология может облегчить жизнь программистам и архитекторам информационных систем. Для начала вспомним стандартную последовательность действий, применяемую при использовании классического ООП (здесь опущены стадии постановки задачи, написания ТЭО, формирования проектной команды, документирования и пр.):
Чем точнее мы сможем описать логику предметной области набором ярлычков и их интерфейсов, тем меньше потом придётся переделывать. Комизм ситуации заключается в том, что все этапы проходят в условиях неполноты, изменчивости и противоречивости имеющейся в наличии информации. Достаточно лёгкого дуновения ветерка, и стройная иерархия начинает заваливаться. Постоянно появляются объекты, для которых разработанная (а возможно, уже и реализованная) система классов не подходит. Ну кто ж мог знать заранее, что сторожевые собаки, охраняющие гаражи – это тоже животные, тоже едят корм, но экспонатами не являются? Многие модные паттэрны проектирования (компонентный подход, гомоморфные иерархии, mixinы и др.) большей частью предназначены именно для минимизации потерь, вызванных неудачным построением иерархии классов. Сила множественной классификации заключается в том, что разработчик избавляется от необходимости выполнения не вполне корректного с логической точки зрения, очень ответственного и весьма трудоёмкого процесса выстраивания иерархии. Если есть большое желание что-то обобщить – пожалуйста, обобщайте, но насильно никто этого делать не заставляет. Вот как тот же самый техпроцесс будет выглядеть в случае применения множественной классификации:
Это сродни логическому программированию. Системе сообщается набор сведений, известных о предметной области, и в результате получается готовая автоматизированная система. Если поступила дополнительная ранее не учтённая информация, просто добавляем её в набор правил и радуемся тому, как быстро получилось удовлетворить пожелания клиента. Это, конечно, слишком идиллическая картина, но, согласитесь, предпосылок для её появления стало значительно больше, чем при использовании устоявшихся "классических" подходов. Что дальше?Дальше можно развиваться в следующих направлениях:
ЗаключениеБыли инкапсуляция, наследование и полиморфизм. Наследование сложили на задворки истории, и добавили множественную классификацию. Получилось: инкапсуляция, множественная классификация, полиморфизм. Выяснили, что множественная классификация:
И самое главное, что множественная классификация в принципе реализуема. Вопросы есть? © Macляев Aлeкcaндp, 2003
| ||||||||||||||||||||||||||||||||||||
Удивительное дело! Когда занимаешься теоретическими построениями в области технологий программирования, попеременно складываются два противоположных ощущения: "всё придумано до нас, и ничего нового выдумать просто невозможно" и "область настолько необъятна и причудлива, что хватит дела не только нам, но и нашим детям и внукам". Всё-таки, мне кажется, второе утверждение ближе к истине. |