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

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

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

А теперь рассмотрим этот пример с точки зрения программирования. При применении раннего связывания, мы как бы говорим компилятору: "Я точно знаю, чего я хочу. Поэтому жестко(статически) связывай все вызовы функций". При применении механизма позднего связывания мы как бы говорим компилятору: "Я пока не знаю чего я хочу. Когда придет время, я сообщу что и как я хочу".

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

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

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

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

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

Приведем определение виртуальных методов:

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

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

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

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

#include // подключение стандартной библиотек С++, в // которой описаны некоторый функции, применяемые в программе class vehicle // класс "транспортное средство" { int wheels; float weight; public: // начало публичного(открытого) раздела класса virtual void message(void) {cout message(); // вызываем метод message объекта delete unicycle; // удаляем объект unicycle // Все последующие блоки по 3 строки абсолютно идентичны первому // блоку с той лишь разницей, что изменяется класс создаваемого объекта // на car, truck, boat unicycle = new car; unicycle->message(); delete unicycle; unicycle = new truck; unicycle->message(); delete unicycle; unicycle = new boat; unicycle->message(); delete unicycle; }

Результаты работы программы(вывод на экран):

Транспортное средство Легковая машина Транспортное средство Лодка

Рассмотрим приведенный пример. У нас есть три класса car , truck и boat , которые являются производными от базового класса vehicle . В базовом классе vehicle описана виртуальная функция message . В двух из трех классов(car , boat ) также описаны свои функции message , а в классе truck нет описания своей функции message . Все строки, к которым я не приводил комментарии, не имеют принципиального для данного примера значения. Теперь пробежимся по основному блоку программы - функции main() . Описываем переменную unicycle , как указатель на объект типа vehicle . Не буду вдаваться в подробности, почему именно указатель на объект. Так надо. В данном случае воспринимайте работу с указателем, как с самим объектом. Подробности работы с указателями можно найти в описаниях конкретного языка ООП. Затем, создаем объект класса vehicle , переменная unicycle указывает на этот объект. После этого вызываем метод message объекта unicycle , а в следующей строке удаляем этот объект. В следующих трех блоках по 3 строки проводим аналогичные операции, с той лишь разницей, что работаем с объектами классов car , truck , boat . Применение указателя позволяет нам использовать один и этот же указатель для всех производных классов. Нас интересует вызов функции message для каждого из объектов. Если бы мы не указали, что функция message класса vehicle является виртуальной(virtual ), то компилятор статически(жестко) связал бы любой вызов метода объекта указателя unicycle с методом message класса vehicle , т.к. при описании мы сказали, что переменная unicycle указывает на объект класса vehicle . Т.е. произвели бы раннее связывание. Результатом работы такой программы был бы вывод четырех строк "Транспортное средство". Но за счет применения виртуальной функции в классе мы получили несколько другие результаты.

При работе с объектами классов car и boat вызываются их собственные методы message , что и подтверждается выводом на экран соответствующих сообщений. У класса truck нет своего метода message , по этой причине производится вызов соответствующего метода базового класса vehicle .

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

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

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

Связывание в языке C++

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

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

Рассмотрим, например, следующее описание классов и глобальных переменных: class Mammal

printf («cant speak»);

class Dog: public Mammal

printf («wouf wouf»);

printf («wouf wouf, as well»);

Mammal *fido = new Dog;

Выражение fred.speak() печатает «cant speak», однако вызов fido->speak() также напечатает «cant speak», поскольку соответствующий метод в классе Mammal не объявлен как виртуальный. Выражение fido->bark() не допускается компилятором, даже если динамический тип для fido класс Dog. Тем не менее статический тип переменной всего лишь класс Mammal.

Если мы добавим слово virtual:

virtual void speak()

printf («cant speak»);

то получим на выходе для выражения fido->speak() ожидаемый результат.

Относительно недавнее изменение в языке С++ добавление средств для распознавания динамического класса объекта. Они образуют систему RTTI (Run-Time Type Identification идентификация типа во время выполнения).

