Учебное пособие по курсу «Технология программирования»

Вид материалаУчебное пособие

Содержание


6. ООП – объектно – ориентированное программирование.
Имя класса (const имя класса & имя объекта)
Подобный материал:
1   2   3   4   5   6   7   8   9   10
^

6. ООП – объектно – ориентированное программирование.



Всё, что мы изучали до сих пор, было основано на идее алгоритмизации. То есть, программа рассматривалась, как запись последовательности некоторых действий, которые выделялись в процессе алгоритмизации. Такое программирование называется процедурным программированием. Общая методика написания таких программ состоит в последовательном разбиении задачи на подзадачи и реализации программ, решающих возникающие подзадачи. Общая программа в таком случае разбивалась на подпрограммы, которые могли реализовываться в каком-то смысле совершенно самостоятельно. По возможности подпрограммы реализовывались так, чтобы их можно было использовать более чем в одной задаче. Подпрограмму можно использовать в другой задаче, если знать как её вызывать, как ей передать параметры и как правильно получить результаты её работы. Во всём остальном подпрограмма может рассматриваться как классический чёрный ящик.

Процедурное программирование или алгоритмизация по сути своей представляет собой выделение в задаче действий и сосредотачивает внимание программиста на действиях. Как ранее указывалось, происходит некоторое абстрагирование от данных. Однако восприятие окружающего мира мы делаем несколько не так, мы, скорее, сначала воспринимаем данные, а потом уже действия: если лес, то сначала какой лес и где, а уж потом, что там можно делать или что можно делать с этим лесом. Принципиально, мы воспринимаем мир объектами, а затем манипуляциями с этими объектами. При этом сам объект естественным образом воспринимается как чёрный ящик, мы редко задумываемся о действительном устройстве воспринимаемых объектов. Эта технология восприятия, рано или поздно должна была найти отражение в технологии программирования.

С другой стороны, чем фактически занимается программист - постоянно изобретает собственные типы данных. Число машинных типов данных изумительно мало: int, float и char. Уже тип string является агрегатом данных. При внимательном изучении мы должны будем заметить, что int, float и char всего лишь разные способы интерпретации одного и того же состояния слова. Отметим ещё раз, что сами данные не изменились: поменялись действия по получению конечного результата! Другими словами, по своей сути на нижнем уровне данные и действия не отделимы друг от друга. Мы всегда изучаем данные и операции, выполняемые над данными, при этом всё это рассматривается без отрыва друг от друга, что очень естественно.

Таким образом, принимая во внимание, скудость встроенных типов данных, программист вынужден изобретать свои собственные типы для решения поставленных задач. Однако в процедурном программировании имеется существенная разница между встроенными типами и типами, придуманными программистом. Операции с типами программиста можно осуществлять лишь подпрограммами. В процедурном программировании нет средств встройки операций с новыми типами. Реализация операций подпрограммами делают программу плохо читаемой, а, следовательно, и плохо понимаемой и, наконец, трудно сопровождаемой. Последнее, между прочим, предполагает и модификацию программы. Всё это накладывает определённые ограничения на величину кода, который в состоянии написать и отладить средний программист в заданное время. То есть, процедурное программирование подошло к своему пределу. Гради Буч [4] оценивает этот предел в 100 000 строк программного текста. Требовалось некое новшевство, которое позволило бы отлаживать более длинные программы за реальное время. Такое новшество, конечно же, появилось.

Самое интересное, что когда реально всё это произошло, то сначала никто ничего не заметил. Справедливости ради, следует отметить, что необходимые средства имеются уже в обыкновенном Си. Рассмотрим следующее объявление структуры:

struct Example {

int A; // просто переменная.

float В; // то же

int MyFisrtFunc (int s) ;

void MySecondFunc (float r); } U;

Что необычного в объявлении этой структуры: в ней объявлены функции. Правила Си не запрещают объявлять функции внутри структуры. Но согласно всё тем же правилам, вы теперь не можете вызвать функцию обычным образом, обратившись MyFisrtFunc: к любой внутренней переменной структуры необходимо обратиться через имя главной структуры - откуда: U.MyFirstFunc (... Более того, если у вас есть, например: Example T, G; То для каждого экземпляра структуры вы вызываете свой экземпляр функции, хотя функция одна - указатель на функцию разный). Но тогда вы вообще можете для каждого экземпляра структуры иметь свою внутреннюю функцию. Многие видели в подобных структурах один из недостатков языка, но Бьёрн Стауструп разглядел достоинство и, переработав концепцию классов из языка SmallTalk 4 , последовательно провёл идею классов в Cи++. В результате получился не просто язык программирования, но новое технологическое средство. Главная заслуга Страуструпа не просто в разработке нового языка, но в заострении внимания программистского мира на основных идеях: инкапсуляция, полиморфизм, наследование.

Кратко последовательно, что это такое:

Инкапсуляция (Encapsulation)  механизм, объединяющий данные и код, работающий с этими данными, в единое целое, который позволяет манипулировать и данными и кодом как единым целым и позволяющим защищать и данные и код от внешнего вмешательства. Когда коды и данные объединяются вместе, создаётся некоторая новая общность, которую мы называем объект. Но тогда в языке необходимы средства, позволяющие объявлять объекты и манипулировать им. Кроме того, надо чтобы мы могли объявлять переменные типа «объект».

Внутри объекта и коды и данные могут быть закрытыми и открытыми.

