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

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

Базисная ОО-модель

Подобно Eiffel, каждая C++ переменная имеет тип, но, в отличие от Eiffel, не все типы основаны на классах, и следовательно, не все значения являются объектами. Тип может быть встроенным, производным (с возможными комбинациями механизмов порождения), определенным пользователем.

Встроенные типы

Встроенные типы предустановлены в языке. Они включают bool - для булевских значений; char, short int, int, long int - для целых из разных диапазонов; float, double, long double - для вещественных с плавающей точкой. Тип char также служит для представления символов.

Каждый целочисленный тип включает две версии - со знаком и без знака, такие как short int и unsigned short int. Версии без знака включают только положительные значения. По умолчанию все целые типы, за исключением char, являются знаковыми, делая избыточным задание такого типа, как signed int. Является ли char типом со знаком, определяется платформой.

Встроенный тип void не имеет значений. Он служит признаком процедур - функций, не возвращающих результат:

void set_name (string s);
Производные типы

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

Тип const T представляет неизменяемое значение типа T. Например, n будет иметь тип const int, если ее определить как:

const int n = 5; 

Разрешается переменной типа T присвоить значение типа const T, но обратное преобразование недопустимо.

Указателем на тип Т является тип T*. Значения этого типа обозначают адреса памяти, где хранится переменная типа T. Для получения указателя на переменную x используется & , как в &x. Возможно обратное преобразование, называемое разыменованием - получением значения по адресу. Если p - указатель, то *p дает значение, хранящееся в области памяти, на которую указывает указатель. Если значением является объект, то возможен доступ к его полям, например, полю f, используя нотацию с точкой: (*p).f. Специальный синтаксис p->f является синонимом нотации с точкой.

Поскольку указатель представляет адрес памяти, в языке разрешено добавление или вычитание целого из указателя для получения нового адреса, как в*(p + n) . Смысл его в следующем. Представим, что в памяти подряд хранятся n + 1 значение типа T, каждое занимает фиксированное число байтов, которое можно получить, используя конструкцию sizeof T. Тогда, если p типа T* и указывает на первое хранимое значение, то *(p + n) возвращает последнее значение (здесь неявно n умножается на число байтов, отводимых элементу - sizeof T, так что (p + n) дает адрес начала соответствующего элемента, а операция * - значение по этому адресу). Это все называется адресной арифметикой и широко используется в низкоуровневых программах на С для получения доступа к нужным участкам памяти. Механизм мощный, но чреватый ошибками (трудно гарантировать, что на самом деле хранится в динамически вычи сляемом адресе). Без необходимости его не следует применять в приложениях С++.

Все типы указателей согласуются со специальным типом void*, напоминающем класс ANY в Eiffel. Но это встроенное соответствие, не индуцированное наследованием. Поскольку нельзя иметь переменные типа void, нельзя проводить разыменование указателей void*, никакие операции над ними не выполняются. Чтобы их использовать, необходим кастинг - явное приведение к типу! В C++ есть разные способы приведения, дающие разные результаты в случае, когда операция приведения не заканчивается успехом.

Тип T& является "ссылкой на T". Подобно указателю, ссылка является адресом, но при любом использовании происходит автоматическое разыменование. Ссылки являются наиболее прямым способом получения эффекта обычного (ссылочного) класса в Eiffel. Рассмотрим класс Eiffel class PERSON … end, и пусть в этом классе определен некоторый метод:

call_her_izzy (p: PERSON)
    do p.set_name ("izzy") end

Эквивалентом метода в С++ будет:

void call_her_izzy (Person& p) 
    {p.set_name ("izzy");}

Рассмотрим вызов этого метода call_her_izzy (Isabelle) , где Isabelle типа Person. Эффект состоит в изменении значения поля имени ссылочного объекта. Если бы аргумент в C++ версии имел тип Person, то вызов создавал бы копию объекта Person.

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

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

void call_her_izzy (Person* p)
    {p->set_name ("izzy");     // или (_p).set_name ("izzy"); 
    }

В этом случае вызов метода имеет вид: call_her_izzy (&Isabelle) . Использование указателей считается хорошим стилем в сравнении с передачей аргументов по ссылке, поскольку делает явной ссылочную семантику.

