Лекция 1. Знакомство с языком C++

Содержание лекции

Знакомство с языком C++ и сравнение с другими языками

Предполагается, что читатель знаком с языком С. Язык C++ является развитием языка С и унаследовал от него большинство конструкций, так что грамотно написанная С-программа компилируется на С++ без всяких изменений. Разве что С++ исправляет некоторые ненормальности языка С. Например, несколько иначе трактуется понятие константы: в С++ описание

    const int N = 1000;
не приводит к выделению памяти под переменную N, т.е. эта запись практически эквивалентна
    #define N 1000
Таким образом, в C++ константы можно описывать в h-файлах, не прибегая к директиве препроцессора "#define" (т.е. оставаясь в рамках самого языка). Попытка поместить такую стоку в h-файл в C-программе приводила к ошибке "множественное описание переменной".

Иначе трактуется описание параметров функции: в C++ запись

    int f();
означает, что функция f не имеет параметров, т.е. она эквивалентна несколько уродливому описанию
    int f(void);
(обычно в C++ так не пишут). В языке C описание "int f();" означало, что информация о параметрах функции f неизвестна.

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

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

  1. В C++ более строгий и разумный контроль типов, чем в C.
  2. Можно не пользоваться директивой препроцессора "#define", а вместо этого использовать "const int" в h-файлах.
  3. В C++ можно описывать локальные переменные в любой точке программы, а не только в начале блока, как в C.
  4. Комментарии в C++ "//..." гораздо удобнее, чем "/*...*/" в C.
  5. В C++ появился настоящий логический тип "bool" и логические константы "true" и "false", в результате чего уже не приходится прибегать к фиктивному типу "BOOL", который в C задавался обычно с помощью директивы "#defile" или оператора "typedef" как один из целых типов, и к использованию целых констант с именами "TRUE" и "FALSE", которые тоже нужно было определять где-нибудь в h-файлах.

Что принципиально нового появилось в C++, чего не было в C? Конечно, это классы и все связанные с ними возможности объектно ориентированного программирования. Далее, это механизм исключений ("exception"), позволяющий удобным образом организовать обработку исключительных ситуаций и нелокальные переходы в программе. Наконец, это механизм шаблонов ("template"), позволяющий вводить типы, зависящие от параметров.

