Последний раз обновлено 27.06.01
Некоторые особенности реализации объектной модели на С++ стоит перечислить. Хотя, я надеюсь, что многие вопросы теперь уже просто не могут быть заданы и для реального применения языка надо просто ознакомиться с правилами, принятыми на С++ для описания того, что хочется, а не наоборот, из описания языка узнавать, что же хочется.
Обычно можно встретить запрещения, касающиеся использования в интерфейсной части данных. В результате это порождает функции для всех данных, даже для встроенных типов и еще и по паре: на чтение и на запись. Далее, борцы против данных, предлагают эти функции объявить как inline
, чтобы не было накладных расходов. Зачем же такие функции? Обычно говорят, что использование данных не защищает внешнюю программу от их модификации, разрушает интерфейс и плохо для наследования. Надо разобраться, как это происходит.
Чем отличаются данные и функции в контексте ООП? Ничем. Так как ни тех, ни других там не существует. Как не отличается слон от носорога в глубинах океана, они там не водятся. В контексте ООП существуют свойства и сообщения. В чем же их отличия? А вот в чем.
Сообщения передают информацию для объекта, в котором они определены, и это означает, что именно этот объект и будет обрабатывать информацию. А свойство, это просто свойство. Оно лежит себе в объекте, и если и может принимать само какие-то сообщения, то это внутреннее дело этого свойства. Данного объекта это не касается. Для свойства, в общем случае, не существует объекта, в котором оно определено, свойства этого объекта не доступны, а для объекта тоже не все свойства этого свойства доступны. Встроенный тип может принимать, например, сообщение "присвоить" как lvalue ( запись ), а определенная пользователем класс может никаких сообщений не принимать.
Сам вопрос о данных в открытой части объекта говорит о том, что человек не работает в модели ООП. Это не та проблема, которая может возникнуть в ООП. Если вы определили модель, значит, вы определили что у вас свойство, а что сообщение. Что у вас открыто, а что закрыто и относится к внутреннему устройству объекта. Что образует виртуальный кластер, а что нет. Это вам не структурная программа, где вы можете во время программирования менять все, что вам заблагорассудится. Если модель не удовлетворяет каким-то требованиям, то надо ее сменить, разобраться что есть что, а только потом ее описывать.
Обычно, методы реализации сообщения описываются функциями. Вот когда они появились. Функция - реализация метода реализации сообщения. Такая тройная вложенность. На этом уровне некая гибридность. Функция может работать как с сообщениями и свойствами объектов, так и просто с функциями и с локальными и глобальными данными, если сделана по модели СП.
Обработка сообщения означает последовательность действий. В С++ последовательность действий определенных и именованных пользователем оформляют как функцию. Но последовательность действий может быть и встроенный оператор, и вобще, что угодно. Я специально говорю "что угодно", чтобы разделить понятия сообщение, метод и последовательность действий метода.
Под функциями, реализующими метод, здесь понимаются функции описанные прямо в контексте объекта по умолчанию, не во вложенном объекте. Почему именно такие функции:
Но сообщение можно реализовать и вложенным подобъектом (не функцией) какого-то класса, который может принимать сообщения сам. В этом случае все изменения в реализации сообщения переносятся в этот подобъект, можно сказать, что подобъект изолирует объект от своих сообщений.
Подобъект формирует пространство имен сообщений: объединяя сообщения по каким-то признакам в группы типа draw, move с доступом draw–>circle(), move–>box(). Это удобно, если в этих группах есть сообщения с одинаковыми логическими именами, например: compile–>work() и draw–>work(). Без этого в имя сообщения, а для общности может и во все сообщения группы, добавилось бы compile_, draw_ или что-то в этом роде. Чем будет реализован метод подобъекта для данного объекта безразлично.
Но свойства (не сообщения) хотя и могут быть полиморфичны при использовании наследования, но на них не распространяется механизм автоматического подбора по типу чего-нибудь.
Это означает, что либо не удобен доступ, либо потомок не сможет наследовать данные.
Проблемы предыдущих пунктов преодолевают, если нужно получить правильные группы пространства имен сообщений путем того, что каждое сообщение с не виртуальным методом из этой переменной вызывает сообщение с именем с префиксом группы и с виртуальным методом из объекта, в который вложена переменная. Такой подобъект служит заглушкой, своими не виртуальными методами вызывая виртуальные методы объекта, как раз изолируя внешние программы от реальной обработки сообщения, что полностью соответствует тому, из-за чего их пытаются одеть в функции борцы с данными.
Из всего этого можно сказать, что если вы в модели заложили, что у вас есть свойство или сообщение с не виртуальным методом Х
типа char
, его не надо одевать в функции get_X()
и set_X()
. Если же это сообщение с виртуальным методом, то должны быть веские причины, чтобы реализовать этот метод не виртуальной функцией. По поводу изоляции от изменений, то хорошо составленная модель - лучшая изоляция.
Стоит собрать в одно место и сравнить все, что уже говорилось о полиморфизме, а так же, добавить немного нового.
Полиморфизм как бы позволяет разделять сообщение на две части: логическую сущность и реализацию-метод. Это позволяет перенести реализацию в АТД, а в вызывающем сообщение коде оставить только логику и создавать шаблоны кода. Это вызывает усиление интерфейсной части АТД.
Обычно вызывает недоумение то, что нет полиморфизма по типу возвращаемого значения. Это происходит от того, что логическая сущность сообщения ( и его полиморфического кластера ) одна, и это определяет и единственность возвращаемого значения. В исключительных случаях, можно определить тип результата как АТД, который допустит фактически разный результат.
Указав имя сообщения можно сослаться на целую группу методов, которые будут выбираться в зависимости от типа параметров. Например:
store_to_disk( <typeof(obj)>& obj );
Записать на диск в зависимости от типа объекта нужный объект. Здесь принципиально разный код метода, но логическая общность, в данном случае, в записи на диск.
Некая разновидность этого — "синтаксический". Это параметры по умолчанию.
store_to_disk( Tstring& str, char skip_header=1 );
Возможность включить идентификационный заголовок строки в файл. Здесь одна функция ( один код и одна логика), но эмулируется набор методов, отличающихся подробностями задания параметров.
Можно комбинировать оба этих способа.
store_to_disk( <typeof(obj)>& obj, char skip_header=1 );
Это частный случай (1). Если помимо логической общности метод имеет довольно много общего в коде, вернее все одинаково кроме типа манипулируемого объекта, а сам объект имеет методы реализующие все необходимые сообщения ( либо метод использует функции вида (1) для работы с объектом ), то это позволяет задать шаблон – фактически схема алгоритма работы с АТД. Если нет методов объекта или функций типа (1) то в схеме такого шаблона появятся switch(<typeof(obj)>)
для вызова правильных функций при каждом использовании объекта. Это будут ненужные подробности для вызывающего объект кода.
Для объектов охваченных наследственной связью каждый потомок может переопределить метод реализующий сообщение для объекта. В вызывающем коде можно всегда явно указать, какой из методов потомка использовать, но чаще всего требуется использовать метод соответствующий типу объекта. Для такого автоматического определения типа, который не всегда может быть узнан на этапе компиляции, вернее, когда тип указателя на объект не соответствует реальному типу объекта ( а это возможно для любого потомка, который является, по крайней мере, объектом базового класса и на него может указывать указатель на любой его базовый класс ) используется ключевое слово virtual
. Это аналогично "автоматическому switch(<typeof(obj)>)" при вызове метода сообщения объекта, при этом метод может обладать, конечно же, параметрическим полиморфизмом. Этот автоматизм можно реализовать вручную, введя дополнительный параметр – тип объекта – и выясняя его перед отправкой сообщения.
if(obj->type==my_type)obj->Tmy_type::msg();
else obj->msg();
Метод объявленный как virtual
открывает так называемый полиморфический кластер. Все методы этого сообщения в одной линии наследования образуют этот кластер. Мне известно несколько случаев использования этого способа:
virtual
). Процесс передаче сообщения самому себе можно представить как обращение через this
, который опускается по умолчанию. При этом подразумевается, что вызывающий свой объект метод в потомке не переопределен.
Довольно странное и длинное название, но мне кажется, что этот вариант стоит на равне с (3), хотя он не автоматически используется компилятором и может рассматриваться частным случаем (3), но он может обеспечить для объекта лучше соответствие АТД. В параметрическом полиморфизме (1) конкретную форму вызов обретает уже во время компиляции и определяется параметрами, при полиморфизме относительно типа объекта (3) выбор метода реализующего сообщение происходит во время выполнения, хоть и автоматически, но для каждого объекта будет вызван по таблице виртуальных функций только один метод, который определяется только типом объекта. Пусть наш кластер состоит из одной линии наследования. Для вызванного метода доступны ( если специально не запрещены ) все методы предков для этого сообщения. Эти методы можно вызвать или полностью передать им управление обработкой сообщения, реализуя конструкции типа
if(condition)return base_type::method();
Когда это может быть нужно? Рассмотрим варианты переопределения метода в потомке:
Первые два случая не представляют проблем и не требуют дополнительных усилий, но крайне интересен последний. Пусть вызывающий сообщение шаблон кода определил, что какой–то информационный поток ( данные ) предназначен для объектов кластера Х и передал одному из объектов Х сообщение с указанием этого потока. Этот поток можно рассматривать здесь:
Пока умышленно не привожу конкретные примеры, пусть с ущербом для понимания, но без ущерба для общности. Если следовать логике АТД, то если это действительно объект какого–то класса из Х то уж он то сам знает как себя распознать, преобразовать и т.д. Поэтому, можно в вызывающем коде посчитать эти подробности несущественными и предложить самому объекту работать с потоком.
friend
функция или какой-нибудь другой сосредоточенный селектор, т.к. класс всегда знает, по крайней мере, свой базовый класс, и не требуется вносить изменения при наследовании для какого–то кода, который знает всех наследников. Этот метод скрывает от передающего в АТД сообщение шаблона кода реальную схему наследования или еще какой-нибудь способ обработки потока данных, который мог, например, измениться со времени написания шаблона, что хорошо.
Однако, есть существенный недостаток, что вместо базового класса надо знать последний класс в линии наследования, чтобы охватить все методы. Но на самом деле достаточно иметь лишь экземпляр такого класса, доступный через указатель на базовый класс. Экземпляр класса можно создать лишь в одном модуле ( но не в модуле определения класса ), локализовав в нем знание о реальном последнем в линии наследования. Согласитесь, что знание только лишь имени класса ( даже не прибегая к #define
подстановкам ) в одном файле и хотя довольно глобальное использование ссылки на этот объект лучше, чем знание формата всех сохраняемых объектов в одной функции или перекомпиляция всех модулей использующих последний класс.
Другой недостаток заключается в том, что если информационный поток адресован именно базовому классу, а линия наследования значительная, будут определенные накладные расходы времени выполнения. Наконец, а существуют ли такие потоки вообще? Я сталкивался с несколькими:
Но есть случаи, когда методы не выходят за рамки свойств базового класса и разные, свои для каждого класса из разных линий наследования. В языке, для компилятора, это описать нельзя. Возможно реализовать проход по таким линиям, если:
Но это уже и неудобно, и совсем из области фантастики и трудно отделить от конкретной задачи и обобщить. Проще иметь несколько групп в вызывающем шаблоне и раздельно для каждой группы пытаться выполнить сообщение.
Разновидность (4). Отличие в том, что для последнего объекта все методы будут вызваны автоматически и в обратном (4.1) порядке. На первый взгляд, кроме проблем, которые были в (4) тут добавятся свои, а выгода разве что в экономии строки вызова предка и правильной работе с виртуальными базовыми классами при множественном наследовании.
Язык не поддерживает "автоматический switch()" по произвольному параметру, не только по типу объекта. А реализация этого программным путем даст отсутствие сервиса компилятора по проверке ошибок и прочие неудобства. У меня нет примеров хорошего использования и необходимости такой возможности.
Говоря о сообщениях, мы всегда давали им имена подобно функциям и другим пользовательским именам программы. Но для многих применений было бы удобно использовать символические имена стандартных операций. Это обеспечит лучшую похожесть на встроенный тип для объектов, которые имеют аналогичные по смыслу операции: арифметические, присваивание, сравнение и т.п.
Во многих случаях, для объектов которые не имеют таких логически обоснованых операций, их тоже вводят, но с другим смыслом. С одной стороны это плохо, так как глядя на код, посторонний программист никогда не расшифрует что значит (~-(!Tmy_type(X)))+=--Tmy_other_type(Y)%Tmy_type(X)++;
С другой стороны, если не злоупотреблять такими вещами, то использование символьных имен операций упрощает как программирование, так и чтение для тех, кому ведом загадочный и тайный смысл знаков этих операций.
Но надо помнить, что для компилятора смысл операций не изменяется от того, что вы их перегрузили, не изменяется и порядок приоритетов, поэтому без крайней необходимости не стоит их определять не по назначению, особенно для бинарных, которые могут использоваться в сложных выражениях. Если про ООП говорят, что оно подстраивается под задачу ( и под ее язык в частности ), то не целевое использование операций наоборот, подстраивает вашу задачу под С++.
Оформление перегрузки операций принципиально не отличается от оформления метода сообщения, но особенности этого надо изучить в руководстве по С++. Это совсем за рамками данного текста.
Для описания конструкторов базовых классов, в определении конструктора производного класса они идут после двоеточия и перед блоком кода самого конструктора, наподобие перечислений базовых классов в описании класса. Но часто там же идут и описания не конструкторов. Что и почему описывается до блока конструктора, помимо конструктора базового класса? Это инициализация
Если в описании класса есть такие, то сам объект обязательно должен иметь конструктор. Для инициализации ссылки используют конструкцию вида имя_ссылки(объект_на_который_она_ссылается). Объектом в скобках может быть или параметр конструктора или другой объект, доступный в контексте вызова и описания конструктора ( например глобальный, но никак не временный ).
Константные объекты мы использовать не будем, но для справки я скажу, что они тоже инициализируются до входа в блок, так как по правилам С++, константное выражение не может быть как lvalue.
Напуганные страшными рассказами о ужасах накладных расходов, связанных с использованием виртуальных функций, многие начинают избегать их как чумы и стараются сделать все функции если не inline
, то по крайней мере не виртуальными. При этом каждый вызов виртуальной функции представляется как катастрофа, связанная, по меньшей мере, с записью всей памяти на диск. На самом деле, накладные расходы, связанные с вызовом виртуальной функции, хотя и не ниже, чем при обычном вызове, но отличие не больше, чем при обращении к глобальной переменной против поля структуры данных. Никто не пытается избегать структур и сделать все данные "inline" или глобальными, чтобы во время выполнения мучительно долго не вычислять адрес структуры и прибавлять смещение поля.
Другой тип накладных расходов связан с тем, что часто происходит конвейерная обработка данных по линии наследования, это порождает много вызовов функций, но виртуальность функций здесь не виновата. Если так критична скорость выполнения, то надо перед выполнением определить и объект и метод, который будет обрабатывать сообщение, отказаться от конвейерной обработки, которую можно, с некоторым приближением, считать интерпретацией во время выполнения с некоего языка, полученного от недокомпилированного С++.
Некоторые думают, что во время выполнения программы существует иерархия классов, т.е. куча объектов, начиная с базового класса до класса потомка, и сообщения во время выполнения каким то образом передаются между объектами. На самом деле, есть только один объект, и поля всех его предков внутри него, в т.ч. переопределенные. Хотя это и не оговаривается, но обычно контекст объекта разделяют на две половины: данные и функции. Код является общим для всех объектов одного класса, а уникален только набор данных.
Многие, даже долго программируя на С, почему-то думают, что выражение
double d;
char a=(char)d;
означает "считать переменную типа double переменной типа char". Но на самом деле оно означает, "преобразовать переменную типа double в char", т.е. a=convert_to_char(d). Первому же предложению будет соответствовать что-нибудь вроде такого
char a=*((char*)&d);
При модернизации классов наследованием, имена которых уже в полной мере отражают их логическое назначение, появляется проблема, как обозначить имя наследника: <базовый_класс>_1 или < базовый_класс >_new, а следующего наследника? Мне не пришло в голову ничего, кроме как для потенциально изменяемых классов задать так:
#define class_name class_name_< суффикс последней реализации >
Когда я говорил здесь о шаблоне кода, я имел в виду шаблоны времени выполнения, использование котрых возможно благодаря наследованию, именно наследованию интерфейса. Такие шаблоны не выделяются ключевым словом template
, а используют ссылку на объект некоторого класса, обычно абстрактного.
Шаблон с template
можно назвать шаблоном времени компиляции. Подобно ссылке на объект для шаблона времени выполнения здесь используется параметр некоторого типа T
. При компиляции описания такого шаблона о типе T
ничего не известно. Чтобы создать объект класса, который описан c template
, надо явно указать тип параметра. Такое создание называют инстанцированием.
Возможные классы параметра шаблона при инстанцировании не обязательно должны быть потомками одного базового класса, достаточно чтобы они имели одноименные операции, которые используются в этом шаблоне. Но даже если два параметра являются потомками одного базового класса, при каждом инстанцировании создаются совершенно разные классы, отличающиеся классом параметра.
|