6. Полиморфизм в С++.

Виртуальные методы. Виртуальные деструкторы. Чисто виртуальные методы. Понятие абстрактного класса. Ошибки возникающие при работе с указателем на базовый класс. Дружественные связи.

Введение в полиморфизм

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

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

Пример. Объединение интерфейсов

class A
{
public:
	void f1() { cout<<"Executing f1 from A;"<<endl; }
	void f2() { cout<<"Executing f2 from A;"<<endl; }
};

class B
{
public:
	void f1() { cout<<"Executing f1 from B;"<<endl; }
	void f3() { cout<<"Executing f3 from B;"<<endl; }
};

class C : public A, public B {};

class D
{
public:
    void g1(A& obj) // Для класса D в метод g1() мы передаем класс C, и
    {               // на объект класса C ставится ссылка класса А.
        obj.f1();   // Соответственно, мы можем вызывать только те методы, которые относятся к классу А.
        obj.f2();   
	}
	void g2(B& obj)
	{
		obj.f1();
        obj.f3();
	}
};

void main()
{
	C obj;
	D d;

	d.g1(obj); // Мы передаем объект класса C, а не класса A!
	d.g2(obj);
}

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

Для класса D в метод g1() мы передаем класс С, и на объект класса С ставится ссылка класса А, соответственно, мы можем вызывать только те методы, которые относятся к классу А. Но объект у нас класса С, не А. В данном примере все нормально - мы не переопределяли никаких методов.

Но представьте, если в классе С мы переопределили метод f1() или f2(). При такой ситуации будут вызываться методы класса А, а нам бы хотелось, чтобы вызывались методы класса С (мы же передаем туда объект класса С).

Решение

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

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

Вот как раз пример.

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

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

class A
{
public:
    virtual void f() { cout<<"Executing f from A;"<<endl; }
};

class B : public A
{
public:
    virtual void f() override { cout<<"Executing f from B;"<<endl; } // Перегрузка метода f()
};

class C
{
public:
    static void g(A& obj) { obj.f(); } // Вызывается метод f() класса B.
};

void main()
{
    B obj;

    C::g(obj); // Ссылка класса A на объект класса C
}

Это приводит к тому, что требуется больше памяти, появляется указатель, виртуальная таблица и увеличивается время, так как сначала нужно перейти на таблицу по указателю, там осуществить поиск и потом по указателю вызвать метод. Что это нам дает?

Что нам дает такой подход:

Когда мы разрабатываем программу, основной подход - рекурсивный дизайн. Все изменения мы делаем за счет наследования, не изменяя базовое понятие, делая над ним надстройку.

Идея какая: мы использовали одно понятие, потом мы начинаем использовать другое понятие. Мы должны в коде легко подменить одно понятие на другое.

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

ВАЖНО! Базовый класс задает интерфейс, который в производных мы не должны не сужать не расширять, мы должны использовать этот интерфейс (для того, чтобы можно было подменить один объект другим).

Важный момент

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

Поэтому разработчик языка добавил следующий синтаксис:

class A
{
public:
	virtual void f() = Ø; // Определяем метод как чисто виртуальный.
};

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

Пример. Абстрактный класс. Чисто виртуальные методы

class A // Абстрактный класс
{
public:
	virtual void f() = 0; // Чисто виртуальный метод.
};

class B : public A
{
public:
	virtual void f() override { cout<<"Executing f from B;"<<endl; }
};

class C
{
public:
	static void g(A& obj) { obj.f(); }
};

void main()
{
	B obj;

	C::g(obj);
}

Следующая проблема:

Предположим, у нас есть класс B, производный от класса A.

А *p = new В; // Создание объекта класса B. Мы вызываем конструктор класса B для создания конкретного объекта.
.           
.
.
delete p;

Класс А – абстрактный, класс В – не абстрактный. Мы можем работать с классом В, вызывая метод. Вступает правило: для класса А мы все методы, которые могут быть подменены в классе В, должны определить с модификатором virtual, чтобы один объект можно было подменить другим.

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

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

class A
{
public:
    virtual void f() = 0;
    virtual ~A() = 0;
};

Но возникает проблема – у нас создается объект какого-то производного класса, по цепочке отрабатывает конструктор, в обратном порядке отрабатывают деструкторы. А мы удалили этот деструктор! Поэтому реализовать этот деструктор мы обязаны. Реализовать как пустой.

class A
{
public:
    virtual void f() = 0;
    virtual ~A() = 0;
};
A::~A() {}

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

Итоги:

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

Пример. Виртуальный деструктор

class A // Абстрактный класс, несмотря на наличие реализации деструктора
{
public:
	virtual ~A() = 0; // Чисто виртуальный деструктор

};

A::~A() {} // Реализация деструктора

class B : public A
{
public:
	virtual ~B() { cout<<"Class B destructor called;"<<endl; }
};

void main()
{
	A* pobj = new B();
	delete pobj;
}

