36628

ПРОГРАММИРОВАНИЕ НА ЯЗЫКЕ ВЫСОКОГО УРОВНЯ

Конспект

Информатика, кибернетика и программирование

1 Языки программирования Языки программирования делятся на 3 основных класса как показано на рис.3 Понятие алгоритма и его свойства Алгоритм – это точное предписание о выполнении в определенном порядке некоторых операций приводящих к решению всех задач данного класса. Непосредственный предшественник C – язык Си с классами – появился в 1979 году а в 1997 году был принят международный стандарт C который фактически подвел итоги его 20летнего развития. Если мы говорим об объектноориентированной программе то она должна создать объект...

Русский

2013-09-23

1015 KB

21 чел.

Министерство образования и науки Российской Федерации

АКАДЕМИЯ МАРКЕТИНГА И СОЦИАЛЬНО-ИНФОРМАЦИОННЫХ ТЕХНОЛОГИЙ (ИМСИТ)

Кафедра компьютерных систем управления и обработки информации

А.В. Алёшин

ПРОГРАММИРОВАНИЕ НА ЯЗЫКЕ ВЫСОКОГО УРОВНЯ

Конспект лекций

для студентов очной формы обучения

специальности 230105.65 – Программное обеспечение

вычислительной техники и автоматизированных систем

г. Краснодар, 2005



Введение

<В РАЗРАБОТКЕ>


Лекция 1 Основы алгоритмизации

1.1 Языки программирования

Языки программирования делятся на 3 основных класса, как показано на рис. 1.1.

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

2. Языки высокого уровня близки к языку математики и разговорному (традиционно - английскому).

В этих языках:

1) формулы записываются на языке, близком к математическому (в одну строку, с явным указанием всех операций и т.д.);

2) используется ограниченное количество типовых конструкций (вычисление по формулам; принятие решения; повторение; цикл; процедуры);

3) для выполнения на ЭВМ программы преобразуются на машинный язык с помощью самой ЭВМ, при этом используются так называемые компиляторы и интерпретаторы с языков высокого уровня.

Итак, выполнение на ЭВМ программ, написанных на языках высокого уровня, состоит из следующих этапов (рис. 1.2):

1) ввод текста программы в ЭВМ (исходный модуль);

2) компиляция и получение текста на машинном языке (объектный модуль);

3) загрузка объектного модуля и стандартных процедур и функций в память ЭВМ и выполнение программы.

1.2 Этапы решения задач на компьютере

Решение задач на ЭВМ – сложный процесс, состоящий из следующих этапов.

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

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

3. Программирование задачи. Оно состоит из трех частей:

1) графическое изображение метода решения (составление схемы алгоритма);

2) написание программы на языке программирования (кодирование);

3) ввод текста программы в ЭВМ.

4. Отладка программы. Выявление ошибок (кодирования и методов решения), проверка правильности результатов.

5. Обработка реальных данных и получение результатов. Выполнение готовой программы на ЭВМ, выдача результатов.

Из этих этапов первые три не требуют ЭВМ, наиболее сложные - первые два, а наиболее трудоемкий – четвертый. Все они сопровождаются соответствующей документацией, состав которой определяется стандартами.

1.3 Понятие алгоритма и его свойства

Алгоритм – это точное предписание о выполнении в определенном порядке некоторых операций, приводящих к решению всех задач данного класса.

Свойства алгоритма:

1) определенность (точность предписаний и однозначность результата);

2) массовость (ориентирован на класс задач, например решение системы произвольного количества уравнений при любых исходных данных);

3) дискретность (деление процесса решения на этапы, понятные человеку и ЭВМ);

4) результативность (результат должен быть обязательно – даже если его нет, должно быть сообщение об этом).

Способы описания алгоритмов:

1) словесный (описание действий, которые должны привести к решению задачи, например построение треугольника по трем его сторонам);

2) математический (в виде формул, например формула для нахождения корней квадратного уравнения);

3) графический (схемы алгоритмов);

4) на языке программирования.

Первые два способа описания известны из школы и в данном курсе будут, в основном, использоваться совместно – для составления плана решения при математической постановке задачи. При составлении плана решения мы будем руководствоваться наиболее перспективным методом программирования – сверху вниз (от общей постановки задачи – к отдельным шагам ее решения). План решения учитывает:

1) особенности задачи, математические методы ее решения;

2) возможности языка программирования и его основные конструкции:

– ввод / вывод данных и вычисление по формулам;

– принятие решения (в зависимости от некоторого условия);

– повторение некоторых команд (групп команд);

– выделение общих частей алгоритма в одну общую часть и обращение к ней в случае необходимости.

Рассмотрим, как составляется план решения на примере алгоритма определения всех делителей нескольких целых чисел. Напомним, что делителем называют число, на которое нацело делится исходное.

Условие задачи. Последовательность чисел вводится в ЭВМ с клавиатуры; в конце ее вводится признак конца последовательности (например, "0" или отрицательное число).

План будем разрабатывать по методу сверху вниз: постепенно уточняя отдельные шаги.

План 1 (укрупненный).

1. Ввести в ЭВМ первое число.

2. Пока нет признака конца последовательности

обработать число и

прочитать следующее число.

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

Пункты 1 и 3 очевидные и реализуются просто, а пункт 2 - надо уточнить, как обработать число. Так, число может быть:

1) простым;

2) составным.

Если число простое, то множитель у него один – оно само, если составное, то оно должно делиться на делитель без остатка. Значения делителя могут быть любые в пределах от 2 до ]число/2[. Здесь запись  ] X [  означает целую часть от X.

Уточним пункт 2.

2.1. Предположить, что число – простое.

2.2. Изменять делитель от 2 до ]число/2[ и выполнять:

если число делится на делитель, то

а) вывести значение делителя и

б) изменить предположение, что число простое, на противоположное.

2.3. Если число простое, то

выдать сообщение 'Простое число'.

2.4. Прочитать следующее число.

Чтобы убедиться, что пункт 2 выполняется верно, проверим его вручную.

Пусть число равно 12.

Делители должны быть: 2, 3, 4 и 6.

Проверяя работу пунктов 2.1 – 2.3, получаем в результате:

2   3   4   6

Теперь план, в котором описаны пункты 1, 2.1 – 2.4 и 3, может быть запрограммирован. Он содержит только типовые конструкции. Программу составим позднее.

1.4 Графическое описание алгоритмов. Схемы алгоритмов

Схемы алгоритмов и программ входят в состав программной документации и оформляются в соответствии с ГОСТ 19.701 – 90 (ИСО 5807 – 85) «Схемы алгоритмов, программ, данных и систем» (взамен ГОСТ 19.002-80, 19.003-80). При этом используются условные графические обозначения (УГО), которые вписываются в прямоугольник (см. рис. 1.3). Стороны прямоугольника имеют следующие размеры:

a = 10, 15, 20 и т.д. через 5 мм, b = 1,5а   или   b = 2a.

Наиболее часто используемые блоки приведены в табл. 1.

Схема алгоритма представляет собой совокупность УГО, соединенных линиями связи. В качестве примеров можно рассмотреть рис. 5, 6, 7. Все линии на схеме (контуры элементов и соединения) имеют одинаковую толщину. Схема начинается блоком "Начало" и заканчивается блоком "Конец". Для каждого элемента схемы должно выполняться условие: существует, по крайней мере, один путь от блока "Начало" до блока "Конец", проходящий через этот элемент.

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

Таблица 1

Блоки для изображения схем алгоритмов и программ

Наименование

Обозначение

Действие

Процесс

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

Решение

Выбор направления выполнения алгоритма в зависимости от условия

Ввод – вывод

Ввод – вывод данных. Внутри блока – имена переменных

Дисплей

Представление данных на дисплее при выводе. Внутри записывают имена выводимых переменных

Документ

Вывод данных на бумажный носитель. Внутри записывают имена выводимых переменных

Пуск – останов

Соединитель

Связь между прерванными линиями схемы

Межстраничный соединитель

Связь между блоками на разных листах

Программная документация должна содержать не только схемы алгоритмов, но и их описание. При этом могут описываться действия, выполняемые отдельными блоками или группами блоков. Для удобства ссылок элементы схем могут нумероваться. При этом блоки «Начало» и «Конец» не нумеруются.

Другим способом обозначения блоков является разбиение листа, на котором изображается схема, на прямоугольные зоны. Границы зон обозначаются черточками на границах листа. Размеры зон пропорциональны размеру "a" стороны основного прямоугольника, показанного на рис. 4 (обычно в 2 раза больше). Столбцы зон обозначаются цифрами, а строки – заглавными латинскими буквами. Блоки располагают посредине каждой зоны и обозначают сочетанием букв и цифр их зон, например, A1, B1 и т.д.

1.5 Типы алгоритмов

Все алгоритмы традиционно можно разделить на три основных типа:

1. Линейный, который предполагает естественный порядок выполнения (следования) блоков ввода, процесса и вывода.

На рис. 1.4 приведена схема линейного алгоритма для решения следующей задачи.

Задача. Ввести длины a, b, c трех сторон треугольника. Вычислить его площадь, используя формулу Герона:

S=(p*(p-a)(p-b)(p-c))½,

где p – полупериметр треугольника.

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

На рис. 1.5 приведена схема разветвляющегося алгоритма для решения следующей задачи.

Задача. На плоскости с центром в начале координат проведена окружность радиусом R. Пользователь вводит координаты (X, Y) некоторой точки. Следует вывести на дисплей ответ: находится ли эта точка внутри (в том числе и на границе) круга, или вне его.

3. Циклический, который содержит многократно повторяющиеся участки

(циклы). На рис. 1.6 приведена схема циклического алгоритма для решения следующей задачи.

Задача. Вычислить значение функции

Y=Sin(X)

для аргумента, изменяющегося в некотором диапазоне

XkXXn

 

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


Лекция 2 Начальные сведения о языке

2.1 История и назначение языка C++

Разработчиком языка C++ является Бьерн Страуструп. В своей работе он опирался на опыт создателей языков Симула, Модула 2, абстрактных типов данных. Основные работы велись в исследовательском центре компании Bell Labs.

Непосредственный предшественник C++ – язык Си с классами – появился в 1979 году, а в 1997 году был принят международный стандарт C++, который фактически подвел итоги его 20-летнего развития. Принятие стандарта обеспечило единообразие всех реализаций языка C++. Не менее важным результатом стандартизации стало то, что в процессе выработки и утверждения стандарта язык был уточнен и дополнен рядом существенных возможностей.

На сегодня стандарт утвержден Международной организацией по стандартизации ISO. Его номер ISO/IEC 14882. ISO бесплатно не распространяет стандарты. Его можно получить на узле американского национального комитета по стандартам в информационных технологиях:

www.ncits.org

В России следует обращаться в ВНИИ Сертификации:

http://www.vniis.ru

Проекты стандарта имеются в свободном доступе:

ftp://ftp.research.att.com/dist/c++std/WP/CD2/

http://www.research.att.com/~bs/bs_faq.html

Язык C++ является универсальным языком программирования, в дополнение к которому разработан набор разнообразных библиотек. Поэтому, строго говоря, он позволяет решить практически любую задачу программирования. Тем не менее, в силу разных причин (не всегда технических) для каких-то типов задач он употребляется чаще, а для каких-то – реже.

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

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

Распределенные системы, функционирующие на разных компьютерах, также разрабатываются на языке C++. Этому способствует то, что у широко распространенных компонентных моделей CORBA и COM есть удобные интерфейсы на языке C++.

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

Разработка графического пользовательского интерфейса на языке C++ выполняется, в основном, тогда, когда необходимо разрабатывать сложные, нестандартные интерфейсы. Простые программы чаще пишутся на языках Visual Basic, Java и т.п.

Программирование для Internet в основном производится на языках Java, VBScript, Perl.

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

2.2 Простейшая программа на языке C++

Самая короткая программа на языке C++ выглядит так:

// Простейшая программа

int main() { return 1; }

    