Открытые члены объекта: public — доступны из любой части программы, в которой объявлен объект.

Закрытые члены объекта: private - доступны только изнутри объекта: только тем функциям, которые объявлены внутри объекта.

Шилдт Г. [10] подчёркивает, что объект является переменной определённого пользователем типа. В самом деле, у нас есть определения необходимых нам данных, и у нас определены операции с этими данными. При этом механизм обращения к этим операциям построен так, что вы не сможете обращаться к данным другого типа этими операциями.

Другой взгляд на инкапсуляцию гласит: инкапсуляция - сокрытие данных и операций над этими данными от постороннего вмешательства или ограничение доступа к некоторым частям программы и данных. Следует понимать, что грамотный программист, зная описание объекта, всегда сумеет получить доступ к любой его части, даже к тщательно «защищённой». Конечно, это будут маленькие программистские хитрости, а не законный доступ. Страуструп применяет даже более неприятный термин: жульничество, негласно предлагая понять, что инкапсуляция это хорошо.

С нашей точки зрения не следует переоценивать значение и возможности инкапсуляции, также как и недооценивать. Само по себе скрытие кодов и данных от пользователя выполняется повсеместно также как и обратное - публикация и выпуск открытого программного обеспечения. Практика показала, что хороши те программы, которые работают, а не те в которых применена инкапсуляция или объектно-ориентированное программирование. Другими словами: всё хорошо в своё время и на своём месте.

Полиморфизм: (Polymorphism)  свойство, позволяющее одно и то же имя использовать для решения нескольких задач. Перегрузка функций один из примеров полиморфизма. На самом деле понятие полиморфизма гораздо шире вышеприведённого определения. Полиморфизм, следует понимать как применение одних и тех же действий/операций к разным объектам. В реальном мире большинство операций полиморфны. Примером типичной полиморфной операций может служить почти любое действие: переместить, например, можно стол, книгу, столицу и мн. др.

Другими словами: полиморфизм такое определение операций и функций, которое не зависит от типа данных. Ну, здесь как говорится: за что боролись - на то и напоролись. Хотим мы того или нет, а Си упорно дрейфует в сторону полиморфизма. Требование обязательного объявления всех переменных сильно упрощает транслятор и несколько повышает надёжность реализуемого программного обеспечения. Мы говорим несколько, поскольку от обязательного объявления переменных ошибок не стало меньше, просто пропали ошибки одного класса, но появились ошибки другого класса. Программирует по-прежнему (по всей видимости, так будет всегда) человек, а он склонен к ошибкам. Но одновременно, обязательное объявление переменных и невозможность по ходу выполнения сменить тип переменной сильно сковывает возможности программиста, точнее заставляет его использовать более хитроумные трюки. Программист всё равно своего добьётся, но теперь ему для этого потребуется больше времени и средств.

Одним из средств ухода от типа будет union. Однако это простенькое средство касается только данных. Речь идёт об операциях, о полиморфных операциях. Мы ещё раз обращаем внимание на то, что сначала последовательно проводится политика обязательного объявления, а затем тратится значительное количество усилий на преодоление возникших ограничений. Типично «коммунистическое» решение проблемы: сначала создать себе трудности, а затем мужественно их преодолевать. Для решения проблемы «ухода от типа» была разработана новая конструкция языка, достаточно сложная в реализации и применении.

Демонстрацией реально сложившейся ситуации может служить следующее заявление Страуструпа [8]: «настоящей объектно-ориентированной программой может называться только та, при трансляции которой транслятор не в состоянии определить типы объектов: реальные типы объектов появляются только при конкретном выполнении программы».

Наследование (Inheriance)  средство, посредством которого один объект может использовать свойства другого. Более точно, именно использование буквального значения слова наследовать. То есть, каким образом свойства одного объекта могут рассматриваться другим объектом как свои собственные. Таким образом, объект может иметь свои собственные свойства и наследуемые свойства. Такая система позволяет строить иерархии объектов: живое существо - животное - млекопитающее - хищное - кошачьи - тигры - уссурийский тигр.

Наследование действительно сильная и новая для Си концепция. Её не было в ANSI Си. Именно идея наследования упрощает разработку и отладку сложной иерархии объектов. Более того, в большинстве визуальных сред можно явно просмотреть, что от чего наследует. Самое интересное, что наследуемое свойство или операцию можно переопределить конкретно для каждого объекта.

Итак: инкапсуляция, полиморфизм и наследование - три кита, на которых держится объектно-ориентированное программирование (ООП). Мы постарались показать, суть каждого принципа и его, так сказать, степень важности для ООП. Но каждый принцип сам по себе в отдельности не создаёт тех преимуществ, которые даёт нам ООП. Видно, что некоторые принципы (полиморфизм) являются в основном преодолением возведённых в принцип некоторых проектных решений. Однако, применяемые все три вместе эти концепции действительно дают нечто новое, то самое, что и называют ООП.

Тем не менее, в Си нельзя объявить объект, что возможно в Basic. Может и правильно, что в Си нельзя объявить объект. Едва ли к конструкции языка можно будет предъявить все те требования, которые мы привыкли предъявлять к объекту. С другой стороны понятие объект настолько обще, что едва ли его можно втиснуть в рамки алгоритмического языка.

В Cи++ можно объявить класс: class. Конструкция class во многом ведёт себя как объект. Страуструп даёт следующее определение класса: «Класс – это тип определяемый пользователем.»

Синтаксис объявления класса следующий:

