Поможем написать учебную работу
Если у вас возникли сложности с курсовой, контрольной, дипломной, рефератом, отчетом по практике, научно-исследовательской и любой другой работой - мы готовы помочь.
Если у вас возникли сложности с курсовой, контрольной, дипломной, рефератом, отчетом по практике, научно-исследовательской и любой другой работой - мы готовы помочь.
Введение в языки программирования C, C++.
Кетков Ю.Л.
[1] Введение [2] Раздел 1. Немного истории [3] Раздел 2. Структура программы на языке C [4] Раздел 3. Среда программирования [4.1] 3.1. Интегрированная среда Borland C++, ver 3.1 [4.2] 3.2. Среда визуального программирования Borland C++ Builder [5] Раздел 4. Системные данные числового типа [5.1] 4.1. Типы числовых данных и их представление в памяти ЭВМ [5.1.1] 4.1.1. Внутреннее представление целочисленных данных [5.1.2] 4.1.2. Однобайтовые целочисленные данные [5.1.3] 4.1.3. Двухбайтовые целочисленные данные [5.1.4] 4.1.4. Четырехбайтовые целочисленные данные [5.1.5] 4.1.5. Восьмибайтовые целочисленные данные [5.2] 4.2. Внутреннее представление данных вещественного типа [5.3] 4.3. Внешнее представление числовых констант [5.4] 4.4. Объявление и инициализация числовых переменных [5.5] 4.5. Ввод числовых данных по запросу программы [5.5.1] 4.5.1. Потоковый ввод данных числового типа [5.5.2] 4.5.2. Форматный ввод [5.6] 4.6. Вывод числовых результатов [5.6.1] 4.6.1. Форматный вывод [5.6.2] 4.6.2. Потоковый вывод [5.7] 4.7. Примеры программ вывода числовых данных [5.8] 4.8. Операции над числовыми данными целого типа [5.9] 4.9. Операции над числовыми данными вещественного типа [6] Раздел 5. Системные данные текстового типа [6.1] 5.1. Символьные данные и их представление в памяти ЭВМ [6.2] 5.2. Строковые данные и их представление в памяти ЭВМ [6.3] 5.3. Ввод текстовых данных во время работы программы [6.3.1] 5.3.1. Форматный ввод [6.3.2] 5.3.3. Потоковый ввод [6.3.3] 5.3.4. Специальные функции ввода текстовых данных [6.4] 5.4. Вывод текстовых данных [6.4.1] 5.4.1. Форматный вывод [6.4.2] 5.4.2. Потоковый вывод [6.4.3] 5.4.3. Специальные функции вывода текстовой информации [6.5] 5.5. Операции над текстовыми данными [6.5.1] 5.5.1. Операции над символьными данными [6.5.2] 5.5.2. Операции над строковыми данными [6.6] 5.6. Управление дисплеем в текстовом режиме [7] Раздел 6. Основные синтаксические конструкции языка C [7.1] 6.1. Заголовок функции и прототип функции [7.1.1] 6.2. Объявление локальных и внешних данных [7.1.2] 6.3. Оператор присваивания [7.1.3] 6.4. Специальные формы оператора присваивания [7.1.4] 6.5. Условный оператор [7.1.5] 6.6. Оператор безусловного перехода [7.1.6] 6.7. Операторы цикла [7.1.7] 6.8. Дополнительные операторы управления циклом [7.1.8] 6.9. Оператор выбора (переключатель) [7.1.9] 6.10. Обращения к функциям [7.1.10] 6.11. Комментарии в программах [8] Раздел 7. Указатели и ссылки [8.1] 7.1. Объявление указателей [8.2] 7.2. Операции над указателями [8.3] 7.3. Ссылки [9] Раздел 8. Функции и их аргументы [9.1] 8.1. Параметры-значения [9.2] 8.2. Параметры-указатели [9.3] 8.3. Параметры-ссылки [9.4] 8.4. Параметры-константы [9.5] 8.5. Параметры по умолчанию [9.6] 8.6. Функции с переменным количеством аргументов [9.7] 8.7. Локальные, глобальные и статические переменные [9.8] 8.8. Возврат значения функции [9.9] 8.9. Рекурсивные функции [9.9.1] 8.10. Указатели на функцию и передача их в качестве параметров [9.10] 8.11. "Левые" функции [10] Раздел 9. Работа с массивами. [10.1] 9.1. Объявление и инициализация массивов. [10.2] 9.2. Некоторые приемы обработки числовых массивов [10.3] 9.2. Программирование задач линейной алгебры [10.3.1] 9.2.1. Работа с векторами [10.3.2] 9.2.2.Работа с матрицами [10.4] 9.3. Поиск [10.4.1] 9.3.1. Последовательный поиск [10.4.2] 9.3.2. Двоичный поиск [10.5] 9.4. Сортировка массивов. [10.5.1] 9.4.1. Сортировка методом пузырька [10.5.2] 9.4.2. Сортировка методом отбора [10.5.3] 9.4.3. Сортировка методом вставки [10.5.4] 9.4.4. Сортировка методом Шелла [10.5.5] 9.4.5.Быстрая сортировка [10.6] 9.5. Слияние отсортированных массивов [10.7] 9.6. Динамические массивы. [11] Раздел 10. Пользовательские типы данных. [11.1] 10.1. Структуры [11.1.1] 10.1.1. Объявление и инициализация структур [11.1.2] 10.1.2. Структуры параметры функций [11.1.3] 10.1.3.Функции, возвращающие структуры [11.1.4] 10.1.4. Структуры в языке C++ [11.2] 10.2. Перечисления [11.3] 10.3. Объединения [12] Раздел 11. Работа с файлами [12.1] 11.1.Файлы в операционной системе [12.1.1] 11.1. Текстовые (строковые) файлы [12.1.2] 11.2. Двоичные файлы [12.1.3] 11.3. Структурированные файлы [12.2] 11.4. Форматные преобразования в оперативной памяти [12.3] 11.5. Файловые процедуры в системе BCB [12.3.1] 11.5.1. Проверка существования файла [12.3.2] 11.5.2. Создание нового файла [12.3.3] 11.5.3. Открытие существующего файла [12.3.4] 11.5.4. Чтение из открытого файла [12.3.5] 11.5.5. Запись в открытый файл [12.3.6] 11.5.6. Перемещение указателя файла [12.3.7] 11.5.7. Закрытие файла [12.3.8] 11.5.8. Расчленение полной спецификации файла [12.3.9] 11.5.9. Удаление файлов и пустых каталогов [12.3.10] 11.5.10. Создание каталога [12.3.11] 11.5.11. Переименование файла [12.3.12] 11.5.12. Изменение расширения [12.3.13] 11.5.13. Опрос атрибутов файла [12.3.14] 11.5.14. Установка атрибутов файла [12.3.15] 11.5.15. Опрос и изменение текущего каталога [12.4] 11.6. Поиск файлов в каталогах [13] Раздел 12. Библиотеки стандартных и нестандартных функций [13.1] 12.1. Библиотеки системных функций в Borland C++ 3.1 [13.2] 12.2. Организация пользовательских библиотек [13.3] 12.3. Динамически загружаемые библиотеки [14] Раздел 13. Дополнительные сведения о системе программирования Borland C++ 3.1 [14.1] 13.1. Препроцессор и условная компиляция [14.2] 13.2. Компилятор bcc.exe [14.3] 13.3. Утилита grep.com поиска в текстовых файлах [15] Раздел 14. Функции. Новые возможности в C++ [15.1] 14.1. Переопределение (перегрузка) функций [15.2] 14.2. Шаблоны функций [16] Раздел 15. Классы. Создание новых типов данных [16.1] 15.1. Школьные дроби на базе структур [16.2] 15.2. Школьные дроби на базе классов [16.3] 15.3. Класс на базе объединения [16.4] 15.4. Новые типы данных на базе перечисления [16.5] 15.5. Встраиваемые функции [16.6] 15.6. Переопределение операций (резюме) [16.7] 15.8. Конструкторы и деструкторы (резюме) [17] Раздел 16. Классы как средство создания больших программных комплексов [17.1] 16.1. Базовый и производный классы [17.1.1] 16.1.1.Простое наследование [17.1.2] 16.1.2. Вызов конструкторов и деструкторов при наследовании [17.1.3] 16.1.3. Динамическое создание и удаление объектов [17.1.4] 16.1.4. Виртуальные функции [17.1.5] 16.1.5. Виртуальные деструкторы [17.1.6] 16.1.6. Чистые виртуальные функции и абстрактные классы [17.2] 16.2. Множественное наследование и виртуальные классы [17.3] 16.3. Объектно-ориентированный подход к созданию графической системы [18] Раздел 17. Прерывания, события, обработка исключений [18.1] 17.1. Аппаратные и программные прерывания [18.2] 17.2. Исключения [19] Литература по С/С++ |
Предлагаемый курс написан по материалам лекций, читавшихся автором на протяжении ряда лет студентам первых курсов факультета вычислительной математики и кибернетики Нижегородского государственного университета им. Н.И. Лобачевского. Как правило, лекции по языкам программирования C, C++ читаются во 2-м семестре после полугодичного курса "Основы программирования", построенного на базе алгоритмического языка Паскаль в средах Turbo Pascal 7.0, Delphi 6. Во втором семестре для обучения используются системы программирования Borland C++ (ver 3.1) и Borland C++ Builder (ver 5.0). Близость интегрированных сред и идейное совпадение основных синтаксических конструкций позволяют избежать ненужных повторений при изучении особенностей второго языка программирования.
Автор выражает признательность сотруднику НИИ прикладной математики и кибернетики ННГУ А.И. Кузнецову, который внимательно прочитал содержимое всех разделов и тщательно проверил все примеры программ. Его советы и рекомендации были учтены при оформлении окончательного варианта предлагаемого пособия.
Язык программирования C++ является наиболее распространенным инструментом разработки программных средств как системного, так и прикладного характера. Историю его появления связывают с сотрудником американской фирмы Bell Labs Денизом Ритчи, хотя его детищу языку C предшествовали разработки и других системных программистов (М. Ричардс система BCPL, К. Томпсон язык B). Толчком к появлению различных программных средств, облегчавших жизнь системных программистов, явились работы по созданию операционной системы Unix для компьютера PDP-7, начатые в 1969 году. Дело в том, что тогда единственной операционной системой большого компьютера GE-645, обслуживавшей сотрудников лаборатории, была довольно громоздкая многопользовательская система Multics. К. Томпсон (кстати, один из разработчиков Multics) в свое время написал программу, моделирующую движение небесных тел. Каждый ее запуск на GE-645 обходился в 75$, а траектории движения выдавались в табличном виде.
И тогда небольшая группа сотрудников, возглавляемая К. Томпсоном, решила создать более удобную однопользовательскую систему на маленьком заброшенном компьютере PDP-7 с дисплеем. В состав этой группы входил и Д. Ритчи. Система Unix стала очень популярной среди сотрудников лаборатории, т.к. она существенно упрощала процесс прохождения задач и не требовала от пользователей знания многочисленных директив системы Multics. В 1970 году Д. Ритчи помог перенести Unix на более мощный компьютер PDP-11. В процессе этой работы пригодился набор макрокоманд на языке ассемблера, который упрощал программирование многочисленных процедур. Этот набор и был положен в основу языка C, который удачно сочетал специфику машинных команд с элементами языка высокого уровня. В 1973 году Д. Ритчи и К. Томпсон переписали ядро операционной системы Unix на язык C (до этого все программы были написаны на ассемблере).
С 1974 года система Unix вместе с исходными текстами на языке C и компилятор этого языка были переданы ряду университетов. Наиболее важную роль в последующем развитии системы Unix, превратившейся из однопользовательской в многопользовательскую, сыграли сотрудники университета Беркли. Популярность системы Unix, устоявшей до наших дней и обслуживающей сегодня более 90% серверов, в значительной мере содействовала и популярности языка C, компилятор которого поставлялся в составе Unix.
Следующий вклад в развитие мощности и универсальности языка C в 1983 году внес сотрудник все той же Bell Labs Бьёрн Страуструп. Предложенные им расширения привели к появлению версии C++ (первоначальное название C с классами). Эти новшества позволили пользователям конструировать собственные типы данных, включать в язык новые операции над такими данными, агрегировать данные с обрабатывающими их функциями-методами, наследовать и переопределять методы в порождаемых классах.
Следует отметить и существенный вклад в развитие систем программирования на базе языков C, C++, внесенный фирмой Borland, точнее, ее основателем Филиппом Канном. Речь идет о создании интегрированных систем разработки, в которых удачно соединились средства подготовки, хранения, отладки и компоновки программ. Впервые такая среда появилась в системе Turbo Pascal, а после ее успешного продвижения аналогичная среда была реализована в системе Turbo C. Все более поздние системы программирования в той или иной мере позаимствовали основные идеи Ф. Канна.
В настоящее время достаточно интенсивно эксплуатируются несколько систем программирования на базе языка C++. В среде профессиональных разработок наибольшей популярностью пользуются различные версии Visual C++ фирмы Microsoft. Они позволяют создавать довольно качественные по объему и производительности приложения. В вузовских организациях предпочтение отдают продукции фирмы Borland Borland C++ (версии 3.1, 4, 5), визуальным средам Borland C++ Builder. Эти системы более просты в освоении, хотя качество производимой ими продукции оставляет желать лучшего. Среди профессионалов высокие оценки можно услышать в адрес компиляторов фирмы Intel. Несколько лет тому назад довольно много пользователей работало с различными версиями фирмы Symantec.
Продемонстрируем пример простейшей программы на языке C, которая запрашивает у пользователя два целочисленных значения переменных a и b, анализирует их и выводит наибольшее число.
01 #include <iostream.h>
02 #include <conio.h>
03 int main(void)
04 {
05 int a,b,max;
06 cout << "a="; //приглашение ввести значение a
07 cin >> a; //ввод значения переменной а
08 cout << "b="; //приглашение ввести значение b
09 cin >> b; //ввод значения переменной b
10 if(a>b) max=a; //если a>b то max=a
11 else max=b; //иначе max=b
12 cout << "max="<<max; //вывод максимального значения
13 getch(); //останов до нажатия клавиши
14 return 0; //выход из функции
15 }
Номера, которые проставлены в начале каждой строки программы, не являются принадлежностью программы. Они введены только для упрощения ссылок на описание действия тех или иных строк. Строки 01 и 02 подключают (include включить) к тексту программы так называемые заголовочные (h от header заголовок) файлы системы. В этих файлах описаны системные функции и их аргументы. Используя эти описания, компилятор проверяет правильность вызова системных функций. В нашем случае программа использует системные функции ввода (cin >>) и вывода (cout <<), описания которых находятся в заголовочном файле iostream.h, а также функцию ожидания нажатия какой-либо клавиши (getch), описание которой находится в заголовочном файле conio.h. Названия заголовочных файлов зачастую образовываются от каких-либо аббревиатур английских слов, их полезно научиться понимать, а не запоминать. В нашем примере: io input/output (ввод/вывод), stream (поток), con (console пульт оператора, т.е. клавиатура и дисплей).
Строка 03 содержит заголовок функции main. Функция с таким названием обязана присутствовать в каждой программе на языке C, C++. Именно с нее начинается выполнение программы, она главная (именно так переводится служебное слово main). Предшествующее ей служебное слово int (от integer целый) сообщает, что результатом работы функции main должно быть целое число. По возвращаемому функцией значению операционная система, запустившая программу main, может "сообразить", правильно или неправильно завершилась работа программы. По общепринятому соглашению нулевое значение, возвращаемое функцией main, свидетельствует о нормальном завершении работы программы. Служебное слово void (дословно пустота), указанное в круглых скобках, сообщает, что у функции main аргументы отсутствуют.
Текст программы (тело функции) заключается в фигурные скобки (строки 04 и 15). В большинстве последующих строк присутствует пояснительный текст на русском языке, следующий после пары символов // это комментарий, на содержание которого система не обращает внимания.
В строке 05 объявлены три переменные с именами a,b и max, которые могут принимать только целочисленные значения (тип int).
Строка 06 является первой строкой программы, которая производит некоторое действие она выводит на дисплей сообщение, состоящее из двух символов (a=). Текст сообщения заключен в двойные кавычки. Строка 07 организует приостанов работы программы до тех пор, пока пользователь не наберет на клавиатуре какое-либо число и нажмет клавишу Enter. Поступившее значение будет хорошо воспринято, если оно целое, и направлено в переменную a. Точно таким же образом в строках 08 и 09 будет организован ввод значения числовой переменной b.
В строке 10 сравниваются (if если) текущие значения переменных a и b. Если проверяемое условие выполнено, т.е. значение переменной a больше, то оно присваивается переменной max выполняется действие, записанное после проверки условия. В противном случае (else иначе) в переменную max заносится значение b.
Строка 12 выводит на дисплей два сообщения текстовое (max=) и числовое (значение переменной max).
Обращение к функции getch (строка 13) приводит к задержке на экране сообщения программы до тех пор, пока пользователь не нажмет какую-либо клавишу (getch от get character, дай символ).
Последняя выполняемая строка с номером 14 возвращает управление операционной системе (return вернуться) и выдает в качестве значения функции нулевой результат.
Обратите внимание на следующие детали. Если программа обращается к каким-либо системным функциям, то в первых ее строках обязательно должно стоять указание о подключении соответствующих заголовочных файлов. Программа может содержать более чем одну функцию, но среди них обязательно должна присутствовать функция с именем main. Каждая строка программы, содержащая какое-либо объявление или выполняемое действие, оканчивается точкой с запятой. Тело функции обязательно заключается в фигурные скобки (в Паскале аналогичные функции выполняли операторные скобки begin и end).
Одним из важнейших навыков программирования является умение ориентироваться в среде разработки программ. Конечно, рассказывать на лекции о назначении каждой команды главного и всплывающих меню, об использовании многочисленных кнопок и вспомогательных окон довольно бессмысленно. Запомнить все это со слов невозможно, такого рода умение приобретается только на практике. Но есть несколько главных моментов, без четкого понимания которых трудно начать осваивать любую систему программирования.
Мы уже упоминали о существенном вкладе основателя фирмы Borland Ф. Канна в создание интегрированной среды разработки (IDE Integrated Development Environment), появившейся в системе Turbo Pascal. Очень многие его идеи в той или иной мере повторяются во многих системах программирования, созданных и другими разработчиками программных продуктов.
Предшествовавшая технология программирования базировалась на так называемом пакетном режиме загрузки ЭВМ, в котором главное внимание уделялось максимальному использованию времени работы процессора. При этом задание на очередной выход на ЭВМ готовилось заблаговременно и вместе с обрабатываемой программой составляло пакет действий операционной системы по прохождению задачи. Пакет выполнялся в автоматическом режиме до обнаружения первой ошибочной ситуации, после чего задача исключалась из обработки, и спустя некоторое время пользователь получал свои материалы с соответствующим комментарием. Размышлять над создавшейся ситуацией программист должен был не за пультом компьютера, а за своим рабочим столом. При таком подходе удавалось в течение рабочего дня один или два раза попасть в очередь на ЭВМ, и, соответственно, обнаружить или исправить одну-две ошибки. Поэтому процесс создания программы и доводки ее до работоспособного состояния затягивался на несколько месяцев.
Диалоговый режим работы, ставший основным на персональных компьютерах, позволил за один сеанс обнаружить и исправить несколько ошибок. Однако на первых ПК для создания полноценных программных продуктов приходилось работать с несколькими автономными системными программами. Сначала запускался текстовый редактор для набора текста исходной программы на соответствующем алгоритмическом языке. Потом неоднократно запускался компилятор для устранения многочисленных синтаксических ошибок. В конечном итоге компилятор переводил исходный модуль в некоторую заготовку на машинном языке (объектный модуль). Непосредственно выполняться объектный модуль не мог, т.к. для его работы приходилось вызывать другие системные и/или прикладные модули, устанавливать связи между ними по общим переменным, объединять модули в единую программу, настраивать ее по месту расположения в оперативной памяти. Этим занимались две автономные утилиты редактор связей (жаргонное название линковщик, от англ. link) и загрузчик (жаргонное название лоадер, от англ. load). После формирования так называемого загрузочного модуля наступал этап отладки, связанный с поиском ошибок алгоритмического характера. Для этой цели использовалась специальная программа отладчик (ее название ассоциируется с процессом поимки вредных насекомых от англ. debug). К перечисленному набору системных программ добавлялся еще и библиотекарь утилита, обслуживающая системные и пользовательские библиотеки объектных модулей. Заслуга интегрированной среды заключалась в объединении всех этих компонент в единую систему, которая автоматически переходила к следующему этапу обработки после устранения ошибок, обнаруженных очередной утилитой. Кроме того, перед глазами пользователя всегда находился текст исходной программы, которую в любой момент времени можно было поправить и дополнить. В случае удачи (из программы исключены все синтаксические и семантические ошибки) запуск программы из интегрированной среды сводился к выполнению единственной команды Run (от англ. Пуск).
Система программирования Borland C++ (ver. 3.1) была разработана фирмой Borland в 1992 году для создания программ под управлением MS-DOS. Поэтому ресурсы, предоставляемые разрабатываемому приложению, подчиняются тем ограничениям, которые действовали в среде MS-DOS. Главные из них объем оперативной памяти не более 640 Кбайт (за минусом того, что занимают компоненты операционной системы), объем каждого массива не более 64 Кбайт (на самом деле, еще немного меньше), диапазон данных типа int от -32768 до 32767. Эти ограничения наиболее характерны для так называемых 16-битных приложений. Во время запуска таким приложениям предоставляется полный экран дисплея, работающего в текстовом режиме.
Система BC 3.1 может быть запущена и из под Windows, но указанные ограничения для создаваемого приложения остаются в силе. После старта BC 3.1 в среде Windows на экране появляется окно интегрированной среды, представленное на рис. 3.1.
Рис. 3.1. Окно интегрированной среды Borland C 3.1
В верхней строке располагаются названия пунктов главного меню, краткое назначение которых таково:
File общение с файловой подсистемой;
Edit ввод и редактирование исходной программы;
Search поиск в тексте исходной программы;
Run запуск программы в автоматическом или пошаговом режиме;
Compile компиляция исходной программы;
Debug отладка программы;
Project управление проектом сборки программы из нескольких модулей;
Options настройка параметров интегрированной среды;
Window управление дополнительными окнами системы программирования;
Help обращение к файлам помощи.
Остановимся более подробно на командах некоторых подменю, которые чаще всего используются на стадии начального знакомства с этой системой программирования.
Команды меню File (рис. 3.2) используются при наборе новой программы (команда New), при запоминании в файле на диске набранной или измененной программы (команды Save и Save as), при вызове ранее сохраненной программы (команда Open). Последняя команда этого меню (Quit) исполняется при выходе из интегрированной среды.
Рис. 3.2. Команды меню File
Вход в меню File происходит либо после щелчка мышью по заголовку File, либо после набора клавишной комбинации Alt+F. Выполнение наиболее употребительных команд также продублировано нажатием одной функциональной клавиши или соответствующей клавишной комбинации. Эквивалентные клавишные команды указаны справа.
Переход к той или иной команде выбранного меню сопровождается появлением в нижней строке подсказки. На рис. 3.2 выделена команда New, а в строке подсказки находится сообщение "Создание нового файла в новом окне редактирования".
До тех пор, пока при сохранении набранной программы соответствующему дисковому файлу не присвоено индивидуальное имя, вновь набираемая программа выступает под именем NONAMEnn.CPP, т.е. программа "безымянная" (здесь nn порядковый номер безымянной программы, созданной в течении одного сеанса).
Процесс набора текста исходной программы ничем не отличается от технологии работы в любом текстовом редакторе. Разве что клавишные команды, связанные с выделением фрагментов текста, их копированием, удалением или вставкой, специфичны для каждой системы программирования. К ним довольно быстро привыкают при продолжительной работе в той или иной среде. На первых порах вы будете заглядывать в команды меню Edit.
Одной из наиболее часто используемых групп команд являются строки меню Run (рис. 3.3). По команде Run производится попытка запуска на выполнение программы, находящейся в текущем окне редактирования. При этом новая программа сначала компилируется, потом редактируются ее связи с другими модулями, затем полученный исполняемый модуль загружается в оперативную память и начинает выполняться. Если текущая программа ранее компилировалась и на диске существует соответствующий исполняемый модуль, то повторное выполнение начальных этапов не происходит. Любые ошибки, обнаруженные на стадии компиляции, выдаются в окне сообщений с указанием строки исходной программы, при обработке которой возникла исключительная ситуация. Аналогичные ошибки, обнаруженные редактором связей, выводятся в окно сообщений и процесс исполнения прерывается. Настоятельно рекомендуется запускать программу только после сохранения ее исходного текста на диске. Дело в том, что при некоторых зависаниях системы или работающей программы продолжение сеанса возможно только после перезагрузки компьютера. А в этом случае содержимое поля редактирования будет потеряно и текст программы придется набирать заново.
Во время выполнения программы ее работа может быть прервана системой, обнаружившей исключительную ситуацию (деление на нуль, переполнение, и т.д.), или пользователем. В ряде случаев для продолжения работы необходимо восстановить первоначальное состояние программы. Для этой цели используется команда Program reset.
Рис. 3.3. Команду меню Run
Командой Go to cursor пользуются для автоматического запуска программы с остановом в той строке исходного текста, где находится курсор. Обычно такой режим используется при отладке программы. Также для отладочных целей используется пошаговое выполнение программы. В этом режиме очередное нажатие функциональной клавиши F7 или F8 приводит к останову после выполнения очередной строки исходного текста. Разница между этими двумя режимами заключается в исполнении строк, содержащих вызов пользовательской функции. Нажатие F7 приводит к тому, что пошаговое исполнение сохраняется и в вызываемой функции. В отличие от этого нажатие F8 приводит к автоматическому выполнению вызываемой функции и останову после возврата из нее.
Очень важно уметь пользоваться справочной системой интегрированной среды командами меню Help (рис. 3.4).
Рис. 3.4. Команды меню Help
Справочная система предназначена для работы в двух режимах. Первый из них, называемый контекстной помощью, срабатывает следующим образом. Курсор мыши подводится к интересующему вас служебному слову или наименованию системной функции и нажимается клавиша F1. Второй способ связан с использованием команд меню Help Contents (Содержание) и Index (Указатель терминов, упорядоченных по алфавиту).
По команде Contents на экране (рис.3.5) появляются названия справочных разделов:
Рис. 3.5. Разделы файла помощи системы BC 3.1
По команде Index на экране появляется упорядоченный по алфавиту список (рис. 3.6), в котором можно выбрать интересующий вас термин и перейти к соответствующему кадру помощи.
Рис. 3.6. Фрагмент списка терминов
Система Borland C++ Builder (ver. 5.0) появилась в 2000 году. После ее запуска на экране появляется многооконное приложение Windows, фрагмент которого приведен на рис. 3.7. Его главное меню содержит уже знакомые наименования File, Edit, Search, Project, Help. Основной набор команд соответствующих разделов главного меню системы BC 3.1 здесь повторяется, но новый набор существенно шире. Кроме того, в эти меню включены команды, ранее приписанные разделам Compile, Debug, Options. Детальное знакомство со всем этим хозяйством состоится в более позднем курсе "Визуальное программирование". В рамках настоящего курса мы познакомимся с основными расширениями языка системы программирования BCB C++ и возможностью создания так называемых консольных приложений Windows.
Консольное приложение Windows внешне очень напоминает приложение в стиле MS-DOS. Монитор работает в режиме, похожем на текстовый режим DOS-приложений, окно консольного приложения может быть распахнуто на весь экран нажатием клмбинации Alt+Enter. Однако сняты все прежние ограничения на ресурсы по оперативной памяти задача может использовать максимальный объем, предоставляемый операционной системой Windows, массивы могут иметь достаточно большие размеры, обусловленные 32-разрядной адресацией памяти. Кроме того, для данных типа int теперь выделяется по 4 байта, что расширяет диапазон представления таких значений по модулю до 231-1.
Рис. 3.7. Среда программирования Borland C++ Builder (ver. 5.0)
В отличие от консольных приложений стандартные приложения Windows могут быть организованы как однооконные или многооконные приложения, использующие типовой интерфейс в виде различных кнопок, обычных и всплывающих меню, различного рода диалоговых окон, списков, линеек прокрутки и других компонент, упрощающих реализацию типовых процедур, устоявшихся в системах программирования.
Для создания консольного приложения в среде BCB необходимо выполнить команду New в меню File и в появившемся диалоговом окне (рис. 3.8) выбрать помощника Console Wizard и нажать кнопку OK.
Рис. 3.8. Выбор режима создания консольного приложения
Затем появится еще одно диалоговое окно (рис. 3.9) для выбора языка программирования (C или C++) и формирования имени текущего проекта. После нажатия кнопки OK появится еще одно окно для набора исходной программы (рис. 3.10). Если имя проекта мы не меняли, то головной программе присваивается стандартное имя Unit1.cpp (или Unit2.cpp, Unit3.cpp, …), а соответствующий проект именуется как Project1.bpr (Project2.bpr, Project3.bpr, …).
Рис. 3.9. Окно помощника Console Wizard
В окне редактирования консольного приложения находится стандартная заготовка (см. рис. 3.10), которая подключает системный заголовочный файл vcl.h, содержит указание hdrstop, предотвращающее повторную обработку заголовочных файлов (это ускоряет процесс повторной компиляции), и стандартную оболочку головной функции main. В этой оболочке предусмотрена возможность использования параметров командной строки количества параметров (argc) и массива со списком имен параметров (argv[]). Так как тип функции объявлен как int, то в заготовке предусмотрен оператор возврата с признаком нормального завершения приложения (return 0).
Рис 3.10. Окно редактирования консольного приложения
Дополнительные заголовочные файлы, подключаемые пользователем, необходимо набирать после строки #include <vcl.h>.
После набора текста консольного приложения его проект и исходный модуль необходимо сохранить. Это рекомендуется делать в отдельном каталоге, выполнив команду File Save Project As. И только теперь можно осуществить запуск по команде Run Run.
Основным назначением любой прикладной программы является преобразование исходных данных в соответствии с заданным алгоритмом. А большая часть исходных данных обычно представлена числовой информацией. В этом разделе мы познакомимся с формой записи числовых данных в программах на языках C, C++ и набором операций, которые можно использовать для различных типов числовых данных. Термин системные, использованный в заголовке раздела, означает, что данные этого типа являются "родными" для системы программирования. Их не надо описывать, не надо определять, как выполняются арифметические или какие-либо другие операции над данными этих типов. Иногда такие данные называют базовыми. Как правило, системные типы данных в алгоритмических языках повторяют те числовые форматы, которые предусмотрены системой машинных команд ПК.
Числовые данные условно можно разбить на три категории положительные целочисленные данные (их значения в компьютере представлены целыми двоичными числами без знака), произвольные целочисленные данные (один из двоичных разрядов их представления играет роль знакового разряда) и числовые данные вещественного типа.
Для хранения целочисленных данных со знаком в IBM PC используется дополнительный двоичный код. Эта особенность распространяется только на отрицательные числа. Для получения дополнительного кода отрицательного числа нужно перевернуть все двоичные разряды соответствующего положительного числа и прибавить единицу в младший разряд. Например:
+5
0 |
0 |
0 |
0 |
0 |
1 |
0 |
1 |
-5
1 |
1 |
1 |
1 |
1 |
0 |
1 |
1 |
Дополнительный код позволяет примерно на 25% ускорить выполнение таких операций как сложение и вычитание.
Самые короткие целочисленные данные со знаком представлены в памяти IBM-совместимых ПК одним байтом, в котором может разместиться любое число из диапазона от -128 до 127, записанное в дополнительном коде. В языках C, C++ для описания переменных такого типа используется спецификатор char. В одном же байте может быть расположено и самое короткое целое число без знака. По терминологии C таким числам соответствует спецификатор unsigned char. Диапазон допустимых данных при этом смещается вправо и равен [0, 255].
Вторая категория целочисленных данных в системах программирования, эксплуатируемых на IBM PC, представлена двухбайтовыми целыми числами. В варианте со знаком они поддерживают диапазон от -32768 до +32767, в варианте без знака от 0 до 65535.
Язык системы BC 3.1 использует для описания двухбайтовых целочисленных данных спецификаторы int (короткое целое со знаком) и unsigned int (короткое целое без знака). При их использовании арифметические операции над короткими операндами выполняются корректно при условии, что результат не выходит за пределы разрешенного диапазона. Однако если к максимальному целому числу прибавить 1, то вместо положительного числа +32768 в компьютере окажется отрицательное число -32768. И никакого предупреждения о нарушении допустимого интервала система не выдаст. Считается, что программист сам должен позаботиться о соответствующих проверках. В версии Borland C++ Builder для объявления двухбайтовых целочисленных данных используются спецификаторы short и unsigned short.
Третья категория целых чисел в IBM PC представлена четырехбайтовыми данными. В варианте со знаком они перекрывают диапазон от -2147483648 до +2147483647, в варианте без знака от 0 до 4294967295.
Для описания четырехбайтовых данных целого типа в языках C, C++ используются спецификаторы long (эквивалент long int) и unsigned long. В среде визуального программирования C++ Builder спецификаторы int и long эквивалентны.
Несмотря на то, что микропроцессоры IBM PC уже давно поддерживают восьмибайтовый целочисленный формат, обеспечивающий диапазон от -263 до 263-1, системы программирования довольно долго обходили этот формат или использовали его особым образом. Так, например, системы Turbo Pascal на базе этого формата предложили тип данных comp, который был причислен к разряду данных вещественного типа. В современных визуальных средах этот тип данных в своем естественном виде представляет числовые объекты типа int64. В недалеком будущем системы программирования воспользуются и сверхдлинными целочисленными данными типа int128.
Для внутреннего представления данных вещественного типа характерно то, что в соответствующей области оперативной памяти хранятся две компоненты числа мантисса m и порядок p. Само число x при этом равно произведению m*2p. Таким образом, мантисса определяет значащие цифры числа и его знак, а порядок положение запятой, которая благодаря этому как бы "плавает" между значащими цифрами (отсюда и термин формат с плавающей запятой). Такой способ представления числовых данных позволяет при одинаковом количестве двоичных разрядов, отведенных для хранения чисел существенно расширить диапазон допустимых данных.
Попробуем оценить тот выигрыш в диапазоне допустимых чисел, который обеспечивает формат вещественных данных по сравнению с целочисленным форматом. Рассмотрим 32-битные двоичные числа. Целочисленные значения со знаком в этом формате позволяют работать с числами, принадлежащими по модулю интервалу [0, 2*109]. Предположим, что для числа с плавающей запятой в 32-битном слове отведены 1 двоичный разряд под знак числа, 8 двоичных разрядов под порядок и оставшиеся 23 разряда под мантиссу. Тогда минимально представимое число равно произведению минимальной мантиссы (2-1) на минимальный порядок (2-128), т.е. 2-129, что примерно соответствует
10-39. Самое большое по модулю число представляет произведение (1-2-23)*2+127, что примерно соответствует 10+38. Таким образом, если целые числа перекрывали диапазон в 9 десятичных порядков, то формат с плавающей запятой при той же разрядности слова перекрывает диапазон в 77 порядков. Однако нельзя не заметить и проигрыш в количестве значащих цифр. Целочисленный 32-битный формат поддерживает 10 значащих цифр, тогда как 23-битные мантиссы вещественных данных позволяют работать с 7-8 десятичными знаками.
Наиболее часто применяемые типы вещественных чисел представлены короткими (4 байта) и длинными (8 байт) данными. Короткий вещественный формат по модулю обеспечивает представление чисел в диапазоне приблизительно от 10-38до 10+38с 7-8 значащими десятичными цифрами. Для 8-байтового формата диапазон существенно расширяется от 10-308 до 10+308, а количество значащих цифр увеличивается до 15-16. Сопроцессор IBM PC предлагает расширенный десятибайтовый формат вещественных данных, перекрывающий диапазон (по модулю) от 10-4932 до 10+4932 и сохраняющий 19-20 значащих цифр.
В системах программирования Borland C++ для объявления данных вещественного типа используют спецификаторы float (короткое вещественное, 4 байта), double (вещественное с удвоенной точностью, 8 байт) и long double (длинное вещественное с удвоенной точностью, 10 байт).
В машинном представлении вещественных данных разного типа на IBM PC не выдержана какая-то общая идеология. Объясняется это, по всей вероятности, разными наслоениями на прежние аппаратные решения, которые принимались при разработке процессоров в разных отделениях фирмы Intel. Поэтому здесь имеют место такие нюансы как сохранение в оперативной памяти или не сохранение старшего бита мантиссы, представление мантиссы в виде чисто дробного (0.5 m < 1) или смешанного (1 m < 2) числа и т.п. Прикладных программистов эти детали мало интересуют, однако при создании специальных системных компонент с точным представлением данных приходится считаться.
В программах на языках C, C++ встречаются литеральные и именованные числовые константы целого или вещественного типа. Числовые константы, употребляемые в тексте программ в арифметических или логических выражениях, называют литеральными. Они представлены числовыми литерами цифрами, знаками + или , точками, отделяющими целую часть числа от дробной, показателями десятичного порядка. Например:
x=-25; //целочисленная константа -25
y=y+2.4; //вещественная константа в форме с фиксированной запятой
z=2.1e-6;// вещественная константа в форме с плавающей запятой
В отличие от литеральных констант программисты часто прибегают к константам, которые подобно переменным имеют индивидуальные имена:
#define Nmax 100
const double eps=1e-6;
..................
int a[Nmax]
..................
for(i=0; i<Nmax; i++)
....................
if(abs(z)<eps)...
Удобство именованных констант заключается в минимальных переделках программы, связанных с изменением размерности массивов и точности других управляющих констант. Достаточно изменить одну строку программы с объявлением той или иной константы и не менять другие операторы, использующие эту константу.
Под внешним представлением числовой информации мы подразумеваем способы записи данных, используемые в текстах программ, при наборе чисел, вводимых в ЭВМ по запросу программы, при отображении результатов на экране дисплея или на принтере.
Наличие в естественной записи числа точки (3.1415) или указателя десятичного порядка (314.159265e-02, 314.159265E-02) означает, что соответствующее значение представлено в ЭВМ в виде вещественного числа с плавающей запятой.
Кроме естественного представления числовых констант в виде целого или вещественного числа языки программирования допускают различные добавки в начале ("префиксы") или конце ("суффиксы") числа, определяющие способы преобразования и хранения данных в памяти компьютера.
В алгоритмическом языке C активно используются как префиксы, так и суффиксы:
0x5,0X5 шестнадцатеричное целое число (префикс 0x или 0X);
05 восьмеричное целое число (префикс незначащий нуль в начале);
5H,5h короткое целое число (суффикс h или H от sHort)
5U,5u целое число без знака (суффикс u или U, от Unsigned);
5HU,5hu,5Hu,5hU короткое целое число без знака;
5L ,5l длинное целое число (суффикс l или L, от Long);
5LU,5lu,5Lu,5lU длинное целое число без знака;
5f,5F короткое вещественное число (суффикс f или F, от Fixed);
5LF,5FL,5fl,5lf,5Lf,5lF,5Fl,5fL длинное вещественное число.
Входной язык системы визуального программирования BCB дополнительно позволяет использовать следующие суффиксы:
i8 для однобайтовых целых чисел со знаком (например, 127i8)
i16 для двухбайтовых целых чисел со знаком;
i32 для четырехбайтовых целых чисел со знаком;
i64 для восьмибайтовых целых чисел со знаком;
ui64 для восьмибайтовых целых чисел без знака.
Переменные числового типа, используемые в программе, обязательно должны быть объявлены до их использования в тех или иных исполняемых операторах. В отличие от языка Pascal алгоритмические языки C, C++ позволяют вводить такие описания не только в начале программных единиц (функций), но и по ходу формирования программы:
int main(void)
{ int i,j;
...........
s=0;
for(int k=0; k<10; k++)
........................
В приведенном примере переменные i и j объявлены в начале функции, а объявление переменной k встретилось в операторе цикла. По стандарту языка C++ место объявления переменной определяет сферу ее действия. Переменные, описанные в начале функции (такие как i и j в приведенном выше примере), считаются локализованными в данной функции и могут быть использованы в любой части тела этой функции. Повторное объявление переменных с такими же именами считается ошибкой (дублирование имен переменных). В отличие от этого действие переменных, объявленные в некотором внутреннем блоке функции (в нашем примере переменная k объявлены в цикле for), распространяется только на время работы этого блока. Т.е после выхода из цикла память, выделенная под переменную k, возвращается системе, и без повторного переобъявления этой переменной пользоваться уже нельзя. Следует отметить, что это положение стандарта языка C++ в системе BC 3.1 реализовано не так. Повторное объявление такой переменной в следующем внутреннем блоке в системе BC 3.1 приводит к сообщению об ошибке, т.к. при выходе из цикла переменная k продолжает свою жизнь. В средах визуального программирования Borland C++ Builder этот пассаж исправлен.
Объявлению любой переменной предшествует служебное слово, определяющее диапазон допустимых значений тип переменной. Имена базовых типов числовых данных и соответствующие им характеристики, приведены в табл. 4.1 (BC 3.1) и 4.2 (BCB). Для вещественных чисел указаны минимальные по модулю значения.
Таблица 4.1
Тип |
Длина |
Минимальное значение |
Максимальное значение |
Char, signed char |
1 байт |
-128 |
127 |
unsigned char |
1 байт |
0 |
255 |
short int, short |
2 байта |
-32768 |
32767 |
unsigned short |
2 байта |
0 |
65535 |
int, signed |
2 байта |
-32768 |
32767 |
unsigned int, unsigned |
2 байта |
0 |
65535 |
long, long int |
4 байта |
-2147483648 |
2147483647 |
unsigned long |
4 байта |
0 |
4294967265 |
float |
4 байта |
3.4*e-38 |
3.4*e38 |
double |
8 байт |
1.7e-308 |
1.7e308 |
long double |
10 байт |
3.4e-4932 |
1.1e4932 |
Таблица 4.2
Тип |
Длина |
Минимальное значение |
Максимальное значение |
char, __int8 |
1 байт |
-128 |
127 |
unsigned char |
1 байт |
0 |
255 |
short int, short, __int16 |
2 байта |
-32768 |
32767 |
unsigned short |
2 байта |
0 |
65535 |
int, signed, __int32 |
4 байта |
-2147483648 |
2147483647 |
unsigned int, unsigned |
4 байта |
0 |
4294967265 |
long, long int |
4 байта |
-2147483648 |
2147483647 |
unsigned long |
4 байта |
0 |
4294967265 |
int64, __int64 |
8 байт |
-4611686018427387904 |
4611686018427387903 |
__uint64 |
8 байт |
0 |
9223372036854775807 |
float |
4 байта |
3.4*e-38 |
3.4*e38 |
double |
8 байт |
1.7e-308 |
1.7e308 |
long double |
10 байт |
3.4e-4932 |
1.1e4932 |
Объявление переменной можно совместить с присвоением ей начального значения (инициализацией):
int x=18,y=-5;
float a=5F;
В отличие от языка Pascal инициализация локальных переменных в функциях на языках C, C++ происходит при каждом вызове функции.
Для начинающих программистов проще всего организовать в программе ввод числовой информации из буфера потока, связанного с так называемым стандартным устройством ввода (stdin). Данные в этом буфере оказываются в тот момент, когда программа обращается к пользователю и ждет окончания набора затребованных числовых значений с клавиатуры. Программа может запросить одно или несколько значений:
cin >> d;
...........
cin >> x1 >> x2 >> x3;
Первая строка соответствует запросу на ввод значения единственной переменной d. Следующая строка программы представляет запрос на ввод трех числовых значений, первое из которых будет присвоено переменной x1, второе значение предназначается для переменной x2, третье значение для переменной x3. В ответ на запрос программы пользователь должен набрать на клавиатуре затребованные числа, разделяя их, по крайней мере, одним пробелом. Набор строки завершается нажатием клавиши Enter. Количество числовых значений, набираемых пользователем в пределах строки, может оказаться как меньше, так и больше, чем требуется программе. В первом случае продолжение программы задержится до тех пор, пока пользователь не введет дополнительные строки с недостающими значениями. Если пользователь наберет слишком много значений в вводимой строке, то лишние числовые данные сохранятся в буфере ввода и будут переданы программе при выполнении следующего оператора cin.
Тип набираемого числового значения и его величина должны соответствовать типу переменной, в которую это значение должно быть введено. Забота об этом целиком возлагается на пользователя.
Для обеспечения потокового ввода к программе следует подключить заголовочный файл iostream.h:
#include <iostream.h>
int main()
{ int i;
float f;
double d;
..........
cin >> i >> f >> d;
В переменные типа char или unsigned char из потока числовые значения ввести невозможно, т.к. они в данном случае воспринимаются системой не как числовые данные, а как символьные. В такие переменные попадет только первый символ набранного значения.
Гораздо более сложным для начинающих программистов является ввод числовых данных, организуемый с помощью функции scanf, использующей так называемую форматную строку:
#include <stdio.h>
int main()
{ int i;
float f;
double d;
..........
scanf("%d %f %lf",&i,&f,&d);
Строка вводимых данных поступает со стандартного устройства ввода (stdin), которым по умолчанию считается клавиатура. Завершение набора строки ввода нажатие клавиши Enter.
Первый аргумент функции scanf представляет форматную строку, управляющую процессом преобразования числовых данных, набранных пользователем в строке ввода, в машинный формат, соответствующий типам переменных, адреса которых указаны вслед за форматной строкой. Числовые значения в строке ввода рекомендуется разделять одним или несколькими пробелами.
В приведенном примере переменной i (в списке ввода указан ее адрес &i), объявленной с помощью спецификатора типа int, соответствует форматный указатель %d. Это означает, что первым числовым значением в строке ввода может быть только целое десятичное число со знаком (d от decimal, десятичный). Вещественной переменной f типа float в форматной строке соответствует указатель %f. Это означает, что второе числовое значение в строке ввода должно принадлежать диапазону, предусмотренному для коротких вещественных данных. Для переменной d типа double использован форматный указатель %lf (l от long).
Как правило, количество форматных указателей, перечисленных в первом аргументе функции scanf, должно совпадать с количеством адресов переменных, следующих за форматной строкой. Исключение составляет случай, когда форматный указатель предписывает программе пропустить очередное значение из введенной строки. В этом случае количество адресов в списке ввода уменьшается соответствующим образом. Например:
scanf("%d %*l %lf",&i,&d);
При выполнении такого оператора ввода программа проигнорирует второе числовое значение, набранное пользователем. Конечно, при ручном наборе вводимых значений, нелепо заставлять пользователя набирать данные, которые программе не понадобятся. Но такая возможность может оказаться полезной, когда строка ввода поступает не с клавиатуры, а из других источников (считана с диска, сформирована другой программой в оперативной памяти).
Вообще говоря, функция scanf возвращает числовое значение, равное количеству правильно обработанных полей из строки ввода. Это полезно помнить при организации проверки правильности ввода, т.к. сообщения об ошибках ввода функция scanf не выдает, но после первой же ошибки прерывает свою работу.
Основная сложность в овладении тонкостями ввода, управляемого списком форматных указателей, заключается в многообразии последних. В самом общем виде числовой форматный указатель, используемый функцией scanf, представляется как следующая последовательность управляющих символов и дополнительных признаков:
%[*][ширина][{l|h|L}]{d|i|u|o|x|X|f|e|E|g|G}
Квадратные скобки здесь означают, что соответствующий элемент форматного указателя может отсутствовать. В фигурных скобках указаны символы, один из которых может быть выбран. Обязательными элементами любого форматного указателя являются начальный символ % и последний символ, определяющий тип вводимого значения.
Символ * после начального символа является указанием о пропуске соответствующего значения из строки ввода. Необязательное, и, как правило, не используемое при вводе, поле ширина задает количество символов во вводимом значении. Дополнительные признаки l, h и L уточняют длину машинного формата соответствующей переменной (l, L long; h short). Значение последнего обязательного символа форматного указателя расшифровано в табл. 4.3.
Таблица 4.3
Символ формата |
Допустимое значение в строке ввода |
d |
целое десятичное число со знаком |
i |
целое число |
u |
целое число без знака |
o |
целое восьмеричное число без знака |
x,X |
целое шестнадцатеричное число без знака |
f |
вещественное число |
e,E |
вещественное число |
g,G |
вещественное число |
Форматный ввод также как и потоковый не позволяет вводить числовые значения в переменные типа char. Дело в том, что минимальная длина числового значения вводимого с помощью функции scanf 2 байта. И значение введенного старшего байта затирает в памяти еще один байт вслед за переменной типа char. Заметить такую ошибку удается не каждому, но на работу программы такой ввод может повлиять довольно серьезно. Поэтому возьмите за правило в однобайтовые переменные типа char числовую информацию вводить нельзя.
В системе BC 3.1 для ввода значений переменных типа short или int можно использовать один из следующих форматных указателей %d, %i, %u, %o, %x или %X. Ввод целочисленных данных типа long требует одного из следующих форматных указателей %ld, %li, %lu, %lo, %lx или %lX.
В системе BCB для двухбайтовых числовых переменных следует использовать форматные указатели в сочетании с символом h %hd, %hi, %hu, %ho, %hx или %hX. Форматные указатели %d и %ld здесь эквивалентны.
Такое обилие целочисленных форматных указателей объясняется тем, что при наборе целых чисел можно добавлять префиксы 0x или 0X для шестнадцатеричных значений, лидирующий 0 для восьмеричных значений. При использовании форматного указателя %d вводимые данные не могут содержать указанные префиксы, все значения интерпретируются только как целые десятичные числа со знаком. При использовании форматного указателя %i (i от integer) можно вводить числа в любой нотации, но обязательно с соответствующим префиксом. Форматные указатели %o, %x или %X позволяют вводить восьмеричные или шестнадцатеричные числа как с соответствующими префиксами, так и без них.
Ввод коротких вещественных данных типа float может быть осуществлен с использованием любого из следующих форматных указателей %f, %e, %E, %g или %G. На вводе все они эквивалентны, их специфика проявляется только при выводе числовых результатов. Для восьмибайтовых данных типа double к каждому из этих указателей добавляется буква l %lf, %le, %lE, %lg или %lG. Десятибайтовым числовым данным типа long double соответствуют форматные указатели %Lf, %Le, %LE, %Lg или %LG.
Форматный вывод числовых результатов на стандартное устройство вывода (stdout), которым по умолчанию является экран дисплея, осуществляется с помощью функции printf. Например:
#include <stdio.h>
int main()
{ int i;
float f;
double d;
..........
printf("%d %f %lf", i+1, f, f*d);
Ее аргументы напоминают по форме обращение к функции scanf с той лишь разницей, что список вывода составляют не адреса переменных, а арифметические выражения, значения которых предварительно будут подсчитаны, а потом выведены в соответствии с использованными форматными указателями.
Для преобразования числовых данных из машинного формата используются форматные указатели, общий вид которых содержит расширенный набор признаков:
%[флажки][ширина][.точность][{l|h|L}]{d|i|u|o|x|X|f|e|E|g|G}
Поле ширина определяет количество знакомест на экране, где будет размещен выводимое значение. Как правило, его задают с некоторым запасом, чтобы смежные значения не сливались друг с другом. Выводимое число прижимается младшим разрядом к правой границе указанного поля. Это обеспечивает общепринятый способ вывода числовых таблиц, когда в столбцах одноименные разряды чисел располагаются друг под другом. Если заданная ширина меньше, чем это требуется для выводимого значения, то оно все равно будет выведено полностью, но стройность колонок в таблицах при этом будет нарушена.
Поле флажков может содержать до четырех управляющих символов из набора [,+,пробел,0,#]. Символ "минус" устанавливает прижим выводимого значения к левой границе выделенного поля (по умолчанию действует правый прижим). Символ "плюс" устанавливает обязательный режим вывода знака числа (даже если оно положительно). Символ "пробел" устанавливает такой режим, при котором вместо знака "+" выводится пробел. Символ "нуль" устанавливает режим вывода чисел, при котором старшие незначащие позиции поля вывода заполняются нулями.
Символ # влияет на формат вывода восьмеричных, шестнадцатеричных и вещественных чисел. При его использовании перед восьмеричными числами выводится лидирующий нуль, перед шестнадцатеричными числами префикс 0x или 0X. Для форматов f, e, и E обязательно отображается десятичная точка после целой части (по умолчанию, числа, у которых дробная часть равна 0, выводятся без десятичной точки). Для форматов g и G не удаляются лидирующие нули и всегда выводится десятичная точка.
В табл. 4.4 и 4.5, заимствованных из файла помощи, показаны способы вывода целочисленного значения I=555 и вещественного значения R=5.5 при различных комбинациях флажков.
Необязательное поле точность задает для целочисленных значений обязательное количество цифр (если число меньше, то перед его старшим разрядом добавляется необходимое количество нулей). Например, вывод целочисленного значения 5 по формату %8.4d выделяет в строке вывода поле из восьми символов, на котором первые три позиции заполнены пробелами, а следующие пять символами 0005.
Для данных вещественного типа поле точность определяет количество цифр в дробной части числа. Не забывайте о количестве значащих цифр, хранение которых обеспечивает тот или иной тип данных. Например, если переменной типа float присвоить значение 3.14159265, то на выводе по формату %10.8f мы увидим результат 3.14159274. Двум последним цифрам результата доверять нельзя, т.к. формат float обеспечивает хранение 7-8 десятичных цифр.
Таблица 4.4
Флажки |
Формат %6d |
Формат %6o |
Формат %8x |
%-+#0 |
+555 |
01053 |
0x22b |
%-+# |
+555 |
01053 |
0x22b |
%-+0 |
+555 |
01053 |
22b |
%-+ |
+555 |
01053 |
22b |
%-#0 |
555 |
01053 |
0x22b |
%-# |
555 |
01053 |
0x22b |
%-0 |
555 |
1053 |
22b |
%- |
555 |
1053 |
22b |
%+#0 |
+00555 |
001053 |
0x00022b |
%+# |
+555 |
01053 |
0x22b |
%+0 |
+00555 |
001053 |
0000022b |
%+ |
+555 |
1053 |
22b |
%#0 |
000555 |
001053 |
0x00022b |
%# |
555 |
01053 |
0x22b |
%0 |
000555 |
001053 |
0000022b |
% |
555 |
1053 |
22b |
Таблица 4.5
Флажки |
Формат %10.2e |
Формат %10.2f |
%+#0 |
+5.50e+00 |
+5.50 |
%-+# |
+5.50e+00 |
+5.50 |
%-+0 |
+5.50e+00 |
+5.50 |
%-+ |
+5.50e+00 |
+5.50 |
%-#0 |
5.50e+00 |
5.50 |
%-# |
5.50e+00 |
5.50 |
%-0 |
5.50e+00 |
5.50 |
%- |
5.50e+00 |
5.50 |
%+#0 |
+005.50e+00 |
+0000005.50 |
%+# |
+5.50e+00 |
+5.50 |
%+0 |
+005.50e+00 |
+0000005.50 |
%+ |
+5.50e+00 |
+5.50 |
%#0 |
005.50e+00 |
0000005.50 |
%# |
5.50e+00 |
5.50 |
%0 |
005.50e+00 |
0000005.50 |
% |
5.50e+00 |
5.50 |
Разница между форматами %0x и %0X заключается в том, что в первом случае шестнадцатеричная запись числа формируется из малых букв [a,b,c,d,e,f], а во втором случае из больших букв [A,B,C,D,E,F].
Для вывода однобайтовых целочисленных данных со знаком (типа char) можно пользоваться одним из следующих форматов %o, %0x, %0X, %i, %d, %ho, %hx, %hX, %hi, %hd. Для вывода однобайтовых целых без знака наряду с перечисленными указателями допустим формат %u. Однако следует иметь ввиду, что однобайтовые значения расширяются до двухбайтовых, сохраняя знак числа. Поэтому попытка вывести однобайтовое значение 127 по формату %u приведет к правильному результату. Но если мы по такому же формату выдадим число -5, то результатом будет число 65531 (дополнение до максимального двухбайтового числа).
Вывод числовых результатов вещественного типа предусматривает две формы отображения с фиксированной запятой (форматные указатели %f и %lf) или с плавающей запятой (форматные указатели %e и %E). Форматные указатели %g и %G предлагают системе самой выбрать один из этих форматов, который окажется более компактным для выводимого значения. Большая или маленькая буква в форматных указателях с плавающей запятой приводит к тому, что порядку числа предшествует большая или маленькая буква "e".
При форматном выводе числовых результатов бывает удобно сопроводить их какой-либо пояснительной подписью. Обычно текст такого пояснения включают в форматную строку. Все символы, которые не являются принадлежностью форматных указателей, автоматически переносятся в выводную строку. Например, при выполнении следующих операторов:
x1=127; x2=-350;
printf("x1=%d x2=%d",x1,x2);
на экране дисплея появится строка:
x1=127 x2=-350
Если мы хотим расположить выводимую информацию на экране дисплея с начала следующей строки, то в начале форматных указателей обычно вставляют управляющий символ "\n":
printf("\nx1=%d x2=%d),x1,x2);
Такой же управляющий символ может быть включен в середину или в конец форматной строки. В языках C, C++ с помощью символа "\" в форматную строку могут быть включены и другие управляющие символы (так называемые Escape-последовательности), список которых приведен в табл. 4.6.
Таблица 4.6
Символ |
Назначение |
Символ |
Назначение |
\a |
Звуковой сигнал (код 0x07, BEL) |
\\ |
Отображение символа \ |
\b |
Забой предыдущего символа (код 0x08, Backspace) |
\' |
Отображение символа ' |
\f |
Переход на новую страницу (код 0x0C, FF) |
\" |
Отображение символа " |
\n |
Переход на следующую строку (код 0x0A, LF) |
\? |
Отображение символа ? |
\r |
Возврат в начало текущей строки (код 0x0D, CR) |
\0xxx |
Отображение символа с восьмеричным кодом xxx |
\t |
Горизонтальная табуляция (код 0x09, HT) |
\xhh |
Отображение символа с шестнадцатеричным кодом hh |
\v |
Вертикальная табуляция (код 0x0B, VT) |
\xHH |
Отображение символа с шестнадцатеричным кодом HH |
В строковых значениях такие символы как апостроф и вопросительный знак могут набираться и без предшествующего обратного слэша.
Для начинающих программистов организация потокового вывода числовой информации на стандартное устройство вывода (stdout) представляется более простой:
#include <iostream.h>
int main()
{ int i;
float f;
double d;
..........
cout << i+1 << f << f*d;
Здесь на первых порах не надо заботиться о форматах выводимых результатов. Для каждого типа данных существуют соответствующие системные соглашения. Переход в начало следующей строки здесь осуществляется путем включения в список вывода либо упомянутого выше управляющего символа '\n', либо аналогичного ему признака конца строки endl:
cout << i+1 << f << f*d << endl;
В список выводимых данных могут быть вкраплены и пояснительные подписи:
cout << "x1="<< x1 << "x2=" << x2 << '\n';
В потоковом выводе тоже имеются средства управления форматом вывода числовых результатов. Однако научиться пользоваться ими почти так же сложно, как и овладеть нюансами работы с форматными указателями. Дело в том, что потоки вывода в языке C++ организованы двумя способами старым и новым. Поэтому в управлении потоком вывода можно встретить различные синтаксические конструкции традиционные функции (hex установка режима вывода шестнадцатеричного числа, setw установка ширины поля вывода, setprecision установка количества отображаемых цифр и т.п.) и более современные обращения к методам класса (cout.fill(…), cout.width(...)). Некоторые из установленных режимов действуют только на вывод следующего значения, другие сохраняют свое действие до следующей установки. С некоторыми деталями управления форматами числовых данных в потоковом выводе можно познакомиться на примере 4 из следующего параграфа.
Пример 1. Вывод однобайтовых числовых значений
#include <iostream.h>
#include <stdio.h>
#include <conio.h>
int main()
{ char ch1 = 69;
char ch2 = -128;
unsigned char uch1 = 70;
unsigned char uch2 = 129;
__int8 i8_1 = 71;
__int8 i8_2 = -127;
printf("\nch1=%d ch2=%d",ch1,ch2);
printf("\nuch1=%u uch2=%u",uch1,uch2);
printf("\ni8_1=%i i8_2=%i\n",i8_1,i8_2);
cout << "ch1=" << ch1 << endl;
cout << "ch1=" << (int)ch1 << endl;
cout << "ch2=" << ch2 << endl;
cout << "ch2=" << (int)ch2 << endl;
cout << "i8_1=" << i8_1 << endl;
cout << "i8_1=" << (int)i8_1 << endl;
cout << "i8_2=" << i8_2 << endl;
cout << "i8_2=" << (int)i8_2 << endl;
getch();
return 0;
}
//=== Результаты работы ===
ch1=69 ch2=-128
uch1=70 uch2=129
i8_1=71 i8_2=-127
ch1=E
ch1=69
ch2=А
ch2=-128
i8_1=G
i8_1=71
i8_2=Б
i8_2=-127
По результатам работы примера 1 видно, что форматный вывод однобайтовых числовых данных никаких затруднений не вызывает. Однако попытка вывести эти же значения в потоке без приведения значений символьных переменных к целочисленному типу (преобразование (int)) разумных результатов не дает. Вместо этого в поток выводятся символы, коды которых совпадают с однобайтовыми числовыми значениями.
Пример 2. Вывод однобайтовых числовых значений
#include <stdio.h>
#include <conio.h>
int main()
{
char ch1 = -5, ch2 = -127;
unsigned char uch1 = 25, uch2 = 255;
printf("With sign :\n");
printf("-5=%3d(10) 127=%3d(10)",ch1,ch2);
printf("\nOhne sign :\n");
printf("25=%#3o(8) %#3x(16) %3d(10)",uch1,uch1,uch1);
printf("\n255=%#3o(8) %#3x(16) %3d(10)",uch2,uch2,uch2);
getch();
return 0;
}
//=== Результаты работы ===
With sign :
-5= -5(10) 127=-127(10)
Ohne sign :
25=031(8) 0x19(16) 25(10)
255=0377(8) 0xff(16) 255(10)
В примере 2 однобайтовые числовые значения выданы по формату в виде десятичных, восьмеричных и шестнадцатеричных данных (основание системы записано в круглых скобках после самого значения).
Пример 3. Вывод коротких и длинных вещественных данных
#include <stdio.h>
#include <conio.h>
int main()
{
float f1=3.14159265, f2=20000000;
double d1=3.14159265, d2=20E+125;
printf("\nf1=%10.8f f2=%f", f1,f2);
printf("\nf1=%e f2=%e", f1,f2);
printf("\nf1=%g f2=%g", f1,f2);
printf("\nd1=%12.9f f2=%g", d1,d2);
printf("\nd1=%12.9e f2=%G", d1,d2);
getch();
return 0;
}
//=== Результаты работы ===
f1=3.14159274 f2=20000000.000000
f1=3.141593e+00 f2=2.000000e+07
f1=3.14159 f2=2e+07
d1= 3.141592650 f2=2e+126
d1=3.141592650e+00 f2=2E+126
Пример 4. Управление форматом при выводе в поток
#include <iostream.h>
#include <iomanip.h>
#include <conio.h>
int main()
{ int i=15, j=6, k;
float v=1.23456, x=3.149;
cout << hex;
cout << "i=" << i << " j=" << j << " x=" << x << " v=" << v;
cout.fill('*');
cout << "\ni=" << setw(6) << i << " j=" << j << endl;
cout << setprecision(3) << x << " v=" << v << endl;
cout.width(10);
cout << x << ' ' << j << endl;
for(k=0; k<8; k++)
cout << setprecision(k) << v << endl;
getch();
return 0;
}
//=== Результаты работы ===
i=f j=6 x=3.149 v=1.23456
i=*****f j=6
3.15 v=1.23
******3.15 6
1.23456
1
1.2
1.23
1.235
1.2346
1.23456
1.23456
Над целочисленными данными (константами и переменными) в языках C, C++ можно выполнять обычные арифметические операции сложение (x+y), вычитание (z-5), умножение (x1*x3) и деление (y/w). В отличие от языка Pascal здесь деление целочисленных операндов дает целочисленный результат. Например, 5/2=2. Для получения остатка от деления в C, C++ используется операция %, например, 5%2=1.
Целочисленные данные можно подвергать операции сдвига как влево (знак операции <<), так и вправо (знак операции >>) на заданное количество двоичных разрядов:
y=x<<3; // сдвиг влево на три двоичные разряда
z=y>>5; // сдвиг вправо на пять двоичных разрядов
Операция сдвига вправо работает по-разному для целых чисел со знаком и целых чисел без знака. Внимательно посмотрите на результат работы следующей программы:
#include <stdio.h>
#include <conio.h>
int main()
{
int x=5, y=-5;
unsigned z=0xFFFFFFFB;
printf("x=%x y=%x z=%x",x,y,z);
printf("\nx<<2=%x x>>2=%x",x<<2,x>>2);
printf("\ny<<2=%x y>>2=%x",y<<2,y>>2);
printf("\nz<<2=%x z>>2=%x",z<<2,z>>2);
getch();
return 0;
}
//=== Результат работы ===
x=5 y=fffffffb z=fffffffb
x<<2=14 x>>2=1
y<<2=ffffffec y>>2=fffffffe
z<<2=ffffffec z>>2=3ffffffe
Дело в том, что для чисел со знаком операция сдвига на n разрядов эквивалентна умножению на 2n при сдвиге влево или делению на 2n при сдвиге вправо. Поэтому для отрицательного операнда результат сдвига должен остаться отрицательным. С этой целью при сдвиге вправо производится размножение знакового разряда.
Над одноименными двоичными разрядами целочисленных операндов могут выполняться логические операции логическое сложение (символ операции '|'), логическое умножение (символ операции '&'), исключающее ИЛИ (символ операции '^') и инвертирование (символ операции '~'). Приведенная ниже программа иллюстрирует выполнение указанных операций над переменными x=5 (двоичный код 00…0101) и y=7 (двоичный код 00…0111).
#include <stdio.h>
#include <conio.h>
int main()
{
int x=5, y=7, z;
printf("x=%x y=%x",x,y);
printf("\nx|y=%x x&y=%x",x|y,x&y);
printf("\nx^y=%x ~x=%x",x^y,~x);
getch();
return 0;
}
//=== Результат работы ===
x=5 y=7
x|y=7 x&y=5
x^y=2 ~x=fffffffa
Определенную помощь при обработке целочисленных данных могут оказать системные функции математической библиотеки. Прототипы этих функций описаны в заголовочных файлах math.h и stdlib.h. Мы упомянем лишь некоторые из них.
Функция abs(x) возвращает модуль своего аргумента. Аргументом функций atoi(s) и atol(s) является строка, представляющая запись целого числа. Каждая из этих функций преобразует символьную запись числа в соответствующий машинный формат (результат atoi имеет тип int, результат atol тип long) и возвращает полученный результат. Довольно полезное преобразование выполняют функции itoa и ltoa. Первый их аргумент числовое значение типа int или long. Вторым аргументом является строковый массив (или указатель на строку), куда записывается результат преобразования. А третий аргумент, значение которого находится в диапазоне от 2 до 36, определяет основание системы счисления, в которую преобразуется значение первого аргумента.
В ряде математических алгоритмов, использующих вероятностные методы (методы Монте-Карло), а чаще в игровых программах активно используются различные датчики случайных чисел. Функция random(N) при каждом повторном обращении к ней выдает очередное случайное число из диапазона от 0 до N-1. Эти числа имеют равномерное распределение вероятностей, что можно трактовать следующим образом. Допустим, мы обратились к функции random(1000) 1000 раз. Можно утверждать, что среди полученных чисел почти все будут разными. Конечно, на практике это не совсем так, но вероятность того, что среди полученных чисел встретится много одинаковых, близка к нулю. Для игровых программ очень важно, чтобы при обращении к датчику случайных чисел каждый раз возникала непредсказуемая последовательность. С этой целью можно обратиться к функции randomize(), которая случайным образом возмущает начальное состояние программы, генерирующей случайные числа.
Продемонстрируем использование датчика случайных чисел на примере программы перемешивания колоды карт. Предположим, что для идентификации карт использованы целые числа в диапазоне от 0 до 35 (или до 51). Идея алгоритма перемешивания состоит в многократной генерации случайных чисел от 0 до 35 и перекладывания первой карты с k-той (k очередное случайное число). Организуем пятикратное перемешивание, чтобы убедиться в том, что каждый раз колода оказывается в новом непредсказуемом состоянии.
#include <stdio.h>
#include <stdlib.h>
#include <conio.h>
void mixer(char *b)
{ int j;
char i,tmp;
randomize();
for(j=0; j<10000; j++)
{ i=random(36);
tmp=b[0]; b[0]=b[i]; b[i]=tmp;
}
}
int main()
{ char j,k,a[36];
printf("\nPlaying-cards before:\n");
for(k=0; k<36; k++)
{ a[k]=k; printf("%4d",a[k]); }
for(j=0; j<5; j++)
{ mixer(a);
printf("\nPlaying-cards after %d:\n",j);
for(k=0; k<36; k++)
printf("%4d",a[k]);
}
getch();
return 0;
}
//=== Результат работы ===
Playing-cards before:
0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35
Playing-cards after 0:
9 0 13 32 18 30 14 24 34 28 7 26 35 16 3 27 5 11 6
23 21 8 31 17 29 2 15 22 33 12 19 25 1 4 10 20
Playing-cards after 1:
28 9 16 1 6 19 3 29 10 33 24 15 20 5 32 22 30 26 14
17 8 34 25 11 12 13 27 31 4 35 23 2 0 18 7 21
Playing-cards after 2:
33 28 5 0 14 23 32 12 7 4 29 27 21 30 1 31 19 15 3
11 34 10 2 26 35 16 22 25 18 20 17 13 9 6 24 8
Playing-cards after 3:
4 33 30 9 3 17 1 35 24 18 12 22 8 19 0 25 23 27 32
26 10 7 13 15 20 5 31 2 6 21 11 16 28 14 29 34
Playing-cards after 4:
18 4 19 28 32 11 0 20 29 6 35 31 34 23 9 2 17 22 1
15 7 24 16 27 21 30 25 13 14 8 26 5 33 3 12 10
При вычислениях с вещественными данными разрешается использовать только четыре арифметические операции сложение (+), вычитание (), умножение (*) и деление (/). В арифметическом выражении могут встречаться операнды разных типов, поэтому важно знать, как компилятор определяет тип результирующего значения. Правило это довольно простое, но оно содержит подводные камни. Арифметические действия выполняются в соответствии общепринятыми правилами:
Несмотря на простоту и естественность описанных выше правил, результаты вычисления некоторых выражений могут поставить в тупик не очень внимательного программиста. Например, не вызывает сомнения, что тип выражения 5/2+1.0 должен быть вещественным. Однако к вещественным данным относятся и значения типа float, и значения типа double, и значения типа long double. В данном случае, компилятор ориентируется на тип числовой константы 1.0, которая по правилам системы программирования преобразуется в машинный формат длинного вещественного числа (т.е. имеет тип double). А вот результат вычисления данного выражения равен 3.000000, и это может вызвать недоумение. На самом деле, тип каждого слагаемого формулы определяется независимо от типов других слагаемых. Первое слагаемое представлено частным от деления двух целых констант, поэтому его тип тоже целый, т.е. результат деления равен 2, а не 2.5. Затем значения всех слагаемых приводятся к типу double, и итоговый результат равен 3.0.
Существенную помощь в вычислениях с вещественными данными оказывают многочисленные математические функции из раздела math.h. Список некоторых из них приведен в табл. 4.7.
Таблица 4.7
Прототип функции |
Возвращаемое значение |
double acos(double x) |
arccos x |
double asin(double x) |
arcsin x |
double atan(double x) |
arctg x |
double atan2(double x, double y) |
arctg (y/x) |
double atof(const char *s) |
машинный формат числа из строки s |
double ceil(double x) |
округление "сверху" |
double cos(double x) |
cos x |
double cosh(double x) |
ch x |
double exp(double x) |
ex |
double fabs(double x) |
| x | |
double floor(double x) |
округление "снизу" |
double log(double x) |
ln x |
double log10(double x) |
lg x |
max(a,b) |
максимум (a,b), тип совпадает с типом максимального аргумента |
min(a,b) |
минимум (a,b) , тип совпадает с типом минимального аргумента |
double pow(double x, double y) |
xy |
double pow10(int p) |
10p |
double sin(double x) |
sin x |
double sinh(double x) |
sh x |
double sqrt(double x) |
квадратный корень из x |
double tan(double x) |
tg x |
double tanh(double x) |
th x |
double hypot((double x, double y) |
квадратный корень из x2+y2 |
double poly(double x, int n, double *a) |
значение полинома |
double ldexp(double x, int n) |
x*2n |
Прототипы функций max и min, которые, на самом деле представлены не настоящими функциями, а соответствующими макроопределениями, содержатся в файле stdlib.h.
Для большинства функций типа double с аргументом типа double имеются их аналоги с данными типа long double. Названия этих функций отличаются от приведенных в табл. 4.7 добавкой окончания l (fabs, fabsl), (acos, acosl), …:
Особо следует остановиться на функциях округления ceil и floor. Первая из них возвращает наименьшее целое значение, которое больше или равно x. Вторая возвращает наибольшее целое значение, не превосходящее x. Обратите внимание на то, что значение, возвращаемое обеими функциями, представлено в формате double.
ceil(0.1) =1.0 floor(0.1) = 0.0
ceil(0.5) =1.0 floor(0.5) = 0.0
ceil(0.9) =1.0 floor(0.9) = 0.0
ceil(-0.9)=0.0 floor(-0.9)=-1.0
ceil(-0.5)=0.0 floor(-0.5)=-1.0
ceil(-0.1)=0.0 floor(-0.1)=-1.0
Довольно часто программисты используют для округления функцию floor(x+0.5). Однако иногда она выдает результат, не совпадающий с общепринятым в математике, например floor(-0.5+0.5)=0. Конечно, жаль, что в языках C, C++ нет прямого аналога функции round из Паскаля, но построить такую функцию совсем не сложно:
int round(double x)
{ int res;
res=(x<0)? x-0.5 : x+0.5;
return res;
}
Если нужно произвести округление в том или ином знаке, то число можно предварительно разделить или умножить на 10k, округлить, а затем результат округления умножить или разделить на 10k.
Замечание 1. При выводе числовых результатов вещественного типа необходимые округления производят системные программы вывода.
Замечание 2. Если целочисленной переменной присваивается вещественное значение, то округление не производится. Дробная часть, какой бы она ни была, просто отбрасывается.
В заголовочном файле math.h приводятся определения именованных констант, которыми полезно воспользоваться в своих программах (см. табл. 4.8).
Таблица 4.8
Константа |
Значение |
Константа |
Значение |
M_PI |
|
M_E |
e=2.718… |
M_PI_2 |
/2 |
M_LOG2E |
log2e |
M_PI_4 |
/4 |
M_LOG10E |
log e |
M_2_PI |
2/ |
M_LN2 |
ln 2 |
M_1_SQRTPI |
1/ |
M_LN10 |
ln 10 |
M_2_SQRTPI |
2/ |
M_SQRT2 |
=1.414… |
M_SQRT_2 |
/2 |
Числовая информация не единственный тип данных, обрабатываемых с помощью ЭВМ. Очень большой пласт прикладных задач связан с обработкой текстовой информации. К ним относятся текстовые редакторы, всевозможные переводчики, компиляторы алгоритмических языков, информационно-справочные системы, системы обработки экономических данных и многие другие.
Языки C, C++ разделяют текстовые данные на примитивы, значением которых являются одиночные символы (символьные данные) и последовательности примитивов в виде цепочек символов, завершающихся установленным признаком конца (строковые данные). Символьные данные могут быть представлены как скалярными величинами (символьные константы, символьные переменные), так и массивами таких данных. Строковые данные наряду с константами и скалярными переменными тоже могут быть организованы в массивы, напоминающие привычные текстовые документы.
В ранних версиях систем программирования для кодировки символьных данных использовались так называемые кодовые страницы (code page). С одной из таких кодовых страниц в свое время выступила фирма IBM, предложившая в качестве стандарта 7-битовую кодировку управляющих и отображаемых символов на компьютерах серии IBM/360. Однако с развитием средств обработки символьной информации 128 различных кодов оказалось недостаточно, и для подключения кодов с символами национальных алфавитов производители средств вычислительной техники соответствующих стран начали подключать к стандарту IBM дополнительные наборы символов. Для кодировки новых расширений потребовался еще один двоичный разряд, и так возникли сменные наборы, дополнявшие устоявшуюся таблицу IBM. Каждая страна или группа стран построила свой уникальный набор из 256 символов, получивший название кодовой страницы. Чтобы отличать эти страницы друг от друга, им присвоили номера. Пользователям нашей страны досталась кодовая страница с номером 866. Довольно много хлопот разнообразие этих страниц вызывало у производителей программных продуктов, учитывающих специфику национальных алфавитов. Трудно приходилось и производителям устройств вывода (принтеры, плоттеры), т.к. в их конструкциях предусматривались аппаратно зашитые таблицы шрифтов.
В новых программных системах и аппаратных средствах планируется переход от так называемой 8-битной кодировки ASCII (American Standard Code for Information Interchange американский стандартный код для обмена информацией) к 16-битной кодировке Unicode. В рамках этой кодировки станет возможным оперировать с 65536 различными символами, которых должно хватить на все страны мира. Многие современные операционные системы и программные продукты уже поддерживают стандарт Unicode.
Системы программирования BC 3.1 и BCB ориентированы на однобайтовую кодировку символьных данных на базе кодовых страниц ASCII. Сложность заключается в том, что под управлением MS-DOS в нашей стране используется кодовая страница с номером 866, а в операционных системах Windows 98/NT/2000/XP отечественная кодовая страница имеет номер 1251. У обеих кодовых страниц первые половины идентичны стандарту IBM. Здесь находятся коды управляющих символов (группа кодов от 0x00 до 0x1F), различные разделители (точки, запятые, скобки и т.п.) и знаки операций, большие и малые буквы латинского алфавита. А вот вторые половины этих кодовых страниц устроены по-разному и из-за этого тексты на русском языке, подготовленные в среде Windows, отображаются консольными приложениями BCB в виде некоторой абракадабры. Это явление не наблюдалось в среде BC 3.1, т.к. там и набор программы и ее выполнение происходят в рамках одной и той же кодовой страницы.
Одиночным символьным данным (константам и переменным) в оперативной памяти ЭВМ выделяется по одному байту, в которых хранятся соответствующие значения числовые коды конкретных символов в соответствии с их кодировкой в той или иной странице.
Чтобы познакомиться с 866-й кодовой страницей предлагается выполнить следующую программу:
#include <stdio.h>
#include <conio.h>
void main()
{
int i,j;
gotoxy(37,1);
printf("ASCII");
for (i=32; i<=52; i++)
{ gotoxy(1,i-29);
for (j=i; j<=255; j+=21)
printf("%c %3d ",j,j);
}
getch();
}
Результат ее работы приведен на рис. 5.1.
Рис. 5.1. Состав отображаемых символов ASCII (code page 866)
Отображаемые символы в таблице ASCII начинаются с кода 32 (0x20), которому соответствует символ "пробел". Коды больших русских букв начинаются с кода 128 (0x80), однако буква 'Ё' расположена не на своем месте. Наблюдается разрыв в числовой последовательности кодов малых букв после буквы 'п' расположена группа кодов псевдографики. С их помощью в текстовом режиме работы дисплея строятся одинарные и двойные контуры таблиц. Малая буква 'ё' также расположена не в алфавитном порядке. Все эти нюансы приходится учитывать при построении программы перекодировки русских текстов из кодовой страницы 1251 (т.е. из кодировки Windows) в кодировку MS-DOS. С такой операцией приходится сталкиваться при необходимости включения русских текстов в консольное приложение BCB.
Значения однобайтовых символьных констант заключаются в одинарные кавычки и могут быть заданы тремя способами. Первый распространяется на отображаемые символы таблицы ASCII (см. рис. 5.1) и заключается в том, что отображаемый символ заключается в одинарные кавычки:
'F', '%', '$', 'ы', '5', '+', '"', 'q', 'Я', ' '
Последняя константа в приведенном перечислении соответствует пробелу. Сам символ апострофа таким образом "закодировать" нельзя.
Второй способ заключается в записи шестнадцатеричного кода символа после знака "обратный слэш":
'\x27' (код символа "апостроф", 39)
'\x5C' (код символа \, 92)
Обратите внимание на то, что шестнадцатеричный код записывается без лидирующего нуля (в отличие от записи числовых шестнадцатеричных констант). Так можно записать любой управляющий код, расположенный в начале таблицы ASCII. Однако в языках C, C++ чаще используются Escape-последовательности, о которых речь шла при управлении выводом (см. табл. 4.6). Вот несколько примеров:
'\'' символ "апостроф"
'\\' символ "обратный слэш"
Третий способ заключается в записи восьмеричного кода символа после знака "обратный слэш":
'\47' (код символа "апостроф", 39)
'\134' (код символа \, 92)
Обратите внимание на то, что восьмеричный код записывается без лидирующего нуля (в отличие от записи числовых восьмеричных констант) и должен принадлежать диапазону [0, 377].
Символьные переменные объявляются с помощью спецификаторов char или
unsigned char. Одновременно их можно проинициализировать тем или иным способом:
char ch1='Я',ch2='\x9F',ch3='\237',ch4= 0x9F,ch5=0237,ch6=159;
Числовой способ инициализации гарантирует правильность вывода как в DOS-приложении, так и в консольном приложении Windows. Символьной инициализацией в консольных приложениях Windows можно пользоваться без проблем только для символов из первой половины таблицы ASCII.
В языке C среди базовых типов данных строк как таковых не оказалось. Вместо этого язык C предлагает использовать одномерные символьные массивы, в которых хранятся те же строки в виде последовательности однобайтовых символов, завершающихся байтом с нулевым кодом. Для этого признака конца строки в состав Escape-последовательностей включен специальный код '\0', хотя можно было бы воспользоваться и другой комбинацией '\0x0'.
В языке C++ появился гораздо более удобный класс строковых данных string. Но знакомство с этим классом мы отложим на более поздний срок.
Значения строковых констант или начальные значения строковых "переменных" в отличие от символьных данных заключаются в двойные кавычки:
const char c1[]="ABCDEFGH";
char str1[]="1234";
char letter[]="a";
char symbol='a';
Байт с нулевым кодом система автоматически добавляет вслед за последним символом строки. Поэтому для символьного массива c1 будет выделено 9 байтов, а для символьного массива str1 5 байтов. Обратите внимание на то, что массив letter занимает в памяти 2 байта, тогда как символьная переменная symbol 1 байт. Так как строка представлена одномерным массивом символов, то доступ к каждому ее символу осуществляется самым обычным способом:
c1[3] четвертый символ в массиве c1 (т.е. буква 'D')
str1[0] первый символ в массиве str1 (т.е. цифра '1')
Значение любого символа в строковой "переменной" можно изменить во время работы программы:
str1[3]='L';
Запись за пределы строки может непредсказуемым образом повлиять на последующую работу программы:
str1[4]=5; //Байт с признаком конца строки испорчен
str1[5]=6; //Испорчен байт с какими-то данными
Список форматных указателей функции scanf предусматривает возможность ввода значений односимвольных (%c) и многосимвольных (%s) переменных:
#include <stdio.h>
void main()
{ char ch1,ch2;
char str1[10];
scanf("%c %c",&ch1,&ch2);
scanf("%s",str1);
....................
Обратите внимание на то, что для ввода данных в скалярные переменные ch1 и ch2 в списке ввода необходимо указывать их адреса (&ch1, &ch2), а при вводе в массив str1 достаточно написать его имя. Дело в том, что имя массива одновременно выполняет роль адреса своего первого элемента. Поэтому str1 и &str1[0] указывают на один и тот же адрес.
Ввод значений символьных переменных ch1 и ch2 можно организовать одним из двух способов. Во-первых, в строке ввода можно набрать два требуемых символа либо слитно, либо разделяя их хотя бы одним пробелом и нажать клавишу Enter. Во-вторых, можно набрать первый символ, предназначенный для переменной ch1, и нажать клавишу Enter. Затем повторить аналогичным образом ввод следующего символа.
Значение, предназначенное для строковой "переменной" str1, не должно содержать более 9 символов (признак конца строки система добавляет автоматически) и среди них не должен присутствовать пробел. Дело в том, что пробел при форматном вводе воспринимается как разделитель данных. Это не означает, что среди символов строки пробел вообще не допустим, просто для организации ввода в строку нескольких слов надо использовать другие средства.
Довольно неожиданно, но с помощью задания ширины поля ввода и одного форматного указателя %с можно за один прием ввести несколько символов в элементы символьного массива:
char q[20];
............
scanf("%20c",q); //ввод 20 символов в массив q
В отличие от ввода по форматному указателю %s в массив q здесь не записывается признак конца строки вводятся только запрашиваемые символы, которые набираются в одной строке с последующим нажатием клавиши Enter.
Функция scanf предусматривает еще два интересных форматных указателя, которые обеспечивают ввод либо тех символов, которые указаны в заданном множестве, либо всех символов, которые не принадлежат заданному множеству. Например, для ввода цифр из диапазона от 0 до 9 такой указатель имеет вид %[0-9], а для ввода символов, не являющихся цифрами %[^0-9]. Рассмотрим следующий пример, который не только демонстрирует ввод по таким форматным указателям, но и содержит непредвиденный пассаж, связанный с использованием "грязного" буфера ввода.
#include <stdio.h>
#include <conio.h>
void main()
{ char str[10];
int j;
printf("Enter 123ABC\n");
scanf("%[0-9]",str);
printf("str=%s\n",str);
for(j=0; j<10; j++)
printf("%3x",str[j]);
printf("\nEnter DEF123\n");
scanf("%[^0-9]",str);
printf("str=%s"\n,str);
for(j=0; j<10; j++)
printf("%3x",str[j]);
getch();
}
//=== Результат работы ===
Enter 123ABC
str=123
31 32 33 0 0 1 0 0 1 0
Enter DEF123
str=ABC
DEF
41 42 43 a 44 45 46 0 1 0
После ввода первой строки программе были переданы символы '123', вслед за которыми в массив str был записан нулевой байт признак конца строки. Однако в буфере ввода остались невостребованными символы 'ABC' и код клавиши Enter (код 0xa). После набора второй строки к содержимому буфера ввода добавились еще три символа 'DEF' и т.к. все семь первых символов не являются кодами цифр, то все они были переданы в массив str. При выводе первые три отобразились в одной строке, затем сработал управляющий код 0xa и три следующие символа были выведены в следующей строке. Для наглядности содержимое массива после каждого вывода по формату %s отображалось еще и в шестнадцатеричном формате.
Чтобы не пострадать от непредвиденного ввода символов, задержавшихся в буфере ввода, рекомендуется после обращения к функции scanf чистить буфер ввода с помощью функции fflush(stdin). Если бы мы включили в предыдущий пример обращение к функции fflush, то после ввода второй строки в массиве str оказались бы только символы 'DEF' .
Потоковый ввод в символьные и "строковые" переменные организуется следующим образом:
#include <iostream.h>
void main()
{ char ch1,ch2;
char str1[10];
cin >> ch1 >> ch2;
cin >> str1;
....................
Набор вводимой информации на клавиатуре осуществляется точно таким же образом, как и при форматном вводе.
Специальные функции ввода символьных данных getch() и getche() упрощают их набор (не приходится дополнительно нажимать клавишу Enter) и предоставляют дополнительные возможности. Ниже приводится программа и результат ее работы после нажатия на цифровую клавишу 5.
#include <stdio.h>
#include <conio.h>
void main()
{ char ch[4]={'1','2','3'};
ch[1]=getch();
printf("ch[0]=%c ch[1]=%c ch[2]=%c",ch[0],ch[1],ch[2]);
getch();
return 0;
}
//=== Результат работы ===
ch[0]=1 ch[2]=5 ch[3]=3
Функция getch (от get character дай символ) организует ввод кода символа без эхо-сигнала, т.е. без отображения на экране знака, соответствующего нажатой клавише. Такая возможность может оказаться полезной при вводе секретных данных (пароль) или в ситуации, когда отображение символа нажатой клавиши может повредить текущее содержимое экрана. Очень часто эту функцию используют в качестве задержки работы программы до нажатия какой-либо клавиши.
Функция getche обеспечивает ввод символа, соответствующего нажатой клавише с выдачей эхо-сигнала.
Обе функции обращаются к буферу клавиатуры. Если к этому моменту буфер пуст, то происходит ожидание нажатия клавиши. Считанный символ из буфера клавиатуры выталкивается. Однако клавиши на клавиатуре разные. Большая их часть связана с отображаемыми символами буквами, цифрами, знаками препинания и т.п. После их нажатия обращение к функциям getch/getche приводит к считыванию соответствующего кода
ASCII. В частности, к "отображаемым" клавишам относятся клавиши Esc (код 27), Enter (код 13), комбинация Ctrl+Z (код 26 признак конца файла). Но на клавиатуре присутствует ряд клавиш, с которыми ассоциируются управляющие символы, не представленные в таблице ASCII. К ним, в частности, относятся функциональные клавиши F1, F2, …, стрелки управления курсором, клавиши Insert и Delete и др. От их нажатия в буфер клавиатуры поступает двухбайтовый код, содержащий в старшем байте 0, а в младшем байте так называемый scan-код (некий порядковый код, приписанный каждой клавише). В этом случае первое обращение к функциям getch/getche приводит к считыванию нулевого кода, а повторное обращение возвращает scan-код ранее нажатой клавиши. Таким образом, для анализа кода нажатой управляющей клавиши к функциям getch/getche приходится обращаться дважды (предварительно следует убедиться в том, что первое обращение возвратило 0).
Функция gets (от get string дай строку) позволяет ввести в символьный массив текстовое значение, содержащее пробелы:
#include <stdio.h>
#include <conio.h>
void main()
{ char str[80];
gets(str);
printf("\nstr=%s",str);
getch();
}
При потоковом вводе со стандартного устройства stdin можно запросить заданное количество k символов, среди которых может встретиться и пробел:
cin.getline(str,k);
При этом можно ввести не более чем k-1 символ, т.к. нужно помнить о резервном байте для признака окончания строки. Если строка, набираемая пользователем, содержит более чем k-1 символ, то продолжение строки будет проигнорировано. И даже последующий оператор ввода не сможет им воспользоваться. Если строка ввода содержит меньше, чем k-1 символ, то она будет введена целиком. Более того, с помощью еще одного параметра, символа завершения операции, можно досрочно прекратить ввод:
cin.getline(str,k,'Q');
Если в строке ввода будет досрочно обнаружен символ 'Q', то он уже не вводится.
Следует упомянуть еще одну функцию форматного ввода cscanf, ориентированную на работу с конкретным устройством клавиатурой. Обращаются к ней точно так же как и к функции scanf, но они не дублируют друг друга. И вот почему. Дело в том, что стандартные устройства ввода (stdin) и вывода (stdout) могут быть подменены другими носителями информации (например, файлами или принтером). А ввод с клавиатуры и вывод на экран дисплея, образующих в совокупности консоль (пульт) оператора, переназначить нельзя.
При выводе текстовых данных особые проблемы возникают только в том случае, когда сообщения, содержащие русские буквы, готовятся в среде Windows (кодовая страница 1251), а выводятся консольным приложением в 866-й кодовой странице. В этом случае можно написать сравнительно несложную функцию конвертирования текстов из одной кодировки в другую. В кодовой странице 1251 буквы русского алфавита кодируются подряд, начиная с кода 192 (большая буква 'А') до кода 255 (малая буква 'я'). Буквы 'Ё' и 'ё' имеют коды 164 и 184 соответственно. Поэтому при перекодировке необходимо:
#include <stdio.h>
#include <iostream.h>
#include <conio.h>
#include <string.h>
char *to_866(unsigned char *s)
{ static unsigned char str[80];
int j=0;
while (s[j]!='\0')
{ str[j]=s[j];
if(s[j]>=192 && s[j]<=239) str[j]-=64;
if(s[j]>=240 && s[j]<=255) str[j]-=16;
if(s[j]==164) str[j]=240;
if(s[j]==184) str[j]=241;
j++;
}
str[j]='\0';
return str;
}
void main()
{ char s[]="Привет;
cout << s << endl;
cout <<to_866(s) << endl;
getch();
}
//=== Результат работы ===
В первой строке вывод реализован без перекодировки, а во второй с перекодировкой.
Для форматного вывода символьных значений в функции printf используется форматный указатель %c, а для вывода строк форматный указатель %s. При создании консольных приложений Windows можно воспользоваться программой перекодировки, аналогичной функции to_866.
#include <stdio.h>
#include <conio.h>
void main()
{
char ch1='F';
unsigned char ch2='5';
char ch3[]="ABCD";
printf("%c %c %s",ch1,ch2,ch3);
getch();
}
//=== Результат работы ===
F 5 ABCD
В потоковом выводе единственная проблема может возникнуть в связи с перекодировкой русских сообщений в консольном приложении Windows.
#include <iostream.h>
#include <conio.h>
void main()
{
char ch1='F';
unsigned char ch2='5';
char ch3[]="ABCD";
cout<<ch1<<' '<<ch2<<' '<<ch3;
getch();
}
//=== Результат работы ===
F 5 ABCD
К дополнительным средствам вывода следует отнести функцию puts(str), передающую строку str на стандартное устройство stdout, и вывод на дисплей с помощью функции cprintf. Обращение к последней не отличается от обращения к функции printf, но дисплей не допускает переназначения потока вывода. Кроме того, при выводе на дисплей имеется возможность окрасить текст в тот или иной цвет.
Значения символьных данных эквивалентны однобайтовым целым числам. Поэтому им можно присваивать целочисленные значения, равные соответствующим кодам таблицы ASCII, сравнивать на принадлежность тем или иным интервалам. Подобного рода операции упрощаются, если прибегнуть к группе специальных функций, прототипы которых сосредоточены в файле ctype.h. Все эти функции организованы по единому шаблону их единственным аргументом является числовой код анализируемого символа. Возвращаемой каждой функцией значение либо равно 0, если соответствующая проверка дала отрицательный результат, либо отлично от нуля в случае истинности проверяемого условия. Перечень этих функций приведен в табл. 5.1.
Таблица 5.1
Функция |
Проверяемое условие |
isalnum(ch) |
Является ли ch цифрой или буквой латинского алфавита |
isalpha(ch) |
Является ли ch буквой латинского алфавита |
isascii(ch) |
Принадлежит ли ch первой половине таблицы ASCII |
iscntrl(ch) |
Принадлежит ли ch группе управляющих символов (ch<0x20) |
isdigit(ch) |
Является ли ch цифрой |
isgraph(ch) |
Является ли ch отображаемым символом (0x21ch0x7E) |
islower(ch) |
Является ли ch малой буквой латинского алфавита |
isprint(ch) |
Является ли ch отображаемым символом (0x20ch0x7E) |
ispunct(ch) |
Является ли ch символом-разделителем (iscntrl || isspace) |
isspace(ch) |
Является ли ch обобщенным пробелом (0x20, 0x09,0x0A,0x0D) |
isupper(ch) |
Является ли ch большой буквой латинского алфавита |
isxdigit(ch) |
Является ли ch шестнадцатеричной цифрой |
В раздел type.h включены еще три функции преобразования аргумента ch. Результатом преобразования является возвращаемое значение функции:
toascii(ch) возвращает код, образованный 7 младшими битами ch;
tolower(ch) возвращает код большой буквы, если ch является кодом малой латинской буквы;
toupper(ch) возвращает код малой буквы, если ch является кодом большой латинской буквы.
Для обработки строк, представленных одномерными символьными массивами, в библиотеке системных функций предусмотрено довольно много различных операций. Прототипы этих функций сгруппированы в заголовочном файле string.h и большинство их названий начинается с префикса str (от string). Условимся о некоторых обозначениях аргументов и их типах, чтобы не повторять их в приведенной таблице:
S, S1,S2 указатель на символьный массив (как правило, имя массива);
CS указатель типа const char * (т.е. неизменяемый массив или строковая константа источник данных);
ch код символа, обычно числовое значение типа int;
k количество символов.
Таблица 5.2
Функция |
Выполняемое действие |
Определение длины строки |
|
strlen(CS) |
Возвращает количество символов в строке S |
Формирование строк |
|
strdup(CS) |
Запрашивает память, копирует туда содержимое CS и возвращает указатель типа chr* на новую строку |
strcpy(S1,CS2) |
Копирует содержимое CS2 в S1, возвращает указатель на S1 |
strncpy(S1,CS2,k) |
Копирует первые k символов из CS2 в S1, возвращает указатель на S1 |
stpcpy(S1,CS2) |
Копирует CS2 в S1, возвращает указатель на конец S1 |
strset(S,ch) |
Расписывает строку S символом ch, возвращает указатель на S1 |
strnset(S,ch,k) |
Повторяет k раз символ ch в строке S, возвращает указатель на S1 |
Конкатенация строк |
|
strcat(S1,CS2) |
Приписывает содержимое CS2 в конец S1, возвращает указатель на S1 (длина массива S1 должна предусматривать такое расширение) |
strncat(S1,CS2,k) |
Присоединяет первые k символов CS2 к содержимому S1, возвращает указатель на S1 |
Смена регистра |
|
strlwr(S) |
замена символов строки S кодами малых букв, действует только на буквы латинского алфавита |
strupr(S) |
замена символов строки S кодами больших букв, действует только на буквы латинского алфавита |
Переворот строки |
|
strrev(S) |
Перестановка символов строки S в обратном порядке |
Преобразование в числовые данные |
|
strtol(CS,ptr,r) |
Число, представленное в символьном виде в CS и записанное в системе счисления с основанием r, преобразуется в машинный формат числа типа long. В указатель ptr заносится адрес символа, прервавшего преобразования. Возвращаемое значение результат преобразования. |
strtoul(CS,ptr,r) |
Аналогичное преобразование в длинное целое число без знака |
strtod(CS,ptr) |
Преобразование вещественного числа из символьного представления в машинный формат числа типа double. |
Сравнение строк |
|
strcmp(CS1,CS2) |
Возвращаемое значение равно 0, если CS1=CS2, больше 0, если CS1>CS2, и меньше 0, если CS1<CS2 |
strncmp(CS1,CS2,k) |
Сравниваются только первые k символов строк CS2 и CS2 |
stricmp(CS1,CS2) |
При сравнении игнорируется разница между кодами больших и малых букв |
strcmpi(CS1,CS2) |
Аналогичная операция, разница только в названии функций |
strnicmp(CS1,CS2,k) |
Сравнение первых k символов с игнорированием разницы между кодами больших и малых букв |
strncmpi(CS1,CS2,k) |
Аналогичная операция, разница только в названии функций |
Поиск символа |
|
strchr(CS,ch) |
Строка CS сканируется слева направо до обнаружения символа ch. Если он найден, возвращаемый указатель "смотрит" на этот символ в строке CS, если такого символа нет, то возвращаемый указатель равен null (т.е. 0) |
strrchr(CS,ch) |
Аналогичный поиск с конца строки CS. |
Поиск строки |
|
strstr(CS1,CS2) |
Поиск первого вхождения строки CS2 в строку CS1. Если поиск завершен успешно, возвращается указатель на первый символ найденной подстроки. В противном случае возвращается null |
Специальный поиск |
|
strpbrk(CS1,CS2) |
В строке CS1 ищется первый символ, содержащийся в CS2. Возвращается указатель на найденный символ или null. |
strspn(CS1,CS2) |
Определяется длина начального фрагмента CS1, целиком состоящая из символов CS2 (порядок символов роли не играет) |
strcspn(CS1,CS2) |
Определяется длина начального фрагмента CS1, который не содержит ни одного символа из CS2 |
strtok(S1,CS2) |
Поиск в строке S1 лексем, разделенных символами CS2 |
Некоторые из функций, приведенные в табл. 5.2, нуждаются в дополнительных пояснениях.
В функциях strtol и strtoul, выполняющих преобразование символьного представления числа в соответствующий машинный формат, допускается задание r=0. В этом случае основание системы определяется символьной записью числа. Если строка начинается с символа '0', за которым следуют символы цифр, не превосходящих 7, то число считается восьмеричным. Если строка начинается с комбинации '0x' или '0X', вслед за которой располагаются шестнадцатеричные цифры, то считается, что r=16.
В функции strtok лексемой считается цепочка символов, завершающаяся одним из предусмотренных символов-разделителей. При первом обращении к этой функции в строке S1 находится начальная лексема и возвращаемое значение является указателем на ее начальный символ. Одновременно в строку S1 на место обнаруженного символа-разделителя заносится нулевой байт. Это позволит в дальнейшем работать с найденной лексемой как со строкой. Для поиска следующих лексем в повторных обращениях к функции strtok вместо первого аргумента нужно задавать нулевой аргумент. Функция будет искать следующую лексему, расположенную правее принудительно вставленного нулевого байта. И так можно последовательно обнаружить все лексемы, содержавшиеся в строке S1. Для пояснения приведем следующий пример:
#include <stdio.h>
#include <conio.h>
#include <string.h>
void main()
{ char *ptr;
ptr=strtok("FEB.14,2006",".,-/");
while(ptr!=NULL)
{ printf("ptr=%s\n",ptr);
ptr=strtok(NULL, ".,-/");
}
getch();
}
//=== Результат работы ===
ptr=FEB
ptr=14
ptr=2006
При запуске приложения MS-DOS или консольных приложений дисплей работает в текстовом режиме (в Windows такой режим эмулируется), для которого характерно разделение экрана на строки и столбцы (обычно строк 25, а столбцов 80, но существуют и другие текстовые режимы). На каждом пересечении строки и столбца находится условный прямоугольник знакоместо, в котором размещается отображаемый символ. На экране выделяется текущее знакоместо, помеченное мерцающим курсором (мигающей черточкой или прямоугольником). Именно в этой позиции появляется символ, набираемый пользователем на клавиатуре, или символ, отображаемый программой.
Системы программирования на базе языка C, предоставляют пользователю некоторый набор возможностей по размещению информации на площади экрана и по управлению цветовыми атрибутами отображаемых символов.
Очистку экрана выполняет функция clrscr(), ее название образовано от сокращения английских слов clear (очистить) и screen (экран).
Для перемещения курсора в заданную позицию экрана используется функция
gotoxy(x,y). Ее аргументы определяют номер столбца x (0x<80) и строки y (0y<25) того знакоместа, которое станет текущим.
Функция window(x1,y1,x2,y2) позволяет выделить на экране прямоугольную область, в пределах которой будет происходить вывод. По умолчанию областью вывода считается весь экран, его левому верхнему углу соответствуют нулевой столбец и нулевая строка, а правому нижнему углу 79-й столбец и 24-я строка. Иногда бывает полезно сузить область вывода с тем, чтобы в оставшейся части экрана расположить какую-то заготовку (формуляр бланка, например, или другую пояснительную информацию, не затираемую выводимыми данными и не смещающуюся при выводе). Знакоместо с координатами (x1,y1) задает положение левого верхнего угла окна вывода, а знакоместо с координатами (x2,y2) положение правого нижнего угла окна вывода.
Так как почти все современные дисплеи цветные, то текстовый режим предоставляет возможность раскрасить каждый выводимый символ. При этом имеется возможность автономно задать цвет фона знакоместа и цвет контура отображаемого символа. Дело в том, что в текстовом режиме для каждого символа, отображаемого на экране, выделено 2 информационных байта. В первом байте располагается код ASCII символа, а во втором хранятся его цветовые атрибуты (рис. 5.1)
Рис. 5.1. Цветовые атрибуты
Цвет формируется в результате наложения трех цветовых компонент красного (R), зеленого (G) и синего (B). Цвет символа может иметь повышенную яркость (признак J=1). Для привлечения внимания к сообщению на экране символ можно заставить мерцать (признак M=1).
Для установки цвета символов во всех сообщениях, выдаваемых с помощью функции cprintf, предназначена функция textcolor(fc). Ее аргумент должен принадлежать диапазону [0, 15]. Цвет фона для последующего вывода по cprintf устанавливается с помощью функции textbackgrount(bc). Имеется возможность произвести установку цветовых атрибутов за один раз, обратившись к функции textattr. Ее единственным аргументом может быть одно из двух следующих выражений:
(bc<<4)+fc //цвет фона и цвет символов
128+(bc<<4)+fc //мерцание, цвет фона и цвет символов
В качестве примера вывода разноцветного текста приведем следующую программу:
#include <conio.h>
void main()
{ int j;
textbackground(0);
clrscr();
for(j=0; j<24; j++)
{ gotoxy(2*j+1,j+1);
textcolor(128+j);
textbackground(j+2);
cprintf("Color palette");
}
getch();
}
Любая программная единица на языках C, C++ оформляется как функция, причем в отличие от языка Pascal функции не могут быть вложены друг в друга. Поэтому функция представляется как некий кирпичик, который может быть размещен в любом месте программы. А вся программа состоит из последовательности таких кирпичиков, среди которых обязательно присутствует главный функция с именем main.
Описание любой функции начинается с ее заголовка, имеющего вид:
trv namef(type1 par1,type2 par2,...)
Здесь trv тип возвращаемого значения;
namef имя функции;
par1 имя первого аргумента функции, имеющего тип type1;
par2 имя второго аргумента функции, имеющего тип type2;
………………………………………………………….
Функция может не возвращать значение и тогда на месте trv указывается служебное слово void. Функция может не иметь аргументов, но тогда после ее имени указываются либо пустые скобки, либо скобки, содержащие служебное слово void.
Если аргументы (параметры) функции являются скалярными (т.е. одиночными) величинами, то различают три способа передачи параметров:
Пример 1. Функции передаются два значения, по которым она находит и возвращает среднее арифметическое.
double mid(double x,double y)
{ return (x+y)/2.0; }
Пример 2. Функции передаются два параметра по указателю. Функция меняет местами значения переданных ей переменных.
void swap(int *x,int *y)
{ int tmp;
tmp=*x; *x=*y; *y=tmp;
}
Пример 3. Функции передаются два параметра по ссылке. Функция меняет местами значения переданных ей переменных.
void swap(float &x,float &y)
{ float tmp;
tmp=x; x=y; y=tmp;
}
По сути дела, два последние способа передачи параметров одинаковы и в том, и в другом случае функции сообщают адрес расположения параметра в оперативной памяти. По этому адресу функция может извлечь текущее значение параметра или записать на его место новое значение. Разница только в форме обращения по полученному адресу. Ссылки делают это обращение более простым, к имени параметра не надо ничего добавлять. При работе с указателями выборка и запись по указанному адресу сопровождается дополнительным символом *.
Так как функция может находиться в любом месте программы (по отношению к точкам программы, из которых производится вызов функции), то компилятор должен удостовериться в правильности обращения к функции. То-есть, он должен знать количество параметров, их последовательность и тип каждого параметра. Для этой цели заголовки всех используемых функций дублируют в начале программы, завершая каждый из них точкой с запятой. Такие строки принято называть прототипами функций.
Пример 4. Заголовки функций вынесены в список прототипов.
double mid(double x, double y);
void swap1(double *x,double *y);
void swap2(double &x,double &y);
void main()
{ double a=1.5, b=-2.5,c;
c=mid(a,b);
swap1(&a,&b);
swap2(b,c);
.............
}
double mid(double x, double y)
{ return (x+y)/2.0; }
void swap1(double *x,double *y)
{ double tmp;
tmp=*x; *x=*y; *y=tmp;
}
void swap2(double &x,double &y)
{ double tmp;
tmp=x; x=y; y=tmp;
}
Вообще говоря, имена формальных параметров в заголовке функции и в ее прототипе могут не совпадать. Более того, некоторые программисты предпочитают указывать в прототипе только типы формальных параметров, опуская их имена. Например:
double mid(double, double);
Для нужд компилятора, который проверяет в вызовах функций только количество параметров и их тип, этого достаточно. И синтаксис языков C, C++ разрешает так делать. Но, на наш взгляд, выбор запоминающихся мнемонических имен формальных параметров и сохранение их в прототипах могут уменьшить вероятность появления ошибок, связанных с перестановкой данных. Например, как догадаться о смысле параметров по следующему прототипу функции, определяющей точку пересечения двух отрезков:
int intersect(double&,double&,double&,double&,double&,double&);
В каком из параметров, в первой паре или в последней, надо указывать ссылки на координаты точки пересечения. Очевидно, что более информативным был бы прототип следующего вида:
int intersect( double &x1, double &y1, double &x2, double &y2,
double &x, double &y);
Данные (переменные и константы), используемые в каждой функции, могут быть объявлены как в теле функции, так и за пределами всех функций. В первом случае они являются индивидуальной собственностью той функции, где они объявлены. Говорят, что они локализованы в этой функции, и с ними связывают термин локальные данные. Другие функции об этих данных ничего не знают, и пользоваться ими не могут. В отличие от этого описания некоторых данных могут быть вынесены за пределы всех функций обычно их выносят в заголовочные файлы или размещают в начале программы. Такими данными может воспользоваться любая функция из этого же программного файла, и применительно к ним говорят о глобальных данных.
Если в какой-то функции объявлена локальная переменная, и ее имя совпадает с именем глобальной переменной, то это не считается ошибкой. Просто данная функция отказывается иметь дело с глобальной переменной и предпочитает у себя использовать только свою локальную переменную. В некоторых функциях, возможно, хочется использовать обе переменные. Тогда перед именем глобальной переменной размещают двойное двоеточие, чтобы отличать ее значение от значения локальной переменной с тем же именем.
#include <iostream.h>
#include <conio.h>
int x=20; //глобальная переменная
void main()
{ int x=40; //локальная переменная
cout << "Local x=" << x << endl;
cout << "Global x=" << ::x << endl;
getch();
}
Глобальным переменным место в памяти выделяется до начала исполнения программы, и это место сохраняется за ними до завершения работы программы. В отличие от этого место для хранения локальных переменных выделяется только в момент вызова функции, а при выходе из функции выделенные ресурсы возвращаются системе. Поэтому значения локальных переменных пропадают, их бывшее место в оперативной памяти будет перераспределено под нужды других функций. Однако существует специальная группа локальных переменных, которая описывается внутри функции со спецификатором static. Ячейки памяти, выделенные для их хранения, фиксируются до окончания работы всей программы. При повторном обращении к функции значения ее статических переменных сохранены, и функция вновь может ими воспользоваться. Однако для других функций внутренние статические переменные недоступны, это собственность объявившей их функции.
Объявление переменных в общем случае выглядит следующим образом:
[static] tv namev [=value]
Здесь tv тип переменной;
namev имя переменной;
value начальное значение переменной.
Если переменная объявляется как глобальная и ее начальное значение не указано, то системы BC 3.1 и BCB выделяют ей соответствующий участок памяти и заносят нули в выделенные байты. Однако из соображений переносимости программы не стоит рассчитывать на такую чистку памяти. Лучше принудительно задавать те или иные значения (в том числе и нулевые) этот способ никогда не подведет.
Если переменная объявляется как локальная и ее начальное значение задано, то оно заносится в такую переменную при каждом вызове функции (при условии, что эта переменная не объявлена статической).
Объявление глобальной переменной тоже может сопровождаться спецификатором static. Это имеет смысл, когда полный текст программы разбросан по нескольким файлам. В этом случае статические глобальные переменные доступны только тем функциям, которые включены в тот же файл. Из других файлов эти переменные не доступны. Для ссылок на глобальные переменные, описанные в другом файле, обычно используют спецификатор extern (от англ. external внешний):
double qq(int n,double r)
{ extern float eps;
...................
Глобальные переменные, объявленные в этом же файле, в таком дополнительном пояснении не нуждаются.
Для объявления именованных констант обычно используют следующую конструкцию:
const [tc] namec=value;
Здесь tc необязательный тип константы (по умолчанию tc=int);
namec имя константы;
value значение константы.
Например:
const Nmax=100;
const double eps=1e-6;
Иногда для задания таких же констант прибегают к механизму простейшей макроподстановки:
#define Nmax 100
#define eps 1e-6
Это означает, что перед трансляцией программы компилятор (точнее, прекомпилятор) просмотрит ее текст и всюду, где будет встречено сочетание символов Nmax,его заменят на число 100, а сочетание символов eps на число 1e-6. Результат будет тем же самым, но работа по макроподстановке связана с более заметными затратами времени.
Оператор присваивания является наиболее распространенным средством, позволяющим изменить значение переменной во время работы программы.
В простейшем случае он имеет следующий формат:
namev = expression;
Здесь namev имя переменной:
expression выражение, значение которого будет присвоено переменной namev.
Если переменная namev относится к категории числовых данных, то результат вычисления выражения тоже должен быть числовым. Типы переменной namev и значения выражения expression при этом могут не совпадать. О соответствующем преобразовании машинных форматов система позаботится сама. Но не забывайте, что при преобразовании вещественного значения в целочисленное дробная часть выражения (какой бы она ни была) будет отброшена. Таким образом, об округлении вы должны позаботиться сами. В тех случаях, когда тип выражения допускает более широкий диапазон представления данных, возможна потеря точности или переполнение диапазона, предусмотренного для переменной namev. В таких случаях компилятор выдает сообщение о возможных последствиях, и на такие сообщения программист обязан обратить внимание.
Если нескольким переменным необходимо присвоить значение одного и того же выражения, то оператор присваивания допускает расширенный формат:
v1=v2=v3=exp;
Используя такую форму множественного присвоения, не пытайтесь писать операторы, результат выполнения которых может по разному толковаться разными пользователями. Например:
i=5;
i=a[i]=4;
j=6;
a[j]=j=2;
Результат таких действий зависит от алгоритма просмотра строки программы и последовательности выполнения присваиваний слева направо или справа налево. Старайтесь избегать подобных ситуаций, т.к. в случае переноса программы в другую систему программирования можно получить неожиданный результат.
В программах на C, C++ очень часто встречаются специфические форматы записи операторов присваивания. В тех случаях, когда значение переменной должно быть увеличено или уменьшено на 1, используются следующие синтаксические конструкции:
x++; //вместо x=x+1;
++x; // вместо x=x+1;
x--; // вместо x=x-1;
--x; // вместо x=x-1;
В приведенных примерах размещение двойного знака сложения или вычитания роли не играет. Однако такая конструкция может входить в качестве операнда в арифметическое выражение (что встречается не очень часто) или выступать в роли индекса у элементов массива. Например:
y=a[i++];
z=b[++j];
В первом случае переменной y будет присвоено значение a[i] со старым индексом, после чего индекс i будет увеличен на 1. Во втором случае сначала индекс j будет увеличен на 1, а потом значение b[j] с новым индексом будет участвовать в операции присвоения.
Еще одна форма оператора присваивания ведет свое происхождение от условного арифметического выражения, впервые появившегося в языке АЛГОЛ-60:
v = (a > b)? e1 : e2;
Выполняется такой оператор следующим образом. Проверяется условие, записанное в круглых скобках и, если оно удовлетворяется, то переменной v присваивается значение выражения e1. В противном случае в переменную v засылается значение выражения e2.
Следующая группа операторов присваивания обязана своим происхождением формату машинных команд в двухадресных машинах:
COD A1,A2
Здесь COD код машинной операции;
A1,A2 адреса ячеек оперативной памяти и/или арифметических регистров.
Результат выполнения такой операции над содержимым A1 и A2 записывается по адресу A1 (A1=A1A2). По аналогии с такой записью в языках C, C++ появилась следующая синтаксическая конструкция:
v = exp; //вместо v = v exp;
Здесь символ обозначает один из следующих знаков двухместных операций:
+,-,*,/,%,<<,>>,&,|,^
Например:
x += 2; //вместо x=x+2;
z /= 1.5; //вместо z=z/1.5;
Довольно экзотически выглядит оператор присваивания, в правой части которого через запятую задана последовательность некоторых действий. Например:
x=(y=sin(a+b),z=cos(a-b),max(y,z));
Действия в скобках выполняются слева направо, т.е. сначала будет вычислено значение sin(a+b), которое занесут в переменную y, затем будет вычислено значение переменной z. Окончательным результатом скобки, которое будет присвоено переменной x, станет вычисление значения функции max(y,z).
С помощью условного оператора в программе можно создать две альтернативные цепочки операторов, выполнение каждой из которых происходит в зависимости от истинности или ложности заданного условия:
if(условие) {S1; S2;...} [else {Q1;Q2;...}]
Если условие, указанное в круглых скобках истинно, то выполняется цепочка операторов S1, S2,… . В противном случае выполняется цепочка операторов Q1,Q2,… . Альтернативная ветвь вместе со служебным словом else может отсутствовать. И тогда речь идет о выполнении или обходе цепочки операторов S1,S2,… .
Простейшие условия задаются в виде проверки соотношения двух однотипных выражений:
if(a>b) cout<<a; else cout<<b;
if(x>=0) y=sqrt(x);
Напомним, что операции отношения в языках C, C++ обозначаются следующими символами и их комбинациями:
== |
равно |
!= |
не равно |
> |
больше |
>= |
больше или равно |
< |
меньше |
<= |
меньше или равно |
Более сложные условия могут составляться из элементарных соотношений с помощью логических операций "ИЛИ" (||), "И" (&&), "НЕ" (!). Например, проверка принадлежности x диапазону [a,b] выглядит следующим образом:
if(a<=x && x<=b) ...
В качестве логического условия иногда можно встретить в круглых скобках обычное арифметическое выражение:
if(a)...
В языке C логические переменные отсутствуют. Вместо этого действует соглашение о том, что ложь эквивалентна нулевому значению числового выражения, а истина не нулевому значению. Поэтому приведенная выше проверка эквивалентна соотношению:
if(a != 0)...
Перед любым исполняемым оператором программы может находиться символьная метка, отделяемая от оператора двоеточием:
m5: printf("\nx=%f",x);
На помеченный таким образом оператор может быть передано управление с помощью оператора goto m5. Но такие переходы допустимы только внутри функции. Переход из одной функции в другую по оператору goto недопустим.
В ряде книг можно встретить утверждение, что пользоваться этим оператором ни в коем случае нельзя. Однако это не так. Оператором goto пользоваться можно, а вот злоупотреблять им действительно не рекомендуется. Бездумное и неумеренное использование операторов goto приводит к появлению программ трудных для понимания, обладающих запутанной логикой, мало пригодных к внесению изменений.
Рассмотрим две реализации программы, которая вводит три целочисленных значения и выводит максимальное из них. В первой из них (программа-спагетти) на 10 исполняемых строк приходится 5 операторов goto. Во второй реализации потребовалось всего 5 исполняемых строк и ни одного оператора goto.
Пример 6.1. Программа-спагетти.
#include <stdio.h>
#include <conio.h>
void main()
{ int a,b,c;
scanf("%d %d %d",&a,&b,&c);
if(a>b) goto m1;
if(b>c) goto m2;
m3: printf("max=%d",c);
goto m5;
m1: if(c>a) goto m3;
m4: printf("max=%d",a);
goto m5;
m2: printf("max=%d",b);
m5: getch();
}
Пример 6.2. Пример прозрачной логики
#include <stdio.h>
#include <conio.h>
void main()
{ int a,b,c;
scanf("%d %d %d",&a,&b,&c);
if(b>a) a=b;
if(c>a) a=c;
printf("max=%d",a);
getch();
}
Одним из случаев, когда применение оператора goto приводит к более простой и более эффективной программе является необходимость выйти из внутреннего цикла за пределы внешнего цикла:
int i,j;
for(i=0; i<n; i++) {...
for(j=0; j<20; j++) {...
if(условие_выхода) goto mm;
}
}
mm:
Конечно, можно придумать более запутанную схему, не используя goto:
int i,j,k=1;
for(i=0; i<n && k; i++) {...
for(j=0; j<20 && k; j++) {...
if(условие_выхода) {k=0; break;}
}
}
Применяя оператор goto, стоит следовать следующим рекомендациям:
Операторы цикла позволяют организовать повторное выполнение фрагмента программы до тех пор, пока не выполнится некоторое условие. Конечно, аналогичные действия могут быть оформлены с использованием условных операторов (if…) и безусловных переходов (goto) в начало повторяющегося фрагмента. Однако использование операторов цикла позволяет сделать логику работы программы более прозрачной и немного более эффективной за счет применения специальных машинных команд, которые вставляет компилятор.
Наиболее часто применяемая конструкция цикла использует оператор for:
for(S1; C; S2) { Q1; Q2;...}
Здесь S1 действие, которое выполняется перед повторением группы операторов Q1, Q2, … , образующих тело цикла;
C условие, при выполнении которого тело цикла повторяется. Если условие C не выполнено, то тело цикла обходится и цикл завершается;
S2 действие, которое выполняется вслед за последним оператором тела цикла. После этого управление передается на проверку условия завершения цикла.
Одна из форм оператора for позволяет организовать повторение тела цикла заданное число раз, изменяя при каждом повторении значение некоторой управляющей переменной (иногда такую переменную называют счетчиком цикла или индексом цикла):
s=0;
for(j=0; j<10; j++)
s=s+a[j];
В приведенном примере тело цикла состоит из единственного оператора, который можно не заключать в фигурные скобки. Перед началом цикла в переменную j заносится 0 и поскольку это значение меньше 10, то к содержимому переменной s прибавляется значение первого элемента массива a[0]. После первого завершения тела цикла к счетчику j добавляется 1 и тело цикла повторяется. Так продолжается до тех пор, пока значение счетчика не увеличится до 10, при котором условие повторения цикла уже не выполняется.
Если счетчик цикла используется только в теле цикла и значение этой переменной за пределами цикла сохранять не нужно, то стандарт C++ позволяет определить такую суперлокальную переменную непосредственно в операторе for:
for(int j=0; j<10; j++)
Значение счетчика (индекса) может не только увеличиваться при каждом повторении цикла, но и уменьшаться:
s=0;
for(j=9; j>=0; j--)
s=s+a[j];
В этом примере массив a суммируется, начиная с последнего элемента.
Иногда цикл по счетчику в обратном порядке организуют следующим образом:
count=20;
for(s=0; count; count--)
s += a[count];
В этом примере условием повторения цикла является положительное значение счетчика count, которое воспринимается как "истина". Как только содержимое счетчика станет равным нулю (т.е. "лжи"), цикл завершит свою работу.
В отличие от языка Паскаль, где оператор for допускает только увеличение или уменьшение счетчика цикла только на 1, оператор for в языках C, C++ позволяет изменять значение управляющей переменной произвольным образом. Эта переменная может быть вещественного типа, и ее значение может меняться не только по закону арифметической прогрессии, но и любым сколь угодно сложным способом. Например, можно просуммировать только элементы массива с четными индексами:
s=0;
for(j=0; j<10; j+=2)
s=s+a[j];
Действие S1, выполняемое при входе в цикл, может состоят не только из единственного оператора, засылающего в счетчик цикла начальное значение. Если нужно выполнить несколько операторов, то их разделяют запятыми (список действий, аналогичный экзотической форме оператора присваивания). В приведенных выше примерах очистку переменной s тоже можно было внести в оператор заголовка цикла:
for(s=0,j=0; j<10; ++j)
s=s+a[j];
Аналогичный список из нескольких операторов можно использовать и в качестве действия S2, выполняемого вслед за последним оператором тела цикла. В приведенном ниже примере оператор цикла вычисляет n-й член последовательности чисел Фибоначчи:
for(x=1,y=1,i=2; i<n; z=x+y, x=y, y=z, i++);
Иногда в программах можно встретить на первый взгляд бессмысленный оператор for:
for(;;){Q1;Q2;...;}
Судя по описанию оператора for, тело такого цикла должно выполняться бесконечное число раз. Однако такие циклы могут оказаться полезными в DOS-приложениях для организации ожидания какого-либо события, появление которого проверяется в теле цикла. Если событие произошло (например, ввод информации со стороны пользователя состоялся, или по каналу связи поступило долгожданное сообщение), то из тела цикла можно выйти с помощью оператора break или goto. В среде Windows существуют и более эффективные (с точки зрения загрузки процессора) средства, контролирующие появление того или иного события.
Вторая конструкция оператора цикла начинается со служебного слова while (от англ. пока):
while(условие) {Q1; Q2;...}
Вход в такой цикл начинается с проверки условия, заданного в круглых скобках. Если это условие истинно, то тело цикла (операторы Q1, Q2,…) выполняется. В случае невыполнения условия тело цикла обходится. С помощью оператора while тоже можно организовать суммирование элементов массива:
s=0; i=0;
while(i<10)
{ s=s+a[i]; i++; }
Согласитесь, что этот вариант выглядит менее изящно, чем его аналог с оператором for, хотя здесь присутствуют те же элементы, которые раньше фигурировали в заголовке цикла for. Однако в других итерационных процессах использование цикла while может оказаться более уместным. Например, итерационная схема вычисления корня квадратного из x (аналогичные схемы можно написать и для корней других степеней) имеет следующий вид:
yn+1=0.5*(yn +x/yn)
Для ее реализации можно воспользоваться следующим циклом:
y=x; //начальное приближение
while(fabs(y-x*x)>1e-6)
y=0.5*(y+x/y);
Третья конструкция цикла начинается с оператора do (от англ. выполнить) и завершается проверкой условия продолжения цикла while:
y=x; //начальное приближение
do
y=0.5*(y+x/y);
while(fabs(y-x*x)>1e-6)
Отличие конструкции do while от двух предыдущих операторов цикла заключается в том, что здесь тело цикла выполняется, по крайней мере, один раз. Этот цикл иногда сопровождают термином с постусловием т.е. проверка условия продолжения цикла производится после выполнения его тела. В отличие от этого два предыдущих цикла начинаются с проверки заданного условия (циклы с предусловием) и в некоторых случаях тело таких циклов может ни разу не выполниться.
Обратите внимание на разницу между организацией цикла с постусловием в Паскале и языках C,C++. В программе на языке Паскаль
Все конструкции операторов цикла допускают вложение других циклов в свои тела. Например, для умножения квадратных матриц можно воспользоваться следующим фрагментом программы:
for(i=0; i<n; i++)
for(j=0; j<n; j++)
{for(d=0,k=0; k<n; k++)
d += a[i][k]*b[k][j];
c[i][j]=d;
}
К числу дополнительных средств управления циклами относятся операторы break (от англ. прервать) и continue (от англ. продолжить).
С помощью оператора break организуется досрочное окончание цикла с передачей управления оператору, следующему непосредственно за концом цикла. Пусть, например, нам предстоит суммирование элементов массива a до тех пор, пока не встретится первое отрицательное значение:
for(s=0,j=0; j<n; j++)
{ if(a[j]<0) break;
s += a[j];
}
Однако если оператор break употреблен во внутреннем цикле, то с его помощью нельзя выйти за пределы внешнего даже в том случае, когда кажется, что тело внешнего цикла кончается там же, где и тело внутреннего. На самом деле, в конце каждого цикла незримо присутствуют системные вставки, обеспечивающие нормальный выход из цикла. В частности, такие вставки возвращают память, выделенную под переменные, объявленные в заголовке цикла. Кроме того, здесь же находятся команды, возвращающие управление в начало цикла при необходимости повторения итераций.
Оператор continue позволяет не выполнять расположенную ниже часть тела цикла, но продолжить итерационный процесс. Пусть, например, нам потребовалось найти среднее арифметическое среди положительных элементов массив а:
for(s=0.0,j=0,k=0; j<n; j++)
{ if(a[j]<=0) continue;
s += a[j];
k++; //счетчик количества положительных слагаемых
}
if(k>0) s=s/k;
Оператор выбора расширяет возможности условного оператора if, с помощью которого можно было организовать разветвление по двум направлениям: одно в случае истинности проверяемого условия, другое в случае ложности. Несколько операторов if позволяют сделать большее количество разветвлений. Например, простейший калькулятор можно было бы смоделировать следующим образом:
cin >>x>>y; //ввод операндов
cin >> ch; //ввод символа операции
if(ch=='+') {z=x+y; goto m;}
if(ch=='-') {z=x-y; goto m;}
if(ch=='*') {z=x*y; goto m;}
if(ch=='/' && y!=0) {x=x/y; goto m;}
cout << "Эта операция не определена"<<endl;
z=0;
m:
cout << "Result="<<z<< endl;
Примерно такую же функциональность обеспечивает фрагмент программы с переключателем switch:
cin >>x>>y; //ввод операндов
cin >> ch; //ввод символа операции
switch(ch)
{
case '+': z=x+y; break;
case '-': z=x-y; break;
case '*': z=x*y; break;
case '/': if(y!=0) {x=x/y; break;}
default : cout << "Эта операция не определена"<<endl; z=0;
}
cout << "Result="<<z<< endl;
После служебного слова switch (от англ. переключатель) в круглых скобках записывается переключающее выражение, которое может быть целочисленным или символьным. Тело переключателя всегда является составным оператором и заключается в фигурные скобки. Вслед за служебным словом case (от англ. случай) записывается константа, с которой сравнивается значение переключающего выражения. Если совпадение обнаружено, то управление передается оператору, отделенному от константы двоеточием. На его месте может оказаться либо один оператор, либо цепочка операторов. И в том, и в другом случае эти действия должны завершаться оператором break, передающим управление оператору, расположенному вслед за переключателем.
Если значение переключающего выражения не совпадает с указанной константой, то из тела переключателя выбирается следующая строка, начинающаяся со служебного слова case, и проверки продолжаются. Самой последней строкой переключателя может быть строка, начинающаяся со служебного слова default (от англ. несоблюдение правил). На нее управление попадает только в том случае, когда значение переключающего выражения не совпадает ни с одной из предусмотренных констант. В этом случае выполняются операторы, отделенные от слова default двоеточием. В переключателе может и не быть строки с непредусмотренной ситуацией, Тогда в случае несовпадения значения переключающего выражения ни с одной из заданных констант переключатель ничего не делает, т.е. срабатывает как пустой оператор.
Отсутствие оператора break в качестве завершающего действии какой-либо группы приведет к тому, что начнут выполняться операторы, принадлежащие следующей группе (в этом случае никакой проверки на совпадение со следующей константой уже не происходит часть следующей строки "case c:" просто игнорируется)
Обращение к функциям, не возвращающим значение, выглядит следующим образом:
namef(a1,a2,...);
Здесь namef имя вызываемой функции;
a1, a2, … список фактических параметров
Если формальный параметр в заголовке функции объявлен как значение, то соответствующим ему фактическим параметром может быть выражением такого же типа. Если формальный параметр объявлен как указатель, то на месте соответствующего ему фактического параметра может находиться только адрес данного такого же типа. Исключение составляют формальные параметры, объявленные с помощью нетипизированного указателя void *, им могут соответствовать адреса переменных любого типа. Для одиночных (скалярных) переменных адрес формируется из имени переменной с предшествующим ему символом &. Для массивов адресом считается имя массива. Например:
char str[80];
int x;
........
scanf("%d %s",&x,str);
Однако если нам предстоит ввод в определенный элемент массива, то нужно указывать его адрес. Например, &str[5] или str+5:
scanf("%c",&str[5]);
scanf("%c",str+5);
Если формальный параметр в описании функции объявлен как ссылка, то на месте соответствующего фактического параметра задается имя переменной. Например:
void swap(int &a,int &b)
{ int tmp=a;
a=b; b=tmp;
}
..........
swap(x,y);
Для функций, возвращающих значение, возможны два варианта вызова. Основной вариант предусматривает использование вызываемой функции в качестве операнда соответствующего выражения:
y=a*fin1(x,-2)+b;
В этом случае сначала будет вычислено значение операнда fin1(x,-2), которое затем будет использовано при подсчете значения выражения.
Второй вариант заключается в игнорировании возвращаемого значения. Пример подобного рода использование функции scanf. Вообще говоря, кроме своей основной миссии (ввод данных, поступающих с терминала), она возвращает количество полей ввода, обработанных без ошибок. Для организации тщательного контроля за вводимыми данными это значение может оказаться полезным. Однако в большинстве практических программ на языке C вы вряд-ли обнаружите обращение следующего вида:
k=scanf("%d %s",&x,str);
Комментарии обязательная принадлежность каждой программы. В комментарий, расположенный вслед за заголовком функции, обычно выносится информация о назначении функции и смысле ее параметров. Описание нетривиального алгоритма, как правило, снабжается пояснениями, которые помогут разобраться в тексте программы персоналу сопровождения программных продуктов. Наконец, отключение части программы путем перевода ее в разряд комментариев наиболее употребительный прием отладки программ.
В языках C, C++ предусматривается две разновидности комментариев многострочные и однострочные.
Многострочные комментарии начинаются с пары символов "/*" и заканчиваются такой же парой, записанной в обратном порядке "*/". Комментарий подобного рода вовсе не обязан содержать несколько строк. Он, в частности, может находиться в любом месте программной строки, например, для исключения из рассмотрения компилятора фрагмента формулы:
y=sin(/*x+*/0.25)*q;
Довольно часто в многострочный комментарий заключают фрагмент программы, который не должен компилироваться в текущем сеансе.
Однострочный комментарий начинается вслед за парой символов "//" и продолжается до конца программной строки. Обычно с его помощью записывается комментарий к текущей строке или исключается фрагмент текущей строки из области обслуживания компилятором.
Указатели это переменные специального типа, значениями которых является адреса различных объектов программы. Если мы используем имя того или иного объекта для извлечения его значения или для изменения его значения, то принято говорить о непосредственном (прямом) доступе к объекту. В том случае, когда адрес объекта помещен в указатель, то речь идет о косвенном доступе к объекту, на который "смотрит" указатель.
Идеи такой косвенной адресации зародились еще в архитектуре ЭВМ первого поколения, когда адрес нужной ячейки памяти помещался в специальный регистр (например, РА регистр адреса в ЭВМ типа М-20). Доступ по содержимому регистра предоставлял программистам более широкие возможности за счет наличия машинных команд изменения содержимого такого регистра (автоматическое увеличение-инкрементирование или уменьшение-декрементирование на 1) и использование РА в командах организации циклов. Наиболее полное воплощение идеи косвенной адресации нашли в проекте адресного языка, разработанного профессором Е.Л. Ющенко (Институт кибернетики АН УССР, Киев).
В языках C, C++ различают три категории указателей. Первая категория указателей предназначена для хранения адресов данных определенного типа (по терминологии языка Паскаль типизированные указатели). При их объявлении указывается тип данных, на которые эти указатели могут "смотреть". Ко второй категории относятся указатели, которые могут "смотреть" на данные любого типа (по терминологии языка Паскаль не типизированные указатели). При их объявлении используется служебное слово void. Наконец, третью группу составляют указатели, значениями которых могут быть только адреса точек входа в функции (по терминологии языка Паскаль данные процедурного типа). Объявление и использование указателей разных категорий имеет свою специфику.
Для объявления одного указателя с именем p1 или нескольких указателей с именами p1, p2, …, которые должны будут "смотреть" на объекты типа type1, используется одна из следующих синтаксических конструкций:
type1 *p1;
type1 *p1,*p2,...;
Объявленные указатели еще никуда конкретно не смотрят в выделенных им участках памяти находится "грязь". И одна из типичных ошибок начинающих программистов попытка записать что-либо по указателям, которым еще не присвоены значения. Инициализацию указателя можно совместить с его объявлением:
int x=2,y;
int *p1=&x; //инициализация адресом переменной x
int *p2(&x); //инициализация адресом переменной x
int *p3=p1; //инициализация значением другого указателя
Указатель является переменной и его значение можно задать или изменить с помощью оператора присваивания:
p1=&y; //теперь значением p1 является адрес переменной y
Если целочисленному указателю p1 присваивается имя массива a или его адрес, то это эквивалентно засылке в p1 адреса первого элемента массива a[0]:
int a[10];
int *p1=a; //p1 смотрит на начало массива a
int *p2=&a[0]; //p2 тоже смотрит на начало массивa a
int *p3=&a; //p3 тоже смотрит на начало массивa a
Когда указатель p1 "смотрит" на переменную x, то по значению указателя можно извлечь значение переменной x или изменить его:
int x=5,y;
int *p1=&x; //значением p1 является адрес x
..........
y=*p1; //теперь значение переменной y равно 5
*p1=2; //теперь значение переменной x равно 2
Когда указатель p2 "смотрит" на начало массива q, то доступ к элементам этого массива можно организовать одним из следующих способов:
int q[20];
int p2=q;
...........
y=*(p2+5); //теперь y=q[5]
x=p2[3]; //теперь x=q[3]
*(p+1)=7; //теперь q[1]=7
В программах на языке C можно встретить нагромождение символов "*". Пугаться не надо это просто многоступенчатая адресация:
int x,y;
int *p1=&y;
int **p2=&p1;
x=**p2; //то же, что x=*(*p2)=*(p1)=y
Объявление не типизированного указателя выглядит следующим образом:
void *pu;
Не типизированному указателю может быть присвоено значение указателя любого типа. Однако непосредственно извлечь или изменить значение по не типизированному указателю нельзя. Приходится прибегать к приведению типов:
#include <iostream.h>
#include <conio.h>
void main()
{ int x=5;
void *p=&x;
int *p1;
p1=(int*)p; //приведение указателя p к типу int*
cout<<"x="<<*p1<<endl;
getch();
}
Для объявления указателя pf на функцию типа double f(double x) имя указателя заключается в круглые скобки:
double (*pf)(double x);
Обратите внимание, что вместо имени функции здесь указано имя указателя в круглых скобках. А в качестве его конкретного значения можно задать адрес любой функции с аргументом типа double, которая возвращает значение типа double:
#include <iostream.h>
#include <conio.h>
#include <math.h>
void main()
{ double (*pf)(double x); //объявление указателя на функцию
double x=0.2;
pf=sin; //присвоение значения указателю
cout<<"sin(0.2)="<< pf(x) <<endl; //обращение по указателю
cout<<"sin(0.2)="<<(*pf)(x)<<endl;//обращение по указателю
getch();
}
Из двух возможных вариантов обращения к функции по указателю, наверное, надо отдать предпочтение первому, как более естественному. Хотя второй более выдержан в плане философии использования указателей.
Так как значениями указателей являются адреса ячеек оперативной памяти, то указатели можно сравнивать. Очевидно, что сравнение на равенство или на неравенство более информативно, чем сведения о том, какой объект лежит "выше" или "ниже".
Основные операции, чаще всего применяемые к указателям сложение указателя с целым числом или вычитание из указателя целого числа. Обе они широко применяются в том случае, когда указатель связан с массивом. По сути дела, эти операции эквивалентны аналогичным процедурам над индексами элементов массива. И точно так же как прибавление к индексу означает переход к следующему элементу массива, прибавление 1 к указателю означает увеличение его текущего значения на количество байт, соответствующих типу указателя (точнее, типу данных, на которые указатель обязан смотреть):
int q[6]={1,2,3,4,5,6};
int *p = &q{3];
cout << *p++ <<endl; //выводится 4, p=&q[4]
cout << (*p)++ <<endl; //выводится 6=5+1
cout << *(p++) <<endl; //p=&q[5], выводится 6
Если при обработке некоторого массива используются два указателя p1 и p2, продвигаемые навстречу друг другу, то их разность (p2-p1) определяет количество элементов массива расположенных между этим двумя адресами.
Ссылки представляют особый вид данных, напоминающих указатели. Будучи объявлены в функции, они должны быть связаны с адресами конкретных объектов и после этого изменять свои значения не могут. В дальнейшем в рамках этой функции они выступают как синонимы своих объектов такое ощущение, что одному и тому же объекту присвоено несколько имен:
int x;
int &rx=x; //объявление и инициализация ссылки
Ссылка rx является эквивалентом идентификатору x, т.е. операторы x=5 и rx=5 абсолютно идентичны. В этом варианте особой пользы от ссылки rx довольно мало ее имя длиннее основного имени переменной. Однако при программировании в среде Borland C++ Builder довольно часто приходится иметь дело с надоедающе длинными обозначениями свойств объектов, и тогда применение разумной ссылки сокращает время набора программы:
TColor old_pc,&pc=TForm1->Image1->Canvas->Pen->Color;
............
//запоминание цвета пера
old_pc=pc; //вместо old_pc=TForm1->Image1->Canvas->Pen->Color;
//смена цвета пера
pc=clRed; //вместо TForm1->Image1->Canvas->Pen->Color=clRed;
............
//восстановление цвета пера
pc=old_pc; //вместо TForm1->Image1->Canvas->Pen->Color=old_pc;
Однако главное преимущество ссылок проявляется при спецификации параметров функций. Если формальный параметр объявлен в заголовке функции как ссылка, то упрощается его использование в теле функции (в отличие от указателей к именам ссылок ничего добавлять не надо) и становится более естественным вызов функций (вместо формальных параметров-ссылок указываются имена переменных).
Функции являются основными программными единицами в языках C, C++. Из них как из кирпичиков складывается программа. В отличие от алголоподобных языков в программах на C, C++ не допускается вложенность функций. Любая программа на C должна содержать главную функцию с именем main, с которой начинается выполнение программы. Вызов всех остальных функций прямо или косвенно инициируется главной функцией.
Аргументы функций один из основных способов обмена информацией между частями программы. Конечно, для этих целей можно использовать и другие средства глобальные переменные, внешнюю память (файлы на дисках). Но обилие глобальных переменных заставляет очень тщательно согласовывать имена общих переменных, усиливает зависимость программных единиц друг от друга, налагает ограничения на выбор имен локальных переменных. Кроме того, общедоступность глобальных переменных может привести к несогласованному их изменению разными функциями. А использование внешней памяти в ряде случаев приводит к резкому замедлению работы программы.
В системах программирования на IBM-совместимых компьютерах используются два основных механизма передачи параметров через стек и через машинные регистры. Первый способ наиболее распространен, т.к. он не ограничивает объем передаваемой информации. В качестве стека используется определенный участок оперативной памяти с фиксированным, но управляемым диапазоном адресов (размер стека можно регулировать при настройке компилятора). Специальный регистр "следит" за очередным доступным участком стека. По адресу, хранящемуся в этом регистре, можно положить нужную порцию данных в стек и одновременно продвинуть содержимое регистра стека. Для этой цели система машинных команд предусматривает специальную операцию PUSH (от англ. протолкнуть). Вторая машинная операция POP (от англ. pop up выскочить наверх) позволяет извлечь из стека очередную порцию данных с одновременной коррекцией регистра стека. Системная программа, обслуживающая стек, следит за тем, чтобы стек не переполнился при записи и не оказался пустым при извлечении данных. Иногда механизм работы стека сравнивают с магазином огнестрельного оружия в нем, пуля, попавшая последней в рожок автомата, стреляет первой. Этим же объясняется технология обслуживания стека LIFO (Last Input First Output, т.е. последним вошел первым вышел). Зарядка магазина имитирует запись в стек, а процедура стрельбы напоминает извлечение данных из стека.
Второй механизм позволяет ускорить процедуру передачи параметров, помещая их в специально выделенные регистры процессора. Однако таких регистров мало, поэтому этот механизм применяют в тех случаях, когда количество передаваемых параметров не превышает трех. В системе программирования BCB для регистровой передачи параметров существует специальная конструкция быстрого вызова (fastcall).
Несмотря на то, что главную функцию никто не вызывает, у нее тоже могут быть параметры, передаваемые ей операционной системой при запуске из командной строки. В большинстве случаев таких параметров два:
int main(int argc, char* argv[])
или
int main(int argc, char **argv)
Предположим, что наша программа с именем nameprog.exe была запущена из командной строки со следующими аргументами:
>nameprog par1 par2 par3
Тогда первый аргумент главной функции argc будет равен 4 (имя программы входит в список параметров командной строки). Второй аргумент функции main представляет собой строковый массив, элементами которого являются отдельные компоненты командной строки:
argv[0] |
argv[1] |
argv[2] |
argv[3] |
"nameprog" |
"par1" |
"par2" |
"par3" |
Параметры командной строки позволяют упростить запуск программы, работа которой зависит от информации, набираемой вручную с клавиатуры. Вспомните довольно старую, но надежную программу архивации данных:
>pkzip a r arch.zip qq1.doc qq2.doc
Функция может возвращать значение, результат своей работы, или выполнять некоторое другое действие, не связанное с возвратом результата. Например, функция clrscr() осуществляет очистку экрана, но не возвращает никакого значения. Если функция возвращает значение, то в ее заголовке перед именем функции должен быть указан тип возвращаемого значения:
double mid(double x,double y)
В теле функции, возвращающей значение, обязан присутствовать оператор return (от англ. возврат), содержащий результат работы функции ее значение:
double mid(double x, double y)
{ return (x+y)/2.; }
Если функция не возвращает значение, то в ее заголовке перед именем функции должен быть указан тип void. В этом случае в теле функции может встретиться оператор return без параметра. Но оператор return может и отсутствовать выход из функции произойдет при достижении последней фигурной скобки:
void print_v(int *a,int n)
{ int j;
printf("\n");
for(j=0; j<n; j++)
printf("%8d",a[j]);
printf("\n");
}
Если перед именем функции не указан ни один из стандартных типов и отсутствует спецификатор void, то считается, что функция возвращает значение типа int.
Формальный параметр в заголовке функции называют параметром-значением, если перед его именем указан только тип. Например, функция mid, вычисляющая среднее арифметическое двух величин, получает в качестве фактических аргументов два числовых значения определенного типа:
double mid(double x, double y)
{ return (x+y)/2.; }
В качестве фактических аргументов, соответствующих параметрам-значениям, могут быть заданы любые числовые выражения (формулы):
w1 = mid(x*cos(fi)+y*sin(fi), x*sin(fi)-y*cos(fi));
Значения этих выражений вычисляются и записываются в стек, откуда их извлекает функция mid и помещает переданные значения в свои локальные переменные x и y (формальные параметры-значения можно рассматривать как локальные переменные функции). При необходимости, значение вычисленного выражения автоматически приводится к типу формального параметра. После работы функции возвращаемый результат возвращается в специально выделенном регистре.
Почти все математические функции раздела math.h используют передачу аргумента по значению.
Формальный параметр в заголовке функции называют явным параметром-указателем, если перед его именем находится символ *. Например, функция swap1, осуществляющая перестановку местами значений двух переменных, должна получить в качестве параметров адреса этих переменных, т.е. указатели на них:
void swap1(int *x,int *y) //явные параметры-указатели
{ int tmp=*x;
*x=*y; *y=tmp;
}
Кроме явных параметров-указателей в объявлении функции можно использовать и косвенные параметры-указатели, описанные с помощью механизма подстановок:
#define pint int*
...............
void swap1(pint x,pint y) //косвенные параметры указатели
{ int tmp=*x;
*x=*y; *y=tmp;
}
Косвенное объявление указателей может использовать и другую синтаксическую конструкцию:
deftype int* pint;
Представим себе, что функция swap1 была бы оформлена с параметрами-значениями:
void swap1(int x,int y)
{ int tmp=x;
x=y; y=tmp;
}
Тогда переставляемые значения поступили бы через стек и попали бы в локальные переменные (формальные параметры) x и y. Перестановка значений в локальных переменных была бы произведена, но вызывающая программа об этом ничего бы не узнала.
Таким образом, если мы хотим, чтобы результат работы вызываемой функции нашел отражение в передаваемых параметрах, мы должны сообщать не значения параметров, а их адреса. Зная адрес, вызванная функция сама может извлечь нужное значение и, при необходимости, отправить по этому адресу полученный результат.
Для вызова функции, параметрами которой являются указатели, в качестве фактических аргументов надо задавать адреса переменных, т.е. их имена с предшествующим символом &:
int x=2,y=3;
swap1(&x,&y);
Рассмотрим еще один пример передачи указателя по указателю:
void swap(int **v1, int **v2)
{ int *tmp=*v1; *v1=*v2; *v2=tmp; }
К этой функции можно обратиться следующим образом:
int i=10, j=20;
int *pi=&i, *pj=&j;
swap(&pi,&pj);
после такого обращения указатели pi и pj "смотрят" на новые значения, т.к. они поменялись адресами (*p1=20, *pj=10), но сами переменные i и j свои значения не поменяли.
Одним из наиболее распространенных способов использования указателей является передача в качестве адреса имени массива. Например, для суммирования компонент вектора можно воспользоваться следующей функцией:
int sum_v(int *a,int n)
{ int j,s=0;
for(j=0; j<n; j++)
s += a[i];
return s;
}
Обращение к такой функции может выглядеть следующим образом:
int q[20];
..........
k1=sum_v(q,20); //суммирование всех компонент вектора
k2=sum_v(q,10); //суммирование первых 10 компонент вектора
k3=sum_v(&q[5],3); //суммирование q[5]+q[6]+q[7]
k4=sum_v(q+5,3); //суммирование q[5]+q[6]+q[7]
Не забывайте, что имя массива одновременно является и указателем на его первый элемент (т.е. q и &q[0] это одно и то же).
Формальный параметр в заголовке функции называют явным параметром-ссылкой, если перед его именем находится символ &. Например, функция swap2, осуществляющая перестановку местами значений двух переменных, должна получить в качестве параметров ссылки на эти переменные, т.е. их адреса:
void swap2(int &x,int &y) //явные параметры-ссылки
{ int tmp=x;
x=y; y=tmp;
}
Параметры-ссылки могут объявлены в заголовках функций и с помощью косвенных типов, предварительно описанных в конструкциях #define или deftype:
#define rint int&
//или
deftype int& rint;
...................
void swap2(rint x,rint y)
Точно так же, как и параметр-указатель, параметр-ссылка является адресом. Поэтому вызванная функция по ссылке может извлечь и, при необходимости, изменить нужное значение. В отличие от указателя доступ по ссылке не требует добавления к имени переменной какого-либо символа. Во-первых, упрощается организация тела функции в нем можно использовать просто имена параметров-указателей. Во-вторых, упрощается вызов такой функции на месте фактических аргументов тоже можно писать просто имена объектов (т.к. имена объектов и ссылки на них являются эквивалентами):
int x=2,y=3;
swap2(x,y);
По ссылке можно передавать не только имена переменных, но и имена указателей:
void swap3(int *&v1,int *&v2)
{ int *t=v2; v2=v1; v1=t; }
К этой функции можно обратиться следующим образом:
int i=10, j=20;
int *pi=&i, *pj=&j;
swap(pi,pj);
После этого обращения указатели pi и pj "смотрят" на новые значения (*pi=20, *pj=10), но сами переменные i и j сохранили свои прежние значения.
В заголовках некоторых функций можно встретить объявление формального параметра с добавлением служебного слова const. Это означает, что в теле программы значение формального параметра меняться не должно. Но ведь тот программист, который пишет тело функции, вряд ли захочет по собственной воле изменять значение параметра, зная, что компилятор предупредит такую ошибку. В чем же смысл объявления некоторых параметров константными?
Оказывается, в этом скрывается некоторая уловка если параметр объявлен как константная ссылка, то в качестве фактического аргумента в этом случае можно задавать не только имя переменной, но и выражение. Таким образом, параметр-ссылка уподобляется параметру-значению. Правда, при этом значение фактического аргумента не попадает в стек. Оно вычисляется и помещается в некоторую скрытую переменную в теле вызывающей функции. А вызываемой функции сообщается адрес этой скрытой переменной, что не меняет логику работы со ссылками. Но запись по этому адресу будет блокирована.
В языке C++ допускается определение функций, у которых в заголовке указаны значения некоторых параметров:
double mid1(double x=0.5, double y=0.5)
{ return (x+y)/2.; }
К такой функции можно обратиться с одним (первым) аргументом или вообще без аргументов:
z=mid1(0.75); //результат равен 0.625=(0.75+0.5)/2.
p=mid1(); //результат равен 0.25=(0.5+0.5)/2.
Значения параметров по умолчанию могут быть заданы не выборочно, а только для параметров, идущих подряд и расположенных в конце списка. Это означает, что компилятор расценивает как ошибку следующий заголовок функции:
double mid2(double x=0.5,double y)
В отличие от этого следующий заголовок считается правильным:
double mid3(double x,double y=0.5)
Обратите внимание на одну практическую деталь. Функция с параметрами по умолчанию работает правильно в двух случаях. Во-первых, если ее описание находится выше вызывающей функции и в заголовке функции содержится информация о параметрах по умолчанию. Во-вторых, если ее описание находится ниже и в заголовке функции отсутствуют сведения о параметрах по умолчанию, но они содержатся в прототипе. Одновременное упоминание значений по умолчанию и в заголовке функции, и в прототипе приводит к сообщению об ошибке как в системе BC 3.1, так и в ВСВ. Ниже приводится один из вариантов правильного оформления такой программы:
#include <iostream.h>
#include <conio.h>
double mid(double x=1.,double y=1.);
void main()
{ //double mid(double x,double y);
double x=0.4,y=0.2,z;
z=mid(x,y); cout<<"z="<<z<<endl;
z=mid(x); cout<<"z="<<z<<endl;
z=mid(); cout<<"z="<<z<<endl;
getch();
}
double mid(double x,double y)
{ return (x+y)/2.; }
Постоянная работа с функциями типа printf или scanf вызывает у программистов зависть это же функции с переменным количеством аргументов. А как написать свою функцию, обрабатывающую столько параметров, сколько будет задано в обращении, и, естественно, допускающую задание разного количество аргументов?
Рассмотрим в качестве примера функцию, вычисляющую среднее арифметическое нескольких своих аргументов, имеющих тип double. Вызванная функция может догадаться о количестве переданных ей параметров только в том случае, если ей сообщают (например, значением первого аргумента) это число n, либо список параметров завершается обусловленным признаком например, нулевым значением последнего параметра.
Если мы собираемся сообщать функции количество передаваемых ей аргументов, то заголовок функции можно оформить следующим образом:
double mid_var(int n,...)
Три точки в конце списка предупреждают компилятор о том, что он не должен контролировать количество и типы следующих аргументов. Все заботы о доступе к списку параметров переменной длины берет на себя вызываемая функция. Предположим, далее, что все аргументы передаются в функцию mid_var как значения, т.е. к моменту передачи управления функции они находятся в стеке. Добраться до них можно следующим образом. Заведем указатель типа int и занесем в него адрес формального параметра n (система знает, где находится стек, и адрес n ей доступен):
int *ip=&n;
Продвинем указатель ip на 1, т.е. переместимся на адрес начала следующего параметра, и занесем его в новый указатель dp уже типа double:
ip++; //переход на адрес первого слагаемого
double *dp=(double *)ip; //преобразование типа указателя
Теперь адрес начала списка слагаемых у нас есть, количество слагаемых мы тоже знаем, поэтому все остальное дело техники. Окончательный вид функции таков:
double mid_var(int n,...)
{ int *ip=&n+1;
double *dp=(double *)ip;
double s=0.;
for(int j=0; j<n; j++)
s += dp[j]; //или s += *(dp+j); или S += *(dp++);
return s/n;
}
Теперь попытаемся построить аналогичную функцию, которая суммирует свои аргументы до тех пор, пока не встречает нулевое слагаемое. Она устроена еще проще:
double mid_var(double a1,...)
{ double *dp=&a1;
double s=0;
int c=0;
while(*dp != 0)
{ s += *(dp++); c++; }
return s/c;
}
Аналогичные функции можно построить, когда список передаваемых параметров состоит из переменного количества однотипных указателей. Только здесь придется использовать не просто указатели типа *dp, а "двойные" указатели типа **dp. И доставать значения нужных данных придется также через двойные указатели s += (**dp);
В файле stdarg.h находится несколько функций (точнее, макроопределений) которые обеспечивают перемещение по списку параметров, завершающемуся нулем:
va_list p; //объявление указателя на список параметров
va_start(p,p1); //установка указателя списка на последний явный
//параметр
va_arg(p,тип); //перемещение указателя на очередной неявный параметр
va_end(p); //уничтожение указателя на список параметров
Продемонстрируем использование этих средств на примере той же самой функции mid_var:
double mid_var(int n,...) //функции передают количество параметров
{ va_list p;
double s=0,c=0;
va_start(p,n);
while(n--) //до тех пор, пока n != 0
{ s += va_arg(p,double); c++; }
va_end(p);
return s/c;
}
Если список параметров начинается с первого слагаемого a1, то программа меняется очень незначительно:
double mid_var(double a1,...)
{ va_list p;
double s=0,c=0,u=a1;
va_start(p,a1);
do {s += u; c++; }
while(u=va_arg(p,double)) //до тех пор, пока u != 0
va_end(p);
return s/c;
}
На наш взгляд, применение обычных указателей выглядит несколько проще, чем использование стандартных средств.
Переменные, объявленные в теле функции, считаются локальными, принадлежащими только данной функции и недоступными для других функций. Поэтому написание функций работа достаточно автономная, не требующая знакомства с текстами других функций. Это позволяет многим исполнителям вести параллельную разработку блоков больших программ.
К числу локальных переменных следует отнести и формальные параметры функции. Всем им оперативная память выделяется в момент вызова функции и возвращается системе после завершения работы функции. Если объявление локальной переменной совмещается с ее инициализацией, то инициализация происходит при каждом вызове функции. Этим языки C, C++ отличаются от языка Паскаль, где инициализация происходит только один раз в момент загрузки программы в память.
Если локальная переменная объявляется со спецификатором static, то такая переменная причисляется к категории статических. Память, выделенная для статических переменных, сохраняется за ними до окончания работы программы. Поэтому значения статических переменных сохраняются после выхода из функции, и при повторном обращении функция-владелец может воспользоваться предыдущим значением своей статической переменной. Для других функций значение этой статической переменной продолжает оставаться недоступным.
Переменные, объявленные за пределами всех функций, относятся к разряду глобальных. Для всех функций, расположенных в этом же программном файле, глобальные переменные общедоступны. Для "чужих" функций, находящихся в других программных файлах, такие глобальные переменные могут оказаться доступными при соблюдении двух условий:
Глобальная переменная, объявленная со спецификатором static, доступна только для функций данного программного файла.
Значение, которая функция возвращает вызывающей программе, указывается в операторе return. В теле функции, возвращающей значение, может находиться несколько операторов return.
Рекурсивные определения и рекуррентные вычислительные схемы довольно часто используются в математике. Например:
n! = n*(n-1)! //рекурсивное определение факториала
yn+1=0.5*(yn+x/yn) //итерационная формула метода Ньютона
По аналогии с такими подходами в программировании появились рекурсивные функции, которые вызывают сами себя. Кроме такой непосредственной (прямой) рекурсии возможна и косвенная рекурсия. Она имеет место, когда в цепочке функций, последовательно вызывающих друг друга, последняя функция обращается к первой. Чтобы избежать бесконечного зацикливания, в таких рекурсивных цепочках должно быть обязательно предусмотрено условие выхода.
Наиболее часто цитируемым примером рекурсивной программы является вычисление факториала:
long fact(int n)
{ if (n<2) return 1L;
return (n*fact(n-1));
}
Еще один пример, демонстрирующий вычисление n-го элемента в последовательности чисел Фибоначчи:
Fn=Fn-2+Fn-1
int Fib(int n)
{ if(n<3) return 1;
return Fib(n-2)+Fib(n-1);
}
Пример с числами Фибоначчи представляет собой крайне неэффективную по производительности программу. Дело в том, что каждый вызов функции связан с выделением участка стека, где должны храниться локальные переменные и промежуточные результаты вычислений. А функция Fib содержит в своем теле два вызова самой себя. В большинстве случаев вместо рекурсивной функции не так уж и сложно написать функцию, не содержащую рекурсии, которая и работает быстрее, и более экономно использует оперативную память. Два выше приведенных примера легко поддаются такой переделке:
long fact(int n)
{ long f=1;
for(int j=2; j<=n; j++) f=f*j;
return f;
}
//-------------------------------
int Fib(int n)
{ int j,f,f1=1,f2=1;
if(n<3) return 1;
for(j=3; j<=n; j++)
{ f=f1+f2; f1=f2; f2=f; }
return f;
}
Однако некоторые рекурсивные программы смотрятся очень неплохо. Например, функция нахождения наибольшего общего делителя двух чисел по алгоритму Евклида. Идея ее построения базируется на трех очевидных фактах:
//Рекурсивный вариант нахождения НОД
int nod(int n1,int n2)
{ if(n1==0) return n2;
return nod(n2%n1,n1);
}
Эту функцию тоже несложно преобразовать в программу без рекурсии:
int nod(int n1,int n2)
{ int t;
m: if(n2<n1) {t=n1; n1=n2; n2=t; }
if(n1==0) return n2;
n2=n1%n2;
goto m;
}
Многие обрабатывающие процедуры нуждаются в получении информации о функциях, к которым они должны обратиться за недостающими данными.
Одним из таких примеров является функция вычисления определенного интеграла кроме параметров-значений, задающих пределы интегрирования, ей необходимо сообщить имя подпрограммы, вычисляющей значения подынтегральной функции. В такой ситуации обычно используют указатель на функцию.
Объявление указателя pf на функцию f(x), аргумент которой и возвращаемое значение имеют тип double, выглядит следующим образом:
double (*pf)(double x);
Оно напоминает прототип функции, в котором имя функции заменено именем указателя, заключенным в круглые скобки.
Простейшая функция, вычисляющая определенный интеграл методом прямоугольников, может быть организована следующим образом:
double int_rect(double a, double b, double (*f)(double x))
{ int i, n=100;
double s=0,h=(b-a)/n;
for(i=0; i<=n; i++) s += f(a+i*h);
return s*h;
}
Последним аргументом процедуры int_rect является указатель на функцию. Поэтому в качестве фактического параметра мы должны подставить имя подынтегральной функции. Таковым, в частности, может быть имя любой стандартной функции из библиотеки math.h:
cout << int_rect(0,M_PI,sin) << endl; //результат= 1.99984
cout << int_rect(0,M_PI,cos) << endl; //результат=-4.18544e-17
В качестве второго примера рассмотрим программу нахождения корня уравнения y=f(x), если известно, что на интервале [x1, x2] эта функция меняет знак. Алгоритм базируется на делении отрезка пополам. В точке xmid=(x1+x2)/2 смотрим знак функции f, который совпадет либо со знаком f(x1), либо со знаком f(x2). Выбираем ту половину отрезка, на концах которой функция принимает разные знаки. Затем исследуем его середину и т.д. Как только длина очередного отрезка станет достаточно малой или значение функции в центре отрезка окажется меньше заданной точности, процесс поиска корня прекращается.
#include <iostream.h>
#include <conio.h>
#include <math.h>
double y(double x) //функция f(x)=x2-4
{ return x*x-4; }
double root(double x1,double x2,double eps,double(*f)(double x))
{ double f12,f1,f2,xmid;
f1=f(x1); f2=f(x2);
if(f1*f2>0)
{ cerr<<"Error: sign(f1)=sign(f2)"; getch(); exit(0); }
while(x2-x1 > eps)
{ xmid=(x1+x2)/2.;
f12=f(xmid);
if(fabs(f12) < eps)
return xmid;
if(f12*f1>0) { x1=xmid; f1=f12; }
else {x2=xmid; f2=f12; }
}
return (x1+x2)/2.;
}void main()
{ cout<<root(0,10,1e-4,y);
getch();
}
//=== Результат работы ===
2.00001
В документации по системам программирования и в сообщениях об ошибках иногда можно встретить термины lvalue и rvalue. Они обозначают, соответственно, величины, которые могут находиться слева (lvalue = left value) от знака равенства в операторе присваивания или справа (rvalue = right value).
Как правило, функции, возвращающие значение, используются в правой части оператора присваивания. Однако функции в качестве своего значения могут возвращать указатели и ссылки. А по указателям и ссылкам возможна запись. Именно такие функции называют "левыми".
Приведем в качестве примера функцию, возвращающую ссылку на максимум из двух своих аргументов:
double& max(double &x, double &y)
{ return (x>y)? x : y; }
Ее обычное использование:
double r=max(a,b);
Использование с учетом "левизны":
double a=5,b=6;
max(a,b)=10; //эквивалентно b=10;
Аналогичный вариант, когда функция max возвращает указатель:
double* max(double *x, double *y)
{ return (*x>*y)?*x:*y; }
.........................
double a=5,b=6;
*max(&a,&b)=10; //эквивалентно b=10;
Левая функция, возвращающая ссылку на максимальный элемент массива:
int& Mmax(int a[],int n)
{ int im=0; //индекс максимального элемента
for(int j=1;j<n;j++) im=(a[im>a[j])? im : j;
return a[im];
}
Левая функция, возвращающая указатель на максимальный элемент массива:
int* Mmax(int a[],int n)
{ int im=0; //индекс максимального элемента
for(int j=1;j<n;j++) im=(a[im>a[j])? im : j;
return &a[im];
}
Для запрета левого использования функции, возвращающей указатель или ссылку, достаточно разместить в начале ее заголовка спецификатор const:
const double& max(double &x, double &y)
{ return (x>y)? x : y); }
Массивы это однородные данные (т.е. данные одного типа), расположенные в последовательных ячейках оперативной памяти. Так как все элементы таких данных имеют одинаковую длину, то для идентификации любого элемента достаточно знать его порядковый номер в последовательности и адрес первого элемента массива. Это позволяет использовать общее групповое имя для всех элементов массива и выделять каждый из них лишь индексом (или индексами), соответствующим порядковому номеру элемента.
В простейшем случае для так называемых одномерных массивов (векторов) индекс массива и его порядковый номер совпадают. Так как в языках C, C++ принято отсчитывать индексы от 0, то обозначение xy[2] соответствует третьему элементу массива с именем xy. Если элементы этого массива имеют тип double и начальный элемент xy[0] расположен в оперативной памяти с адреса 0x02000000, то для вычисления адреса элемента xy[2] достаточно выполнить пару операций 0x02000000+2*8. Естественно, что такого рода операции перекладываются на компилятор, а программист может записывать алгоритм обработки элементов массива, манипулируя с индексами его элементов. Это приближает запись программы к общепринятым математическим обозначениям (xy[j] соответствует элементу xyj) и позволяет писать достаточно компактные программы, близкие по идеологии к формулам, принятым в математике. Например, определение суммы элементов массива xy, содержащего 20 компонент, выглядит следующим образом:
for(s=0,j=0; j<=19; j++) s=s+xy[j];
Элементы двумерных массивов (матриц) характеризуются двумя индексами w[i][j], где i представляет номер строки, а j номер столбца матрицы, на пересечении которых находится элемент wi,j. В системах программирования на базе языка C принято располагать в памяти элементы матриц по строкам w[0][0], w[0][1], w[0][2],…, w[0][n], w[1][0], w[1][1],… . Поэтому для вычисления адреса элемента w[i][j] необходимо подсчитать значение выражения:
address(w[0][0])+(i*n+j)*size_w
Здесь address(w[0][0]) адрес начала массива w в оперативной памяти
size_w длина в байтах каждого элемента массива w.
По сути дела, выражение i*n+j, где n количество элементов в строке, определяет порядковый номер элемента w[i][j] в матрице w и носит название приведенного индекса. Обозначения элементов массива, более привычные для программиста, компилятор преобразует в выражения с указателями по правилам приведения индекса:
a[6] эквивалентно *(a+6)
b[1][2] эквивалентно *(*(b+1)+2)
Эти преобразования основаны на следующих соглашениях языка C. Имя одномерного массива a одновременно является указателем на его первый элемент, т.е. значением, доступным по адресу *a, является элемент массива a[0]. Имя двумерного массива b одновременно является указателем на указатель его первой строки, т.е. значением, доступным по адресу **b, является элемент массива b[0][0]. Указатель b+1 "смотрит" на указатель, определяющий адрес первого элемента второй строки массива b. Смысл подобного рода преобразований заключается в повышении эффективности программы, т.к. операции с указателями выполняются немного быстрее. Иногда к такого рода преобразованиям прибегают и программисты, например, сводя обработку двумерного массива к одномерным приведенным индексам.
Объявление массива сводится к указанию типа его элементов и количества элементов по каждому измерению:
#define Nmax 50
char a1[20],a2[2][80];
int b1[25],b2[Nmax];
По такому объявлению компилятор будет знать, сколько места в оперативной памяти понадобится для хранения такого массива. Для глобальных массивов место в памяти будет выделено в момент запуска программы, а для локальных в момент вызова соответствующей функции.
Объявление массива можно совместить с его инициализацией, т.е. с присвоением начальных значений всем элементами массива или только нескольким первым элементам:
char a[7]="Привет";
char b[7]={'П','р','и','в','е','т',0x0};
char c[]="Привет";
float d[10]={1.,2.,3.,4.};
int q[2][3]={{1,2,3},
{4,5,6}};
Обратите внимание на инициализацию символьных массивов a,b и c. В первом случае значения элементов массива совпадают с символами указанной строковой константы. Хотя значащих символов там 6, не следует забывать и о невидимом признаке конца строки байте с нулевым значением. В случае инициализации массива b каждый его элемент задан символьной константой, не забыть и признак конца строки. Самый удобный способ использован при инициализации массива c вместо того, чтобы указывать количество элементов, здесь заданы пустые скобки. Компилятор по заданному значению текстовой константы сам определит нужное количество байтов. Это позволяет избежать ненужных ошибок при подсчете количества символов в достаточно длинных строках.
При инициализации массива d вместо десяти значений заданы только четыре. Это означает, что указанные величины будут присвоены только первым четырем элементам. Остальные элементы не инициализированы. В случае если d является глобальным массивом, значения этих элементов будут равны 0 (память, выделяемая глобальным данным, предварительно чистится). Если массив d локализован в какой-то функции, то значения его элементов, начиная с d[4], предсказать невозможно там будет находиться "грязь", которая при повторных вызовах функции может оказаться разной.
Инициализация двумерных массивов выглядит более естественно, если значения элементов строк располагать друг под другом (так как это сделано в инициализации массива q).
Использование двумерных массивов для хранения строковых данных не всегда лучшее решение:
char spisok[][20]={"Иванов","Петров-Водкин","Репин"};
Для хранения элементов такого массива компилятор выделит 3*20=60 байт. Вместо этого можно было бы завести 3 указателя на соответствующие строковые константы:
char *sp[]={"Иванов","Петров-Водкин","Репин"};
В этом случае понадобилось бы 3*4=12 байт для хранения элементов массива sp и 27 байт для хранения строковых констант, т.е. почти в полтора раза меньше памяти. Дополнительный выигрыш может быть получен при дальнейшей обработке. Например, при сортировке строковых значений вместо перестановки фамилий могли бы меняться местами только четырехбайтовые указатели.
Для изучения некоторых приемов обработки числовых массивов рассмотрим несколько конкретных задач.
Пример 9.1. Переворот одномерного целочисленного массива перестановка его элементов в обратном порядке. Выделим в отдельную функцию процедуру инвертирования:
void invert(int *a,int n)
{ for(int j=0,tmp; j<n/2; j++)
{ tmp=a[j]; a[j]=a[n-j-1]; a[n-j-1]=tmp; }
}
Для проверки ее работоспособности можно воспользоваться следующей программой:
#include <stdio.h>
#include <conio.h>
#define N 20
void invert(int *a,int n);
void main()
{ int j,a[N];
printf("Before reverse:\n");
for(j=0; j<N; j++)
{ a[j]=j+1; printf("%3d",a[j]); }
invert(a,N);
printf("\nAfter reverse:\n");
for(j=0; j<N; j++) printf("%3d",a[j]);
getch();
}
//=== Результат работы ===
Before reverse:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
After reverse:
20 19 18 17 16 15 14 13 12 11 10 9 8 7 6 5 4 3 2 1
Пример 9.2. Перестановка головы и хвоста массива без использования промежуточного массива. Алгоритм этой процедуры был опубликован много лет тому назад в книге Дж. Бентли "Жемчужины для программистов". Заключается он в том, что надо последовательно выполнить 3 инвертирования головы массива, хвоста массива и всего массива целиком. Для этой цели мы и воспользуемся модификацией ранее написанной процедурой invert:
void invert1(int *a, int k, int n)
{//k индекс первого элемента инвертируемого фрагмента массива
//n количество инвертируемых элементов
int j,tmp;
for(j=k; j<k+n/2; j++)
{ tmp=a[j];
a[j]=a[2*k+n-j-1];
a[2*k+n-j-1]=tmp; }
}
Для проверки описанного выше алгоритма можно воспользоваться следующей программой:
#include <stdio.h>
#include <conio.h>
#define N 20
#define M 15
void invert1(int *a, int k, int n);
void main()
{ int j,a[N];
printf("Before reverse:\n");
for(j=0; j<N; j++)
{ a[j]=j+1; printf("%3d",a[j]); }
invert1(a,0,M);
printf("\nAfter reverse head:\n");
for(j=0; j<N; j++) printf("%3d",a[j]);
invert1(a,M,N-M);
printf("\nAfter reverse tail:\n");
for(j=0; j<N; j++) printf("%3d",a[j]);
invert1(a,0,N);
printf("\nAfter reverse all:\n");
for(j=0; j<N; j++) printf("%3d",a[j]);
getch();
}
//=== Результат работы ===
Before reverse:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
After reverse head:
15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 16 17 18 19 20
After reverse tail:
15 14 13 12 11 10 9 8 7 6 5 4 3 2 1 20 19 18 17 16
After reverse all:
16 17 18 19 20 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
Пример 9.3. Вывод целочисленной матрицы в заданном окне. Договоримся о следующих обозначениях параметров нашей процедуры вывода:
*c указатель на первый элемент матрицы;
n количество строк матрицы;
m количество столбцов матрицы;
w ширина каждой колонки матрицы при выводе на экран (w ≤ 9);
row, col экранные координаты, определяющие позицию верхнего левого знакоместа окна, в котором должна быть размещена матрица.
Тогда функция printa, которая выводит заданную целочисленную матрицу в указанном окне, может быть оформлена следующим образом:
void printa(int row,int col,int w,int *c,int n, int m)
{ int j,k;
char f[4]="%0d"; //заготовка для форматного указателя
f[1] += w; //формирование форматного указателя %wd
for(j=0; j<n; j++)
for(k=0; k<m; k++)
{ gotoxy(col+k*w,row+j); //переход в позицию элемента c[j][k]
printf(f,c[k+j*m]);
}
}
Отметим три особенности приведенной выше программы. Во-первых, для вывода числовых данных в поле, содержащем w позиций, удобно воспользоваться функцией printf с форматным указателем %wd. Но w это числовой параметр, передаваемый программе printa. Поэтому строку с форматным указателем можно сформировать. Именно для этой цели заведена строковая константа f, второй байт которой (символ f[1]) формируется как сумма кода цифры 0 с числом w. Затем сформированная строка используется в функции printf, первым аргументом которой может быть не только литеральная константа, но и ссылка на строку. Во-вторых, вместо того, чтобы перебирать элементы матрицы c[j][k] как элементы двумерного массива, в программе printa используются приведенные индексы (k+j*m). Наконец, для перевода курсора в позицию, соответствующую началу поля элемента c[j][k] используется функция gotoxy.
Работа функции printa может быть проверена с помощью следующей программы:
#include <stdio.h>
#include <conio.h>
void main()
{ int a[3][4]={{1,2,3,4},
{10,20,30,40},
{100,200,300,400}};
int b[4][4]={{1,2,3,4},
{5,6,7,8},
{9,10,11,12},
{13,14,15,16}};
printa(5,5,4,(int *)a,3,4);
printa(5,40,5,(int *)b,4,4);
getch();
}
Результат ее работы приведен на рис. 9.1. Обратите внимание еще на одну деталь: поскольку имена матриц являются указателями не на первый элемент матрицы, а на ее первую строку, то при обращении к функции printa потребовалось преобразовать указатель к типу (int *). Прибавление 1 к преобразованному указателю позволит с помощью указателя c перебирать элементы матрицы, а не ее строки.
Рис. 9.1. Вывод числовых матриц в заданных окнах
Следует отметить, что можно обойтись и без формирования переменного формата в функции printf, если воспользоваться сравнительно редко применяемым форматным указателем *:
printf("%*d",w,c[k+j*m]);
Значение переменной w в данном случае не выводится на экран, а замещает символ * в форматном указателе "%*d".
Пример 9.3. Количество "счастливых" билетов. Билеты для общественного транспорта хранятся в рулонах и идентифицируются номерами от 000000 до 999999. В молодежной среде "счастливым" считался билет, у которого сумма трех первых цифр совпадала с суммой трех последних цифр. Попытаемся оценить количество "счастливых билетов в полном рулоне с целью определения вероятности получить такой билет.
Самый простой способ заключается в организации 6 вложенных друг в друга циклов, где каждый счетчик перебирает цифры от 0 до 9, а во внутреннем цикле сравниваются суммы первых трех и последних трех счетчиков:
#include <iostream.h>
#include <conio.h>
void main()
{ int n=0;
for(int j1=0; j1<10; j1++)
for(int j2=0; j2<10; j2++)
for(int j3=0; j3<10; j3++)
for(int j4=0; j4<10; j4++)
for(int j5=0; j5<10; j5++)
for(int j6=0; j6<10; j6++)
if(j1+j2+j3==j4+j5+j6) n++;
cout << n;
getch();
}
Считает эта программа правильно и довольно быстро (на ПК с частотой 2 ГГц время работы не превышает 1 сек). Но работу этой программы можно ускорить почти в 1000 раз за счет использования массивов. Сумма трех цифр принадлежит диапазону [0,27]. Допустим, что нам удалось подсчитать, сколько раз встретилась каждая сумма s0 (количество сочетаний, давших сумму 0), s1 (количество сочетаний, давших сумму 1), … . Тогда количество "счастливых" билетов будет равно (s0)2+(s1)2+ ... . Поэтому гораздо более быстрой программой будет следующая:
#include <iostream.h>
#include <conio.h>
void main()
{ int n=0,k,s[28];
for(k=0; k<28; k++) s[k]=0;
for(int j1=0; j1<10; j1++)
for(int j2=0; j2<10; j2++)
for(int j3=0; j3<10; j3++)
s[j1+j2+j3]++;
for(k=0; k<28; k++)
n += s[k]*s[k];
cout << n;
getch();
}
Количество циклов, которое "крутится" в этой программе равно 28+1000+28, тогда как в предыдущей программе тело самого внутреннего цикла повторялось 1000000 раз.
Пример 9.4. Ход конем. Одна из задач, связанных с шахматами заключается в определении минимального количества ходов коня, за которое он может перебраться из стартовой клетки шахматного поля в заданную клетку. Напомним, что конь ходит "буквой Г", совершая за один ход перемещение на 2 клетки по одной координате (вертикали или горизонтали) и на одну клетку по другой координате. Шахматисты пользуются алфавитно-цифровой кодировкой полей шахматной доски, обозначая их по горизонтали буквами (A,B,C,D,E,F,G,H), а по вертикали цифрами (1,2,3,4,5,6,7,8). Однако для программы удобнее иметь дело только с цифровыми индексами, изменяющимися (с учетом требований языка C) от 0 до 7. Идея определения минимального количества ходов заключается в заполнении клеток шахматного поля числами досягаемости. Сначала мы находимся в стартовой позиции и пытаемся сделать из нее один из 8 возможных ходов (не выходя за пределы шахматной доски). Каждую из достижимых таким образом клеток отметим числом 1. Затем из каждой из них попытаемся сделать тоже один из возможных ходов, не возвращаясь в те клетки, где мы уже побывали. Вновь достижимые клетки пометим числом 2. Таким способом можно заполнить все клетки шахматного поля, включая и ту, в которую мы должны были придти из стартовой позиции. Для того чтобы выделить свободные клетки, еще не помеченные уровнем досягаемости, распишем в начале все клетки числом -1.
Пусть массив a размерности 8×8 моделирует шахматную доску. Распишем элементы этого массива числами -1 (признак того, что все клетки свободны). Затем запросим у пользователя индексы стартовой позиции и занесем в этот элемент массива 0 (из стартовой позиции в нее же можно добраться за 0 ходов). Пусть функция, которая пытается сделать один из 8 возможных ходов, называется newlevel и использует в качестве исходной информации данные, вынесенные в глобальные переменные матрицу a, уровень досягаемости k и управляющую переменную xod. Ее задачей является поиск всех незанятых клеток, находящихся на расстоянии одного хода от клеток с уровнем досягаемости k и записью в них кода k+1. Значение управляющей переменной xod=1, если хотя бы один такой ход удалось сделать. Если все клетки шахматной доски помечены неотрицательными уровнями досягаемости, т.е. ни одного хода больше сделать нельзя, то значение переменной xod равно 0. В функции newlevel удобно выделить процедуру try_ (подчерк добавлен к имени потому, что слово try является служебным), которая пробует, достижима и свободна ли позиция с индексами (p,q), в которой надо разместить очередной уровень досягаемости (k+1).
Описанные выше функции newlevel и try_ могут быть организованы следующим образом:
void try_(int p, int q)
{ if(p>=0 && p<8 && q>=0 && q<8 && a[p][q]<0)
{ a[p][q]=k+1; xod=1; }
}
//----------------------------------
void newlevel()
{ char di[8]={-2,-2,-1,-1, 1,1, 2,2};
char dj[8]={-1, 1,-2, 2,-2,2,-1,1};
xod=0;
for(int i=0; i<8; i++)
for(int j=0; j<8; j++)
if(a[i][j]==k)
for(int r=0; r<8; r++)
try_(i+di[r],j+dj[r]);
k++;
}
//----------------------------------
Обратите внимание на массивы di и dj. Их одноименные элементы образованы из всевозможных сочетаний ±1 и ±2, которые определяют 8 всевозможных направлений для хода конем. Цикл по r перебирает все эти комбинации относительно позиции (i,j).
Головная программа, обращающаяся к функции newlevel, может иметь следующий вид:
#include <stdio.h>
#include <conio.h>
char a[8][8],i,j,k=0,xod;
void main()
{ for(i=0;i<8;i++)
for(j=0; j<8;j++) a[i][j]=-1;
printf("begin position=");
scanf("%d %d",&i,&j);
a[i][j]=0;
do newlevel()
while (xod==1);
for(i=0;i<8;i++)
{ for(j=0; j<8; j++) printf("%3d",a[i][j]);
printf("\n");
}
getch();
}
Результат работы для начальной позиции (1,1) приведен на рис 9.2.
Рис. 9.2. Ход конем
Пример 9.5. Определение количества разных элементов в целочисленном массиве. Первый вариант программы основан на предварительной сортировке исходного массива. После того как массив отсортирован, следует проанализировать соседние элементы и, как только встречается пара разных чисел, к счетчику надо добавлять 1.
#include <stdio.h>
#include <conio.h>
void sort(int *a,int n)
{ int tmp;
for(int i=0;i<n-1;i++)
for(int j=i+1;j<n; j++)
if(a[j]<a[i])
{tmp=a[i]; a[i]=a[j]; a[j]=tmp; }
}
int difference(int *a,int n)
{ int i,m=1;
sort(a,n); //сортировка исходного массива
for(i=0; i<n-1; i++)
if(a[i]!=a[i+1]) m++;
return m;
}
void main()
{ int a0[5]={0,0,0,0,0};
int a1[5]={1,1,1,1,1};
int a2[5]={0,1,1,1,1};
int a3[5]={0,0,1,1,2};
int a4[5]={0,1,2,3,4};
int a5[5]={1,2,3,4,5};
printf("\na0:%d",difference(a0,5));
printf("\na1:%d",difference(a1,5));
printf("\na2:%d",difference(a2,5));
printf("\na3:%d",difference(a3,5));
printf("\na4:%d",difference(a4,5));
printf("\na5:%d",difference(a5,5));
getch();
}
//=== Результат работы ===
a0:1
a1:1
a2:2
a3:3
a4:5
a5:5
Вариант 2. При первом просмотре определяем, содержится ли в исходном массиве хотя бы один нулевой элемент. Если содержится, то в переменную k0 заносим 1, в противном случае в k0 заносим 0. При втором проходе нулевые элементы исключаем из рассмотрения и сравниваем a[i] c a[j]. В случае равенства в элемент a[i] заносим 0. При третьем проходе подсчитываем ненулевые элементы и добавляем к сумме k0. Мы ограничимся только видоизмененной функцией difference:
int difference(int *a,int n)
{ int i,j,k0=0,m=0;
for(i=0; i<n; i++) //первый проход
if(a[i]==0) { k0=1; break; } //поиск нулевого элемента
for(i=0; i<n-1; i++) //второй проход
{ if(a[i]==0) continue; //обход нулей
for(j=i+1; j<n; j++) //поиск дубликатов
if(a[i]==a[j])
{ a[i]=0; break; } //забой дубликата
}
for(i=0;i<n;i++) //подсчет ненулевых элементов
if(a[i]!=0) m++;
return m+k0;
}
Вариант 3. Использование логической шкалы для подсчета количества разных элементов в целочисленном массиве с положительными данными. Если массив содержит отрицательные элементы, то при первом проходе можно определить максимальный из них и на эту величину сместить все значения. Идея использования логической шкалы состоит в том, что с каждым двоичным разрядом шкалы можно связать последовательно возрастающие целые числа. Сначала все биты логической шкалы сбрасываются в 0. Затем организуется цикл просмотра всех элементов массива, при котором мы определяем номер разряда в шкале, соответствующий проверяемому значению. Если в соответствующей позиции шкалы находится 0, то такое число встретилось впервые и к счетчику разных чисел надо добавить 1. В противном случае такое число уже попадалось и его надо пропустить.
Обратите внимание на следующие приемы работы со шкалой. Во-первых, под нее надо запросить память (желательно, чистую). Это можно сделать следующим образом. Если длина нашего массива не превышает 32767 элементов, то под логическую шкалу потребуется не более 4096 байт. Запрос памяти под шкалу лучше всего организовать через библиотечную функцию calloc. У нее задается два аргумента количество элементов данных и число байт, необходимых для хранения каждого элемента:
b=calloc(4096,1); //запрос чистой памяти
Во вторых, для перевода целого числа N в соответствующий разряд шкалы b удобнее воспользоваться номером байта шкала (переменная byte) и номером бита (переменная bit) в этом байте. Кроме этого нам потребуется массив из 8 однобайтовых констант, каждая из которых представляет один бит шкалы:
char mask[8]={128,64,32,16,8,4,2,1};
Для определения номеров байта и бита, соответствующих числу N, достаточно проделать два деления:
byte=N / 8;
bit =N % 8;
В итоге окончательный вид функции difference таков:
int difference(int *a,int n)
{ int bit,byte,i,m=0;
char mask[8]={128,64,32,16,8,4,2,1};
char *b=(char *)calloc(4096,1); //запрос и очистка памяти
for(i=0; i<n; i++)
{ byte = a[i] / 8;
bit = a[i] % 8;
if((b[byte]& mask[bit])==0) //проверка бита шкалы
{ m++; b[byte] |= mask[bit]; } //вписывание бита в шкалу
}
free(b); //освобождение памяти
return m;
}
При окончательной сборке программы надо не забыть подключить заголовочный файл alloc.h, в котором находится прототип функции calloc.
Создание программ для обработки одномерных массивов на языках C, C++ особых проблем не вызывает. Чтобы это доказать, продемонстрируем несколько функций, реализующих стандартные операции над векторами.
Пример 9.6. Вычисление нормы вектора.
#include <stdio.h>
#include <math.h> //здесь находится прототип функции sqrt
#include <conio.h>
double norm(double *a, int n)
{ double s=0;
for(int i=0; i<n; i++) s += a[i]*a[i];
return sqrt(s);
}
void main()
{ double v1[5]={1.,2.,3.,4.,5.};
printf("norm=%f",norm(v1,5));
getch();
}
//=== Результат работы ===
norm=7.416198
Функцией norm можно воспользоваться и для того, чтобы вычислить "норму" любого фрагмента вектора достаточно вместо первого аргумента задать адрес начальной компоненты (например, &v1[2]), а в качестве второго аргумента количество обрабатываемых компонент.
Пример 9.7. Нормирование вектора.
#include <iostream.h>
#include <math.h> //здесь находится прототип функции sqrt
#include <conio.h>
void norm_vec(double *a, int n)
{ double s=0;
for(int i=0; i<n; i++) s += a[i]*a[i];
s= sqrt(s);
for(int i=0; i<n; i++) a[i] /= s;
}
void main()
{ double v1[5]={1.,2.,3.,4.,5.};
norm_vec(v1,5);
for(int i=0;i<5;i++)
cout << v1[i]<< " ";
getch();
}
//=== Результат работы ===
0.13484 0.26968 0.40452 0.53936 0.6742
Пример 9.8. Вычисление скалярного произведения двух векторов.
#include <stdio.h>
#include <conio.h>
double scal_prod(double *a, double *b,int n)
{ double s=0;
for(int i=0; i<n; i++) s += a[i]*b[i];
return s;
}
void main()
{ double v1[5]={1.,2.,3.,4.,5.};
printf("scal_prod=%f",scal_prod(v1,v1,5));
getch();
}
//=== Результат работы ===
scal_prod=55.000000
Пример 9.9. Сумма векторов.
#include <stdio.h>
#include <conio.h>
void sum_vec(double *a,double *b,double *c,int n)
{ for(int i=0; i<n; i++) c[i]=a[i]+b[i]; }
void main()
{ double v1[5]={1.,2.,3.,4.,5.};
double v2[5]={1.,0.,1.,0.,1.};
double v3[5];
sum_vec(v1,v2,v3,5);
for(int i=0;i<5;i++)
printf("%3.0f",v3[i]);
getch();
}
//=== Результат работы ===
2 2 4 4 6
Работу с двумерными массивами можно организовать двумя способами. Во-первых, операции над элементами двумерных массивов можно свести к операциям над одномерными массивами, используя приведенные индексы. Во-вторых, можно воспользоваться указателями на строки матрицы (как известно, имя массива одновременно является указателем на ее первую строку). Мы приведем примеры программ, демонстрирующие оба подхода.
Пример 9.10. Формирование единичной матрицы с приведенными индексами.
#include <stdio.h>
#include <math.h>
#include <conio.h>
void eye(int *a, int n)
{ int i,j;
for(i=0; i<n; i++)
for(j=0; j<n;j++)
{ if(i==j) a[i*n+j]=1;
else a[i*n+j]=0;
}
}
void main()
{ int i,j,v[5][5];
eye((int*)v,5);
for(i=0;i<5;i++)
{ for(j=0;j<5;j++)
printf("%3d",v[i][j]);
printf("\n");
}
getch();
}
//=== Результат работы
Обратите внимание на то, что при обращении к функции eye указатель v приводится к типу (int *). После этого указатель будет "смотреть" на элементы массива, а не на его строки.
Пример 9.11. Сложение квадратных матриц. Поскольку в операции сложения участвуют n2 одноименных компонент матриц-слагаемых, то матрицы можно рассматривать как длинные вектора и производить сложение как с векторами:
#include <stdio.h>
#include <math.h>
#include <conio.h>
void add_mat(int *a,int *b,int *c,int n)
{ int i;
for(i=0; i<n*n; i++)
c[i]=a[i]+b[i];
}
void main()
{ int i,j,v3[3][3];
int v1[3][3]={{1,2,3},{4,5,6},{7,8,9}};
int v2[3][3]={{0,0,1},{0,0,2},{0,0,3}};
add_mat((int*)v1,(int*)v2,(int*)v3,3);
for(i=0;i<3;i++)
{ for(j=0;j<3;j++)
printf("%3d",v3[i][j]);
printf("\n");
}
getch();
}
//=== Результат работы ===
Пример 9.12.Умножение квадратных матриц с использованием приведенных индексов.
#include <stdio.h>
#include <conio.h>
void mult_mat(int *a,int *b,int *c,int n)
{ int i,j,k,s;
for(i=0; i<n; i++)
for(j=0; j<n;j++)
{ s=0;
for(k=0;k<n;k++)
s += a[i*n+k]*b[k*n+j]; //s=s+ai,k*bk,j
c[i*n+j]=s; //ci,j=s
}
}
void main()
{ int i,j,v3[2][2];
int v1[2][2]={{1,2},{3,4}};
int v2[2][2]={{5,6},{7,8}};
mult_mat((int*)v1,(int*)v2,(int*)v3,2);
for(i=0;i<2;i++)
{ for(j=0;j<2;j++)
printf("%4d",v3[i][j]);
printf("\n");
}
getch();
}
//=== Результат работы ===
Пример 9.13. Транспонирование матрицы с использованием массива указателей на строки.
#include <stdio.h>
#include <conio.h>
void transp(int *p[],int n)
{ int tmp,i,j;
for(i=0; i<n-1; i++)
for(j=i+1; j<n; j++)
{ tmp=p[i][j]; p[i][j]=p[j][i]; p[j][i]=tmp; }
}
void main()
{ int v[4][4]={{ 1, 2, 3, 4},
{ 5, 6, 7, 8},
{ 9,10,11,12},
{13,14,15,16}};
//массив указателей на строки
int *p[4]={(int *)&v[0],(int *)&v[1],(int *)&v[2],(int *)&v[3]};
transp(p,4);
for(int i=0; i<4; i++)
{ for(int j=0;j<4;j++)
printf("%3d",v[i][j]);
printf("\n");
}
getch();
}
//=== Результат работы ===
Массив указателей p на строки двумерного массива v может быть сформирован и другими способами:
int *p[4]={(int *)v,(int *)(v+1),(int *)(v+2),(int *)(v+3)};
int *p[4]={v[0],v[1],v[2],v[3]};
int *p[4]={*v,*(v+1),*(v+2),*(v+3)};
Очевидно, что указатель p[0] "смотрит" на элемент v[0][0]. Поэтому указатель p[0][1]=p[0]+1 "смотрит" на элемент v[0][1], указатель p[0][2] на элемент v[0][2] и т.д. Можно было бы видоизменить заголовок функции transp следующим образом:
void transp(int **p,int n)
Все эти модификации ничего не меняют в алгоритмах работы программ.
Задача поиска формулируется следующим образом: задан массив a, содержащий n однотипных элементов (чисел, строк, записей и т.п.). Нужно установить, содержится ли в этом массиве заданный объект q. При положительном ответе следует дополнительно сообщить порядковый номер (индекс j), найденного объекта (a[j]=q).
Классический алгоритм последовательно поиска в неупорядоченном массиве состоит из четырех следующих шагов:
шаг S1: Установить начальный индекс j=1;
шаг S2: Проверить условие q=a[j]. Если условие выполнено, то решение найдено и работа прекращается;
шаг S3: Увеличить индекс j на 1;
шаг S4: Проверить условие окончания цикла j<n+1. Если условие выполнено, повторяется шаг S2. В противном случае сообщить, что объект q в массиве a не содержится.
В книге Д.Кнута "Искусство программирования" имеется упоминание об усовершенствовании приведенного выше алгоритма. В цикле этого алгоритма содержится два сравнения. Чтобы исключить одно из них (условие окончания цикла) к массиву a добавляют еще один элемент, равный q. Тогда необходимость в проверке j<n+1 отпадает. Перед выдачей результата надо убедиться в том, что найденный индекс не равен n+1. Но такая проверка выполняется всего один раз. При программировании на языке ассемблера не требуется добавлять a[n+1]. Проще организовать поиск в обратном порядке с последнего элемента массива. Для этого в языке ассемблера используется команда LOOP, совмещающая изменение счетчика цикла (j--) и возврат в начало цикла при j≠0.
Трудоемкость классического последовательного поиска можно оценить только в среднем. В лучшем случае первое же сравнение может дать ответ (q=a[1]). В худшем случае придется перебрать все n элементов. В среднем на поиск будет затрачено n/2 сравнений.
Функция ssearch, реализующая последовательный поиск, устроена довольно просто:
int ssearch(int q, int *a, int n)
{ register int j;
for(i=0; j<n;j++)
if(q==a[j]) return j;
return -1;
}
Единственная особенность, направленная на повышение скорости поиска, связана с попыткой распределить счетчик цикла j в машинном регистре (если у компилятора имеется в наличии свободный регистр, то он положительно среагирует на объявление register int j).
Двоичный поиск можно применить только в том случае, если исходный массив упорядочен, например, по возрастанию величин объектов. Тривиальные случаи типа q<a[1] или q>a[n] не рассматриваются, хотя ничего не стоит подключить к поиску и такие проверки. Идея двоичного поиска заключается в уменьшении вдвое зоны поиска на каждом шаге (отсюда и второе название метода деление пополам). Сначала искомый объект q сравнивается со средним элементом массива. В зависимости от результата сравнения на следующий шаг остается первая или вторая половина массива. Оставшаяся половина вновь делится на 2, и так продолжается до тех пор, пока зона поиска не сузится до двух элементов. В этом случае либо объект q совпадает с одним из этих элементов, либо продолжает сохраняться строгое неравенство с обеими границами.
Функция bsearch, реализующая двоичный поиск, может быть организована следующим образом:
int bsearch(int q, int *a, int n)
{ register int left=0,right=n-1,mid;
if(q<a[0] || q>a[n-1]) return -1;
for(;left<=right;)
{ mid=(left+right)/2;
if(q<a[mid]) right=mid-1;
else if(q>a[mid]) left=mid+1;
else return mid;
}
return -1;
}
Максимальное количество шагов, которое требуется для двоичного поиска, оценивается ближайшим целым к log2n. Для массива в 1000 элементов прямой поиск в среднем затрачивает 500 шагов, тогда как двоичный поиск ограничивается 10 шагами.
В реальных задачах типа поиска в телефонном справочнике к массиву данных можно добавить несколько отсортированных массивов-указателей по фамилиям, по телефонам, по адресам. Сам массив исходных данных при этом не сортируется.
Сортировка числовых и нечисловых данных одна из важнейших процедур обработки информации, т.к. она существенно ускоряет последующий поиск тех или иных объектов. О том, какое внимание уделяется различным алгоритмам сортировки, свидетельствует специальный том Д.Кнута "Искусство программирования для ЭВМ: Сортировка и поиск" объемом порядка 840 стр. Надо отметить, что оценка трудоемкости различных методов сортировки представляет собой довольно сложную математическую задачу. Те оценки, которые приведены ниже, заимствованы из литературных источников.
Мы рассмотрим несколько разных алгоритмов сортировки от самых простых и самых медленных до одного из наиболее эффективных.
Идея метода состоит в сравнении двух соседних элементов, в результате чего меньшее число (более легкий "пузырек") перемещается на одну позицию влево. Обычно просмотр организуют с конца, и после первого прохода самое маленькое число перемещается на первое место. Затем все повторяется от конца массива до второго элемента и т.д. Известен и другой вариант пузырьковой сортировки, в котором также сравнивают два соседних элемента, и если хотя бы одна из смежных пар была переставлена, то просмотр начинают с самого начала.
Функция bubble, реализующая первый алгоритм пузырьковой сортировки приведена ниже:
void bubble(int *x, int n)
{ register int i,j;
int tmp;
for(i=1;i<n;i++)
for(j=n-1;j>=i; j--)
if(x[j-1]>x[j])
{ tmp=x[j-1]; x[j-1]=x[j]; x[j]=tmp; }
}
Более известный алгоритм пузырьковой сортировки реализован в функции bubble1. В ней использована флажковая переменная q, которая принимает ненулевое значение в случае перестановки какой-либо смежной пары:
void bubble1(int *x, int n)
{ register int i,j;
int tmp,q;
m: q=0;
for(i=1;i<n-1;i++)
if(x[i]>x[i+1])
{ tmp=x[i]; x[i]=x[i+1]; x[i+1]=tmp; q=1;}
if(q) goto m;
}
Пузырьковая сортировка неплохо работает, когда в исходных данных многие элементы уже упорядочены. Если исходный массив уже отсортирован, то работа функции ограничивается первым проходом. В худшем случае (массив упорядочен по убыванию) количество сравнений составляет n*(n-1)/2, а количество перестановок достигает 3*n*(n-1)/2. Среднее количество перестановок равно 3*n*(n-1)/4.
Идея метода: находится элемент с наименьшим значением и меняется местами с первым элементом. Среди оставшихся элементов ищется наименьший, который меняется со вторым и т.д. Функция select, реализующая такую процедуру, приведена ниже:
void select(int *x, int n)
{ register int i,j,k;
int q,tmp;
for(i=0; i<n-1;i++)
{ q=0; k=i; tmp=x[i];
for(j=i+1; j<n; j++)
{ if(x[j]<tmp)
{ k=j; tmp=x[j]; q=1; }
}
if(q) { x[k]=x[i]; x[i]=tmp; }
}
}
Оценка трудоемкости метода отбора:
количество сравнений n*(n-1)/2;
количество перестановок: в лучшем случае 3*(n-1)
в худшем случае n2/4+3*(n-1)
в среднем n*(log n +0.577216)
Идея метода: последовательное пополнение ранее упорядоченных элементов. На первом шаге сортируются два первые элемента. Затем на свое место среди них вставляется третий элемент. К трем упорядоченным добавляется четвертый, который занимает свое место в четверке и т.д. Примерно так игроки упорядочивают свои карты при сдаче их по одной. Функция insert, реализующая описанную процедуру, приведена ниже:
void insert(int *x, int n)
{ register int i,j;
int tmp;
for(i=1;i<n;i++)
{ tmp=x[i];
for(j=i-1;j>=0 && tmp<x[j]; j--)
x[j+1]=x[j];
x[j+1]=tmp;
}
}
Трудоемкость метода: количество сравнений зависит от исходной упорядоченности массива. Если массив уже отсортирован, то все равно потребуется 2*(n-1) сравнение. Если массив упорядочен по убыванию, то число сравнений возрастает до n*(n+1)/2.
В 1959 году сотрудник фирмы IBM D.L. Shell предложил оригинальный алгоритм сортировки. По его предложению сначала сортируются элементы, отстоящие друг от друга на 3 позиции, затем на две позиции и, наконец, сортируются смежные элементы. В дальнейшем экспериментальным путем были найдены более удачные расстояния между сортируемыми элементами: 95321. Среднее время работы усовершенствованного алгоритма Шелла порядка n1.2. Это существенно лучше, чем характерная для трех предыдущих методов величина порядка n2.
void shell(int *x, int n)
{ register int i,j,gap,k;
int xx;
char a[5]={9,5,3,2,1};
for(k=0;k<5;k++)
{ gap=a[k];
for(i=gap;i<n;i++)
{ xx=x[i];
for(j=i-gap; xx<x[j] && j>=0; j=j-gap)
x[j+gap]=x[j];
x[j+gap]=xx;
}
}
}
Известный математик C.A.R. Hoare в 1962 году опубликовал алгоритм быстрой сортировки, за которым закрепилось название quicksort. Основная идея быстрой сортировки напоминает метод поиска делением пополам. Сначала выбирается средний элемент в сортируемом массиве. Все, что больше этого элемента переносится в правую часть массива, а все, что меньше в левую. После первого шага средний элемент оказывается на своем месте. Затем аналогичная процедура повторяется для каждой половины массива. На каждом последующем шаге размер обрабатываемого фрагмента массива уменьшается вдвое. Количество операций, которое требуется для реализации этой процедуры, оценивается константой n*log2n. Это еще быстрее, чем сортировка Шелла. В отличие от предыдущих функций быстрая сортировка оформлена из двух функций quick, которая допускает принятое в других функциях обращение, и рекурсивной процедуры qs:
void quick(int *x, int n)
{ qs(x,0,n-1); }
//----------------------------------
void qs(int *x,int left,int right)
{ register int i,j;
int xx,tmp;
i=left; j=right;
xx=x[(left+right)/2];
do { while(x[i]<xx && i<right)i++;
while(xx<x[j] && j>left) j--;
if(i<=j)
{ tmp=x[i]; x[i]=x[j];
x[j]=tmp; i++; j--;
}
}
while(i<=j);
if(left<j) qs(x,left,j);
if(i<right)qs(x,i,right);
}
Головная программа, предназначенная для тестирования и хронометража функций сортировки, приведена ниже. Заложенная в ней константа MAX для целей отладки принимает значение 20. Для хронометража методов сортировки ее надо увеличить до 100000 (BCB массивы такого размера допускает).
#include <iostream.h>
#include <conio.h>
#include <dos.h>
#define MAX 20
void bubble(int *x,int n);
void select(int *x,int n);
void insert(int *x,int n);
void shell(int *x,int n);
void quick(int *x,int n);
void qs(int *x,int left,int right);
void main()
{ int num[MAX],i;
int t1,t2;
/* при отладке включить этот фрагмент
cout << "Before sort:\n";
for(i=0; i<MAX; i++)
{ num[i]=random(MAX);
cout << num[i] << " ";
}
cout << endl;
*/
t1=GetTickCount();
// bubble(num,MAX);
// select(num,MAX);
// insert(num,MAX);
// shell(num,MAX);
quick(num,MAX);
t2=GetTickCount();
cout << t2-t1;
/* при отладке включить этот фрагмент
cout << "After sort:" << endl;
for(i=0; i<MAX; i++)
cout << num[i] << " ";
cout << endl;
*/
cout << "end";
getch();
}
//Методы сортировки
В таблице 9.1 приведены данные работы каждой функции сортировки на массиве длиной в 100000 элементов на компьютере типа Pentium 4 (частота 2 ГГц). Сортируемый массив заполнялся случайными числами (для каждой функции набор исходных данных был одинаков).
Таблица 9.1
Функция |
Время сортировки в млсек |
bubble |
20312 |
insert |
5266 |
select |
10843 |
shell |
1406 |
quick |
16 |
Довольно много методов сортировки построено на сортировке фрагментов массивов с последующим объединением (слиянием) двух или более фрагментов в общий массив. Ниже приведена одна из реализаций объединения двух отсортированных массивов a и b, содержащих, соответственно, по ka и kb упорядоченных по возрастанию целых чисел. Чего-то особенного в алгоритме слияния нет надо поочередно просматривать претендентов из обоих массивов и вставлять нужный из них в результирующий массив. Единственное, за чем приходится следить не исчерпался ли тот или иной поставщик данных. Несмотря на использование в функции merge трех операторов goto, приведенный вариант представляет собой наиболее эффективную программу слияния.
#include <stdio.h>
#include <conio.h>
void merge(int *a,int ka,int *b, int kb, int *c)
{ int ja=0,jb=0,jc;
for(jc=0; jc<ka+kb; jc++)
{ if(ja==ka) goto mb;
if(jb==kb) goto ma;
if(a[ja]<b[jb]) goto ma;
mb: c[jc]=b[jb]; jb++; continue;
ma: c[jc]=a[ja]; ja++;
}
}
#define na 3
#define nb 4
void main()
{ int j,a[na]={0,2,4},b[nb]={1,3,5,7};
int c[na+nb];
for(j=0; j<na; j++) printf("%4d",a[j]);
printf("\n");
for(j=0; j<nb; j++) printf("%4d",b[j]);
printf("\n");
merge(a,na,b,nb,c);
for(j=0; j<na+nb; j++) printf("%4d",c[j]);
getch();
}
//=== Результат работы ===
0 2 4
1 3 5 7
0 1 2 3 4 5 7
К динамическим массивам относятся массивы, память под которые выделяется работающей программе по запросам, предусмотренным программистом. Не следует путать их с локальными массивами функций, память под которые автоматически выделяется при обращении к функции и также автоматически возвращается при выходе из функции. Память, выделенная под динамические массивы, освобождается в результате вызова специальных процедур, которые должны быть предусмотрены в тексте программы.
Пусть q указатель на одномерный массив с элементами типа type_q. Тогда запрос на выделение памяти без ее предварительной очистки выполняется с помощью функции malloc:
q=(type_q *)malloc(n_byte);
Приведение к типу данных потребовалось потому, что функция malloc возвращает указатель типа void. Аргументом функции malloc является запрашиваемое количество байт. Необходимо иметь в виду, что данные типа int в 16-битной системе программирования (например, BC 3.1 под управлением MS-DOS) занимают по 2 байта, а в 32-битной среде типа BCB по 4 байта.
Аналогичный запрос на выделении памяти с ее предварительной очисткой выполняется с помощью функции calloc:
q=(type_q *)calloc(n_el,sizeof(type_q));
В отличие от предыдущей функции здесь уже два аргумента количество элементов массива (n_el) и длина каждого элемента в байтах sizeof(type_q).
Прототипы обеих функций находятся в заголовочных файлах alloc.h и stdlib.h. Если по каким-то причинам память выделить не удалось, каждая из функций возвращает нулевой указатель (q==NULL).
После выделения блока памяти по malloc или calloc его можно перераспределить, изменив ранее объявленную длину:
q=(type_q)realloc(q,new_len);
Если новая длина больше предыдущей, то содержимое массива q копируется в начало нового блока памяти. Если новая длина меньше предыдущей, то в новом блоке сохраняются значения только начала старого массива. Если new_len=0, то это эквивалентно освобождению занимаемого блока памяти.
После того, как массив q будет использован и больше не понадобится, выделенную память надо возвратить с помощью функции free:
free(q);
Освобождение памяти не сбрасывает указатель q, поэтому с целью предупреждения возможных ошибок в дальнейшем его следует обнулить (операционная система Windows блокирует запись по нулевому адресу):
q=NULL; //или q=0;
Некоторое представление о работе описанных функций дает следующий пример:
#include <alloc.h>
#include <stdio.h>
#include <conio.h>
#include <string.h>
void main()
{ int j,*p,s;
char *str="abcdefghijk",*s1;
p=(int *)malloc(4000); //запрос "грязной" памяти
for(s=0,j=0; j<1000; j++) s += p[j];
printf("s=%d",s); //улика - память "грязная"
for(j=0; j<1000; j++) p[j]=j; //роспись выделенной памяти
printf("\np[500]=%d",p[500]); //выборочная проверка
free(p); //освобождение памяти
p=(int *)calloc(1000,sizeof(int)); //запрос чистой памяти
for(s=0,j=0; j<1000; j++) s += p[j];
printf("\ns=%d",s); //алиби - память чистая
free(p); //освобождение памяти
s1=(char *)calloc(20,1); //запрос памяти под строку
strcpy(s1,str); //копирование данных
printf("\ns1=%s",s1); //вывод старой строки
s1=(char*)realloc(s1,8); //перераспределение памяти
s1[5]=0x0; //признак конца новой строки
printf("\ns1=%s",s1); //вывод новой строки
getch();
}
//=== Результат работы ===
s=-2138551277
p[500]=500
s=0
s1=abcdefghijk
s1=abcde
В языке C++ появились дополнительные средства для запроса и освобождения памяти:
q = new type_q; //запрос памяти под скалярную переменную
q = new type_q[n_el]; //запрос памяти под массив из n_el элементов
delete q; //освобождение памяти из-под скаляра
delete []q; //освобождение памяти из-под массива
Динамическое выделение памяти под скалярную переменную можно совместить с ее инициализацией:
int v=new int(5); //после выделения памяти v=5
Память, выделяемую с помощью оператора new под динамические массивы, таким образом инициализировать нельзя.
К пользовательским типам данных относятся нестандартные данные, о структуре которых система не имеет представления, и операции над которыми стандартом языка C не определены. Структура таких данных становится известной компилятору только по описанию, содержащемуся в тексте исходной программы. К пользовательским данным такого типа относятся массивы (о них речь шла в предыдущем разделе), структуры (в других алгоритмических языках они известны под термином записи), перечисления (в некоторых книгах по языкам C, C++ их относят к целочисленным данным) и объединения.
Первоначальным образом для данных типа структур явились строки таблиц, с которыми знаком любой человек. Характерным для таблиц любого содержания является наличие столбцов, в каждом из которых хранятся однотипные данные. Однако в соседних столбцах типы данных могут отличаться. Если специфической особенностью массивов является использование одного и того же типа для всех элементов массива, то строки таблиц можно представлять как последовательность полей данных разного типа. Для каждого поля строки таблицы известно наименование соответствующего столбца таблицы и тип размещаемого в этом поле значения. Например, поле "Фамилия" заполняется текстовой информацией, поле "Год рождения" хранит целочисленные данные, на поле "Пол" достаточно записывать единственный символ 'М' или 'Ж' и т.д.
То, что принято называть "шапкой" таблицы в языках программирования носит название шаблона структуры. Например, шаблон структуры, описывающей данные о книге, может быть устроен следующим образом:
struct book {
char title[40]; //наименование
char authors[30]; //авторы
char publishing_house[15]; //издательство
int year; //год издания
int pages; //количество страниц
float price; //цена
}
Идентификатор book выполняет функцию имени шаблона или типа структуры. В дальнейшем им можно пользоваться для объявления конкретных переменных структур типа book:
struct book b1,b2,b3; //три переменных типа book
В языке C++ служебное слово struct можно опускать:
book b1,b2,b3;
Обратите внимание на то, что строковые поля в структурах имеют фиксированные размеры. Это существенно упрощает обработку данных, т.к. работа с полями переменной длины могла бы привести к очень медленно работающим программам.
Вообще говоря, объявление шаблона структуры и переменных, связанных с этой структурой, можно совместить:
struct book {
char title[40];
char authors[30];
char publishing_house[15];
int year;
int pages;
float price;
} b1,b2,b3;
Более того, в объявлении шаблона структуры можно опустить имя шаблона:
struct {
char title[40];
char authors[30];
char publishing_house[15];
int year;
int pages;
float price;
} b1,b2,b3;
Однако если данная структура должна выступить в качестве параметра какой-либо функции, то и в заголовке этой функции и в ее прототипе без имени шаблона не обойтись.
Для доступа к соответствующим полям структур используются составные имена, образованные из имени структуры и имени поля:
strcpy(b1.title,"C++:Экспресс курс"
strcpy(b1.authors,"В.Лаптев");
cout << b1.title;
b1.year=2004;
Если мы имеем дело с указателем, который настроен на адрес структуры, то составные имена записываются с использованием двух символов "->":
struct book *p=&b1; //указатель на структуру
strcpy(p->title,"C++:Экспресс курс"
strcpy(p->authors,"В.Лаптев");
cout << p->title;
p->year=2004;
Структуры могут объединяться в массивы:
struct book c[20]; //массив структур
И тогда для доступа к полям соответствующего элемента используются индексированные составные имена:
strcpy(с[2].title,"Язык C++ "
strcpy(c[2].authors,"В.В.Подбельский");
cout << c[2].title;
c[2].year=1996;
struct book *pс=с;
strcpy(pс[2]->title,"C++:Экспресс курс"
strcpy(pс[2]->authors,"В.Лаптев");
cout << pс[2]->title;
pс[2]->year=2004;
При объявлении переменных типа структура их поля могут быть проинициализированы довольно естественным способом:
struct book a={"Programming Languages", "Jean E.Sammet",
"Prentice-Hall", 1969,785,49.99};
Для структур, объявленных с использованием одного и того же шаблона, допустима операция присваивания:
b1=a; //все поля структуры a копируются в структуру b1
К сожалению, одноименные поля строкового типа у структур так копировать нельзя необходимо прибегать к услугам функций типа strcpy:
strcpy(b1.authors,a.authors); //копируем поле "авторы"
Над содержимым числовых полей структур можно выполнять все операции, доступные для соответствующего типа данных.
Некоторые числовые поля структур могут быть объявлены как битовые шкалы с указанным количеством двоичных разрядов. С одной стороны, это предоставляет возможность компоновать в рамках машинных слов некоторые группы данных и располагать их более компактно в оперативной памяти. С другой стороны, такая возможность избавляет программиста от забот по выделению и вклеиванию соответствующих групп двоичных разрядов. Однако на расположение битовых полей в структурах оказывает дополнительное влияние установка компилятора признак выравнивания данных на границу байта, слова, двойного слова. Длины битовых шкал при объявлении структур задаются следующим образом:
struct qq{ int a:10; int b:14; } xx, *px;
xx.a=127;
px=&xx;
px->b=9;
В зависимости от признака выравнивания границ данных эти значения могут быть расположены в оперативной памяти по-разному:
С целью принудительного расположения битовых полей в таких структурах допускается вставка безымянных полей:
struct { int a:10;
int :6;
int b:14; } yy;
Если функция не изменяет структуру, то такую структуру можно передать по значению.
Пример 10.1. Отображение полей структуры на экране дисплея
#include <iostream.h>
#include <conio.h>
struct book {
char title[40];
char authors[30];
char publishing_house[15];
int year;
int pages;
float price;
};
void show_book(book b)
{ cout << "Title: "<< b.title << endl;
cout << "Authors: "<< b.authors << endl;
cout << "Publishing house: "<< b. publishing_house << endl;
cout << "Year: "<< b.year << endl;
cout << "Pages: " << b.pages << endl;
cout << "Price: " << b.price << endl;
}
void main()
{ book a={"Programming Languages", "Jean E.Sammet",
"Prentice-Hall", 1969,785,49.99};
show_book(a);
getch();
}
//=== Результат работы ===
Если обработка структуры в функции связана с изменением содержимого полей, то такую структуру необходимо передавать по указателю или по ссылке.
Пример 10.2. Параметр функции указатель на структуру.
#include <iostream.h>
#include <conio.h>
struct book {
char title[40];
char authors[30];
char publishing_house[15];
int year;
int pages;
float price;
};
void input_book(book *b) //структура передается по указателю
{ cout << "Title: ";
cin >> b->title;
cout << "Authors: ";
cin >> b->authors;
cout << "Publishing house: ";
cin >> b->publishing_house;
cout << "Year: ";
cin >> b->year;
cout << "Pages: ";
cin >> b->pages;
cout << "Price: ";
cin >> b->price;
}
void main()
{ book a,*pa=&a;
input_book(pa);
getch();
}
Приме 10.3. Параметр функции ссылка на структуру
#include <iostream.h>
#include <conio.h>
struct book {
char title[40];
char authors[30];
char publishing_house[15];
int year;
int pages;
float price;
};
void input_book(book &b) //структура передается по ссылке
{ cout << "Title: ";
cin >> b.title;
cout << "Authors: ";
cin >> b.authors;
cout << "Publishing house: ";
cin >> b.publishing_house;
cout << "Year: ";
cin >> b.year;
cout << "Pages: ";
cin >> b.pages;
cout << "Price: ";
cin >> b.price;
}
void main()
{ book a;
input_book(a);
getch();
}
В некоторых таблицах используются многоэтажные шапки, когда определенное поле заголовка, в свою очередь, распадается на несколько полей. Такая же ситуация может встретиться и в структурах, когда в качестве очередного поля выступает другая структура. И уровень вложения таких полей ничем не ограничен.
Пример 10.4. Вложенные структуры
struct Point {int x; int y:};
struct Line {Point P1; Point P2;}
Line ab;
ab.P1.x=5;
ab.P1.y=5;
ab.P2.x=25;
ab.P2.y=30;
Конечно, приведенная выше запись не являет образец компактности в программе, но она демонстрирует составные имена, обеспечивающие доступ к вложенным полям.
Функции могут не только получать структуры в качестве своих параметров, но и возвращать результаты в виде структур. И это означает, что функция, возвращающая значение может иметь в качестве результата своей работы совокупность значений полей соответствующей структуры.
Пример 10.4. Функции g1, g2 и g3 возвращают структуру
#include <iostream.h>
#include <conio.h>
struct ss {int a; float b; };
ss g1(ss v) //параметр - значение
{ v.a=7; v.b=8; return v; }
ss g2(ss &v) //параметр - ссылка
{ v.a=v.b+7; v.b=v.a+8; return v; }
ss g3(const ss &v) //параметр - константная ссылка
{ ss q;
q.a=v.b+7; q.b=v.a+8; return q;
}
void main()
{ ss x1,y1={1,2};
ss x2,y2={3,4};
ss x3,y3={5,6};
x1=g1(y1);
cout << "x1=" << x1.a << '; ' << x1.b << endl;
cout << "y1=" << y1.a << '; ' << y1.b << endl;
y1=g1(y1);
cout << "x1=" << x1.a << '; ' << x1.b << endl;
cout << "y1=" << y1.a << '; ' << y1.b << endl;
x2=g2(y2);
cout << "x2=" << x2.a << '; ' << x2.b << endl;
cout << "y2=" << y2.a << '; ' << y2.b << endl;
x3=g3(y3);
cout << "x3=" << x3.a << '; ' << x3.b << endl;
cout << "y3=" << y3.a << '; ' << y3.b << endl;
y3=g3(y3);
cout << "x3=" << x3.a << '; ' << x3.b << endl;
cout << "y3=" << y3.a << '; ' << y3.b << endl;
getch();
}
//=== Результат работы ===
x1=7; 8 //x1 - изменился
y1=1; 2 //y1 - не изменился
x1=7; 8 //x1 - не изменился
y1=7; 8 //y1 - изменился
x2=11; 19 //x2 - изменился
y2=11; 19 //y2 - изменился
x3=13; 13 //x3 - изменился
y3=5; 6 //y3 - не изменился
x3=13; 13 //x3 - не изменился
y3=13; 13 //y3 - изменился
В языке C++ структуры послужили прообразом классов. Их основное отличие от структур языка C в том, что теперь структуры могут объединять разнородные данные с функциями методами их обработки. Часть компонент структуры может быть доступна всем пользователям, другая часть может быть сокрыта, т.к. предназначена для выполнения внутренних задач. Однако со всеми этими особенностями структур мы будем знакомиться после изучения основ объектно-ориентированного программирования.
Перечисления представляют собой список идентификаторов, введенных пользователем:
enum name_list {name1,name2,...};
За каждым таким именем по умолчанию закрепляются целочисленные константы:
имени name1 соответствует константа 0;
имени name2 соответствует константа 1;
……………………………………………
В чем смысл замены целочисленных констант такими символьными обозначениями? Дело в том, что в конкретных прикладных задачах удобно иметь дело с мнемоническими обозначениями характеристик некоторых объектов. Например, имея дело с цветами радуги, было бы удобно ввести обозначение для палитры радуги (rainbow) и перечислить в ней цвета в том порядке, как они упорядочены в природе ("каждый охотник желает знать, где сидят фазаны" красный, оранжевый, желтый, зеленый, синий, голубой, фиолетовый):
enum rainbow {red, orange, yellow, green, aqua, blue, purple};
Переменная r_col, которая будет в дальнейшем обозначать цвет радуги, должна быть связана с именем списка, и это позволит присваивать ей значения, более понятные, чем соответствующие целые числа:
enum rainbow r_col;
r_col=yellow;
...............
if(r_col==yellow)...
В языке C++ при объявлении переменных типа перечисление разрешается опускать служебное слово enum:
rainbow r_col=yellow;
В программах обработки экономической информации очень часто приходится иметь дело с названиями месяцев, дней недели. В таких случаях полезно прибегать к перечислениям типа:
enum week {sunday=1, monday, tuesday, wednesday, thursday, friday, saturday};
enum month {jan=1, feb, mar, apr, may, jun, jul, aug, sep, oct, nov, dec };
В приведенных выше примерах две особенности. Во-первых, нумерация констант по умолчанию с 0 противоречит общепринятым нормам работы с календарем. Поэтому для первой константы указано нестандартное значение 1, а все последующие будут пронумерованы в порядке возрастания номеров. Во-вторых, в перечислении названий месяцев имеется возможность нарваться на сообщение об ошибке. Дело в том, что сокращения для "октября" и "декабря" совпадают со служебными словами oct - восьмеричный и dec - десятичный, которые участвуют в управлении потоковым выводом числовых данных. Можно перейти к другим обозначениям, где первая буква месяца будет заглавной, тогда конфликта удастся избежать.
Перечисления очень широко используются многими системными программами, особенно графическими:
enum line_style{SOLID_LINE, //сплошная линия
DOTTED_LINE, //пунктирная линия
CENTER_LINE, //штрих-пунктирная линия
DASHED_LINE, //штриховая линия
USERBIT_LINE}; //линия, определяемая пользователем
enum COLORS {BLACK,BLUE,GREEN,CYAN,RED,MAGENTA,BROWN,...};
Системный набор операций над переменными типа перечислений довольно ограниченный: им можно присваивать значения из объявленного списка, сравнивать значения однотипных переменных, передавать в качестве параметров другим функциям. Попытка вывести их значения приводит к появлению на экране приписанных им числовых номеров. Даже какой-то контроль за выходом из допустимого числового интервала для этих данных не реализован. Об этом свидетельствует следующий пример:
#include <stdio.h>
#include ,conio.h>
void main()
{ enum qq {zero, one, two, three};
enum qq a,b;
a=one;
b=a+two;
printf("a=%d b=%d",a,b);
getch();
}
//=== Результат работы ===
a=1 b=3
Правда, при компиляции 7-й строки было выдано предупреждение о попытке присвоения значения типа int переменной типа qq, но все обошлось. Однако замена на оператор b=a+three; никаких эмоций у компилятора не вызвала и был получен результат b=4. Даже попытка выполнить b=5; тоже прошла гладко. Так что с данными типа enum компилятор работает как с целыми числами и на выход за пределы заданного диапазона внимания не обращает.
Обратим внимание еще на один нюанс в приписывании числовых номеров константам из перечисления:
enum num12 {one=1,ein=1,two,zwei=2};
Этот пример демонстрирует, что числовые номера в списке констант могут дублироваться, но сами имена должны быть уникальными. Использование одного и того же имени в двух разных перечислениях недопустимо.
Объединения это такие наборы данных, которые компилятор должен разместить в оперативной памяти, начиная с одного и того же места. Впервые такое совмещение разных данных появилось в языке ФОРТАН, где для этой цели использовался оператор EQUIVALENCE (эквивалентность). Основным назначением этого оператора была попытка экономии оперативной памяти за счет размещения вновь используемых массивов на месте уже отработавших массивов. В последующем этот оператор использовался и для совместного доступа к одним и тем же полям оперативной памяти как к данным разного типа. Наконец, еще одна дополнительная услуга со стороны оператора EQUIVALENCE состояла в том, что программисты, создающие разные фрагменты программы могли использовать разные имена для обозначения одних и тех же физических величин. Эквивалентность двух разных имен позволяла свести к минимуму переделки при объединении фрагментов программ.
В языках C, C++ объединения создаются с помощью оператора union. В рамках MS-DOS, где нехватка оперативной памяти давала себя знать, с помощью объединений можно наложить друг на друга массивы, используемые в разное время. Однако наиболее важная цель объединений расположить в одном и том же месте данные разного типа. Это позволяет обращаться к тем или иным полям, используя переменные разного типа. Наиболее характерным объектом такого типа является ячейка электронной таблицы, в которой пользователь может разместить текст или числовое выражение того или иного типа.
Самым употребительным объединением в рамках MS-DOS было использование машинных регистров при обращении к функциям BIOS и операционной системы. Для этой цели в заголовочном файле dos.h было определено объединение REGS:
struct WORDREGS // структура из 16-битных данных
{ unsigned int ax,bx,cx,dx,si,di,cflag,flags; };
struct BYTEREGS // структура из 8-битных данных
{ unsigned char al,ah,bl,bh,cl,ch,dl,dh; }
union REGS {struct WORDREGS x;
struct BYTEREGS h; };
В этих объявлениях содержатся описания двух структур, которые имитируют распределение в оперативной памяти машинных регистров процессора Intel-8086. На языке ассемблера эти регистры обозначаются как 16-битные регистры общего назначения (AX, BX, CX, DX), индексные регистры (SI, DI) и регистры флагов (CFLAG, FLAGS). Особенность регистров общего назначения в том, что каждый из них объединяет по 2 байта, к которым возможен автономный доступ отдельно к старшему байту регистра (AH, BH, CH, DH), отдельно к младшему байту (AL, BL, CL, DL). При обращениях к функциям MS-DOS приходится оперировать и с каждым байтом того или иного регистра, и с общим содержимым обоих байтов. Например, функция перевода курсора дисплея в заданную позицию, реализуется следующим фрагментом программы на языке ассемблера:
MOV AH,2 ; номер функции 2 засылается в регистр AH
MOV BH,0 ; номер страницы в текстовом режиме
MOV DH,10 ; номер строки, в которую переводится курсор
MOV DL,25 ; номер колонки
INT 10H ; вызов прерывания с номером 16
Для того чтобы выполнить аналогичные действия (не прибегая к библиотечной функции gotoxy) на языке C надо проделать следующие операции:
union REGS r; //заводим область регистров в памяти
...............
r.h.ah=2; //засылаем номер функции в "регистр" AH
r.h.bh=0; //засылаем номер страницы в "регистр" BH
r.h.dh=y; //засылаем номер строки в "регистр" DH
r.h.dl=x; //засылаем номер столбца в "регистр" DL
int86(0x10,&r,&r); //имитация прерывания с номером 10h
Функция int86 перепишет содержимое объединения r в машинные регистры, предварительно сохранив их содержимое, выполнит команду прерывания, передающую управление подфункции MS-DOS с номером 2, которая переместит курсор в заданную позицию. По окончании работы подфункции содержимое машинных регистров запомнится в объединении r, а их прежнее содержимое будет восстановлено.
Может быть, вам покажется, что приведенный фрагмент излишне усложнен, но реальная работа функции gotoxy(x,y) требует еще большего числа операций.
В языке C++ также была предпринята попытка использовать объединения для создания классов. Однако о деталях другого использования объединений в разделе "C++ и объектно-ориентированное программирование".
В большинстве своем файлы представляют собой именованные области внешней (дисковой) памяти, с которыми программы могут обмениваться информацией. Необходимость в таких обменах, во-первых, возникает, когда объем оперативной памяти недостаточен для хранения нужной информации. Во-вторых, программа может воспользоваться данными, полученными ранее другой программой и предусмотрительно записанными на диск. Наконец, в программах, требующих во время своей работы ввод исходных данных достаточно большого объема, целесообразно считывать эти данные из файла данные в файле можно подготовить заблаговременно и тщательно выверить.
К числу абонентов, которые могут участвовать в обмене данными, относятся и файлы-устройства (дисплей, принтер, графопостроитель, сканер, клавиатура, каналы связи и т.п.). Данный раздел посвящен работе с дисковыми файлами, хотя технология обслуживания файлов-устройств ничем принципиально не отличается.
Система управления файлами является одним из важнейших компонентов операционной системы. На нижнем (физическом) уровне поддержку работы с файлами и устройствами обеспечивает BIOS (Basic Input Output System базовая система ввода-вывода). Если на ранних моделях IBM-совместимых ПК программы BIOS "прошивались" в постоянном запоминающем устройстве (ПЗУ), то в современных компьютерах программы BIOS можно обновлять. Как правило, программисты напрямую к функциям BIOS не обращаются, предпочитая иметь дело с функциями среднего уровня, которые предоставляются пользователям операционной системой или функциями более высокого уровня, предусмотренными соответствующей системой программирования.
Файловая подсистема рассматривает внешние запоминающие устройства (жесткие и гибкие магнитные диски) как совокупность логических дисков, обозначаемых буквами латинского алфавита a, b, c, d, ... (или A, B, C, ...). На каждом логическом диске может быть создано корневое оглавление (каталог), где хранится информация о содержимом логического раздела. Минимальной единицей хранения является файл набор данных, снабженных следующей информацией:
Кластер это несколько следующих друг за другом секторов дискового пространства (по умолчанию объем каждого физического сектора 512 байт). Размер кластера зависит от общей емкости накопителя на магнитном диске. Если объем информации в файле превышает объем одного кластера, то следующая порция данных располагается на ближайшем свободном кластере, который физически может не быть смежным с первой порцией данных. Таким образом, большой набор данных оказывается разбросанным по разным не обязательно соседним участкам диска. Для сборки этих разрозненных цепочек и определения адреса последнего кластера с данными используется специальная таблица FAT (File Allocation Table таблица размещения файлов), где для каждого кластера записана либо ссылка на номер следующего кластера, либо указан признак конца файла (EOF End-of-File). Чем больше емкость винчестера, тем больше секторов в кластере. Поэтому последний кластер файла обычно бывает заполнен только частично, следовательно, система FAT не самым лучшим образом использует дисковое пространство. В современных операционных системах типа Windows XP поддерживается более эффективная система хранения файловой информации NTFS. Однако и в ней соблюдается древообразное построение каталогов и подкаталогов логических разделов диска.
Кроме информации о файлах в корневом каталоге содержатся сведения о вложенных подкаталогах, устроенных по тому же принципу, что и корневой каталог. Вложенные каталоги имеют индивидуальные имена. Поэтому, чтобы добраться до какого-то конкретного набора данных приходится указывать так называемую полную спецификацию файла, состоящую из имени логического диска, цепочки вложенных подкаталогов и собственно имени файла:
Максимальная длина полной спецификации, допускаемой в MS-DOS 79 символов. В операционных системах Windows-98/2000/XP для идентификации файлов могут использоваться более длинные имена, содержащие наряду с латинскими буквами и русские, а также пробелы.
При использовании имен файлов в программах на языках C, C++ следует помнить об одном соглашении спецификация файла задается строкой, в которой знак "обратного слэша" заменяется удвоенным обратным слэшем:
char namef[]="c:\\bc\\bin\\bc.exe"
Файловая подсистема обеспечивает возможность создания новых подкаталогов, создания и удаления файлов, копирования и переименования файлов. Средства операционной системы обеспечивают некоторую защиту наборов данных например, системные и скрытые файлы не всегда предоставляются взору пользователя, файлы, снабженные атрибутом Read-Only (только для чтения) уничтожаются не по первому нажатию соответствующей клавиши. Однако соответствующие оболочки операционных систем (типа Far, Disco Commander, Windows Commander и т.п.) с легкостью преодолевают такую защиту.
Оценивая ключевые аспекты процесса обмена данными, можно сказать, что работа с файлами, в основном, ограничивается тремя-четырьмя операциями:
Несмотря на кажущуюся простоту процесса обмена данными, файловые операции достаточно сложны в освоении. Во-первых, не следует забывать о трех уровнях доступа к файловым данным (BIOS, операционная система, система программирования). Во-вторых, операционные системы MS-DOS, Windows и Linux пытаются достичь совместимости в выполнении файловых операций. Все это приводит к появлению довольно большого количества различных обслуживающих программ. Так, системная библиотека BC 3.1 насчитывает более 120 функций для работы с файлами и свыше 60 констант, задающих режимы работы файловых процедур.
При работе с файлами приходится учитывать многочисленные форматы представления данных того или иного типа на разных носителях информации. Так, например, цепочка из k символов, представляющая текстовую строку, может храниться в одном из следующих форматов:
"S1S2S3...Sk" (переменное число символов, заключенных в одинарные или двойные кавычки);
kS1S2...Sk (k однобайтовый или двухбайтовый счетчик числа символов, предшествующий тексту);
S1S2...Sk\0 (\0 однобайтовый признак конца строки, расположенный вслед за последним символом текста);
S1S2...Sk 0D 0A (двухбайтовый признак конца строки, 0D "возврат каретки", 0A "перевод строки").
Числовая информация может быть записана в дисковый файл либо в машинном формате (а в языках С, С++ количество разных типов числовых данных достигает десятка), либо с предварительным преобразованием из машинного представления в символьное.
Кроме числовых и текстовых данных в файлах может храниться информация и другого происхождения. Например, графические изображения, которые в процессах обмена данными выступают как двоичные коды, условно разделенные на байты. Естественно, что на содержимое этих байтов нельзя реагировать так же, как на некоторые управляющие коды типа "Возврат каретки", "Перевод строки", "Признак конца файла", влияющие на передачу текстовой информации.
Далее, существует несколько способов доступа к файловым данным, из которых на практике чаще всего используют два последовательный и произвольный. Последний иногда называют прямым (DIRECT ACCESS) или случайным (RANDOM ACCESS).
Последовательный доступ при записи на диск характерен тем, что очередная записываемая порция пристраивается в хвост к предыдущей. Размеры смежных порций при этом могут оказаться разными по длине. При чтении такой набор данных начинает извлекаться с самой первой порции и очередность считываемых данных повторяет их последовательность во время записи.
Файлы с произвольным доступом состоят из данных, разбитых на порции фиксированной длины. При этом имеется возможность записывать или читать данные в произвольном порядке, указывая дополнительно номер нужной порции.
Наконец, необходимо учитывать и способы разделения отдельных числовых или символьных значений в дисковых наборах данных. В некоторых ситуациях роль таких разграничителей могут выполнять кавычки, запятые, пробелы и различные управляющие байты ("табуляторный пропуск", "возврат каретки", "перевод строки", "признак конца файла"). В других ситуациях для каждого данного может быть выделено поле фиксированной длины.
Системы программирования BC 3.1 и BCB поддерживает работу с файлами и потоками, данные в которых представлены либо в символьном, либо в двоичном формате.
Содержимое текстового файла очень напоминает то, что мы видим на экране дисплея, когда программа отображает на нем результаты вычислений. Разница только в том, что на экран дисплея только выводят, а текстовый файл можно использовать как хранилище информации, в которое не только пишут, но из которого еще и читают.
Текстовые файлы относятся к файлам последовательного доступа, т.к. единицей хранения информации в них являются строки переменной длины. Каждая строка заканчивается специальным признаком, обычно его функцию выполняет пара символов 0D0A "возврат каретки" и "перевод строки". Самым важным преимуществом текстовых файлов является универсальность формата хранения информации числовые данные в символьном виде доступны на любом компьютере, при необходимости их может прочитать и человек. Однако это преимущество имеет и обратную сторону медали преобразование числовых данных из машинных форматов в символьный вид при выводе и обратное преобразование при вводе сопряжено с дополнительными расходами. Кроме того, объем числовых данных в символьном формате занимает в несколько раз больше памяти по сравнению с их машинным представлением.
Текстовый файл может быть создан путем записи на диск символьных и/или числовых данных по заданному формату с помощью оператора fprintf. В качестве признака конца строки здесь заносятся те же самые байты 0D0A , которые появляются на диске в результате вывода управляющего символа \n.
Для инициализации текстового файла необходимо завести указатель на структуру типа FILE и открыть файл по оператору fopen в одном из нужных режимов "rt" (текстовый для чтения), "wt" (текстовый для записи), "at" (текстовый для дозаписи в уже существующий набор данных):
FILE *f1;
.........
f1=fopen(имя_файла, "режим");
Формат оператора обмена с текстовыми файлами мало чем отличается от операторов форматного ввода (scanf) и вывода (printf). Вместо них при работе с файлами используются функции fscanf и fprintf, у которых единственным дополнительным аргументом является указатель на соответствующий файл:
fscanf(f1,"список_форматов", список_ввода);
fprintf(f1,"список_форматов \n",список_вывода);
Если очередная строка текстового файла формируется из значения элементов символьного массива str, то вместо функции fprintf проще воспользоваться функцией fputs(f1, str). Чтение полной строки из текстового файла удобнее выполнить с помощью функции fgets(str,n,f1). Здесь параметр n означает максимальное количество считываемых символов, если раньше не встретится управляющий байт 0A.
Библиотека C предусматривает и другие возможности для работы с текстовыми файлами функции open, creat, read, write.
Пример 1. Рассмотрим программу, которая создает в текущем каталоге (т.е. в каталоге, где находится наша программа) текстовый файл с именем c_txt и записывает в него 10 строк. Каждая из записываемых строк содержит символьное поле с текстом "Line" (5 байт, включая нулевой байт признак конца строки), пробел, поле целочисленного значения переменной j, пробел и поле вещественного значения квадратного корня из j. Очевидно, что числовые поля каждой строки могут иметь разную длину. После записи информации файл закрывается и вновь открывается, но уже для чтения. Для контроля содержимое записываемых строк и содержимое считанных строк дублируется на экране.
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <conio.h>
main( )
{
FILE *f; //указатель на блок управления файлом
int j,k;
double d;
char s[]="Line";
f=fopen("c_txt","wt"); //создание нового или открытие существующего
// файла для записи
for(j=1;j<11;j++)
{
fprintf(f,"%s %d %lf\n",s,j,sqrt(j)); //запись в файл
printf("%s %d %lf\n",s,j,sqrt(j)); //вывод на экран
}
fclose(f); //закрытие файла
printf("\n");
f=fopen("c_txt","rt"); //открытие файла для чтения
for(j=10; j>0; j--)
{
fscanf(f,"%s %d %lf",s,&k,&d); //чтение из файла
printf("%s %d %lf\n",s,k,d); //вывод на экран
}
getch();
}
//== Результат работы ===
Line 1 1.000000
Line 2 1.414214
Line 3 1.732051
Line 4 2.000000
Line 5 2.236068
Line 6 2.449490
Line 7 2.645751
Line 8 2.828427
Line 9 3.000000
Line 10 3.162278
Line 1 1.000000
Line 2 1.414214
Line 3 1.732051
Line 4 2.000000
Line 5 2.236068
Line 6 2.449490
Line 7 2.645751
Line 8 2.828427
Line 9 3.000000
Line 10 3.162278
Обратите внимание на возможную ошибку при наборе этой программы. Если между форматными указателями %s и %d не сделать пробел, то в файле текст "Line" склеится с последующим целым числом. После этого при чтении в переменную s будут попадать строки вида "Line1", "Line2", , "Line10", в переменную k будут считываться старшие цифры корня из j (до символа "точка"), а в переменной d окажутся дробные разряды соответствующего корня. Тогда результат работы программы будет выглядеть следующим образом:
Line1 1.000000
Line2 1.414214
Line3 1.732051
Line4 2.000000
Line5 2.236068
Line6 2.449490
Line7 2.645751
Line8 2.828427
Line9 3.000000
Line10 3.162278
Line11 0.000000
Line21 0.414214
Line31 0.732051
Line42 0.000000
Line52 0.236068
Line62 0.449490
Line72 0.645751
Line82 0.828427
Line93 0.000000
Line103 0.162278
При считывании данных из текстового файла надо следить за ситуацией, когда данные в файле исчерпаны. Для этой цели можно воспользоваться функцией feof:
if(feof(f1))... //если данные исчерпаны
Двоичные файлы отличаются от текстовых тем, что представляют собой последовательность байтов, содержимое которых может иметь различную природу. Это могут быть байты, представляющие числовую информацию в машинном формате, байты с графическими изображениями, байты с аудиоинформацией и т.п. Содержимое таких байтов может случайно совпасть с управляющими кодами таблицы ASCII, но на них нельзя реагировать так, как это делается при обработке текстовой информации. Естественно, что единицей обмена с такими данными могут быть только порции байтов указанной длины.
Создание двоичных файлов с помощью функции fopen отличается от создания текстовых файлов только указанием режима обмена "rb" (двоичный для чтения), "rb+" (двоичный для чтения и записи), "wb" (двоичный для записи), "wb+" (двоичный для записи и чтения):
FILE *f1;
.........
f1=fopen(имя_файла, "режим");
Обычно для обмена с двоичными файлами используются функции fread и fwrite:
c_w = fwrite(buf, size_rec, n_rec, f1);
Здесь buf указатель типа void* на начало буфера в оперативной памяти, из которого информация переписывается в файл;
size_rec размер передаваемой порции в байтах;
n_rec количество порций, которое должно быть записано в файл;
f1 указатель на блок управления файлом;
c_w количество порций, которое фактически записалось в файл.
Считывание данных из двоичного файла осуществляется с помощью функции fread с таким же набором параметров:
c_r = fread(buf, size_rec, n_rec, f1);
Здесь c_r количество порций, которое фактически прочиталось из файла;
buf указатель типа void* на начало буфера в оперативной памяти, в который информация считывается из файла.
Обратите внимание на значения, возвращаемые функциями fread и fwrite. В какой ситуации количество записываемых порций может не совпасть с количеством записавшихся данных? Как правило, на диске не хватило места, и на такую ошибку надо реагировать. А вот при чтении ситуация, когда количество прочитанных порций не совпадает с количеством запрашиваемых порций, не обязательно является ошибкой. Типичная картина количество данных в файле не кратно размеру заказанных порций.
Двоичные файлы допускают не только последовательный обмен данными. Так как размеры порций данных и их количество, участвующее в очередном обмене, диктуются программистом, а не смыслом информации, хранящейся в файле, то имеется возможность пропустить часть данных или вернуться повторно к ранее обработанной информации. Контроль за текущей позицией доступных данных в файле осуществляет система с помощью указателя, находящегося в блоке управления файлом. С помощью функции fseek программист имеет возможность переместить этот указатель:
fseek(f1,delta,pos);
Здесь f1 указатель на блок управления файлом;
delta величина смещения в байтах, на которую следует переместить указатель файла;
pos позиция, от которой производится смещение указателя (0 или SEEK_SET от начала файла, 1 или SEEK_CUR от текущей позиции, 2 или SEEK_END от конца файла)
Кроме набора функций {fopen/fclose, fread/fwrite} для работы с двоичными файлами в библиотеке BC предусмотрены и другие средства _dos_open /__dos_close, _dos_read /_dos_write, _creat /_close, _read /_write. Однако знакомство со всеми возможностями этой библиотеки в рамках настоящего курса не предусмотрено.
Пример 2. Рассмотрим программу, которая создает двоичный файл для записи с именем c_bin и записывает в него 4*10 порций данных в машинном формате (строки, целые и вещественные числа). После записи данных файл закрывается и вновь открывается для чтения. Для демонстрации прямого доступа к данным информация из файла считывается в обратном порядке с конца. Контроль записываемой и считываемой информации обеспечивается дублированием данных на экране дисплея.
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <conio.h>
main( )
{ FILE *f1; //указатель на блок управления файлом
int j,k;
char s[]="Line";
int n;
float r;
f1=fopen("c_bin","wb"); //создание двоичного файла для записи
for(j=1;j<11;j++)
{ r=sqrt(j);
fwrite(s,sizeof(s),1,f1); //запись строки в файл
fwrite(&j,sizeof(int),1,f1); //запись целого числа в файл
fwrite(&r,sizeof(float),1,f1); //запись вещественного числа
printf("\n%s %d %f",s,j,r); //контрольный вывод
}
fclose(f1); //закрытие файла
printf("\n");
f1=fopen("c_bin","rb"); //открытие двоичного файла для чтения
for(j=10; j>0; j--)
{//перемещение указателя файла
fseek(f1,(j-1)*(sizeof(s)+sizeof(int)+sizeof(float)),SEEK_SET);
fread(&s,sizeof(s),1,f1); //чтение строки
fread(&n,sizeof(int),1,f1); //чтение целого числа
fread(&r,sizeof(float),1,f1); //чтение вещественного числа
printf("\n%s %d %f",s,n,r); //контрольный вывод
}
getch();
}
//=== Результат работы ===
Line 1 1.000000
Line 2 1.414214
Line 3 1.732051
Line 4 2.000000
Line 5 2.236068
Line 6 2.449490
Line 7 2.645751
Line 8 2.828427
Line 9 3.000000
Line 10 3.162278
Line 10 3.162278
Line 9 3.000000
Line 8 2.828427
Line 7 2.645751
Line 6 2.449490
Line 5 2.236068
Line 4 2.000000
Line 3 1.732051
Line 2 1.414214
Line 1 1.000000
Использованные в этом примере операторы:
fclose(f1); //закрытие файла
f1=fopen("c_bin","rb"); //открытие двоичного файла для чтения
могут быть заменены обращением к единственной функции freopen, которая повторно открывает ранее открытый файл:
f1=freopen("c_bin","rb");
Основное правило, которого надо придерживаться при обмене с двоичными файлами звучит примерно так как данные записывались в файл, так они должны и читаться.
Структурированный файл является частным случаем двоичного файла, в котором в качестве порции обмена выступает структура языка C, являющаяся точным аналогом записи в Паскале. По сравнению с предыдущим примером использование записей позволяет сократить количество обращений к функциям fread/fwrite, т.к. в одном обращении участвуют все поля записи.
Инициализация структурированного файла выполняется точно таким же способом, как и подготовка к работе двоичного файла.
Пример 3. Приведенная ниже программа является модификацией предыдущего примера. Единственное ее отличие состоит в использовании структуры (записи) b, состоящей из символьного (b.s, 5 байт, включая нулевой байт признак конца строки), целочисленного (b.n, 2 байта в BC и 4 байта в BCB) и вещественного (b.r, 4 байта) полей.
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <string.h>
#include <conio.h>
main( )
{ FILE *f1;
int j,k;
struct {
char s[5];
int n;
float r;
} b;
strcpy(b.s,"Line");
f1=fopen("c_rec","wb");
for(j=1;j<11;j++)
{ b.n=j; b.r=sqrt(j);
fwrite(&b,sizeof(b),1,f1);
printf("\n%s %d %f",b.s,b.n,b.r);
}
fclose(f1);
printf("\n");
f1=fopen("c_rec","rb");
for(j=10; j>0; j--)
{ fseek(f1,(j-1)*sizeof(b),SEEK_SET);
fread(&b,sizeof(b),1,f1);
printf("\n%s %d %f",b.s,b.n,b.r);
}
getch();
}
Результат работы этой программы ничем не отличается от предыдущего примера.
Библиотека языков C, C++ включает две функции sprintf и sscanf, с помощью которых реализуются прямые и обратные форматные преобразования данных в оперативной памяти. Техника их использования ничем не отличается от уже рассмотренных функций printf/fprintf и scanf/fscanf. Разница только в том, что первым аргументом новых функций является указатель на строку массив типа char, расположенный в оперативной памяти. Для функции sscanf эта строка является источником данных, а для функции sprintf в эту строку помещаются результаты преобразования данных из машинного представления:
sscanf(str,"список_форматов", список_ввода);
sprintf(str,"список_форматов \n",список_вывода);
К форматным преобразованиям в оперативной памяти можно прибегнуть и при работе с двоичным файлом. Перед записью в файл с помощью функции sprintf машинные форматы данных преобразуются в символьную строку, которая потом записывается в двоичный файл. По сути дела, такое же преобразование происходит при записи данных в текстовый файл
Визуальная среда программирования BCB поддерживает все описанные выше функции работы с файлами и предлагает дополнительный набор средств ориентированных на возможности операционной системы Windows. Главные отличия новых процедур заключаются в двух моментах. Во-первых, вместо указателя на блок управления файлом здесь используется условный целочисленный номер (мы будем обозначать его через fHandle), который операционная система присваивает каждому открываемому файлу (термин handle на американском сленге звучит как кликуха, кличка). Во-вторых, почти все строковые константы, используемые в качестве параметров процедур, имеют тип AnsiString. Это новый тип (класс) строковых данных, упрощающий манипуляции над строками. Чаще всего в рассматриваемых ниже процедурах фигурирует строка с именем файла FileName (на самом деле, в его качестве может выступать и полная спецификация файла).
В некоторых ситуациях перед созданием нового файла или перед открытием существующего файла бывает полезно удостовериться в том, что файл с таким именем уже существует. В одном случае так можно предотвратить ошибочное уничтожение данных в существующем файле, который мы пытаемся открыть для записи. В другом случае так можно удостовериться в том, что файл, из которого мы собираемся читать исходные данные, действительно существует.
Для такого рода проверки используется функция FileExists:
if(FileExists(FileName))... //если файл существует
Под управлением MS-DOS аналогичную роль выполняла функция access:
k=access(имя_файла,0); //k=0,если файл существует
Для создания нового файла и его одновременного открытия используется функция FileCreate:
int fHandle = FileCreate(FileName);
Если возвращаемое значение положительно, то работа по созданию файла завершена успешно. В случае ошибки возвращаемое значение равно 1.
Для открытия существующего файла используется функция FileOpen:
int fHandle = FileOpen(FileName, Mode);
Здесь Mode целочисленная константа, определяющая режим работы файла. Список мнемонических констант, предусмотренных в системе BCB, приведен в таблице 11.1.
Таблица 11.1
Константа |
Режим |
fmOpenRead |
Файл открывается только для чтения |
fmOpenWrite |
Файл открывается только для записи |
fmOpenReadWrite |
Файл открывается и для записи, и для чтения |
fmShareExclusive |
Файл только для личного использования |
fmShareDenyWrite |
Файл доступен другим приложениям для чтения |
fmShareDenyRead |
Файл доступен другим приложениям для записи |
fmShareDenyNone |
Файл доступен всем и для чтения, и для записи |
Если значение, возвращаемое функцией равно -1, то открытие не состоялось.
Для чтения двоичных данных из открытого файла используется функция
FileRead:
int k = FileRead(fHandle, buf, count);
Здесь buf указатель типа void* на начало буфера, в который читаются данные;
count количество запрашиваемых байтов;
k количество реально прочитанных байтов.
Для записи двоичных данных в открытый файл используется функция FileWrite:
k = FileWrite(fHandle, buf, count);
Здесь buf указатель типа void* на начало буфера, из которого пишутся данные;
count количество байтов, подлежащих записи;
k количество реально записанных байтов.
Если k=-1, то произошла какая-то ошибка.
Указатель файла перемещают в нужную позицию для организации прямого доступа к нужным данным. Для этой цели используется функция FileSeek:
int k = FileSeek(fHandle, delta, pos);
Здесь delta смещение в байтах, на которое нужно переместить указатель файла;
pos точка отсчета, относительно которой производится смещение (0 от начала файла, 1 от текущей позиции, 2 от конца файла);
k новая текущая позиция.
Для определения длины файла можно воспользоваться обращением:
fLength = FileSeek(fHandle,0,2);
Под управлением MS-DOS перемещение указателя в двоичном файле выполняла функция fseek:
fseek(f1,delta,pos); //f1 указатель типа FILE
Для закрытия файла используется функция FileClose:
FileClose(fHandle);
Описываемые ниже функции позволяют выделить из полной спецификации файла имя диска, имя каталога, имя файла, расширение файла и путь к файлу. Все они возвращают значение строки типа AnsiString:
s=ExtractFileDrive(FileName); //имя диска
s=ExtractFileDir(FileName); //имя каталога
s=ExtractFileName(FileName); //имя файла
s=ExtractFileExt(FileName); //расширение имени файла
s=ExtractFilePath(FileName); //путь к файлу
Аналогичные операции под управлением MS-DOS выполнялись единственной функцией fnsplit, которая из полной спецификации файла (первый аргумент функции) выделяла все составляющие компоненты:
fnsplit(const char *path,char *drive,char *dir,char *name,char *ext);
Функции удаления возвращают логическое значение true, если удаление состоялось, и false в случае отказа от операции (например, нельзя удалять не пустые каталоги, файлы с атрибутом Read-Only или несуществующие файлы).
bv = DeleteFile(FileName);
bv = RemoveDir(DirName);
Под управлением MS-DOS для удаления файла можно было воспользоваться функцией unlink:
k=unlink(const char *filename); //k=0, если файл удален
Новый каталог создается с помощью функции CreateDir:
bv = CreateDir(DirName);
Если каталог создан, то возвращаемое значение равно true.
Для замены имени файла используется функция RenameFile:
bv = FileRename(OldName,NewName);
Если переименование состоялось, то возвращаемое значение равно true.
Новое расширение (Extension) заменяет прежнее расширение в имени файла, если обратиться к функции ChangeExt:
as = ChangeFileExt(FileName, Extension);
При этом прежний файл и его содержимое сохраняется со своим старым именем. Создается новое имя файла (as), имеющее новое расширение.
Точно такой же функции в системе MS-DOS нет, но там можно было бы разложить спецификацию файла на составляющие (функция fnsplit) и заново собрать компоненты, подменив расширение файла (функция fnmerge).
Функция FileGetAttr позволяет узнать атрибуты файла, которые выдаются как двоичные разряды в возвращаемом значении:
int k = FileGetAttr(FileName);
В таблице 11.2 приведены значения отдельных флажков.
Таблица 11.2
Флажок |
Значение |
Пояснение |
faReadOnly |
0x01 |
признак "Только для чтения" |
faHidden |
0x02 |
скрытый файл |
faSysFile |
0x04 |
системный файл |
faVolumeId |
0x08 |
признак "Идентификатор тома" |
faDirectory |
0x10 |
признак "Каталог" |
faArchive |
0x20 |
архивируемый файл |
faAnyFile |
0x3F |
суммарный набор признаков |
Для проверки тог или иного признака можно воспользоваться указанными константами:
if(k & faHidden)... //если файл скрытый
Для установки новых атрибутов используется функция FileSetAttr:
k = FileSetAttr(FileName,Attr);
Второй аргумент задается как логическая сумма флажков, приведенных в табл. 11.2:
Attr = faReadOnly | faSysFile;
Текущий каталог это каталог, из которого запущена программа. Если файлы используемые программой находятся в текущем каталоге, то во всех файловых процедурах можно указывать только короткое имя файла. Если файл находится в другом каталоге, то приходится задавать полную спецификацию файла. Для сокращения можно изменить имя текущего каталога, чтобы избежать задания полных путей. Система BCB предлагает две функции, с помощью которых можно узнать или изменить имя текущего каталога:
as = GetCurrentDir(); //опрос имени текущего каталога
vb = SetCurrentDir(NameDir); //смена текущего каталога
Одной из довольно распространенных процедур является составление списка файлов указанного каталога, имена которых удовлетворяют заданной маске. Под управлением MS-DOS такая задача решается с помощью функций findfirst (найти первый файл) и findnext (найти следующий файл). Обе функции используют в качестве одного из своих аргументов адрес структуры типа ffblk, в которую они заносят информацию о найденном файле:
struct ffblk {
char ff_reserved[21]; //зарезервировано для MS-DOS
char ff_attrib; //байт атрибутов найденного файла
int ff_time; //время создания/модификации файла
int ff_date; //дата создания/модификации файла
long ff_size; //размер файла
char ff_name[13]; //имя найденного файла
};
Описание этой структуры и прототипы указанных функций находятся в заголовочном файле dir.h. Обе функции возвращают нулевое значение, если поиск закончился удачно. Достаточно четкое представление об их использовании дает следующий пример, который выводит на экран список всех файлов с расширением .cpp из каталога c:\bc\bin:
#include <stdio.h>
#include <conio.h>
#include <dir.h>
void main()
{ struct ffblk qq;
int a;
printf("Список файлов *.cpp\n");
a=findfirst("c:\\bc\\bin\\*.cpp",&qq,0); //поиск первого файла
while(!a)
{ printf(" %s\n",qq.ff_name);
a=findnext(&qq); //поиск следующего файла
}
getch();
}
Первый аргумент функции findfirst определяет маску, которой должно удовлетворять имя искомого файла. Третий аргумент этой функции имеет тип int и позволяет фильтровать найденные объекты по любой комбинации их атрибутов в файловой системе (Read Only, Hidden, System, Archive, Volume, Directory). Нулевое значение этого параметра, использованное в примере, игнорирует отбор по атрибутам.
Точно таким же способом можно осуществлять поиск нужных файлов в среде BCB.
На разных этапах развития программирования выдвигались различные концепции, которые могли бы обеспечить быстрое создание качественных программ, возможность компоновки больших программных систем из небольших хорошо отработанных модулей, возможность повторного использования ранее разработанных программ. Среди таких подходов можно упомянуть структурное и модульное программирование, объектно-ориентированное программирование. Следует отметить, что универсальных средств, которые бы полностью решали указанные задачи не существует и на сегодняшний день.
Однако определенные технологии получили свое развитие и в программировании, приблизив его к промышленным методам создания программных продуктов.
В этой связи мы должны вспомнить историю развития одного из мощнейших средств автоматизации программирования разработку библиотек стандартных программ. В нашей стране в начале 60-х годов прошлого столетия на отечественных ЭВМ типа М-20 в Институте Прикладной математики АН СССР им. акад. М.В. Келдыша была разработана интерпретирующая система ИС-2, которая положила начало созданию библиотек стандартных подпрограмм, а впоследствии и стандартных алгоритмов на алгоритмических языках. Традиция эта была подхвачена журналом Ассоциации вычислительных машин (Communications of the ACM, США), который на протяжении десятков лет публиковал алгоритмы решения математических задач на языках АЛГОЛ-60 и ФОРТРАН. Публикация исходных текстов преследовала не только просветительские цели, читатели находили ошибки в опубликованных алгоритмах, предлагали более эффективные решения. Таким способом на протяжении полутора десятков лет отлаживался и накапливался архив типовых методов решения задач линейной алгебры, дифференциального и интегрального исчислений, решения нелинейных уравнений, статистической обработки данных, вычисления математических функций и т.п. Он составил основу пакета научных программ SSP (Scientific Subroutine Package) на ФОРТРАНЕ, который был взят на вооружение фирмой IBM и стал доступным для пользователей IBM/360, IBM/370. С появлением в нашей стране IBM-совместимых моделей серии ЕС ЭВМ с этим пакетом познакомились и отечественные программисты.
Современные системы программирования на базе ПК, к сожалению, не оценили вычислительные возможности пакета SSP и его расширений. Конечно, они включили в состав своих системных библиотек наиболее распространенные математические функции. Но основу сегодняшних системных библиотек составляют не методы решения математических задач. Начинка системных библиотек в BC и BCB состоит, в основном, из всякого рода сервисных программ, обеспечивающих поддержку некоторых типов данных (строки, множества, комплексная арифметика, дата и время, преобразования типов данных), управление дисплеем в текстовом и графическом режимах, обслуживание интерфейса с другими внешними устройствами и др. Те алгоритмы и программы, которые когда-то входили в состав SSP, теперь распространяются в различных коммерческих продуктах типа MathCAD, MatLab, Statistica, Mathematica и т.п.
Библиотеки системы BC 3.1 представлены файлами в каталоге BC\LIB. Там находится примерно 25 файлов с расширениями .lib, около двух десятков файлов с расширениями .obj и три файла с расширениями .c. Для более детального знакомства с этими файлами напомним терминологию, принятую во многих системах программирования:
Большое количество файлов, находящихся в разделе BC\LIB, объясняется тем, что некоторые библиотеки сформированы в нескольких вариантах для разных моделей памяти. В рамках MS-DOS система BC 3.1 могла создавать программы для шести разных моделей памяти Tiny (крошечная), Small (малая), Medium (средняя), Compact (компактная), Large (большая) и Huge (огромная). Для каждой из этих моделей использовалось свое распределение памяти в пределах выделенного ресурса, использовалась своя адресация, разные типы указателей (ближние и дальние) и т.п. Поэтому ряд библиотек представлен в шести экземплярах.
Библиотека системных функций BC 3.1 условно разбита на 17 разделов, названия которых представлены в табл. 12.1. В общей сложности в этих разделах хранится порядка 600 функций.
Таблица 12.1
Раздел библиотеки |
Число функций |
Пояснение |
Classification routines |
12 |
Проверка символов на принадлежность определенной категории |
Conversion routines |
21 |
Преобразование типов данных |
Directory control routines |
31 |
Работа с дисками и каталогами |
Diagnostic routines |
3 |
Программы диагностики |
Graphics routines |
79 |
Графические процедуры |
Inline routines |
16 |
Встраиваемые функции |
Input/Output routines |
110 |
Управление вводом/выводом |
Interface routines |
75 |
Обслуживание BIOS |
Math routines |
88 |
Математические функции |
Memory routines |
28 |
Управление оперативной памятью |
Miscellaneous routines |
7 |
Разное (звук, задержка, переходы) |
Process control routines |
24 |
Управление процессами и сигналами |
Standard routines |
30 |
Стандартные функции |
String and memory routines |
43 |
Управление строками и памятью |
Text window display routines |
20 |
Управление дисплеем в текстовом режиме |
Time and date routines |
25 |
Работа с датой и временем |
Variable argument list routines |
3 |
Обслуживание списка аргументов |
Для обслуживания библиотек в состав системы программирования входит утилита tlib.exe. Запускается она из командной строки со следующим списком возможных параметров:
>tlib.exe lib_name [/C][/E][/P][/0] commands,listfile
Здесь lib_name имя библиотечного файла, расширение .lib можно не писать;
[/C][/E][/P][/0] необязательные ключи режимов работы;
commands команды по обслуживанию библиотеки;
listfile список файлов.
Ключи режимов имеют следующий смысл:
/C в библиотеке предусмотрено различать большие и малые буквы, как это предусмотрено и стандартом языка C (по умолчанию в библиотеках большие и малые буквы не различаются);
/E в библиотеке будет создан расширенный словарь, который ускорит компоновку программ, использующих объектные модули из библиотеки;
/Pnnnn в библиотеке будет использован заданный размер "страницы" (в MS-DOS этот размер фиксирован и равен 200h);
/0 из объектных модулей будут удалены записи с комментариями.
Команды, набираемые в строке, состоят из одного или двух символов, вслед за которыми указывается имя объектного модуля. В имени объектного модуля разрешается опускать расширение .obj. Символ (-ы) перед именем модуля определяет действие, которое должно быть выполнено:
+ добавить модуль к содержимому библиотеки;
удалить модуль из библиотеки;
* извлечь модуль из библиотеки, сохранив там его копию;
+ (или +) заменить прежний модуль в библиотеке новым;
* (или *) извлечь модуль и удалить его из библиотеки.
С помощью утилиты tlib.exe можно выдать содержимое библиотеки в указанный файл:
>tlib.exe lib_name,1.txt
По этой команде в текстовом файле 1.txt окажется список всех объектных модулей, находящихся в библиотеке lib_name с указанием их длин и меток входа. Выглядит это примерно так:
Publics by module
ABS size = 14
_abs
ABSREAD size = 292
_absread _abswrite
ACCESS size = 55
_access
ALLOCA size = 50
_alloca
Любую функцию, написанную на языке C или C++ можно автономно откомпилировать и получить объектный модуль, который впоследствии можно присоединять к любому проекту. Естественно, что объектный модуль может оказаться полезным в последующей разработке программ, если он тщательно отлажен. Чтобы не хранить большое количество отдельных объектных файлов, их можно с помощью утилиты tlib.exe объединить в библиотеку. Любая библиотека может быть присоединена к проекту вновь разрабатываемой программы. Этими возможностями нужно обязательно пользоваться при разработке больших программных систем.
Для создания новой библиотеки достаточно добавить один или несколько объектных модулей к библиотечному файлу. Если такой библиотеки еще не было, то она будет создана. Пусть, например, в текущем каталоге находятся три объектных модуля с именами q1.obj, q2.obj и q3.obj. Для того чтобы создать в этом же каталоге новую библиотеку с именем qqq.lib, выполнить следующую команду:
>tlib qqq +q1 +q2 +q3
Если нам потребуется заменить модуль q1.obj на исправленную версию q1.obj, добавить модуль q4.obj и одновременно удалить из библиотеки модуль q2.obj, то это можно сделать следующим образом:
>tlib qqq -+q1 +q4 -q2
Если количество одновременно подключаемых модулей достаточно велико, то командной строки, ограниченной 127 символами, может не хватить. В этом случае обычно создают текстовый файл из строк вида:
+q1 +q2 +q3 ... +q15 &
+q16 +q17 ... +q30 &
+q31 +q32 ....
В конце каждой строки записывается символ &, который играет роль знака переноса. Если созданный таким образом файл имеет имя qq.txt, то запуск утилиты tlib выглядит следующим образом:
>tlib qqq @qq.txt
Подключение заранее изготовленных объектных модулей или извлечение нужных модулей из присоединяемых библиотек можно выполнить как в интегрированной среде, так и с помощью автономной утилиты tlink.exe, запускаемой из командной строки.
В интегрированной среде BC 3.1 файл проекта имеет расширение .prj (от project проект). Автоматически он не создается, т.к. большинство небольших программ состоит из единственного программного файла и кроме системных компонент (библиотек и объектных модулей) ничего другого не использует. Однако в случае необходимости мы можем открыть файл проекта с помощью команды Project Open project (если он еще не существовал, то будет создан заново) и добавить к нему нужные компоненты с помощью команд ProjectAdd item.
В интегрированной среде BCB файл проекта создается автоматически и имеет расширение .bpr (от Builder Project). Команда Add to Project в меню Project предназначена для добавления в состав проекта новых объектных модулей и библиотек.
Автономный редактор связей tlink.exe запускается из командной строки со следующим набором возможных параметров:
>tlink /k1 /k2 ,q1 q2 ,eq,mq,lib1 lib2
Здесь /k1 /k2... набор ключей, управляющих работой tlink;
q1 q2 список объектных модулей, включая и головную функцию main;
eq имя исполняемого модуля;
mq имя модуля, в который записывается "карта" памяти;
lib1 lib2 имена подключаемых библиотек.
Перечень ключей программа tlink.exe выдает после запуска без параметров. Их расшифровка приведена в табл. 12.2. Расширения имен файлов, если они стандартные, можно опускать (.obj объектные модули, .exe исполняемый модуль, .map файл "карты" памяти, .lib библиотечный файл). "Карта" памяти или таблица распределения памяти содержит информацию о размещении функций и глобальных переменных в сегментах памяти.
Таблица 12.2
Ключ |
Пояснение |
/m |
Выдача "карты" памяти со всеми общедоступными именами |
/x |
Запрет вывода "карты" памяти |
/i |
Инициализация всех сегментов данных |
/l |
Подключение номеров строк исходных программ |
/s |
Вывод подробной "карты" памяти сегментов |
/n |
Отключение библиотек по умолчанию |
/d |
Выдача предупреждений о дублировании имен в библиотеках |
/c |
Режим различения больших и малых букв |
/3 |
Разрешение использовать 32-битные операнды и команды |
/v |
Сохранение дополнительной информации для отладки |
/e |
Игнорирование расширенных словарей в библиотеках |
/t |
Создание com-файла вместо exe-файла |
В довольно ранних версиях операционных систем наряду со статическими библиотеками объектных модулей появились динамически загружаемые библиотеки с расширением .dll (от Dynamic-link libraries). Динамически загружаемые библиотеки Windows могут иметь и другие расширения .exe или .drv.
Основная разница между статическими и динамическими библиотеками заключается в следующем. Если используется статическая библиотека, то на стадии редактирования связей в состав исполняемого модуля включаются все функции, для которых обнаружено обращение из текста исходной программы. В отличие от этого вызов модуля из динамической библиотеки происходит только на стадии выполнения программы. При таком подходе библиотечные функции не включаются в состав исполняемого модуля, его размеры становятся меньше и, тем самым, экономится место, занимаемое исполняемыми файлами на диске.
У каждого из этих подходов есть свои плюсы и минусы. При использовании статических библиотек размер исполняемого модуля возрастает, т.к. к нему подключаются все функции, упомянутые в программе. Однако такой модуль можно выполнить на любом компьютере независимо от того, установлена ли там соответствующая система программирования или нет. При использовании динамически загружаемых библиотек размер исполняемого модуля не так велик, но для его работы требуется присутствие в оперативной памяти динамической библиотеки, из которой в случае необходимости потребуется запустить тот или иной модуль. Поэтому при переносе программы на другой компьютер придется кроме исполняемого модуля захватить и всю цепочку задействованных динамических библиотек. Правда, в разумных системах программирования предусмотрен режим компиляции с включением всех вызываемых функций в состав исполняемого модуля.
В состав системы программирования BC 3.1 входит препроцессор cpp.exe, который выполняет следующую подготовительную работу перед компиляцией программы:
Все действия препроцессора диктуются директивами, которые программист включает в текст своей программы. Первым символом директивы является символ #.
Для включения в текст программы указанных файлов используется директива #include (от англ. включить), допускающая два следующие формата:
#include <file_name>
#include "file_name"
Угловые скобки являются указанием препроцессору, что поиск файла с заданным именем надо начинать с системного каталога (например, с каталога c:\bc\include). Если в указанном каталоге файл file_name не обнаружен, то поиск продолжается сначала с текущего каталога, а затем по всем каталогам, перечисленным в директиве PATH операционной системы. Если имя файла заключено в двойные кавычки, то поиск начинается с текущего каталога.
Обычно, с помощью директивы #include к программе подключаются системные и пользовательские заголовочные файлы с расширением .h (от header заголовок). Однако точно так же к программе можно подключить ранее заготовленный фрагмент исходного кода, оформленный в виде текстового файла с расширением .inc.
Замена одной цепочки символов в тексте программы на другую цепочку символов реализуется с помощью макроподстановки #define (от англ. определить):
#define s1s2s3...sn q1q2...qm
При этом цепочка символов s1s2s3...sn в тексте исходной программы будет заменена на цепочку q1q2...qm . Пробелы перед замещающей цепочкой и в ее конце игнорируются. Замене не подвергаются значения строк и комментарии. Заменяющий фрагмент может оказаться и многостроковым. В этом случае в конце каждой строки помещается символ переноса "\". В приведенной ниже программе содержится несколько наиболее характерных примеров использования директивы #define:
#include <stdio.h>
#include <conio.h>
#define Nmax 100
#define max(a,b) ((a)>(b))?(a):(b)
#define print(a) printf("\n%s=%d\n",#a,a);\
getch()
void main()
{ int x=5,y=8;
int z=Nmax;
int w=max(x*y,z);
print(x);
print(y);
print(z);
print(w);
}
//=== Результат работы ===
x=5
y=8
z=100
w=100
Обратите внимание на некоторые тонкости в приведенных подстановках.
Во-первых, аргументы макроопределения-функции max в замещающем выражении заключены в круглые скобки. Это позволяет использовать в конкретных обращениях нормальные арифметические выражения. Представим себе, что в программе достаточно часто приходится использовать операцию возведения в квадрат. Если для этой цели макроподстановку Square(x) определить без использования скобок (#define Square(x) x*x), то для арифметического выражения Square(1+z) результат такой подстановки даст 1+z*1+z=2*z+1, т.е. заведомо неправильное значение.
Наличие круглых скобок тоже не является стопроцентной гарантией правильности результата подстановки. Например, попытка обратиться к макросу max необычным образом сообщений об ошибках не вызовет, но и результат может оказаться далеким от истины:
w=max(x+=2,y+=3); //результат подстановки w=14
w=max(x+2,y+3); //результат правильный w=11
В бесскобочном макроопределении первое обращение привело бы к синтаксической ошибке (Lvalue required требуется левое значение).
Во-вторых, макроопределение max не зависит от типа используемых данных.
В-третьих, в макроопределении print использована довольно редко описываемая возможность вывода имени переменной (#a интерпретируется препроцессором как имя переменной a). Если бы мы включили эту переменную в форматную строку (printf("\na=%d",a);), то ничего хорошего из этого бы не вышло. Так как на содержимое строк макроподстановка не распространяется, то при каждом обращении вместо имени очередной переменной выводился бы символ 'a'.
В операторе макроподстановки иногда используется операция склейки лексем (аналог того, что при работе со строками называют конкатенацией):
#define Paste(a,b) a##b
В результате такой подстановки строка Paste(x,4) будет заменена на x4.
Две группы следующих директив используются для организации "условной компиляции":
#define...#ifdef...ifndef...#undef
#if...#elif...#elif...#else...#endif
На самом деле препроцессор компиляцией не занимается, он просто включает или отключает фрагменты исходной программы, которые в дальнейшем будут или не будут обрабатываться компилятором.
Если заглянуть в любой заголовочный файл из каталога ...BC\INCLUDE, например, в файл math.h, то в самом его начале (исключая комментарий по поводу авторских прав) находятся следующие строки:
#ifndef __MATH_H
#define __MATH_H
#if !defined(___DEFS_H)
#include <_defs.h>
#endif
В чем смысл двух первых строк? Сначала проверяется, была ли ранее объявлена подстановка для идентификатора __MATH_H (этот идентификатор является уникальным, т.к. он повторяет имя заголовочного файла). Если такого указания еще не было, то следующая строка объявляет о необходимости такой подстановки в строках программы, следующих ниже по тексту (не важно, что замещающее выражение пусто), и все остальное содержимое файла, который мы присоединяем по директиве #include <math.h>, будет включено в текст наше программы. Но если такая подстановка ранее была заявлена, то повторное подключение файла math.h не произойдет. Это позволяет избежать дублирования констант и других макроопределений, которые могли бы появиться из-за повторения заголовочного файла.
Примерно такую же функцию выполняют три следующие строки. Первая из них проверяет, не состоялось ли ранее присвоение значения идентификатору ___DEFS_H. Если такого действия еще не было, то файл defs.h будет загружен в оперативную память. В противном случае фрагмент программы до строки #endif не будет передан компилятору. Кстати, подобная тройка строк присутствует во многих заголовочных файлах, и такая проверка предупреждает повторную загрузку файла defs.h.
Более детально, первая группа директив препроцессора выполняет следующие действия:
Таблица 13.1
Директива |
Пояснение |
#define name value |
Объявляет о необходимости замены выражения name на значение value в строках программы, расположенных ниже |
#ifdef name |
Проверяет, была ли объявлена замена выражения name (true, если была) |
#ifndef name |
Проверяет, была ли объявлена замена выражения name (true, если не была) |
#undef name |
Отменяет указание о замене выражения name в строках программы, расположенных ниже |
Вторая группа директив предназначена для включения или отключения фрагментов программы пользователя средствами, напоминающими действие условного оператора if then else:
#if (условие_1) //если выполнено условие_1
фрагмент_1
#elif (условие_2) //если выполнено условие_2
фрагмент_2
#elif (условие_3) //если выполнено условие_3
фрагмент_3
.............
#else //если не выполнено ни одно из предшествующих условий
фрагмент_k
#endif //конец проверок
Если результат проверки дал положительный результат, то следующий за ним фрагмент программы будет передан компилятору. В противном случае это фрагмент исключается из текста исходной программы (не затирается, а просто не поступает на вход компилятора). Условия, которые задаются в директивах проверки, могут выполняться только над константными выражениями:
#define name 1
.............
#if (name==1)
Одним из достаточно частых применений группы #if...#endif является подключение или отключение отладочных выдач.
В состав системы программирования включен компилятор командной строки bcc.exe. К его услугам прибегают опытные программисты в тех случаях, когда необходимо создать достаточно большую программу. Под управлением MS-DOS из 640 килобайт задаче доступно максимум 580 610 Кб (часть занята компонентами операционной системы и оболочки типа Norton Commander), но интегрированная среда отнимает из этого объема памяти еще порядка 300 Кб.
Командная строка по запуску автономного компилятора имеет следующий вид:
>bcc [ключи] file1 file2 ...
Список ключей, управляющих работой компилятора, приведен в табл.13.2. Для отключения режима надо использовать дополнительный минус после ключа (например -K- вместо -K).
Обработка файлов, указанных в командной строке, осуществляется по следующим правилам:
Таблица 13.2
Ключ |
Пояснение |
Режимы по умолчанию |
-1 |
Использовать команды процессора 80186/286 |
|
-2 |
Использовать команды процессора 80286 в защищенном режиме |
|
-Ax |
Запретить расширения |
|
-B |
Компилировать, используя ассемблер |
|
-C |
Разрешить вложенные комментарии |
|
-Dxxx |
Определить макро |
|
-Exxx |
Задать имя альтернативного компилятора с ассемблера |
|
-G |
Оптимизировать программу по скорости |
|
-Hxxx |
Использовать прекомпилированные заголовочные файлы |
|
-Ixxx |
Задать каталог с подключаемыми файлами |
|
-K |
Считать тип char по умолчанию беззнаковым |
|
-Lxxx |
Задать каталог с библиотеками |
|
-M |
Генерировать карту распределения памяти |
|
-N |
Контролировать переполнение стека |
|
-Ox |
Использовать указанную оптимизацию |
|
-P |
Отдать предпочтение компилятору C++ (даже если исходный модуль имел расширение .c) |
|
-Qxxx |
Контролировать использование памяти |
|
-S |
Генерировать программу на ассемблере |
|
-Txxx |
Установить опции компилятора с ассемблера |
|
-Uxxx |
Отменить действие макро |
|
-Vx |
Контролировать таблицу виртуальных адресов |
|
-Wxxx |
Создать приложение Windows |
|
-X |
Не создавать файл адресных ссылок |
|
-Yx |
Управлять загрузкой оверлеев |
|
-Z |
Запретить перезагрузку регистров |
|
-a |
Распределять память по границе слова |
|
-b |
Рассматривать тип данных enum как целые числа |
да |
-c |
Только компилировать |
|
-d |
Объединять дублирующиеся строки |
|
-exxx |
Задание имени исполняемого файла |
|
-fxx |
Задание опций с плавающей запятой |
|
-gN |
Останов после N предупреждений |
|
-iN |
Задание максимальной длины идентификаторов |
|
-jN |
Останов после N ошибок |
|
-k |
Установка стандартной конфигурации стека |
|
-lx |
Установка опций редактора связей |
|
-mx |
Установка модели памяти |
|
-nxxx |
Задание каталога для результирующего файла |
|
-oxxx |
Задание имени объектного файла |
|
-p |
Передавать параметры функций в соответствии с соглашениями Паскаля |
|
-r |
Использование регистровых переменных |
да |
-u |
Добавлять подчерк к именам внешних меток |
да |
-v |
Отладка на уровне исходного текста |
|
-wxxx |
Контроль предупреждений |
|
-y |
Генерировать информацию о номерах строк |
|
-zxxx |
Задание имен сегментов |
Управление ключами компилятора bcc.exe требует достаточно глубоких профессиональных знаний, поэтому начинающим программистам следует избегать переназначения режимов, действующих по умолчанию.
В состав систем программирования BC 3.1 и BCB входит очень мощная поисковая утилита grep.com. Ее имя образовано от аббревиатуры, которая в ранних руководствах представлялась как Global Regular Expression Print Печать Глобальных Регулярных Выражений. В новых руководствах предложена более прогрессивная комбинация слов Generalized Regular Expression Parser Обобщенный Грамматический Разбор Регулярных Выражений. Так или иначе, в обеих аббревиатурах присутствует термин регулярные выражения. С простейшими представителями регулярных выражений встречался практически каждый программист, который искал или копировал файлы с именами типа *.txt или ?set.txt. В этих именах символ * обозначал цепочку любых символов (в том числе и цепочку нулевой длины), а знак ? заменял любой одиночный символ.
Утилита запускается из командной строки со следующим набором параметров:
>grep [ключи поиска] поисковый_образ область_поиска
Ключи поиска представляют собой одиночные буквы или цепочки букв, которым предшествует символ ''. Например, A -D . Вслед за буквой может находиться знак минус (опция отключена) или знак плюс (опция включена). По умолчанию R эквивалентно R+. В ключах большие и малые буквы эквивалентны. Смежные ключи могут объединяться в группы: -C -D -I эквивалентно -CDI или -CI -D. В некоторых случаях порядок ключей может оказаться важным, т.к. действия одного могут поглощать действия другого. Список ключей и расшифровка устанавливаемых ими режимов поиска приведены в табл. 13.3.
Таблица 13.3
Ключ |
Назначение |
-C- |
Выводить только количество совпадений в каждом файле без указания номеров строк. Без этой опции номера строк выдаются. |
-D+ |
Производить поиск с заходом в подкаталоги. Без указания пути и без ключа -D поиск производится только в текущем каталоге. |
-I+ |
Игнорировать разницу между большими и малыми буквами (по умолчанию большие и малые буквы различаются). |
-L- |
Выводить только имена файлов, где обнаружен поисковый образ |
-N- |
Выводить номера строк в файлах, где обнаружен поисковый образ |
-O- |
Выводить сообщения в формате Unix |
-R+ |
Искать регулярные выражения |
-U- |
Изменить стандартные опции поиска, действующие в GREP по умолчанию, и сохранить новые опции в grep.com |
-V- |
Инверсный поиск выдаются только те строки, в которых поисковый образ не содержится. |
-W- |
Поиск слова. По умолчанию слово состоит из букв, цифр и символа подчерк Все остальные символы рассматриваются как разделители слов. |
-Z- |
Более подробный вывод имя файла, число совпадений (даже если оно равно 0) |
Поисковый образ S1S2...Sk может быть представлен просто словом, текстом, заключенным в кавычки (если в поисковом образе есть пробелы или табуляторные пропуски), или регулярным выражением. Если используется ключ -R, то поисковый образ представляет регулярное выражение, в котором присутствуют специальные символы, имеющие следующий смысл:
"^S1S2...Sk" символ "крышка" (caret) перед поисковым образом означает, что результат поиска должен находиться в начале строки;
"S1S2...Sk$" символ доллара после поискового образа означает, что результат поиска должен находиться в конце строки;
. точка заменяет любой символ;
\ слэш предшествует символу, который ищется (например, \. означает поиск точки);
* предшествующий символ может встретиться 0 или большее число раз (например,
qw* означает поиск q, qw, qww, qwww, ...);
+ предшествующий символ может встретиться 1 или большее число раз (например,
qw+ означает поиск qw, qww, qwww, ...);
[α1α2... αk] должен встретиться хотя бы один из символов, указанных в скобках;
[^α1α2... αk] не должен встретиться ни один из символов, указанных в скобках;
В поисковом множестве можно задавать диапазоны подряд идущих символов:
[0-9] все цифры;
[a-jx -z] малые буквы от a до j, x ,y, z
Очень полезная возможность открывается ключом -u. Вы можете скопировать утилиту grep.com в свой каталог и произвести там нужную настройку режимов поиска с последующим запоминанием удобной для вас комбинации ключей в теле модуля grep. После этого можно уже не набирать эту последовательность ключей при повторных поисках.
Поиск осуществляется среди тех файлов и каталогов, которые указаны в конце командной строки. Например:
>grep -W fun1 *.c *.cpp
В этом случае производится поиск слова fun1 в текущем каталоге среди всех файлов с расширениями .c и .cpp.
>grep -D "my book" \*.doc
В этом случае область поиска файлы с расширением .doc в корневом каталоге и всех его подкаталогах. Так как поисковый образ содержит пробелы, то он заключен в кавычки.
Для поиска тех или иных идентификаторов в большом количестве программ удобно создать bat-файл, например, с именем grep.bat:
grep.com -i -d -w -n %1 *.c *.cpp > 1.txt
Такой файл запускается по команде:
grep.bat word
Здесь word искомый идентификатор, он заменит в bat-файле параметр %1. Удобство заключается еще и в том, что результат поиска записывается в файл 1.txt, который можно в дальнейшем детально изучать. Смотреть результаты большого поиска на экране бывает не всегда удобно (да и объем выдаваемой информации может превысить емкость экрана).
Программисты, работавшие на ранних версиях алгоритмического языка ФОРТРАН, недоумевали, почему в этом языке так много функций. Например:
Конечно, для каждой из этих функций в системной библиотеке существует свой алгоритм и своя программа, но разве компилятор не может определить тип аргумента и сам решить, к какой из этих программ следует обратиться. Почему бы не упростить жизнь программисту и не сказать ему хочешь вычислить синус, так и пользуйся общепринятым в математике обозначением sin(z). А вот в зависимости от типа аргумента тебе сосчитают то, что нужно с соответствующей точностью. Эта идея была реализована в следующих версиях ФОРТРАНА и для пользователя количество математических функций "уменьшилось" почти в 4 раза.
В языке C наблюдается примерно такая же картина, как и в ранних версиях ФОРТРАНА. Представьте себе, что мы часто используем в программе форматный вывод числовых скалярных величин разного типа. Было бы удобно выделить такие операции в отдельные функции. И вот как это могло выглядеть на языке C:
void print_int(char *nx,int x)
{ printf("\n%s=%d",nx,x); }
void print_float(char *nx,float x)
{ printf("\n%s=%f",nx,x); }
void print_double(char *nx,double x)
{ printf("\n%s=%lf",nx,x); }
А в языке C++ эти же процедуры могли выглядеть так:
void print (char *nx,int x)
{ printf("\n%s=%d",nx,x); }
void print (char *nx,float x)
{ printf("\n%s=%f",nx,x); }
void print (char *nx,double x)
{ printf("\n%s=%lf",nx,x); }
Т.е. язык C++ позволяет написать несколько функций с одинаковыми именами, но они должны отличаться чем-то друг от друга. Например, по типам своих аргументов, по их количеству, по типу возвращаемого значения. Следует отметить, что две перегружаемые функции не могут отличаться только типом возвращаемого значения в этом случае у компилятора нет никаких оснований для выбора нужной функции.
На языке C довольно часто приходится писать стереотипные функции, которые работают по одному и тому же алгоритму, обрабатывая данные разного типа. Типичные примеры функция swap, меняющая местами значения своих аргументов, функции сортировки числовых или строковых массивов и т.п.
В языке C++ предусмотрена возможность написания единственной функции, у которой типу обрабатываемых данных присвоено условное обозначение. Это так называемый шаблон функции. Компилятор, просматривая фактические вызовы такой функции, определяет, с какими типами данных она должна работать, и сам размножает нашу "безтиповую" функцию в те ее модификации, которые понадобятся программе.
Для написания шаблона функции ее заголовку предшествует следующая конструкция:
template <class Type>
Здесь template служебное слово (от англ. шаблон);
class служебное слово (от англ. класс). На наш взгляд, не самый удачный термин по смыслу. Позднее в C++ появилась более разумная замена: typename имя типа. Однако версия BC 3.1 с этим термином еще не знакома;
Type уникальный идентификатор, придумываемый программистом для условного обозначения типа обрабатываемых данных.
И в заголовке функции, и в ее теле идентификатором Type можно пользоваться для описания типа параметров и локальных переменных:
template <class Type> void swap(Type &x,Type &y)
{ Type tmp=x; x=y; y=tmp; }
Естественно, что по такой заготовке компилятор не может сформировать машинную программу ему явно не указан известный системе тип данных, и он не знает, какими машинными командами надо пользоваться. Но, встретив первый же вызов функции swap, он установит тип данных фактических аргументов и получит всю информацию, необходимую для реализации функции. Если в следующем обращении попадутся фактические аргументы другого типа, то компилятор построит еще одну версию функции swap и т.д.
Таким образом, шаблоны функций за счет незначительного увеличения текста программы экономят труд программиста.
В строке шаблона может быть указан не обязательно только один условный тип данных:
template <class Type1, class Type2, class Type3)...
Одно из важнейших достижений языка C++ возможность объявления нового типа данных и описания тех операций, которые компилятор должен научиться делать с новыми данными. Именно таким образом состав входного языка пополнился классами данных String (строки), Set (множества), Complex (комплексные переменные) и др.
Мы продемонстрируем технику создания таких данных на примере дробно-рациональных чисел, представленных в виде пары целых чисел числителя (num) и знаменателя (denum). Этот пример подробно исследован в книгах В. Лаптева (С++. Экспресс-курс. СПб.: БХВ-Петербург, 2004. 512 с), У. Торпа и У. Форда ("Структуры данных в С++",....).
В качестве первого инструмента воспользуемся механизмом структур:
struct Rational {unsigned num,denum;};
Теперь имя структуры Rational может использоваться для объявления переменных или массивов нового типа:
Rational x,y,z[20];
x.num=1; x.denum=3;
Одна из первых операций, которую нам предстоит определить, связана с упрощением дроби за счет сокращения. Для этой цели нам потребуется вспомогательная функция определения наибольшего общего делителя (подобного рода пример приводился в разделе "Рекурсивные функции"):
unsigned gcd(unsigned x,unsigned y)
{//Поиск наибольшего общего делителя
if(y==0) return x;
return gcd(y,x%y);
}
void reduce(Rational &c)
{//Сокращение дроби
unsigned t=((c.num>c.denum)?gcd(c.num,c.denum):gcd(c.denum,c.num));
c.num /= t; c.denum /= t;
}
Теперь начинается самое интересное надо научить компилятор выполнять простейшие операции над дробями. По существу, мы должны переопределить некоторые действия, которые, будучи записаны в естественном для человека виде, должны правильно интерпретироваться и компилятором. В терминологии C++ такое "волшебство" называется перегрузкой операций. Сначала мы перегрузим операцию +=, которая знакома нам по школе:
Rational& operator+=(Rational &a, const Rational &b)
{ a.num = a.num*b.denum + b.num*a.denum;
a.denum *= b.denum;
reduce(a); return a;
}
Как видите, вся хитрость переопределения операции заключается в написании функции с определенным именем. Теперь мы воспользуемся новой операцией для переопределения обычного сложения:
Rational operator+(Rational &a,const Rational &b)
{ Rational t=a; t += b; reduce(t); return t; }
Немного сложнее выглядит переопределение операций потокового ввода/вывода. Во-первых, среди аргументов функции мы должны предусмотреть ссылку на входной
(istream &) или выходной (ostream &) поток. Во-вторых, мы должны организовать ввод или вывод по указанной ссылке и возвратить ее в качестве значения функции. Поэтому тип возвращаемого значения тоже должен быть ссылкой на входной или выходной поток:
istream& operator>>(istream &t, Rational &a)
{ char s;
t >> a.num; t>>s; t>> a.denum;
reduce(a); return t;
}
Вспомогательная переменная s, использованная в этой функции, предназначена для ввода символа "/", которым мы будем отделять числитель дроби от ее знаменателя.
ostream& operator<<(ostream &t, const Rational &a)
{ t << a.num << '/'<<a.denum;
return t;
}
На базе построенных функций уже можно организовать простейшую программу:
void main()
{ Rational A,B,C;
A.num=1; A.denum=2;
B.num=1; B.denum=3;
C=A+B;
cout << C << endl;
getch();
}
//===Результат работы ===
5/6
Операцию "=" мы не перегружали, хотя и использовали в программе. Но дело в том, что структуры одинакового типа можно присваивать. Точно также, не перегружая операцию индексирования ("[]") можно работать с элементами массивов:
void main()
{ Rational d[5],c;
int i;
cout<<"Enter 5 rational number:"
cout<<" num / denum <Enter>"<<endl;
for(i=0; i<5;i++) cin>>d[i];
for(i=0; i<5;i++) cout<<d[i]<<' ';
c=d[0]+d[1];
c += d[2]; c += d[3]; c+=d[4];
cout<<endl<<c<<endl;
getch();
}
//=== Результат работы ===
Enter 5 rational number: num / denum <Enter>
1/1
2/2
3/3
4/4
5/5
1/1 1/1 1/1 1/1 1/1
5/1
К уже переопределенным операциям можно добавить еще несколько операций умножения, когда оба сомножителя дробно-рациональные или один из них целочисленный:
Rational operator*(const Rational &a, const Rational &b)
{ Rational c;
c.num=a.num*b.num;
c.denum=a.denum*b.denum;
reduce(c); return c;
}
Rational operator*(const Rational &a, const unsigned &b)
{ Rational c;
c.num=a.num*b;
c.denum=a.denum;
reduce(c); return c;
}
Rational operator*(const unsigned &a, const Rational &b)
{ Rational c;
c.num=a*b.num;
c.denum=b.denum;
reduce(c); return c;
}
Для последующей перегрузки инкрементных операций нам потребуется еще одна процедура прибавления к дробно-рациональному числу целого числа:
Rational operator+=(Rational &a, const unsigned &b)
{ a.num=a.num+b*a.denum;
reduce(a); return a;
}
С операциями x++ и ++x дело обстоит не так просто. Дело в том, что обозначения этих операций одинаковы (operator++), но выполняются они по-разному. В первом случае сначала используется старое значение переменной x, а уже потом ее значение увеличивается на 1. А во втором случае сначала увеличивается x, а уже потом используется новое значение переменной. Игра строится на том, что если формула заканчивается знаком + или , то компилятор добавляет несуществующее слагаемое, равное 0. Поэтому мы в одной операции (++x) оставим один аргумент, а во второй (x++) добавим неиспользуемый аргумент типа int. Хотя он не влияет на результат операции, но по его фиктивному присутствию компилятор правильно сориентируется между похожими функциями:
Rational operator++(Rational &a)
{//Переопределение операции ++a
a += 1; return a;
}
Rational operator++(Rational &a, int)
{//Переопределение операции a++
Rational t=a; a += 1; return t;
}
Оглядываясь на все наши перегруженные функции, невольно задаешься вопросом, а стоило ли городить весь этот огород. Не проще ли было непосредственно в программе расписывать операции над числителями и знаменателями. Если все это делается только один раз с целью демонстрации новых технологий, то, наверное, затея выеденного яйца не стоила. Но теперь мы спрячем все наши описания и новые функции в файл, который назовем rational.h. После этого программа, использующая наши навороты, выглядит совсем неплохо:
#include <stdio.h>
#include <iostream.h>
#include <conio.h>
#include "rational.h"
void main()
{ Rational A,B,C;
A.num=1; A.denum=2;
B.num=1; B.denum=3;
C=A+B;
cout << C << endl;
getch();
}
Надо иметь в виду, что новые типы данных и обслуживающие их операции тщательно планируются и разрабатываются не ради одноразового применения. Впоследствии ими может пользоваться любой программист. Зато операции над вновь созданными объектами выглядят очень естественно, вероятность появления ошибок в результате неверного использования операндов резко уменьшается. Все это положительно сказывается на качестве программ и времени их создания.
Построенный нами набор операций над дробно-рациональными числами далеко не полон. При работе с дробями потребуется их сравнивать, мы ничего не предусмотрели для обслуживания отрицательных дробей. Одним словом, предстоит еще большая работа по созданию законченного пакета для новых данных. Но мы показали, как конструируются некоторые компоненты такого пакета.
Надо отметить, что объявление нового типа данных с использованием структур и внешних функций не приветствуется современными технологиями. В нашем варианте функции не защищены от внешнего вмешательства, они не объединены в некую целостную структуру, в которой не все предназначено для внешнего использования. Т.е. то, что демонстрирует разбираемый пример это только объяснение некоторых идей, получивших в C++ существенно более серьезное развитие.
Следует заметить, что в приведенном выше варианте программы имеется довольно непривлекательный фрагмент, связанный с объявлением типа данных и их отдельной инициализацией. В языке C для данных стандартного типа эти две процедуры совмещены, что гораздо удобнее в использовании. Для преодоления такого неудобства с новыми типами данных были придуманы специальные функции конструкторы. Имена конструкторов совпадают с именами новых типов данных. В отличие от обычных функций конструкторы не возвращают значений (даже значений типа void). Они преследуют две цели выделить необходимые ресурсы памяти для хранения объявляемого объекта и произвести начальную инициализацию всех его полей.
К типовым конструкторам относятся конструктор по умолчанию, конструктор инициализации и конструктор копирования. Конструктор по умолчанию не имеет параметров и, выделяя поля под хранение компонент объекта, обычно производит их очистку. Конструктору инициализации сообщают начальные значения определенных полей. Конструктор копирования выделяет ресурсы новому объекту и копирует в его поля содержимое полей другого объекта, полученного в качестве параметра. В примере с дробно-рациональными числами могли быть объявлены следующие конструкторы:
//Конструктор по умолчанию
Rational() { num=0; denum=1; }
//Конструктор инициализации
Rational(unsigned n) { num=n; denum=1; }
//Еще один конструктор инициализации
Rational(unsigned n,unsigned d) {if(d!=0){num=n; denum=d;}}
//Конструктор копирования
Rational(const Rational &r) { num=r.num; denum=r.denum; }
В языке C++ более распространен другой способ объявления inline-конструкторов, использующий так называемые списки инициализации:
Rational():num(0),denum(1){}
Rational(unsigned n):num(n),denum(1){}
Rational(unsigned n,unsigned d):num(n),denum((d!=0)?d:1){}
Rational(const Rational &r):num(r.num),denum(r.denum){}
Включение таких конструкторов в файл rational.h сделает процедуру объявления и инициализации новых данных более цивилизованной:
Rational A(1,2),B(1,3),C;
Вы, наверное, помните, что компилятор языка C берет на себя преобразования типов аргумента при обращении к математическим функциям. По прототипам, как правило, их аргументы имеют тип double, но мы можем обращаться к ним и с данными типа float, и с целочисленными аргументами. Необходимое преобразование аргумента компилятор выполняет сам. Аналогичные преобразования следовало бы предусмотреть и в нашем пакете обработки дробно-рациональных данных. Если бы в нашем распоряжении оказались средства по прямому и обратному преобразованию данных типов Rational и unsigned, то не пришлось бы писать по три варианта операций умножения (Rational* Rational, Rational*unsigned, unsigned* Rational).
Роль преобразования данных могут выполнять конструкторы и специальные функции. Например, для преобразования типа unsignedRational можно было бы написать следующий конструктор:
Rational(unsigned n=0,unsigned d=1)
{ if(d!=0){num=n; denum=d; }}
Наличие в конструкторе параметров по умолчанию позволяет теперь объявлять переменные типа Rational следующим образом:
Rational x(5,1); //по-старому
Rational x(5); //по-новому, с преобразованием unsignedRational
Rational x=5; //по-новому
Для обратного преобразования Rationalunsigned необходимо написать специальную функцию, которую надо объявить внутри структуры:
operator unsigned(){return num/denum;}
После этого целочисленным данным типа unsigned можно присваивать значения дробно-рациональных данных.
На первых порах развития языка C++ довольно часто прибегали к услугам структур для создания новых типов данных и процедур их обработки. Однако на современном этапе для создания классов обычно используют специальную конструкцию class. На примере дробно-рациональных чисел попробуем разобраться в особенностях современного подхода. Ниже приводится текст файла rational.h, в котором содержится описание данных, конструкторов и некоторых методов для работы с дробями, а также прототипов всех используемых функций.
#ifndef __RATIONAL_H
#define __RATIONAL_H
//Класс Rational (У.Торп, У.Форд)
#include <iostream.h>
#include <stdlib.h>
//----------------------------------------------------------------
class Rational
{
private:
long num,den;
//Закрытый конструктор, используется в арифметических операциях
Rational(long num,long den);
//Функции-утилиты
void Reduce(void); //Метод сокращение дроби
long gcd(long m,long n); //наибольший общий делитель
public:
Rational(int num=0, int denom=1); //Конструктор int->Rational
Rational(double x); //Конструктор double->Rational
//ввод/вывод
friend istream& operator>>(istream& t,Rational &r);
friend ostream& operator<<(ostream& t,const Rational &r);
// бинарные арифметические операции
Rational operator+(Rational r)const;
Rational operator-(Rational r)const;
Rational operator*(Rational r)const;
Rational operator/(Rational r)const;
//унарный минус, изменение знака
Rational operator-(void)const;
//операторы отношения
int operator<(Rational r)const;
int operator>(Rational r)const;
int operator==(Rational r)const;
//Преобразование Rational->double
operator double(void)const;
//Методы-утилиты
long GetNum(void)const;
long GetDen(void)const;
}; //конец объявления класса
#endif
Тела остальных функций и методов полезно вынести в отдельный файл с именем rational.cpp, содержимое которого приводится ниже:
#include "rational.h"
//Определение наибольшего общего делителя
long Rational::gcd(long x,long y)
{ if(y==0)return x; return gcd(y,x%y); }
//-------------------------------------------------------
//Сложение Rational+Rational
Rational Rational::operator+(Rational r)const
{ Rational t;
t.num=num*r.den+den*r.num; t.den=den*r.den;
t.Reduce(); return t; }
//-------------------------------------------------------
//Вычитание Rational-Rational
Rational Rational::operator-(Rational r)const
{ Rational t;
t.num=num*r.den-den*r.num; t.den=den*r.den;
t.Reduce(); return t; }
//--------------------------------------------------------
//Умножение Rational*Rational
Rational Rational::operator*(Rational r)const
{ Rational t;
t.num=num*r.num; t.den=den*r.den; t.Reduce();
return t; }
//-------------------------------------------------------
//Деление Rational/Rational
Rational Rational::operator/(Rational r)const
{ Rational t=Rational(num*r.den,den*r.num);
t.Reduce(); return t; }
//-------------------------------------------------------
//сравнение на ==
int Rational::operator==(Rational r)const
{ return num*r.den==den*r.num; }
//-------------------------------------------------------
// сравнение на >
int Rational::operator>(Rational r)const
{ return num*r.den>den*r.num; }
//-------------------------------------------------------
// сравнение на <
int Rational::operator<(Rational r)const
{ return num*r.den<den*r.num; }
//--------------------------------------------------------
//унарный минус
Rational Rational::operator-(void)const
{ return Rational(-num, den); }
//-------------------------------------------------------
//ввод в формате P/Q (дружественная функция)
istream& operator>>(istream& t,Rational &r)
{ char c; //для чтения разделителя /
t>>r.num>>c>>r.den;
if(r.den==0) { cerr<<"Denominator=0!"; exit(1); }
r.Reduce(); return t; }
//-------------------------------------------------------
// вывод в формате P/Q (дружественная функция)
ostream& operator<<(ostream& t,const Rational &r)
{ t<<r.num<<'/'<<r.den; return t; }
//-------------------------------------------------------
//конструктор Rational(p,q)
Rational:: Rational(long p,long q):num(p),den(q)
{ if(den==0) { cerr<<"Denominator=0!"; exit(1); } }
//-------------------------------------------------------
//конструктор Rational(p,q)
Rational:: Rational(int p,int q):num(p),den(q)
{ if(den==0) { cerr<<"Denominator=0!"; exit(1); } }
//-------------------------------------------------------
//конструктор double->Rational
Rational:: Rational(double x)
{ double val1,val2;
val1=100000000L*x; val2=10000000L*x;
num=long(val1-val2); den=90000000L;
Reduce(); }
//------------------------------------------------------
//преобразование Rational->double
Rational::operator double(void)const
{ return double(num)/den; }
//------------------------------------------------------
//Метод сокращение дроби
void Rational::Reduce(void)
{ long bigdiv,temp;
temp=(num<0)?-num:num;
if(num==0)den=1;
else
{ bigdiv=gcd(temp,den);
if(bigdiv>1)
{ num /= bigdiv; den /= bigdiv; }
}
}
//---------------------------------------------------------
//Метод извлечение числителя
long Rational::GetNum(void)const
{ return num; }
//---------------------------------------------------------
//Метод извлечение знаменателя
long Rational::GetDen(void)const
{ return den; }
//---------------------------------------------------------
Объявление класса начинается со служебного слова class, вслед за которым указывается имя класса. Затем в фигурных скобках следует описание класса. Присутствующие в нем служебные слова private и public предшествуют данным и функциям, объявляемым как личные (приватные) и общедоступные компоненты класса. К личным компонентам класса имеют доступ только функции, описанные в классе (так называемые члены-функции), а также функции и классы, причисленные к друзьям класса (их описания сопровождаются добавкой friend). В описании класса может присутствовать несколько групп, выделенных как личные и общедоступные, порядок их роли не играет. Но если в самом начале описания класса объявлены члены-данные и члены-функции без указания права собственности, то они считаются приватными.
В нашем примере члены-данные объявлены приватными, поэтому в программе, использующей данный класс, нельзя воспользоваться следующим оператором:
Rational A;
A.num=1; A.den=3;
Это означает, что прямой доступ к членам-данным для внешних пользователей запрещен. Объявленный объект можно проинициализировать либо с помощью соответствующего конструктора, либо прибегнуть к специальным функциям или методам типа SetNum и SetDen (правда, в приведенном тексте эти функции отсутствуют, мы ограничились только методами GetNum и GetDen). Такая мера предосторожности позволяет проконтролировать, не нарушил ли программист диапазон данных, предусмотренный для тех или иных полей.
За пределы описания класса вынесены описания его членов-функций. Но для того, чтобы подчеркнуть их принадлежность к классу, перед именем функции расположено специальное указание Rational::. Обратите внимание на то, что функции-друзья таким свойством не обладают. Однако им разрешен доступ к приватным компонентам класса.
В описании членов-функций класса Rational присутствуют обычные функции и методы. Основное отличие метода от функции заключается в способе обращения:
k = fun(obj1,obj2); //вызов обычной функции с аргументами obj1 и obj2
k = obj1.met(obj2); //вызов метода с аналогичным набором параметров
Говорят, что метод применяется к объекту и "знает" все его характеристики, поэтому количество параметров в методе на один меньше. Вообще говоря, эта информация об объекте поступает в метод в качестве неявного параметра указателя this (от англ. этот). В приведенном примере сокращение дроби осуществляется методом Reduce и соответствующее обращение с объектом тип Rational выглядит так: t.Reduce(). А в реализации класса с использованием структуры обращение к этой же процедуре имело вид: Reduce(t).
Среди членов-функций класса Rational довольно много функций, повторяющих ранее приводившиеся тексты программ. Обратим внимание лишь на некоторые особенности новой реализации. Во-первых, сами дроби представляются и на вводе, и на выводе в формате num/den. Во-вторых, в новой версии допускается работа с отрицательными дробями. Наконец, представляет интерес оригинальный алгоритм преобразования данных типа double в формат Rational.
Ниже приводится пример программы, использующей новый тип данных. Обращаем ваше внимание на то, что описание класса не создает никаких объектов. Объекты типа Rational появляются только в результате их объявления с использованием тех или иных конструкторов. Каждому объекту выделяется соответствующий участок оперативной памяти для хранения данных, а вот функции и методы, обслуживающие дробно-рациональные данные, не дублируются. Единственная их копия обслуживает все созданные объекты.
#include <iostream.h>
#include <conio.h>
#include "rational.cpp"
void main()
{ Rational r1(5),r2,r3;
double d;
d=r1.GetNum();
cout<<"d="<<d<<endl;
cout<<"1.Rational - value 5 is "<<r1<<endl;
cout<<"2.Input Rational number: ";
cin>>r1;
d=double(r1);
cout<<"Equivalent of double: "<<d<<endl;
cout<<"3.Input two Rational number: ";
cin>>r1>>r2;
cout<<"Results: "<<endl;
cout<<r1<<" + "<<r2<<"="<<(r1+r2)<<endl;
cout<<r1<<" - "<<r2<<"="<<(r1-r2)<<endl;
cout<<r1<<" * "<<r2<<"="<<(r1*r2)<<endl;
cout<<r1<<" / "<<r2<<"="<<(r1/r2)<<endl;
if(r1<r2)
cout<<"Relation < : " <<r1<<"<"<<r2<<endl;
if(r1==r2)
cout<<"Relation = : " <<r1<<"="<<r2<<endl;
if(r1>r2)
cout<<"Relation > : " <<r1<<" > "<<r2<<endl;
cout<<"4.Input double number: ";
cin>>d;
r1=d;
cout<<"Convert to Rational: "<<r1<<endl;
d=r1;
cout<<"Convert to double: "<<d<<endl;
getch();
}
//=== Результат работы ===
Не так уж и часто для создания класса используют объединения. Один из таких редких примеров описан в книге Г. Шилдта "Теория и практика C++". СПб.: БХВ-Петербург, 2000, 416 с. Он относится к узко специализированному классу, который выводит в двоичном виде все байты вещественного числа типа double.
#include <iostream.h>
#include <conio.h>
union bits {
double d; //первое данное
unsigned char c[sizeof(double)]; //совмещаемый массив
bits(double n); //конструктор инициализации
void show_bits(); //отображение байтов d
};
bits::bits(double n) {d=n;}
void bits::show_bits()
{ int i,j;
for(j=sizeof(double)-1; j>=0; j--)
{ cout<<"Byte "<<j<<": ";
for(i=128; i; i>>=1)
if(i & c[j]) cout << "1";
else cout<< "0";
cout<<"\n";
}
}
void main()
{ bits qq(2006.0203);
qq.show_bits();
getch();
}
//=== Результат работы ===
Byte 7: 01000000
Byte 6: 10011111
Byte 5: 01011000
Byte 4: 00010100
Byte 3: 11001001
Byte 2: 10000101
Byte 1: 11110000
Byte 0: 01101111
На что в этом примере можно обратить внимание. Во-первых, на организацию цикла по i. Начальное значение управляющей переменной в этом цикле представлено единицей в восьмом разряде. При очередном повторении цикла эта единица сдвигается вправо на один бит и используется для проверки соответствующего разряда в очередном байте массива c. Во-вторых, в приведенном классе отсутствуют указания о правах доступа. Для объединения это означает, что все члены класса являются общедоступными (в частности, это одно из немногих отличий между классами, созданными с использованием union, от настоящих классов, создаваемых с помощью class). Классы, создаваемые на базе объединений, имеют ряд ограничений. В них, например, не могут использоваться статические и виртуальные функции.
Еще реже для создания новых типов данных в языке C++ используют перечисления. Один из таких примеров приведен в книге В.Лаптева. Он связан с "наведением порядка" в перечислении Decade, где для обозначения цифр от 0 до 9 использованы их английские эквиваленты. Новый порядок разрешает пользователю складывать элементы этого "множества" по модулю 10, производить сложения элементов перечисления с целыми числами (по тому же модулю 10), выводить значения переменных типа Decade словами. Ниже приводится полный текст файла decade.h, содержащего описание класса и тексты всех его внешних функций:
#ifndef _DECADE_H
#define _DECADE_H
//Объявление нового типа данных
enum Decade {zero,one,two,three,four,five,six,seven,eight,nine};
//Переопределение операции +
Decade operator+(const Decade &a, const Decade &b)
{ return Decade((int(a)+int(b))%10); }
//Переопределение операции +=
Decade operator+=(Decade &a, const Decade &b)
{ return a=Decade(int(a+b) % 10); }
//Переопределение операции ++a
Decade operator++(Decade &a)
{ a=Decade((int(a)+1) % 10); return a; }
//Переопределение операции a++
Decade operator++( Decade &a,int)
{ Decade t=a;
a=Decade((int(a)+1) % 10); return Decade(t); }
//Переопределение операции <<
ostream& operator<<(ostream &out, const Decade &a)
{ char *s[]={"zero","one","two","three","four","five","six","seven",
"eight","nine"};
out<<s[a]; return out; }
//Переопределение операции Decade+int
Decade operator+(const Decade &a, const int b)
{ int t= ((int)a + b) % 10;
return Decade(t); }
//Переопределение операции int+Decade
Decade operator+(const int a, const Decade &b)
{ return Decade((a+int(b)) % 10); }
#endif
Программа тестирования новой декады и результаты ее работы приведены ниже:
#include <iostream.h>
#include <conio.h>
#include "decade.h"
void main()
{ Decade A=seven,B=six;
cout<<"A="<<A<<endl; //Результат A=seven
cout<<"B="<<B<<endl; //Результат B=six
cout<<"A+B="<<A+B<<endl; //Результат A+B=three
A += B;
cout<<"A="<<A<<endl; //Результат A=three
++A;
cout<<"A="<<A<<endl; //Результат A=four
A++;
cout<<"A="<<A<<endl; //Результат A=five
B=A+6;
cout<<"B="<<B<<endl; //Результат B=one
A=3+B;
cout<<"A="<<A<<endl; //Результат A=four
getch();
}
Приведенный пример, конечно, носит только учебный характер. Как такового полноценного класса с объединением данных и обрабатывающих функций в единый блок здесь нет. Собственно и синтаксис перечисления не позволяет включить в фигурные скобки ничего кроме списка символьных констант. Но этот пример демонстрирует возможность "обучения " компилятора процедурам обработки новых данных, которые выглядят в программе достаточно привычно для человека.
Встраиваемые (inline) функции это очень короткие функции, реализуемые небольшим числом машинных команд. К ним невыгодно обращаться с использованием стандартного механизма, требующего обязательной засылки передаваемых аргументов в стек, извлечения данных из стека, засылки возвращаемого результата в стандартный регистр и т.п. Гораздо проще на место вызова inline-функции вставить и настроить само тело функции. Это намного эффективнее, особенно в тех случаях, когда работа функции сводится к нескольким машинным командам. Такая техника компиляции напоминает процедуру макроподстановки в ассемблере или процесс обработки препроцессором директив #define.
Типичными примерами встраиваемых функций являются процедуры определения абсолютной величины (abs(x)), выбора максимального или минимального значения из двух аргументов и т.п. Иногда, с целью оптимизации узких мест в программе, полезно попросить компилятор применить технику встраивания к наиболее часто вызываемым функциям. Прямым указанием о том, что функция должна быть встраиваемой, является использование служебного слова inline в заголовке функции:
inline int even(int x)
{ return !(x%2); }
inline double min(double a,double b)
{ return a < b ? a : b; }
К числу встраиваемых функций относятся и функции-члены класса, тела которых описаны в разделе объявления класса, хотя они могут и не содержать спецификатора inline. Обычно в описание класса включают конструкторы и деструкторы. Для встраиваемых функций-членов, описание которых вынесено за пределы объявление класса, добавление служебного слова inline обязательно:
class sample {
int i,j; //приватные данные
public
sample(int x,int y):i(x),j(y){} //встраиваемый конструктор
int is_divisor(); //описание функции вынесено
};
inline int sample::is_divisor() //вынесенная встроенная функция
{ return !(i%j); }
Использование встраиваемых функций в некоторых случаях позволяет избежать трудно воспринимаемые фокусы, которые происходят при макроподстановке. Рассмотрим, например, процедуру возведения числа в куб. Ее можно оформить как макроопределение:
#define Cube(x) (x)*(x)*(x)
Казалось бы все нормально с точки зрения математики. Должно работать для числовых данных любого типа. Предусмотрели скобки, которые снимают проблемы при подстановке формул вместо аргумента x. Однако попробуйте вставить в программу следующие строки:
q=4;
cout << Cube(q++);
Макроподстановка заменит вторую строку на:
cout << (q++)*(q++)*(q++);
В соответствии с правилами выполнения инкрементных операций такое произведение в результате даст 5*6*7. Какой же это куб?
Если же оформить эту процедуру как встроенную функцию, то никакие выкрутасы в смысле языка C на правильность результата не повлияют:
inline int Cube(int x) { return x*x*x; }
Если бы понадобилось написать более универсальную функцию, обрабатывающую числовые аргументы любого типа, то можно было бы использовать следующий шаблон:
inline template <class T> T Cube(T x) { return x*x*x; }
Результат использования такого шаблона приведен ниже:
#include <iostream.h>
#include <conio.h>
inline template <class T> T Cube(T x) { return x*x*x; }
void main()
{ int x=2;
float y=3;
double z=4;
cout<<Cube(x)<<endl;
cout<<Cube(y)<<endl;
cout<<Cube(z)<<endl;
getch();
}
//=== Результат работы ===
8
27
64
Создавая новый тип данных, мы были вынуждены объяснить компилятору, как надлежит выполнять обычные операции (сложения, вычитания, сравнения, ввод/вывод и т.п.) с объектами нового типа. По сути дела, операции это простейшие функции, на вход которых поступает один или два операнда, над ними выполняется обусловленное действие и формируется возвращаемое значение. В этом смысле переопределение операций, практически, ничем не отличается от переопределения функций. В самом общем виде операция переопределяется следующим образом:
тип_результата имя_класса::operator(список_аргументов)
{ //процедура выполнения новой операции }
Здесь обозначение знака операции.
Единственное отличие от переопределения функции заключается в специфическом имени, заменяющем имя функции.
В связи с тем, что должна быть сохранена логика обработки компилятором арифметических и логических формул, процедур ввода/вывода, операторов присваивания, инкрементирования и декрементирования, на переопределение операций накладываются три важных ограничения:
При написании процедуры переопределения двухместной операции a1a2 надо помнить, что аргумент a1, расположенный левее знака операции, передается в функцию-член класса двумя способами. Во-первых, как поля объекта, объявленные в качестве членов-данных класса. Во-вторых, как скрытый указатель this. Поэтому первый операнд операции как аргумент в функции переопределения не указывается.
Еще одно ограничение на функцию переопределения операции заключается в том, что среди ее параметров нельзя пользоваться значениями по умолчанию.
Рассмотрим варианты переопределения двухместных операций на примере класса Tpoint, представляющего точку с парой целочисленных координат:
class Tpoint {
int x,y;
public
Tpoint(){x=0;y=0;} //конструктор по умолчанию
Tpoint(int xx,int yy){x=xx;y=yy;} //конструктор инициализации
void GeTpoint(int &xx,int &yy){xx=x; yy=y;} //опрос координат
Tpoint operator+(Tpoint P2);
};
Операция сложения, объявленная в приведенном выше примере, принимает второе слагаемое как значение. Поэтому описание новой процедуры может выглядеть следующим образом:
Tpoint Tpoint::operator+(Tpoint P2)
{ Tpoint q;
q.x=x+P2.x; q.y=y+P2.y;
return q;
}
Более экономный вариант функции сложения заключается в использовании ссылки на объект P2:
Tpoint Tpoint::operator+(Tpoint &P2)
{ Tpoint q;
q.x=x+P2.x; q.y=y+P2.y;
return q;
}
Во-первых, ссылка на объект P2 это 4 байта, тогда как передача значения P2 потребовала бы передачи двух четырехбайтовых координат. Во-вторых, для значения P2, являющегося формальным параметром первого варианта функции, пришлось бы выделять временную память (как и для локальной переменной q), а при выходе из функции эту память освобождать.
Операция присваивания над объектами типа Tpoint a1=a2 тоже является двухместной, но ее переопределение использует указатель this, чтобы возвратить значение:
Tpoint Tpoint::operator=(Tpoint &P2)
{ x=P2.x; y=P2.y; return *this; }
В переопределении логических операций и операций отношения (хотя для точек большинство из этих действий лишено смысла) имеется особенность они должны возвращать целочисленное значение, соответствующее истине (не нуль) или лжи (нуль):
int Tpoint::operator==( Tpoint &P2)
{ return (x==P2.x)&&(y==P2.y); }
Специфика переопределения унарных операций (над одним операндом) заключается в том, что соответствующая функция не имеет формальных параметров, хотя операнд, расположенный левее знака операции она "знает". Например, операция двухместного вычитания и операция смены знака у точки могут быть переопределены следующим образом:
Tpoint Tpoint::operator-(Tpoint &P2)
{ Tpoint q;
q.x=x-P2.x; q.y=y-P2.y; return q;
}
Tpoint Tpoint::operator-()
{ x=-x; y=-y; return *this; }
Мы уже упоминали, что с операциями левого и правого декремента ситуация несколько сложнее. Операция ++P1 мало чем отличается от унарной смены знака -P1, и ее переопределение вопросов не вызывает:
Tpoint Tpoint::operator++()
{ x++; y++; return *this; }
А вот для операции P1++ в реализации C++ пришлось придумывать специальный выход было решено, что если знак инкремента (или декремента) следует за операндом, то после этого знака якобы появляется фиктивный нулевой операнд. Поэтому соответствующее переопределение операции P1++ "использует" фиктивный формальный параметр:
Tpoint Tpoint::operator++(int P)
{ Tpoint q=*this;
x++; y++; return q; }
Иногда для переопределения операций прибегают к услугам дружественных функций. Хотя дружественные функции и имеют доступ к приватным данным класса, но указатель this они не получают. Поэтому, например, с их помощью нельзя переопределить операцию '='. А другие унарные или бинарные операции такому переопределению поддаются легко, надо только передавать в дружественные функции на один параметр больше:
class Tpoint {
int x,y;
public
Tpoint(){x=0;y=0;} //конструктор по умолчанию
Tpoint(int xx,int yy){x=xx;y=yy;} //конструктор инициализации
void GeTpoint(int &xx,int &yy){xx=x; yy=y;} //опрос координат
friend Tpoint operator+(Tpoint P1,Tpoint P2);
};
Tpoint Tpoint::operator+(Tpoint P1,Tpoint P2)
{ Tpoint q;
q.x=P1.x+P2.x; q.y=P1.y+P2.y; return q;
}
О специфике переопределения операций потокового ввода и вывода мы уже упоминали при проектировании класса Rational. Продемонстрируем это еще раз на примере ввода и вывода данных типа "точка". Предположим, что нас устраивает следующий формат представления "точек" на вводе и выводе (x,y), где x и y представляют целочисленные значения соответствующих координат. Напоминаем, что среди аргументов функций должна быть ссылка на входной (istream &) или выходной (ostream &) поток. Ввод или вывод должен быть организован по указанной ссылке и в качестве возвращаемого результата должна быть указана эта ссылка. Поэтому тип возвращаемого значения тоже должен быть ссылкой на входной или выходной поток:
#include <iostream.h>
#include <conio.h>
class Tpoint {
int x,y;
public
Tpoint(){x=0;y=0;} //конструктор по умолчанию
Tpoint(int xx,int yy){x=xx;y=yy;} //конструктор инициализации
void GetPoint(int &xx,int &yy){xx=x; yy=y;} //опрос координат
friend istream& operator>>(istream& t,Tpoint &P);
friend ostream& operator<<(ostream& t,Tpoint &P);
};
//ввод в формате (x,y) (дружественная функция)
istream& operator>>(istream& t,Tpoint &P)
{ char c; //для чтения разделителей /
t >> c >> P.x >> c >> P.y >> c;
return t; }
//-------------------------------------------------------
// вывод в формате (x,y) (дружественная функция)
ostream& operator<<(ostream& t,Tpoint &P)
{ t<<'('<<P.x<<','<<P.y<<')'; return t; }
void main()
{ Tpoint P(10,20);
cout<<P<<endl;
cin>>P;
cout<<P<<endl;
getch();
}
//=== Результат работы
(10,20)
(30,50) <Enter> //введена точка с новыми координатами
(30,50)
При создании классов с новыми типами данных системы программирования на базе языка C++ облегчают работу программиста тем, что автоматически создают средства для объявления объектов нового типа, их инициализации и уничтожения. Эти средства получили название конструкторов (созидателей) и деструкторов (разрушителей).
Создание объекта в соответствии с описанием данных в классе сводится к выделению ресурсов (как правило, речь идет об участке оперативной памяти) для хранения объекта и инициализации полей данных. К числу наиболее характерных методов инициализации участков памяти, которые не зависят от специфики задач, относятся три следующие процедуры:
В случае необходимости программист может включить в свой класс указанные конструкторы:
#include <iostream.h>
class B {
int x;
public:
B(){x=0; cout<<"Def_Constr_B "<<this<<endl;}
B(int y){x=y; cout<<"Init_Constr_B "<<this<<endl;}
B(const B &z){x=z.x; cout<<"Copy_Constr_B "<<this<<endl;}
int get_x(){return x;}
~B(){cout<<"Destr B"<<this<<endl;}
};
void main()
{ B q1; //обращение к конструктору по умолчанию
B q2(2); //обращение к конструктору инициализации
B q3(q2); //прямое обращение к конструктору копирования
B q4=q1; //косвенное обращение к конструктору копирования
cout<<"q1="<<q1.get_x()<<endl;
cout<<"q2="<<q2.get_x()<<endl;
cout<<"q3="<<q3.get_x()<<endl;
cout<<"q4="<<q4.get_x()<<endl;
}
//=== Результат работы ===
Def_Constr_B 0012FF88 //конструктор по умолчанию, адрес q1.x
Init_Constr_B 0012FF84 //конструктор инициализации, адрес q2.x
Copy_Constr_B 0012FF80 //конструктор копирования, адрес q3.x
Copy_Constr_B 0012FF7C //конструктор копирования, адрес q4.x
q1=0
q2=2
q3=2
q4=0
//Деструктор вызывается автоматически при выходе из блока
Destr B 0012FF7C //уничтожение объекта q4
Destr B 0012FF80 //уничтожение объекта q3
Destr B 0012FF84 //уничтожение объекта q2
Destr B 0012FF88 //уничтожение объекта q1
Если программист не включает в свой класс ни одного конструктора, то компилятор автоматически вписывает ему конструктор по умолчанию (в отличие от приведенного выше системный конструктор не чистит память) и конструктор копирования.
Специфика конструкторов заключается в следующем:
Каждый класс обязан иметь хотя бы один общедоступный конструктор, в противном случае программа не сможет объявить ни один объект новой структуры.
Пример нестандартного конструктора по умолчанию, который пришлось написать нам самим, мы видели в классе Rational с нулевым знаменателем работать было бы нельзя.
В отличие от конструкторов, создающих объекты, деструктор их уничтожает, освобождая ресурсы, выделенные для хранения объекта. Деструктор у класса может быть только один. Если программист не напишет свой деструктор, который производит дополнительные нестандартные действия, то компилятор подключит системный деструктор. Имя деструктора начинается с "тильды", которая предшествует имени класса. Параметров у деструктора нет:
Rational v1; //объявление с помощью конструктора по умолчанию
Rational v2(1,2); //использование конструктора инициализации
Rational v3(v2); //использование конструктора копирования
~Rational(); //деструктор
Явно к деструктору обращаться не приходится, т.к. он вызывается автоматически при выходе из блока программы, в котором был создан объект.
Иногда деструктор класса приходится писать. Необходимость в этом возникает в тех случаях, когда деструктор должен выполнить некоторые нестандартные действия. Например, если в классе создается динамический массив, то для его уничтожения приходится разрушать каждый элемент массива:
class array {
int size;
char *p;
public:
array(int dim); //конструктор создания динамического массива
~array(){ delete [] p; } //деструктор разрушения массива
.........................
};
array::array(int dim) //тело конструктора
{ p=new char[dim]; //запрос памяти под динамический массив
if(!p) //если память не выделена
{ cout<<"Allocation error"; exit(1); }
size=dim;
}
До сих пор мы знакомились с возможностями классов как средства создания и обработки новых типов данных. Наряду с этим важнейшим достижением языка C++ имеется другая, не менее важная заслуга классов, они позволяют строить развивающиеся иерархические структуры программных комплексов. И главным механизмом здесь является наследование возможность порождать новые классы на базе уже имеющихся с передачей порожденным классам наследства в виде данных и членов-функций родительского класса (или классов, если прямых родителей несколько). Порожденные классы имеют возможность расширять набор данных, полученных по наследству, модифицировать родительские методы и функции, создавать новые данные и новые функции их обработки. Возможность сохранять ранее созданное программное хозяйство, модифицируя его в соответствии с новыми задачами, позволяет с меньшими затратами и с большей надежностью вести разработки больших программных систем. Дополнительный выигрыш в производительности процесса разработки программного обеспечения можно получить за счет использования библиотек классов и шаблонов, активно создаваемых в настоящее время.
Когда говорят о классе D, порожденном из класса B, то принято называть родительский класс базовым, а вновь созданный класс производным. Механизм наследования (inheritance) предусматривает две возможности. В первом случае, который называют простым наследованием, родительский класс один. Во втором случае родителей два или больше, и соответствующий процесс именуют термином множественное наследование. В первую очередь мы познакомимся с механизмом простого наследования.
Итак, как формально выглядит процедура объявления производного класса D и что он получает в наследство от своего родителя класса B ?
class D: [virtual][public|private|protected] B
{тело производного класса};
Служебное слово virtual (виртуальный) используется для предотвращения коллизий в случае сложного множественного наследования (по этому поводу см. раздел 16.2). Кроме уровней доступа public (общедоступный) и private (личный) в классах, создаваемых на базе структур (struct) и настоящих классов (class), используется еще один уровень защиты protected (защищенный). Защищенными данными класса могут пользоваться функции и методы самого класса, производных классов и дружественные функции. При создании производного класса D может быть упомянут один из этих уровней доступа, что повлияет на изменение уровня доступа к унаследованным данным и функциям. По этому поводу в стандарте C++ существует целая таблица:
Таблица 16.1
Уровень доступа в B |
Уровень доступа при объявлении D |
Уровень доступа в D |
|
D=struc |
D=class |
||
public |
опущен |
public |
private |
protected |
опущен |
public |
private |
private |
опущен |
нет доступа |
нет доступа |
public |
public |
public |
public |
protected |
public |
protected |
protected |
private |
public |
нет доступа |
нет доступа |
public |
protected |
protected |
protected |
protected |
protected |
protected |
protected |
private |
protected |
нет доступа |
нет доступа |
public |
private |
private |
private |
protected |
private |
private |
private |
private |
private |
нет доступа |
нет доступа |
В современной практике программирования действует общепринятое правило родителями и потомками должны быть только настоящие классы. Поэтому о существовании третьей колонки в табл. 16.1 можно сразу забыть.
Чаще всего производный класс конструируют по следующей схеме, которая носит название открытого наследования:
class D: public B {тело производного класса};
Это означает, что общедоступные данные и функции из родительского класса остаются общедоступными и в порожденном классе, защищенные данные и функции из родительского класса остаются защищенными и в порожденном классе, а к приватным компонентам родителя потомок прямого доступа не имеет. И добраться до них он может на равных правах с другими программами только через соответствующий метод, если таковой у родителя был предусмотрен.
Однако приведенное выше утверждение не распространяется на конструкторы и деструкторы. Они не наследуются, но к ним можно обратиться с добавлением принадлежности классу B.
В приведенном ниже примере имеет место открытое наследование производного класса D от своего родителя. Поле данных x родительского класса для потомка закрыто, но методы setb и showb сохраняют в классе D уровень доступа public. Поэтому с объектом типа D к этим методам обращаться можно:
#include <iostream.h>
#include <conio.h>
class B {
int b;
public:
void setb(int n){b=n;}
void showb(){cout<<"in B b="<<b<<endl;}
};
class D: public B {
int d;
public:
void setd(int n){d=n;}
void showd(){cout<<"in D d="<<d<<endl;}
};
void main()
{ D qq; //объявление объекта порожденного класса
qq.setb(1); //доступ к члену базового класса qq.x
qq.showb(); //доступ к члену базового класса
qq.setd(2); //доступ к члену производного класса qq.y
qq.showd(); //доступ к члену производного класса
qq.showb(); //доступ к члену базового класса
getch();
}
//=== Результат работы ===
in B b=1 //qq.x
in D d=2 //qq.y
in B b=1 //qq.x
Обратите внимание на то, что после обращения к методу setd значение поля qq.x не изменилось.
А теперь модифицируем уровень доступа при объявлении производного класса:
class D: private B {
int d;
public:
void setbd(int n,int m)
{ setb(n); //для класса D функция стала private, но она доступна
d=m; }
void showbd()
{ showb(); // для класса D функция стала private, но она доступна
cout<<"in D d="<<d<<endl;}
};
void main()
{ D qq; //объявление объекта порожденного класса
qq.setbd(1,2);
qq.showbd();
getch();
}
Результат работы программы прежний, но в доступе к методам класса B помог производный класс. В последнем примере можно заменить в объявлении класса D уровень доступа на protected функции setb и showb получат в классе D статус protected, но они по-прежнему будут доступны, и результат работы программы будет прежним.
Последовательность вызова конструкторов и деструкторов легче проследить на следующем примере. В базовом классе B содержится единственный закрытый член данных x, предусмотрены три конструктора (по умолчанию, инициализации и копирования), функция опроса значения закрытого поля и деструктор. Каждый из них выводит свое условное обозначение при вызове. В производном классе D, который наследует поле x в режиме private, содержится и собственное закрытое поле y. В его составе такие же три конструктора, функция опроса значения закрытого поля и деструктор.
Головная программа сначала создает четыре объекта w1, w2, w3 и w4 типа B, а затем четыре объекта q1, q2, q3 и q4 типа D. После создания каждого объекта фиксируется содержимое соответствующих полей и цепочка вызываемых конструкторов. Перед окончанием программы фиксируется цепочка вызовов деструкторов.
#include <iostream.h>
#include <conio.h>
class B {
int x;
public:
B(){x=0; cout<<"Def_B "<<endl;}
B(int n){x=n; cout<<"Init_B "<<endl;}
B(const B &y){x=y.x; cout<<"Copy_B "<<endl;}
int get_x(){return x;}
~B(){cout<<"Destr_B"<<endl;}
};
class D : public B {
int y;
public:
D(){y=0; cout<<"Def_D "<<endl;}
D(int n){y=n; cout<<"Init_D "<<endl;}
D(const D &z){y=z.y; cout<<"Copy_D "<<endl;}
int get_y(){return y;}
~D(){cout<<"Destr_D"<<endl;}
};
void main()
{ B w1;
cout<<"w1.x="<<w1.get_x()<<endl;
B w2(2);
cout<<"w2.x="<<w2.get_x()<<endl;
B w3(w2);
cout<<"w3.x="<<w3.get_x()<<endl;
B w4=w1;
cout<<"w4.x="<<w4.get_x()<<endl;
D q1;
cout<<"q1.x="<<q1.get_x()<<' '<<"q1.y="<<q1.get_y()<<endl;
D q2(2);
cout<<"q2.x="<<q2.get_x()<<' '<<"q2.y="<<q2.get_y()<<endl;
D q3(q2);
cout<<"q3.x="<<q3.get_x()<<' '<<"q3.y="<<q3.get_y()<<endl;
D q4=q1;
cout<<"q4.x="<<q4.get_x()<<' '<<"q4.y="<<q4.get_y()<<endl;
}
//=== Результаты работы ===
Def_B //конструктор B по умолчанию для создания w1.x
w1.x=0 //значение созданного объекта
Init_B //конструктор B инициализации для создания w2.x
w2.x=2 //значение созданного объекта
Copy_B //конструктор B копирования для создания w3.x
w3.x=2 //значение созданного объекта
Copy_B //конструктор B копирования для создания w4.x
w4.x=0 //значение созданного объекта
Def_B //неявный вызов конструктора B для создания q1.x
Def_D //конструктор D по умолчанию для создания q1.y
q1.x=0 q1.y=0 //значения созданных объектов
Def_B //неявный вызов конструктора B для создания q2.x
Init_D //конструктор D инициализации для создания q2.y
q2.x=0 q2.y=2 //значения созданных объектов
Def_B //неявный вызов конструктора B для создания q3.x
Copy_D //конструктор D копирования для создания w3.y
q3.x=0 q3.y=2 //значения созданных объектов
Def_B //неявный вызов конструктора B для создания q4.x
Copy_D //конструктор D копирования для создания w4.y
q4.x=0 q4.y=0 //значения созданных объектов
Destr_D //деструктор D для уничтожения w4.y
Destr_B //деструктор B для уничтожения w4.x
Destr_D //деструктор D для уничтожения w3.y
Destr_B //деструктор B для уничтожения w3.x
Destr_D //деструктор D для уничтожения w2.y
Destr_B //деструктор B для уничтожения w2.x
Destr_D //деструктор D для уничтожения w1.y
Destr_B //деструктор B для уничтожения w1.x
Destr_B //деструктор B для уничтожения q4.x
Destr_B //деструктор B для уничтожения q3.x
Destr_B //деструктор B для уничтожения q2.x
Destr_B //деструктор B для уничтожения q1.x
Обратите внимание на то, что в этом примере создание объектов производного класса начинается с автоматического вызова конструктора базового класса по умолчанию. Кроме того, объекты уничтожаются в порядке, обратном последовательности их создания самый первый объект разрушается последним.
Однако возможна ситуация, когда ни программист, ни система не включили в базовый класс конструктор по умолчанию. Это происходит в тех случаях, когда программист написал только конструкторы с параметрами. В такой ситуации конструкторы производного класса должны сами позаботиться об инициализации объектов родительского класса. Сделать это можно разными способами явно вызвать конструктор базового класса либо в своем списке инициализации, либо в теле конструктора. Для защищенных (protected) полей базового класса можно воспользоваться указателем this. В приводимом ниже примере демонстрируются эти возможности. В качестве базового класса выступает класс Point2D, моделирующий точку на плоскости:
class Point2D {
int x,y; //закрытые данные класса Point2D
public:
Point2D(int xx,int yy):x(xx),y(yy){} //конструктор инициализации
Point2D(const Point2D &P):x(P.x),y(P.y){} //конструктор копирования
int get_x(){return x;}
int get_y(){return y;}
};
Порожденный класс Point3D моделирует точку в трехмерном пространстве:
class Point3D: public Point2D {
int z; //новая координата в классе Point3D
public:
Point3D(int xx,int yy,int zz):Point2D(xx,yy),z(zz){}
int get_z(){return z;} //новый метод в классе Point3D
};
А теперь протестируем оба класса на следующей программе:
#include <iostream.h>
#include <conio.h>
void main()
{ Point2D P2(1,2);
Point3D P3(3,4,5);
cout<<"P3.x="<<P3.get_x()<<" P3.y="<<P3.get_y()<<" P3.z=" <<P3.get_z()<<endl;
cout<<"P2.x="<<P2.get_x()<<" P2.y="<<P2.get_y()<<endl;
P2=P3;
cout<<"P2.x="<<P2.get_x()<<" P2.y="<<P2.get_y()<<endl;
getch();
}
//=== Результат работы ===
P3.x=3 P3.y=4 P3.z=5
P2.x=1 P2.y=2
P2.x=3 P2.y=4
Производному классу по наследству достались приватные данные координаты (x,y) родительского объекта и общедоступные методы доступа к этим координатам. Поэтому в головной программе мы можем пользоваться этими методами как по отношению к объектам типа Point2D, так и по отношению к объектам типа Point3D. Немного странным кажется оператор присваивания двухмерному объекту P2 значения трехмерного объекта P3. Но происходит вполне естественная операция те поля, которые являются общими у этих двух объектов, переносятся, а "лишнее" поле P3.z отсекается. Обратная операция P3=P2 была бы ошибочной, т.к. компилятор не "знает", чем следует заполнить поле P3.z.
Если бы поля (x,y) в базовом классе были объявлены как защищенные (protected), то их инициализацию в конструкторе производного класса можно было бы выполнить и так:
Point3D(int xx,int yy,int zz):z(zz)
{ this->x=xx; this->y=yy; }
Объявление объектов с использованием конструкторов создает данные, которые существуют до выхода из блока, в котором они появились. Однако иногда объекты могут потребоваться на более короткое время. Такие объекты можно создавать и уничтожать во время работы программы с помощью операторов new и delete:
class A {...}; //объявление класса
..............
A *ps=new A; //объявление указателя и создание объекта типа A
A* *pa=new A[20]; //объявление указателя и создание массива объектов
...............
delete ps; //удаление объекта по указателю ps
delete [] pa; //удаление массива объектов по указателю pa
Фактически, выполнение оператора new эквивалентно вызову конструктора класса, а обращение к оператору delete на автомате означает вызов деструктора. Создание одиночных объектов может быть совмещено с инициализацией объекта, если в классе предусмотрен соответствующий конструктор:
A *ptr1=new A(5);//создание объекта и вызов конструктора инициализации
Массив создаваемых объектов проинициализировать таким же образом нельзя.
В ранних версиях C++ для создания и уничтожения динамических объектов использовали обращения к функциям malloc (запрос памяти) и free (освобождение памяти). Неудобство применения этих функций по сравнению с операторами new/delete заключается в том, что для запроса памяти нужно знать количество байт, занимаемых объектом в оперативной памяти. Конечно, это не так уж и сложно существует функция sizeof, с помощью которой длину объекта можно определить. Второе неудобство заключается в том, что функция malloc выдает указатель типа void* и его еще надо преобразовать к типу указателя на объект класса.
Довольно распространенная ситуация, которая может оказаться потенциальным источником ошибок, возникает в процессе создания и удаления динамических объектов. Она заключается в том, что после уничтожения объекта, связанного, например, с указателем ps, этот указатель не чистится. Если после удаления объекта сделать попытку что-то прочитать или записать по этому указателю, то поведение программы предсказать трудно. Поэтому достаточно разумным правилом является засылка нуля в указатель разрушаемого объекта:
delete ps;
ps=NULL; //или ps=0;
Виртуальными называют функции базового класса, которые могут быть переопределены в производном классе. В базовом классе их заголовок начинается со служебного слова virtual. В производном классе такая функция продолжает оставаться виртуальной, даже если перед ее заголовком слово virtual опущено. Однако на практике рекомендуют и в производном классе перед заголовком переопределяемой виртуальной функции указывать термин virtual.
Заголовки виртуальных функций в базовом и производном классах должны быть обязательно идентичными. Поэтому переопределение распространяется только на тело функции. Это позволяет обращаться к виртуальным функциям, не указывая их принадлежность тому или иному классу. Выбором нужной функции управляет тип объекта, заданный явно или неявно через указатель, который может быть объявлен как указатель родительского класса. Если в производном классе виртуальная функция не переопределяется, то к объектам порожденного класса применяется родительский виртуальный метод.
Рассмотрим пример, в котором базовый класс B содержит защищенное поле n и отображает его содержимое на экране. Производный класс D1 отображает квадрат доставшегося по наследству поля. Еще один класс D2, порожденный тем же родителем B, отображает куб своего наследства.
#include <iostream.h>
#include <conio.h>
class B {
public:
B(int k):n(k){} //конструктор инициализации
virtual void show(){cout<<n<<endl;} //виртуальная функция
protected:
int n;
};
class D1: public B {
public:
D1(int k):B(k){} // конструктор инициализации
virtual void show(){cout<<n*n<<endl;}
};
class D2: public B {
public:
D2(int k):B(k){} // конструктор инициализации
virtual void show(){cout<<n*n*n<<endl;}
};
void main()
{ B bb(2),*ptr;
D1 dd1(2);
D2 dd2(2);
ptr=&bb;
ptr->show();
ptr=&dd1;
ptr->show();
ptr=&dd2;
ptr->show();
getch();
}
//=== Результат работы ===
2 //результат работы функции B::show
4 //результат работы функции D1::show
8 //результат работы функции D2::show
Обратите внимание на то, что в предыдущем примере адреса объектов производных классов присваиваются указателю, чей тип был связан с объектами базового класса. Такое присвоение можно делать без явного приведения типов. А вот обратное преобразование указателя базового класса в указатель производного класса сопровождается явным преобразованием типа:
B *bptr;
D1 dd1(2);
bptr=&dd1; //вниз по иерархии классов без преобразования
D1 *dptr;
dptr=(D1 *)bptr; //вверх по иерархии с преобразованием типа
Когда существует иерархия производных классов, и мы создаем массив динамических указателей на объекты разных производных классов, то при уничтожении такого рода объектов могут возникнуть проблемы. Продемонстрируем это на примере иерархии геометрических фигур: Shape (фигура базового класса), Circle (окружность, производная от Shape) и Rectangle (прямоугольник, производный от Shape):
#include <iostream.h>
class Shape {
public:
Shape(); //конструктор по умолчанию
~Shape(); //стандартный деструктор
virtual void show() {cout <<"Shape"<<endl;
};
class Circle: public Shape {
int xc,yc,r; //координаты центра и радиус
public:
Circle(int x,int y,int R):xc(x),yc(y),r(R) {} //конструктор
~Circle(); //стандартный деструктор
void show() {cout<<"x="<<xc<<" y="<<yc<<" r="<<r<<endl;
};
class Rectangle: public Shape {
int x1,y1,x2,y2; //координаты противоположных вершин
public:
Rectangle(int ix1,int iy1,int ix2,int iy2):
x1(ix1),y1(iy1),x2(ux2),y2(iy2) {} //конструктор
~Rectangle(); //стандартный деструктор
Создаем массив указателей на объекты базового класса и присваиваем им адреса динамически создаваемых объектов:
Shape *ptr_s[2];
ptr_s[0]=new Circle(20,20,10)
ptr_s[1]=new Rectangle(20,40,50,50);
Поработали с временно созданными объектами, и пришла пора их удалить. Попытка сделать это следующим способом ни к чему хорошему не приведет:
for(int i=0; i<2; i++) delete ptr_s[i];
Причина заключается в том, что для удаления этих фигур будет вызван деструктор класса Shape (именно на объекты этого класса был объявлен массив указателей ptr_s). А ресурсы, занятые окружностью и прямоугольником, при этом не будут освобождены. Выход из создавшегося положения довольно простой надо объявить деструктор базового класса виртуальным (virtual ~Shape();). Тогда автоматически виртуальными станут и деструкторы производных классов (хотя деструкторы и не наследуются). И все проблемы, связанные с утечкой памяти, будут решены.
Существует практический совет если в базовом классе хотя бы одна из функций объявлена виртуальной, то надо сделать деструктор базового класса тоже виртуальным. На конструкторы это правило не распространяется конструкторы вызываются только тогда, когда создаются объекты (т.е. экземпляры класса, а не указатели на них). Поэтому конструкторы виртуальными не бывают.
Чистая виртуальная функция не совершает никаких действий, и ее описание выглядит следующим образом:
virtual тип name_f(тип1 a1,тип2 a2,...)=0;
Класс, содержащий хотя бы одно объявление чистой виртуальной функции, называют абстрактным классом. Для такого класса невозможно создавать объекты, но он может служить базовым для других классов, в которых чистые виртуальные функции должны быть переопределены.
Объявим абстрактным класс Shape (Геометрическая Фигура), в состав которого включим две чистые виртуальные функции определение площади фигуры (Get_Area) и определение периметра фигуры (Get_Perim).
class Shape {
public:
Shape(){} //конструктор
virtual double Get_Area()=0;
virtual double Get_Perim()=0;
};
class Rectangle: public Shape {
double w,h; //ширина и высота
public:
Rectangle(double w1,double h1):w(w1),h(h1) {}
double Get_Area() {return w*h;}
double Get_Perim() {return 2*w+2*h);}
};
class Circle: public Shape {
double r; //радиус
public:
Circle(double r1):r(r1) {}
double Get_Area() {return M_PI*r*r;}
double Get_Perim() {return 2*M_PI*r;}
};
Если в производном классе хотя бы одна из чисто виртуальных функций не переопределяется, то производный класс продолжает оставаться абстрактным и попытка создать объект (экземпляр класса) будет пресечена компилятором.
О множественном наследовании говорят в тех случаях, когда в создании производного класса участвуют два или более родителей:
class B1 {//первый базовый класс
int x;
public:
B1(int n):x(n) {cout<<"Init_B1"<<endl;} //конструктор B1
int get_x(){return x;}
~B1() {cout<<"Destr_B1"<<endl;} //деструктор B1
};
class B2 {//второй базовый класс
int y;
public:
B2(int n):y(n) {cout<<"Init_B2"<<endl;} // конструктор B2
int get_y(){return y;}
~B2() {cout<<"Destr_B2"<<endl;} //деструктор B2
};
class D: public B1, public B2 {
int z;
public:
D(int a,int b,int c):B1(a),B2(b),z(c)
{cout<<"Init_D"<<endl;} //конструктор D
void show() {cout<<"x="<<get_x()<<" y="<<get_y()<<" z="<<z<<endl;}
~D() {cout<<"Destr_D"<<endl;} //деструктор D
};
#include <iostream.h>
void main()
{ D qq(1,2,3);
qq.show();
}
//=== Результат работы ===
Init_B1
Init_B2
Init_D
x=1 y=2 z=3
Destr_D
Destr_B2
Destr_B1
Последовательность обращений к конструкторам родительских классов определяется очередностью их вызовов в списке инициализации. Если таковой отсутствует, то определяющим является порядок перечисления родителей в объявлении производного класса.
При множественном наследовании может возникнуть некоторая неопределенность, связанная с тем, что родительские данные могут попытаться попасть в производный класс несколькими путями. Например, классы A и B являются родителями класса C. Если в формировании класса D участвуют классы A и C, то данные-потомки класса A попадают в класс D и прямым путем, и в составе наследства класса C. И тогда перед компилятором возникает неразрешимая проблема с какой веточкой унаследованных данных надо работать и методы какого класса надо вызывать. Для разрешения такой двойственности класс A должен быть объявлен виртуальным:
class B: virtual public A {
...//описание класса B
};
class C: virtual public A, public B {
//описание класса C
};
При наследовании от виртуального класса производный класс вместо родительских данных получает ссылки на эти данные, что позволяет предотвратить дублирование наследства.
Создание достаточно универсальной графической системы это серьезный проект, требующий разработки большого количества разнообразных процедур. В их состав помимо средств объявления графических примитивов (точки, отрезки прямых, дуги окружностей, прямоугольники, пояснительные подписи и т.п.) и манипуляций с объектами (отображение на экране, стирание, перекраска, перемещение) должны входить различные вспомогательные утилиты. Например, такие как запоминание и восстановление фрагментов изображения, процедуры анимации, аппроксимации и сглаживания кривых, заливки и штриховки замкнутых областей, пересечения полигонов и многое другое.
Поэтому мы ограничимся лишь демонстрацией простейшей графической системы, имеющей в своем распоряжении минимальное число графических объектов точки, окружности и залитые окружности. Эти объекты можно будет создавать в оперативной памяти, отображать на экране, делать невидимыми и перемещать по экрану в заданное место. Более того, для манипуляций с этими объектами мы воспользуемся существующей в среде BC 3.1 библиотекой процедур BGI (Borland Graphics Interface), обеспечивающих перевод экрана в простейший графический режим (режим VGA с разрешением 640×480) и отображение на нем графических примитивов. Однако детали работы с этой библиотекой мы постараемся скрыть от пользователя. Основная цель нашей демонстрации показать главные аспекты объектно-ориентированного подхода на достаточно наглядном примере.
Описания наших новых классов, методов и вспомогательных утилит мы разместим в файле с именем gs.h (от Graphics System). По аналогии с работой с файлами нам понадобятся процедуры открытия (инициализации) графической системы и ее закрытия. Для этого мы включим в файл gs.h следующий фрагмент:
#include <graphics.h>
int gs;
void open_gs()
{ int gd=0,gm;
initgraph(&gd,&gm,"");
gs=1;
}
void close_gs()
{ closegraph(); gs=0; }
Заголовочный файл graphics.h содержит заголовки функций и описания констант библиотеки BGI. Переменная gs, может быть, понадобится в будущем для индикации готовности графической системы к работе (при gs=1 система открыта для работы с графическими объектами, закрытие системы сопровождается засылкой нуля в переменную gs). Для приведения библиотеки BGI в состояние готовности используется процедура initgraph и графический драйвер egavga.bgi, который мы из соображений удобства разместим в своем текущем каталоге. Восстановление текстового режима работы дисплея осуществляется процедурой closegraph из библиотеки BGI. Однако пользователь о деталях работы с процедурами BGI ничего знать не должен. Для "открытия" графической системы он должен обратиться к процедуре open_gs, а для закрытия к процедуре close_gs (почти полная аналогия открытия и закрытия файлов).
Описание нашей графической системы мы начнем с абстрактного класса GO (от Graphics Object).
class GO {
protected:
int x,y,is_v,fc,bc;
public:
GO():x(0),y(0),is_v(0),bc(15),fc(0)
{ setcolor(fc);setbkcolor(bc); }
GO(int x1,int y1,int c=0):x(x1),y(y1),is_v(0),fc(c),bc(15)
{ setcolor(fc);setbkcolor(bc); }
virtual void hide()=0;
virtual void show()=0;
void move(int x1,int y1);
};
Защищенными данными в этом классе являются:
x,y целочисленные координаты точки привязки графического объекта в системе координат экрана (для объекта "точка" это координаты точки, для окружности координаты центра);
is_v индикатор видимости (видимому на экране объекту соответствует is_v=1);
fc цвет рисования (целое число из диапазона [0,15]);
bc цвет фона (целое число из диапазона [0,15]).
Конструктор по умолчанию считает, что точкой привязки графического объекта является начало координат (верхний левый угол экрана). С помощью процедуры setcolor устанавливается черный цвет рисования (fc=0), а с помощью процедуры setbkcolor белый цвет фона (bc=15).
В классе GO объявлены два чисто виртуальных метода hide (стереть изображение объекта) и show (отобразить объект). Метод move осуществляет перемещение объекта в новую точку привязки и не является виртуальным. Поэтому мы его определим за пределами описания класса:
void GO::move(int x1,int y1)
{ hide(); //стереть прежнее изображение объекта
x=x1; y=y1; //изменить координаты точки привязки
show(); //отобразить объект в новом месте
}
Теперь определим производный класс point, с помощью которого вводятся объекты типа "точка" и манипуляции с объектами этого типа. Новый класс наследует от класса GO все данные (повторять их в классе point не надо). Конструкторы класса point явно вызывают конструкторы родителя, передавая им в случае необходимости недостающие параметры.
class point: public GO {
public:
point():GO() {}
point(int x1,int y1,int c=0):GO(x1,y1,c) {}
void hide();
void show();
};
В классе point переопределяются наследуемые виртуальные методы. Для стирания изображения видимой точки используется процедура putpixel, которая "рисует" точку цветом фона. Для отображения невидимой точки используется та же процедура с заданным значением цвета.
void point::hide() //стирание точки
{ if(is_v) { putpixel(x,y,bc); is_v=0; } }
void point::show() //отображение точки
{ if(!is_v) { putpixel(x,y,fc); is_v=1; } }
Для перемещения точки сохраняется родительская процедура move, которая теперь обращается не к виртуальным, а реальным методам класса point hide и show.
Добавим класс circ, производный от класса GO и предназначенный для работы с объектами типа "окружность". В дополнение к данным, унаследованным от родителя, здесь понадобится еще и радиус окружности (переменная r)
class circ: public GO {
int r;
public:
circ():GO(),r(1){ }
circ(int x1,int y1,int r1,int c=0): GO(x1,y1,c),r(r1) { }
void hide();
void show();
};
Унаследованные виртуальные методы hide и show здесь также придется переопределить. Для стирания видимой окружности используем процедуру построения объекта, задав в качестве цвета рисования цвет фона.
void circ::hide() //стирание окружности
{ if(is_v==0) return;
int fc1=getcolor(); //запоминание цвета рисования
setcolor(bc); //замена цвета рисования на цвет фона
circle(x,y,r); //построение окружности
setcolor(fc1); //восстановление цвета рисования
is_v=0;
}
void circ::show() //отображение окружности
{ if(is_v) return;
int fc1=getcolor(); //запоминание цвета рисования
setcolor(fc); //замена на цвет объекта
circle(x,y,r); //построение окружности
setcolor(fc1); //восстановление цвета рисования
is_v=1;
}
Класс circf для работы с залитыми окружностями тоже образуем из класса GO.
class circf: public GO {
int r;
public:
circf():GO(),r(1){}
circf(int x1,int y1,int r1,int c=0):r(r1),GO(x1,y1,c) {}
void show();
void hide();
};
Для реализации метода show воспользуемся процедурой построения залитого эллипса fillellipse. Но предварительно потребуется задать шаблон заливки, соответствующий сплошному заполнению замкнутой области (графическая константа SOLID_FILL=1), и цвет заливки, равный цвету объекта (значение переменной fc). Обе эти установки выполняются библиотечной процедурой setfillstyle.
void circf::show()
{ if(is_v) return;
setfillstyle(1,fc); //установка стиля и цвета заливки
fillellipse(x,y,r,r); //построение залитой окружности
is_v=1;
}
Метод hide оказался не совсем тривиальным, т.к. после построения эллипса, залитого цветом фона, сохраняется цветная граница. Поэтому приходится выполнить еще одно построение нарисовать контуры окружности цветом фона.
void circf::hide()
{ if(!is_v) return;
setfillstyle(1,bc); //установка стиля и цвета заливки
fillellipse(x,y,r,r); //стирание залитой окружности
setcolor(bc); //замена цвета рисования на цвет фона
circle(x,y,r); //стирание границы окружности
setcolor(fc); //восстановление цвета рисования
is_v=0;
}
А теперь настало время апробировать нашу графическую систему. Если забыть о содержимом файла gs.h и о времени, затраченном на его создание, то работа с допустимым набором графических объектов выглядит абсолютно прозрачно. После каждой графической манипуляции организована пауза в работе программы до нажатия любой клавиши. Это дает возможность рассмотреть на экране результат очередной операции.
#include "gs.h"
#include <conio.h>
void main()
{ open_gs(); //открытие графической системы
//Объявление графических объектов
point P1(21,10,2); //зеленая точка (21,10)
circ C1(21,50,20,4); //красная окружность радиуса 20
circf CF1(21,100,20,12); //залитая окружность
//Отображение графических объектов
P1.show(); getch(); //показ точки
C1.show(); getch(); //показ окружности
CF1.show(); getch(); //показ залитой окружности
//Перемещение графических объектов
P1.move(121,10); getch(); //сдвиг точки
C1.move(121,50); getch(); //сдвиг окружности
CF1.move(121,100); getch(); //сдвиг залитой окружности
//Стирание графических объектов
P1.hide(); getch(); //стирание точки
C1.hide(); getch(); //стирание окружности
CF1.hide(); getch(); //стирание залитой окружности
// Перемещение графических объектов
P1.move(221,10); getch(); //сдвиг точки
C1.move(221,50); getch(); //сдвиг окружности
CF1.move(221,100); getch(); //сдвиг залитой окружности
close_gs(); //закрытие графической системы
}
Можно создать массив указателей на объекты класса GO и заполнить его адресами графических объектов разного типа. Применение к этим указателям методов с одними и теми же названиями, приводит к вызовам методов тех классов, чей тип совпадает с типом адресуемого объекта. Это и есть демонстрация одного из важнейших принципов объектно-ориентированного подхода полиморфизма:
#include "gs.h"
#include <conio.h>
void main()
{ open_gs();
point P1(21,10,2);
circ C1(21,50,20,4);
circf CF1(21,100,20,12);
GO *m[3]={&P1,&C1,&CF1}; //массив указателей
for(int i=0;i<3;i++) m[i]->show(); //отображение объектов
getch();
m[0]->move(121,10); getch(); //сдвиг точки
m[1]->move(121,50); getch(); //сдвиг окружности
m[2]->move(121,100); getch(); //сдвиг залитой окружности
close_gs();
}
Процесс выполнения любого приложения находится под постоянным контролем операционной системы. Система выделяет приложению ресурсы, необходимые для решения задачи, защищает эти ресурсы от несанкционированного доступа со стороны других приложений, выполняющихся в это же время. Для продвижения параллельно работающих приложений операционная система выделяет каждому из них определенный квант времени, в течение которого процессор занимается обслуживанием очередного задания. Одновременно система должна не забывать и о собственных нуждах ей приходится следить за функционированием драйверов, выполняющих заказы приложений по общению с периферийным оборудованием, вовремя реагировать на различные события (сигналы таймера, вмешательство пользователя и т.п.).
Основным механизмом, помогающим операционной системе в этой работе, является аппарат прерываний (в англоязычной технической литературе для его обозначения используется термин interrupt). Не вдаваясь в тонкости технической реализации, действие этого механизма можно представить себе следующим образом. Для фиксации возникающих событий используется специальный регистр прерываний, в котором каждый разряд связан с определенным событием (англ. event событие). При возникновении этого события разряд регистра прерываний взводится в "1", после чего должна сработать специальная системная функция, реагирующая на это событие. В операционной системе MS-DOS для реализации подобного механизма был предусмотрен участок в начале оперативной памяти под названием "вектор прерываний". Каждая компонента этого вектора представляла собой команду передачи управления на функцию обработки соответствующего события. При возникновении того или иного события аппаратно останавливалось выполнение текущей программы, автоматически запоминалось состояние центрального процессора (содержимое регистров, установка различных флажков) и управление передавалось вектору, индекс которого соответствовал номеру события. Разряд в регистре прерываний при этом сбрасывался в "0". После обработки события состояние прерванного процесса восстанавливалось, и работа продолжалась. Некоторые события требовали безотлагательного вмешательства операционной системы, другие могли "подождать". Для предотвращения зацикливания имелась возможность заблокировать прием других сигналов прерывания на время обработки срочного события.
Причины, по которым возникали те или иные события, можно разделить на две категории. К первой относились события, связанные с нормальным функционированием тех или иных устройств компьютера сигналы датчиков времени, сигналы, поступающие при нажатии клавиш клавиатуры или других устройств управления (мышь, джойстик), сигналы драйверов, завершивших выполнение порученных им операций, критическое изменение уровня питающего напряжения. Эту группу можно отнести к аппаратным прерываниям. Причина других событий кроется в ненормальном функционировании приложения, которое предпринимает попытку разделить на нуль, извлечь квадратный корень из отрицательного аргумента, выйти за пределы отведенной ему памяти, передать управление по несуществующему адресу, нарушить границы того или иного массива. Одним словом, речь идет об ошибках, которые могут возникнуть во время исполнения программы. Такого рода прерывания называют программными. Для их обработки существуют две возможности. Если приложение не позаботилось об индивидуальной реакции на программные ошибки, то операционная система сообщит о возникшей ситуации и прервет работу приложения. Вторая возможность, которая может продолжить работу приложения, связана с запрограммированной реакцией самого приложения на те или иные нештатные ситуации.
Одним из первых алгоритмических языков, в которых появилась возможность организовать индивидуальную реакцию на ошибки периода выполнения программы, был Бейсик. И хотя на первых порах профессионалы его просто проигнорировали, уже в ранних версиях Бейсика были предусмотрены операторы типа ON ERROR GOTO ... и ON ERROR GOSUB... . Они позволяли включить в программу пользователя те фрагменты, которые могли реагировать на динамически возникающие ошибки. Для анализа возникшей ситуации приложение могло использовать системные переменные типа ERR (код программной ошибки), ERL (номер строки исходной программы, при выполнении которой была обнаружена ошибка) и др. С тех пор прошел не один десяток лет, пока создатели языка C++ не удосужились включить в состав языковых средств аналогичные конструкции для обработки особых ситуаций (англ. Exception исключение).
В средах визуального программирования механизм событий является основным инструментом управления приложения со стороны пользователя и взаимодействия компонент друг с другом.
Для обработки нештатных ситуаций в язык C++ внесены следующие служебные слова try (попробуй, проверь), catch (перехвати) и throw (имитируй событие). В версии BC 3.1 эти средства еще не были задействованы, поэтому содержимое настоящего раздела распространяется на среду Borland C++ Builder.
Программный блок, в котором могут возникнуть нештатные события, заключается в фигурные скобки, перед которыми располагается служебное слово try:
try { контролируемый участок программы }
Если на контролируемом участке программы возникает та или иная особая ситуация, то для ее анализа надо предусмотреть одну или несколько ловушек, каждая из которых начинается со служебного слова catch:
catch(тип_события_1 значение_события_1) { блок обработки события_1 }
catch(тип_события_2 значение_события_2) { блок обработки события_2 }
.........................................................
Аргумент оператора catch можно рассматривать как специфический объект некоторого класса. Этот объект может быть создан как в результате аварийной ситуации, фиксируемой операционной системой, так и в результате выполнения программой оператора throw. Вторая возможность позволяет программе пользователя генерировать особые ситуации в случае выполнения условий, запланированных в работе алгоритма, и структурировать обработку возникающих событий в блоках catch.
Рассмотрим в качестве примера защищенную функцию fact(n), вычисляющую n!, которая возвращает значение типа double. По определению аргумент функции факториал не может быть отрицательным, а из диапазона представления вещественных данных типа double следует, что аргумент n не должен превосходить величины 1754, т.к. 1755! > 1e+308. Конечно, проверки аргумента на принадлежность допустимому интервалу можно было предусмотреть в теле функции, например, следующим образом:
double fact(int n)
{ if(0>n || n>1754)
{ cerr<<"fact: недопустимый аргумент"<<endl;
getch(); exit(1)
}
if(n==0)return 1;
else return n*fact(n-1);
}
Однако такая жесткая реакция на недопустимый аргумент всегда приведет к завершению работы приложения. А вдруг в распоряжении пользователя имеется возможность изменить схему алгоритма, и в ответ на возникшую ситуацию он захочет предпринять какие-то другие действия. Бóльшую гибкость в подобной ситуации может обеспечить следующая схема:
double fact(int n)
{ try
{ if(n<0) throw "fact: Argument < 0";
if(n>1754) throw "fact: Argument too big";
if(n==0)return 1;
else return n*fact(n-1);
}
catch(const char *s)
{ cerr<<s<<endl; //вывод сообщения об ошибке
return -1; //возврат несуществующего значения функции
}
}
В приведенном примере в случае выхода аргумента за пределы области определения срабатывает один из операторов throw, который генерирует объект типа символьная строка и прерывает работу контролируемого блока try (напоминает выход из цикла по оператору break). Ловушка catch настроена на перехват исключений типа символьная строка, об этом свидетельствует тип ее аргумента. В процессе обработки возникшего события блок catch выводит полученное сообщение и возвращает несуществующее значение функции. По такому результату вызывающая программа может сообразить, что необходимо предпринять для продолжения работы. Если аргумент функции fact оказался допустимым, то после удачного выполнения блока try фрагмент программы-ловушки обходится.
Следует заметить, что механизм обработки исключений еще не попал в стандарт языка C++, поэтому разные системы программирования проводят обработку таких событий по-разному. Система BCB, обнаружив оператор throw, сначала выдает общее предупреждение о происшедшем событии:
Project ... raised exception class char * with message 'Exception Object Address: ...'
Process Stopped. Use Step or Run to continue.
После этого нажатие клавиши F9 (команда Run) или F8 (команда Step) передает управление блоку catch, который выдает сообщение, сгенерированное соответствующим оператором throw. В автоматическом режиме функция fact выдаст несуществующее значение факториала и программа продолжит свою работу:
void main()
{ int x=-5;
double z=fact(x);
cout<<"fact("<<x<<")="<<z<<endl;
getch();
}
//=== Результат работы ===
fact: Argument < 0
fact(-5)=-1
Система Visual C++ в такой ситуации не выдает системное предупреждение и не останавливает процесс происходит просто автоматическая передача управления блоку catch.
В системе BCB существует довольно развитая иерархия классов, образованных от класса Exception. Например, один из таких порожденных классов EInvalidArgument, свидетельствующий об ошибке в аргументе, очень бы подошел в нашем примере:
double fact(int n)
{ try
{ if(n<0) throw EInvalidArgument("fact: Argument < 0");
if(n>1754) throw EInvalidArgument("fact: Argument too big");
if(n==0)return 1;
else return n*fact(n-1);
}
catch(EInvalidArgument &s)
{ cerr<<s.Message.c_str()<<endl; //вывод сообщения об ошибке
return -1; //возврат несуществующего значения функции
}
}
Для правильной работы такой функции нам понадобится подключение заголовочного файла math.hpp, в котором находится описание класса EInvalidArgument. В модифицированном варианте функции оператор throw использует конструктор этого класса для генерации ссылки на соответствующий объект. После обработки события в блоке catch созданный объект будет автоматически уничтожен. Обратите внимание на то, что все объекты класса Exception (и порожденных классов тоже) обладают свойством Message, значение которого представлено строкой типа AnsiString. Именно поэтому при выводе сообщения его приходится преобразовывать в обычную строку с помощью функции c_str.
Использование классов типа Exception существенно расширяет многообразие нестандартных ситуаций, которое может предусмотреть программа пользователя. Если бы для индикации событий мы использовали только переменные базовых типов, то количество различных ситуаций не превышало бы десятка (char*, unsigned char*, short int, unsigned short int, ...).
Если в защищенном блоке может возникнуть несколько особых событий, которые связаны с разными объектами одного типа, то после блока try можно предусмотреть несколько обработчиков исключительных ситуаций. Последовательность их размещения может повлиять на правильность реакции на происшедшее событие. Дело в том, что сначала управление попробует получить блок catch, расположенный сразу после блока try. Если блок catch ориентирован на обработку однотипного события с другим значением, то при выходе из блока catch полученный объект-исключение будет уничтожен. И тогда следующий блок catch, который мог бы справиться с возникшей ситуацией, уже не сработает. Поэтому первый блок catch, получив управление и обнаружив, что событие адресовано не ему, должен позаботиться о сохранении объекта для следующего обработчика. Для этого в конце первого блока catch должен находиться оператор throw().
В программах, связанных с обработкой исключений, можно встретить ловушку catch с аргументом в виде трех точек (catch(...)). Такая ловушка перехватывает исключения любого типа.