Первая строчка в программе – комментарий, который служит лишь для пояснения. Признаком комментария являются два знака деления подряд (//).

main – это имя главной функции программы. С функции main всегда начинается выполнение. У функции есть имя (main), после имени в круглых скобках перечисляются аргументы или параметры функции (в данном случае у функции main аргументов нет). У функции может быть результат или возвращаемое значение. Если функция не возвращает никакого значения, то это обозначается ключевым словом void. В фигурных скобках записывается тело функции – действия, которые она выполняет. Оператор return 1 означает, что функция возвращает результат – целое число 1.

Если мы говорим об объектно-ориентированной программе, то она должна создать объект какого-либо класса и послать ему сообщение. Чтобы не усложнять программу, мы воспользуемся одним из готовых, предопределенных классов – классом ostream (поток ввода-вывода). Этот класс определен в файле заголовков "iostream.h". Поэтому первое, что надо сделать – включить файл заголовков в нашу программу:

#include <iostream.h>

int main() { return 1; }

    

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

#include <iostream.h>

int main()

{

  cout << "Hello world!" << endl;

  return 1;

}

    

Операция сдвига << для класса ostream определена как "вывести". Таким образом, программа посылает объекту cout сообщения "вывести строку Hello world!" и "вывести перевод строки" (endl обозначает новую строку). В ответ на эти сообщения объект cout выведет строку "Hello world!" на консоль и переведет курсор на следующую строку.

2.3 Компиляция и выполнение программы

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

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

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

2.4 Описание типичной среды программирования на C++

Обычно системы программирования на C++ состоят из нескольких частей:

– среда программирования;

– язык;

– стандартная библиотека С и различные библиотеки классов;

Чтобы выполнить программу на C++, нужно пройти 5 этапов.

1. Редактирование - программист набирает с помощью редактора свою программу на C++ и, если это необходимо, вносит в нее исправления. Программа запоминается на диске. Имена файлов программ обычно имеют расширение срр.

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

3. Компиляция - полный текст программы поступает на вход компилятора, которые выявляет синтаксические ошибки и в случае их отсутствия переводит программу в машинный код (называемый также объектным кодом)

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

5. Выполнение программы.


Лекция 3 Имена, переменные и константы

3.1 Имена

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

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

abc A12 NameOfPerson    BITES_PER_WORD

   

Отметим, что abc и Abc – два разных идентификатора, т.е. заглавные и строчные буквы различаются. Примеры неправильных идентификаторов:

12X a-b

   

Ряд слов в языке C++ имеет особое значение и не может использоваться в качестве идентификаторов. Такие зарезервированные слова называются ключевыми.

Список ключевых слов:

asm               auto          bad_cast     

bad_typeid        bool          break        

case              catch         char         

class             const         const_cast  

continue          default       delete       

do                double        dynamic_cast

else              enum          extern       

float             for           friend  

goto              if            inline       

int               long          namespace    

new               operator      private      

protected         public        register    

reinterpret_cast  return        short        

signed            sizeof        static       

static_cast       struct        switch       

template          then          this    

throw             try           type_info    

typedef           typeid        union        

unsigned          using         virtual

void              volatile      while   

xalloc

В следующем примере

int max(int x, int y)

{

   if (x > y)

       return x;

   else

       return y;

}

max, x и yимена или идентификаторы. Слова int, if, return и else – ключевые слова, они не могут быть именами переменных или функций и используются для других целей.

3.2 Переменные

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

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

В языке C++ прежде чем использовать переменную, ее необходимо объявить. Объявить переменную с именем x можно так:

int x;

В объявлении первым стоит название типа переменной int (целое число), а затем идентификатор xимя переменной. У переменной x есть тип – в данном случае целое число. Тип переменной определяет, какие возможные значения эта переменная может принимать и какие операции можно выполнять над данной переменной. Тип переменной изменить нельзя, т.е. пока переменная x существует, она всегда будет целого типа.

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

Например, если мы запишем x+y, где x – объявленная выше переменная, то переменная y должна быть одного из числовых типов.

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

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

int x;      // объявить целую переменную x

int y;      // объявить целую переменную y

x = 0;      // присвоить x значение 0

y = x + 1;  // присвоить y значение x + 1,

           // т.е. 1

x = 1;      // присвоить x значение 1

y = x + 1;  // присвоить y значение x + 1,

           // теперь уже 2

3.3 Константы

В программе можно явно записать величину – число, символ и т.п. Например, мы можем записать выражение x + 4 – сложить текущее значение переменной x и число 4. В зависимости от того, при каких условиях мы будем выполнять программу, значение переменной x может быть различным. Однако целое число четыре всегда останется прежним. Это неизменяемая величина или константа.

Таким образом, явная запись значения в программе – это константа.

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

const int BITS_IN_WORD = 32;

то затем имя BITS_IN_WORD можно будет использовать вместо целого числа 32.

Преимущества такого подхода очевидны. Во-первых, имя BITS_IN_WORD (битов в машинном слове) дает хорошую подсказку, для чего используется данное число. Без комментариев понятно, что выражение

b / BITS_IN_WORD

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


Лекция 4 Операции и выражения

4.1 Выражения

Программа оперирует с данными. Числа можно складывать, вычитать, умножать, делить. Знаки можно сравнивать и т.д. То есть из разных величин можно составлять выражения, результат вычисления которых – новая величина. Приведем примеры выражений:

X * 12 + Y  // значение X умножить на 12 и к

           // результату прибавить значение Y

val < 3     // сравнить значение val с 3

-9          // константное выражение -9  

Выражение, после которого стоит точка с запятой – это оператор-выражение. Его смысл состоит в том, что компьютер должен выполнить все действия, записанные в данном выражении, иначе говоря, вычислить выражение.

x + y – 12; // сложить значения x и y и затем

           // вычесть 12

a = b + 1;  // прибавить единицу к значению b и

           // запомнить результат в переменной a  

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

В типизированном языке, которым является C++, у переменных и констант есть определенный тип. Есть он и у результата выражения. Например, операции сложения (+), умножения (*), вычитания (-) и деления (/), примененные к целым числам, выполняются по общепринятым математическим правилам и дают в результате целое значение. Те же операции можно применить к вещественным числам и получить вещественное значение.

Операции сравнения: больше (>), меньше (<), равно (==), не равно (!=) сравнивают значения чисел и выдают логическое значение: истина (true) или ложь (false).

4.2 Операция присваивания

Присваивание – это тоже операция, она является частью выражения. Значение правого операнда присваивается левому операнду.

x = 2;        // переменной x присвоить значе-

cond = x < 2; // ние 2, переменной cond

             // присвоить значение true,

             // если x меньше 2, в противном

             // случае присвоить значение

3 = 5;        // false ошибка, число 3  

             // неспособно изменять свое

             // значение  

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

int x = 0;

x = 3;

x = 4;

x = x + 1;

вначале объявляется переменная x с начальным значением 0. После этого значение x изменяется на 3, 4 и затем 5. Опять-таки, обратим внимание на последнюю строчку. При вычислении операции присваивания сначала вычисляется левый операнд, а затем правый. Когда вычисляется выражение x + 1, значение переменной x равно 4. Поэтому значение выражения x + 1 равно 5. После вычисления операции присваивания (или, проще говоря, после присваивания) значение переменной x становится равным 5.

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

z = (x = y + 3);

В приведенном примере переменным x и z присваивается значение y + 3.

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

int x = 0;

++x;

Значение x увеличивается на единицу и становится равным 1.

--x;

Значение x уменьшается на единицу и становится равным 0.

int y = ++x;

Значение x опять увеличивается на единицу. Результат операции ++ – новое значение x, т.е. переменной y присваивается значение 1.

int z = x++;

Здесь используется постфиксная запись операции увеличения на единицу. Значение переменной x до выполнения операции равно 1. Сама операция та же – значение x увеличивается на единицу и становится равным 2. Однако результат постфиксной операции – значение аргумента до увеличения. Таким образом, переменной z присваивается значение 1. Аналогично, результатом постфиксной операции уменьшения на единицу является начальное значение операнда, а префиксной – его конечное значение.

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

x = x + 5;

y = y * 3;

z = z – (x + y);

В C++ эти выражения можно записать короче:

x += 5;

y *= 3;

z -= x + y;

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

4.3 Все операции языка C++

Наряду с общепринятыми арифметическими и логическими операциями, в языке C++ имеется набор операций для работы с битами – поразрядные И, ИЛИ, ИСКЛЮЧАЮЩЕЕ ИЛИ и НЕ, а также сдвиги.

Особняком стоит операция sizeof. Эта операция позволяет определить, сколько памяти занимает то или иное значение. Например:

sizeof(long);    

// сколько байтов занимает тип long

sizeof b;        

// сколько байтов занимает переменная b  

Операция sizeof в качестве аргумента берет имя типа или выражение. Аргумент заключается в скобки (если аргумент – выражение, скобки не обязательны). Результат операции – целое число, равное количеству байтов, которое необходимо для хранения в памяти заданной величины.

Ниже приводятся все операции языка C++.

4.3.1 Арифметические операции

+ сложение

- вычитание

* умножение

/ деление

Операции сложения, вычитания, умножения и деления целых и вещественных чисел. Результат операции – число, по типу соответствующее большему по разрядности операнду. Например, сложение чисел типа short и long в результате дает число типа long.

% остаток

Операция нахождения остатка от деления одного целого числа на другое. Тип результата – целое число.

- минус

+ плюс

Операция "минус" – это унарная операция, при которой знак числа изменяется на противоположный. Она применима к любым числам со знаком. Операция "плюс" существует для симметрии. Она ничего не делает, т.е. примененная к целому числу, его же и выдает.

++ увеличить на единицу, префиксная и

  постфиксная формы

-- уменьшить на единицу, префиксная и

  постфиксная формы

Эти операции иногда называют "автоувеличением" и "автоуменьшением". Они увеличивают (или, соответственно, уменьшают) операнд на единицу. Разница между постфиксной (знак операции записывается после операнда, например x++) и префиксной (знак операции записывается перед операндом, например --y) операциями заключается в том, что в первом случае результатом является значение операнда до изменения на единицу, а во втором случае – после изменения на единицу.

4.3.2 Операции сравнения

== равно

!= не равно

< меньше

> больше

<= меньше или равно

>= больше или равно

Операции сравнения. Сравнивать можно операнды любого типа, но либо они должны быть оба одного и того же встроенного типа (сравнение на равенство и неравенство работает для двух величин любого типа), либо между ними должна быть определена соответствующая операция сравнения. Результат – логическое значение true или false.

4.3.3 Логические операции

&& логическое И

|| логическое ИЛИ

! логическое НЕ

Логические операции конъюнкции, дизъюнкции и отрицания. В качестве операндов выступают логические значения, результат – тоже логическое значение true или false.

4.3.4 Битовые операции

& битовое И

| битовое ИЛИ

^ битовое ИСКЛЮЧАЮЩЕЕ ИЛИ

~ битовое НЕ

Побитовые операции над целыми числами. Соответствующая операция выполняется над каждым битом операндов. Результатом является целое число.

<< сдвиг влево

>> сдвиг вправо

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

4.3.5 Условная операция

? : условное выражение

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

4.3.6 Последовательность

, последовательность

Выполнить выражение до запятой, затем выражение после запятой. Два произвольных выражения можно поставить рядом, разделив их запятой. Они будут выполняться последовательно, и результатом всего выражения будет результат последнего выражения.

4.3.7 Операции присваивания

= присваивание

Присвоить значение правого операнда левому. Результат операции присваивания – это значение правого операнда.

+=, -=, *=, /=, %=, |=, &=, ^=, <<=, >>=

выполнить операцию и присвоить

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

4.4 Порядок вычисления выражений

У каждой операции имеется приоритет. Если в выражении несколько операций, то первой будет выполнена операция с более высоким приоритетом. Если же операции одного и того же приоритета, они выполняются слева направо.

Например, в выражении 

2 + 3 * 6

сначала будет выполнено умножение, а затем сложение; соответственно, значение этого выражения – число 20.

В выражении 

2 * 3 + 4 * 5

сначала будет выполнено умножение, а затем сложение. В каком порядке будет производиться умножение – сначала 2 * 3, а затем 4 * 5 или наоборот, не определено. Т.е. для операции сложения порядок вычисления ее операндов не задан.

В выражении 

x = y + 3

вначале выполняется сложение, а затем присваивание, поскольку приоритет операции присваивания ниже, чем приоритет операции сложения.

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

x = y = 2

сначала будет выполнена операция присваивания значения 2 переменной y. Затем результат этой операции – значение 2 – присваивается переменной x.

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

:: (разрешение области видимости имен)

. (обращение к элементу класса), -> (обращение к элементу класса по указателю), [] (индексирование), вызов функции, ++ (постфиксное увеличение на единицу), -- (постфиксное уменьшение на единицу), typeid (нахождение типа), dynamic_cast static_cast reinterpret_cast const_cast (преобразования типа)

sizeof (определение размера), ++ (префиксное увеличение на единицу), -- (префиксное уменьшение на единицу), ~ (битовое НЕ), ! (логическое НЕ), (изменение знака), + (плюс), & (взятие адреса), * (обращение по адресу), new (создание объекта), delete (удаление объекта), (type) (преобразование типа)

.* ->* (обращение по указателю на элемент класса)

* (умножение), / (деление), % (остаток)

+ (сложение), (вычитание)

<< , >> (сдвиг)

< <= > >= (сравнения на больше или меньше)

== != (равно, неравно)

& (поразрядное И)

^ (поразрадное исключающее ИЛИ)

| (поразрядное ИЛИ)

&& (логическое И)

|| (логическое ИЛИ)

= (присваивание), *= /= %= += -= <<= >>= &= |= ^= (выполнить операцию и присвоить)

?: (условная операция)

throw

, (последовательность)

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

(2 + 3) * 6

будет 30.

Скобки могут быть вложенными, соответственно, самые внутренние выполняются первыми:

(2 + (3 * (4 + 5) ) – 2)


Лекция 5 Операторы

5.1 Что такое оператор

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

Различают операторы объявления имен, операторы управления и операторы-выражения.

5.1.1 Операторы-выражения

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

a  =  1;

b  =  3;

m  =  max(a,  b);

        

Переменной a присваивается значение 1, переменной b – значение 3. Затем вызывается функция max с параметрами 1 и 3, и ее результат присваивается переменной m.

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

x + y – 12;     // сложить значения x и y и

               // затем вычесть 12

func(d, 12, x) // вызвать функцию func с

               // заданными параметрами  

5.1.2 Объявления имен

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

Оператор объявления состоит из названия типа и объявляемого имени:

int  x;         // объявить целую переменную x

double f;       // объявить переменную f типа

               // double

const float pi = 3.1415;  

    // объявить константу pi типа float

    // со значением 3.1415  

        

Оператор объявления заканчивается точкой с запятой.

5.1.3 Операторы управления

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

5.1.3.1 Условные операторы

Условные операторы позволяют выбрать один из вариантов выполнения действий в зависимости от каких-либо условий. Условие – это логическое выражение, т.е. выражение, результатом которого является логическое значение true (истина) или false (ложь).

Оператор if выбирает один из двух вариантов последовательности вычислений.

if   (условие)

    оператор1

else

    оператор2

           

Если условие истинно, выполняется оператор1, если ложно, то выполняется оператор2.

if   (x > y)

    a = x;

else

    a = y;

           

В данном примере переменной a присваивается значение максимума из двух величин x и y.

Конструкция else необязательна. Можно записать:

if   (x < 0)

    x = -x;

abs = x;

           

В данном примере оператор x = -x; выполняется только в том случае, если значение переменной x было отрицательным. Присваивание переменной abs выполняется в любом случае. Таким образом, приведенный фрагмент программы изменит значение переменной x на его абсолютное значение и присвоит переменной abs новое значение x.

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

if   (x < 0) {

    x = -x;

    cout << "Изменить значение x на

        противоположное по знаку";

}

abs = x;

           

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

Условный оператор можно расширить для проверки нескольких условий:

if   (x  < 0)

    cout  << "Отрицательная величина";

else if   (x > 0)

    cout  << "Положительная величина";

else

    cout  << "Ноль";

           

Конструкций else if может быть несколько.

Хотя любые комбинации условий можно выразить с помощью оператора if, довольно часто запись становится неудобной и запутанной. Оператор выбора switch используется, когда для каждого из нескольких возможных значений выражения нужно выполнить определенные действия. Например, предположим, что в переменной code хранится целое число от 0 до 2, и нам нужно выполнить различные действия в зависимости от ее значения:

switch (code) {

case 0:

    cout << "код ноль";

    x = x + 1;

    break;

case 1 :

    cout << "код один";

    y = y + 1;

    break;

case 2:

    cout << "код два";

    z = z + 1;

    break;

default:

    cout << "Необрабатываемое значение";

}

           

В зависимости от значения code управление передается на одну из меток case. Выполнение оператора заканчивается по достижении либо оператора break, либо конца оператора switch. Таким образом, если code равно 1, выводится "код один", а затем переменная y увеличивается на единицу. Если бы после этого не стоял оператор break, то управление "провалилось" бы дальше, была бы выведена фраза "код два", и переменная z тоже увеличилась бы на единицу.

Если значение переключателя не совпадает ни с одним из значений меток case, то выполняются операторы, записанные после метки default. Метка default может быть опущена, что эквивалентно записи:

default:

  ;  // пустой оператор, не выполняющий

     // никаких действий

           

Очевидно, что приведенный пример можно переписать с помощью оператора if:

if   (code == 0) {

    cout << "код ноль";

    x = x + 1;

} else if (code == 1) {

    cout << "код один";

    y = y + 1;

} else if (code == 2) {

    cout << "код два";

    z = z + 1;

} else {

    cout << "Необрабатываемое значение";

}

           

Пожалуй, запись с помощью оператора переключения switch более наглядна. Особенно часто переключатель используется, когда значение выражения имеет тип набора. :

5.1.3.2 Операторы цикла

Предположим, нам нужно вычислить сумму всех целых чисел от 0 до 100. Для этого воспользуемся оператором цикла for:

int sum = 0;

int i;

for (i = 1; i <= 100; i = i + 1)

                        // заголовок цикла

    sum = sum + i;      // тело цикла

           

Оператор цикла состоит из заголовка цикла и тела цикла. Тело цикла – это оператор, который будет повторно выполняться (в данном случае – увеличение значения переменной sum на величину переменной i). Заголовок – это ключевое слово for, после которого в круглых скобках записаны три выражения, разделенные точкой с запятой. Первое выражение вычисляется один раз до начала выполнения цикла. Второе – это условие цикла. Тело цикла будет повторяться до тех пор, пока условие цикла истинно. Третье выражение вычисляется после каждого повторения тела цикла.

Оператор for реализует фундаментальный принцип вычислений в программировании – итерацию. Тело цикла повторяется для разных, в данном случае последовательных, значений переменной i. Повторение иногда называется итерацией. Мы как бы проходим по последовательности значений переменной i, выполняя с текущим значением одно и то же действие, тем самым постепенно вычисляя нужное значение. С каждой итерацией мы подходим к нему все ближе и ближе. С другим принципом вычислений в программировании – рекурсией – мы познакомимся в разделе, описывающем функции.

Любое из трех выражений в заголовке цикла может быть опущено (в том числе и все три). То же самое можно записать следующим образом:

int sum = 0;

int i = 1;

for (; i <= 100; ) {

    sum = sum + i;

    i = i + 1;

}

           

Заметим, что вместо одного оператора цикла мы записали несколько операторов, заключенных в фигурные скобки – блок. Другой вариант:

int sum = 0;

int i = 1;

for (; ;) {

    if (i > 100)

         break;

    sum = sum + i;

    i = i + 1;

}

           

В последнем примере мы опять встречаем оператор break. Оператор break завершает выполнение цикла. Еще одним вспомогательным оператором при выполнении циклов служит оператор продолжения continue. Оператор continue заставляет пропустить остаток тела цикла и перейти к следующей итерации (повторению). Например, если мы хотим найти сумму всех целых чисел от 0 до 100, которые не делятся на 7, можно записать это так:

 

int sum = 0;

for (int i = 1; i <= 100; i = i+1) {

    if ( i % 7 == 0)

         continue;

    sum = sum + i;

}

           

Еще одно полезное свойство цикла for: в первом выражении заголовка цикла можно объявить переменную. Эта переменная будет действительна только в пределах цикла.

Другой формой оператора цикла является оператор while. Его форма следующая:

 

while (условие)

    оператор

           

Условие – как и в условном операторе if – это выражение, которое принимает логическое значение "истина" или "ложь". Выполнение оператора повторяется до тех пор, пока значением условия является true (истина). Условие вычисляется заново перед каждой итерацией. Подсчитать, сколько десятичных цифр нужно для записи целого положительного числа N, можно с помощью следующего фрагмента:

 

int digits = 0;

while (N > 0) {

    digits = digits + 1;

    N = N / 10;

}

           

Если число N меньше либо равно нулю, тело цикла не будет выполнено.

Третьей формой оператора цикла является цикл do while. Он имеет форму:

 

do { операторы } while ( условие);

           

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

 

char ch;

do {

  ch = getch();  // функция getch возвращает

                 // символ, введёный с

                 // клавиатуры

} while (ch != '*');  

           

В операторах while и do также можно использовать операторы break и continue.

Как легко заметить, операторы цикла взаимозаменяемы. Оператор while соответствует операторатору for:

 

for  ( ; условие ; )

    оператор

           

Пример чтения символов с терминала можно переписать в виде:

 

char ch;

ch = getch();

while (ch != '*') {

    ch = getch();

}

           

Разные формы нужны для удобства и наглядности записи.

5.1.3.3 Оператор возврата

Оператор return завершает выполнение функции и возвращает управление в ту точку, откуда она была вызвана. Его форма:

 

return выражение;

           

Где выражение – это результат функции. Если функция не возвращает никакого значения, то оператор возврата имеет форму

 

return;

           

5.1.3.4 Оператор перехода

Последовательность выполнения операторов в программе можно изменить с помощью оператора перехода goto. Он имеет вид:

 

goto метка;

           

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

 

if ( x >= 0)

 goto positiv;

x = -x;        // переменить знак x

positiv:       // объявление метки

abs = x;       // присвоить переменной abs

              // положительное значение  

           

При выполнении goto вместо следующего оператора выполняется оператор, стоящий после метки positiv. Если значение x положительное, оператор x = - x выполняться не будет.

В настоящее время считается, что оператор goto очень легко запутывает программу.Без него, вообще говоря, можно обойтись, поэтому лучше его не использовать, ну разве что лишь в самом крайнем случае.

Пример:

 

int fact(int n)

{

    int k;

    if (n == 1) {

         k = 1;

    } else {

         k = n * fact(n – 1);

    }

    return k;

}

           

Это функция вычисления факториала. Первый оператор в ней – это объявление переменной k, в которой будет храниться результат вычисления. Затем выполняется условный оператор if. Если n равно единице, то вычисления факториала закончены, и выполняется оператор-выражение, который присваивает переменной значение 1. В противном случае выполняется другой оператор-выражение.

Последний оператор – это оператор возврата из функции.


Лекция 6 Функции

Функции – это основные единицы построения программ при процедурном программировании на языке C++.

6.1 Вызов функций

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

Программа на языке C++ состоит, по крайней мере, из одной функциифункции main. С нее всегда начинается выполнение программы. Встретив имя функции в выражении, программа вызовет эту функцию, т.е. передаст управление на ее начало и начнет выполнять операторы. Достигнув конца функции или оператора return – выхода из функции, управление вернется в ту точку, откуда функция была вызвана, подставив вместо нее вычисленный результат.

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

// функция sqrt с одним аргументом –

// вещественным числом двойной точности,

//  возвращает результат типа double

double sqrt(double x);

// функция sum от трех целых аргументов

// возвращает целое число 

int sum(int a, int b, int c);

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

double x = sqrt(3) + 1;

sum(k, l, m) / 15;

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

func(a,b,c);

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

int

sum(int a, int b, int c)

{

    int result;

    result = a + b + c;

    return result;

}

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

Аргументы a, b и c называются формальными параметрами. Это переменные, которые определены в теле функции (т.е. к ним можно обращаться только внутри фигурных скобок). При написании определения функции программа не знает их значения. При вызове функции вместо них подставляются фактические параметры – значения, с которыми функция вызывается. Выше, в примере вызова функции sum, фактическими параметрами ( или фактическими аргументами) являлись значения переменных k, l и m.

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

Первое, что мы делаем в теле функции — объявляем внутреннюю переменную result типа целое. Переменные, объявленные в теле функции, также называют локальными. Это связано с тем, что переменная result существует только во время выполнения тела функции sum. После завершения выполнения функции она уничтожается – ее имя становится неизвестным, и память, занимаемая этой переменной, освобождается.

Вторая строка определения тела функции – вычисление результата. Сумма всех аргументов присваивается переменной result. Отметим, что до присваивания значение result было неопределенным (то есть значение переменной было неким произвольным числом, которое нельзя определить заранее).

Последняя строчка функции возвращает в качестве результата вычисленное значение. Оператор return завершает выполнение функции и возвращает выражение, записанное после ключевого слова return, в качестве выходного значения. В следующем фрагменте программы переменной s присваивается значение 10:

int k = 2;

int l = 3;

int m = 5;

int s = sum(k, l, m);

6.2 Имена функций

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

double

sum(double a, double b, double c)

{

    double result;

    result = a + b + c;

    return result;

}

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

int x, y, z, ires;

double p,q,s, dres;

. . .

// вызвать первое определение функции sum

ires = sum(x,y,z);

// вызвать второе определение функции sum

dres = sum(p,q,s);

При первом вызове функции sum все фактические аргументы имеют тип int. Поэтому вызывается первая функция. Во втором вызове все аргументы имеют тип double, соответственно, вызывается вторая функция.

Важен не только тип аргументов, но и их количество. Можно определить функцию sum, суммирующую четыре аргумента:

int

sum(int x1, int x2, int x3, int x4)

{

    return x1 + x2 + x3 + x4;

}

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

int foo(int x);

double foo(int x);    

// ошибка – двукратное определение имени  

6.3 Необязательные аргументы функций

При объявлении функций в языке C++ имеется возможность задать значения аргументов по умолчанию. Первый случай применения этой возможности языка – сокращение записи. Если функция вызывается с одним и тем же значением аргумента в 99% случаев, и это значение достаточно очевидно, можно задать его по умолчанию. Предположим, функция exp возводит число в произвольную целую положительную степень. Чаще всего она используется для возведения в квадрат. Ее объявление можно записать так:

double exp(double x, unsigned int e = 2);

Определение функции:

double

exp(double x, unsigned int e)

{

    double result = 1;

    for (int i = 0; i < e; i++)

         result *= x;

    return result;

}

main()

{

    double y = exp(3.14);     

    double x = exp(2.9, 5);   

}  

Использовать аргументы по умолчанию удобно при изменении функции. Если при изменении программы нужно добавить новый аргумент, то для того чтобы не изменять все вызовы этой функции, можно новый аргумент объявить со значением по умолчанию. В таком случае старые вызовы будут использовать значение по умолчанию, а новые – значения, указанные при вызове.

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

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

double exp(double x, unsigned int e = 2);

double exp(double x);

приведет к ошибке компиляции – неоднозначности определения функции. Это происходит потому, что вызов

double x = exp(4.1);

подходит как для первой, так и для второй функции.

6.4 Рекурсия

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

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

int

fact(int n)

{

    int result = 1;

    for (int i = 1; i <= n; i++)

         result = result * i;

    return result;

}

Второй способ:

int

fact(int n)

{

  if (n == 1)     // факториал 1 равен 1

         return 1;

  else            // факториал числа n равен

                  // факториалу n-1

                  // умноженному на n  

                                                

         return n * fact(n -1);

}  

Функция fact вызывает сама себя с модифицированными аргументами. Такой способ вычислений называется рекурсией. Рекурсия – это очень мощный метод вычислений. Значительная часть математических функций определяется в рекурсивных терминах. В программировании алгоритмы обработки сложных структур данных также часто бывают рекурсивными. Рассмотрим, например, структуру двоичного дерева. Дерево состоит из узлов и направленных связей. С каждым узлом могут быть связаны один или два узла, называемые сыновьями этого узла. Соответственно, для "сыновей" узел, из которого к ним идут связи, называется "отцом". Узел, у которого нет "отца", называется корнем. У дерева есть только один корень. Узлы, у которых нет "сыновей", называются листьями. Пример дерева приведен на рис. 6.1.


Рис. 6.1. Пример дерева.

В этом дереве узел A – корень дерева, узлы B и C – "сыновья" узла A, узлы D и E – "сыновья" узла B, узел F – "сын" узла C. Узлы D, E и F – листья. Узел B является корнем поддерева, состоящего из трех узлов B, D и E. Обход дерева (прохождение по всем его узлам) можно описать таким образом:

  1.  Посетить корень дерева.
  2.  Обойти поддеревья с корнями — "сыновьями" данного узла, если у узла есть "сыновья".
  3.  Если у узла нет "сыновей" — обход закончен.

Очевидно, что реализация такого алгоритма с помощью рекурсии не составляет труда.

Довольно часто рекурсия и итерация взаимозаменяемы (как в примере с факториалом). Выбор между ними может быть обусловлен разными факторами. Чаще рекурсия более наглядна и легче реализуется. Кроме того, в большинстве случаев итерация более эффективна.


Лекция 7 Встроенные типы данных

7.1 Общая информация

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

В Таблице 7.1 перечислены простейшие типы данных, которые определяет язык C++, и приведены наиболее типичные диапазоны их значений.

Таблица 7.1 Встроенные типы языка Си++

Название

Обозначение

Диапазон значений

Байт

char 

от -128 до +127

Байт без знака

unsigned char 

от 0 до 255

Короткое целое число

short 

от -32768 до +32767

Короткое целое число без знака

unsigned short 

от 0 до 65535

Целое число

int 

от – 2147483648 до + 2147483647

Целое число без знака

unsigned int (или просто unsigned)

от 0 до 4294967295

Длинное целое число

long 

от – 2147483648 до + 2147483647

Длинное целое число

unsigned long 

от 0 до 4294967295

Вещественное число одинарной точности

float 

от ±3.4e-38 до ±3.4e+38 (7 значащих цифр)

Вещественное число двойной точности

double 

от ±1.7e-308 до ±1.7e+308 (15 значащих цифр)

Вещественное число увеличенной точности

long double 

от ±1.2e-4932 до ±1.2e+4932

Логическое значение

bool 

значения trueстина) или false (ложь)

