Опубликован: 06.10.2011 | Доступ: свободный | Студентов: 1678 / 90 | Оценка: 4.67 / 3.67 | Длительность: 18:18:00
Дополнительный материал 3:

Введение в C++ (по материалам Надежды Поликарповой)

Операции

Как в Eiffel, можно определять функции, вызываемые в стиле синтаксиса операций:

class Complex {
      …
      Complex operator+ (const Complex& other) const {…}
      Complex operator- (const Complex& other) const {…}
      Complex operator* (const Complex& other) const {…}
      Complex operator/ (const Complex& other) const {…} 
};

В отличие от Eiffel, операции не имеют эквивалентного имени, заданного идентификатором, и ограничены фиксированным множеством предопределённых операций (таких как + и другие знаки), чьи синтаксические свойства - число аргументов, инфиксные или префиксные, лево- или правоассоциативные, приоритет - не могут быть изменены.

Перегрузка

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

void print (int n) {…} 
void print (string s) {…} 
…
print (5);	          // Использует функцию print печати целых
print ("hello");      // Использует функцию print печати строк

Этот механизм называется перегрузкой функций. Он часто используется, например, для операций (перегрузка операций). Перегруженными являются и конструкторы класса.

Статические объявления

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

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

class Rocket_launcher {
    …
    static int rocket_count;
    static const int max_count = 100;
    void launch ()        // Запуск ракеты
    { 
      … 
       rocket_count++;   // Увеличение на 1 при очередном запуске
    } 
};

Статические члены функции оперируют только со статическими членами переменных и константами, как в примере1 В статических функциях разрешается вызов других статических функций. При обсуждении статических классов и компонентов в Java и в C# я уже высказывал свою точку зрения, что эта концепция согласуется с ОО-подходом - есть статический конструктор, который автоматически создает статический объект, доступный клиентам и экземплярам самого класса.:

class Rocket_launcher {
   … Rest of class as above …
   static bool is_in_bounds ()
   {return rocket_count <= max_count;} 
};

Вызовы статических членов не требуют целевого объекта. Вместо точечной нотации используется операция разрешения области:

r = Rocket_launcher::rocket_count;
m = Rocket_launcher::max_count;
if (Rocket_launcher::is_in_bounds ()) …

Совсем другое по семантике использование ключевого слова static применяется при описании локальных переменных функции. Если локальная переменная объявляется как статическая, то сохраняется ее значение, полученное при предыдущем вызове функции, как показано в данном примере:

void f ()
       { static int invocation_count = 0;   // локальная статическая
       …
       invocation_count++;	                // Увеличивается на 1 при каждом вызове
}
Время жизни объектов

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

Объекты, управляемые программистом, называются также динамическими объектами, хранимыми в области памяти, которая называется "куча". Программисты создают объекты, вызывая операцию new с соответствующим конструктором класса:

Complex* c;
…
c = new Complex (1.0, 2.0);

Вычисление выражения new создает указатель на объект типа Complex, который и присваивается соответствующей переменной, - эффект тот же, что и в Eiffel при вызове процедуры создания create c.make (1.0, 2.0) с типом COMPLEX.

В отличие от Eiffel, C++ не проектировался для автоматической сборки мусора. В его распоряжении есть оператор удаления объектов из кучи - delete:

delete c;

Для тех же самых целей C обеспечивает библиотечную функцию, называемую free. Если после выполнения этой операции некоторые указатели или ссылки будут все еще ссылаться на объект, который до этого был присоединен к c, то они станут висячими указателями: разыменование такого указателя означает попытку доступа к несуществующему объекту и приведет к ошибке периода выполнения или, что еще хуже, выработке произвольного значения. Но, с другой стороны, если не удалять недостижимые объекты, это может стать причиной исчерпания памяти.

В то время как динамические объекты всегда доступны через указатели - с возможностью нескольких указателей быть присоединенными к одному и тому же объекту, - нединамические объекты (которые все же могут иметь "вторичные" указатели и ссылки, присоединенные к ним) связываются с единственной переменной или константой, чья область и определяет время жизни объекта. Нединамические объекты могут быть двух видов.

  • Локальные переменные функций или других блоков, для которых объекты создаются автоматически (в стеке). Они управляются стеком вызовов, распределяются в точке определения и забываются при выходе из блока.
  • Глобальные переменные и переменные, объявленные как статические (статические данные класса, статические локальные переменные функции), они создаются до начала выполнения функции запуска - main и существуют до конца выполнения системы.

