С++ — это объектно-ориентированный язык программирования, и в этот раз мы будем говорить только об ооп. Без него современному программисту ну совсем никак, а «приплюснутому» программеру так и вовсе приходится быть начеку — ооп в срр несколько отличается от предлагаемого в других языках программирования.

Действительно, реализация ООП в C++ несколько отличается от реализаций в других языках. Наследование может быть одиночным и множественным, а отдельный путь наследования — открытым, защищенным или закрытым. Путь также может быть виртуальным и невиртуальным. Для функций-членов тоже есть свои варианты: виртуальные, невиртуальные, чисто виртуальные. А если добавить сюда еще взаимодействие с другими средствами языка, то мы получим кучу мелких, но важных нюансов, которые просто необходимо знать для написания качественного кода на C++.

 

Правило №1

В первом правиле мы поговорим об открытом наследовании. Допустим, мы пишем класс D (derived, производный), который открыто наследуется от класса B (base, базовый). Создавая такую иерархию классов, мы тем самым сообщаем компилятору, что каждый объект типа D является также объектом типа B, но не наоборот. Мы говорим, что B представляет собой более общую концепцию чем D, а D — более конкретную концепцию, чем B. Мы утверждаем, что везде, где может быть использован объект B, можно использовать также объект типа D, потому что D является объектом типа B. С другой стороны, если нам нужен объект типа D, то объект B не подойдет, поскольку каждый D «является разновидностью» B, но не наоборот. Теперь переведем предыдущий абзац в код.

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

 

Иерархия классов человек-студент

class Person {...};
class Student: public Person {...};
void eat (const Person &p);
// все люди могут есть
void study (const Student &s);
// только студент учится
Person p;
Student s;
eat(p);
// правильно, p — человек
eat(s);
// правильно, s — студент,
// и студент также человек
study(s);
// правильно
study(p);
// ошибка! p — не студент

Код в примере полностью соответствует нашим интуитивным понятиям. Мы ожидаем что всякое утверждение, справедливое для человека — например что у него есть дата рождения справедливо и для студента, но не все что справедливо для студента — например, что он учится в каком-то определенном институте, верно для человека в общем.

Идея торжества открытого наследования и понятия «является» кажется достаточно очевидной, но иногда интуиция нас подводит. Рассмотрим следующий пример: пингвин — это птица. Птицы умеют летать. На C++ это будет выглядеть так:

 

Пингвины умеют летать

class Bird {
public:
virtual void fly();
// птицы умеют летать

};
// пингвины — птицы
class Penguin: public Bird {

};

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

 

Теперь пингвины не летают

class Bird {
public:
… // ф-я fly не объявлена
};
class FlyingBird: public Bird {
public:
virtual void fly();

};
class Penguin: public Bird {
… // ф-я fly не объявлена
};

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

 

Если не летаем — ошибка

void error(const std::string& msg);
class Penguin: public Bird {
virtual void fly()
{ error(“Попытка заставить пингвина летать!”); }

};

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

 

Неверный код не компилируется

class Bird {
public:
… // ф-я fly не объявлена
};
class Penguin: public Bird {
… // ф-я fly не объявлена
};
Penguin p;
p.fly(); // ошибка!

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

Отношение «является» — не единственное возможное между классами. Два других, достаточно распространенных отношения — это «содержит» и «реализован посредством», разговор о которых, возможно, пойдет в других статьях. Очень часто при проектировании на C++ весь проект идет вкривь и вкось из-за того, что эти взаимосвязи моделируются отношением «является».

 

Правило №2

В этом правиле мы поговорим о наследовании и области видимости имен. Рассмотрим следующий пример:

 

Область видимости

// глобальная переменная
int x;
void someFunc()
{
// локальная переменная
double x;
std::cin >> x;
}

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

Когда компилятор встречает имя x внутри функции someFunc, он смотрит, определено ли что-то с таким именем в локальной области видимости. Если да, то объемлющие области видимости не просматриваются. В данном примере имя x ссылается на переменную типа double. Теперь добавим к сокрытию имен наследование. Рассмотрим пример посложнее.

 

Наследование и область видимости

class Base {
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf1(int);
virtual void mf2();
void mf3();
void mf3(double);

};
class Derived: public Base {
public:
virtual void mf1()
void mf3();
void mf4();

};
Derived d;
int x;

d.mf1();
// правильно, вызывается Derived::mf1
d.mf1(x);
// ошибка! Derived::mf1 скрывает Base::mf1
d.mf2();
// правильно, вызывается Base::mf2
d.mf3();
// правильно, вызывается Derived::mf3
d.mf3(x); // ошибка! Derived::mf3 скрывает
Base::mf3

Как видно из этого кода, мы перегрузили функцию mf3, что само по себе уже не совсем правильно, но чтобы лучше разобраться с видимостью имен, закроем на это глаза. Этот код своим поведением способен удивить любого впервые с ним столкнувшегося C++-программиста.