7.2 Целые числа

Для представления целых чисел в языке C++ существует несколько типов – char, short int и long (полное название типов: short int, long int, unsigned long int и т.д.. Поскольку описатель int можно опустить, мы используем сокращенные названия). Они отличаются друг от друга диапазоном возможных значений. Каждый из этих типов может быть знаковым или беззнаковым. По умолчанию, тип целых величин – знаковый. Если перед определением типа стоит ключевое слово unsigned , то тип целого числа — беззнаковый. Для того чтобы определить переменную x типа короткого целого числа, нужно записать:

short x;

Число без знака принимает только положительные значения и значение ноль. Число со знаком принимает положительные значения, отрицательные значения и значение ноль.

Целое число может быть непосредственно записано в программе в виде константы. Запись чисел соответствует общепринятой нотации. Примеры целых констант: 0, 125, -37. По умолчанию целые константы принадлежат к типу int. Если необходимо указать, что целое число — это константа типа long , можно добавить символ L или l после числа. Если константа беззнаковая, т.е. относится к типу unsigned long или unsigned int , после числа записывается символ U или u. Например: 34U, 700034L, 7654ul.

Кроме стандартной десятичной записи, числа можно записывать в восьмеричной или шестнадцатеричной системе счисления. Признаком восьмеричной системы счисления является цифра 0 в начале числа. Признаком шестнадцатеричной — 0x или 0X перед числом. Для шестнадцатеричных цифр используются латинские буквы от A до F (неважно, большие или маленькие).

Таким образом, фрагмент программы

const int x = 240;

int y = 0360;

const int z = 0xF0;

  

определяет три целые константы x, y и z с одинаковыми значениями.

Отрицательные числа предваряются знаком минус "-". Приведем еще несколько примеров:

  

  // ошибка в записи восьмеричного числа

const usigned long ll = 0678;

  // правильная запись

const short a = 0xa4;

  // ошибка в записи десятичного числа

const int x = 23F3;

  

Для целых чисел определены стандартные арифметические операции сложения (+), вычитания (-), умножения (*), деления (/); нахождение остатка от деления (%), изменение знака (-). Результатом этих операций также является целое число. При делении остаток отбрасывается. Примеры выражений с целыми величинами:

x + 4;

30 — x;

x * 2;

-x;

10 / x;

x % 3;

  

Кроме стандартных арифметических операций, для целых чисел определен набор битовых (или поразрядных) операций. В них целое число рассматривается как строка битов (нулей и единиц при записи числа в двоичной системе счисления или разрядов машинного представления).

