Чем отличается виртуальный вызов от виртуальной цепи
Перейти к содержимому

Чем отличается виртуальный вызов от виртуальной цепи

  • автор:

Чем отличается виртуальный вызов от виртуальной цепи

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

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

А чтобы переопределить метод в классе-наследнике, этот метод определяется с модификатором override . Переопределенный метод в классе-наследнике должен иметь тот же набор параметров, что и виртуальный метод в базовом классе.

Например, рассмотрим следующие классы:

class Person < public string Name < get; set; >public Person(string name) < Name = name; >public virtual void Print() < Console.WriteLine(Name); >> class Employee : Person < public string Company < get; set; >public Employee(string name, string company) : base(name) < Company = company; >>

Здесь класс Person представляет человека. Класс Employee наследуется от Person и представляет сотруднника предприятия. Этот класс кроме унаследованного свойства Name имеет еще одно свойство — Company.

Чтобы сделать метод Print доступным для переопределения, этот метод определен с модификатором virtual . Поэтому мы можем переопределить этот метод, но можем и не переопределять. Допустим, нас устраивает реализация метода из базового класса. В этом случае объекты Employee будут использовать реализацию метода Print из класса Person:

Person bob = new Person("Bob"); bob.Print(); // вызов метода Print из класса Person Employee tom = new Employee("Tom", "Microsoft"); tom.Print(); // вызов метода Print из класса Person
Bob Tom

Но также можем переопределить виртуальный метод. Для этого в классе-наследнике определяется метод с модификатором override , который имеет то же самое имя и набор параметров:

class Employee : Person < public string Company < get; set; >public Employee(string name, string company) : base(name) < Company = company; >public override void Print() < Console.WriteLine($"работает в "); > >

Возьмем те же самые объекты:

Person bob = new Person("Bob"); bob.Print(); // вызов метода Print из класса Person Employee tom = new Employee("Tom", "Microsoft"); tom.Print(); // вызов метода Print из класса Employee
Bob Tom работает в Microsoft

Виртуальные методы базового класса определяют интерфейс всей иерархии, то есть в любом производном классе, который не является прямым наследником от базового класса, можно переопределить виртуальные методы. Например, мы можем определить класс Manager, который будет производным от Employee, и в нем также переопределить метод Print.

При переопределении виртуальных методов следует учитывать ряд ограничений:

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

Ключевое слово base

Кроме конструкторов, мы можем обратиться с помощью ключевого слова base к другим членам базового класса. В нашем случае вызов base.Print(); будет обращением к методу Print() в классе Person:

class Employee : Person < public string Company < get; set; >public Employee(string name, string company) :base(name) < Company = company; >public override void Print() < base.Print(); Console.WriteLine($"работает в "); > >

Переопределение свойств

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

class Person < int age = 1; public virtual int Age < get =>age; set < if(value >0 && value < 110) age = value; >> public string Name < get; set; >public Person(string name) < Name = name; >public virtual void Print() => Console.WriteLine(Name); > class Employee : Person < public override int Age < get =>base.Age; set < if (value >17 && value < 110) base.Age = value; >> public string Company < get; set; >public Employee(string name, string company) : base(name) < Company = company; base.Age = 18; // возраст для работников по умолчанию >>

В данном случае в классе Person определено виртуальное свойство Age, которое устанавливает значение, если оно больше 0 и меньше 110. В классе Employee это свойство переопределено — возраст работника должен быть не меньше 18.

Person bob = new Person("Bob"); Console.WriteLine(bob.Age); // 1 Employee tom = new Employee("Tom", "Microsoft"); Console.WriteLine(tom.Age); // 18 tom.Age = 22; Console.WriteLine(tom.Age); // 22 tom.Age = 12; Console.WriteLine(tom.Age); // 22

Запрет переопределения методов

Также можно запретить переопределение методов и свойств. В этом случае их надо объявлять с модификатором sealed :

class Employee : Person < public string Company < get; set; >public Employee(string name, string company) : base(name) < Company = company; >public override sealed void Print() < Console.WriteLine($"работает в "); > >

При создании методов с модификатором sealed надо учитывать, что sealed применяется в паре с override, то есть только в переопределяемых методах.

И в этом случае мы не сможем переопределить метод Print в классе, унаследованном от Employee.

Виртуальный метод

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

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

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

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

Пример виртуальной функции на C++

Пример на C++, иллюстрирующий отличие виртуальных функций от невиртуальных:

class Ancestor  public: virtual void function1 ()  cout  <"Ancestor::function1()"  ; > void function2 ()  cout  <"Ancestor::function2()"  ; > >; class Descendant : public Ancestor  public: virtual void function1 ()  cout  <"Descendant::function1()"  ; > void function2 ()  cout  <"Descendant::function2()"  ; > >; Descendant* pointer = new Descendant (); Ancestor* pointer_copy = pointer; pointer->function1 (); pointer->function2 (); pointer_copy->function1 (); pointer_copy->function2 (); 

В этом примере класс Ancestor определяет две функции, одну из них виртуальную, другую — нет. Класс Descendant переопределяет обе функции. Однако, казалось бы одинаковое обращение к функциям даёт разные результаты. На выводе программа даст следующее:

Descendant::function1() Descendant::function2() Descendant::function1() Ancestor::function2()

То есть, в случае виртуальной функции, для определения реализации функции используется информация о типе объекта и вызывается «правильная» реализация, независимо от типа указателя. При вызове невиртуальной функции, компилятор руководствуется типом указателя или ссылки, поэтому вызываются две разные реализации function2() , несмотря на то, что используется один и тот же объект.

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

pointer->Ancestor::function1 (); 

для нашего примера выведет Ancestor::function1(), игнорируя тип объекта.

Пример виртуальной функции в Delphi

Язык Object Pascal, использующийся в Delphi, тоже поддерживает полиморфизм. Рассмотрим пример:

Объявим два класса. Предка (Ancestor):

TAncestor = class private protected public procedure VirtualProcedure; virtual; procedure StaticProcedure; end; 

и его потомка (Descendant):

TDescendant = class(TAncestor) private protected public procedure VirtualProcedure; override; procedure StaticProcedure; end; 

Как видно в классе предке объявлена виртуальная функция — VirtualProcedure . Чтобы воспользоваться достоинствами полиморфизма, её нужно перекрыть в потомке.

Реализация выглядит следующим образом:

 < TAncestor >procedure TAncestor.StaticProcedure; begin ShowMessage('Ancestor static procedure.'); end; procedure TAncestor.VirtualProcedure; begin ShowMessage('Ancestor virtual procedure.'); end; 
 < TDescendant >procedure TDescendant.StaticProcedure; begin ShowMessage('Descendant static procedure.'); end; procedure TDescendant.VirtualProcedure; begin ShowMessage('Descendant override procedure.'); end; 

Посмотрим как это работает:

procedure TForm2.BitBtn1Click(Sender: TObject); var MyObject1: TAncestor; MyObject2: TAncestor; begin MyObject1 := TAncestor.Create; MyObject2 := TDescendant.Create; try MyObject1.StaticProcedure; MyObject1.VirtualProcedure; MyObject2.StaticProcedure; MyObject2.VirtualProcedure; finally MyObject1.Free; MyObject2.Free; end; end; 

Заметьте, что в разделе var мы объявили два объекта MyObject1 и MyObject2 типа TAncestor . А при создании MyObject1 создали как TAncestor , а MyObject2 как TDescendant . Вот что мы увидим при нажатии на кнопку BitBtn1 :

  1. Ancestor static procedure.
  2. Ancestor virtual procedure.
  3. Ancestor static procedure.
  4. Descendant override procedure.

Для MyObject1 все понятно, просто вызвались указанные процедуры. А вот для MyObject2 это не так.

Вызов MyObject2.StaticProcedure; привел к появлению «Ancestor static procedure.». Ведь мы объявили MyObject2: TAncestor , поэтому и была вызвана процедура StaticProcedure; класса TAncestor .

А вот вызов MyObject2.VirtualProcedure; привел к вызову VirtualProcedure; реализованной в потомке( TDescendant ). Это произошло потому, что MyObject2 был создан не как TAncestor , а как TDescendant : MyObject2 := TDescendant.Create; . И виртуальный метод VirtualProcdure был перекрыт.

В Delphi полиморфизм реализован с помощью так называемой виртуальной таблицы методов (или VMT).

Достаточно часто виртуальные методы забывают перекрыть с помощью ключевого слова override . Это приводит к закрытию метода. В этом случае замещения методов в VMT не произойдет и требуемая функциональность не будет получена.

Эта ошибка отслеживается компилятором, который выдаёт соответствующее предупреждение.

Вызов метода предка из перекрытого метода

Бывает необходимо вызвать метод предка в перекрытом методе.

Объявим два класса. Предка(Ancestor):

TAncestor = class private protected public procedure VirtualProcedure; virtual; end; 

и его потомка (Descendant):

TDescendant = class(TAncestor) private protected public procedure VirtualProcedure; override; end; 

Обращение к методу предка реализуется с помощью ключевого слова «inherited»

procedure TDescendant.VirtualProcedure; begin inherited; end; 

Стоит помнить, что в Delphi деструктор должен быть обязательно перекрытым — «override» — и содержать вызов деструктора предка

TDescendant = class(TAncestor) private protected public destructor Destroy; override; end; 
destructor TDescendant. Destroy; begin inherited; end; 

В языке C++ не нужно вызывать конструктор и деструктор предка, деструктор должен быть виртуальным. Деструкторы предков вызовутся автоматически. Чтобы вызвать метод предка, нужно явно вызвать метод:

class Ancestor  public: virtual void function1 ()  printf("Ancestor::function1"); > >; class Descendant : public Ancestor  public: virtual void function1 ()  printf("Descendant::function1"); Ancestor::function1(); // здесь будет напечатано "Ancestor::function1" > >; 

Для вызова конструктора предка нужно указать конструктор:

class Descendant : public Ancestor  public: Descendant(): Ancestor()> >; 

Еще один пример