class имя класса {

закрытые функции и переменные класса. public:

открытые функции и переменные класса.

) список переменных',


Обратим внимание, что объявление класса во многом похоже на объявление структуры. Но при работе с классами существует много такого, чего нет со структурами.

имя класса - фактически это имя нового, введённого программистом типа. Это имя затем везде можно применять для объявления переменных. При этом переменные приобретают тип имя класса. Синтаксис не требует обязательного задания имени класса, но вряд ли нужен класс без имени, такой класс нельзя затем будет использовать. То есть, вы сможете использовать только те переменные, которые перечислены в список_переменных.

По умолчанию всё, что вы объявляете внутри класса приобретает статус private. Открытый раздел класса начинается меткой public. Однако вы можете в любом месте прервать один раздел и начать другой, правилами это не запрещено.

Обратите внимание на следующее:

Объявление класса является логической абстракцией, которая задаёт новый тип данных или объекта. Объявление объекта создаёт физическую сущность объекта. (Или: объект занимает память! Задание типа  нет.)

После объявления класса вы можете задать переменные/объекты имеющие тип этого класса:

MyClass А, С, D[3]; или

MyClass * U; U = new MyClass; ...... delete U;

Конкретных экземпляров класса мы можем иметь столько, сколько нам позволит память компьютера. Видим, что мы можем объявлять массивы классов и создавать класс динамически. Ясно, что переменные типа класс можно вставлять в структуру. В некотором смысле структуру можно рассмотреть, как класс, у которого все данные и функции открыты по умолчанию.

Что очень важно: каждый объект класса имеет собственную копию всех переменных, объявленных внутри класса!

Внутри класса можно объявить как переменные, так и функции. Переменные, обычно, называются членами (members) класса, а функции - функциями-членами (memberfunction). Так сложилось, что в визуальном программировании этим понятиям соответствуют свойства и методы. Хотя это не совсем однозначно, так как существуют свойства, которые являются некоторой функцией обязательно возвращающей значение запрашиваемого свойства.

Принято, собственно объявление класса делать в файле - заголовке. Там же, чаще всего определяются короткие функции-члены и функции inline. Обычно имя заголовка и имя класса совпадают. Функции - члены класса определяются в файле, имеющим имя одинаковое с именем класса и расширение .с или .срр. Но очень часто, в этих двух файлах совмещают объявление нескольких родственных классов.

Функции, объявленные в классе имеют доступ ко всем членам и функциям класса. Все остальные переменные, операторы и функции программы могут обращаться только к открытым членам и функциям класса. Обращение идёт через имя объекта: U.имя члена или U.имя функции - члена. Указывая конкретное имя, вы обращаетесь к конкретной реализации переменной класса, которая имеет значение свойства конкретного объекта. Также, если вы обращаетесь к функции-члену, которая в свою очередь обращается к членам класса, то и вы и функция - член работает только с членами объекта, от имени которого функция запущена. Таким образом, вы изменяете только переменные объекта, от имени которого работаете.

Пока, как видим, никакого отличия от структур, кроме того, что слово struct заменили на class и ввели две новых метки, к которым нельзя обратиться по goto. Различия появляются при работе.

Очень часто, почти всегда, создаваемые объекты должны иметь умалчиваемые значения своих членов. Вспомним, что транслятор Cи++, по умолчанию не выполняет инициализацию своих переменных. Пока дело касается простых агрегатов данных, ничего страшного, все, что нужно выполнит программист. Всё меняется, когда вы широко начинаете использовать классы. Всё дело в том, что у классов имеется закрытая часть данных, к которой рядовой программист не имеет доступа. Откуда ясно, что требуется средство инициализации членов класса, прежде всего закрытой его части. Такое средство называется конструктор класса или функция-конструктор (costructor function).

Конструктор - функция инициализирующая члены класса и, возможно, выполняющая некоторые вспомогательные действия. Конструктор может быть реализован программистом. Если программист не задал свой конструктор класса, создаётся конструктор по умолчанию. Поскольку существует перегрузка функций, никто не запрещает иметь несколько разных конструкторов класса, но все они должны быть объявлены и определены в пределах класса.

Таким образом, при создании объекта класса обязательно вызывается конструктор класса. Если имеется реализованный программистом конструктор, то вызывается он, иначе вызывается конструктор по умолчанию. Для глобальных объектов конструктор вызывается тогда, когда начинается выполнение программы. Для локальных объектов, конструктор вызывается всякий раз при выполнении оператора, объявляющего переменную. Программист сам может вызвать конструктор тогда, когда посчитает нужным. При этом конструктор вызывается немного не так как все остальные члены-функции:

имя переменной типа объект = конструктор класса;

То, что конструктор вызывается при появлении объявления объекта легко продемонстрировать, объявив глобальный класс, выводящий в конструкторе сообщение на монитор: наличие хотя бы одного экземпляра объекта сразу становится видно по сообщению.

Конструктор имеет то же имя, что и класс, частью которого он является.

Конструктор не имеет возвращаемого значения.

Невозможно получить указатель на конструктор.

Во всём остальном это обычная функция.

Функция обратная конструктору называется деструктор. Деструктор класса вызывается при удалении объекта. Локальные объекты удаляются тогда, когда они выходят из области видимости. Глобальные объекты удаляются при завершении программы.

Деструктор имеет такое же имя, как и класс, но первым символом обязательно должна быть '~' - тильда.

Деструктор не может иметь входных параметров.