К этим операциям относятся поразрядные операции И, ИЛИ, ИСКЛЮЧАЮЩЕЕ ИЛИ, поразрядное отрицание и сдвиги. Поразрядная операция ИЛИ, обозначаемая знаком |, выполняет операцию ИЛИ над каждым индивидуальным битом двух своих операндов. Например, 1 | 2 в результате дают 3, поскольку в двоичном виде 1 это 01, 2 – это 10, соответственно, операция ИЛИ дает 11 или 3 в десятичной системе (нули слева мы опустили).

Аналогично выполняются поразрядные операции И, ИСКЛЮЧАЮЩЕЕ ИЛИ и отрицание.

3 | 1    результат 3

4 & 7    результат 4

4 ^ 7    результат 3

0 & 0xF    результат 0

~0x00F0    результат 0xFF0F

Операция сдвига перемещает двоичное представление левого операнда на количество битов, соответствующее значению правого операнда. Например, двоичное представление короткого целого числа 30000000000000011. Результатом операции 3 << 2 (сдвиг влево) будет двоичное число 0000000000001100 или, в десятичной записи, 12. Аналогично, сдвинув число 9 (в двоичном виде 0000000000001001) на 2 разряда вправо (записывается 9 >> 2) получим 0000000000000010, т.е. 2.

При сдвиге влево число дополняется нулями справа. При сдвиге вправо бит, которым дополняется число, зависит от того, знаковое оно или беззнаковое. Для беззнаковых чисел при сдвиге вправо они всегда дополняются нулевым битом. Если же число знаковое, то значение самого левого бита числа используется для дополнения. Это объясняется тем, что самый левый бит как раз и является знаком — 0 означает плюс и 1 означает минус. Таким образом, если

short x = 0xFF00;

unsigned short y = 0xFF00;

  

то результатом x >> 2 будет 0xFFC0 (двоичное представление 1111111111000000), а результатом y >> 2 будет 0x3FC0 (двоичное представление 0011111111000000).

Рассмотренные арифметические и поразрядные операции выполняются над целыми числами и в результате дают целое число. В отличие от них операции сравнения выполняются над целыми числами, но в результате дают логическое значение истина ( true ) или ложь ( false ).

Для целых чисел определены операции сравнения: равенства (==), неравенства (!=), больше (>), меньше (<), больше или равно (>=) и меньше или равно (<=).

Последний вопрос, который мы рассмотрим в отношении целых чисел, – это преобразование типов. В языке C++ допустимо смешивать в выражении различные целые типы. Например, вполне допустимо записать x + y, где x типа short , а y – типа long . При выполнении операции сложения величина переменной x преобразуется к типу long . Такое преобразование можно произвести всегда, и оно безопасно, т.е. мы не теряем никаких значащих цифр. Общее правило преобразования целых типов состоит в том, что более короткий тип при вычислениях преобразуется в более длинный. Только при выполнении присваивания длинный тип может преобразовываться в более короткий. Например:

short x;

long y = 15;

. . .

x = y;  

// преобразование длинного типа

// в более короткий

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

7.3 Вещественные числа

Вещественные числа в C++ могут быть одного из трех типов: с одинарной точностью — float , с двойной точностью – double , и с расширенной точностью – long double.

float x;

double e = 2.9;

long double s;   

В большинстве реализаций языка представление и диапазоны значений соответствуют стандарту IEEE (Institute of Electrical and Electronics Engineers) для представления вещественных чисел. Точность представления чисел составляет 7 десятичных значащих цифр для типа float , 15 значащих цифр для double и 19 — для типа long double .

Вещественные числа записываются либо в виде десятичных дробей, например 1.3, 3.1415, 0.0005, либо в виде мантиссы и экспоненты: 1.2E0, 0.12e1. Отметим, что обе предыдущие записи изображают одно и то же число 1.2.

По умолчанию вещественная константа принадлежит к типу double . Чтобы обозначить, что константа на самом деле float , нужно добавить символ f или F после числа: 2.7f. Символ l или L означает, что записанное число относится к типу long double .

const float pi_f = 3.14f;

double pi_d = 3.1415;

long double pi_l = 3.1415L;

  

Для вещественных чисел определены все стандартные арифметические операции сложения (+), вычитания (-), умножения (*), деления (/) и изменения знака (-). В отличие от целых чисел, операция нахождения остатка от деления для вещественных чисел не определена. Аналогично, все битовые операции и сдвиги к вещественным числам неприменимы; они работают только с целыми числами. Примеры операций:

2 * pi;

(x – e) / 4.0

  

Вещественные числа можно сравнивать на равенство (==), неравенство (!=), больше (>), меньше (<), больше или равно (>=) и меньше или равно (<=). В результате операции сравнения получается логическое значение истина или ложь.

Если арифметическая операция применяется к двум вещественным числам разных типов, то менее точное число преобразуется в более точное, т.е. float преобразуется в double и double преобразуется в long double . Очевидно, что такое преобразование всегда можно выполнить без потери точности.

Если вторым операндом в операции с вещественным числом является целое число, то целое число преобразуется в вещественное представление.

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

7.4 Логические величины

В языке C++ существует специальный тип для представления логических значений bool . Для величин этого типа существует только два возможных значения: true (истина) и false (ложь). Объявление логической переменной выглядит следующим образом:

bool condition;

  

Соответственно, существуют только две логические константы – истина и ложь. Они обозначаются, соответственно, true и false .

Для типа bool определены стандартные логические операции: логическое И (&&), ИЛИ (||) и НЕ (!).

// истинно, если обе переменные,

// cond1 и cond2, истинны

cond1 && cond2

// истинно, если хотя бы одна из переменных

// истинна

cond1 || cond2

// результат противоположен значению cond1

!cond1

  

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

int k = 100;

while (k) {      // выполнить цикл 100 раз

    k--;

}

7.5 Символы и байты

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

Итак, для записи знаков в языке C++ служат типы char и unsigned char . Первый – это целое число со знаком, хранящееся в одном байте, второй – беззнаковое байтовое число. Эти типы чаще всего используются для манипулирования символами, поскольку коды символов как раз помещаются в байт.

Пояснение. Единственное, что может хранить компьютер, это числа. Поэтому для того чтобы можно было хранить символы и манипулировать ими, символам присвоены коды – целые числа. Существует несколько стандартов, определяющих, какие коды каким символам соответствуют. Для английского алфавита и знаков препинания используется стандарт ASCII. Этот стандарт определяет коды от 0 до 127. Для представления русских букв используется стандарт КОИ-8 или CP-1251. В этих стандартах русские буквы кодируются числами от 128 до 255. Таким образом, все символы могут быть представлены в одном байте (максимальное число символов в одном байте – 255). Для работы с китайским, японским, корейским и рядом других алфавитов одного байта недостаточно, и используется кодировка с помощью двух байтов и, соответственно, тип wchar_t (подробнее см. ниже).

Чтобы объявить переменную байтового типа, нужно записать:

char c;                   

// байтовое число со знаком

unsigned char u;   

// байтовое число без знака

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

c = 45;

  

где c — байтовая переменная. Однако для байтов существует и другая запись констант. Знак алфавита (буква, цифра, знак препинания), заключенный в апострофы, представляет собой байтовую константу, например:

'S' '&' '8' 'ф'

  

Числовым значением такой константы является код данного символа, принятый в Вашей операционной системе.

В кодировке ASCII два следующих оператора эквивалентны:

char c = 68;

char c = 'D';

Первый из них присваивает байтовой переменной c значение числа 68. Второй присваивает этой переменной код латинской буквы D, который в кодировке ASCII равен 68.

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

\a     звонок

\b     возврат на один символ назад

\f     перевод страницы

\n     новая строка

\r     перевод каретки

\t     горизонтальная табуляция

\v     вертикальная табуляция

\'     апостроф

\"     двойные кавычки

\\     обратная дробная черта

\?     вопросительный знак

Для того чтобы записать произвольное байтовое значение, также используется экранированная последовательность: после обратной дробной черты записывается целое число от 0 до 255.

char zero = '\0';

const unsigned char bitmask = '\0xFF';

char tab = '\010';

Следующая программа выведет все печатные символы ASCII и их коды в порядке увеличения:

for (char c = 32; c < 127; c++)

    cout << c << " " << (int)c << " ";

Однако напомним еще раз, что байтовые величины – это, прежде всего, целые числа, поэтому вполне допустимы выражения вида

'F' + 1

'a' < 23

и тому подобные. Тип char был придуман для языка Си, от которого C++ достались все базовые типы данных. Язык Си предназначался для программирования на достаточно "низком" уровне, приближенном к тому, как работает процессор ЭВМ, именно поэтому символ в нем – это лишь число.

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

7.6 Кодировка, многобайтовые символы 

Мы уже упоминали о наличии разных кодировок букв, цифр, знаков препинания и т.д. Алфавит большинства европейских языков может быть представлен однобайтовыми числами (т.е. кодами в диапазоне от 0 до 255). В большинстве кодировок принято, что первые 127 кодов отводятся для символов, входящих в набор ASCII: ряд специальных символов, латинские заглавные и строчные буквы, арабские цифры и знаки препинания. Вторая половина кодов – от 128 до 255 отводится под буквы того или иного языка. Фактически, вторая половина кодовой таблицы интерпретируется по-разному, в зависимости от того, какой язык считается "текущим". Один и тот же код может соответствовать разным символам в зависимости от того, какой язык считается "текущим".

Однако для таких языков, как китайский, японский и некоторые другие, одного байта недостаточно – алфавиты этих языков насчитывают более 255 символов.

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

wchar_t wch;

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

Константы типа wchar_t записываются в виде L'ab'.

7.7 Наборы перечисляемых значений

Достаточно часто в программе вводится тип, состоящий лишь из нескольких заранее известных значений. Например, в программе используется переменная, хранящая величину, отражающую время суток, и мы решили, что будем различать ночь, утро, день и вечер. Конечно, можно договориться обозначить время суток числами от 1 до 4. Но, во-первых, это не наглядно. Во-вторых, что даже более существенно, очень легко сделать ошибку и,например, использовать число 5, которое не соответствует никакому времени дня. Гораздо удобней и надежнее определить набор значений с помощью типа enum языка C++:

enum DayTime { morning, day, evening, night };

Теперь можно определить переменную

DayTime current;

которая хранит текущее время дня, а затем присваивать ей одно из допустимых значений типа DayTime:

current = day;

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

Для наборов определены операции сравнения на равенство (==) и неравенство (!=) с атрибутами этого же типа, т.е.

if (current != night)

    // выполнить работу

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

enum { morning = 4, day = 3, evening = 2,

           night = 1 };

    // последовательные числа начиная с 1

enum { morning = 1, day, evening, night };

    // используются числа 0, 2, 3 и 4

enum { morning, day = 2, evening, night };

Во-вторых, атрибуты наборов можно использовать в выражениях вместо целых чисел. Преобразования из набора в целое и наоборот разрешены.

Однако мы не рекомендуем так делать. Для работы с целыми константами лучше применять символические обозначения констант, а наборы использовать по их прямому назначению.


Лекция 8 Классы и объекты

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

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

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

class Complex {

public:

    int real;   // вещественная часть

    int imaginary;   // мнимая часть

    void Add(Complex x);

         // прибавить комплексное число

};

Приведенный выше пример - упрощенное определение класса Complex, представляющее комплексное число. Комплексное число состоит из вещественной части - целого числа real и мнимой части, которая представлена целым числом imaginary. real и imaginary - это атрибуты класса. Для класса Complex определена одна операция или метод - Add.

Определив класс, мы можем создать переменную типа Complex:

Complex number;

Переменная с именем number содержит значение типа Complex, то есть содержит объект класса Complex. Имея объект, мы можем установить значения атрибутов объекта:

number.real = 1;

number.imaginary = 2;

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

Complex num2;

number.Add(num2);

Как можно заметить, метод Add выполняется с объектом. Имя объекта (или переменной, содержащей объект, что, в сущности, одно и то же), в данном случае, number, записано первым. Через точку записано имя метода - Add с аргументом - значением другого объекта класса Complex, который прибавляется к number. Методы часто называются сообщениями. Но чтобы послать сообщение, необходим получатель. Таким образом, объекту number посылается сообщение Add с аргументом num2. Объект number принимает это сообщение и складывает свое значение со значением аргумента сообщения.

8.2 Определение методов класса

Данные рассуждения будут яснее, если мы определим, как выполняется операция сложения.

void

Complex::Add(Complex x)

{

  this->real = this->real + x.real;

  this->imaginary = this->imaginary +

                    x.imaginary;

}

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

Запись this-> говорит о том, что атрибут принадлежит к тому объекту, который выполняет метод Add (объекту, получившему сообщение Add). В большинстве случаев this-> можно опустить. В записи определения метода какого-либо класса упоминание атрибута класса без всякой дополнительной информации означает, что речь идет об атрибуте текущего объекта.

Теперь приведем этот небольшой пример полностью:

// определение класса комплексных чисел

class Complex {

public:

    int real; // вещественная часть

    int imaginary; // мнимая часть

    void Add(Complex x);   

      // прибавить комплексное число

};

// определение метода сложения

void

Complex::Add(Complex x)

{

    real = real + x.real;

    imaginary = imaginary + x.imaginary;

}

int

main()

{

Complex number;

    number.real = 1;  

            // первый объект класса Complex

    number.imaginary = 3;

    Complex num2;   

            // второй объект класса Complex

    num2.real = 2;

    num2.imaginary = 1;

    number.Add(num2);  

            // прибавить значение второго

            // объекта к первому

    return 1;

}

8.3 Переопределение операций 

В языке C++ можно сделать так, что класс будет практически неотличим от предопределенных встроенных типов при использовании в выражениях. Для класса можно определить операции сложения, умножения и т.д. пользуясь стандартной записью таких операций, т.е. x + y. В языке C++ считается, что подобная запись - это также вызов метода с именем operator+ того класса, к которому принадлежит переменная x. Перепишем определение класса Complex:

// определение класса комплексных чисел

class Complex

{

public:

  int real; // вещественная часть

  int imaginary; // мнимая часть

  // прибавить комплексное число

  Complex operator+(const Complex x) const;

};

Вместо метода Add появился метод operator+. Изменилось и его определение. Во-первых, этот метод возвращает значение типа Complex (операция сложения в результате дает новое значение того же типа, что и типы операндов). Во-вторых, перед аргументом метода появилось ключевое слово const. Это слово обозначает, что при выполнении данного метода аргумент изменяться не будет. Также const появилось после объявление метода. Второе ключевое слово const означает, что объект, выполняющий метод, не будет изменен. При выполнении операции сложения x + y над двумя величинами x и y сами эти величины не изменяются. Теперь запишем определение операции сложения:

Complex

Complex::operator+(const Complex x) const

{

  Complex result;

  result.real = real + x.real;

  result.imaginary = imaginary + x.imaginary;

  return result;

}

8.4 Подписи методов и необязательные аргументы

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

// определение класса комплексных чисел

class Complex

{

public:

  int real; // вещественная часть

  int imaginary; // мнимая часть

  // прибавить комплексное число

