Полиморфизм

Заключение.

Эффективное использование классов позволяет усовершенствовать процесс разработки сложных систем и сократить время на их разработку, отладку и сопровождение. Язык С# стимулирует многократное использование программного кода предоставлением библиотеки классов .NET Framework (FCL), обладающей максимальными преимуществами повторного использования программных средств через наследования.

Наследование класса Y от класса X означает, что Y представляет собой разновидность класса X, то есть более конкретную, частную концепцию. Базовый класс Х является более общим понятием, чем Y. Везде, где можно использовать X, можно использовать и Y, но не наоборот (вспомните, что на место базового класса можно передавать любой из производных). Необходимо помнить, что во время выполнения программы не существует иерархии классов и передачи сообщений объектам базового класса из производных — есть только конкретные объекты классов, поля которых формируются на основе иерархии на этапе компиляции.

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

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

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

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

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

Альтернативным наследованию механизмом использования одним классом другого является вложение, когда один класс является полем другого. Вложение представляет отношения классов "Y содержит X" или "Y реализуется посредством X". Для выбора между наследованием и вложением служит ответ на вопрос о том, может ли у Y быть несколько объектов класса X ("Y содержит X"). Кроме того, вложение используется вместо наследования тогда, когда про классы X и Y нельзя сказать, что Y является разновидностью X, но при этом Y использует часть функциональности X ("Y реализуется посредством X").

Рекомендации программисту.

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

2. Необходимо помнить, что создание производного класса не влияет на исходный программный код базового класса. Наследование сохраняет целостность базового класса.

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

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

 

Под полиморфизмом в объектно-ориентированном программировании понимают способность одного и того же программного текста objectX.Method() выполняться по-разному, в зависимости от того, с каким объектом связана сущность objectX. Полиморфизм означает, что объекты разных классов могут по-разному реагировать на вызовы одного и того же метода. Полиморфизм гарантирует, что вызываемый метод Method() будет принадлежать только тому классу объекта, которому принадлежит данный метод. В основе полиморфизма, характерного для семейства классов, лежат три механизма:

* Приведение объекта класса-потомка к объектам родительских классов. Этот механизм позволяет рассматривать объекты классов-потомков и как полноценные представители их родительских классов.

* Переопределение классом-потомком методов, унаследованных от его родителя. Благодаря переопределению, в семействе классов может существовать совокупность полиморфных методов с одним и тем же именем и сигнатурой;

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

В совокупности все это называется полиморфизмом семейства классов. Целевую сущность (класс или метод) часто называют полиморфной сущностью, вызываемый метод - полиморфным методом, а сам вызов - полиморфным вызовом.

Полиморфизм наследующих классов.

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

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

С помощью оператора new можно создать экземпляр любого класса, который входит в это дерево наследования. Например, экземпляр упомянутых классов можно создать с помощью следующей записи на языке С#:

class TC {…}

class НTC : TC {…}

class M : HTC {…}

TC vehicle = new TC();

HTC groundVehicle = new HTC();

M motocycle = new M();

В результате, под экземпляры указанных классов компьютером будут выделены соответствующие области памяти, размеры которых соответствуют их типам. А переменные vehicle, motocycle и motocycle будут хранить соответствующие адреса области памяти, выделенной под эти объекты. Допустим, что значение адреса под объект типа мотоцикла, равно 1000, а объем памяти, выделяемый под каждый тип объектов условно примем равными соответственно: 50, 86 и 100 байтам.

 

 

 
 

 

 


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

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

  • если приводимые типы (классы) связаны между собой отношением наследования;
  • если класс, к типу которого нужно привести (преобразовать) объект, является родительским классом.

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

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

На языке C# это можно показать с помощью следующего примера:

class A{…}

class B : A{…}

A a = new A();

B b = new B();

a = (A)b; // Такое преобразование типов сделать можно.

в = (B)a; // А вот такое – нельзя. Это ошибка!

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

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

Правила приведения типов.

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

Язык С# обеспечивает три способа определения того, что ссылка на объект базового класса действительно указывает на объект производного типа: явное приведение типа, ключевое слово is и ключевое слово as.

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

class A

{

functionА(){…}// функция базового класса А

}

class B : A

{

functionВ(){…}// собственная функция в классе В

}

public void f( A obj)

{

if(obj is B)

{

(В)obj.functionB();// вызов функции класса В

}

}

Здесь ключевое слово is используется для того, чтобы динамически (т.е. во время работы программы) определить реальный тип объекта. Однако, для того, чтобы получить доступ к содержимому реального объекта, необходимо использовать явное приведение типов.

Альтернативным способом является использование ключевого слова as для получения ссылки на объект производного типа. Если типы окажутся несовместимыми (например, не состоящими в отношении наследования), то ссылка получит значение равное null:

A a = new B();

B obj = a as B;

if(obj!=null) { obj.functionВ(); }// вызов функции класса В

Переопределение методов родительского класса. Раннее связывание.

Самый простой способрасширить функциональные возможности базовых классов заключается в порождении от них дочерних классов и добавлении в их состав новых методов. Рассмотрим конкретный пример.

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