Еще одна разница между указателями и ссылками в том, что ссылки требуют инициализации, присоединяющей значение типа T к T& ссылке, в то время как указатели могут иметь нулевое значение (называемое также null и соответствующее void в Eiffel). Однако это не обеспечивает преимущества присоединенных типов Eiffel, так как возможно присвоить указатель T* переменной типа T& или T, что станет причиной ошибки периода выполнения, если указатель равен null.

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

Теперь поговорим о массивах. Тип, задающий массив (иногда говорят "массивный тип"), - T[size] , где размер size является целой константой, известной во время компиляции, представляет последовательность значений типа T, хранимой в подряд идущих словах памяти. Если ar - это массив, то ar[i] обозначает i-й элемент этого массива, где i - выражение целого типа. В C++ массив рассматривается как адрес начала расположения элементов массива в предположении, что памяти для их хранения достаточно. Для безопасной работы с индексами, гарантирующей, что выход за границы контролируется, следует использовать библиотечные классы.

Наконец, функциональный тип. Он фактически является указателем на тип функции. Пусть R, A1, …, An являются типами (последовательность задает сигнатуру функции). Рассмотрим объявление:

R (*f) (A1, …, An);

Здесь объявлена переменная f, чьи значения являются указателями на функции, возвращающие значение типа R и имеющие n аргументов типа A1, … An. Например, рассмотрим объявление:

void (*f) (Person*)

Переменной f можно присвоить указатель на функцию:

f = call_her_izzy; //или f = &call_her_izzy;

После этого можно выполнять непрямой вызов:

f (Isabelle);     // или (*f) (Isabelle);

Эффект будет тот же, что и при прямом вызове call_her_izzy (Isabelle) . Разница в том, что f - это переменная, которой можно присвоить указатели на разные функции (с заданной сигнатурой).

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

void (Person::*p) (string)
…
p = Person::set_name;

Здесь объявляется p и ему присваивается указатель на функцию класса Person, принимающий один аргумент типа string. Эту функцию можно вызывать, используя следующий синтаксис:

 (Isabelle.*p) ("Izzy");

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

  • Они являются лучшим способом получения эффекта агентов, хотя и не в полной мере, поскольку агент - это объект со многими свойствами и гарантией типизации, а все, что можно делать с указателем, сводится к вызову функции.
  • Указатели функций позволяют получить эффект динамического связывания, используя динамический тип объекта как индекс в массиве указателей функций, позволяющий выбрать правильную версию функции. Эта техника детально была описана при обсуждении реализации наследования. Нет необходимости в ее использовании, поскольку C++ предоставляет механизм виртуальных функций, изучаемый ниже, который реализует динамическое связывание. Поскольку в чистом С виртуальных функций нет, у них остается только одна возможность - применение указателей функций. Вот почему компиляторы Eiffel, транслирующие программу в код на C, основываются на объясненной ранее схеме.
Комбинирование механизмов производных типов

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

Можно комбинировать модификаторы const и pointer, чтобы получить:

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

Подобным образом можно комбинировать const и ссылки. Следующие примеры иллюстрируют некоторые из возможностей:

const int* pointer_to_const; 
const int& reference_to_const; 
int* const const_pointer; 
int& const const_reference;

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

typedef const Person* Cp; 
typedef void (* Pf) (Person*);

Теперь возможно использовать в объявлениях Cp p вместо const Person *p. Тип Pf обозначает теперь соответствующий тип указателя на функцию.

Типы, определенные пользователем

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

enum Week_day {Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday}; 
enum Error {Division_by_zero, Null_pointer_dereference, File_error, Memory_error};

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

Еще одним видом типа, определяемого пользователем, является тип, задающий структуры, обычно называемый просто "struct" и представляющий некоторую форму класса. Классы изучаются ниже. У структур в основном те же свойства, что и у классов, но другая политика экспорта.

Наконец, тип "объединение" описывает объекты, которые могут быть нескольких видов, занимая одну и ту же область памяти. Вместо ключевых слов class или struct для этого типа применяется термин union. Этот механизм, перешедший от C, где он был предназначен для оптимизации памяти, не является типо-безопасным, так как при использовании p.a нельзя гарантировать, что p является правильным вариантом, имеющим атрибут a. По этой причине программисты С++ редко используют тип объединения, основываясь вместо этого на наследовании, как в других ОО-языках, для поддержки вариантов общего типа.

Классы

Класс C++ задается следующим синтаксисом:

class A{
     …// список членов класса 
};

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

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