  Complex operator+(const Complex x) const;

  // прибавить целое число

  Complex operator+(long x) const;

};

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

Complex c1;

Complex c2;

long x;

c1 + c2;

c2 + x;

Аналогично можно задавать значения аргументов методов по умолчанию. Более подробное описание можно найти в лекции 5.

8.4.1 Запись классов

Как уже отмечалось раньше, выбор имен - это не праздный вопрос. Существует множество систем именования классов. Опишем ту, которой мы придерживаемся в данной книге.

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

Complex, String, StudentLibrarian

Имена методов классов также начинаются с большой буквы:

Add, Concat

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

real, classElement

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


Лекция 9 Производные типы данных

9.1 Массивы

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

int days[12];  

days[0] = 31;     // январь

days[1] = 28;     // февраль

days[2] = 31;     // март

days[3] = 30;     // апрель

days[4] = 31;     // май

days[5] = 30;     // июнь

days[6] = 31;     // июль

days[7] = 31;     // август

days[8] = 30;     // сентябрь

days[9] = 31;     // октябрь

days[10] = 30;    // ноябрь

days[11] = 31;    // декабрь  

 

В первой строчке мы объявили массив из 12 элементов типа int и дали ему имя days. Остальные строки примера – присваивания значений элементам массива. Для того, чтобы обратиться к определенному элементу массива, используют операцию индексации []. Как видно из примера, первый элемент массива имеет индекс 0, соответственно, последний – 11.

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

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

for (int i = 0; i < 12; i++) {

    cout << days[i];

}

Следует отметить, что при выполнении программы границы массива не контролируются. Если мы ошиблись и вместо 12 в приведенном выше цикле написали 13, то компилятор не выдаст ошибку. При выполнении программа попытается напечатать 13-е число. Что при этом случится, вообще говоря, не определено. Быть может, произойдет сбой программы. Более вероятно, что будет напечатано какое-то случайное 13-е число. Выход индексов за границы массива – довольно распространенная ошибка, которую иногда очень трудно обнаружить. В дальнейшем при изучении классов мы рассмотрим, как можно переопределить операцию [] и добавить контроль за индексами.

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

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

int array[100];

. . .

for (int i = 0; i < 99; i++ ) {

    for (int j = i + 1; j < 100; j++) {

         if (array[j] < array[i] ) {

              int tmp = array[j];

              array[j] = array[i];

              array[i] = tmp;

         }

    }

}

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

int m[10][5];

представляет матрицу целых чисел размером 10 на 5. По-другому интерпретировать приведенное выше объявление можно как массив из 10 элементов, каждый из которых – вектор целых чисел длиной 5. Общее количество целых чисел в массиве m равно 50.

Обращение к элементам многомерных массивов аналогично обращению к элементам векторов: m[1][2] обращается к третьему элементу второй строки матрицы m.

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

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

int days[12] = { 31, 28, 31, 30, 31, 31,

                30, 31, 30, 31 };

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

double temp[2][3] = {

    { 3.2, 3.3, 3.4 },

    { 4.1, 3.9, 3.9 } };

Интересной особенностью инициализации многомерных массивов является возможность не задавать размеры всех измерений массива, кроме самого последнего. Приведенный выше пример можно переписать так:

double temp[][3] = {

    { 3.2, 3.3, 3.4 },

    { 4.1, 3.9, 3.9 } };

// Вычислить размер пропущенной размерности

const int size_first = sizeof (temp) / sizeof

                      (double[3]);

9.2 Структуры

Структуры – это не что иное, как классы, у которых разрешен доступ ко всем их элементам (доступ к определенным атрибутам класса может быть ограничен, о чем мы узнаем в лекции 11). Пример структуры:

struct Record {

    int number;

    char name[20];

};

Так же, как и для классов, операция "." обозначает обращение к элементу структуры.

В отличие от классов, можно определить переменную-структуру без определения отдельного типа:

struct {

    double x;

    double y;

} coord;

Обратиться к атрибутам переменной coord можно coord.x и coord.y.

9.2.1 Битовые поля

В структуре можно определить размеры атрибута с точностью до бита. Традиционно структуры используются в системном программировании для описания регистров аппаратуры. В них каждый бит имеет свое значение. Не менее важной является возможность экономии памяти – ведь минимальный тип атрибута структуры это байт (char), который занимает 8 битов. До сих пор, несмотря на мегабайты и даже гигабайты оперативной памяти, используемые в современных компьютерах, существует немало задач, где каждый бит на счету.

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

struct TimeAndDate

{

unsigned hours    :5; // часы от 0 до 24

unsigned mins     :6; // минуты 

unsigned secs     :6; // секунды от 0 до 60

unsigned weekDay  :3; // день недели

unsigned monthDay :6; // день месяца от 1 до 31

unsigned month    :5; // месяц от 1 до 12

unsigned year     :8; // год от 0 до 100  

};  

Одна структура TimeAndDate требует всего 39 битов, т.е. 5 байтов (один байт — 8 битов). Если бы мы использовали для каждого атрибута этой структуры тип char, нам бы потребовалось 7 байтов.

9.3 Объединения

Особым видом структур данных является объединение. Определение объединения напоминает определение структуры, только вместо ключевого слова struct используется union:

union number {

    short sx;

    long lx;

    double dx;

};

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

struct Value {

 enum NumberType { ShortType, LongType,

                   DoubleType };

 NumberType type;  

 short sx;      // если type равен ShortType

 long lx;       // если type равен LongType

 double dx;     // если type равен DoubleType

};  

Атрибут type содержит тип хранимого числа, а соответствующий атрибут структуры – значение числа.

Value shortVal;

shortVal.type = Value::ShortType;

shortVal.sx = 15;

Хотя память выделяется под все три атрибута sx, ls и dx, реально используется только один из них. Сэкономить память можно, используя объединение:

struct Value {

    enum NumberType { ShortType, LongType,

                      DoubleType };

    NumberType type;

    union number {  

         short sx;

         long lx;

         double dx;

    } val;

};    // если type равен ShortType

 // если type равен LongType

 // если type равен DoubleType  

Теперь память выделена только для максимального из этих трех атрибутов (в данном случае dx). Однако и обращаться с объединением надо осторожно. Поскольку все три атрибута делят одну и ту же область памяти, изменение одного из них означает изменение всех остальных. На рисунке поясняется выделение памяти под объединение. В обоих случаях мы предполагаем, что структура расположена по адресу 1000. Объединение располагает все три своих атрибута по одному и тому же адресу.

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



Рис. 9.1. Использование памяти в объединениях.

9.4 Указатели

Указатель – это производный тип, который представляет собой адрес какого-либо значения. В языке C++ используется понятие адреса переменных. Работа с адресами досталась C++ в наследство от языка Си. Предположим, что в программе определена переменная типа int:

int x;

Можно определить переменную типа "указатель" на целое число:

int* xptr;

и присвоить переменной xptr адрес переменной x:

xptr = &x;

Операция &, примененная к переменной, – это операция взятия адреса. Операция *, примененная к адресу, – это операция обращения по адресу. Таким образом, два оператора эквивалентны:

int y = x;          

// присвоить переменной y значение x

int y = *xptr;      

// присвоить переменной y значение,

// находящееся по адресу xptr

С помощью операции обращения по адресу можно записывать значения:

*xptr = 10;  

// записать число 10 по адресу xptr

После выполнения этого оператора значение переменной x станет равным 10, поскольку xptr указывает на переменную x.

Указатель – это не просто адрес, а адрес величины определенного типа. Указатель xptrадрес целой величины. Определить адреса величин других типов можно следующим образом:

unsigned long* lPtr;      

// указатель на целое число без знака

char* cp;                 

// указатель на байт

Complex* p;               

// указатель на объект класса Complex  

Если указатель ссылается на объект некоторого класса, то операция обращения к атрибуту класса вместо точки обозначается "->", например p->real. Если вспомнить один из предыдущих примеров:

void

Complex::Add(Complex x)

{

 this->real = this->real + x.real;

 this->imaginary = this->imaginary +

                         x.imaginary;

}

то this – это указатель на текущий объект, т.е. объект, который выполняет метод Add. Запись this-> означает обращение к атрибуту текущего объекта.

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

int foo(long x);

int bar(long x);

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

int (*functptr)(long x);

functptr = &foo;

(*functptr)(2);

functptr = &bar;

(*functptr)(4);

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

int* hardwareRegiste =0x80000;

*hardwareRegiste =12;

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

struct TempResults {

    double x1;

    double x2;

} tempArea;

 // Функция calc возвращает истину, если

 // вычисления были успешны, и ложь – при

 // наличии ошибки. Вычисленные результаты

 // записываются на место аргументов по

 // адресу, переданному в указателе trPtr

bool

calc(TempResults* trPtr)

{

    // вычисления

    if (noerrors) {

         trPtr->x1 = res1;

         trPtr->x2 = res2;

         return true;

    } else {

         return false;

    }

}

void

fun1(void)

{

    . . .

    TempResults tr;

    tr.x1 = 3.4;

    tr.x2 = 5.4;

    if (calc(&tr) == false) {

         // обработка ошибки

    }

    . . .

}

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

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

9.4.1 Адресная арифметика

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

int x = 10;

int y = 10;

int* xptr = &x;

int* yptr = &y;

// сравниваем указатели

if (xptr == yptr) {

   cout << "Указатели равны" << endl;

} else {

   cout << "Указатели неравны" << endl;

}

// сравниваем значения, на которые указывают

// указатели

if (*xptr == *yptr) {

    cout << "Значения равны" << endl;

} else {

    cout << "Значения неравны" << endl;

}

Однако результат второй операции сравнения будет истинным, поскольку переменные x и y имеют одно и то же значение.

Кроме того, над указателями можно выполнять ограниченный набор арифметических операций. К указателю можно прибавить целое число или вычесть из него целое число. Результатом прибавления к указателю единицы является адрес следующей величины типа, на который ссылается указатель, в памяти. Поясним это на рисунке. Пусть xPtrуказатель на целое число типа long, а cpуказатель на тип char. Начиная с адреса 1000, в памяти расположены два целых числа. Адрес второго — 1004 (в большинстве реализаций C++ под тип long выделяется четыре байта). Начиная с адреса 2000, в памяти расположены объекты типа char.


Рис. 9.2. Адресная арифметика.

Размер памяти, выделяемой для числа типа long и для char, различен. Поэтому адрес при увеличении xPtr и cp тоже изменяется по-разному. Однако и в том, и в другом случае увеличение указателя на единицу означает переход к следующей в памяти величине того же типа. Прибавление или вычитание любого целого числа работает по тому же принципу, что и увеличение на единицу. Указатель сдвигается вперед (при прибавлении положительного числа) или назад (при вычитании положительного числа) на соответствующее количество объектов того типа, на который показывает указатель. Вообще говоря, неважно, объекты какого типа на самом деле находятся в памяти — адрес просто увеличивается или уменьшается на необходимую величину. На самом деле значение указателя ptr всегда изменяется на число, равное sizeof(*ptr).

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

9.4.2 Связь между массивами и указателями

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

long array[100];

long sum = 0;

for (int i = 0; i < 100; i++)

    sum += array[i];

То же самое можно сделать с помощью указателей:

long array[100];

long sum = 0;

for (long* ptr = &array[0];

    ptr < &array[99] + 1; ptr++)

    sum += *ptr;

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

for (long* ptr = array;

    ptr < <array[99] + 1; ptr++)

    sum += *ptr;

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

При использовании многомерных массивов указатели позволяют обращаться к срезам или подмассивам. Если мы объявим трехмерный массив exmpl:

long exmpl[5][6][7]

то выражение вида exmpl[1][1][2] – это целое число, exmpl[1][1] – вектор целых чисел (адрес первого элемента вектора, т.е. имеет тип *long), exmpl[1] – двухмерная матрица или указатель на вектор (тип (*long)[7]). Таким образом, задавая не все индексы массива, мы получаем указатели на массивы меньшей размерности.

9.4.3 Бестиповый указатель

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

void* ptr;

Для указателя на тип void не определена операция ->, не определена операция обращения по адресу *, не определена адресная арифметика. Использование бестиповых указателей ограничено работой с памятью при использовании ряда системных функций, передачей адресов в функции, написанные на языках программирования более низкого уровня, например на ассемблере.

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

void

printbytes(void* ptr, int nbytes)

{

 if (nbytes == 1) {

    char* cptr = (char*)ptr;

    cout << *cptr;

 } else if (nbytes == 2) {

    short* sptr = (short*)ptr;

    cout << *sptr;

 } else if (nbytes == 4) {

    long* lptr = (long*)ptr;

    cout << *lptr;

 } else {

    cout << "Неверное значение аргумента";

 }

}

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

9.4.4 Нулевой указатель

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

long* foo(void);

. . .

long* resPtr;

if ((resPtr = foo()) != 0) {

         // использовать результат

} else {

         // ошибка

}

В языке C++ определена символическая константа NULL для обозначения нулевого значения указателя.

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

9.5 Строки и литералы

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

Строки представляются в виде массива байтов:

char string[20];

string[0] = 'H';

string[1] = 'e';

string[2] = 'l';

string[3] = 'l';

string[4] = 'o';

string[5] = 0;

В массиве string записана строка "Hello". При этом мы использовали только 6 из 20 элементов массива.

Для записи строковых констант в программе используются литералы. Литерал – это последовательность знаков, заключенная в двойные кавычки:

"Это строка"

"0123456789"

"*"

Заметим, что символ, заключенный в двойные кавычки, отличается от символа, заключенного в апострофы. Литерал "*" обозначает два байта: первый байт содержит символ звездочки, второй байт содержит ноль. Константа '*' обозначает один байт, содержащий знак звездочки.

С помощью литералов можно инициализировать массивы:

char alldigits[] = "0123456789";

Размер массива явно не задан, он определяется исходя из размера инициализирующего его литерала, в данном случае 11 (10 символов плюс нулевой байт).

При работе со строками особенно часто используется связь между массивами и указателями. Значение литерала – это массив неизменяемых байтов нужного размера. Строковый литерал может быть присвоен указателю на char:

const char* message = "Сообщение программы";

Значение литерала – это адрес его первого байта, указатель на начало строки. В следующем примере функция CopyString копирует первую строку во вторую:

void

CopyString(char* src, char* dst)

{

    while (*dst++ = *src++)

         ;

    *dst = 0;

}

void

main()

{

    char first[] = "Первая строка";

    char second[100];

    CopyString(first, second);

}

Указатель на байт (тип char*) указывает на начало строки. Предположим, нам нужно подсчитать количество цифр в строке, на которую показывает указатель str:

#include <ctype.h>

int count = 0;  

while (*str != 0) {         

   // признак конца строки – ноль

    if (isdigit(*str++))   

   // проверить байт, на который

   

        count++;          

   // указывает str, и сдвинуть

   // указатель на следующий байт  

}  

При выходе из цикла while переменная count содержит количество цифр в строке str, а сам указатель str указывает на конец строки – нулевой байт. Чтобы проверить, является ли текущий символ цифрой, используется функция isdigit. Это одна из многих стандартных функций языка, предназначенных для работы с символами и строками.