В системе RTTI каждый класс имеет связанную с ним структуру типа typeinfo, которая кодирует различную информацию о классе. Поле данных name одно из полей данных этой структуры содержит имя класса в виде текстовой строки. Функция typeid может использоваться для анализа информации о типе данных. Следовательно, следующая ниже команда будет печатать строку «Dog» динамический тип данных для fido. В этом примере необходимо разыменовывать переменную-указатель fido, чтобы аргумент был значением, на которое ссылается указатель, а не самим указателем:

cout << «fido is a» << typeid(*fido).name() << endl;

Можно также спросить, используя функцию-член before, соответствует ли одна структура с информацией о типе данных подклассу класса, соотносящегося с другой структурой. Например, следующие два оператора выдают true и false:

if (typeid(*fido).before (typeid(fred)))…

if (typeid(fred).before (typeid(lassie)))…

До появления системы RTTI стандартный программистский трюк состоял в том, чтобы явным образом закодировать в иерархии класса методы быть экземпляром. Например, для проверки значения переменных типа Animal на принадлежность к типу Cat или к типу Dog можно было бы определить следующую систему методов:

virtual int isaDog()

virtual int isaCat()

class Dog: public Mammal

virtual int isaDog()

class Cat: public Mammal

virtual int isaCat()

Теперь для определения того, является ли текущим значением переменной fido величина типа Dog, можно использовать команду fido->isaDog(). Если возвращается ненулевое значение, то можно привести тип переменной к нужному типу данных.

Возвращая указатель, а не целое число, мы объединяем проверку на принадлежность к подклассу и приведение типа. Это аналогично другой части системы RTTI, называемой dynamic_cast, которую мы вкратце опишем. Если некая функция в классе Mammal возвращает указатель на Dog, то класс Dog должен быть предварительно описан. Результатом присваивания является либо нулевой указатель, либо правильная ссылка на класс Dog. Итак, проверка результата все еще должна осуществляться, но мы исключаем необходимость приведения типа. Это показано в следующем примере:

class Dog; // предварительное описание

virtual Dog* isaDog()

virtual Cat* isaCat()

class Dog: public Mammal

virtual Dog* isaDog()

class Cat: public Mammal

virtual Cat* isaCat()

Оператор lassie = fido->isaDog(); теперь выполним всегда. В результате переменная lassie получает ненулевое значение, только если fido имеет динамический класс Dog. Если fido не принадлежит Dog, то переменной lassie будет присвоен нулевой указатель.

lassie = fido->isaDog();

… // fido и в самом деле относится к типу Dog

… // присваивание не сработало

… // fido не принадлежит к типу Dog

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

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

// конвертировать только в том случае, если fido является собакой

lassie = dynamic_cast < Dog* > (fido);

// затем проверить, выполнено ли приведение

В язык C++ были добавлены еще три типа приведения (static_cast, const_cast и reinterpret_cast), но они используются в особых случаях и поэтому здесь не описываются. Программистам рекомендуется применять их как более безопасные средства вместо прежнего механизма приведения типов.

2. Проектная часть

Последнее обновление: 04.02.2019

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

Переопределение

Возьмем пример с переопределением методов:

Class Person { public string FirstName { get; set; } public string LastName { get; set; } public Person(string firstName, string lastName) { FirstName = firstName; LastName = lastName; } public virtual void Display() { Console.WriteLine($"{FirstName} {LastName}"); } } class Employee: Person { public string Company { get; set; } public Employee(string firstName, string lastName, string company) : base(firstName, lastName) { Company = company; } public override void Display() { Console.WriteLine($"{FirstName} {LastName} работает в {Company}"); } }

Также создадим объект Employee и передадим его переменной типа Person:

Person tom = new Employee("Tom", "Smith", "Microsoft"); tom.Display(); // Tom Smith работает в Microsoft

Теперь мы получаем иной результат, нежели при сокрытии. А при вызове tom.Display() выполняется реализация метода Display из класса Employee.

Для работы с виртуальными методами компилятор формирует таблицу виртуальных методов (Virtual Method Table или VMT). В нее записывается адреса виртуальных методов. Для каждого класса создается своя таблица.

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