Изучая язык C++, следует всегда иметь в виду, что он не является "настоящим" объектно ориентированным языком (настоящие объектно ориентированные языки -- это, например, Java и C#). По своему положению C++ занимает промежуточное положение между объектно ориентированными и традиционными языками. Внешне C++ похож на объектно ориентированный язык, в нем присутствует главное понятие любого объектно ориентированного языка -- понятие класса и все связанное с этим (наследование, виртуальные методы и т.п.). Однако по способам размещения объектов в памяти C++ -- это традиционный язык. Поэтому часто C++ называют "объектно ориентированным Ассемблером". Более подробно эти различия рассмотрены в разделе "сравнение традиционных и объектно ориентированных языков"

Понятие класса

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

Всегда следует различать описание класса, т.е. как бы "чертеж" класса, и конкретные объекты класса. Когда мы говорим "автомобиль Жигули 2106", что мы подразумеваем? Конкретный экземпляр или конструкцию и технологию производства? Под словом "класс" подразумевается обычно второе, т.е. описание класса. Как только описание класса готово, можно легко создавать объекты класса. Точно так же производство конкретного экземляра автомобиля не представляет особой сложности после завершения его разработки и организации производства. Разработка новой модели автомобиля обходится в сотни миллионов долларов, производство конкретного автомобиля -- в несколько тысяч.

В C++ объекты класса можно создавать двумя способами. Пусть мы имеем класс с именем "A". В C++ считается, что всякая переменная типа A содержит объект класса A. В этом принципиальное отличие языка C++ от объектно ориентированных языков! В объектно ориентированных языках (Java, C# и т.п.) переменные содержат не объекты, а хэндлы (handle) объектов, т.е. некоторую информацию, позволяющую получить доступ к объекту -- например, индекс объекта в некоторой глобальной таблице. Сами объекты расположены в "мире объектов", т.е. в специально организованной динамической памяти.

Итак, в C++, как только мы описываем переменную типа A, где A -- имя класса, сразу же создается объект, который располагается непосредственно в этой переменной. Описание

    A x;
приводит в выделению памяти под переменную x, размер которой равен размеру экземпляра класса A (этот размер в C++ всегда фиксирован для каждого класса A и равен суммарному размеру всех переменных-членов класса, с учетом выравнивания). Память выделяется либо в стеке в случае локальных переменных, либо в статической памяти в случае глобальных и статических переменных. Созданный таким образом объект в C++ "живет", пока существует переменная, содержащая его. Например, объекты, содержащиеся в локальных переменных, уничтожаются при выходе из функции, в которой они описаны. Принципиальное отличие от объектно ориентированных языков: в них объект не уничтожается, пока он кому-то нужен, т.е. пока существуют ссылки на него.

Второй способ создания объекта заключается в использовании оператора "new". В этом случае объект создается в динамической памяти (ее называют также "кучей", heap). Оператор "new" возвращает указатель на созданный объект (т.е. просто его адрес). Обычно этот указатель запоминается в переменной типа "указатель на класс A":

    A* p = new A;

Созданный таким образом объект никогда не удаляется автоматически! Программист в нужный момент должен сделать это сам, используя оператор "delete":

    delete p;
Это еще одно отличие от объектно ориентированных языков: в них программист не может удалять объекты, система сама удалит объект автоматически, когда он перестанет быть кому-то нужным. Для этого постоянно работает специальный подпроцесс, который называется "сборкой мусора". Причем удаление объекта не гарантируется! Если памяти очень много, система может и не тратить время на сборку мусора. Такая недетерминированность объектно ориентированных языков является их характерной чертой, из-за чего многие опасаются их использовать в ситуациях, где надежность является самым главным критерием. Например, объектно ориентированные языки запрещено использовать в системах реального времени, таких, как управление ядерным реактором или посадкой самолетов. К счастью, C++ вполне детерминирован и надежен, и на него такие ограничения не распространяются.

Описание класса: интерфейс и реализация

Описание класса в C++ состоит из двух частей: описание интерфейса и реализация методов класса. Традиционно описание интерфейса класса помещается в заголовочный файл (h-файл), имя такого файла имеет расширение ".h". Описание интерфейса содержит описание переменных-членов класса и описание прототипов методов. Впрочем, некоторые простые методы могут быть реализованы непосредственно в h-файле; такую реализацию называют "внутристроковой" или "инлайновой" (inline). (Русский термин "внутристроковая" совершенно не прижился, и все русскоязычные программисты употребляют термин "инлайновая", так что если хотите, чтобы вас понимали, говорите на программистском языке.) При использовании инлайновых методов и функций их текст непосредственно вставляется в то место, где он используется, подобно макроопределениям в Ассемблере. (Вспомним, что C++ -- это объектно ориентированный Ассемблер!) Объем кода при этом увеличивается (один и тот же фрагмент повторяется многократно при каждом использовании), зато программа получается значительно более быстрой: во первых, не используется механизм вызова функций; во вторых, и самое главное, -- компилятор C++ получает возможность оптимизации кода, которая не ограничивается рамками одного метода.

Реализация объемных (не инлайновых) методов класса выносится из заголовочного файла в файл реализации, который называют C++ файлом. К сожалению, в отличие от h-файлов программисты не сумели договориться между собой о стандартном расширении для C++ файлов. В разных операционных системах и разных системах разработки используются расширения ".cpp", ".cxx", ".cc" и другие. Стандарт языка не устанавливает никаких явных правил на этот счет. Обычно все конкретные компиляторы понимают по умолчанию перечисленные выше расширения как расширения файлов, содержащих программы на C++. Но компилятор можно явным образом заставить трактовать программу как программу на C++ независимо от расширения файла. Например, в случае компилятора "gcc" можно использовать ключ "-x":

    gcc -x c++ ...
(но лучше пользоваться командой "g++"); в случае компилятора Visual C "cl" фирмы Microsoft -- ключ "/TP":
    cl /TP ...

Мы будем использовать расширение ".cpp" для файлов, содержащих реализацию классов и функций на C++. Кроме реализации методов и функций, cpp-файлы содержат также

Члены класса

Члены класса -- это переменные, содержащиеся внутри каждого объекта класса, как бы входящие в "конструкцию" класса. (Мы не рассматриваем пока статические члены класса.) Описание членов класса содержится в описании интерфейса класса, которое помещается в h-файл. Каждый член класса имеет имя. Описания членов класса имеют точно такой же вид, как и описания обычных переменных в языке C. Отличие только в том, что они содержатся внутри описания класса. Рассмотрим пример. Пусть класс R2Vector представляет собой вектор на двумерной плоскости. Вектор задается двумя вещественными координатами x, y. Описание класса выглядит так:

    class R2Vector {
    public:
        double x;
        double y;
        . . .
    };
Здесь слово "public" означает, что мы разрешаем доступ к членам "x" и "y" класса R2Vector откуда угодно, а не только из методов этого класса. Мы как бы не скрываем переменных от постороних глаз. По умолчанию все члены и методы класса защищены от доступа снаружи, что можно задать явным образом с помощью ключевого слова "private". Можно также открыть доступ только для классов, выведенных из данного класса ("унаследованных" из данного класса) с помощью ключевого слова "protected". Таким образом, по строгости ограничений модификатор "protected" расположен между "private" и "public".

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

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

Стандарт языка не накладывает никаких ограничений на возможные действия в конструкторе, так что можно написать целую содержательную программу внутри конструктора. Но это дурной стиль! Никогда не делайте ничего содержательного в конструкторе, ограничивайтесь только начальной инициализацией. Если это не так, то постоянно будут возникать проблемы с описанием временных переменных, с наследованием классов, с возвратом значений функций и методов и т.п. К примеру, во всякой оконной системе конструктор класса "Окно" не создает окна на экране. Для того, чтобы окно появилось на экране, требуется вызвать специальные методы вроде "Create", "ShowWindow", "Map" и т.п. Итак, в конструкторе нужно лишь инициализировать переменные и при необходимости захватывать память в куче, если какие-то члены класса являются указателями.

Аналогично при удалении объекта вызывается специальный метод, который называется деструктором. Деструктор вызывается непосредственно перед освобождением памяти, занятой объектом. Крайне дурным стилем является выполнение каких-либо содержательных действий в деструкторе! Например, сохранение информации в файле на диске не следует помещать в деструктор, для этого используют специальный метод вроде "save". В деструкторе чаще всего выполняется только одно действие: освобождение динамической памяти, захваченной в конструкторе или в других методах класса. Делается это тогда, когда объект класса содержит указатели на области динамической памяти.

Рассмотрим пример. Пусть класс "Matrix" реализует прямоугольную матрицу размера n*m (n строк, m столбцов). Память под элементы матрицы захватывается в конструкторе, указатель на нее содержится в переменной "elements". Освобождать эту память надо в деструкторе.
    class Matrix {
        const int n;
        const int m;
        double* elements;
    public:
        Matrix(int rows, int columns):
            n(rows),
            m(columns),
            elements(new double[n * m])
        {
            for (int i = 0; i < n*m; i++)
                elements[i] = 0.;
        }

        ~Matrix() { delete[] elements; }

        . . .
    };

Инициализация переменных в конструкторе

Основная проблема для начинающих при программировании на C++ -- это отсутствие однозначных решений. Примерно одинакового результата можно достичь десятками различных способов. Причем ни в одном компилятор не выдает сообщений об ошибках, и программа работает правильно. Однако на поверку оказывается, что, например, при некорректных решениях программа замедляется в сотни раз или исхоный код невозможно использовать другим программистам (достаточно, к примеру, опустить модификатор "const", и метод уже нельзя использовать для константных объектов). Из многих возможных решений обычно только одно правильное, остальные -- это дурной стиль. Так что опыт программиста на C++ -- это прежде всего умение из множества различных возможных решений выбрать единственное правильное, т.е. избежать дурного стиля. Мы будем часто приводить примеры дурного стиля, чтобы потом было легче избежать их. Примеры дурного стиля будут обводиться красной рамкой.

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

  1. Все переменные-члены класса должны быть инициализированы в конструкторе, подчеркиваем, все! Если их даже 1000 и инициализация большинства по смыслу не нужна, все равно их все надо тупо выписать в том порядке, в котором они описаны в интерфейсе класса, и присвоить им некоторые начальные значения.
  2. Инициализацию всегда следует выполнять не внутри тела конструктора, а в списке инициализации.

Список инициализации располагается между заголовком конструктора, после которого ставится двоеточие, и телом конструктора, которое начинается с открывающей фигурной скобки. Рассмотрим, например, конструктор по умолчанию (т.е. без аргументов) упомянутого выше класса R2Vector. Если реализация инлайновая, т.е. внутри описания класса в h-файле, то она должна выглядеть так:

    R2Vector():
        x(0.),
        y(0.)
    {}
Тело конструктора в нашем примере пустое, и это в 99% случаев так. Если реализация метода не инлайновая, т.е. она содержится в ".cpp"-файле, то перед именем метода добавляется имя класса, к которому он относится; имя класса и имя метода разделяются двойным двоеточием:
    R2Vector::R2Vector():
        x(0.),
        y(0.)
    {}
А вот пример дурного стиля:
R2Vector::R2Vector() {
    x = 0.;
    y = 0.;
}
Дурной стиль: переменные x, y инициализируются в теле конструктора, а не в списке инициализации.
Почему так не надо делать? Во-первых, константные члены можно инициализировать только в списке инициализации. Во-вторых, инициализация базовых классов в случае, когда используется механизм наследования, может выполняться только в списке инициализации. В-третьих, это просто правило хорошего стиля: инициализируйте все члены класса в списке инициализации, ни о чем не задумываясь; это убережет вас от многих возможных ошибок.

Вернемся еще раз к рассмотренному выше классу Matrix. В конструкторе этого класса инициализируются константные члены "n" и "m" и указатель на массив элементов матрицы "elements":

    Matrix(int rows, int columns):
        n(rows),
        m(columns),
        elements(new double[n * m])
    {
        for (int i = 0; i < n*m; i++)
            elements[i] = 0.;
    }
Память под массив элементов захватывается непосредственно в списке инициализации с помощью оператора "new". В теле конструктора элементы матрицы обнуляются. В данном случае нам не удалось бы привести следующий фрагмент в качестве примера дурного стиля:
class Matrix {
    const int n;
    const int m;
    double* elements;
public:
    Matrix(int rows, int columns) {
        n = rows;
        m = columns;
        elements = new double[n * m];
        for (int i = 0; i < n*m; i++)
            elements[i] = 0.;
    }
    . . .
Ошибка: нельзя в теле конструктора присваивать значения константным членам m и n.
Компилятор выдаст ошибку, так что это не дурной стиль, а просто неправильная программа. Дело в том, что n и m являются константными членами класса Matrix, а изменять значение константных членов нельзя. Единственное место, где им можно присвоить значения, -- это список инициализации конструктора.

Сколько конструкторов должно быть?

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

  1. Конструктор без параметров, или конструктор "по умолчанию" (default constructor). Именно он вызывается, когда создается простая переменная или массив. Например, пусть мы имеем класс с именем "A", в котором определен конструктор по умолчанию. Тогда при описании
        A x;
    
    конструктор по умолчанию вызывается для объекта, который размещается в переменной x. Точно так же он вызывается, когда объект создается в динамической памяти с помощью оператора "new":
        A* p = new A;
    
    или
        A* p = new A();
    
    (в C++ обе записи допустимы и эквивалентны). При создании массива элементов типа "A" default-конструктор вызывается для каждого элемента создаваемого массива:
        A v[100];
    
    аналогично default-конструктор вызывается и при создании массива в динамической памяти:
        A* w = new A[100];
    
    В обоих случаях default-конструктор будет вызван 100 раз. Отметим, что в последнем случае при освобождении памяти, занятой массивом, который был создан в динамической памяти, надо использовать векторную форму оператора "delete":
        delete[] w;
    

    При уничтожении массива деструктор вызывается для каждого элемента массива (в нашем случае -- 100 раз).

  2. Копирующий конструктор (copy-constructor). В качестве аргумента copy-конструктору передается объект, с которого нужно сделать копию. Прототип copy-конструктора для класса "A" выглядит следующим образом:
        A(const A& x);
    
    Эта запись означает, что copy-конструктору передается константная ссылка на объект "x". Тип данных "ссылка" появился в C++, его не было в языке C. Ссылка описывается с помощью значка "&", не следует путать его с операцией взятия адреса, никакой связи нет! По механизму реализации ссылка похожа на указатель -- физически ссылка представляется адресом памяти, так же как и указатель. Однако, в отличие от указателя, в случае ссылки для доступа к объекту не надо использовать операцию косвенной адресации "*" (dereference). Ссылка используется так же, как и сам объект. Логически ссылку следует понимать как второе имя или как псевдоним объекта. Объект, на который указывает ссылка, должен быть задан при описании ссылки. Например, следующее описание ошибочно:
        A& x;
    
    Ошибка: при описании ссылки следует задать объект, на который она указывает.
    Правильное описание может выглядеть, например, так:
        A y;
        A& x = y;
    
    Переменные "y" и "x" после этого описания используются как различные имена для одного и того же объекта. В отличие от указателя, ссылку уже нельзя перенаправить на другой объект; ссылка навеки связана с объектом, который был задан при ее описании.

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

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

Для примера рассмотрим еще раз упомянутый выше класс R2Vector, моделирующий вектор на двумерной плоскости. У него 3 конструктора: конструктор по умолчанию, copy-конструктор и конструктор, создающий вектор по двум его координатам:

class R2Vector {
public:
    double x;
    double y;

    // Default-constructor
    R2Vector():     
        x(0.),
        y(0.)
    {}

    // Copy-constructor
    R2Vector(const R2Vector& v):
        x(v.x),
        y(v.y)
    {}

    // Constructor creating a vector by coordinates
    R2Vector(double coordX, double coordY):
        x(coordX),
        y(coordY)
    {}

    . . .
};

Модификатор "const": константные ссылки и указатели, константные методы классов

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

Заметим, что в первом стандарте языка C модификатора "const" не было, он появился лишь в ANSI-стандарте 1989 г. Причем этот стандарт появился уже значительно позже языка C++ и, по-видимому, под сильным влиянием C++. ANSI-стандарт языка C -- это своеобразное возвращение в прошлое, попытка подтянуть устаревший язык C до уровня более совершенного C++. Она явилась достаточно успешной, ANSI стандарт намного лучше старого C (сейчас уже даже трудно найти учебники, описывающие старый стандарт.) Тем не менее C++ гораздо удобнее и естественнее ANSI-C, и трудно придумать серьезные причины, по которым стоит держаться за C.

Особенность применения модификатора const: его можно либо не использовать вообще (так поступили, например, создатели языка Java), либо всюду использовать его правильно. Применение "const" совершенно не совместимо с ошибками и дурным стилем! Коль скоро вы его применяете, вам приходится применять его всюду правильно, иначе в лучшем случае вы не сможете скомпилировать программу (это по меньшей мере заставит вас исправить ошибки), в худшем случае компилятор не сможет отследить ошибку (вернее, дурного стиля), и вашими классами никто не сможет пользоваться.

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

Адрес константного объекта можно помещать лишь в указатель на константный объект. Проиллюстрируем на примере, как в C и C++ описывается указатель на константу:

    const int *p;
Описан указатель на константный объект типа int. Отметим, что сам указатель не является константой, ему можно присвоить значение. Если мы хотим определить константный указатель на неконстантный объект, надо использовать следующую запись:
    int x;
    int * const p = &x;
Такой указатель надо инициализировать сразу при описании, так как ему нельзя присваивать никаких значений (он константный!). Впрочем, такую запись в C++ почти не употребляют, поскольку такого же эффекта можно достичь с помощью ссылки. Наоборот, указатели на константные объекты и ссылки на константные объекты встречаются на каждом шагу, ведь они гарантируют, что объект, на который ссылается указатель, не будет испорчен даже в результате неправильных действий. Указатель на константный объект позволяет прочесть объект, но не дает возможности изменить его.

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

Константные методы классов -- это те методы, которые гарантированно не меняют объекта, к которому они применяются. К константным объектам можно применять лишь константные методы. Константный метод задается с помощью модификатора "const", который записывается после круглых скобок, содержащих параметры метода. Например, для класса R2Vector метод "length", вычисляющий длину вектора, является константным. Описание его прототипа в h-файле выглядит так:

    class R2Vector {
        . . .
        double length() const;
        . . .
    };
Реализация метода содержится в cpp-файле:
    double R2Vector::length() const {
        return sqrt(x*x + y*y);
    }

Как правило, все методы, которые выдают какие-либо характеристики объекта, должны быть описаны как константные. В противном случае их нельзя будет применить для константных объектов или даже для нормальных объектов, доступ к которым осуществляется через константный указатель или ссылку! Обычная ошибка начинающих: забывают записать модификатор "const" в описании прототипа метода, который по своей сути не меняет объект. Например, отсутствие "const" в описании прототипа метода "length()" является крайне дурным стилем.

class R2Vector {
    . . .
    double length() {
        return sqrt(x*x + y*y);
    }
    . . .
};
Дурной стиль: отсутствует модификатор "const" в описании метода "length".

Бывают, однако, и менее очевидные ошибки. Не забывайте о следующей возможности C++: может быть несколько одинаковых методов одного и того же класса, но с разными формальными параметрами -- как иногда говорят, с разной сигнатурой. Это знают все без исключения программисты на C++, однако не все помнят, что модификатор "const" также включается в сигнатуру метода! Это означает, что у класса могут быть два метода, которые отличаются только модификатором "const"; например, такая запись вовсе не является ошибкой:

    class A {
        . . .
        int f();
        int f() const;
        . . .
    };
Первый (неконстантный) метод "f" применяется к обычным объектам, второй -- к константным. Рассмотрим следующий фрагмент кода:
    A x;
    const A y;
    . . .
    int res1 = x.f();
    int res2 = y.f();
К объекту "x" применяется первый, неконстантный метод "f", к объекту "y" -- второй, константный метод "f".

Часто встречающаяся ошибка состоит в том, что в некоторых ситуациях забывают задать константный вариант метода, ограничиваясь только обычным вариантом. Рассмотрим пример с классом "Matrix". Пусть у него будет метод "at", который возвращает ссылку на элемент матрицы с заданными индексами. С его помощью элемент матрицы "a" с индексами i, j записывается как "a.at(i,j)". Дурной стиль состоит в том, что забывают реализовать два метода "at", обычный и константный, ограничиваясь только обычным методом.

class Matrix {
    const int n;
    const int m;
    double* elements;
public:
    . . .
    double& at(int i, int j) {
        return elements[i*m + j];
    }
    // Больше методов с именем "at" нет
    . . .
};
Дурной стиль: не реализован константный метод "at".
Правильное решение состоит в том, что должно быть два метода "at". Обычный метод возвращает ссылку (не константную) на элемент матрицы, константный метод -- константную ссылку на элемент матрицы или просто его значение.
    class Matrix {
        . . .
        double& at(int i, int j) {
            return elements[i*m + j];
        }
        const double& at(int i, int j) const {
           return elements[i*m + j];
        }
 
        /* или
        double at(int i, int j) const {
            return elements[i*m + j];
        }
        */
        . . .
    };
Аналогичные соображения справедливы и в случае оператора доступа к элементу по индексу, который позволяет записывать элемент матрицы "a" с индексами i, j как "a[i][j]": "operator[]" для класса "Matrix" также должен быть реализован в двух вариантах: обычном и константном. Оператор доступа по индексу с параметром "i" возвращает обычный указатель на начало i-й строки матрицы в случае обычного метода и константный указатель в случае константного метода. Выпишем полностью правильную реализацию всех четырех методов доступа к элементам матрицы:
    class Matrix {
        const int n;
        const int m;
        double* elements;
    public:
        . . .
        double& at(int i, int j) {
            return elements[i*m + j];
        }
        double at(int i, int j) const {
            return elements[i*m + j];
        }
        double* operator[](int i) {
            return elements + i*m;
        }
        const double* operator[](int i) const {
            return elements + i*m;
        }
        . . .
    };
Реализация обязательно инлайновая, поскольку очень важно обеспечить как можно более быстрый доступ к элементам матрицы.

Подведем итог:

Прототипы методов: правильный выбор типов формальных аргументов и возвращаемых значений

Владение языком C++ состоит прежде всего в умении правильно выбрать типы аргументов и возвращаемых значений методов, т.е. правильно написать прототипы методов. К сожалению, компилятор позволяет сделать это десятками способов, не выдавая сообщений об ошибках. Между тем правильный способ почти всегда единственный, остальные -- это дурной стиль. Рассмотрим типичные ошибки на примере класса R2Vector и методов сложения двух векторов ("operator+") и увеличения вектора на другой вектор ("operator+=").

Переопределение операторов

В C++ можно определять действие всех обычных операций языка C на объекты классов. Соответствующий метод называется "operatorX", где вместо "X" подставляется обозначение операции языка C, оно может быть однобуквенным (например, "operator+"), двухбуквенным ("operator+=", "operator<<") или трехбуквенным ("operator<<="). Если, к примеру, для класса R2Vector определены операторы "+" и "+=", то запись

    R2Vector a, b, c;
    . . .
    c = a + b;
    a += b;
означает, что в первом случае к объекту "a" применяется метод "operator+", которому в качестве аргумента передается объект "b". Таким образом, строка
    c = a + b;
эквивалентна
    c = a.operator+(b);
(такая запись в C++ вполне допустима!). Во втором случае к объекту "a" применяется метод "operator+=", которому в качестве аргумента передается объект "b"; эквивалентная запись:
    a.operator+=(b);
Цель переопределения операторов состоит в том, чтобы использовать более привычную запись для объектов классов. Мой совет: не стоит этим злоупотреблять! Используйте эту возможность только тогда, когда семантика операции применительно к данному классу ясна на 100%. Например, для случая векторов в трехмерном пространстве, каким значком обозначить векторное произведение? Знака "Андреевский крест" в языке C (и в наборе ASCII-символов) нет, а знак умножения "звездочка" обычно закрепляют за скалярным произведением векторов. Так что лучше использовать метод с названием "vectorProduct", которое однозначно определяет суть метода.

Рассмотрим, какие ошибки можно сделать, определяя прототип метода "operator+" для класса R2Vector. Первое, совсем неправильное решение:

    R2Vector operator+(R2Vector a, R2Vector b);
Беда! Человек, написавший такое, совсем не владеет языком C++. Если действительно описывается метод класса, а не глобальный оператор, то следует понимать, что оператор "+" имеет один аргумент, а не два! Первым операндом сложения является тот объект, к которому применяется метод, ведь запись "a+b", как уже было сказано, эквивалентна "a.operator+(b)".

Впрочем, следующее решение не намного лучше:

    R2Vector operator+(R2Vector b);
оно также свидетельствует о непонимании языка C++. Такая запись означает, что при вызове метода создается копия аргумента, которая и передается методу. Создание дополнительного объекта означает дополнительные расходы времени процессора, а это абсолютно излишне! Вместо копии объекта следует использовать передачу ссылки на аргумент. Итак, следующее неправильное решение:
    R2Vector operator+(R2Vector& b);
Мы черепашьими шагами приближаемся к правильному решению. Ошибка в данном случае состоит в том, что второй операнд сложения в результате выполнения операции не изменяется. Поэтому ссылка должна быть константной! Если она не константная, то операцию невозможно применить для константного вектора в качестве второго операнда. К тому же передача константной ссылки обезопасит нас от возможных ошибок в реализации метода, ведь константность ссылки гарантирует, что аргумент метода не будет испорчен в результате его выполнения. Попробуем еще одно решение:
    R2Vector operator+(const R2Vector& b);
Уже лучше, но все равно неправильно! Мы отметили, что второй операнд сложения не меняется при выполнении операции, но забыли сделать то же самое для первого операнда. А именно, объект, к которому применяется метод "operator+", не изменяется в результате его выполнения, следовательно, этот метод должен быть описан как константный! Иначе, опять же, его нельзя будет применить к константному объекту. Выпишем наконец-то правильное решение:
    R2Vector operator+(const R2Vector& b) const;
Здесь отмечено, что метод не меняет объект, к которому применяется, т.е. является константным. В результате применения метода создается новый объект типа "R2Vector", который и возвращается как результат метода. Выпишем еще раз возможные неправильные решения как примеры дурного стиля:
class R2Vector {
    . . .
    R2Vector operator+(R2Vector a, R2Vector b);
    // Неправильно! Два аргумента вместо одного

    R2Vector operator+(R2Vector b);
    // Неправильно! Передается копия аргумента вместо ссылки

    R2Vector operator+(R2Vector& b);
    // Неправильно! Используется обычная ссылка на аргумент
    //              вместо константной ссылки

    R2Vector operator+(const R2Vector& b);
    // Неправильно! Метод не объявлен как константный

    // Правильное решение:
    R2Vector operator+(const R2Vector& b) const;
    . . .
};

Рассмотрим теперь оператор "увеличить на" ("operator+=") применительно к классу R2Vector. Не будем выписывать множества неправильных решений, сразу укажем правильное:

class R2Vector {
    . . .
    R2Vector& operator+=(const R2Vector& b);
    . . .
};
Здесь имеются два важных отличия от метода "operator+".
  1. Этот метод изменяет объект, к которому применяется (увеличивает вектор, к которому применяется, на вектор, передаваемый в качестве аргумента), и поэтому не является константным.
  2. В результате выполнения этого метода не создается нового объекта (в отличие от метода "operator+", который создает новый вектор, равный сумме двух операндов). Поэтому тип возвращаемого значения является ссылкой на класс R2Vector, которая на самом деле указывает на объект, к которому применяется метод. Реализация метода должна оканчиваться строкой
            return *this;
    
Чтобы наглядно проиллюстрировать это отличие, выпишем (правильную!) инлайновую реализацию обоих этих методов:
    class R2Vector {
    public:
        double x;
        double y;
        . . .
        R2Vector operator+(const R2Vector& b) const {
            return R2Vector(x + b.x, y + b.y);
        }

        R2Vector& operator+=(const R2Vector& b) {
            x += b.x;
            y += b.y;
            return *this;
        }
        . . .
    };
Здесь при реализации метода "operator+" мы воспользовались конструктором класса R2Vector, который создает объект "вектор" по двум его вещественным координатам (этот конструктор был выписан ранее). Созданный объект возвращается как результат работы метода. Второй метод возвращает в качестве результата ссылку на объект, к которому он применяется и который изменяется в результате его работы.

Суммируем вышесказанное в виде краткого набора правил, которые следует соблюдать при написании прототипов методов.

  1. В качестве аргументов следует передавать не сами объекты, а ссылки на них. Ссылка должна быть константной, если аргумент не меняется в результате выполнения метода. (Замечание: в отличие от классов, объекты базовых типов -- "int", "double" и т.п. лучше передавать по значению, а не по ссылке -- конечно, только в том случае, когда аргумент не должен изменяться.) Пример для класса R2Vector:
        R2Vector operator+(const R2Vector& v) const;
    
  2. Метод должен быть описан как константный, если он не изменяет объекта, к которому применяется. Пример для класса R2Vector:
        double length() const;
    
  3. Метод, осуществляющий доступ к внутренним элементам объекта класса (например, метод, осуществляющий доступ к элементу матрицы с заданными индексами), должен быть определен в двух вариантах: обычном и константном. Обычный вариант не содержит модификатора "const" после списка формальных аргументов и возвращает обычную ссылку или обычный указатель на нужный элемент объекта. Константный вариант метода описывается с помощью модификатора "const" после формальных аргументов и возвращает либо константную ссылку или константный указатель, либо просто значение нужного элемента. Пример для класса Matrix:
        double& at(int i, int j);
        double at(int i, int j) const;
    
  4. Метод, создающий новый объект в результате его выполнения, должен возвращать этот объект в качестве результата; таким образом, тип возвращаемого значения -- это просто имя класса. Чаще всего такой метод не изменяет объекта, к которому применяется, и потому должен быть описан как константный. Метод, изменяющий объект, к которому он применяется, и возвращающий измененный объект, не является константным и должен возвращать значение типа "ссылка на класс", к которому он принадлежит. Реализация такого метода заканчивается строкой "return *this;". Примеры для класса R2Vector:
        R2Vector operator+(const R2Vector& v) const;
        R2Vector& operator+=(const R2Vector& v);
    

Библиотека классов для поддержки двумерной графики

Мы используем небольшой набор классов, реализующих объекты на двумерной плоскости: вектор, точка, прямоугольник. Каждый объект реализован в двух вариантах: с вещественными и с целочисленными координатами. Дело в том, что во всех вычислениях (особенно если они включают объекты в трехмерном пространстве и их проекции) вещественные координаты гораздо удобнее. Однако традиционно вся экранная графика использует целочисленные, или пиксельные, координаты. Связано это с тем, что экран современного монитора представляет собой прямоугольную матрицу точек, таким образом, каждая точка имеет целые координаты. Мы будем последовательно использовать подход, когда все вычисления и все промежуточные операции выполняются с вещественными объектами и лишь на самом последнем этапе, непосредственно перед рисованием на экране, осуществляется отображение вещественных координат на целочисленные. Реализация всех объектов инлайновая, она содержится в файле "R2Graph.h". Классы, описанные в этом файле, используются во всех проектах, связанных с графикой (например, в проекте "Графическое окно" -- GWindow.zip).

Заголовочный файл "R2Graph.h" включает в себя определение и инлайновую реализацию следующих классов:

R2Vector
двумерный вектор с вещественными координатами;
R2Point
точка на двумерной плоскости с вещественными координатами;
R2Rectangle
прямоугольник на двумерной плоскости с вещественными координатами;
I2Vector
двумерный вектор с целочисленными координатами;
I2Point
точка на двумерной плоскости с целочисленными координатами;
I2Rectangle
прямоугольник на двумерной плоскости с целочисленными координатами.
Префикс "R2" означает вещественные двумерные координаты ("R" -- от слова "Real"); префикс "I2" означает целочисленные координаты ("I" -- от слова "Integer").

Особенности реализации

Класс R2Vector был рассмотрен достаточно подробно выше. Реализация класса I2Vector почти ничем не отличается, только вместо вещественных координат x, y используются целочисленные x, y.

Класс R2Point (точка с вещественными координатами) очень похож на R2Vector, однако стоит отметить следующие отличия:

Класс "Прямоугольник с вещественными координатами" R2Rectangle задается его левым нижним углом, шириной и высотой:

class R2Rectangle {
    double l;   // left
    double b;   // bottom
    double w;   // width
    double h;   // height
    . . .
Соответствующие члены класса объявлены по умолчанию как "private" (т.е. скрыты от использования извне); имеются "public" методы для чтения и изменения характеристик прямоугольника:
public:
    double left() const { return l; }
    double right() const { return l + w; }
    double bottom() const { return b; }
    double top() const { return b + h; }
    double width() const { return w; }
    double height() const { return h; }

    void setLeft(double left) { l = left; }
    void setBottom(double bottom) { b = bottom; }
    void setWidth(double width) { w = width; }
    void setHeight(double height) { h = height; }
Заметим, что есть два стиля определения прямоугольника, условно назовем их Unix-стиль и Windows-стиль (первый используется в Unix X-Windows, второй -- в MS Windows). В Unix-стиле прямоугольник задается координатами одного из углов (левого верхнего или левого нижнего), шириной и высотой. В Windows-стиле прямоугольник задается координатами двух углов, расположенных по диагонали -- например, левого верхнего и правого нижнего. Мы используем Unix-стиль, который чуть более удобен. Чтобы работа с классом не зависела от стиля его определения, внутренние переменные описаны как "private", т.е. скрыты от использования извне.

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

class I2Rectangle {
    // Y-coordinate axis in case of I2Rectangle goes down, so that
    // y-coordinate of top is less than y-coordinate of bottom
    int l;      // left
    int t;      // top
    int w;      // width
    int h;      // height
    . . .
При этом координата Y нижней границы целочисленного прямоугольника больше, чем координата верхней границы.
    int bottom() const { return t + h; }
    int top() const { return t; }
Изменять можно левый верхний угол, ширину и высоту целочисленного прямоугольника (для вещественного -- левый нижний угол):
    void setLeft(int left) { l = left; }
    void setTop(int top) { t = top; }


Задачи по теме "Реализация классов на C++".


Конец лекции