С помощью функций стандартной библиотеки языка реализованы многие часто используемые операции над символьными строками. В большинстве своем в качестве строк они воспринимают указатели. Приведем ряд наиболее употребительных. Прежде чем использовать эти указатели в программе, нужно подключить их описания с помощью операторов #include <string.h> и #include <ctype.h>.

char* strcpy(char* target,

            const char* source);

Копировать строку source по адресу target, включая завершающий нулевой байт. Функция предполагает, что памяти, выделенной по адресу target, достаточно для копируемой строки. В качестве результата функция возвращает адрес первой строки.

char* strcat(char* target,

            const char* source);

Присоединить вторую строку с конца первой, включая завершающий нулевой байт. На место завершающего нулевого байта первой строки переписывается первый символ второй строки. В результате по адресу target получается строка, образованная слиянием первой со второй. В качестве результата функция возвращает адрес первой строки.

int strcmp(const char* string1,

          const char* string2);

Сравнить две строки в лексикографическом порядке (по алфавиту). Если первая строка должна стоять по алфавиту раньше, чем вторая, то результат функции меньше нуля, если позже – больше нуля, и ноль, если две строки равны.

size_t strlen(const char* string);

Определить длину строки в байтах, не считая завершающего нулевого байта.

В следующем примере, использующем приведенные функции, в массиве result будет образована строка "1 января 1998 года, 12 часов":

char result[100];

char* date = "1 января 1998 года";

char* time = "12 часов";

strcpy(result, date);

strcat(result, ", ");

strcat(result, time);

Как видно из этого примера, литералы можно непосредственно использовать в выражениях.

Определить массив строк можно с помощью следующего объявления:

char* StrArray[5] =

 { "one", "two", "three", "four", "five" };  


Лекция 10 Распределение памяти

10.1 Автоматические переменные

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

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

int

funct()

{  

    double f;  // значение f не определено

    f = 1.2;    

    // теперь значение f определено  

    // явная инициализация автоматической

   // переменной

    bool result = true;

    . . .

}  

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

Замечание. Распространенной ошибкой является использование адреса автоматической переменной после выхода из функции. Конструкция типа:

int*

func()

{

    int x;

    . . .

    return  &х;

}

дает непредсказуемый результат.

10.2 Статические переменные

Другой способ выделения памяти – статический.

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

double globalMax;  

// переменная определена вне функции

void

func(int x)

{

    static bool visited = false;

    if (!visited) {

         . . .  // инициализация

         visited = true;

    }

    . . .

}

В данном примере переменная visited создается в начале выполнения программы. Ее начальное значение – false. При первом вызове функции func условие в операторе if будет истинным, выполнится инициализация, и переменной visited будет присвоено значение true. Поскольку статическая переменная создается только один раз, ее значения между вызовами функции сохраняются. При втором и последующих вызовах функции func инициализация производиться не будет.

Если бы переменная visited не была объявлена static, то инициализация происходила бы при каждом вызове функции.

10.3 Динамическое выделение памяти

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

long* lp;

lp = new long;

Complex* cp;

cp = new Complex;  

 // создать новое целое число

 // создать новый объект типа Complex  

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

delete lp;

delete cp;

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

Если необходимо динамически создать массив, то нужно использовать немного другую форму new:

new int[100];

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

Освобождение памяти, выделенной под массив, должно быть выполнено с помощью следующей операции delete 

delete [] address;

10.4 Выделение памяти под строки

В следующем фрагменте программы мы динамически выделяем память под строку переменной длины и копируем туда исходную строку

// стандартная функция strlen подсчитывает

// количество символов в строке

int length = strlen(src_str);

// выделить память и добавить один байт

// для завершающего нулевого байта

char* buffer = new char[length + 1];

strcpy(buffer, src_str);

// копирование строки

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

char* newstr;

newstr = new char[length];

if (newstr == NULL) {  // проверить результат

         // обработка ошибок

}

// память выделена успешно

10.5 Рекомендации по использованию указателей и динамического распределения памяти

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

Приведем несколько примеров.

Использование неверного адреса в операции delete. Результат такой операции непредсказуем. Вполне возможно, что сама операция пройдет успешно, однако внутренняя структура памяти будет испорчена, что приведет либо к ошибке в следующей операции new, либо к порче какой-нибудь информации.

Пропущенное освобождение памяти, т.е. программа многократно выделяет память под данные, но "забывает" ее освобождать. Такие ошибки называют утечками памяти. Во-первых, программа использует ненужную ей память, тем самым понижая производительность. Кроме того, вполне возможно, что в 99 случаях из 100 программа будет успешно выполнена. Однако если потеря памяти окажется слишком большой, программе не хватит памяти под какие-нибудь данные и, соответственно, произойдет сбой.

Запись по неверному адресу. Скорее всего, будут испорчены какие-либо данные. Как проявится такая ошибка – неверным результатом, сбоем программы или иным образом – предсказать трудно

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

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

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

2. Старайтесь локализовать распределение памяти. Если какой-либо метод выделяет память (в особенности под временные данные), он же и должен ее освободить.

3. Там, где это возможно, вместо указателей используйте ссылки.

4. Проверяйте программы с помощью специальных средств контроля памяти (Purify компании Rational, Bounce Checker компании Nu-Mega и т.д.)

10.6 Ссылки

Ссылка – это еще одно имя переменной. Если имеется какая-либо переменная, например

Complex x;

то можно определить ссылку на переменную x как

Complex& y = x;

и тогда x и y обозначают одну и ту же величину. Если выполнены операторы

x.real = 1;

x.imaginary = 2;

то y.real равно 1 и y.imaginary равно 2. Фактически, ссылка – это адрес переменной (поэтому при определении ссылки используется символ & -- знак операции взятия адреса), и в этом смысле она сходна с указателем, однако у ссылок есть свои особенности.

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

int& xref;

можно только

int& xref = x;

Во-вторых, нельзя переопределить ссылку, т.е. изменить на какой объект она ссылается. Если после определения ссылки xref мы выполним присваивание

xref = y;

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

int x = 10;

int y = 20;

int& xref = x;

xref = y;

x += 2;

cout << "x = " << x << endl;

cout << "y = " << y << endl;

cout << "xref = " << xref << endl;

будет выведено:

x = 22

y = 20

xref = 22

В-третьих, синтаксически обращение к ссылке аналогично обращению к переменной. Если для обращения к атрибуту объекта, на который ссылается указатель, применяется операция ->, то для подобной же операции со ссылкой применяется точка ".".

Complex a;

Complex* aptr = &a;

Complex& aref = a;

aptr->real = 1;

aref.imaginary = 2;

Как и указатель, ссылка сама по себе не имеет значения. Ссылка должна на что-то ссылаться, тогда как указатель должен на что-то указывать.

10.6 Распределение памяти при передаче аргументов функции

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

Рассмотрим более подробно метод Add класса Complex. Изменим его немного, так, чтобы он вместо изменения состояния объекта возвращал результат операции сложения:

Complex

Complex::Add(Complex x)

{

 Complex result;

 result.real = real + x.real;

 result.imaginary = imaginary + x.imaginary;

 return result;

}

При вызове этого метода

Complex n1;

Complex n2;

. . .

Complex n3 = n1.Add(n2);

значение переменной n2 передается в качестве аргумента. Компилятор создает временную переменную типа Complex, копирует в нее значение n2 и передает эту переменную в метод Add. Такая передача аргумента называется передачей по значению. У передачи аргументов по значению имеется два свойства. Во-первых, эта операция не очень эффективна, особенно если объект сложный и требует большого объема памяти или же если создание объекта сопряжено с выполнением сложных действий (о конструкторах объектов будет рассказано в лекции 12). Во-вторых, изменения аргумента функции не сохраняются. Если бы метод Add был бы определен как

Complex

Complex::Add(Complex x)

{

 Complex result;

 x.imaginary = 0;   

   // изменение аргумента метода

 result.real = real + x.real;

 result.imaginary = imaginary + x.imaginary;

 return result;

}

то при вызове n3 = n1.Add(n2) результат был бы, конечно, другой, но значение переменной n2 не изменилось бы. Хотя в данном примере изменяется значение аргумента метода Add, этот аргумент – лишь копия объекта n2, а не сам объект. По завершении выполнения метода Add его аргументы просто уничтожаются, и первоначальные значения фактических параметров сохраняются.

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

Другим способом передачи аргументов является передача по ссылке. Если изменить описание метода Add на

Complex

Complex::Add(Complex& x)

{

 Complex result;

 result.real = real + x.real;

 result.imaginary = imaginary + x.imaginary;

 return result;

}

то при вызове n3 = n1.Add(n2) компилятор будет создавать ссылку на переменную n2 и передавать ее методу Add. В большинстве случаев это намного эффективнее, так как для ссылки требуется немного памяти и создать ее проще. Однако мы получим нежелательный в данном случае эффект. Метод

Complex

Complex::Add(Complex& x)

{

 Complex result;

 x.imaginary = 0;   // изменение значения

                    // по переданной ссылке

 result.real = real + x.real;

 result.imaginary = imaginary + x.imaginary;

  return result;

}

изменит значение переменной n2. Операция Add не предусматривает изменения собственных операндов. Чтобы избежать ошибок, лучше записать аргумент с описателем const, который определяет соответствующую переменную как неизменяемую.

Complex::Add(const Complex& x)

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

void

Figure::Coord(int& x, int& y)

{

x = coordx;

y = coordy;

}

При вызове 

int cx, cy;

Figure fig;

. . .

fig.Coord(cx, cy);

переменным cx и cy будет присвоено значение координат фигуры fig.

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

Complex&

Complex::Add(const Complex& x)

{

 Complex result;

 result.real = real + x.real;

 result.imaginary = imaginary + x.imaginary;

 return result;

}

При выходе из метода автоматическая переменная result уничтожается, и память, выделенная для нее, освобождается. Поэтому результат Addссылка на несуществующую память. Результат подобных действий непредсказуем. Иногда программа будет работать как ни в чем не бывало, иногда может произойти сбой, иногда результат будет испорчен. Однако возвращение результата по ссылке возможно, если объект, на который эта ссылка ссылается, не уничтожается после выхода из функции или метода. Если метод Add прибавляет значение аргумента к текущему значению объекта и возвращает новое значение в качестве результата, то его можно записать:

Complex&

Complex::Add(const Complex& x)

{

 real += x.real;

 imaginary += x.imaginary;

 return *this;   

   // передать ссылку на текущий объект

}

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

x.Add(y) = z;

К значению объекта x прибавляется значение y, а затем результату присваивается значение z (фактически это эквивалентно x = z). Чтобы запретить подобные конструкции, достаточно добавить описатель const перед типом возвращаемого значения:

const Complex&

Complex::Add(const Complex& x)

. . .

Передача аргументов и результата по ссылке аналогична передаче указателя в качестве аргумента:

Complex*

Complex::Add(Complex* x)

{

 real += x->real;

 imaginary += x->imaginary;

 return this;

}

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

10.6.1 Рекомендации по передаче аргументов

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

2. Если в функции или методе значение аргумента используется, но не изменяется, передавайте аргумент по неизменяемой ссылке.

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

4. Передача по указателю используется, только если функции нужен именно указатель, а не значение объекта.


Лекция 11 Производные классы, наследование

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

class A

{

public:

    A();

    ~A();

    MethodA();

};

class B : public A

{

public:

    B();

    . . .

};

Термин "наследование" означает, что класс B обладает всеми свойствами класса A, он их унаследовал. У объекта производного класса есть все атрибуты и методы базового класса. Разумеется, новый класс может добавить собственные атрибуты и методы.

B b;

b.MethodA();  // вызов метода базового класса

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


Рис. 11.1.  Пример иерархии классов.

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

Предположим, что для библиотечной системы, которую мы разрабатываем, необходимо создать классы, описывающие различные книги, журналы и т.п., которые хранятся в библиотеке. Книга, журнал, газета и микрофильм обладают как общими, так и различными свойствами. У книги имеется автор или авторы, название и год издания. У журнала есть название, номер и содержание – список статей. В то же время книги, журналы и т.д. имеют и общие свойства: все это – "единицы хранения" в библиотеке, у них есть инвентарный номер, они могут быть в читальном зале, у читателей или в фонде хранения. Их можно выдать и, соответственно, сдать в библиотеку. Эти общие свойства удобно объединить в одном базовом классе. Введем класс Item, который описывает единицу хранения в библиотеке:

class Item

{

public:

  Item();

  ~Item();

  // истина, если единица хранения на руках

  bool IsTaken() const;

  // истина, если этот предмет имеется

  // в библиотеке

  

  bool IsAvailable() const;

  long GetInvNumber() const;   

  // инвентарный номер  

  

  void Take();      // операция "взять"

  void Return();    // операция "вернуть"

                      

private:

    // инвентарный номер — целое число

    long invNumber;

    // хранит состояние объекта —

    // взят на руки

    bool taken;

};  

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

// выдать на руки

void

TakeAnItem(Item& i)

{

    . . .

    if (i.IsAvailable())

         i.Take();

}

Конкретные свойства книги будут представлены классом Book.

class Book : public Item

{

public:  

    String Author(void) const;

    String Title(void) const;

    String Publisher(void) const;

    long YearOfPublishing(void) const;

    String Reference(void) const;

private:

    String author;

    String title;

    String publisher;

    short year;

};    // автор

 // название

 // издательство

 // год выпуска

 // полная ссылка

 // на книгу  

Для журнала класс Magazin предоставляет другие сведения:

class Magazin : public Item

{

public:  

    String Volume(void) const;

    short Number(void) const;

    String Title(void) const;

    Date DateOfIssue() const;

private:

    String volume;

    short number;

    String title;

    Date date;

};  

 // том

 // номер

 // название

 // дата выпуска  

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

У объекта класса Book имеются методы, непосредственно определенные в классе Book и методы, определенные в классе Item.

Book b;

long in = b.GetInvNumber();

String t = b.Reference();

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

String

Book::Reference(void) const