Время жизни полей объекта (соответствующих нестатическим членам-переменным класса) определяется временем жизни самого объекта. Процесс разрушения начинается с самого объекта, а затем разрушаются его поля. В отсутствие автоматической сборки мусора часто необходимо специфицировать некоторые операции, выполняемые при удалении объекта (либо при явном вызове delete, либо при выходе из блока). Любой класс T в C++ может определить для этих целей специальный член-функцию - деструктор, со стандартно построенным именем ~T, который будет вызываться при разрушении объекта. Вот пример типичного деструктора:

class Person1 { 
…
    Passport* pp;
    Person1 (string n, date d)// Конструктор, создающий объект Passport
      {pp = new Passport (n, d); }
    ~Person1 ()	// Деструктор
      {delete pp;}   // Удаление объекта passport 
};

Этот пример иллюстрирует общий C++ образец: инициализация как способ овладения ресурсом (Resource Acquisition Is Initialization - RAII) - использование конструктора для захвата ресурсов, необходимых объекту, и деструктора, для их освобождения. Это устраняет некоторые источники ошибок при уверенности, что операции удаления - delete - выполняются в правильном порядке. Систематическое применение RAII ограничивает использование динамической памяти специальными классами, часто библиотечными. В остальной части ПО экземпляры этих классов используются не динамически, ослабляя тем самым последствия отсутствия сборки мусора.

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

Инициализация

В отличие от Eiffel, автоматическая инициализация применима к статическим объектам (предустановленными значениями для встроенных типов, конструктор по умолчанию для типов, заданных классами). Вернемся к объявлению:

int n;
class Rocket_launcher {
    static int rocket_count;
    … Остальное как выше … 
}; 
int Rocket_launcher::rocket_count;

Здесь как n, так и rocket_count инициализируются нулем (заметьте: необходимо включать второе объявление rocket_count, так как объявление статического члена переменной внутри класса не является определяющим объявлением).

Ссылки, константы и автоматические объекты необходимо инициализировать вручную.

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

Обработка исключений

В C++ обработка исключений доступна через процесс исключительных событий, возникающих в период выполнения. Вместо стиля Eiffel, основанного на принципе проектирования по контракту, используется стиль "try-catch".

Исключение, причиной которого, например, явилась ошибочная арифметическая операция (переполнение сверху или снизу - overflow или underflow), прервет нормальный поток выполнения. Блок кода со специальным синтаксисом, в котором контролируется возникновение исключительных ситуаций, называется охраняемым блоком - try-блоком. Исключительная ситуация, возникшая в охраняемом блоке, может быть перехвачена и обработана в одном из блоков обработки - catch-блоке. Рассмотрим пример:

try {
    … Код, который может включить исключение … 
} catch (io_error e) {
    … Обработка исключений ввода-вывода I/O … 
} catch (memory_error e) {
    … Обработка исключений, связанных с памятью … 
}

Блок catch задает тип исключения, такой как io_error, и имя объекта, задающего исключение, здесь e, используемое в операторах обработки (способом, подобным формальным аргументам метода) для получения доступа к специфическим свойствам исключения.

Исключения можно также включать (говорят также - "выбрасывать"), применяя специальный оператор:

throw exp

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

Любое исключение, встретившееся во время выполнения блока, прерывает выполнение этого блока (оставшиеся операторы не выполняются). После чего:

  • если блок был охраняемым и один из catch-блоков соответствует типу возникшего исключения, то выполнение будет передано в соответствующий catch-блок, затем будет выполняться следующая конструкция, если только сам catch-блок не выбросит повторно исключение - throw ();
  • если нет соответствующего обработчика или исключение возникло вне охраняемого try-блока, текущая функция завершается, выбрасывая исключение в вызывателе - функции, вызвавшей функцию, в которой возникло исключение. И здесь рекурсивно применяется описанная схема.

Если в цепочке вызовов не найдется соответствующий catch-блок, то выполнение закончится прерыванием начальной точки - функции main и, следовательно, завершением программы в состоянии, свидетельствующем об ошибке.

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

Для безопасной обработки исключений можно указать, как часть сигнатуры функции, множество throw - список исключений, которые могут быть выброшены при выполнении. Вот пример:

void read_and_store (string a_file_name) throw (Io_error, Memory_error) 
{ … }

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

Шаблоны

Шаблоны являются C++-версией универсальности. Вот простой пример:

template <typename G> class Stack {
…
public:
   G item () {…}
   void push (G an_item) {…}
   void pop () {…} 
};

Здесь определен класс Stack с родовым параметром G, аналог класса STACK [G] в Eiffel. Конкретный стек задается как:

Stack<int> s;

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

Препроцессирование - преобразование текста программы до компиляции, используемое в данном случае, сохраняет мощь языка программирования и дает некоторые экзотические применения в продвинутом C++-программировании.

Здесь нет аналога понятия ограниченной универсальности: если применяется операция к переменной, тип которой задан формальным параметром, таким как G из примера выше, проверка типа будет применяться к каждому экземпляру, чтобы убедиться, что операция всегда правильна.

