msiu_logo
Кафедра информационных систем и технологий
http://edu.msiu.ru

Знакомство с языком C++ (часть 2)

А.Г. Верещагин

21 февраля 2014

Объектно-ориентированные возможности языка C++

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

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

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

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

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

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

Классы

Обычно абстракции в объектно-ориентированных языках описываются с помощью классов. Описание класса содержит перечень полей (из чего состоит объект?) и методов (что объект может делать?). Содержимое класса в C++ принято называть членами (members), методы называют функциями-членами (member functions), а поля — объектами-членами (member objects).

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

В C++ понятие классов восходит к структурам из языка C. Соответственно, синтаксис описания классов берет своё начало в синтаксисе для структур с тем отличием, что ключевое слово struct меняется на class. В остальном синтаксис для структур и классов был единообразно расширен, так что структуры языка C++ по своим возможностям стали полностью эквивалентны классам данного языка.

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

Прототипы функций-членов, которые не изменяют объекта, для которого были вызваны, могут быть дополнены в конце квалификатором const. Такие функции-члены называются константными. Следует различать логическую и побитовую константность. Объект может не изменить своего представления в памяти, но начать вести себя иначе (например, если содержит указатель на изменившееся значение). Наоборот, объект может изменить своё представление в памяти, но не изменить поведения (например, прокэшировать данные). Для поддержки второго случая некоторые поля класса можно пометить квалификатором mutable. Такие поля могут менять даже константные функции-члены.

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

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

Как и в языке C в случае структур, в C++ определения классов принято размещать в отдельных заголовочных файлах. Важным замечанием является тот факт, что функции, определённые внутри структур и классов, неявно становятся встраиваемыми (inline). Таким образом объектный код, реализующий данные функции, будет дублироваться (возможно, многократно) в каждой единице трансляции, в которой они используются. Хотя окончательный результат после объединения различных единиц трансляции в один исполняемый файл зависит от компилятора и настроек оптимизации, следует осознанно подходить к выбору между объявлением и определением функции внутри класса или структуры. Обычно функции определяют вне объявления класса (и вне заголовочного файла), если только нет особых соображений специально сделать данные функции встраиваемыми.

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

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

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

Спецификаторы доступа

Для реализации идиомы инкапсуляции в объектно-ориентированных языках применяются так называемые спецификаторы доступа, определяющие возможность доступа к тому или иному члену класса из разных частей программы. В C++ существует три спецификатора доступа: public, private и protected. Каждый член класса явно или неявно имеет один из трёх возможных типов доступа, соответствующих упомянутым выше спецификаторам. Открытые (public) члены доступны из любой части программы, включающей в себя описание класса (то есть, как правило, содержащей соответствующую директиву #include). Закрытые (private) члены класса доступны только членам данного класса и его друзьям. В случае защищённых (protected) членов круг имеющих к ним доступ членов расширен членами классов-наследников и их друзей.

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

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

Дружественные классы и функции

Следует осторожно подходить к вопросу о том, следует ли делать какую-либо функцию членом класса или нет. Каждый класс должен быть специализирован под какую-то одну простую задачу, следовательно и объём доступных ему действий так же должен быть ограничен. Таким образом функций-членов у класса не должно быть много. Все операции над объектами некоторого класса, не требующие доступа к не открытой части класса, могут быть вынесены из этого класса, делая его таким образом «легче». Внутри класса остаются только самые основные функции, весь остальной «вспомогательный» функционал выносится наружу. Функции, которые должны иметь доступ к закрытой части, так же могут быть вынесены из класса, если они являются вспомогательными по смыслу или должны принадлежать другому классу, с помощью механизма дружбы (friendship).

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

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

Спецификатор friend так же позволяет сделать другом целый класс, так что каждая его функция-член станет функцией-другом. Так, чтобы класс B стал другом классу A, внутри тела класса A достаточно записать объявление friend class B;. Следует иметь ввиду, что механизм дружбы имеет ряд ограничений, призванных минимизировать свободу, обретаемую друзьями класса. Так:

Вложенные и локальные классы

Классы могут быть объявлены внутри других классов (вложенные классы/nested classes) или даже функций (локальные классы/local classes). Но существуют определённые ограничения на такие объявления.

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

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

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

Конструкторы и деструкторы

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

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

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

Конструктор по умолчанию

Если определение класса не содержит ни одного конструктора, конструктор по умолчанию будет сгенерирован для него автоматически: такой конструктор просто вызовет для всех объектов-членов и базовых классов их конструкторы по умолчанию, если такие конструкторы существуют. Но если генерация конструктора по умолчанию невозможна, программист должен предоставить собственную реализацию какого-либо конструктора для класса. Конструкторы по умолчанию могут быть неявно использованы при объявлении объектов или массивов. В некоторых случаях от классов требуется наличие конструктора по умолчанию. Явно определить конструктор по умолчанию класса A, но сохранить стандартную реализацию можно с помощью конструкцииC++11:

A() = default;

Конструкторы копирования и перемещения

Конструктор, принимающий один аргумент — ссылку на константный объект такого же типа — называется конструктором копирования (copy constructor). Такой конструктор должен проинициализировать создаваемый объект таким образом, чтобы он мог считаться копией оригинального объекта. Такой конструктор так же может быть сгенерирован автоматически, если это возможно и есть такая необходимость. Но реализация по умолчанию (использующая копирование всех объектов-членов) может быть неудовлетворительной. В этом случае можно самостоятельно определить конструктор копирования или запретить его генерацию. Подробнее см. ниже.

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

Конструкторы копирования или перемещения в зависимости от типа величины вызываются при передачи аргументов в функцию и возврате результата из функции по значению. Правда, иногда компилятор способен применить особые вид оптимизации, устраняющие лишние вызовы конструкторов (например, «оптимизация возвращаемого значения» — return value optimization, RVO). Часто для повышения эффективности передаче аргументов и возврату результата по значению следует предпочесть использование ссылок на константу (или просто ссылок). Так же конструктор копирования неявно вызывается при использовании объявлений, содержащих инициализацию однотипным значением.

Конструкторы преобразования

Есть ещё одна особая категория конструкторов, называемых конструкторами преобразования (conversion constructors). Теоретически, в соответствии с правилами стандарта C++11, к данной категории можно отнести все «обычные» конструкторы, вызываемые с аргументами и не отмеченные спецификатором explicit. Но на практике достаточно сконцентрировать внимание на конструкторах, принимающих один аргумент. Допустим, это конструктор класса A, принимающий в качестве аргумента объект класса B. Если подобный конструктор не будет помечен спецификатором explicit, то он может быть использован во всех случаях, когда может потребоваться преобразование объекта типа B к типу A. Компилятор выполнит такое преобразование неявно. Примеры подобных ситуаций: присвоение объекта типа B переменной типа A, вызов функции, принимающий аргумент типа A, с аргументом типа B и т.п. Не редко подобные ситуации возникают в силу программистских ошибок, о которых компилятор ничего не скажет, а сама возможность кажется избыточной. Поэтому даже появилось правило «всегда объявляйте конструктор с одним аргументом как explicit». Такое правило несёт в себе рациональное зерно, но из него существуют и исключения, когда неявное приведение типов, наоборот, удобно. Возможно, в будущем данный тип конструкторов станет трактоваться explicit по умолчанию, а в языке появится новый спецификатор implicit.

Деструкторы

Наконец, подробнее рассмотрим деструкторы. Деструктор можно определить подобно конструктору по умолчанию, но с префиксом в виде знака ~. У деструктора нет списка инициализации и аргументов. У каждого класса есть только один деструктор. Любой деструктор при вызове выполняет своё тело, после чего неявно вызывает деструкторы объектов-членов и базовых классов в порядке, обратном их конструированию. Таким образом редко есть необходимость в явном определении собственных деструкторов (если только объекты не хранят напрямую каких-то особых ресурсов, которые необходимо освободить перед уничтожением объекта). Удаление деструктора или ограничение доступа к нему сильно ограничивает варианты использования подобных объектов. Даже если объект возможно сконструировать, рано или поздно для него должен будет вызван деструктор. Если доступа деструктору нет, теряется смысл в использовании подобных объектов.

Есть несколько простых правил относительно деструкторов. Во-первых, деструкторы не должны генерировать исключений. Это верный путь к утечкам ресурсов и непредсказуемому поведению. Во-вторых, для полиморфных типов необходимо помечать деструкторы спецификатором virtual. Добавить спецификатор virtual деструктору класса A, сохранив определение по умолчанию, можно с помощью следующей конструкцииC++11:

virtual ~A() = default;

Перегрузка операторов

Большинство операторов (operators, не путать со statements) в C++ могут быть перегружены подобно обычным функциям. Для этого используется синтаксис определения т.н. операторных функций, где имя функции заменяется на ключевое слово operator, после которого следует некоторый суффикс, соответствующий тому или иному оператору. При этом различают операторные функции-члены и свободные операторные функции. В случае операторных функций-членов в качестве левого операнда выступает this. Для свободных операторных функций все операнды передаются как аргументы. Такие функции как правило являются друзьями по отношению к своим операндам, так как обычно нуждаются в повышенном уровне доступа к их членам.

С помощью перегрузки операторов можно расширить область действия стандартных операторов языка, приспособить их для новых типов данных, определить практически любое поведение, но существует ряд ограничений. С помощью перегрузки операторов невозможно изменить их приоритет, число операндов, не могут быть использованы аргументы по умолчанию. Не могут быть перегружены операторы ::, .*, ?, . (хотя можно перегрузить операторы * и ->, что делает возможным функционирование так называемых «умных указателей»). Нельзя создавать свои собственные новые операторы, можно лишь использовать те, что предопределены в языке.

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

Перегрузка оператора копирующего присваивания

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

Для класса A типичным прототипом копирующего оператора присваивания будет A &operator=(A const &a);. Предполагается, что данный оператор делает свой левый операнд (доступный по указателю this) копией правого операнда a, переданного для эффективности по ссылке на константу, и вернёт ссылку на левый операнд, ставший копией правого. Если соответствующий прототип вместе с прототипом конструктора копирования пометить ключевым словом deleteC++11, экземпляры данного класса невозможно будет скопировать:

class A {
  A() = default; // Разрешить конструирование,
                 // использовать реализацию компилятора
  A(A const &) = delete;  // Но запретить копирование
  A &operaotr=(A const&) = delete;
};

Теперь рассмотрим ситуацию, когда необходимо создать собственную реализацию оператора копирующего присваивания. При этом предположим, что в соответствии с «правилом трёх» (rule of three: «деструктор, конструктор копирования и оператор копирующего присваивания должны переопределяться совместно») конструктор копирования уже определён. Хотя копирующее присваивание и конструктор копирования выполняют очень похожую задачу, они работают по-разному: конструктор создаёт новый объект, тогда как оператор модифицирует существующий. Тем не менее, если возникла необходимость в переопределении логики копирования, можно предположить, что она достаточно сложна, а следовательно может вызывать исключения. Для реализации строгой гарантии безопасности относительно исключений можно применить имеющий широкую популярность подход «скопировать и обменять» (copy and swap), идея которого заключается в том, чтобы подвергать изменениям не модифицируемый объект, а его копию, после чего копия и оригинал обмениваются состояниями. Работа над копией гарантирует безопасность исходному объекту в случае возникновения исключительных ситуаций. Реализующий такой подход оператор копирующего присваивания выглядит следующим образом:

A &operator=(A a)
{
  swap(*this, a);
  return *this;
}

При этом предполагается, что функция swap(A &a, A &b) гарантирует отсутствие исключений и реализует логику обмена двух объектов состояниями. В качестве swap может быть использована std::swap, специализированная версия srd::swap или собственная реализация. Следует обратить внимание, что данная реализация оператора принимает в качестве аргумента не ссылку, а значение, так что неявно будет вызван конструктор копирования или перемещения (если он существует) в зависимости от типа аргумента. Использование семантики перемещения C++11 позволяет несколько повысить эффективность там, где возможно заменить копирование на перемещение. Сама функция swap так же может быть реализована с использованием семантики перемещения или иных техник и эффективна по памяти. Такая реализация копирующего оператора присваивания даёт строгую гарантию относительно исключений (при правильных реализациях конструкторов копирования и, возможно, перемещения), повторно использует уже реализованный код и является относительно эффективной. Тем не менее, не стоит забывать, что идиома «скопировать и обменять» не является «бесплатной», и там, где не нужны строгие гарантии безопасности или процесс копирования не вызывает исключений, необходимости в данной идиоме нет. Если это так, то при определении собственного оператора присваивания не стоит забывать копировать члены базового класса (вероятно, с помощью соответствующего оператора) и осуществлять проверку на присваивание самому себе.

Наследование

В соответствии с идеологией объектно-ориентированного программирования производные классы (derived classes) могут заимствовать детали реализации базовых классов (base classes) с помощью механизма наследования (inheritance). Производные классы так же иногда называют дочерними, а базовые — родительскими. C++ поддерживает концепцию множественного наследования (multiple inheritance), позволяющей производному классу иметь несколько базовых. Базовые классы могут повторяться, вызывая неоднозначность. Типичный пример — так называемое ромбическое наследование (diamond inheritance), когда класс A имеет два потомка B и C, а класс D множественно наследует и A, и B. Объект класса D может в такой ситуации может иметь либо два, либо один подобъект класса A. Первый вариант называется повторным наследованием (repeated inheritance), а второй — виртуальным наследованием (virtual inheritance) или разделяемым наследованием (shared inheritance). По умолчанию в подобных ситуациях C++ использует повторное наследование, но если при определении потомков (B и C) их общий класс (A) указывается с использованием ключевого слова virtual, то объекты класса D будут иметь единственный подобъект класса A. Правила, определяющие инициализацию виртуальных базовых классов, сложнее и интуитивно не так понятны, как правила для невиртуальных базовых классов. Рекомендуется продуманно подходить к использованию виртуального наследования, избегать его, где это возможно и стремиться не наследовать классы, требующие инициализации.

Управление доступом к членам базового класса

При определении класса имена базовых классов указываются через запятую после символа :, который ставится после имени класса перед телом определения. Члены базовых классов становятся членами производного класса. Есть три типа доступа к членам базового класса: открытый (public), защищённый (protected) и закрытый (private). Соответствующие ключевые слова необходимо указать перед именем базового класса. Эти слова можно и не указывать. В случае определения классов по умолчанию используется закрытый тип доступа, в случае структур — открытый.

Закрытые члены базового класса недоступны в производных. При открытом наследовании открытые члены базового класса остаются открытыми, а защищённые — защищёнными. При защищённом наследовании открытые и защищённые члены базового класса в становятся защищёнными. А при закрытом наследовании и открытые, и защищённые члены базового класса в производном становятся закрытыми. Следует заметить, что только открытое наследование не нарушает принципа подстановки Барбары Лисков: «Функции, которые используют базовый тип, должны иметь возвожность использовать подтипы базового типа не зная об этом». Открытое наследование моделирует отношение «является». Если A открыто наследует B, то тип A так же является и типом B, расширяя, реализуя, специализируя его функциональные возможности. Защищённое и закрытое наследование моделирует отношение «реализуется посредством» или «содержит». Как правило, композиция будет лучшей альтернативой такому наследованию, но не тогда, когда нужен доступ к защищённым членам базового класса. Выбор между защищённым или закрытым наследованием зависит от того, будет ли нужен доступ к членам наследуемого класса производным наследующего.

Иногда при наследовании возникает потребность в «перераспределении» типа доступа к унаследованным членам. Имя унаследованного члена с применением оператора разрешения области видимости можно переместить в требуемую область видимости с помощью директивы using.

Переопределение функций и скрытые члены

В производном классе можно определять члены, чьи имена совпадают с членами базового класса. В этом случае члены базового класса становятся скрытыми (hiding members) — недоступными в производном классе по своему имени без использования оператора разрешения области видимости. Можно предоставить свою собственную реализацию унаследованной функции — это будет называться переопределением функции (function override). Не следует путать переопределение функций с перегрузкой функций — это два независимых понятия. Иногда может возникнуть желание перегрузить унаследованную функцию. Но при попытке определить в производном классе функцию с тем же именем, что и в базовом, функция из базового класса будет скрыта. «Проявить» скрытые члены в производном классе можно с помощью директивы using аналогично приведённому выше описанию. Директива using «проявит» все перегруженные варианты базового класса. Для имитации «проявления» лишь некоторых из них следует использовать переопределение соответствующих функций в виде функций-обёрток, тело которых содержит лишь вызов функции базового класса с применением оператора разрешения области видимости.

Одно и то же определение может скрыть некоторую функцию, переопределить её или перегрузить. Такая зависимость от контекста может причинить определённые неудобства. Поэтому в C++11 появился спецификатор overrideC++11, употребляемый в конце прототипа функции при её объявлении. Употребление данного спецификатора явным образом демонстрирует желание перегрузить функцию, так что если на практике действительность не будет соответствовать желаемому, компилятор сгенерирует ошибку.

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

Финальные классы и функции

Иногда бывает полезно запретить возможность переопределения функций в производных классах. Функции, которые нельзя переопределять, при объявлении можно пометить спецификатором finalC++11. Попытка переопределить такие функции в производном классе приведёт к ошибке компиляции.

Аналогичным образом может возникнуть желание запретить наследование от некоторого финального класса (final class). При определении финального класса после его имени нужно поставить ключевое слово final.

Виртуальные функции

Виртуальная функция (virtual function) — это функция-член, которая может принадлежать производному классу, но будет вызвана даже при использовании указателя или ссылки на базовый класс. Такое поведение кажется естественным и во многих объектно-ориентированных языках все функции (методы) являются виртуальными. Но поддержка виртуальных функций является накладной. Адрес виртуальной функции в точке вызова не может быть вычислен на этапе компиляции и должен быть найден уже во время исполнения. Такой механизм называется поздним связыванием (late binding). Класс, содержащий виртуальные функции, называется полиморфным классом (polymorphic class). C++ в соответствии со своей философией не может допустить такого удара по эффективности, поэтому в C++ функции по умолчанию не являются виртуальными. Это означает, что если у класса A есть метод m, а в классе B — наследнике класса A — этот метод переопределён, то в случае вызова метода m у экземпляра класса B при использовании ссылки типа A& будет выбрана реализация метода m из класса A, а не из B. Это может нарушить логику работы программы, ввиду чего и существуют правила о непереопределении невиртуальных функций и определении виртуальных деструкторов в полиморфных классах.

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

Чисто виртуальные функции, абстрактные классы и интерфейсы

Чтобы объявить функцию виртуальной, достаточно в начале её прототипа указать спецификатор virtual. При наследовании «виртуальность» функции сохраняется, так что дублировать спецификатор virtual в производных классах нет необходимости — лучше использовать спецификатор overrideC++11. Если в в конце прототипа виртуальной функции дописать = 0, то будет объявлена чисто виртуальная функция (pure virtual function). Для чисто виртуальной функции не предоставляется определения. Цель существования чисто виртуальной функции — заявить о том, что такая функция существует и должна быть определена в одном из производных классов. Определение чисто виртуальных функций — это нормальный способ определения виртуальных функций в базовых классах, так как базовый класс, в общем случае, не может знать, какая реализация потребуется производным классам. Определять в базовом классе не чисто виртуальные функции имеет смысл только, если существует некоторое поведение по умолчанию, пригодное для использование производными классами, но которое они могут при желании переопределить.

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

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

Полиморфизм и перегрузка функций

Перегрузка функций — это, по сути, ещё один из инструментов полиморфизма в C++. Действительно, можно использовать одно и то же имя функции для аргументов различного типа, и при этом будет вызвана правильная реализация функции. Это увеличивает степень независимости кода от конкретных типов данных, повышая уровень полиморфизма. К сожалению, в отличие от виртуальных функций, перегрузка функций в C++ не использует динамической диспетчеризации (dynamic dispatch). Иными словами, конкретная реализация в точке вызова перегруженной функции определяется на этапе компиляции. Если аргумент перегруженной функции имеет тип указателя или ссылки на базовый класс для экземпляра некоторого производного класса, будет учитываться только тип базового класса, но не производного. Таким образом тип производного класса учитывается только для объекта, у которого вызывается метод, но не для аргументов метода. Про языки с таким ограничениям говорят, что они реализуют одинарную диспетчеризацию (single dispatch). Реализация множественной диспетчеризации (multiple dispatch), в частности, двойной диспетчеризации (double dispatch), является, строго говоря, довольно сложной задачей, а необходимость в ней возникает не очень часто, так что C++, как и большинство других объектно-ориентированных языков, пока не поддерживает данного механизма, хотя в будущем это может измениться. Очевидно, что применение множественной диспетчеризации на практике так же повысит накладные расходы на вызов функции, и это следует учитывать при проектировании.

Шаблоны

Шаблоны (templates) — мощный инструмент C++ для практики обобщённого программирования, открывающий новые грани для реализации идиомы полиморфизма. C++ поддерживает шаблонные функции (template function) и шаблонные классы (template class). Определения шаблонных функций и классов могут быть параметризированны определённым числом типов, а так же (с некоторыми ограничениями) величинами целочисленных, перечислимых или указательных типов. Такие определения становятся «шаблонами» для настоящих реализаций. При использовании шаблонных функций или классов необходимо явно или неявно конкретизировать параметры шаблона, и тогда компилятор сгенерирует в данной единице трансляции реализацию соответствующей функции или класса.

Шаблоны, по сути, являются мощным инструментом полиморфизма. Обобщённый код, реализованный в виде шаблона, может быть сгенерирован для любого типа данных, для которого это вообще возможно. При подстановке конкретного типа данных в шаблон должна порождаться синтаксически корректная конструкция. Таким образом можно сказать, что шаблоны определяют неявные интерфейсы (implicit interfaces) для своих параметров, которые не должны быть нигде отдельно описаны, а для параметризирующего типа нигде не должно быть явно указано, что он данный интерфейс реализует. Ограничения на тип параметра явным образом не описываются, а зависят от того, как данный параметр используется в определении шаблона. Иными словами, шаблоны C++ реализуют подход, именуемый утиной типизацией (duck typing).

Для шаблонов возможна полная и частичная специализация (full and partial template specialization). Это означает, что из одного шаблона можно породить другой, увеличив или сократив число параметров и конкретизировав параметры исходного шаблона. Специализированный шаблон предоставляет свою собственную реализации функции или класса в соответствии с новыми параметрами. Имя же шаблона станет «перегруженным» (по аналогии с перегрузкой функций), конкретный шаблон при конкретизации будет выбран на основе количества параметров и их типов.

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

Полное определение шаблона должно быть доступно в точке его конкретизации. Поэтому частой практикой является реализация шаблонных классов прямо в заголовочных файлах (иногда для таких файлов используют расширение .tpp), а функций — в классах. (Для шаблонов вообще характерно, что некоторые правила языка перестают работать и меняются на другие.) Из-за упомянутого ограничения при многократном использовании шаблонов происходит «раздувание» исполняемых файлов, а процесс компиляции замедляется. Это неизбежная цена за мощный механизм обобщённого программирования, но при определённом старании негативный эффект можно минимизировать. В частности, конкретизированные шаблоны можно снабдить спецификатором extern, так что реализация конкретизированного шаблона может быть найдена в другой единице трансляции. Дублирующийся код, не зависящий от параметров шаблона, выносится из шаблонов, что так же минимизирует «эффект разбухания».

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