{

    String result = author + "\n"

              + title + "\n"

              + String(GetInvNumber());

    return result;

(Предполагается, что у класса String есть конструктор, который преобразует целое число в строку.) Запись:

String result = author + "\n"

              + title + "\n"

              + String(invNumber);

не разрешена, поскольку invNumber – внутренний атрибут класса Item. Однако если бы мы поместили invNumber в защищенную часть класса:

class Item

{

. . .

protected:

    long invNumber;

};

то методы классов Book и Magazin могли бы непосредственно использовать этот атрибут.

Назначение защищенной (protected) части класса в том и состоит, чтобы, закрыв доступ "извне" к определенным атрибутам и методам, разрешить пользоваться ими производным классам.

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

class A

{

public:

    . . .

    int foo();

    . . .

};

class B : public A

{

public:

    int foo();

    void bar();

};

void

B::bar()

{

    x = foo();   

   // вызывается метод foo класса B

}

Однако метод базового класса не исчезает. Просто при поиске имени foo сначала просматриваются атрибуты и методы самого класса. Если бы имя не было найдено, начался бы просмотр имен в базовом классе, затем просмотр внешних имен. В данном случае имя foo существует в самом классе, поэтому оно и используется.

С помощью записи A::foo() можно явно указать, что нас интересует имя, определенное в классе A, и тогда запись:

x = A::foo();

вызовет метод базового класса.

Вообще, запись класс::имя уже многократно нами использовалась. При поиске имени она означает, что имя относится к заданному классу.

11.1 Виртуальные методы

В обоих классах, выведенных из класса Item, имеется метод Title, выдающий в качестве результата заглавие книги или название журнала. Кроме этого метода, полезно было бы иметь метод, выдающий полное название любой единицы хранения. Реализация этого метода различна, поскольку название книги и журнала состоит из разных частей. Однако вид метода – возвращаемое значение и аргументы – и его общий смысл один и тот же. Название – это общее свойство всех единиц хранения в библиотеке, и логично поместить метод, выдающий название, в базовый класс.

class Item

{

public:

    virtual String Name(void) const;

    . . .

};

class Book : public Item

{

public:

    virtual String Name(void) const;

    . . .

};

class Magazin : public Item

{

public:

    virtual String Name(void) const;

    . . .

};

Реализация метода Name для базового класса тривиальна: поскольку название известно только производному классу, мы будем возвращать пустую строку.

String

Item::Name(void) const

{

    return "";

}

Для книги название состоит из фамилии автора, названия книги, издательства и года издания:

String

Book::Name(void) const

{

    return author + title + publisher +

           String(year);

}

У журнала полное название состоит из названия журнала, года и номера:

String

Magazin::Name(void) const

{

    return title + String(year) +

           String(number);

}

Методы Name определены как виртуальные с помощью описателя virtual, стоящего перед определением метода. Виртуальные методы реализуют идею полиморфизма в языке Си++. Если в программе используется указатель на базовый класс   Item и с его помощью вызывается метод Name:

Item* ptr;

. . .

String name = ptr->Name();

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

Item* ptr;

. . .

if (type == "Book")

    ptr = new Book;

else if (type == "Magazin")

    ptr = new Magazin;

. . .

String name = ptr->Name();

В данном фрагменте программы, если переменная type, обозначающая тип библиотечной единицы, была равна "Book", то будет вызван метод Name класса Book. Если же она была равна "Magazin", то будет вызван метод класса Magazin.

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

Приведем еще один пример виртуального метода. Предположим, в графическом редакторе при нажатии определенной клавиши нужно перерисовать текущую форму на экране. Форма может быть квадратом, кругом, эллипсом и т.д. Мы введем базовый класс для всех форм Shape. Конкретные фигуры, с которыми работает редактор, будут представлены классами Square (квадрат), Circle (круг), Ellipse (эллипс), производными от класса Shape. Класс Shape определяет виртуальный метод   Draw для отображения формы на экране.

class Shape

{

public:

    Shape();

    virtual void Draw(void);

};

//

// квадрат

//

class Square : public Shape

{

public:

    Square();

    virtual void Draw(void);

private:

    double length;   // длина стороны

};

//

// круг

//

class Circle : public Shape

{

public:

    Circle();

    virtual void Draw(void);

private:

    short radius;

};

. . .

Конкретные классы реализуют данный метод, и, разумеется, делают это по-разному. Однако в функции перерисовки текущей формы, если у нас имеется указатель на базовый класс, достаточно лишь записать вызов виртуального метода, и динамически будет вызван нужный алгоритм рисования конкретной формы в зависимости от того, к какому из классов (Square, Circle и т.д.) принадлежит объект, на который указывает указатель shape:

Repaint(Shape* shape)

{

    shape->Draw();

}

11.1.1 Виртуальные методы и переопределение методов

Что бы изменилось, если бы метод Name не был описан как виртуальный? В таком случае решение о том, какой именно метод будет выполняться, принимается статически, во время компиляции программы. В примере с методом Name, поскольку мы работаем с указателем на базовый класс, был бы вызван метод Name класса Item. При определении метода как virtual решение о том, какой именно метод будет выполняться, принимается во время выполнения.

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

func(Item item)

{

    item.Name();

}

func1(Item& item)

{

    item.Name();

}  

 // вызывается метод Item::Name()

 // вызывается метод в соответствии

 // с типом того объекта, на который

 // ссылается item  

11.2 Преобразование базового и производного классов

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

Circle* pC;

. . .

Shape* pShape = pC;

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

Item* iPtr;

. . .

Book* bPtr = (Book*)iPtr;

небезопасно. Такое преобразование можно выполнять только тогда, когда точно известно, что iPtr указывает на объект класса Book.

11.3 Внутреннее и защищенное наследование

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

class B : private A

{

. . .

};

В случае внутреннего наследования внешняя и защищенная части базового класса становятся внутренней частью производного класса. Внутренняя часть базового класса остается для производного класса недоступной.

Если перед именем базового класса поставить ключевое слово protected, то будет использоваться защищенное наследование. При нем внешняя и защищенная части базового класса становятся защищенной частью производного класса. Внутренняя часть базового класса остается недоступной для производного класса.

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

Если в классе A был определен какой-то метод:

class A

{

public:

    int foo();

};

то запись

B b;

b.foo();

недопустима, так же, как и

class C

{

    int m() {

         foo();

    }

};

если класс B внутренне наследует A. Если же класс B использовал защищенное наследование, то первая запись b.foo() также была бы неправильной, но зато вторая была бы верна.

11.4 Абстрактные классы

Вернемся к примеру наследования, который мы рассматривали раньше. Мы ввели базовый класс   Item, который представляет общие свойства всех единиц хранения в библиотеке. Но существуют ли объекты класса Item? То есть существует ли в действительности "единица хранения" сама по себе? Конечно, каждая книга (класс Book), журнал (класс Magazin) и т.д. принадлежат и к классу Item, поскольку они выведены из него, однако объект самого базового класса вряд ли имеет смысл. Базовый класс – это некое абстрактное понятие, описывающее общие свойства других, конкретных объектов.

Тот факт, что в данном случае объекты базового класса не могут существовать сами по себе, обусловлен еще одним обстоятельством. Некоторые методы базового класса не могут быть реализованы в нем, а должны быть реализованы в порожденных классах. Возьмем, например, тот же метод Name. Его реализация в базовом классе довольно условна, она не имеет особого смысла. Было бы логичнее вообще не реализовывать этот метод в базовом классе, а возложить ответственность за его реализацию на производные классы.

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

Такая ситуация складывается довольно часто в объектно-ориентированном программировании. (Вспомните пример с различными формами в графическом редакторе: рисование некой обобщенной формы невозможно.) В подобных случаях используется механизм абстрактных классов. Запишем базовый класс   Item немного по-другому:

class Item

{

public:

    . . .

    virtual String Name() const = 0;

};

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

Если метод объявлен чисто виртуальным, значит, он должен быть определен во всех классах, производных от Item. Наличие чисто виртуального метода запрещает создание объекта типа Item. В программе можно использовать указатели или ссылки на тип Item. Записи

Item it;

Item* itptr = new Item;

не разрешены, и компилятор сообщит об ошибке. Однако можно записать:

Book b;

Item* itptr = &b;

Item& itref = b;

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

Если по каким-либо причинам в производном классе   чисто виртуальный метод не определен, то этот класс тоже будет абстрактным, и любые попытки создать объект данного класса будут вызывать ошибку. Таким образом, забыть определить чисто виртуальный метод просто невозможно. Абстрактный   базовый класс навязывает определенный интерфейс всем производным из него классам. Собственно, в этом и состоит главное назначение абстрактных классов – в определении интерфейса для всей иерархии классов. Разумеется, это не означает, что в абстрактном классе не может быть определенных методов или атрибутов.

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

class A

{

public:

    virtual ~A() = 0;

};

A::~A()

{

. . .

}

Класс Aабстрактный, и объект типа A создать невозможно. Однако деструктор его определен и будет вызван при уничтожении объектов производных классов (о порядке выполнения конструкторов и деструкторов см. ниже).

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

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


Рис. 11.2.  Иерархия классов при множественном наследовании.

В данном случае класс C наследует двум классам, A и B.

Множественное наследование – мощное средство языка. Приведем некоторые примеры использования множественного наследования.

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

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

class Annotation

{

public:

    String GetText(void);

private:

    String annotation;

};

class Shape

{

public:

    virtual void Draw(void);

};

class AnnotatedSquare : public Shape,

                       public Annotation

{

public:

    virtual void Draw();

};

У объекта класса AnnotatedSquare имеется метод GetText, унаследованный от класса Annotation, он определяет виртуальный метод   Draw, унаследованный от класса Shape.

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

class A

{

public:

    void fun();

    int a;

};

class B

{

public:

    int fun();

    int a;

};

class C : public A, public B

{

};

При записи

C* cp = new C;

cp->fun();

невозможно определить, к какому из двух методов fun происходит обращение. Ситуация называется неоднозначной, и компилятор выдаст ошибку. Заметим, что ошибка выдается не при определении класса C, в котором заложена возможность возникновения неоднозначной ситуации, а лишь при попытке вызова метода fun.

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

cp->A::fun();

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

class Person

{

public:

    String name();

};

class Student : public Person

{

. . .

};

class Librarian : public Person

{

. . .

};

Если теперь создать класс для представления студентов, подрабатывающих в библиотеке

class StudentLibrarian : public Student,

                        public Librarian

{

};

то объект данного класса будет содержать объект базового класса   Person дважды (см. рисунок 11.3).


Рис. 11.3.  Структура объекта StudentLibrarian.

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

StudentLibrarian* sp;

// ошибка – неоднозначное обращение,

// непонятно, к какому именно экземпляру

// типа Person обращаться

sp->Person::name();

// правильное обращение

sp->Student::Person::name();

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

11.5.1 Виртуальное наследование

Базовый класс можно объявить виртуальным базовым классом, используя запись:

class Student : virtual Person

{

};

class Librarian : virtual Person

{

};

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


Рис. 11.4.  Структура объекта StudentLibrarian при виртуальном множественном наследовании.


Лекция 12 Контроль доступа к объекту

<В РАЗРАБОТКЕ>


Лекция 13 Классы – конструкторы и деструкторы

<В РАЗРАБОТКЕ>


Лекция 14 Дополнительные возможности классов

<В РАЗРАБОТКЕ>


Лекция 15 Компоновка программ, препроцессор

15.1 Компоновка нескольких файлов в одну программу 

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

Если мы рассмотрим этот процесс чуть более подробно, то выяснится, что обработка исходных файлов происходит в три этапа. Сначала файл обрабатывается препроцессором, который выполняет операторы #include, #define и еще несколько других. После этого программа все еще представлена в виде текстового файла, хотя и измененного по сравнению с первоначальным. Затем, на втором этапе, компилятор создает так называемый объектный файл. Программа уже переведена в машинные инструкции, однако еще не полностью готова к выполнению. В объектном файле имеются ссылки на различные системные функции и на стандартные функции языка Си++. Например, выполнение операции new заключается в вызове определенной системной функции. Даже если в программе явно не упомянута ни одна функция, необходим, по крайней мере, один вызов системной функции – завершение программы и освобождение всех принадлежащих ей ресурсов.

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

Системные функции и стандартные функции языка Си++ заранее откомпилированы и хранятся в виде библиотек. Библиотека – это некий архив объектных модулей, с которым удобно компоновать программу.

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

15.2 Проблема использования общих функций и имен 

В языке Си++ существует строгое правило, в соответствии с которым прежде чем использовать в программе имя или идентификатор, его необходимо определить. Рассмотрим для начала функции. Для того чтобы имя функции стало известно программе, его нужно либо объявить, либо определить.

Объявление функции состоит лишь из ее прототипа, т.е. имени, типа результата и списка аргументов. Объявление функции задает ее формат, но не определяет, как она выполняется. Примеры объявлений функций:

double sqrt(double x);// функция sqrt

long fact(long x); // функция fact  

 

// функция PrintBookAnnotation

void PrintBookAnnotation(const Book& book);  

  

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

// функция вычисления факториала

// целого положительного числа

long fact(long x)

{

    if (x == 1)

         return 1;

    else

         return x * fact(x - 1);

}

  

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

Удобно было бы поместить Определение функции в отдельный файл, а в других файлах в начале помещать лишь объявление, прототип функции.

// начало файла main.cpp

long fact(long); // прототип функции

int main()

{

    . . .

    int x10 = fact(10);   // вызов функции  

    . . .

}

// конец файла main.cpp

// начало файла fact.cpp

// определение функции

// вычисления факториала целого

// положительного числа

//

long fact(long x)

{

    if (x == 1)

         return 1;

    else

         return x * fact(x - 1);

}

// конец файла fact. cpp  

  

Компоновщик объединит оба файла в одну программу.

Аналогичная ситуация существует и для классов. Любой класс в языке Си++ состоит из двух частей: объявления и определения. В объявлении класса говорится, каков интерфейс класса, какие методы и атрибуты составляют объекты этого класса. Объявление класса состоит из ключевого слова class, за которым следует имя класса, список наследования и затем в фигурных скобках - методы и атрибуты класса. Заканчивается объявление класса точкой с запятой.

class Book : public Item

{

public:

    Book();

    ~Book();

    String Title();

    String Author();

private:

    String title;

    String author;

};

  

Определение класса – это определение всех его методов.

// определение метода Title

String

Book::Title()

{

    return title;

}

  

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

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

Программа работать будет, однако писать ее не очень удобно.

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

15.3 Использование включаемых файлов 

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

#include "Book.h"

. . .

Book b;

  

Фактически оператор   #include подставляет содержимое файла Book.h в текущий файл перед тем, как начать его компиляцию. Эта подстановка осуществляется во время первого прохода компилятора по программе – препроцессора. Файл Book.h называется файлом заголовков.

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

Таким образом, текст программы на языке Си++ помещается в файлы двух типов – файлы заголовков и файлы программ. В большинстве случаев имеет смысл каждый класс помещать в отдельный файл, вернее, два файла – файл заголовков для объявления класса и файл программ для определения класса. Имя файла обычно состоит из имени класса. Для файла заголовков к нему добавляется окончание ".h" (иногда, особенно в системе Unix, ".hh" или ".H"). Имя файла программы – опять-таки имя класса с окончанием ".cpp" (иногда ".cc" или ".C").

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

Включение файлов может быть вложенным, т.е. файл заголовков может сам использовать оператор #include. Файл Book.h выглядит следующим образом:

#ifndef __BOOK_H__

#define __BOOK_H__

// включить файл с объявлением используемого

// здесь базового класса

#include "Item .h"

#include "String.h"   

// объявление класса String

// объявление класса Book

class Book : public Item

{

public:

. . .

private:

         String title;

    . . .

}; #endif 

  

Обратите внимание на первые две и последнюю строки этого файла. Оператор #ifndef начинает блок так называемой условной компиляции, который заканчивается оператором #endif. Блок условной компиляции – это кусок текста, который будет компилироваться, только если выполнено определенное условие. В данном случае условие заключается в том, что символ __BOOK_H__ не определен. Если этот символ определен, текст между #ifndef и #endif не будет включен в программу. Первым оператором в блоке условной компиляции стоит оператор #define, который определяет символ __BOOK_H__ как пустую строку.

Давайте посмотрим, что произойдет, если в какой-либо .cpp-файл будет дважды включен файл Book.h:

#include "Book.h"

. . .

#include "Book.h"

  

Перед началом компиляции текст файла Book.h будет подставлен вместо оператора #include:

#ifndef __BOOK_H__

#define __BOOK_H__

. . .

class Book

{

. . .

};

#endif

. . .

#ifndef __BOOK_H__

#define __BOOK_H__

. . .

class Book

{

. . .

};

#endif 

  

В самом начале символ __BOOK_H__ не определен, и блок условной компиляции обрабатывается. В нем определяется символ __BOOK_H__ . Теперь условие для второго блока условной компиляции уже не выполняется, и он будет пропущен. Таким образом, объявление класса Book будет вставлено в файл только один раз. Разумеется, написание два раза подряд оператора #include с одинаковым аргументом легко поправить. Однако структура заголовков может быть очень сложной. Чтобы избежать необходимости отслеживать все вложенные заголовки и искать, почему какой-либо файл оказался вставленным дважды, можно применить изложенный выше прием и существенно упростить себе жизнь.

Еще одно замечание по составлению заголовков. Включайте в заголовок как можно меньше других заголовков. Например, в заголовок Book.h необходимо включить заголовки Item.h и String.h, поскольку класс Book использует их. Однако если используется лишь имя класса без упоминания его содержимого, можно обойтись и объявлением этого имени:

#include "Item.h"

#include "String.h"

class Annotation;   

// Annotation – имя некого класса

class Book : public Item

{

public:

    Annotation* CreateAnnotation();

private:

    String title;

};

  

Объявление класса Item требуется знать целиком, для того, чтобы обработать объявление класса Book, т.е. компилятору надо знать все методы и атрибуты Item, чтобы включить их в класс Book. Объявление класса String также необходимо знать целиком, по крайней мере, для того, чтобы правильно вычислить размер экземпляра класса Book. Что же касается класса Annotation, то ни размер его объектов, ни его методы не важны для определения содержимого объекта класса Book. Единственное, что надо знать, это то, что Annotation есть имя некоего класса, который будет определен в другом месте.

Общее правило таково, что если объявление класса использует указатель или ссылку на другой класс и не задействует никаких методов или атрибутов этого класса, достаточно объявления имени класса. Разумеется, полное объявление класса Annotation понадобится в определении метода CreateAnnotation.

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

#include <string.h>

15.4 Препроцессор 

В языке Си++ имеется несколько операторов, которые начинаются со знака #: #include, #define, #undef, #ifdef, #else, #if, #pragma. Все они обрабатываются так называемым препроцессором.

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

15.4.1 Определение макросов 

Форма директивы #define 

#define имя определение

     

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

     

#define NAME "database"

Connect(NAME);

     

после препроцессора будет заменен на

     

Connect("database");

     

По умолчанию имя определяется как пустая строка, т.е. после директивы

     

#define XYZ 

     

макроимя XYZ считается определенным со значением – пустой строкой.

Другая форма #define 

#define имя ( список_имен ) определение

     

определяет макрос – текстовую подстановку с аргументами

     

#define max(X, Y) ((X > Y) ? X : Y)

     

Текст max(5, a) будет заменен на

((5 > a) ? 5 : a)

     

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

Директива #undef отменяет определение имени, после нее имя перестает быть определенным.

У препроцессора есть несколько макроимен, которые он определяет сам, их называют предопределенными именами. У разных компиляторов набор этих имен различен, но два определены всегда: __FILE__ и __LINE__. Значением макроимени __FILE__ является имя текущего исходного файла, заключенное в кавычки. Значением __LINE__ – номер текущей строки в файле. Эти макроимена часто используют для печати отладочной информации.

Условная компиляция

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

#if LEVEL > 3

текст1

#elif LEVEL > 1

текст2

#else

текст3

#endif 

     

Предполагается, что LEVEL – это макроимя, поэтому выражение в директивах #if и #elif можно вычислить во время обработки исходного текста препроцессором.

Итак, если LEVEL больше 3, то компилироваться будет текст1, если LEVEL больше 1, то компилироваться будет текст2, в противном случае компилируется текст3. Блок условной компиляции должен завершаться директивой #endif.

В каком-то смысле директива #if похожа на условный оператор if. Однако, в отличие от него, условие – это константа, которая вычисляется на стадии препроцессора, и куски текста, не удовлетворяющие условию, просто игнорируются.

Директив #elif может быть несколько (либо вообще ни одной), директива #else также может быть опущена.

Директива   #ifdef – модификация условия компиляции. Условие считается выполненным, если указанное после нее макроимя определено. Соответственно, для директивы #ifndef условие выполнено, если имя не определено.

15.4.2 Дополнительные директивы препроцессора 

Директива   #pragma используется для выдачи дополнительных указаний компилятору. Например, не выдавать предупреждений при компиляции, или вставить дополнительную информацию для отладчика. Конкретные возможности директивы #pragma у разных компиляторов различные.

Директива #error выдает сообщение и завершает компиляцию. Например, конструкция

#ifndef unix

#error "Программу можно компилировать

                          только для Unix!"

#endif 

        

выдаст сообщение и не даст откомпилировать исходный файл, если макроимя unix не определено.

Директива #line изменяет номер строки и имя файла, которые хранятся в предопределенных макроименах __LINE__ и __FILE__.

Кроме директив, у препроцессора есть одна операция ##, которая соединяет строки, например A ## B.


Лекция 16 Определение, время жизни и области видимости переменных в больших программах

16.1 Файлы и переменные

Автоматические переменные определены внутри какой-либо функции или метода класса. Назначение автоматических переменных – хранение каких-либо данных во время выполнения функции или метода. По завершении выполнения этой функции автоматические переменные уничтожаются и данные теряются. С этой точки зрения автоматические переменные представляют собой временные переменные.

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

funct(int N, Book[]& bookArray)

{  

 int x;    // автоматическая переменная x  

 for (int i = 0; i < N; i++) {  

 // переменная i определена только на время

 // выполнения цикла for  

    String s;  

 // новая автоматическая переменная создается

 // при каждой итерации цикла заново  

    s.Append(bookArray[i].Title());

    s.Append(bookArray[i].Author());

    cout << s;

 }  

 cout << s;

}    // ошибка, переменная s не существует  

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

funct(int n, Book[]& bookArray)

{      

 for (int i = 0; i < n; i++) {

   static String allAuthors;

   allAuthors.Append(bookArray[i].Author());

   cout << allAuthors;

   // авторы всех ранее обработанных книг, в

   // том числе в предыдущих вызовах функции

 }

 cout << allAuthors;   

 // ошибка, переменная недоступна

}

16.1.1 Общие данные

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

Во-первых, определим класс RandomGenerator с двумя методами: Init, для инициализации генератора, и GetNumber — для получения следующего числа.

//

// файл RandomGenerator.h

//

class RandomGenerator

{

public:

 RandomGenerator();

 ~RandomGenerator();

 void Init(unsigned long start);

 unsigned long GetNumber();

private:

 unsigned long previousNumber;

};

//

// файл RandomGenerator.cpp

//

#include "RandomGenerator.h"

#include <time.h>

void

RandomGenerator::Init(unsigned long x)

{

 previousNumber = x;

}

unsigned long

RandomGenerator::GetNumber(void)

{

 unsigned long ltime;

 // получить текущее время в секундах,

 // прошедших с полуночи 1 января 1970 года

 time(&ltime);

 ltime <<= 16;

 ltime >>= 16;   

 // взять младшие 16 битов

 previousNumber = previousNumber * ltime;

 return previousNumber;

}

Первый вариант состоит в создании объекта класса RandomGenerator в функции  main и передаче ссылки на него во все функции и методы, где он потребуется.

// файл main.cpp

#include "RandomGenerator.h"

main()

{

    RandomGenerator rgen;

    rgen.Init(1000);

    fun1(rgen);

    . . .

    Class1 b(rgen);

    . . .

    fun2(rgen);

}

void

fun1(RandomGenerator& r)

{

    unsigned long x = r.GetNumber();

    . . .

}

// файл class.cpp

#include "RandomGenerator.h"

Class1::Class1(RandomGenerator& r)

{

    . . .

}

void

fun2(RandomGenerator& r)

{

    unsigned long x = r.GetNumber();

    . . .

}

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

16.1.2 Глобальные переменные

Язык Си++ предоставляет возможность определения глобальной переменной. Если переменная определена вне функции, она создается в самом начале выполнения программы (еще до начала выполнения main). Эта переменная доступна во всех функциях того файла, где она определена. Аналогично прототипу функции, имя глобальной переменной можно объявить в других файлах и тем самым предоставить возможность обращаться к ней и в других файлах:

// файл main.cpp

#include "RandomGenerator.h"

// определение глобальной переменной

RandomGenerator rgen;

main()

{

    rgen.Init(1000);

}

void

fun1(void)

{

    unsigned long x = rgen.GetNumber();

    . . .

}

// файл class.cpp

#include "RandomGenerator.h"

// объявление глобальной переменной,

// внешней по отношению к данному файлу

extern RandomGenerator rgen;

Class1::Class1()

{

    . . .

}

void

fun2()

{

    unsigned long x = rgen.GetNumber();

    . . .

}

Объявление внешней переменной можно поместить в файл-заголовок. Тогда не нужно будет повторять объявление переменной с описателем extern в каждом файле, который ее использует.

Модификацией определения глобальной переменной является добавление описателя static. Для глобальной переменной описатель static означает то, что эта переменная доступна только в одном файле – в том, в котором она определена. (Правда, в данном примере такая модификация недопустима – нам-то как раз нужно, чтобы к глобальной переменной rgen можно было обращаться из разных файлов.)

16.1.3 Повышение надежности обращения к общим данным

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

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

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

Изменим описание класса RandomGenerator:

class RandomGenerator

{

public:

    static void Init(unsigned long start);

    static unsigned long GetNumber(void);

private:

    static unsigned long previousNumber;

};

Определения методов Init и GetNumber не изменятся. Единственное, что надо будет добавить в файл RandomGenerator.cpp, это определение переменной previousNumber:

//

// файл RandomGenerator.cpp

//

#include "RandomGenerator.h"

#include <time.h>

unsigned long RandomGenerator::previousNumber;

. . .

Методы и атрибуты класса, описанные static, существуют независимо от объектов этого класса. Вызов статического метода имеет вид имя_класса::имя_метода, например RandomGenerator::Init(x). У статического метода не существует указателя this, таким образом, он имеет доступ либо к статическим атрибутам класса, либо к атрибутам передаваемых ему в качестве аргументов объектов. Например:

class A

{

public:

    static void Method(const A& a);

private:

    static int a1;

    int a2;

};

void

A::Method(const A& a)

{  

    int x = a1;

    int y = a2;

    int z = a.a2;

}    // обращение к статическому атрибуту

 // ошибка, a2 не определен

 // правильно  

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

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

// файл main.cpp

#include "RandomGenerator.h"

main()

{

 RandomGenerator::Init(1000);

}

void

fun1(void)

{

unsigned long x=RandomGenerator::GetNumber();

    . . .

}

// файл class.cpp

#include "RandomGenerator.h"

Class1::Class1()

{

    . . .

}

void

fun2()

{

unsigned long x=RandomGenerator::GetNumber();

    . . .

}

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

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

Кратко суммируем результаты этого параграфа:

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

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

К глобальным переменным можно обращаться из всей программы.

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

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

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

  1.  Статические методы класса используются для функций, по сути являющихся глобальными, но логически относящихся к какому-либо классу.

16.2 Область видимости имен

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

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

Имена, объявленные в классе, видимы внутри этого класса, т.е. во всех его методах. Для того чтобы обратиться к атрибуту класса, нужно использовать операции ".", "->" или "::".

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

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

int x = 7;

class A

{

public:

    void foo(int y);

    int x;

};

int main()

{

    A a;  

    a.foo(x);    

 // используется глобальная переменная x

 // и передается значение 7  

    cout << x;

    return 1;

}

void

A::foo(int y)

{  

    x = y + 1;

    {

         double x = 3.14;

         cout << x;

    }

    cout << x;

}    // x – атрибут объекта типа A

 // новая переменная x перекрывает

 // атрибут класса x

В результате выполнения приведенной программы будет напечатано 3.14, 8 и 7.

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

Если перед квалификатором поставить имя класса, то поиск имени будет производиться в указанном классе. Например, обозначение A::x показало бы, что речь идет об атрибуте класса A. Аналогично можно обращаться к атрибутам структур и объединений. Поскольку определения классов и структур могут быть вложенными, у имени может быть несколько квалификаторов:

class Example

{

public:

    enum Color { RED, WHITE, BLUE };

    struct Structure

    {

         static int Flag;

         int x;

    };

    int y;

    void Method();

};

Следующие обращения допустимы извне класса:

Example::BLUE

Example::Structure::Flag 

При реализации метода Method обращения к тем же именам могут быть проще:

void

Example::Method()

{

    Color x = BLUE;

    y = Structure::flag;

}

При попытке обратиться извне класса к атрибуту набора BLUE компилятор выдаст ошибку, поскольку имя BLUE определено только в контексте класса.

Отметим одну особенность типа enum. Его атрибуты как бы экспортируются во внешнюю область имен. Несмотря на наличие фигурных скобок, к атрибутам перечисленного типа Color не обязательно (хотя и не воспрещается) обращаться Color::BLUE.

16.3 Оператор определения контекста namespace

Несмотря на столь развитую систему областей видимости имен, иногда и ее недостаточно. В больших программах возможность возникновения конфликтов на глобальном уровне достаточно реальна. Имена всех классов верхнего уровня должны быть различны. Хорошо, если вся программа разрабатывается одним человеком. А если группой? Особенно при использовании готовых библиотек классов. Чтобы избежать конфликтов, обычно договариваются о системе имен классов. Договариваться о стиле имен всегда полезно, однако проблема остается, особенно в случае разработки классов, которыми будут пользоваться другие.

Одно из сравнительно поздних добавлений к языку Си++ – контексты, определяемые с помощью оператора namespace. Они позволяют заключить группу объявлений классов, переменных и функций в отдельный контекст со своим именем. Предположим, мы разработали набор классов для вычисления различных математических функций. Все эти классы, константы и функции можно заключить в контекст   math для того, чтобы, разрабатывая программу, использующую наши классы, другой программист не должен был бы выбирать имена, обязательно отличные от тех, что мы использовали.

namespace math

{

    double const pi = 3.1415;

    double sqrt(double x);

    class Complex

    {

    public:

         . . .

    };

};

Теперь к константе pi следует обращаться math::pi.

Контекст может содержать как объявления, так и определения переменных, функций и классов. Если функция или метод определяется вне контекста, ее имя должно быть полностью квалифицировано

double math::sqrt(double x)

{

    . . .

}

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

namespace first

{

    int i;

    namespace second    // первый контекст

 // второй контекст