Основанное на областях видимости правило сокрытия имен никуда не делось, поэтому все функции с именами mf1 и mf3 в базовом классе окажутся скрыты одноименными функциями в производном классе. С точки зрения поиска имен, Base::mf1 и Base::mf3 более не наследуются классом Derived. Это касается даже тех случаев, когда функции в базовом и производном классах принимают параметры разных типов, независимо от того, идет ли речь о виртуальных или не виртуальных функциях. Обоснование подобного поведения в том, что оно не дает нечаянно унаследовать перегруженные функции из базового класса, расположенного много выше в иерархии наследования. К сожалению, такое поведение нарушает основной принцип открытого наследования — отношение «является». Обойти этот недостаток можно использованием usingобъявления:

 

Использование using-объявлений

class Base {
private:
int x;
public:
virtual void mf1() = 0;
virtual void mf1(int);
virtual void mf2();
void mf3();
void mf3(double);

};
class Derived: public Base {
public:
// обеспечить видимость всех (открытых) имен
// mf1 и mf3 из класса Base в классе Derived
using Base::mf1;
using Base::mf3;
virtual void mf1()
void mf3();
void mf4();

};
Derived d;
int x;

d.mf1(); // правильно, вызывается Derived::mf1
d.mf1(x); // теперь правильно, вызывается Base::mf1
d.mf2(); // правильно, вызывается Base::mf2
d.mf3(); // правильно, вызывается Derived::mf3
d.mf3(x); // теперь правильно, вызывается Base::mf3

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

 

Правило №3

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

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

 

Иерархия классов

class Shape {
public:
virtual void draw() const = 0;
virtual void error();
int objectID() const;
};
class Rectangle: public Shape {…};
class Ellipse: public Shape {…};

Как видно, в примере имеются все три вида функций: чисто виртуальные, виртуальные и не виртуальные. Начнем с чисто виртуальных.
Как говорилось выше, открытое наследование подразумевает под собой отношение «является», которое означает, что интерфейсы абсолютно всех функций класса должны быть унаследованы потомком. Чистая виртуальность как раз и обеспечивает тот необходимый минимум, в соответствии с которым потомок должен наследовать интерфейсы. В нашем примере чисто виртуальной является функция draw() обеспечивающая отрисовку геометрических фигур. Оно и понятно, ведь алгоритмы рисования прямоугольника и эллипса сильно отличаются и невозможно написать единую функцию рисования для обеих этих фигур. Потомки Shape должны сами определить реализацию этого метода и чистая виртуальность функции draw в родительском классе обязывает их это сделать.

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

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

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

 

Triangle

class Shape {
public:
...
virtual void error();
...
};
class Triangle: public Shape {
// должны были переопределить, но забыли
virtual void error();
};
Shape *tr = new Triangle;
// вызывается Shape::error(), а нам этого не надо
tr->error();

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

 

Отделение реализации

class Shape {
public:
virtual void error();

protected:
void defaultError();
};
void Shape::defaultError()
{
// код по умолчанию, обрабатывающий ошибки
}
class Rectangle: public Shape {
public:
virtual void error() {defaultError();}

};
class Ellipse: public Shape {
public:
virtual void error() {defaultError();}

};
class Triangle: public Shape {
public:
virtual void error();

};
void Triangle::error()
{
// код, обрабатывающий ошибки именно для класса
Triangle
}

Теперь реализацией по умолчанию смогут воспользоваться только те классы, которым это на самом деле нужно, а наша забывчивость не приведет к вызову дефолтной версии функции. Однако, у этого подхода есть несколько недостатков, главный из которых — засорение пространства имен класса близкими названиями функций. Решить эту проблему можно с помощью определения реализации чисто виртуальных функций. Эта особенность языка C++ многим неизвестна, да и в большинстве случаем она и не нужна, но в нашем примере такое определение оказалось кстати.

 

Отделение реализации. Вариант 2

class Shape {
public:
virtual void error() = 0;

};
// реализация чисто виртуальной функции
void Shape::error()
{
// код по умолчанию, обрабатывающий ошибки
}
class Rectangle: public Shape {
public:
virtual void error() {Shape::error();}
...
};
class Ellipse: public Shape {
public:
virtual void error() {Shape::error();}
...
};
class Triangle: public Shape {
public:
virtual void error();
...
};
void Triangle::error()
{
// код, обрабатывающий ошибки именно для класса
Triangle
}

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

Последняя функция, которую мы рассмотрим — это невиртуальная функция objectID(). Цель объявления невиртуальной функции заключается в том, чтобы заставить производные классы наследовать как ее интерфейс, так и обязательную реализацию. Каждый объект Shape имеет функцию, которая дает идентификатор объекта, и задается определением функции Shape::objectID, и никакой другой производный класс не должен его изменять.

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

 

Заключение

На этом мы завершим разговор о тонкостях ООП в C++. Напомню, что, помимо рассмотренного, существует еще много нюансов связанных с объектно-ориентированным программированием в C++, которые нужно знать, чтобы быть уверенным в своих силах.

  • Подпишись на наc в Telegram!

    Только важные новости и лучшие статьи

    Подписаться

  • Подписаться
    Уведомить о
    0 комментариев
    Межтекстовые Отзывы
    Посмотреть все комментарии