class Rect

{

Rectangle area; // Положение и размеры прямоугольника

Color bc, fc; // Цвет контура и цвет закраски

public Rect(){ area=new Rectangle(100,100,200,200); } // Конструктор по умолчанию

// Еще один конструктор (с аргументами)

public Rect(Rectangle r)

{ area = new Rectangle(r.Location, r.Size); bc=Color.Dlack; fc=Color.Red; }

public void Move(int dx, int dy){ area.offset(dx, dy); } // Метод перемещения

public void Draw(Graphics g) // Метод рисования

{

SolidBrush br=new SolidBrush(fc); // Создание объекта кисти

g.FillRectangle(lgb, NodeRect); // Закрашивание области прямоугольника

Pen pencil = new Pen(bc, 1); // Создание объекта пера

g.DrawRectangle(pencil, NodeRect); // Рисование контура прямоугольника

br.Dispose(); // Освобождение ресурса кисти

pencil.Dispose(); // Освобождение ресурса пера

}

}

Как видно из листинга, в состав класса вошли переменные для описания положения и размера прямоугольника (area), цвета его контура (bc) и цвет закраски занимаемой области (fc), а также методы (Move и Draw), позволяющие перемещать и рисовать объекты этого класса.

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

class Ellipse : Rect

{

public void DrawEllipse(Graphics g) // Метод рисования

{

SolidBrush br=new SolidBrush(fc); // Создание объекта кисти

g.FillEllipse (br, area); // Закрашивание области эллипса

Pen pencil = new Pen(bc, 1); // Создание объекта пера

g.DrawEllipse(pencil, area); // Рисование контура эллипса

br.Dispose(); // Освобождение ресурса кисти

pencil.Dispose(); // Освобождение ресурса пера

}

}

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

В данном случае, в класс эллипса был добавлен метод DrawEllipse, имя которого отличается от имени унаследованного метода Draw родительского класса Rect. Но при таком подходе для каждого нового класса-потомка пришлось бы задавать уникальное имя каждому новому методу рисования. Например, для класса прямоугольника с текстовым комментарием – DrawRectangleWithText, для класса прямоугольника с комментарием и тенью – DrawRectangleWithTextAndShadow и т.д. Это не совсем удобно, потому, что приводит к необходимости создавать большое количество имен для методов, выполняющие действия одинакового смыслового назначения - визуальным отображением экземпляров класса. Более простым решением было бы сохранить имя унаследованного метода в классе-потомке, но определить в нем только новую реализацию этого метода. Вот как раз для этого и используется такая форма полиморфизма как переопределение.

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

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

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

class Ellipse : Rect

{

public Ellipse(Rectangle r) : Rect(r) // Конструктор

{ bc=Color.Dlack; fc=Color.Red; }

new public void Draw(Graphics g) // Метод рисования

{

SolidBrush br=new SolidBrush(fc); // Создание объекта кисти

g.FillEllipse (br, area); // Закрашивание области эллипса

Pen pencil = new Pen(bc, 1); // Создание объекта пера

g.DrawEllipse(pencil, area); // Рисование контура эллипса

br.Dispose(); // Освобождение ресурса кисти

pencil.Dispose(); // Освобождение ресурса пера

}

}

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

Переопределение как форма полиморфизма позволяет методу выполнять действия, которые зависят не только от его имени (названия), а еще и от типа объекта, у которого это метод вызывается. А это позволяет унифицированным образом (одинаково) обращаться к методам объектов разного типа.

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

Недостатки переопределения методов.

Рассмотрим следующий пример. Пусть у нас есть три класса: класс окружности (назовем его классом А), класс прямоугольника (класс В) и класс прямоугольника с текстовым комментарием (класс С), связанные между собой отношением наследования (рис.1).

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

А circle = new A();

B rect = new B();

C rect_txt = new C();

circle.Draw();

rect.Draw();

rect_txt.Draw();

 
 

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

Рис.1. Переопределение функции Draw в классах, связанных отношением наследования.

 

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

List<А> figures;

figures.Add(new A);

figures.Add(new B);

figures.Add(new C);

foreach(A figure in figures) figure.Draw();

Но теперь, несмотря на то, что в список фигур были добавлены экземпляры разных классов, на экране появятся изображения не трех разных фигур, а трех окружностей (Рис.2).

 
 

Рис.2. Вызов метода Draw у объектов, размещаемых в списке.

 

Дело в том, что, когда объекты разного типа добавлялись в список, то выполнялась операция неявного приведения типа каждого объекта к типу, определенному в объявлении списка (в нашем случае это класс А, то есть класс окружности). В результате список рассматривает все свои элементы (в том числе и объекты классов В и С) как элементы класса А, который для них является базовым классом. Поэтому, когда мы последовательно выбираем из списка его очередной элемент, то список ссылается на него как на объект класса А, а не на объект реального его класса (В или С). Поэтому при вызове у объектов, являющихся элементами списка, метода Draw вызывается только та его реализация, которая определяется типом ссылки на элемент списка, а не реальным типом объекта.