Отправляет email-рассылки с помощью сервиса Sendsay

C++ для всех

  Все выпуски  

C++ для всех


Информационный Канал Subscribe.Ru

C++ для всех. Выпуск 4

Виртуальные функции. Чисто виртуальные функции. Виртуальные деструкторы.

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


 class A
 {
 public:
  void printInfo(){ print(); }
 protected:
  virtual void print(){ printf("A::print()\n"); }
 };
 
 class B : public A
 {
 protected:
  virtual void print(){ printf("B::print()\n"); }
 };
 
 int main()
 {
  B b;
  b.printInfo();

  return 0;
 }
 
 На экране увидим:

 B::print()

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

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


 class A
 {
 public:
  virtual void f(){}
 };
 
 class B
 {
 public:
  virtual void g(){}
 };

 class C
 {
 public:
  virtual void e(){}
 };

 class D : public A, public B, public C
 {
 };
 
 int main()
 {
  std::cerr << "A: " << sizeof(A) << '\n';
  std::cerr << "D: " << sizeof(D) << '\n';
  return 0;
 }

Рассмотрим внутреннее устройство механизма вызова виртуальной функции.

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


 class A
 {
 public:
  virtual void func(){ printf("A\n"); }
 };
 
 class B : public A
 { 
  virtual void func(){ printf("B\n"); }
 };
 
 int main()
 {
  A *p = new B;
  p->func();
  ...
 }

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


 class A
 {
 public:
  A(){ init(); }
  virtual void print(){}
 protected:
  virtual void init(){ std::cerr << "A->init()\n"; }
 };

 class B : public A
 {
 public:
  B():A(){}
  ~B(){ free(str); }
  virtual void print(){ printf("%s\n",str); }
 protected:
  virtual void init(){   
   A::init();//типа хотим проинициализировать данные базового класса

   str = (char*)malloc(16); 
   strcpy(str,"Супер-строка!"); 

   std::cerr << "B->init()\n";
  }
 private:
  char *str;  
 };

 int main()
 {
  B b;
  b.print();
  
  return 0;
 }

Рассмотрим этот пример подробнее. Делая функцию init() виртуальной, программист хотел создать отдельную функцию, в которой будут конструироваться и инициализироваться все переменные производного класса. Он хотел, по сути, уйти от лишнего вызова функции init() после создания объекта (кстати, методика проводить доинициализацию объекта после его конструирования считается крайне плохим стилем программирования, однако широко используется в майкрософтском произведении под названием MFC). Данный конкретный пример компилируется, выполняется, что-то печатает и завершает работу. Проблема в том, что требуемая функция B->init() не вызывается вообще, еще хуже, что в объекте класса B мы имеем неинициализированные данные, с которыми работаем и уверенно освобождаем в деструкторе. Результат работы с неинициализированным указателем не определен, поэтому на лучшее надеяться не приходится. Отсюда вывод: если Вы вызываете виртуальные функции из конструктора, то следует понимать, что будет вызвана функция, определенная в базовом или текущем классе, но никогда не функция производного класса. Теперь камень в сторону MFC, - полное конструирование и инициализацию объекта (включая конструирование и инициализацию переменных- членов) настоятельно рекомендуется проводить только в конструкторе следуя правилу: конструктор выполнен - значит объект полностью создан и готов к работе.

Теперь поговорим о чисто виртуальных (pure virtual) функциях.

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


 class A
 {
 public:
  virtual void print()const = 0;
 };

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


 class A
 {
 public:
  virtual void print()const = 0;
 };
 
 int main()
 {
  A a; //ошибка компиляции,- невозможно создать объект абстрактного класса
  ...
 }

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


 class A
 {
 public: 
  virtual bool isValid()const = 0;
 };

 bool A::isValid()
 {
  return true;
 }

 class B : public A
 {
 public:
  virtual bool isValid()const{ return A::isValid(); }
 };

 int main()
 {
  B b;
  bool is_valid = b.isValid();
  std::cerr << is_valid << '\n';
  return 0;
 }

Кстати, если у Вас получиться вызвать чисто виртуальную функцию, то на большинстве компиляторов Вы получите ошибку времени выполнения с сообщением вроде этого "pure virtual function call..."