Шаблон класса позволяет задать полную или частичную специализацию. Полная специализация замораживает фактические параметры, как в этом примере, используя вышеприведенный Stack:

template<>
class Stack<bool> {
       … Операции, специфические для булевских стеков …   
};

Частичная спецификация оставляет формальные параметры, задавая некоторые ограничения:

template<typename G> 
class Stack<G*> {
           … Операции, специфические для стеков указателей … 
}

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

Помимо шаблонов классов, C++ поддерживает шаблоны функций:

template <typename G>
G max (G a, G b) { … Вычисление максимума ...}

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

int a, b, c;
…
c = max (a, b);   // Вызов max<int>

Здесь автоматически порождается max с типом int для G.

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

template <int n, int m> class Matrix { … };
template <int n, int m, int k> Matrix<n, k> operator*
       (const Matrix<n, m>& m1, const Matrix<m, k>& m2) 
{… Алгоритм умножения матриц … }

Попытка умножить матрицы несовместимых (константы) размеров приведет к ошибке, обнаруживаемой на этапе компиляции.

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

Класс B можно определить как наследника (производный класс) класса A (базового класса для B):

class B : A {
       … 
};
Переопределение

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

Статус экспорта и наследование

По умолчанию наследуемые члены закрыты (private). Для изменения их статуса можно задать модификатор доступа - private, public или protected для отношения наследования, как в следующем примере:

class B : public A {…};

При такой спецификации каждый наследуемый элемент получает статус доступа, представляющий минимум из двух статусов - оригинального и статуса наследования. В данном примере, где статус наследования самый высокий, все члены A сохранят свой статус доступа в классе B.

Доступ в стиле Precursor

Для получения доступа к оригинальной версии переопределенной функции - эквивалент Precursor в Eiffel - можно использовать операцию разрешения области, если только эта версия не является private:

class B : public A {
    void b_function () // Не обязательно, чтобы это было переопределение r 
    {
       A::r (); // Вызов метода из класса A
       … 
    }};
Статическое и динамическое связывание

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

class Rectangle { 
…
    virtual void draw() {…} // Следует объявить как виртуальную
    virtual void rotate() {…} 
};
class Rounded_rectangle : public Rectangle { 
...
    virtual void draw() {…}    // Можно, но не обязательно объявлять виртуальной
                               //при переопределении
    void rotate() {…}	       // … Эффект тот же - динамическое связывание
                               // будет применяться!) 
};

Динамическое связывание применимо только для объектов, доступных через указатели или ссылки, как показано в следующем примере:

Rectangle r (1.2, 0.5);
Rounded_rectangle rr (5.0, 3.2, 0.2);
r = rr;       // r все еще обычный прямоугольник с полями, скопированными из rr
r.draw ();  // Статическое связывание …: rectangle::draw()
Rectangle*p = &rr;
p -> draw ();       // Динамическое связывание…: rounded_rectangle::draw()
Rectangle& ref = rr;
ref.draw ();           // Динамическое связывание…): rounded_rectangle::draw()

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

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

Ближайшим эквивалентом отложенного метода является понятие чистой виртуальной функции, имеющей определение, но не реализацию:

class Figure {
    virtual void draw() = 0;
    … Другие члены класса … 
};

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

Множественное наследование

В C++ поддерживается множественное наследование:

class Arrayed_stack : public Stack, private Array {…}

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

class A {void f () {…}};
class B {void f () {…}};
class C : public A, public B {…};
void test()
{
C* p = new C();
     // p->f();    Этот вызов неоднозначен и, следовательно, неправилен
     p->A::f();
     p->B::f(); 
}

Повторное наследование не позволяет выбирать между склеиванием и репликацией для каждого члена. Выбор делается глобально для класса в целом. Разрешение противоречий может требовать сложного использования области разрешения:

class D {int n;};
class E : public D{};
class F : public D {};
class G : public E, public F {};
void f()
{
      G* p = new G();
      // p->n = 0;	           // Это было бы неверным - какое n?
      p->E::D::n = 0;        // Правильно: присваивается версия из E
      p->F::D::n = 0;        // Правильно: присваивается версия из F 
}

Если требуется, чтобы были присоединены общие поля предка вместо реплицированных, следует определить этого предка как виртуальный базовый класс:

class T {int n;};
class U : public virtual T {};
class V : public virtual T {};
class W : public U, public V {};
void f()
{
     W*p = new W;
     p->n = 0; // Теперь правильно 
}

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

Наследование и создание объекта

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

class Rounded_rectangle : public Rectangle {
public:
Rounded_rectangle (float w, float h, float r) : Rectangle (w, h), radius (r)
       {…} 
… Остальная часть класса как ранее … 
};