Деструктор не может возвращать значения.

Невозможно получить указатель на деструктор.

Отсюда видно, что деструктор может быть только один, так как невозможно перегрузить деструктор согласно требованиям синтаксиса Cи++.

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

Объекты класса обычные переменные, которые мы можем обычным образом использовать везде, где разрешается реализованными для данного класса операциями. Доступ к открытым членам класса осуществляется через указание объекта и точку: объект . имя открытого члена класса. Если у вас имеется указатель на объект, то вместо точки используется ->.

Точно так же, как и простые переменные вы можете выполнять присвоение переменной типа класс 1 такой же по типу переменной. При присвоении выполняется простое копирование переменных. Здесь всегда следует учесть два момента:

1. Имя класса должно быть одинаковым, а именно одинаковым должно быть имя типа. Транслятор тип переменной определяет по имени, а следовательно при разных именах, независимо от структуры класса, тип переменной будет разный и следовательно транслятор должен будет выполнять преобразование одного типа пользователя в другой, а как это делать транслятор не знает.

Выходов может быть несколько: во-первых, едва ли надо иметь два класса одной структуры с разными именами; во-вторых, можно написать собственную программу преобразования типа, что мы рассмотрим дальше; в-третьих, можно реализовать собственную программу, перегружающую операцию присвоения для этих двух типов пользователя, о чём также позже.

2. Если у вас в классе имеются прямые или косвенные указатели на области памяти или при создании элемента класса память запрашивается динамически, то после копирования объект-цель будет ссылаться на области памяти объекта-источника. Чаще всего это совершенно неправильно. В этом случае потребуется писать специальный, так называемый конструктор – копирования.

Эти особенности ещё раз подчёркивают, что мы работаем с типами определёнными самим пользователем. Транслятор многого про эти типы не знает, а следовательно все операции с этими типами должен определять сам пользователь.

Как обычные переменные объекты можно передавать в функции. При этом, по умолчанию объекты передаются по значению. Никто не запрещает передачу по ссылке, но по умолчанию объект передаётся по значению. Это приводит к тому, что при передаче в функцию объект копируется и его копия используется внутри функции, что в принципе не так страшно, так как для создания копии объекта в функцию не вызывается конструктор объекта. Однако при завершении работы функции копия объекта выпадает из области видимости и эту копию требуется удалить, для чего вызывается деструктор! Деструктор вызывается всегда. Если объект имеет так или иначе динамически полученные области памяти, то, естественно, деструктор их освободит.

Здесь необходимо отвлечься на разъяснение синтаксиса пространства имён. Как известно Cи не поддерживает понятия модуля, хотя программа чаще всего состоит из множества модулей. В связи с этим в Cи при разработке сложных программ так или иначе приходится определяться с объявлениями переменных. Программисты склонны применять одни и те же имена в разных модулях, что составляет проблему при компоновке и отладке программ. С областями видимости каждой переменной приходится разбираться. Одним из средств, облегчающим жизнь, будет пространство имён.

Пространство имён объявляется следующим образом:


namespace имя { …… }


здесь имя – имя пространства имён. Все переменные объявленные в пределах заданного пространства имён видны только в пределах объявленного имени. Таким образом, пространство имён является областью видимости. Операцией область видимости мы можем указывать именно ту переменную, которая объявлена в необходимой нам области.


имя пространства имён :: имя переменной.


Если же нам необходим доступ сразу ко всем элементам пространства имён, то можно использовать директиву using.


using namespace имя пространства имён.


Директива using может быть использована немного по-другому.


using имя пространства имён :: имя переменной – далее использовать переменную только из указанного пространства имён.


Принципиально, мы теперь имеем всё, чтобы попробовать разработать простой класс. Пусть это будет класс работы со строками, если уж работа со строками в Cи так плохо реализована.

#include