Как это можно получить? А довольно просто:


 class A
 {
 public:
  ~A(){ destroy(); }
 protected:
  void destroy(){ clear(); }
  virtual void clear() = 0; 
 };
 
 class B : public A
 {
 protected:
  virtual void clear(){};
 };
 
 int main()
 {
  B b;
  return 0;
 }

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


Теперь займемся деструкторами.

Посмотрите на этот пример:


 class A
 {
 public:  
  ~A(){ printf("~A()\n"); }
 };

 class B : public A
 {
 public: 
  B():buf(new int[1024]){}  
  ~B(){
   printf("~B()\n"); 
   delete [] buf;
  }
 private:
  int *buf;
 };
 
 int main()
 {
  A *pa = new B;
  delete pa;

  return 0;
 }

Какие строки будут напечатаны на экране? Правильно (Вы знали), на экране будет напечатано ~A(), т.е. ~B()-деструктор вызван не был. К чему это привело? В нашем случае просто к потерям памяти, что для этого примера некритично, в реальной же программе потеря вызова деструктора может привести от потерь памяти (это обязательно) до непредсказуемого поведения программы: здесь файл не закрыли, там дескриптор не освободили... и так по нарастающей.

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


 A *pa = new B;
 delete (B*)pa;

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


 class Base
 {
 public:
  virtual int rtti()const = 0;
 };
 
 class A : public Base
 {
 public:
  ~A(){ printf("~A()\n"); }
  virtual int rtti()const{ return 1; }   
 };
 
 class B : public A
 {
 public:
  ~B(){ printf("~B()\n"); }
  virtual int rtti()const{ return 2; }
 };

 int main()
 {
  Base *p = new B;
  switch(p->rtti()){
   case 1: delete (A*)p; break;
   case 2: delete (B*)p; break;   
  }
  return 0;
 }
 
 На экране увидим:
 ~B()
 ~A()
 Т.е. деструкторы вызваны правильно.

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


 class A
 {
 public:  
  virtual ~A(){ printf("~A()\n"); }
 };

 class B : public A
 {
 public: 
  ~B(){ printf("~B()\n"); }
 };
 
 int main()
 {
  A *pa = new B;
  delete pa;

  return 0;
 }

Итак, обобщим информацию о деструкторах в виде рекомендаций. Деструктор базового класса может быть двух видов: либо невиртуальный в protected области (в этом случае никто не сможет удалить объект через указатель на базовый класс) либо виртуальный в public области, и никак иначе.

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

Начнем по очереди:

  • Во-первых,- если Вы (скорее кто-то плюс имеем только объектники) 'забыли' в производном классе объявить функцию виртуальной, а затем (в производных классах) надумались, то функция (виртуальная) производного класса уже не перекроет аналогичную невиртуальную функцию базового класса.
  • 
     class Base
     {
     public:
      void print(){ printf("Base::print()\n"); }
      void xprint(){ print(); }
     };
    
     class A : public Base
     {
     public:
      virtual void print(){ printf("A::print()\n"); }
     };
    
     class B : public A
     {
     public:
      virtual void print(){ printf("B::print()\n"); }
     };
     
    
     int main()
     {
      B b;
      b.xprint();
    
      return 0;
     }
    
     На экране видим:
    
     Base::print()
    

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

  • Во-вторых,- с помощью виртуальных функций нарушить права доступа класса,- здесь я круто завернул - поясню на примере:
  • 
     class A
     {
     public:
      void print(){ xfunc(); }
     protected:
      virtual void xfunc(){ printf("security\n"); }
     };
    
     class B : private A
     {
     public:
      void print(){ A::print(); }
     };
    
     class C : public B
     {
     public:
      virtual void xfunc(){ printf("overhead security\n"); }
     };
     
     int main()
     {
      C c;
      c.print();
      return 0;
     }
    
     На экране видим:
     
     overhead security
    

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

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

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


    На сегодня все. Пожелания, предложения, вопросы прошу на iqsoft@cg.ukrtel.net

    В следующем выпуске: Множественное наследование.


    (с) Юрий Гордиенко iqsoft@cg.ukrtel.net



    http://subscribe.ru/
    E-mail: ask@subscribe.ru
    Отписаться
    Убрать рекламу

    В избранное