class A  public: virtual int function ()  return 1; > int get()  return this->function(); > >; class B: public A  public: int function()  return 2; > >; #include int main()  A a; B b; std::cout  get()  ::endl; // 2 return 0; > 

Несмотря на то, что в классе B отсутствует метод get(), его можно позаимствовать у класса A, при этом результат работы этого метода вернет вычисления для B::function()!

См. также

  • Объектно-ориентированное программирование
  • Полиморфизм (программирование)
  • Абстрактный класс

Ссылки

  • C++ FAQ Lite: Виртуальные функции в C++ (англ.)
  • Метод (информатика)

Wikimedia Foundation . 2010 .

Полезное

Смотреть что такое «Виртуальный метод» в других словарях:

  • виртуальный метод доступа к памяти — — [http://www.iks media.ru/glossary/index.html?glossid=2400324] Тематики электросвязь, основные понятия EN virtual storage access methodVSAM … Справочник технического переводчика
  • виртуальный телекоммуникационный метод доступа — Программа IBM, постоянно хранящаяся в памяти на сервере и управляющая обменом информацией и потоками данных в сети SNA. [http://www.morepc.ru/dict/] Тематики информационные технологии в целом EN Virtual Telecommunications Access MethodVTAM … Справочник технического переводчика
  • Метод контурных токов — Метод контурных токов метод сокращения размерности системы уравнений, описывающей электрическую цепь. Содержание 1 Основные принципы 2 Построение системы контуров … Википедия
  • Виртуальный собеседник — В этой статье не хватает ссылок на источники информации. Информация должна быть проверяема, иначе она может быть поставлена под сомнение и удалена. Вы можете … Википедия
  • Абстрактный метод — Эту статью следует викифицировать. Пожалуйста, оформите её согласно правилам оформления статей. Абстрактный метод (или чистый виртуальный метод (pure virtual method часто неверно переводится как чисто виртуальный метод)) в … Википедия
  • Фабричный метод (шаблон проектирования) — Шаблон проектирования Фабричный метод Factory Method Тип: порождающий Описан в Design Patterns Да Фабричный метод (англ. Factory Method) порождающий шаблон проектирования, предоставляющий подклассам интерфейс для созда … Википедия
  • Фабричный метод — (англ. Factory Method) порождающий шаблон проектирования, предоставляющий подклассам интерфейс для создания экземпляров некоторого класса. В момент создания наследники могут определить, какой класс инстанциировать. Иными словами, Фабрика… … Википедия
  • интеллектуальный постоянный виртуальный канал — Метод организации связи с использованием гибридного логического соединения, создаваемого на основе сочетания двух типов виртуальных каналов постоянных (PVC) и коммутируемых (SVC). Администратор сети производит настройку соединений, однако… … Справочник технического переводчика
  • Виртуальная функция — Виртуальный метод (виртуальная функция) в объектно ориентированном программировании метод (функция) класса, который может быть переопределён в классах наследниках так, что конкретная реализация метода для вызова будет определяться во время… … Википедия
  • Сравнение C Sharp и Java — Правильный заголовок этой статьи Сравнение C# и Java. Он показан некорректно из за технических ограничений. Сравнения языков программирования Общее сравнение Основной синтаксис Основные инструкции Массивы Ассоциативные массивы Операции со… … Википедия
  • Обратная связь: Техподдержка, Реклама на сайте
  • �� Путешествия

Экспорт словарей на сайты, сделанные на PHP,
WordPress, MODx.

  • Пометить текст и поделитьсяИскать в этом же словареИскать синонимы
  • Искать во всех словарях
  • Искать в переводах
  • Искать в ИнтернетеИскать в этой же категории

Чем отличается виртуальный вызов от виртуальной цепи

При вызове функции программа должна определять, с какой именно реализацией функции соотносить этот вызов, то есть связать вызов функции с самой функцией. В С++ есть два типа связывания — статическое и динамическое.

Когда вызовы функций фиксируются до выполнения программы на этапе компиляции, это называется статическим связыванием (static binding), либо ранним связыванием (early binding). При этом вызов функции через указатель определяется исключительно типом указателя, а не объектом, на который он указывает. Например:

#include class Person < public: Person(std::string name): name < >void print() const < std::cout private: std::string name; // имя >; class Employee: public Person < public: Employee(std::string name, std::string company): Person, company  < >void print() const < Person::print(); std::cout private: std::string company; // компания >; int main() < Person tom ; Person* person ; person->print(); // Name: Tom Employee bob ; person = &bob; person->print(); // Name: Bob >

В данном случае класс Employee наследуется от класса Person, но оба этих класса определяют функцию print() , которая выводит данные об объекте. В функции main создаем два объекта и поочередно присваиваем их указателю на тип Person и вызываем через этот указатель функцию print. Однако даже если этому указателю присваивается адрес объекта Employee, то все равно вызывает реализация функции из класса Person:

Employee bob ; person = &bob; person->print(); // Name: Bob

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

Name: Tom Name: Bob

Динамическое связывание и виртуальные функции

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

Итак, сделаем функцию print в базовом классе Person виртуальной:

#include class Person < public: Person(std::string name): name < >virtual void print() const // виртуальная функция < std::cout private: std::string name; >; class Employee: public Person < public: Employee(std::string name, std::string company): Person, company  < >void print() const < Person::print(); std::cout private: std::string company; >; int main() < Person tom ; Person* person ; person->print(); // Name: Tom Employee bob ; person = &bob; person->print(); // Name: Bob // Works in Microsoft >

Таким образом, базовый класс Person определяет виртуальную функцию print, а производный класс Employee переопределяет ее. В первом же примере, где функция print не была виртуальной, класс Employee не переопределял, а скрывал ее. Теперь при вызове функции print для объекта Employee через указатель Person* будет вызываться реализация функции именно класса Employee. Соответственно тепепрь мы получим другой консольный вывод:

Name: Tom Name: Bob Works in Microsoft

В этом и состоит отличие переопределения виртуальных функций от скрытия.

Класс, который определяет или наследует виртуальную функцию, еще назвается полиморфным (polymorphic class). То есть в данном случае Person и Employee являются полиморфными классами.

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

Employee bob ; Person p = bob; p.print(); // Name: Bob - статическое связывание

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

Employee bob ; Person &p ; // присвоение ссылке p.print(); // динамическое связывание Person *ptr ; // присвоение адреса указателю ptr->print(); // динамическое связывание

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

Также статические функции не могут быть виртуальными.

Ключевое слово override

Чтобы явным образом указать, что мы хотим переопредлить функцию, а не скрыть ее, в производном классе после списка параметров функции указывается слово override

#include class Person < public: Person(std::string name): name < >virtual void print() const // виртуальная функция < std::cout private: std::string name; >; class Employee: public Person < public: Employee(std::string name, std::string company): Person, company  < >void print() const override // явным образом указываем, что функция переопределена < Person::print(); std::cout private: std::string company; >; int main() < Person tom ; Person* person ; person->print(); // Name: Tom Employee bob ; person = &bob; person->print(); // Name: Bob // Works in Microsoft >

То есть здесь выражение

void print() const override

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

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

Принцип выполнения виртуальных функций

Стоит отметить, что виртальные функции имеют свою цены — объекты классов с виртуальными функциями требуют немного больше памяти и немного больше времени для выполнения. Поскольку при создании объекта полиморфного класса (который имеет виртуальные функции) в объекте создается специальный указатель. Этот указатель используется для вызова любой виртуальной функции в объекте. Специальный указатель указывает на таблицу указателей функций, которая создается для класса. Эта таблица, называемая виртуальной таблицей или vtable, содержит по одной записи для каждой виртуальной функции в классе.

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

vtable, полиформизм и виртуальные функции в C++

  1. Указатель на vtable в объекте используется для поиска адреса vtable для класса.
  2. Затем в таблице идет поиск указателя на вызываемую виртуальную функцию.
  3. Через найденный указатель функции в vtable вызывается сама функция. В итоге вызов виртуальной функции происходит немного медленнее, чем прямой вызов невиртуальной функции, поэтому каждое объявление и вызов виртуальной функции несет некоторые накладные расходы.

Запрет переопределения

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

class Person < public: virtual void print() const final < >>; class Employee : public Person < public: void print() const override // Ошибка. < >>;

Также можно переопределить функцию базового класса, но запретить ее переопределение в дальнейших производных классах:

class Person < public: virtual void print() const // переопределение разрешено < >>; class Employee : public Person < public: void print() const override final // в классах, производных от Employee переопределение запрещено < >>;

NEWOBJ.ru → Введение в ООП с примерами на C# →

Теперь положим, для чтения текущего значения curVal с устройства используется метод Readout . Где нам его следует разместить: в базовом классе или в производных классах? Если мы его разместим в производных классах, то мы не сможем его вызвать через экземпляр базового класса. Если же мы разместим его в базовом классе – сможем ли мы привести общую реализацию? Очевидно, что нет, так как в этом и причина создания нескольких производных классов – они имеют разную реализацию загрузки данных с устройств.

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

 // АНТИШАБЛОН class Sensor < /* . */ public void Readout() < if (this is TemperatureSensor) < ((TemperatureSensor)this).ReadoutTemperature48(); > else if (this is PressureSensor) < ((PressureSensor)this).ReadoutPressure(); >else < throw new NotImplementedException(); >> > 

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

 abstract class Sensor < /* . */ public abstract void Readout(); > class TemperatureSensor : Sensor < /* . */ public override void Readout() < // Считывает значение по датчику температуры из базы и сохраняет в curVal. Console.WriteLine (‘TemperatureSensor.Readout’); curVal = 1; >> class PressureSensor : Sensor < /* . */ public override void Readout() < // Считывает значение по датчику давления из базы и сохраняет в curVal. Console.WriteLine (‘PressureSensor.Readout’); curVal = 2; >> Sensors[] sensors = new Sensors[2]; sensors[0] = new TemperatureSensor(“T-101”); sensors[1] = new PressureSensor (“P-202”); // Использование для массива Sensor[] sensors: for (int i = 0; i < sensors.Length; i++) < Sensor s = sensors[i]; // Сначала актуализируем значение из базы данных. s.Readout(); // Потом выводим его. Console.WriteLine ($”= ”); > 

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

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

В приведенном коде в первой итерации цикла ( i == 0 ) переменная s типа Sensor обозначает объект реального типа TemperatureSensor , поэтому при вызове метода s.Readout будет вызван код этого метода из класса TemperatureSensor . В следующей же итерации цикла ( i == 1 ) переменная s типа Sensor обозначает объект другого реального типа – PressureSensor , поэтому при вызове метода s.Readout будет вызван код этого метода из класса PressureSensor .

 > TemperatureSensor > T-101 = 1 > PressureSensor > P-202 = 2 

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

Абстрактный метод (abstract) – метод, объявленный без реализации (тела); реализация метода может быть приведена в любом из подклассов; класс, в котором объявлен хотя бы один абстрактный метод или который наследует, но не реализует, хотя бы один абстрактный метод, является абстрактным классом; не допускается создание экземпляров абстрактного класса 50 .

Переопределение метода (overriding) – определение (с телом или без) в производном классе метода с сигнатурой, совпадающей с сигнатурой метода, определенного в базовом классе.

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

Продемонстрируем это следующим примером:

 // Объявлен абстрактный метод void M(). abstract class A < public abstract void M(); >// Метод базового класса не переопределяется, // поэтому класс B также абстрактный. abstract class B : A < >// Класс C переопределяет абстрактный метод класса A. class C : B < public override void M() < Console.WriteLine (“C”); >> // Класс D переопределеяет метод класса C. // Для экземлпяров класса D будет использоваться именно эта реализация // (ближайшая по цепочке наследования), а не из класса C. class D : C < public override void M() < Console.WriteLine (“D”); >> // Класс E переопределяет метод класса D как абстрактный, // таким образом, «скрывает» переопределение из класса D. abstract class E : D < public abstract override void M(); >// Класс F переопределяет метод класса E. class F : E < public override void M() < Console.WriteLine (“F”); >> // Класс G не переопределяет метод М(), // но так как в базовом классе F этот метод переопределен как конкретный, // то класс G – конкретный класс и при вызове для его экземпляра метода M // будет вызвана реализация из класса F (ближайшего по цепочке наследования). class G : F < >// Использование: A c = new C(); c.M(); // Вывод: С A d = new D(); d.M(); // Вывод: D A f = new F(); f.M(); // Вывод: F ((D)f).M(); // Вывод: F A g = new G(); g.M() // Вывод: F 

То есть, еще раз: при вызове метода класса, в частности, при вызове абстрактного метода, всегда вызывается «самая частная» («самая производная») реализация в иерархии наследования для реального типа объекта, а не для типа переменной, через которую мы вызываем метод. На следующем рисунке показано, что при вызове метода M() для переменной g типа A будет вызвана реализация из класса F , так как она ближе всего в иерархии наследования к реальному типу объекта – G .

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

 // Данные отдельного измерения. class Data < // Время измерения. public DateTime Time; // Измеренное значение. public float Value; >abstract class Sensor < /* . */ // Теперь это неабстрактный метод базового класса. public void Readout() < // Вызываем из метода базового класса абстрактный метод, // который будет «доопределен» в производном классе. Data[] data = ReadoutRaw(); // Сохраняем последнее значение. if (data.Length >0) < curValue = data[data.Lenght – 1]; >> public abstract Data[] ReadoutRaw(); > class TemperatureSensor : Sensor < /* . */ public override Data[] ReadoutRaw () < /* . */ >> class PressureSensor : Sensor < /* . */ public override Data[] ReadoutRaw () < /* . */ >> 

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

 Sensor sensor = new TemperatureSensor(); // Вызов абстрактного метода приводит к выполненению реализации // для реального типа объекта, // здесь – к выполнению метода из класса TemperatureSensor Data[] data = sensor.ReadoutRow(); abstract class Sensor < public void Readout() < // Эквивалентно: Data[] data = this.ReadoutRaw(); Data[] data = ReadoutRaw(); /* . */ > public abstract Data[] ReadoutRaw(); /* . */ > 

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

 class A < protected abstract string M(); public A() < // Использование абстрактных методов в конструкторе – плохая практика! N(); >public void N () < Console.WriteLine (M()); >> class B < private string m = “1”; public B() < m = “2”; >protected override string M() < return m; >> B b = new B(); // Вывод: 1 b.N(); // Вывод: 2 

Объясните, почему при создании объекта при вызове метода N выводится значение 1, а при явном вызове метода N – 2.

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

§ 38. Виртуальные методы. В примере с датчиками из предыдущего параграфа метод GetCurVal имеет общую для всех производных классов сигнатуру и реализацию (это обычный, «конкретный» метод), а метод Readout имеет общую для всех производных классов сигнатуру, но реализация определяется в каждом из классов отдельно (абстрактный метод). Однако вполне можно представить, что семантика некоторых методов будет таковой, что у них или (первая ситуация) будет общая реализация, но она будет общей только для некоторых производных классов, или (вторая ситуация) для некоторых производных классов потребуется дополнить общую реализацию некоторым кодом. Рассмотрим такие ситуации.

Логика работы метода Readout() вполне устраивает нас для датчиков, считывающих значение с периодичностью раз в несколько секунд. Но, положим, некоторый датчик измеряет скорость потока жидкости в трубе и физически фиксирует значение несколько раз в секунду. В этом случае, последнее из измеренных за интервал значение будет не очень информативно, а большая часть измерений будет пропускаться и не отображаться пользователю вовсе. Мы хотели бы для повышения информативности выводить среднее арифметическое полученных за некоторый период, положим, за прошедшие 3 секунды, значений. Таким образом, реализация метода Readout нас устраивает для производных классов TemperatureSensor и PressureSensor , но для класса VelocityHDSensor нам требуется другая реализация, что соответствует описанной в начале параграфа первой ситуации.

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

 abstract class Sensor < public abstract string GetUnits(); public string GetCurValString() < return curVal + “ “ + GetUnit(); >> class PressureSensor : Sensor < public override string GetUnits() < return “МПа”; >> 

Приведенный код вполне решает наши задачи, однако, допустим, для класса VelocityHDSensor , который определяет текущее значение как среднее арифметическое, мы хотели бы дополнить метод GetCurValString , выводя в конце строки в скобках текст “avg”. То есть, если для класса PressureSensor метод вернет, например, “1,2 МПа”, то для класса VelocityHDSensor мы бы хотели получить «0,4 м/с (avg)». Это пример второй ситуации, обозначенной в начале параграфа.

Как мы можем решить эти две задачи?

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

 // АНТИШАБЛОН abstract class Sensor < public string GetCurValString() < return GetCurValStringExt (curVal + “ “ + GetUnit()); > public abstract string GetUnits(); public abstract string GetCurValStringExt (string b); > class TemperatureSensor : Sensor < public string GetCurValStringExt (string b) < return b; >> class PressureSensor : Sensor < public string GetCurValStringExt (string b) < return b; >> class VelocityHDSensor : Sensor < public override string GetCurValStringExt (string b) < return b + “ (avg)”; >> 

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

Внимательный читатель может предложить еще одно решение, подходящее для обоих ситуаций, используя только механизм абстрактных и обычных методов. В предыдущем параграфе мы показывали, что абстрактный метод может быть перегружен несколько раз в иерархии наследования. Соответственно, мы можем определить дополнительный базовый класс SensorBase , в котором определить абстрактные методы Readout и GetCurValString , далее, сначала переопределить их в классе Sensor , а потом, при необходимости, переопределить их в некоторых из производных классов. Проанализируем код:

 // Также АНТИШАБЛОН, но существенно лучше предыдущего решения. abstract class SensorBase < public abstract void Readout(); public abstract string GetCurValString(); >abstract class Sensor : SensorBase < public override void Readout() < Data[] data = ReadoutRaw(); if (data.Length >0) < curValue = data[data.Lenght – 1]; >> public override string GetCurValString() < return curVal + “ “ + GetUnit(); >> class TemperatureSensor : Sensor < // Не переопределяем методы Readout и GetCurValString, // будут использоваться реализации из класса Sensor, // дополнительного кода в настоящем классе TemperatureSensor не потребуется. >class PressureSensor : Sensor < // Не переопределяем методы Readout и GetCurValString, // будут использоваться реализации из класса Sensor, // дополнительного кода в настоящем классе PressureSensor не потребуется. >class VelocityHDSensor : Sensor < public override void Readout() < Data[] data = ReadoutRaw(); // Рассчитываем среднее значение за последние 3 сек // и сохраняем его в curVal. >public override string GetCurValString() < return curVal + “ “ + GetUnit() + “ (avg)”; >> 

Приведенное решение, во-первых, подходит и для первой, и для второй ситуации. Во-вторых, не требует изменения кода существующих производных классов при переопределении методов Readout и GetCurValString в новом производном. Однако остается две проблемы. Во-первых, переопределенные методы производного класса целиком заменяют методы базового класса. Так, при выводе отформатированной строки мы дублируем логику метода базового класса. Конкретно в этом случае она крайне проста, однако даже здесь, если, к примеру, потребуется для всех классов изменить форматирование и выводить единицу измерения в скобках, нам потребуется вносить изменение в двух местах: в базовом классе Sensor и в производном классе VelocityHDSensor . Во-вторых, мы вынуждены были объявить дополнительный базовый класс, хотя никакой семантики в нем нет: если датчики температуры, давления и скорости – это «частное» по отношению к датчику – «общему», то подобной семантической связи между Sensor и SensorBase нет, это лишь синтаксическая уловка. Как следствие, код становится сложнее для восприятия.

Какие возможности языка программирования могли бы решить эти две проблемы? Первая – возможность вызвать из переопределенного метода в производном классе тот же переопределенный метод в базовом классе. Вторая – возможность переопределять не только абстрактные, но и обычные, «конкретные» методы. Проанализируйте следующий код, демонстрирующий обе возможности:

 abstract class Sensor < // Помечая обычный, неабстрактный метод, ключевым словом virtual, // мы разрешаем переопределить его в производных классах, // как если бы он был абстрактным. public virtual void Readout() < Data[] data = ReadoutRaw(); if (data.Length >0) < curValue = data[data.Lenght – 1]; >> public virtual string GetCurValString() < return curVal + “ “ + GetUnit(); >> class TemperatureSensor : Sensor < // Не переопределяем методы Readout и GetCurValString, // будут использоваться реализации из класса Sensor, // дополнительного кода в настоящем классе TemperatureSensor не потребуется. >class PressureSensor : Sensor < // Не переопределяем методы Readout и GetCurValString, // будут использоваться реализации из класса Sensor, // дополнительного кода в настоящем классе PressureSensor не потребуется. >class VelocityHDSensor : Sensor < public override void Readout() < Data[] data = ReadoutRaw(); // Рассчитываем среднее значение за последние 3 сек // и сохраняем его в curVal. >public override string GetCurValString() < // Ипользуя ключевое слово base, // мы вызываем реализацию метода GetCurValString // из базового класса Sensor. // Использование имени GetCurValString без base // приведет к рекурсивному вызову этого метода. return base.GetCurValString() + “ (avg)”; > > 

Виртуальный метод (virtual) – метод, который может быть переопределен в производных классах.

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

В случае многоуровневого наследования логика вызовов методов такая же как для абстрактных методов: всегда вызывается самая последняя реализация в цепочке наследования для реального объекта. Продемонстрируем это сначала на приведенном выше коде. Положим, мы вызываем метод GetCurValString для объекта реального типа VelocityHDSensor через переменную типа Sensor :

 Sensor sensor = new VelocityHDSensor(); Console.WriteLine (sensor.GetCurValString()); 

Будет вызвана самая последняя реализация в цепочке наследования для реального объекта, то есть реализация из класса VelocityHDSensor , далее, в коде этого класса мы используем переменную base , вызывая реализацию того же самого метода, но определенную в базовом классе. При этом используется та же логика: вызывается самая последняя (самая «частная») реализация в цепочке наследования, но не до реального объекта, а до ближайшего базового класса этого реального объекта. В нашем случае «цепочки» уже не будет, останется только класс Sensor :

Следующий рисунок демонстрирует поведение в аналогичной ситуации при многоуровневом наследовании:

Обратим внимание на особую логику вызова метода базового класса через ключевое слово base : хотя мы можем представить, что base это переменная-указатель на текущий объект, то есть base == (BaseClass)this , такое представление не вполне верно. Первая причина: при вызове абстрактных и виртуальных методов через переменную base вызываемая реализация определяется, как если бы реальный тип объекта был типом базового класса. Иначе мы просто не смогли бы вызвать в виртуальном методе производного класса реализацию из базового класса. Вторая причина синтаксическая – в C# мы не можем использовать base , как обычную переменную, в частности, не можем преобразовывать ее к другим типам или сравнивать с другими переменными, это ключевое слово применяется только для вызова методов и обращения к полям базового класса. Рассмотрим следующий пример:

 class A < public virtual void M() < Console.WriteLine (“A.M”); >> class B : A < public override void M() < Console.WriteLine (“B.M”); >public void Test() < // Далее в комментариях указывается результат выполнения // при вызове метода следующим образом: // B obj = new C(); // obj.Test(); // Вывод: C.M // (Так как реальный тип объекта this – С.) this.M(); // Вывод: C.M // (Так как реальный тип объекта this – С.) ((A)this).M(); // Вывод: A.M. // Виртуальные и абстрактные методы обрабатываются, // как если бы реальный тип объекта base был A. base.M(); // Следующие операции с ключевым словом base недопустимы и // приведут к ошибке компиляции. // ((B)base).M(); // Console.WriteLine ((A) this == base); >> class C : B < public override void M() < Console.WriteLine (“C.M”); >> 

В заключение, рассмотрим еще один пример:

 сlass A < public virtual M () < Trace.WriteLine (“A.M”); >> abstract class B: A < public abstract override M () < Trace.WriteLine (“B.M”); >class C : B < >class D : B < public override M () < Trace.WriteLine (“D.M”); >// Объясните результат выполнения следующего кода: D d = new D(); d.M(); // Вывод: D.M ((B)d).M(); // Вывод: D.M ((A)d).M(); // Вывод: D.M C c = new C(); c.M(); // Вывод: B.M ((B)c).M(); // Вывод: B.M ((A)c).M(); // Вывод: B.M 

Отметим, что абстрактные методы являются частным случаем виртуальных методов. Можно сказать, что абстрактный метод – это виртуальный метод без реализации. Поэтому, например, в С++, абстрактные методы также называются чистыми виртуальными методами (pure virtual).

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

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

§ 39. Класс object. В большинстве языков программирования есть специальный тип, который может хранить указатель на объект любого типа. Такой тип условно можно назвать базовым по отношению к любому пользовательскому или встроенному типу в том смысле, что преобразование между ним и другими типами выполняется по правилам преобразования между базовым и производным типом, рассмотренными в главе 3.2. В C# таким типом является тип object . В этом параграфе мы коротко рассмотрим несколько его ключевых и часто используемых возможностей.

Первая возможность, связанная с типом object – использование виртуальных методов, определённых в этом типе. Мы ограничимся рассмотрением только одного такого метода:

 public virtual string ToString() < /* . */ >

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

 namespace Geometry < class Point < private float x; private float y; // Используем переопределение, // хотя явно класс Point не наследуется ни от какого другого. // Неявное наследование от базового класса object. public override string ToString() < return $”X = , Y = ”; > > > // Без переопределения: Console.WriteLine (p.ToString()); // Вывод: Geometry.Point // С переопределением: Console.WriteLine (p.ToString()); // Вывод: X = 1, Y = 2 

Вторая возможность, связанная с типом object – упаковка и распаковка значимых типов. Ранее мы уже обсуждали отличия значимых и ссылочных типов данных в C#. В некоторых языках мы можем использовать любой класс или как значимый, или как ссылочный. Например, так работает C++ 52 . В некоторых языках эта логика скрывается, и тип фиксировано или всегда является значимым, или всегда является ссылочным. Так работает C#. Однако тот факт, что тип object является ссылочным и базовым по отношению ко всем другим типам, говорит нам, что мы можем преобразовать значимый тип, например, int к типу object :

 int a = 123; object o = a; int b = (int) o; 

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

В случае преобразования переменной значимого типа в object и обратно (на рисунке – слева) выполняется создание новой ссылочной переменной и копирование в нее значения исходной. В случае ссылочных переменных (на рисунке – справа), исходные данные остаются без изменений, меняются только ячейки для хранения адреса.

Преобразование переменной значимого типа в ссылочную переменную типа object называется упаковкой (boxing). Обратное преобразование называется распаковкой (unboxing).

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

 int n = 123; // string.Format (string format, params object[] args) string message = string.Format (“Обработано строк”, n); Console.WriteLine (message); // Обработано 123 строк 

Мы вернемся к вопросу упаковки и распаковки в главе 3.6 «Обобщенное программирование».

Вопросы и задания

Дайте определения следующим терминам, а также сопоставьте русские и английские термины: абстрактный метод, абстрактный класс, виртуальный метод, чистый абстрактный метод, переопределенный метод, упаковка, распаковка; abstract, virtual, pure virtual, override, boxing, unboxing.

Зачем в абстрактном классе нужен конструктор, если экземпляр все равно никогда не создается?
Можно ли при переопределении метода изменить его видимость?

Напишите метод, создающий массив long[] , содержащий миллион элементов и заполняющий его числами по порядку. Напишите аналогичный метод, создающий массив object[] , содержащий миллион элементов и заполняющий его числами типа long по порядку. Сравните время выполнения методов с помощью класса Stopwatch и объясните результат.

** Изучите назначение виртуальных методов Equals и GetHash класса object .

* В объектно-ориентированных языках существует возможность запретить наследование от заданного класса. Так в C#, мы можем пометить класс ключевым словом sealed (запечатанный). Чем можно объяснить, что в практике программирования, это ключевое слово значительно чаще используется в Java чем в C#?

47. Мы вернемся к вопросу о недопустимости зависимости базовых классов от производных в § 43.

48. Отметим, что мы не можем объявить метод с одинаковой сигнатурой и базовом, и в производном классе (будет ошибка компиляции), не используя специальные механизмы переопределения, которые мы будем рассматривать далее. Поэтому в этом примере мы используем разные имена методов в производных классах (ReadoutTemperature, ReadoutPressure вместо Readout).

49. Не следует путать с абстрактными типами данных. Это не связанные термины. Как мы уже говорили в главе 2.1, термин «абстрактные типы данных» сегодня употребляется не так широко и обозначает типы данных, моделирующие в программе некоторые абстракции. Слово «абстрактные» в терминах «абстрактные методы» и «абстрактные классы» используется в несколько ином значения, обозначая, что методы или классы не вполне реализованы, в отличие от обычных, конкретных, методов и классов.

50. Заметим, что ключевым словом abstract может быть помечен и класс без абстрактных методов. В этом случае также запрещается создание экземпляров этого класса (но допускается создание экземпляров неабстрактных производных классов).

51. Термин «переопределение» используется, даже если в базовом классе метод только объявлен (приведена сигнатура без тела). Строго говоря, исходный английский термин override переводится как «замещение», то есть мы замещаем метод из базового класса (только объявленный или определенный с реализацией), новым методом (также только объявленным или определённым с реализацией). А термины «объявленный» и «определенный» используются непоследовательно.

52. Строго говоря, в С++ нет ссылочных типов, вместо этого мы создаем переменную указатель и присваиваем ей адрес объекта. Но эта схема в первом приближении полностью аналогична ссылочным типам C#. C++: Point* p = new Point(); C#: Point p = new Point(); Однако в C++ мы можем объявить переменную класса и как значимую: Point p; (аналогия: int x;). В этом случае память будет выделена в стеке метода и p будет обозначать не ячейку с адресом объекта, а сам объект.

&copy Тимофей Усов, 2019—2020.

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *