Глава 9. Рекомендации по программированию В этой главе были рассмотрены объектно-ориентированные возможности языка С++, и теперь пришло время остановиться на проблемах, которые возникают в процессе написания программ: какие средства языка предпочтительнее выбирать в разных случаях, на что обратить особое внимание, чего нужно избегать, чтобы программа получилась эффективной и легко сопровождаемой. Однозначные рекомендации, как всегда, дать невозможно — только понимание механизма работы С++ позволит грамотно его использовать. Создание любого программного продукта начинается с процесса проектирования, и одна из первых задач, возникающих при этом — определить, должна программа быть объектно-ориентированной или нет. Объектно-ориентированное программирование, примененное к задаче, в которой в нем нет необходимости, только увеличит объем программы и сложность ее написания. Если в процессе анализа постановки задачи выясняется, что необходимости в иерархии классов нет, чаще всего можно обойтись структурным подходом. Б. Страуструп считает, что «везде, где не нужно более одного объекта определенного типа, достаточно стиля программирования с сокрытием данных при помощи модулей». Смешивать два подхода в одном проекте не рекомендуется, поскольку может оказаться, что готовый продукт обладает недостатками и структурного, и объектноориентированного принципов построения. Технологии проектирования программ посвящено много литературы, и освещение этого вопроса не входит в задачу данной книги. Тем, кто собирается приниматься за серьезные программные проекты, рекомендуется предварительно изучить ставшие классическими книги Ф. Брукса [6], Г. Буча [7] и Б. Страуструпа [17]. Ниже рассматриваются вопросы, на которые следует обращать внимание на следующем этапе создания программы — в процессе написания программного кода. При создании класса, то есть нового типа данных, следует хорошо продумать его интерфейс — средства работы с классом, доступные использующим его программистам. Интерфейс хорошо спроектированного класса интуитивно ясен, непротиворечив и обозрим. Как правило, он должен включать только методы, но не поля данных. Поля данных должны быть скрытыми (private). Это дает возможность впоследствии изменить реализацию класса без изменений в его интерфейсе, а также регулировать доступ к полям класса с помощью набора предоставляемых пользователю методов. Не следует определять методы типа get/set для всех скрытых полей класса — это все равно, что открыть к ним доступ, только более сложным способом. Важно помнить, что поля класса вводятся только для того, чтобы реализовать свойства класса, представленные в его интерфейсе с помощью методов. Конечно, нет ничего плохого в том, чтобы установить или получить значение некоторого поля с помощью метода, если таким образом реализуется свойство класса (таким образом, код будет тот же, а его смысл — совсем другим). Не нужно расширять интерфейс класса без необходимости, «на всякий случай», поскольку увеличение количества методов ведет к трудности понимания класса пользователем1. В идеале интерфейс должен быть полным, то есть предоставлять возможность выполнить любые разумные действия с классом, и минимальным — без дублирования и пересечения возможностей методов. В виде методов рекомендуется определять только действия, реализующие свойства класса. Если какое-либо действие можно реализовать, не обращаясь к скрытым полям класса, его нет необходимости описывать как метод; лучше описать его как обычную функцию, поместив ее в общее с классом пространство имен. Если функция выполняет действие, не являющееся свойством класса, но нуждается в доступе к его скрытым полям, ее следует объявить как дружественную. Но в общем случае дружественных функций и классов надо избегать, поскольку главной идеей ООП является минимизация связей между инкапсулированными классами. Для увеличения производительности программы наиболее часто вызываемые методы можно объявить как встроенные (inline). В основном это касается коротких методов, тело которых оказывается меньше размера кода, генерируемого для их вызова. Кроме ускорения программы за счет исключения вызовов, это дает возможность компилятору производить более полную оптимизацию. Однако необходимо учитывать, что директива inline носит для компилятора рекомендательный характер, и он может ей не последовать — например, если метод содержит сложные циклы или объявлен как виртуальный. Кроме того, если программа или компилятор используют указатель на метод или функцию, дополнительно будет сгенерирована их невстраиваемая копия. Еще одним соображением, которое требуется принимать во внимание, является то, что многие отладчики имеют проблемы со встраиваемыми функциями. Конструкторы и деструкторы делать встраиваемыми не рекомендуется, поскольку в них фактически присутствует код, помещаемый компилятором, и размер этого кода может быть весьма значительным (например, в конструкторе производного класса должны быть вызваны конструкторы всех базовых и вложенных классов). Перегруженные операции класса должны иметь интуитивно понятный общепринятый смысл (например, не следует заставлять операцию + выполнять чтолибо, кроме сложения или добавления). В основном перегрузка операций используется для создаваемых программистом арифметических типов, а в остальных случаях для реализации каких-либо действий с объектами класса предпочтительнее использовать методы, поскольку им можно дать осмысленные имена. Если какая-либо операция перегружена, следует, если возможно, перегрузить и аналогичные операции, например, +, += и ++ (компилятор этого автоматически не сделает). При этом операции должны иметь ту же семантику, что и их стандартные аналоги. Основой любого класса являются его конструкторы и деструкторы. Как известно, при отсутствии явно заданных конструкторов, конструктора копирования и операции присваивания компилятор создает их автоматически. И конструктор копирования, и операция присваивания, создаваемые по умолчанию, выполняют поэлементное копирование из области-источника в область-приемник. 1 Под пользователем имеется в виду программист, использующий класс. Если объект содержит указатели, это приведет к тому, что после копирования два соответствующих указателя разных объектов будут ссылаться на одну и ту же область памяти. При уничтожении первого из объектов эта память будет освобождена, а повторная попытка освободить ее при уничтожении второго объекта приведет к неопределенному поведению программы. Поэтому для классов, содержащих поля-указатели, следует всегда явно определять конструктор копирования и операцию присваивания, выполняющие выделение памяти под динамические поля объекта. Кроме того, динамическая память, выделенная в конструкторе объекта, должна освобождаться в его деструкторе. Невыполнение этого требования приводит к утечкам памяти. Удаление нулевого указателя безопасно (при этом ничего не происходит), поэтому если конструкторы, конструкторы копирования и операция присваивания написаны правильно, любой указатель либо ссылается на выделенную область памяти, либо равен нулю, и к нему можно применять delete без проверки. Разница между конструктором копирования и операцией присваивания заключается в том, что последняя работает в том случае, когда объект-приемник уже существует, поэтому в ней перед выделением динамической памяти следует освободить занятую ранее. Из этого следует, что при реализации операции присваивания для классов, содержащих поля-указатели, необходимо проводить проверку на самоприсваивание и в этом случае оставить объект без изменений. Необходимо также помнить о том, что операция присваивания должна возвращать ссылку на константу. Таким образом, определение операции присваивания должно иметь вид: class X{ const X & operator=(const X & r); }; const X & X::operator=(const X & r){ if(this != &r){ // Копирование } return *this; } В конструкторах для задания начальных значений использовать инициализацию, а не присваивание: class X{ string s; public: X(const char * str); }; полям рекомендуется // Вариант конструктора с присваиванием: X::X(const char * str){ s = str; } // Вариант конструктора с инициализацией: X::X(const char * str): s(str){} Инициализация более универсальна, так как может применяться в тех случаях, когда присваиванием пользоваться нельзя (например, при задании значений константным полям или ссылкам). Кроме того, она выполняется более эффективно, потому что создание объекта в С++ начинается с инициализации его полей конструктором по умолчанию, после чего выполняется вызываемый конструктор. Таким образом, для первого варианта рассмотренного примера сначала будет вызван конструктор класса string по умолчанию, и только потом будет выполнено присваивание, в то время как второй вариант позволяет сразу же вызвать нужный конструктор. Для сложных классов, составляющих иерархию, разница между приведенными вариантами гораздо значительнее. Необходимо учитывать и тот факт, что поля инициализируются в порядке их объявления, а не в порядке появления в списке инициализации. Поэтому для уменьшения числа возможных ошибок порядок указания полей в списке инициализации конструктора должен соответствовать порядку их объявления в классе. Статические поля не должны инициализироваться в конструкторе, поскольку им нужно присваивать начальное значение только один раз для каждого класса, а конструктор выполняется для каждого объекта класса. Статические поля инициализируются в глобальной области определения (вне любой функции). Конструкторы копирования также должны использовать списки инициализации полей, поскольку иначе для базовых классов и вложенных объектов будут вызваны конструкторы по умолчанию: class X{ public: X(void); // Конструктор копирования: X(const X & r); }; class Y: public X{ string s; // Вложенный объект public: // Конструктор копирования: Y(const Y & r): X(r), s(r.s){} }; Операция присваивания не наследуется, поэтому она должна быть определена в производных классах. При этом из нее следует явным образом вызывать соответствующую операцию базового класса (см. с. <$Rnasl_prisv>). Наследование классов предоставляет программисту богатейшие возможности — виртуальные и невиртуальные базовые классы, открытое, защищенное и закрытое наследование, виртуальные методы и т. д. Выбор наиболее подходящих возможностей для целей конкретного проекта основывается на знании механизма их работы и взаимодействия. Открытое наследование класса Y из класса X означает, что Y представляет собой разновидность класса X, то есть более конкретную, частную концепцию. Базовый класс X является более общим понятием, чем Y 2. Везде, где можно использовать X, можно использовать и Y, но не наоборот (вспомните, что на место ссылок на базовый класс можно передавать ссылку на любой из производных). Необходимо помнить, что во время выполнения программы не существует иерархии классов и передачи сообщений объектам базового класса из производных — есть только конкретные объекты классов, поля которых формируются на основе иерархии на этапе компиляции. Методы, которые должны иметь все производные классы, но которые не могут быть реализованы на уровне базового класса, должны быть виртуальными. Например, все объекты иерархии должны уметь выводить информацию о себе. Поскольку она хранится в различных полях производных классов, эту функцию нельзя реализовать в базовом классе. Естественно назвать ее во всех классах одинаково и объявить как виртуальную с тем, чтобы другие методы базового класса могли вызывать ее в зависимости от фактического типа объекта, с которым они работают. По этой причине деструкторы объявляются как виртуальные. При переопределении виртуальных методов нельзя изменять наследуемое значение аргумента по умолчанию, поскольку по правилам С++ оно определяется типом указателя, а не фактическим типом объекта, вызвавшего метод: #include <iostream.h> class X{ public: virtual void fun(int a = 0){cout << a;} }; class Y: public X{ public: virtual void fun(int a = 1) {cout << a;} }; int main(){ X *px = new X; Например, каждый программист — человек, но не каждый человек — программист. 2 px->fun(); // Выводится 0 X *py = new Y; py->fun(); // Выводится 0 } Невиртуальные методы переопределять в производных классах не рекомендуется, поскольку производные классы должны наследовать свойства базовых. Иными словами, невиртуальный метод инвариантен относительно специализации, то есть сохраняет те свойства, которые должны наследоваться из базового класса независимо от того, как конкретизируется (специализируется) производный класс. Специализация производного класса достигается добавлением новых методов и переопределением существующих виртуальных методов. Главное преимущество наследования состоит в том, что можно на уровне базового класса написать универсальный код, с помощью которого можно работать и с объектами производного класса, что реализуется с помощью виртуальных методов. Альтернативным наследованию методом использования одним классом другого является вложение, когда один класс является полем другого: class X{ }; class Y{ X x; }; Вложение представляет отношения классов «Y содержит X» или «Y реализуется посредством Х». Необходимость использовать вложение вместо наследования можно определить, задав себе вопрос, может ли у Y быть несколько объектов класса X. Если требуется, к примеру, описать класс для моделирования самолета, будет логично описать в нем поле типа «двигатель»: самолет содержит двигатель, но не является его разновидностью. Отношение «реализуется посредством» используется вместо наследования тогда, когда про классы X и Y нельзя сказать, что Y является разновидностью X, но при этом Y использует часть функциональности X. Следует предпочитать вложение наследованию. В случаях, когда между классами нет логической взаимосвязи, а требуется просто использовать часть кода одного класса в другом, может быть полезным использовать закрытое наследование (с ключом доступа private). Этот способ используется, когда в производном классе требуется доступ к защищенным элементам базового класса и замещение его виртуальных методов. Шаблоны классов используются для создания семейств классов, поведение которых не зависит от типа объектов. Шаблоны следует использовать аккуратно, отдавая себе отчет в том, что для каждого типа порождается собственная копия шаблона, что может привести к разбуханию кода. Для уменьшения размера кода рекомендуется вынести в базовый класс все методы, не использующие информацию о типе объекта, и унаследовать от него шаблонный класс со всеми остальными методами. Это позволит избежать дублирования тех методов, тексты которых не зависят от параметра шаблона. Исключения используются, как правило, в тех случаях, когда иного способа сообщить об ошибке не существует (например, если она произошла в конструкторе или перегруженной операции), а также когда ошибка неисправимая или очень редкая и неожиданная. Обработка исключений несколько уменьшает производительность программы и, с моей точки зрения, ухудшает ее читаемость, поэтому без необходимости пользоваться исключениями не нужно. Для более подробного изучения рассмотренных в этом разделе вопросов рекомендуется обратиться к книгам А. Голуба [9] и С. Мейерса [13].