Кодерские типсы и триксы: три правила кодинга на C++ для настоящих спецовС++ — это объектно-ориентированный язык программирования, и в этот раз мы будем говорить только об ооп. Без него современному программисту ну совсем никак, а «приплюснутому» программеру так и вовсе приходится быть начеку — ооп в срр несколько отличается от предлагаемого в других языках программирования. Действительно, реализация ООП в C++ несколько отличается от реализаций в других языках. Наследование может быть одиночным и множественным, а отдельный путь наследования — открытым, защищенным или закрытым. Путь также может быть виртуальным и невиртуальным. Для функций-членов тоже есть свои варианты: виртуальные, невиртуальные, чисто виртуальные. А если добавить сюда еще взаимодействие с другими средствами языка, то мы получим кучу мелких, но важных нюансов, которые просто необходимо знать для написания качественного кода на C++. Правило №1В первом правиле мы поговорим об открытом наследовании. Допустим, мы пишем класс D (derived, производный), который открыто наследуется от класса B (base, базовый). Создавая такую иерархию классов, мы тем самым сообщаем компилятору, что каждый объект типа D является также объектом типа B, но не наоборот. Мы говорим, что B представляет собой более общую концепцию чем D, а D — более конкретную концепцию, чем B. Мы утверждаем, что везде, где может быть использован объект B, можно использовать также объект типа D, потому что D является объектом типа B. С другой стороны, если нам нужен объект типа D, то объект B не подойдет, поскольку каждый D «является разновидностью» B, но не наоборот. Теперь переведем предыдущий абзац в код. Будем использовать понятия человека и студента. Каждый студент является человеком, поэтому человек будет базовым классом, а студент — производным. Иерархия классов человек-студент Код в примере полностью соответствует нашим интуитивным понятиям. Мы ожидаем что всякое утверждение, справедливое для человека — например что у него есть дата рождения справедливо и для студента, но не все что справедливо для студента — например, что он учится в каком-то определенном институте, верно для человека в общем. Идея торжества открытого наследования и понятия «является» кажется достаточно очевидной, но иногда интуиция нас подводит. Рассмотрим следующий пример: пингвин — это птица. Птицы умеют летать. На C++ это будет выглядеть так: Пингвины умеют летать Неожиданно мы столкнулись с затруднением. Получается, что пингвины умеют летать, но это совсем не так. Во всем виновата неточность формулировки на человеческом языке. Умеют летать только среднестатистические представители птиц, а не все. Поэтому наш код можно немного изменить. Теперь пингвины не летают Создавать отдельный класс для обозначения летающих птиц иногда не совсем целесообразно, да и, на мой взгляд, не очень красиво. Если не летаем — ошибка
Важно понимать, что тут мы говорим: «Пингвины могут летать, но с их стороны это было бы ошибкой». Разница заключается во времени обнаружения ошибки. В последнем примере ошибка будет выявлена только во время исполнения кода, а не во время компиляции, а хороший интерфейс должен предотвращать компиляцию неверного кода. Сделать это можно так: Неверный код не компилируется Здесь функция fly() не объявлена в базовом классе, что делает ее вызов невозможным и в производном. Это дает нам гарантию того, что спроектированные нами классы будут использованы правильно. Ситуации, подобные описанной выше (с птичками и пингвинами) встречаются достаточно часто, поэтому с открытым наследованием надо быть внимательным. Отношение «является» — не единственное возможное между классами. Два других, достаточно распространенных отношения — это «содержит» и «реализован посредством», разговор о которых, возможно, пойдет в других статьях. Очень часто при проектировании на C++ весь проект идет вкривь и вкось из-за того, что эти взаимосвязи моделируются отношением «является». Правило №2В этом правиле мы поговорим о наследовании и области видимости имен. Рассмотрим следующий пример: Область видимости Все мы знаем, что в таком коде имя x в предложении считывания относится к локальной, а не к глобальной переменной, потому что имена во вложенной области видимости скрывают имена из внешних областей. Когда компилятор встречает имя x внутри функции someFunc, он смотрит, определено ли что-то с таким именем в локальной области видимости. Если да, то объемлющие области видимости не просматриваются. В данном примере имя x ссылается на переменную типа double. Теперь добавим к сокрытию имен наследование. Рассмотрим пример посложнее. Наследование и область видимости Как видно из этого кода, мы перегрузили функцию mf3, что само по себе уже не совсем правильно, но чтобы лучше разобраться с видимостью имен, закроем на это глаза. Этот код своим поведением способен удивить любого впервые с ним столкнувшегося C++-программиста. Основанное на областях видимости правило сокрытия имен никуда не делось, поэтому все функции с именами mf1 и mf3 в базовом классе окажутся скрыты одноименными функциями в производном классе. С точки зрения поиска имен, Base::mf1 и Base::mf3 более не наследуются классом Derived. Это касается даже тех случаев, когда функции в базовом и производном классах принимают параметры разных типов, независимо от того, идет ли речь о виртуальных или не виртуальных функциях. Обоснование подобного поведения в том, что оно не дает нечаянно унаследовать перегруженные функции из базового класса, расположенного много выше в иерархии наследования. К сожалению, такое поведение нарушает основной принцип открытого наследования — отношение «является». Обойти этот недостаток можно использованием usingобъявления: Использование using-объявлений Теперь наследование будет работать, как и ожидается. Это означает, что если ты наследуешь базовому классу с перегруженными функциями и хочешь переопределить только некоторые из них, то придется включить using-объявления для каждого имени, иначе оно будет сокрыто. Правило №3Внешне простая идея открытого наследования при ближайшем рассмотрении оказывается состоящей из двух различных частей: наследование интерфейса и наследование реализации. Различие между этими двумя видами наследования соответствует различию между объявлениями и определениями функций. Иногда требуется, чтобы класс наследовал только интерфейс функции, иногда — интерфейс и реализацию, но так, чтобы он впоследствии мог (или не мог, по желанию) переопределить реализацию. Классический пример, который часто приводят во время изложения принципов ООП основывается на описании иерархии классов геометрических фигур. Мы не будем отклоняться от этой традиции. Иерархия классов Как видно, в примере имеются все три вида функций: чисто виртуальные, виртуальные и не виртуальные. Начнем с чисто виртуальных. Учитывая, что чисто виртуальные функции должны быть заново объявлены в любом конкретном наследующем их классе, и в абстрактном классе они обычно не определяются, мы приходим к пониманию следующего обстоятельства: цель объявления чисто виртуальной функции состоит в том, чтобы производные классы наследовали только интерфейс. Теперь перейдем к просто виртуальным функциям. В нашем примере это метод error(…), который выводит сообщение об ошибке. Простая виртуальность подразумевает под собой то, что производным классам не обязательно переопределять код наследуемой функции, а можно использовать реализацию по умолчанию. Получается что цель объявления обычной виртуальной функции — наследовать в производных классах как интерфейс, так и ее реализацию. Добавим еще один производный класс от Shape в нашу иерархию. Пусть это будет треугольник (Triangle). В отличие от прямоугольника и эллипса нас не устраивает базовая реализация обработки ошибок и мы должны переопределить ее в классе-потомке. Но по каким-то причинам мы забываем это сделать и используем реализацию по умолчанию, после чего начинаем получать странные сообщения об ошибках, которые мы совсем не ждем. Triangle Чтобы такого не произошло, надо каким-то образом не дать классу треугольника использовать базовую реализацию функции, но вместе с тем сохранить эту возможность для прямоугольника и эллипса. На помощь приходит парадигма разделения интерфейса и реализации. Отделение реализации Теперь реализацией по умолчанию смогут воспользоваться только те классы, которым это на самом деле нужно, а наша забывчивость не приведет к вызову дефолтной версии функции. Однако, у этого подхода есть несколько недостатков, главный из которых — засорение пространства имен класса близкими названиями функций. Решить эту проблему можно с помощью определения реализации чисто виртуальных функций. Эта особенность языка C++ многим неизвестна, да и в большинстве случаем она и не нужна, но в нашем примере такое определение оказалось кстати. Отделение реализации. Вариант 2 Как видно, производные классы по-прежнему должны определять свою версию функции error, но для этого они могут воспользоваться ее реализацией в базовом классе. Сделав такой хитрый ход, мы решили проблему засорения пространства имен, однако, производя слияние error и defaultError, мы теряем возможность задать для этих функций разные уровни доступа. Последняя функция, которую мы рассмотрим — это невиртуальная функция objectID(). Цель объявления невиртуальной функции заключается в том, чтобы заставить производные классы наследовать как ее интерфейс, так и обязательную реализацию. Каждый объект Shape имеет функцию, которая дает идентификатор объекта, и задается определением функции Shape::objectID, и никакой другой производный класс не должен его изменять. Разница в объявлении чисто виртуальных, виртуальных и невирутальных функциях позволяет то, что по замыслу программиста, должны наследовать производные классы: только интерфейс, интерфейс и реализацию по умолчанию либо интерфейс и обязательную реализацию соответственно. ЗаключениеНа этом мы завершим разговор о тонкостях ООП в C++. Напомню, что, помимо рассмотренного, существует еще много нюансов связанных с объектно-ориентированным программированием в C++, которые нужно знать, чтобы быть уверенным в своих силах. |