А при вызове виртуального метода из объекта берется адрес его таблицы VMT. Затем из VMT извлекается адрес метода и ему передается управление. То есть процесс выбора реализации метода производится во время выполнения программы. Собственно так и выполняется виртуальный метод. Следует учитывать, что так как среде выполнения вначале необходимо получить из таблицы VMT адрес нужного метода, то это немного замедляет выполнение программы.

Сокрытие

Теперь возьмем те же классы Person и Employee, но вместо переопределения используем сокрытие:

Class Person { public string FirstName { get; set; } public string LastName { get; set; } public Person(string firstName, string lastName) { FirstName = firstName; LastName = lastName; } public void Display() { Console.WriteLine($"{FirstName} {LastName}"); } } class Employee: Person { public string Company { get; set; } public Employee(string firstName, string lastName, string company) : base(firstName, lastName) { Company = company; } public new void Display() { Console.WriteLine($"{FirstName} {LastName} работает в {Company}"); } }

И посмотрим, что будет в следующем случае:

Person tom = new Employee("Tom", "Smith", "Microsoft"); tom.Display(); // Tom Smith

Переменная tom представляет тип Person, но хранит ссылку на объект Employee. Однако при вызове метода Display будет выполняться та версия метода, которая определена именно в классе Person, а не в классе Employee. Почему? Класс Employee никак не переопределяет метод Display, унаследованный от базового класса, а фактически определяет новый метод. Поэтому при вызове tom.Display() вызывается метод Display из класса Person.

Мы кратко познакомились с тем что это такое. По существу это просто переопределение методов суперкласса в подклассах. Но наверное вся мощь и красота этого еще не совсем понятна. И может не совсем ясно для чего все это нужно. Теперь попробуем разобраться более глубже. Приготовились к глубокой медитации. Оммммм…. Ну и погнали! :)

Возьмем затертый до дыр пример с фигурами. Не будем отклонятся от классиков жанра:)

И так общим суперклассом у нас будет класс Shape, и у него будут наследники царь, царевич, король, королевич, Circle, Square, Triangle. Но мы пойдем чуть дальше заезженного примера:) и образуем еще парочку наследников. Oval у нас будет наследником Circle, а Rect наследником Square.

На диаграмме все можно изобразить примерно так:

Методы drow() в каждом классе будут переопределены, а метод erase() будет просто наследоваться от Shape. Теперь осталось всю эту красоту забабахать в коде:)

Код у нас вышел очень красивый:) Буквочка к буквочке:) и вывод у него такой же:)

Теперь внимательно посмотрим на код. У нас есть одномерный массив shape классов Shape размером 6. И первому элементу массива мы присвоили ссылку на объект Shape (созадется new Shape()). А вот далее начинается магия, которую вы уже видели и должны понимать. Это называется восходящее преобразование. Я уже про это говорил, что ссылка суперкласса может указывать на объекты подклассов. И так далее мы присваиваем следующим элементам массива shape ссылки на подклассы. Но затем в выводе работает вообще сумасшедшая магия полиморфизма – вызываются методы подклассов, хотя ссылка имеет тип суперкласса.

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

А компилятор то и не знает… :) Ну а кто же тогда знает?

Кто, кто? Дракон в пальто!

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

Но я это сделал для простоты понимания и наглядности того что происходит.

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

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

Встает все тот же вопрос – кто знает метод какого объекта надо вызывать в каждом конкретном случае? А знает это JVM. Но как она узнает? И тут начинается серьезная магия виртуальной машины Java вкупе с компилятором Java.

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

Чтобы в полной мере разобраться в сути про-исходящего, необходимо рассмотреть понятие связывания (binding ).

Присоединение вызова метода к телу метода называется связыванием . Если связывание проводится перед запуском программы (компилятором и компоновщиком, если он есть), оно называется ранним связыванием (early binding ). В процедурных языках никакого выбора связывания не было. Компиляторы C поддерживают только один тип вызова — раннее связывание.

Проблема определения метод какого объекта вызывать в нашей программе решается благодаря позднему связыванию (late binding ), то есть связыванию, проводимому во время выполнения программы, в зависимости от типа объекта. Позднее связывание также называют динамическим (dynamic binding ) или связыванием на стадии выполнения (runtime binding ).

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

