Переход от С к С++
Малышев Сергей Михайлович
Хочу сразу же сказать, что эта статья отнюдь не претендует на полные и безоговорочные рекомендации по переходу от С к С++. Тут даны лишь некоторые из очень многочисленных и может быть наиболее распространенные из них. Итак, к делу...
Для того чтобы освоиться с C++, необходимо некоторое время. Поскольку С является, по существу, подмножеством C++, все его старые "трюки" остаются в силе, но многие из них теряют свою значимость. Так, например, для программистов на C++ выражение "указатель на указатель" звучит немного забавно. Почему вместо указателя не была использована просто ссылка?
С - достаточно простой язык. Макросы, указатели, структуры, массивы и функции - это почти все, что он в действительности предлагает. Каким бы сложным ни оказался алгоритм, его всегда можно реализовать, используя перечисленный набор средств.
В C++ дело обстоит несколько иначе: наравне с макросами, указателями, структурами, массивами и функциями используются закрытые и защищенные члены классов, перегрузка функций, аргументы по умолчанию, конструкторы и деструкторы. Операции, определяемые пользователем, встроенные функции, ссылки, дружественные классы и функции, шаблоны, исключения, пространства имен и т. д. Очевидно, что более богатые средства проектирования предоставляют и более широкие возможности, а это в свою очередь, требует существенно иной культуры программирования.
Столкнувшись с таким разнообразием выбора, многие теряются, продолжая крепко держаться за то, к чему они привыкли. По большей части в этом нет особого греха, но некоторые "привычки" из С идут вразрез с духом C++. От них просто необходимо избавиться!
Давайте рассмотрим две наиболее частые и стойкие (на мой взгляд и собственный опыт) "привычки" из С - это использование директивы #define и функций scanf/printf.
Предпочитайте const и inline использованию #define
Этот правило лучше было бы назвать "Компилятор предпочтительнее препроцессора", поскольку #define зачастую вообще не относят к языку C++. В этом и заключается одна из проблем. Рассмотрим простой пример; попробуйте написать что-нибудь вроде: #define ASPECT_RATIO 1.653
Символическое обозначение может так и остаться неизвестным компилятору или быть удалено препроцессором, прежде чем код попадет в компилятор. Если это произойдет, то обозначение ASPECT_RATIO не окажется в таблице символов. Поэтому в ходе компиляции вы получите ошибку, связанную с использованием константы (в сообщении об ошибке будет сказано 1.653, а не ASPECT_RATIO).
Это вызовет путаницу. Если файл заголовков писали не вы, а кто-либо другой, у вас не будет никакого представления о том, откуда взялось значение 1.653, и на поиски ответа вы потеряете много времени. Та же проблема может возникнуть и при отладке, поскольку обозначение, выбранное вами, будет отсутствовать в таблице символов.
Указанная задача решается просто и быстро. Вместо использования макроса препроцессора определите константу: const double ASPECT_RATIO = 1.653;
Однако есть два специальных случая, заслуживающих упоминания. Во-первых, при определении константных указателей могут возникнуть некоторые осложнения. Поскольку определения констант обычно выносятся в заголовочные файлы (где к ним получает доступ множество различных исходных файлов), важно, чтобы сам указатель был объявлен с const, в дополнение к объявлению const того, на что он указывает. Например, для определения в файле заголовков константной строки char* следует писать const дважды: const char* const constantString = "String is constant";
Во-вторых, иногда удобно определять некоторые константы, как относящиеся к конкретным классам, а это требует другого подхода. Для того чтобы ограничить область действия константы конкретным классом, необходимо сделать ее членом этого класса, а чтобы гарантировать, что существует только одна копия константы, требуется сделать ее статическим (static) членом класса:
class GamePlayer
{
private:
static const int NUM_TURNS = 5; // Объявление константы
int scores[NUM_TURNS]; // Использование константы
};
Остается еще одна небольшая проблема, поскольку все то, что вы видите выше - это объявление, а не определение NUM_TURNS. Если вам необходимо определить статические члены класса в файле реализации, то напишите следующее:
сonst int GamePlayer::NUM_TURNS; // Обязательное объявление
// находится в файле реализации
Впрочем, терять сон из-за подобных пустяков не стоит. Если об определении забудете вы, то напомнит компоновщик.
Старые компиляторы могут не поддерживать принятый здесь синтаксис, так как в более ранних версиях языка было запрещено задавать значения статических членов класса во время их объявления. Более того, инициализация в классе допускалась только для целых типов (таких как int, bool, char и пр.) и для констант. Если вышеприведенный синтаксис не работает, то начальное значение следует задавать в определении:
class EngineeringConstants // Это находится в файле заголовка класса.
{
private:
static const double FUDGE_FACTOR;
};
А это находится в файле реализации класса: const double EngineeringConstants::FUDGE_FACTOR = 1.35;
Единственное исключение обнаруживается тогда, когда для компиляции класса необходима константа. Например, при объявлении массива GamePlayer::scores в листинге, приведенном выше, в момент компиляции может потребоваться задание его размера. Для того чтобы работать с компилятором, ошибочно запрещающим инициализировать целые константы внутри класса, следует применять технику, которая иногда называется "трюком с перечислением". Она основана на том, что переменные перечисляемого типа можно использовать там, где ожидаются целые числа, поэтому GamePlayer определяют следующим образом:
class GamePlayer
{
private:
enum { NUM_TURNS = 5 }; //трюк с перечислением - делает из
//NUM_TURNS символ со значением 5
int scores[NUM_TURNS]; //теперь нормально
};
Если вы имеете дело не с примитивным компилятором, написанным до 1995 года и представляющим собой только исторический интерес, то считайте, что вам скорее всего повезло: необходимость использовать этот трюк вероятно отпадет сама собой. Вернемся к препроцессору. Другой частый случай неправильного использования директивы #define - создание макросов, которые выглядят как функции, но не обременены накладными расходами функционального вызова. Канонический пример - вычисление максимума двух значений: #define max(a, b) ((a)>(b)?(а):(b))
В этой небольшой строчке содержится так много недостатков, что даже не совсем понятно, с какого проще начать. Всякий раз, когда вы пишете макрос подобный этому, необходимо помнить, что все аргументы следует заключать в скобки. В противном случае у пользователей будут возникать серьезные проблемы с применением таких макросов в выражениях. Но, даже если вы все сделаете верно, посмотрите, какие странные (если не сказать - страшные) вещи могут при этом произойти:
int а = 5, b = 0;
mах(++а, b); //здесь переменная "а" - увеличивается дважды
mах(++а, b+10); //а здесь - только 1 раз и это правильно
Происходящее внутри mах зависит от того, что с чем сравнивается! Не верится? Проверьте сами. К счастью, вам нет нужды мириться с поведением, так сильно противоречащим привычной логике. Существует метод, позволяющий добиться такой же эффективности, как при использовании макросов. В таком случае обеспечиваются как предсказуемость поведения, так и контроль типов аргументов (что характерно для обычных функций). Этот результат достигается применением встраиваемых функций:
inline int max(int a, int b) { return a > b ? a : b; }
Новая версия max несколько отличается от предыдущей, поскольку она может работать только с целыми аргументами. Возникшую проблему удачно решает шаблон:
template
inline const Т& max(const Т& а, const T& b)
{ return а > b ? а : b; }
Он генерирует целое семейство функций, каждая из которых берет два приводимых к одному типу объекта и возвращает ссылку (с модификатором const) на больший из двух объектов. Поскольку вам неизвестно, каким будет тип Т, для эффективности передача и возврат значения происходят по ссылке.
Кстати говоря, прежде чем вы решите писать шаблон для какой-либо функции, подобной max, узнайте, не присутствует ли она уже в стандартной библиотеке. В случае с max вы можете воспользоваться плодами чужих усилий: max является частью стандартной библиотеки C++.
Предпочитайте использованию
О эти операторы sсanf() и printf()! Практически все формы обучения языку С и (увы) С++ начинаются с них. Да, они переносимы. Да, они эффективны. Да, вы уже даже знаете, как их нужно использовать. Но какой бы благоговейный восторг они ни вызывали, факт остается фактом: операторы sсanf() и printf() и им подобные далеки от совершенства. В частности, они не осуществляют контроль типа переменной и к тому же нерасширяемы.
Поскольку контроль типов и расширяемость - краеугольные камни идеологии C++, то лучше всего с самого начала во всем опираться на них. Кроме того, семейство функций printf/scanf отделяет переменные, которые необходимо прочитать или записать, от форматирующей информации, управляющей записью и чтением. Неудивительно, что эти слабости функции printf/scanf - сила операторов " и ".
int i;
Rational r; // r является рациональным числом (класс Rational).
cin " i " r;
cout " i " r;
Если этот код предназначен для компиляции, должны быть в наличии функции operator" и operator", которые могли бы работать с объектом типа Rational. Отсутствие данных функций является ошибкой. (Для int и других стандартных типов имеются стандартные версии этих операторов.)
Более того, компилятор берет на себя заботу о том, какие версии операторов вызывать для разных переменных. Таким образом, вам нет необходимости беспокоиться о том, что первый читаемый или записываемый объект имеет тип int, а второй - Rational - с этим разберется компилятор!
Кроме того, считывание объектов происходит с использованием той же синтаксической формы, что и при записи. Поэтому нет необходимости помнить о том, что, если вы работаете не с указателем, важно не забыть взять адрес, а если имеете дело с указателем, следует убедиться, что вы не берете адрес. Пусть о таких деталях заботится компилятор C++. Это его дело. У вас же есть задачи посерьезнее.
И наконец, заметьте, что встроенные типы, подобные int, читаются и записываются совершенно аналогично типам, определенным пользователями, таким, например, как Rational. Попробуйте сделать то же самое, используя scanf/printf!
Ниже приводится пример того, как можно написать функцию для вывода класса рациональных чисел:
class Rational
{
public:
Rational(int numerator=0, int denominator=1);
int n, d; //числитель и знаменатель
friend ostream& operator"(ostream& s, const Rational& r);
};
ostream& operator"(ostream& s, const Rational& r)
{
s " r.n " '/' " r.d;
return s;
}
Эта версия operator" демонстрирует некоторые тонкости (притом весьма важные!). Например, она не является функцией-членом, а объект Rational передается operator" по ссылке const, а не как объект. Соответствующая функция ввода, operator", объявляется и реализуется аналогичным образом.
Как ни обидно это признавать, в ряде случаев имеет смысл вернуться к старому и проверенному способу. Во-первых, некоторые реализации операций потоков ввода/вывода менее эффективны, чем соответствующие операции С, и возможно (хотя маловероятно), что в отдельных приложениях это может оказаться существенным. Помните, однако: это относится не к потокам ввода/вывода вообще, а только к той или иной реализации. Во-вторых, библиотека потоков ввода/вывода, в ходе своей стандартизации претерпела ряд кардинальных изменений. Следовательно, приложения, требующие максимальной переносимости, могут столкнуться с тем фактом, что различные поставщики поддерживают различные приближения стандарта.
И наконец, поскольку классы библиотеки потоков ввода/вывода имеют конструкторы, а функции <stdio.h> - нет, в редких случаях существенным будет порядок инициализации статических объектов, и стандартная библиотека С окажется более удобной просто потому, что вы можете ею пользоваться без опасений.
Контроль типов и расширяемость, предлагаемые классами и функциями библиотеки потоков ввода/вывода, являются более важными, чем это может показаться, - не стоит отвергать их только из-за того, что вы привыкли к <stdio.h>.
Между прочим, это не опечатка - в названии данного правила действительно фигурирует <iostream>, а не <iostream.h>. Строго говоря, такого заголовка, как <iostream.h>, не существует: Комитет по стандартам отказался от него в пользу названия <iostream> при сокращении имен стандартных файлов заголовков, отсутствующих в библиотеке языка С.
Важно уяснить следующее: если (что весьма вероятно) ваш компилятор поддерживает как файл заголовков <iostream>, так и <iostream.h>, необходимо иметь в виду, что они слегка отличаются друг от друга. В частности, если вы включаете <iostream>, элементы библиотеки потоков ввода/вывода весьма удобно расположены в пространстве имен std; включая <iostream.h>, вы получаете те же элементы, но в глобальном пространстве имен. Их определение в нем может вести к конфликтам, предотвращению которых и должно было послужить введение понятия пространства имен. Кроме того, <iostream> короче, чем <iostream.h>.
Для многих это оказывается достаточным аргументом в пользу нового названия. Вот на этом пока и все. Если будут вопросы - пишите. По результатам вашего любопытства могут появиться новые статьи.
При написании данного текста активно использовалась книга Скотта Мейерса.
Список литературы
Scott Meyers Effective C++ Second Edition AWG 1998