A var;	                    // Переменная типа A
const A const_var;      // Константа типа A
A* p;	                    // Указатель на объекты типа A

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

Определение класса может содержать как определения, так и неопределяющие объявления членов. В последнем случае эти члены должны быть определены вне класса.

class A{
       int n;	    // член-переменная
       void f ()	// член-функция, определенная в классе
              {…}
       void g ();	// член-функция, определенная вне класса
};
void A::g ()//Определение g 
{…}

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

A var; 
int i; 
i = var.n; 
var.f ();

Еще одно важное отличие от Eiffel (и отклонение от принципа скрытия информации) состоит в предоставлении клиентам возможности непосредственного присваивания значений полям класса:

var.n = 5;

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

class A{ 
    int n;
                // Следующие две строки определяют конструктор 
    A (int i) 
         {n = i;} 
… 
};

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

A var = A(2); 
A another_var(2);

Здесь вторая форма является сокращением первой. Класс может иметь конструктор по умолчанию без аргументов (вспомните аналог, процедуру создания default_create из класса ANY в Eiffel, позволяющую опускать явную инициализацию):

A var;     // Синоним для A var = A ();

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

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

class B {
    int value;
    Person a_friend;
    B (int v, string name) : value (v), a_friend (name) {}
           // Может также быть записано в виде:B (int v, string name) : a_friend 
(name)
           // {value = v;} 
  };

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

Для обозначения текущего объекта(Current в Eiffel) используется ключевое слово this, определенное как константный указатель.

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

class A {
     int n;
     int n_squared () const
     {return n_n; } 
 };

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

Скрытие информации

В Eiffel привилегии доступа клиентов определяются через спецификации экспорта, которые могут быть выборочными. В C++ можно определить для каждого члена класса один из трех модификаторов доступа: public, protected и private. Public-доступ означает полные права для клиентов и потомков (как с экспортом для ANY in Eiffel). Модификатор protected разрешает доступ потомкам. Модификатор private запрещает доступ и для потомков, оставляя доступ только для самого класса (в Eiffel потомки всегда имеют доступ для неквалифицированного вызова). Эти правила на самом деле одинаковы для квалифицированных и неквалифицированных вызовов (так что здесь нет эквивалента, позволяющего сделать компонент полностью недоступным для квалифицированного вызова, экспортируя его NONE). Следующие примеры используют некоторые модификаторы доступа:

class A { 
private:
    int n;
    float secret_function () { … } 
protected:
    float variable_for_descendants;
    int n_squared () { … } 
public:
    string variable_for_everyone;
    do_everything () { … } 
};

На практике члены-переменные редко задаются с модификатором public, так как это делает их доступными для присваивания, а не только для чтения. Общая практика состоит в определении геттеров, мы видели, почему в этом нет необходимости в Eiffel.

Модификатором по умолчанию для членов класса является модификатор private. Для структур, однако, таковым является public.

Эти приемы не поддерживают в полной мере понятие "выборочного экспорта" (feature {A, B, C} как в Eiffel, который позволяет экспортировать компонент классам, перечисленным в списке, и их потомкам). В C++ имеется, однако, близкий механизм "друзей" - класс может указать функцию или другой класс в качестве друга - friend:

class Linkable {
    friend class Linked_list;
    friend bool is_equal (const Linkable& other);
    … 
};

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

Область действия

Любая переменная, константа, функция или тип имеет область действия: локальную в блоке (часть программы, заключенная в фигурные скобки{…}), если там появилось ее объявление, в противном случае - глобальную (расширенную на весь модуль трансляции).

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

Блоки могут быть вложенными. Объявление внутреннего блока скрывает элемент, определенный с тем же именем во внешнем блоке или глобальный:

int x;     //	глобальная переменная x
void f ()
    {x = 1;	// Присваивание глобальной x
    int x;       // Определение локальной переменной x
    x = 2;	// Присваивание локальной x
}
int y = x;	// используется глобальная x

Скрытый элемент может быть все же доступен во внутреннем блоке, используя :: -нотацию - операцию разрешения области:

int x;	                   // Глобальный x
class A {
    int x;	               // Член x
    void f ()
       {int x;	           // Локальный x
       int y = A::x;       // Использование члена x
       int z = ::x;        // Использование глобального x
       }  
};

Эти свойства чреваты ошибками - предпочтительнее для переменных вложенных областей выбирать различные имена.