В прошлом посте , мы уже вкратце коснулись этого вопроса. Теперь постараемся понять более глубоко.

Для всех методов Java используется механизм позднего связывания, если только метод не был объявлен как private . Вызов private метода компилируется в инструкцию байт-кода invokespecial , которая вызывает реализацию метода из конкретного класса, определенного в момент компиляции . Вызов метода с другим уровнем доступа компилируется в invokevirtual , которая уже смотрит на тип объекта по ссылке в момент исполнения . Финальные неприватные методы тоже вызываются через invokevirtual .

В инструкцию байт-кода invokespesial компилируются:

  • Инициализационный вызов ( ) при создании объекта
  • Вызов private метода
  • Вызов метода с использованием ключевого слова super

Есть конечно еще несколько других инструкций байт-кода для вызова методов: invokedynamic , invokeinterface и invokestatic . Но хотя об их использовании и говорят их названия, пока мы их обсуждать не будем. Если кому-то сильно хочется то можно почитать на враждебном каждому правоверному программисту буржуйском языке:) Чтиво полезное, но для понимания того о чём сейчас речь, достаточно того, что я тут уже написал. Так же можно почитать на родном языке.

И так, надо уже переходить к практике. Модифицируем программу из этого поста , следующим образом:

Я подсветил private и final модификаторы чтобы вы обратили на них внимание и затем на то, какой байт-код для них создаст компилятор. Вывод у нашей программы сейчас следующий:

Заострю внимание на том, что ссылка root имеет тип Root, но указывает на объект типа Branch. И как я уже не однократно писал, обычные методы вызываются по версии объекта на который указывает ссылка. Именно через это свойство и реализуется полиморфизм.

Но в нашем случае, не смотря на это, первая команда вывела на консоль Root, а не Branch.

Теперь заглянем под капот этой программе при помощи команды: javap -c -p -v Root.class

Эта команда сгенерирует достаточно длинный вывод, но нам нужна только эта часть:

Как видно из вывода команда root.prt() была преобразована в вызов типа invokespecial , а команда branch.prt() в invokevirtual .

Вот мы и раскрыли магию всего этого действа. Надеюсь вам понравилось представление:) и теперь вы стали чуть больше понимать как работают полиморфные методы в Java.

--- Сборки.NET --- Позднее связывание

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

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

Класс System.Activator

Класс System. Activator (определенный в сборке mscorlib.dll) играет ключевую роль в процессе позднего связывания в.NET. В текущем примере интересует пока что только его метод Activator.CreateInstance() , который позволят создавать экземпляр подлежащего позднему связыванию типа. Этот метод имеет несколько перегруженных версий и потому обеспечивает довольно высокую гибкость. В самой простой версии CreateInstance() принимает действительный объект Type, описывающий сущность, которая должна размещаться в памяти на лету.

Чтобы увидеть, что имеется в виду, давайте создадим новый проект типа Console Application, импортируем в него пространства имен System.I0 и System.Reflection с помощью ключевого слова using и затем изменим класс Program, как-показано ниже:

Using System; using System.Reflection; using System.IO; namespace ConsoleApplication1 { class Program { static void Main() { Assembly ass = null; try { ass = Assembly.Load("fontinfo"); } catch (FileNotFoundException ex) { Console.WriteLine(ex.Message); } if (ass != null) CreateBinding(ass); Console.ReadLine(); } static void CreateBinding(Assembly a) { try { Type color1 = a.GetType("FontColor"); // Используем позднее связывание object obj = Activator.CreateInstance(color1); Console.WriteLine("Объект создан!"); } catch (Exception ex) { Console.WriteLine(ex.Message); } } } }

Прежде чем запускать данное приложение, необходимо вручную скопировать сборку fontinfo.dll в подкаталог bin\Debug внутри каталога этого нового приложения с помощью проводника Windows. Дело в том, что здесь вызывается метод Assembly.Load(), а это значит, что CLR-среда будет зондировать только папку клиента (при желании можно было бы воспользоваться методом Assembly.LoadFrom() и указывать полный путь к сборке, но в данном случае в этом нет никакой необходимости).