class MyString {

char * ps; // собственно указатель на строку.

int Ls; // объём выделенной памяти под строку.

int Tl; // текущая длина строки.

int flag; // флажки строки

public:

MyString () { Ls = 256;

ps = NULL;

ps = new char [Ls];

if (ps == NULL) { Ls = -1; Tl = -1; flag = -1; return; }

Tl = 0; flag = 0;

return;

}

MyString (int A) {

if (A <= 0) A = 256;

Ls = A;

ps = NULL;

ps = new char [Ls];

if (ps == NULL) { Ls = -1; Tl = -1; flag = -1; return; }

Tl = 0; flag = 0;

return;

}

MyString (char * U) { int i, ls = strlen (U);

Ls = ls + 10;

ps = NULL;

ps = new char [Ls];

if (ps == NULL) { Ls = -1; Tl = -1; flag = -1; return; }

for ( i = 0; i < ls; i++) ps[i] = U[i];

for (; i < Ls; i++) ps[i] = 0x00;

Tl = ls; flag = 1;

return;

}


~MyString () { if (flag < 0) return;

delete [] ps; }


int MyString::LenMyStr () { return Tl; }

void MyString::ClearMyStr () { if (flag < 1) return;

for (int i = 0; i < Ls; i++) ps[i] = 0x00;

Tl = 0; flag = 0; return; }

bool MyString::IsMyStrEmpty () { if (flag < 1) return true; else return false; }

char * MyString::c_har () { if (flag < 1) return NULL; else return ps; }


На этом все наши знания кончаются. Мы не можем выполнить даже реальной операции присвоения.


Пусть имеется MyString A = MyString (“ Первая строка. “);

MyString B;

И мы выполняем B = A; Тогда в B мы получаем побитную копию объекта A. Далее при работе с B мы можем испортить объект A.


Ещё одно отвлечение на понятие ссылки. В Cи++ можно объявить переменную типа ссылка: тип & имя переменной = имя другой переменной того же типа. Тогда первая переменная будет содержать адрес второй переменной. Такое объявление ссылки называется объявлением «независимой» ссылки. Однако такое применение ссылок встречается редко, так как по сути своей не требуется. Сначала перечислим ограничения, относящиеся к ссылкам, а затем укажем их реальное применение.

Нельзя ссылаться на другую ссылку.

Нельзя получить адрес ссылки.

Нельзя создавать массивы ссылок и ссылаться на битовое поле.

Ссылка должна быть инициализирована до того, как стать членом класса.


Ссылки собственно были придуманы затем, чтобы упростить передачу параметров в функции.

Посмотрим следующее объявление функции:


void Func (int & A) { }; Как видим, в функцию передаётся сразу адрес переменной или ссылка.


Однако при обращении к этой функции нам не надо указывать адрес входного параметра: он просто указывается как таковой.


int G; Func (G);  транслятор сам строит правильное обращение к функции и передаёт в функцию адрес переменной, а не значение.

Но самое интересное, что внутри функции мы можем обращаться к значению параметра просто по имени, не употребляя операцию разыменования. Все необходимые преобразования строит транслятор.

Функция может возвращать ссылку. В этом случае мы можем использовать функцию слева от знака присваивания!

Имеем: int & f(); тогда возможно f() = const; причём внутри функции должен быть обычный оператор return выражение. Транслятор вернёт ссылку на результат значения выражения, но само значение.

Здесь использование ссылок очень удобно и оправдано.


Основное назначение ссылок – реализация конструктора копирования.


Общий синтаксис конструктора копирования:

^ Имя класса (const имя класса & имя объекта)


Имеем с Cи два типа ситуаций, когда значение одного объекта передаются другому: Первая ситуация – это присваивание; вторая – инициализация. Инициализация выполняется в трёх случаях.


1. Когда в операторе объявления один объект используется для инициализации другого.

2. Когда объект передаётся в функцию в качестве параметра.

3. Когда создаётся временный объект для возврата значения функции.


Конструктор копирования годится только для инициализации объекта. Он не применяется при присвоении!

Подчеркнём: конструктор копирования не влияет на операцию присваивания.


// конструктор копирования.

MyString (const MyString & Qs) {

int i;

ps = NULL;

ps = new char [Qs.Ls];

if (ps == NULL) { Ls = -1; Tl = -1; flag = -1; return; }

Ls = Qs.Ls; Tl = Qs.Tl; flag = Qs.flag;

for (i = 0; i < Ls; i++) ps[i] = Qs.ps[i];

return;

}

Что здесь важно, так это то, что Qs – это правый операнд, а левый операнд передаётся в функцию неявно и тогда все внутренние переменные класса, относятся к левому операнду!


Для дальнейшей работы с классами нам необходимо освоить перегрузку операторов. Перегрузка операторов, фактически, является одним из видов перегрузки функций. Но всё не так просто. На перегрузку операторов есть определённые ограничения, которые мы обсудим по мере необходимости. Для перегрузки операторов необходимо написать оператор-функцию. Обычно, оператор функция является членом класса для которого она задана. Общая форма оператор-функции – члена класса:


возвращаемый тип имя класса :: operator знак операции (список аргументов)

{

тело функции

}


Возвращаемый тип может быть любым.

Знак операции – знак перегружаемой операции.

Список аргументов зависит от реализуемой операции.


Во время перегрузки операторов нельзя менять приоритет операций.

Во время перегрузки операторов нельзя менять число операндов.

Нельзя перегрузить операции: . :: ?

Нельзя перегружать операторы препроцессора.


Перегрузка бинарных операций:

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

При перегрузке бинарных операций, левый операнд передаётся функции неявно, а правый - передаётся функции в качестве аргумента. Таким образом, все члены класса, употребляемые в функции по имени, относятся к левому операнду, а члены класса правого операнда указываются только через имя переменной.


Давайте перегрузим операцию + для нашего класса:. Это, во-первых, реализация операции конкатенация, которая не коммутативна; а во-вторых, исходные строки не должны изменяться, что естественно,итак:


// перегрузка оператора + фактически выполняем конкатенацию наших строк.

MyString MyString::operator + (MyString & Oth) {

int L, i, j;

L = LenMyStr() + Oth.LenMyStr()+1;

MyString temp = MyString (L);

if (Tl > 0) {

for (i = 0; i < Tl; i++) temp.ps[i] = ps[i];

if (temp.ps[i-1] == 0x00) i--;

}

else i = 0;

if (Oth.Tl < 1) { temp.Tl = i; return temp; }

for (j = 0; j < Oth.Tl; j++) {

temp.ps[i] = Oth.ps[j];

i++; }

temp.Tl = L; temp.flag = 1;

return temp;

}


Отметим, что мы создаём временный объект temp, что естественно, так как исходные объекты (строки) при выполнении операции не должны измениться. Важно, что сначала в temp копируется левый операнд, а затем и правый. После всех операций объект temp возвращается в качестве результата работы функции (операции). Просим не забывать, что при возврате значения будет вызван конструктор копирования.

Так реализуется бинарная операция для одинаковых типов. Но можно реализовать операцию и для разных типов. Определим операцию конкатенация для MyString и char *:


// перегрузка оператора + char *

MyString MyString::operator + (char * Ot) {

int L, i, j, k;

k = strlen(Ot);

L = LenMyStr() + k + 1;

MyString temp = MyString (L);

if (Tl > 0) {

for (i = 0; i < Tl; i++) temp.ps[i] = ps[i];

if (temp.ps[i-1] == 0x00) i--;

}

else i = 0;

if (k < 1) { temp.Tl = i; return temp; }

for (j = 0; j < k; j++) {

temp.ps[i] = Ot[j];

i++; }

temp.ps[i] = 0x00;

temp.Tl = i; temp.flag = 1;

return temp;

}


Видим, что принципиально нового здесь ничего нет, только вместо объекта справа используется строка. Такую функцию можно использовать, когда переменная типа char будет справа.


Несколько более внимательным следует быть при перегрузке оператора присвоения:


// перегрузка оператора =

MyString & MyString::operator = (MyString Oth) {

int i;

if (Ls < Oth.Ls) Redefinition(Oth.Ls);

for (i = 0; i < Oth.Ls; i++) ps[i] = Oth.ps[i];

Tl = i; flag = 1;

if (Ls > Oth.Ls) for (; i < Ls; i++) ps[i] = 0x00;

return *this;

}


Во-первых, мы возвращаем ссылку на объект, что ускоряет нам работу при возврате аргумента. Во-вторых, поскольку у нас меняется левый операнд, то его и возвращаем указанием *this. Обратим внимание, что тут используется внутренняя функция класса Redefinition.

Заменив во входном параметре класс MyString на char, мы перегрузим операцию присвоения для типа char и сможем присваивать переменным типа MyString переменные типа char:


// перегрузка оператора = char *

MyString & MyString::operator = (char * Oth) {

int i, k;

k = strlen(Oth);

if (Ls < k) Redefinition(k);

for (i = 0; i < k; i++) ps[i] = Oth[i];

if (Ls > k) for (; i < Ls; i++) ps[i] = 0x00;

Tl = k; flag = 1;

return *this;

}


Бинарные операции можно перегрузить и дружественными функциями.

Дружественной функцией называется функция, которой разрешается доступ к закрытым членам класса, но она не является членом класса. Дружественная функция объявляется сама по себе, но в объявлении класса, для которого она будет дружественной должен быть прототип функции с ключевым словом friend. Функция может быть дружественна более чем одному классу. Поскольку дружественная функция не член класса, то она не может обращаться к членам класса непосредственно, а только через указания конкретного объекта. То есть, объект должен быть передан в дружественную функцию как входной параметр. Итак, в заголовок вставляем:


friend MyString operator + (char *, MyString &);


В файл .cpp:


MyString operator + (char * Ot, MyString & Se)

{

int L, i, j, k;

k = strlen(Ot);

L = Se.LenMyStr() + k + 1;

MyString temp = MyString (L);

if (k > 0) {

for (i = 0; i < k; i++) temp.ps[i] = Ot[i];

if (Ot[i-1] == 0x00) i--;

}

else i = 0;

if (Se.Tl < 1) { temp.Tl = i; return temp; }

for (j = 0; j < Se.Tl; j++) {

temp.ps[i] = Se.ps[j];

i++; }

temp.ps[i] = 0x00;

temp.Tl = i; temp.flag = 1;

return temp;

}


Видим, что наша новая функция поразительно похожа на две предыдущие функции, перегружающие оператор +. Но, теперь у нас два входных параметра: первый параметр относится к левому операнду операции, а второй параметр  к правому. Теперь в программе допускается любой порядок с-строк и строк MyString:


Пусть: MyString As, Rs; то теперь мы можем использовать следующие выражения:

As = “ простой текст “ + Rs; или As = Rs + “ простой текст. “;


Унарные операции перегружаются почти точно так же, как и бинарные. Ясно, что в случае унарной операции у нас нет входных параметров. Как будет выглядеть перегрузка инкремента: примерно следующим образом: MyString operator ++ () ;

Более интересно, что бывают префиксные и постфиксные унарные операции. Если их надо различать, то следует выполнить перегрузку для обеих форм операции:

Функция MyString operator ++() будет вызываться в случае префиксной формы, для постфиксной форсы надо объявить следующую функцию:

MyString operator ++ (int x);


Точно также можно перегрузить логические операторы и операции сравнения, только в этом случае функции-операторы будут возвращать не сами объекты, а чаще всего int или bool.


Некоторое затруднение вызывает перегрузка оператора []. Чаще всего это потому, что в реальной жизни мы не рассматриваем этот оператор как операцию. В общем, это правильно. Чаще всего программист – практик не различает операции получения данного и операцию доступа к данным. В Cи++ оператор [] считается бинарным оператором и может быть перегружен только функцией - членом.

Общая форма перегрузки этого оператора следующая:

type class_name::operator [] (int index) { }

Поскольку имеет смысл использовать индексацию, как справа, так и слева от знака присваивания, то, как правило, возвращается ссылка на возвращаемое значение. Приведём пример перегрузки оператор [] для нашего класса MyString:


char _NULL_STR = 0x00;

char & MyString::operator [] (int i)

{

if ((i < 0) | (i >= Tl)) return _NULL_STR;

return ps[i];

}


как видим, сама перегрузка тривиальна. Внимание следует обратить на то, что как-то надо решить, что делать, когда входной индекс указывает за пределы строки. Мы меняем тип, то есть по индексу возвращаем не объект класса, а символ, точнее ссылку на символ. Тогда в случае неверного индекса вернём нуль-строку. Однако мы не можем вернуть ссылку на внутреннюю переменную, а это значит, что где-то в классе должна храниться возвращаемая переменная. У нас она приведена сверху, а в реальном классе чаще всего это глобальная переменная предшествующая описанию класса. Такая перегрузка позволяет использовать индексацию как справа от знака равно, так и слева. Более того, вспоминая, что операция [] для обычного Cи++ работает как для int - индекса, так и для float - индекса, можно попытаться перегрузить операцию [] и для переменной типа float. Как это ни удивительно, перегрузка проходит, хотя не должна бы, но теперь следует предельно внимательно использовать индексы, иначе транслятор будет сообщать, что не может выбрать какую функцию использовать для реализации программы.


Наследование – это механизм, посредством которого один класс может наследовать (приобретать) свойства и методы другого класса. Класс, который наследуется, называют базовым классом (base class). Наследующий класс, называется производным классом (derived class). В базовом классе определяются свойства общие для обоих классов или для всех производных классов.

Общая форма наследования:

class имя производного класса : доступ имя базового класса { }

Спецификация доступа может быть private, public, protected

Если класс наследуется со спецификатором public – то все открытые члены базового класса становятся открытыми и в производном классе. Закрытые члены базового класса в любом случае закрыты и для производного класса.

Если базовый класс наследуется со спецификатором private – то все члены базового класса становятся закрытыми в производном классе. Однако, открытые члены базового класса по-прежнему доступны в функциях производного класса, так как они получаются для производного класса как собственные закрытые члены.

Спецификатор protected (защищённый) – предписывает, что члены класса с этим спецификатором являются закрытыми, но доступными для всех членов производных классов. Этот спецификатор также может появляться в любом месте объявления класса, но чаще всего он помещается после раздела private до раздела public.

Любой класс и базовый и производный может иметь конструкторы. Конструкторы выполняются в порядке наследования. Деструкторы в обратном порядке. Это естественно.

Как обычно, в конструкторы можно передавать параметры. Если есть необходимость передать параметры базовому классу, то этот параметр передаётся конструктору производного класса, а тот в свою очередь, передаёт параметры конструктору базового класса. Это выглядит следующим образом:

Пусть имеем class Derived : public Based { }

Вариант конструктора с параметрами для базового класса может выглядеть следующим образом:

Derived (int m, int n, float p) : Based (float p);

Класс может наследовать более одного базового класса. Выполнить можно это двояким образом. Во-первых, производный класс может быть базовым для другого производного класса, то есть исходный базовый класс будет косвенным базовым классом для производного. Таким образом, можно строить иерархии классов. Конструкторы в этом случае вызываются в порядке наследования, деструкторы в обратном порядке. Во-вторых, класс может наследовать несколько базовых классов прямо;

тогда общая форма будет следующей:

class имя производного класса : доступ имя базового класса,

доступ имя базового класса,

доступ имя базового класса, { }

В этом случае конструкторы выполняются слева направо, в порядке, задаваемом в объявлении производного класса, деструкторы, как водится в обратном порядке.

При прямом наследовании, однако, могут возникнуть проблемы. Пусть у нас есть два разных производных класса от одного базового. Пусть существует третий производный класс, использующий напрямую как базовые оба производных класса. Тогда третий производный класс косвенно наследует исходный базовый класс два раза, что приводит к противоречиям при реализации: результаты работы какого конструктора базового класса считать исходными для производного класса?

Для разрешения этой коллизии было введено понятие виртуального базового класса. Класс наследуется виртуально тогда, когда перед спецификатором доступа к базовому классу стоит ключевое слово virtual.

class имя класса : virtual доступ имя базового класса.

Тогда в ситуации, описанной выше, базовый класс наследуется только один раз! Во всём остальном виртуальный класс не отличается от обычного базового класса.

Подобная система не совсем корректна. Здесь косвенно требуется, чтобы производный класс «заранее знал», что он будет базовым для другого класса, который может наследовать от нескольких потомков одного базового класса. Естественней было сделать, чтобы по умолчанию все классы наследовались виртуально!

Для дальнейшего обсуждения нам необходимо вспомнить указатели на функцию.

Обычное объявление указателя на функцию выглядит так:

возвращаемый тип (*имя) (список параметров);

Так мы можем иметь несколько разных функций с одним и тем же списком входных параметров и одним возвращаемым типом. Для обращения к функции по указателю не требуется операции разыменования, а для получения адреса функции не требуется операции адресации: транслятор и так всё понимает. То есть, все нижеприведённые примеры законны:

void (*fn) (); void SF ();

fn = SF; fn();

Функция, вызываемая через указатель, должна вызываться абсолютно правильно: с правильными типами входных параметров и правильным типом возвращаемого значения: транслятор не будет строить никакого неявного преобразования.

Ещё одна особенность указателей: указатель на базовый класс можно использовать, как указатель на любой класс производный от базового. Обратный порядок неверен. При использовании указателя базового класса как указателя на объекты производного класса следует иметь ввиду, что этим указателем можно сослаться только на унаследованные от базового класса члены. Кроме того, при выполнении действий арифметики с такими указателями они будут изменяться на длины базового объекта.

Если в каком-либо классе некоторая функция объявлена с ключевым словом virtual, такая функция называется виртуальной. Это значит, что в производном классе она может быть переопределена. Виртуальная функция обязана быть членом класса.

Идея виртуальных функций позволяет воплотить в жизнь идею полиморфизма: один интерфейс – множество методов. Каждое переопределение виртуальной функции в производном классе определяет конкретную реализацию метода для производного класса. Виртуальная функция вызывается также как обыкновенная и с этой точки зрения ничего интересного не представляет. Но если виртуальная функция вызывается через указатель на базовый класс, то все вызовы этой виртуальной функции могут быть реализованы одним обращением для всех производных классов: транслятор, в зависимости от того на какой объект указывает указатель, строит вызов именно той версии виртуальной функции, которая связана с указываемым объектом. То есть: тип адресуемого через указатель объекта определяет, какая версия виртуальной функции вызывается. Это решение принимается во время выполнения программы! Другими словами программа становится динамически полиморфна.

Если виртуальная функция не переопределяется в производном классе, то вызывается её базовая версия. Чаще всего, однако, базовый класс рассматривается как набор функций-членов и переменных, для которых производный класс задаёт всё недостающее. В этом случае определение виртуальной функции в базовом классе будет просто ненужным, так как эта функция всё равно будет переопределяться в производном классе. Тогда транслятору следует как-то сообщить, что данная виртуальная функция имеет в классе только прототип, но не имеет определения. Таким образом – чисто виртуальная функция:

virtual тип имя функции ( список параметров) = 0;

Если класс содержит хотя бы одну чисто виртуальную функцию, то такой класс называется абстрактным классом. Поскольку нет реализации хотя бы одной из функций, то нельзя построить объект этого класса, но можно использовать этот класс для наследования.

В связи с полиморфизмом следует обсудить ещё два термина: раннее связывание и позднее связывание.

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

Позднее связывание относится к событиям, которые происходят в процессе выполнения программы. Адрес вызываемой функции в этом случае неизвестен до запуска программы. Вызов виртуальной функции – типичный вызов позднего связывания. Только во время работы программы определяется с каким типом объектов работает программа и следовательно какие функция она будет вызывать. Программы становятся более гибкими в использовании, но более сложными, длинными и медленно работающими объектами.

Родовая функция, или шаблонная функция, или параметризованная функция:
определяет базовый набор операций, который будет применяться к разным типам данных. Известно, что многие операции или алгоритмы можно применять к разным типам данных: сортировка, сравнение, перестановка и т.д. Конечно, функции, реализующие такие алгоритмы можно перегружать. Тем более, что часто вся перегрузка представляет собой только замену типа в объявлении и определении функции. Однако, нельзя ли этот процесс переложить на транслятор? Можно с использованием функции – шаблона.

template < class Type> возвращаемый тип имя функции (список входных параметров);

где Type – имя типа: имя далее используемое вместо реального типа данных.

Напишем простую шаблонную функцию перестановки элементов.

template <class Sw> void swap (Sw &x, Sw &y) {

Sw a;

a = x;

x = y;

y = a;

}

Эту функцию можно использовать с любым типом данных. Транслятор сам сгенерирует необходимый код, в зависимости от того с каким типом вызывается программа. Это означает то, что транслятор должен иметь одновременный доступ к прототипу функции (класса; шаблона) и к обращению, иначе транслятор не сможет сгенерировать всех обращений к функциям. Обратите внимание, что имя Sw везде используется вместо типа данных. Иногда говорят, что функция перегружает сама себя. Если необходимо несколько типов данных, то используется несколько имён через запятую:

template <class Sw, class Uw, class Kw> void func (Sw &x, Uw &y, Kw &d) …

Точно так же, как родовые/параметризованные функции, можно создать параметризованный класс.

template <class Ttype> class имя класса { … … }

Ttype – фиктивное имя имени типа. Реальное имя типа проявится при создании объекта класса:

Имя класса < type > имя объекта.

Точно так же можно использовать несколько имён типов, разделяя их запятыми.


Вопросы к главе 6.


1. Суть процесса разработки класса.

2. Что такое класс?

3. Инкапсуляция. Что это такое?

4. Полиморфизм. Что это такое?

5. Наследование. Что это такое?

6. Схема объявления класса в языке Cи++.

7. Что такое конструктор? Деструктор?

8. Конструктор копирования?

9. Когда вызывается конструктор?

10. Схема наследования в языке Cи++.

11. Порядок вызова конструктора и деструктора при наследовании.

12. Строение любого класса.

13. Перегрузка операторов, Что это такое?

14. Понятие ссылки в языке Cи++.

15. Перегрузка бинарных операторов.

16. Перегрузка унарных операторов.

17. Дружественные функции. Зачем они нужны?

18. Перегрузка оператора [].

20. Что такое виртуальный базовый класс?

21. Что такое виртуальная функция?

22. Что такое родовая функция?

23. Шаблоны.

24. Что такое родовой класс?

25. Что происходит при присваивании одного объекта другому?
  1. В чём разница между перегрузкой бинарной операции членом-функции
    и дружественной функцией.
  2. Зачем может потребоваться перегрузка оператора присваивания?
  3. Что такое абстрактный класс?
  4. Чего нельзя делать при перегрузке операций?
  5. В чём разница между функции-членом класса, дружественной функцией и обычной функцией?
  6. Укажите обычную схему доступа к закрытым членам класса и программы, использующей объекты класса.
  7. Можно ли создать объекты абстрактного класса?