Пример. Виртуальные методы и конструкторы и деструкторы

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

class A
{
public:
	virtual ~A() { cout<<"Class A destructor called;"<<endl; }

	virtual void f() { cout<<"Executing f from A;"<<endl; }
};

class B : public A
{
public:
	B() { this->f(); }
	virtual ~B()
	{
		cout<<"Class B destructor called;"<<endl;
		this->f();
	}

	void g() { this->f(); }

};

class C : public B
{
public:
	virtual ~C() { cout<<"Class C destructor called;"<<endl; }

	virtual void f() override { cout<<"Executing f from C;"<<endl; }
};

void main()
{
	C obj;

	obj.g();
}

Есть класс А, имеющий виртуальный деструктор и виртуальный метод f(). Класс В – конструктор, в котором мы вызываем метод f() и деструктор, в котором мы тоже вызываем метод f(). Метод g() тоже написан специально. У нас есть класс С, который подменяет метод f базового класса.

Создается объект класса C. Прежде чем создастся объект класса C, сначала отрабатывает конструктор класса B, а перед этим A! Но в конструкторе класса B мы вызываем метод f(), но объекта класса C еще нет, поэтому вызовется метод класса A(). Проблема - объекта еще нет. С деструктором наоборот - объекта уже нет, и тоже вызовется метод f().

При вызове метода g() для объекта C будет вызываться для объекта C через указатель this - это указатель на объект класса C.

Правило: мы не должны вызывать виртуальные методы в конструкторах и деструкторах.

Тогда как быть: Мы строим иерархию. Ту часть, которую мы бы хотели вызвать, или, возможно, мы вызовем для объекта, мы выносим в методы protected или private. И они у нас не виртуальные. Виртуальный только интерфейс.

Про override

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

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

Дружба

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

Что такое дружба? Мы можем дать возможность объектам одного класса дать доступ ко всем членам другого класса, прописав его как друг этого класса. У нас даже наследники не имеют доступа ко всем членам, а друг имеет!

Дружба в программировании - это плохо. В жизни - хорошо. Если можно обойтись без дружбы - надо обходиться без дружбы.

Дружбы не наследуется и дружба не транзитивна (друг моего друга мне не друг).

Пример. Дружба и наследование

Есть класс А. Производный от него класс В. У нас есть друг – класс С. Все методы этого класса будут иметь доступ ко всем членам класса А.

class C; // forward объявление

class A
{
private:
	void f1() { cout<<"Executing f1;"<<endl; }

	friend C; // Мы говорим, что у нас есть друг - класс C. Все методы класса C будут иметь доступ ко всем членам
    		  // класса A
};

class B : public A
{
private:
	void f2() { cout<<"Executing f2;"<<endl; }
};

class C
{
public:
	static void g1(A& obj) { obj.f1(); } // Метод, который принимает ссылку на объект класса А
    					     // В нём мы имеем доступ к этому методу
    
	static void g2(B& obj) // Получаем ссылку на объект класса B - производный от класса A
	{
		obj.f1(); // f1() мы можем вызвать - мы имеем доступ ко всем членам класса 
//		obj.f2(); // Error!!! Имеет доступ только к членам A
	}
};

class D : public C
{
public:
//	static void g2(A& obj) ( obj.f1(); } // Error!!! Дружба не наследуется
};


void main()
{
	A aobj;

	C::g1(aobj);

	B bobj;

	C::g1(bobj);
	C::g2(bobj);
}

Пример. Дружба и виртуальные методы (лучше так не делать)

Что касается виртуальных методов - если мы работаем с ними, возникает подмена. Виртуальный метод protected - лучше так не делать. Виртуальные методы лучше делать только с уровнем доступа public.

class C; // forward объявление

class A
{
protected:
	virtual void f() { cout<<"Executing f from A;"<<endl; }

	friend C;
};

class B : public A
{
protected:
	virtual void f() override { cout<<"Executing f from B;"<<endl; }
};

class C
{
public:
	static void g(A& obj) { obj.f(); } // Друг принимает ссылку на объект класса А, а мы передаем объект класса B.
    					   // Будет вызываться метод, который подменяет производный.
};

void main()
{
	B bobj;

	C::g(bobj);
}

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

class A
{
    friend void  f(C* pc);
    friend void A::f(); // Наш выбор!
    friend class B;
};


C obj;
A *pa = & obj C;
B *pb = & obj C;
C *pc = & obj C;
pa->f(); pb->f(); pc->f(); // C::f()

Другом класса может быть функция, метод из класса или класс. Приоритет надо отдавать варианту, когда мы делаем какой то определённый метод класса другом. Таким образом, если класс С меняется, нам не нужно будет просматривать весь класс А, а только вносить изменения в конкретный метод. Дружба приводит к тому, что если вносить изменения, надо изменять написанный код. Надо проектировать таким образом, чтобы не вносить изменения в написанный код.

Last updated