Лекция 19 (лекции (2002))
Описание файла
Файл "Лекция 19" внутри архива находится в папке "лекции (2002)". Документ из архива "лекции (2002)", который расположен в категории "". Всё это находится в предмете "языки программирования" из 7 семестр, которые можно найти в файловом архиве МГУ им. Ломоносова. Не смотря на прямую связь этого архива с МГУ им. Ломоносова, его также можно найти и в других разделах. .
Онлайн просмотр документа "Лекция 19"
Текст из документа "Лекция 19"
Динамически связанные методы нужны прежде всего для того, чтобы можно было гибко проектировать различного рода библиотеки. Под гибкостью при этом понимается то, что соответствующая библиотека является легко расширяемой.
Пример: отрисовка фигур.
Figure
Point Circle Rect
Пусть у нас есть некоторые объекты, общее название для которых, а стало быть и название базового класса - Figure. Известно, что каждая фигура умеет сама себя отрисовывать на экране, т.е. обладает методом
void Draw( );
Каждая фигура умеет себя двигать, т.е. обладает методом
void Move(int dx, int dy);
Каким образом фигура отрисовывается на экране по абстрактной фигуре ничего сказать нельзя. У каждой фигуры есть координаты точки привязки
int X, Y;
Для точки – это координаты самой точки. Для окружности – это координаты центра. Для прямоугольника – это, например, координаты левой верхней вершины. Для каждой конкретной фигуры мы просто переопределяем метод Draw. При этом метод Draw, естественно, является динамически связанным. Если мы объявим в классе Figure метод Draw виртуальным, и потом для каждой фигуры переопределим соответствующий виртуальный метод Draw, то, если, например, у нас есть некоторый массив фигур
Figure * a[];
для того, чтобы отрисовать весь массив
for(int i=0; i<N; i++)
a[i]->Draw();
Не смотря на то, что каждый объект массива – это просто указатель на абстрактную фигуру, за счет динамического связывания этот код будет работать.
Гибкость с точки зрения развития заключается в следующем. Этот код написан в предположении, что у нас есть классы Point, Circle, Rect. Если мы выведем еще какой-то тип данных (например, FillRect – заполненный прямоугольник).
Figure
Point Circle Rect
FillRect
Для него переопределим метод Draw так, чтобы он рисовал заполненный прямоугольник. Код, работающий с массивом от того, что мы добавили новый тип данных, никак не изменится. Поэтому во многих случаях не требуется перетрансляция.
На языках, в которых нет объектно-ориентированного программирования и динамического связывания, это реализовывалось бы с помощью записи с вариантами. И код, который бы выполнял соответствующую отрисовку, выглядел бы следующим образом
case …
when Circle => Draw
when … => …
when … => …
Для каждого нового типа мы должны будем добавлять новый when. Это чревато тем, что при появлении нового типа данных (например, новой фигуры) мы должны перелопатить весь проект, отловить все подобного рода места, и сделать в них соответствующую модификацию. В случае объектно-ориентированного программирования с динамическим связыванием этого делать не нужно. Поэтому как программисты, так и менеджеры, начиная с конца 80-ых г.г. с удовольствием ухватились за возможности, которые предоставляют соответствующие языки.
В языке С++ методы делятся на виртуальные и не виртуальные. Если функция описана в каком-то базовом классе как виртуальная
virtual void f( ){…}
то любое ее переопределение (замещение – override) в базовом классе с тем же прототипом обязательно является виртуальной функцией. Т.е. в С++ сделать ее не виртуальной нельзя.
В языке Java все методы по определению являются виртуальными, поэтому ключевого слова virtual там нет. Все методы в языке Java имеют только динамическое связывание.
Языки Delphi и C# пошли примерно по пути языка С++, но с некоторыми модификациями. И в Delphi, и в C# методы делятся на виртуальные и не виртуальные. Поэтому в этих языках есть ключевое слово virtual. Но и в Delphi, и в C# функция с тем же самым прототипом, не обязательно будет замещать виртуальную функцию в производном классе. В случае, если мы хотим, чтобы было динамическое замещение, то в языках C# и Delphi надо писать ключевое слово override. В языке С# если даже мы напишем
virtual void f( ){…};
в производном классе, то компилятор выдаст предупреждение потому, что, не смотря на то, что f объявлена виртуальной, мы явно не сказали, что она замещает соответствующую функцию в базовом классе. Предупреждение не будет выдано, если мы напишем override (тогда ключевое слово virtual писать не обязательно), либо если мы напишем new. new говорит о том, что мы не хотим замещать эту функцию. Т.е. начиная с этого нового класса в иерархии f приобретает совершенно другой смысл. Это немного более гибкий механизм. Подход языка С++ к реализации иерархии классов более строг. И в Delphi, и в C# от этого более строгого подхода отказались. Если нас не устраивает иерархия базовых классов в языке С++, единственная возможность – переделать реализацию этих классов. В языке C# можно отказаться от наследия базовых классов и внести свою собственную семантику в соответствующую операцию. Прежде всего это связано с тем, что если нам поставляется какая-то библиотека, даже если она поставляется в исходных текстах, теоретически мы не имеем права менять соответствующие функции в библиотеке. Точно также мы не имеем права ничего менять в NET.Framework. Но, если нас не устраивает функциональность NET.Framework, мы можем переопределить соответствующие функции.
Когда мы говорим о динамическом связывание, речь идет исключительно о способе вызова. Сами по себе реализации функций (тела функций) и в виртуальном, и в не виртуальном случае представляют собой одно и то же. Т.е. речь идет исключительно о механизме виртуального вызова. В языке С++, если у нас есть класс Х и в нем виртуальная функция f
Х х;
то
x:f( );
- это не виртуальный вызов. А вызов через указатель, или, соответственно, через ссылку
X * px; X & xx;
px->f( ); xx.f( );
виртуальный. Т.е. динамический тип в языке С++ имеется только у указателей и ссылок. В Java, C# и Delphi объявление
Х х;
вводит не сам объект, а ссылку, и инициализируется она следующим образом
X x = new X( );
Естественно, что все вызовы вида
x:f( );
по определению будут динамическими. Иногда динамический вызов можно снимать. В С++ виртуальность вызова можно снимать в функциях-членах того же самого класса. Это совершенно обычная ситуация потому, что довольно часто механизм реализации виртуальных функций следующий. Есть класс Х и в нем виртуальная функция f. Из него выведен класс Y и в нем есть виртуальная функция f. Мы можем либо полностью переписать функцию f, либо, в некоторых случаях, мы можем использовать и функциональность базовых функций, при этом добавляя в них что-нибудь еще. Например, тот же самый метод Draw может реализовываться различным образом. Если у нас есть базовый тип Rect и в нем есть метод Draw. Мы из этого типа выводим FillRect, и одна из возможных реализаций метода Draw для FillRect следующая: вызвать метод Draw для Rect, который просто обрисует рамку вокруг прямоугольника, а затем вызвать какой-то метод, который заполняет каким-то шаблоном внутренность этого прямоугольника. Нам необходимо добавить некоторую функциональность в метод Draw, но при этом мы используем и старый метод. Для этого и служит механизм снятия виртуального вызова. Он в языке С++ применяется только к функциям–членам самого этого класса. Т.е. если мы хотим вызвать в методе Draw для FillRect метод Draw для типа данных Rect просто пишем
Rect::Draw( );
Такая конструкция снимает виртуальность. Ее нельзя применять извне функций–членов.
В C# и Java в принципе тоже можно снимать виртуальный вызов, но в некоторых частных случаях. В языке Java при описании метода можно задать ключевое слово final:
final void f( ){…}
Поскольку в языке Java все методы связаны только динамически, при любом переопределении метода речь идет именно о его замещении. Ключевое слово final говорит о том, что этот метод в производных классах нельзя замещать. В языке С# есть аналогичное по смыслу ключевое слово sealed (запечатанный). Это означает, что любая попытка переопределить в производном классе соответствующий метод приводит к выдаче ошибки компиляции. Т.е. компилятор не позволит переопределить соответствующий метод. final и sealed нужны, во-первых, из соображений безопасности. Например, если у нас есть какой-то механизм проверки, то мы можем в произвольном классе его заместить и ввести свой, и таким образом обойти тот алгоритм, который нам задан с самого начала. Если создателю класса это не по нутру, он может запечатать или закрыть соответствующий метод для расширения, и тогда мы можем использовать только его реализацию. Во-вторых, как мы увидим чуть позже, динамическое связывание сводится к косвенной ссылке. Страуструп приводит для реализации языка С++ такую цифру: порядка 8 "лишних" ассемблерных операций с точки зрения машинного кода добавляется при виртуальном вызове (при динамическом связывании). Т.е. в общем случае динамическое связывание влечет за собой определенного рода расходы. В принципе возможны случаи, если компилятор видит, что в классе Х метод f является финальным или запечатанным, и если есть определение
X x = …;
при вызове
x.f( );
компилятор как в С#, так и в Java имеет право вставлять здесь не виртуальный вызов. Это так потому, что, т.к. f является финальным или соответственно запечатанным методом, и речь идет об объекте класса Х, ясно, что вызывается именно эта функция. Даже если динамический тип х больше Х, все равно f будет браться из класса Х. Поэтому компилятор вполне может в данном случае выполнить вызов
X.f( );
Это некоторый вариант оптимизации. Все доступные реализации Java и C# используют эту оптимизацию. Т.е. механизм виртуальности иногда можно снимать.
Еще есть некоторые тонкости, связанные с замещением. Например, в языке Java, или в языке C++ рассмотрим такую ситуацию:
class X{
virtual void f(int);
…
};
class Y: public X{
virtual void f(string &);
};
Класс Y не замещает функцию f. И в языке С++, и в языке Java такая ситуация является допустимой, и в данном случае произойдет перекрытие.
Y y; String s;
В данном случае, не смотря на то, что речь идет о двух вложенных областях действия, вполне можно вызывать и
y.f(1);
и
y.f(s);
И это не будет являться ошибкой.
В случае языка C# ситуация следующая: замещение в языке C# идет не по сигнатурам (сигнатура – это прототип), а по именам. Если функции являются виртуальными, здесь имеет место механизм замещения, и в языке C# в рассмотренном случае будет выдана ошибка. Т.е., если мы переопределяем функцию f даже с другой сигнатурой, то новая функция f скрывает функцию f из базового класса (механизм скрытия). И у той, и у другой точки зрения есть свои противники, и есть свои защитники. В любом случае (даже в случае языка C#) можно получить доступ к старой функции f за счет механизма преобразования типа. Т.е., если для объекта у нам хочется вызвать функцию y.f(1), нам нужно преобразовать ссылку на производный класс в ссылку на базовый класс
((X)y).f(1);
В C# взяли синтаксис приведения типов С.
Методы реализация динамического связывания в различных ЯП.
Оно реализовано достаточно простым способом. Рассмотрим прототип реализации для языка С++, который, в принципе, используется и в других языках программирования. Для каждого типа, в котором есть виртуальный метод, создается так называемая таблица виртуальных методов (ТВМ). Она одна (!) для каждого типа (а не для экземпляра). В объект, котором содержит виртуальный метод, добавляется одно невидимое для программиста поле – ссылка на соответствующую ТВМ. Виртуальные методы перечислены в описании типа в каком-то порядке. ТВМ состоит из указателей на функции – на реализации соответствующих виртуальных функций.
class X{
virtual void f( );
virtual void g( );
}
В этом классе 2 виртуальных функции.